From e1caa7345678bcf630640af91ee8127e6bb5b377 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 27 Aug 2026 07:28:02 -0700 Subject: [PATCH] Prove Claude resume owner identity --- .../claude-resume-process-proof.test.ts | 78 +++++++++++++++++++ .../claude/claude-resume-process-proof.ts | 39 ++++++++++ .../claude-structured-owner-identity.test.ts | 21 ++++- .../claude-structured-owner-identity.ts | 4 +- .../claude-transcript-branch-proof.test.ts | 20 +++++ .../claude/claude-transcript-branch-proof.ts | 6 +- src/main/codex/codex-resume-process-proof.ts | 74 +----------------- src/main/runtime/orca-runtime.ts | 8 +- .../runtime/process-command-line-tokens.ts | 73 +++++++++++++++++ 9 files changed, 241 insertions(+), 82 deletions(-) create mode 100644 src/main/claude/claude-resume-process-proof.test.ts create mode 100644 src/main/claude/claude-resume-process-proof.ts create mode 100644 src/main/claude/claude-transcript-branch-proof.test.ts create mode 100644 src/main/runtime/process-command-line-tokens.ts diff --git a/src/main/claude/claude-resume-process-proof.test.ts b/src/main/claude/claude-resume-process-proof.test.ts new file mode 100644 index 00000000000..78a50d167bf --- /dev/null +++ b/src/main/claude/claude-resume-process-proof.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest' +import { + isClaudeResumeProcessCommandLine, + readClaudeResumeProcessIdentity +} from './claude-resume-process-proof' + +const SESSION_ID = 'session-claude-1' + +describe('Claude resume process proof', () => { + it('refuses a lingering Claude child for another session', async () => { + await expect( + readClaudeResumeProcessIdentity({ + hostId: 'local', + rootPid: 100, + spawnToken: 'spawn-new', + sessionId: SESSION_ID, + platform: 'darwin', + readPosixRows: async () => [ + { pid: 100, ppid: 1, stat: 'Ss', command: '/bin/zsh' }, + { pid: 101, ppid: 100, stat: 'S+', command: 'claude --resume session-other' } + ], + timeoutMs: 0 + }) + ).rejects.toThrow('one exact Claude child process') + }) + + it('excludes the previous owner and all of its descendants', async () => { + await expect( + readClaudeResumeProcessIdentity({ + hostId: 'local', + rootPid: 100, + spawnToken: 'spawn-new', + sessionId: SESSION_ID, + platform: 'darwin', + readPosixRows: async () => [ + { pid: 100, ppid: 1, stat: 'Ss', command: '/bin/zsh' }, + { pid: 101, ppid: 100, stat: 'S+', command: `claude --resume ${SESSION_ID}` }, + { pid: 102, ppid: 101, stat: 'S+', command: `claude --resume ${SESSION_ID}` } + ], + excludedProcessTreeRootIdentities: [{ pid: 101, processStartTimeMs: null }], + timeoutMs: 0 + }) + ).rejects.toThrow('one exact Claude child process') + }) + + it('requires a PID-reuse-safe start time for the new owner', async () => { + await expect( + readClaudeResumeProcessIdentity({ + hostId: 'local', + rootPid: 100, + spawnToken: 'spawn-new', + sessionId: SESSION_ID, + platform: 'darwin', + readPosixRows: async () => [ + { pid: 100, ppid: 1, stat: 'Ss', command: '/bin/zsh' }, + { pid: 101, ppid: 100, stat: 'S+', command: `claude --resume ${SESSION_ID}` } + ], + readStartTime: async () => null, + timeoutMs: 0 + }) + ).rejects.toThrow('PID-reuse-safe start time') + }) + + it('matches adjacent resume and session-id argv tokens exactly', () => { + expect( + isClaudeResumeProcessCommandLine(`claude --resume ${SESSION_ID}`, SESSION_ID, 'darwin') + ).toBe(true) + expect( + isClaudeResumeProcessCommandLine(`claude --session-id ${SESSION_ID}`, SESSION_ID, 'darwin') + ).toBe(true) + expect( + isClaudeResumeProcessCommandLine(`claude --resume ${SESSION_ID}-other`, SESSION_ID, 'darwin') + ).toBe(false) + expect( + isClaudeResumeProcessCommandLine(`claude --resume=${SESSION_ID}`, SESSION_ID, 'darwin') + ).toBe(false) + }) +}) diff --git a/src/main/claude/claude-resume-process-proof.ts b/src/main/claude/claude-resume-process-proof.ts new file mode 100644 index 00000000000..0340d3359f8 --- /dev/null +++ b/src/main/claude/claude-resume-process-proof.ts @@ -0,0 +1,39 @@ +import type { AgentSessionProcessIdentity } from '../../shared/agent-session-record' +import { tokenizeProcessCommandLine } from '../runtime/process-command-line-tokens' +import { readStructuredTuiProcessIdentity } from '../runtime/structured-tui-process-identity' + +export function isClaudeResumeProcessCommandLine( + commandLine: string, + sessionId: string, + platform: NodeJS.Platform = process.platform +): boolean { + const tokens = tokenizeProcessCommandLine(commandLine, platform) + if (!tokens || !sessionId) { + return false + } + return tokens.some( + (token, index) => + (token === '--resume' || token === '--session-id') && tokens[index + 1] === sessionId + ) +} + +type StructuredTuiIdentityInput = Parameters[0] + +export async function readClaudeResumeProcessIdentity( + input: Omit & { + sessionId: string + } +): Promise { + const { sessionId, ...identityInput } = input + const platform = input.platform ?? process.platform + const identity = await readStructuredTuiProcessIdentity({ + ...identityInput, + agent: 'claude', + processCommandMatches: (command) => + isClaudeResumeProcessCommandLine(command, sessionId, platform) + }) + if (identity.processStartTimeMs === null) { + throw new Error('The resumed Claude child process has no PID-reuse-safe start time.') + } + return identity +} diff --git a/src/main/claude/claude-structured-owner-identity.test.ts b/src/main/claude/claude-structured-owner-identity.test.ts index a254ce4a94c..ebdc45b3169 100644 --- a/src/main/claude/claude-structured-owner-identity.test.ts +++ b/src/main/claude/claude-structured-owner-identity.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { claudeProcessIdentity } from './claude-structured-owner-identity' +import { claudeProcessIdentity, claudeProviderHandleLink } from './claude-structured-owner-identity' const IDENTITY = { sessionId: 'session-identity', @@ -10,6 +10,25 @@ const IDENTITY = { } describe('claude process identity', () => { + it('keeps the provider link id stable when the advisory leaf advances', () => { + const first = claudeProviderHandleLink({ + sessionId: 'provider-1', + leafUuid: 'leaf-a', + resumed: false, + fence: 7, + observedAt: 1 + }) + const second = claudeProviderHandleLink({ + sessionId: 'provider-1', + leafUuid: 'leaf-b', + resumed: true, + fence: 7, + observedAt: 2 + }) + expect(second.linkId).toBe(first.linkId) + expect(second.handle).toMatchObject({ provider: 'claude', leafUuid: 'leaf-b' }) + }) + it('records the observed start time alongside the spawn token', async () => { await expect( claudeProcessIdentity( diff --git a/src/main/claude/claude-structured-owner-identity.ts b/src/main/claude/claude-structured-owner-identity.ts index e833e38413e..7c6f21cf883 100644 --- a/src/main/claude/claude-structured-owner-identity.ts +++ b/src/main/claude/claude-structured-owner-identity.ts @@ -48,9 +48,7 @@ export function claudeProviderHandleLink(input: { observedAt: number }): AgentSessionProviderHandleLink { return { - linkId: - input.linkId ?? - `claude-${input.fence}-${input.sessionId}-${input.leafUuid ?? 'empty'}`.slice(0, 128), + linkId: input.linkId ?? `claude-${input.fence}-${input.sessionId}`.slice(0, 128), handle: { provider: 'claude', sessionId: input.sessionId, leafUuid: input.leafUuid }, origin: input.origin ?? (input.resumed ? 'resumed' : 'created'), mintedAtFence: input.fence, diff --git a/src/main/claude/claude-transcript-branch-proof.test.ts b/src/main/claude/claude-transcript-branch-proof.test.ts new file mode 100644 index 00000000000..5ee0b0f89e3 --- /dev/null +++ b/src/main/claude/claude-transcript-branch-proof.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest' +import { proveClaudeTranscriptBranchFromJsonl } from './claude-transcript-branch-proof' + +describe('Claude transcript branch proof', () => { + it('accepts a new leaf on a sibling branch as diagnostic evidence', () => { + const contents = [ + JSON.stringify({ uuid: 'root', parentUuid: null, sessionId: 'session-1' }), + JSON.stringify({ uuid: 'old-leaf', parentUuid: 'root', sessionId: 'session-1' }), + JSON.stringify({ uuid: 'new-leaf', parentUuid: 'root', sessionId: 'session-1' }), + JSON.stringify({ type: 'last-prompt', sessionId: 'session-1', leafUuid: 'new-leaf' }) + ].join('\n') + expect( + proveClaudeTranscriptBranchFromJsonl({ + contents, + providerSessionId: 'session-1', + previousLeafUuid: 'old-leaf' + }) + ).toEqual({ leafUuid: 'new-leaf', relation: 'sibling' }) + }) +}) diff --git a/src/main/claude/claude-transcript-branch-proof.ts b/src/main/claude/claude-transcript-branch-proof.ts index b3f26bf8640..3a99d5b4e0d 100644 --- a/src/main/claude/claude-transcript-branch-proof.ts +++ b/src/main/claude/claude-transcript-branch-proof.ts @@ -9,7 +9,7 @@ type TranscriptNode = { export type ClaudeTranscriptBranchProof = { leafUuid: string - relation: 'initial' | 'same' | 'descendant' + relation: 'initial' | 'same' | 'descendant' | 'sibling' } function nonEmptyString(value: unknown): string | null { @@ -120,7 +120,7 @@ export function proveClaudeTranscriptBranchFromJsonl(input: { } const previous = nodes.get(previousLeafUuid) if (!previous || previous.sessionId !== input.providerSessionId) { - throw transcriptError('previous cursor is missing from the session graph') + return { leafUuid, relation: 'sibling' } } if (leafUuid === previousLeafUuid) { return { leafUuid, relation: 'same' } @@ -144,7 +144,7 @@ export function proveClaudeTranscriptBranchFromJsonl(input: { if (cursor !== null) { throw transcriptError('ancestry exceeds the bounded proof limit') } - throw transcriptError('latest marker is on a sibling branch') + return { leafUuid, relation: 'sibling' } } export async function proveClaudeTranscriptBranch(input: { diff --git a/src/main/codex/codex-resume-process-proof.ts b/src/main/codex/codex-resume-process-proof.ts index 1c6839bf867..bfb3e2879a2 100644 --- a/src/main/codex/codex-resume-process-proof.ts +++ b/src/main/codex/codex-resume-process-proof.ts @@ -1,79 +1,7 @@ -import { tokenizeCustomCommandTemplate } from '../../shared/commit-message-prompt' import type { AgentSessionProcessIdentity } from '../../shared/agent-session-record' +import { tokenizeProcessCommandLine } from '../runtime/process-command-line-tokens' import { readStructuredTuiProcessIdentity } from '../runtime/structured-tui-process-identity' -const PROCESS_COMMAND_LINE_MAX_CHARS = 16 * 1024 - -function tokenizeWindowsProcessCommandLine(commandLine: string): string[] { - const tokens: string[] = [] - let current = '' - let quoted = false - let started = false - let index = 0 - while (index < commandLine.length) { - const char = commandLine[index]! - if (!started && /\s/.test(char)) { - index += 1 - continue - } - started = true - if (char === '\\') { - let backslashes = 0 - while (commandLine[index] === '\\') { - backslashes += 1 - index += 1 - } - if (commandLine[index] === '"') { - current += '\\'.repeat(Math.floor(backslashes / 2)) - if (backslashes % 2 === 1) { - current += '"' - index += 1 - } - } else { - current += '\\'.repeat(backslashes) - } - continue - } - if (char === '"') { - if (quoted && commandLine[index + 1] === '"') { - current += '"' - index += 2 - continue - } - quoted = !quoted - index += 1 - continue - } - if (!quoted && /\s/.test(char)) { - tokens.push(current) - current = '' - started = false - index += 1 - continue - } - current += char - index += 1 - } - if (started) { - tokens.push(current) - } - return tokens -} - -function tokenizeProcessCommandLine( - commandLine: string, - platform: NodeJS.Platform -): string[] | null { - if (!commandLine || commandLine.length > PROCESS_COMMAND_LINE_MAX_CHARS) { - return null - } - if (platform === 'win32') { - return tokenizeWindowsProcessCommandLine(commandLine) - } - const parsed = tokenizeCustomCommandTemplate(commandLine) - return parsed.ok ? parsed.tokens : null -} - export function isCodexResumeProcessCommandLine( commandLine: string, threadId: string, diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 5fffe01d491..1a847aca810 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -103,6 +103,7 @@ import { import { codexProviderHandleLink } from '../codex/codex-structured-owner-identity' import { claudeProviderHandleLink } from '../claude/claude-structured-owner-identity' import { readCodexResumeProcessIdentity } from '../codex/codex-resume-process-proof' +import { readClaudeResumeProcessIdentity } from '../claude/claude-resume-process-proof' import { proveCodexTuiRollout, resolvePinnedCodexRolloutProof @@ -11317,11 +11318,14 @@ export class OrcaRuntimeService { spawnToken, threadId: head.handle.threadId }) - : await readStructuredTuiProcessIdentity({ + : await readClaudeResumeProcessIdentity({ hostId: record.location.executionHostId, rootPid: terminal.processId, spawnToken, - agent: provider + sessionId: head.handle.sessionId, + excludedProcessTreeRootIdentities: record.lease.ownerProcess + ? [record.lease.ownerProcess] + : undefined }), link: provider === 'codex' diff --git a/src/main/runtime/process-command-line-tokens.ts b/src/main/runtime/process-command-line-tokens.ts new file mode 100644 index 00000000000..1b6191dabe8 --- /dev/null +++ b/src/main/runtime/process-command-line-tokens.ts @@ -0,0 +1,73 @@ +import { tokenizeCustomCommandTemplate } from '../../shared/commit-message-prompt' + +const PROCESS_COMMAND_LINE_MAX_CHARS = 16 * 1024 + +function tokenizeWindowsProcessCommandLine(commandLine: string): string[] { + const tokens: string[] = [] + let current = '' + let quoted = false + let started = false + let index = 0 + while (index < commandLine.length) { + const char = commandLine[index]! + if (!started && /\s/.test(char)) { + index += 1 + continue + } + started = true + if (char === '\\') { + let backslashes = 0 + while (commandLine[index] === '\\') { + backslashes += 1 + index += 1 + } + if (commandLine[index] === '"') { + current += '\\'.repeat(Math.floor(backslashes / 2)) + if (backslashes % 2 === 1) { + current += '"' + index += 1 + } + } else { + current += '\\'.repeat(backslashes) + } + continue + } + if (char === '"') { + if (quoted && commandLine[index + 1] === '"') { + current += '"' + index += 2 + continue + } + quoted = !quoted + index += 1 + continue + } + if (!quoted && /\s/.test(char)) { + tokens.push(current) + current = '' + started = false + index += 1 + continue + } + current += char + index += 1 + } + if (started) { + tokens.push(current) + } + return tokens +} + +export function tokenizeProcessCommandLine( + commandLine: string, + platform: NodeJS.Platform +): string[] | null { + if (!commandLine || commandLine.length > PROCESS_COMMAND_LINE_MAX_CHARS) { + return null + } + if (platform === 'win32') { + return tokenizeWindowsProcessCommandLine(commandLine) + } + const parsed = tokenizeCustomCommandTemplate(commandLine) + return parsed.ok ? parsed.tokens : null +}