From 3fe4b18dae6d775f50d78a42f9a771368ec9f470 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Wed, 30 Sep 2026 22:57:01 -0700 Subject: [PATCH] fix(ai-vault): require node:sqlite backup support in remote SQLite probes (#24086) * fix(ai-vault): require the full SyncDatabase node:sqlite surface in host SQLite probes The SSH and WSL OpenCode probes admitted any Node with DatabaseSync, so Node 22.13-22.15 hosts (no backup export) skipped the pinned-runtime fallback. Share one admission predicate with isSqliteAvailable() and embed its source in both probe scripts. * build(cli): list the node:sqlite admission predicate in the CLI project --------- Co-authored-by: m4air --- config/tsconfig.cli.json | 1 + .../opencode-wsl-runtime-preparation.test.ts | 33 +++++++++++++++++ .../opencode-wsl-runtime-preparation.ts | 3 +- .../sqlite/node-sqlite-reader-api.test.ts | 30 ++++++++++++++++ src/main/sqlite/node-sqlite-reader-api.ts | 20 +++++++++++ src/main/sqlite/sync-database.ts | 3 +- ...sh-relay-opencode-runtime-commands.test.ts | 35 +++++++++++++++++++ .../ssh-relay-opencode-runtime-commands.ts | 4 ++- src/relay/ai-vault-opencode-reader.ts | 6 +++- 9 files changed, 131 insertions(+), 4 deletions(-) create mode 100644 src/main/sqlite/node-sqlite-reader-api.test.ts create mode 100644 src/main/sqlite/node-sqlite-reader-api.ts diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index e259da81609..1c3a35ac8ae 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -246,6 +246,7 @@ "../src/main/sqlite/bun-readonly-wal.ts", "../src/main/sqlite/sqlite-statement.ts", "../src/main/sqlite/sqlite-integer-reader.ts", + "../src/main/sqlite/node-sqlite-reader-api.ts", "../src/main/sqlite/node-sqlite-statement.ts", "../src/main/sqlite/bun-sqlite-statement.ts", "../src/main/sqlite/bun-sqlite-database.ts", diff --git a/src/main/ai-vault/opencode-wsl-runtime-preparation.test.ts b/src/main/ai-vault/opencode-wsl-runtime-preparation.test.ts index d78130a119f..70f00c18db8 100644 --- a/src/main/ai-vault/opencode-wsl-runtime-preparation.test.ts +++ b/src/main/ai-vault/opencode-wsl-runtime-preparation.test.ts @@ -131,6 +131,39 @@ describe('WSL SQLite runtime preparation', () => { await prepared() }) + it('rejects a guest Node without the SyncDatabase surface and tolerates its stderr warning', async () => { + await prepared() + const probe = mocks.run.mock.calls.find(([spec]) => spec.program === 'node')?.[0] + expect(probe?.args[0]).toBe('-e') + const directory = await mkdtemp(join(tmpdir(), 'orca-wsl-sqlite-probe-')) + const runProbe = (preload: string) => + runProcess({ + program: process.execPath, + args: probe.args, + timeoutMs: 10_000, + env: { ...process.env, NODE_OPTIONS: `--require ${JSON.stringify(preload)}` } + }) + try { + // Node 22.13-22.15: DatabaseSync ships, the backup export does not. + const withoutBackup = join(directory, 'node-22-13-sqlite.cjs') + await writeFile(withoutBackup, "delete require('node:sqlite').backup") + const rejected = await runProbe(withoutBackup) + expect(rejected.code).not.toBe(0) + expect(rejected.stdout).toBe('') + const warning = join(directory, 'experimental-warning.cjs') + await writeFile( + warning, + "process.emitWarning('SQLite is an experimental feature and might change at any time','ExperimentalWarning')" + ) + const admitted = await runProbe(warning) + expect(admitted.code, admitted.stderr).toBe(0) + expect(admitted.stderr).toContain('ExperimentalWarning') + expect(admitted.stdout.trim()).toBe(process.execPath) + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) + it('falls back to the pinned proxy runtime, verifies the guest stage, and preserves literal argv', async () => { const expected = ORCAD_BUN_RELEASE_ASSETS['linux-arm64-musl'].executableSha256 mocks.run.mockImplementation(async (spec) => { diff --git a/src/main/ai-vault/opencode-wsl-runtime-preparation.ts b/src/main/ai-vault/opencode-wsl-runtime-preparation.ts index c9bc81fff77..4c0b22bd218 100644 --- a/src/main/ai-vault/opencode-wsl-runtime-preparation.ts +++ b/src/main/ai-vault/opencode-wsl-runtime-preparation.ts @@ -5,6 +5,7 @@ import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' import { ORCAD_BUN_RELEASE_ASSETS, type OrcadBunTarget } from '../../shared/orcad-bun-runtime' import { RELAY_OPENCODE_SQLITE_READER_FILENAME } from '../../shared/relay-artifacts' import { parseWslUncPath, toWindowsWslUncPath } from '../../shared/wsl-paths' +import { NODE_SQLITE_READER_API_SOURCE } from '../sqlite/node-sqlite-reader-api' import { relayBundleCandidates } from '../ssh/relay-bundle-paths' import { materializeCachedOrcadBunRuntime } from '../ssh/orcad-bun-runtime-materializer' import { parseOrcadLinuxLibc } from '../ssh/orcad-deployment-target' @@ -15,7 +16,7 @@ import type { OpenCodeWslRuntime } from './session-scanner-opencode-wsl-runtime' const preparation = new Map() const downloads = new Map>() const PREPARATION_TIMEOUT_MS = 180_000 -const SQLITE_PROBE = `const db=new (require('node:sqlite').DatabaseSync)(':memory:');db.prepare('SELECT 1').get();db.close();process.stdout.write(process.execPath)` +const SQLITE_PROBE = `const sqlite=require('node:sqlite');if(!(${NODE_SQLITE_READER_API_SOURCE})(sqlite))throw Error('SQLite reader API missing');const db=new sqlite.DatabaseSync(':memory:');db.prepare('SELECT 1').get();db.close();process.stdout.write(process.execPath)` /** Only running distro roots enter here; a slow first install must not hold up local history. */ export async function prepareOpenCodeWslReaders( diff --git a/src/main/sqlite/node-sqlite-reader-api.test.ts b/src/main/sqlite/node-sqlite-reader-api.test.ts new file mode 100644 index 00000000000..5818457684c --- /dev/null +++ b/src/main/sqlite/node-sqlite-reader-api.test.ts @@ -0,0 +1,30 @@ +import { runInNewContext } from 'node:vm' +import { describe, expect, it } from 'vitest' +import { hasNodeSqliteReaderApi, NODE_SQLITE_READER_API_SOURCE } from './node-sqlite-reader-api' + +const probe = (sqlite: unknown): unknown => + runInNewContext(`(${NODE_SQLITE_READER_API_SOURCE})(sqlite)`, { sqlite }) + +class DatabaseSync {} +const backup = async (): Promise => {} + +describe('node:sqlite reader admission', () => { + it.each([ + ['DatabaseSync with backup (Node >= 22.16)', { DatabaseSync, backup }, true], + ['DatabaseSync without backup (Node 22.13-22.15)', { DatabaseSync }, false], + ['backup without DatabaseSync', { backup }, false], + ['non-function DatabaseSync', { DatabaseSync: {}, backup }, false], + ['non-function backup', { DatabaseSync, backup: true }, false], + ['missing module', undefined, false], + ['null module', null, false] + ])('agrees in-process and in the embedded probe for %s', (_label, sqlite, admitted) => { + expect(hasNodeSqliteReaderApi(sqlite)).toBe(admitted) + expect(probe(sqlite)).toBe(admitted) + }) + + it('admits the actual runtime module', () => { + const sqlite: unknown = process.getBuiltinModule('node:sqlite') + expect(hasNodeSqliteReaderApi(sqlite)).toBe(true) + expect(probe(sqlite)).toBe(true) + }) +}) diff --git a/src/main/sqlite/node-sqlite-reader-api.ts b/src/main/sqlite/node-sqlite-reader-api.ts new file mode 100644 index 00000000000..84a5f94a100 --- /dev/null +++ b/src/main/sqlite/node-sqlite-reader-api.ts @@ -0,0 +1,20 @@ +/** + * Source of the node:sqlite admission check, embedded in remote/WSL probe scripts so a + * host's Node passes exactly when `isSqliteAvailable()` would admit it there. + * Why `backup`: it landed with the DatabaseSync options SyncDatabase relies on (Node 22.16), + * so Node 22.13–22.15 must take the pinned-runtime fallback. + */ +export const NODE_SQLITE_READER_API_SOURCE = + "(m)=>typeof m==='object'&&m!==null&&typeof m.DatabaseSync==='function'&&typeof m.backup==='function'" + +/** In-process twin of {@link NODE_SQLITE_READER_API_SOURCE}; a parity test keeps them equal. */ +export function hasNodeSqliteReaderApi(sqlite: unknown): boolean { + return ( + typeof sqlite === 'object' && + sqlite !== null && + 'DatabaseSync' in sqlite && + typeof sqlite.DatabaseSync === 'function' && + 'backup' in sqlite && + typeof sqlite.backup === 'function' + ) +} diff --git a/src/main/sqlite/sync-database.ts b/src/main/sqlite/sync-database.ts index 5f27b52329e..7ff04f12dac 100644 --- a/src/main/sqlite/sync-database.ts +++ b/src/main/sqlite/sync-database.ts @@ -1,6 +1,7 @@ import { existsSync } from 'node:fs' import type { backup, BackupOptions, DatabaseSync, SQLInputValue } from 'node:sqlite' import { BunSqliteDatabase, loadBunSqlite } from './bun-sqlite-database' +import { hasNodeSqliteReaderApi } from './node-sqlite-reader-api' import { NodeSqliteStatement } from './node-sqlite-statement' import type { SqliteStatement } from './sqlite-statement' @@ -66,7 +67,7 @@ export function isSqliteAvailable(): boolean { return loadBunSqlite() !== undefined } const sqlite: unknown = process.getBuiltinModule?.('node:sqlite') - return hasDatabaseSync(sqlite) && hasBackup(sqlite) + return hasNodeSqliteReaderApi(sqlite) } catch { return false } diff --git a/src/main/ssh/ssh-relay-opencode-runtime-commands.test.ts b/src/main/ssh/ssh-relay-opencode-runtime-commands.test.ts index 72b5254c322..3b45f9f7193 100644 --- a/src/main/ssh/ssh-relay-opencode-runtime-commands.test.ts +++ b/src/main/ssh/ssh-relay-opencode-runtime-commands.test.ts @@ -68,6 +68,41 @@ describe.skipIf(process.platform === 'win32')('host-owned SQLite setup commands' }) }) + it('sends a host Node without the SyncDatabase surface to the pinned runtime', async () => { + const home = await directory() + const data = join(home, '.local', 'share', 'opencode') + await mkdir(data, { recursive: true }) + await writeFile(join(data, 'opencode.db'), '') + // Node 22.13-22.15: DatabaseSync ships, the backup export does not. + const preload = join(home, 'node-22-13-sqlite.cjs') + await writeFile(preload, "delete require('node:sqlite').backup") + const result = await command(probeOpenCodeNodeSqliteCommand(host, nodePath, home), { + NODE_OPTIONS: `--require ${JSON.stringify(preload)}` + }) + expect(result.code, result.stderr).toBe(0) + expect(parseOpenCodeRuntimeResult(result.stdout)).toEqual({ status: 'unsupported' }) + }) + + it('ignores the experimental SQLite warning older Node prints to stderr', async () => { + const home = await directory() + const data = join(home, '.local', 'share', 'opencode') + await mkdir(data, { recursive: true }) + await writeFile(join(data, 'opencode.db'), '') + const preload = join(home, 'experimental-warning.cjs') + await writeFile( + preload, + "process.emitWarning('SQLite is an experimental feature and might change at any time','ExperimentalWarning')" + ) + const result = await command(probeOpenCodeNodeSqliteCommand(host, nodePath, home), { + NODE_OPTIONS: `--require ${JSON.stringify(preload)}` + }) + expect(result.stderr).toContain('ExperimentalWarning') + expect(parseOpenCodeRuntimeResult(result.stdout)).toEqual({ + status: 'ready', + executable: nodePath + }) + }) + it('stages, verifies by bytes, promotes and atomically publishes under quoted paths', async () => { const root = await directory() const stage = await reserveStage(root) diff --git a/src/main/ssh/ssh-relay-opencode-runtime-commands.ts b/src/main/ssh/ssh-relay-opencode-runtime-commands.ts index 14900f15e5b..e324a6c224e 100644 --- a/src/main/ssh/ssh-relay-opencode-runtime-commands.ts +++ b/src/main/ssh/ssh-relay-opencode-runtime-commands.ts @@ -1,3 +1,4 @@ +import { NODE_SQLITE_READER_API_SOURCE } from '../sqlite/node-sqlite-reader-api' import { shellEscape } from './ssh-connection-utils' import { posix, win32 } from 'node:path' import { powerShellCommand, powerShellLiteral, powerShellNativeArg } from './ssh-remote-powershell' @@ -40,7 +41,8 @@ try{if(override&&override!==':memory:'){present=(await fs.stat(path.isAbsolute(o else if(!override){const directory=await fs.opendir(data);for await(const entry of directory){if(entry.isFile()&&/^opencode(?:-[A-Za-z0-9_.-]+)?\\.db$/.test(entry.name)){present=true;break}}}} catch(error){if(error.code!=='ENOENT'&&error.code!=='ENOTDIR')throw error} if(!present){send({status:'not-needed'});return} -let db;try{db=new(require('node:sqlite').DatabaseSync)(':memory:'); +let db;try{const sqlite=require('node:sqlite');if(!(${NODE_SQLITE_READER_API_SOURCE})(sqlite))throw Error('SQLite reader API missing'); +db=new sqlite.DatabaseSync(':memory:'); if(db.prepare('SELECT 1 AS ready').get().ready!==1)throw Error('SQLite read failed'); send({status:'ready',executable:process.execPath})}catch{send({status:'unsupported'})}finally{if(db)db.close()} })().catch(error=>{console.error(error.message);process.exitCode=1})`, diff --git a/src/relay/ai-vault-opencode-reader.ts b/src/relay/ai-vault-opencode-reader.ts index d90f2e08cd1..a4222a742bb 100644 --- a/src/relay/ai-vault-opencode-reader.ts +++ b/src/relay/ai-vault-opencode-reader.ts @@ -8,7 +8,7 @@ import { parseOpenCodeSessionFile } from '../main/ai-vault/session-scanner-openc import { createOpenCodeSqliteProcessClient } from '../main/ai-vault/session-scanner-opencode-sqlite-process-client' import { buildRelayAiVaultServiceEnv } from '../main/ai-vault/session-scanner-service-env' import { resolveOpenCodeDataDirectory } from '../main/opencode/opencode-data-directory' -import SyncDatabase from '../main/sqlite/sync-database' +import SyncDatabase, { isSqliteAvailable } from '../main/sqlite/sync-database' type Reader = Pick & { dispose(): void } @@ -147,6 +147,10 @@ async function readRuntimeExecutable(path: string): Promise } function canCurrentRuntimeReadSqlite(): boolean { + // Why: same admission as the host probes, so Node 22.13–22.15 waits for the pinned runtime. + if (!isSqliteAvailable()) { + return false + } let db: SyncDatabase | undefined try { db = new SyncDatabase(':memory:')