mirror of
https://github.com/stablyai/orca.git
synced 2026-10-12 16:03:03 +00:00
* feat(ssh): wire rung B to the glibc 2.17 compat runtime; gate rung C vault on full node:sqlite - COMPAT_RELAY_RUNTIMES lists linux-x64-glibc217; rung B plans the compat slot and compat pinned Node when glibc is below 2.28 or rung A refused with libc_floor/missing_lib. - The relay version folds the compat runtime's executable hash; refusals are cached per runtime. - The orcad template stages an optional linux-x64-glibc217 target (base package + compat node-pty slot + compat runtime marker); the verifier and materializer accept it. - node-pty slot loader falls back to the compat slot when the default slot is missing or needs a newer glibc. - Runtime store GC keeps the compat pin beside the default one on every relay connect. - hasNodeSqliteReaderApi (DatabaseSync + backup) gates relay session search and the relay OpenCode reader, which now names the host Node version in its unavailable reason; the SSH vault reader installs the compat Node on old-glibc hosts and uploads nothing when no pinned Node can run. - Rung D: a remembered noexec reports home_noexec and never advises installing Node. * fix(ssh): re-prove a replayed noexec after rung D so allowing exec recovers the host * fix(ssh): keep the rung B compat runtime pinned in the relay-connect store GC * test(ssh): mock deployment-target facts in the Windows OpenCode runtime tests * ci(ssh): build the glibc 2.17 compat slot for the hostile-host matrix; CentOS 7 lands on rung B --------- Co-authored-by: m4air <m4air@m4airs-Air.localdomain> Co-authored-by: m4air <m4air@Mac.localdomain>
172 lines
5.8 KiB
TypeScript
172 lines
5.8 KiB
TypeScript
import { open } from 'node:fs/promises'
|
||
import { homedir } from 'node:os'
|
||
import { isAbsolute, join } from 'node:path'
|
||
import type { RemoteOpenCodeSessionReader } from '../main/ai-vault/remote-session-scanner-types'
|
||
import { throwIfAiVaultScanCancelled } from '../main/ai-vault/ai-vault-scan-cancellation'
|
||
import { isMissingRemoteSessionPathError } from '../main/ai-vault/remote-session-file-stat'
|
||
import { parseOpenCodeSessionFile } from '../main/ai-vault/session-scanner-opencode-parser'
|
||
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, { isSqliteAvailable } from '../main/sqlite/sync-database'
|
||
|
||
type Reader = Pick<RemoteOpenCodeSessionReader, 'list' | 'parse'> & { dispose(): void }
|
||
|
||
export type RelayOpenCodeReaderOptions = {
|
||
baseDir?: string
|
||
environment?: NodeJS.ProcessEnv
|
||
homeDirectory?: string
|
||
currentExecutable?: string
|
||
readerFactory?: (options: Parameters<typeof createOpenCodeSqliteProcessClient>[0]) => Reader
|
||
canReadSqlite?: () => boolean
|
||
}
|
||
|
||
export function createRelayOpenCodeReader(
|
||
options: RelayOpenCodeReaderOptions = {}
|
||
): RemoteOpenCodeSessionReader & { dispose(): void } {
|
||
const environment = options.environment ?? process.env
|
||
const dataDirectory = resolveOpenCodeDataDirectory(
|
||
environment,
|
||
options.homeDirectory ?? homedir()
|
||
)
|
||
const override = environment.OPENCODE_DB?.trim()
|
||
const baseDir = options.baseDir ?? __dirname
|
||
let reader: Reader | undefined
|
||
let executable: string | undefined
|
||
let pending: Promise<Reader> | undefined
|
||
let disposed = false
|
||
const getReader = (refreshRuntime = false): Promise<Reader> => {
|
||
if (disposed) {
|
||
return Promise.reject(new Error('OpenCode database reader was disposed.'))
|
||
}
|
||
if (reader && !refreshRuntime) {
|
||
return Promise.resolve(reader)
|
||
}
|
||
pending ??= (async () => {
|
||
const configured = await readRuntimeExecutable(join(baseDir, 'opencode-sqlite-runtime.json'))
|
||
if (!configured && !(options.canReadSqlite ?? canCurrentRuntimeReadSqlite)()) {
|
||
throw new Error(openCodeReaderUnavailableMessage(process.version))
|
||
}
|
||
if (disposed) {
|
||
throw new Error('OpenCode database reader was disposed.')
|
||
}
|
||
const nextExecutable = configured ?? options.currentExecutable ?? process.execPath
|
||
if (!reader || executable !== nextExecutable) {
|
||
reader?.dispose()
|
||
reader = (options.readerFactory ?? createOpenCodeSqliteProcessClient)({
|
||
executable: nextExecutable,
|
||
args: [join(baseDir, 'opencode-sqlite-reader.cjs')],
|
||
env: buildRelayAiVaultServiceEnv(environment)
|
||
})
|
||
executable = nextExecutable
|
||
}
|
||
return reader
|
||
})().finally(() => {
|
||
pending = undefined
|
||
})
|
||
return pending
|
||
}
|
||
return {
|
||
dataDirectory,
|
||
...(override
|
||
? {
|
||
databasePath:
|
||
override === ':memory:'
|
||
? null
|
||
: isAbsolute(override)
|
||
? override
|
||
: join(dataDirectory, override)
|
||
}
|
||
: {}),
|
||
async list(args) {
|
||
throwIfAiVaultScanCancelled(args.signal)
|
||
if (args.dbPaths.length === 0) {
|
||
return []
|
||
}
|
||
try {
|
||
return await (await getReader(true)).list(args)
|
||
} catch (error) {
|
||
throwIfAiVaultScanCancelled(args.signal)
|
||
args.issues.push({
|
||
agent: args.agent ?? 'opencode',
|
||
kind: 'scope',
|
||
path: args.dbPaths[0] ?? dataDirectory,
|
||
message: error instanceof Error ? error.message : String(error)
|
||
})
|
||
return []
|
||
}
|
||
},
|
||
async parse(args) {
|
||
throwIfAiVaultScanCancelled(args.signal)
|
||
return (await getReader()).parse(args)
|
||
},
|
||
parseLegacy: parseOpenCodeSessionFile,
|
||
dispose() {
|
||
if (disposed) {
|
||
return
|
||
}
|
||
disposed = true
|
||
reader?.dispose()
|
||
}
|
||
}
|
||
}
|
||
|
||
async function readRuntimeExecutable(path: string): Promise<string | undefined> {
|
||
let file
|
||
try {
|
||
file = await open(path, 'r')
|
||
} catch (error) {
|
||
if (isMissingRemoteSessionPathError(error)) {
|
||
return undefined
|
||
}
|
||
throw error
|
||
}
|
||
try {
|
||
const bytes = Buffer.alloc(16_385)
|
||
const { bytesRead } = await file.read(bytes, 0, bytes.length, 0)
|
||
if (bytesRead > 16_384) {
|
||
throw new Error('OpenCode database runtime reference exceeds its size limit.')
|
||
}
|
||
const reference: unknown = JSON.parse(bytes.subarray(0, bytesRead).toString('utf8'))
|
||
if (
|
||
typeof reference !== 'object' ||
|
||
reference === null ||
|
||
!('protocol' in reference) ||
|
||
reference.protocol !== 1 ||
|
||
!('executable' in reference) ||
|
||
typeof reference.executable !== 'string' ||
|
||
!isAbsolute(reference.executable) ||
|
||
reference.executable.includes('\0')
|
||
) {
|
||
throw new Error('OpenCode database runtime reference is invalid.')
|
||
}
|
||
return reference.executable
|
||
} finally {
|
||
await file.close()
|
||
}
|
||
}
|
||
|
||
/** Rung C runs the relay on the host's Node, which may predate the full node:sqlite surface. */
|
||
export function openCodeReaderUnavailableMessage(nodeVersion: string): string {
|
||
return (
|
||
`OpenCode history is unavailable on this host for now: its Node.js ${nodeVersion} has no ` +
|
||
'complete node:sqlite (22.16 or newer), and Orca has not installed its own Node reader here yet.'
|
||
)
|
||
}
|
||
|
||
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:')
|
||
return db.prepare('SELECT 1 AS ready').get()?.ready === 1
|
||
} catch {
|
||
return false
|
||
} finally {
|
||
db?.close()
|
||
}
|
||
}
|