fix(agent-hooks): keep Node 18 relay companion loadable (#13135)
Co-authored-by: Jinwoo-H <Jinwoo-H@users.noreply.github.com>
This commit is contained in:
parent
5f3d5950d0
commit
985dbec060
|
|
@ -259,6 +259,29 @@ jobs:
|
|||
- name: Old/new client and server terminal journey
|
||||
run: pnpm exec vitest run --config config/vitest.config.ts tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts
|
||||
|
||||
managed_hook_node18:
|
||||
name: managed hooks on Node 18
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
|
||||
- name: Build relay companions
|
||||
run: pnpm run build:relay
|
||||
|
||||
- name: Setup Node 18 runtime
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '18'
|
||||
|
||||
- name: Smoke managed-hook companions
|
||||
run: node config/scripts/smoke-managed-hook-runtime-node18.mjs
|
||||
|
||||
package:
|
||||
name: package
|
||||
runs-on: ubuntu-latest
|
||||
|
|
@ -423,6 +446,7 @@ jobs:
|
|||
- git_compatibility
|
||||
- shell_contracts
|
||||
- test
|
||||
- managed_hook_node18
|
||||
- package
|
||||
- package_windows
|
||||
runs-on: ubuntu-latest
|
||||
|
|
@ -444,6 +468,7 @@ jobs:
|
|||
GIT_COMPATIBILITY: ${{ needs.git_compatibility.result }}
|
||||
SHELL_CONTRACTS: ${{ needs.shell_contracts.result }}
|
||||
TEST: ${{ needs.test.result }}
|
||||
MANAGED_HOOK_NODE18: ${{ needs.managed_hook_node18.result }}
|
||||
PACKAGE: ${{ needs.package.result }}
|
||||
PACKAGE_WINDOWS: ${{ needs.package_windows.result }}
|
||||
run: |
|
||||
|
|
@ -454,6 +479,7 @@ jobs:
|
|||
"$GIT_COMPATIBILITY" \
|
||||
"$SHELL_CONTRACTS" \
|
||||
"$TEST" \
|
||||
"$MANAGED_HOOK_NODE18" \
|
||||
"$PACKAGE" \
|
||||
"$PACKAGE_WINDOWS"; do
|
||||
if [ "$result" != "success" ]; then
|
||||
|
|
|
|||
|
|
@ -10064,6 +10064,88 @@
|
|||
"Physical Windows, WSL, SSH, and paired-runtime-host journeys were not run."
|
||||
],
|
||||
"demotionRule": "Demote if any exit duration can evade the spawn budget, concurrent children or timers appear, cleanup retains resources, or a transient failure cannot recover within the budget."
|
||||
},
|
||||
{
|
||||
"id": "ssh-managed-hooks.node18-runtime-compatibility",
|
||||
"title": "SSH managed-hook companions load and install hooks on Node 18",
|
||||
"maturity": "experimental",
|
||||
"protection": "partial",
|
||||
"owner": "agent-hooks",
|
||||
"layer": "ssh-relay-companion-runtime",
|
||||
"surfaces": [
|
||||
"SSH managed-hook companion startup",
|
||||
"remote Codex hook installation",
|
||||
"remote Claude hook installation"
|
||||
],
|
||||
"platforms": ["macos", "linux", "windows"],
|
||||
"providers": ["ssh"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["ssh"],
|
||||
"coverageNotes": "A real Node 18 process loads every platform's built companion and runs the bundled Codex and Claude installers against an isolated temporary home. The six artifacts share one platform-neutral source bundle; live SSH transport and physical remote hosts remain uncollected.",
|
||||
"motivatingLinks": ["https://github.com/stablyai/orca/pull/12617"],
|
||||
"invariant": "Every managed-hook companion shipped for Node 18 must load before agent installers execute, and importing desktop-only state inspection must not make unrelated remote installers depend on node:sqlite.",
|
||||
"oracle": "Build all relay artifacts, require each managed-hook-runtime.js in a real Node 18 process, assert its installer export, then install only Codex and Claude into a fresh temporary home. Require two successful installer results, executable hook scripts, and both agent configs to reference their managed scripts without touching the user's home.",
|
||||
"commands": [
|
||||
"pnpm run build:relay && npx -y node@18 config/scripts/smoke-managed-hook-runtime-node18.mjs",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts config/scripts/pr-workflow-parallelism.test.mjs"
|
||||
],
|
||||
"testFiles": [
|
||||
"config/scripts/smoke-managed-hook-runtime-node18.mjs",
|
||||
"config/scripts/pr-workflow-parallelism.test.mjs"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "config/scripts/smoke-managed-hook-runtime-node18.mjs",
|
||||
"assertions": [
|
||||
"all six built companions load and export installManagedHooks under Node 18",
|
||||
"Codex and Claude installations both succeed in an isolated home",
|
||||
"both generated configs reference executable managed hook scripts"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "config/scripts/pr-workflow-parallelism.test.mjs",
|
||||
"assertions": [
|
||||
"PR CI builds the companions before switching to Node 18 and running the smoke oracle"
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-08-08",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"result": "passed",
|
||||
"command": "pnpm run build:relay && npx -y node@18 config/scripts/smoke-managed-hook-runtime-node18.mjs",
|
||||
"durationSeconds": 4,
|
||||
"summary": "All six companions loaded under Node 18.20.8; Codex and Claude produced two successful results, executable hook scripts, and managed configs in a temporary home."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
"p95Seconds": 30,
|
||||
"scope": "six built-companion loads plus two isolated local-filesystem installer executions"
|
||||
},
|
||||
"flakeHistory": {
|
||||
"status": "not-started",
|
||||
"evidence": "New deterministic artifact smoke with no timing oracle; CI history is not yet available."
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "complete",
|
||||
"evidence": "On main de4f272b31, the same Node 18 require exited 1 before exports with ERR_UNKNOWN_BUILTIN_MODULE for node:sqlite. The candidate passes all six loads and both installers; restoring the eager node:sqlite value import reproduces the original exit."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": false,
|
||||
"evidence": "The product change replaces one eager built-in import with a constructor-time built-in lookup. It adds no polling, IPC, filesystem scan, subprocess, listener, or recurring work."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Collect 100 consecutive focused CI passes or 14 days of soak history.",
|
||||
"Collect one live SSH install on Linux and one on Windows OpenSSH.",
|
||||
"Keep all-platform load, selected installer, isolated-home, and workflow-order assertions green."
|
||||
],
|
||||
"knownGaps": [
|
||||
"The artifact journey uses local filesystem calls rather than a live SSH transport.",
|
||||
"Platform-named bundles are loaded on macOS locally; physical Linux and Windows evidence is not yet recorded."
|
||||
],
|
||||
"demotionRule": "Demote if any shipped companion stops loading on Node 18, selected installers mutate outside the isolated home, or the CI lane no longer runs the built artifact with Node 18."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -97,6 +97,24 @@ describe('PR workflow parallelism', () => {
|
|||
expect(packageJson.scripts['build:release']).toContain('pnpm run build:web-from-renderer')
|
||||
})
|
||||
|
||||
it('smokes managed-hook companions under their supported Node 18 runtime', () => {
|
||||
const steps = workflow.jobs.managed_hook_node18.steps
|
||||
const installIndex = steps.findIndex(
|
||||
(step) => step.uses === './.github/actions/install-node-dependencies'
|
||||
)
|
||||
const buildIndex = steps.findIndex((step) => step.run === 'pnpm run build:relay')
|
||||
const node18Index = steps.findIndex(
|
||||
(step) => step.uses === 'actions/setup-node@v6' && step.with['node-version'] === '18'
|
||||
)
|
||||
const smokeIndex = steps.findIndex(
|
||||
(step) => step.run === 'node config/scripts/smoke-managed-hook-runtime-node18.mjs'
|
||||
)
|
||||
|
||||
expect(installIndex).toBeLessThan(buildIndex)
|
||||
expect(buildIndex).toBeLessThan(node18Index)
|
||||
expect(node18Index).toBeLessThan(smokeIndex)
|
||||
})
|
||||
|
||||
it('restores the pnpm store before dependency installation', () => {
|
||||
const steps = dependencyAction.runs.steps
|
||||
const pnpmIndex = steps.findIndex((step) => step.name === 'Setup pnpm')
|
||||
|
|
@ -178,8 +196,14 @@ describe('PR workflow parallelism', () => {
|
|||
'git_compatibility',
|
||||
'shell_contracts',
|
||||
'test',
|
||||
'managed_hook_node18',
|
||||
'package',
|
||||
'package_windows'
|
||||
])
|
||||
const verifyStep = workflow.jobs.verify.steps.find(
|
||||
(step) => step.name === 'Require successful checks'
|
||||
)
|
||||
expect(verifyStep.env.MANAGED_HOOK_NODE18).toBe('${{ needs.managed_hook_node18.result }}')
|
||||
expect(verifyStep.run).toContain('"$MANAGED_HOOK_NODE18"')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -0,0 +1,64 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import { constants } from 'node:fs'
|
||||
import { access, mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const PLATFORMS = [
|
||||
'linux-x64',
|
||||
'linux-arm64',
|
||||
'darwin-x64',
|
||||
'darwin-arm64',
|
||||
'win32-x64',
|
||||
'win32-arm64'
|
||||
]
|
||||
const ROOT = fileURLToPath(new URL('../..', import.meta.url))
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
assert.match(process.versions.node, /^18\./, 'This smoke test must run under Node 18')
|
||||
|
||||
const home = await mkdtemp(join(tmpdir(), 'orca-managed-hook-node18-'))
|
||||
const originalHome = process.env.HOME
|
||||
const originalUserProfile = process.env.USERPROFILE
|
||||
const originalGetuid = process.getuid
|
||||
process.env.HOME = home
|
||||
process.env.USERPROFILE = home
|
||||
process.getuid = undefined
|
||||
|
||||
try {
|
||||
const runtimes = PLATFORMS.map((platform) => {
|
||||
const artifact = join(ROOT, 'out', 'relay', platform, 'managed-hook-runtime.js')
|
||||
const runtime = require(artifact)
|
||||
assert.equal(typeof runtime.installManagedHooks, 'function', `${platform} installer export`)
|
||||
return runtime
|
||||
})
|
||||
|
||||
const summary = await runtimes[0].installManagedHooks({ agents: ['codex', 'claude'] })
|
||||
assert.deepEqual(summary, { installers: 2, errors: 0 })
|
||||
|
||||
const codexHooks = await readFile(join(home, '.codex', 'hooks.json'), 'utf8')
|
||||
const claudeSettings = await readFile(join(home, '.claude', 'settings.json'), 'utf8')
|
||||
assert.match(codexHooks, /\.orca\/agent-hooks\/codex-hook\.sh/)
|
||||
assert.match(claudeSettings, /\.orca\/agent-hooks\/claude-hook\.sh/)
|
||||
await access(join(home, '.orca', 'agent-hooks', 'codex-hook.sh'), constants.X_OK)
|
||||
await access(join(home, '.orca', 'agent-hooks', 'claude-hook.sh'), constants.X_OK)
|
||||
} finally {
|
||||
if (originalHome === undefined) {
|
||||
delete process.env.HOME
|
||||
} else {
|
||||
process.env.HOME = originalHome
|
||||
}
|
||||
if (originalUserProfile === undefined) {
|
||||
delete process.env.USERPROFILE
|
||||
} else {
|
||||
process.env.USERPROFILE = originalUserProfile
|
||||
}
|
||||
process.getuid = originalGetuid
|
||||
await rm(home, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
console.log('Node 18 managed-hook runtime smoke passed for all relay platforms.')
|
||||
|
|
@ -1,8 +1,6 @@
|
|||
import { existsSync } from 'node:fs'
|
||||
import { DatabaseSync, type StatementSync, type SQLInputValue } from 'node:sqlite'
|
||||
import type { DatabaseSync, StatementSync, SQLInputValue } from 'node:sqlite'
|
||||
|
||||
// Why: keep existing synchronous DB call sites on a tiny adapter while using
|
||||
// Electron's built-in Node SQLite instead of a third-party native addon.
|
||||
type SqlitePath = ConstructorParameters<typeof DatabaseSync>[0]
|
||||
|
||||
type SyncDatabaseOptions = {
|
||||
|
|
@ -17,6 +15,15 @@ type PragmaOptions = {
|
|||
|
||||
export type SqliteStatement = StatementSync
|
||||
|
||||
// Why: SSH companions target Node 18 and import this adapter without opening SQLite.
|
||||
function loadDatabaseSync(): typeof DatabaseSync {
|
||||
if (typeof process.getBuiltinModule !== 'function') {
|
||||
throw new Error('node:sqlite is unavailable in this Node.js runtime')
|
||||
}
|
||||
return (process.getBuiltinModule('node:sqlite') as { DatabaseSync: typeof DatabaseSync })
|
||||
.DatabaseSync
|
||||
}
|
||||
|
||||
class SyncDatabase {
|
||||
private readonly db: DatabaseSync
|
||||
|
||||
|
|
@ -29,6 +36,7 @@ class SyncDatabase {
|
|||
) {
|
||||
throw new Error(`SQLite database does not exist: ${path}`)
|
||||
}
|
||||
const DatabaseSync = loadDatabaseSync()
|
||||
this.db = new DatabaseSync(path, {
|
||||
readOnly: options.readonly,
|
||||
timeout: options.timeout
|
||||
|
|
|
|||
Loading…
Reference in New Issue