mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 16:03:49 +00:00
Prove Claude resume owner identity
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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<typeof readStructuredTuiProcessIdentity>[0]
|
||||
|
||||
export async function readClaudeResumeProcessIdentity(
|
||||
input: Omit<StructuredTuiIdentityInput, 'agent' | 'processCommandMatches'> & {
|
||||
sessionId: string
|
||||
}
|
||||
): Promise<AgentSessionProcessIdentity> {
|
||||
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
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user