Files
orca/src/relay/ai-vault-opencode-reader.test.ts
T
8afa1db50c feat(ssh): rung B glibc 2.17 compat runtime; gate remote vault on host node:sqlite (#24148)
* 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>
2026-10-01 05:32:05 -07:00

256 lines
10 KiB
TypeScript

import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { AiVaultScanIssue } from '../shared/ai-vault-types'
import { getRemoteHostPlatform } from '../main/ssh/ssh-remote-platform'
import { scanRemoteAiVaultSessions } from '../main/ai-vault/remote-session-scanner'
import { resetRemoteSessionParseCacheForTests } from '../main/ai-vault/remote-session-parse-cache'
import { listOpenCodeSqliteSessions } from '../main/ai-vault/session-scanner-opencode-sqlite-list'
import { listOpenCode2SqliteSessions } from '../main/ai-vault/session-scanner-opencode2-sqlite-list'
import { parseOpenCodeSqliteSession } from '../main/ai-vault/session-scanner-opencode-sqlite'
import { parseOpenCode2SqliteSession } from '../main/ai-vault/session-scanner-opencode2-sqlite'
import { writeOpenCodeSqliteDatabase } from '../main/ai-vault/session-scanner-opencode-sqlite-fixture'
import { createRelayAiVaultFilesystemProvider } from './ai-vault-service-filesystem'
import {
createRelayOpenCodeReader,
openCodeReaderUnavailableMessage,
type RelayOpenCodeReaderOptions
} from './ai-vault-opencode-reader'
const directories: string[] = []
const disposables: { dispose(): void }[] = []
afterEach(async () => {
for (const disposable of disposables.splice(0)) {
disposable.dispose()
}
await Promise.all(directories.splice(0).map((path) => rm(path, { recursive: true, force: true })))
resetRemoteSessionParseCacheForTests()
})
async function temporaryDirectory(): Promise<string> {
const path = await mkdtemp(join(tmpdir(), 'orca-relay-opencode-'))
directories.push(path)
return path
}
function factory() {
const reader = { list: vi.fn(async () => []), parse: vi.fn(async () => null), dispose: vi.fn() }
return {
reader,
create: vi.fn(
(_options: Parameters<NonNullable<RelayOpenCodeReaderOptions['readerFactory']>>[0]) => reader
)
}
}
describe('relay OpenCode reader', () => {
it('coalesces a persistent reader selected by the provisioned runtime reference', async () => {
const baseDir = await temporaryDirectory()
const executable = join(baseDir, 'runtime', 'bun')
await writeFile(
join(baseDir, 'opencode-sqlite-runtime.json'),
JSON.stringify({ protocol: 1, executable })
)
const { reader, create } = factory()
const probe = vi.fn(() => false)
const provider = createRelayAiVaultFilesystemProvider({
baseDir,
readerFactory: create,
canReadSqlite: probe,
environment: {
HOME: baseDir,
NODE_OPTIONS: '--require=bad',
XDG_DATA_HOME: join(baseDir, 'data')
}
})
disposables.push(provider)
const issues: AiVaultScanIssue[] = []
const args = { dbPaths: [join(baseDir, 'opencode.db')], limit: 10, issues }
await Promise.all([provider.openCode?.list(args), provider.openCode?.list(args)])
expect(issues).toEqual([])
expect(create).toHaveBeenCalledTimes(1)
expect(create).toHaveBeenCalledWith(
expect.objectContaining({ executable, args: [join(baseDir, 'opencode-sqlite-reader.cjs')] })
)
expect(create.mock.calls[0]?.[0]?.env?.NODE_OPTIONS).toBeUndefined()
expect(probe).not.toHaveBeenCalled()
provider.dispose()
expect(reader.dispose).toHaveBeenCalledTimes(1)
})
it('reports an unavailable reader once per source and retries after provisioning', async () => {
const baseDir = await temporaryDirectory()
const { create } = factory()
const reader = createRelayOpenCodeReader({
baseDir,
readerFactory: create,
canReadSqlite: () => false
})
disposables.push(reader)
const issues: AiVaultScanIssue[] = []
const args = {
dbPaths: [join(baseDir, 'opencode.db'), join(baseDir, 'opencode-other.db')],
limit: 10,
issues
}
await reader.list(args)
expect(issues).toHaveLength(1)
expect(issues[0]).toMatchObject({
agent: 'opencode',
kind: 'scope',
message: openCodeReaderUnavailableMessage(process.version)
})
expect(issues[0]?.message).toContain('22.16 or newer')
expect(create).not.toHaveBeenCalled()
await writeFile(
join(baseDir, 'opencode-sqlite-runtime.json'),
JSON.stringify({ protocol: 1, executable: join(baseDir, 'bun') })
)
await reader.list({ ...args, issues: [] })
expect(create).toHaveBeenCalledTimes(1)
})
it('uses the current runtime only after a successful SQLite read probe', async () => {
const baseDir = await temporaryDirectory()
const { create } = factory()
const reader = createRelayOpenCodeReader({
baseDir,
readerFactory: create,
currentExecutable: process.execPath
})
disposables.push(reader)
const issues: AiVaultScanIssue[] = []
await reader.list({ dbPaths: [join(baseDir, 'opencode.db')], limit: 1, issues })
expect(issues).toEqual([])
expect(create).toHaveBeenCalledWith(expect.objectContaining({ executable: process.execPath }))
})
it('replaces the persistent reader after a repaired runtime reference changes', async () => {
const baseDir = await temporaryDirectory()
const referencePath = join(baseDir, 'opencode-sqlite-runtime.json')
await writeFile(
referencePath,
JSON.stringify({ protocol: 1, executable: join(baseDir, 'old-bun') })
)
const first = factory()
const second = factory()
const create = vi
.fn<NonNullable<RelayOpenCodeReaderOptions['readerFactory']>>()
.mockReturnValueOnce(first.reader)
.mockReturnValueOnce(second.reader)
const reader = createRelayOpenCodeReader({ baseDir, readerFactory: create })
disposables.push(reader)
const args = { dbPaths: [join(baseDir, 'opencode.db')], limit: 1, issues: [] }
await reader.list(args)
await writeFile(
referencePath,
JSON.stringify({ protocol: 1, executable: join(baseDir, 'repaired-bun') })
)
await reader.list(args)
expect(first.reader.dispose).toHaveBeenCalledOnce()
expect(second.reader.list).toHaveBeenCalledOnce()
expect(create).toHaveBeenLastCalledWith(
expect.objectContaining({ executable: join(baseDir, 'repaired-bun') })
)
})
it.each([
{ protocol: 2, executable: '/runtime/bun' },
{ protocol: 1, executable: 'bun' },
{ protocol: 1, executable: '/runtime/bun\0bad' }
])('refuses an invalid runtime reference: %j', async (reference) => {
const baseDir = await temporaryDirectory()
await writeFile(join(baseDir, 'opencode-sqlite-runtime.json'), JSON.stringify(reference))
const { create } = factory()
const reader = createRelayOpenCodeReader({
baseDir,
readerFactory: create,
canReadSqlite: () => true
})
disposables.push(reader)
const issues: AiVaultScanIssue[] = []
await reader.list({ dbPaths: ['opencode.db'], limit: 1, issues })
expect(issues).toHaveLength(1)
expect(create).not.toHaveBeenCalled()
})
it('retains the host database overrides including relative and in-memory paths', async () => {
const baseDir = await temporaryDirectory()
const data = join(baseDir, 'data')
const reader = createRelayOpenCodeReader({
baseDir,
environment: { XDG_DATA_HOME: data, OPENCODE_DB: 'opencode-team.db' }
})
expect(reader.dataDirectory).toBe(join(data, 'opencode'))
expect(reader.databasePath).toBe(join(data, 'opencode', 'opencode-team.db'))
expect(
createRelayOpenCodeReader({ environment: { OPENCODE_DB: ':memory:' } }).databasePath
).toBeNull()
})
it('does not start a child for empty or cancelled scans', async () => {
const baseDir = await temporaryDirectory()
const { create } = factory()
const reader = createRelayOpenCodeReader({ baseDir, readerFactory: create })
disposables.push(reader)
await reader.list({ dbPaths: [], limit: 1, issues: [] })
const controller = new AbortController()
controller.abort()
await expect(
reader.list({ dbPaths: ['opencode.db'], limit: 1, issues: [], signal: controller.signal })
).rejects.toMatchObject({ name: 'AbortError' })
expect(create).not.toHaveBeenCalled()
})
it('scans the host database and legacy JSON through the existing readers', async () => {
const baseDir = await temporaryDirectory()
const xdg = join(baseDir, 'data')
const dbPath = join(xdg, 'opencode', 'opencode-team.db')
writeOpenCodeSqliteDatabase(dbPath, [
{ id: 'migrated', turns: [{ role: 'user', parts: ['Database prompt'] }] }
])
const sessionDir = join(xdg, 'opencode', 'storage', 'session', 'project')
await mkdir(sessionDir, { recursive: true })
await writeFile(
join(sessionDir, 'migrated.json'),
JSON.stringify({ id: 'migrated', title: 'Stale title' })
)
await writeFile(
join(sessionDir, 'legacy.json'),
JSON.stringify({ id: 'legacy', title: 'Legacy title', directory: '/project' })
)
const readerFactory: NonNullable<RelayOpenCodeReaderOptions['readerFactory']> = () => ({
list: (args) =>
args.agent === 'opencode2'
? listOpenCode2SqliteSessions(args)
: listOpenCodeSqliteSessions({ ...args, agent: 'opencode' }),
parse: async (args) =>
args.agent === 'opencode2'
? parseOpenCode2SqliteSession(args)
: parseOpenCodeSqliteSession({ ...args, agent: 'opencode' }),
dispose() {}
})
const provider = createRelayAiVaultFilesystemProvider({
baseDir,
environment: { XDG_DATA_HOME: xdg, OPENCODE_DB: 'opencode-team.db' },
readerFactory
})
disposables.push(provider)
const result = await scanRemoteAiVaultSessions({
provider,
remoteHome: baseDir,
executionHostId: 'ssh:actual',
hostPlatform: getRemoteHostPlatform('linux-x64')
})
expect(result.issues).toEqual([])
expect(result.sessions.map((entry) => entry.sessionId).sort()).toEqual(['legacy', 'migrated'])
expect(result.sessions.find((entry) => entry.sessionId === 'migrated')).toMatchObject({
filePath: dbPath,
executionHostId: 'ssh:actual',
previewMessages: [expect.objectContaining({ text: 'Database prompt' })]
})
})
})