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:
Jinwoo Hong 2026-08-07 22:54:42 -07:00 committed by Jinwoo-H
parent 5f3d5950d0
commit 985dbec060
5 changed files with 207 additions and 3 deletions

View File

@ -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

View File

@ -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."
}
]
}

View File

@ -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"')
})
})

View File

@ -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.')

View File

@ -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