diff --git a/src/main/ai-vault/structured-session-ownership.test.ts b/src/main/ai-vault/structured-session-ownership.test.ts index f260e985e7c..b7104e50a0c 100644 --- a/src/main/ai-vault/structured-session-ownership.test.ts +++ b/src/main/ai-vault/structured-session-ownership.test.ts @@ -52,6 +52,35 @@ describe('structured AI Vault ownership', () => { ) ).rejects.toThrow('agent_session_ownership_unknown') }) + + it.each([ + `codex resume --last`, + `claude --resume`, + `claude -r`, + `claude --continue`, + `claude -c`, + // `--continue` takes no session id, so the trailing token is a prompt — + // reading it as a target would admit a writer onto the owned session. + `claude --continue "keep going"`, + `claude -c 019fd532-7c11-7a90-b6de-4e1a2c3d5f61` + ])('refuses resume commands without a provably different target: %s', async (command) => { + installOwnership(command.startsWith('claude') ? { provider: 'claude' } : {}) + + await expect( + assertLegacyAiVaultResumeCommandAllowed(command, async () => undefined) + ).rejects.toThrow('agent_session_conflict') + }) + + it('allows a resume command that names a different provider session', async () => { + installOwnership() + + await expect( + assertLegacyAiVaultResumeCommandAllowed( + 'codex resume 019fd532-7c11-7a90-b6de-4e1a2c3d5f61', + async () => undefined + ) + ).resolves.toBeUndefined() + }) }) function installOwnership(overrides: Partial = {}): void { @@ -76,7 +105,7 @@ function installOwnership(overrides: Partial providerHandleChain: [ { ...record.providerHandleChain[0]!, - handle: { provider: 'codex', threadId: ownership.providerSessionId } + handle: { provider: ownership.provider, threadId: ownership.providerSessionId } } ], lease: { ...ownership.lease, sessionId: ownership.sessionId } diff --git a/src/main/ai-vault/structured-session-ownership.ts b/src/main/ai-vault/structured-session-ownership.ts index 911c1f509e0..03c84f023c6 100644 --- a/src/main/ai-vault/structured-session-ownership.ts +++ b/src/main/ai-vault/structured-session-ownership.ts @@ -66,10 +66,7 @@ export async function assertLegacyAiVaultResumeCommandAllowed( } function isPotentialStructuredResumeCommand(command: string): boolean { - return ( - /\bcodex(?:\.exe)?\b[\s\S]*\bresume\b/i.test(command) || - /\bclaude(?:\.exe)?\b[\s\S]*--resume\b/i.test(command) - ) + return parseResumeInvocation(command) !== null } function findSessionOwnership(session: AiVaultSession): StructuredProviderSessionOwnership | null { @@ -123,12 +120,61 @@ function isResumeCommandFor( command: string, ownership: StructuredProviderSessionOwnership ): boolean { - if (!command.includes(ownership.providerSessionId)) { + const invocation = parseResumeInvocation(command) + if (!invocation || invocation.provider !== ownership.provider) { return false } - return ownership.provider === 'codex' - ? /\bcodex(?:\.exe)?\b[\s\S]*\bresume\b/i.test(command) - : /\bclaude(?:\.exe)?\b[\s\S]*--resume\b/i.test(command) + // A target-less resume (--last, --continue, or a bare --resume/-r) may pick + // any provider session, so it cannot be admitted while one is structured. + // Only an explicit target that differs from this owned session is safe. + return invocation.target === null || invocation.target === ownership.providerSessionId +} + +type ResumeInvocation = { + provider: 'codex' | 'claude' + target: string | null +} + +function parseResumeInvocation(command: string): ResumeInvocation | null { + // Keep this deliberately conservative: shell quoting is normalized only + // enough to identify executable/flag tokens; an unrecognized shape is not + // treated as proof that a different session is being resumed. + const tokens = command.match(/"[^"\\]*(?:\\.[^"\\]*)*"|'[^']*'|[^\s]+/g) ?? [] + const normalized = tokens.map((token) => token.replace(/^['"]|['"]$/g, '')) + const executableIndex = normalized.findIndex((token) => + /(?:^|[\\/])(?:codex|claude)(?:\.exe)?$/i.test(token) + ) + if (executableIndex === -1) { + return null + } + const provider = /codex(?:\.exe)?$/i.test(normalized[executableIndex]!) ? 'codex' : 'claude' + const args = normalized.slice(executableIndex + 1) + // `--continue`/`-c` resume the most recent session and never take an id, so a + // following token is a prompt, not a target — they are always target-less. + const targetlessFlags = provider === 'codex' ? [] : ['--continue', '-c'] + const targetlessIndex = args.findIndex((token) => targetlessFlags.includes(token.toLowerCase())) + if (targetlessIndex !== -1) { + return { provider, target: null } + } + const resumeFlags = provider === 'codex' ? ['resume'] : ['--resume', '-r'] + const inlineIndex = args.findIndex( + (token) => + provider === 'claude' && + (token.toLowerCase().startsWith('--resume=') || token.toLowerCase().startsWith('-r=')) + ) + if (inlineIndex !== -1) { + const target = args[inlineIndex]!.slice(args[inlineIndex]!.indexOf('=') + 1) + return { provider, target: target.length > 0 ? target : null } + } + const markerIndex = args.findIndex((token) => resumeFlags.includes(token.toLowerCase())) + if (markerIndex === -1) { + return null + } + const candidate = args[markerIndex + 1] + return { + provider, + target: candidate && !candidate.startsWith('-') ? candidate : null + } } function refuseLegacyWriter(ownership: StructuredProviderSessionOwnership): never {