refactor(relay): delete the SSH relay server, its build assets and tests (#27215)

* wip: delete the SSH relay server and its build, assets and tests

* wip: drop main/relay plugin conformance; WSL reader lookup test

* fix: drop the emptied relay bench; prune baselines

* test: keep the native halves of tests that also covered the relay

* chore(ci): drop shard timings for the deleted relay server tests

* fix: keep the directory listing budget that darwin terminal names now import

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
This commit is contained in:
OrcaWin
2026-10-10 22:36:42 -07:00
committed by GitHub
co-authored by m4air
parent c2c63e7f9a
commit 2b4b60a87e
512 changed files with 359 additions and 85654 deletions
@@ -18,8 +18,7 @@ function finish<T>(callback: Callback<T>, error: NodeJS.ErrnoException | null, v
callback(null, value)
}
/** The managed installers only need this small callback-style SFTP surface.
* On the remote host it turns hundreds of WAN round trips into local fs calls. */
/** Runs the managed installers' callback-style SFTP surface against the local disk in tests. */
export function createManagedHookLocalFilesystem(): SFTPWrapper {
const adapter = {
readFile(path: string, _encoding: string, callback: Callback<string | Buffer>): void {
@@ -1,81 +0,0 @@
import { mkdtemp, mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
installRemoteManagedAgentHooks,
REMOTE_MANAGED_HOOK_INSTALLER_AGENTS
} from './remote-managed-hook-installers'
import { createManagedHookLocalFilesystem } from './managed-hook-local-filesystem'
const tempHomes: string[] = []
const tempRoot = process.platform === 'win32' ? tmpdir() : '/tmp'
async function createTempHome(): Promise<string> {
// Why: mkdir-p probes ancestors; macOS's per-user temp directory can contain hundreds of thousands of entries.
const home = await mkdtemp(join(tempRoot, 'orca-managed-hooks-'))
tempHomes.push(home)
return home
}
async function listFiles(root: string): Promise<string[]> {
const entries = await readdir(root, { withFileTypes: true })
const nested = await Promise.all(
entries.map(async (entry) => {
const path = join(root, entry.name)
return entry.isDirectory() ? await listFiles(path) : [path]
})
)
return nested.flat()
}
afterEach(async () => {
await Promise.all(tempHomes.splice(0).map((home) => rm(home, { recursive: true, force: true })))
})
describe('managed-hook local filesystem', () => {
it('supports cold and warm aggregate installs without SFTP or temp-file residue', async () => {
const home = await createTempHome()
const filesystem = createManagedHookLocalFilesystem()
const options = {
grokHomeDir: join(home, '.grok'),
agents: REMOTE_MANAGED_HOOK_INSTALLER_AGENTS
}
const cold = await installRemoteManagedAgentHooks(filesystem, home, options)
const warm = await installRemoteManagedAgentHooks(filesystem, home, options)
expect(cold).toHaveLength(REMOTE_MANAGED_HOOK_INSTALLER_AGENTS.length)
expect(cold.filter((result) => result.state === 'error')).toEqual([])
expect(warm).toHaveLength(REMOTE_MANAGED_HOOK_INSTALLER_AGENTS.length)
expect(warm.filter((result) => result.state === 'error')).toEqual([])
const files = await listFiles(home)
expect(files.filter((path) => path.endsWith('.tmp'))).toEqual([])
const scripts = files.filter((path) => /\.(?:sh|cmd)$/.test(path))
expect(scripts.length).toBeGreaterThanOrEqual(10)
if (process.platform !== 'win32') {
for (const script of scripts) {
expect((await stat(script)).mode & 0o777).toBe(0o755)
}
}
})
it('isolates a malformed config while installing the remaining agents', async () => {
const home = await createTempHome()
const claudeConfig = join(home, '.claude', 'settings.json')
await mkdir(join(home, '.claude'), { recursive: true })
await writeFile(claudeConfig, '{"hooks": }', 'utf8')
const results = await installRemoteManagedAgentHooks(createManagedHookLocalFilesystem(), home, {
grokHomeDir: join(home, '.grok'),
agents: REMOTE_MANAGED_HOOK_INSTALLER_AGENTS
})
expect(results).toHaveLength(REMOTE_MANAGED_HOOK_INSTALLER_AGENTS.length)
expect(results.find((result) => result.agent === 'claude')?.state).toBe('error')
expect(results.find((result) => result.agent === 'openclaude')?.state).toBe('installed')
expect(results.find((result) => result.agent === 'kimi')?.state).toBe('installed')
expect(results.find((result) => result.agent === 'jcode')?.state).toBe('installed')
expect(await readFile(claudeConfig, 'utf8')).toBe('{"hooks": }')
})
})
@@ -1,86 +0,0 @@
import { chmod, mkdtemp, readdir, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
const { installManagedHooks } = await import('./managed-hook-runtime')
const tempHomes: string[] = []
const tempRoot = process.platform === 'win32' ? tmpdir() : '/tmp'
const SHELL_NAME = 'login-shell'
const SHELL_RUNS_NAME = 'login-shell-runs'
async function createTempHome(): Promise<string> {
const home = await mkdtemp(join(tempRoot, 'orca-managed-hook-runtime-'))
tempHomes.push(home)
return home
}
/** Login shell that records each invocation, so a skipped GROK_HOME probe is observable. */
async function stubLoginShell(home: string): Promise<void> {
const shell = join(home, SHELL_NAME)
await writeFile(
shell,
`#!/bin/sh\necho ran >> "${join(home, SHELL_RUNS_NAME)}"\nexit 0\n`,
'utf8'
)
await chmod(shell, 0o755)
vi.stubEnv('HOME', home)
vi.stubEnv('SHELL', shell)
}
afterEach(async () => {
vi.unstubAllEnvs()
await Promise.all(tempHomes.splice(0).map((home) => rm(home, { recursive: true, force: true })))
})
describe.runIf(process.platform !== 'win32')('installManagedHooks', () => {
it.each([
['omitted', undefined],
['empty', { agents: [] }]
])(
'writes nothing and runs no probe when the allowlist is %s (issue #11641)',
async (_label, options) => {
const home = await createTempHome()
await stubLoginShell(home)
await expect(installManagedHooks(options)).resolves.toEqual({ installers: 0, errors: 0 })
// Why: no agent config home, no ~/.orca install lock, and no GROK_HOME login-shell probe.
expect(await readdir(home)).toEqual([SHELL_NAME])
}
)
it('still rejects an aborted request rather than resolving an empty summary', async () => {
const controller = new AbortController()
controller.abort()
await expect(
installManagedHooks({ signal: controller.signal, agents: [] })
).rejects.toMatchObject({ name: 'AbortError' })
})
it('installs only the allowlisted agent, under the install lock', async () => {
const home = await createTempHome()
await stubLoginShell(home)
await expect(installManagedHooks({ agents: ['claude'] })).resolves.toEqual({
installers: 1,
errors: 0
})
expect((await readdir(home)).sort()).toEqual(['.claude', '.orca', SHELL_NAME])
})
it('still probes the login shell when Grok is selected', async () => {
const home = await createTempHome()
await stubLoginShell(home)
await expect(installManagedHooks({ agents: ['grok'] })).resolves.toEqual({
installers: 1,
errors: 0
})
expect((await readdir(home)).sort()).toEqual(['.grok', '.orca', SHELL_NAME, SHELL_RUNS_NAME])
})
})
@@ -1,62 +0,0 @@
import { homedir } from 'node:os'
import { resolveLoginShellGrokHome, resolveLoginShellKiroHome } from './agent-home-login-shell'
import { installRemoteManagedAgentHooks } from './remote-managed-hook-installers'
import type { AgentHookTarget } from '../../shared/agent-hook-types'
import { createManagedHookLocalFilesystem } from './managed-hook-local-filesystem'
import { withManagedHookInstallLock } from './managed-hook-install-lock'
import {
readManagedHookHostIdentity,
scopeManagedHookHostIdentity
} from './managed-hook-owner-identity'
export type ManagedHookInstallSummary = {
installers: number
errors: number
}
export async function installManagedHooks(options?: {
signal?: AbortSignal
hostKeyFingerprint?: string
agents?: readonly AgentHookTarget[]
claudeVersion?: string
}): Promise<ManagedHookInstallSummary> {
options?.signal?.throwIfAborted()
// Why: empty/omitted allowlist fails closed before any home/host probes.
const agents = options?.agents ?? []
if (agents.length === 0) {
return { installers: 0, errors: 0 }
}
const home = homedir()
// Why parallel: each probe may wait out its own login-shell timeout.
const [grokHomeDir, kiroHomeDir] = await Promise.all([
agents.includes('grok') ? resolveLoginShellGrokHome(home, options?.signal) : undefined,
agents.includes('kiro') ? resolveLoginShellKiroHome(home, options?.signal) : undefined
])
options?.signal?.throwIfAborted()
const hostIdentity = scopeManagedHookHostIdentity(
await readManagedHookHostIdentity(),
options?.hostKeyFingerprint
)
return await withManagedHookInstallLock(
home,
options?.signal,
async () => {
const results = await installRemoteManagedAgentHooks(
createManagedHookLocalFilesystem(),
home,
{
grokHomeDir,
kiroHomeDir,
signal: options?.signal,
agents,
...(options?.claudeVersion ? { claudeVersion: options.claudeVersion } : {})
}
)
return {
installers: results.length,
errors: results.filter((result) => result.state === 'error').length
}
},
hostIdentity
)
}
@@ -13,7 +13,7 @@ import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import { AgentHookServer } from './server'
import { WslHookRelayManager } from './wsl-hook-relay-manager'
import { createManagedHookLocalFilesystem } from './managed-hook-local-filesystem'
import { createManagedHookLocalFilesystem } from './managed-hook-local-filesystem.test-fixture'
import { codexHookService } from '../codex/hook-service'
const BUNDLE_DIR = join(process.cwd(), 'out', 'relay', 'wsl')
@@ -1,10 +1,7 @@
import '../runtime/rpc/unused-default-rpc-methods.test-fixture'
import { afterEach, describe, expect, it } from 'vitest'
import { AiVaultHandler } from '../../relay/ai-vault-handler'
import type { RelayDispatcher } from '../../wsl-guest/dispatcher'
import { createSessionSearchClient } from '../../shared/ai-vault-search-client'
import { fakeSearchService } from '../../shared/ai-vault-search-test-fixture'
import { AI_VAULT_AGENTS } from '../../shared/ai-vault-types'
import { RpcDispatcher } from '../runtime/rpc/dispatcher'
import { OrcaRuntimeService } from '../runtime/orca-runtime'
import { AI_VAULT_METHODS } from '../runtime/rpc/methods/ai-vault'
@@ -28,22 +25,6 @@ afterEach(() => {
resetSessionSearchScopeCatalogForTests()
})
function relayHandler(): (params: Record<string, unknown>) => Promise<unknown> {
const handlers = new Map<string, (params: Record<string, unknown>) => Promise<unknown>>()
const dispatcher = {
onRequest: (method: string, handler: (params: Record<string, unknown>) => Promise<unknown>) => {
handlers.set(method, handler)
}
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handler only calls `onRequest`, and the test asserts the registration it makes.
new AiVaultHandler(dispatcher as unknown as RelayDispatcher)
const search = handlers.get('aiVault.searchSessions')
if (!search) {
throw new Error('relay registered no session search handler')
}
return search
}
describe('every search entry point carries the scope identity through', () => {
it('resolves over the in-process IPC entry point', async () => {
const service = fakeSearchService()
@@ -76,40 +57,6 @@ describe('every search entry point carries the scope identity through', () => {
})
})
it('resolves over the relay entry point', async () => {
const service = fakeSearchService()
setSessionSearchService(service)
installSessionSearchScopeCatalogSource(() => CATALOG)
await relayHandler()({
query: 'needle',
supportedAgents: [...AI_VAULT_AGENTS],
supportsQoderHistory: true,
supportsJcodeHistory: true,
within: WITHIN
})
expect(service.search).toHaveBeenCalledWith(expect.anything(), {
kind: 'resolved',
paths: ['/work/app']
})
})
it('hands the relay’s own verdict down, that host carrying no repo catalog', async () => {
const service = fakeSearchService()
setSessionSearchService(service)
await relayHandler()({
query: 'needle',
supportedAgents: [...AI_VAULT_AGENTS],
supportsQoderHistory: true,
supportsJcodeHistory: true,
within: WITHIN
})
expect(service.search).toHaveBeenCalledWith(
{ query: 'needle', limit: 20 },
{
kind: 'unknown'
}
)
})
})
describe('the shared remote client', () => {
@@ -2,13 +2,10 @@ import { runProcess } from '@orca/process-host'
import { mkdtemp, mkdir, rm, writeFile, utimes } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it } from 'vitest'
import { scanAiVaultSessions } from './session-scanner'
import { isolatedScanRoots, writeAntigravityTranscript } from './session-scanner-test-fixtures'
import { MemoryRemoteProvider, jsonLines } from './remote-session-scanner-test-fixtures'
import { scanRemoteAiVaultSessions } from './remote-session-scanner'
import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform'
import { dedupeScannedSessions } from './session-root-dedup'
import { jsonLines } from './remote-session-scanner-test-fixtures'
import { antigravitySessionOrigin } from '../../shared/antigravity-session-origin'
import { readLocalAntigravityHistory } from './session-scanner-antigravity-history'
import { ANTIGRAVITY_INDEX_MAX_BYTES } from './session-scanner-antigravity-metadata'
@@ -190,39 +187,4 @@ describe('Antigravity IDE history discovery', () => {
expect(await readLocalAntigravityHistory(file)).toBeNull()
})
it('reads only the execution host and never collapses equal origin IDs from different remote hosts', async () => {
const provider = new MemoryRemoteProvider()
const remoteHome = '/remote/home'
for (const origin of origins) {
const logs = `${remoteHome}/.gemini/${origin}/brain/${conversationId}/.system_generated/logs`
provider.addFile(`${logs}/transcript_full.jsonl`, jsonLines(records(origin)), 10)
provider.addFile(`${logs}/transcript.jsonl`, jsonLines(records(origin)), 11)
}
const read = vi.spyOn(provider, 'readFile')
const options = {
provider,
remoteHome,
hostPlatform: getRemoteHostPlatform('linux-x64'),
includeAntigravityIdeSessions: true
}
const first = await scanRemoteAiVaultSessions({ ...options, executionHostId: 'ssh:first-host' })
const second = await scanRemoteAiVaultSessions({
...options,
executionHostId: 'ssh:second-host'
})
expect(first.sessions).toHaveLength(3)
expect(second.sessions).toHaveLength(3)
expect(dedupeScannedSessions([...first.sessions, ...second.sessions])).toHaveLength(6)
expect(read.mock.calls.every(([path]) => path.startsWith(remoteHome))).toBe(true)
expect(provider.readDirPaths.every((path) => path.startsWith(remoteHome))).toBe(true)
expect(
first.sessions.every((session) => session.filePath.endsWith('transcript_full.jsonl'))
).toBe(true)
const legacy = await scanRemoteAiVaultSessions({
...options,
executionHostId: 'ssh:first-host',
includeAntigravityIdeSessions: false
})
expect(legacy.sessions).toHaveLength(1)
})
})
@@ -1,6 +1,6 @@
import type * as NodeFsPromises from 'node:fs/promises'
import { constants } from 'node:fs'
import { mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
@@ -23,7 +23,6 @@ vi.mock('node:fs/promises', async (original) => {
})
import { readNodeFileWithinLimit } from '../../shared/node-bounded-file-reader'
import { readLocalAntigravityHistory } from './session-scanner-antigravity-history'
import { readRelayTranscriptBytes } from '../../relay/ai-vault-transcript-stream'
const roots: string[] = []
async function filePath() {
@@ -43,25 +42,6 @@ afterEach(async () => {
})
describe('bounded regular metadata reads', () => {
it.skipIf(process.platform === 'win32')(
'rejects real FIFO paths and symlinks without waiting for a writer',
async () => {
const file = await filePath()
await fifo(file)
const alias = `${file}.link`
await symlink(file, alias)
for (const path of [file, alias]) {
expect(await readLocalAntigravityHistory(path)).toBeNull()
await expect(readNodeFileWithinLimit(path, 64, { regularFileOnly: true })).rejects.toThrow(
'regular file'
)
await expect(
readRelayTranscriptBytes(path, undefined, { regularFileOnly: true, maxBytes: 64 }).next()
).rejects.toThrow('regular file')
}
},
2000
)
it.skipIf(process.platform === 'win32')(
'rejects a regular path replaced by a FIFO between stat and open',
@@ -1,67 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import { readRemoteAntigravityIndex } from './antigravity-index-reader'
import { limitRemoteScanFilesystemConcurrency } from './remote-session-scan-concurrency'
import type { RemoteSessionFilesystemProvider } from './remote-session-scanner-types'
import { ANTIGRAVITY_INDEX_MAX_BYTES } from './session-scanner-antigravity-metadata'
function provider(): RemoteSessionFilesystemProvider {
return {
readDir: async () => [],
stat: async () => ({ type: 'file', size: 2, mtime: 0 }),
readFile: async () => ({ content: '{}', isBinary: false })
}
}
describe('execution-host Antigravity metadata reads', () => {
it('forwards regular-file admission and byte limits through the concurrency wrapper', async () => {
const host = provider()
const read = vi.fn(async function* () {
yield Buffer.from('{}')
})
host.readTranscriptBytes = read
expect(
await readRemoteAntigravityIndex(
limitRemoteScanFilesystemConcurrency(host),
'/host/projects.json'
)
).toBe('{}')
expect(read).toHaveBeenCalledWith('/host/projects.json', undefined, {
regularFileOnly: true,
maxBytes: ANTIGRAVITY_INDEX_MAX_BYTES
})
})
it('preserves the existing bounded read-file options on legacy providers', async () => {
const host = provider()
const read = vi.spyOn(host, 'readFile')
expect(
await readRemoteAntigravityIndex(
limitRemoteScanFilesystemConcurrency(host),
'/host/projects.json'
)
).toBe('{}')
expect(read).toHaveBeenCalledWith('/host/projects.json', {
maxTextBytes: ANTIGRAVITY_INDEX_MAX_BYTES
})
})
it('does not read directories or symlinks from legacy providers', async () => {
for (const type of ['directory', 'symlink'] as const) {
const host = provider()
host.stat = async () => ({ type, size: 0, mtime: 0 })
const read = vi.spyOn(host, 'readFile')
expect(await readRemoteAntigravityIndex(host, '/host/projects.json')).toBeNull()
expect(read).not.toHaveBeenCalled()
}
})
it('stops waiting for a legacy request that cannot cancel on the wire', async () => {
const host = provider()
host.readFile = () => new Promise(() => {})
const controller = new AbortController()
const read = readRemoteAntigravityIndex(host, '/host/projects.json', controller.signal)
await Promise.resolve()
controller.abort()
await expect(read).rejects.toMatchObject({ name: 'AbortError' })
})
})
@@ -1,48 +0,0 @@
import {
ANTIGRAVITY_INDEX_MAX_BYTES,
readBoundedAntigravityIndex
} from './session-scanner-antigravity-metadata'
import type { RemoteSessionFilesystemProvider } from './remote-session-scanner-types'
import {
abandonRemoteSessionScanOnCancel,
throwIfAiVaultScanCancelled
} from './ai-vault-scan-cancellation'
export async function readRemoteAntigravityIndex(
provider: RemoteSessionFilesystemProvider,
path: string,
signal?: AbortSignal
): Promise<string | null> {
try {
throwIfAiVaultScanCancelled(signal)
if (provider.readTranscriptBytes) {
return await abandonRemoteSessionScanOnCancel(
readBoundedAntigravityIndex(
provider.readTranscriptBytes(path, signal, {
regularFileOnly: true,
maxBytes: ANTIGRAVITY_INDEX_MAX_BYTES
})
),
signal
)
}
const stat = await abandonRemoteSessionScanOnCancel(provider.stat(path), signal)
if (stat.type !== 'file' || stat.size > ANTIGRAVITY_INDEX_MAX_BYTES) {
return null
}
const read = await abandonRemoteSessionScanOnCancel(
provider.readFile(path, { maxTextBytes: ANTIGRAVITY_INDEX_MAX_BYTES }),
signal
)
throwIfAiVaultScanCancelled(signal)
return read.isBinary || Buffer.byteLength(read.content) > ANTIGRAVITY_INDEX_MAX_BYTES
? null
: read.content
} catch (error) {
throwIfAiVaultScanCancelled(signal)
if (error instanceof Error && error.name === 'AbortError') {
throw error
}
return null
}
}
@@ -1,11 +1,7 @@
import { mkdir, mkdtemp, readdir, rm, stat, writeFile } from 'node:fs/promises'
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, relative } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform'
import { discoverRemoteSourceCandidates } from './remote-session-scanner-discovery'
import { remoteSessionSources } from './remote-session-scanner-sources'
import type { RemoteScannerContext } from './remote-session-scanner-types'
import { AI_VAULT_AGENT_SOURCES } from './session-scanner-agent-sources'
import { walkSessionFiles } from './session-scanner-discovery'
@@ -49,44 +45,4 @@ describe('OpenClaw session discovery', () => {
expect(relativeToAgents(files)).toEqual(EXPECTED)
})
it('never lists an embedded agent home over a remote filesystem', async () => {
const readDirs: string[] = []
const provider: RemoteScannerContext['provider'] = {
readDir: async (dirPath) => {
readDirs.push(dirPath)
const entries = await readdir(dirPath, { withFileTypes: true })
return entries.map((entry) => ({
name: entry.name,
isDirectory: entry.isDirectory(),
isSymlink: entry.isSymbolicLink()
}))
},
stat: async (path) => {
const info = await stat(path)
return { size: info.size, type: 'file', mtime: info.mtimeMs }
},
readFile: async () => ({ content: '', isBinary: false })
}
const hostPlatform = getRemoteHostPlatform(
process.platform === 'win32' ? 'win32-x64' : 'linux-x64'
)
const source = remoteSessionSources(home, hostPlatform).find(
(candidate) => candidate.agent === 'openclaw'
)
const context: RemoteScannerContext = {
provider,
executionHostId: 'ssh:host',
hostPlatform,
titleCaches: new Map(),
antigravityWorkspaceResolver: { enrich: async (session) => session }
}
expect(source).toBeDefined()
const candidates = source
? await discoverRemoteSourceCandidates({ source, context, issues: [] })
: []
expect(relativeToAgents(candidates.map(({ file }) => file.path))).toEqual(EXPECTED)
expect(readDirs.some((dir) => dir.includes('codex-home'))).toBe(false)
})
})
@@ -87,10 +87,7 @@ describe('WSL SQLite runtime preparation', () => {
args: ['-e', expect.stringMatching(/require\('node:sqlite'\).*backup/)]
})
)
expect(mocks.bundles.mock.calls.map(([platform]) => platform)).toEqual([
'linux-x64',
'linux-arm64'
])
expect(mocks.bundles.mock.calls.map(([platform]) => platform)).toEqual(['wsl'])
expect(mocks.download).not.toHaveBeenCalled()
})
@@ -102,9 +102,7 @@ async function prepare(distro: string): Promise<OpenCodeWslRuntime> {
return result.stdout.trim()
}
const app = getAppEnvironment()
// The reader is plain JavaScript; either packaged Linux architecture is usable.
const reader = (['linux-x64', 'linux-arm64'] as const)
.flatMap((platform) => relayBundleCandidates(platform, app.getAppPath()))
const reader = relayBundleCandidates('wsl', app.getAppPath())
.map((directory) => join(directory, RELAY_OPENCODE_SQLITE_READER_FILENAME))
.find(existsSync)
if (!reader) {
@@ -1,51 +0,0 @@
import type { AiVaultAgent } from '../../shared/ai-vault-types'
import { parseDevinSessionDocument } from './session-scanner-devin-parser'
import { parseHermesSessionDocument } from './session-scanner-hermes-parser'
import {
parseGeminiSessionDocument,
parseGeminiJsonlSessionLines
} from './session-scanner-gemini-parsers'
import type { RemoteSessionSource } from './remote-session-scanner-types'
export function remoteSessionDocumentParsers(
agent: AiVaultAgent
): Pick<RemoteSessionSource, 'parseLines' | 'parseDocument'> {
const parse =
agent === 'hermes'
? parseHermesSessionDocument
: agent === 'devin'
? parseDevinSessionDocument
: agent === 'gemini'
? parseGeminiSessionDocument
: null
if (!parse) {
return {}
}
return {
parseDocument: (file, bytes, context) =>
parse(
file,
bytes,
context.hostPlatform.os,
{
executionHostId: context.executionHostId,
executionHostPlatform: context.hostPlatform.os
},
context.signal
),
...(agent === 'gemini'
? {
parseLines: (file, lines, context) =>
parseGeminiJsonlSessionLines({
file,
lines,
platform: context.hostPlatform.os,
options: {
executionHostId: context.executionHostId,
executionHostPlatform: context.hostPlatform.os
}
})
}
: {})
}
}
@@ -1,223 +0,0 @@
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, dirname } from 'node:path'
import { describe, it, expect } from 'vitest'
import { createRelayAiVaultFilesystemProvider } from '../../relay/ai-vault-service-filesystem'
import { scanRemoteAiVaultSessions } from './remote-session-scanner'
import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform'
const platform = getRemoteHostPlatform(
process.platform === 'win32'
? 'win32-x64'
: process.platform === 'darwin'
? 'darwin-arm64'
: 'linux-x64'
)
const jsonl = (rows: unknown[]) => `${rows.map((row) => JSON.stringify(row)).join('\n')}\n`
const filler = jsonl([{ type: 'irrelevant_event', payload: 'x'.repeat(1024) }]).repeat(11000)
describe('large remote history through real relay filesystem', () => {
it('reports an oversized record without losing healthy sessions or publishing a partial session', async () => {
const home = await mkdtemp(join(tmpdir(), 'orca-history-record-limit-'))
try {
const directory = join(home, '.codex', 'sessions')
await mkdir(directory, { recursive: true })
const metadata = (id: string) =>
jsonl([{ type: 'session_meta', payload: { id, cwd: '/repo' } }])
const badPath = join(directory, 'bad.jsonl')
await writeFile(badPath, metadata('bad') + 'x'.repeat(11 * 1024 * 1024))
await writeFile(join(directory, 'good.jsonl'), metadata('good'))
const result = await scanRemoteAiVaultSessions({
provider: createRelayAiVaultFilesystemProvider({ homeDirectory: home, environment: {} }),
executionHostId: 'ssh:record-limit',
remoteHome: home,
hostPlatform: platform,
unlimited: true
})
expect(result.sessions.map((session) => session.sessionId)).toEqual(['good'])
expect(result.issues).toEqual([
expect.objectContaining({
path: badPath.replace(/\\/g, '/'),
message: 'Session transcript record exceeds 10485760 byte limit'
})
])
} finally {
await rm(home, { recursive: true, force: true })
}
})
it('lists a large Codex rollout with middle messages and usage intact', async () => {
const home = await mkdtemp(join(tmpdir(), 'orca-history-17744-'))
try {
const path = join(home, '.codex', 'sessions', 'large.jsonl')
await mkdir(dirname(path), { recursive: true })
await writeFile(
path,
jsonl([
{
type: 'session_meta',
timestamp: '2026-09-13T01:00:00Z',
payload: { id: 'large', cwd: '/repo' }
},
{
type: 'response_item',
timestamp: '2026-09-13T01:00:01Z',
payload: {
type: 'message',
role: 'user',
content: [{ type: 'input_text', text: 'Keep my history' }]
}
}
]) +
filler.slice(0, filler.length / 2) +
jsonl([
{
type: 'response_item',
timestamp: '2026-09-13T01:02:00Z',
payload: {
type: 'message',
role: 'assistant',
content: [{ type: 'output_text', text: 'Middle answer' }]
}
},
{
type: 'event_msg',
timestamp: '2026-09-13T01:03:00Z',
payload: {
type: 'token_count',
info: {
total_token_usage: { input_tokens: 123, output_tokens: 45, total_tokens: 168 }
}
}
}
]) +
filler.slice(filler.length / 2)
)
const result = await scanRemoteAiVaultSessions({
provider: createRelayAiVaultFilesystemProvider({ homeDirectory: home, environment: {} }),
executionHostId: 'ssh:synthetic-17744',
remoteHome: home,
hostPlatform: platform,
unlimited: true
})
expect(result.issues).toEqual([])
expect(result.sessions).toHaveLength(1)
expect(result.sessions[0]).toMatchObject({
sessionId: 'large',
messageCount: 2,
totalTokens: 168,
title: 'Keep my history'
})
} finally {
await rm(home, { recursive: true, force: true })
}
})
it.each(['hermes', 'devin', 'gemini', 'cline'] as const)(
'lists large %s documents with every message counted',
async (agent) => {
const home = await mkdtemp(join(tmpdir(), 'orca-history-17744-document-'))
try {
const messages = Array.from({ length: 11000 }, () => ({
role: 'assistant',
content: 'x'.repeat(1024)
}))
messages.splice(5000, 0, { role: 'user', content: 'A middle user turn' })
let path: string, record: unknown
if (agent === 'hermes') {
path = join(home, '.hermes', 'sessions', 'large.json')
record = { session_id: 'large', cwd: '/repo', model: 'test-model', messages }
} else if (agent === 'devin') {
path =
platform.os === 'win32'
? join(home, 'AppData', 'Roaming', 'devin', 'cli', 'transcripts', 'large.json')
: join(home, '.local', 'share', 'devin', 'cli', 'transcripts', 'large.json')
record = {
session_id: 'large',
working_directory: '/repo',
steps: messages.map((message) => ({
...message,
metadata: {
is_user_input: message.role === 'user',
metrics: { input_tokens: 2, output_tokens: 1 }
}
}))
}
} else if (agent === 'gemini') {
path = join(home, '.gemini', 'tmp', 'large.json')
record = {
sessionId: 'large',
messages: messages.map((message) => ({
type: message.role === 'assistant' ? 'gemini' : 'user',
content: message.content
}))
}
} else {
path = join(home, '.cline', 'data', 'sessions', 'large', 'large.json')
record = { session_id: 'large', cwd: '/repo' }
await mkdir(dirname(path), { recursive: true })
await writeFile(path.replace('.json', '.messages.json'), JSON.stringify({ messages }))
}
await mkdir(dirname(path), { recursive: true })
await writeFile(path, JSON.stringify(record))
const result = await scanRemoteAiVaultSessions({
provider: createRelayAiVaultFilesystemProvider({ homeDirectory: home, environment: {} }),
executionHostId: `ssh:large-${agent}`,
remoteHome: home,
hostPlatform: platform,
unlimited: true
})
expect(result.issues).toEqual([])
expect(result.sessions).toHaveLength(1)
expect(result.sessions[0]).toMatchObject({ agent, sessionId: 'large', messageCount: 11001 })
if (agent === 'devin') {
expect(result.sessions[0].totalTokens).toBe(33003)
}
} finally {
await rm(home, { recursive: true, force: true })
}
}
)
it('keeps normal-size reads on their existing path and supports providers without streaming', async () => {
const home = await mkdtemp(join(tmpdir(), 'orca-history-legacy-'))
try {
const directory = join(home, '.codex', 'sessions')
await mkdir(directory, { recursive: true })
const content = jsonl([{ type: 'session_meta', payload: { id: 'small', cwd: '/repo' } }])
await writeFile(join(directory, 'small.jsonl'), content)
const provider = createRelayAiVaultFilesystemProvider({
homeDirectory: home,
environment: {}
})
provider.readTranscriptBytes = () => {
throw new Error('Small file must keep its existing read path')
}
const small = await scanRemoteAiVaultSessions({
provider,
executionHostId: 'ssh:small-original',
remoteHome: home,
hostPlatform: platform,
unlimited: true
})
expect(small.issues).toEqual([])
expect(small.sessions.map((session) => session.sessionId)).toEqual(['small'])
await writeFile(join(directory, 'large.jsonl'), content + filler)
const legacy = { readDir: provider.readDir, readFile: provider.readFile, stat: provider.stat }
const fallback = await scanRemoteAiVaultSessions({
provider: legacy,
executionHostId: 'ssh:legacy-original',
remoteHome: home,
hostPlatform: platform,
unlimited: true
})
expect(fallback.sessions.map((session) => session.sessionId)).toEqual(['small'])
expect(
fallback.issues.some(
(issue) => issue.path.endsWith('large.jsonl') && issue.message.includes('10MB limit')
)
).toBe(true)
} finally {
await rm(home, { recursive: true, force: true })
}
})
})
@@ -1,216 +0,0 @@
import { beforeEach, describe, expect, it } from 'vitest'
import type { FileReadResult } from '../providers/types'
import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform'
import { resetRemoteSessionParseCacheForTests } from './remote-session-parse-cache'
import { scanRemoteAiVaultSessions } from './remote-session-scanner'
import { MemoryRemoteProvider, jsonLines } from './remote-session-scanner-test-fixtures'
/**
* Counts whole-transcript reads, which is the cost #13753 is about. Codex's
* per-scan `session_index.jsonl` title lookup is one small file and is not part
* of the corpus term, so it is excluded rather than asserted on.
*/
class CountingRemoteProvider extends MemoryRemoteProvider {
readonly readFilePaths: string[] = []
override async readFile(filePath: string): Promise<FileReadResult> {
if (filePath.includes('/sessions/')) {
this.readFilePaths.push(filePath)
}
return await super.readFile(filePath)
}
}
function transcript(sessionId: string, title: string, timestamp: string): string {
return jsonLines([
{
timestamp,
type: 'session_meta',
payload: { id: sessionId, cwd: '/home/ada/repo' }
},
{
timestamp,
type: 'response_item',
payload: { type: 'message', role: 'user', content: [{ type: 'text', text: title }] }
}
])
}
function scan(provider: CountingRemoteProvider): ReturnType<typeof scanRemoteAiVaultSessions> {
return scanRemoteAiVaultSessions({
provider,
executionHostId: 'ssh:dev-box',
remoteHome: '/home/ada',
hostPlatform: getRemoteHostPlatform('linux-x64')
})
}
describe('remote AI Vault transcript re-reads', () => {
beforeEach(() => {
resetRemoteSessionParseCacheForTests()
})
it('does not re-read an unchanged corpus on the next scan', async () => {
const provider = new CountingRemoteProvider()
for (const day of ['07/07', '07/25', '08/10']) {
provider.addFile(
`/home/ada/.codex/sessions/2026/${day}/rollout-${day.replace('/', '')}.jsonl`,
transcript(
`session-${day.replace('/', '')}`,
`Work from ${day}`,
'2026-07-07T01:00:00.000Z'
),
1_000
)
}
const first = await scan(provider)
expect(first.sessions).toHaveLength(3)
expect(provider.readFilePaths).toHaveLength(3)
provider.readFilePaths.length = 0
const second = await scan(provider)
// Historical transcripts are immutable; a second pass must cost zero reads.
expect(provider.readFilePaths).toEqual([])
expect(second.sessions.map((session) => session.title)).toEqual(
first.sessions.map((session) => session.title)
)
})
it('re-reads a transcript that actually changed', async () => {
const provider = new CountingRemoteProvider()
const path = '/home/ada/.codex/sessions/2026/08/31/rollout-live.jsonl'
provider.addFile(
path,
transcript('live-session', 'First prompt', '2026-08-31T01:00:00.000Z'),
1_000
)
await scan(provider)
provider.readFilePaths.length = 0
provider.addFile(
path,
transcript('live-session', 'Second prompt', '2026-08-31T02:00:00.000Z'),
2_000
)
const result = await scan(provider)
expect(provider.readFilePaths).toEqual([path])
expect(result.sessions[0]?.title).toBe('Second prompt')
})
it('re-reads when only the size changed under an unchanged mtime', async () => {
const provider = new CountingRemoteProvider()
const path = '/home/ada/.codex/sessions/2026/08/31/rollout-grown.jsonl'
provider.addFile(path, transcript('grown-session', 'Short', '2026-08-31T01:00:00.000Z'), 1_000)
await scan(provider)
provider.readFilePaths.length = 0
provider.addFile(
path,
transcript(
'grown-session',
'A much longer first prompt than before',
'2026-08-31T01:00:00.000Z'
),
1_000
)
const result = await scan(provider)
expect(provider.readFilePaths).toEqual([path])
expect(result.sessions[0]?.title).toBe('A much longer first prompt than before')
})
// Codex names threads in $CODEX_HOME/session_index.jsonl asynchronously, after
// the rollout's last append — so the transcript's mtime+size never changes to
// signal it. That file sits outside `sessions/`, hence outside the read count.
it('picks up a session_index title written after the transcript was cached', async () => {
const provider = new CountingRemoteProvider()
const path = '/home/ada/.codex/sessions/2026/08/31/rollout-named-later.jsonl'
provider.addFile(
path,
transcript('named-later-session', 'First prompt', '2026-08-31T01:00:00.000Z'),
1_000
)
provider.addFile(
'/home/ada/.codex/session_index.jsonl',
jsonLines([{ id: 'some-other-session', thread_name: 'Unrelated thread' }]),
1_000
)
const first = await scan(provider)
expect(first.sessions[0]?.title).toBe('First prompt')
provider.readFilePaths.length = 0
provider.addFile(
'/home/ada/.codex/session_index.jsonl',
jsonLines([
{ id: 'some-other-session', thread_name: 'Unrelated thread' },
{ id: 'named-later-session', thread_name: 'Named by Codex after the fact' }
]),
2_000
)
const second = await scan(provider)
expect(second.sessions[0]?.title).toBe('Named by Codex after the fact')
// The #13753 win is preserved: the index is read, the transcript is not.
expect(provider.readFilePaths).toEqual([])
})
it('does not serve a cached parse to a different execution host', async () => {
const provider = new CountingRemoteProvider()
const path = '/home/ada/.codex/sessions/2026/08/31/rollout-host.jsonl'
provider.addFile(
path,
transcript('host-session', 'Host scoped', '2026-08-31T01:00:00.000Z'),
1_000
)
await scan(provider)
provider.readFilePaths.length = 0
const other = await scanRemoteAiVaultSessions({
provider,
executionHostId: 'ssh:other-box',
remoteHome: '/home/ada',
hostPlatform: getRemoteHostPlatform('linux-x64')
})
expect(provider.readFilePaths).toEqual([path])
expect(other.sessions[0]?.executionHostId).toBe('ssh:other-box')
})
it('does not cache a read that failed', async () => {
const provider = new CountingRemoteProvider()
const path = '/home/ada/.codex/sessions/2026/08/31/rollout-flaky.jsonl'
provider.addFile(
path,
transcript('flaky-session', 'Recovered', '2026-08-31T01:00:00.000Z'),
1_000
)
let failNextRead = true
const originalReadFile = provider.readFile.bind(provider)
provider.readFile = async (filePath: string): Promise<FileReadResult> => {
if (failNextRead && filePath === path) {
failNextRead = false
provider.readFilePaths.push(filePath)
throw new Error('EIO: transient relay read failure')
}
return await originalReadFile(filePath)
}
const failed = await scan(provider)
expect(failed.sessions).toEqual([])
expect(failed.issues).toHaveLength(1)
provider.readFilePaths.length = 0
const recovered = await scan(provider)
expect(provider.readFilePaths).toEqual([path])
expect(recovered.sessions[0]?.title).toBe('Recovered')
})
})
@@ -1,116 +0,0 @@
import type { AiVaultSession } from '../../shared/ai-vault-types'
import type { RemoteScannerContext, RemoteSessionCandidate } from './remote-session-scanner-types'
import { sidecarUnchanged, type SessionSidecarObservation } from './session-sidecar-stat'
// Matches the local scanner's cap. The relay sidecar is forked with
// --max-old-space-size=384, and a retained session row is a title, a preview
// window and counters — orders of magnitude smaller than the transcript it was
// parsed from, which is what the cache stops us re-reading.
const MAX_CACHE_ENTRIES = 4096
type RemoteSessionParseCacheEntry = {
mtimeMs: number
sizeBytes: number | null
hostKey: string
sidecar?: SessionSidecarObservation
session: AiVaultSession | null
}
// Module scope so it outlives one scan: the sidecar is retired only after 10
// idle minutes, so it spans many passes of a 30s cadence.
const cache = new Map<string, RemoteSessionParseCacheEntry>()
export type RemoteSessionParseStats = { reused: number; parsed: number }
export function resetRemoteSessionParseCacheForTests(): void {
cache.clear()
}
/** Identity of the host a parse result belongs to; a result is not portable across either field. */
export function remoteSessionParseHostKey(context: RemoteScannerContext): string {
return `${context.executionHostId}\u0000${context.hostPlatform.relayPlatform}`
}
export function remoteSessionCandidateKey(candidate: RemoteSessionCandidate): string {
return `${candidate.source.agent}\u0000${candidate.file.path}`
}
function storeEntry(path: string, entry: RemoteSessionParseCacheEntry): void {
cache.delete(path)
cache.set(path, entry)
if (cache.size > MAX_CACHE_ENTRIES) {
const oldest = cache.keys().next()
if (!oldest.done) {
cache.delete(oldest.value)
}
}
}
/**
* Parse a remote transcript, reusing the previous result when the file is
* provably unchanged.
*
* Why this exists: the remote scanner had no cache of any kind, so every pass
* re-read and re-parsed the whole corpus — up to 3000 whole-file reads, GBs of
* JSONL, including July transcripts that had not changed in a month — which is
* what pegged the relay host on the renderer's 30s forced-rescan cadence
* (#13753). The local scanner has had `parseAgentSessionFileCached` for exactly
* this reason; this is its remote counterpart.
*
* `(mtimeMs, sizeBytes)` covers the transcript, and the sidecar observation
* discovery records beside it (remote-session-scanner-discovery.ts) covers a
* source's companion file, so a metadata-only transcript whose companion
* changed still looks changed. Remote Cline is the only such source; remote
* Cursor streams transcript content with no sibling to read. Sources whose
* parse reads a file discovery does not stat — Codex looks its title up in
* `session_index.jsonl` — are not covered by either and pass
* `refreshReusedSession` to re-derive the uncovered part without touching the
* transcript.
*
* Only a completed parse is stored. A read that threw stays uncached so a
* transient filesystem failure cannot pin a wrong answer for the corpus's life.
*/
export async function parseRemoteSessionFileCached(args: {
candidate: RemoteSessionCandidate
hostKey: string
parse: () => Promise<AiVaultSession | null>
// Applied to a reused session only; must not re-read the transcript.
refreshReusedSession?: (session: AiVaultSession) => Promise<AiVaultSession>
stats?: RemoteSessionParseStats
}): Promise<AiVaultSession | null> {
const { file } = args.candidate
const key = remoteSessionCandidateKey(args.candidate)
const entry = cache.get(key)
const unchanged =
entry !== undefined &&
entry.hostKey === args.hostKey &&
entry.mtimeMs === file.mtimeMs &&
(entry.sizeBytes === null ||
file.sizeBytes === undefined ||
entry.sizeBytes === file.sizeBytes) &&
sidecarUnchanged(entry.sidecar, file.sidecar)
if (unchanged) {
if (args.stats) {
args.stats.reused++
}
if (entry.session && args.refreshReusedSession) {
entry.session = await args.refreshReusedSession(entry.session)
}
// Refresh recency without re-parsing so the LRU evicts cold paths first.
storeEntry(key, entry)
return entry.session
}
const session = await args.parse()
if (args.stats) {
args.stats.parsed++
}
storeEntry(key, {
mtimeMs: file.mtimeMs,
sizeBytes: file.sizeBytes ?? null,
hostKey: args.hostKey,
sidecar: file.sidecar,
session
})
return session
}
@@ -1,33 +0,0 @@
import { describe, expect, it } from 'vitest'
import { mapRemoteScanBatches } from './remote-session-scan-batching'
describe('mapRemoteScanBatches', () => {
it('observes an abort that lands while the final batch yields', async () => {
const controller = new AbortController()
await expect(
mapRemoteScanBatches(
['a', 'b'],
2,
async (item) => {
controller.abort()
return item
},
controller.signal
)
).rejects.toMatchObject({ name: 'AbortError' })
})
it('observes an abort for empty inputs that never enter the loop', async () => {
const controller = new AbortController()
controller.abort()
await expect(
mapRemoteScanBatches([], 8, async (item) => item, controller.signal)
).rejects.toMatchObject({ name: 'AbortError' })
})
it('returns every batch result when nothing cancels', async () => {
expect(await mapRemoteScanBatches([1, 2, 3], 2, async (item) => item * 2)).toEqual([2, 4, 6])
})
})
@@ -1,22 +0,0 @@
import { setImmediate as yieldToEventLoop } from 'node:timers/promises'
import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation'
// Batching bounds concurrent remote round trips; the yield between batches keeps
// the process responsive, and the batch boundary is where cancellation lands.
export async function mapRemoteScanBatches<T, U>(
items: readonly T[],
concurrency: number,
mapper: (item: T) => Promise<U>,
signal?: AbortSignal
): Promise<U[]> {
const results: U[] = []
for (let index = 0; index < items.length; index += concurrency) {
throwIfAiVaultScanCancelled(signal)
results.push(...(await Promise.all(items.slice(index, index + concurrency).map(mapper))))
await yieldToEventLoop()
}
// An abort can land while the last batch yields, and empty inputs never enter
// the loop at all — observe it here so neither path returns as a success.
throwIfAiVaultScanCancelled(signal)
return results
}
@@ -1,77 +0,0 @@
import type { RemoteSessionFilesystemProvider } from './remote-session-scanner-types'
// Why: discovery batches (8 sources) each stat in batches of 8, and parse
// batches read whole transcripts — nested fan-out put ~64 filesystem round
// trips on one SSH mux or relay event loop at once, blowing the scan budget and
// starving pty/fs/hook traffic. Every scan shares one in-flight ceiling.
const REMOTE_SCAN_FILESYSTEM_CONCURRENCY = 8
/** Wraps a provider so all of one scan's filesystem calls share a single
* in-flight ceiling, regardless of how the callers nest their batches. */
export function limitRemoteScanFilesystemConcurrency(
provider: RemoteSessionFilesystemProvider,
maxInFlight: number = REMOTE_SCAN_FILESYSTEM_CONCURRENCY
): RemoteSessionFilesystemProvider {
const gate = createConcurrencyGate(maxInFlight)
return {
openCode: provider.openCode,
readDir: (dirPath) => gate(() => provider.readDir(dirPath)),
readFile: (filePath, limits) => gate(() => provider.readFile(filePath, limits)),
stat: (filePath) => gate(() => provider.stat(filePath)),
...(provider.readTranscriptBytes
? {
readTranscriptBytes: async function* (
path: string,
signal?: AbortSignal,
options?: { regularFileOnly: true; maxBytes: number }
) {
let enter!: () => void
let release!: () => void
const entered = new Promise<void>((resolve) => {
enter = resolve
})
const released = new Promise<void>((resolve) => {
release = resolve
})
const held = gate(async () => {
enter()
await released
})
await entered
try {
yield* provider.readTranscriptBytes!(path, signal, options)
} finally {
release()
await held
}
}
}
: {})
}
}
function createConcurrencyGate(maxInFlight: number): <T>(run: () => Promise<T>) => Promise<T> {
const limit = Math.max(1, Math.floor(maxInFlight))
const waiting: (() => void)[] = []
let inFlight = 0
return async <T>(run: () => Promise<T>): Promise<T> => {
if (inFlight < limit) {
inFlight++
} else {
// The releasing call hands its slot over directly, so a queued caller can
// never race a fresh one into an over-limit slot.
await new Promise<void>((resolve) => waiting.push(resolve))
}
try {
return await run()
} finally {
const next = waiting.shift()
if (next) {
next()
} else {
inFlight--
}
}
}
}
@@ -1,59 +0,0 @@
import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform'
import { joinRemotePath } from '../ssh/ssh-remote-platform'
import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation'
import { isMissingRemoteSessionPathError } from './remote-session-file-stat'
import type { RemoteSessionSource } from './remote-session-scanner-types'
import {
clineMessagesPathForMetadata,
isClineSessionMetadataPath,
parseClineSessionDocuments,
parseClineSessionContent
} from './session-scanner-cline-parser'
export function remoteClineSource(
remoteHome: string,
hostPlatform: RemoteHostPlatform
): RemoteSessionSource {
return {
agent: 'cline',
rootDir: joinRemotePath(hostPlatform, remoteHome, '.cline', 'data', 'sessions'),
extensions: ['.json'],
filePredicate: isClineSessionMetadataPath,
contentDependencyPath: clineMessagesPathForMetadata,
directoryPredicate: (_name, depth) => depth === 0,
parseDocument: (file, bytes, context) =>
parseClineSessionDocuments(
file,
bytes,
() =>
context.provider.readTranscriptBytes!(
clineMessagesPathForMetadata(file.path),
context.signal
),
context.hostPlatform.os,
{
executionHostId: context.executionHostId,
executionHostPlatform: context.hostPlatform.os
},
context.signal
),
parse: async (file, content, context) => {
let messagesContent: string | null = null
try {
throwIfAiVaultScanCancelled(context.signal)
const read = await context.provider.readFile(clineMessagesPathForMetadata(file.path))
throwIfAiVaultScanCancelled(context.signal)
messagesContent = read.isBinary ? null : read.content
} catch (error) {
throwIfAiVaultScanCancelled(context.signal)
if (!isMissingRemoteSessionPathError(error)) {
throw error
}
}
return parseClineSessionContent(file, content, messagesContent, context.hostPlatform.os, {
executionHostId: context.executionHostId,
executionHostPlatform: context.hostPlatform.os
})
}
}
}
@@ -1,84 +0,0 @@
import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform'
import { joinRemotePath } from '../ssh/ssh-remote-platform'
import { extractString, normalizeTitleText, parseJsonObject } from './session-scanner-values'
import { remoteSessionContentLines } from './remote-session-content-lines'
import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation'
import type {
RemoteScannerContext,
RemoteSessionFilesystemProvider
} from './remote-session-scanner-types'
const CODEX_SESSION_INDEX_FILE = 'session_index.jsonl'
// One index read per CODEX_HOME per scan (`context.titleCaches` is scan-scoped);
// used both by the transcript parse and by the parse cache's reuse path.
export function remoteCodexIndexedTitleReader(
codexHome: string,
context: RemoteScannerContext
): (sessionId: string) => Promise<string | null> {
return async (sessionId) =>
(
await remoteCodexIndexTitles({
provider: context.provider,
codexHome,
hostPlatform: context.hostPlatform,
titleCaches: context.titleCaches,
signal: context.signal
})
).get(sessionId) ?? null
}
export async function remoteCodexIndexTitles(args: {
provider: RemoteSessionFilesystemProvider
codexHome: string
hostPlatform: RemoteHostPlatform
titleCaches: Map<string, Promise<Map<string, string>>>
signal?: AbortSignal
}): Promise<Map<string, string>> {
const cached = args.titleCaches.get(args.codexHome)
if (cached) {
return cached
}
const pending = readRemoteCodexIndexTitles(
args.provider,
args.codexHome,
args.hostPlatform,
args.signal
)
args.titleCaches.set(args.codexHome, pending)
return pending
}
async function readRemoteCodexIndexTitles(
provider: RemoteSessionFilesystemProvider,
codexHome: string,
hostPlatform: RemoteHostPlatform,
signal?: AbortSignal
): Promise<Map<string, string>> {
const titleBySessionId = new Map<string, string>()
try {
throwIfAiVaultScanCancelled(signal)
const { content, isBinary } = await provider.readFile(
joinRemotePath(hostPlatform, codexHome, CODEX_SESSION_INDEX_FILE)
)
throwIfAiVaultScanCancelled(signal)
if (isBinary) {
return titleBySessionId
}
for await (const line of remoteSessionContentLines(content, signal)) {
const record = parseJsonObject(line)
if (!record) {
continue
}
const sessionId = extractString(record.id)
const title = normalizeTitleText(extractString(record.thread_name) ?? '')
if (sessionId && title) {
titleBySessionId.set(sessionId, title)
}
}
} catch {
throwIfAiVaultScanCancelled(signal)
// Codex indexes are opportunistic; raw transcripts remain sufficient.
}
return titleBySessionId
}
@@ -1,51 +0,0 @@
import { joinRemotePath, type RemoteHostPlatform } from '../ssh/ssh-remote-platform'
import { parseCodexSessionContent } from './session-scanner-codex-parser'
import { remoteCodexIndexedTitleReader } from './remote-session-scanner-codex-index'
import type { RemoteSessionContent } from './remote-session-content-lines'
import type { FileWithMtime } from './session-scanner-types'
import type { RemoteScannerContext, RemoteSessionSource } from './remote-session-scanner-types'
/** Codex is the one remote agent whose sessions live under two CODEX_HOME roots, so it
* yields a source per root. Split from `remote-session-scanner-sources.ts` so adding an
* agent does not push that module past its line budget. */
export function remoteCodexSources(
remoteHome: string,
hostPlatform: RemoteHostPlatform
): RemoteSessionSource[] {
return [
joinRemotePath(hostPlatform, remoteHome, '.codex'),
joinRemotePath(
hostPlatform,
remoteHome,
'.local',
'share',
'orca',
'codex-runtime-home',
'home'
)
].map((codexHome) => {
const parse = (
file: FileWithMtime,
content: RemoteSessionContent,
context: RemoteScannerContext
) =>
parseCodexSessionContent({
file,
content,
platform: context.hostPlatform.os,
codexHome,
executionHostId: context.executionHostId,
executionHostPlatform: context.hostPlatform.os,
signal: context.signal,
readIndexedTitle: remoteCodexIndexedTitleReader(codexHome, context)
})
return {
agent: 'codex',
rootDir: joinRemotePath(hostPlatform, codexHome, 'sessions'),
codexHome,
extensions: ['.jsonl'],
parse,
parseLines: parse
}
})
}
@@ -1,38 +0,0 @@
import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform'
import { joinRemotePath } from '../ssh/ssh-remote-platform'
import { remoteSessionDocumentParsers } from './remote-session-document-parsers'
import type { RemoteSessionSource } from './remote-session-scanner-types'
import { parseDevinSessionContent } from './session-scanner-devin-parser'
// Why: Devin CLI writes transcripts under %APPDATA% on a Windows host and
// ~/.local/share on posix ones.
function remoteDevinDataSegments(hostPlatform: RemoteHostPlatform): string[] {
return hostPlatform.os === 'win32'
? ['AppData', 'Roaming', 'devin', 'cli']
: ['.local', 'share', 'devin', 'cli']
}
export function remoteDevinSource(
remoteHome: string,
hostPlatform: RemoteHostPlatform,
directory: 'transcripts' | 'agent_logs' = 'transcripts'
): RemoteSessionSource {
return {
agent: 'devin',
rootDir: joinRemotePath(
hostPlatform,
remoteHome,
...remoteDevinDataSegments(hostPlatform),
directory
),
extensions: ['.json'],
...remoteSessionDocumentParsers('devin'),
parse: (file, content, context) =>
Promise.resolve(
parseDevinSessionContent(file, content, context.hostPlatform.os, {
executionHostId: context.executionHostId,
executionHostPlatform: context.hostPlatform.os
})
)
}
}
@@ -1,189 +0,0 @@
import { extname } from 'node:path'
import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
import type { ExecutionHostId } from '../../shared/execution-host'
import { joinRemotePath } from '../ssh/ssh-remote-platform'
import { isMissingRemoteSessionPathError, statRemoteSessionFile } from './remote-session-file-stat'
import type { FileWithMtime } from './session-scanner-types'
import type { SessionSidecarObservation } from './session-sidecar-stat'
import { errorMessage } from './session-scanner-values'
import { mapRemoteScanBatches } from './remote-session-scan-batching'
import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation'
import { recordSessionScanIssue } from './session-scan-issues'
import type {
RemoteScannerContext,
RemoteSessionCandidate,
RemoteSessionSource
} from './remote-session-scanner-types'
const REMOTE_DISCOVERY_CONCURRENCY = 8
export async function discoverRemoteSourceCandidates(args: {
source: RemoteSessionSource
context: RemoteScannerContext
issues: AiVaultScanIssue[]
}): Promise<RemoteSessionCandidate[]> {
if (args.source.discover) {
const files = await args.source.discover(args.context, args.issues)
throwIfAiVaultScanCancelled(args.context.signal)
return files.map((file) => ({ source: args.source, file }))
}
const walked = args.source.fixedChildFileSegments
? await listRemoteFixedChildFiles(args.source, args.context, args.issues)
: await walkRemoteSessionFiles(args.source, args.context, args.issues)
const partition = args.source.partitionSubagentTranscripts?.(walked) ?? null
const paths = partition ? partition.sessionFilePaths : walked
const files = await mapRemoteScanBatches(
paths,
REMOTE_DISCOVERY_CONCURRENCY,
(path) => statRemoteCandidateFile(path, args.source, args.context, args.issues),
args.context.signal
)
return files
.filter((file): file is FileWithMtime => Boolean(file))
.map((file) => ({
source: args.source,
file,
subagentTranscriptCount: partition?.subagentTranscriptCounts.get(file.path) ?? 0
}))
}
async function statRemoteCandidateFile(
path: string,
source: RemoteSessionSource,
context: RemoteScannerContext,
issues: AiVaultScanIssue[]
): Promise<FileWithMtime | null> {
const file = await statRemoteSessionFile(
context.provider,
path,
source.agent,
context.executionHostId,
issues,
{
missingIsExpected: Boolean(source.fixedChildFileSegments),
signal: context.signal
}
)
if (!file || !source.contentDependencyPath) {
return file
}
const sidecarPath = source.contentDependencyPath(path)
// Recorded beside the transcript's own stat, never folded into it: one key
// cannot mean both "the transcript grew" and "the sibling changed".
return { ...file, sidecar: await observeRemoteSidecar(source, context, sidecarPath, issues) }
}
/**
* A stat that failed for any reason other than a missing path is `'unknown'`,
* not `'none'`: serving the cached session over an unreadable sibling would
* publish metadata nobody can currently see. `statRemoteSessionFile` already
* recorded the issue for the failure.
*/
async function observeRemoteSidecar(
source: RemoteSessionSource,
context: RemoteScannerContext,
sidecarPath: string,
issues: AiVaultScanIssue[]
): Promise<SessionSidecarObservation> {
try {
const sidecar = await statRemoteSessionFile(
context.provider,
sidecarPath,
source.agent,
context.executionHostId,
issues,
{ missingIsExpected: true, signal: context.signal, rethrowFailures: true }
)
return sidecar
? { path: sidecarPath, mtimeMs: sidecar.mtimeMs, sizeBytes: sidecar.sizeBytes ?? 0 }
: 'none'
} catch {
return 'unknown'
}
}
async function listRemoteFixedChildFiles(
source: RemoteSessionSource,
context: RemoteScannerContext,
issues: AiVaultScanIssue[]
): Promise<string[]> {
throwIfAiVaultScanCancelled(context.signal)
let entries
try {
entries = await context.provider.readDir(source.rootDir)
} catch (err) {
throwIfAiVaultScanCancelled(context.signal)
recordRemoteDirectoryIssue(source, context.executionHostId, issues, source.rootDir, err)
return []
}
const segments = source.fixedChildFileSegments ?? []
// Why: Antigravity's transcript path is fixed. Constructing it avoids three
// serialized SSH readDir round trips for every conversation directory.
return entries
.filter((entry) => entry.isDirectory && !entry.isSymlink)
.flatMap((entry) =>
[segments, ...(source.additionalFixedChildFileSegments ?? [])].map((fileSegments) =>
joinRemotePath(context.hostPlatform, source.rootDir, entry.name, ...fileSegments)
)
)
.filter((path) => source.filePredicate?.(path) ?? true)
}
async function walkRemoteSessionFiles(
source: RemoteSessionSource,
context: RemoteScannerContext,
issues: AiVaultScanIssue[],
dirPath = source.rootDir,
depth = 0
): Promise<string[]> {
throwIfAiVaultScanCancelled(context.signal)
let entries
try {
entries = await context.provider.readDir(dirPath)
} catch (err) {
throwIfAiVaultScanCancelled(context.signal)
recordRemoteDirectoryIssue(source, context.executionHostId, issues, dirPath, err)
return []
}
const extensions = new Set(source.extensions)
const files: string[] = []
for (const entry of entries) {
throwIfAiVaultScanCancelled(context.signal)
const fullPath = joinRemotePath(context.hostPlatform, dirPath, entry.name)
if (
entry.isDirectory &&
!entry.isSymlink &&
(source.directoryPredicate?.(entry.name, depth) ?? true)
) {
files.push(...(await walkRemoteSessionFiles(source, context, issues, fullPath, depth + 1)))
continue
}
if (
!entry.isSymlink &&
extensions.has(extname(entry.name).toLowerCase()) &&
(source.filePredicate?.(fullPath) ?? true)
) {
files.push(fullPath)
}
}
return files
}
function recordRemoteDirectoryIssue(
source: RemoteSessionSource,
executionHostId: ExecutionHostId,
issues: AiVaultScanIssue[],
path: string,
err: unknown
): void {
if (!isMissingRemoteSessionPathError(err)) {
recordSessionScanIssue(issues, {
executionHostId,
agent: source.agent,
kind: 'host',
path,
message: errorMessage(err)
})
}
}
@@ -1,73 +0,0 @@
import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform'
import { joinRemotePath } from '../ssh/ssh-remote-platform'
import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation'
import { isMissingRemoteSessionPathError } from './remote-session-file-stat'
import {
remoteSessionContentLines,
streamedSessionContentLines
} from './remote-session-content-lines'
import type { RemoteScannerContext, RemoteSessionSource } from './remote-session-scanner-types'
import {
isKiroSessionMetadataPath,
kiroTranscriptPathForMetadata,
parseKiroSessionContent
} from './session-scanner-kiro-parser'
async function readTranscriptLines(
metadataPath: string,
context: RemoteScannerContext
): Promise<Iterable<string> | AsyncIterable<string> | null> {
const transcriptPath = kiroTranscriptPathForMetadata(metadataPath)
// Why: beside the execution host the transcript streams; megabyte tool outputs are common.
if (context.provider.readTranscriptBytes) {
return streamedSessionContentLines(
context.provider.readTranscriptBytes(transcriptPath, context.signal),
context.signal
)
}
try {
throwIfAiVaultScanCancelled(context.signal)
const read = await context.provider.readFile(transcriptPath)
throwIfAiVaultScanCancelled(context.signal)
return read.isBinary ? null : remoteSessionContentLines(read.content, context.signal)
} catch (error) {
throwIfAiVaultScanCancelled(context.signal)
if (isMissingRemoteSessionPathError(error)) {
return null
}
throw error
}
}
export function remoteKiroSource(
remoteHome: string,
hostPlatform: RemoteHostPlatform,
kiroHomeDir?: string
): RemoteSessionSource {
return {
agent: 'kiro',
rootDir: kiroHomeDir
? joinRemotePath(hostPlatform, kiroHomeDir, 'sessions', 'cli')
: joinRemotePath(hostPlatform, remoteHome, '.kiro', 'sessions', 'cli'),
extensions: ['.json'],
filePredicate: isKiroSessionMetadataPath,
contentDependencyPath: kiroTranscriptPathForMetadata,
directoryPredicate: () => false,
parse: async (file, content, context) => {
const options = {
executionHostId: context.executionHostId,
executionHostPlatform: context.hostPlatform.os
}
try {
const lines = await readTranscriptLines(file.path, context)
return await parseKiroSessionContent(file, content, lines, context.hostPlatform.os, options)
} catch (error) {
// A transcript that vanished mid-stream leaves a valid metadata-only session.
if (!isMissingRemoteSessionPathError(error)) {
throw error
}
return parseKiroSessionContent(file, content, null, context.hostPlatform.os, options)
}
}
}
}
@@ -1,61 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform'
import { scanRemoteAiVaultSessions } from './remote-session-scanner'
import { MemoryRemoteProvider, jsonLines } from './remote-session-scanner-test-fixtures'
import { KIRO_FIXTURE_SESSION_ID } from './session-scanner-kiro-fixtures'
afterEach(() => vi.unstubAllEnvs())
describe('remote Kiro history', () => {
it.each([
['linux-x64', '/home/ada', '/srv/kiro'],
['win32-x64', 'C:/Users/Ada', 'D:/kiro']
] as const)(
'reads the %s execution host override, independent of the client',
async (platform, remoteHome, kiroHomeDir) => {
vi.stubEnv('KIRO_HOME', '/client/must-not-be-read')
const provider = new MemoryRemoteProvider()
const path = `${kiroHomeDir}/sessions/cli/${KIRO_FIXTURE_SESSION_ID}.json`
provider.addFile(path, JSON.stringify({ cwd: remoteHome, title: 'Remote Kiro' }), 10)
provider.addFile(
`${path}l`,
jsonLines([
{ kind: 'Prompt', data: { content: [{ kind: 'text', data: 'Read the file' }] } },
{ kind: 'AssistantMessage', data: { content: [{ kind: 'text', data: 'Done' }] } }
]),
11
)
const result = await scanRemoteAiVaultSessions({
provider,
executionHostId: 'ssh:kiro-host',
remoteHome,
kiroHomeDir,
hostPlatform: getRemoteHostPlatform(platform)
})
expect(result.issues).toEqual([])
expect(result.sessions).toHaveLength(1)
expect(result.sessions[0]).toMatchObject({
agent: 'kiro',
sessionId: KIRO_FIXTURE_SESSION_ID,
filePath: path,
messageCount: 2,
executionHostId: 'ssh:kiro-host'
})
}
)
it('keeps the remote default when the peer supplies no override', async () => {
vi.stubEnv('KIRO_HOME', '/client/must-not-be-read')
const provider = new MemoryRemoteProvider()
const path = `/home/ada/.kiro/sessions/cli/${KIRO_FIXTURE_SESSION_ID}.json`
provider.addFile(path, JSON.stringify({ cwd: '/home/ada', title: 'Remote Kiro' }), 10)
const result = await scanRemoteAiVaultSessions({
provider,
executionHostId: 'ssh:kiro-host',
remoteHome: '/home/ada',
hostPlatform: getRemoteHostPlatform('linux-x64')
})
expect(result.issues).toEqual([])
expect(result.sessions[0]?.filePath).toBe(path)
})
})
@@ -1,67 +0,0 @@
import { describe, expect, it } from 'vitest'
import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform'
import { scanRemoteAiVaultSessions } from './remote-session-scanner'
import { MemoryRemoteProvider, jsonLines } from './remote-session-scanner-test-fixtures'
describe('scanRemoteAiVaultSessions muse', () => {
it('discovers Muse transcripts under the remote XDG sessions root', async () => {
const provider = new MemoryRemoteProvider()
const sessionDir = '/home/ada/.local/share/muse/sessions/2026/07/04/muse-remote'
provider.addFile(
`${sessionDir}/session.jsonl`,
jsonLines([
{
record_type: 'event',
payload_type: 'runtime.session.metadata',
recorded_at: 1780000000000000,
payload: { kind: 'metadata', record: { workspace_root: '/home/ada/repo' } }
},
{
record_type: 'event',
payload_type: 'runtime.user_intent.accepted',
recorded_at: 1780000001000000,
payload: {
intent_id: 'intent-remote',
refill_blocks: [{ kind: 'text', text: 'Remote muse title' }]
}
},
{
record_type: 'event',
payload_type: 'runtime.session',
recorded_at: 1780000002000000,
payload: {
kind: 'run',
run_id: 'run-remote',
event: {
kind: 'model_completed',
model: 'muse-spark-remote',
usage: { input_tokens: 3, output_tokens: 4 }
}
}
}
]),
40
)
// Sidecars next to the transcript must not list as sessions.
provider.addFile(`${sessionDir}/cli-abc.log`, 'log output', 41)
const result = await scanRemoteAiVaultSessions({
provider,
executionHostId: 'ssh:dev-box',
remoteHome: '/home/ada',
hostPlatform: getRemoteHostPlatform('linux-x64')
})
expect(result.issues).toEqual([])
expect(result.sessions).toHaveLength(1)
expect(result.sessions[0]).toMatchObject({
executionHostId: 'ssh:dev-box',
executionHostPlatform: 'linux',
agent: 'muse',
sessionId: 'muse-remote',
title: 'Remote muse title',
model: 'muse-spark-remote',
filePath: `${sessionDir}/session.jsonl`
})
})
})
@@ -1,71 +0,0 @@
import { describe, expect, it } from 'vitest'
import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform'
import { scanRemoteAiVaultSessions } from './remote-session-scanner'
import { MemoryRemoteProvider, jsonLines } from './remote-session-scanner-test-fixtures'
describe('scanRemoteAiVaultSessions OMP subagent partitioning', () => {
it('excludes remote OMP artifact-dir transcripts and counts them on the parent', async () => {
const stem = '2026-05-01T10-00-00-000Z_cccccccc-dddd-4eee-8fff-000000000000'
const workspaceDir = '/home/ada/.omp/agent/sessions/home-app-85dfa2f0'
const provider = new MemoryRemoteProvider()
provider.addFile(
`${workspaceDir}/${stem}.jsonl`,
jsonLines([
{
type: 'session',
version: 3,
id: 'cccccccc-dddd-4eee-8fff-000000000000',
cwd: '/home/ada/repo',
timestamp: '2026-05-01T10:00:00.000Z'
},
{
type: 'message',
timestamp: '2026-05-01T10:00:01.000Z',
message: { role: 'user', content: 'Delegate some tasks' }
}
]),
70
)
for (const label of ['AuthAndPreflight', 'BitbucketDcApi']) {
provider.addFile(
`${workspaceDir}/${stem}/${label}.jsonl`,
jsonLines([
{
type: 'message',
timestamp: '2026-05-01T10:01:00.000Z',
message: { role: 'user', content: `${label} task prompt` }
}
]),
71
)
}
// A grandchild belongs to its own parent: neither a row nor a count here.
provider.addFile(
`${workspaceDir}/${stem}/AuthAndPreflight/Nested.jsonl`,
jsonLines([
{
type: 'message',
timestamp: '2026-05-01T10:02:00.000Z',
message: { role: 'user', content: 'Nested task prompt' }
}
]),
72
)
const result = await scanRemoteAiVaultSessions({
provider,
executionHostId: 'ssh:dev-box',
remoteHome: '/home/ada',
hostPlatform: getRemoteHostPlatform('linux-x64')
})
expect(result.issues).toEqual([])
expect(result.sessions).toHaveLength(1)
expect(result.sessions[0]).toMatchObject({
agent: 'omp',
sessionId: 'cccccccc-dddd-4eee-8fff-000000000000',
subagentTranscriptCount: 2,
filePath: `${workspaceDir}/${stem}.jsonl`
})
})
})
@@ -1,149 +0,0 @@
import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
import { isOpenCodeV2DatabaseName } from '../../shared/opencode-database-name'
import { joinRemotePath } from '../ssh/ssh-remote-platform'
import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation'
import { isMissingRemoteSessionPathError } from './remote-session-file-stat'
import { discoverRemoteSourceCandidates } from './remote-session-scanner-discovery'
import { remotePathSegments } from './remote-session-scanner-source-parsers'
import type {
RemoteOpenCodeSessionReader,
RemoteScannerContext,
RemoteSessionSource
} from './remote-session-scanner-types'
import { recordSessionScanIssue } from './session-scan-issues'
import { restampAiVaultListResult } from './session-list-results'
import { splitOpenCodeSqliteCandidate } from './session-scanner-opencode-sqlite-paths'
import { errorMessage } from './session-scanner-values'
export function remoteOpenCodeSources(
reader: RemoteOpenCodeSessionReader | undefined,
candidateLimit: number
): RemoteSessionSource[] {
if (!reader) {
return []
}
let databasePaths: Promise<string[]> | undefined
return (['opencode', 'opencode2'] as const).map((agent) => {
const source: RemoteSessionSource = {
agent,
rootDir: reader.dataDirectory,
extensions: ['.json'],
parse: async () => null,
parseCandidate: async (file, context) => {
throwIfAiVaultScanCancelled(context.signal)
const candidate = splitOpenCodeSqliteCandidate(file.path)
const session = candidate
? await reader.parse({
...candidate,
platform: context.hostPlatform.os,
...(agent === 'opencode2' ? { agent } : {}),
signal: context.signal
})
: await reader.parseLegacy(file, context.hostPlatform.os)
throwIfAiVaultScanCancelled(context.signal)
if (!session) {
return null
}
const stamped = restampAiVaultListResult(
{ sessions: [session], issues: [], scannedAt: '' },
context.executionHostId
).sessions[0]
return stamped ? { ...stamped, executionHostPlatform: context.hostPlatform.os } : null
},
discover: async (context, issues) => {
databasePaths ??= discoverDatabasePaths(reader, context, issues)
const paths = await databasePaths
const ownPaths =
agent === 'opencode2'
? paths
: paths.filter(
(path) => !isOpenCodeV2DatabaseName(remotePathSegments(path).at(-1) ?? '')
)
const readIssues: AiVaultScanIssue[] = []
const candidates = await reader.list({
dbPaths: ownPaths,
limit: candidateLimit,
issues: readIssues,
...(agent === 'opencode2' ? { agent } : {}),
signal: context.signal
})
throwIfAiVaultScanCancelled(context.signal)
for (const issue of readIssues) {
recordSessionScanIssue(issues, { ...issue, executionHostId: context.executionHostId })
}
const files = candidates.map((candidate) => candidate.file)
if (agent === 'opencode2') {
return files
}
const legacy = await discoverRemoteSourceCandidates({
source: {
...source,
discover: undefined,
rootDir: joinRemotePath(
context.hostPlatform,
reader.dataDirectory,
'storage',
'session'
)
},
context,
issues
})
const ids = new Set(files.map((file) => splitOpenCodeSqliteCandidate(file.path)?.sessionId))
return [
...files,
...legacy
.filter(
({ file }) =>
!ids.has(
remotePathSegments(file.path)
.at(-1)
?.replace(/\.json$/, '')
)
)
.map(({ file }) => file)
]
}
}
return source
})
}
async function discoverDatabasePaths(
reader: RemoteOpenCodeSessionReader,
context: RemoteScannerContext,
issues: AiVaultScanIssue[]
): Promise<string[]> {
throwIfAiVaultScanCancelled(context.signal)
if (reader.databasePath === null) {
return []
}
try {
if (reader.databasePath !== undefined) {
const stat = await context.provider.stat(reader.databasePath)
return stat.type === 'file' || stat.type === 'symlink' ? [reader.databasePath] : []
}
const entries = await context.provider.readDir(reader.dataDirectory)
return entries
.filter(
(entry) =>
!entry.isDirectory &&
!entry.isSymlink &&
/^opencode(?:-[A-Za-z0-9_.-]+)?\.db$/.test(entry.name)
)
.map((entry) => joinRemotePath(context.hostPlatform, reader.dataDirectory, entry.name))
.sort()
} catch (error) {
throwIfAiVaultScanCancelled(context.signal)
if (!isMissingRemoteSessionPathError(error)) {
recordSessionScanIssue(issues, {
executionHostId: context.executionHostId,
agent: 'opencode',
kind: 'scope',
path: reader.databasePath ?? reader.dataDirectory,
message: errorMessage(error)
})
}
return []
}
}
@@ -1,235 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AiVaultSession } from '../../shared/ai-vault-types'
import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform'
import { resetRemoteSessionParseCacheForTests } from './remote-session-parse-cache'
import { scanRemoteAiVaultSessions } from './remote-session-scanner'
import { MemoryRemoteProvider, jsonLines } from './remote-session-scanner-test-fixtures'
import type { RemoteOpenCodeSessionReader } from './remote-session-scanner-types'
import { createAccumulator, finalizeSession, updateTimeline } from './session-scanner-accumulator'
import {
buildOpenCodeSqliteCandidatePath,
splitOpenCodeSqliteCandidate
} from './session-scanner-opencode-sqlite-paths'
import type { FileWithMtime } from './session-scanner-types'
const remoteHome = '/home/ada'
const dataDirectory = `${remoteHome}/.local/share/opencode`
const hostPlatform = getRemoteHostPlatform('linux-x64')
const scanOptions = { remoteHome, hostPlatform, executionHostId: 'ssh:one' as const }
beforeEach(resetRemoteSessionParseCacheForTests)
function fixture() {
const sessions = new Map<string, AiVaultSession>()
const list: RemoteOpenCodeSessionReader['list'] = vi.fn(async (args) =>
[...sessions.values()]
.filter(
(session) =>
session.agent === (args.agent ?? 'opencode') && args.dbPaths.includes(session.filePath)
)
.sort((left, right) => Date.parse(right.modifiedAt) - Date.parse(left.modifiedAt))
.slice(0, args.limit)
.map((session) => ({
agent: session.agent,
codexHome: null,
file: file(
buildOpenCodeSqliteCandidatePath(session.filePath, session.sessionId),
Date.parse(session.modifiedAt)
)
}))
)
const parse: RemoteOpenCodeSessionReader['parse'] = vi.fn(
async (args) =>
sessions.get(
`${args.agent ?? 'opencode'}:${buildOpenCodeSqliteCandidatePath(args.dbPath, args.sessionId)}`
) ?? null
)
const parseLegacy = vi.fn(async (entry: FileWithMtime) =>
session('opencode', 'legacy', entry, '/home/ada/repo')
)
const provider = Object.assign(new MemoryRemoteProvider(), {
openCode: { dataDirectory, list, parse, parseLegacy }
})
const add = (
id: string,
mtime: number,
options: { agent?: 'opencode' | 'opencode2'; cwd?: string; dbName?: string } = {}
) => {
const agent = options.agent ?? 'opencode'
const dbPath = `${dataDirectory}/${options.dbName ?? 'opencode.db'}`
provider.addFile(dbPath, 'database', 1)
const path = buildOpenCodeSqliteCandidatePath(dbPath, id)
sessions.set(
`${agent}:${path}`,
session(agent, id, file(path, mtime), options.cwd ?? '/home/ada/other')
)
}
return { provider, add, list, parse, parseLegacy }
}
function file(path: string, mtimeMs: number): FileWithMtime {
return { path, mtimeMs, modifiedAt: new Date(mtimeMs).toISOString() }
}
function session(
agent: 'opencode' | 'opencode2',
sessionId: string,
entry: FileWithMtime,
cwd: string
): AiVaultSession {
const accumulator = createAccumulator({
agent,
file: { ...entry, path: splitOpenCodeSqliteCandidate(entry.path)?.dbPath ?? entry.path },
sessionId
})
accumulator.cwd = cwd
updateTimeline(accumulator, entry.modifiedAt)
const parsed = finalizeSession(accumulator, 'linux')
if (!parsed) {
throw new Error('Invalid fixture session')
}
return parsed
}
describe('remote OpenCode discovery', () => {
it('sorts database rows with file-backed agents before applying the global cap', async () => {
const { provider, add } = fixture()
add('older', 1000)
add('newest', 3000)
provider.addFile(
`${remoteHome}/.claude/projects/project/middle.jsonl`,
jsonLines([
{
sessionId: 'middle',
timestamp: new Date(2000).toISOString(),
type: 'user',
message: { content: 'middle' }
}
]),
2000
)
const read = vi.spyOn(provider, 'readFile')
const result = await scanRemoteAiVaultSessions({ ...scanOptions, provider, limit: 2 })
expect(result.issues).toEqual([])
expect(result.sessions.map((entry) => entry.sessionId)).toEqual(['newest', 'middle'])
expect(result.sessions[0]).toMatchObject({
executionHostId: 'ssh:one',
executionHostPlatform: 'linux',
id: `ssh:one:opencode:newest:${dataDirectory}/opencode.db`
})
expect(read.mock.calls.every(([path]) => !path.includes('.db#'))).toBe(true)
})
it('retains older scoped rows beyond the recent parse budget', async () => {
const { provider, add, list } = fixture()
for (let index = 0; index < 10; index++) {
add(`other-${index}`, 10000 + index)
}
add('scoped', 1000, { cwd: '/home/ada/repo/app' })
const result = await scanRemoteAiVaultSessions({
...scanOptions,
provider,
limit: 1,
scopePaths: ['/home/ada/repo']
})
expect(result.sessions.map((entry) => entry.sessionId)).toEqual(['other-9', 'scoped'])
expect(list).toHaveBeenCalledWith(expect.objectContaining({ limit: 1003 }))
})
it('drops stale legacy duplicates before limiting while keeping legacy-only history', async () => {
const { provider, add, parseLegacy } = fixture()
add('migrated', 2000)
provider.addFile(`${dataDirectory}/storage/session/project/migrated.json`, '{}', 9000)
provider.addFile(`${dataDirectory}/storage/session/project/legacy.json`, '{}', 1000)
const result = await scanRemoteAiVaultSessions({ ...scanOptions, provider, limit: 10 })
expect(result.sessions.map((entry) => entry.sessionId)).toEqual(['migrated', 'legacy'])
expect(parseLegacy).toHaveBeenCalledTimes(1)
expect(parseLegacy).toHaveBeenCalledWith(
expect.objectContaining({ path: expect.stringContaining('/legacy.json') }),
'linux'
)
})
it('keeps v1 and v2 identities separate in a shared database and parse cache', async () => {
const { provider, add, parse, list } = fixture()
add('same-id', 2000)
add('same-id', 2000, { agent: 'opencode2' })
add('beta', 1000, { agent: 'opencode2', dbName: 'opencode-next.db' })
const first = await scanRemoteAiVaultSessions({ ...scanOptions, provider })
const second = await scanRemoteAiVaultSessions({ ...scanOptions, provider })
expect(first.sessions.map((entry) => entry.agent).sort()).toEqual([
'opencode',
'opencode2',
'opencode2'
])
expect(second.sessions).toEqual(first.sessions)
expect(parse).toHaveBeenCalledTimes(3)
expect(list).toHaveBeenCalledWith(
expect.objectContaining({ dbPaths: [`${dataDirectory}/opencode.db`] })
)
expect(list).toHaveBeenCalledWith(
expect.objectContaining({
dbPaths: [`${dataDirectory}/opencode-next.db`, `${dataDirectory}/opencode.db`],
agent: 'opencode2'
})
)
})
it('invalidates cached database rows after updates and across hosts', async () => {
const { provider, add, parse } = fixture()
add('session', 1000)
await scanRemoteAiVaultSessions({ ...scanOptions, provider })
add('session', 2000)
const updated = await scanRemoteAiVaultSessions({ ...scanOptions, provider })
const other = await scanRemoteAiVaultSessions({
...scanOptions,
executionHostId: 'ssh:two',
provider
})
expect(parse).toHaveBeenCalledTimes(3)
expect(updated.sessions[0]?.modifiedAt).toBe(new Date(2000).toISOString())
expect(other.sessions[0]?.executionHostId).toBe('ssh:two')
})
it('propagates cancellation into the reader and does not cache cancelled parses', async () => {
const { provider, add, parse } = fixture()
add('session', 1000)
const controller = new AbortController()
provider.openCode.parse = async (args) => {
expect(args.signal).toBe(controller.signal)
controller.abort()
return parse(args)
}
await expect(
scanRemoteAiVaultSessions({ ...scanOptions, provider, signal: controller.signal })
).rejects.toMatchObject({ name: 'AbortError' })
provider.openCode.parse = parse
await scanRemoteAiVaultSessions({ ...scanOptions, provider })
expect(parse).toHaveBeenCalledTimes(2)
})
it('leaves legacy filesystem providers on their existing fallback', async () => {
const provider = new MemoryRemoteProvider()
provider.addFile(`${dataDirectory}/opencode.db`, 'database', 1000)
const result = await scanRemoteAiVaultSessions({ ...scanOptions, provider })
expect(result.sessions).toEqual([])
expect(result.issues).toEqual([])
expect(provider.readDirPaths).not.toContain(dataDirectory)
})
it('honors an explicitly configured database symlink without enumerating sibling databases', async () => {
const { provider, add, list } = fixture()
add('selected', 1000, { dbName: 'opencode-team.db' })
add('other', 2000)
const selected = `${dataDirectory}/opencode-team.db`
Object.assign(provider.openCode, { databasePath: selected })
const stat = provider.stat.bind(provider)
vi.spyOn(provider, 'stat').mockImplementation(async (path) =>
path === selected ? { type: 'symlink', size: 20, mtime: 1000, mtimeMs: 1000 } : stat(path)
)
const result = await scanRemoteAiVaultSessions({ ...scanOptions, provider })
expect(result.sessions.map((entry) => entry.sessionId)).toEqual(['selected'])
expect(list).toHaveBeenCalledWith(expect.objectContaining({ dbPaths: [selected] }))
expect(provider.readDirPaths).not.toContain(dataDirectory)
})
})
@@ -1,80 +0,0 @@
import type { AiVaultSession } from '../../shared/ai-vault-types'
import { parseMessageGraphSessionContent } from './session-scanner-graph-parsers'
import {
parseMuseSessionContent,
parseMuseSessionRemoteContent
} from './session-scanner-muse-parser'
import type { FileWithMtime } from './session-scanner-types'
import { normalizeAgentSessionsDir } from './session-scanner-values'
import type { RemoteSessionContent } from './remote-session-content-lines'
import type { RemoteParserOptions } from './remote-session-scanner-types'
export function parseMuseRemoteContent(
file: FileWithMtime,
content: RemoteSessionContent,
platform: NodeJS.Platform,
options: RemoteParserOptions,
signal?: AbortSignal
): Promise<AiVaultSession | null> {
if (typeof content === 'string') {
return Promise.resolve(parseMuseSessionContent(file, content, platform, options))
}
return parseMuseSessionRemoteContent(file, content, platform, options, signal)
}
export function piParser(
file: FileWithMtime,
content: RemoteSessionContent,
platform: NodeJS.Platform,
options: RemoteParserOptions,
signal?: AbortSignal
): Promise<AiVaultSession | null> {
return parseMessageGraphSessionContent('pi', file, content, platform, options, signal)
}
export function ompParser(
file: FileWithMtime,
content: RemoteSessionContent,
platform: NodeJS.Platform,
options: RemoteParserOptions,
signal?: AbortSignal
): Promise<AiVaultSession | null> {
return parseMessageGraphSessionContent('omp', file, content, platform, options, signal)
}
export function primeAgentParser(
file: FileWithMtime,
content: RemoteSessionContent,
platform: NodeJS.Platform,
options: RemoteParserOptions,
signal?: AbortSignal
): Promise<AiVaultSession | null> {
return parseMessageGraphSessionContent('prime-agent', file, content, platform, options, signal)
}
export function openClawParser(
file: FileWithMtime,
content: RemoteSessionContent,
platform: NodeJS.Platform,
options: RemoteParserOptions,
signal?: AbortSignal
): Promise<AiVaultSession | null> {
return parseMessageGraphSessionContent('openclaw', file, content, platform, options, signal)
}
export function remotePathSegments(path: string): string[] {
return path.replace(/\\/g, '/').split('/').filter(Boolean)
}
export function remotePiSessionsSegments(): string[] {
return normalizeAgentSessionsDir('/.pi/agent/sessions', '.pi').split('/').filter(Boolean)
}
export function remoteOmpSessionsSegments(): string[] {
return normalizeAgentSessionsDir('/.omp/agent/sessions', '.omp').split('/').filter(Boolean)
}
// Remote roots are POSIX regardless of the client platform.
export function remotePrimeAgentSessionsSegments(): string[] {
return ['.prime', 'agent', 'sessions']
}
@@ -1,36 +0,0 @@
import { describe, expect, it } from 'vitest'
import { getRemoteHostPlatform, type RemoteHostPlatform } from '../ssh/ssh-remote-platform'
import type { RelayPlatform } from '../ssh/relay-protocol'
import { remoteSessionSources } from './remote-session-scanner-sources'
function devinRootDirs(relayPlatform: RelayPlatform, remoteHome: string): string[] {
const hostPlatform: RemoteHostPlatform = getRemoteHostPlatform(relayPlatform)
return remoteSessionSources(remoteHome, hostPlatform)
.filter((source) => source.agent === 'devin')
.map((source) => source.rootDir)
}
describe('remoteSessionSources devin transcripts root', () => {
it.each([
{
relayPlatform: 'win32-x64' as const,
remoteHome: 'C:/Users/dev',
expected: 'C:/Users/dev/AppData/Roaming/devin/cli/transcripts'
},
{
relayPlatform: 'linux-x64' as const,
remoteHome: '/home/dev',
expected: '/home/dev/.local/share/devin/cli/transcripts'
},
{
relayPlatform: 'darwin-arm64' as const,
remoteHome: '/Users/dev',
expected: '/Users/dev/.local/share/devin/cli/transcripts'
}
])('resolves $expected on $relayPlatform', ({ relayPlatform, remoteHome, expected }) => {
expect(devinRootDirs(relayPlatform, remoteHome)).toEqual([
expected,
expected.replace(/transcripts$/, 'agent_logs')
])
})
})
@@ -1,286 +0,0 @@
import {
ANTIGRAVITY_HISTORY_ROOTS,
type AntigravitySessionOrigin
} from '../../shared/antigravity-session-origin'
import { parseQoderSessionContent } from './session-scanner-qoder-parser'
import { remoteSessionDocumentParsers } from './remote-session-document-parsers'
import type { RemoteSessionContent } from './remote-session-content-lines'
import type { AiVaultAgent, AiVaultSession } from '../../shared/ai-vault-types'
import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform'
import { joinRemotePath } from '../ssh/ssh-remote-platform'
import { parseAntigravitySessionContent } from './session-scanner-antigravity-parser'
import { isAntigravityTranscriptPath } from './session-scanner-antigravity-paths'
import { parseDroidSessionContent } from './session-scanner-droid-parser'
import { parseClaudeSessionContent } from './session-scanner-primary-parsers'
import { parseCodebuddySessionContent } from './session-scanner-codebuddy-parser'
import { parseGeminiSessionContent } from './session-scanner-gemini-parsers'
import { parseCopilotSessionContent } from './session-scanner-copilot-parser'
import { parseCursorSessionContent } from './session-scanner-cursor-parser'
import { parseHermesSessionContent } from './session-scanner-hermes-parser'
import { parseJcodeSessionContent } from './session-scanner-jcode-parser'
import { partitionSubagentTranscriptPaths } from './session-scanner-subagent-transcripts'
import { partitionOmpSubagentTranscriptPaths } from './session-scanner-omp-subagent-transcripts'
import {
ompParser,
openClawParser,
parseMuseRemoteContent,
piParser,
primeAgentParser,
remoteOmpSessionsSegments,
remotePathSegments,
remotePiSessionsSegments,
remotePrimeAgentSessionsSegments
} from './remote-session-scanner-source-parsers'
import type { FileWithMtime } from './session-scanner-types'
import { openClawSessionDirectoryPredicate } from './openclaw-session-layout'
import { remoteCodexSources } from './remote-session-scanner-codex-sources'
import { remoteClineSource } from './remote-session-scanner-cline-source'
import { remoteKiroSource } from './remote-session-scanner-kiro-source'
import { remoteDevinSource } from './remote-session-scanner-devin-source'
import type {
RemoteParserOptions,
RemoteScannerContext,
RemoteSessionSource
} from './remote-session-scanner-types'
type RemoteContentParser<T = string> = (
file: FileWithMtime,
content: T,
platform: NodeJS.Platform,
options: RemoteParserOptions,
// Line-based parsers iterate cancellably; whole-document parsers ignore it.
signal?: AbortSignal
) => Promise<AiVaultSession | null> | AiVaultSession | null
export function remoteSessionSources(
remoteHome: string,
hostPlatform: RemoteHostPlatform,
includeAntigravityIdeSessions = false,
kiroHomeDir?: string
): RemoteSessionSource[] {
return [
...remoteCodexSources(remoteHome, hostPlatform),
{
...jsonlSource(
'claude',
remoteHome,
hostPlatform,
['.claude', 'projects'],
parseClaudeSessionContent
),
// The remote host owns the transcript disk, so the local readdir in the
// Claude parser is skipped; the walked listing supplies the sibling
// subagent counts instead. Partitioning also prunes the subagent
// transcripts themselves, which would otherwise list as phantom
// top-level sessions carrying the parent's sessionId.
partitionSubagentTranscripts: partitionSubagentTranscriptPaths
},
{
// The execution host owns CodeBuddy's transcripts, including subagent pruning.
...jsonlSource(
'codebuddy',
remoteHome,
hostPlatform,
['.codebuddy', 'projects'],
parseCodebuddySessionContent
),
partitionSubagentTranscripts: partitionSubagentTranscriptPaths
},
{
...jsonlSource(
'qoder',
remoteHome,
hostPlatform,
['.qoder', 'projects'],
parseQoderSessionContent
),
partitionSubagentTranscripts: partitionSubagentTranscriptPaths
},
...(includeAntigravityIdeSessions
? ANTIGRAVITY_HISTORY_ROOTS
: (['antigravity-cli'] as const)
).map((origin) => remoteAntigravitySource(remoteHome, hostPlatform, origin)),
source(
'gemini',
remoteHome,
hostPlatform,
['.gemini', 'tmp'],
['.json', '.jsonl'],
parseGeminiSessionContent
),
jsonlSource(
'copilot',
remoteHome,
hostPlatform,
['.copilot', 'session-state'],
parseCopilotSessionContent
),
jsonlSource(
'cursor',
remoteHome,
hostPlatform,
['.cursor', 'projects'],
parseCursorSessionContent,
(path) => remotePathSegments(path).includes('agent-transcripts')
),
remoteClineSource(remoteHome, hostPlatform),
remoteKiroSource(remoteHome, hostPlatform, kiroHomeDir),
source(
'hermes',
remoteHome,
hostPlatform,
['.hermes', 'sessions'],
['.json'],
parseHermesSessionContent
),
remoteDevinSource(remoteHome, hostPlatform),
remoteDevinSource(remoteHome, hostPlatform, 'agent_logs'),
source(
'jcode',
remoteHome,
hostPlatform,
['.jcode', 'sessions'],
['.json'],
parseJcodeSessionContent,
(path) => remotePathSegments(path).pop()?.startsWith('session_') === true
),
jsonlSource('pi', remoteHome, hostPlatform, remotePiSessionsSegments(), piParser),
{
...jsonlSource('omp', remoteHome, hostPlatform, remoteOmpSessionsSegments(), ompParser),
// Same posture as Claude above: OMP stores task-subagent transcripts in
// the session's same-named artifact dir; the walk supplies counts and the
// partition keeps the children out of the top-level list (#9330).
partitionSubagentTranscripts: partitionOmpSubagentTranscriptPaths
},
jsonlSource(
'prime-agent',
remoteHome,
hostPlatform,
remotePrimeAgentSessionsSegments(),
primeAgentParser
),
jsonlSource(
'muse',
remoteHome,
hostPlatform,
['.local', 'share', 'muse', 'sessions'],
parseMuseRemoteContent,
// Why: each session dir holds session.jsonl plus .log/.sqlite3 sidecars;
// match only the transcript (same predicate as local discovery).
(path) => remotePathSegments(path).at(-1) === 'session.jsonl'
),
jsonlSource(
'droid',
remoteHome,
hostPlatform,
['.factory', 'sessions'],
parseDroidSessionContent
),
jsonlSource(
'droid',
remoteHome,
hostPlatform,
['.factory', 'projects'],
parseDroidSessionContent
),
...remoteOpenClawSources(remoteHome, hostPlatform)
]
}
function remoteAntigravitySource(
remoteHome: string,
hostPlatform: RemoteHostPlatform,
origin: AntigravitySessionOrigin
): RemoteSessionSource {
const cliRoot = joinRemotePath(hostPlatform, remoteHome, '.gemini', origin)
const historyPath = joinRemotePath(hostPlatform, cliRoot, 'history.jsonl')
const parse = async (
file: FileWithMtime,
content: RemoteSessionContent,
context: RemoteScannerContext
) => {
const session = await parseAntigravitySessionContent(
file,
content,
context.hostPlatform.os,
parserOptions(context),
context.signal
)
return session ? context.antigravityWorkspaceResolver.enrich(session, historyPath) : null
}
return {
agent: 'antigravity',
rootDir: joinRemotePath(hostPlatform, cliRoot, 'brain'),
extensions: ['.jsonl'],
filePredicate: isAntigravityTranscriptPath,
fixedChildFileSegments: ['.system_generated', 'logs', 'transcript.jsonl'],
additionalFixedChildFileSegments: [['.system_generated', 'logs', 'transcript_full.jsonl']],
parse,
parseLines: parse
}
}
function source(
agent: AiVaultAgent,
remoteHome: string,
hostPlatform: RemoteHostPlatform,
segments: readonly string[],
extensions: readonly string[],
parseContent: RemoteContentParser,
filePredicate?: (path: string) => boolean,
directoryPredicate?: (name: string, depth: number) => boolean
): RemoteSessionSource {
return {
agent,
rootDir: joinRemotePath(hostPlatform, remoteHome, ...segments),
extensions,
filePredicate,
directoryPredicate,
...remoteSessionDocumentParsers(agent),
parse: (file, content, context) =>
Promise.resolve(
parseContent(file, content, context.hostPlatform.os, parserOptions(context), context.signal)
)
}
}
function jsonlSource(
agent: AiVaultAgent,
remoteHome: string,
hostPlatform: RemoteHostPlatform,
segments: readonly string[],
parseContent: RemoteContentParser<RemoteSessionContent>,
filePredicate?: (path: string) => boolean
): RemoteSessionSource {
return {
...source(agent, remoteHome, hostPlatform, segments, ['.jsonl'], parseContent, filePredicate),
parseLines: (file, lines, context) =>
Promise.resolve(
parseContent(file, lines, context.hostPlatform.os, parserOptions(context), context.signal)
)
}
}
function remoteOpenClawSources(
remoteHome: string,
hostPlatform: RemoteHostPlatform
): RemoteSessionSource[] {
return ['.openclaw', '.clawdbot'].map((rootName) => ({
...jsonlSource(
'openclaw',
remoteHome,
hostPlatform,
[rootName, 'agents'],
openClawParser,
(path) => remotePathSegments(path).includes('sessions')
),
directoryPredicate: openClawSessionDirectoryPredicate
}))
}
function parserOptions(context: RemoteScannerContext): RemoteParserOptions {
return {
executionHostId: context.executionHostId,
executionHostPlatform: context.hostPlatform.os
}
}
@@ -1,823 +0,0 @@
import { describe, expect, it } from 'vitest'
import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform'
import { scanRemoteAiVaultSessions } from './remote-session-scanner'
import { MemoryRemoteProvider, jsonLines } from './remote-session-scanner-test-fixtures'
import { primeAgentFixture } from './session-scanner-prime-agent-fixtures'
describe('scanRemoteAiVaultSessions', () => {
it('indexes Cline manifests on the SSH-owned disk without messages-file phantoms', async () => {
const provider = new MemoryRemoteProvider()
const sessionId = '1786466194549_xrzrl'
const sessionDir = `/home/ada/.cline/data/sessions/${sessionId}`
provider.addFile(
`${sessionDir}/${sessionId}.json`,
JSON.stringify({
version: 1,
session_id: sessionId,
started_at: '2026-08-11T16:36:34.551Z',
provider: 'deepseek',
model: 'deepseek-v4-flash',
cwd: '/home/ada/repo'
}),
10
)
provider.addFile(
`${sessionDir}/${sessionId}.messages.json`,
JSON.stringify({
version: 1,
updated_at: '2026-08-11T16:38:00.000Z',
sessionId,
messages: [
{
role: 'user',
content: [{ type: 'text', text: 'Fix remote Cline history' }]
}
]
}),
11
)
const result = await scanRemoteAiVaultSessions({
provider,
executionHostId: 'ssh:dev-box',
remoteHome: '/home/ada',
hostPlatform: getRemoteHostPlatform('linux-x64')
})
expect(result.issues).toEqual([])
expect(result.sessions).toHaveLength(1)
expect(result.sessions[0]).toMatchObject({
agent: 'cline',
sessionId,
title: 'Fix remote Cline history',
cwd: '/home/ada/repo',
executionHostId: 'ssh:dev-box',
executionHostPlatform: 'linux',
filePath: `${sessionDir}/${sessionId}.json`
})
})
it('indexes and resumes Cline sessions from a Windows SSH host', async () => {
const provider = new MemoryRemoteProvider()
const sessionId = '1786466194549_xrzrl'
const sessionDir = `C:/Users/Ada/.cline/data/sessions/${sessionId}`
provider.addFile(
`${sessionDir}/${sessionId}.json`,
JSON.stringify({
session_id: sessionId,
started_at: '2026-08-11T16:36:34.551Z',
cwd: 'C:/repo/app'
}),
10
)
provider.addFile(
`${sessionDir}/${sessionId}.messages.json`,
JSON.stringify({
updated_at: '2026-08-11T16:38:00.000Z',
messages: [{ role: 'user', content: [{ type: 'text', text: 'Fix Windows history' }] }]
}),
11
)
const result = await scanRemoteAiVaultSessions({
provider,
executionHostId: 'ssh:win-box',
remoteHome: 'C:/Users/Ada',
hostPlatform: getRemoteHostPlatform('win32-x64')
})
expect(result.issues).toEqual([])
expect(result.sessions).toHaveLength(1)
expect(result.sessions[0]).toMatchObject({
agent: 'cline',
sessionId,
executionHostPlatform: 'win32',
filePath: `${sessionDir}/${sessionId}.json`,
resumeCommand: `cmd /d /s /c "cd /d ""C:/repo/app"" && cline --id ""${sessionId}"""`
})
})
it('parses remote default and Orca-managed Codex homes with SSH host ids', async () => {
const provider = new MemoryRemoteProvider()
provider.addFile(
'/home/ada/.codex/session_index.jsonl',
jsonLines([{ id: 'default-session', thread_name: 'Indexed remote title' }]),
1
)
provider.addFile(
'/home/ada/.codex/sessions/2026/07/04/default.jsonl',
jsonLines([
{
timestamp: '2026-07-04T01:00:00.000Z',
type: 'session_meta',
payload: { id: 'default-session', cwd: '/home/ada/repo' }
},
{
timestamp: '2026-07-04T01:00:01.000Z',
type: 'response_item',
payload: {
type: 'message',
role: 'user',
content: [{ type: 'text', text: 'Fallback default title' }]
}
}
]),
10
)
provider.addFile(
'/home/ada/.local/share/orca/codex-runtime-home/home/sessions/runtime.jsonl',
jsonLines([
{
timestamp: '2026-07-04T02:00:00.000Z',
type: 'session_meta',
payload: { id: 'runtime-session', cwd: '/home/ada/runtime-repo' }
},
{
timestamp: '2026-07-04T02:00:01.000Z',
type: 'response_item',
payload: {
type: 'message',
role: 'user',
content: [{ type: 'text', text: 'Managed remote title' }]
}
}
]),
20
)
const result = await scanRemoteAiVaultSessions({
provider,
executionHostId: 'ssh:dev-box',
remoteHome: '/home/ada',
hostPlatform: getRemoteHostPlatform('linux-x64'),
limit: 1,
unlimited: true
})
expect(result.issues).toEqual([])
expect(result.sessions.map((session) => session.title)).toEqual([
'Managed remote title',
'Indexed remote title'
])
expect(new Set(result.sessions.map((session) => session.id)).size).toBe(2)
expect(result.sessions.every((session) => session.executionHostId === 'ssh:dev-box')).toBe(true)
expect(result.sessions.every((session) => session.executionHostPlatform === 'linux')).toBe(true)
expect(
result.sessions.find((session) => session.sessionId === 'default-session')
).toMatchObject({
codexHome: '/home/ada/.codex',
resumeCommand:
"cd '/home/ada/repo' && CODEX_HOME='/home/ada/.codex' codex resume 'default-session'"
})
expect(
result.sessions.find((session) => session.sessionId === 'runtime-session')
).toMatchObject({
codexHome: '/home/ada/.local/share/orca/codex-runtime-home/home',
resumeCommand:
"cd '/home/ada/runtime-repo' && CODEX_HOME='/home/ada/.local/share/orca/codex-runtime-home/home' codex resume 'runtime-session'"
})
})
it('collapses a bridged rollout present in both remote Codex homes to one row', async () => {
const provider = new MemoryRemoteProvider()
const rolloutName = 'rollout-2026-07-04T10-00-00-019f0000-1111-7222-8333-444444444444.jsonl'
const transcript = codexTranscript({
sessionId: '019f0000-1111-7222-8333-444444444444',
title: 'Bridged both-homes session',
cwd: '/home/ada/repo',
timestamp: '2026-07-04T10:00:00.000Z'
})
// Same rollout name in both homes — the in-distro bridge/backfill hardlink.
provider.addFile(`/home/ada/.codex/sessions/2026/07/04/${rolloutName}`, transcript, 3_000)
provider.addFile(
`/home/ada/.local/share/orca/codex-runtime-home/home/sessions/2026/07/04/${rolloutName}`,
transcript,
3_000
)
const result = await scanRemoteAiVaultSessions({
provider,
executionHostId: 'ssh:build-box',
remoteHome: '/home/ada',
hostPlatform: getRemoteHostPlatform('linux-x64')
})
expect(result.issues).toEqual([])
expect(result.sessions).toHaveLength(1)
// Remote lanes have not flipped to the real home: the managed runtime-home
// row stays canonical so resume keeps Orca-refreshed auth, as today.
expect(result.sessions[0]).toMatchObject({
sessionId: '019f0000-1111-7222-8333-444444444444',
codexHome: '/home/ada/.local/share/orca/codex-runtime-home/home'
})
})
it('parses non-Codex transcripts through the same remote scanner', async () => {
const provider = new MemoryRemoteProvider()
provider.addFile(
'/home/ada/.claude/projects/repo/claude-session.jsonl',
jsonLines([
{
sessionId: 'claude-session',
timestamp: '2026-07-04T04:00:00.000Z',
type: 'user',
message: { content: [{ type: 'text', text: 'Summarize the remote branch' }] }
},
{
sessionId: 'claude-session',
timestamp: '2026-07-04T04:00:01.000Z',
type: 'assistant',
message: { model: 'claude-opus-4', content: 'Sure.' }
}
]),
40
)
const result = await scanRemoteAiVaultSessions({
provider,
executionHostId: 'ssh:dev-box',
remoteHome: '/home/ada',
hostPlatform: getRemoteHostPlatform('linux-x64')
})
expect(result.issues).toEqual([])
expect(result.sessions).toHaveLength(1)
expect(result.sessions[0]).toMatchObject({
executionHostId: 'ssh:dev-box',
executionHostPlatform: 'linux',
agent: 'claude',
sessionId: 'claude-session',
title: 'Summarize the remote branch',
model: 'claude-opus-4',
filePath: '/home/ada/.claude/projects/repo/claude-session.jsonl'
})
})
it('discovers Prime Agent transcripts under the remote home sessions root', async () => {
const provider = new MemoryRemoteProvider()
const fixture = primeAgentFixture()
provider.addFile(
`/home/ada/.prime/agent/sessions/${fixture.fileName}`,
[...fixture.seedLines, ...fixture.appendLines].join('\n'),
40
)
const result = await scanRemoteAiVaultSessions({
provider,
executionHostId: 'ssh:dev-box',
remoteHome: '/home/ada',
hostPlatform: getRemoteHostPlatform('linux-x64')
})
expect(result.issues).toEqual([])
expect(result.sessions).toHaveLength(1)
expect(result.sessions[0]).toMatchObject({
executionHostId: 'ssh:dev-box',
executionHostPlatform: 'linux',
agent: 'prime-agent',
title: 'prime-agent seed question',
model: 'inference/big-model',
filePath: `/home/ada/.prime/agent/sessions/${fixture.fileName}`
})
})
it('parses only canonical Antigravity transcripts on SSH hosts', async () => {
const provider = new MemoryRemoteProvider()
const sessionId = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee'
const logsDir = `/home/ada/.gemini/antigravity-cli/brain/${sessionId}/.system_generated/logs`
provider.addFile(
`${logsDir}/transcript.jsonl`,
jsonLines([
{
source: 'USER_EXPLICIT',
type: 'USER_INPUT',
created_at: '2026-07-15T11:39:10Z',
content: '<USER_REQUEST>Fix remote Antigravity history</USER_REQUEST>'
},
{
source: 'MODEL',
type: 'PLANNER_RESPONSE',
created_at: '2026-07-15T11:39:12Z',
content: 'Done'
}
]),
50
)
provider.addFile(`${logsDir}/transcript_full.jsonl`, 'duplicate', 51)
provider.addFile(
`/home/ada/.gemini/antigravity-cli/brain/${sessionId}/artifacts/task.jsonl`,
'not a transcript',
52
)
provider.addFile(
'/home/ada/.gemini/antigravity-cli/history.jsonl',
jsonLines([
{
display: 'Fix remote Antigravity history',
timestamp: Date.parse('2026-07-15T11:39:10.100Z'),
workspace: '/home/ada/project'
}
]),
53
)
const result = await scanRemoteAiVaultSessions({
provider,
executionHostId: 'ssh:dev-box',
remoteHome: '/home/ada',
hostPlatform: getRemoteHostPlatform('linux-x64'),
scopePaths: ['/home/ada/project']
})
expect(result.issues).toEqual([])
expect(result.sessions).toHaveLength(1)
expect(result.sessions[0]).toMatchObject({
executionHostId: 'ssh:dev-box',
executionHostPlatform: 'linux',
agent: 'antigravity',
sessionId,
title: 'Fix remote Antigravity history',
cwd: '/home/ada/project',
messageCount: 2,
resumeCommand: `agy --conversation '${sessionId}'`,
filePath: `${logsDir}/transcript.jsonl`
})
expect(
provider.readDirPaths.filter((path) =>
path.startsWith('/home/ada/.gemini/antigravity-cli/brain')
)
).toEqual(['/home/ada/.gemini/antigravity-cli/brain'])
})
it('ignores missing canonical Antigravity transcripts but reports other stat failures', async () => {
const provider = new MemoryRemoteProvider()
const brainDir = '/home/ada/.gemini/antigravity-cli/brain'
provider.addFile(`${brainDir}/missing-session/artifacts/task.jsonl`, 'artifact', 1)
const deniedTranscript = `${brainDir}/denied-session/.system_generated/logs/transcript.jsonl`
provider.addFile(deniedTranscript, 'unreadable', 2)
provider.failStat(
deniedTranscript,
new Error(`EACCES: permission denied, stat '${deniedTranscript}'`)
)
const result = await scanRemoteAiVaultSessions({
provider,
executionHostId: 'ssh:dev-box',
remoteHome: '/home/ada',
hostPlatform: getRemoteHostPlatform('linux-x64')
})
expect(result.sessions).toEqual([])
expect(result.issues).toEqual([
expect.objectContaining({
agent: 'antigravity',
path: deniedTranscript,
message: expect.stringContaining('EACCES')
})
])
})
it('reports non-missing fixed and recursive remote directory failures', async () => {
const provider = new MemoryRemoteProvider()
const brainDir = '/home/ada/.gemini/antigravity-cli/brain'
const claudeProjectDir = '/home/ada/.claude/projects/repo'
provider.addFile(`${claudeProjectDir}/session.jsonl`, 'unreadable', 1)
provider.failReadDir(brainDir, new Error(`EACCES: permission denied, scandir '${brainDir}'`))
provider.failReadDir(
claudeProjectDir,
new Error(`ECONNRESET: connection lost while reading '${claudeProjectDir}'`)
)
const result = await scanRemoteAiVaultSessions({
provider,
executionHostId: 'ssh:dev-box',
remoteHome: '/home/ada',
hostPlatform: getRemoteHostPlatform('linux-x64')
})
expect(result.sessions).toEqual([])
expect(result.issues).toEqual(
expect.arrayContaining([
expect.objectContaining({
agent: 'antigravity',
kind: 'host',
path: brainDir,
message: expect.stringContaining('EACCES')
}),
expect.objectContaining({
agent: 'claude',
kind: 'host',
path: claudeProjectDir,
message: expect.stringContaining('ECONNRESET')
})
])
)
expect(result.issues).toHaveLength(2)
})
it('keeps missing optional remote directories silent', async () => {
const provider = new MemoryRemoteProvider()
const brainDir = '/home/ada/.gemini/antigravity-cli/brain'
provider.failReadDir(brainDir, new Error(`ENOENT: no such directory, scandir '${brainDir}'`))
const result = await scanRemoteAiVaultSessions({
provider,
executionHostId: 'ssh:dev-box',
remoteHome: '/home/ada',
hostPlatform: getRemoteHostPlatform('linux-x64')
})
expect(result.sessions).toEqual([])
expect(result.issues).toEqual([])
})
it('excludes Claude subagent transcripts from remote scans', async () => {
const provider = new MemoryRemoteProvider()
provider.addFile(
'/home/ada/.claude/projects/repo/claude-session.jsonl',
jsonLines([
{
sessionId: 'claude-session',
timestamp: '2026-07-04T04:00:00.000Z',
type: 'user',
message: { content: [{ type: 'text', text: 'Spawn a Task' }] }
}
]),
40
)
provider.addFile(
'/home/ada/.claude/projects/repo/claude-session/subagents/agent-abc123.jsonl',
jsonLines([
{
sessionId: 'claude-session',
isSidechain: true,
agentId: 'abc123',
timestamp: '2026-07-04T04:01:00.000Z',
type: 'user',
message: { content: [{ type: 'text', text: 'Subagent task prompt' }] }
}
]),
41
)
const result = await scanRemoteAiVaultSessions({
provider,
executionHostId: 'ssh:dev-box',
remoteHome: '/home/ada',
hostPlatform: getRemoteHostPlatform('linux-x64')
})
expect(result.issues).toEqual([])
// Only the parent session surfaces; the subagent transcript would carry
// the parent's sessionId and list as a phantom top-level row.
expect(result.sessions.map((session) => session.filePath)).toEqual([
'/home/ada/.claude/projects/repo/claude-session.jsonl'
])
})
it('counts remote sibling subagent transcripts for zero-turn Claude sessions', async () => {
const provider = new MemoryRemoteProvider()
provider.addFile(
'/home/ada/.claude/projects/repo/lost-session.jsonl',
jsonLines([{ type: 'mode', mode: 'default', sessionId: 'lost-session' }]),
50
)
provider.addFile(
'/home/ada/.claude/projects/repo/lost-session/subagents/agent-a.jsonl',
jsonLines([{ type: 'user', message: { role: 'user', content: 'Subtask A' } }]),
51
)
provider.addFile(
'/home/ada/.claude/projects/repo/lost-session/subagents/agent-b.jsonl',
jsonLines([{ type: 'user', message: { role: 'user', content: 'Subtask B' } }]),
52
)
const result = await scanRemoteAiVaultSessions({
provider,
executionHostId: 'ssh:dev-box',
remoteHome: '/home/ada',
hostPlatform: getRemoteHostPlatform('linux-x64')
})
expect(result.issues).toEqual([])
// Subagent transcripts must not surface as standalone sessions; they only
// contribute recoverable signal to their zero-turn parent.
expect(result.sessions).toHaveLength(1)
expect(result.sessions[0]).toMatchObject({
agent: 'claude',
sessionId: 'lost-session',
messageCount: 0,
subagentTranscriptCount: 2,
filePath: '/home/ada/.claude/projects/repo/lost-session.jsonl'
})
})
it('counts remote subagent siblings for Claude sessions with real turns', async () => {
const provider = new MemoryRemoteProvider()
provider.addFile(
'/home/ada/.claude/projects/repo/live-session.jsonl',
jsonLines([
{
sessionId: 'live-session',
type: 'user',
message: { content: [{ type: 'text', text: 'Do the thing' }] }
}
]),
60
)
provider.addFile(
'/home/ada/.claude/projects/repo/live-session/subagents/agent-a.jsonl',
jsonLines([{ type: 'user', message: { role: 'user', content: 'Subtask' } }]),
61
)
const result = await scanRemoteAiVaultSessions({
provider,
executionHostId: 'ssh:dev-box',
remoteHome: '/home/ada',
hostPlatform: getRemoteHostPlatform('linux-x64')
})
expect(result.issues).toEqual([])
expect(result.sessions).toHaveLength(1)
// The walk listing supplies the count for every session (the row badge),
// not only zero-turn recoverable ones.
expect(result.sessions[0]).toMatchObject({
sessionId: 'live-session',
messageCount: 1,
subagentTranscriptCount: 1
})
})
it('builds resume commands with the remote host platform', async () => {
const provider = new MemoryRemoteProvider()
provider.addFile(
'C:/Users/Ada/.codex/sessions/win.jsonl',
jsonLines([
{
timestamp: '2026-07-04T03:00:00.000Z',
type: 'session_meta',
payload: { id: 'win-session', cwd: 'C:/repo/app' }
},
{
timestamp: '2026-07-04T03:00:01.000Z',
type: 'response_item',
payload: {
type: 'message',
role: 'user',
content: [{ type: 'text', text: 'Windows remote title' }]
}
}
]),
30
)
const result = await scanRemoteAiVaultSessions({
provider,
executionHostId: 'ssh:win-box',
remoteHome: 'C:/Users/Ada',
hostPlatform: getRemoteHostPlatform('win32-x64')
})
expect(result.issues).toEqual([])
expect(result.sessions[0]?.executionHostPlatform).toBe('win32')
expect(result.sessions[0]?.resumeCommand).toBe(
'cmd /d /s /c "cd /d ""C:/repo/app"" && set ""CODEX_HOME=C:/Users/Ada/.codex"" && codex resume ""win-session"""'
)
})
it('loads Antigravity workspace history with Windows remote paths', async () => {
const provider = new MemoryRemoteProvider()
const sessionId = 'dddddddd-eeee-4fff-8aaa-bbbbbbbbbbbb'
provider.addFile(
`C:/Users/Ada/.gemini/antigravity-cli/brain/${sessionId}/.system_generated/logs/transcript.jsonl`,
jsonLines([
{
source: 'USER_EXPLICIT',
type: 'USER_INPUT',
created_at: '2026-07-15T11:39:10.000Z',
content: '<USER_REQUEST>Windows Antigravity title</USER_REQUEST>'
}
]),
30
)
provider.addFile(
'C:/Users/Ada/.gemini/antigravity-cli/history.jsonl',
jsonLines([
{
display: 'Windows Antigravity title',
timestamp: Date.parse('2026-07-15T11:39:10.100Z'),
workspace: 'C:/repo/app'
}
]),
31
)
const result = await scanRemoteAiVaultSessions({
provider,
executionHostId: 'ssh:win-box',
remoteHome: 'C:/Users/Ada',
hostPlatform: getRemoteHostPlatform('win32-x64')
})
expect(result.issues).toEqual([])
expect(result.sessions[0]).toMatchObject({
agent: 'antigravity',
sessionId,
cwd: 'C:/repo/app',
executionHostPlatform: 'win32'
})
})
it('continues past skipped candidates to fill the remote scan limit', async () => {
const provider = new MemoryRemoteProvider()
provider.addFile(
'/home/ada/.codex/sessions/worker.jsonl',
codexTranscript({
sessionId: 'worker-session',
title: 'Internal worker',
cwd: '/home/ada/repo',
timestamp: '2026-07-04T04:00:00.000Z',
threadSource: 'agent'
}),
40
)
provider.addFile(
'/home/ada/.codex/sessions/user.jsonl',
codexTranscript({
sessionId: 'user-session',
title: 'Visible user session',
cwd: '/home/ada/repo',
timestamp: '2026-07-04T03:00:00.000Z'
}),
30
)
const result = await scanRemoteAiVaultSessions({
provider,
executionHostId: 'ssh:dev-box',
remoteHome: '/home/ada',
hostPlatform: getRemoteHostPlatform('linux-x64'),
limit: 1
})
expect(result.issues).toEqual([])
expect(result.sessions.map((session) => session.sessionId)).toEqual(['user-session'])
})
it('keeps scoped remote sessions even when they are older than the recency cap', async () => {
const provider = new MemoryRemoteProvider()
provider.addFile(
'/home/ada/.codex/sessions/other.jsonl',
codexTranscript({
sessionId: 'other-session',
title: 'Other workspace',
cwd: '/home/ada/other',
timestamp: '2026-07-04T05:00:00.000Z'
}),
50
)
provider.addFile(
'/home/ada/.codex/sessions/scoped.jsonl',
codexTranscript({
sessionId: 'scoped-session',
title: 'Scoped workspace',
cwd: '/home/ada/repo/app',
timestamp: '2026-07-04T01:00:00.000Z'
}),
10
)
const result = await scanRemoteAiVaultSessions({
provider,
executionHostId: 'ssh:dev-box',
remoteHome: '/home/ada',
hostPlatform: getRemoteHostPlatform('linux-x64'),
limit: 1,
scopePaths: ['/home/ada/repo']
})
expect(result.issues).toEqual([])
expect(result.sessions.map((session) => session.sessionId)).toEqual([
'other-session',
'scoped-session'
])
})
it('keeps looking past newer out-of-scope candidates during scoped backfill', async () => {
const provider = new MemoryRemoteProvider()
for (const [sessionId, mtimeMs, hour] of [
['other-newest', 50, '05'],
['other-newer', 40, '04']
] as const) {
provider.addFile(
`/home/ada/.codex/sessions/${sessionId}.jsonl`,
codexTranscript({
sessionId,
title: sessionId,
cwd: '/home/ada/other',
timestamp: `2026-07-04T${hour}:00:00.000Z`
}),
mtimeMs
)
}
provider.addFile(
'/home/ada/.codex/sessions/scoped.jsonl',
codexTranscript({
sessionId: 'scoped-session',
title: 'Scoped workspace',
cwd: '/home/ada/repo',
timestamp: '2026-07-04T01:00:00.000Z'
}),
10
)
const result = await scanRemoteAiVaultSessions({
provider,
executionHostId: 'ssh:dev-box',
remoteHome: '/home/ada',
hostPlatform: getRemoteHostPlatform('linux-x64'),
limit: 1,
scopePaths: ['/home/ada/repo']
})
expect(result.issues).toEqual([])
expect(result.sessions.map((session) => session.sessionId)).toEqual([
'other-newest',
'scoped-session'
])
})
it('caps scoped backfill at the requested limit', async () => {
const provider = new MemoryRemoteProvider()
provider.addFile(
'/home/ada/.codex/sessions/other.jsonl',
codexTranscript({
sessionId: 'other-session',
title: 'Other workspace',
cwd: '/home/ada/other',
timestamp: '2026-07-04T05:00:00.000Z'
}),
50
)
for (const [sessionId, mtimeMs] of [
['newer-scoped', 30],
['older-scoped', 20]
] as const) {
provider.addFile(
`/home/ada/.codex/sessions/${sessionId}.jsonl`,
codexTranscript({
sessionId,
title: sessionId,
cwd: '/home/ada/repo',
timestamp: `2026-07-04T0${mtimeMs / 10}:00:00.000Z`
}),
mtimeMs
)
}
const result = await scanRemoteAiVaultSessions({
provider,
executionHostId: 'ssh:dev-box',
remoteHome: '/home/ada',
hostPlatform: getRemoteHostPlatform('linux-x64'),
limit: 1,
scopePaths: ['/home/ada/repo']
})
expect(result.sessions.map((session) => session.sessionId)).toEqual([
'other-session',
'newer-scoped'
])
})
})
function codexTranscript(args: {
sessionId: string
title: string
cwd: string
timestamp: string
threadSource?: string
}): string {
return jsonLines([
{
timestamp: args.timestamp,
type: 'session_meta',
payload: {
id: args.sessionId,
cwd: args.cwd,
...(args.threadSource ? { thread_source: args.threadSource } : {})
}
},
{
timestamp: args.timestamp.replace(':00.000Z', ':01.000Z'),
type: 'response_item',
payload: {
type: 'message',
role: 'user',
content: [{ type: 'text', text: args.title }]
}
}
])
}
-328
View File
@@ -1,328 +0,0 @@
import {
candidateFileTime,
prioritizeAntigravityTranscriptCandidates
} from './antigravity-transcript-candidates'
import { readRemoteAntigravityIndex } from './antigravity-index-reader'
import { parseRemoteSessionTranscript } from './remote-session-transcript-read'
import { BinarySessionTranscriptError } from './remote-session-content-lines'
import type {
AiVaultListResult,
AiVaultScanIssue,
AiVaultSession
} from '../../shared/ai-vault-types'
import { isPathInsideOrEqual } from '../../shared/cross-platform-path'
import type { ExecutionHostId } from '../../shared/execution-host'
import { setImmediate as yieldToEventLoop } from 'node:timers/promises'
import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform'
import {
codexRolloutHardlinkIdentity,
dedupeCodexRolloutFileAliases
} from './codex-session-root-dedup'
import { ScannedSessionCollection, dedupeScannedSessions } from './session-root-dedup'
import {
parseRemoteSessionFileCached,
remoteSessionCandidateKey,
remoteSessionParseHostKey
} from './remote-session-parse-cache'
import { remoteCodexIndexedTitleReader } from './remote-session-scanner-codex-index'
import { discoverRemoteSourceCandidates } from './remote-session-scanner-discovery'
import { remoteSessionSources } from './remote-session-scanner-sources'
import type {
RemoteScannerContext,
RemoteSessionCandidate,
RemoteSessionFilesystemProvider
} from './remote-session-scanner-types'
import { sessionSortTime } from './session-scanner-accumulator'
import { createAntigravityWorkspaceResolver } from './session-scanner-antigravity-history'
import { errorMessage } from './session-scanner-values'
import { mapRemoteScanBatches } from './remote-session-scan-batching'
import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation'
import { recordSessionScanIssue } from './session-scan-issues'
import { canStopParsingSessions } from './session-scan-cutoff'
import { refreshCodexTitleFromIndex } from './session-scanner-codex-cached-title'
import { limitRemoteScanFilesystemConcurrency } from './remote-session-scan-concurrency'
import { aiVaultScanLimit } from '../../shared/ai-vault-session-depth'
import { remoteOpenCodeSources } from './remote-session-scanner-opencode-source'
const REMOTE_SCAN_CONCURRENCY = 8
const REMOTE_PARSE_CANDIDATE_MULTIPLIER = 2
// Remote scope membership is only known after a transcript is read, so the scope
// backfill carries its own ceiling on remote reads instead of borrowing the
// recency cap — under the cap, newer out-of-scope files ate the scope budget and
// silently dropped older in-scope sessions the scope contract guarantees.
const REMOTE_SCOPE_PARSE_CANDIDATE_LIMIT = 1000
export async function scanRemoteAiVaultSessions(args: {
provider: RemoteSessionFilesystemProvider
executionHostId: ExecutionHostId
remoteHome: string
kiroHomeDir?: string
hostPlatform: RemoteHostPlatform
includeAntigravityIdeSessions?: boolean
limit?: number
unlimited?: boolean
scopePaths?: readonly string[]
signal?: AbortSignal
}): Promise<AiVaultListResult> {
throwIfAiVaultScanCancelled(args.signal)
const limit = aiVaultScanLimit(args)
const issues: AiVaultScanIssue[] = []
// One ceiling for the whole scan: discovery walks, stats and transcript reads
// all queue behind it instead of multiplying into a nested fan-out.
const provider = limitRemoteScanFilesystemConcurrency(args.provider)
const context: RemoteScannerContext = {
provider,
executionHostId: args.executionHostId,
hostPlatform: args.hostPlatform,
signal: args.signal,
titleCaches: new Map(),
antigravityWorkspaceResolver: createAntigravityWorkspaceResolver((path) =>
readRemoteAntigravityIndex(provider, path, args.signal)
)
}
const discoveredCandidates = dedupeCodexRolloutFileAliases(
(
await mapRemoteScanBatches(
[
...remoteSessionSources(
args.remoteHome,
args.hostPlatform,
args.includeAntigravityIdeSessions,
args.kiroHomeDir
),
...remoteOpenCodeSources(
provider.openCode,
limit * REMOTE_PARSE_CANDIDATE_MULTIPLIER +
(args.scopePaths?.length ? REMOTE_SCOPE_PARSE_CANDIDATE_LIMIT + 1 : 0)
)
],
REMOTE_SCAN_CONCURRENCY,
(source) => discoverRemoteSourceCandidates({ source, context, issues }),
args.signal
)
)
.flat()
.sort((left, right) => right.file.mtimeMs - left.file.mtimeMs),
{
isCodex: (candidate) => candidate.source.agent === 'codex',
getFilePath: (candidate) => candidate.file.path,
getCodexHome: (candidate) => candidate.source.codexHome ?? null,
getHardlinkIdentity: (candidate) => codexRolloutHardlinkIdentity(candidate.file)
}
)
const candidates = prioritizeAntigravityTranscriptCandidates(
discoveredCandidates,
(candidate) => candidate.source.agent === 'antigravity'
)
const parsed = await parseRemoteSessionCandidates({
candidates: candidates.slice(0, limit * REMOTE_PARSE_CANDIDATE_MULTIPLIER),
context,
issues,
limit
})
const parsedSessions = dedupeScannedSessions(parsed.sessions)
const cappedSessions = parsedSessions
.sort((left, right) => sessionSortTime(right) - sessionSortTime(left))
.slice(0, limit)
const scopePaths = normalizeRemoteScopePaths(args.scopePaths ?? [])
const parsedScopeSessions = parsedSessions.filter((session) =>
isRemoteSessionInScope(session, scopePaths)
)
const extraScopeSessions = await scanRemoteInScopeSessions({
candidates,
context,
issues,
scopePaths,
limit,
alreadyParsedCandidateKeys: parsed.parsedCandidateKeys
})
const scopeSessions = dedupeScannedSessions([...parsedScopeSessions, ...extraScopeSessions])
.sort((left, right) => sessionSortTime(right) - sessionSortTime(left))
.slice(0, limit)
return {
sessions: mergeRemoteSessions(cappedSessions, scopeSessions),
issues,
scannedAt: new Date().toISOString()
}
}
async function parseRemoteSessionCandidates(args: {
candidates: readonly RemoteSessionCandidate[]
context: RemoteScannerContext
issues: AiVaultScanIssue[]
limit: number
}): Promise<{ sessions: AiVaultSession[]; parsedCandidateKeys: Set<string> }> {
const sessions = new ScannedSessionCollection()
const parsedCandidateKeys = new Set<string>()
let index = 0
while (index < args.candidates.length) {
if (
canStopParsingSessions(sessions, args.limit, candidateFileTime(args.candidates[index]?.file))
) {
break
}
const remaining = args.candidates.length - index
const needed = Math.max(args.limit - sessions.size, 1)
const batchSize = Math.min(REMOTE_SCAN_CONCURRENCY, needed, remaining)
const batch = args.candidates.slice(index, index + batchSize)
for (const candidate of batch) {
parsedCandidateKeys.add(remoteSessionCandidateKey(candidate))
}
throwIfAiVaultScanCancelled(args.context.signal)
const results = await Promise.all(
batch.map((candidate) => parseRemoteSessionCandidate(candidate, args.context, args.issues))
)
for (const session of results) {
if (session) {
sessions.add(session)
}
}
index += batchSize
await yieldToEventLoop()
}
// The loop can terminate on the yield after its final batch, so re-check
// rather than letting a cancelled scan return a partial parse as a success.
throwIfAiVaultScanCancelled(args.context.signal)
return { sessions: [...sessions.values()], parsedCandidateKeys }
}
async function scanRemoteInScopeSessions(args: {
candidates: readonly RemoteSessionCandidate[]
context: RemoteScannerContext
issues: AiVaultScanIssue[]
scopePaths: readonly string[]
limit: number
alreadyParsedCandidateKeys: ReadonlySet<string>
}): Promise<AiVaultSession[]> {
if (args.scopePaths.length === 0) {
return []
}
const candidates = args.candidates.filter(
(candidate) => !args.alreadyParsedCandidateKeys.has(remoteSessionCandidateKey(candidate))
)
const bound = Math.min(candidates.length, REMOTE_SCOPE_PARSE_CANDIDATE_LIMIT)
const sessions: AiVaultSession[] = []
let index = 0
// Keep reading newest-first until the scope has its requested number of
// sessions; out-of-scope candidates no longer end the search.
while (index < bound && sessions.length < args.limit) {
const batchEnd = Math.min(index + REMOTE_SCAN_CONCURRENCY, bound)
const results = await mapRemoteScanBatches(
candidates.slice(index, batchEnd),
REMOTE_SCAN_CONCURRENCY,
(candidate) => parseRemoteSessionCandidate(candidate, args.context, args.issues),
args.context.signal
)
sessions.push(
...results.filter(
(session): session is AiVaultSession =>
isAiVaultSession(session) && isRemoteSessionInScope(session, args.scopePaths)
)
)
index = batchEnd
}
if (index < candidates.length && sessions.length < args.limit) {
recordSessionScanIssue(args.issues, {
executionHostId: args.context.executionHostId,
agent: 'codex',
kind: 'scope',
path: 'Agent Session History scan',
message: `Only the ${REMOTE_SCOPE_PARSE_CANDIDATE_LIMIT} most recent remote transcripts were checked for this workspace; older sessions may be missing.`
})
}
return sessions
}
async function parseRemoteSessionCandidate(
candidate: RemoteSessionCandidate,
context: RemoteScannerContext,
issues: AiVaultScanIssue[]
): Promise<AiVaultSession | null> {
try {
throwIfAiVaultScanCancelled(context.signal)
// The read is inside the cached parse: an unchanged transcript must not be
// pulled off the remote disk at all, which is the whole cost of #13753.
const session = await parseRemoteSessionFileCached({
candidate,
hostKey: remoteSessionParseHostKey(context),
parse: () => parseRemoteSessionTranscript(candidate, context),
refreshReusedSession: reusedCodexTitleRefresh(candidate, context)
})
throwIfAiVaultScanCancelled(context.signal)
// Mirror the local rule: every session carries its sibling subagent
// transcript count (row badge; recoverable signal at zero turns). The
// walk listing supplies it — the parser can't readdir a remote disk.
const subagentTranscriptCount = candidate.subagentTranscriptCount ?? 0
if (session && subagentTranscriptCount > 0) {
return { ...session, subagentTranscriptCount }
}
return session
} catch (err) {
throwIfAiVaultScanCancelled(context.signal)
if (err instanceof BinarySessionTranscriptError) {
return null
}
recordSessionScanIssue(issues, {
executionHostId: context.executionHostId,
agent: candidate.source.agent,
path: candidate.file.path,
message: errorMessage(err)
})
return null
}
}
// Codex thread names live in `<CODEX_HOME>/session_index.jsonl`, not the
// rollout, and are written after it — so a transcript-keyed cache hit would
// pin the fallback title forever. Local counterpart:
// session-scanner-parse-cache.ts's reuse path.
function reusedCodexTitleRefresh(
candidate: RemoteSessionCandidate,
context: RemoteScannerContext
): ((session: AiVaultSession) => Promise<AiVaultSession>) | undefined {
const codexHome = candidate.source.agent === 'codex' ? candidate.source.codexHome : undefined
if (!codexHome) {
return undefined
}
const readIndexedTitle = remoteCodexIndexedTitleReader(codexHome, context)
return (session) => refreshCodexTitleFromIndex(session, readIndexedTitle)
}
function mergeRemoteSessions(
cappedSessions: AiVaultSession[],
scopeSessions: AiVaultSession[]
): AiVaultSession[] {
if (scopeSessions.length === 0) {
return cappedSessions
}
const byId = new Map<string, AiVaultSession>()
for (const session of cappedSessions) {
byId.set(session.id, session)
}
for (const session of scopeSessions) {
byId.set(session.id, session)
}
return [...byId.values()].sort((left, right) => sessionSortTime(right) - sessionSortTime(left))
}
function isRemoteSessionInScope(session: AiVaultSession, scopePaths: readonly string[]): boolean {
const cwd = session.cwd
return Boolean(cwd && scopePaths.some((scopePath) => isPathInsideOrEqual(scopePath, cwd)))
}
function normalizeRemoteScopePaths(scopePaths: readonly string[]): string[] {
return scopePaths.map((scopePath) => scopePath.trim()).filter(Boolean)
}
function isAiVaultSession(session: AiVaultSession | null): session is AiVaultSession {
return Boolean(session)
}
@@ -1,84 +0,0 @@
import { describe, expect, it } from 'vitest'
import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform'
import { scanRemoteAiVaultSessions } from './remote-session-scanner'
import { MemoryRemoteProvider } from './remote-session-scanner-test-fixtures'
import { resetRemoteSessionParseCacheForTests } from './remote-session-parse-cache'
describe('remote sidecar observations', () => {
const sessionId = '1786466194549_sidecar'
const sessionDir = `/home/ada/.cline/data/sessions/${sessionId}`
const messagesPath = `${sessionDir}/${sessionId}.messages.json`
function addCline(provider: MemoryRemoteProvider, firstPrompt: string): void {
provider.addFile(
`${sessionDir}/${sessionId}.json`,
JSON.stringify({
version: 1,
session_id: sessionId,
started_at: '2026-08-11T16:36:34.551Z',
cwd: '/home/ada/repo'
}),
10
)
provider.addFile(
messagesPath,
JSON.stringify({
version: 1,
updated_at: '2026-08-11T16:38:00.000Z',
sessionId,
messages: [{ role: 'user', content: [{ type: 'text', text: firstPrompt }] }]
}),
11
)
}
const scan = (provider: MemoryRemoteProvider): ReturnType<typeof scanRemoteAiVaultSessions> =>
scanRemoteAiVaultSessions({
provider,
executionHostId: 'ssh:dev-box',
remoteHome: '/home/ada',
hostPlatform: getRemoteHostPlatform('linux-x64')
})
it('re-parses when a messages-file stat fails rather than serving the cached row', async () => {
resetRemoteSessionParseCacheForTests()
const provider = new MemoryRemoteProvider()
addCline(provider, 'first prompt')
expect((await scan(provider)).sessions[0]).toMatchObject({ title: 'first prompt' })
// The sidecar changed underneath, and its stat now fails for a reason that
// is not "missing": nothing about it may be assumed, so the row is re-read.
addCline(provider, 'second prompt')
provider.failStat(
messagesPath,
Object.assign(new Error('permission denied'), { code: 'EACCES' })
)
const refused = await scan(provider)
expect(refused.sessions[0]).toMatchObject({ title: 'second prompt' })
expect(refused.issues.map((issue) => issue.path)).toContain(messagesPath)
})
it('treats a genuinely missing messages file as no sidecar, not as unknown', async () => {
resetRemoteSessionParseCacheForTests()
const provider = new MemoryRemoteProvider()
provider.addFile(
`${sessionDir}/${sessionId}.json`,
JSON.stringify({
version: 1,
session_id: sessionId,
started_at: '2026-08-11T16:36:34.551Z',
cwd: '/home/ada/repo'
}),
10
)
const first = await scan(provider)
const second = await scan(provider)
expect(first.issues).toEqual([])
expect(second.issues).toEqual([])
expect(second.sessions[0]?.sessionId).toBe(sessionId)
})
})
@@ -1,115 +0,0 @@
import { describe, it, expect, vi } from 'vitest'
import { streamedSessionContentLines } from './remote-session-content-lines'
import { readStreamedSessionDocument } from './session-document-stream'
import { limitRemoteScanFilesystemConcurrency } from './remote-session-scan-concurrency'
describe('stream lifetime and retained document work', () => {
it('aborts a newline-free record at the byte ceiling and closes the source', async () => {
let closed = false
let reads = 0
async function* bytes() {
const chunk = Buffer.alloc(1024 * 1024, 'x')
try {
for (; reads < 100;) {
reads++
yield chunk
}
} finally {
closed = true
}
}
const lines = streamedSessionContentLines(bytes())
await expect(lines.next()).rejects.toThrow('record exceeds 10485760 byte limit')
expect(reads).toBe(11)
expect(closed).toBe(true)
})
it('releases the source when a line consumer finishes early', async () => {
let closed = false
async function* bytes() {
try {
yield Buffer.from('one\ntwo\n')
yield Buffer.from('three\n')
} finally {
closed = true
}
}
for await (const line of streamedSessionContentLines(bytes())) {
expect(line).toBe('one')
break
}
await vi.waitFor(() => expect(closed).toBe(true))
})
it('propagates disk failure and closes the source', async () => {
let closed = false
async function* bytes() {
try {
yield Buffer.from('one\n')
throw new Error('disk read failed')
} finally {
closed = true
}
}
await expect(
(async () => {
for await (const _ of streamedSessionContentLines(bytes())) {
/* consume */
}
})()
).rejects.toThrow('disk read failed')
expect(closed).toBe(true)
})
it('cancellation discards a document fold and releases its source', async () => {
const controller = new AbortController()
let closed = false
async function* bytes() {
try {
yield Buffer.from('{"messages":[{"role":"user"}')
controller.abort()
yield Buffer.from(']}')
} finally {
closed = true
}
}
await expect(
readStreamedSessionDocument({
bytes: bytes(),
arrayKey: 'messages',
fields: [],
create: () => ({ count: 0 }),
consume: (state) => {
state.count++
},
signal: controller.signal
})
).rejects.toThrow()
expect(closed).toBe(true)
})
it('holds one filesystem slot for the stream lifetime and releases it on return', async () => {
let entered = 0
async function* bytes() {
entered++
yield Buffer.from('a')
yield Buffer.from('b')
}
const provider = limitRemoteScanFilesystemConcurrency(
{
readDir: async () => [],
readFile: async () => ({ content: '', isBinary: false }),
stat: async () => ({ size: 0, type: 'file', mtime: 0 }),
readTranscriptBytes: bytes
},
1
)
const first = provider.readTranscriptBytes!('/one')[Symbol.asyncIterator](),
second = provider.readTranscriptBytes!('/two')[Symbol.asyncIterator]()
await first.next()
const pending = second.next()
await Promise.resolve()
expect(entered).toBe(1)
await first.return!()
await pending
expect(entered).toBe(2)
await second.return!()
})
})
@@ -1,52 +0,0 @@
import { streamedSessionContentLines } from './remote-session-content-lines'
import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation'
import type { RemoteSessionCandidate, RemoteScannerContext } from './remote-session-scanner-types'
import type { AiVaultSession } from '../../shared/ai-vault-types'
const LEGACY_SESSION_TEXT_LIMIT_BYTES = 10 * 1024 * 1024
export async function parseRemoteSessionTranscript(
candidate: RemoteSessionCandidate,
context: RemoteScannerContext
): Promise<AiVaultSession | null> {
if (candidate.source.parseCandidate) {
return candidate.source.parseCandidate(candidate.file, context)
}
const sidecar = candidate.file.sidecar
const exceedsWholeReadLimit =
(candidate.file.sizeBytes ?? 0) > LEGACY_SESSION_TEXT_LIMIT_BYTES ||
(typeof sidecar === 'object' && sidecar.sizeBytes > LEGACY_SESSION_TEXT_LIMIT_BYTES)
if (
exceedsWholeReadLimit &&
candidate.source.parseDocument &&
!candidate.file.path.endsWith('.jsonl') &&
context.provider.readTranscriptBytes
) {
return candidate.source.parseDocument(
candidate.file,
context.provider.readTranscriptBytes(candidate.file.path, context.signal),
context
)
}
if (
exceedsWholeReadLimit &&
candidate.file.path.endsWith('.jsonl') &&
candidate.source.parseLines &&
context.provider.readTranscriptBytes
) {
return candidate.source.parseLines(
candidate.file,
streamedSessionContentLines(
context.provider.readTranscriptBytes(candidate.file.path, context.signal),
context.signal
),
context
)
}
const read = await context.provider.readFile(candidate.file.path)
throwIfAiVaultScanCancelled(context.signal)
if (read.isBinary) {
return null
}
return await candidate.source.parse(candidate.file, read.content, context)
}
@@ -2,9 +2,6 @@ import { mkdtemp, mkdir, rm, utimes, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform'
import { scanRemoteAiVaultSessions } from './remote-session-scanner'
import { MemoryRemoteProvider } from './remote-session-scanner-test-fixtures'
import { scanAiVaultSessions } from './session-scanner'
import { isolatedScanRoots, jsonLines } from './session-scanner-test-fixtures'
@@ -15,63 +12,46 @@ afterEach(async () => {
})
describe('session scanner cutoff', () => {
it.each(['native', 'remote'] as const)(
'%s does not sort timestamps at every post-limit candidate',
async (host) => {
const count = 128
const limit = count / 2
const provider = new MemoryRemoteProvider()
const root = await mkdtemp(join(tmpdir(), 'orca-session-cutoff-'))
tempRoots.push(root)
const roots = isolatedScanRoots(root)
await mkdir(roots.codexSessionsDir, { recursive: true })
for (let index = 0; index < count; index++) {
const name = `rollout-session-${index}.jsonl`
const content = jsonLines([
{ type: 'session_meta', payload: { id: `session-${index}`, cwd: '/repo/folder' } },
{
type: 'event_msg',
timestamp: new Date(index).toISOString(),
payload: { type: 'user_message', message: 'Check this session' }
}
])
const mtime = 10_000 - index
if (host === 'native') {
const filePath = join(roots.codexSessionsDir, name)
await writeFile(filePath, content)
await utimes(filePath, new Date(mtime), new Date(mtime))
} else {
provider.addFile(`/home/ada/.codex/sessions/${name}`, content, mtime)
it('does not sort timestamps at every post-limit candidate', async () => {
const count = 128
const limit = count / 2
const root = await mkdtemp(join(tmpdir(), 'orca-session-cutoff-'))
tempRoots.push(root)
const roots = isolatedScanRoots(root)
await mkdir(roots.codexSessionsDir, { recursive: true })
for (let index = 0; index < count; index++) {
const name = `rollout-session-${index}.jsonl`
const content = jsonLines([
{ type: 'session_meta', payload: { id: `session-${index}`, cwd: '/repo/folder' } },
{
type: 'event_msg',
timestamp: new Date(index).toISOString(),
payload: { type: 'user_message', message: 'Check this session' }
}
}
let numericSorts = 0
const originalSort = Array.prototype.sort
vi.spyOn(Array.prototype, 'sort').mockImplementation(function (this: unknown[], compare) {
if (typeof this[0] === 'number') {
numericSorts++
}
return originalSort.call(this, compare)
})
const scan = () =>
host === 'native'
? scanAiVaultSessions({ ...roots, limit })
: scanRemoteAiVaultSessions({
provider,
remoteHome: '/home/ada',
hostPlatform: getRemoteHostPlatform('linux-x64'),
executionHostId: 'ssh:scan-cutoff',
limit
})
for (let pass = 0; pass < 2; pass++) {
numericSorts = 0
const result = await scan()
expect(result.issues).toEqual([])
expect(result.sessions.map((row) => row.sessionId)).toEqual(
Array.from({ length: limit }, (_, index) => `session-${count - index - 1}`)
)
expect(numericSorts).toBe(0)
}
])
const mtime = 10_000 - index
const filePath = join(roots.codexSessionsDir, name)
await writeFile(filePath, content)
await utimes(filePath, new Date(mtime), new Date(mtime))
}
)
let numericSorts = 0
const originalSort = Array.prototype.sort
vi.spyOn(Array.prototype, 'sort').mockImplementation(function (this: unknown[], compare) {
if (typeof this[0] === 'number') {
numericSorts++
}
return originalSort.call(this, compare)
})
const scan = () => scanAiVaultSessions({ ...roots, limit })
for (let pass = 0; pass < 2; pass++) {
numericSorts = 0
const result = await scan()
expect(result.issues).toEqual([])
expect(result.sessions.map((row) => row.sessionId)).toEqual(
Array.from({ length: limit }, (_, index) => `session-${count - index - 1}`)
)
expect(numericSorts).toBe(0)
}
})
})
@@ -2,9 +2,6 @@ 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 { getRemoteHostPlatform } from '../ssh/ssh-remote-platform'
import { scanRemoteAiVaultSessions } from './remote-session-scanner'
import { MemoryRemoteProvider } from './remote-session-scanner-test-fixtures'
import { scanAiVaultSessions } from './session-scanner'
import { isolatedScanRoots, jsonLines } from './session-scanner-test-fixtures'
@@ -15,96 +12,37 @@ afterEach(async () => {
})
describe('session scan batch deduplication', () => {
it.each(['native', 'remote'] as const)(
'%s does not rederive every retained rollout alias after each batch',
async (host) => {
const count = 128
const provider = new MemoryRemoteProvider()
const root = await mkdtemp(join(tmpdir(), 'orca-session-dedup-'))
tempRoots.push(root)
const roots = isolatedScanRoots(root)
await mkdir(roots.codexSessionsDir, { recursive: true })
for (let index = 0; index < count; index++) {
const name = `rollout-session-${index}.jsonl`
const content = jsonLines([
{ type: 'session_meta', payload: { id: `session-${index}`, cwd: '/repo/folder' } },
{ type: 'event_msg', payload: { type: 'user_message', message: 'Check this session' } }
])
if (host === 'native') {
await writeFile(join(roots.codexSessionsDir, name), content)
} else {
provider.addFile(`/home/ada/.codex/sessions/${name}`, content, count - index)
}
}
let aliasChecks = 0
const originalTest = RegExp.prototype.test
vi.spyOn(RegExp.prototype, 'test').mockImplementation(function (this: RegExp, value) {
if (this.source === '^rollout-.+\\.jsonl$') {
aliasChecks++
}
return originalTest.call(this, value)
})
const scan = () =>
host === 'native'
? scanAiVaultSessions({ ...roots, unlimited: true })
: scanRemoteAiVaultSessions({
provider,
remoteHome: '/home/ada',
hostPlatform: getRemoteHostPlatform('linux-x64'),
executionHostId: 'ssh:dedup-batches',
unlimited: true
})
for (let pass = 0; pass < 2; pass++) {
aliasChecks = 0
const result = await scan()
expect(result.issues).toEqual([])
expect(result.sessions).toHaveLength(count)
expect(new Set(result.sessions.map((session) => session.sessionId)).size).toBe(count)
expect(aliasChecks).toBeLessThanOrEqual(count * 8)
}
}
)
it('replaces aliases across remote batches without consuming the unique-session budget', async () => {
const provider = new MemoryRemoteProvider()
const managedHome = '/home/ada/.local/share/orca/codex-runtime-home/home'
const content = (id: string) =>
jsonLines([
{ type: 'session_meta', payload: { id, cwd: '/repo/folder' } },
{ type: 'event_msg', payload: { type: 'user_message', message: 'Session' } }
it('does not rederive every retained rollout alias after each batch', async () => {
const count = 128
const root = await mkdtemp(join(tmpdir(), 'orca-session-dedup-'))
tempRoots.push(root)
const roots = isolatedScanRoots(root)
await mkdir(roots.codexSessionsDir, { recursive: true })
for (let index = 0; index < count; index++) {
const name = `rollout-session-${index}.jsonl`
const content = jsonLines([
{ type: 'session_meta', payload: { id: `session-${index}`, cwd: '/repo/folder' } },
{ type: 'event_msg', payload: { type: 'user_message', message: 'Check this session' } }
])
for (let index = 0; index < 8; index++) {
const name = `rollout-${index}.jsonl`
provider.addFile(`/home/ada/.codex/sessions/${name}`, content(`${index}`), 1000 - index)
provider.addFile(`${managedHome}/sessions/${name}`, content(`${index}`), 500 - index)
await writeFile(join(roots.codexSessionsDir, name), content)
}
provider.addFile('/home/ada/.codex/sessions/rollout-unique.jsonl', content('unique'), 100)
const tooOld = '/home/ada/.codex/sessions/rollout-too-old.jsonl'
provider.addFile(tooOld, content('too-old'), 50)
const reads = vi.spyOn(provider, 'readFile')
const result = await scanRemoteAiVaultSessions({
provider,
remoteHome: '/home/ada',
hostPlatform: getRemoteHostPlatform('linux-x64'),
executionHostId: 'ssh:batch-replacements',
limit: 9
let aliasChecks = 0
const originalTest = RegExp.prototype.test
vi.spyOn(RegExp.prototype, 'test').mockImplementation(function (this: RegExp, value) {
if (this.source === '^rollout-.+\\.jsonl$') {
aliasChecks++
}
return originalTest.call(this, value)
})
expect(result.issues).toEqual([])
expect(result.sessions.map((session) => session.sessionId)).toEqual([
'0',
'1',
'2',
'3',
'4',
'5',
'6',
'7',
'unique'
])
expect(result.sessions.slice(0, 8).every((session) => session.codexHome === managedHome)).toBe(
true
)
expect(reads).not.toHaveBeenCalledWith(tooOld)
const scan = () => scanAiVaultSessions({ ...roots, unlimited: true })
for (let pass = 0; pass < 2; pass++) {
aliasChecks = 0
const result = await scan()
expect(result.issues).toEqual([])
expect(result.sessions).toHaveLength(count)
expect(new Set(result.sessions.map((session) => session.sessionId)).size).toBe(count)
expect(aliasChecks).toBeLessThanOrEqual(count * 8)
}
})
})
@@ -2,9 +2,6 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, expect, it, vi } from 'vitest'
import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform'
import { MemoryRemoteProvider } from './remote-session-scanner-test-fixtures'
import { scanRemoteAiVaultSessions } from './remote-session-scanner'
import { isolatedScanRoots } from './session-scanner-test-fixtures'
import { parseDevinSessionContent } from './session-scanner-devin-parser'
import { dedupeScannedSessions, ScannedSessionCollection } from './session-root-dedup'
@@ -45,32 +42,6 @@ it('lists a session in both default directories once and still fills the scan li
expect(unlimited.sessions.map((session) => session.sessionId)).toEqual(['same', 'other'])
})
it.each(['win32-x64', 'linux-x64'] as const)(
'deduplicates both remote directories on %s',
async (platform) => {
const provider = new MemoryRemoteProvider()
const windows = platform === 'win32-x64'
const home = windows ? 'C:/Users/ada' : '/home/ada'
const cliDir = `${home}/${windows ? 'AppData/Roaming' : '.local/share'}/devin/cli`
provider.addFile(`${cliDir}/transcripts/same.json`, transcript('same'), 10)
provider.addFile(`${cliDir}/agent_logs/devin-same.json`, transcript('same'), 20)
provider.addFile(
`${cliDir}/transcripts/other.json`,
transcript('other', '2026-09-18T00:00:00Z'),
5
)
const result = await scanRemoteAiVaultSessions({
provider,
remoteHome: home,
executionHostId: 'ssh:devin',
hostPlatform: getRemoteHostPlatform(platform),
limit: 2
})
expect(result.sessions.map((session) => session.sessionId)).toEqual(['same', 'other'])
expect(result.sessions[0].filePath).toBe(`${cliDir}/agent_logs/devin-same.json`)
}
)
function session(path: string) {
const parsed = parseDevinSessionContent(
{ path, mtimeMs: 100, sizeBytes: 1, modifiedAt: new Date(100).toISOString() },
@@ -1,16 +1,10 @@
import { readFile, mkdtemp, rm, stat } from 'node:fs/promises'
import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform'
import { tmpdir } from 'node:os'
import { readFile, rm } from 'node:fs/promises'
import { join } from 'node:path'
import { afterEach, expect, it } from 'vitest'
import { AI_VAULT_AGENT_SOURCES } from './session-scanner-agent-sources'
import { isolatedScanRoots, writeJsonlFile } from './session-scanner-test-fixtures'
import { scanAiVaultSessions } from './session-scanner'
import {
createQoderSessionResumeState,
parseQoderSessionContent
} from './session-scanner-qoder-parser'
import { remoteSessionSources } from './remote-session-scanner-sources'
import { resetSessionParseCacheForTests } from './session-scanner-parse-cache'
import type { TranscriptMessage } from './session-transcript-consumers'
@@ -67,39 +61,3 @@ it('decodes a real generated and resumed Qoder 1.1.64 session for preview and se
cwd: '/tmp/qoder-proof'
})
})
it('discovers Qoder folder history and prunes nested workers on local, WSL, and SSH hosts', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-qoder-vault-'))
roots.push(root)
const options = isolatedScanRoots(root)
const path = join(options.qoderProjectsDir, 'folder', 'qoder-session.jsonl')
const records = [
{ type: 'workspace-directories', sessionId: 'qoder-session', directories: ['/tmp/folder'] },
{
type: 'user',
sessionId: 'qoder-session',
message: { role: 'user', content: 'Qoder folder proof' }
}
]
await writeJsonlFile(path, records)
await writeJsonlFile(
join(options.qoderProjectsDir, 'folder', 'qoder-session', 'subagents', 'worker.jsonl'),
records
)
const result = await scanAiVaultSessions({ ...options, wslHomeDirs: [] })
const sessions = result.sessions.filter((s) => s.agent === 'qoder')
expect(sessions).toHaveLength(1)
expect(sessions[0]).toMatchObject({ title: 'Qoder folder proof', cwd: '/tmp/folder' })
expect(AI_VAULT_AGENT_SOURCES.qoder?.rootDirs(options, ['/home/test'])).toContain(
join('/home/test', '.qoder', 'projects')
)
for (const platform of ['darwin-arm64', 'linux-x64', 'win32-x64'] as const) {
const source = remoteSessionSources(
platform === 'win32-x64' ? 'C:\\Users\\test' : '/home/test',
getRemoteHostPlatform(platform)
).find((s) => s.agent === 'qoder')
expect(source).toBeDefined()
expect(source?.partitionSubagentTranscripts).toBeDefined()
}
expect((await stat(path)).size).toBeGreaterThan(0)
})
@@ -1,6 +1,5 @@
import { beforeEach, expect, it, vi } from 'vitest'
import type * as SessionDedup from './session-root-dedup'
import type * as RemoteSessionParseCache from './remote-session-parse-cache'
import type { AiVaultSession } from '../../shared/ai-vault-types'
const fixture = vi.hoisted((): { sessions: AiVaultSession[]; visits: number } => ({
@@ -28,16 +27,6 @@ vi.mock('./session-scanner-parse-cache', () => ({
}),
parseAgentSessionFileCached: async (candidate: { session: AiVaultSession }) => candidate.session
}))
vi.mock('./remote-session-scanner-sources', () => ({ remoteSessionSources: () => [{}] }))
vi.mock('./remote-session-scanner-discovery', () => ({
discoverRemoteSourceCandidates: async () => candidates()
}))
vi.mock('./remote-session-parse-cache', async (original) => ({
...(await original<typeof RemoteSessionParseCache>()),
remoteSessionParseHostKey: () => 'fixture',
parseRemoteSessionFileCached: async ({ candidate }: { candidate: { session: AiVaultSession } }) =>
candidate.session
}))
vi.mock('./session-root-dedup', async (original) => {
const actual = await original<typeof SessionDedup>()
return {
@@ -50,7 +39,6 @@ vi.mock('./session-root-dedup', async (original) => {
})
import { scanAiVaultSessions } from './session-scanner'
import { scanRemoteAiVaultSessions } from './remote-session-scanner'
import { ScannedSessionCollection, dedupeScannedSessions } from './session-root-dedup'
function candidates() {
@@ -93,62 +81,42 @@ beforeEach(() => {
fixture.visits = 0
})
for (const host of ['local', 'remote'] as const) {
const scan = (unlimited: boolean, limit?: number) =>
host === 'local'
? scanAiVaultSessions({ unlimited, limit })
: scanRemoteAiVaultSessions({
unlimited,
limit,
provider: { readDir: vi.fn(), readFile: vi.fn(), stat: vi.fn() },
executionHostId: 'local',
remoteHome: '/fixture',
hostPlatform: {
relayPlatform: 'linux-x64',
os: 'linux',
arch: 'x64',
pathFlavor: 'posix',
commandDialect: 'posix',
pathSeparator: '/',
pathDelimiter: ':'
}
})
const scan = (unlimited: boolean, limit?: number) => scanAiVaultSessions({ unlimited, limit })
it(`${host}: load-all processes deduplication linearly and retains late canonical aliases`, async () => {
fixture.sessions = Array.from({ length: 10000 }, (_, i) => session(i))
fixture.sessions[0] = {
...fixture.sessions[0]!,
codexHome: '/custom',
filePath: '/custom/rollout-0.jsonl'
}
fixture.sessions.push(session(0))
const expected = dedupeScannedSessions(fixture.sessions)
fixture.visits = 0
const started = performance.now()
const result = await scan(true)
process.stdout.write(
`${JSON.stringify({ host, candidates: fixture.sessions.length, scanMs: performance.now() - started, dedupVisits: fixture.visits })}\n`
)
expect(result.issues).toEqual([])
expect(result.sessions).toEqual(expected)
expect(fixture.visits).toBeLessThanOrEqual(fixture.sessions.length * 2)
}, 30000)
it('load-all processes deduplication linearly and retains late canonical aliases', async () => {
fixture.sessions = Array.from({ length: 10000 }, (_, i) => session(i))
fixture.sessions[0] = {
...fixture.sessions[0]!,
codexHome: '/custom',
filePath: '/custom/rollout-0.jsonl'
}
fixture.sessions.push(session(0))
const expected = dedupeScannedSessions(fixture.sessions)
fixture.visits = 0
const started = performance.now()
const result = await scan(true)
process.stdout.write(
`${JSON.stringify({ candidates: fixture.sessions.length, scanMs: performance.now() - started, dedupVisits: fixture.visits })}\n`
)
expect(result.issues).toEqual([])
expect(result.sessions).toEqual(expected)
expect(fixture.visits).toBeLessThanOrEqual(fixture.sessions.length * 2)
}, 30000)
it(`${host}: capped scans still fill the unique-session budget`, async () => {
fixture.sessions = [
session(0),
...Array.from({ length: 8 }, () => ({
...session(0),
filePath: '/custom/rollout-0.jsonl',
codexHome: '/custom'
})),
...Array.from({ length: 10 }, (_, i) => session(i + 1))
]
const result = await scan(false, 10)
expect(result.sessions).toHaveLength(10)
expect(new Set(result.sessions.map((row) => row.sessionId)).size).toBe(10)
})
}
it('capped scans still fill the unique-session budget', async () => {
fixture.sessions = [
session(0),
...Array.from({ length: 8 }, () => ({
...session(0),
filePath: '/custom/rollout-0.jsonl',
codexHome: '/custom'
})),
...Array.from({ length: 10 }, (_, i) => session(i + 1))
]
const result = await scan(false, 10)
expect(result.sessions).toHaveLength(10)
expect(new Set(result.sessions.map((row) => row.sessionId)).size).toBe(10)
})
it('incremental canonical selection preserves winner occurrence order, ties and repeated references', () => {
const collection = new ScannedSessionCollection()
@@ -4,7 +4,6 @@ import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { runProcess } from '@orca/process-host'
import { quotePosixShell } from '../../shared/wsl-login-shell-command'
import { createGitHandlerRelay } from '../../relay/git-handler-test-harness'
import { gitExecFileAsync, gitSpawnAfterWindowsEnvironmentReady } from './runner'
let root = ''
@@ -73,25 +72,6 @@ async function expectIdentity(identity: string): Promise<void> {
}
describe('network SSH configuration with real Git', () => {
it.each(['native', 'relay'] as const)(
'%s honors repository SSH wrappers on ordinary fetches',
async (host) => {
await git(['config', 'core.sshCommand', sshCommand('configured identity')])
if (host === 'native') {
await expect(gitExecFileAsync(['fetch', 'origin'], { cwd: repo, env })).rejects.toThrow()
} else {
const { dispatcher, handler } = createGitHandlerRelay()
try {
await expect(
dispatcher.callRequest('git.fetch', { worktreePath: repo })
).rejects.toThrow()
} finally {
handler.dispose()
}
}
await expectIdentity('configured identity')
}
)
it('preserves command-line SSH configuration ahead of repository configuration', async () => {
await git(['config', 'core.sshCommand', sshCommand('repository')])
@@ -104,28 +84,6 @@ describe('network SSH configuration with real Git', () => {
await expectIdentity('command-line')
})
it.each(['native', 'relay'] as const)('%s preserves an explicit SSH command', async (host) => {
await git(['config', 'core.sshCommand', sshCommand('repository')])
const command = sshCommand('environment')
if (host === 'native') {
await expect(
gitExecFileAsync(['fetch', 'origin'], {
cwd: repo,
env: { ...env, GIT_SSH_COMMAND: command }
})
).rejects.toThrow()
} else {
vi.stubEnv('GIT_SSH_COMMAND', command)
const { dispatcher, handler } = createGitHandlerRelay()
try {
await expect(dispatcher.callRequest('git.fetch', { worktreePath: repo })).rejects.toThrow()
} finally {
handler.dispose()
}
}
await expectIdentity('environment')
})
it('honors configured global SSH wrappers for streaming clones from a folder', async () => {
await git(['config', '--global', 'core.sshCommand', sshCommand('clone identity')])
const child = await gitSpawnAfterWindowsEnvironmentReady(
@@ -1,36 +1,39 @@
import { readFile, unlink, utimes, writeFile } from 'node:fs/promises'
import { readFile, rm, unlink, utimes, writeFile } from 'node:fs/promises'
import { execFileSync } from 'node:child_process'
import { mkdtempSync } from 'node:fs'
import { tmpdir } from 'node:os'
import * as path from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { runProcess } from '@orca/process-host'
import { gitCommit, gitInit, type MockDispatcher } from '../../../relay/git-handler-test-setup'
import {
createGitHandlerRelay,
createGitTempDir,
removeGitTempDir
} from '../../../relay/git-handler-test-harness'
import type { GitHandler } from '../../../relay/git-handler'
import { isMissingGitBlobPath } from '../../../shared/git-blob-absence'
import { readGitBlobAtIndexPath } from './git-blob-read'
import { getDiff } from './file-diff'
import { invalidateGitReadCaches, settledDiffCache } from './git-read-cache-invalidation'
import { readWorktreeDiffStamp } from './worktree-diff-stamp'
function gitInit(dir: string): void {
execFileSync('git', ['init'], { cwd: dir, stdio: 'pipe' })
execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: dir, stdio: 'pipe' })
execFileSync('git', ['config', 'user.name', 'Test User'], { cwd: dir, stdio: 'pipe' })
}
function gitCommit(dir: string, message: string): void {
execFileSync('git', ['add', '.'], { cwd: dir, stdio: 'pipe' })
execFileSync('git', ['commit', '-m', message, '--allow-empty'], { cwd: dir, stdio: 'pipe' })
}
describe('real Git missing paths and failed blob reads', () => {
let repo: string
let dispatcher: MockDispatcher
let handler: GitHandler
beforeEach(() => {
repo = createGitTempDir()
;({ dispatcher, handler } = createGitHandlerRelay())
repo = mkdtempSync(path.join(tmpdir(), 'orca-real-git-'))
gitInit(repo)
invalidateGitReadCaches()
})
afterEach(async () => {
handler.dispose()
invalidateGitReadCaches()
await removeGitTempDir(repo)
await rm(repo, { recursive: true, force: true })
})
async function git(args: string[], input?: string): Promise<string> {
@@ -39,75 +42,60 @@ describe('real Git missing paths and failed blob reads', () => {
return result.stdout
}
function diff(host: 'native' | 'relay', staged = false): Promise<unknown> {
return host === 'native'
? getDiff(repo, 'file.txt', staged)
: dispatcher.callRequest('git.diff', { worktreePath: repo, filePath: 'file.txt', staged })
function diff(staged = false): Promise<unknown> {
return getDiff(repo, 'file.txt', staged)
}
it.each(['native', 'relay'] as const)(
'does not substitute HEAD for a corrupt index blob on %s',
async (host) => {
await writeFile(path.join(repo, 'file.txt'), 'committed\n')
gitCommit(repo, 'initial')
await writeFile(path.join(repo, 'file.txt'), 'staged\n')
await git(['add', 'file.txt'])
const oid = (await git(['rev-parse', ':file.txt'])).trim()
await writeFile(path.join(repo, 'file.txt'), 'working\n')
const old = new Date(Date.now() - 10_000)
await Promise.all(
['.git/index', 'file.txt'].map((file) => utimes(path.join(repo, file), old, old))
)
const before = await readWorktreeDiffStamp(repo, 'file.txt', true)
await unlink(path.join(repo, '.git', 'objects', oid.slice(0, 2), oid.slice(2)))
expect(await readGitBlobAtIndexPath(repo, 'file.txt')).toMatchObject({
exists: false,
failed: true
})
it('does not substitute HEAD for a corrupt index blob', async () => {
await writeFile(path.join(repo, 'file.txt'), 'committed\n')
gitCommit(repo, 'initial')
await writeFile(path.join(repo, 'file.txt'), 'staged\n')
await git(['add', 'file.txt'])
const oid = (await git(['rev-parse', ':file.txt'])).trim()
await writeFile(path.join(repo, 'file.txt'), 'working\n')
const old = new Date(Date.now() - 10_000)
await Promise.all(
['.git/index', 'file.txt'].map((file) => utimes(path.join(repo, file), old, old))
)
const before = await readWorktreeDiffStamp(repo, 'file.txt', true)
await unlink(path.join(repo, '.git', 'objects', oid.slice(0, 2), oid.slice(2)))
expect(await readGitBlobAtIndexPath(repo, 'file.txt')).toMatchObject({
exists: false,
failed: true
})
expect(await diff(host)).toMatchObject({ originalContent: '', modifiedContent: 'working\n' })
if (host === 'native') {
expect(settledDiffCache.stats().entries).toBe(0)
}
await git(['hash-object', '-w', '--stdin'], 'staged\n')
const after = await readWorktreeDiffStamp(repo, 'file.txt', true)
expect(after?.value).toBe(before?.value)
expect(await diff()).toMatchObject({ originalContent: '', modifiedContent: 'working\n' })
expect(settledDiffCache.stats().entries).toBe(0)
await git(['hash-object', '-w', '--stdin'], 'staged\n')
const after = await readWorktreeDiffStamp(repo, 'file.txt', true)
expect(after?.value).toBe(before?.value)
expect(await diff(host)).toMatchObject({
originalContent: 'staged\n',
modifiedContent: 'working\n'
})
if (host === 'native') {
expect(settledDiffCache.stats().entries).toBe(1)
}
}
)
expect(await diff()).toMatchObject({
originalContent: 'staged\n',
modifiedContent: 'working\n'
})
expect(settledDiffCache.stats().entries).toBe(1)
})
it.each(['native', 'relay'] as const)(
'retains added paths on an unborn branch on %s',
async (host) => {
await writeFile(path.join(repo, 'file.txt'), 'new file\n')
await git(['add', 'file.txt'])
expect(await diff(host, true)).toMatchObject({
originalContent: '',
modifiedContent: 'new file\n'
})
}
)
it('retains added paths on an unborn branch', async () => {
await writeFile(path.join(repo, 'file.txt'), 'new file\n')
await git(['add', 'file.txt'])
expect(await diff(true)).toMatchObject({
originalContent: '',
modifiedContent: 'new file\n'
})
})
it.each(['native', 'relay'] as const)(
'retains additions absent from a valid HEAD tree on %s',
async (host) => {
await writeFile(path.join(repo, 'seed.txt'), 'seed\n')
gitCommit(repo, 'initial')
await writeFile(path.join(repo, 'file.txt'), 'new file\n')
await git(['add', 'file.txt'])
expect(await diff(host, true)).toMatchObject({
originalContent: '',
modifiedContent: 'new file\n'
})
}
)
it('retains additions absent from a valid HEAD tree', async () => {
await writeFile(path.join(repo, 'seed.txt'), 'seed\n')
gitCommit(repo, 'initial')
await writeFile(path.join(repo, 'file.txt'), 'new file\n')
await git(['add', 'file.txt'])
expect(await diff(true)).toMatchObject({
originalContent: '',
modifiedContent: 'new file\n'
})
})
it('recognizes actual index and tree absence without accepting corrupt objects', async () => {
await writeFile(path.join(repo, 'seed.txt'), 'seed\n')
@@ -1,103 +0,0 @@
import { readFile, writeFile } from 'node:fs/promises'
import * as path from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { runProcess } from '@orca/process-host'
import { gitCommit, gitInit, type MockDispatcher } from '../../../relay/git-handler-test-setup'
import {
createGitHandlerRelay,
createGitTempDir,
removeGitTempDir
} from '../../../relay/git-handler-test-harness'
import type { GitHandler } from '../../../relay/git-handler'
import { bulkStageFiles, bulkUnstageFiles } from './staging'
import { bulkDiscardChanges } from './discard-changes'
describe('bulk pathspec stdin on execution hosts', () => {
let repo: string
let dispatcher: MockDispatcher
let handler: GitHandler
beforeEach(() => {
repo = createGitTempDir()
;({ dispatcher, handler } = createGitHandlerRelay())
gitInit(repo)
})
afterEach(async () => {
handler.dispose()
await removeGitTempDir(repo)
})
async function changedFiles(staged: boolean): Promise<string[]> {
const result = await runProcess({
program: 'git',
args: ['diff', ...(staged ? ['--cached'] : []), '--name-only', '-z'],
cwd: repo
})
expect(result.code).toBe(0)
return result.stdout.split('\0').filter(Boolean).sort()
}
function mutate(
host: 'native' | 'relay',
action: 'stage' | 'unstage' | 'discard',
filePaths: string[]
): Promise<unknown> {
if (host === 'relay') {
const methods = {
stage: 'git.bulkStage',
unstage: 'git.bulkUnstage',
discard: 'git.bulkDiscard'
}
return dispatcher.callRequest(methods[action], { worktreePath: repo, filePaths })
}
const operations = {
stage: bulkStageFiles,
unstage: bulkUnstageFiles,
discard: bulkDiscardChanges
}
return operations[action](repo, filePaths)
}
it.each(['native', 'relay'] as const)(
'stages, unstages and discards over 100 literal paths on %s',
async (host) => {
const selected = Array.from({ length: 105 }, (_, index) => `file-${index}.txt`)
selected.push('[k]eep.log', 'space name.txt', '-option.txt')
if (process.platform !== 'win32') {
selected.push('line\nname.txt', ':(magic).txt', 'back\\slash.txt')
}
const allPaths = [...selected, 'keep.log']
await Promise.all(
allPaths.map((filePath) => writeFile(path.join(repo, filePath), 'original\n'))
)
gitCommit(repo, 'initial')
await Promise.all(
allPaths.map((filePath) => writeFile(path.join(repo, filePath), 'modified\n'))
)
await mutate(host, 'stage', [])
expect(await changedFiles(true)).toEqual([])
await mutate(host, 'stage', selected)
expect(await changedFiles(true)).toEqual([...selected].sort())
await mutate(host, 'unstage', [])
expect(await changedFiles(true)).toEqual([...selected].sort())
await mutate(host, 'unstage', selected)
expect(await changedFiles(true)).toEqual([])
await mutate(host, 'discard', [])
expect(await changedFiles(false)).toEqual([...allPaths].sort())
await mutate(host, 'discard', selected)
expect(await changedFiles(false)).toEqual(['keep.log'])
expect(await readFile(path.join(repo, 'keep.log'), 'utf8')).toBe('modified\n')
}
)
it.each(['native', 'relay'] as const)('rejects NUL path injection on %s', async (host) => {
await writeFile(path.join(repo, 'one.txt'), 'original\n')
gitCommit(repo, 'initial')
await writeFile(path.join(repo, 'one.txt'), 'modified\n')
await expect(mutate(host, 'stage', ['one.txt\0:(top)**'])).rejects.toThrow('NUL')
expect(await changedFiles(true)).toEqual([])
})
})
@@ -1,38 +1,36 @@
import { readFile, writeFile } from 'node:fs/promises'
import { readFile, rm, writeFile } from 'node:fs/promises'
import { execFileSync } from 'node:child_process'
import { mkdtempSync } from 'node:fs'
import { tmpdir } from 'node:os'
import * as path from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { runProcess } from '@orca/process-host'
import { gitCommit, gitInit, type MockDispatcher } from '../../../relay/git-handler-test-setup'
import {
createGitHandlerRelay,
createGitTempDir,
removeGitTempDir
} from '../../../relay/git-handler-test-harness'
import type { GitHandler } from '../../../relay/git-handler'
import { bulkUnstageFiles, unstageFile } from './staging'
import { bulkDiscardChanges, discardChanges } from './discard-changes'
const modes = [
{ host: 'native', bulk: false },
{ host: 'native', bulk: true },
{ host: 'relay', bulk: false },
{ host: 'relay', bulk: true }
] as const
function gitInit(dir: string): void {
execFileSync('git', ['init'], { cwd: dir, stdio: 'pipe' })
execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: dir, stdio: 'pipe' })
execFileSync('git', ['config', 'user.name', 'Test User'], { cwd: dir, stdio: 'pipe' })
}
function gitCommit(dir: string, message: string): void {
execFileSync('git', ['add', '.'], { cwd: dir, stdio: 'pipe' })
execFileSync('git', ['commit', '-m', message, '--allow-empty'], { cwd: dir, stdio: 'pipe' })
}
const modes = [{ bulk: false }, { bulk: true }] as const
describe('staging and discard preserve index authority', () => {
let repo: string
let dispatcher: MockDispatcher
let handler: GitHandler
beforeEach(() => {
repo = createGitTempDir()
;({ dispatcher, handler } = createGitHandlerRelay())
repo = mkdtempSync(path.join(tmpdir(), 'orca-real-git-'))
gitInit(repo)
})
afterEach(async () => {
handler.dispose()
await removeGitTempDir(repo)
await rm(repo, { recursive: true, force: true })
})
async function git(args: string[], input?: string): Promise<string> {
@@ -46,19 +44,13 @@ describe('staging and discard preserve index authority', () => {
action: 'unstage' | 'discard',
filePaths: string[]
): Promise<unknown> {
if (mode.host === 'relay') {
return dispatcher.callRequest(
`git.${mode.bulk ? (action === 'unstage' ? 'bulkUnstage' : 'bulkDiscard') : action}`,
{ worktreePath: repo, ...(mode.bulk ? { filePaths } : { filePath: filePaths[0] }) }
)
}
if (mode.bulk) {
return (action === 'unstage' ? bulkUnstageFiles : bulkDiscardChanges)(repo, filePaths)
}
return (action === 'unstage' ? unstageFile : discardChanges)(repo, filePaths[0])
}
it.each(modes)('preserves staged MM content during $host bulk=$bulk discard', async (mode) => {
it.each(modes)('preserves staged MM content during bulk=$bulk discard', async (mode) => {
await writeFile(path.join(repo, 'file.txt'), 'committed\n')
gitCommit(repo, 'initial')
await writeFile(path.join(repo, 'file.txt'), 'staged\n')
@@ -73,7 +65,7 @@ describe('staging and discard preserve index authority', () => {
expect(await git(['diff', '--cached', '--name-only'])).toBe('file.txt\n')
})
it.each(modes)('restores staged additions during $host bulk=$bulk AM discard', async (mode) => {
it.each(modes)('restores staged additions during bulk=$bulk AM discard', async (mode) => {
await writeFile(path.join(repo, 'initial.txt'), 'committed\n')
gitCommit(repo, 'initial')
await writeFile(path.join(repo, 'new.txt'), 'staged addition\n')
@@ -87,7 +79,7 @@ describe('staging and discard preserve index authority', () => {
expect(await git(['diff', '--name-only'])).toBe('')
})
it.each(modes)('unstages selected unborn paths on $host bulk=$bulk', async (mode) => {
it.each(modes)('unstages selected unborn paths on bulk=$bulk', async (mode) => {
await writeFile(path.join(repo, 'selected.txt'), 'selected\n')
await writeFile(path.join(repo, 'keep.txt'), 'keep\n')
await git(['add', '.'])
@@ -98,7 +90,7 @@ describe('staging and discard preserve index authority', () => {
expect(await readFile(path.join(repo, 'selected.txt'), 'utf8')).toBe('selected\n')
})
it.each(['native', 'relay'] as const)('unstages both rename paths on %s', async (host) => {
it('unstages both rename paths', async () => {
await writeFile(path.join(repo, 'old.txt'), 'rename content\n')
await writeFile(path.join(repo, 'keep.txt'), 'keep\n')
gitCommit(repo, 'initial')
@@ -106,32 +98,29 @@ describe('staging and discard preserve index authority', () => {
await writeFile(path.join(repo, 'keep.txt'), 'keep staged\n')
await git(['add', 'keep.txt'])
await mutate({ host, bulk: true }, 'unstage', ['new.txt', 'old.txt'])
await mutate({ bulk: true }, 'unstage', ['new.txt', 'old.txt'])
expect(await git(['diff', '--cached', '--name-only'])).toBe('keep.txt\n')
expect(await git(['ls-files', '-z'])).toBe('keep.txt\0old.txt\0')
expect(await readFile(path.join(repo, 'new.txt'), 'utf8')).toBe('rename content\n')
})
it.each(['native', 'relay'] as const)(
'discards a staged rename after unstaging both paths on %s',
async (host) => {
await writeFile(path.join(repo, 'old.txt'), 'rename content\n')
gitCommit(repo, 'initial')
await git(['mv', 'old.txt', 'new.txt'])
it('discards a staged rename after unstaging both paths', async () => {
await writeFile(path.join(repo, 'old.txt'), 'rename content\n')
gitCommit(repo, 'initial')
await git(['mv', 'old.txt', 'new.txt'])
await mutate({ host, bulk: true }, 'unstage', ['new.txt', 'old.txt'])
await mutate({ host, bulk: true }, 'discard', ['new.txt', 'old.txt'])
await mutate({ bulk: true }, 'unstage', ['new.txt', 'old.txt'])
await mutate({ bulk: true }, 'discard', ['new.txt', 'old.txt'])
expect(await git(['status', '--porcelain'])).toBe('')
expect(await readFile(path.join(repo, 'old.txt'), 'utf8')).toBe('rename content\n')
await expect(readFile(path.join(repo, 'new.txt'), 'utf8')).rejects.toMatchObject({
code: 'ENOENT'
})
}
)
expect(await git(['status', '--porcelain'])).toBe('')
expect(await readFile(path.join(repo, 'old.txt'), 'utf8')).toBe('rename content\n')
await expect(readFile(path.join(repo, 'new.txt'), 'utf8')).rejects.toMatchObject({
code: 'ENOENT'
})
})
it.each(modes)('rejects an unresolved conflict on $host bulk=$bulk', async (mode) => {
it.each(modes)('rejects an unresolved conflict on bulk=$bulk', async (mode) => {
await writeFile(path.join(repo, 'file.txt'), 'base\n')
gitCommit(repo, 'initial')
const blobs = await Promise.all(
@@ -4,8 +4,6 @@ import { tmpdir } from 'node:os'
import * as path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { getBranchCompare } from './status'
import { branchCompare } from '../../relay/git-handler-ops'
import { gitChangeListArgs, parseGitChangeList } from '../../shared/git-change-list'
const tempRoots: string[] = []
@@ -17,22 +15,12 @@ function git(repo: string, args: string[]): string {
}).trim()
}
function relayCompare(repo: string, baseRef: string) {
return branchCompare(
async (args, cwd) => ({ stdout: git(cwd, args), stderr: '' }),
repo,
baseRef,
async (mergeBase, headOid) =>
parseGitChangeList(git(repo, gitChangeListArgs(mergeBase, headOid)))
)
}
afterEach(async () => {
await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})
describe('getBranchCompare real refs', () => {
it('reports equal commits as empty on native and relay despite staged and unstaged changes', async () => {
it('reports equal commits as empty despite staged and unstaged changes', async () => {
const repo = await mkdtemp(path.join(tmpdir(), 'orca-equal-branch-compare-'))
tempRoots.push(repo)
git(repo, ['init', '-q'])
@@ -46,37 +34,18 @@ describe('getBranchCompare real refs', () => {
git(repo, ['add', 'changes.txt'])
await writeFile(path.join(repo, 'changes.txt'), 'unstaged\n')
for (const result of await Promise.all([
getBranchCompare(repo, 'base'),
relayCompare(repo, 'base')
])) {
expect(result).toEqual({
summary: {
baseRef: 'base',
baseOid: oid,
compareRef: 'feature',
headOid: oid,
mergeBase: oid,
changedFiles: 0,
commitsAhead: 0,
commitsBehind: 0,
status: 'ready'
},
entries: []
})
}
})
it('keeps the merge-base failure for identical blob tips on the relay', async () => {
const repo = await mkdtemp(path.join(tmpdir(), 'orca-blob-branch-compare-'))
tempRoots.push(repo)
git(repo, ['init', '-q'])
await writeFile(path.join(repo, 'blob.txt'), 'not a commit\n')
const oid = git(repo, ['hash-object', '-w', 'blob.txt'])
await writeFile(path.join(repo, '.git', 'HEAD'), `${oid}\n`)
await expect(relayCompare(repo, oid)).resolves.toMatchObject({
summary: { headOid: oid, baseOid: oid, status: 'no-merge-base', mergeBase: null },
expect(await getBranchCompare(repo, 'base')).toEqual({
summary: {
baseRef: 'base',
baseOid: oid,
compareRef: 'feature',
headOid: oid,
mergeBase: oid,
changedFiles: 0,
commitsAhead: 0,
commitsBehind: 0,
status: 'ready'
},
entries: []
})
})
@@ -109,19 +78,14 @@ describe('getBranchCompare real refs', () => {
])
expect(rawOid).not.toBe(peeledOid)
for (const result of await Promise.all([
getBranchCompare(client, 'origin/tagbase'),
relayCompare(client, 'origin/tagbase')
])) {
expect(result.summary).toMatchObject({
baseOid: rawOid,
headOid: peeledOid,
mergeBase: peeledOid,
changedFiles: 0,
commitsAhead: 0,
commitsBehind: 0,
status: 'ready'
})
}
expect((await getBranchCompare(client, 'origin/tagbase')).summary).toMatchObject({
baseOid: rawOid,
headOid: peeledOid,
mergeBase: peeledOid,
changedFiles: 0,
commitsAhead: 0,
commitsBehind: 0,
status: 'ready'
})
})
})
@@ -1,178 +0,0 @@
/**
* Main and relay must publish the same `branchLineTotal` for the same fixture
* repo. Both call sites
* share `src/shared/git-branch-line-total.ts`; this is the test that catches one
* of them wiring it up differently — different flags, a different untracked
* source, a different completeness gate.
*/
import { execFile, execFileSync } from 'node:child_process'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import * as path from 'node:path'
import { promisify } from 'node:util'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { getStatus } from './status'
import type { GitExec } from '../../relay/git-handler-ops'
import { getStatusOp } from '../../relay/git-handler-status-ops'
import type { RelayGitStreamExec } from '../../relay/git-stdout-stream'
import { invalidateGitBranchLineTotalInFlight } from '../../shared/git-branch-line-total'
import { clearGitStatusLineStatsCache } from '../../shared/git-status-line-stats-cache'
const execFileAsync = promisify(execFile)
// Fork point → working tree for the fixture below:
// tracked.txt +2 (branch commit only)
// partial.txt +1 (staged +2 then one of those lines removed unstaged)
// flip.txt 0 (added in the branch commit, removed again in the worktree)
// moved.txt 0 (pure rename)
// fresh.txt +3 (untracked)
// No fixture path here looks like test or generated code, so it is all source.
const EXPECTED_TOTAL = {
added: 6,
removed: 0,
test: { added: 0, removed: 0 },
generated: { added: 0, removed: 0 }
}
// Summing the per-area status rows instead would give this — the wrong answer
// the shared module exists to avoid.
const AREA_ROW_SUM = { added: 5, removed: 2 }
const relayGit: GitExec = async (args, cwd, opts) => {
const { stdout, stderr } = await execFileAsync('git', args, {
cwd,
encoding: 'utf8',
...(opts?.signal ? { signal: opts.signal } : {}),
...(opts?.timeout ? { timeout: opts.timeout } : {})
})
return { stdout, stderr }
}
const relayStreamGit: RelayGitStreamExec = async (args, cwd, options) => {
const { stdout } = await relayGit(args, cwd, {
disableOptionalLocks: options.disableOptionalLocks,
signal: options.signal
})
return { stoppedEarly: options.onStdout(stdout) === true }
}
function runFixtureGit(repo: string, args: string[]): string {
return execFileSync(
'git',
[
'-c',
'user.email=test@test.com',
'-c',
'user.name=Test',
'-c',
'commit.gpgSign=false',
...args
],
{ cwd: repo, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }
).trim()
}
/** Returns the merge-base OID the chip is measured against. */
async function seedParityFixture(repo: string): Promise<string> {
execFileSync('git', ['init', '-q', repo], { stdio: 'pipe' })
await writeFile(path.join(repo, 'tracked.txt'), 'a\nb\n')
await writeFile(path.join(repo, 'partial.txt'), 'one\ntwo\nthree\n')
await writeFile(path.join(repo, 'renamed.txt'), 'stable\n')
await writeFile(path.join(repo, 'flip.txt'), 'p\n')
runFixtureGit(repo, ['add', '.'])
runFixtureGit(repo, ['commit', '-m', 'base'])
const mergeBase = runFixtureGit(repo, ['rev-parse', 'HEAD'])
runFixtureGit(repo, ['checkout', '-q', '-b', 'feature'])
await writeFile(path.join(repo, 'tracked.txt'), 'a\nb\nc\nd\n')
await writeFile(path.join(repo, 'flip.txt'), 'p\nq\n')
runFixtureGit(repo, ['add', '-A'])
runFixtureGit(repo, ['commit', '-m', 'branch commit'])
runFixtureGit(repo, ['mv', 'renamed.txt', 'moved.txt'])
// Staged and unstaged hunks land on the same added lines, so an area sum
// double-counts them.
await writeFile(path.join(repo, 'partial.txt'), 'one\ntwo\nthree\nfoo\nbaz\n')
runFixtureGit(repo, ['add', 'partial.txt'])
await writeFile(path.join(repo, 'partial.txt'), 'one\ntwo\nthree\nfoo\n')
// The branch commit's line, taken back out in the worktree: net zero.
await writeFile(path.join(repo, 'flip.txt'), 'p\n')
await writeFile(path.join(repo, 'fresh.txt'), 'n1\nn2\nn3\n')
return mergeBase
}
function sumAreaRows(entries: readonly { added?: number; removed?: number }[]): {
added: number
removed: number
} {
let added = 0
let removed = 0
for (const entry of entries) {
added += entry.added ?? 0
removed += entry.removed ?? 0
}
return { added, removed }
}
describe('branch line total parity between main and relay', () => {
let repo: string
beforeEach(async () => {
clearGitStatusLineStatsCache()
invalidateGitBranchLineTotalInFlight()
repo = await mkdtemp(path.join(tmpdir(), 'branch-line-total-parity-'))
})
afterEach(async () => {
clearGitStatusLineStatsCache()
invalidateGitBranchLineTotalInFlight()
await rm(repo, { recursive: true, force: true })
})
it('produces identical totals for the same fixture repo', async () => {
const mergeBase = await seedParityFixture(repo)
const mainStatus = await getStatus(repo, { branchLineTotalMergeBase: mergeBase })
clearGitStatusLineStatsCache()
const relayStatus = await getStatusOp(relayGit, relayStreamGit, {
worktreePath: repo,
branchLineTotalMergeBase: mergeBase
})
expect(mainStatus.branchLineTotal).toEqual({ ...EXPECTED_TOTAL, mergeBase })
expect(relayStatus.branchLineTotal).toEqual(mainStatus.branchLineTotal)
// Both sides model the same worktree, so a parity pass on a mismatched
// entry list would be meaningless.
expect(relayStatus.entries.map((entry) => entry.path).sort()).toEqual(
mainStatus.entries.map((entry) => entry.path).sort()
)
})
it('agrees on a number no per-area row sum could produce', async () => {
const mergeBase = await seedParityFixture(repo)
const mainStatus = await getStatus(repo, { branchLineTotalMergeBase: mergeBase })
clearGitStatusLineStatsCache()
const relayStatus = await getStatusOp(relayGit, relayStreamGit, {
worktreePath: repo,
branchLineTotalMergeBase: mergeBase
})
expect(sumAreaRows(mainStatus.entries)).toEqual(AREA_ROW_SUM)
expect(sumAreaRows(relayStatus.entries as { added?: number; removed?: number }[])).toEqual(
AREA_ROW_SUM
)
expect(mainStatus.branchLineTotal).not.toMatchObject(AREA_ROW_SUM)
expect(relayStatus.branchLineTotal).toEqual(mainStatus.branchLineTotal)
})
it('omits the total on both sides when no merge base is requested', async () => {
await seedParityFixture(repo)
const mainStatus = await getStatus(repo)
clearGitStatusLineStatsCache()
const relayStatus = await getStatusOp(relayGit, relayStreamGit, { worktreePath: repo })
expect(Object.hasOwn(mainStatus, 'branchLineTotal')).toBe(false)
expect(Object.hasOwn(relayStatus, 'branchLineTotal')).toBe(false)
})
})
@@ -1,250 +0,0 @@
import { performance } from 'node:perf_hooks'
import { join } from 'node:path'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type * as NodeFsPromises from 'node:fs/promises'
import type * as GitRunner from './runner'
import type { GitExec } from '../../relay/git-handler-ops'
import type { RelayGitStreamExec } from '../../relay/git-stdout-stream'
const { gitStreamStdoutMock, readFileMock } = vi.hoisted(() => ({
gitStreamStdoutMock: vi.fn(),
readFileMock: vi.fn()
}))
vi.mock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof NodeFsPromises>()
return { ...actual, readFile: readFileMock }
})
vi.mock('./runner', async (importOriginal) => {
const actual = await importOriginal<typeof GitRunner>()
return { ...actual, gitStreamStdout: gitStreamStdoutMock }
})
import { getStatus } from './status'
import { getStatusOp } from '../../relay/git-handler-status-ops'
const BENCH_DELAY_MS = 25
const BENCH_SAMPLES = 31
const BENCH_WARMUPS = 5
const describeBench = process.env.ORCA_GIT_STATUS_OVERLAP_BENCH === '1' ? describe : describe.skip
type Deferred<T> = {
promise: Promise<T>
resolve: (value: T) => void
}
type BenchmarkResult = {
path: string
medianMs: number
p95Ms: number
minMs: number
maxMs: number
}
function deferred<T>(): Deferred<T> {
let resolve!: (value: T) => void
const promise = new Promise<T>((innerResolve) => {
resolve = innerResolve
})
return { promise, resolve }
}
function wait(delayMs: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, delayMs))
}
function percentile(samples: number[], fraction: number): number {
const sorted = [...samples].sort((a, b) => a - b)
return sorted[Math.ceil(sorted.length * fraction) - 1]!
}
describe('git status conflict-read overlap', () => {
const relayGit: GitExec = async () => ({ stdout: '', stderr: '' })
beforeEach(() => {
readFileMock.mockReset()
gitStreamStdoutMock.mockReset()
readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n')
gitStreamStdoutMock.mockResolvedValue({ stoppedEarly: false })
})
it('starts native status before conflict-marker I/O settles', async () => {
const markerRead = deferred<string>()
const statusStarted = deferred<void>()
readFileMock.mockReturnValue(markerRead.promise)
gitStreamStdoutMock.mockImplementation(async () => {
statusStarted.resolve()
return { stoppedEarly: false }
})
const resultPromise = getStatus('/repo')
await statusStarted.promise
expect(readFileMock).toHaveBeenCalledWith(join('/repo', '.git'), 'utf-8')
markerRead.resolve('gitdir: /repo/.git/worktrees/feature\n')
await expect(resultPromise).resolves.toMatchObject({
entries: [],
conflictOperation: 'unknown'
})
})
it('starts relay status before conflict-marker I/O settles', async () => {
const markerRead = deferred<string>()
const statusStarted = deferred<void>()
readFileMock.mockReturnValue(markerRead.promise)
const relayStreamGit: RelayGitStreamExec = async () => {
statusStarted.resolve()
return { stoppedEarly: false }
}
const resultPromise = getStatusOp(relayGit, relayStreamGit, { worktreePath: '/repo' })
await statusStarted.promise
expect(readFileMock).toHaveBeenCalledWith(join('/repo', '.git'), 'utf-8')
markerRead.resolve('gitdir: /repo/.git/worktrees/feature\n')
await expect(resultPromise).resolves.toMatchObject({
entries: [],
conflictOperation: 'unknown'
})
})
it('observes an early native status rejection while marker I/O remains pending', async () => {
const markerRead = deferred<string>()
readFileMock.mockReturnValue(markerRead.promise)
gitStreamStdoutMock.mockRejectedValue(new Error('status failed first'))
let settled = false
const resultPromise = getStatus('/repo').finally(() => {
settled = true
})
await Promise.resolve()
await Promise.resolve()
expect(settled).toBe(false)
markerRead.resolve('gitdir: /repo/.git/worktrees/feature\n')
await expect(resultPromise).resolves.toMatchObject({
entries: [],
conflictOperation: 'unknown'
})
})
it('keeps a synchronous native status failure fail-soft', async () => {
const statusError = new Error('status threw')
gitStreamStdoutMock.mockImplementation(() => {
throw statusError
})
await expect(getStatus('/repo')).resolves.toMatchObject({
entries: [],
conflictOperation: 'unknown'
})
})
it.each(['throw', 'reject'] as const)('keeps relay %s failures fail-soft', async (mode) => {
const statusError = new Error(`status ${mode}`)
const relayStreamGit = (() => {
if (mode === 'throw') {
throw statusError
}
return Promise.reject(statusError)
}) as RelayGitStreamExec
await expect(
getStatusOp(relayGit, relayStreamGit, { worktreePath: '/repo' })
).resolves.toMatchObject({ entries: [], conflictOperation: 'unknown' })
})
it('rethrows the original relay status failure after cancellation', async () => {
const controller = new AbortController()
const statusError = new Error('cancelled status')
const relayStreamGit: RelayGitStreamExec = async () => {
controller.abort(statusError)
throw statusError
}
await expect(
getStatusOp(
relayGit,
relayStreamGit,
{ worktreePath: '/repo' },
{ signal: controller.signal }
)
).rejects.toBe(statusError)
})
it('surfaces detector errors without waiting for a hung status read', async () => {
const statusStarted = deferred<void>()
const relayStreamGit: RelayGitStreamExec = () => {
statusStarted.resolve()
return new Promise(() => {})
}
const invalidPath = { toString: () => '/repo' }
const resultPromise = getStatusOp(relayGit, relayStreamGit, {
worktreePath: invalidPath
})
const rejection = expect(resultPromise).rejects.toThrow(TypeError)
await statusStarted.promise
await rejection
})
it('keeps detector errors ahead of concurrent status failures', async () => {
const statusError = new Error('status failed too')
const relayStreamGit = (() => {
throw statusError
}) as RelayGitStreamExec
const invalidPath = { toString: () => '/repo' }
const result = getStatusOp(relayGit, relayStreamGit, { worktreePath: invalidPath })
await expect(result).rejects.toBeInstanceOf(TypeError)
})
})
describeBench('git status conflict-read overlap benchmark', () => {
it('measures native and relay orchestration with matched independent latency', async () => {
readFileMock.mockImplementation(async () => {
await wait(BENCH_DELAY_MS)
return 'gitdir: /repo/.git/worktrees/feature\n'
})
gitStreamStdoutMock.mockImplementation(async () => {
await wait(BENCH_DELAY_MS)
return { stoppedEarly: false }
})
const relayGit: GitExec = async () => ({ stdout: '', stderr: '' })
const relayStreamGit: RelayGitStreamExec = async () => {
await wait(BENCH_DELAY_MS)
return { stoppedEarly: false }
}
const cases = [
{ name: 'native', run: () => getStatus('/repo') },
{
name: 'relay',
run: () => getStatusOp(relayGit, relayStreamGit, { worktreePath: '/repo' })
}
]
const results: BenchmarkResult[] = []
for (const benchmarkCase of cases) {
for (let index = 0; index < BENCH_WARMUPS; index += 1) {
await benchmarkCase.run()
}
const samples: number[] = []
for (let index = 0; index < BENCH_SAMPLES; index += 1) {
const startedAt = performance.now()
await benchmarkCase.run()
samples.push(performance.now() - startedAt)
}
results.push({
path: benchmarkCase.name,
medianMs: Number(percentile(samples, 0.5).toFixed(3)),
p95Ms: Number(percentile(samples, 0.95).toFixed(3)),
minMs: Number(Math.min(...samples).toFixed(3)),
maxMs: Number(Math.max(...samples).toFixed(3))
})
}
console.table(results)
expect(results).toHaveLength(2)
}, 10_000)
})
-1
View File
@@ -253,7 +253,6 @@ async function performAddWorktree(
await persistWorktreeCreationBase(worktreePath, branch, effectiveBase, options)
}
// SSH parity: relay's addWorktreeOp (src/relay/git-handler-worktree-ops.ts) mirrors this — change both in lockstep.
// Why: --no-track leaves no upstream until first push; push.autoSetupRemote=true lets a plain
// `git push` create+set origin/<branch> (git >=2.37; older clients ignore it). `--local` on a
// linked worktree writes the shared common-dir config (whole repo) — intentional and idempotent,
@@ -10,7 +10,6 @@ vi.mock('./local-repo-ref-maintenance', () => ({
}))
import { forceDeleteLocalBranch } from './worktree-branch-removal'
import { forceDeletePreservedRelayBranch } from '../../relay/git-handler-branch-cleanup'
const image = process.env.ORCA_GIT_COMPAT_IMAGE
const binary = process.env.ORCA_GIT_COMPAT_BINARY ?? 'git'
@@ -82,10 +81,10 @@ afterEach(async () => {
})
describe('rebase branch reservations with real Git', () => {
it.for(['native', 'relay'] as const)(
it.for(['native'] as const)(
'%s retains auxiliary branches reserved by a paused real rebase --update-refs',
{ timeout: 120_000 },
async (host, context) => {
async (_host, context) => {
await git(['worktree', 'add', '-q', '-b', 'feature', checkout])
await writeFile(join(checkout, 'first'), 'first\n')
await git(['add', 'first'], checkout)
@@ -117,10 +116,7 @@ describe('rebase branch reservations with real Git', () => {
/checked out|used by worktree/
)
const cleanupGit = vi.fn((args: string[], cwd: string) => git(args, cwd))
const remove = () =>
host === 'native'
? forceDeleteLocalBranch(repo, 'auxiliary', expected, cleanupGit)
: forceDeletePreservedRelayBranch(cleanupGit, repo, 'auxiliary', expected)
const remove = () => forceDeleteLocalBranch(repo, 'auxiliary', expected, cleanupGit)
await expect(remove()).rejects.toThrow('checked out in another worktree')
expect(cleanupGit.mock.calls.map(([args]) => args)).toEqual([
['worktree', 'list', '--porcelain']
+1 -164
View File
@@ -1,14 +1,9 @@
import { execFile } from 'node:child_process'
import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'
import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { promisify } from 'node:util'
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { GitCapabilityCache } from '../../shared/git-capability-cache'
import { isWorktreeCreatePreparation } from '../../shared/worktree/create-preparation'
import { parseWorktreeList } from '../../shared/git-worktree-porcelain-parser'
import { annotateWorktreeLocksFromAdmin } from '../../shared/git-worktree-admin'
import type { GitExec } from '../../relay/git-handler-ops'
const { runner } = vi.hoisted(() => ({ runner: vi.fn() }))
vi.mock('./runner', () => ({
@@ -18,14 +13,8 @@ vi.mock('./runner', () => ({
}))
vi.mock('./status', () => ({ runWithGitReadCacheInvalidation: (run: () => unknown) => run() }))
import { removeWorktree } from './worktree-removal'
import { forceDeleteLocalBranch } from './worktree-branch-removal'
import { readWorktreeList } from './worktree-list-reader'
import { getBranchConflictKind, getBranchConflictKindViaExec } from './repo-branch-conflict'
import { clearGitCapabilityStateForTests } from './git-capability-state'
import { removeWorktreeOp } from '../../relay/git-handler-worktree-remove'
import { forceDeletePreservedRelayBranch } from '../../relay/git-handler-branch-cleanup'
import { readRelayWorktreeList } from '../../relay/git-handler-worktree-list'
const execFileAsync = promisify(execFile)
const image = process.env.ORCA_GIT_COMPAT_IMAGE
@@ -37,7 +26,6 @@ const dockerUser =
: []
let root = ''
let repo = ''
let checkout = ''
async function git(args: string[], cwd = repo): Promise<{ stdout: string; stderr: string }> {
return image
@@ -66,8 +54,6 @@ async function git(args: string[], cwd = repo): Promise<{ stdout: string; stderr
})
}
const relay: GitExec = (args, cwd) => git(args, cwd)
async function initializeRepo(cwd: string): Promise<void> {
await mkdir(cwd, { recursive: true })
await git(['init', '-q', '-b', 'main'], cwd).catch(() => git(['init', '-q'], cwd))
@@ -92,7 +78,6 @@ beforeAll(async () => {
beforeEach(async () => {
root = await realpath(await mkdtemp(join(tmpdir(), 'orca-worktree-safety-')))
repo = join(root, 'repo')
checkout = join(root, 'checkout')
await initializeRepo(repo)
clearGitCapabilityStateForTests()
runner.mockImplementation((args: string[], options: { cwd?: string }) => git(args, options.cwd))
@@ -103,99 +88,6 @@ afterEach(async () => {
})
describe('worktree safety with the real Git binary', () => {
it.each(['native', 'relay'] as const)(
'%s preserves unpublished submodule objects without implicit force',
async (host) => {
const origin = join(root, 'sub-origin')
await initializeRepo(origin)
await git(['-c', 'protocol.file.allow=always', 'submodule', 'add', origin, 'sub'])
await git(['commit', '-qam', 'submodule'])
await git(['worktree', 'add', '-q', '-b', 'feature', checkout])
await git(['-c', 'protocol.file.allow=always', 'submodule', 'update', '--init'], checkout)
const sub = join(checkout, 'sub')
await git(['checkout', '-qb', 'unpublished'], sub)
await writeFile(join(sub, 'local'), 'unpublished work\n')
await git(['add', 'local'], sub)
await git(
[
'-c',
'user.name=Safety',
'-c',
'user.email=safety@example.invalid',
'-c',
'commit.gpgSign=false',
'commit',
'-qm',
'local'
],
sub
)
const local = (await git(['rev-parse', 'HEAD'], sub)).stdout.trim()
await git(['checkout', '--detach', 'HEAD~1'], sub)
expect(
(await git(['status', '--porcelain', '--ignore-submodules=none'], checkout)).stdout
).toBe('')
const remove = (force: boolean) =>
host === 'native'
? removeWorktree(repo, checkout, force, {
deleteBranch: false,
knownRemovedWorktree: { branch: 'refs/heads/feature', head: '', locked: false }
})
: removeWorktreeOp(
relay,
{ worktreePath: checkout, force, deleteBranch: false },
new GitCapabilityCache()
)
await expect(remove(false)).rejects.toThrow(/submodules cannot be moved or removed/)
expect((await git(['cat-file', '-t', local], sub)).stdout.trim()).toBe('commit')
expect((await git(['worktree', 'list', '--porcelain'])).stdout).toContain(checkout)
await expect(remove(true)).resolves.toEqual({})
await expect(readFile(join(checkout, '.git'))).rejects.toThrow()
},
120_000
)
it.each(['native', 'relay'] as const)(
'%s reads preparation ownership from admin metadata on old porcelain',
async (host) => {
await git(['worktree', 'add', '-q', '--detach', checkout])
const reason = 'orca-create-preparation:v1:12345:retained'
await git(['worktree', 'lock', '--reason', reason, checkout])
const oldList = (await git(['worktree', 'list', '--porcelain'])).stdout
.split('\n')
.filter((line) => !line.startsWith('locked'))
.join('\n')
const capabilities = new GitCapabilityCache()
capabilities.rememberUnsupported('worktree-list-z')
runner.mockImplementation(async (args: string[], options: { cwd?: string }) => {
if (args[0] === 'worktree' && args[1] === 'list') {
if (args.includes('-z')) {
throw Object.assign(new Error("unknown switch `z'"), {
code: 129,
stderr: "error: unknown switch `z'"
})
}
return { stdout: oldList, stderr: '' }
}
return git(args, options.cwd)
})
const entries =
host === 'native'
? await readWorktreeList(repo)
: await readRelayWorktreeList((args, cwd) => runner(args, { cwd }), repo, capabilities)
const prepared = entries.find((entry) => entry.path === checkout)
expect(prepared).toMatchObject({ locked: true, lockReason: reason })
expect(prepared && isWorktreeCreatePreparation(prepared)).toBe(true)
await rm(checkout, { recursive: true })
const annotated = await annotateWorktreeLocksFromAdmin(repo, parseWorktreeList(oldList))
expect(annotated.find((entry) => entry.path === checkout)).toMatchObject({
locked: true,
lockReason: reason
})
},
90_000
)
it.each([
['feature', 'Feature'],
@@ -226,59 +118,4 @@ describe('worktree safety with the real Git binary', () => {
90_000
)
it.each(['native', 'relay'] as const)(
'%s retains detached branches reserved by rebase or bisect',
async (host) => {
await git(['worktree', 'add', '-q', '-b', 'feature', checkout])
const expected = (await git(['rev-parse', 'refs/heads/feature'])).stdout.trim()
await git(['checkout', '--detach'], checkout)
const gitDir = (await git(['rev-parse', '--absolute-git-dir'], checkout)).stdout.trim()
const remove = () =>
host === 'native'
? forceDeleteLocalBranch(repo, 'feature', expected, git)
: forceDeletePreservedRelayBranch(relay, repo, 'feature', expected)
for (const marker of ['rebase-merge/head-name', 'rebase-apply/head-name', 'BISECT_START']) {
const file = join(gitDir, ...marker.split('/'))
await mkdir(join(file, '..'), { recursive: true })
await writeFile(file, marker === 'BISECT_START' ? 'feature\n' : 'refs/heads/feature\n')
if (marker === 'BISECT_START') {
await writeFile(join(gitDir, 'BISECT_LOG'), '')
}
await expect(git(['branch', '-D', 'feature'])).rejects.toThrow(
/checked out|used by worktree/
)
await expect(remove()).rejects.toThrow('checked out in another worktree')
expect((await git(['rev-parse', 'refs/heads/feature'])).stdout.trim()).toBe(expected)
await rm(file)
if (marker === 'BISECT_START') {
await rm(join(gitDir, 'BISECT_LOG'))
}
}
await expect(remove()).resolves.toBeUndefined()
},
120_000
)
it.each(['native', 'relay'] as const)(
'%s restores a deleted ref when detached branch use starts during deletion',
async (host) => {
await git(['worktree', 'add', '-q', '-b', 'feature', checkout])
const expected = (await git(['rev-parse', 'refs/heads/feature'])).stdout.trim()
await git(['checkout', '--detach'], checkout)
const gitDir = (await git(['rev-parse', '--absolute-git-dir'], checkout)).stdout.trim()
const racingGit = async (args: string[], cwd: string) => {
const result = await git(args, cwd)
if (args[0] === 'update-ref' && args[1] === '-d') {
await writeFile(join(gitDir, 'BISECT_START'), 'feature\n')
}
return result
}
const remove = () =>
host === 'native'
? forceDeleteLocalBranch(repo, 'feature', expected, racingGit)
: forceDeletePreservedRelayBranch(racingGit, repo, 'feature', expected)
await expect(remove()).rejects.toThrow('checked out in another worktree')
expect((await git(['rev-parse', 'refs/heads/feature'])).stdout.trim()).toBe(expected)
},
90_000
)
})
@@ -3,7 +3,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { parseDocument } from 'yaml'
import { createManagedHookLocalFilesystem } from '../agent-hooks/managed-hook-local-filesystem'
import { createManagedHookLocalFilesystem } from '../agent-hooks/managed-hook-local-filesystem.test-fixture'
import { disablePlugin, enablePlugin, updateConfigContent } from './hermes-config-yaml'
import { HermesHookService } from './hook-service'
@@ -13,7 +13,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { parse } from 'yaml'
import { createManagedHookLocalFilesystem } from '../agent-hooks/managed-hook-local-filesystem'
import { createManagedHookLocalFilesystem } from '../agent-hooks/managed-hook-local-filesystem.test-fixture'
import { HermesHookService } from './hook-service'
describe('Hermes remote installer through a local filesystem adapter (no SSH)', () => {
@@ -2,22 +2,17 @@ import { mkdtemp, mkdir, writeFile, symlink, rm } from 'node:fs/promises'
import { writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, expect, it } from 'vitest'
import { afterEach, expect, it } from 'vitest'
import type { Store } from '../persistence'
import { listQuickOpenFiles } from './filesystem-list-files'
import { listFilesWithRg } from '../../relay/fs-handler-list-files'
import { searchQuickOpenFilePaths } from './filesystem-search-file-paths'
import { bundledRipgrepCommand } from '../ripgrep/bundled-ripgrep-path'
import { configureRelayBundledRipgrep } from '../../relay/relay-bundled-ripgrep'
const fixtures: string[] = []
beforeEach(() => configureRelayBundledRipgrep(bundledRipgrepCommand()))
afterEach(async () => {
configureRelayBundledRipgrep(undefined)
await Promise.all(fixtures.splice(0).map((path) => rm(path, { recursive: true, force: true })))
})
it('honors inherited ignores, opt-in links, cycles, retargets and relay parity in real processes', async () => {
it('honors inherited ignores, opt-in links, cycles, and retargets in real processes', async () => {
const parent = await mkdtemp(join(tmpdir(), 'orca-quick-open-options-'))
fixtures.push(parent)
await mkdir(join(parent, '.git'))
@@ -53,8 +48,6 @@ it('honors inherited ignores, opt-in links, cycles, retargets and relay parity i
undefined,
options
)
const relay = await listFilesWithRg(root, [], options)
expect(local.sort()).toEqual(relay.sort())
expect(local.includes('ignored.txt')).toBe(options.includeIgnored)
expect(local.includes('linked/linked.md')).toBe(options.followSymlinks)
expect(local).not.toContain('cycle/apps/api/.env')
@@ -122,13 +115,6 @@ it('validates recent membership independently of top32, ignores, exclusions and
getSettings: () => ({}),
getFolderWorkspaces: () => []
} as unknown as Store
const top = await listFilesWithRg(root, [], {
searchQuery: 'file',
maxResults: 32,
includeIgnored: false
})
expect(top).toHaveLength(32)
expect(top).not.toContain('src/file059.ts')
const candidates = [
'src/file059.ts',
'deleted.ts',
@@ -148,17 +134,17 @@ it('validates recent membership independently of top32, ignores, exclusions and
undefined,
options
)
const relay = await listFilesWithRg(root, ['excluded'], {
...options,
maxResults: candidates.length
})
expect(local).toEqual(['src/file059.ts'])
expect(relay).toEqual(local)
const broad = await listFilesWithRg(root, ['excluded'], {
...options,
includeIgnored: true,
maxResults: candidates.length
})
const broad = await listQuickOpenFiles(
root,
store,
[join(root, 'excluded')],
undefined,
candidates.length,
undefined,
undefined,
{ ...options, includeIgnored: true }
)
expect(broad.sort()).toEqual(['ignored.ts', 'src/file059.ts'])
await mkdir(join(root, 'large'))
for (let index = 0; index < 20_020; index += 1) {
@@ -180,11 +166,4 @@ it('validates recent membership independently of top32, ignores, exclusions and
includeIgnored: false
})
).toEqual([missing])
expect(
await listFilesWithRg(root, [], {
candidatePaths: [missing],
includeIgnored: false,
maxResults: 1
})
).toEqual([missing])
}, 60_000)
@@ -1,5 +1,4 @@
import { describe, expect, it, vi, type Mock } from 'vitest'
import { validateGitExecArgs } from '../../relay/git-exec-validator'
import type { WorktreeMeta } from '../../shared/worktree/meta-types'
import type { GitPushTarget } from '../../shared/worktree/types'
import {
@@ -84,20 +83,6 @@ describe('cleanupUnusedWorktreePushTargetRemoteWithExec', () => {
expect(removeCalls(exec)).toEqual([['remote', 'remove', FORK_REMOTE]])
})
it('sends only argv the relay accepts, so SSH cleanup is not silently skipped', async () => {
const exec = makeExec()
await cleanupUnusedWorktreePushTargetRemoteWithExec(
REPO_PATH,
'repo-1::/wt/a',
forkTarget(),
storeOf({ 'repo-1::/wt/a': forkTarget() }),
exec
)
for (const [args] of exec.mock.calls) {
expect(() => validateGitExecArgs(args)).not.toThrow()
}
})
it('keeps a remote Orca did not create (remoteCreated falsy)', async () => {
const exec = makeExec()
await cleanupUnusedWorktreePushTargetRemoteWithExec(
@@ -1,5 +1,4 @@
import { describe, expect, it, vi, type Mock } from 'vitest'
import { validateGitExecArgs } from '../../relay/git-exec-validator'
import type { WorktreeMeta } from '../../shared/worktree/meta-types'
import type { GitPushTarget } from '../../shared/worktree/types'
import type { GitRemoteExec, WorktreePushTargetStore } from './worktree-push-target-cleanup'
@@ -234,25 +233,6 @@ describe('reconcileOrphanedPrRemotesWithExec', () => {
expect(reclaimed).toEqual([])
})
it('sends only argv the relay accepts, so the sweep is not silently skipped over SSH', async () => {
// Exercise every branch (config probe, for-each-ref probe, and the reclaim itself).
const exec = makeExec({
remotes: remoteLines([{ name: FORK_REMOTE, url: FORK_URL }]),
branchConfig: `branch.contributor/fix.remote ${FORK_REMOTE}`,
localBranches: 'main'
})
await reconcileOrphanedPrRemotesWithExec(
REPO_PATH,
REPO_ID,
storeOf({ [worktreeId('/wt/gone')]: forkTarget() }),
exec,
[]
)
expect(exec.mock.calls.length).toBeGreaterThan(0)
for (const [args] of exec.mock.calls) {
expect(() => validateGitExecArgs(args)).not.toThrow()
}
})
})
describe('reconcileOrphanedPrRemotes rate limiting', () => {
@@ -1,469 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { validateGitExecArgs } from '../../relay/git-exec-validator'
import {
getSshGitProviderMock,
getActiveMultiplexerMock,
removeWorktreeMock,
forceDeleteLocalBranchMock,
gitExecFileAsyncMock
} from './worktrees-test-module-mocks'
import { handlers, setupWorktreeHandlers, store } from './worktrees-test-harness'
import { materializeWorktreePushTargetRemoteSsh } from './worktree-remote'
import type { SshGitProvider } from '../providers/ssh-git-provider'
import { makeWorktreeMeta, mockKnownFeatureWorktree } from './worktrees-test-fixtures'
vi.mock('electron', async () =>
(await import('./worktrees-test-module-mocks')).electronModuleMock()
)
vi.mock('../git/worktree', async () =>
(await import('./worktrees-test-module-mocks')).gitWorktreeModuleMock()
)
vi.mock('../git/runner', async () =>
(await import('./worktrees-test-module-mocks')).gitRunnerModuleMock()
)
vi.mock('../git/repo', async () =>
(await import('./worktrees-test-module-mocks')).gitRepoModuleMock()
)
vi.mock('../git/git-username', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
resolveLocalGitUsername: (await import('./worktrees-test-module-mocks'))
.resolveLocalGitUsernameMock
}))
vi.mock('../github/client', async () =>
(await import('./worktrees-test-module-mocks')).githubClientModuleMock()
)
vi.mock('../source-control/hosted-review', async () =>
(await import('./worktrees-test-module-mocks')).hostedReviewModuleMock()
)
vi.mock('../providers/ssh-git-dispatch', async () =>
(await import('./worktrees-test-module-mocks')).sshGitDispatchModuleMock()
)
vi.mock('../providers/ssh-filesystem-dispatch', async () =>
(await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock()
)
vi.mock('./worktree-symlinks', async () =>
(await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock()
)
vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock())
vi.mock('../ssh/ssh-target-registry', async () =>
(await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock()
)
vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock())
vi.mock('../setup-runner-script-text', async (importOriginal) =>
(await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock(
(await importOriginal()) as Record<string, unknown>
)
)
vi.mock('../worktree-runner-script', async (importOriginal) =>
(await import('./worktrees-test-module-mocks')).worktreeRunnerScriptModuleMock(
(await importOriginal()) as Record<string, unknown>
)
)
vi.mock('../effective-hook-config', async (importOriginal) =>
(await import('./worktrees-test-module-mocks')).effectiveHookConfigModuleMock(
(await importOriginal()) as Record<string, unknown>
)
)
vi.mock('../setup-hook-env-vars', async (importOriginal) =>
(await import('./worktrees-test-module-mocks')).setupHookEnvVarsModuleMock(
(await importOriginal()) as Record<string, unknown>
)
)
vi.mock('./worktree-logic', async (importOriginal) =>
(await import('./worktrees-test-module-mocks')).worktreeLogicModuleMock(
(await importOriginal()) as Record<string, unknown>
)
)
vi.mock('../terminal-history-deletion', async () =>
(await import('./worktrees-test-module-mocks')).terminalHistoryDeletionModuleMock()
)
vi.mock('../ports/advertised-url-watcher', async () =>
(await import('./worktrees-test-module-mocks')).advertisedUrlWatcherModuleMock()
)
vi.mock('../workspace-cleanup-scan-snapshot', async () =>
(await import('./worktrees-test-module-mocks')).workspaceCleanupScanSnapshotModuleMock()
)
vi.mock('../workspace-space-analysis-snapshot', async () =>
(await import('./worktrees-test-module-mocks')).workspaceSpaceAnalysisSnapshotModuleMock()
)
vi.mock('../workspace-cleanup-removal-snapshot-prune', async () =>
(await import('./worktrees-test-module-mocks')).workspaceCleanupRemovalSnapshotPruneModuleMock()
)
vi.mock('../runtime/worktree-teardown', async () =>
(await import('./worktrees-test-module-mocks')).worktreeTeardownModuleMock()
)
vi.mock('./pty', async () => (await import('./worktrees-test-module-mocks')).ptyModuleMock())
describe('registerWorktreeHandlers', () => {
beforeEach(() => {
setupWorktreeHandlers()
})
// Was "adds the fork remote ... through git.exec": create used to mint the fork
// remote unconditionally. It now defers to first sync (#17828) -- split in two so
// each half stays true to a single claim: create stays a no-op, sync still mints.
it('defers minting the fork remote for an SSH fork-PR worktree until first sync', async () => {
const repo = {
id: 'repo-ssh',
path: '/remote/repo',
displayName: 'ssh',
badgeColor: '#000',
addedAt: 0,
connectionId: 'conn-1',
worktreeBaseRef: 'origin/main'
}
// Why: run every exec through the relay's own validator so a create shape the
// relay rejects fails here instead of on the user's SSH host.
const exec = vi.fn().mockImplementation(async (args: string[]) => {
validateGitExecArgs(args)
if (args[0] === 'remote' && args[1] === 'get-url') {
return { stdout: 'git@github.com:stablyai/orca.git\n', stderr: '' }
}
if (args[0] === 'remote' && args.length === 1) {
return { stdout: 'origin\n', stderr: '' }
}
if (args[0] === 'show-ref') {
throw Object.assign(new Error('missing exact ref'), { code: 1 })
}
return { stdout: '', stderr: '' }
})
const provider = {
exec,
fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined),
addWorktree: vi.fn().mockResolvedValue(undefined),
listWorktrees: vi.fn().mockResolvedValue([
{
path: '/remote/repo-contributor-fix',
head: 'abc123',
branch: 'refs/heads/contributor/fix',
isBare: false,
isMainWorktree: false
}
])
}
const mux = { request: vi.fn().mockResolvedValue(undefined), notify: vi.fn() }
store.getRepos.mockReturnValue([repo])
store.getRepo.mockReturnValue(repo)
getSshGitProviderMock.mockReturnValue(provider)
getActiveMultiplexerMock.mockReturnValue(mux)
store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta)
await handlers['worktrees:create'](null, {
repoId: 'repo-ssh',
name: 'contributor-fix',
branchNameOverride: 'contributor/fix',
pushTarget: {
remoteName: 'pr-contributor-orca',
branchName: 'contributor/fix',
remoteUrl: 'https://github.com/contributor/orca.git'
}
})
expect(exec).not.toHaveBeenCalledWith(
['remote', 'add', 'pr-contributor-orca', 'https://github.com/contributor/orca.git'],
'/remote/repo'
)
// fetchRemoteTrackingRef IS called once here, but for create's unrelated
// base-ref refresh (origin/main) -- not for the fork remote, which defers.
expect(provider.fetchRemoteTrackingRef).not.toHaveBeenCalledWith(
'/remote/repo',
'pr-contributor-orca',
'contributor/fix',
'refs/remotes/pr-contributor-orca/contributor/fix'
)
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
pushTarget: {
remoteName: 'pr-contributor-orca',
branchName: 'contributor/fix',
remoteUrl: 'https://github.com/contributor/orca.git'
}
})
)
})
// Companion to the deferral test above: `materializeWorktreePushTargetRemoteSsh` is
// exactly what `git:push`/`git:pull`'s SSH dispatch calls before syncing, so this is
// "first sync" without needing the sync IPC handlers registered in this harness.
it('mints the fork remote for an SSH fork-PR worktree on first sync', async () => {
const exec = vi.fn().mockImplementation(async (args: string[]) => {
validateGitExecArgs(args)
if (args[0] === 'remote' && args[1] === 'get-url') {
throw new Error('No such remote')
}
if (args[0] === 'remote' && args.length === 1) {
return { stdout: 'origin\n', stderr: '' }
}
return { stdout: '', stderr: '' }
})
const fetchRemoteTrackingRef = vi.fn().mockResolvedValue(undefined)
const markRemoteOrcaCreated = vi.fn().mockResolvedValue(undefined)
const target = {
remoteName: 'pr-contributor-orca',
branchName: 'contributor/fix',
remoteUrl: 'https://github.com/contributor/orca.git'
}
const result = await materializeWorktreePushTargetRemoteSsh(
{ exec, fetchRemoteTrackingRef, markRemoteOrcaCreated } as unknown as SshGitProvider,
'/remote/repo',
target
)
expect(result).toEqual({ ...target, remoteCreated: true })
expect(exec).toHaveBeenCalledWith(
['remote', 'add', 'pr-contributor-orca', 'https://github.com/contributor/orca.git'],
'/remote/repo'
)
expect(fetchRemoteTrackingRef).toHaveBeenCalledWith(
'/remote/repo',
'pr-contributor-orca',
'contributor/fix',
'refs/remotes/pr-contributor-orca/contributor/fix'
)
expect(markRemoteOrcaCreated).toHaveBeenCalledWith('/remote/repo', 'pr-contributor-orca')
})
// The relay-upgrade-messaging, fetch-failure rollback, and sibling-remote-preserved
// cases used to be exercised here because create minted the remote unconditionally.
// That code (prepareWorktreePushTargetSsh) is unchanged -- it just no longer runs at
// create time for a fork remote, only from materializeWorktreePushTargetRemoteSsh on
// first sync. Coverage for all three moved with it to
// worktree-remote-push-target-materialization.test.ts, which calls that function directly.
})
describe('worktree preserved branch fork remotes', () => {
beforeEach(() => {
setupWorktreeHandlers()
})
it('preserves the branch on remove for worktrees created from an existing local branch', async () => {
mockKnownFeatureWorktree()
removeWorktreeMock.mockResolvedValue(undefined)
store.getWorktreeMeta.mockReturnValue(makeWorktreeMeta({ preserveBranchOnDelete: true }))
await handlers['worktrees:remove'](null, {
worktreeId: 'repo-1::/workspace/feature-wt'
})
expect(removeWorktreeMock).toHaveBeenCalledWith(
'/workspace/repo',
'/workspace/feature-wt',
false,
expect.objectContaining({
deleteBranch: false,
knownRemovedWorktree: expect.objectContaining({
branch: 'feature',
head: 'feature',
path: '/workspace/feature-wt'
})
})
)
})
it('force-deletes a branch that was preserved by safe worktree removal', async () => {
mockKnownFeatureWorktree()
removeWorktreeMock.mockResolvedValue({
preservedBranch: { branchName: 'feature/test', head: 'def456' }
})
await handlers['worktrees:remove'](null, {
worktreeId: 'repo-1::/workspace/feature-wt'
})
const result = await handlers['worktrees:forceDeletePreservedBranch'](null, {
worktreeId: 'repo-1::/workspace/feature-wt',
branchName: 'feature/test',
expectedHead: 'def456'
})
expect(result).toMatchObject({ deleted: true })
expect(forceDeleteLocalBranchMock).toHaveBeenCalledWith(
'/workspace/repo',
'feature/test',
'def456'
)
})
it('force-deletes a preserved SSH branch without borrowing colliding local metadata', async () => {
const repo = {
id: 'repo-ssh',
path: '/remote/repo',
displayName: 'ssh',
badgeColor: '#000',
addedAt: 0,
connectionId: 'conn-1',
worktreeBaseRef: null
}
const worktreeId = 'repo-ssh::/remote/feature-wt'
const localRepo = { ...repo, path: '/workspace/repo', connectionId: undefined }
const provider = {
exec: vi.fn().mockResolvedValue({ stdout: '', stderr: '' }),
forceDeletePreservedBranch: vi.fn().mockResolvedValue(undefined),
listWorktrees: vi.fn().mockResolvedValue([
{
path: repo.path,
head: 'main',
branch: 'main',
isBare: false,
isMainWorktree: true
},
{
path: '/remote/feature-wt',
head: 'def456',
branch: 'feature/test',
isBare: false,
isMainWorktree: false
}
]),
removeWorktree: vi.fn().mockResolvedValue({
preservedBranch: { branchName: 'feature/test', head: 'def456' }
}),
worktreeIsClean: vi.fn().mockResolvedValue({ clean: true })
}
store.getRepos.mockReturnValue([localRepo, repo])
store.getRepo.mockReturnValue(localRepo)
store.getWorktreeMeta.mockReturnValue(
makeWorktreeMeta({ hostId: 'local', preserveBranchOnDelete: true })
)
getSshGitProviderMock.mockReturnValue(provider)
getActiveMultiplexerMock.mockReturnValue({ request: vi.fn(), notify: vi.fn() })
await handlers['worktrees:remove'](null, { worktreeId, hostId: 'ssh:conn-1' })
expect(provider.removeWorktree).toHaveBeenCalledWith('/remote/feature-wt', undefined)
const result = await handlers['worktrees:forceDeletePreservedBranch'](null, {
worktreeId,
branchName: 'feature/test',
expectedHead: 'def456',
hostId: 'ssh:conn-1'
})
expect(result).toMatchObject({ deleted: true })
expect(provider.forceDeletePreservedBranch).toHaveBeenCalledWith(
'/remote/repo',
'feature/test',
'def456'
)
expect(forceDeleteLocalBranchMock).not.toHaveBeenCalled()
})
it('rejects stale preserved-branch cleanup actions with an old head', async () => {
mockKnownFeatureWorktree()
removeWorktreeMock.mockResolvedValue({
preservedBranch: { branchName: 'feature/test', head: 'new456' }
})
await handlers['worktrees:remove'](null, {
worktreeId: 'repo-1::/workspace/feature-wt'
})
await expect(
handlers['worktrees:forceDeletePreservedBranch'](null, {
worktreeId: 'repo-1::/workspace/feature-wt',
branchName: 'feature/test',
expectedHead: 'old123'
})
).rejects.toThrow('No preserved branch cleanup is pending')
expect(forceDeleteLocalBranchMock).not.toHaveBeenCalled()
})
it('removes an unused Orca-created fork remote after deleting its worktree', async () => {
mockKnownFeatureWorktree()
removeWorktreeMock.mockResolvedValue(undefined)
const pushTarget = {
remoteName: 'pr-contributor-orca',
branchName: 'feature/from-fork',
remoteUrl: 'https://github.com/contributor/orca.git',
remoteCreated: true
}
store.getWorktreeMeta.mockReturnValue(makeWorktreeMeta({ pushTarget }))
store.getAllWorktreeMeta.mockReturnValue({
'repo-1::/workspace/feature-wt': makeWorktreeMeta({ pushTarget })
})
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
if (args[0] === 'config') {
throw new Error('no branch config')
}
if (args[0] === 'remote' && args[1] === 'get-url') {
return { stdout: 'https://github.com/contributor/orca.git\n', stderr: '' }
}
return { stdout: '', stderr: '' }
})
await handlers['worktrees:remove'](null, {
worktreeId: 'repo-1::/workspace/feature-wt'
})
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['remote', 'remove', 'pr-contributor-orca'], {
cwd: '/workspace/repo'
})
})
it('keeps an Orca-created fork remote while another worktree still uses it', async () => {
mockKnownFeatureWorktree()
removeWorktreeMock.mockResolvedValue(undefined)
const pushTarget = {
remoteName: 'pr-contributor-orca',
branchName: 'feature/from-fork',
remoteUrl: 'https://github.com/contributor/orca.git',
remoteCreated: true
}
store.getWorktreeMeta.mockReturnValue(makeWorktreeMeta({ pushTarget }))
store.getAllWorktreeMeta.mockReturnValue({
'repo-1::/workspace/feature-wt': makeWorktreeMeta({ pushTarget }),
'repo-1::/workspace/other-wt': makeWorktreeMeta({
pushTarget: {
...pushTarget,
branchName: 'other-branch'
}
})
})
await handlers['worktrees:remove'](null, {
worktreeId: 'repo-1::/workspace/feature-wt'
})
expect(gitExecFileAsyncMock).not.toHaveBeenCalledWith(
['remote', 'remove', 'pr-contributor-orca'],
expect.any(Object)
)
})
it('ignores matching push targets from other repos when deciding fork remote cleanup', async () => {
mockKnownFeatureWorktree()
removeWorktreeMock.mockResolvedValue(undefined)
const pushTarget = {
remoteName: 'pr-contributor-orca',
branchName: 'feature/from-fork',
remoteUrl: 'https://github.com/contributor/orca.git',
remoteCreated: true
}
store.getWorktreeMeta.mockReturnValue(makeWorktreeMeta({ pushTarget }))
store.getAllWorktreeMeta.mockReturnValue({
'repo-1::/workspace/feature-wt': makeWorktreeMeta({ pushTarget }),
'repo-2::/workspace/other-wt': makeWorktreeMeta({
pushTarget: {
...pushTarget,
branchName: 'other-branch'
}
})
})
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
if (args[0] === 'config') {
throw new Error('no branch config')
}
if (args[0] === 'remote' && args[1] === 'get-url') {
return { stdout: 'https://github.com/contributor/orca.git\n', stderr: '' }
}
return { stdout: '', stderr: '' }
})
await handlers['worktrees:remove'](null, {
worktreeId: 'repo-1::/workspace/feature-wt'
})
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['remote', 'remove', 'pr-contributor-orca'], {
cwd: '/workspace/repo'
})
})
})
+1 -1
View File
@@ -2,7 +2,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { createManagedHookLocalFilesystem } from '../agent-hooks/managed-hook-local-filesystem'
import { createManagedHookLocalFilesystem } from '../agent-hooks/managed-hook-local-filesystem.test-fixture'
import { isPlainObject } from '../agent-hooks/installer-utils'
import { KiroHookService } from './hook-service'
import { getKiroManagedCommandMatcher, KIRO_HOOK_EVENTS } from './hook-settings'
@@ -1,366 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import { PLUGIN_HOST_API_V0 } from '../../shared/plugins/plugin-host-api'
import type { PluginCapabilityKind } from '../../shared/plugins/plugin-capabilities'
import {
admitPluginPanelCall,
createPluginPanelCallAdmission
} from '../../shared/plugins/plugin-panel-call-admission'
import type { PluginPanelActionOutcome } from '../../shared/plugins/plugin-panel-bridge'
import type { MethodHandler } from '../../wsl-guest/dispatcher'
import {
RELAY_PLUGIN_PANEL_HOST_CALL_METHOD,
RELAY_PLUGIN_WORKER_HOST_CALL_METHOD,
registerRelayPluginHostCallHandlers
} from '../../relay/plugin-host-call-handler'
import {
executePluginHostCallRequest,
type PluginHostCallPolicy,
type ResolvePluginHostCallPolicy
} from './plugin-host-call-adapter'
import type { PluginHostServices } from './plugin-host-methods'
const PLUGIN_KEY = 'orca-samples.demo'
const WORKTREE_ID = 'repo-id::/Users/private/orca'
const TERMINAL_ID = 'terminal:local:one'
type HostCallAdapter = (request: unknown, viaPanel: boolean) => Promise<PluginPanelActionOutcome>
function createServices(): PluginHostServices {
return {
resolveActiveWorktreeContext: vi.fn().mockResolvedValue({
worktreeId: WORKTREE_ID,
branch: 'main',
displayName: 'Orca',
path: '/Users/private/orca'
}),
listWorktreeTerminals: vi
.fn()
.mockResolvedValue([{ id: TERMINAL_ID, title: '/home/private/orca' }]),
sendTerminalText: vi.fn().mockResolvedValue({ accepted: true }),
dispatchPluginNotification: vi.fn().mockResolvedValue({ delivered: true }),
storage: {
get: vi.fn().mockReturnValue('stored'),
set: vi.fn().mockReturnValue({ ok: true }),
delete: vi.fn(),
keys: vi.fn().mockReturnValue(['alpha'])
},
secrets: {
get: vi.fn().mockReturnValue({ ok: true, value: 'secret' }),
set: vi.fn().mockReturnValue({ ok: true }),
delete: vi.fn()
},
settings: {
getAll: vi.fn().mockReturnValue({ theme: 'dark' }),
set: vi.fn().mockReturnValue({ ok: true })
},
subscribeEvents: vi.fn().mockImplementation((_pluginKey, events) => events)
}
}
function createPolicy(
grantedCapabilities: readonly PluginCapabilityKind[] | null,
services: PluginHostServices = createServices(),
audit = { record: vi.fn().mockResolvedValue(undefined) }
): PluginHostCallPolicy {
return { grantedCapabilities, services, audit }
}
function createAdapters(
resolvePolicy: ResolvePluginHostCallPolicy,
limits?: { maxBytes?: number; maxMessages?: number; perMs?: number }
): Record<string, HostCallAdapter> {
const relayHandlers = new Map<string, MethodHandler>()
registerRelayPluginHostCallHandlers(
{ onRequest: (method, handler) => relayHandlers.set(method, handler) },
(context) => (context.clientId === 1 ? PLUGIN_KEY : null),
resolvePolicy,
{ panelAdmission: createPluginPanelCallAdmission({ limits, now: () => 0 }) }
)
const desktopAdmission = createPluginPanelCallAdmission({ limits, now: () => 0 })
return {
'desktop-main': async (request, viaPanel) => {
if (viaPanel) {
const admissionRefusal = admitPluginPanelCall(desktopAdmission, PLUGIN_KEY, request)
if (admissionRefusal) {
return admissionRefusal
}
}
return executePluginHostCallRequest({
pluginKey: PLUGIN_KEY,
request,
viaPanel,
resolvePolicy
})
},
relay: async (request, viaPanel) => {
const registeredMethod = viaPanel
? RELAY_PLUGIN_PANEL_HOST_CALL_METHOD
: RELAY_PLUGIN_WORKER_HOST_CALL_METHOD
return (await relayHandlers.get(registeredMethod)!(request as Record<string, unknown>, {
clientId: 1,
isStale: () => false
})) as PluginPanelActionOutcome
}
}
}
const successParams: Record<string, unknown> = {
'workspace.readContext': {},
'terminal.sendText': { terminalId: TERMINAL_ID, text: 'echo hi', enter: true },
'notifications.show': { title: 'Hello' },
'storage.get': { key: 'alpha' },
'storage.set': { key: 'alpha', value: 1 },
'storage.delete': { key: 'alpha' },
'storage.keys': {},
'secrets.get': { key: 'token' },
'secrets.set': { key: 'token', value: 'secret' },
'secrets.delete': { key: 'token' },
'settings.get': {},
'settings.set': { key: 'theme', value: 'dark' },
'events.subscribe': { events: ['worktree.created'] }
}
describe('plugin host main/relay conformance', () => {
it('runs a granted success through both transports for all 13 v0 methods', async () => {
expect(PLUGIN_HOST_API_V0).toHaveLength(13)
expect(Object.keys(successParams).sort()).toEqual(
PLUGIN_HOST_API_V0.map((entry) => entry.name).sort()
)
expect(PLUGIN_HOST_API_V0.every((entry) => entry.stability === 'experimental')).toBe(true)
expect(PLUGIN_HOST_API_V0.every((entry) => entry.scope.length > 0)).toBe(true)
for (const spec of PLUGIN_HOST_API_V0) {
const policy = createPolicy([spec.capability])
const resolvePolicy = vi.fn().mockResolvedValue(policy)
const outcomes = await Promise.all(
Object.values(createAdapters(resolvePolicy)).map((adapter) =>
adapter({ method: spec.name, params: successParams[spec.name] }, spec.panel)
)
)
expect(outcomes, spec.name).toHaveLength(2)
expect(outcomes[0], spec.name).toEqual(outcomes[1])
expect(outcomes[0], spec.name).toMatchObject({ ok: true })
}
})
it('projects workspace context without host paths on main and relay', async () => {
const resolvePolicy = vi.fn().mockResolvedValue(createPolicy(['workspace:read']))
for (const adapter of Object.values(createAdapters(resolvePolicy))) {
const outcome = await adapter({ method: 'workspace.readContext', params: {} }, true)
expect(outcome).toEqual({
ok: true,
value: {
branch: 'main',
displayName: 'Orca',
terminals: [{ id: TERMINAL_ID }]
}
})
expect(outcome).not.toHaveProperty('value.path')
expect(outcome).not.toHaveProperty('value.worktreeId')
}
})
const deniedCases: {
name: string
request: unknown
viaPanel: boolean
policy: () => PluginHostCallPolicy
code: string
}[] = [
{
name: 'missing or stale consent',
request: { method: 'workspace.readContext', params: {} },
viaPanel: true,
policy: () => createPolicy(null),
code: 'consent_required'
},
{
name: 'missing capability',
request: { method: 'workspace.readContext', params: {} },
viaPanel: true,
policy: () => createPolicy([]),
code: 'capability_denied'
},
{
name: 'unknown method',
request: { method: 'workspace.erase', params: {} },
viaPanel: false,
policy: () => createPolicy(['workspace:read']),
code: 'unknown_method'
},
{
name: 'malformed params',
request: {
method: 'terminal.sendText',
params: { terminalId: TERMINAL_ID, text: '' }
},
viaPanel: true,
policy: () => createPolicy(['terminal:send']),
code: 'invalid_params'
},
{
name: 'panel-forbidden method',
request: { method: 'storage.get', params: { key: 'alpha' } },
viaPanel: true,
policy: () => createPolicy(['storage']),
code: 'panel_forbidden'
},
{
name: 'malformed result',
request: {
method: 'notifications.show',
params: { title: 'Hello' }
},
viaPanel: true,
policy: () => {
const services = createServices()
services.dispatchPluginNotification = vi
.fn()
.mockResolvedValue({ delivered: 'yes' } as unknown as { delivered: boolean })
return createPolicy(['notifications:show'], services)
},
code: 'action_failed'
},
{
name: 'mutation audit failure',
request: {
method: 'storage.set',
params: { key: 'alpha', value: 1 }
},
viaPanel: false,
policy: () =>
createPolicy(['storage'], createServices(), {
record: vi.fn().mockRejectedValue(new Error('disk full'))
}),
code: 'action_failed'
}
]
it.each(deniedCases)('returns identical $code codes for $name', async (testCase) => {
const outcomes: PluginPanelActionOutcome[] = []
for (const adapterName of ['desktop-main', 'relay']) {
const resolvePolicy = vi.fn().mockImplementation(() => testCase.policy())
const adapter = createAdapters(resolvePolicy)[adapterName]!
outcomes.push(await adapter(testCase.request, testCase.viaPanel))
}
expect(outcomes[0]).toMatchObject({ ok: false, code: testCase.code })
expect(outcomes[1]).toMatchObject({ ok: false, code: testCase.code })
expect(outcomes[0]).toEqual(outcomes[1])
})
it('enforces the same per-plugin panel budget on desktop main and relay', async () => {
for (const adapterName of ['desktop-main', 'relay']) {
const resolvePolicy = vi.fn().mockResolvedValue(createPolicy(['notifications:show']))
const adapter = createAdapters(resolvePolicy, {
maxMessages: 1,
perMs: 10_000
})[adapterName]!
await expect(
adapter({ method: 'notifications.show', params: { title: 'first' } }, true)
).resolves.toMatchObject({ ok: true })
await expect(
adapter({ method: 'notifications.show', params: { title: 'second' } }, true)
).resolves.toEqual({
ok: false,
code: 'rate_limited',
error: 'too many panel requests'
})
}
})
it('charges malformed and oversized panel traffic before schema parsing', async () => {
for (const adapterName of ['desktop-main', 'relay']) {
const resolvePolicy = vi.fn().mockResolvedValue(createPolicy(['notifications:show']))
const adapter = createAdapters(resolvePolicy, {
maxBytes: 128,
maxMessages: 2,
perMs: 10_000
})[adapterName]!
await expect(
adapter({ method: 'notifications.show', unexpected: true }, true)
).resolves.toMatchObject({ ok: false, code: 'invalid_request' })
await expect(
adapter(
{
method: 'notifications.show',
params: { title: 'x'.repeat(256) }
},
true
)
).resolves.toEqual({
ok: false,
code: 'invalid_request',
error: 'panel message exceeds the size limit'
})
await expect(
adapter({ method: 'notifications.show', params: { title: 'third' } }, true)
).resolves.toEqual({
ok: false,
code: 'rate_limited',
error: 'too many panel requests'
})
expect(resolvePolicy).not.toHaveBeenCalled()
}
})
it('binds relay plugin identity to the requesting connection', async () => {
const relayHandlers = new Map<string, MethodHandler>()
const services = createServices()
const resolvePolicy = vi.fn().mockResolvedValue(createPolicy(['storage'], services))
const resolveIdentity = vi
.fn()
.mockImplementation(({ clientId }: { clientId: number }) =>
clientId === 7 ? PLUGIN_KEY : null
)
registerRelayPluginHostCallHandlers(
{ onRequest: (method, handler) => relayHandlers.set(method, handler) },
resolveIdentity,
resolvePolicy
)
const handler = relayHandlers.get(RELAY_PLUGIN_WORKER_HOST_CALL_METHOD)!
await expect(
handler(
{ method: 'storage.get', params: { key: 'alpha' } },
{ clientId: 7, isStale: () => false }
)
).resolves.toMatchObject({ ok: true })
expect(services.storage.get).toHaveBeenCalledWith(PLUGIN_KEY, 'alpha')
await expect(
handler(
{ method: 'storage.get', params: { key: 'alpha' } },
{ clientId: 8, isStale: () => false }
)
).resolves.toMatchObject({ ok: false, code: 'unavailable' })
expect(resolvePolicy).toHaveBeenCalledTimes(1)
})
it('rejects malformed envelopes and client-supplied authority before policy resolution', async () => {
const requests = [
{ pluginKey: '../evil', method: 'storage.get', params: { key: 'alpha' } },
{
pluginKey: PLUGIN_KEY,
method: 'storage.get',
params: { key: 'alpha' },
grantedCapabilities: ['storage']
},
{
pluginKey: PLUGIN_KEY,
method: 'storage.get',
params: { key: 'alpha' },
viaPanel: false
}
]
for (const request of requests) {
for (const adapterName of ['desktop-main', 'relay']) {
const resolvePolicy = vi.fn().mockResolvedValue(createPolicy(['storage']))
const outcome = await createAdapters(resolvePolicy)[adapterName]!(request, false)
expect(outcome).toMatchObject({ ok: false, code: 'invalid_request' })
expect(resolvePolicy).not.toHaveBeenCalled()
}
}
})
})
-5
View File
@@ -4,11 +4,6 @@ import { readlink } from 'node:fs/promises'
/**
* Resolve the current working directory of a local process by pid.
*
* Why duplicated from `src/relay/pty-shell-utils.ts`: the relay and Electron
* main process have separate build graphs, and cross-importing across them
* is not a pattern used in this repo. The function is short and pure, and
* the duplication is cheaper than reshaping both bundle graphs.
*
* Tries `/proc/<pid>/cwd` on Linux, falls back to `lsof -d cwd` on macOS.
* Returns `''` when neither works (including Windows, where `/proc` is
* absent and `lsof` is not native).
@@ -1,334 +0,0 @@
import { mkdir, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { runProcess } from '@orca/process-host'
import { ReviewDraftContextError } from '../../shared/review-draft-context-error'
import { RelayContext } from '../../relay/context'
import { RelayDispatcher } from '../../wsl-guest/dispatcher'
import { GitHandler } from '../../relay/git-handler'
import { createGitTempDir, removeGitTempDir } from '../../relay/git-handler-test-harness'
import { gitCommit, gitInit } from '../../relay/git-handler-test-setup'
import {
encodeJsonRpcFrame,
FrameDecoder,
MessageType,
parseJsonRpcMessage
} from '../../wsl-guest/protocol'
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
import { getPullRequestDraftContext } from '../text-generation/pull-request-context'
import { SshGitProvider } from './ssh-git-provider'
import { createMockMux } from './ssh-git-provider-test-harness'
import { execSshReviewDraft } from './ssh-review-draft-context'
const input = {
base: 'main',
currentTitle: 'Existing review title',
currentBody: 'Existing review body',
currentDraft: true
}
const mergeBase = 'a'.repeat(40)
const disposals: (() => void)[] = []
const directories: string[] = []
afterEach(async () => {
disposals.splice(0).forEach((dispose) => dispose())
await Promise.all(directories.splice(0).map(removeGitTempDir))
vi.restoreAllMocks()
vi.unstubAllEnvs()
})
function providerForMux(mux: ReturnType<typeof createMockMux>): SshGitProvider {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this harness implements every request, stream notification, disposal and notify method used by these provider calls.
return new SshGitProvider('review-host', mux as unknown as SshChannelMultiplexer)
}
function createWireProvider() {
const mux = createMockMux()
const pending = new Map<
number,
{ resolve: (value: unknown) => void; reject: (error: Error) => void }
>()
const notifications = new Map<string, Set<(params: Record<string, unknown>) => void>>()
const decoder = new FrameDecoder((frame) => {
if (frame.type !== MessageType.Regular) {
return
}
const message = parseJsonRpcMessage(frame.payload)
if ('method' in message) {
notifications.get(message.method)?.forEach((listener) => listener(message.params ?? {}))
return
}
const request = pending.get(message.id)
if (!request) {
return
}
pending.delete(message.id)
if (message.error) {
request.reject(Object.assign(new Error(message.error.message), { code: message.error.code }))
} else {
request.resolve(message.result)
}
})
const dispatcher = new RelayDispatcher((frame) => decoder.feed(frame))
const handler = new GitHandler(dispatcher, new RelayContext())
let sequence = 0
mux.request.mockImplementation((method: string, params: Record<string, unknown>) => {
const id = ++sequence
return new Promise<unknown>((resolve, reject) => {
pending.set(id, { resolve, reject })
dispatcher.feed(encodeJsonRpcFrame({ jsonrpc: '2.0', id, method, params }, id, 0))
})
})
mux.notify.mockImplementation((method: string, params: Record<string, unknown>) => {
dispatcher.feed(encodeJsonRpcFrame({ jsonrpc: '2.0', method, params }, ++sequence, 0))
})
mux.onNotificationByMethod.mockImplementation(
(method: string, listener: (params: Record<string, unknown>) => void) => {
const listeners = notifications.get(method) ?? new Set()
notifications.set(method, listeners)
listeners.add(listener)
return () => listeners.delete(listener)
}
)
disposals.push(() => {
handler.dispose()
dispatcher.dispose()
pending.forEach((request) => request.reject(new Error('Test relay disposed')))
pending.clear()
})
return { provider: providerForMux(mux), mux, dispatcher, notifications }
}
async function git(cwd: string, args: string[]) {
const result = await runProcess({ program: 'git', args, cwd, timeoutMs: 10_000 })
if (result.code !== 0) {
throw Object.assign(new Error(result.stderr), { code: result.code })
}
return { stdout: result.stdout, stderr: result.stderr }
}
async function createReviewFixture(large: boolean) {
const root = createGitTempDir()
directories.push(root)
const globalConfig = join(root, 'global.gitconfig')
await writeFile(globalConfig, '')
// Newer desktop Git preferences may be rejected while a baseline fixture loads config.
vi.stubEnv('GIT_CONFIG_GLOBAL', globalConfig)
vi.stubEnv('GIT_CONFIG_NOSYSTEM', '1')
const remote = join(root, 'remote repo')
const worktree = join(root, 'review worktree')
await mkdir(remote)
gitInit(remote)
await git(remote, ['checkout', '-b', 'main'])
await writeFile(join(remote, 'README.md'), 'Original content\n')
gitCommit(remote, 'Initial base')
await git(root, ['clone', remote, worktree])
const originalBase = (await git(worktree, ['rev-parse', 'HEAD'])).stdout.trim()
await writeFile(join(remote, 'base-only.txt'), 'Advance the remote base after cloning\n')
gitCommit(remote, 'Advance base')
await git(worktree, ['checkout', '-b', 'feature/review'])
await writeFile(join(worktree, 'README.md'), 'Committed review evidence\n')
await writeFile(
join(worktree, 'new file.txt'),
large
? 'Committed line with unicode café for a streamed review.\n'.repeat(36_000)
: 'New evidence\n'
)
gitCommit(worktree, 'Add review evidence')
await writeFile(join(worktree, 'README.md'), 'Uncommitted content must stay out of the draft\n')
return { worktree, originalBase }
}
describe('SSH review draft through the real relay dispatcher', () => {
it.each([false, true])('matches complete local context with streamed patch=%s', async (large) => {
const { worktree, originalBase } = await createReviewFixture(large)
const local = await getPullRequestDraftContext((args) => git(worktree, args), input)
expect(local).toMatchObject({
branch: 'feature/review',
commitSummary: '- Add review evidence',
changeSummary: expect.stringContaining('new file.txt'),
patch: expect.stringContaining('+Committed review evidence')
})
await git(worktree, ['update-ref', 'refs/remotes/origin/main', originalBase])
const { provider, mux, notifications } = createWireProvider()
const remote = await getPullRequestDraftContext(
(args, options) => execSshReviewDraft(provider, args, worktree, options),
input
)
expect(remote).toEqual(local)
expect(remote?.patch).not.toContain('Uncommitted content')
expect(mux.request).toHaveBeenCalledWith('git.fetchRemoteTrackingRef', {
worktreePath: worktree,
remote: 'origin',
branch: 'main',
ref: 'refs/remotes/origin/main'
})
expect(mux.request.mock.calls.filter(([method]) => method === 'git.reviewDiff')).toHaveLength(2)
expect(
mux.request.mock.calls.some(
([method, params]) =>
method === 'git.exec' && (params.args[0] === 'fetch' || params.args[0] === 'diff')
)
).toBe(false)
expect([...notifications.values()].every((listeners) => listeners.size === 0)).toBe(true)
if (large) {
expect(mux.notify).toHaveBeenCalledWith('git.responseAck', expect.any(Object))
}
})
})
function createContextProvider() {
const mux = createMockMux()
const runRequest = async (method: string, params: Record<string, unknown>) => {
if (method === 'git.fetchRemoteTrackingRef') {
return undefined
}
if (method === 'git.reviewDiff') {
return { stdout: 'remote evidence', stderr: '' }
}
const args = params.args
if (!Array.isArray(args)) {
throw new Error('Missing context Git arguments')
}
if (args[0] === 'remote') {
return { stdout: 'origin\n', stderr: '' }
}
if (args[0] === 'show-ref') {
return { stdout: '', stderr: '' }
}
if (args[0] === 'branch') {
return { stdout: 'feature/review\n', stderr: '' }
}
if (args[0] === 'merge-base') {
return { stdout: `${mergeBase}\n`, stderr: '' }
}
if (args[0] === 'log') {
return { stdout: '- committed evidence\n', stderr: '' }
}
throw new Error(`Unexpected method ${method}`)
}
mux.request.mockImplementation(runRequest)
return { mux, provider: providerForMux(mux), runRequest }
}
describe('SSH review draft failures and routing', () => {
it('rejects an old-host missing diff method instead of returning commit-only context', async () => {
const { mux, provider, runRequest } = createContextProvider()
mux.request.mockImplementation((method, params) => {
if (method === 'git.reviewDiff') {
return Promise.reject(Object.assign(new Error('Method not found'), { code: -32601 }))
}
return runRequest(method, params)
})
await expect(
getPullRequestDraftContext(
(args, options) => execSshReviewDraft(provider, args, '/repo', options),
input
)
).rejects.toThrow('Reconnect the SSH target')
})
it.each(['SSH connection closed', 'Git request timed out', 'git output exceeded maxBuffer.'])(
'rejects incomplete context after %s',
async (message) => {
const { mux, provider, runRequest } = createContextProvider()
mux.request.mockImplementation((method, params) => {
if (method === 'git.reviewDiff') {
return Promise.reject(new Error(message))
}
return runRequest(method, params)
})
const draft = getPullRequestDraftContext(
(args, options) => execSshReviewDraft(provider, args, '/repo', options),
input
)
await expect(draft).rejects.toBeInstanceOf(ReviewDraftContextError)
await expect(draft).rejects.toThrow(message)
}
)
it('stops before reading evidence when an old host lacks the narrow fetch method', async () => {
const { mux, provider, runRequest } = createContextProvider()
mux.request.mockImplementation((method, params) => {
if (method === 'git.fetchRemoteTrackingRef') {
return Promise.reject(
Object.assign(new Error('Method not found: git.fetchRemoteTrackingRef'), { code: -32601 })
)
}
return runRequest(method, params)
})
await expect(
getPullRequestDraftContext(
(args, options) => execSshReviewDraft(provider, args, '/repo', options),
input
)
).rejects.toThrow('Fetch before generating PR details failed')
expect(mux.request.mock.calls.some(([method]) => method === 'git.reviewDiff')).toBe(false)
})
it.each([
undefined,
null,
{},
{ stdout: 'patch' },
{ stdout: 3, stderr: '' },
{ stdout: '', stderr: null }
])('refuses malformed remote evidence %j', async (reply) => {
const mux = createMockMux()
mux.request.mockResolvedValue(reply)
await expect(providerForMux(mux).readReviewDiff('/repo', mergeBase, 'patch')).rejects.toThrow(
ReviewDraftContextError
)
})
it.each([{ timeout: 321 }, { timeoutMs: 654 }, { timeout: 321, timeoutMs: 654 }])(
'forwards draft deadlines %j to the narrow diff request',
async (options) => {
const mux = createMockMux()
mux.request.mockResolvedValue({ stdout: 'patch', stderr: '' })
await execSshReviewDraft(
providerForMux(mux),
['diff', '--name-status', `${mergeBase}..HEAD`],
'/repo',
options
)
expect(mux.request).toHaveBeenCalledWith(
'git.reviewDiff',
{
worktreePath: '/repo',
mergeBase,
format: 'name-status',
__streamResponse: true
},
{ signal: undefined, timeoutMs: options.timeoutMs ?? options.timeout }
)
}
)
it('keeps non-draft fetch and diff shapes on the restricted generic path', async () => {
const mux = createMockMux()
mux.request.mockResolvedValue({ stdout: '', stderr: '' })
const provider = providerForMux(mux)
for (const args of [
['fetch', '--all'],
['fetch', '--no-tags', 'origin', '+refs/heads/main:refs/heads/overwrite'],
['diff', '--name-status', '--no-ext-diff', `${mergeBase}..HEAD`],
['diff', '--patch', '--minimal', '--no-color', '--no-ext-diff', `${mergeBase}...HEAD`]
]) {
await execSshReviewDraft(provider, args, '/repo', { timeoutMs: 456 })
expect(mux.request).toHaveBeenLastCalledWith(
'git.exec',
{
args,
cwd: '/repo',
__streamResponse: true
},
{ signal: undefined, timeoutMs: 456 }
)
}
expect(mux.request.mock.calls.every(([method]) => method === 'git.exec')).toBe(true)
})
})
@@ -1,98 +0,0 @@
import './rpc/unused-default-rpc-methods.test-fixture'
import { afterEach, expect, it, vi } from 'vitest'
import { mkdtemp, writeFile, rm } from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { RuntimeFileCommands } from './orca-runtime-files'
import { RpcDispatcher } from './rpc/dispatcher'
import { FILE_METHODS } from './rpc/methods/files'
import {
registerSshFilesystemProvider,
unregisterSshFilesystemProvider
} from '../providers/ssh-filesystem-dispatch'
import { pathsExistOnRelay } from '../../relay/fs-path-existence'
import { statRelayPath } from '../../relay/fs-path-metadata-requests'
let root: string | undefined
const connection = 'batch-fixture-host'
afterEach(async () => {
unregisterSshFilesystemProvider(connection)
if (root) {
await rm(root, { recursive: true, force: true })
}
root = undefined
})
async function setup(legacy = false) {
root = await mkdtemp(join(tmpdir(), 'orca-runtime-batch-'))
const names = Array.from({ length: 8 }, (_, i) => `file-${i}.ts`)
await Promise.all(names.map((name) => writeFile(join(root!, name), 'fixture')))
const provider = {
pathsExist: legacy
? undefined
: vi.fn((paths: string[]) => pathsExistOnRelay({ filePaths: paths })),
stat: vi.fn((filePath: string) => statRelayPath({ filePath }))
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The registered fixture implements the stat and optional batch operations exercised here.
registerSshFilesystemProvider(connection, provider as never)
const resolveTarget = vi.fn(async () => ({
worktree: { id: 'folder-1', path: root, kind: 'folder', repoId: 'folder-repo' },
executionHostId: `ssh:${connection}`
}))
const host = {
getRuntimeId: () => 'runtime-fixture',
requireStore: vi.fn(() => {
throw new Error('Local store should not be read')
}),
resolveRuntimeFileTarget: resolveTarget
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The fixture supplies runtime identity, target resolution and the guarded store accessor used by these reads.
const commands = new RuntimeFileCommands(host as never)
const runtime = {
getRuntimeId: host.getRuntimeId,
pathsExistRuntimeFiles: commands.pathsExistRuntimeFiles.bind(commands),
statRuntimeFile: commands.statRuntimeFile.bind(commands)
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Dispatch is limited to the two file methods implemented by this fixture.
const dispatcher = new RpcDispatcher({ runtime: runtime as never, methods: FILE_METHODS })
const dispatch = (relativePaths: string[]) =>
dispatcher.dispatch({
id: 'batch-1',
authToken: 'fixture',
method: 'files.pathsExist',
params: { worktree: 'id:folder-1', relativePaths }
})
return { names, provider, resolveTarget, host, dispatch }
}
it('actual RPC dispatch resolves one folder owner and sends one provider batch for eight real files', async () => {
const f = await setup()
expect(await f.dispatch(f.names)).toMatchObject({
ok: true,
result: f.names.map(() => ({ exists: true }))
})
expect(f.resolveTarget).toHaveBeenCalledTimes(1)
expect(f.resolveTarget).toHaveBeenCalledWith('id:folder-1')
expect(f.provider.pathsExist).toHaveBeenCalledTimes(1)
expect(f.provider.stat).not.toHaveBeenCalled()
expect(f.host.requireStore).not.toHaveBeenCalled()
expect(await f.dispatch(['../escape'])).toMatchObject({ ok: false })
expect(f.provider.pathsExist).toHaveBeenCalledTimes(1)
})
it('legacy provider preserves all answers through scoped scalar stats', async () => {
const f = await setup(true)
expect(await f.dispatch([...f.names, 'missing'])).toMatchObject({
ok: true,
result: [...f.names.map(() => ({ exists: true })), { exists: false }]
})
expect(f.provider.stat).toHaveBeenCalledTimes(9)
expect(f.host.requireStore).not.toHaveBeenCalled()
})
it('unavailable SSH never falls back to matching local files; oversized input never reaches provider', async () => {
const f = await setup()
unregisterSshFilesystemProvider(connection)
expect(await f.dispatch(f.names)).toMatchObject({
ok: false,
error: { message: expect.stringContaining('Remote connection dropped') }
})
expect(f.host.requireStore).not.toHaveBeenCalled()
expect(await f.dispatch(Array(129).fill('file-0.ts'))).toMatchObject({ ok: false })
expect(f.provider.pathsExist).not.toHaveBeenCalled()
})
@@ -1,6 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import type { Repo } from '../../shared/repo-types'
import { validateGitExecArgs } from '../../relay/git-exec-validator'
import { getSshGitProvider } from '../providers/ssh-git-dispatch'
import { RuntimeRepositoryRefQueries } from './runtime-repository-ref-queries'
@@ -17,42 +16,6 @@ const repo: Repo = {
}
describe('qualified refs in repository searches', () => {
it.each(['feature', 'origin/feature'])(
'fills legacy pages before the limit for query %s',
async (query) => {
const exec = vi.fn(async (argv: string[]) => {
validateGitExecArgs(argv)
return {
stdout:
argv[0] === 'remote'
? 'origin\n'
: [
'refs/heads/feature/加\0feature/�',
'refs/remotes/origin/feature/加\0origin/feature/�',
'refs/remotes/origin/feature/one\0origin/feature/one',
'refs/remotes/origin/feature/two\0origin/feature/two',
'refs/remotes/origin/feature/three\0origin/feature/three'
].join('\n'),
stderr: ''
}
})
getProvider.mockReturnValue({ exec })
const queries = new RuntimeRepositoryRefQueries({ resolveRepo: async () => repo })
expect(await queries.search('id:remote-repo', query, 2, false)).toEqual({
refs: ['origin/feature/one', 'origin/feature/two'],
refDetails: [
{ refName: 'origin/feature/one', localBranchName: 'feature/one' },
{ refName: 'origin/feature/two', localBranchName: 'feature/two' }
],
truncated: true
})
expect(await queries.search('id:remote-repo', query, 2)).toMatchObject({
refs: ['refs/heads/feature/加', 'refs/remotes/origin/feature/加'],
truncated: true
})
expect(exec).toHaveBeenCalledWith(expect.arrayContaining(['--count=12']), '/repo')
}
)
it('keeps folder workspaces outside Git searches', async () => {
vi.mocked(getSshGitProvider).mockClear()
@@ -5,7 +5,6 @@ import type { OrcaRuntimeService } from './orca-runtime'
import { makeTuiIdleRuntime } from './tui-idle-wait-test-harness'
import type { RuntimeSyncWindowGraph } from '../../shared/runtime-types'
import { TERMINAL_LIFECYCLE_METHODS } from './rpc/methods/terminal/terminal-lifecycle-methods'
import { getForegroundProcessName } from '../../relay/pty-shell-utils'
// #6011 end-to-end: a REAL pty running a REAL process that emits a REAL name-only
// OSC title while streaming must not satisfy `orca terminal wait --for tui-idle`.
@@ -41,11 +40,10 @@ async function startRealAgentPane(mode: 'explicit-idle' | 'quiet', workMs: numbe
})
running.push(child)
// Real foreground read against the real pty: the same helper the relay serves
// `pty.getForegroundProcess` with, so corroboration is host-produced here too.
// Real foreground read against the real pty, so corroboration is host-produced here too.
const runtime = makeTuiIdleRuntime({
repoPath: '/tmp/tui-idle-real-pty',
getForegroundProcess: () => getForegroundProcessName(child.pid, child.process || null)
getForegroundProcess: async () => child.process || null
})
runtime.attachWindow(1)
const graph: RuntimeSyncWindowGraph = {
@@ -17,7 +17,6 @@ import {
getShellLaunchConfig as getDaemonShellLaunchConfig,
getShellReadyWrapperRoot as getDaemonShellReadyWrapperRoot
} from './daemon/shell-ready'
import { ensureOverlayRestoreWrappers } from '../relay/pty-shell-overlay-wrappers'
import { getShellLaunchConfig as getLocalShellLaunchConfig } from './providers/local-pty-shell-ready'
import { selectShellStartupFeatures } from './shell-startup-features'
@@ -142,11 +141,6 @@ describePosix('generated shell wrapper files', () => {
await expectWrapperFiles('daemon', getDaemonShellReadyWrapperRoot())
})
it('relay overlay wrappers', async () => {
ensureOverlayRestoreWrappers(root)
await expectWrapperFiles('relay', root)
})
// Why a rule and not another fixture: `REPLY`, zsh's shared scratch global,
// was the wrapper's resolver out-parameter. A user config that constrained it
// (`typeset -r REPLY`) aborted the wrapper at its first executable line — on
@@ -161,8 +155,7 @@ describePosix('generated shell wrapper files', () => {
getDaemonShellLaunchConfig('/bin/zsh', STARTUP_COMMAND_FEATURES)
},
(): string => getDaemonShellReadyWrapperRoot()
],
['relay', (): void => void ensureOverlayRestoreWrappers(root), (): string => root]
]
])('%s wrappers write no shell global outside Orca’s namespace', (_transport, generate, dir) => {
generate()
@@ -22,7 +22,6 @@ import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:f
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { ensureOverlayRestoreWrappers } from '../relay/pty-shell-overlay-wrappers'
import { getShellLaunchConfig } from './providers/local-pty-shell-ready'
import { selectShellStartupFeatures } from './shell-startup-features'
import { hasZsh, makeZshHome, MARKERS, runZshPty, ZSH_PATH } from './zsh-startup-hook-pty-harness'
@@ -378,44 +377,6 @@ describe.skipIf(process.platform === 'win32')(
* why it drifted from the desktop template; now the only differences are the
* spec flags, and this pins that the variant still works in a real shell.
*/
describe.skipIf(process.platform === 'win32')('the relay variant of the hook', () => {
itWithZsh(
'scopes history and restores the remote CLI path without emitting OSC 133',
withHome(USER_FILES, async (home) => {
const relayRoot = mkdtempSync(join(tmpdir(), 'orca-relay-wrapper-'))
try {
expect(ensureOverlayRestoreWrappers(relayRoot)).toBe(true)
const scoped = join(home, 'orca-history', 'zsh_history')
const { output, values } = await runZshPty({
env: {
PATH: '/usr/bin:/bin',
HOME: home,
ZDOTDIR: join(relayRoot, 'zsh'),
ORCA_ORIG_ZDOTDIR: home,
ORCA_SHELL_FEATURES: 'overlay,history,ready',
ORCA_HISTFILE: scoped,
ORCA_REMOTE_CLI_BIN_DIR: '/orca/remote-bin'
},
commands: ['true'],
report: ['HISTFILE', 'ZDOTDIR', 'PATH', 'ORCA_HISTFILE']
})
expect(values.HISTFILE).toBe(scoped)
expect(values.ZDOTDIR).toBe(home)
expect(values.ORCA_HISTFILE).toBe('UNSET')
expect(values.PATH.startsWith('/orca/remote-bin:')).toBe(true)
expect(output).toContain(MARKERS.ready)
// Remote panes get their command lifecycle from the bash rcfile, so the
// zsh variant must stay silent here.
expect(output).not.toContain(MARKERS.promptStart)
expect(output).not.toContain(MARKERS.commandStart)
} finally {
rmSync(relayRoot, { recursive: true, force: true })
}
})
)
})
describe.skipIf(process.platform === 'win32')('the real-zsh harness', () => {
itWithZsh(
@@ -1,107 +0,0 @@
/**
* Real-zsh proof that a wrapper dir holding files from two Orca builds still
* loads the user's own zsh config.
*
* A shared dir used to mean a shell could read one build's `.zshenv` and
* another's `.zprofile`/`.zshrc`/`.zlogin`, which is why every generated file
* redefined the helpers it called. #15285 removed the hazard for the desktop and
* daemon trees by naming each one after a hash of its contents, so two builds
* never write the same directory.
*
* The relay is the one writer left on a fixed path — `~/.orca-relay/shell-ready`
* — so this is where the scenario is still reachable, and it is now much smaller:
* Orca writes one file, and that file hands ZDOTDIR back before anything else
* runs. Both halves are pinned here:
*
* 1. Files an older build left beside the hook are inert — zsh reads .zprofile,
* .zshrc and .zlogin from the user's own directory and never from here.
* 2. Generation does not delete them. An older build checks for all four before
* calling its tree complete; deleting them makes it rewrite its own `.zshenv`
* over ours, and an older `.zshenv` points ZDOTDIR at a directory that would
* then hold no `.zshrc` at all — the user losing their entire config, which is
* the failure this file exists to prevent.
*/
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { ensureOverlayRestoreWrappers } from '../relay/pty-shell-overlay-wrappers'
import { hasZsh, makeZshHome, runZshPty } from './zsh-startup-hook-pty-harness'
const itWithZsh = hasZsh ? it : it.skip
/** The three files an older build wrote alongside its own `.zshenv`. */
const OLDER_BUILD_FILES = {
'.zprofile': 'export ORCA_TEST_STALE_ZPROFILE=1\n',
'.zshrc': 'export ORCA_TEST_STALE_ZSHRC=1\n',
'.zlogin': 'export ORCA_TEST_STALE_ZLOGIN=1\n'
}
describe.skipIf(process.platform === 'win32')('zsh wrapper dir written by mixed builds', () => {
itWithZsh('ignores an older build’s files and loads the user’s config instead', async () => {
const root = mkdtempSync(join(tmpdir(), 'orca-wrapper-mismatch-'))
const home = makeZshHome({
'.zshenv': 'export ORCA_TEST_USER_ZSHENV=1\n',
'.zprofile': 'export ORCA_TEST_USER_ZPROFILE=1\n',
'.zshrc': 'export ORCA_TEST_USER_ZSHRC=1\n'
})
try {
expect(ensureOverlayRestoreWrappers(root)).toBe(true)
const zshDir = join(root, 'zsh')
for (const [name, content] of Object.entries(OLDER_BUILD_FILES)) {
writeFileSync(join(zshDir, name), content)
}
const { values } = await runZshPty({
env: {
PATH: '/usr/bin:/bin',
HOME: home,
ZDOTDIR: zshDir,
ORCA_ORIG_ZDOTDIR: home,
ORCA_SHELL_FEATURES: 'history',
ORCA_HISTFILE: join(home, 'scoped_history')
},
report: [
'ORCA_TEST_USER_ZPROFILE',
'ORCA_TEST_USER_ZSHRC',
'ORCA_TEST_STALE_ZPROFILE',
'ORCA_TEST_STALE_ZSHRC',
'ORCA_TEST_STALE_ZLOGIN',
'HISTFILE'
]
})
// The user's own files loaded; the older build's leftovers did not.
expect(values.ORCA_TEST_USER_ZPROFILE).toBe('1')
expect(values.ORCA_TEST_USER_ZSHRC).toBe('1')
expect(values.ORCA_TEST_STALE_ZPROFILE).toBe('UNSET')
expect(values.ORCA_TEST_STALE_ZSHRC).toBe('UNSET')
expect(values.ORCA_TEST_STALE_ZLOGIN).toBe('UNSET')
expect(values.HISTFILE).toBe(join(home, 'scoped_history'))
} finally {
rmSync(root, { recursive: true, force: true })
rmSync(home, { recursive: true, force: true })
}
})
it('leaves an older build’s files in place so that build can still use them', () => {
const root = mkdtempSync(join(tmpdir(), 'orca-wrapper-mismatch-keep-'))
try {
expect(ensureOverlayRestoreWrappers(root)).toBe(true)
const zshDir = join(root, 'zsh')
for (const [name, content] of Object.entries(OLDER_BUILD_FILES)) {
writeFileSync(join(zshDir, name), content)
}
// A second generation pass is what an older build's launch would race.
expect(ensureOverlayRestoreWrappers(root)).toBe(true)
for (const [name, content] of Object.entries(OLDER_BUILD_FILES)) {
expect(existsSync(join(zshDir, name))).toBe(true)
expect(readFileSync(join(zshDir, name), 'utf8')).toBe(content)
}
} finally {
rmSync(root, { recursive: true, force: true })
}
})
})
-196
View File
@@ -1,196 +0,0 @@
import { execFile } from 'node:child_process'
import type * as ChildProcess from 'node:child_process'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { AgentExecHandler } from './agent-exec-handler'
import {
RELAY_AGENT_CLOSE_DEADLINE_MS,
RelayAgentProcessLifetime
} from './relay-agent-process-lifetime'
import { createFakeChild, requestContext } from './agent-exec-handler-test-harness'
import type { MethodHandler, RelayDispatcher } from '../wsl-guest/dispatcher'
// Why an untyped mock: the fake child stubs only what AgentExecHandler reads from a ChildProcess.
const { spawnMock, loginShellMock } = vi.hoisted(() => ({
spawnMock: vi.fn(),
loginShellMock: vi.fn()
}))
vi.mock('../main/startup/login-shell-environment', () => ({
resolveLoginShellEnvironment: () => loginShellMock()
}))
vi.mock('child_process', async (importOriginal) => ({
...(await importOriginal<typeof ChildProcess>()),
spawn: (...args: unknown[]) => spawnMock(...args),
execFile: vi.fn()
}))
function fixture() {
const methods = new Map<string, MethodHandler>()
const handler = new AgentExecHandler(
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handler only registers request methods.
{
onRequest: (name: string, method: MethodHandler) => methods.set(name, method)
} as unknown as RelayDispatcher
)
const exec = (params: Record<string, unknown> = {}) =>
methods.get('agent.execNonInteractive')!(
{ binary: 'agent', timeoutMs: 1000, ...params },
requestContext()
)
return { handler, exec }
}
beforeEach(() => {
vi.useFakeTimers()
spawnMock.mockReset()
vi.mocked(execFile).mockReset()
})
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
})
it('waits for every child close, including commands without a cwd and replaced lanes', async () => {
const f = fixture()
const children = [createFakeChild(), createFakeChild(), createFakeChild()]
for (const child of children) {
spawnMock.mockReturnValueOnce(child)
}
const requests = [f.exec(), f.exec({ cwd: '/repo' }), f.exec({ cwd: '/repo' })]
let finished = false
const disposal = f.handler.dispose().then(() => {
finished = true
})
await Promise.resolve()
expect(finished).toBe(false)
if (process.platform === 'win32') {
expect(execFile).toHaveBeenCalledWith(
'taskkill',
['/pid', '12345', '/T', '/F'],
expect.any(Function)
)
} else {
for (const child of children) {
expect(child.kill).toHaveBeenCalledWith('SIGKILL')
}
}
children[0].emit('close', null)
children[2].emit('close', null)
await Promise.resolve()
expect(finished).toBe(false)
children[1].emit('close', null)
await disposal
expect(finished).toBe(true)
await Promise.all(requests)
})
it('refuses execution once disposal begins and after it completes', async () => {
const f = fixture()
const child = createFakeChild()
spawnMock.mockReturnValue(child)
const request = f.exec()
const disposal = f.handler.dispose()
await expect(f.exec()).rejects.toThrow()
expect(spawnMock).toHaveBeenCalledTimes(1)
child.emit('close', null)
await Promise.all([request, disposal])
await expect(f.exec()).rejects.toThrow()
expect(spawnMock).toHaveBeenCalledTimes(1)
})
it('does not spawn when shutdown fences during login-shell resolution, and admits after reopen', async () => {
const f = fixture()
let resolveShell!: (env: NodeJS.ProcessEnv) => void
loginShellMock.mockReturnValueOnce(
new Promise<NodeJS.ProcessEnv>((resolve) => {
resolveShell = resolve
})
)
const request = f.exec({ shell: true })
await f.handler.dispose()
resolveShell({})
await expect(request).rejects.toThrow('relay_agent_execution_shutdown_fenced')
expect(spawnMock).not.toHaveBeenCalled()
f.handler.reopen()
const child = createFakeChild()
spawnMock.mockReturnValue(child)
const next = f.exec()
child.emit('close', 0)
await next
expect(spawnMock).toHaveBeenCalledOnce()
})
it('kills a child tracked after the fence instead of throwing', async () => {
const lifetime = new RelayAgentProcessLifetime()
void lifetime.dispose()
const child = createFakeChild()
expect(() =>
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: track reads only pid, kill and the close/error events the fake emits.
lifetime.track(child as unknown as Parameters<typeof lifetime.track>[0])
).not.toThrow()
if (process.platform !== 'win32') {
expect(child.kill).toHaveBeenCalledWith('SIGKILL')
}
const disposal = lifetime.dispose()
child.emit('close', null)
await disposal
})
it('retains timed-out children until close rather than treating RPC settlement as exit', async () => {
const f = fixture()
const child = createFakeChild()
spawnMock.mockReturnValue(child)
const request = f.exec({ cwd: '/repo' })
await vi.advanceTimersByTimeAsync(1000)
await expect(request).resolves.toMatchObject({ timedOut: true })
let finished = false
const disposal = f.handler.dispose().then(() => {
finished = true
})
await Promise.resolve()
expect(finished).toBe(false)
child.emit('error', new Error('late child error'))
await Promise.resolve()
expect(finished).toBe(false)
child.emit('close', null)
await disposal
expect(finished).toBe(true)
expect(child.listenerCount('error')).toBe(0)
expect(child.listenerCount('close')).toBe(0)
})
it('resolves disposal with no children or only already-closed children', async () => {
await fixture().handler.dispose()
const f = fixture()
const child = createFakeChild()
spawnMock.mockReturnValue(child)
const request = f.exec()
child.emit('close', 0)
await request
await f.handler.dispose()
expect(child.kill).not.toHaveBeenCalled()
})
it('rejects within the close deadline and re-checks without re-killing on retry', async () => {
const f = fixture()
const child = createFakeChild()
spawnMock.mockReturnValue(child)
const request = f.exec({ timeoutMs: 60_000 })
const signals = () =>
process.platform === 'win32'
? vi.mocked(execFile).mock.calls.length
: child.kill.mock.calls.length
const first = f.handler.dispose()
const firstOutcome = first.then(
() => 'resolved',
(error: Error) => error.message
)
expect(signals()).toBe(1)
await vi.advanceTimersByTimeAsync(RELAY_AGENT_CLOSE_DEADLINE_MS)
await expect(firstOutcome).resolves.toBe('relay_agent_execution_shutdown_incomplete')
const retry = f.handler.dispose()
expect(signals()).toBe(1)
child.emit('close', null)
await expect(retry).resolves.toBeUndefined()
await request
})
@@ -1,46 +0,0 @@
import { EventEmitter } from 'node:events'
import { vi } from 'vitest'
import type { MethodHandler, RequestContext } from '../wsl-guest/dispatcher'
import { AgentExecHandler } from './agent-exec-handler'
export function withPlatform<T>(platform: NodeJS.Platform, fn: () => T): T {
const original = process.platform
Object.defineProperty(process, 'platform', { configurable: true, value: platform })
try {
return fn()
} finally {
Object.defineProperty(process, 'platform', { configurable: true, value: original })
}
}
export type FakeChild = EventEmitter & {
pid: number
kill: ReturnType<typeof vi.fn>
stdout: EventEmitter
stderr: EventEmitter
stdin: { end: ReturnType<typeof vi.fn> }
}
export function createFakeChild(): FakeChild {
return Object.assign(new EventEmitter(), {
pid: 12345,
kill: vi.fn(),
stdout: new EventEmitter(),
stderr: new EventEmitter(),
stdin: { end: vi.fn() }
})
}
export function createHandlers(): Map<string, MethodHandler> {
const handlers = new Map<string, MethodHandler>()
new AgentExecHandler({
onRequest: (method: string, handler: MethodHandler): void => {
handlers.set(method, handler)
}
} as never)
return handlers
}
export function requestContext(clientId = 1): RequestContext {
return { clientId, isStale: () => false }
}
@@ -1,108 +0,0 @@
import { exec, spawn } from 'node:child_process'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type * as ChildProcess from 'node:child_process'
import {
createFakeChild,
createHandlers,
requestContext,
withPlatform
} from './agent-exec-handler-test-harness'
vi.mock('child_process', async (importOriginal) => {
const actual = await importOriginal<typeof ChildProcess>()
return {
...actual,
exec: vi.fn(),
spawn: vi.fn()
}
})
const spawnMock = vi.mocked(spawn)
const execMock = vi.mocked(exec)
describe('AgentExecHandler Windows command spawning', () => {
beforeEach(() => {
spawnMock.mockReset()
execMock.mockReset()
})
it('resolves bare Windows agent commands to batch shims before spawning', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'orca-agent-exec-'))
const originalComSpec = process.env.ComSpec
process.env.ComSpec = 'C:\\Windows\\System32\\cmd.exe'
try {
await withPlatform('win32', async () => {
const codexShim = join(tempDir, 'codex.cmd')
writeFileSync(codexShim, '@echo off\r\n')
const child = createFakeChild()
spawnMock.mockReturnValue(child as never)
const handlers = createHandlers()
const pending = handlers.get('agent.execNonInteractive')!(
{
binary: 'codex',
args: ['exec', '-s', 'read-only'],
cwd: 'C:\\repo',
stdin: 'PROMPT',
timeoutMs: 5_000,
env: { PATH: tempDir }
},
requestContext()
)
child.emit('close', 0)
await expect(pending).resolves.toMatchObject({
exitCode: 0,
timedOut: false
})
expect(spawnMock).toHaveBeenCalledWith(
'C:\\Windows\\System32\\cmd.exe',
['/d', '/s', '/c', `"${codexShim}" "exec" "-s" "read-only"`],
{
cwd: 'C:\\repo',
env: expect.objectContaining({ PATH: tempDir }),
stdio: ['pipe', 'pipe', 'pipe'],
windowsHide: true
}
)
})
} finally {
rmSync(tempDir, { recursive: true, force: true })
if (originalComSpec === undefined) {
delete process.env.ComSpec
} else {
process.env.ComSpec = originalComSpec
}
}
})
it('rejects unsafe args when routing Windows batch shims through cmd.exe', async () => {
await withPlatform('win32', async () => {
const handlers = createHandlers()
const result = await handlers.get('agent.execNonInteractive')!(
{
binary: 'C:\\tools\\agent.cmd',
args: ['hello & goodbye'],
cwd: 'C:\\repo',
stdin: null,
timeoutMs: 5_000
},
requestContext()
)
expect(result).toEqual({
stdout: '',
stderr: '',
exitCode: null,
timedOut: false,
spawnError: 'UNSAFE_WINDOWS_BATCH_ARGUMENTS'
})
expect(spawnMock).not.toHaveBeenCalled()
})
})
})
-536
View File
@@ -1,536 +0,0 @@
import { execFile, spawn } from 'node:child_process'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as ChildProcess from 'node:child_process'
import { createFakeChild, createHandlers, requestContext } from './agent-exec-handler-test-harness'
import { TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV } from '../shared/terminal-git-credential-guard'
vi.mock('child_process', async (importOriginal) => {
const actual = await importOriginal<typeof ChildProcess>()
return {
...actual,
execFile: vi.fn(),
spawn: vi.fn()
}
})
const spawnMock = vi.mocked(spawn)
const execFileMock = vi.mocked(execFile)
type AgentExecResult = { exitCode: number | null; timedOut: boolean }
const GUARD_OWNED_ENV_RE = /^(?:GIT_CONFIG_(?:COUNT|KEY_\d+|VALUE_\d+)|WSLENV)$/
describe('AgentExecHandler', () => {
let ambientGuardEnv: Record<string, string | undefined> = {}
beforeEach(() => {
spawnMock.mockReset()
execFileMock.mockReset()
// Why: the guard rewrites these, so an already-guarded runner (Orca guards
// its own agent terminals) would not see its ambient values passed through.
ambientGuardEnv = {}
for (const key of Object.keys(process.env).filter((name) => GUARD_OWNED_ENV_RE.test(name))) {
ambientGuardEnv[key] = process.env[key]
delete process.env[key]
}
})
afterEach(() => {
vi.unstubAllEnvs()
for (const [key, value] of Object.entries(ambientGuardEnv)) {
if (value !== undefined) {
process.env[key] = value
}
}
})
it('executes a non-interactive command with captured output and stdin', async () => {
const child = createFakeChild()
spawnMock.mockReturnValue(child as never)
const handlers = createHandlers()
const pending = handlers.get('agent.execNonInteractive')!(
{
binary: 'agent',
args: ['--flag', 42],
cwd: '/repo',
stdin: 'PROMPT',
timeoutMs: 5_000
},
requestContext()
)
child.stdout.emit('data', Buffer.from('message'))
child.stderr.emit('data', Buffer.from('warning'))
child.emit('close', 0)
await expect(pending).resolves.toEqual({
stdout: 'message',
stderr: 'warning',
exitCode: 0,
timedOut: false,
canceled: false
})
expect(spawnMock).toHaveBeenCalledWith('agent', ['--flag', '42'], {
cwd: '/repo',
env: expect.objectContaining({
...process.env,
GIT_TERMINAL_PROMPT: '0',
GCM_INTERACTIVE: 'never'
}),
stdio: ['pipe', 'pipe', 'pipe'],
windowsHide: true
})
expect(child.stdin.end).toHaveBeenCalledWith('PROMPT')
})
it('merges caller-supplied provider environment into the spawned command environment', async () => {
vi.stubEnv('ORCA_EXEC_INHERITED', 'inherited-value')
const child = createFakeChild()
spawnMock.mockReturnValue(child as never)
const handlers = createHandlers()
const pending = handlers.get('agent.execNonInteractive')!(
{
binary: 'codex',
args: ['exec'],
cwd: '/repo',
stdin: 'PROMPT',
timeoutMs: 5_000,
env: {
CODEX_HOME: '/managed/codex-home',
PATH: '/managed/bin'
}
},
requestContext()
)
child.emit('close', 0)
await expect(pending).resolves.toMatchObject({
exitCode: 0,
timedOut: false
})
expect(spawnMock).toHaveBeenCalledWith('codex', ['exec'], {
cwd: '/repo',
env: expect.objectContaining({
ORCA_EXEC_INHERITED: 'inherited-value',
CODEX_HOME: '/managed/codex-home',
PATH: '/managed/bin'
}),
stdio: ['pipe', 'pipe', 'pipe'],
windowsHide: true
})
})
it('honors Windows override casing while replacing indexed Git configuration', async () => {
const child = createFakeChild()
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This fixture supplies every child event and stream used by the handler.
spawnMock.mockReturnValue(child as never)
const originalPlatform = process.platform
const originalPath = process.env.PATH
process.env.PATH = 'inherited-path'
process.env.GIT_CONFIG_COUNT = '2'
process.env.GIT_CONFIG_KEY_1 = 'stale.key'
process.env.GIT_CONFIG_VALUE_1 = 'stale-value'
try {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
const handlers = createHandlers()
const pending = handlers.get('agent.execNonInteractive')!(
{
binary: 'agent.exe',
args: [],
env: {
Path: 'override-path',
token: 'override-token',
git_config_count: '1',
git_config_key_0: 'http.proxy',
git_config_value_0: 'proxy-value'
}
},
requestContext()
)
child.emit('close', 0)
await expect(pending).resolves.toMatchObject({ exitCode: 0 })
const env = spawnMock.mock.calls[0][2]?.env
expect(env).toMatchObject({
PATH: 'override-path',
TOKEN: 'override-token',
GIT_CONFIG_KEY_0: 'http.proxy',
GIT_CONFIG_VALUE_0: 'proxy-value'
})
expect(env).not.toHaveProperty('Path')
expect(env).not.toHaveProperty('git_config_count')
expect(Object.values(env ?? {})).not.toContain('stale.key')
expect(Object.values(env ?? {})).not.toContain('stale-value')
} finally {
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
if (originalPath === undefined) {
delete process.env.PATH
} else {
process.env.PATH = originalPath
}
delete process.env.GIT_CONFIG_COUNT
delete process.env.GIT_CONFIG_KEY_1
delete process.env.GIT_CONFIG_VALUE_1
}
})
it('consumes an unattended marker and applies the full Git guard on the relay host', async () => {
const child = createFakeChild()
spawnMock.mockReturnValue(child as never)
const handlers = createHandlers()
const pending = handlers.get('agent.execNonInteractive')!(
{
binary: '/bin/bash',
args: ['-lc', 'git fetch'],
cwd: '/repo',
timeoutMs: 5_000,
env: { [TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV]: 'guard' }
},
requestContext()
)
child.emit('close', 0)
await expect(pending).resolves.toMatchObject({ exitCode: 0 })
const env = spawnMock.mock.calls[0]?.[2]?.env as Record<string, string>
expect(env[TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV]).toBeUndefined()
expect(env.GIT_TERMINAL_PROMPT).toBe('0')
expect(env.GCM_INTERACTIVE).toBe('never')
expect(Object.values(env)).toContain('credential.interactive')
expect(Object.values(env)).toContain('credential.guiPrompt')
})
it('guards wrapped agents after atomically replacing inherited indexed config', async () => {
const keys = [
'GIT_CONFIG_COUNT',
'GIT_CONFIG_KEY_0',
'GIT_CONFIG_VALUE_0',
'GIT_CONFIG_KEY_1',
'GIT_CONFIG_VALUE_1'
] as const
const saved = Object.fromEntries(keys.map((key) => [key, process.env[key]]))
process.env.GIT_CONFIG_COUNT = '2'
process.env.GIT_CONFIG_KEY_0 = 'base.one'
process.env.GIT_CONFIG_VALUE_0 = 'one'
process.env.GIT_CONFIG_KEY_1 = 'base.two'
process.env.GIT_CONFIG_VALUE_1 = 'two'
try {
const child = createFakeChild()
spawnMock.mockReturnValue(child as never)
const handlers = createHandlers()
const pending = handlers.get('agent.execNonInteractive')!(
{
binary: 'npx',
args: ['codex', 'exec'],
cwd: '/repo',
timeoutMs: 5_000,
env: {
GIT_CONFIG_COUNT: '1',
GIT_CONFIG_KEY_0: 'http.proxy',
GIT_CONFIG_VALUE_0: 'http://proxy.invalid'
}
},
requestContext()
)
child.emit('close', 0)
await expect(pending).resolves.toMatchObject({ exitCode: 0 })
const env = spawnMock.mock.calls[0]?.[2]?.env as Record<string, string>
expect(env.GIT_TERMINAL_PROMPT).toBe('0')
expect(env.GIT_CONFIG_COUNT).toBe('3')
expect(env.GIT_CONFIG_KEY_0).toBe('http.proxy')
expect(env.GIT_CONFIG_KEY_1).toBe('credential.interactive')
expect(env.GIT_CONFIG_KEY_2).toBe('credential.guiPrompt')
expect(Object.values(env)).not.toContain('base.two')
} finally {
for (const key of keys) {
if (saved[key] === undefined) {
delete process.env[key]
} else {
process.env[key] = saved[key]
}
}
}
})
it('cancels the in-flight command for the requested cwd', async () => {
const child = createFakeChild()
spawnMock.mockReturnValue(child as never)
const handlers = createHandlers()
const pending = handlers.get('agent.execNonInteractive')!(
{
binary: 'agent',
args: [],
cwd: '/repo',
stdin: null,
timeoutMs: 5_000
},
requestContext()
)
await expect(
handlers.get('agent.cancelExec')!({ cwd: '/repo' }, requestContext())
).resolves.toEqual({ canceled: true })
if (process.platform === 'win32') {
expect(execFileMock).toHaveBeenCalledWith(
'taskkill',
['/pid', '12345', '/T', '/F'],
expect.any(Function)
)
} else {
expect(child.kill).toHaveBeenCalledWith('SIGKILL')
}
child.emit('close', null)
await expect(pending).resolves.toMatchObject({
exitCode: null,
timedOut: false,
canceled: true
})
})
it('cancels only the matching operation lane for a cwd', async () => {
const commitChild = createFakeChild()
const pullRequestChild = createFakeChild()
pullRequestChild.pid = 12346
spawnMock
.mockReturnValueOnce(commitChild as never)
.mockReturnValueOnce(pullRequestChild as never)
const handlers = createHandlers()
const commit = handlers.get('agent.execNonInteractive')!(
{
binary: 'agent',
args: [],
cwd: '/repo',
stdin: null,
timeoutMs: 5_000,
operation: 'commit-message'
},
requestContext()
)
const pullRequest = handlers.get('agent.execNonInteractive')!(
{
binary: 'agent',
args: [],
cwd: '/repo',
stdin: null,
timeoutMs: 5_000,
operation: 'pull-request-fields'
},
requestContext()
)
await expect(
handlers.get('agent.cancelExec')!(
{ cwd: '/repo', operation: 'commit-message' },
requestContext()
)
).resolves.toEqual({ canceled: true })
if (process.platform === 'win32') {
expect(execFileMock).toHaveBeenCalledWith(
'taskkill',
['/pid', '12345', '/T', '/F'],
expect.any(Function)
)
expect(execFileMock).not.toHaveBeenCalledWith(
'taskkill',
['/pid', '12346', '/T', '/F'],
expect.any(Function)
)
} else {
expect(commitChild.kill).toHaveBeenCalledWith('SIGKILL')
expect(pullRequestChild.kill).not.toHaveBeenCalled()
}
commitChild.emit('close', null)
pullRequestChild.stdout.emit(
'data',
Buffer.from('{"base":"main","title":"Update README","body":"Details","draft":false}')
)
pullRequestChild.emit('close', 0)
await expect(commit).resolves.toMatchObject({
exitCode: null,
timedOut: false,
canceled: true
})
await expect(pullRequest).resolves.toMatchObject({
exitCode: 0,
timedOut: false,
canceled: false
})
})
it('kills the active command when the request aborts', async () => {
const child = createFakeChild()
spawnMock.mockReturnValue(child as never)
const handlers = createHandlers()
const controller = new AbortController()
const pending = handlers.get('agent.execNonInteractive')!(
{
binary: 'agent',
args: [],
cwd: '/repo',
stdin: null,
timeoutMs: 5_000
},
{ clientId: 1, isStale: () => controller.signal.aborted, signal: controller.signal }
)
controller.abort()
if (process.platform === 'win32') {
expect(execFileMock).toHaveBeenCalledWith(
'taskkill',
['/pid', '12345', '/T', '/F'],
expect.any(Function)
)
} else {
expect(child.kill).toHaveBeenCalledWith('SIGKILL')
}
child.emit('close', null)
await expect(pending).resolves.toMatchObject({
exitCode: null,
timedOut: false,
canceled: true
})
expect(child.stdout.listenerCount('data')).toBe(0)
expect(child.stderr.listenerCount('data')).toBe(0)
expect(child.listenerCount('error')).toBe(0)
expect(child.listenerCount('close')).toBe(0)
})
it('cancels a superseded command in the same operation lane', async () => {
const firstChild = createFakeChild()
const secondChild = createFakeChild()
secondChild.pid = 12346
spawnMock.mockReturnValueOnce(firstChild as never).mockReturnValueOnce(secondChild as never)
const handlers = createHandlers()
const first = handlers.get('agent.execNonInteractive')!(
{
binary: 'agent',
args: ['first'],
cwd: '/repo',
stdin: null,
timeoutMs: 5_000,
operation: 'commit-message'
},
requestContext()
)
const second = handlers.get('agent.execNonInteractive')!(
{
binary: 'agent',
args: ['second'],
cwd: '/repo',
stdin: null,
timeoutMs: 5_000,
operation: 'commit-message'
},
requestContext()
)
if (process.platform === 'win32') {
expect(execFileMock).toHaveBeenCalledWith(
'taskkill',
['/pid', '12345', '/T', '/F'],
expect.any(Function)
)
expect(execFileMock).not.toHaveBeenCalledWith(
'taskkill',
['/pid', '12346', '/T', '/F'],
expect.any(Function)
)
} else {
expect(firstChild.kill).toHaveBeenCalledWith('SIGKILL')
expect(secondChild.kill).not.toHaveBeenCalled()
}
await expect(
handlers.get('agent.cancelExec')!(
{ cwd: '/repo', operation: 'commit-message' },
requestContext()
)
).resolves.toEqual({ canceled: true })
if (process.platform === 'win32') {
expect(execFileMock).toHaveBeenCalledWith(
'taskkill',
['/pid', '12346', '/T', '/F'],
expect.any(Function)
)
} else {
expect(secondChild.kill).toHaveBeenCalledWith('SIGKILL')
}
firstChild.emit('close', null)
secondChild.emit('close', null)
await expect(first).resolves.toMatchObject({ canceled: true })
await expect(second).resolves.toMatchObject({ canceled: true })
})
it('reports when cancellation has no matching in-flight command', async () => {
const handlers = createHandlers()
await expect(
handlers.get('agent.cancelExec')!({ cwd: '/repo' }, requestContext())
).resolves.toEqual({ canceled: false })
})
it('settles timed-out commands even when the killed child does not close', async () => {
vi.useFakeTimers()
try {
const child = createFakeChild()
spawnMock.mockReturnValue(child as never)
const handlers = createHandlers()
const pending = handlers.get('agent.execNonInteractive')!(
{
binary: 'agent',
args: [],
cwd: '/repo',
stdin: null,
timeoutMs: 5_000
},
requestContext()
) as Promise<AgentExecResult>
const outcomePromise = pending.then((result) =>
result.timedOut ? `timed-out:${result.exitCode}` : 'not-timed-out'
)
await vi.advanceTimersByTimeAsync(5_000)
const outcome = await Promise.race([outcomePromise, Promise.resolve('pending')])
expect(outcome).toBe('timed-out:null')
if (process.platform === 'win32') {
expect(execFileMock).toHaveBeenCalledWith(
'taskkill',
['/pid', '12345', '/T', '/F'],
expect.any(Function)
)
} else {
expect(child.kill).toHaveBeenCalledWith('SIGKILL')
}
expect(child.stdout.listenerCount('data')).toBe(0)
expect(child.stderr.listenerCount('data')).toBe(0)
// Shutdown keeps tracking the child until it physically closes.
expect(child.listenerCount('error')).toBe(1)
expect(child.listenerCount('close')).toBe(1)
child.emit('close', null)
expect(child.listenerCount('error')).toBe(0)
expect(child.listenerCount('close')).toBe(0)
} finally {
vi.useRealTimers()
}
})
})
-344
View File
@@ -1,344 +0,0 @@
import { mergeCommandEnvironment } from '../shared/command-environment'
import { PromiseSettlementWaiters } from '../shared/promise-settlement-waiters'
import { spawn, type ChildProcess } from 'node:child_process'
import { existsSync } from 'node:fs'
import { delimiter, join } from 'node:path'
import type { RelayDispatcher, RequestContext } from '../wsl-guest/dispatcher'
import { applyTerminalGitCredentialPromptGuard } from '../shared/terminal-git-credential-guard'
import { mergeGitConfigEnvProtocol } from '../shared/git-credential-prompt-env'
import { terminateRelaySubprocessTree } from './subprocess-tree-termination'
import { RelayAgentProcessLifetime } from './relay-agent-process-lifetime'
import { resolveLoginShellEnvironment } from '../main/startup/login-shell-environment'
import { errorMessage } from '../shared/error-message'
const DEFAULT_TIMEOUT_MS = 60_000
const MAX_TIMEOUT_MS = 5 * 60 * 1000
const MAX_OUTPUT_BYTES = 4 * 1024 * 1024
const WINDOWS_BATCH_UNSAFE_ARGUMENTS_ERROR = 'UNSAFE_WINDOWS_BATCH_ARGUMENTS'
function getCmdExePath(): string {
return process.env.ComSpec || `${process.env.SystemRoot ?? 'C:\\Windows'}\\System32\\cmd.exe`
}
function isWindowsBatchScript(commandPath: string): boolean {
return process.platform === 'win32' && /\.(cmd|bat)$/i.test(commandPath)
}
function hasUnsafeWindowsBatchSyntax(value: string): boolean {
return /[&|<>^"%!\r\n]/.test(value)
}
function quoteWindowsBatchToken(value: string): string {
if (hasUnsafeWindowsBatchSyntax(value)) {
throw new Error(WINDOWS_BATCH_UNSAFE_ARGUMENTS_ERROR)
}
return `"${value}"`
}
function resolveWindowsCommand(binary: string, env: NodeJS.ProcessEnv): string {
if (process.platform !== 'win32') {
return binary
}
if (/[\\/]/.test(binary) || /\.[a-z0-9]+$/i.test(binary)) {
return binary
}
const pathEnv = env.PATH ?? env.Path
if (!pathEnv) {
return binary
}
const names = [`${binary}.cmd`, `${binary}.exe`, `${binary}.bat`, binary]
for (const directory of pathEnv.split(delimiter).filter(Boolean)) {
for (const name of names) {
const candidate = join(directory, name)
if (existsSync(candidate)) {
return candidate
}
}
}
return binary
}
function getWindowsSafeSpawn(
binary: string,
args: string[],
env: NodeJS.ProcessEnv
): { spawnCmd: string; spawnArgs: string[] } {
const resolvedBinary = resolveWindowsCommand(binary, env)
if (!isWindowsBatchScript(resolvedBinary)) {
return { spawnCmd: resolvedBinary, spawnArgs: args }
}
const commandLine = [resolvedBinary, ...args].map(quoteWindowsBatchToken).join(' ')
return { spawnCmd: getCmdExePath(), spawnArgs: ['/d', '/s', '/c', commandLine] }
}
type ExecParams = {
binary: unknown
args: unknown
cwd: unknown
stdin: unknown
timeoutMs: unknown
env: unknown
operation: unknown
shell: unknown
}
type CancelParams = {
cwd: unknown
operation: unknown
}
function laneKeyFor(cwd: string, operation: unknown): string {
const op = typeof operation === 'string' && operation ? operation : 'default'
return JSON.stringify([op, cwd])
}
type InFlightExec = { child?: ChildProcess; cancel: () => void }
type ExecResult = {
stdout: string
stderr: string
exitCode: number | null
timedOut: boolean
/** Set when the user canceled the exec via `agent.cancelExec`. */
canceled?: boolean
/** Set when the binary could not be spawned (e.g. ENOENT). */
spawnError?: string
}
/**
* Non-interactive subprocess exec on the remote host. Used by the AI commit
* message generator to spawn agent CLIs (claude, codex, …) with the staged
* diff piped via stdin and the output captured to stdout. Distinct from
* `pty.spawn` because we want no terminal allocation, no escape sequences,
* and a clean exit code instead of an interactive session.
*/
export class AgentExecHandler {
private readonly processLifetime = new RelayAgentProcessLifetime()
// Why: commit-message and PR-field generation can run together for one cwd;
// operation lanes let cancel target only the user-visible job that stopped.
private inFlightByLane = new Map<string, InFlightExec>()
constructor(dispatcher: RelayDispatcher) {
dispatcher.onRequest('agent.execNonInteractive', (p, context) =>
this.exec(p as ExecParams, context)
)
dispatcher.onRequest('agent.cancelExec', (p) => this.cancel(p as CancelParams))
}
dispose(): Promise<void> {
return this.processLifetime.dispose()
}
reopen = (): void => this.processLifetime.reopen()
private async cancel(params: CancelParams): Promise<{ canceled: boolean }> {
const cwd = typeof params.cwd === 'string' ? params.cwd : ''
const entry = this.inFlightByLane.get(laneKeyFor(cwd, params.operation))
if (!entry) {
return { canceled: false }
}
entry.cancel()
return { canceled: true }
}
private async exec(params: ExecParams, context?: RequestContext): Promise<ExecResult> {
this.processLifetime.assertAdmission()
const binary = typeof params.binary === 'string' ? params.binary : ''
if (!binary) {
throw new Error('agent.execNonInteractive: binary is required')
}
const args = Array.isArray(params.args) ? params.args.map((a) => String(a)) : []
const cwd = typeof params.cwd === 'string' && params.cwd.length > 0 ? params.cwd : undefined
const stdinPayload = typeof params.stdin === 'string' ? params.stdin : null
const requestedTimeout =
typeof params.timeoutMs === 'number' ? params.timeoutMs : DEFAULT_TIMEOUT_MS
const deadline = Date.now() + Math.max(1_000, Math.min(MAX_TIMEOUT_MS, requestedTimeout))
const extraEnv =
params.env && typeof params.env === 'object' && !Array.isArray(params.env)
? (params.env as Record<string, string>)
: null
let hostEnv = process.env
if (params.shell === true) {
const timeoutError = new Error('Profile resolution exceeded the request deadline')
const controller = new AbortController()
const key = laneKeyFor(cwd ?? '', params.operation)
const pending = { cancel: (): void => controller.abort() }
this.inFlightByLane.get(key)?.cancel()
this.inFlightByLane.set(key, pending)
context?.signal?.addEventListener('abort', pending.cancel, { once: true })
if (context?.signal?.aborted) {
pending.cancel()
}
try {
hostEnv = await new PromiseSettlementWaiters(
resolveLoginShellEnvironment({ env: process.env })
).wait({
signal: controller.signal,
timeoutMs: Math.max(1, deadline - Date.now()),
createTimeoutError: () => timeoutError
})
} catch (error) {
if (error === timeoutError) {
return { stdout: '', stderr: '', exitCode: null, timedOut: true }
}
if (controller.signal.aborted) {
return { stdout: '', stderr: '', exitCode: null, timedOut: false, canceled: true }
}
throw error
} finally {
context?.signal?.removeEventListener('abort', pending.cancel)
if (this.inFlightByLane.get(key) === pending) {
this.inFlightByLane.delete(key)
}
}
if (controller.signal.aborted) {
return { stdout: '', stderr: '', exitCode: null, timedOut: false, canceled: true }
}
}
// Why again: shutdown may have fenced while the login shell resolved.
this.processLifetime.assertAdmission()
if (Date.now() >= deadline) {
return { stdout: '', stderr: '', exitCode: null, timedOut: true }
}
const baseEnv = mergeCommandEnvironment(hostEnv, extraEnv ? {} : undefined, process.platform)
const overrides = mergeCommandEnvironment({}, extraEnv ?? undefined, process.platform)
const spawnEnv = Object.fromEntries(
Object.entries(mergeGitConfigEnvProtocol(baseEnv ?? hostEnv, overrides)).filter(
(entry): entry is [string, string] => typeof entry[1] === 'string'
)
)
// Why: this RPC has no interactive terminal, regardless of which wrapper
// launches the agent or hook command.
applyTerminalGitCredentialPromptGuard(spawnEnv, {
isUnattended: true,
platform: process.platform
})
return new Promise<ExecResult>((resolve) => {
let child
try {
const { spawnCmd, spawnArgs } = getWindowsSafeSpawn(binary, args, spawnEnv)
if (Date.now() >= deadline) {
resolve({ stdout: '', stderr: '', exitCode: null, timedOut: true })
return
}
child = spawn(spawnCmd, spawnArgs, {
cwd,
env: spawnEnv,
stdio: ['pipe', 'pipe', 'pipe'],
windowsHide: true
})
} catch (error) {
resolve({
stdout: '',
stderr: '',
exitCode: null,
timedOut: false,
spawnError: errorMessage(error)
})
return
}
this.processLifetime.track(child)
let stdout = ''
let stderr = ''
let stdoutBytes = 0
let stderrBytes = 0
let timedOut = false
let canceled = false
let settled = false
const laneKey = typeof cwd === 'string' ? laneKeyFor(cwd, params.operation) : ''
let entry: InFlightExec | null = null
let timer: ReturnType<typeof setTimeout> | null = null
let detachChildListeners = (): void => {}
let detachRequestAbortListener = (): void => {}
const finish = (result: ExecResult): void => {
if (settled) {
return
}
settled = true
if (timer) {
clearTimeout(timer)
timer = null
}
detachRequestAbortListener()
detachChildListeners()
if (laneKey && entry && this.inFlightByLane.get(laneKey) === entry) {
this.inFlightByLane.delete(laneKey)
}
resolve(result)
}
const cancelCurrent = (): void => {
canceled = true
terminateRelaySubprocessTree(child)
}
if (laneKey) {
// Why: the relay owns one visible non-interactive job per cwd+operation.
// Replacing the lane without canceling the prior child would orphan
// that process until timeout because future cancelExec calls reach only
// the newest map entry.
this.inFlightByLane.get(laneKey)?.cancel()
entry = { child, cancel: cancelCurrent }
this.inFlightByLane.set(laneKey, entry)
}
const onStdoutData = (chunk: Buffer): void => {
stdoutBytes += chunk.byteLength
if (stdoutBytes > MAX_OUTPUT_BYTES) {
terminateRelaySubprocessTree(child)
return
}
stdout += chunk.toString('utf-8')
}
const onStderrData = (chunk: Buffer): void => {
stderrBytes += chunk.byteLength
if (stderrBytes > MAX_OUTPUT_BYTES) {
terminateRelaySubprocessTree(child)
return
}
stderr += chunk.toString('utf-8')
}
const onError = (error: Error): void =>
finish({ stdout, stderr, exitCode: null, timedOut, spawnError: error.message })
const onClose = (code: number | null): void =>
finish({ stdout, stderr, exitCode: code, timedOut, canceled })
child.stdout?.on('data', onStdoutData)
child.stderr?.on('data', onStderrData)
child.on('error', onError)
child.on('close', onClose)
detachChildListeners = () => {
child.stdout?.off('data', onStdoutData)
child.stderr?.off('data', onStderrData)
child.off('error', onError)
child.off('close', onClose)
}
const expireCurrent = (): void => {
timedOut = true
// Why: wrappers and signal-trapping CLIs require terminating the whole tree.
terminateRelaySubprocessTree(child)
finish({ stdout, stderr, exitCode: null, timedOut, canceled })
}
const remainingTimeoutMs = deadline - Date.now()
if (remainingTimeoutMs <= 0) {
expireCurrent()
return
}
timer = setTimeout(expireCurrent, remainingTimeoutMs)
if (context?.signal) {
if (context.signal.aborted) {
cancelCurrent()
} else {
context.signal.addEventListener('abort', cancelCurrent, { once: true })
detachRequestAbortListener = () => {
context.signal?.removeEventListener('abort', cancelCurrent)
}
}
}
child.stdin?.end(stdinPayload ?? undefined)
})
}
}
@@ -1,356 +0,0 @@
import { execFile, spawn } from 'node:child_process'
import { existsSync } from 'node:fs'
import type * as FileSystem from 'node:fs'
import type * as ChildProcess from 'node:child_process'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { resolveLoginShellEnvironment } from '../main/startup/login-shell-environment'
import { createFakeChild, createHandlers, requestContext } from './agent-exec-handler-test-harness'
vi.mock('node:child_process', async (importOriginal) => ({
...(await importOriginal<typeof ChildProcess>()),
spawn: vi.fn(),
execFile: vi.fn()
}))
vi.mock('../main/startup/login-shell-environment', () => ({
resolveLoginShellEnvironment: vi.fn()
}))
vi.mock('node:fs', async (importOriginal) => {
const original = await importOriginal<typeof FileSystem>()
return { ...original, existsSync: vi.fn(original.existsSync) }
})
describe('relay headless generation shell environment', () => {
afterEach(() => {
vi.useRealTimers()
vi.clearAllMocks()
})
it('uses the execution host profile PATH and retains explicit command overrides', async () => {
vi.mocked(resolveLoginShellEnvironment).mockResolvedValue({
PATH: '/profile/bin:/usr/bin',
MODEL: 'profile'
})
const child = createFakeChild()
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This fake provides the streams and lifecycle used by the relay.
vi.mocked(spawn).mockReturnValue(child as never)
const handlers = createHandlers()
const pending = handlers.get('agent.execNonInteractive')?.(
{ binary: 'opencode', args: ['run'], cwd: '/repo', shell: true, env: { MODEL: 'override' } },
requestContext()
)
await vi.waitFor(() => expect(spawn).toHaveBeenCalled())
child.emit('close', 0)
await expect(pending).resolves.toMatchObject({ exitCode: 0 })
expect(spawn).toHaveBeenLastCalledWith(
'opencode',
['run'],
expect.objectContaining({
env: expect.objectContaining({ PATH: '/profile/bin:/usr/bin', MODEL: 'override' })
})
)
})
it('does not start generation after cancellation during profile resolution', async () => {
vi.mocked(spawn).mockClear()
let resolveProfile: (env: NodeJS.ProcessEnv) => void = () => {}
vi.mocked(resolveLoginShellEnvironment).mockReturnValue(
new Promise((resolve) => {
resolveProfile = resolve
})
)
const handlers = createHandlers()
const pending = handlers.get('agent.execNonInteractive')?.(
{ binary: 'opencode', args: ['run'], cwd: '/repo', operation: 'commit-message', shell: true },
requestContext()
)
await expect(
handlers.get('agent.cancelExec')?.(
{ cwd: '/repo', operation: 'commit-message' },
requestContext()
)
).resolves.toEqual({ canceled: true })
resolveProfile({ PATH: '/profile/bin' })
await expect(pending).resolves.toMatchObject({ canceled: true })
expect(spawn).not.toHaveBeenCalled()
})
})
describe('relay generation deadline includes profile resolution', () => {
afterEach(() => {
vi.useRealTimers()
vi.clearAllMocks()
})
it('counts both five-second primary and fallback profile probes in the generation budget', async () => {
vi.useFakeTimers()
vi.mocked(resolveLoginShellEnvironment).mockImplementation(
() =>
new Promise((resolve) =>
setTimeout(() => {
setTimeout(() => resolve({ PATH: '/profile/bin' }), 5_000)
}, 5_000)
)
)
const child = createFakeChild()
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The existing fake supplies the relay streams and lifecycle.
vi.mocked(spawn).mockReturnValue(child as never)
const handlers = createHandlers()
const pending = handlers.get('agent.execNonInteractive')?.(
{ binary: 'opencode', args: ['run'], cwd: '/repo', shell: true, timeoutMs: 12_000 },
requestContext()
)
let result: unknown
void pending?.then((value) => {
result = value
})
await vi.advanceTimersByTimeAsync(10_000)
expect(spawn).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(1_999)
expect(result).toBeUndefined()
await vi.advanceTimersByTimeAsync(1)
expect(result).toMatchObject({ timedOut: true, exitCode: null })
child.emit('close', 0)
await pending
})
it('settles at the request deadline while the shared profile probe remains pending', async () => {
vi.useFakeTimers()
let resolveProfile: (env: NodeJS.ProcessEnv) => void = () => {}
vi.mocked(resolveLoginShellEnvironment).mockReturnValue(
new Promise((resolve) => {
resolveProfile = resolve
})
)
const child = createFakeChild()
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The existing fake supplies the relay streams and lifecycle.
vi.mocked(spawn).mockReturnValue(child as never)
const handlers = createHandlers()
const pending = handlers.get('agent.execNonInteractive')?.(
{ binary: 'opencode', args: ['run'], cwd: '/repo', shell: true, timeoutMs: 1_000 },
requestContext()
)
let result: unknown
void pending?.then((value) => {
result = value
})
await vi.advanceTimersByTimeAsync(1_000)
try {
expect(result).toMatchObject({ timedOut: true, exitCode: null })
expect(spawn).not.toHaveBeenCalled()
} finally {
resolveProfile({ PATH: '/profile/bin' })
await vi.advanceTimersByTimeAsync(0)
child.emit('close', 0)
await pending
}
expect(spawn).not.toHaveBeenCalled()
})
it.each(['cancel', 'abort'] as const)(
'settles %s immediately while the shared profile probe remains pending',
async (kind) => {
vi.useFakeTimers()
let resolveProfile: (env: NodeJS.ProcessEnv) => void = () => {}
vi.mocked(resolveLoginShellEnvironment).mockReturnValue(
new Promise((resolve) => {
resolveProfile = resolve
})
)
const handlers = createHandlers()
const controller = new AbortController()
const pending = handlers.get('agent.execNonInteractive')?.(
{
binary: 'opencode',
args: ['run'],
cwd: '/repo',
operation: 'commit-message',
shell: true
},
{ ...requestContext(), signal: controller.signal }
)
let result: unknown
void pending?.then((value) => {
result = value
})
if (kind === 'cancel') {
await handlers.get('agent.cancelExec')?.(
{ cwd: '/repo', operation: 'commit-message' },
requestContext()
)
} else {
controller.abort()
}
await vi.advanceTimersByTimeAsync(0)
try {
expect(result).toMatchObject({ canceled: true, timedOut: false })
expect(spawn).not.toHaveBeenCalled()
} finally {
resolveProfile({ PATH: '/profile/bin' })
await vi.advanceTimersByTimeAsync(0)
await pending
}
}
)
})
describe('relay deadline boundaries and profile failures', () => {
afterEach(() => {
vi.useRealTimers()
vi.clearAllMocks()
})
it('retains the final millisecond of the request budget without resetting the minimum timeout', async () => {
vi.useFakeTimers()
vi.mocked(resolveLoginShellEnvironment).mockImplementation(
() => new Promise((resolve) => setTimeout(() => resolve({ PATH: '/profile/bin' }), 999))
)
const child = createFakeChild()
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The existing fake supplies the relay streams and lifecycle.
vi.mocked(spawn).mockReturnValue(child as never)
const handlers = createHandlers()
const pending = handlers.get('agent.execNonInteractive')?.(
{ binary: 'opencode', args: ['run'], cwd: '/repo', shell: true, timeoutMs: 1_000 },
requestContext()
)
let result: unknown
void pending?.then((value) => {
result = value
})
await vi.advanceTimersByTimeAsync(999)
expect(spawn).toHaveBeenCalledTimes(1)
expect(result).toBeUndefined()
await vi.advanceTimersByTimeAsync(1)
expect(result).toMatchObject({ timedOut: true })
child.emit('close', 0)
await pending
})
it('does not spawn when profile settlement consumes the whole request budget', async () => {
vi.useFakeTimers()
vi.mocked(resolveLoginShellEnvironment).mockImplementation(
() => new Promise((resolve) => setTimeout(() => resolve({ PATH: '/profile/bin' }), 1_000))
)
const child = createFakeChild()
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The existing fake supplies the relay streams and lifecycle.
vi.mocked(spawn).mockReturnValue(child as never)
const handlers = createHandlers()
const pending = handlers.get('agent.execNonInteractive')?.(
{ binary: 'opencode', args: ['run'], cwd: '/repo', shell: true, timeoutMs: 1_000 },
requestContext()
)
let result: unknown
void pending?.then((value) => {
result = value
})
await vi.advanceTimersByTimeAsync(1_000)
try {
expect(result).toMatchObject({ timedOut: true })
expect(spawn).not.toHaveBeenCalled()
} finally {
child.emit('close', 0)
await pending
}
})
it('preserves a profile rejection and removes the pending cancellation lane', async () => {
const failure = new Error('profile resolution refused')
vi.mocked(resolveLoginShellEnvironment).mockRejectedValue(failure)
const handlers = createHandlers()
const pending = handlers.get('agent.execNonInteractive')?.(
{ binary: 'opencode', args: ['run'], cwd: '/repo', operation: 'commit-message', shell: true },
requestContext()
)
await expect(pending).rejects.toBe(failure)
expect(spawn).not.toHaveBeenCalled()
await expect(
handlers.get('agent.cancelExec')?.(
{ cwd: '/repo', operation: 'commit-message' },
requestContext()
)
).resolves.toEqual({ canceled: false })
})
})
describe('relay deadline covers synchronous command startup', () => {
afterEach(() => {
vi.useRealTimers()
vi.mocked(existsSync).mockReset()
vi.clearAllMocks()
})
it('does not spawn after a Windows PATH lookup exhausts the request budget', async () => {
vi.useFakeTimers()
vi.setSystemTime(0)
vi.mocked(resolveLoginShellEnvironment).mockImplementation(() => {
vi.setSystemTime(999)
return Promise.resolve({ PATH: 'C:\\slow-network-bin' })
})
vi.mocked(existsSync).mockImplementation(() => {
vi.setSystemTime(1_001)
return false
})
const child = createFakeChild()
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The existing fake supplies the relay streams and lifecycle.
vi.mocked(spawn).mockReturnValue(child as never)
const originalPlatform = process.platform
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
try {
const pending = createHandlers().get('agent.execNonInteractive')?.(
{ binary: 'opencode', args: ['run'], cwd: 'C:\\repo', shell: true, timeoutMs: 1_000 },
requestContext()
)
await vi.advanceTimersByTimeAsync(0)
try {
expect(existsSync).toHaveBeenCalled()
expect(spawn).not.toHaveBeenCalled()
await expect(pending).resolves.toMatchObject({ timedOut: true, exitCode: null })
} finally {
child.emit('close', 0)
await pending
}
} finally {
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
}
})
it('times out immediately when spawning consumes the remaining request budget', async () => {
vi.useFakeTimers()
vi.setSystemTime(0)
vi.mocked(existsSync).mockReturnValue(false)
const child = createFakeChild()
vi.mocked(spawn).mockImplementation(() => {
vi.setSystemTime(1_001)
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The existing fake supplies the relay streams and lifecycle.
return child as never
})
const handlers = createHandlers()
const pending = handlers.get('agent.execNonInteractive')?.(
{ binary: 'opencode', args: ['run'], cwd: '/repo', timeoutMs: 1_000 },
requestContext()
)
let result: unknown
void pending?.then((value) => {
result = value
})
await vi.advanceTimersByTimeAsync(0)
try {
expect(spawn).toHaveBeenCalledTimes(1)
expect(result).toMatchObject({ timedOut: true, exitCode: null })
if (process.platform === 'win32') {
expect(execFile).toHaveBeenCalledWith(
'taskkill',
['/pid', String(child.pid), '/T', '/F'],
expect.any(Function)
)
} else {
expect(child.kill).toHaveBeenCalled()
}
await expect(
handlers.get('agent.cancelExec')?.({ cwd: '/repo' }, requestContext())
).resolves.toEqual({ canceled: false })
} finally {
child.emit('close', 0)
await pending
}
})
})
@@ -1,228 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { RelayAgentHookServer } from '../wsl-guest/agent-hook-server'
import { RelayAgentHookRuntime } from './relay-agent-hook-runtime'
import { RetiredPaneSurfaceRegistry } from './retired-pane-surfaces'
import type { AgentHookRelayEnvelope } from '../shared/agent-hook-relay'
import { makePaneKey } from '../shared/stable-pane-id'
import type { PtyHandler, PtySurfaceRetiredListener } from './pty-handler'
import type { RelayDispatcher } from '../wsl-guest/dispatcher'
import type { ClaudeTerminalEvidence } from '../shared/claude-terminal-interrupt'
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
const PANE_KEY = makePaneKey('tab-1', LEAF_ID)
function hookMeta(paneKey: string): string {
return Buffer.from([paneKey, 'tab-1', '', 'wt-1', 'remote', '1'].join('\x1f')).toString('base64')
}
async function postHook(
server: RelayAgentHookServer,
paneKey: string,
prompt = 'hi'
): Promise<number> {
const { port, token } = server.getCoordinates()
const res = await fetch(`http://127.0.0.1:${port}/hook/claude`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': token,
'X-Orca-Agent-Hook-Meta-Encoding': 'base64',
'X-Orca-Agent-Hook-Meta': hookMeta(paneKey)
},
body: JSON.stringify({ hook_event_name: 'UserPromptSubmit', prompt })
})
return res.status
}
type CachedPanes = { state: { lastStatusByPaneKey: Map<string, unknown> } }
function cachedPaneKeys(server: RelayAgentHookServer): string[] {
return [...(server as unknown as CachedPanes).state.lastStatusByPaneKey.keys()]
}
describe('relay hook forwarding for a retired pane surface', () => {
let dir: string
let servers: RelayAgentHookServer[]
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'relay-retired-pane-'))
servers = []
})
afterEach(() => {
for (const server of servers) {
server.stop()
}
rmSync(dir, { recursive: true, force: true })
})
async function startServer(options: {
forward: (envelope: AgentHookRelayEnvelope) => void
isPaneSurfaceRetired?: (paneKey: string) => boolean
}): Promise<RelayAgentHookServer> {
const server = new RelayAgentHookServer({ endpointDir: dir, ...options })
servers.push(server)
await server.start({ publishEndpoint: false })
return server
}
it('forwards and caches a post from a pane that still has a tab', async () => {
const retired = new RetiredPaneSurfaceRegistry()
const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>()
const server = await startServer({
forward,
isPaneSurfaceRetired: (paneKey) => retired.isRetired(paneKey)
})
expect(await postHook(server, PANE_KEY)).toBe(204)
expect(forward).toHaveBeenCalledTimes(1)
expect(cachedPaneKeys(server)).toEqual([PANE_KEY])
})
it('does not advertise an orphan agent whose tab was closed', async () => {
const retired = new RetiredPaneSurfaceRegistry()
const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>()
const server = await startServer({
forward,
isPaneSurfaceRetired: (paneKey) => retired.isRetired(paneKey)
})
expect(await postHook(server, PANE_KEY, 'first')).toBe(204)
forward.mockClear()
// The user closes the tab; the agent inside the pane shell survives and keeps posting.
retired.retire(PANE_KEY)
expect(await postHook(server, PANE_KEY, 'orphan')).toBe(204)
expect(forward).not.toHaveBeenCalled()
// The pre-close status must go too, or a reconnect replays it as a live agent pane.
expect(cachedPaneKeys(server)).toEqual([])
})
it('does not replay a cached status for a pane retired after it was cached', async () => {
const retired = new RetiredPaneSurfaceRegistry()
const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>()
const server = await startServer({
forward,
isPaneSurfaceRetired: (paneKey) => retired.isRetired(paneKey)
})
expect(await postHook(server, PANE_KEY)).toBe(204)
forward.mockClear()
retired.retire(PANE_KEY)
expect(server.replayCachedPayloadsForPanes()).toBe(0)
expect(forward).not.toHaveBeenCalled()
expect(cachedPaneKeys(server)).toEqual([])
})
it('forwards again once the pane key is bound by a new PTY', async () => {
const retired = new RetiredPaneSurfaceRegistry()
const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>()
const server = await startServer({
forward,
isPaneSurfaceRetired: (paneKey) => retired.isRetired(paneKey)
})
retired.retire(PANE_KEY)
expect(await postHook(server, PANE_KEY, 'orphan')).toBe(204)
expect(forward).not.toHaveBeenCalled()
retired.restore(PANE_KEY)
expect(await postHook(server, PANE_KEY, 'reopened')).toBe(204)
expect(forward).toHaveBeenCalledTimes(1)
})
it('an embedder that supplies no retirement source keeps forwarding everything', async () => {
const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>()
const server = await startServer({ forward })
expect(await postHook(server, PANE_KEY)).toBe(204)
expect(forward).toHaveBeenCalledTimes(1)
expect(cachedPaneKeys(server)).toEqual([PANE_KEY])
})
})
describe('RelayAgentHookRuntime wiring', () => {
let dir: string
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'relay-retired-pane-runtime-'))
})
afterEach(() => {
rmSync(dir, { recursive: true, force: true })
})
it('routes hook admission through the PTY handler and drops the cache on retirement', async () => {
const retired = new RetiredPaneSurfaceRegistry()
const surfaceRetiredListeners: PtySurfaceRetiredListener[] = []
const setClaudeTerminalEvidenceListener =
vi.fn<(listener: (paneKey: string, evidence: ClaudeTerminalEvidence) => void) => void>()
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this stand-in implements every PtyHandler member the hook runtime registers or reads.
const ptyHandler = {
addEnvAugmenter: vi.fn(),
setAgentPresenceTrigger: vi.fn(),
setClaudeTerminalEvidenceListener,
setExitListener: vi.fn(),
getAgentLaunchToken: () => undefined,
setSurfaceRetiredListener: vi.fn((listener: PtySurfaceRetiredListener | null) => {
if (listener) {
surfaceRetiredListeners.push(listener)
}
}),
isPaneSurfaceRetired: (paneKey: string) => retired.isRetired(paneKey)
} as unknown as PtyHandler
// Zero attached clients: publishAgentHookEnvelope returns before touching any sink.
const dispatcher = {
onRequest: vi.fn(),
activeClientIds: () => [] as number[]
} as unknown as RelayDispatcher
const runtime = new RelayAgentHookRuntime(
dispatcher,
ptyHandler,
join(dir, 'relay.sock'),
join(dir, 'hooks')
)
await runtime.start()
try {
const server = (runtime as unknown as { hookServer: RelayAgentHookServer }).hookServer
expect(await postHook(server, PANE_KEY)).toBe(204)
expect(cachedPaneKeys(server)).toEqual([PANE_KEY])
expect(setClaudeTerminalEvidenceListener).toHaveBeenCalledOnce()
const observe = setClaudeTerminalEvidenceListener.mock.calls[0]?.[0]
if (!observe) {
throw new Error('Terminal evidence listener was not registered')
}
observe(PANE_KEY, { kind: 'title', title: '◐ Task' })
observe(PANE_KEY, { kind: 'input', data: '\x1b[27u' })
observe(PANE_KEY, { kind: 'title', title: '✳ Task' })
expect(
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the server owns this pane-status map, as checked by cachedPaneKeys above.
(server as unknown as CachedPanes).state.lastStatusByPaneKey.get(PANE_KEY)
).toMatchObject({
payload: { state: 'done', mainAgent: { outcome: 'cancellation' } }
})
// The PTY handler retires the surface: the runtime must drop the cached status immediately,
// rather than waiting for a process exit a surviving shell never produces.
retired.retire(PANE_KEY)
expect(surfaceRetiredListeners).toHaveLength(1)
surfaceRetiredListeners[0]({ id: 'pty-1', paneKey: PANE_KEY })
expect(cachedPaneKeys(server)).toEqual([])
expect(await postHook(server, PANE_KEY, 'orphan')).toBe(204)
expect(cachedPaneKeys(server)).toEqual([])
} finally {
runtime.stop()
}
})
})
@@ -1,47 +0,0 @@
import { describe, expect, it } from 'vitest'
import { createAgentChildWorkAdmission } from '../shared/agent-status-child-work-admission'
import { createAgentStatusStore } from '../shared/agent-status-store'
import { makeStructuredAgentStatusSubject } from '../shared/agent-status-subject'
const trustedSubject = makeStructuredAgentStatusSubject(
{
executionHostId: 'ssh:relay-host-a',
wslDistro: null,
workspaceId: 'folder-workspace-a',
workspaceKind: 'folder'
},
'session_11111111-1111-4111-8111-111111111111'
)
describe('agent status store relay context', () => {
it('instantiates the same shared core and completes an admission/snapshot round-trip', () => {
const authority = createAgentStatusStore({ epoch: 'relay-epoch-a', mode: 'authority' })
expect(
authority.applyMutation({ parent: { subject: trustedSubject, firstObservedAt: 10 } })
).not.toBeNull()
const admission = createAgentChildWorkAdmission(authority, {
mintChildWorkId: () => 'relay-child-1'
})
expect(
admission.announce({
parent: trustedSubject,
provider: 'claude',
aliases: [{ segmentId: 'segment-1', aliasKind: 'task_id', alias: 'task-1' }],
fence: { invocationId: 'invocation-1', generation: 1 },
lifetime: 'current',
kind: 'agent',
state: 'working',
membership: 'live',
observedAt: 20,
stoppable: true,
provenance: { source: 'transport', producerId: 'relay-fixture' }
})
).toMatchObject({ accepted: true, childWorkId: 'relay-child-1' })
const replica = createAgentStatusStore({ epoch: 'replica-placeholder', mode: 'replica' })
expect(replica.applySnapshot(authority.getSnapshot())).toBe(true)
expect(replica.getParent(trustedSubject)?.firstObservedAt).toBe(10)
expect(replica.getChildren(trustedSubject)[0]?.childWorkId).toBe('relay-child-1')
})
})
@@ -1,23 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
vi.mock('../shared/home-or-filesystem-root', () => ({
isTooBroadToPreTrust: () => {
throw new Error('homedir unavailable')
}
}))
import { applyRelayAgentWorkspaceTrust } from './agent-workspace-trust-spawn'
describe('applyRelayAgentWorkspaceTrust when the breadth guard fails', () => {
it.each(['claude', 'copilot', 'qoder'] as const)(
'skips %s trust and never fails the spawn',
async (agent) => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
await expect(
applyRelayAgentWorkspaceTrust({ workspacePath: '/srv/wt' }, agent, {}, { wslShell: false })
).resolves.toBeUndefined()
expect(warn).toHaveBeenCalled()
warn.mockRestore()
}
)
})
@@ -1,255 +0,0 @@
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
realpathSync,
rmSync,
symlinkSync,
writeFileSync
} from 'node:fs'
import type * as Os from 'node:os'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AgentTrustPreset } from '../main/agent-trust-presets'
import type { TuiAgent } from '../shared/tui-agent'
import { linkGitWorktree, workspaceTrustWritten } from '../main/workspace-trust-test-fixtures'
const state = vi.hoisted(() => ({ home: '' }))
// Why: a spawn env without HOME leaves the writers on this process's home, as on a real relay.
vi.mock('node:os', async (importOriginal) => {
const actual = await importOriginal<typeof Os>()
return { ...actual, homedir: () => state.home }
})
import { applyRelayAgentWorkspaceTrust } from './agent-workspace-trust-spawn'
import { buildSshPtySpawnRequest } from '../main/providers/ssh-pty-spawn-request'
const HOST_SHELL = { wslShell: false }
const ORIGINAL_CLAUDE_CONFIG = '{"oauthAccount":{"x":1}}'
const AGENTS_WITH_A_RELAY_WRITER: [TuiAgent, AgentTrustPreset][] = [
['claude', 'claude'],
['claude-agent-teams', 'claude'],
['codex', 'codex'],
['cursor', 'cursor'],
['copilot', 'copilot'],
['qoder', 'qoder'],
['qoder-cn', 'qoder-cn']
]
const AGENTS_THAT_INHERIT_TRUST = AGENTS_WITH_A_RELAY_WRITER.filter(([, preset]) =>
['claude', 'copilot', 'qoder', 'qoder-cn'].includes(preset)
)
const ALL_PRESETS: AgentTrustPreset[] = [
'claude',
'codex',
'cursor',
'copilot',
'qoder',
'qoder-cn',
'antigravity'
]
let root: string
let home: string
let workspace: string
beforeEach(() => {
root = realpathSync(mkdtempSync(join(tmpdir(), 'orca-relay-agent-trust-')))
home = join(root, 'home', 'me')
mkdirSync(home, { recursive: true })
state.home = home
writeFileSync(join(home, '.claude.json'), ORIGINAL_CLAUDE_CONFIG, { mode: 0o600 })
workspace = join(root, 'wt')
mkdirSync(workspace)
})
afterEach(() => {
rmSync(root, { recursive: true, force: true })
})
function nothingWritten(): boolean {
return ALL_PRESETS.every((preset) => !workspaceTrustWritten(home, preset))
}
describe('applyRelayAgentWorkspaceTrust', () => {
it.each(AGENTS_WITH_A_RELAY_WRITER)(
"writes %s trust on the relay host's own disk",
async (agent, preset) => {
await applyRelayAgentWorkspaceTrust(
{ workspacePath: workspace },
agent,
{ HOME: home },
HOST_SHELL
)
expect(workspaceTrustWritten(home, preset)).toBe(true)
}
)
it("grants Claude in the remote host's own config, named by the merged spawn env", async () => {
const configDir = join(root, 'cfg')
mkdirSync(configDir)
writeFileSync(join(configDir, '.claude.json'), ORIGINAL_CLAUDE_CONFIG, { mode: 0o600 })
await applyRelayAgentWorkspaceTrust(
{ workspacePath: workspace },
'claude',
{ CLAUDE_CONFIG_DIR: configDir, HOME: home },
HOST_SHELL
)
expect(JSON.parse(readFileSync(join(configDir, '.claude.json'), 'utf-8'))).toEqual({
oauthAccount: { x: 1 },
projects: { [workspace]: { hasTrustDialogAccepted: true } }
})
expect(readFileSync(join(home, '.claude.json'), 'utf-8')).toBe(ORIGINAL_CLAUDE_CONFIG)
})
it('never creates a Claude config file Claude has not written', async () => {
const emptyHome = join(root, 'empty-home')
mkdirSync(emptyHome)
await applyRelayAgentWorkspaceTrust(
{ workspacePath: workspace },
'claude',
{ HOME: emptyHome },
HOST_SHELL
)
expect(existsSync(join(emptyHome, '.claude.json'))).toBe(false)
})
it('writes Codex trust into the CODEX_HOME the spawn env names', async () => {
const codexHome = join(root, 'codex-home')
await applyRelayAgentWorkspaceTrust(
{ workspacePath: workspace },
'codex',
{ HOME: home, CODEX_HOME: codexHome },
HOST_SHELL
)
expect(readFileSync(join(codexHome, 'config.toml'), 'utf-8')).toContain(
`[projects."${workspace}"]`
)
expect(workspaceTrustWritten(home, 'codex')).toBe(false)
})
it("stores a Codex worktree's own path, not the relay home it hangs off, as a local launch does", async () => {
const worktree = join(root, 'worktrees', 'feature')
linkGitWorktree(home, worktree)
await applyRelayAgentWorkspaceTrust(
{ workspacePath: worktree },
'codex',
{ HOME: home },
HOST_SHELL
)
const written = readFileSync(join(home, '.codex', 'config.toml'), 'utf-8')
expect(written).toContain(`[projects."${worktree}"]`)
expect(written).not.toContain(`[projects."${home}"]`)
})
it.each([
['codex', 'codex'],
['cursor', 'cursor']
] as const)(
'trusts the relay home for %s, whose trust there covers only the home',
async (agent, preset) => {
await applyRelayAgentWorkspaceTrust(
{ workspacePath: home },
agent,
{ HOME: home },
HOST_SHELL
)
expect(workspaceTrustWritten(home, preset)).toBe(true)
}
)
const tooBroad: [string, () => string][] = [
['the relay home', () => home],
['a folder containing the relay home', () => join(root, 'home')],
[
'a symlink to the relay home',
() => {
symlinkSync(home, join(workspace, 'home-link'), 'junction')
return join(workspace, 'home-link')
}
]
]
describe.each(tooBroad)('for %s', (_label, arrange) => {
it.each(AGENTS_THAT_INHERIT_TRUST)('writes no %s trust', async (agent) => {
await applyRelayAgentWorkspaceTrust(
{ workspacePath: arrange() },
agent,
{ HOME: home },
HOST_SHELL
)
expect(nothingWritten()).toBe(true)
})
})
it.each(AGENTS_WITH_A_RELAY_WRITER)(
"never writes this host's files for %s started in a WSL guest",
async (agent) => {
await applyRelayAgentWorkspaceTrust(
{ workspacePath: workspace },
agent,
{ HOME: home },
{ wslShell: true }
)
expect(nothingWritten()).toBe(true)
}
)
it('leaves Antigravity to ask, since its writer is unverified on SSH hosts', async () => {
await applyRelayAgentWorkspaceTrust(
{ workspacePath: workspace },
'antigravity',
{ HOME: home },
HOST_SHELL
)
expect(workspaceTrustWritten(home, 'antigravity')).toBe(false)
})
it.each([
['no request', () => undefined, 'claude'],
['a malformed request', () => ({ workspacePath: 42 }), 'claude'],
['an earlier-shaped request', () => ({ worktreeRoot: workspace, trusted: true }), 'codex'],
['an agent with no trust preset', () => ({ workspacePath: workspace }), 'gemini'],
['no declared agent', () => ({ workspacePath: workspace }), undefined]
] as const)('writes nothing, so the agent asks, for %s', async (_label, request, agent) => {
await applyRelayAgentWorkspaceTrust(request(), agent, { HOME: home }, HOST_SHELL)
expect(nothingWritten()).toBe(true)
})
it('never fails the spawn when a writer throws', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const codexHome = join(root, 'codex-home')
// Why: a config.toml that cannot be read as a file makes the Codex writer throw.
mkdirSync(join(codexHome, 'config.toml'), { recursive: true })
await expect(
applyRelayAgentWorkspaceTrust(
{ workspacePath: workspace },
'codex',
{ HOME: home, CODEX_HOME: codexHome },
HOST_SHELL
)
).resolves.toBeUndefined()
expect(warn).toHaveBeenCalled()
warn.mockRestore()
})
})
describe('buildSshPtySpawnRequest', () => {
it('forwards the workspace to trust as an optional field an old relay ignores', () => {
const request = buildSshPtySpawnRequest({
options: {
cols: 80,
rows: 24,
launchAgent: 'codex',
agentWorkspaceTrust: { workspacePath: '/w' }
},
supportsCreateOperation: false
})
expect(request.agentWorkspaceTrust).toEqual({ workspacePath: '/w' })
expect(request.launchAgent).toBe('codex')
expect(
buildSshPtySpawnRequest({ options: { cols: 80, rows: 24 }, supportsCreateOperation: false })
).not.toHaveProperty('agentWorkspaceTrust')
})
})
-51
View File
@@ -1,51 +0,0 @@
import { existsSync } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
import { parseAgentWorkspaceTrustSpawnRequest } from '../shared/agent-workspace-trust-spawn-request'
import { TUI_AGENT_CONFIG } from '../shared/tui-agent-config'
import type { TuiAgent } from '../shared/tui-agent'
import { resolveClaudeGlobalConfigFile } from '../main/claude/claude-folder-trust-file'
import { SHORT_AGENT_TRUST_WRITE_DEADLINE_MS } from '../main/agent-trust-write-deadline'
import {
applyWorkspaceTrustOnThisHost,
launchedAgentHome
} from '../main/execution-host-workspace-trust'
/**
* Why here: this host owns the files the agent reads, so each writer's lock, re-read, atomic
* rename and refusal rules act on local disk, with no round trip across the SSH link.
*/
export async function applyRelayAgentWorkspaceTrust(
rawRequest: unknown,
launchAgent: TuiAgent | undefined,
spawnEnv: Record<string, string | undefined>,
launch: { wslShell: boolean }
): Promise<void> {
const request = parseAgentWorkspaceTrustSpawnRequest(rawRequest)
const preset = launchAgent ? TUI_AGENT_CONFIG[launchAgent].preflightTrust : undefined
// Why: an agent inside a WSL guest reads the guest's files, not this Windows host's, and
// Antigravity's writer is unverified on SSH hosts, so it still asks there.
if (!request || !preset || preset === 'antigravity' || launch.wslShell) {
return
}
await applyWorkspaceTrustOnThisHost(preset, request.workspacePath, () => {
const keyStyle = process.platform === 'win32' ? 'win32' : 'posix'
const homeDir = launchedAgentHome(spawnEnv)
return {
homes: [homeDir, homedir()],
agentHome: homeDir,
claudeConfig: () => ({
configFile: resolveClaudeGlobalConfigFile({
env: spawnEnv,
homeDir,
style: keyStyle,
exists: existsSync
}),
keyStyle
}),
codexConfigFiles: () => [join(spawnEnv.CODEX_HOME || join(homeDir, '.codex'), 'config.toml')],
// Why: every write here is on the relay's own disk, so each preset gets the local budget.
deadlineMs: SHORT_AGENT_TRUST_WRITE_DEADLINE_MS
}
})
}
-437
View File
@@ -1,437 +0,0 @@
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { AiVaultListResult } from '../shared/ai-vault-types'
import {
SSH_AI_VAULT_LIST_SESSIONS_METHOD,
SSH_AI_VAULT_RESOLVE_SESSION_TITLES_METHOD
} from '../shared/ssh-ai-vault-relay'
import { getRemoteHostPlatform } from '../main/ssh/ssh-remote-platform'
import type { RemoteHostPlatform } from '../main/ssh/ssh-remote-platform'
import { scanRemoteAiVaultSessions } from '../main/ai-vault/remote-session-scanner'
import { readAiVaultSessionTitlesFromFiles } from '../main/ai-vault/session-title-file-reader'
import type { RelayDispatcher, RequestContext } from '../wsl-guest/dispatcher'
import { AiVaultHandler } from './ai-vault-handler'
import { createRelayAiVaultFilesystemProvider } from './ai-vault-service-filesystem'
import type { RelayAiVaultServiceApi } from './ai-vault-service-client-state'
type RequestHandler = (params: Record<string, unknown>, context: RequestContext) => Promise<unknown>
const temporaryHomes: string[] = []
afterEach(async () => {
await Promise.all(
temporaryHomes.splice(0).map((path) => rm(path, { recursive: true, force: true }))
)
})
describe('AiVaultHandler', () => {
it('resolves an exact transcript title without invoking the broad scanner', async () => {
const remoteHome = await makeTemporaryHome()
const transcriptPath = join(remoteHome, 'session.jsonl')
await writeFile(
transcriptPath,
[
JSON.stringify({
timestamp: '2026-07-26T01:00:00.000Z',
type: 'session_meta',
payload: { id: 'ssh-session', cwd: join(remoteHome, 'repo') }
}),
JSON.stringify({
timestamp: '2026-07-26T01:00:01.000Z',
type: 'response_item',
payload: {
type: 'message',
role: 'user',
content: [{ type: 'text', text: 'Resolve only this transcript' }]
}
})
].join('\n')
)
const scanRemoteSessions = vi.fn().mockResolvedValue(emptyResult())
const dispatcher = createMockDispatcher()
new AiVaultHandler(dispatcher.value, {
remoteHome,
hostPlatform: getRemoteHostPlatform('linux-x64'),
service: createTestService(remoteHome, getRemoteHostPlatform('linux-x64'), scanRemoteSessions)
})
await expect(
dispatcher.call(SSH_AI_VAULT_RESOLVE_SESSION_TITLES_METHOD, {
requests: [
{ agent: 'codex', sessionId: 'ssh-session', transcriptPath },
{ agent: 'codex', sessionId: 'other', transcriptPath: '' }
]
})
).resolves.toEqual({
titles: [{ agent: 'codex', sessionId: 'ssh-session', title: 'Resolve only this transcript' }]
})
expect(scanRemoteSessions).not.toHaveBeenCalled()
})
it('discovers and parses sessions entirely on the relay host', async () => {
const remoteHome = await makeTemporaryHome()
const transcriptPath = join(
remoteHome,
'.codex',
'sessions',
'2026',
'07',
'26',
'rollout-test.jsonl'
)
await mkdir(dirname(transcriptPath), { recursive: true })
await writeFile(
transcriptPath,
[
JSON.stringify({
timestamp: '2026-07-26T01:00:00.000Z',
type: 'session_meta',
payload: { id: 'ssh-session', cwd: join(remoteHome, 'repo') }
}),
JSON.stringify({
timestamp: '2026-07-26T01:00:01.000Z',
type: 'response_item',
payload: {
type: 'message',
role: 'user',
content: [{ type: 'text', text: 'Scan on the SSH target' }]
}
})
].join('\n')
)
const dispatcher = createMockDispatcher()
new AiVaultHandler(dispatcher.value, {
remoteHome,
hostPlatform: getRemoteHostPlatform('linux-x64'),
service: createTestService(remoteHome, getRemoteHostPlatform('linux-x64'))
})
const result = (await dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, {
limit: 20
})) as AiVaultListResult
expect(result.issues).toEqual([])
expect(result.sessions).toHaveLength(1)
expect(result.sessions[0]).toMatchObject({
executionHostId: 'local',
executionHostPlatform: 'linux',
sessionId: 'ssh-session',
title: 'Scan on the SSH target',
filePath: transcriptPath
})
})
it('bounds relay scan parameters before touching the target filesystem', async () => {
const scanRemoteSessions = vi.fn().mockResolvedValue(emptyResult())
const dispatcher = createMockDispatcher()
new AiVaultHandler(dispatcher.value, {
remoteHome: '/home/ada',
hostPlatform: getRemoteHostPlatform('linux-x64'),
service: createTestService(
'/home/ada',
getRemoteHostPlatform('linux-x64'),
scanRemoteSessions
)
})
const scopePaths = Array.from({ length: 80 }, (_, index) => `/repo/${index}`)
await dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, {
limit: 50_000,
scopePaths
})
expect(scanRemoteSessions).toHaveBeenCalledWith(
expect.objectContaining({
executionHostId: 'local',
limit: 1000,
scopePaths: scopePaths.slice(0, 64)
})
)
const result = (await dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, {
scopePaths
})) as AiVaultListResult
expect(result.issues).toContainEqual(
expect.objectContaining({
kind: 'scope',
message: expect.stringContaining('first 64 project paths')
})
)
})
it('forwards Unlimited without the relay numeric cap', async () => {
const scanRemoteSessions = vi.fn().mockResolvedValue(emptyResult())
const dispatcher = createMockDispatcher()
new AiVaultHandler(dispatcher.value, {
remoteHome: '/home/ada',
hostPlatform: getRemoteHostPlatform('linux-x64'),
service: createTestService(
'/home/ada',
getRemoteHostPlatform('linux-x64'),
scanRemoteSessions
)
})
await dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, {
limit: 50_000,
unlimited: true
})
expect(scanRemoteSessions).toHaveBeenCalledWith(
expect.objectContaining({ limit: undefined, unlimited: true })
)
})
it('coalesces identical in-flight scans without coupling caller cancellation', async () => {
let resolveScan: ((result: AiVaultListResult) => void) | undefined
let sharedSignal: AbortSignal | undefined
const scanRemoteSessions = vi.fn(
(args: { signal?: AbortSignal }) =>
new Promise<AiVaultListResult>((resolve) => {
sharedSignal = args.signal
resolveScan = resolve
})
)
const dispatcher = createMockDispatcher()
new AiVaultHandler(dispatcher.value, {
remoteHome: '/home/ada',
hostPlatform: getRemoteHostPlatform('linux-x64'),
service: createTestService(
'/home/ada',
getRemoteHostPlatform('linux-x64'),
scanRemoteSessions as never
)
})
const firstController = new AbortController()
const first = dispatcher.call(
SSH_AI_VAULT_LIST_SESSIONS_METHOD,
{ limit: 20 },
firstController.signal
)
const second = dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, { limit: 20 })
await vi.waitFor(() => expect(scanRemoteSessions).toHaveBeenCalledTimes(1))
firstController.abort()
await expect(first).rejects.toMatchObject({ name: 'AbortError' })
expect(sharedSignal?.aborted).toBe(false)
resolveScan?.(emptyResult())
await expect(second).resolves.toEqual(emptyResult())
})
it('re-joins a preempted relay caller onto the forced refresh', async () => {
const signals: AbortSignal[] = []
let resolveForced: ((result: AiVaultListResult) => void) | undefined
const scanRemoteSessions = vi.fn((args: { signal: AbortSignal }) => {
signals.push(args.signal)
return new Promise<AiVaultListResult>((resolve) => {
if (signals.length === 1) {
args.signal.addEventListener('abort', () => resolve(emptyResult()), { once: true })
} else {
resolveForced = resolve
}
})
})
const dispatcher = createMockDispatcher()
new AiVaultHandler(dispatcher.value, {
remoteHome: '/home/ada',
hostPlatform: getRemoteHostPlatform('linux-x64'),
service: createTestService(
'/home/ada',
getRemoteHostPlatform('linux-x64'),
scanRemoteSessions as never
)
})
const first = dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, { limit: 20 })
await vi.waitFor(() => expect(signals).toHaveLength(1))
const forced = dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, {
limit: 20,
force: true
})
await vi.waitFor(() => expect(signals).toHaveLength(2))
expect(signals[0]?.aborted).toBe(true)
resolveForced?.(emptyResult())
// The desktop caller that did not ask for a refresh still gets sessions.
await expect(Promise.all([first, forced])).resolves.toEqual([emptyResult(), emptyResult()])
})
it('soft-disables the method instead of aborting relay startup on an unsupported platform', () => {
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { value: 'freebsd', configurable: true })
const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true)
try {
const dispatcher = createMockDispatcher()
expect(() => new AiVaultHandler(dispatcher.value)).not.toThrow()
expect(() => dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, {})).toThrow(/No handler/)
} finally {
stderr.mockRestore()
if (platform) {
Object.defineProperty(process, 'platform', platform)
}
}
})
it('soft-disables the method instead of aborting relay startup when the service is missing', () => {
const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true)
try {
const dispatcher = createMockDispatcher()
expect(
() =>
new AiVaultHandler(dispatcher.value, {
remoteHome: '/home/ada',
hostPlatform: getRemoteHostPlatform('linux-x64')
})
).not.toThrow()
expect(() => dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, {})).toThrow(/No handler/)
} finally {
stderr.mockRestore()
}
})
it('stops a relay-local scan when the owning request is cancelled', async () => {
const dispatcher = createMockDispatcher()
new AiVaultHandler(dispatcher.value, {
remoteHome: '/home/ada',
hostPlatform: getRemoteHostPlatform('linux-x64'),
service: createTestService('/home/ada', getRemoteHostPlatform('linux-x64'))
})
const controller = new AbortController()
controller.abort()
await expect(
dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, {}, controller.signal)
).rejects.toMatchObject({ name: 'AbortError' })
})
it('returns a host issue when the sidecar is unavailable', async () => {
const dispatcher = createMockDispatcher()
new AiVaultHandler(dispatcher.value, {
remoteHome: '/home/ada',
hostPlatform: getRemoteHostPlatform('linux-x64'),
service: {
listSessions: () => Promise.reject(new Error('sidecar crashed')),
resolveSessionTitles: () => Promise.resolve({ titles: [] })
}
})
await expect(dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, {})).resolves.toMatchObject({
sessions: [],
issues: [
expect.objectContaining({ kind: 'host', message: expect.stringContaining('sidecar') })
]
})
})
it('returns no titles instead of an RPC error when the sidecar is unavailable', async () => {
const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true)
try {
const dispatcher = createMockDispatcher()
new AiVaultHandler(dispatcher.value, {
remoteHome: '/home/ada',
hostPlatform: getRemoteHostPlatform('linux-x64'),
service: {
listSessions: () => Promise.resolve(emptyResult()),
resolveSessionTitles: () => Promise.reject(new Error('sidecar crashed'))
}
})
await expect(
dispatcher.call(SSH_AI_VAULT_RESOLVE_SESSION_TITLES_METHOD, {
requests: [
{ agent: 'codex', sessionId: 'ssh-session', transcriptPath: '/home/ada/s.jsonl' }
]
})
).resolves.toEqual({ titles: [] })
} finally {
stderr.mockRestore()
}
})
it('propagates title cancellation instead of degrading it', async () => {
const dispatcher = createMockDispatcher()
new AiVaultHandler(dispatcher.value, {
remoteHome: '/home/ada',
hostPlatform: getRemoteHostPlatform('linux-x64'),
service: {
listSessions: () => Promise.resolve(emptyResult()),
resolveSessionTitles: () => {
const error = new Error('The operation was aborted.')
error.name = 'AbortError'
return Promise.reject(error)
}
}
})
await expect(
dispatcher.call(SSH_AI_VAULT_RESOLVE_SESSION_TITLES_METHOD, { requests: [] })
).rejects.toMatchObject({ name: 'AbortError' })
})
})
async function makeTemporaryHome(): Promise<string> {
const path = await mkdtemp(join(tmpdir(), 'orca-relay-ai-vault-'))
temporaryHomes.push(path)
return path
}
function emptyResult(): AiVaultListResult {
return { sessions: [], issues: [], scannedAt: '2026-07-26T00:00:00.000Z' }
}
function createTestService(
remoteHome: string,
hostPlatform: RemoteHostPlatform,
scan: typeof scanRemoteAiVaultSessions = scanRemoteAiVaultSessions
): RelayAiVaultServiceApi {
return {
listSessions: (params, signal) =>
scan({
provider: createRelayAiVaultFilesystemProvider({
homeDirectory: remoteHome,
environment: {}
}),
executionHostId: 'local',
remoteHome,
hostPlatform,
limit: params.limit,
unlimited: params.unlimited,
scopePaths: params.scopePaths,
signal
}),
resolveSessionTitles: (requests, signal) =>
readAiVaultSessionTitlesFromFiles(requests, { signal })
}
}
function createMockDispatcher(): {
value: RelayDispatcher
call: (method: string, params: Record<string, unknown>, signal?: AbortSignal) => Promise<unknown>
} {
const handlers = new Map<string, RequestHandler>()
const value = {
onRequest(method: string, handler: RequestHandler) {
handlers.set(method, handler)
}
} as RelayDispatcher
return {
value,
call(method, params, signal) {
const handler = handlers.get(method)
if (!handler) {
throw new Error(`No handler for ${method}`)
}
return handler(params, {
clientId: 1,
isStale: () => signal?.aborted ?? false,
signal
})
}
}
}
-212
View File
@@ -1,212 +0,0 @@
import {
searchSessionService,
sessionSearchServiceStatus
} from '../main/ai-vault-search/session-search-service-registry'
import { homedir } from 'node:os'
import { AI_VAULT_SCOPE_PATHS_MAX_COUNT, type AiVaultListResult } from '../shared/ai-vault-types'
import { LOCAL_EXECUTION_HOST_ID } from '../shared/execution-host'
import {
AI_VAULT_SESSION_TITLE_REQUEST_MAX_COUNT,
type AiVaultSessionTitleRequest,
type AiVaultSessionTitlesResult
} from '../shared/ai-vault-session-title'
import {
SSH_AI_VAULT_LIST_LIMIT_MAX,
SSH_AI_VAULT_LIST_SESSIONS_METHOD,
SSH_AI_VAULT_RESOLVE_SESSION_TITLES_METHOD,
SSH_AI_VAULT_SCOPE_PATH_MAX_LENGTH,
type SshAiVaultRelayListParams
} from '../shared/ssh-ai-vault-relay'
import { getRemoteHostPlatform, type RemoteHostPlatform } from '../main/ssh/ssh-remote-platform'
import { parseUnameToRelayPlatform } from '../main/ssh/relay-protocol'
import { relayLogLine } from './relay-diagnostic-log'
import type { RelayDispatcher } from '../wsl-guest/dispatcher'
import { AiVaultScanCoordinator } from '../main/ai-vault/ai-vault-scan-coordinator'
import type { RelayAiVaultServiceApi } from './ai-vault-service-client-state'
type AiVaultHandlerOptions = {
remoteHome?: string
hostPlatform?: RemoteHostPlatform
service?: RelayAiVaultServiceApi
}
export class AiVaultHandler {
private readonly remoteHome: string
private readonly scanCoordinator = new AiVaultScanCoordinator()
constructor(dispatcher: RelayDispatcher, options: AiVaultHandlerOptions = {}) {
dispatcher.onRequest('aiVault.searchSessions', (params) =>
searchSessionService(params, 'relay')
)
dispatcher.onRequest('aiVault.searchStatus', (params) =>
sessionSearchServiceStatus(params, 'relay')
)
this.remoteHome = options.remoteHome ?? homedir()
const hostPlatform = options.hostPlatform ?? currentRelayHostPlatform()
// Why: an OS/arch this build has no path flavor for must not abort relay
// startup — leaving the method unregistered soft-disables the feature and
// the host falls back to its own SSH filesystem scan.
if (!hostPlatform) {
relayLogLine(
`[relay] Agent Session History disabled: unsupported platform ${process.platform}-${process.arch}`
)
return
}
// Why: same reasoning as an unsupported platform. Throwing here would take
// relay startup — and every PTY on the host — down over a Vault wiring bug.
const service = options.service
if (!service) {
relayLogLine('[relay] Agent Session History disabled: service unavailable')
return
}
dispatcher.onRequest(SSH_AI_VAULT_LIST_SESSIONS_METHOD, (params, context) =>
this.listSessions(service, params, context.signal)
)
dispatcher.onRequest(SSH_AI_VAULT_RESOLVE_SESSION_TITLES_METHOD, (params, context) =>
this.resolveSessionTitles(service, params, context.signal)
)
}
private async resolveSessionTitles(
service: RelayAiVaultServiceApi,
rawParams: Record<string, unknown>,
signal?: AbortSignal
): Promise<AiVaultSessionTitlesResult> {
try {
return await service.resolveSessionTitles(normalizeTitleRequests(rawParams.requests), signal)
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
throw error
}
// Why: titles are decoration. Degrade like an unresolvable title instead of
// failing the RPC, which would surface a raw error on every list row.
relayLogLine(
`[relay-ai-vault-service] title resolution unavailable: ${error instanceof Error ? error.message : String(error)}`
)
return { titles: [] }
}
}
private async listSessions(
service: RelayAiVaultServiceApi,
rawParams: Record<string, unknown>,
signal?: AbortSignal
): Promise<AiVaultListResult> {
const params = normalizeSshAiVaultRelayListParams(rawParams)
let result: AiVaultListResult
try {
result = await this.scanCoordinator.run({
key: JSON.stringify({
includeAntigravityIdeSessions: params.includeAntigravityIdeSessions,
limit: params.limit,
unlimited: params.unlimited,
scopePaths: params.scopePaths,
scopePathsTruncated: params.scopePathsTruncated
}),
force: params.force,
signal,
start: (scanSignal) => service.listSessions(params, scanSignal)
})
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
throw error
}
result = {
sessions: [],
scannedAt: new Date().toISOString(),
issues: [
{
executionHostId: LOCAL_EXECUTION_HOST_ID,
agent: 'codex',
kind: 'host',
path: this.remoteHome,
message: `Agent Session History service unavailable: ${error instanceof Error ? error.message : String(error)}`
}
]
}
}
if (!params.scopePathsTruncated) {
return result
}
return {
...result,
issues: [
...result.issues,
{
executionHostId: LOCAL_EXECUTION_HOST_ID,
agent: 'codex',
kind: 'scope',
path: this.remoteHome,
message: `Only the first ${AI_VAULT_SCOPE_PATHS_MAX_COUNT} project paths were scanned.`
}
]
}
}
}
function normalizeTitleRequests(raw: unknown): AiVaultSessionTitleRequest[] {
if (!Array.isArray(raw)) {
return []
}
const requests: AiVaultSessionTitleRequest[] = []
for (const value of raw.slice(0, AI_VAULT_SESSION_TITLE_REQUEST_MAX_COUNT)) {
if (!value || typeof value !== 'object') {
continue
}
const record = value as Record<string, unknown>
const agent = record.agent
const sessionId = typeof record.sessionId === 'string' ? record.sessionId.trim() : ''
const transcriptPath =
typeof record.transcriptPath === 'string' ? record.transcriptPath.trim() : ''
if (
(agent !== 'claude' && agent !== 'codex') ||
!sessionId ||
sessionId.length > 512 ||
!transcriptPath ||
transcriptPath.length > 32_768
) {
continue
}
requests.push({ agent, sessionId, transcriptPath })
}
return requests
}
export function normalizeSshAiVaultRelayListParams(
params: Record<string, unknown>
): SshAiVaultRelayListParams {
const rawLimit = params.limit
const unlimited = params.unlimited === true
const limit =
!unlimited && typeof rawLimit === 'number' && Number.isFinite(rawLimit) && rawLimit > 0
? Math.min(Math.floor(rawLimit), SSH_AI_VAULT_LIST_LIMIT_MAX)
: undefined
const scopePaths = Array.isArray(params.scopePaths)
? params.scopePaths
.slice(0, AI_VAULT_SCOPE_PATHS_MAX_COUNT)
.filter(
(path): path is string =>
typeof path === 'string' &&
path.trim().length > 0 &&
path.length <= SSH_AI_VAULT_SCOPE_PATH_MAX_LENGTH
)
: undefined
const scopePathsTruncated =
params.scopePathsTruncated === true ||
(Array.isArray(params.scopePaths) && params.scopePaths.length > AI_VAULT_SCOPE_PATHS_MAX_COUNT)
return {
...(params.includeAntigravityIdeSessions === true
? { includeAntigravityIdeSessions: true }
: {}),
...(unlimited ? { unlimited: true } : {}),
...(limit === undefined ? {} : { limit }),
...(params.force === true ? { force: true } : {}),
...(scopePaths === undefined ? {} : { scopePaths }),
...(scopePathsTruncated ? { scopePathsTruncated: true } : {})
}
}
function currentRelayHostPlatform(): RemoteHostPlatform | null {
const relayPlatform = parseUnameToRelayPlatform(process.platform, process.arch)
return relayPlatform ? getRemoteHostPlatform(relayPlatform) : null
}
-255
View File
@@ -1,255 +0,0 @@
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' })]
})
})
})
-171
View File
@@ -1,171 +0,0 @@
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()
}
}
@@ -1,348 +0,0 @@
import { setImmediate } from 'node:timers/promises'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { RelayAiVaultServiceClient } from './ai-vault-service-client'
import { AiVaultServiceTestChild } from '../main/ai-vault/session-scanner-service-test-child'
import { getRemoteHostPlatform } from '../main/ssh/ssh-remote-platform'
import type { RelayAiVaultServiceChildMessage } from './ai-vault-service-protocol'
import type { scanRemoteAiVaultSessions } from '../main/ai-vault/remote-session-scanner'
const scan = vi.fn<typeof scanRemoteAiVaultSessions>()
const disposeProvider = vi.fn<() => void>()
const result = { sessions: [], issues: [], scannedAt: '2026-10-02T13:00:00.000Z' }
let outbound: RelayAiVaultServiceChildMessage[] = []
let frames: RelayAiVaultServiceChildMessage[] = []
let restoreProcess = (): void => undefined
let client: RelayAiVaultServiceClient | undefined
class BridgeChild extends AiVaultServiceTestChild {
override send(message: unknown, callback?: (error: Error | null) => void): boolean {
super.send(message, callback)
process.emit('message', message)
return true
}
}
function deliver(child: BridgeChild): void {
const message = outbound.shift()
if (!message) {
throw new Error('Expected one complete service reply in transit')
}
child.emit('message', message)
}
async function turn(): Promise<void> {
await setImmediate()
}
function createClient(): BridgeChild {
const child = new BridgeChild()
client = new RelayAiVaultServiceClient({
processFactory: () => child.asChildProcess(),
init: { remoteHome: '/home/ada', hostPlatform: getRemoteHostPlatform('linux-x64') }
})
return child
}
function currentClient(): RelayAiVaultServiceClient {
if (!client) {
throw new Error('Expected the owned service client')
}
return client
}
async function close(child: BridgeChild): Promise<void> {
const disposed = client?.dispose()
await turn()
child.emit('exit', 0)
await disposed
expect(disposeProvider).toHaveBeenCalledOnce()
expect(vi.getTimerCount()).toBe(0)
client = undefined
}
beforeEach(async () => {
vi.resetModules()
vi.clearAllMocks()
scan.mockReset()
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
outbound = []
frames = []
scan.mockResolvedValue(result)
const send = Object.getOwnPropertyDescriptor(process, 'send')
const disconnect = Object.getOwnPropertyDescriptor(process, 'disconnect')
const messages = new Set(process.listeners('message'))
const disconnects = new Set(process.listeners('disconnect'))
Object.defineProperty(process, 'send', {
configurable: true,
value: (message: RelayAiVaultServiceChildMessage) => {
outbound.push(message)
frames.push(message)
return true
}
})
Object.defineProperty(process, 'disconnect', { configurable: true, value: vi.fn() })
restoreProcess = () => {
for (const listener of process.listeners('message')) {
if (!messages.has(listener)) {
process.removeListener('message', listener)
}
}
for (const listener of process.listeners('disconnect')) {
if (!disconnects.has(listener)) {
process.removeListener('disconnect', listener)
}
}
if (send) {
Object.defineProperty(process, 'send', send)
} else {
delete process.send
}
if (disconnect) {
Object.defineProperty(process, 'disconnect', disconnect)
} else {
delete process.disconnect
}
}
const scanner = await import('../main/ai-vault/remote-session-scanner')
vi.spyOn(scanner, 'scanRemoteAiVaultSessions').mockImplementation(scan)
const titles = await import('../main/ai-vault/session-title-file-reader')
vi.spyOn(titles, 'readAiVaultSessionTitlesFromFiles').mockResolvedValue({ titles: [] })
const filesystem = await import('./ai-vault-service-filesystem')
const createProvider = filesystem.createRelayAiVaultFilesystemProvider
vi.spyOn(filesystem, 'createRelayAiVaultFilesystemProvider').mockImplementation((options) => {
const provider = createProvider(options)
const dispose = provider.dispose
vi.spyOn(provider, 'dispose').mockImplementation(() => {
disposeProvider()
dispose()
})
return provider
})
await import('./ai-vault-service-entry')
})
afterEach(async () => {
process.emit('message', { type: 'shutdown' })
await turn()
restoreProcess()
vi.clearAllTimers()
vi.restoreAllMocks()
vi.useRealTimers()
})
it('actual parent cancellation during completed-response transit retains zero completed IDs', async () => {
const child = createClient()
const errors: unknown[] = []
const retained: Set<unknown>[] = []
const counts: number[] = []
const warm = Promise.withResolvers<typeof result>()
scan.mockImplementationOnce(() => warm.promise)
const warmController = new AbortController()
const warmResponse = currentClient()
.listSessions({}, warmController.signal)
.catch((error: unknown) => error)
deliver(child)
await turn()
const warmAdd = vi.spyOn(Set.prototype, 'add')
try {
warmController.abort()
const callIndex = warmAdd.mock.calls.findIndex(([value]) => value === 1)
const owned = warmAdd.mock.contexts[callIndex]
if (!(owned instanceof Set)) {
throw new Error('Expected the actual native cancellation set for the admitted warm request')
}
retained.push(owned)
} finally {
warmAdd.mockRestore()
}
errors.push(await warmResponse)
warm.resolve(result)
await turn()
deliver(child)
await turn()
expect(retained[0]?.size).toBe(0)
for (let index = 0; index < 64; index++) {
const id = index + 2
if (index % 3 !== 2) {
scan.mockImplementationOnce(async () => {
if (index % 3 === 1) {
throw new Error(`scan failure ${id}`)
}
return result
})
}
const controller = new AbortController()
const response = (
index % 3 === 2
? currentClient().resolveSessionTitles([], controller.signal)
: currentClient().listSessions({}, controller.signal)
).catch((error: unknown) => error)
await turn()
expect(outbound).toHaveLength(1)
expect(outbound[0]).toEqual(
index % 3 === 2
? { type: 'result', id, operation: 'titles', value: { titles: [] } }
: index % 3 === 1
? { type: 'error', id, message: `scan failure ${id}` }
: { type: 'result', id, operation: 'list', value: result }
)
const add = vi.spyOn(Set.prototype, 'add')
try {
controller.abort()
const matching = add.mock.calls.flatMap(([value], callIndex) =>
value === id ? [callIndex] : []
)
counts.push(matching.length)
for (const callIndex of matching) {
const owned = add.mock.contexts[callIndex]
if (!(owned instanceof Set)) {
throw new Error('Expected the actual native cancellation set')
}
retained.push(owned)
}
} finally {
add.mockRestore()
}
errors.push(await response)
deliver(child)
await turn()
}
expect(
errors.every(
(error) =>
error instanceof Error &&
error.name === 'AbortError' &&
error.message === 'The operation was aborted.'
)
).toBe(true)
expect(scan).toHaveBeenCalledTimes(44)
expect(
child.sent.filter(
(message) =>
typeof message === 'object' &&
message !== null &&
'type' in message &&
message.type === 'cancel'
)
).toEqual(Array.from({ length: 65 }, (_, index) => ({ type: 'cancel', id: index + 1 })))
const sizes = [...new Set(retained)].map((owned) => owned.size)
await close(child)
expect(frames).toEqual([
{ type: 'ready', protocol: 1, pid: process.pid },
{ type: 'result', id: 1, operation: 'list', value: result },
...Array.from({ length: 64 }, (_, index) => {
const id = index + 2
return index % 3 === 2
? { type: 'result', id, operation: 'titles', value: { titles: [] } }
: index % 3 === 1
? { type: 'error', id, message: `scan failure ${id}` }
: { type: 'result', id, operation: 'list', value: result }
})
])
expect(counts).toEqual(Array(64).fill(0))
expect(sizes).toEqual([0])
})
it('an admitted active cancellation still aborts its exact scanner signal', async () => {
const child = createClient()
const pending = Promise.withResolvers<typeof result>()
let signal: AbortSignal | undefined
scan.mockImplementationOnce((options) => {
signal = options.signal
return pending.promise
})
const controller = new AbortController()
const response = currentClient()
.listSessions({}, controller.signal)
.catch((error: unknown) => error)
deliver(child)
await turn()
expect(signal?.aborted).toBe(false)
controller.abort()
await expect(response).resolves.toMatchObject({ name: 'AbortError' })
expect(signal?.aborted).toBe(true)
pending.resolve(result)
await turn()
expect(outbound).toEqual([{ type: 'result', id: 1, operation: 'list', value: result }])
deliver(child)
await close(child)
})
it('a pending queued cancellation still aborts before the queued scanner begins', async () => {
const child = createClient()
const pending = Promise.withResolvers<typeof result>()
const signals: AbortSignal[] = []
scan.mockImplementation((options) => {
if (!options.signal) {
throw new Error('Expected the owned scanner signal')
}
signals.push(options.signal)
return signals.length === 1 ? pending.promise : Promise.resolve(result)
})
const response = currentClient().listSessions({})
deliver(child)
await turn()
process.emit('message', { type: 'request', id: 999, operation: 'list', params: {} })
process.emit('message', { type: 'cancel', id: 999 })
expect(signals).toHaveLength(1)
pending.resolve(result)
await turn()
expect(signals).toHaveLength(2)
expect(signals[1]?.aborted).toBe(true)
expect(outbound).toEqual([
{ type: 'result', id: 1, operation: 'list', value: result },
{ type: 'result', id: 999, operation: 'list', value: result }
])
deliver(child)
await expect(response).resolves.toEqual(result)
deliver(child)
await close(child)
})
it('unknown, pre-init and duplicate completed cancels preserve the next live request', async () => {
process.emit('message', { type: 'cancel', id: 9000 })
const child = createClient()
const first = currentClient().listSessions({})
deliver(child)
await turn()
deliver(child)
await expect(first).resolves.toEqual(result)
for (const id of [1, 1, 9000, 9000]) {
process.emit('message', { type: 'cancel', id })
}
expect(outbound).toEqual([])
const live = currentClient().listSessions({})
await turn()
expect(scan.mock.calls[1]?.[0].signal?.aborted).toBe(false)
expect(outbound).toEqual([{ type: 'result', id: 2, operation: 'list', value: result }])
deliver(child)
await expect(live).resolves.toEqual(result)
await close(child)
})
it('shutdown aborts active work and ignores later cancellations and admission', async () => {
const child = createClient()
const pending = Promise.withResolvers<typeof result>()
let signal: AbortSignal | undefined
scan.mockImplementationOnce((options) => {
signal = options.signal
return pending.promise
})
const response = currentClient().listSessions({})
const rejected = expect(response).rejects.toThrow('scan stopped')
deliver(child)
await turn()
process.emit('message', { type: 'shutdown' })
expect(signal?.aborted).toBe(true)
process.emit('message', { type: 'cancel', id: 1 })
process.emit('message', { type: 'cancel', id: 9000 })
process.emit('message', { type: 'request', id: 9000, operation: 'list', params: {} })
pending.reject(new Error('scan stopped'))
await turn()
expect(scan).toHaveBeenCalledOnce()
expect(outbound).toEqual([{ type: 'error', id: 1, message: 'scan stopped' }])
deliver(child)
await rejected
await close(child)
})
-190
View File
@@ -1,190 +0,0 @@
import type { ChildProcess } from 'node:child_process'
import type { AiVaultListResult } from '../shared/ai-vault-types'
import type {
AiVaultSessionTitleRequest,
AiVaultSessionTitlesResult
} from '../shared/ai-vault-session-title'
import type { SshAiVaultRelayListParams } from '../shared/ssh-ai-vault-relay'
import type { RemoteHostPlatform } from '../main/ssh/ssh-remote-platform'
import {
relayAiVaultServiceLane,
type RelayAiVaultServiceLane,
type RelayAiVaultServiceRequest
} from './ai-vault-service-protocol'
export const RELAY_AI_VAULT_READY_TIMEOUT_MS = 5_000
export const RELAY_AI_VAULT_SCAN_TIMEOUT_MS = 130_000
export const RELAY_AI_VAULT_TITLE_TIMEOUT_MS = 15_000
export const RELAY_AI_VAULT_MAX_CALLS = 16
export const RELAY_AI_VAULT_IDLE_TIMEOUT_MS = 10 * 60_000
export function relayAiVaultAbortError(): Error {
const error = new Error('The operation was aborted.')
error.name = 'AbortError'
return error
}
export function relayAiVaultError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error))
}
export function armRelayAiVaultCallTimeout(
call: RelayAiVaultServiceCall,
onExpired: (timeout: number) => void
): void {
const timeout =
call.request.operation === 'list'
? RELAY_AI_VAULT_SCAN_TIMEOUT_MS
: RELAY_AI_VAULT_TITLE_TIMEOUT_MS
call.timer = setTimeout(() => onExpired(timeout), timeout)
call.timer.unref?.()
}
export function armRelayAiVaultCancellationTimeout(
call: RelayAiVaultServiceCall,
onExpired: () => void
): void {
call.timer = setTimeout(onExpired, 2_000)
call.timer.unref?.()
}
export function createRelayAiVaultServiceCall(args: {
request: RelayAiVaultServiceRequest
signal?: AbortSignal
resolve: RelayAiVaultServiceCall['resolve']
reject: RelayAiVaultServiceCall['reject']
}): RelayAiVaultServiceCall {
return {
request: args.request,
lane: relayAiVaultServiceLane(args.request.operation),
signal: args.signal,
forceStart: args.request.operation === 'list' && args.request.params.force === true,
resolve: args.resolve,
reject: args.reject,
timer: null,
onAbort: null,
settled: false,
sent: false,
startRetried: false,
startAttempt: 0
}
}
/**
* A cold start that faults before the request reached the sidecar self-heals on
* the scheduled respawn. Requeue once; the caller settles when this returns false.
*/
export function requeueRelayAiVaultServiceStart(
call: RelayAiVaultServiceCall,
queue: RelayAiVaultServiceCall[]
): boolean {
if (call.sent || call.settled || call.startRetried) {
return false
}
call.startRetried = true
queue.unshift(call)
return true
}
export function settleRelayAiVaultServiceCall(
call: RelayAiVaultServiceCall,
value: Error | AiVaultListResult | AiVaultSessionTitlesResult
): void {
// A cancelled call is settled before its cancel watchdog is armed, so the
// timer has to be cleared even when the reject/resolve is already done.
if (call.timer) {
clearTimeout(call.timer)
call.timer = null
}
if (call.settled) {
return
}
call.settled = true
if (call.signal && call.onAbort) {
call.signal.removeEventListener('abort', call.onAbort)
}
if (value instanceof Error) {
call.reject(value)
} else {
call.resolve(value)
}
}
export type RelayAiVaultServiceCall = {
request: RelayAiVaultServiceRequest
lane: RelayAiVaultServiceLane
signal?: AbortSignal
forceStart: boolean
resolve: (value: AiVaultListResult | AiVaultSessionTitlesResult) => void
reject: (error: Error) => void
timer: NodeJS.Timeout | null
onAbort: (() => void) | null
settled: boolean
/** Whether the sidecar received the request; an unsent call gets no reply. */
sent: boolean
startRetried: boolean
startAttempt: number
}
export type RelayAiVaultServiceApi = {
listSessions(params: SshAiVaultRelayListParams, signal?: AbortSignal): Promise<AiVaultListResult>
resolveSessionTitles(
requests: AiVaultSessionTitleRequest[],
signal?: AbortSignal
): Promise<AiVaultSessionTitlesResult>
}
export type RelayAiVaultServiceClientOptions = {
processFactory: () => ChildProcess
init: {
remoteHome: string
hostPlatform: RemoteHostPlatform
}
now?: () => number
idleTimeoutMs?: number
}
export class RelayAiVaultIdleRetirement {
private timer: NodeJS.Timeout | null = null
clear(): void {
if (this.timer) {
clearTimeout(this.timer)
this.timer = null
}
}
schedule(busy: boolean, timeoutMs: number, retire: () => void): void {
if (busy || this.timer) {
return
}
this.timer = setTimeout(() => {
this.timer = null
retire()
}, timeoutMs)
this.timer.unref?.()
}
}
export function retireRelayAiVaultServiceChild(child: ChildProcess): void {
const timer = setTimeout(() => child.kill(), 2_000)
timer.unref?.()
child.once('exit', () => clearTimeout(timer))
child.send({ type: 'shutdown' }, () => undefined)
}
/** Orderly shutdown that never holds relay teardown past the kill deadline. */
export function shutdownRelayAiVaultServiceChild(child: ChildProcess): Promise<void> {
child.send({ type: 'shutdown' }, () => undefined)
return new Promise<void>((resolve) => {
const timer = setTimeout(() => {
child.kill()
resolve()
}, 2_000)
timer.unref?.()
child.once('exit', () => {
clearTimeout(timer)
resolve()
})
})
}
-299
View File
@@ -1,299 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { getRemoteHostPlatform } from '../main/ssh/ssh-remote-platform'
import {
AiVaultServiceTestChild,
readyAiVaultServiceChild
} from '../main/ai-vault/session-scanner-service-test-child'
import { RelayAiVaultServiceClient } from './ai-vault-service-client'
import { RELAY_AI_VAULT_READY_TIMEOUT_MS } from './ai-vault-service-client-state'
import { relayAiVaultServiceEntryPath } from './ai-vault-service-spawn'
function createClient(
children: AiVaultServiceTestChild[],
idleTimeoutMs?: number
): RelayAiVaultServiceClient {
return new RelayAiVaultServiceClient({
init: {
remoteHome: '/home/ada',
hostPlatform: getRemoteHostPlatform('linux-x64')
},
processFactory: () => {
const child = new AiVaultServiceTestChild(20_000 + children.length)
children.push(child)
return child.asChildProcess()
},
idleTimeoutMs
})
}
function relayRequests(child: AiVaultServiceTestChild, operation: string): { id: number }[] {
return child.sent.filter(
(message) => (message as { operation?: string }).operation === operation
) as { id: number }[]
}
function relayRequestCount(child: AiVaultServiceTestChild, operation: string): number {
return relayRequests(child, operation).length
}
function relayRequestId(child: AiVaultServiceTestChild, operation: string): number {
const request = relayRequests(child, operation).at(-1)
if (!request) {
throw new Error(`No ${operation} request was sent.`)
}
return request.id
}
afterEach(() => vi.useRealTimers())
describe('RelayAiVaultServiceClient', () => {
it('holds list and title calls behind the ready handshake', async () => {
const children: AiVaultServiceTestChild[] = []
const client = createClient(children)
const list = client.listSessions({ limit: 20 })
const titles = client.resolveSessionTitles([])
const child = children[0]!
expect(child.sent).toEqual([expect.objectContaining({ type: 'init', protocol: 1 })])
readyAiVaultServiceChild(child)
await Promise.resolve()
const listRequest = child.sent.find(
(message) => (message as { operation?: string }).operation === 'list'
) as { id: number }
child.emit('message', {
type: 'result',
id: listRequest.id,
operation: 'list',
value: { sessions: [], issues: [], scannedAt: '2026-08-09T00:00:00.000Z' }
})
await expect(list).resolves.toMatchObject({ sessions: [] })
const titleRequest = child.sent.find(
(message) => (message as { operation?: string }).operation === 'titles'
) as { id: number }
child.emit('message', {
type: 'result',
id: titleRequest.id,
operation: 'titles',
value: { titles: [] }
})
await expect(titles).resolves.toEqual({ titles: [] })
const disposing = client.dispose()
child.emit('exit', 0)
await disposing
})
it('resolves titles while a scan still occupies the cache lane', async () => {
const children: AiVaultServiceTestChild[] = []
const client = createClient(children)
const list = client.listSessions({})
const child = children[0]!
readyAiVaultServiceChild(child)
await Promise.resolve()
const titles = client.resolveSessionTitles([
{ agent: 'claude', sessionId: 'session-1', transcriptPath: '/home/ada/session-1.jsonl' }
])
await Promise.resolve()
child.emit('message', {
type: 'result',
id: relayRequestId(child, 'titles'),
operation: 'titles',
value: { titles: [] }
})
await expect(titles).resolves.toEqual({ titles: [] })
child.emit('message', {
type: 'result',
id: relayRequestId(child, 'list'),
operation: 'list',
value: { sessions: [], issues: [], scannedAt: '2026-08-09T00:00:00.000Z' }
})
await expect(list).resolves.toMatchObject({ sessions: [] })
const disposing = client.dispose()
child.emit('exit', 0)
await disposing
})
it('does not start queued cache work until cancelled work acknowledges', async () => {
vi.useFakeTimers()
const children: AiVaultServiceTestChild[] = []
const client = createClient(children)
const controller = new AbortController()
const first = client.listSessions({}, controller.signal)
const second = client.listSessions({ limit: 5 })
const child = children[0]!
readyAiVaultServiceChild(child)
await Promise.resolve()
const firstRequest = relayRequestId(child, 'list')
controller.abort()
await expect(first).rejects.toMatchObject({ name: 'AbortError' })
expect(relayRequestCount(child, 'list')).toBe(1)
child.emit('message', {
type: 'error',
id: firstRequest,
message: 'aborted'
})
await Promise.resolve()
expect(relayRequestCount(child, 'list')).toBe(2)
void second.catch(() => undefined)
const disposing = client.dispose()
child.emit('exit', 0)
await disposing
})
it('drops a call cancelled before the sidecar received it', async () => {
vi.useFakeTimers()
const children: AiVaultServiceTestChild[] = []
const client = createClient(children)
const controller = new AbortController()
const cancelled = client.listSessions({}, controller.signal)
const child = children[0]!
controller.abort()
await expect(cancelled).rejects.toMatchObject({ name: 'AbortError' })
expect(child.sent).not.toContainEqual(expect.objectContaining({ type: 'cancel' }))
readyAiVaultServiceChild(child)
const next = client.listSessions({ limit: 5 })
await Promise.resolve()
child.emit('message', {
type: 'result',
id: relayRequestId(child, 'list'),
operation: 'list',
value: { sessions: [], issues: [], scannedAt: '2026-08-09T00:00:00.000Z' }
})
await expect(next).resolves.toMatchObject({ sessions: [] })
vi.advanceTimersByTime(2_000)
expect(child.killed).toBe(false)
const disposing = client.dispose()
child.emit('exit', 0)
await disposing
})
it('retries a cold-start crash once without faulting the replacement sidecar', async () => {
vi.useFakeTimers()
const children: AiVaultServiceTestChild[] = []
const client = createClient(children)
const list = client.listSessions({})
children[0]!.emit('exit', 1)
await Promise.resolve()
vi.advanceTimersByTime(250)
expect(children).toHaveLength(2)
readyAiVaultServiceChild(children[1]!)
await Promise.resolve()
vi.advanceTimersByTime(RELAY_AI_VAULT_READY_TIMEOUT_MS)
expect(children[1]!.killed).toBe(false)
children[1]!.emit('message', {
type: 'result',
id: relayRequestId(children[1]!, 'list'),
operation: 'list',
value: { sessions: [], issues: [], scannedAt: '2026-08-09T00:00:00.000Z' }
})
await expect(list).resolves.toMatchObject({ sessions: [] })
const disposing = client.dispose()
children[1]!.emit('exit', 0)
await disposing
})
it('keeps the sidecar alive once a cancelled call acknowledges', async () => {
vi.useFakeTimers()
const children: AiVaultServiceTestChild[] = []
const client = createClient(children)
const controller = new AbortController()
const first = client.listSessions({}, controller.signal)
const second = client.resolveSessionTitles([])
const child = children[0]!
readyAiVaultServiceChild(child)
await Promise.resolve()
const firstRequest = child.sent.find(
(message) => (message as { operation?: string }).operation === 'list'
) as { id: number }
controller.abort()
await expect(first).rejects.toMatchObject({ name: 'AbortError' })
child.emit('message', { type: 'error', id: firstRequest.id, message: 'aborted' })
await Promise.resolve()
vi.advanceTimersByTime(2_000)
expect(child.killed).toBe(false)
const titleRequest = child.sent.find(
(message) => (message as { operation?: string }).operation === 'titles'
) as { id: number }
child.emit('message', {
type: 'result',
id: titleRequest.id,
operation: 'titles',
value: { titles: [] }
})
await expect(second).resolves.toEqual({ titles: [] })
const disposing = client.dispose()
child.emit('exit', 0)
await disposing
})
it('restarts queued work after a sidecar crash with bounded backoff', async () => {
vi.useFakeTimers()
const children: AiVaultServiceTestChild[] = []
const client = createClient(children)
const first = client.listSessions({})
const second = client.listSessions({ limit: 5 })
readyAiVaultServiceChild(children[0]!)
await Promise.resolve()
children[0]!.emit('exit', 1)
await expect(first).rejects.toThrow('exited')
expect(children).toHaveLength(1)
vi.advanceTimersByTime(250)
expect(children).toHaveLength(2)
readyAiVaultServiceChild(children[1]!)
await Promise.resolve()
children[1]!.emit('message', {
type: 'result',
id: relayRequestId(children[1]!, 'list'),
operation: 'list',
value: { sessions: [], issues: [], scannedAt: '2026-08-09T00:00:00.000Z' }
})
await expect(second).resolves.toMatchObject({ sessions: [] })
const disposing = client.dispose()
children[1]!.emit('exit', 0)
await disposing
})
it('retires the sidecar after the idle bound', async () => {
vi.useFakeTimers()
const children: AiVaultServiceTestChild[] = []
const client = createClient(children, 100)
const list = client.listSessions({})
const child = children[0]!
readyAiVaultServiceChild(child)
await Promise.resolve()
const request = child.sent.find(
(message) => (message as { operation?: string }).operation === 'list'
) as { id: number }
child.emit('message', {
type: 'result',
id: request.id,
operation: 'list',
value: { sessions: [], issues: [], scannedAt: '2026-08-09T00:00:00.000Z' }
})
await list
vi.advanceTimersByTime(99)
expect(child.sent).not.toContainEqual({ type: 'shutdown' })
vi.advanceTimersByTime(1)
expect(child.sent).toContainEqual({ type: 'shutdown' })
vi.advanceTimersByTime(2_000)
expect(child.killed).toBe(true)
await client.dispose()
})
it('resolves the sidecar beside each bundled relay', () => {
expect(relayAiVaultServiceEntryPath('/opt/orca/relay')).toBe(
'/opt/orca/relay/relay-ai-vault-service.js'
)
})
})
-318
View File
@@ -1,318 +0,0 @@
import type { ChildProcess } from 'node:child_process'
import { PromiseSettlementWaiters } from '../shared/promise-settlement-waiters'
import type { AiVaultListResult } from '../shared/ai-vault-types'
import type {
AiVaultSessionTitleRequest,
AiVaultSessionTitlesResult
} from '../shared/ai-vault-session-title'
import type { SshAiVaultRelayListParams } from '../shared/ssh-ai-vault-relay'
import {
RELAY_AI_VAULT_MAX_CALLS,
RELAY_AI_VAULT_IDLE_TIMEOUT_MS,
RELAY_AI_VAULT_READY_TIMEOUT_MS,
armRelayAiVaultCallTimeout,
armRelayAiVaultCancellationTimeout,
createRelayAiVaultServiceCall,
relayAiVaultAbortError,
relayAiVaultError,
RelayAiVaultIdleRetirement,
requeueRelayAiVaultServiceStart,
retireRelayAiVaultServiceChild,
settleRelayAiVaultServiceCall,
shutdownRelayAiVaultServiceChild,
type RelayAiVaultServiceApi,
type RelayAiVaultServiceCall,
type RelayAiVaultServiceClientOptions
} from './ai-vault-service-client-state'
import {
RELAY_AI_VAULT_SERVICE_PROTOCOL,
isRelayAiVaultServiceChildMessage,
type RelayAiVaultServiceChildMessage,
type RelayAiVaultServiceInit,
type RelayAiVaultServiceLane,
type RelayAiVaultServiceRequest
} from './ai-vault-service-protocol'
import { RelayAiVaultRestartPolicy } from './ai-vault-service-restart-policy'
import { relayLogLine } from './relay-diagnostic-log'
export class RelayAiVaultServiceClient implements RelayAiVaultServiceApi {
private child: ChildProcess | null = null
private ready: PromiseSettlementWaiters<ChildProcess> | null = null
private readyReject: ((error: Error) => void) | null = null
private readyTimer: NodeJS.Timeout | null = null
private readonly active = new Map<RelayAiVaultServiceLane, RelayAiVaultServiceCall>()
private readonly queue: RelayAiVaultServiceCall[] = []
private nextId = 1
private readonly restartPolicy: RelayAiVaultRestartPolicy
private readonly idleRetirement = new RelayAiVaultIdleRetirement()
private disposed = false
constructor(private readonly options: RelayAiVaultServiceClientOptions) {
this.restartPolicy = new RelayAiVaultRestartPolicy(options.now)
}
listSessions(
params: SshAiVaultRelayListParams,
signal?: AbortSignal
): Promise<AiVaultListResult> {
return this.request({ type: 'request', id: this.nextId++, operation: 'list', params }, signal)
}
resolveSessionTitles(
requests: AiVaultSessionTitleRequest[],
signal?: AbortSignal
): Promise<AiVaultSessionTitlesResult> {
return this.request(
{ type: 'request', id: this.nextId++, operation: 'titles', requests },
signal
)
}
async dispose(): Promise<void> {
this.disposed = true
this.idleRetirement.clear()
this.clearReadyTimer()
this.restartPolicy.dispose()
const error = new Error('Relay AI Vault service was disposed.')
this.readyReject?.(error)
this.readyReject = null
this.ready = null
for (const call of [...this.active.values(), ...this.queue.splice(0)]) {
settleRelayAiVaultServiceCall(call, error)
}
this.active.clear()
const child = this.detachChild()
if (child) {
await shutdownRelayAiVaultServiceChild(child)
}
}
private request<T extends AiVaultListResult | AiVaultSessionTitlesResult>(
request: RelayAiVaultServiceRequest,
signal?: AbortSignal
): Promise<T> {
if (this.disposed) {
return Promise.reject(new Error('Relay AI Vault service was disposed.'))
}
if (signal?.aborted) {
return Promise.reject(relayAiVaultAbortError())
}
if (this.queue.length + this.active.size >= RELAY_AI_VAULT_MAX_CALLS) {
return Promise.reject(new Error('Relay AI Vault service queue is full.'))
}
return new Promise<T>((resolve, reject) => {
const call = createRelayAiVaultServiceCall({
request,
signal,
resolve: resolve as RelayAiVaultServiceCall['resolve'],
reject
})
if (signal) {
call.onAbort = () => this.cancel(call)
signal.addEventListener('abort', call.onAbort, { once: true })
}
this.queue.push(call)
this.idleRetirement.clear()
this.pump()
})
}
private pump(): void {
if (this.disposed || this.restartPolicy.restartScheduled) {
return
}
for (const lane of ['cache', 'interactive'] as const) {
if (this.active.has(lane)) {
continue
}
const index = this.queue.findIndex((call) => call.lane === lane)
if (index === -1) {
continue
}
const call = this.queue.splice(index, 1)[0]!
this.active.set(lane, call)
const attempt = ++call.startAttempt
const readiness = this.ensureChild(call.forceStart)
void readiness
.wait({
signal: call.signal,
createAbortError: relayAiVaultAbortError,
onFulfilled: (child) => this.sendCall(child, call)
})
.catch((error: Error) => {
if (this.active.get(lane) !== call || call.startAttempt !== attempt) {
return
}
this.active.delete(lane)
this.retryStartOrSettle(call, error)
this.pump()
})
}
this.scheduleIdleIfNeeded()
}
private sendCall(child: ChildProcess, call: RelayAiVaultServiceCall): void {
if (this.active.get(call.lane) !== call || call.settled) {
return
}
armRelayAiVaultCallTimeout(call, (timeout) =>
this.onFault(new Error(`Relay AI Vault service timed out after ${timeout}ms.`))
)
call.sent = true
child.send(call.request)
}
private retryStartOrSettle(call: RelayAiVaultServiceCall, error: Error): void {
if (this.disposed || !requeueRelayAiVaultServiceStart(call, this.queue)) {
settleRelayAiVaultServiceCall(call, error)
}
}
private ensureChild(forceStart: boolean): PromiseSettlementWaiters<ChildProcess> {
if (this.child && !this.ready) {
return new PromiseSettlementWaiters(Promise.resolve(this.child))
}
if (this.ready) {
return this.ready
}
const startError = this.restartPolicy.startError(forceStart)
if (startError) {
return new PromiseSettlementWaiters(Promise.reject(startError))
}
let child: ChildProcess
try {
child = this.options.processFactory()
} catch (error) {
return new PromiseSettlementWaiters(Promise.reject(relayAiVaultError(error)))
}
this.child = child
this.ready = new PromiseSettlementWaiters(
new Promise<ChildProcess>((resolve, reject) => {
this.readyReject = reject
// Why: held on the instance so a crash before ready cannot leave the deadline
// armed, where it would later fault the healthy replacement sidecar.
this.readyTimer = setTimeout(
() => this.onFault(new Error('Relay AI Vault service did not become ready.')),
RELAY_AI_VAULT_READY_TIMEOUT_MS
)
this.readyTimer.unref?.()
child.on('message', (message) => {
if (isRelayAiVaultServiceChildMessage(message) && message.type === 'ready') {
this.clearReadyTimer()
this.ready = null
this.readyReject = null
resolve(child)
return
}
this.onMessage(message)
})
})
)
child.on('error', (error) => this.onFault(error))
child.on('disconnect', () => this.onFault(new Error('Relay AI Vault service disconnected.')))
child.on('exit', (code) => this.onFault(new Error(`Relay AI Vault service exited (${code}).`)))
child.stderr?.on('data', (chunk: Buffer) =>
relayLogLine(`[relay-ai-vault-service] ${String(chunk).trimEnd()}`)
)
child.send({
type: 'init',
protocol: RELAY_AI_VAULT_SERVICE_PROTOCOL,
...this.options.init
} satisfies RelayAiVaultServiceInit)
return this.ready
}
private onMessage(raw: unknown): void {
if (!isRelayAiVaultServiceChildMessage(raw)) {
this.onFault(new Error('Relay AI Vault service sent a malformed message.'))
return
}
const message = raw as RelayAiVaultServiceChildMessage
if (message.type === 'ready') {
return
}
const call = [...this.active.values()].find((entry) => entry.request.id === message.id)
if (!call) {
return
}
this.active.delete(call.lane)
settleRelayAiVaultServiceCall(
call,
message.type === 'error' ? new Error(message.message) : message.value
)
this.pump()
}
private cancel(call: RelayAiVaultServiceCall): void {
const index = this.queue.indexOf(call)
if (index !== -1) {
this.queue.splice(index, 1)
settleRelayAiVaultServiceCall(call, relayAiVaultAbortError())
this.pump()
return
}
if (this.active.get(call.lane) === call) {
// Why: a call cancelled before it reached the sidecar gets no acknowledgement,
// so waiting on one would kill a healthy sidecar and stall the lane.
if (!call.sent) {
this.active.delete(call.lane)
settleRelayAiVaultServiceCall(call, relayAiVaultAbortError())
this.pump()
return
}
this.child?.send({ type: 'cancel', id: call.request.id })
settleRelayAiVaultServiceCall(call, relayAiVaultAbortError())
armRelayAiVaultCancellationTimeout(call, () =>
this.onFault(new Error('Relay AI Vault service did not cancel within 2000ms.'))
)
}
}
private onFault(error: Error): void {
if (!this.child) {
return
}
this.idleRetirement.clear()
this.detachChild()?.kill()
this.clearReadyTimer()
this.readyReject?.(error)
this.readyReject = null
this.ready = null
const active = [...this.active.values()]
this.active.clear()
for (const call of active) {
this.retryStartOrSettle(call, error)
}
this.restartPolicy.recordFault()
if (this.queue.length > 0 && !this.disposed) {
this.restartPolicy.scheduleRestart(() => this.pump())
}
}
private clearReadyTimer(): void {
if (this.readyTimer) {
clearTimeout(this.readyTimer)
this.readyTimer = null
}
}
private detachChild(): ChildProcess | null {
const child = this.child
this.child = null
child?.removeAllListeners()
return child
}
private scheduleIdleIfNeeded(): void {
this.idleRetirement.schedule(
this.active.size > 0 || this.queue.length > 0 || !this.child,
this.options.idleTimeoutMs ?? RELAY_AI_VAULT_IDLE_TIMEOUT_MS,
() => {
const child = this.detachChild()
if (child) {
retireRelayAiVaultServiceChild(child)
}
}
)
}
}
-134
View File
@@ -1,134 +0,0 @@
import { LOCAL_EXECUTION_HOST_ID } from '../shared/execution-host'
import { resolveAbsoluteDirOverride } from '../shared/absolute-dir-override'
import { joinRemotePath } from '../main/ssh/ssh-remote-platform'
import { scanRemoteAiVaultSessions } from '../main/ai-vault/remote-session-scanner'
import { readAiVaultSessionTitlesFromFiles } from '../main/ai-vault/session-title-file-reader'
import { createRelayAiVaultFilesystemProvider } from './ai-vault-service-filesystem'
import {
RELAY_AI_VAULT_SERVICE_PROTOCOL,
isRelayAiVaultServiceRequest,
relayAiVaultServiceLane,
type RelayAiVaultServiceChildMessage,
type RelayAiVaultServiceInit,
type RelayAiVaultServiceParentMessage,
type RelayAiVaultServiceRequest
} from './ai-vault-service-protocol'
if (!process.send) {
throw new Error('Relay AI Vault service requires a parent IPC channel.')
}
const controllers = new Map<number, AbortController>()
const cancelled = new Set<number>()
const pending = new Set<number>()
let provider: ReturnType<typeof createRelayAiVaultFilesystemProvider> | null = null
let init: RelayAiVaultServiceInit | null = null
let cacheLane = Promise.resolve()
let interactiveLane = Promise.resolve()
let shuttingDown = false
function send(message: RelayAiVaultServiceChildMessage): void {
process.send?.(message)
}
async function execute(request: RelayAiVaultServiceRequest): Promise<void> {
const controller = new AbortController()
controllers.set(request.id, controller)
if (cancelled.delete(request.id)) {
controller.abort()
}
try {
if (!init || !provider) {
throw new Error('Relay AI Vault service is not initialized.')
}
if (request.operation === 'titles') {
const value = await readAiVaultSessionTitlesFromFiles(request.requests, {
signal: controller.signal
})
send({ type: 'result', id: request.id, operation: 'titles', value })
return
}
const value = await scanRemoteAiVaultSessions({
provider,
executionHostId: LOCAL_EXECUTION_HOST_ID,
remoteHome: init.remoteHome,
kiroHomeDir: resolveAbsoluteDirOverride(
process.env.KIRO_HOME,
joinRemotePath(init.hostPlatform, init.remoteHome, '.kiro')
),
hostPlatform: init.hostPlatform,
limit: request.params.limit,
unlimited: request.params.unlimited,
includeAntigravityIdeSessions: request.params.includeAntigravityIdeSessions,
scopePaths: request.params.scopePaths,
signal: controller.signal
})
send({ type: 'result', id: request.id, operation: 'list', value })
} catch (error) {
send({
type: 'error',
id: request.id,
message: error instanceof Error ? error.message : String(error)
})
} finally {
controllers.delete(request.id)
cancelled.delete(request.id)
pending.delete(request.id)
}
}
async function shutdown(): Promise<void> {
if (shuttingDown) {
return
}
shuttingDown = true
for (const controller of controllers.values()) {
controller.abort()
}
await Promise.allSettled([cacheLane, interactiveLane])
provider?.dispose()
process.disconnect?.()
}
process.on('message', (raw: RelayAiVaultServiceParentMessage) => {
if (raw?.type === 'init') {
if (init || raw.protocol !== RELAY_AI_VAULT_SERVICE_PROTOCOL) {
void shutdown()
return
}
init = raw
provider = createRelayAiVaultFilesystemProvider({ homeDirectory: raw.remoteHome })
send({ type: 'ready', protocol: RELAY_AI_VAULT_SERVICE_PROTOCOL, pid: process.pid })
return
}
if (!init || shuttingDown) {
return
}
if (raw?.type === 'cancel') {
if (!pending.has(raw.id)) {
return
}
cancelled.add(raw.id)
controllers.get(raw.id)?.abort()
return
}
if (raw?.type === 'shutdown') {
void shutdown()
return
}
if (!isRelayAiVaultServiceRequest(raw)) {
return
}
if (pending.size >= 16) {
send({ type: 'error', id: raw.id, message: 'Relay AI Vault service queue is full.' })
return
}
pending.add(raw.id)
if (relayAiVaultServiceLane(raw.operation) === 'interactive') {
interactiveLane = interactiveLane.then(() => execute(raw))
return
}
cacheLane = cacheLane.then(() => execute(raw))
})
process.on('disconnect', () => void shutdown())
-13
View File
@@ -1,13 +0,0 @@
import type { RemoteHostPlatform } from '../main/ssh/ssh-remote-platform'
import { RelayAiVaultServiceClient } from './ai-vault-service-client'
import { spawnRelayAiVaultService } from './ai-vault-service-spawn'
export function createRelayAiVaultService(
remoteHome: string,
hostPlatform: RemoteHostPlatform
): RelayAiVaultServiceClient {
return new RelayAiVaultServiceClient({
init: { remoteHome, hostPlatform },
processFactory: spawnRelayAiVaultService
})
}
-40
View File
@@ -1,40 +0,0 @@
import { readRelayTranscriptBytes } from './ai-vault-transcript-stream'
import { lstat, readdir } from 'node:fs/promises'
import type { RemoteSessionFilesystemProvider } from '../main/ai-vault/remote-session-scanner-types'
import { readRelayFileContent } from './fs-handler-file-read'
import {
createRelayOpenCodeReader,
type RelayOpenCodeReaderOptions
} from './ai-vault-opencode-reader'
export function createRelayAiVaultFilesystemProvider(
options: RelayOpenCodeReaderOptions = {}
): RemoteSessionFilesystemProvider & { dispose(): void } {
const openCode = createRelayOpenCodeReader(options)
return {
openCode,
dispose: () => openCode.dispose(),
async readDir(dirPath) {
const entries = await readdir(dirPath, { withFileTypes: true })
return entries.map((entry) => ({
name: entry.name,
isDirectory: entry.isDirectory(),
isSymlink: entry.isSymbolicLink()
}))
},
readFile: readRelayFileContent,
readTranscriptBytes: readRelayTranscriptBytes,
async stat(filePath) {
const stats = await lstat(filePath)
return {
size: stats.size,
type: stats.isDirectory() ? 'directory' : stats.isSymbolicLink() ? 'symlink' : 'file',
mtime: stats.mtimeMs,
mtimeMs: stats.mtimeMs,
dev: stats.dev,
ino: stats.ino,
nlink: stats.nlink
}
}
}
}
-86
View File
@@ -1,86 +0,0 @@
import type { AiVaultListResult } from '../shared/ai-vault-types'
import type {
AiVaultSessionTitleRequest,
AiVaultSessionTitlesResult
} from '../shared/ai-vault-session-title'
import type { SshAiVaultRelayListParams } from '../shared/ssh-ai-vault-relay'
import type { RemoteHostPlatform } from '../main/ssh/ssh-remote-platform'
export const RELAY_AI_VAULT_SERVICE_PROTOCOL = 1
export type RelayAiVaultServiceInit = {
type: 'init'
protocol: typeof RELAY_AI_VAULT_SERVICE_PROTOCOL
remoteHome: string
hostPlatform: RemoteHostPlatform
}
export type RelayAiVaultServiceRequest =
| {
type: 'request'
id: number
operation: 'list'
params: SshAiVaultRelayListParams
}
| {
type: 'request'
id: number
operation: 'titles'
requests: AiVaultSessionTitleRequest[]
}
export type RelayAiVaultServiceLane = 'cache' | 'interactive'
export type RelayAiVaultServiceOperation = RelayAiVaultServiceRequest['operation']
/** Title reads must not queue behind a full scan; they back interactive UI. */
export function relayAiVaultServiceLane(
operation: RelayAiVaultServiceOperation
): RelayAiVaultServiceLane {
return operation === 'titles' ? 'interactive' : 'cache'
}
export type RelayAiVaultServiceParentMessage =
| RelayAiVaultServiceInit
| RelayAiVaultServiceRequest
| { type: 'cancel'; id: number }
| { type: 'shutdown' }
export type RelayAiVaultServiceChildMessage =
| {
type: 'ready'
protocol: typeof RELAY_AI_VAULT_SERVICE_PROTOCOL
pid: number
}
| { type: 'result'; id: number; operation: 'list'; value: AiVaultListResult }
| {
type: 'result'
id: number
operation: 'titles'
value: AiVaultSessionTitlesResult
}
| { type: 'error'; id: number; message: string }
export function isRelayAiVaultServiceRequest(value: unknown): value is RelayAiVaultServiceRequest {
if (!value || typeof value !== 'object') {
return false
}
const message = value as Record<string, unknown>
return (
message.type === 'request' &&
Number.isSafeInteger(message.id) &&
(message.operation === 'list' || message.operation === 'titles')
)
}
export function isRelayAiVaultServiceChildMessage(
value: unknown
): value is RelayAiVaultServiceChildMessage {
if (!value || typeof value !== 'object') {
return false
}
const message = value as Record<string, unknown>
if (message.type === 'ready') {
return message.protocol === RELAY_AI_VAULT_SERVICE_PROTOCOL && Number.isSafeInteger(message.pid)
}
return (message.type === 'result' || message.type === 'error') && Number.isSafeInteger(message.id)
}
@@ -1,202 +0,0 @@
import { setImmediate } from 'node:timers/promises'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getRemoteHostPlatform } from '../main/ssh/ssh-remote-platform'
import {
AiVaultServiceTestChild,
readyAiVaultServiceChild
} from '../main/ai-vault/session-scanner-service-test-child'
import type { AiVaultSessionTitleRequest } from '../shared/ai-vault-session-title'
import { RelayAiVaultServiceClient } from './ai-vault-service-client'
function fixture() {
const child = new AiVaultServiceTestChild()
const processFactory = vi.fn(() => child.asChildProcess())
const client = new RelayAiVaultServiceClient({
processFactory,
init: { remoteHome: '/home/ada', hostPlatform: getRemoteHostPlatform('linux-x64') }
})
return { client, child, processFactory }
}
async function collect(): Promise<void> {
if (!global.gc) {
throw new Error('This regression requires --expose-gc')
}
for (let turn = 0; turn < 3; turn++) {
await setImmediate()
global.gc()
}
}
function titleRequests(): AiVaultSessionTitleRequest[] {
return [{ agent: 'claude', sessionId: 'session', transcriptPath: '/home/ada/session.jsonl' }]
}
beforeEach(() => vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }))
afterEach(() => {
vi.clearAllTimers()
vi.useRealTimers()
})
describe('AI Vault readiness ownership', () => {
it('releases canceled request payloads while readiness remains pending', async () => {
const { client, child, processFactory } = fixture()
const canceled = await (async () => {
const refs: WeakRef<AiVaultSessionTitleRequest[]>[] = []
for (let index = 0; index < 1000; index++) {
const requests = titleRequests()
refs.push(new WeakRef(requests))
const controller = new AbortController()
const result = client.resolveSessionTitles(requests, controller.signal)
controller.abort()
const error = await result.catch((reason: unknown) => reason)
if (!(error instanceof Error) || error.name !== 'AbortError') {
throw new Error('Canceled title request did not reject with AbortError')
}
}
return refs
})()
await collect()
expect(canceled.filter((ref) => ref.deref() !== undefined)).toHaveLength(0)
expect(child.sent).toHaveLength(1)
expect(processFactory).toHaveBeenCalledOnce()
const live = client.resolveSessionTitles(titleRequests())
readyAiVaultServiceChild(child)
await Promise.resolve()
expect(child.sent.at(-1)).toMatchObject({ type: 'request', id: 1001, operation: 'titles' })
child.emit('message', { type: 'result', id: 1001, operation: 'titles', value: { titles: [] } })
await expect(live).resolves.toEqual({ titles: [] })
const disposed = client.dispose()
child.emit('exit', 0)
await disposed
expect(vi.getTimerCount()).toBe(0)
})
it('releases uncanceled readiness payloads when the client is disposed', async () => {
const { client, child } = fixture()
const discarded = await (async () => {
const requests = titleRequests()
const ref = new WeakRef(requests)
const result = client.resolveSessionTitles(requests)
const rejection = expect(result).rejects.toThrow('disposed')
const disposed = client.dispose()
child.emit('exit', 0)
await disposed
await rejection
return ref
})()
await collect()
expect(discarded.deref() === undefined).toBe(true)
expect(child.sent).toEqual([expect.objectContaining({ type: 'init' }), { type: 'shutdown' }])
expect(vi.getTimerCount()).toBe(0)
await expect(client.resolveSessionTitles([])).rejects.toThrow('disposed')
})
it('does not send a call canceled in the turn that readiness arrives', async () => {
const { client, child } = fixture()
const controller = new AbortController()
const canceled = client.resolveSessionTitles(titleRequests(), controller.signal)
readyAiVaultServiceChild(child)
controller.abort()
const live = client.resolveSessionTitles([])
await expect(canceled).rejects.toMatchObject({ name: 'AbortError' })
expect(child.sent).toEqual([
expect.objectContaining({ type: 'init' }),
{ type: 'request', id: 2, operation: 'titles', requests: [] }
])
child.emit('message', { type: 'result', id: 2, operation: 'titles', value: { titles: [] } })
await expect(live).resolves.toEqual({ titles: [] })
const disposed = client.dispose()
child.emit('exit', 0)
await disposed
})
})
it('retries an unsent lane after spawn fails while the other lane starts a child', async () => {
const child = new AiVaultServiceTestChild()
const processFactory = vi.fn(() => child.asChildProcess())
processFactory.mockImplementationOnce(() => {
throw new Error('temporary spawn failure')
})
const client = new RelayAiVaultServiceClient({
processFactory,
init: { remoteHome: '/home/ada', hostPlatform: getRemoteHostPlatform('linux-x64') }
})
const list = client.listSessions({})
const titles = client.resolveSessionTitles([])
void list.catch(() => undefined)
void titles.catch(() => undefined)
try {
readyAiVaultServiceChild(child)
await vi.waitFor(() => {
expect(child.sent).toContainEqual({ type: 'request', id: 1, operation: 'list', params: {} })
})
expect(processFactory).toHaveBeenCalledTimes(2)
expect(child.sent).toContainEqual({ type: 'request', id: 1, operation: 'list', params: {} })
expect(child.sent).toContainEqual({ type: 'request', id: 2, operation: 'titles', requests: [] })
child.emit('message', {
type: 'result',
id: 1,
operation: 'list',
value: { sessions: [], issues: [], scannedAt: '2026-09-25T00:00:00.000Z' }
})
child.emit('message', { type: 'result', id: 2, operation: 'titles', value: { titles: [] } })
await expect(list).resolves.toMatchObject({ sessions: [] })
await expect(titles).resolves.toEqual({ titles: [] })
} finally {
const disposed = client.dispose()
child.emit('exit', 0)
await disposed
}
})
it('ignores an earlier readiness rejection after a replacement startup begins', async () => {
const first = new AiVaultServiceTestChild()
const replacement = new AiVaultServiceTestChild()
const processFactory = vi.fn(() => replacement.asChildProcess())
processFactory.mockImplementationOnce(() => first.asChildProcess())
const client = new RelayAiVaultServiceClient({
processFactory,
init: { remoteHome: '/home/ada', hostPlatform: getRemoteHostPlatform('linux-x64') }
})
const list = client.listSessions({})
void list.catch(() => undefined)
try {
first.emit('exit', 1)
// Start the replacement before the old readiness rejection's microtasks run.
vi.advanceTimersByTime(250)
const titles = client.resolveSessionTitles([])
void titles.catch(() => undefined)
readyAiVaultServiceChild(replacement)
await setImmediate()
expect(processFactory).toHaveBeenCalledTimes(2)
expect(first.sent).toEqual([expect.objectContaining({ type: 'init' })])
expect(replacement.sent).toEqual([
expect.objectContaining({ type: 'init' }),
{ type: 'request', id: 1, operation: 'list', params: {} },
{ type: 'request', id: 2, operation: 'titles', requests: [] }
])
replacement.emit('message', {
type: 'result',
id: 1,
operation: 'list',
value: { sessions: [], issues: [], scannedAt: '2026-09-25T00:00:00.000Z' }
})
replacement.emit('message', {
type: 'result',
id: 2,
operation: 'titles',
value: { titles: [] }
})
await expect(list).resolves.toMatchObject({ sessions: [] })
await expect(titles).resolves.toEqual({ titles: [] })
expect(processFactory).toHaveBeenCalledTimes(2)
} finally {
const disposed = client.dispose()
replacement.emit('exit', 0)
await disposed
}
expect(vi.getTimerCount()).toBe(0)
})
@@ -1,83 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { RelayAiVaultRestartPolicy } from './ai-vault-service-restart-policy'
afterEach(() => vi.useRealTimers())
describe('RelayAiVaultRestartPolicy', () => {
it('opens the circuit after three faults inside the window and closes it after', () => {
let now = 0
const policy = new RelayAiVaultRestartPolicy(() => now)
expect(policy.startError(false)).toBeNull()
for (let index = 0; index < 3; index += 1) {
policy.recordFault()
now += 1_000
}
expect(policy.startError(false)?.message).toContain('circuit is open')
now += 60_000
expect(policy.startError(false)).toBeNull()
})
it('keeps the circuit closed when the faults age out of the window', () => {
let now = 0
const policy = new RelayAiVaultRestartPolicy(() => now)
for (let index = 0; index < 3; index += 1) {
policy.recordFault()
now += 30_000
}
expect(policy.startError(false)).toBeNull()
})
it('lets a forced refresh reopen an open circuit', () => {
let now = 0
const policy = new RelayAiVaultRestartPolicy(() => now)
for (let index = 0; index < 3; index += 1) {
policy.recordFault()
}
expect(policy.startError(false)?.message).toContain('circuit is open')
expect(policy.startError(true)).toBeNull()
// The forced start clears the circuit outright, so the next background
// start is no longer refused either.
expect(policy.startError(false)).toBeNull()
})
it('backs off further with each fault and keeps one pending restart', () => {
vi.useFakeTimers()
const policy = new RelayAiVaultRestartPolicy()
const restart = vi.fn()
policy.recordFault()
policy.scheduleRestart(restart)
vi.advanceTimersByTime(250)
expect(restart).toHaveBeenCalledTimes(1)
policy.recordFault()
policy.scheduleRestart(restart)
policy.recordFault()
policy.scheduleRestart(restart)
vi.advanceTimersByTime(250)
expect(restart).toHaveBeenCalledTimes(1)
vi.advanceTimersByTime(5_000)
expect(restart).toHaveBeenCalledTimes(2)
})
it('cancels the pending restart on dispose', () => {
vi.useFakeTimers()
const policy = new RelayAiVaultRestartPolicy()
const restart = vi.fn()
policy.recordFault()
policy.scheduleRestart(restart)
policy.dispose()
vi.advanceTimersByTime(10_000)
expect(restart).not.toHaveBeenCalled()
expect(policy.restartScheduled).toBe(false)
})
})
@@ -1,55 +0,0 @@
const RELAY_AI_VAULT_FAULT_WINDOW_MS = 60_000
const RELAY_AI_VAULT_RESTART_DELAYS_MS = [250, 1_000, 5_000] as const
export class RelayAiVaultRestartPolicy {
private faults: number[] = []
private circuitUntil = 0
private timer: NodeJS.Timeout | null = null
constructor(private readonly now: () => number = Date.now) {}
get restartScheduled(): boolean {
return this.timer !== null
}
/** A forced refresh is a deliberate user action, so it reopens the circuit. */
startError(forceStart: boolean): Error | null {
if (forceStart) {
this.circuitUntil = 0
return null
}
return this.now() < this.circuitUntil
? new Error('Relay AI Vault service restart circuit is open.')
: null
}
recordFault(): void {
const now = this.now()
this.faults = [
...this.faults.filter((time) => now - time < RELAY_AI_VAULT_FAULT_WINDOW_MS),
now
]
if (this.faults.length >= 3) {
this.circuitUntil = now + RELAY_AI_VAULT_FAULT_WINDOW_MS
}
}
scheduleRestart(restart: () => void): void {
if (this.timer) {
clearTimeout(this.timer)
}
const delay = RELAY_AI_VAULT_RESTART_DELAYS_MS[Math.min(this.faults.length - 1, 2)]
this.timer = setTimeout(() => {
this.timer = null
restart()
}, delay)
this.timer.unref?.()
}
dispose(): void {
if (this.timer) {
clearTimeout(this.timer)
this.timer = null
}
}
}
-42
View File
@@ -1,42 +0,0 @@
import type { ChildProcess } from 'node:child_process'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const forkMock = vi.hoisted(() => vi.fn())
vi.mock('node:child_process', () => ({ fork: forkMock }))
const { spawnRelayAiVaultService } = await import('./ai-vault-service-spawn')
function forkOptions(): { env?: NodeJS.ProcessEnv; execArgv?: string[] } {
return forkMock.mock.calls.at(-1)?.[2] ?? {}
}
describe('spawnRelayAiVaultService', () => {
beforeEach(() => {
forkMock.mockReset()
forkMock.mockReturnValue({ pid: undefined, unref: vi.fn() } as unknown as ChildProcess)
})
it('keeps NODE_OPTIONS out of the sidecar so the heap cap and loader stand', () => {
vi.stubEnv('NODE_OPTIONS', '--max-old-space-size=8192 --require=/tmp/evil.js')
spawnRelayAiVaultService()
const options = forkOptions()
// Asserted first: omitting `env` entirely inherits everything, and would
// leave the NODE_OPTIONS assertion below passing for the wrong reason.
expect(options.env).toBeDefined()
expect(options.env?.NODE_OPTIONS).toBeUndefined()
expect(options.execArgv).toEqual(['--max-old-space-size=384'])
vi.unstubAllEnvs()
})
it('does not hand the sidecar the rest of the remote login environment', () => {
vi.stubEnv('AWS_SECRET_ACCESS_KEY', 'shhh')
spawnRelayAiVaultService()
const options = forkOptions()
expect(options.env).toBeDefined()
expect(options.env?.AWS_SECRET_ACCESS_KEY).toBeUndefined()
vi.unstubAllEnvs()
})
})
-20
View File
@@ -1,20 +0,0 @@
import { fork, type ChildProcess } from 'node:child_process'
import { join } from 'node:path'
import { buildRelayAiVaultServiceEnv } from '../main/ai-vault/session-scanner-service-env'
import { lowerAiVaultServicePriority } from '../main/ai-vault/session-scanner-service-priority'
export function relayAiVaultServiceEntryPath(baseDir = __dirname): string {
return join(baseDir, 'relay-ai-vault-service.js')
}
export function spawnRelayAiVaultService(): ChildProcess {
const child = fork(relayAiVaultServiceEntryPath(), [], {
stdio: ['ignore', 'ignore', 'pipe', 'ipc'],
execArgv: ['--max-old-space-size=384'],
env: buildRelayAiVaultServiceEnv(),
...(process.platform === 'win32' ? { windowsHide: true } : {})
})
lowerAiVaultServicePriority(child.pid)
child.unref()
return child
}
-48
View File
@@ -1,48 +0,0 @@
import { open } from 'node:fs/promises'
import { throwIfAiVaultScanCancelled } from '../main/ai-vault/ai-vault-scan-cancellation'
import { BinarySessionTranscriptError } from '../main/ai-vault/remote-session-content-lines'
import { BINARY_PROBE_BYTES, isBinaryBuffer } from '../shared/binary-buffer'
import { readNodeFileWithinLimit } from '../shared/node-bounded-file-reader'
/** The same open handle supplies the probe and stream, including across renames. */
export async function* readRelayTranscriptBytes(
path: string,
signal?: AbortSignal,
options?: { regularFileOnly: true; maxBytes: number }
): AsyncGenerator<Buffer> {
throwIfAiVaultScanCancelled(signal)
if (options?.regularFileOnly) {
const read = await readNodeFileWithinLimit(path, options.maxBytes, {
regularFileOnly: true,
signal
})
if (isBinaryBuffer(read.buffer.subarray(0, BINARY_PROBE_BYTES))) {
throw new BinarySessionTranscriptError()
}
throwIfAiVaultScanCancelled(signal)
yield read.buffer
return
}
const handle = await open(path, 'r')
try {
const probe = Buffer.alloc(BINARY_PROBE_BYTES)
const { bytesRead } = await handle.read(probe, 0, probe.length, 0)
if (isBinaryBuffer(probe.subarray(0, bytesRead))) {
throw new BinarySessionTranscriptError()
}
const input = handle.createReadStream({ start: 0, autoClose: false, signal })
try {
for await (const chunk of input) {
throwIfAiVaultScanCancelled(signal)
if (!Buffer.isBuffer(chunk)) {
throw new TypeError('Expected transcript byte buffer')
}
yield chunk
}
} finally {
input.destroy()
}
} finally {
await handle.close()
}
}
-14
View File
@@ -1,14 +0,0 @@
import { homedir } from 'node:os'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { expandTilde } from './context'
describe('expandTilde', () => {
it('expands POSIX-style home paths', () => {
expect(expandTilde('~/projects')).toBe(resolve(homedir(), 'projects'))
})
it('expands Windows-style home paths without forcing POSIX separators', () => {
expect(expandTilde('~\\projects')).toBe(`${homedir()}\\projects`)
})
})

Some files were not shown because too many files have changed in this diff Show More