diff --git a/docs/reference/omp-relay-session-roots.md b/docs/reference/omp-relay-session-roots.md new file mode 100644 index 00000000000..cee4f1ef424 --- /dev/null +++ b/docs/reference/omp-relay-session-roots.md @@ -0,0 +1,36 @@ +# OMP relay history roots + +The relay's history sidecar now resolves OMP storage on the execution host using +`resolveOmpSessionsDir`, the same resolver as the native reader and local history +scanner. Its environment allowlist carries only OMP root/profile inputs alongside +the existing runtime variables. Node loader flags and unrelated credentials remain +excluded. + +The selected root replaces the legacy OMP source; it does not add a second scan. +An invalid profile/root disables that source rather than selecting another store. +The same bounded discovery, cancellation, child partition and parse cache remain. +There are no new subprocesses, polls, recursive scan roots or wire fields. + +The optional scanner input is in-process only. A client performing the existing +filesystem fallback against an older relay still uses that host's legacy root, +without consulting the client's environment or disk. A new relay publishes the +same session schema to old and new clients; relocated conversations become visible. +No capability is needed to interpret those ordinary existing session rows. + +This covers the relay process's inherited environment. A profile or root set only +inside an individual terminal is not automatically part of that environment. It +does not prove resolution of the original iOS timing report #18663. + +Run the actual persistence-to-sidecar proof with a read-only OMP checkout: + +```sh +ORCA_BACKGROUND_LAUNCH=1 bun tests/tools/omp-relay-root-smoke.mjs /path/to/oh-my-pi +``` + +It bundles the production service entry, launches it under native Node using the +production environment filter, and sends the real init/list IPC messages. OMP's +actual SessionManager writes default and named-profile XDG conversations in +isolated home/config/data directories while a legacy directory coexists. Both are +found by exact UUID and path. An invalid profile yields no OMP sessions. No model +calls or visible app windows are used. This local sidecar proof is not a live SSH +connection or Windows/Linux runtime validation. diff --git a/src/main/ai-vault/remote-session-scanner-omp-roots.test.ts b/src/main/ai-vault/remote-session-scanner-omp-roots.test.ts new file mode 100644 index 00000000000..ae218163540 --- /dev/null +++ b/src/main/ai-vault/remote-session-scanner-omp-roots.test.ts @@ -0,0 +1,106 @@ +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' + +const home = '/home/ada' +const legacy = `${home}/.omp/agent/sessions` +const relocated = '/data/omp/profiles/work/sessions' +const platform = getRemoteHostPlatform('linux-x64') + +function fixture() { + const provider = new MemoryRemoteProvider() + for (const [root, id] of [ + [legacy, 'legacy'], + [relocated, 'relocated'] + ]) { + provider.addFile( + `${root}/folder/${id}.jsonl`, + jsonLines([ + { type: 'session', id, cwd: '/folder workspace', timestamp: '2026-09-14T10:00:00Z' }, + { type: 'message', message: { role: 'user', content: id } } + ]), + 70 + ) + } + return provider +} + +afterEach(() => vi.unstubAllEnvs()) + +describe('host-resolved OMP remote scan roots', () => { + it('scans the host-selected root once instead of the coexisting legacy store', async () => { + const provider = fixture() + const result = await scanRemoteAiVaultSessions({ + provider, + remoteHome: home, + hostPlatform: platform, + executionHostId: 'ssh:box', + ompSessionsDir: relocated + }) + expect(result.sessions.map((row) => row.sessionId)).toEqual(['relocated']) + expect(result.sessions[0]?.executionHostId).toBe('ssh:box') + expect(provider.readDirPaths.filter((path) => path === relocated)).toHaveLength(1) + expect(provider.readDirPaths).not.toContain(legacy) + }) + + it('does not fall back when the host refuses an unsafe root', async () => { + const provider = fixture() + const result = await scanRemoteAiVaultSessions({ + provider, + remoteHome: home, + hostPlatform: platform, + executionHostId: 'ssh:box', + ompSessionsDir: '' + }) + expect(result.sessions).toEqual([]) + expect(provider.readDirPaths).not.toContain(legacy) + expect(provider.readDirPaths).not.toContain('') + }) + + it('uses a Windows host root verbatim on a different client platform', async () => { + const provider = new MemoryRemoteProvider() + const root = 'D:\\OMP data\\profiles\\work\\sessions' + const file = `${root}\\folder\\session.jsonl` + provider.addFile( + file, + jsonLines([ + { + type: 'session', + id: 'windows', + cwd: 'D:\\folder workspace', + timestamp: '2026-09-14T10:00:00Z' + }, + { type: 'message', message: { role: 'user', content: 'Windows host' } } + ]), + 80 + ) + const result = await scanRemoteAiVaultSessions({ + provider, + remoteHome: 'C:\\Users\\ada', + hostPlatform: getRemoteHostPlatform('win32-x64'), + executionHostId: 'ssh:windows', + ompSessionsDir: root + }) + expect(result.sessions).toHaveLength(1) + expect(result.sessions[0]).toMatchObject({ + sessionId: 'windows', + executionHostId: 'ssh:windows', + executionHostPlatform: 'win32', + cwd: 'D:\\folder workspace' + }) + expect(provider.readDirPaths).toContain(root.replaceAll('\\', '/')) + }) + + it('keeps legacy fallback scans independent of the client environment', async () => { + vi.stubEnv('OMP_CODING_AGENT_DIR', relocated) + vi.stubEnv('OMP_PROFILE', 'work') + const result = await scanRemoteAiVaultSessions({ + provider: fixture(), + remoteHome: home, + hostPlatform: platform, + executionHostId: 'ssh:box' + }) + expect(result.sessions.map((row) => row.sessionId)).toEqual(['legacy']) + }) +}) diff --git a/src/main/ai-vault/remote-session-scanner-sources.ts b/src/main/ai-vault/remote-session-scanner-sources.ts index 92b9e9b4dc5..f99bf5f1ec1 100644 --- a/src/main/ai-vault/remote-session-scanner-sources.ts +++ b/src/main/ai-vault/remote-session-scanner-sources.ts @@ -17,7 +17,6 @@ import { parseHermesSessionContent } from './session-scanner-hermes-parser' import { partitionSubagentTranscriptPaths } from './session-scanner-subagent-transcripts' import { partitionOmpSubagentTranscriptPaths } from './session-scanner-omp-subagent-transcripts' import type { FileWithMtime } from './session-scanner-types' -import { normalizeAgentSessionsDir } from './session-scanner-values' import { remoteCodexIndexedTitleReader } from './remote-session-scanner-codex-index' import { remoteClineSource } from './remote-session-scanner-cline-source' import type { @@ -26,6 +25,9 @@ import type { RemoteSessionSource } from './remote-session-scanner-types' +const PI_SESSIONS_SEGMENTS = ['.pi', 'agent', 'sessions'] +const OMP_SESSIONS_SEGMENTS = ['.omp', 'agent', 'sessions'] + type RemoteContentParser = ( file: FileWithMtime, content: T, @@ -37,7 +39,8 @@ type RemoteContentParser = ( export function remoteSessionSources( remoteHome: string, - hostPlatform: RemoteHostPlatform + hostPlatform: RemoteHostPlatform, + ompSessionsDir?: string ): RemoteSessionSource[] { return [ ...remoteCodexSources(remoteHome, hostPlatform), @@ -97,14 +100,16 @@ export function remoteSessionSources( ['.json'], parseDevinSessionContent ), - 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('pi', remoteHome, hostPlatform, PI_SESSIONS_SEGMENTS, piParser), + ...(ompSessionsDir === '' + ? [] + : [ + { + ...jsonlSource('omp', remoteHome, hostPlatform, OMP_SESSIONS_SEGMENTS, ompParser), + ...(ompSessionsDir === undefined ? {} : { rootDir: ompSessionsDir }), + partitionSubagentTranscripts: partitionOmpSubagentTranscriptPaths + } + ]), jsonlSource( 'prime-agent', remoteHome, @@ -311,14 +316,6 @@ function remotePathSegments(path: string): string[] { return path.replace(/\\/g, '/').split('/').filter(Boolean) } -function remotePiSessionsSegments(): string[] { - return normalizeAgentSessionsDir('/.pi/agent/sessions', '.pi').split('/').filter(Boolean) -} - -function remoteOmpSessionsSegments(): string[] { - return normalizeAgentSessionsDir('/.omp/agent/sessions', '.omp').split('/').filter(Boolean) -} - // Why: remote roots are posix regardless of the client platform, so these stay literal // rather than round-tripping through a local-platform path join that would emit // backslashes on a Windows client and collapse into a single bogus segment. diff --git a/src/main/ai-vault/remote-session-scanner.ts b/src/main/ai-vault/remote-session-scanner.ts index 971bc20f70e..635e76f3e0a 100644 --- a/src/main/ai-vault/remote-session-scanner.ts +++ b/src/main/ai-vault/remote-session-scanner.ts @@ -50,6 +50,8 @@ export async function scanRemoteAiVaultSessions(args: { provider: RemoteSessionFilesystemProvider executionHostId: ExecutionHostId remoteHome: string + // Host-resolved only; omission preserves legacy client-side fallback discovery. + ompSessionsDir?: string hostPlatform: RemoteHostPlatform limit?: number unlimited?: boolean @@ -85,7 +87,7 @@ export async function scanRemoteAiVaultSessions(args: { const candidates = dedupeCodexRolloutFileAliases( ( await mapRemoteScanBatches( - remoteSessionSources(args.remoteHome, args.hostPlatform), + remoteSessionSources(args.remoteHome, args.hostPlatform, args.ompSessionsDir), REMOTE_SCAN_CONCURRENCY, (source) => discoverRemoteSourceCandidates({ source, context, issues }), args.signal diff --git a/src/main/ai-vault/session-scanner-service-env.test.ts b/src/main/ai-vault/session-scanner-service-env.test.ts index 88d393fcced..3b68d0d50df 100644 --- a/src/main/ai-vault/session-scanner-service-env.test.ts +++ b/src/main/ai-vault/session-scanner-service-env.test.ts @@ -118,9 +118,7 @@ describe('buildRelayAiVaultServiceEnv', () => { expect(env.HOME).toBe('/home/ada') }) - // The sidecar takes remoteHome and hostPlatform from its init message, so an - // agent-home override on the remote host is not part of how it finds roots. - it('withholds the agent-home variables the desktop child needs', () => { + it('withholds unrelated agent-home variables', () => { const env = buildRelayAiVaultServiceEnv( { CODEX_HOME: '/remote/.codex', PATH: '/usr/bin' }, 'linux' @@ -135,7 +133,7 @@ describe('buildRelayAiVaultServiceEnv', () => { }) }) -it('carries OMP root/profile inputs only to the desktop service, retaining empty canonical profile', () => { +it('carries OMP root/profile inputs to both services, retaining empty canonical profile', () => { const roots = { OMP_PROFILE: '', PI_PROFILE: 'work', @@ -144,5 +142,30 @@ it('carries OMP root/profile inputs only to the desktop service, retaining empty XDG_DATA_HOME: '/home/dev/data' } expect(buildAiVaultServiceEnv(roots, 'linux')).toEqual({ ...roots, ELECTRON_RUN_AS_NODE: '1' }) - expect(buildRelayAiVaultServiceEnv(roots, 'linux')).toEqual({}) + expect(buildRelayAiVaultServiceEnv(roots, 'linux')).toEqual(roots) +}) + +it('carries Windows OMP roots case-insensitively without forwarding other agent roots', () => { + expect( + buildRelayAiVaultServiceEnv( + { + omp_profile: 'work', + omp_coding_agent_dir: 'D:\\omp', + pi_profile: 'old', + pi_config_dir: '.custom', + pi_coding_agent_dir: 'D:\\inherited', + xdg_data_home: 'D:\\data', + codex_home: 'D:\\codex', + AWS_SECRET_ACCESS_KEY: 'secret' + }, + 'win32' + ) + ).toEqual({ + OMP_PROFILE: 'work', + OMP_CODING_AGENT_DIR: 'D:\\omp', + PI_PROFILE: 'old', + PI_CONFIG_DIR: '.custom', + PI_CODING_AGENT_DIR: 'D:\\inherited', + XDG_DATA_HOME: 'D:\\data' + }) }) diff --git a/src/main/ai-vault/session-scanner-service-env.ts b/src/main/ai-vault/session-scanner-service-env.ts index c6513434c8d..40f78c6ea01 100644 --- a/src/main/ai-vault/session-scanner-service-env.ts +++ b/src/main/ai-vault/session-scanner-service-env.ts @@ -30,6 +30,15 @@ export const RUNTIME_ENV_ALLOWLIST = [ 'NUMBER_OF_PROCESSORS' ] as const +const OMP_ROOT_ENV_ALLOWLIST = [ + 'OMP_CODING_AGENT_DIR', + 'OMP_PROFILE', + 'PI_CODING_AGENT_DIR', + 'PI_CONFIG_DIR', + 'PI_PROFILE', + 'XDG_DATA_HOME' +] as const + // Why: the desktop child resolves agent roots from its own environment, so // dropping one hides every session of a user who relocated that agent's home. const AGENT_ROOT_ENV_ALLOWLIST = [ @@ -39,19 +48,12 @@ const AGENT_ROOT_ENV_ALLOWLIST = [ 'DEVIN_HOME', 'GROK_HOME', 'KIMI_CODE_HOME', - 'OMP_CODING_AGENT_DIR', - 'OMP_PROFILE', 'OPENCLAW_STATE_DIR', 'OPENCODE_DB', - 'PI_CODING_AGENT_DIR', - 'PI_CONFIG_DIR', - 'PI_PROFILE', 'PRIME_AGENT_CODING_AGENT_DIR', 'PRIME_AGENT_CODING_AGENT_SESSION_DIR', 'PRIME_AGENT_SESSION_DIR', - // Why: OpenCode and migrated OMP session stores use the XDG data dir, - // so this one is an agent root here rather than generic runtime state. - 'XDG_DATA_HOME' + ...OMP_ROOT_ENV_ALLOWLIST ] as const export function pickAllowedEnv( @@ -94,10 +96,10 @@ export function buildAiVaultServiceEnv( return env } -/** Relay: the sidecar takes every root from its init message, not the environment. */ +/** Relay: resolve OMP storage from the execution host, never the client. */ export function buildRelayAiVaultServiceEnv( baseEnv: NodeJS.ProcessEnv = process.env, platform: NodeJS.Platform = process.platform ): NodeJS.ProcessEnv { - return pickAllowedEnv(RUNTIME_ENV_ALLOWLIST, baseEnv, platform) + return pickAllowedEnv([...RUNTIME_ENV_ALLOWLIST, ...OMP_ROOT_ENV_ALLOWLIST], baseEnv, platform) } diff --git a/src/relay/ai-vault-service-entry.ts b/src/relay/ai-vault-service-entry.ts index 9bd3ea35786..2c268106ff0 100644 --- a/src/relay/ai-vault-service-entry.ts +++ b/src/relay/ai-vault-service-entry.ts @@ -1,3 +1,4 @@ +import { resolveOmpSessionsDir } from '../main/ai-vault/omp-session-root' import { LOCAL_EXECUTION_HOST_ID } from '../shared/execution-host' import { scanRemoteAiVaultSessions } from '../main/ai-vault/remote-session-scanner' import { readAiVaultSessionTitlesFromFiles } from '../main/ai-vault/session-title-file-reader' @@ -50,6 +51,10 @@ async function execute(request: RelayAiVaultServiceRequest): Promise { provider, executionHostId: LOCAL_EXECUTION_HOST_ID, remoteHome: init.remoteHome, + ompSessionsDir: resolveOmpSessionsDir({ + homeDir: init.remoteHome, + platform: init.hostPlatform.os + }), hostPlatform: init.hostPlatform, limit: request.params.limit, unlimited: request.params.unlimited, diff --git a/tests/tools/omp-relay-root-smoke.mjs b/tests/tools/omp-relay-root-smoke.mjs new file mode 100644 index 00000000000..0a82db3f980 --- /dev/null +++ b/tests/tools/omp-relay-root-smoke.mjs @@ -0,0 +1,145 @@ +import assert from 'node:assert/strict' +import { mkdtemp, mkdir, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { build } from 'esbuild' +import { spawnProcess } from '../../src/shared/child-process/run-process.ts' +import { buildRelayAiVaultServiceEnv } from '../../src/main/ai-vault/session-scanner-service-env.ts' +import { getRemoteHostPlatform } from '../../src/main/ssh/ssh-remote-platform.ts' + +assert.ok(process.argv[2], 'Pass a read-only OMP checkout') +const node = Bun.which('node') +assert.ok(node, 'A native Node executable is required') +const root = fileURLToPath(new URL('../../', import.meta.url)) +const scratch = await mkdtemp(join(tmpdir(), 'orca-omp-relay-root-')) +const home = join(scratch, 'home') +const xdg = join(scratch, 'data') +Object.assign(process.env, { + HOME: home, + USERPROFILE: home, + XDG_CONFIG_HOME: join(scratch, 'config'), + XDG_DATA_HOME: xdg, + XDG_STATE_HOME: join(scratch, 'state'), + XDG_CACHE_HOME: join(scratch, 'cache') +}) +for (const key of [ + 'OMP_CODING_AGENT_DIR', + 'PI_CODING_AGENT_DIR', + 'OMP_PROFILE', + 'PI_PROFILE', + 'PI_CONFIG_DIR', + 'PI_CONFIG_FILES' +]) { + delete process.env[key] +} +const source = (path) => pathToFileURL(join(resolve(process.argv[2]), path)).href +const managers = [] +const entry = join(scratch, 'relay-ai-vault-service.cjs') + +async function scan(profile, env = process.env) { + const child = spawnProcess({ + program: node, + args: [entry], + cwd: scratch, + env: { ...buildRelayAiVaultServiceEnv(env), ORCA_BACKGROUND_LAUNCH: '1' }, + stdio: ['ignore', 'pipe', 'pipe', 'ipc'] + }) + let stderr = '' + child.stderr.on('data', (data) => { + stderr = (stderr + data).slice(-16000) + }) + const exited = new Promise((resolveExit) => child.once('exit', resolveExit)) + let timer + try { + const result = new Promise((resolveResult, reject) => { + timer = setTimeout(() => reject(new Error(`Relay scan timed out: ${stderr}`)), 15000) + child.once('error', reject) + child.once('exit', (code) => reject(new Error(`Relay exited ${code}: ${stderr}`))) + child.on('message', (message) => { + if (message.type === 'ready') { + child.send({ type: 'request', id: 1, operation: 'list', params: { limit: 20 } }) + } else if (message.type === 'error') { + reject(new Error(message.message)) + } else if (message.type === 'result') { + resolveResult(message.value) + } + }) + }) + child.send({ + type: 'init', + protocol: 1, + remoteHome: home, + hostPlatform: getRemoteHostPlatform(`${process.platform}-${process.arch}`) + }) + const value = await result + assert.equal(value.issues.length, 0, JSON.stringify(value.issues)) + const sessions = value.sessions.filter((session) => session.agent === 'omp') + if (profile === 'invalid') { + assert.deepEqual(sessions, []) + } else { + const manager = managers.at(-1) + assert.equal(sessions.length, 1) + assert.equal(sessions[0].sessionId, manager.getSessionId()) + assert.equal(sessions[0].filePath, manager.getSessionFile()) + assert.equal(sessions[0].cwd, join(scratch, 'folder workspace')) + } + return { profile: profile || 'default', sessions: sessions.length, actualNodeSidecar: true } + } finally { + clearTimeout(timer) + child.kill() + await exited + } +} + +try { + await mkdir(join(home, '.omp', 'agent', 'sessions'), { recursive: true }) + await mkdir(join(xdg, 'omp', 'profiles', 'work'), { recursive: true }) + await build({ + entryPoints: [join(root, 'src/relay/ai-vault-service-entry.ts')], + bundle: true, + platform: 'node', + target: 'node18', + format: 'cjs', + outfile: entry, + external: ['electron'], + define: { 'process.env.NODE_ENV': '"production"' } + }) + const upstream = await import(source('packages/utils/src/dirs.ts')) + const { SessionManager } = await import( + source('packages/coding-agent/src/session/session-manager.ts') + ) + const results = [] + for (const profile of ['', 'work']) { + process.env.OMP_PROFILE = profile + upstream.__resetDirsFromEnvForTests() + const cwd = join(scratch, 'folder workspace') + await mkdir(cwd, { recursive: true }) + const manager = SessionManager.create(cwd) + managers.push(manager) + manager.appendMessage({ + role: 'user', + content: 'OMP relay history proof', + timestamp: Date.now() + }) + await manager.ensureOnDisk() + await manager.flush() + const expected = join(xdg, 'omp', ...(profile ? ['profiles', profile] : []), 'sessions') + assert.ok(manager.getSessionFile().startsWith(expected)) + results.push(await scan(profile)) + } + results.push(await scan('invalid', { ...process.env, OMP_PROFILE: '../invalid' })) + console.log( + JSON.stringify({ + results, + actualOmpPersistence: true, + legacyDirectoryCoexists: true, + modelCalls: 0 + }) + ) +} finally { + for (const manager of managers) { + await manager.close() + } + await rm(scratch, { recursive: true, force: true }) +}