From 985dbec0604212832e4e8aad17e464aad83fa0b2 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 7 Aug 2026 22:54:42 -0700 Subject: [PATCH] fix(agent-hooks): keep Node 18 relay companion loadable (#13135) Co-authored-by: Jinwoo-H --- .github/workflows/pr.yml | 26 ++++++ config/reliability-gates.jsonc | 82 +++++++++++++++++++ .../scripts/pr-workflow-parallelism.test.mjs | 24 ++++++ .../smoke-managed-hook-runtime-node18.mjs | 64 +++++++++++++++ src/main/sqlite/sync-database.ts | 14 +++- 5 files changed, 207 insertions(+), 3 deletions(-) create mode 100644 config/scripts/smoke-managed-hook-runtime-node18.mjs diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 7b702db9e..8d44cabf0 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -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 diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index e56e3ad1a..7e9ccbdc0 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -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." } ] } diff --git a/config/scripts/pr-workflow-parallelism.test.mjs b/config/scripts/pr-workflow-parallelism.test.mjs index d1e90fad1..fe86ab7e7 100644 --- a/config/scripts/pr-workflow-parallelism.test.mjs +++ b/config/scripts/pr-workflow-parallelism.test.mjs @@ -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"') }) }) diff --git a/config/scripts/smoke-managed-hook-runtime-node18.mjs b/config/scripts/smoke-managed-hook-runtime-node18.mjs new file mode 100644 index 000000000..40c44285f --- /dev/null +++ b/config/scripts/smoke-managed-hook-runtime-node18.mjs @@ -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.') diff --git a/src/main/sqlite/sync-database.ts b/src/main/sqlite/sync-database.ts index c57f29399..70d8fa1c0 100644 --- a/src/main/sqlite/sync-database.ts +++ b/src/main/sqlite/sync-database.ts @@ -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[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