diff --git a/config/scripts/verify-localization-catalog.mjs b/config/scripts/verify-localization-catalog.mjs index 002a84360f6..a73e9d5e3cc 100644 --- a/config/scripts/verify-localization-catalog.mjs +++ b/config/scripts/verify-localization-catalog.mjs @@ -11,7 +11,12 @@ import { repairTranslatedValue } from './locale-translation-policy.mjs' const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts']) const SKIP_PATH_PARTS = new Set(['.git', 'dist', 'node_modules', 'out', '__snapshots__', 'assets']) -const LOCALIZATION_FUNCTION_NAMES = new Set(['t', 'translate', 'translateMain', 'translateSearchKeyword']) +const LOCALIZATION_FUNCTION_NAMES = new Set([ + 't', + 'translate', + 'translateMain', + 'translateSearchKeyword' +]) const PLACEHOLDER_RE = /\{\{[^}]+\}\}/g const LOCALES_RELATIVE_DIR = path.join('src', 'renderer', 'src', 'i18n', 'locales') export const LOCALIZATION_SOURCE_ROOTS = [ diff --git a/mobile/src/session/MobileNativeChatSessionOptionPickers.test.ts b/mobile/src/session/MobileNativeChatSessionOptionPickers.test.ts index 4430c60115a..5a959b870bf 100644 --- a/mobile/src/session/MobileNativeChatSessionOptionPickers.test.ts +++ b/mobile/src/session/MobileNativeChatSessionOptionPickers.test.ts @@ -41,6 +41,7 @@ const MODEL_DESCRIPTOR: SessionOptionDescriptor = { ] }, valueSource: 'reported', + transport: 'catalog', settable: true } @@ -57,6 +58,7 @@ const EFFORT_DESCRIPTOR: SessionOptionDescriptor = { ] }, valueSource: 'dispatched', + transport: 'catalog', settable: true } @@ -66,6 +68,7 @@ const FAST_MODE_DESCRIPTOR: SessionOptionDescriptor = { category: 'mode', kind: { type: 'boolean', currentValue: false }, valueSource: 'reported', + transport: 'catalog', settable: true } @@ -227,6 +230,7 @@ describe('MobileNativeChatSessionOptionPickers', () => { ...MODEL_DESCRIPTOR, kind: { type: 'select', choices: [] }, valueSource: 'unknown', + transport: 'catalog', action: { type: 'agent-picker' } } ]) @@ -236,6 +240,46 @@ describe('MobileNativeChatSessionOptionPickers', () => { expect(invokeAction).toHaveBeenCalledWith('model') }) + // The terminal transport can only learn the outcome by parsing the screen back, + // so the sheet admits the value is unconfirmed; the structured transport reports + // it every turn, which makes the same caption noise there. + it.each([ + { transport: 'catalog' as const, caption: true }, + { transport: 'agent-session' as const, caption: false } + ])('captions a dispatched value only on the terminal transport', async (scenario) => { + mount([ + MODEL_DESCRIPTOR, + { ...EFFORT_DESCRIPTOR, valueSource: 'dispatched', transport: scenario.transport } + ]) + await act(async () => pill('Model').props.onPress()) + await act(async () => rowByText('Effort').props.onPress()) + const captions = renderer!.root + .findAll((node) => node.type === 'Text') + .filter( + (node) => + (node.props as { children?: unknown }).children === 'Sent to the agent — not confirmed' + ) + expect(captions.length > 0).toBe(scenario.caption) + }) + + it.each(['catalog', 'agent-session'] as const)( + 'does not caption a reported value on the %s transport', + async (transport) => { + mount([MODEL_DESCRIPTOR, { ...EFFORT_DESCRIPTOR, valueSource: 'reported', transport }]) + await act(async () => pill('Model').props.onPress()) + await act(async () => rowByText('Effort').props.onPress()) + expect( + renderer!.root + .findAll((node) => node.type === 'Text') + .some( + (node) => + (node.props as { children?: unknown }).children === + 'Sent to the agent — not confirmed' + ) + ).toBe(false) + } + ) + it('locks the pills while the agent is working', () => { mount([MODEL_DESCRIPTOR, EFFORT_DESCRIPTOR], true) expect(pill('Model').props).toMatchObject({ disabled: true }) diff --git a/mobile/src/session/MobileNativeChatSessionOptionPickers.tsx b/mobile/src/session/MobileNativeChatSessionOptionPickers.tsx index bfa5244a398..c9d641f74ec 100644 --- a/mobile/src/session/MobileNativeChatSessionOptionPickers.tsx +++ b/mobile/src/session/MobileNativeChatSessionOptionPickers.tsx @@ -3,9 +3,10 @@ import { ActivityIndicator, Keyboard, Pressable, StyleSheet, Text, View } from ' import { ChevronLeft, X } from 'lucide-react-native' import { BottomDrawer } from '../components/BottomDrawer' import { colors, radii, spacing, typography } from '../theme/mobile-theme' -import type { - SessionOptionDescriptor, - SessionOptionValue +import { + sessionOptionDispatchUnconfirmed, + type SessionOptionDescriptor, + type SessionOptionValue } from '../../../src/shared/native-chat-session-options' import { mobileModelPillLabel, @@ -119,7 +120,7 @@ export function MobileNativeChatSessionOptionPickers({ ) : null} - {activeDescriptor.valueSource === 'dispatched' ? ( + {sessionOptionDispatchUnconfirmed(activeDescriptor) ? ( Sent to the agent — not confirmed ) : null} {reason ? {reason} : null} diff --git a/mobile/src/session/use-mobile-native-chat-session-options.ts b/mobile/src/session/use-mobile-native-chat-session-options.ts index 66a929aeb56..6acdf8b3750 100644 --- a/mobile/src/session/use-mobile-native-chat-session-options.ts +++ b/mobile/src/session/use-mobile-native-chat-session-options.ts @@ -168,7 +168,8 @@ export function useMobileNativeChatSessionOptions(args: { models: activeModels(catalog, record), record, mode: 'live', - modelLabel: 'Model' + modelLabel: 'Model', + liveTransport: 'catalog' }) }, [agent, catalog, scopeKey, version]) diff --git a/package.json b/package.json index 58f4805d937..c519d7ea6b1 100644 --- a/package.json +++ b/package.json @@ -153,6 +153,7 @@ "repro:live-remote-realistic-freeze": "node config/scripts/live-remote-realistic-freeze-repro.mjs" }, "dependencies": { + "@anthropic-ai/claude-agent-sdk": "0.3.251", "@electron-toolkit/preload": "^3.0.2", "@electron-toolkit/utils": "^4.0.0", "@floating-ui/dom": "1.7.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d7481f2e556..6b59d23e026 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -122,6 +122,9 @@ importers: .: dependencies: + '@anthropic-ai/claude-agent-sdk': + specifier: 0.3.251 + version: 0.3.251(@anthropic-ai/sdk@0.122.0(zod@4.5.4))(@modelcontextprotocol/sdk@1.30.0(supports-color@7.2.0)(zod@4.5.4))(zod@4.5.4) '@electron-toolkit/preload': specifier: ^3.0.2 version: 3.0.2(electron@43.4.1(supports-color@7.2.0)) @@ -535,6 +538,23 @@ packages: '@antfu/install-pkg@1.1.0': resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} + '@anthropic-ai/claude-agent-sdk@0.3.251': + resolution: {integrity: sha512-DqSi8mH2tQYRlVV0G+lJnQ/WbjJZ/a+8cJ3vPuYoqh8esIIvXHm1ZOXV1UPGsFYRnbBytEoiSGitguEXd+sQ+Q==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@anthropic-ai/sdk': '>=0.93.0' + '@modelcontextprotocol/sdk': ^1.29.0 + zod: ^4.0.0 + + '@anthropic-ai/sdk@0.122.0': + resolution: {integrity: sha512-GGPNftt0caaz9MDlmNQGHX8855Ojaduyy5pm9Sm1h7HalCn0cWNb5/bweadJF+4yzbal+QL6ztBa09WAAOzLmQ==} + hasBin: true + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -2628,6 +2648,9 @@ packages: resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} + '@stablelib/base64@1.0.1': + resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} + '@stablyai/playwright-base@2.1.14': resolution: {integrity: sha512-/iAgMW5tC0ETDo3mFyTzszRrD7rGFIT4fgDgtZxqa9vPhiTLix/1+GeOOBNY0uS+XRLFY0Uc/irsC3XProL47g==} engines: {node: '>=18'} @@ -4493,6 +4516,9 @@ packages: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} + fast-sha256@1.3.0: + resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} + fast-string-truncated-width@3.0.3: resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} @@ -5039,6 +5065,10 @@ packages: json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + json-schema-to-ts@3.1.1: + resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==} + engines: {node: '>=16'} + json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} @@ -6425,6 +6455,9 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + standardwebhooks@1.1.1: + resolution: {integrity: sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==} + stat-mode@1.0.0: resolution: {integrity: sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==} engines: {node: '>= 6'} @@ -6608,6 +6641,9 @@ packages: truncate-utf8-bytes@1.0.2: resolution: {integrity: sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==} + ts-algebra@2.0.0: + resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} + ts-dedent@2.2.0: resolution: {integrity: sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==} engines: {node: '>=6.10'} @@ -7007,6 +7043,16 @@ packages: zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} +ignoredOptionalDependencies: + - '@anthropic-ai/claude-agent-sdk-darwin-arm64' + - '@anthropic-ai/claude-agent-sdk-darwin-x64' + - '@anthropic-ai/claude-agent-sdk-linux-arm64' + - '@anthropic-ai/claude-agent-sdk-linux-arm64-musl' + - '@anthropic-ai/claude-agent-sdk-linux-x64' + - '@anthropic-ai/claude-agent-sdk-linux-x64-musl' + - '@anthropic-ai/claude-agent-sdk-win32-arm64' + - '@anthropic-ai/claude-agent-sdk-win32-x64' + snapshots: '@adobe/css-tools@4.5.0': {} @@ -7016,6 +7062,19 @@ snapshots: package-manager-detector: 1.6.0 tinyexec: 1.1.2 + '@anthropic-ai/claude-agent-sdk@0.3.251(@anthropic-ai/sdk@0.122.0(zod@4.5.4))(@modelcontextprotocol/sdk@1.30.0(supports-color@7.2.0)(zod@4.5.4))(zod@4.5.4)': + dependencies: + '@anthropic-ai/sdk': 0.122.0(zod@4.5.4) + '@modelcontextprotocol/sdk': 1.30.0(supports-color@7.2.0)(zod@4.5.4) + zod: 4.5.4 + + '@anthropic-ai/sdk@0.122.0(zod@4.5.4)': + dependencies: + json-schema-to-ts: 3.1.1 + standardwebhooks: 1.1.1 + optionalDependencies: + zod: 4.5.4 + '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -7669,6 +7728,28 @@ snapshots: dependencies: '@chevrotain/types': 11.1.2 + '@modelcontextprotocol/sdk@1.30.0(supports-color@7.2.0)(zod@4.5.4)': + dependencies: + '@hono/node-server': 2.1.0(hono@4.13.0) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.0.8 + express: 5.2.1(supports-color@7.2.0) + express-rate-limit: 8.5.2(express@5.2.1(supports-color@7.2.0)) + hono: 4.13.0 + jose: 6.2.3 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.5.4 + zod-to-json-schema: 3.25.2(zod@4.5.4) + transitivePeerDependencies: + - supports-color + '@modelcontextprotocol/sdk@1.30.0(zod@3.25.76)': dependencies: '@hono/node-server': 2.1.0(hono@4.13.0) @@ -7679,8 +7760,8 @@ snapshots: cross-spawn: 7.0.6 eventsource: 3.0.7 eventsource-parser: 3.0.8 - express: 5.2.1 - express-rate-limit: 8.5.2(express@5.2.1) + express: 5.2.1(supports-color@7.2.0) + express-rate-limit: 8.5.2(express@5.2.1(supports-color@7.2.0)) hono: 4.13.0 jose: 6.2.3 json-schema-typed: 8.0.2 @@ -8917,6 +8998,8 @@ snapshots: '@sindresorhus/merge-streams@4.0.0': {} + '@stablelib/base64@1.0.1': {} + '@stablyai/playwright-base@2.1.14(@playwright/test@1.59.1)(zod@4.5.4)': dependencies: '@playwright/test': 1.59.1 @@ -9923,7 +10006,7 @@ snapshots: bluebird@3.7.2: {} - body-parser@2.3.0: + body-parser@2.3.0(supports-color@7.2.0): dependencies: bytes: 3.1.2 content-type: 2.0.0 @@ -10779,15 +10862,15 @@ snapshots: exponential-backoff@3.1.3: {} - express-rate-limit@8.5.2(express@5.2.1): + express-rate-limit@8.5.2(express@5.2.1(supports-color@7.2.0)): dependencies: - express: 5.2.1 + express: 5.2.1(supports-color@7.2.0) ip-address: 10.4.0 - express@5.2.1: + express@5.2.1(supports-color@7.2.0): dependencies: accepts: 2.0.0 - body-parser: 2.3.0 + body-parser: 2.3.0(supports-color@7.2.0) content-disposition: 1.1.0 content-type: 1.0.5 cookie: 0.7.2 @@ -10797,7 +10880,7 @@ snapshots: encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 - finalhandler: 2.1.1 + finalhandler: 2.1.1(supports-color@7.2.0) fresh: 2.0.0 http-errors: 2.0.1 merge-descriptors: 2.0.0 @@ -10808,9 +10891,9 @@ snapshots: proxy-addr: 2.0.7 qs: 6.15.2 range-parser: 1.2.1 - router: 2.2.0 - send: 1.2.1 - serve-static: 2.2.1 + router: 2.2.0(supports-color@7.2.0) + send: 1.2.1(supports-color@7.2.0) + serve-static: 2.2.1(supports-color@7.2.0) statuses: 2.0.2 type-is: 2.1.0 vary: 1.1.2 @@ -10831,6 +10914,8 @@ snapshots: merge2: 1.4.1 micromatch: 4.0.8 + fast-sha256@1.3.0: {} + fast-string-truncated-width@3.0.3: {} fast-string-width@3.0.2: @@ -10871,7 +10956,7 @@ snapshots: dependencies: to-regex-range: 5.0.1 - finalhandler@2.1.1: + finalhandler@2.1.1(supports-color@7.2.0): dependencies: debug: 4.4.3(supports-color@7.2.0) encodeurl: 2.0.0 @@ -11445,6 +11530,11 @@ snapshots: json-parse-even-better-errors@2.3.1: {} + json-schema-to-ts@3.1.1: + dependencies: + '@babel/runtime': 7.29.7 + ts-algebra: 2.0.0 + json-schema-traverse@1.0.0: {} json-schema-typed@8.0.2: {} @@ -13037,7 +13127,7 @@ snapshots: points-on-curve: 0.2.0 points-on-path: 0.2.1 - router@2.2.0: + router@2.2.0(supports-color@7.2.0): dependencies: debug: 4.4.3(supports-color@7.2.0) depd: 2.0.0 @@ -13080,7 +13170,7 @@ snapshots: semver@7.8.1: {} - send@1.2.1: + send@1.2.1(supports-color@7.2.0): dependencies: debug: 4.4.3(supports-color@7.2.0) encodeurl: 2.0.0 @@ -13107,12 +13197,12 @@ snapshots: transitivePeerDependencies: - typescript - serve-static@2.2.1: + serve-static@2.2.1(supports-color@7.2.0): dependencies: encodeurl: 2.0.0 escape-html: 1.0.3 parseurl: 1.3.3 - send: 1.2.1 + send: 1.2.1(supports-color@7.2.0) transitivePeerDependencies: - supports-color @@ -13267,6 +13357,11 @@ snapshots: stackback@0.0.2: {} + standardwebhooks@1.1.1: + dependencies: + '@stablelib/base64': 1.0.1 + fast-sha256: 1.3.0 + stat-mode@1.0.0: {} state-local@1.0.7: {} @@ -13437,6 +13532,8 @@ snapshots: dependencies: utf8-byte-length: 1.0.5 + ts-algebra@2.0.0: {} + ts-dedent@2.2.0: {} ts-morph@26.0.0: @@ -13786,6 +13883,10 @@ snapshots: dependencies: zod: 3.25.76 + zod-to-json-schema@3.25.2(zod@4.5.4): + dependencies: + zod: 4.5.4 + zod@3.25.76: {} zod@4.5.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index d241459f884..97920f087c5 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -12,6 +12,20 @@ minimumReleaseAgeExclude: - zod@4.5.4 shamefullyHoist: true +# Orca always launches the user's own resolved Claude CLI via +# pathToClaudeCodeExecutable, so the SDK's bundled ~95 MB-per-platform CLI +# binaries must never be installed. Excluding them is what makes the path +# override mandatory rather than merely preferred. +ignoredOptionalDependencies: + - '@anthropic-ai/claude-agent-sdk-darwin-arm64' + - '@anthropic-ai/claude-agent-sdk-darwin-x64' + - '@anthropic-ai/claude-agent-sdk-linux-arm64' + - '@anthropic-ai/claude-agent-sdk-linux-arm64-musl' + - '@anthropic-ai/claude-agent-sdk-linux-x64' + - '@anthropic-ai/claude-agent-sdk-linux-x64-musl' + - '@anthropic-ai/claude-agent-sdk-win32-arm64' + - '@anthropic-ai/claude-agent-sdk-win32-x64' + supportedArchitectures: os: - current diff --git a/src/main/claude-accounts/claude-structured-auth-policy.test.ts b/src/main/claude-accounts/claude-structured-auth-policy.test.ts new file mode 100644 index 00000000000..a2d7c7d5da8 --- /dev/null +++ b/src/main/claude-accounts/claude-structured-auth-policy.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from 'vitest' +import type { GlobalSettings } from '../../shared/global-settings-types' +import type { ClaudeManagedAccount } from '../../shared/managed-account-types' +import { + CLAUDE_AUTH_ENV_VARS, + hasClaudeAuthEnvConflict, + shouldStripClaudeAuthEnvForAccount +} from './environment' +import { + normalizeTuiAgentEnvRecord, + resolveTuiAgentLaunchEnv +} from '../../shared/tui-agent-launch-defaults' +import { claudeStructuredAuthPolicyForSettings } from './claude-structured-auth-policy' + +const HOST_ACCOUNT = { id: 'host-a', managedAuthRuntime: 'host' } as ClaudeManagedAccount +const WSL_ACCOUNT = { id: 'wsl-b', managedAuthRuntime: 'wsl' } as ClaudeManagedAccount +const LEGACY_ACCOUNT = { id: 'legacy-c' } as ClaudeManagedAccount + +function settings( + overrides: Partial< + Pick< + GlobalSettings, + | 'claudeManagedAccounts' + | 'activeClaudeManagedAccountId' + | 'activeClaudeManagedAccountIdsByRuntime' + > + > +): Parameters[0] { + return { + claudeManagedAccounts: [HOST_ACCOUNT, WSL_ACCOUNT, LEGACY_ACCOUNT], + activeClaudeManagedAccountId: null, + ...overrides + } as Parameters[0] +} + +// The predicate now backs BOTH transports (runtime-auth-preparation.ts and the +// structured wiring), so it needs a test of its own: forcing it to a constant used +// to leave ~1000 tests green. +describe('shouldStripClaudeAuthEnvForAccount', () => { + it('does not strip when no managed account is selected', () => { + expect(shouldStripClaudeAuthEnvForAccount([HOST_ACCOUNT], null)).toBe(false) + expect(shouldStripClaudeAuthEnvForAccount([HOST_ACCOUNT], undefined)).toBe(false) + expect(shouldStripClaudeAuthEnvForAccount([HOST_ACCOUNT], '')).toBe(false) + }) + + it('strips for a host-managed account', () => { + expect(shouldStripClaudeAuthEnvForAccount([HOST_ACCOUNT, WSL_ACCOUNT], 'host-a')).toBe(true) + }) + + it('strips for an account with no explicit runtime (the legacy host shape)', () => { + expect(shouldStripClaudeAuthEnvForAccount([LEGACY_ACCOUNT], 'legacy-c')).toBe(true) + }) + + it('does not strip for a WSL-managed account, matching runtime-auth-preparation', () => { + expect(shouldStripClaudeAuthEnvForAccount([HOST_ACCOUNT, WSL_ACCOUNT], 'wsl-b')).toBe(false) + }) + + it('strips for a selected id no account list explains', () => { + // Fail-safe: an id we cannot resolve is treated as a pinned account, never as + // "no account", so an unreadable settings blob cannot open the strip. + expect(shouldStripClaudeAuthEnvForAccount([HOST_ACCOUNT], 'deleted-d')).toBe(true) + expect(shouldStripClaudeAuthEnvForAccount(undefined, 'deleted-d')).toBe(true) + expect(shouldStripClaudeAuthEnvForAccount([], 'deleted-d')).toBe(true) + }) +}) + +describe('claudeStructuredAuthPolicyForSettings', () => { + it('reads the host runtime selection, not the legacy flat field alone', () => { + expect( + claudeStructuredAuthPolicyForSettings( + settings({ + activeClaudeManagedAccountId: 'host-a', + activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: {} } + }) + ) + ).toEqual({ stripAuthEnv: true }) + }) + + it('strips when a host account is pinned by runtime selection', () => { + expect( + claudeStructuredAuthPolicyForSettings( + settings({ activeClaudeManagedAccountIdsByRuntime: { host: 'host-a', wsl: {} } }) + ) + ).toEqual({ stripAuthEnv: true }) + }) + + it('does not strip for system auth, so an API-key-only user keeps their sign-in', () => { + expect(claudeStructuredAuthPolicyForSettings(settings({}))).toEqual({ stripAuthEnv: false }) + }) + + it('ignores a WSL-only selection: the structured child is always a native host process', () => { + expect( + claudeStructuredAuthPolicyForSettings( + settings({ + activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'wsl-b' } } + }) + ) + ).toEqual({ stripAuthEnv: false }) + }) +}) + +describe('the strip vocabulary the policy governs', () => { + it('covers every Anthropic auth variable the terminal path knows about', () => { + // A new auth var added to the list without a matching refusal/strip path is the + // shape of the leak this lane already shipped once. + expect([...CLAUDE_AUTH_ENV_VARS]).toEqual([ + 'ANTHROPIC_API_KEY', + 'ANTHROPIC_AUTH_TOKEN', + 'CLAUDE_CODE_OAUTH_TOKEN', + 'AWS_BEARER_TOKEN_BEDROCK' + ]) + }) +}) + +// The refusal has to cover exactly what the strip removes. Anything narrower lets an +// override reach the child that applyClaudeEnvPatch would have deleted. +describe('hasClaudeAuthEnvConflict matches the strip it guards', () => { + it('refuses each Anthropic auth variable', () => { + for (const key of CLAUDE_AUTH_ENV_VARS) { + expect(hasClaudeAuthEnvConflict({ [key]: 'v' }, 'linux')).toBe(true) + } + }) + + // `ANTHROPIC_API_KEY=` in the agent env box is how a user blanks a variable, and the + // settings pipeline preserves the empty value (agent-default-env-draft.ts assigns + // everything after the `=`; normalizeTuiAgentEnvRecord drops empty KEYS only). An + // empty value cannot beat the pinned account and the strip removes the name anyway, + // so refusing it would break a terminal launch that works today for no security gain. + it('admits an override whose value is empty, the documented way to blank a variable', () => { + expect(hasClaudeAuthEnvConflict({ ANTHROPIC_API_KEY: '' }, 'linux')).toBe(false) + expect(hasClaudeAuthEnvConflict({ anthropic_api_key: '' }, 'win32')).toBe(false) + expect(hasClaudeAuthEnvConflict({ ANTHROPIC_CUSTOM_HEADERS: '' }, 'linux')).toBe(false) + }) + + it('still refuses the same names once they carry a value', () => { + expect(hasClaudeAuthEnvConflict({ ANTHROPIC_API_KEY: 'sk-ant' }, 'linux')).toBe(true) + }) + + // The end-to-end shape the regression actually took: settings text -> normalized + // record -> launch env -> the predicate the terminal preflight gates on. + it('admits a blanked variable all the way from the settings record', () => { + const configured = normalizeTuiAgentEnvRecord({ claude: { ANTHROPIC_API_KEY: '' } }) + const launchEnv = resolveTuiAgentLaunchEnv('claude', configured) + + expect(launchEnv).toEqual({ ANTHROPIC_API_KEY: '' }) + expect(hasClaudeAuthEnvConflict(launchEnv, 'linux')).toBe(false) + }) + + it('folds case on win32, where the OS does', () => { + expect(hasClaudeAuthEnvConflict({ anthropic_api_key: 'sk-lower' }, 'win32')).toBe(true) + expect(hasClaudeAuthEnvConflict({ Anthropic_Custom_Headers: 'x-api-key: v' }, 'win32')).toBe( + true + ) + }) + + it('keeps env names case-sensitive off win32', () => { + expect(hasClaudeAuthEnvConflict({ anthropic_api_key: 'sk-lower' }, 'linux')).toBe(false) + }) + + it('admits non-auth Anthropic settings on both platforms', () => { + expect(hasClaudeAuthEnvConflict({ ANTHROPIC_BASE_URL: 'https://gw.test' }, 'linux')).toBe(false) + expect(hasClaudeAuthEnvConflict({ ANTHROPIC_BASE_URL: 'https://gw.test' }, 'win32')).toBe(false) + expect(hasClaudeAuthEnvConflict({ ANTHROPIC_CUSTOM_HEADERS: 'X-Trace: 1' }, 'linux')).toBe( + false + ) + expect(hasClaudeAuthEnvConflict(undefined, 'linux')).toBe(false) + }) +}) diff --git a/src/main/claude-accounts/claude-structured-auth-policy.ts b/src/main/claude-accounts/claude-structured-auth-policy.ts new file mode 100644 index 00000000000..c30cd69b827 --- /dev/null +++ b/src/main/claude-accounts/claude-structured-auth-policy.ts @@ -0,0 +1,37 @@ +import type { GlobalSettings } from '../../shared/global-settings-types' +import { shouldStripClaudeAuthEnvForAccount } from './environment' +import { getSelectedClaudeAccountIdForTarget } from './runtime-selection' + +/** The structured mirror of the terminal preflight's `prepareClaudeAuth` result: + * the one field a launch resolution needs from the managed-account state. */ +export type ClaudeStructuredAuthPolicy = { + stripAuthEnv: boolean +} + +/** + * The only supported way to build a structured launch's auth policy. + * + * It exists as a named function rather than an inline object at the wiring site so + * that the settings-to-policy mapping is testable on its own: the one production + * wiring lives in a `@ts-nocheck` file, where neither the compiler nor a type test + * can see a dropped field. + * + * Structured Claude always spawns a native local-host child — the launch resolver + * refuses any record with a remote execution host or a WSL distro — so the host + * selection, not the platform default target, owns its auth. + */ +export function claudeStructuredAuthPolicyForSettings( + settings: Pick< + GlobalSettings, + | 'claudeManagedAccounts' + | 'activeClaudeManagedAccountId' + | 'activeClaudeManagedAccountIdsByRuntime' + > +): ClaudeStructuredAuthPolicy { + return { + stripAuthEnv: shouldStripClaudeAuthEnvForAccount( + settings.claudeManagedAccounts, + getSelectedClaudeAccountIdForTarget(settings, { runtime: 'host' }) + ) + } +} diff --git a/src/main/claude-accounts/environment.ts b/src/main/claude-accounts/environment.ts index 83fe3b40209..b85dd60a854 100644 --- a/src/main/claude-accounts/environment.ts +++ b/src/main/claude-accounts/environment.ts @@ -1,3 +1,5 @@ +import type { ClaudeManagedAccount } from '../../shared/managed-account-types' + export const CLAUDE_AUTH_ENV_VARS = [ 'ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', @@ -13,14 +15,21 @@ export type ClaudeEnvPatch = { export function applyClaudeEnvPatch( baseEnv: Record, patch: ClaudeEnvPatch, - options?: { stripAuthEnv?: boolean } + options?: { stripAuthEnv?: boolean; platform?: NodeJS.Platform } ): Record { if (options?.stripAuthEnv) { for (const key of CLAUDE_AUTH_ENV_VARS) { delete baseEnv[key] } - if (isAuthLikeCustomHeaders(baseEnv.ANTHROPIC_CUSTOM_HEADERS)) { - delete baseEnv.ANTHROPIC_CUSTOM_HEADERS + const platform = options.platform ?? process.platform + for (const key of Object.keys(baseEnv)) { + const normalized = platform === 'win32' ? key.toUpperCase() : key + if ( + (platform === 'win32' && CLAUDE_AUTH_ENV_VARS.some((authKey) => authKey === normalized)) || + (normalized === 'ANTHROPIC_CUSTOM_HEADERS' && isAuthLikeCustomHeaders(baseEnv[key])) + ) { + delete baseEnv[key] + } } } @@ -34,16 +43,94 @@ export function applyClaudeEnvPatch( return baseEnv } -export function hasClaudeAuthEnvConflict(env: Record | undefined): boolean { - if (!env) { +/** One string for every transport, so a terminal launch and a structured launch + * cannot drift into telling the user two different things about one refusal. */ +export const CLAUDE_AUTH_ENV_CONFLICT_MESSAGE = + 'This Claude launch defines explicit Anthropic auth environment variables. Remove those overrides before using a managed Claude account.' + +export const CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE = + 'A Claude account switch is in progress. Try again after it finishes.' + +/** + * Whether a launch on the host runtime must drop inherited Anthropic auth. + * + * Only a pinned host-managed account owns the credential, so only it may strip: + * with no managed account the user's own `ANTHROPIC_*` is their sign-in, and + * removing it signs them out of a CLI that would otherwise have worked. + */ +export function shouldStripClaudeAuthEnvForAccount( + accounts: readonly ClaudeManagedAccount[] | undefined, + activeAccountId: string | null | undefined +): boolean { + if (!activeAccountId) { return false } return ( - CLAUDE_AUTH_ENV_VARS.some((key) => Boolean(env[key])) || - isAuthLikeCustomHeaders(env.ANTHROPIC_CUSTOM_HEADERS) + (accounts ?? []).find((account) => account.id === activeAccountId)?.managedAuthRuntime !== 'wsl' ) } +/** + * Whether a launch's explicit env carries Anthropic auth a managed account must own. + * + * The key comparison mirrors applyClaudeEnvPatch's strip exactly: case-insensitive on + * win32, where the OS folds env names so `anthropic_api_key` is an effective + * `ANTHROPIC_API_KEY`, and case-sensitive elsewhere. A refusal narrower than the strip + * lets an override through that the strip would have removed. + * + * A non-empty value is what makes it a conflict. `ANTHROPIC_API_KEY=` in the agent env + * box is how a user blanks a variable — the settings pipeline preserves that empty value + * (normalizeTuiAgentEnvRecord drops empty KEYS only) — and an empty override can neither + * authenticate nor beat the pinned account, while the strip removes the name regardless. + * Refusing it would break a terminal launch that works today for no security gain. + */ +/** + * The inherited Anthropic auth a non-stripping launch has to carry forward explicitly. + * + * applyClaudeEnvPatch always strips the inherited half of a child env, and the + * configured half is what overrides it — so a system-auth user's own key only survives + * if the caller puts it back deliberately. Returns the exact keys present, so a + * win32 `anthropic_api_key` is carried under the name the OS actually has. + */ +export function claudeAuthEnvCarriedForward( + inherited: NodeJS.ProcessEnv, + platform: NodeJS.Platform = process.platform +): Record { + const carried: Record = {} + for (const [key, value] of Object.entries(inherited)) { + if (value === undefined) { + continue + } + const normalized = platform === 'win32' ? key.toUpperCase() : key + if ( + CLAUDE_AUTH_ENV_VARS.some((authKey) => authKey === normalized) || + (normalized === 'ANTHROPIC_CUSTOM_HEADERS' && isAuthLikeCustomHeaders(value)) + ) { + carried[key] = value + } + } + return carried +} + +export function hasClaudeAuthEnvConflict( + env: Record | undefined, + platform: NodeJS.Platform = process.platform +): boolean { + if (!env) { + return false + } + for (const [key, value] of Object.entries(env)) { + const normalized = platform === 'win32' ? key.toUpperCase() : key + if (value && CLAUDE_AUTH_ENV_VARS.some((authKey) => authKey === normalized)) { + return true + } + if (normalized === 'ANTHROPIC_CUSTOM_HEADERS' && isAuthLikeCustomHeaders(value)) { + return true + } + } + return false +} + function isAuthLikeCustomHeaders(value: string | undefined): boolean { if (!value) { return false diff --git a/src/main/claude-accounts/live-pty-gate.ts b/src/main/claude-accounts/live-pty-gate.ts index 9e30b621924..caab66c3430 100644 --- a/src/main/claude-accounts/live-pty-gate.ts +++ b/src/main/claude-accounts/live-pty-gate.ts @@ -5,6 +5,13 @@ const liveClaudePtyIds = new Set() // survived the app restart inside the daemon. const seededUnconfirmedPtyIds = new Set() let switchInProgress = false +// Woken by endClaudeAuthSwitch so a caller past the point of no return can wait the +// swap out instead of refusing. See whenClaudeAuthSwitchSettles. +const switchSettledListeners = new Set<() => void>() + +/** A managed account swap is a credential-file rewrite, not a network round trip; + * anything past this is a wedged switch, and refusing beats waiting forever. */ +export const CLAUDE_AUTH_SWITCH_SETTLE_TIMEOUT_MS = 15_000 export type ClaudeLivePtyPersistence = { addClaudeLivePtySessionId(sessionId: string): void @@ -81,6 +88,35 @@ export function markClaudePtyExited(ptyId: string): void { notifyDrainedOnTransition(hadLivePtys) } +/** + * Register a structured Claude child with the same gate the terminal path uses. + * + * The gate is what makes the managed OAuth refresh defer instead of rotating a + * single-use refresh token out from under a running Claude (runtime-auth-sync.ts). + * A structured session's child is as much a live Claude as a PTY's is, so it has to + * hold the gate too — otherwise a refresh mid-turn breaks its next API call while an + * identical terminal session is protected. + * + * Deliberately not persisted, unlike markClaudePtySpawned: these children are direct + * children of this process and cannot survive a restart, so seeding them back on the + * next launch would hold the gate closed for a process that is provably gone. + */ +export function markClaudeStructuredChildSpawned(childKey: string): void { + liveClaudePtyIds.add(structuredChildGateId(childKey)) +} + +export function markClaudeStructuredChildExited(childKey: string): void { + const hadLivePtys = liveClaudePtyIds.size > 0 + liveClaudePtyIds.delete(structuredChildGateId(childKey)) + notifyDrainedOnTransition(hadLivePtys) +} + +// Namespaced so a structured child can never collide with a daemon PTY session id, +// which confirmSeededClaudeLivePtys reconciles against the daemon's own list. +function structuredChildGateId(childKey: string): string { + return `claude-structured:${childKey}` +} + export function hasLiveClaudePtys(): boolean { return liveClaudePtyIds.size > 0 } @@ -93,7 +129,44 @@ export function beginClaudeAuthSwitch(): void { } export function endClaudeAuthSwitch(): void { + const wasInProgress = switchInProgress switchInProgress = false + if (!wasInProgress) { + return + } + // Each listener removes itself as it settles; Set iteration is defined over that. + for (const listener of switchSettledListeners) { + listener() + } +} + +/** + * Resolves `true` once no account switch is running, `false` if one is still running + * at the deadline. + * + * Exists for callers that have already done irreversible work — a structured acquire + * has closed the old child by the time it resolves its launch, so turning a switch + * into a refusal there strands the user with a dead session and no replacement. + * Waiting for the swap and then launching against it is the recoverable answer; + * refusing is only correct when nothing has been torn down yet. + */ +export function whenClaudeAuthSwitchSettles( + timeoutMs = CLAUDE_AUTH_SWITCH_SETTLE_TIMEOUT_MS +): Promise { + if (!switchInProgress) { + return Promise.resolve(true) + } + return new Promise((resolve) => { + const settle = (settled: boolean): void => { + switchSettledListeners.delete(listener) + clearTimeout(timer) + resolve(settled) + } + const listener = (): void => settle(true) + switchSettledListeners.add(listener) + const timer = setTimeout(() => settle(false), timeoutMs) + timer.unref?.() + }) } export function isClaudeAuthSwitchInProgress(): boolean { diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-preparation.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-preparation.ts index ae79c4c7bbb..dabcd9d472f 100644 --- a/src/main/claude-accounts/runtime-auth/runtime-auth-preparation.ts +++ b/src/main/claude-accounts/runtime-auth/runtime-auth-preparation.ts @@ -2,6 +2,7 @@ import { join } from 'node:path' import type { ClaudeManagedAccount } from '../../../shared/managed-account-types' import { resolveLocalAccountRuntimeTarget } from '../../../shared/local-account-runtime' import { parseWslUncPath } from '../../../shared/wsl-paths' +import { shouldStripClaudeAuthEnvForAccount } from '../environment' import { getDefaultWslDistro, getWslHome } from '../../wsl' import { getSelectedClaudeAccountIdForTarget, @@ -69,7 +70,10 @@ export class ClaudeRuntimeAuthPreparationService extends ClaudeRuntimeAuthSnapsh wslDistro: null, wslLinuxConfigDir: null, envPatch: paths.envPatch, - stripAuthEnv: Boolean(activeAccountId && activeAccount?.managedAuthRuntime !== 'wsl'), + stripAuthEnv: shouldStripClaudeAuthEnvForAccount( + settings.claudeManagedAccounts, + activeAccountId + ), managedRefreshDeferredByLivePty: Boolean( activeAccountId && activeAccount?.managedAuthRuntime !== 'wsl' && diff --git a/src/main/claude/__fixtures__/claude-agent-sdk-scripted-cli.mjs b/src/main/claude/__fixtures__/claude-agent-sdk-scripted-cli.mjs new file mode 100644 index 00000000000..4f2a09425fd --- /dev/null +++ b/src/main/claude/__fixtures__/claude-agent-sdk-scripted-cli.mjs @@ -0,0 +1,144 @@ +// Scripted stand-in for the Claude Code CLI, driven by the SDK contract-pin +// tests. It speaks just enough stream-json to satisfy the SDK: it answers every +// inbound control_request with a success control_response, records everything it +// observes to a report file, and plays back the steps listed in a scenario file. +// +// Env contract (set by the test): +// ORCA_SDK_CONTRACT_SCENARIO_PATH — JSON file +// { steps: Step[], controlResponses?: { [subtype]: } } where a Step is +// { emit: } | { awaitUserMessage: true } | { stderr: } | +// { awaitControlResponse: } | { delayMs: } | { exit: } +// ORCA_SDK_CONTRACT_REPORT_PATH — where argv/env observations are written +// ORCA_SDK_CONTRACT_IGNORE_SIGTERM — trap SIGTERM/SIGINT and outlive stdin close +// ORCA_SDK_CONTRACT_IGNORE_CONTROL_REQUESTS — record control requests but never answer +// ORCA_SDK_CONTRACT_DESCENDANT — fork an idle grandchild and report its pid +import { spawn } from 'node:child_process' +import { readFileSync, writeFileSync } from 'node:fs' +import { createInterface } from 'node:readline' + +const scenarioPath = process.env.ORCA_SDK_CONTRACT_SCENARIO_PATH +const reportPath = process.env.ORCA_SDK_CONTRACT_REPORT_PATH + +const report = { + argv: process.argv.slice(1), + execPath: process.execPath, + controlRequests: [], + controlResponses: [], + userMessages: [], + descendantPid: null +} +const writeReport = () => { + if (reportPath) { + writeFileSync(reportPath, JSON.stringify(report)) + } +} +// Written immediately so a test can prove which script the SDK executed even if +// the session dies before the scenario completes. +writeReport() + +const scenario = scenarioPath ? JSON.parse(readFileSync(scenarioPath, 'utf8')) : { steps: [] } + +if (process.env.ORCA_SDK_CONTRACT_IGNORE_SIGTERM) { + process.on('SIGTERM', () => {}) + process.on('SIGINT', () => {}) + setInterval(() => {}, 1_000_000) +} +if (process.env.ORCA_SDK_CONTRACT_DESCENDANT) { + const descendant = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000000)'], { + stdio: 'ignore' + }) + descendant.unref() + report.descendantPid = descendant.pid ?? null + writeReport() +} + +const emit = (frame) => process.stdout.write(`${JSON.stringify(frame)}\n`) + +const waiters = [] +const settle = (kind, requestId) => { + for (let i = waiters.length - 1; i >= 0; i--) { + const waiter = waiters[i] + if ( + waiter.kind === kind && + (waiter.requestId === undefined || waiter.requestId === requestId) + ) { + waiters.splice(i, 1) + waiter.resolve() + } + } +} +const waitFor = (kind, requestId) => { + if (kind === 'user' && report.userMessages.length > 0) { + return Promise.resolve() + } + if ( + kind === 'control_response' && + report.controlResponses.some((frame) => frame.response?.request_id === requestId) + ) { + return Promise.resolve() + } + return new Promise((resolve) => waiters.push({ kind, requestId, resolve })) +} + +createInterface({ input: process.stdin }).on('line', (line) => { + let frame + try { + frame = JSON.parse(line) + } catch { + return + } + if (frame.type === 'control_request') { + report.controlRequests.push(frame) + writeReport() + if (process.env.ORCA_SDK_CONTRACT_IGNORE_CONTROL_REQUESTS) { + return + } + emit({ + type: 'control_response', + response: { + subtype: 'success', + request_id: frame.request_id, + response: scenario.controlResponses?.[frame.request?.subtype] ?? { + commands: [], + models: [] + } + } + }) + return + } + if (frame.type === 'control_response') { + report.controlResponses.push(frame) + writeReport() + settle('control_response', frame.response?.request_id) + return + } + if (frame.type === 'user') { + report.userMessages.push(frame) + writeReport() + settle('user') + } +}) + +// Never outlive a wedged test: the readline subscription would otherwise hold +// this process open forever if the SDK side stops driving the scenario. +setTimeout(() => process.exit(3), 20_000).unref() + +for (const step of scenario.steps) { + if (step.emit) { + emit(step.emit) + } else if (step.stderr !== undefined) { + process.stderr.write(step.stderr) + } else if (step.awaitUserMessage) { + await waitFor('user') + } else if (step.awaitControlResponse !== undefined) { + await waitFor('control_response', step.awaitControlResponse) + } else if (step.delayMs) { + await new Promise((resolve) => setTimeout(resolve, step.delayMs)) + } else if (step.exit !== undefined) { + // A CLI that refuses to start: leave with its own status, stderr already written. + writeReport() + process.exit(step.exit) + } +} +writeReport() +process.exit(0) diff --git a/src/main/claude/claude-agent-sdk-contract-pins.test.ts b/src/main/claude/claude-agent-sdk-contract-pins.test.ts new file mode 100644 index 00000000000..46bcb7219d3 --- /dev/null +++ b/src/main/claude/claude-agent-sdk-contract-pins.test.ts @@ -0,0 +1,519 @@ +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { + query, + type CanUseTool, + type Options, + type SDKUserMessage, + type SpawnedProcess as SdkSpawnedProcess, + type SpawnOptions as SdkSpawnOptions +} from '@anthropic-ai/claude-agent-sdk' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { spawnProcess } from '../../shared/child-process/run-process' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store' +import { claudeQuerySettingsReader } from './claude-agent-sdk-control-requests' +import { createClaudeStructuredLaunchResolver } from './claude-structured-launch-resolution' + +// Contract pins for @anthropic-ai/claude-agent-sdk, run against the real SDK +// driving a scripted fake CLI (never the real Claude binary). These tests exist +// to catch a future SDK version drifting under Orca: unknown-frame pass-through, +// spawner env fidelity, argument parity with the pre-SDK argv, +// permission-callback semantics, and executable-path override. + +const FAKE_CLI = join(__dirname, '__fixtures__', 'claude-agent-sdk-scripted-cli.mjs') +const SESSION_ID = '5348c19f-6a54-4c2e-9c68-9c2b1a3d4e5f' +const LEAF_UUID = 'ad0f7c9e-1b2c-4d3e-8f90-abc123def456' +const PINNED_SDK_VERSION = '0.3.251' +const SDK_PLATFORM_PACKAGE_BASENAMES = [ + 'claude-agent-sdk-darwin-arm64', + 'claude-agent-sdk-darwin-x64', + 'claude-agent-sdk-linux-arm64', + 'claude-agent-sdk-linux-arm64-musl', + 'claude-agent-sdk-linux-x64', + 'claude-agent-sdk-linux-x64-musl', + 'claude-agent-sdk-win32-arm64', + 'claude-agent-sdk-win32-x64' +] + +/** + * The exact argv the hand-rolled transport built before the SDK swap. Frozen here + * as the parity oracle: CLAUDE_STRUCTURED_BASE_OPTIONS has to keep producing it. + */ +const PRE_SDK_ARGV = [ + '-p', + '--input-format', + 'stream-json', + '--output-format', + 'stream-json', + '--include-partial-messages', + '--verbose', + '--replay-user-messages', + '--permission-prompt-tool', + 'stdio', + '--setting-sources', + 'user,project,local' +] + +const RESULT_FRAME = { + type: 'result', + subtype: 'success', + is_error: false, + duration_ms: 1, + duration_api_ms: 1, + num_turns: 1, + result: 'ok', + session_id: SESSION_ID, + total_cost_usd: 0, + usage: { input_tokens: 1, output_tokens: 1 }, + uuid: 'uuid-result-1' +} + +type ScenarioStep = Record +type SpawnSeen = { + command: string + args: string[] + cwd: string | undefined + env: Record +} +type ScriptedCliReport = { + argv: string[] + execPath: string + controlRequests: { request_id: string; request: { subtype: string } }[] + controlResponses: { response: { request_id: string; response?: Record } }[] + userMessages: Record[] +} + +const scratchDirs: string[] = [] +afterEach(() => { + vi.unstubAllEnvs() + for (const dir of scratchDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } +}) + +function scriptScenario( + steps: ScenarioStep[], + controlResponses: Record = {} +): { + scenarioPath: string + reportPath: string + cwd: string + readReport: () => ScriptedCliReport +} { + const dir = mkdtempSync(join(tmpdir(), 'claude-sdk-contract-')) + scratchDirs.push(dir) + const scenarioPath = join(dir, 'scenario.json') + const reportPath = join(dir, 'report.json') + writeFileSync(scenarioPath, JSON.stringify({ steps, controlResponses })) + return { + scenarioPath, + reportPath, + cwd: dir, + readReport: () => JSON.parse(readFileSync(reportPath, 'utf8')) as ScriptedCliReport + } +} + +function scenarioEnv(scenario: { scenarioPath: string; reportPath: string }) { + return { + PATH: process.env.PATH, + ORCA_SDK_CONTRACT_SCENARIO_PATH: scenario.scenarioPath, + ORCA_SDK_CONTRACT_REPORT_PATH: scenario.reportPath + } +} + +function recordingSpawner(spawns: SpawnSeen[]) { + return (opts: SdkSpawnOptions): SdkSpawnedProcess => { + spawns.push({ + command: opts.command, + args: [...opts.args], + cwd: opts.cwd, + env: { ...opts.env } + }) + return spawnProcess({ + program: opts.command, + args: opts.args, + cwd: opts.cwd, + env: opts.env as NodeJS.ProcessEnv, + signal: opts.signal + }) as unknown as SdkSpawnedProcess + } +} + +function resolvedLaunch(launchArgs: string[]) { + const record = { + sessionId: 'contract-pin-session', + provider: 'claude', + location: { + executionHostId: LOCAL_EXECUTION_HOST_ID, + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'folder' + }, + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/work/.claude' }, + providerHandleChain: [], + launchArgs + } as unknown as AgentSessionRecord + return createClaudeStructuredLaunchResolver({ + store: { getRecord: () => record } as unknown as AgentSessionRecordStore, + resolveWorkspacePath: async () => '/repos/workspace-1', + resolveCommand: () => FAKE_CLI, + resolveAuthPolicy: () => ({ stripAuthEnv: true }) + })({ identity: { sessionId: record.sessionId } as never }) +} + +function singleUserTurn(): AsyncIterable { + return (async function* () { + yield { + type: 'user', + message: { role: 'user', content: [{ type: 'text', text: 'hello' }] }, + parent_tool_use_id: null, + session_id: SESSION_ID + } as SDKUserMessage + // Hold input open; the stream ends when the scripted CLI exits, and an + // unresolved bare promise does not keep the event loop alive. + await new Promise(() => {}) + })() +} + +async function drainQuery(options: Options): Promise[]> { + const messages: Record[] = [] + for await (const message of query({ prompt: singleUserTurn(), options })) { + messages.push(message as unknown as Record) + } + return messages +} + +/** Expand `--flag=value` argv entries so both SDK spellings compare equal. */ +function normalizeArgv(args: string[]): string[] { + return args.flatMap((arg) => { + if (!arg.startsWith('--')) { + return [arg] + } + const eq = arg.indexOf('=') + return eq === -1 ? [arg] : [arg.slice(0, eq), arg.slice(eq + 1)] + }) +} + +/** Group the pre-SDK argv into flag/value pairs. */ +function flagTable(args: readonly string[]): { flag: string; value: string | null }[] { + const table: { flag: string; value: string | null }[] = [] + for (let i = 0; i < args.length; i++) { + const flag = args[i]! + const next = args[i + 1] + if (next !== undefined && !next.startsWith('-')) { + table.push({ flag, value: next }) + i++ + } else { + table.push({ flag, value: null }) + } + } + return table +} + +describe('Claude Agent SDK contract pins', () => { + it('yields unknown types, unknown fields and unknown content blocks verbatim, and consumes keep_alive', async () => { + const unknownTopLevel = { + type: 'message_kind_from_the_future', + session_id: SESSION_ID, + uuid: 'uuid-unknown-1', + payload: { alpha: 1, nested: { flags: ['a', 'b'] } } + } + const assistantWithUnknowns = { + type: 'assistant', + message: { + id: 'msg-1', + type: 'message', + role: 'assistant', + model: 'claude-x', + content: [ + { type: 'text', text: 'hello back' }, + { type: 'content_block_from_the_future', payload: { depth: 3 } } + ], + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 1, output_tokens: 2 } + }, + parent_tool_use_id: null, + uuid: 'uuid-assistant-1', + session_id: SESSION_ID, + field_from_the_future: 'preserved' + } + const scenario = scriptScenario([ + { awaitUserMessage: true }, + { emit: { type: 'keep_alive' } }, + { emit: unknownTopLevel }, + { emit: assistantWithUnknowns }, + { emit: RESULT_FRAME } + ]) + const spawns: SpawnSeen[] = [] + const messages = await drainQuery({ + pathToClaudeCodeExecutable: FAKE_CLI, + cwd: scenario.cwd, + env: scenarioEnv(scenario), + spawnClaudeCodeProcess: recordingSpawner(spawns) + }) + + expect(messages.find((m) => m.uuid === 'uuid-unknown-1')).toEqual(unknownTopLevel) + expect(messages.find((m) => m.uuid === 'uuid-assistant-1')).toEqual(assistantWithUnknowns) + // The SDK intercepts keep_alive internally — a liveness signal must never + // be derived from it reaching the consumer, because it does not. + expect(messages.some((m) => m.type === 'keep_alive')).toBe(false) + expect(messages.some((m) => m.type === 'result')).toBe(true) + }) + + it('hands the custom spawner exactly the caller-supplied env, plus the two pinned SDK mutations', async () => { + vi.stubEnv('ANTHROPIC_API_KEY', 'ambient-key-must-not-leak') + const scenario = scriptScenario([{ awaitUserMessage: true }, { emit: RESULT_FRAME }]) + const spawns: SpawnSeen[] = [] + await drainQuery({ + pathToClaudeCodeExecutable: FAKE_CLI, + cwd: scenario.cwd, + env: { + ...scenarioEnv(scenario), + CLAUDE_CONFIG_DIR: '/pinned/claude-config', + ORCA_AGENT_SESSION_SPAWN_TOKEN: 'spawn-token-1', + NODE_OPTIONS: '--max-old-space-size=64' + }, + spawnClaudeCodeProcess: recordingSpawner(spawns) + }) + + const env = spawns[0]!.env + // Supplied values arrive verbatim: the config-dir pin and spawn token are + // observable at this boundary, so Orca's auth scrubbing stays assertable. + expect(env.CLAUDE_CONFIG_DIR).toBe('/pinned/claude-config') + expect(env.ORCA_AGENT_SESSION_SPAWN_TOKEN).toBe('spawn-token-1') + // Ambient process.env is NOT merged in when env is supplied. + expect(env.ANTHROPIC_API_KEY).toBeUndefined() + // The SDK's two documented mutations, pinned so a change is noticed. + expect(env.CLAUDE_CODE_ENTRYPOINT).toBe('sdk-ts') + expect('NODE_OPTIONS' in env).toBe(false) + }) + + it('inherits process.env into the child when env is omitted — the ambient-auth sharp edge', async () => { + const scenario = scriptScenario([{ awaitUserMessage: true }, { emit: RESULT_FRAME }]) + vi.stubEnv('ORCA_SDK_CONTRACT_SCENARIO_PATH', scenario.scenarioPath) + vi.stubEnv('ORCA_SDK_CONTRACT_REPORT_PATH', scenario.reportPath) + vi.stubEnv('ORCA_SDK_CONTRACT_AMBIENT_CANARY', 'inherited-from-process-env') + const spawns: SpawnSeen[] = [] + await drainQuery({ + pathToClaudeCodeExecutable: FAKE_CLI, + cwd: scenario.cwd, + spawnClaudeCodeProcess: recordingSpawner(spawns) + }) + + // Omitting env reproduces the ambient-auth-leak failure mode: the child + // sees everything in process.env. Orca must therefore always pass an + // explicit, fully-constructed env. + expect(spawns[0]!.env.ORCA_SDK_CONTRACT_AMBIENT_CANARY).toBe('inherited-from-process-env') + }) + + it('emits --replay-user-messages only through extraArgs, never on its own', async () => { + const scenario = scriptScenario([{ awaitUserMessage: true }, { emit: RESULT_FRAME }]) + const bareSpawns: SpawnSeen[] = [] + await drainQuery({ + pathToClaudeCodeExecutable: FAKE_CLI, + cwd: scenario.cwd, + env: scenarioEnv(scenario), + spawnClaudeCodeProcess: recordingSpawner(bareSpawns) + }) + expect(bareSpawns[0]!.args).not.toContain('--replay-user-messages') + + const replayScenario = scriptScenario([{ awaitUserMessage: true }, { emit: RESULT_FRAME }]) + const replaySpawns: SpawnSeen[] = [] + await drainQuery({ + pathToClaudeCodeExecutable: FAKE_CLI, + cwd: replayScenario.cwd, + env: scenarioEnv(replayScenario), + extraArgs: { 'replay-user-messages': null }, + spawnClaudeCodeProcess: recordingSpawner(replaySpawns) + }) + const replayArgs = replaySpawns[0]!.args + expect(replayArgs.filter((arg) => arg === '--replay-user-messages')).toHaveLength(1) + }) + + it('produces a matching CLI flag for every pre-SDK argv entry', async () => { + const scenario = scriptScenario([{ awaitUserMessage: true }, { emit: RESULT_FRAME }]) + const spawns: SpawnSeen[] = [] + // Driven by the real resolver, so the argv walk covers the durable-launchArgs + // translation and its merge order, not a hand-written options literal. + const launch = await resolvedLaunch(['--model', 'claude-sonnet-4-5', '--effort', 'high']) + await drainQuery({ + ...launch.options, + pathToClaudeCodeExecutable: FAKE_CLI, + cwd: scenario.cwd, + env: scenarioEnv(scenario), + canUseTool: (async () => ({ behavior: 'deny', message: 'unused' })) as CanUseTool, + spawnClaudeCodeProcess: recordingSpawner(spawns) + }) + + expect(spawns).toHaveLength(1) + const argv = normalizeArgv(spawns[0]!.args) + // Typed-first translation must not also spell the flag through extraArgs. + for (const flag of ['--model', '--effort']) { + expect( + argv.filter((arg) => arg === flag), + `${flag} occurrences` + ).toHaveLength(1) + } + expect(argv[argv.indexOf('--model') + 1]).toBe('claude-sonnet-4-5') + expect(argv[argv.indexOf('--effort') + 1]).toBe('high') + // Headless print mode is the SDK's only mode; `query()` never passes `-p`, + // and if the SDK ever started passing it this pin would notice. + const impliedByHeadlessQuery = new Set(['-p']) + for (const entry of flagTable(PRE_SDK_ARGV)) { + if (impliedByHeadlessQuery.has(entry.flag)) { + expect(argv, `${entry.flag} is implied, never spelled`).not.toContain(entry.flag) + continue + } + const at = argv.indexOf(entry.flag) + expect(at, `SDK argv is missing ${entry.flag}`).toBeGreaterThanOrEqual(0) + if (entry.value !== null) { + expect(argv[at + 1], `value of ${entry.flag}`).toBe(entry.value) + } + } + // The launch resolver always carries one of --session-id / --resume. + const sessionAt = argv.indexOf('--session-id') + expect(sessionAt).toBeGreaterThanOrEqual(0) + expect(argv[sessionAt + 1]).toBe(launch.providerSessionId) + }) + + it('still exposes the runtime get_settings reader the auth diagnostic depends on', async () => { + // 0.3.251 ships getSettings() but redacts it from the Query declaration. This pin + // is the drift alarm: if a bump drops or reshapes it, the diagnostic degrades and + // this test says so instead of the degradation shipping silently. + const settings = { env: { ANTHROPIC_BASE_URL: 'https://settings.example.test' } } + const scenario = scriptScenario([{ delayMs: 3_000 }], { get_settings: settings }) + const session = query({ + prompt: singleUserTurn(), + options: { + pathToClaudeCodeExecutable: FAKE_CLI, + cwd: scenario.cwd, + env: scenarioEnv(scenario) + } + }) + try { + const read = claudeQuerySettingsReader(session) + expect(read, 'the SDK no longer exposes get_settings at runtime').not.toBeNull() + await expect(read?.()).resolves.toEqual(settings) + } finally { + await session.return(undefined) + } + }) + + it('maps resume identity to --resume and --resume-session-at', async () => { + const scenario = scriptScenario([{ awaitUserMessage: true }, { emit: RESULT_FRAME }]) + const spawns: SpawnSeen[] = [] + await drainQuery({ + pathToClaudeCodeExecutable: FAKE_CLI, + cwd: scenario.cwd, + env: scenarioEnv(scenario), + resume: SESSION_ID, + resumeSessionAt: LEAF_UUID, + spawnClaudeCodeProcess: recordingSpawner(spawns) + }) + + const argv = normalizeArgv(spawns[0]!.args) + const resumeAt = argv.indexOf('--resume') + expect(resumeAt).toBeGreaterThanOrEqual(0) + expect(argv[resumeAt + 1]).toBe(SESSION_ID) + const leafAt = argv.indexOf('--resume-session-at') + expect(leafAt).toBeGreaterThanOrEqual(0) + expect(argv[leafAt + 1]).toBe(LEAF_UUID) + }) + + it('gives canUseTool the wire request_id and fires its abort signal on control_cancel_request', async () => { + const scenario = scriptScenario([ + { awaitUserMessage: true }, + { + emit: { + type: 'control_request', + request_id: 'perm-421', + request: { + subtype: 'can_use_tool', + tool_name: 'Bash', + input: { command: 'echo hi' }, + tool_use_id: 'tool-use-9' + } + } + }, + { delayMs: 120 }, + { emit: { type: 'control_cancel_request', request_id: 'perm-421' } }, + { awaitControlResponse: 'perm-421' }, + { emit: RESULT_FRAME } + ]) + const seen: { toolName: string; requestId: string; toolUseID: string }[] = [] + let abortFired = false + const canUseTool: CanUseTool = (toolName, _input, { signal, requestId, toolUseID }) => { + seen.push({ toolName, requestId, toolUseID }) + return new Promise((resolve) => { + signal.addEventListener('abort', () => { + abortFired = true + resolve({ behavior: 'deny', message: 'cancelled by test' }) + }) + }) + } + const spawns: SpawnSeen[] = [] + await drainQuery({ + pathToClaudeCodeExecutable: FAKE_CLI, + cwd: scenario.cwd, + env: scenarioEnv(scenario), + canUseTool, + spawnClaudeCodeProcess: recordingSpawner(spawns) + }) + + expect(seen).toEqual([{ toolName: 'Bash', requestId: 'perm-421', toolUseID: 'tool-use-9' }]) + expect(abortFired).toBe(true) + // The callback's settlement is written back onto the wire against the same id. + const settled = scenario + .readReport() + .controlResponses.find((frame) => frame.response.request_id === 'perm-421') + expect(settled?.response.response?.behavior).toBe('deny') + // Exactly one process spawn per query, control traffic included. + expect(spawns).toHaveLength(1) + }) + + it('runs the executable given via pathToClaudeCodeExecutable under the default spawner', async () => { + const scenario = scriptScenario([{ awaitUserMessage: true }, { emit: RESULT_FRAME }]) + const messages = await drainQuery({ + pathToClaudeCodeExecutable: FAKE_CLI, + cwd: scenario.cwd, + env: scenarioEnv(scenario) + }) + + expect(messages.some((m) => m.type === 'result')).toBe(true) + const report = scenario.readReport() + // The SDK executed exactly the script we pointed it at — no bundled binary. + expect(report.argv[0]).toBe(FAKE_CLI) + expect(report.execPath).toContain('node') + // And the streaming handshake went to it: the SDK sent its initialize + // control request to our script. + expect(report.controlRequests.some((frame) => frame.request.subtype === 'initialize')).toBe( + true + ) + }) + + it('pins the SDK version the contract was verified against', () => { + const sdkEntry = createRequire(__filename).resolve('@anthropic-ai/claude-agent-sdk') + const manifest = JSON.parse(readFileSync(join(dirname(sdkEntry), 'package.json'), 'utf8')) as { + version: string + } + expect(manifest.version).toBe(PINNED_SDK_VERSION) + }) + + it('keeps the eight bundled CLI platform binaries out of the install', () => { + const sdkEntry = createRequire(__filename).resolve('@anthropic-ai/claude-agent-sdk') + // The SDK's own scoped directory is where pnpm would link its optional + // platform packages; ignoredOptionalDependencies must keep them all absent. + const scopeDir = dirname(dirname(sdkEntry)) + for (const basename of SDK_PLATFORM_PACKAGE_BASENAMES) { + expect( + existsSync(join(scopeDir, basename, 'package.json')), + `${basename} must not be installed` + ).toBe(false) + } + }) +}) diff --git a/src/main/claude/claude-agent-sdk-control-requests.ts b/src/main/claude/claude-agent-sdk-control-requests.ts new file mode 100644 index 00000000000..6bd396413fa --- /dev/null +++ b/src/main/claude/claude-agent-sdk-control-requests.ts @@ -0,0 +1,154 @@ +import type { + PermissionMode, + Query, + SDKControlInterruptResponse +} from '@anthropic-ai/claude-agent-sdk' + +export class ClaudeControlRequestError extends Error { + constructor( + readonly subtype: string, + message: string + ) { + super(message) + this.name = 'ClaudeControlRequestError' + } +} + +export const CLAUDE_DEFAULT_REQUEST_TIMEOUT_MS = 30_000 + +/** The SDK closes a query out from under an in-flight control request with this exact message. */ +const QUERY_CLOSED_MESSAGE = 'Query closed before response received' + +/** 0.3.251 ships getSettings() but redacts it from the Query declaration; the typeof guard below is its degradation path. */ +type ClaudeQuerySettingsReader = { getSettings?: () => Promise } + +export function claudeQuerySettingsReader(query: Query): (() => Promise) | null { + const reader = (query as unknown as ClaudeQuerySettingsReader).getSettings + return typeof reader === 'function' ? reader.bind(query) : null +} + +/** + * cancel_async_message is a runtime Query method the shipped 0.3.251 declaration omits; + * it withdraws a single still-queued async user message by uuid so an interrupted turn + * cannot spawn a later unexpected turn. The typeof guard is its degradation path. + */ +type ClaudeQueryAsyncCanceller = { cancelAsyncMessage?: (uuid: string) => Promise } + +export function claudeQueryAsyncCanceller( + query: Query +): ((uuid: string) => Promise) | null { + const cancel = (query as unknown as ClaudeQueryAsyncCanceller).cancelAsyncMessage + return typeof cancel === 'function' ? cancel.bind(query) : null +} + +export type ClaudeControlOptions = { timeoutMs?: number } + +/** + * Run one native Query control method under Orca's deadline and error classification. + * + * The SDK owns correlation but applies no deadline, so the timeout stays here — and its + * message is load-bearing: the init proof matches on `claude initialize request timed out`. + * A closed query is a transport failure, not the CLI rejecting the request, so only the + * latter is re-thrown as a `ClaudeControlRequestError` a caller may surface as a rejection. + */ +export function runClaudeControl( + subtype: string, + run: () => Promise, + timeoutMs: number = CLAUDE_DEFAULT_REQUEST_TIMEOUT_MS +): Promise { + let timer: ReturnType | null = null + const deadline = new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error(`claude ${subtype} request timed out`)), timeoutMs) + timer.unref?.() + }) + return Promise.race([ + Promise.resolve() + .then(run) + .catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error) + if (error instanceof ClaudeControlRequestError || message === QUERY_CLOSED_MESSAGE) { + throw error + } + throw new ClaudeControlRequestError(subtype, message) + }), + deadline + ]).finally(() => { + if (timer) { + clearTimeout(timer) + } + }) +} + +/** The native control surface Orca drives, one method per Query control request. */ +export type ClaudeControlSurface = { + interrupt: ( + options?: ClaudeControlOptions & { cancelQueued?: boolean } + ) => Promise + cancelAsyncMessage: (uuid: string, options?: ClaudeControlOptions) => Promise + setModel: (model: string | undefined, options?: ClaudeControlOptions) => Promise + setPermissionMode: (mode: PermissionMode, options?: ClaudeControlOptions) => Promise + applyFlagSettings: ( + settings: Parameters[0], + options?: ClaudeControlOptions + ) => Promise + supportedModels: (options?: ClaudeControlOptions) => Promise + initializationResult: (options?: ClaudeControlOptions) => Promise + getSettings: (options?: ClaudeControlOptions) => Promise +} + +type InterruptingQuery = { + interrupt: (options?: { + cancelQueued?: boolean + }) => Promise +} + +export function createClaudeControlSurface(query: Query): ClaudeControlSurface { + return { + interrupt: (options) => + runClaudeControl( + 'interrupt', + () => + (query as unknown as InterruptingQuery).interrupt( + options?.cancelQueued ? { cancelQueued: true } : undefined + ), + options?.timeoutMs + ), + cancelAsyncMessage: (uuid, options) => { + const cancel = claudeQueryAsyncCanceller(query) + return cancel + ? runClaudeControl('cancel_async_message', () => cancel(uuid), options?.timeoutMs).then( + () => {} + ) + : Promise.resolve() + }, + setModel: (model, options) => + runClaudeControl('set_model', () => query.setModel(model), options?.timeoutMs).then(() => {}), + setPermissionMode: (mode, options) => + runClaudeControl( + 'set_permission_mode', + () => query.setPermissionMode(mode), + options?.timeoutMs + ).then(() => {}), + applyFlagSettings: (settings, options) => + runClaudeControl( + 'apply_flag_settings', + () => query.applyFlagSettings(settings), + options?.timeoutMs + ).then(() => {}), + supportedModels: (options) => + runClaudeControl('list_models', () => query.supportedModels(), options?.timeoutMs), + initializationResult: (options) => + runClaudeControl('initialize', () => query.initializationResult(), options?.timeoutMs), + getSettings: (options) => { + const read = claudeQuerySettingsReader(query) + return read + ? runClaudeControl('get_settings', read, options?.timeoutMs) + : Promise.reject( + new ClaudeControlRequestError( + 'get_settings', + 'this SDK exposes no get_settings request' + ) + ) + } + } +} diff --git a/src/main/claude/claude-agent-sdk-exit-proof-identity.test.ts b/src/main/claude/claude-agent-sdk-exit-proof-identity.test.ts new file mode 100644 index 00000000000..04d58067353 --- /dev/null +++ b/src/main/claude/claude-agent-sdk-exit-proof-identity.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it, vi } from 'vitest' +import type { DescendantSnapshot } from '../pty-descendant-termination' +import type { WindowsDescendantSnapshot } from '../windows-descendant-exit-verification' +import { collectDescendantRows } from '../pty-descendant-termination' +import { createClaudeChildTreeReaper } from './claude-agent-sdk-exit-proof' +import { mergeClaudeCapturedTrees } from './claude-child-tree-snapshot' + +function posixSnapshot(capturedAtMs: number): DescendantSnapshot { + return { + root: { pid: 100, startedAt: 'Mon Jan 1 00:00:00 2026' }, + rootPgid: 100, + descendants: [{ pid: 200, ppid: 100, pgid: 100, startedAt: 'Mon Jan 1 00:00:01 2026' }], + capturedAtMs + } +} + +function windowsSnapshot(): WindowsDescendantSnapshot { + return { + root: { pid: 100, creationTimeMs: 5 }, + descendants: [{ pid: 200, creationTimeMs: 7 }], + unidentifiedCount: 0, + capturedAtMs: 1 + } +} + +describe('Claude child root identity', () => { + it('keeps a retained row boundary when a refresh observes no new descendants', () => { + const previous = posixSnapshot(1_700_000_000_900) + const next = posixSnapshot(1_700_000_002_100) + + expect( + mergeClaudeCapturedTrees( + { platform: 'posix', tree: previous }, + { platform: 'posix', tree: next } + ) + ).toEqual({ + platform: 'posix', + tree: { ...next, capturedAtMsByPid: { '200': previous.capturedAtMs } } + }) + }) + + it('keeps the descendant verdict when a POSIX root probe is unavailable', async () => { + const child = { pid: 100, kill: vi.fn(() => true) } + const terminateDescendants = vi.fn(async () => 'exited' as const) + const tree = createClaudeChildTreeReaper(child, { + platform: 'linux', + captureDescendants: vi.fn(async () => posixSnapshot(1)), + terminateDescendants, + verifyRootIdentity: vi.fn(async () => false) + }) + + // POSIX runs no bare-pid root operation, so a declined probe withholds + // nothing: the handle kill still lands and the verification still speaks. + await expect(tree.reap()).resolves.toBe('exited') + expect(terminateDescendants).toHaveBeenCalled() + expect(child.kill).toHaveBeenCalledWith('SIGKILL') + }) + + it('rejects mixed old and recycled root rows instead of making the tree killable', async () => { + const child = { pid: 100, kill: vi.fn(() => true) } + const terminateDescendants = vi.fn(async () => 'exited' as const) + const tree = createClaudeChildTreeReaper(child, { + platform: 'linux', + captureDescendants: vi.fn(async () => + collectDescendantRows( + 100, + [ + { pid: 100, ppid: 1, pgid: 100, startedAt: 'Mon Jan 1 00:00:00 2026' }, + { pid: 100, ppid: 1, pgid: 101, startedAt: 'Mon Jan 1 00:00:01 2026' }, + { pid: 200, ppid: 100, pgid: 200, startedAt: 'Mon Jan 1 00:00:00 2026' } + ], + 1 + ) + ), + terminateDescendants, + verifyRootIdentity: vi.fn(async () => true) + }) + + await expect(tree.reap()).resolves.toBe('unverifiable') + // No admissible snapshot means no row may be signalled from its number, but + // the root still leaves through the handle Node owns. + expect(terminateDescendants).not.toHaveBeenCalled() + expect(child.kill).toHaveBeenCalledWith('SIGKILL') + }) + + it('fails closed when Windows root identity revalidation is unavailable', async () => { + const child = { pid: 100, kill: vi.fn(() => true) } + const terminateWindowsTree = vi.fn(async () => {}) + const terminateWindowsDescendants = vi.fn(async () => 'exited' as const) + const tree = createClaudeChildTreeReaper(child, { + platform: 'win32', + captureWindowsDescendants: vi.fn(async () => windowsSnapshot()), + terminateWindowsTree, + terminateWindowsDescendants, + verifyRootIdentity: vi.fn(async () => false) + }) + + await expect(tree.reap()).resolves.toBe('unverifiable') + // taskkill /T /F addresses a bare pid and stays gated; the handle does not. + expect(terminateWindowsTree).not.toHaveBeenCalled() + expect(terminateWindowsDescendants).not.toHaveBeenCalled() + expect(child.kill).toHaveBeenCalledWith('SIGKILL') + }) +}) diff --git a/src/main/claude/claude-agent-sdk-exit-proof.test.ts b/src/main/claude/claude-agent-sdk-exit-proof.test.ts new file mode 100644 index 00000000000..3f15f8e8fca --- /dev/null +++ b/src/main/claude/claude-agent-sdk-exit-proof.test.ts @@ -0,0 +1,934 @@ +import { execFileSync } from 'node:child_process' +import { EventEmitter } from 'node:events' +import { PassThrough } from 'node:stream' +import { describe, expect, it, vi } from 'vitest' +import { spawnProcess, type SpawnedProcess } from '../../shared/child-process/run-process' +import type { DescendantTreeVerdict } from '../pty-descendant-exit-verification' +import type { DescendantSnapshot } from '../pty-descendant-termination' +import type { WindowsDescendantSnapshot } from '../windows-descendant-exit-verification' +import { + createClaudeChildTreeReaper as createClaudeChildTreeReaperImpl, + proveClaudeChildExit, + type ClaudeChildTreeReaper +} from './claude-agent-sdk-exit-proof' + +// The descendant models an MCP server: it either cooperates or, when it traps +// SIGTERM, only a forced, verified sweep can reach it. The root either traps +// SIGTERM too, or leaves promptly on stdin end the way a healthy CLI does — +// which is the path that used to skip descendant proof entirely. +function childWithDescendantScript(input: { + rootTrapsSigterm: boolean + descendantTrapsSigterm: boolean +}): string { + const descendantScript = `${input.descendantTrapsSigterm ? 'process.on("SIGTERM", () => {}); ' : ''}setInterval(() => {}, 1000000)` + const rootBehaviour = input.rootTrapsSigterm + ? `process.on('SIGTERM', () => {}) +process.on('SIGINT', () => {}) +setInterval(() => {}, 1000000)` + : `process.stdin.on('end', () => process.exit(0)) +process.stdin.resume()` + return ` +const descendant = require('node:child_process').spawn( + process.execPath, + ['-e', ${JSON.stringify(descendantScript)}], + { stdio: 'ignore' } +) +descendant.unref() +process.stdout.write(JSON.stringify({ descendantPid: descendant.pid }) + '\\n') +${rootBehaviour} +` +} + +const COOPERATIVE_CHILD = ` +process.stdin.on('end', () => process.exit(0)) +process.stdin.resume() +process.stdout.write('ready\\n') +` + +/** + * Sampled synchronously so it reads the exact moment the close boundary is + * crossed. A zombie has exited (its parent just has not reaped it yet), so a + * kill(pid, 0) probe would misreport it as running. + */ +function descendantState(pid: number): 'running' | 'exited' { + let state: string + try { + state = execFileSync('ps', ['-o', 'state=', '-p', String(pid)], { + encoding: 'utf8', + env: { ...process.env, LANG: 'C', LC_ALL: 'C' } + }).trim() + } catch (error) { + // ps exits 1 when no process matches; anything else is a failed probe, not an answer. + if ((error as { status?: number }).status !== 1) { + throw error + } + return 'exited' + } + return state.startsWith('Z') ? 'exited' : 'running' +} + +/** + * ps lstart is second-resolution, so the identity-safe sweep only SIGKILLs a row + * born strictly before the second the snapshot was captured in. The snapshot is + * armed the moment close begins, so a descendant born in that same second can + * only be asked, never forced — the same bound an MCP server spawned within a + * second of the user closing the chat would hit. + */ +function ageDescendantPastTheCaptureSecond(): Promise { + return new Promise((resolve) => setTimeout(resolve, 1_000 - (Date.now() % 1_000) + 20)) +} + +/** + * The close ladder as production drives it: `closeProcessRegistry` retries an + * unproven close, and each retry re-verifies the retained snapshot. A loaded + * host can spend one attempt's whole window inside `ps`, and reporting false + * there is the honest verdict — the requirement is that TRUE never outruns the + * observation, which the caller asserts at whichever boundary returns it. + */ +async function proveExitWithRetries( + input: Parameters[0], + attempts = 3 +): Promise { + for (let attempt = 1; attempt < attempts; attempt += 1) { + if (await proveClaudeChildExit(input)) { + return true + } + } + return proveClaudeChildExit(input) +} + +function spawnScript(script: string): ReturnType { + return spawnProcess({ + program: process.execPath, + args: ['-e', script], + stdio: ['pipe', 'pipe', 'pipe'] + }) +} + +function firstStdoutLine(child: ReturnType): Promise { + return new Promise((resolve) => { + child.stdout.setEncoding('utf8').once('data', (chunk: string) => resolve(chunk.trim())) + }) +} + +function observeExit(child: EventEmitter): { exitPromise: Promise; exited: () => boolean } { + let exited = false + const exitPromise = new Promise((resolve) => { + child.once('exit', () => { + exited = true + resolve() + }) + }) + return { exitPromise, exited: () => exited } +} + +/** `null` models a spawn that failed before a pid existed. */ +function mockChild( + pid: number | null = 424242 +): EventEmitter & + Pick & { kill: ReturnType } { + const child = new EventEmitter() + return Object.assign(child, { + pid: pid ?? undefined, + stdin: new PassThrough(), + kill: vi.fn(() => true) + }) as never +} + +/** A tree whose verdict is scripted per reap, recording when it was armed. */ +function mockTree(verdicts: DescendantTreeVerdict[]): ClaudeChildTreeReaper & { + capture: ReturnType + reap: ReturnType +} { + let treeVerdict: DescendantTreeVerdict = 'unverifiable' + return { + capture: vi.fn(async () => {}), + reap: vi.fn(async () => { + treeVerdict = verdicts.shift() ?? treeVerdict + return treeVerdict + }), + get treeVerdict() { + return treeVerdict + } + } +} + +function windowsSnapshotOf(descendantPid: number): WindowsDescendantSnapshot { + return { + root: { pid: 424242, creationTimeMs: 1_700_000_000_001 }, + descendants: [{ pid: descendantPid, creationTimeMs: 1_700_000_000_000 }], + unidentifiedCount: 0, + capturedAtMs: 1 + } +} + +function snapshotOf(descendantPid: number): DescendantSnapshot { + return { + root: { pid: 424242, startedAt: 'Mon Jan 1 00:00:00 2026' }, + rootPgid: 1, + descendants: [ + { pid: descendantPid, ppid: 424242, pgid: 1, startedAt: 'Mon Jan 1 00:00:00 2026' } + ], + capturedAtMs: 1 + } +} + +// Unit tests use synthetic process ids; production always supplies the fresh +// identity probe, so the harness explicitly models a matching probe. +function createClaudeChildTreeReaper( + child: Parameters[0], + deps: Parameters[1] = {} +): ReturnType { + return createClaudeChildTreeReaperImpl(child, { + verifyRootIdentity: async () => true, + ...deps + }) +} + +describe('claude child exit proof', () => { + it.runIf(process.platform !== 'win32')( + 'reports a proven exit only once a SIGTERM-resistant descendant is gone at the close boundary', + async () => { + const child = spawnScript( + childWithDescendantScript({ rootTrapsSigterm: true, descendantTrapsSigterm: true }) + ) + const { descendantPid } = JSON.parse(await firstStdoutLine(child)) as { + descendantPid: number + } + expect(descendantState(descendantPid)).toBe('running') + await ageDescendantPastTheCaptureSecond() + + try { + const proven = await proveExitWithRetries({ child, ...observeExit(child) }) + // Evaluated AT the boundary, not by polling until a deferred sweep timer + // wins: true releases the lease, so a descendant still running here is + // exactly the orphan the proof exists to prevent. False would be the + // honest verdict for a tree that outlived the bounded ladder. + expect({ proven, descendant: descendantState(descendantPid) }).toEqual({ + proven: true, + descendant: 'exited' + }) + } finally { + // Failure-safe only: the assertion above owns the requirement, this just + // stops a failing run from leaking a process. + try { + process.kill(descendantPid, 'SIGKILL') + } catch { + // Already gone. + } + } + }, + 20_000 + ) + + it.runIf(process.platform !== 'win32')( + 'proves a promptly exiting root only once its stubborn descendant is gone too', + async () => { + // The ordinary healthy close: the root leaves on stdin end within the graceful + // window. Its descendant must still be proven gone, not assumed gone with it. + const child = spawnScript( + childWithDescendantScript({ rootTrapsSigterm: false, descendantTrapsSigterm: true }) + ) + const { descendantPid } = JSON.parse(await firstStdoutLine(child)) as { + descendantPid: number + } + expect(descendantState(descendantPid)).toBe('running') + await ageDescendantPastTheCaptureSecond() + + try { + const proven = await proveExitWithRetries({ child, ...observeExit(child) }) + expect({ proven, descendant: descendantState(descendantPid) }).toEqual({ + proven: true, + descendant: 'exited' + }) + } finally { + try { + process.kill(descendantPid, 'SIGKILL') + } catch { + // Already gone. + } + } + }, + 20_000 + ) + + it.runIf(process.platform !== 'win32')( + 'still proves a stubborn child whose descendant honours SIGTERM', + async () => { + const child = spawnScript( + childWithDescendantScript({ rootTrapsSigterm: true, descendantTrapsSigterm: false }) + ) + const { descendantPid } = JSON.parse(await firstStdoutLine(child)) as { + descendantPid: number + } + try { + const proven = await proveExitWithRetries({ child, ...observeExit(child) }) + expect({ proven, descendant: descendantState(descendantPid) }).toEqual({ + proven: true, + descendant: 'exited' + }) + } finally { + try { + process.kill(descendantPid, 'SIGKILL') + } catch { + // Already gone. + } + } + }, + 20_000 + ) + + it('arms the snapshot before stdin closes and verifies it after a clean exit', async () => { + const child = spawnScript(COOPERATIVE_CHILD) + expect(await firstStdoutLine(child)).toBe('ready') + const exit = observeExit(child) + const tree = mockTree(['exited']) + let exitedWhenArmed: boolean | null = null + tree.capture.mockImplementation(async () => { + exitedWhenArmed = exit.exited() + }) + + await expect(proveClaudeChildExit({ child, ...exit, tree })).resolves.toBe(true) + // The snapshot is the only proof that survives the root: taken while it lived, + // verified once it left. A reap before the exit would have been the forced ladder. + expect(exitedWhenArmed).toBe(false) + expect(tree.reap).toHaveBeenCalledTimes(1) + expect(exit.exited()).toBe(true) + }, 20_000) + + it('proves a clean close of a childless root with one snapshot and no signal', async () => { + const child = spawnScript(COOPERATIVE_CHILD) + expect(await firstStdoutLine(child)).toBe('ready') + + await expect(proveClaudeChildExit({ child, ...observeExit(child) })).resolves.toBe(true) + }, 20_000) + + it('reports an unprovable exit as false rather than assuming the child died', async () => { + const child = mockChild() + const tree = mockTree(['exited']) + + await expect( + proveClaudeChildExit({ + child, + exitPromise: new Promise(() => {}), + exited: () => false, + tree + }) + ).resolves.toBe(false) + expect(tree.reap).toHaveBeenCalledTimes(1) + }, 20_000) + + it('reports false when the root exit was observed but a descendant was seen alive', async () => { + const child = mockChild() + const exit = observeExit(child) + const tree = mockTree(['live']) + tree.reap.mockImplementation(async () => { + child.emit('exit', null, 'SIGKILL') + return 'live' + }) + + await expect(proveClaudeChildExit({ child, ...exit, tree })).resolves.toBe(false) + expect(exit.exited()).toBe(true) + // One verification per attempt: the retried close re-verifies, this one does not. + expect(tree.reap).toHaveBeenCalledTimes(1) + }, 20_000) + + it('re-verifies an unproven tree on a retried close instead of trusting the dead root', async () => { + const child = mockChild() + const tree = mockTree(['exited']) + + await expect( + proveClaudeChildExit({ child, exitPromise: Promise.resolve(), exited: () => true, tree }) + ).resolves.toBe(true) + expect(tree.reap).toHaveBeenCalledTimes(1) + }) + + it('stays unproven for a root that left before any snapshot could be armed', async () => { + const child = mockChild() + const captureDescendants = vi.fn(async () => snapshotOf(4243)) + const terminateDescendants = vi.fn() + const tree = createClaudeChildTreeReaper(child, { + platform: 'darwin', + exited: () => true, + captureDescendants, + terminateDescendants + }) + + await expect( + proveClaudeChildExit({ child, exitPromise: Promise.resolve(), exited: () => true, tree }) + ).resolves.toBe(false) + // A dead root's descendants have reparented: walking its pid now could only + // sweep a stranger, so no walk is attempted and nothing is proven. + expect(captureDescendants).not.toHaveBeenCalled() + expect(terminateDescendants).not.toHaveBeenCalled() + expect(tree.treeVerdict).toBe('unverifiable') + }) +}) + +describe('claude child tree reaper', () => { + it('kills the root while verification runs and never stops it first', async () => { + const child = mockChild() + const release = Promise.withResolvers() + const terminateDescendants = vi.fn(() => release.promise) + const captureDescendants = vi.fn(async () => snapshotOf(4243)) + const tree = createClaudeChildTreeReaper(child, { + platform: 'darwin', + captureDescendants, + terminateDescendants + }) + + const first = tree.reap() + const second = tree.reap() + await vi.waitFor(() => expect(terminateDescendants).toHaveBeenCalledTimes(1)) + // A stopped root cannot verify: its killed children stay zombie rows in ps. + expect(child.kill.mock.calls).toEqual([['SIGKILL']]) + expect(tree.treeVerdict).toBe('unverifiable') + + release.resolve('exited') + await expect(Promise.all([first, second])).resolves.toEqual(['exited', 'exited']) + expect(captureDescendants).toHaveBeenCalledTimes(1) + expect(tree.treeVerdict).toBe('exited') + }) + + it('re-verifies the retained snapshot on a later reap rather than re-walking a dead root', async () => { + const child = mockChild() + const captureDescendants = vi.fn(async () => snapshotOf(4243)) + const terminateDescendants = vi + .fn() + .mockResolvedValueOnce('live') + .mockResolvedValueOnce('exited') + const tree = createClaudeChildTreeReaper(child, { + platform: 'linux', + captureDescendants, + terminateDescendants + }) + + await expect(tree.reap()).resolves.toBe('live') + expect(tree.treeVerdict).toBe('live') + await expect(tree.reap()).resolves.toBe('exited') + expect(captureDescendants).toHaveBeenCalledTimes(1) + expect(terminateDescendants).toHaveBeenNthCalledWith(2, snapshotOf(4243)) + expect(tree.treeVerdict).toBe('exited') + }) + + it('keeps an observed exit when a later re-read cannot see the table', async () => { + const child = mockChild() + const terminateDescendants = vi + .fn() + .mockResolvedValueOnce('exited') + .mockResolvedValueOnce('unverifiable') + const tree = createClaudeChildTreeReaper(child, { + platform: 'linux', + captureDescendants: vi.fn(async () => snapshotOf(4243)), + terminateDescendants + }) + + await expect(tree.reap()).resolves.toBe('exited') + await expect(tree.reap()).resolves.toBe('unverifiable') + expect(tree.treeVerdict).toBe('exited') + }) + + it('keeps an observed live descendant when a later re-read cannot see the table', async () => { + const child = mockChild() + // Reap #1 completed and saw a descendant alive at its deadline; the root then + // left on its own and the re-verification on a loaded host could not read the + // table. "Could not look" must not erase "was seen alive": the lease release + // gate is exactly the pair this distinguishes. + const terminateDescendants = vi + .fn() + .mockResolvedValueOnce('live') + .mockResolvedValueOnce('unverifiable') + const tree = createClaudeChildTreeReaper(child, { + platform: 'linux', + captureDescendants: vi.fn(async () => snapshotOf(4243)), + terminateDescendants + }) + + await expect(tree.reap()).resolves.toBe('live') + await expect(tree.reap()).resolves.toBe('unverifiable') + expect(tree.treeVerdict).toBe('live') + }) + + it('treats an unreadable process table as unproven and re-walks the live root', async () => { + const child = mockChild() + // A loaded host can miss the table's deadline; while the root still lives + // that is a retryable read, not evidence that it has no descendants. + const captureDescendants = vi + .fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(snapshotOf(4243)) + const terminateDescendants = vi.fn(async () => 'exited' as const) + const tree = createClaudeChildTreeReaper(child, { + platform: 'linux', + captureDescendants, + terminateDescendants + }) + + await expect(tree.reap()).resolves.toBe('unverifiable') + expect(terminateDescendants).not.toHaveBeenCalled() + await expect(tree.reap()).resolves.toBe('exited') + expect(captureDescendants).toHaveBeenCalledTimes(2) + }) + + it('does not latch a missing root while it is still live', async () => { + const child = mockChild() + const captureDescendants = vi + .fn() + .mockResolvedValueOnce({ rootPgid: null, descendants: [], capturedAtMs: 1 }) + .mockResolvedValueOnce(snapshotOf(4243)) + const terminateDescendants = vi.fn(async () => 'exited' as const) + const tree = createClaudeChildTreeReaper(child, { + platform: 'linux', + captureDescendants, + terminateDescendants + }) + + await tree.capture() + await expect(tree.reap()).resolves.toBe('exited') + expect(captureDescendants).toHaveBeenCalledTimes(2) + expect(terminateDescendants).toHaveBeenCalledWith(snapshotOf(4243)) + }) + + it('refreshes the live snapshot at close time so late descendants are included', async () => { + const child = mockChild() + const first = snapshotOf(4243) + const second = { + ...first, + descendants: [...first.descendants, { ...first.descendants[0], pid: 4244 }] + } + const captureDescendants = vi.fn().mockResolvedValueOnce(first).mockResolvedValueOnce(second) + const terminateDescendants = vi.fn(async () => 'exited' as const) + const tree = createClaudeChildTreeReaper(child, { + platform: 'linux', + captureDescendants, + terminateDescendants + }) + + await tree.capture() + await tree.refresh?.() + await tree.reap() + + expect(captureDescendants).toHaveBeenCalledTimes(2) + expect(terminateDescendants).toHaveBeenCalledWith(second) + expect(child.kill).toHaveBeenCalledWith('SIGKILL') + }) + + it('keeps the original capture boundary for retained POSIX rows', async () => { + const child = mockChild() + const first = { + ...snapshotOf(4243), + capturedAtMs: 1_700_000_000_900 + } + const refreshed = { + ...first, + capturedAtMs: 1_700_000_002_100, + descendants: [ + ...first.descendants, + { + pid: 4244, + ppid: 424242, + pgid: 1, + startedAt: 'Tue Jan 2 00:00:00 2026' + } + ] + } + const captureDescendants = vi.fn().mockResolvedValueOnce(first).mockResolvedValueOnce(refreshed) + const terminateDescendants = vi.fn(async () => 'exited' as const) + const tree = createClaudeChildTreeReaper(child, { + platform: 'linux', + captureDescendants, + terminateDescendants + }) + + await tree.capture() + await tree.refresh?.() + await tree.reap() + + expect(terminateDescendants).toHaveBeenCalledWith({ + ...refreshed, + // The retained 4243 row was first observed in the earlier displayed + // second. Its per-row boundary must not advance with the refresh. + capturedAtMsByPid: { + '4243': first.capturedAtMs, + '4244': refreshed.capturedAtMs + } + }) + }) + + it('fails closed when a POSIX refresh reuses a PID with a new identity', async () => { + const child = mockChild() + const first = snapshotOf(4243) + const replacement = { + ...first, + descendants: [ + { + ...first.descendants[0], + pgid: 9, + startedAt: 'Tue Jan 2 00:00:00 2026' + } + ] + } + const captureDescendants = vi + .fn() + .mockResolvedValueOnce(first) + .mockResolvedValueOnce(replacement) + const terminateDescendants = vi.fn(async () => 'exited' as const) + const tree = createClaudeChildTreeReaper(child, { + platform: 'linux', + captureDescendants, + terminateDescendants + }) + + await tree.capture() + await tree.refresh?.() + + await expect(tree.reap()).resolves.toBe('unverifiable') + // The descendant evidence is discarded; the root's identity never was in doubt. + expect(terminateDescendants).not.toHaveBeenCalled() + expect(child.kill).toHaveBeenCalledWith('SIGKILL') + }) + + it('fails closed when a Windows refresh reuses a PID with a new creation time', async () => { + const child = mockChild() + const first = windowsSnapshotOf(4243) + const replacement = { + ...first, + descendants: [{ pid: 4243, creationTimeMs: first.descendants[0].creationTimeMs + 1 }] + } + const captureWindowsDescendants = vi + .fn() + .mockResolvedValueOnce(first) + .mockResolvedValueOnce(replacement) + const terminateWindowsTree = vi.fn(async () => {}) + const terminateWindowsDescendants = vi.fn(async () => 'exited' as const) + const tree = createClaudeChildTreeReaper(child, { + platform: 'win32', + captureWindowsDescendants, + terminateWindowsTree, + terminateWindowsDescendants + }) + + await tree.capture() + await tree.refresh?.() + + await expect(tree.reap()).resolves.toBe('unverifiable') + expect(terminateWindowsTree).not.toHaveBeenCalled() + expect(terminateWindowsDescendants).not.toHaveBeenCalled() + expect(child.kill).toHaveBeenCalledWith('SIGKILL') + }) + + it('queues a fresh boundary behind an output-triggered capture already in flight', async () => { + const child = mockChild() + const firstDone = Promise.withResolvers() + const first = snapshotOf(4243) + const second = { + ...first, + descendants: [...first.descendants, { ...first.descendants[0], pid: 4244 }] + } + const captureDescendants = vi + .fn() + .mockImplementationOnce(async () => { + await firstDone.promise + return first + }) + .mockResolvedValueOnce(second) + const terminateDescendants = vi.fn(async () => 'exited' as const) + const tree = createClaudeChildTreeReaper(child, { + platform: 'linux', + captureDescendants, + terminateDescendants + }) + + const outputCapture = tree.refresh!() + await vi.waitFor(() => expect(captureDescendants).toHaveBeenCalledTimes(1)) + const closeCapture = tree.refresh!() + await Promise.resolve() + expect(captureDescendants).toHaveBeenCalledTimes(1) + + firstDone.resolve() + await closeCapture + await tree.reap() + + expect(captureDescendants).toHaveBeenCalledTimes(2) + expect(terminateDescendants).toHaveBeenCalledWith(second) + await outputCapture + }) + + it('retains a replacement descendant when the prior identity exited', async () => { + const child = mockChild() + const first = snapshotOf(4243) + const replacement = snapshotOf(4244) + const captureDescendants = vi + .fn() + .mockResolvedValueOnce(first) + .mockResolvedValueOnce(replacement) + const terminateDescendants = vi.fn(async (snapshot: DescendantSnapshot) => + snapshot.descendants.some((row) => row.pid === 4244) ? ('live' as const) : ('exited' as const) + ) + const tree = createClaudeChildTreeReaper(child, { + platform: 'linux', + captureDescendants, + terminateDescendants + }) + + await tree.capture() + await tree.refresh?.() + await expect(tree.reap()).resolves.toBe('live') + + expect(terminateDescendants).toHaveBeenCalledWith({ + ...replacement, + descendants: [...first.descendants, ...replacement.descendants] + }) + }) + + it('retains a Windows replacement descendant while preserving unidentified rows', async () => { + const child = mockChild() + const first = windowsSnapshotOf(4243) + const replacement = { + ...windowsSnapshotOf(4244), + unidentifiedCount: 0 + } + const captureWindowsDescendants = vi + .fn() + .mockResolvedValueOnce({ ...first, unidentifiedCount: 1 }) + .mockResolvedValueOnce(replacement) + const terminateWindowsTree = vi.fn(async () => {}) + const terminateWindowsDescendants = vi.fn(async (snapshot: WindowsDescendantSnapshot) => + snapshot.descendants.some((row) => row.pid === 4244) ? ('live' as const) : ('exited' as const) + ) + const tree = createClaudeChildTreeReaper(child, { + platform: 'win32', + captureWindowsDescendants, + terminateWindowsTree, + terminateWindowsDescendants + }) + + await tree.capture() + await tree.refresh?.() + await expect(tree.reap()).resolves.toBe('live') + + expect(terminateWindowsDescendants).toHaveBeenCalledWith({ + ...replacement, + descendants: [...first.descendants, ...replacement.descendants], + unidentifiedCount: 1 + }) + }) + + it('retains the prior identity-safe snapshot when a refresh is partial', async () => { + const child = mockChild() + const first = { + ...snapshotOf(4243), + descendants: [ + ...snapshotOf(4243).descendants, + { ...snapshotOf(4243).descendants[0], pid: 4244 } + ] + } + const captureDescendants = vi + .fn() + .mockResolvedValueOnce(first) + .mockResolvedValueOnce({ + ...first, + descendants: first.descendants.slice(0, 1) + }) + const terminateDescendants = vi.fn(async () => 'exited' as const) + const tree = createClaudeChildTreeReaper(child, { + platform: 'linux', + captureDescendants, + terminateDescendants + }) + + await tree.capture() + await tree.refresh?.() + await tree.reap() + + expect(terminateDescendants).toHaveBeenCalledWith(first) + }) + + it('stops re-walking once the root is gone, however the table behaved', async () => { + const child = mockChild() + let exited = false + const captureDescendants = vi.fn(async () => null) + const tree = createClaudeChildTreeReaper(child, { + platform: 'linux', + exited: () => exited, + captureDescendants, + terminateDescendants: vi.fn() + }) + + await expect(tree.reap()).resolves.toBe('unverifiable') + // An unreadable table costs the snapshot, never the kill on the live root. + expect(child.kill).toHaveBeenCalledTimes(1) + exited = true + await expect(tree.reap()).resolves.toBe('unverifiable') + expect(captureDescendants).toHaveBeenCalledTimes(1) + // The second attempt observes a dead root: Node has dropped the handle, so + // there is nothing left to signal and no recycled pid to reach. + expect(child.kill).toHaveBeenCalledTimes(1) + }) + + it('discards a walk that found no root instead of proving an empty tree', async () => { + const child = mockChild() + const captureDescendants = vi.fn(async () => ({ + rootPgid: null, + descendants: [], + capturedAtMs: 1 + })) + const tree = createClaudeChildTreeReaper(child, { + platform: 'linux', + captureDescendants, + terminateDescendants: vi.fn() + }) + + await tree.capture() + await expect(tree.reap()).resolves.toBe('unverifiable') + // A vacuous walk remains retryable while the root is live; no empty-tree + // verdict is latched from a missing root row. + expect(captureDescendants).toHaveBeenCalledTimes(2) + }) + + it('discards a walk that raced the root exit instead of proving an empty tree', async () => { + const child = mockChild() + let exited = false + const captureDescendants = vi.fn(async () => { + exited = true + return { rootPgid: 1, descendants: [], capturedAtMs: 1 } + }) + const tree = createClaudeChildTreeReaper(child, { + platform: 'linux', + exited: () => exited, + captureDescendants, + terminateDescendants: vi.fn() + }) + + await tree.capture() + await expect(tree.reap()).resolves.toBe('unverifiable') + }) + + it('proves a childless snapshot without signalling anything', async () => { + const child = mockChild() + const terminateDescendants = vi.fn() + const tree = createClaudeChildTreeReaper(child, { + platform: 'linux', + captureDescendants: vi.fn(async () => ({ + root: { pid: 424242, startedAt: 'Mon Jan 1 00:00:00 2026' }, + rootPgid: 1, + descendants: [], + capturedAtMs: 1 + })), + terminateDescendants + }) + + await expect(tree.reap()).resolves.toBe('exited') + expect(terminateDescendants).not.toHaveBeenCalled() + expect(child.kill).toHaveBeenCalledWith('SIGKILL') + }) + + it('waits for the Windows tree kill before releasing the root', async () => { + const child = mockChild() + const release = Promise.withResolvers() + const terminateWindowsTree = vi.fn(() => release.promise) + const captureDescendants = vi.fn() + const terminateWindowsDescendants = vi.fn(async () => 'exited' as const) + const tree = createClaudeChildTreeReaper(child, { + platform: 'win32', + captureDescendants, + captureWindowsDescendants: vi.fn(async () => windowsSnapshotOf(4243)), + terminateWindowsTree, + terminateWindowsDescendants + }) + + const reap = tree.reap() + await vi.waitFor(() => + expect(terminateWindowsTree).toHaveBeenCalledWith({ + pid: 424242, + creationTimeMs: 1_700_000_000_001 + }) + ) + expect(child.kill).not.toHaveBeenCalled() + expect(terminateWindowsDescendants).not.toHaveBeenCalled() + release.resolve() + await expect(reap).resolves.toBe('exited') + expect(child.kill).toHaveBeenCalledWith('SIGKILL') + expect(terminateWindowsDescendants).toHaveBeenCalledWith(windowsSnapshotOf(4243)) + expect(captureDescendants).not.toHaveBeenCalled() + }) + + it('stays unproven on Windows when taskkill fails and a descendant is still observed', async () => { + const child = mockChild() + const tree = createClaudeChildTreeReaper(child, { + platform: 'win32', + captureWindowsDescendants: vi.fn(async () => windowsSnapshotOf(4243)), + terminateWindowsTree: vi.fn(async () => { + throw new Error('taskkill: access denied') + }), + terminateWindowsDescendants: vi.fn(async () => 'live' as const) + }) + + // taskkill's own outcome is not the proof; the table read after it is. + await expect(tree.reap()).resolves.toBe('live') + expect(tree.treeVerdict).toBe('live') + expect(child.kill).toHaveBeenCalledWith('SIGKILL') + }) + + it('stays unproven on Windows when taskkill resolves but a descendant survives it', async () => { + const child = mockChild() + const terminateWindowsTree = vi.fn(async () => {}) + const tree = createClaudeChildTreeReaper(child, { + platform: 'win32', + captureWindowsDescendants: vi.fn(async () => windowsSnapshotOf(4243)), + terminateWindowsTree, + terminateWindowsDescendants: vi.fn(async () => 'live' as const) + }) + + await expect(tree.reap()).resolves.toBe('live') + expect(terminateWindowsTree).toHaveBeenCalledTimes(1) + expect(tree.treeVerdict).toBe('live') + }) + + it('never taskkills a Windows root that already exited, but still verifies its snapshot', async () => { + const child = mockChild() + let exited = false + const terminateWindowsTree = vi.fn(async () => {}) + const terminateWindowsDescendants = vi.fn(async () => 'exited' as const) + const tree = createClaudeChildTreeReaper(child, { + platform: 'win32', + exited: () => exited, + captureWindowsDescendants: vi.fn(async () => windowsSnapshotOf(4243)), + terminateWindowsTree, + terminateWindowsDescendants + }) + + await tree.capture() + exited = true + await expect(tree.reap()).resolves.toBe('exited') + // A dead root's pid may already belong to a stranger: taskkill /T /F on it + // would take down an unrelated tree. + expect(terminateWindowsTree).not.toHaveBeenCalled() + expect(terminateWindowsDescendants).toHaveBeenCalledWith(windowsSnapshotOf(4243)) + }) + + it('treats an unreadable Windows table as unproven', async () => { + const child = mockChild() + const terminateWindowsDescendants = vi.fn() + const tree = createClaudeChildTreeReaper(child, { + platform: 'win32', + captureWindowsDescendants: vi.fn(async () => null), + terminateWindowsTree: vi.fn(async () => {}), + terminateWindowsDescendants + }) + + await expect(tree.reap()).resolves.toBe('unverifiable') + expect(terminateWindowsDescendants).not.toHaveBeenCalled() + // A host that cannot supply creation times blocks taskkill, not the root kill. + expect(child.kill).toHaveBeenCalledWith('SIGKILL') + }) + + it('has nothing to reap for a child that never spawned', async () => { + const child = mockChild(null) + const captureDescendants = vi.fn() + const tree = createClaudeChildTreeReaper(child, { platform: 'linux', captureDescendants }) + + await expect(tree.reap()).resolves.toBe('exited') + expect(captureDescendants).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/claude/claude-agent-sdk-exit-proof.ts b/src/main/claude/claude-agent-sdk-exit-proof.ts new file mode 100644 index 00000000000..17533f87a70 --- /dev/null +++ b/src/main/claude/claude-agent-sdk-exit-proof.ts @@ -0,0 +1,366 @@ +import type { SpawnedProcess } from '../../shared/child-process/run-process' +import { + terminateDescendantSnapshotWithVerdict, + type DescendantTreeVerdict +} from '../pty-descendant-exit-verification' +import { + captureDescendantSnapshot, + type DescendantSnapshot, + type PosixProcessIdentity +} from '../pty-descendant-termination' +import { + captureWindowsDescendantSnapshot, + terminateIdentifiedWindowsProcessTree, + verifyWindowsDescendantSnapshotExit, + verifyWindowsProcessIdentity, + type WindowsDescendantSnapshot, + type WindowsProcessIdentity +} from '../windows-descendant-exit-verification' +import { mergeClaudeCapturedTrees, type ClaudeCapturedTree } from './claude-child-tree-snapshot' +import { terminateClaudeRoot, terminateClaudeWindowsRoot } from './claude-child-root-termination' +import { + proveClaudeChildExitWithReaper, + type ClaudeChildExitProofInput +} from './claude-child-exit-proof-ladder' + +/** + * A later reap may only raise the latched verdict. An observed exit is final, and + * a descendant seen alive at a deadline is never forgotten by a later look that + * could not read the table: the lease gate discriminates on exactly that pair. + */ +const TREE_VERDICT_TRUST: Record = { + unverifiable: 0, + live: 1, + exited: 2 +} + +type ReapableChild = Pick + +/** + * A walk is only admissible while the root it walked was alive. A POSIX walk + * that found no root says so with a null pgid; either platform's walk can also + * have raced the root's death. Both can only have missed descendants that + * already reparented away, so neither is evidence about the tree. + */ +function admissibleTree( + captured: DescendantSnapshot | WindowsDescendantSnapshot | null, + platform: NodeJS.Platform, + exited: boolean +): ClaudeCapturedTree | null { + if (!captured || exited) { + return null + } + if (platform === 'win32') { + return { platform: 'win32', tree: captured as WindowsDescendantSnapshot } + } + const tree = captured as DescendantSnapshot + return tree.rootPgid === null ? null : { platform: 'posix', tree } +} + +export type ClaudeChildTreeReaperDeps = { + platform?: NodeJS.Platform + /** Whether the root's exit has been observed; only a live root can be walked. */ + exited?: () => boolean + captureDescendants?: (rootPid: number) => Promise + terminateDescendants?: (snapshot: DescendantSnapshot) => Promise + terminateWindowsTree?: (root: WindowsProcessIdentity) => Promise + captureWindowsDescendants?: (rootPid: number) => Promise + terminateWindowsDescendants?: ( + snapshot: WindowsDescendantSnapshot + ) => Promise + /** Identity probe for the bare-pid tree kill; only Windows has one to gate. */ + verifyRootIdentity?: (root: PosixProcessIdentity | WindowsProcessIdentity) => Promise +} + +export type ClaudeChildTreeReaper = { + /** + * Snapshot the root's live descendants. The moment the root dies they reparent + * and no table walk can find them again, so this has to run before anything + * gives the root a reason to leave. Held once; later calls are no-ops. + */ + capture(): Promise + /** Refresh a live root's snapshot at the close boundary; a failed refresh keeps the prior proof. */ + refresh?: () => Promise + /** + * Kill the child's whole tree and report what the bounded verification + * observed. Concurrent calls share one reap, and a later call re-verifies the + * same snapshot rather than trusting a root that has since died on its own. + */ + reap(): Promise + /** + * `unverifiable` until a reap observes otherwise. `exited` is the only verdict + * that lets a close release the lease; `live` names a descendant that was seen + * still running, which no later caller may collapse into "unknown". + */ + readonly treeVerdict: DescendantTreeVerdict +} + +/** + * The same shared primitives the Codex structured provider composes: a raw + * pipe child owns no PTY job, so there is nothing for the PTY job sweep to + * terminate on Windows and no unref'd timer is allowed to outlive the proof. + * + * The proof is unproven by default. `treeVerdict` is assigned in exactly one + * place, from the verdict of `judgeTree`, so a code path that never reaches a + * verification cannot report the tree gone by omission. + */ +export function createClaudeChildTreeReaper( + child: ReapableChild, + deps: ClaudeChildTreeReaperDeps = {} +): ClaudeChildTreeReaper { + const platform = deps.platform ?? process.platform + const exited = deps.exited ?? (() => false) + // Undefined until captured; null when no admissible snapshot exists — the root + // was already gone, or the table could not be read while it was alive — which + // no later read can make up for. + let snapshot: ClaudeCapturedTree | null | undefined + let capturing: Promise | null = null + let refreshing: Promise | null = null + let queuedRefresh: Promise | null = null + let inFlight: Promise | null = null + let treeVerdict: DescendantTreeVerdict = 'unverifiable' + + // Consulted only on win32: POSIX signals descendants by revalidated identity + // and reaches the root solely through Node's handle, so neither needs a probe. + const verifyRoot = + deps.verifyRootIdentity ?? + ((root: PosixProcessIdentity | WindowsProcessIdentity) => + verifyWindowsProcessIdentity(root as WindowsProcessIdentity)) + + function captureOnce(): Promise { + if (refreshing) { + const pending = refreshing + return pending.then(() => queuedRefresh ?? undefined) + } + if (snapshot !== undefined) { + return Promise.resolve() + } + if (capturing) { + const pending = capturing + return pending.then(() => queuedRefresh ?? undefined) + } + const rootPid = child.pid + if (!rootPid || exited()) { + // Only the root's death makes a missing snapshot final: its descendants + // have reparented, and no later walk can reach them. + snapshot = exited() ? null : snapshot + return Promise.resolve() + } + const capture = + platform === 'win32' + ? (deps.captureWindowsDescendants ?? captureWindowsDescendantSnapshot) + : (deps.captureDescendants ?? captureDescendantSnapshot) + capturing = capture(rootPid) + .catch(() => null) + .then((captured) => { + // A walk that found no root, or that raced the root's death, can only + // have missed descendants that already reparented away. A table that + // could not be read in time is not an answer at all: while the root + // still lives the walk is simply retried, rather than latching a failed + // read as proof that there was nothing to find. + const rootExited = exited() + const tree = admissibleTree(captured, platform, rootExited) + if (tree) { + snapshot = tree + } else if (rootExited) { + // Once the root has exited its descendants may have reparented; no + // later table read can make an absent snapshot safe to signal. + snapshot = null + } else { + // A failed read or a walk that did not observe the live root is + // retryable while the root remains alive. Never latch a vacuous null. + snapshot = undefined + } + }) + .finally(() => { + capturing = null + }) + return capturing + } + + function startRefresh(): Promise { + if (exited()) { + return Promise.resolve() + } + const rootPid = child.pid + if (!rootPid) { + return Promise.resolve() + } + const capture = + platform === 'win32' + ? (deps.captureWindowsDescendants ?? captureWindowsDescendantSnapshot) + : (deps.captureDescendants ?? captureDescendantSnapshot) + const operation = (async () => { + const captured = await capture(rootPid).catch(() => null) + if (exited()) { + return + } + const tree = admissibleTree(captured, platform, false) + if (!tree) { + return + } + if (snapshot === undefined) { + snapshot = tree + return + } + if (snapshot !== null) { + // A merge that returns null saw a same-PID identity change: a + // recycle/replace decision, not an absent descendant, so no row here may + // be signalled from its number. Only the descendant evidence is lost — + // the root still leaves through the handle no recycled pid can reach. + snapshot = mergeClaudeCapturedTrees(snapshot, tree) + } + // Keep an earlier admissible snapshot when this close-boundary read fails; + // it remains the only identity-safe evidence after root exit. + })() + refreshing = operation + const clearRefreshing = (): void => { + if (refreshing === operation) { + refreshing = null + } + } + void operation.then(clearRefreshing, clearRefreshing) + return operation + } + + function queueRefreshAfter(pending: Promise): Promise { + if (queuedRefresh) { + return queuedRefresh + } + const operation = pending.then(() => { + if (exited()) { + return + } + return startRefresh() + }) + queuedRefresh = operation + const clearQueuedRefresh = (): void => { + if (queuedRefresh === operation) { + queuedRefresh = null + } + } + void operation.then(clearQueuedRefresh, clearQueuedRefresh) + return operation + } + + async function refresh(): Promise { + const pending = capturing ?? refreshing + if (pending) { + await queueRefreshAfter(pending) + return + } + if (queuedRefresh) { + await queuedRefresh + return + } + try { + await startRefresh() + } catch { + // A refresh is advisory; capture failures leave the prior proof intact. + } + } + + /** The only source of a tree verdict: every `exited` here is an observation. */ + async function judgeTree(): Promise { + const killRoot = (): boolean => terminateClaudeRoot({ child, exited }) + const rootPid = child.pid + if (!rootPid) { + // Never spawned, so the OS never created a tree to orphan. + return 'exited' + } + await captureOnce() + if (platform === 'win32') { + // Why taskkill's own outcome is never the verdict: it resolves identically + // on a timeout, an access denial, a recycled root and a real kill. + const { rootVerified } = await terminateClaudeWindowsRoot({ + snapshot: snapshot?.platform === 'win32' ? snapshot.tree : null, + exited, + verifyRoot: (root) => verifyRoot(root), + terminateTree: (root) => + deps.terminateWindowsTree + ? deps.terminateWindowsTree(root) + : terminateIdentifiedWindowsProcessTree(root, { + ownsRoot: () => !exited() + }).then(() => undefined), + killRoot + }) + if (!rootVerified && !exited()) { + return 'unverifiable' + } + return snapshot?.platform === 'win32' + ? await (deps.terminateWindowsDescendants ?? verifyWindowsDescendantSnapshotExit)( + snapshot.tree + ) + : 'unverifiable' + } + if (snapshot?.platform !== 'posix') { + killRoot() + return 'unverifiable' + } + if (snapshot.tree.descendants.length === 0) { + // Read while the root was alive and childless: a later table read has no + // row it could match, so it would add nothing to this observation. + killRoot() + return 'exited' + } + // Why the root is killed while verification is already running, and never + // SIGSTOPped first the way the Codex non-group path does: measured on macOS, a + // killed child of a stopped parent stays a zombie row in ps with its lstart + // and pgid intact, so verification cannot pass until the root is dead. The + // descendants are signalled by the verifier as soon as it revalidates their + // identities; the root's death then reparents any zombies to init, which + // reaps them. After a root exit the kill is a no-op: Node drops the handle + // on exit and never signals a possibly recycled pid. + const verdictPromise = deps.terminateDescendants + ? deps.terminateDescendants(snapshot.tree) + : terminateDescendantSnapshotWithVerdict(snapshot.tree, { + requireIdentityBeforeSignal: true + }) + killRoot() + // What the verification observed is the verdict: a kill that reports no + // signal means the handle was already gone, never that the tree survived. + return verdictPromise + } + + return { + capture: captureOnce, + refresh, + reap() { + if (inFlight) { + return inFlight + } + const attempt = judgeTree() + .catch((): DescendantTreeVerdict => 'unverifiable') + .then((verdict) => { + treeVerdict = + TREE_VERDICT_TRUST[verdict] > TREE_VERDICT_TRUST[treeVerdict] ? verdict : treeVerdict + return verdict + }) + inFlight = attempt + void attempt.finally(() => { + if (inFlight === attempt) { + inFlight = null + } + }) + return attempt + }, + get treeVerdict() { + return treeVerdict + } + } +} + +/** + * Orca's own shutdown ladder on the child it spawned, kept because the SDK's + * close path returns no proof and Orca never releases a lease on an assumed exit. + * + * Resolves true only after the child actually emitted exit and its snapshotted + * descendants were observed gone; false is unproven. A root that left on its + * own before a snapshot could be armed stays unproven: its descendants had + * already reparented out of reach when the ladder first looked. + */ +export function proveClaudeChildExit(input: ClaudeChildExitProofInput): Promise { + return proveClaudeChildExitWithReaper(input, () => + createClaudeChildTreeReaper(input.child, { exited: input.exited }) + ) +} diff --git a/src/main/claude/claude-agent-sdk-import-boundary.test.ts b/src/main/claude/claude-agent-sdk-import-boundary.test.ts new file mode 100644 index 00000000000..f1a38466d41 --- /dev/null +++ b/src/main/claude/claude-agent-sdk-import-boundary.test.ts @@ -0,0 +1,154 @@ +import { existsSync, readFileSync, statSync } from 'node:fs' +import { dirname, join, relative, resolve } from 'node:path' +import { describe, expect, it } from 'vitest' +import { spawnProcess } from '../../shared/child-process/run-process' + +/** + * Keep the agent SDK on the structured-Claude side of the toggle. + * + * A user who never leaves the terminal/TUI Claude path must not pay for the SDK: + * importing it evaluates a package that rewrites + * `process.env.NoDefaultCurrentDirectoryInExePath`, changing how Windows resolves + * executables for every later subprocess, and a missing or incompatible install + * would take normal runtime startup down with it. The ordinary + * `OrcaRuntimeService` graph reaches the Claude transport module, so only a + * deferred import keeps that boundary — and only a walk of the real import graph + * keeps the next static import from quietly restoring it. + */ +const SDK_PACKAGE = '@anthropic-ai/claude-agent-sdk' +const REPO_ROOT = resolve(__dirname, '..', '..', '..') + +/** The Electron main entry: everything the app loads before any session exists. */ +const ROOT = 'src/main/index.ts' +/** Proof the walk goes all the way into the Claude transport rather than stopping short. */ +const TRANSPORT_MODULE = 'src/main/claude/claude-stream-json-connection.ts' + +/** + * Static, value-carrying specifiers only, read statement by statement so a + * multi-line `import { ... } from '...'` counts. `import type` is erased before + * the module ever loads and a bare `import(...)` is the deferral this guards, so + * neither is an edge the runtime traverses at load time. + */ +const STATEMENT_START = /^\s*(?:import|export)\b/ +const TYPE_ONLY = /^\s*(?:import|export)\s+type\b/ +const FROM_SPECIFIER = /(?:^|\s)from\s*['"]([^'"]+)['"]/ +const SIDE_EFFECT_IMPORT = /^\s*import\s*['"]([^'"]+)['"]/ +/** An import statement never spans more lines than its longest specifier list. */ +const MAX_STATEMENT_LINES = 60 + +function readSpecifiers(source: string): string[] { + const lines = source.split('\n') + const found: string[] = [] + for (let index = 0; index < lines.length; index += 1) { + const first = lines[index] as string + if (!STATEMENT_START.test(first) || TYPE_ONLY.test(first)) { + continue + } + const sideEffect = SIDE_EFFECT_IMPORT.exec(first) + if (sideEffect) { + found.push(sideEffect[1] as string) + continue + } + for (let scan = index; scan < Math.min(lines.length, index + MAX_STATEMENT_LINES); scan += 1) { + if (scan > index && STATEMENT_START.test(lines[scan] as string)) { + break + } + const specifier = FROM_SPECIFIER.exec(lines[scan] as string) + if (specifier) { + found.push(specifier[1] as string) + break + } + } + } + return found +} + +/** Resolve a relative specifier the way the bundler does; unresolvable means not a module. */ +function resolveRelative(fromFile: string, specifier: string): string | null { + const base = join(dirname(fromFile), specifier) + for (const candidate of [base, `${base}.ts`, `${base}.tsx`, join(base, 'index.ts')]) { + if (existsSync(candidate) && statSync(candidate).isFile()) { + return candidate + } + } + return null +} + +function walkStaticImports(rootFile: string): { visited: Set; sdkImporters: string[] } { + const visited = new Set() + const sdkImporters: string[] = [] + const queue = [resolve(REPO_ROOT, rootFile)] + while (queue.length > 0) { + const file = queue.pop() as string + const key = relative(REPO_ROOT, file).split('\\').join('/') + if (visited.has(key)) { + continue + } + visited.add(key) + for (const specifier of readSpecifiers(readFileSync(file, 'utf8'))) { + if (specifier === SDK_PACKAGE || specifier.startsWith(`${SDK_PACKAGE}/`)) { + sdkImporters.push(key) + continue + } + if (!specifier.startsWith('.')) { + continue + } + const target = resolveRelative(file, specifier) + if (target) { + queue.push(target) + } + } + } + return { visited, sdkImporters } +} + +describe('claude agent SDK import boundary', () => { + const walk = walkStaticImports(ROOT) + + it('walks a graph deep enough to reach the Claude transport', () => { + // Without this the guard passes for the wrong reason the moment the walk breaks. + expect(walk.visited.size).toBeGreaterThan(500) + expect([...walk.visited]).toContain(TRANSPORT_MODULE) + }) + + it('never reaches the SDK through a static import from the main entry', () => { + expect( + walk.sdkImporters, + `${SDK_PACKAGE} must stay behind the structured-Claude boundary. Load it with a deferred import inside the session path instead.` + ).toEqual([]) + }) + + it('leaves the Windows executable-search environment alone when the runtime loads', async () => { + // A vitest file runs in its own fork, so this is a clean process; the ambient + // value is cleared first because the developer's own shell may carry one. + delete process.env.NoDefaultCurrentDirectoryInExePath + await import('../runtime/structured-agent-session-runtime') + + expect(process.env.NoDefaultCurrentDirectoryInExePath).toBeUndefined() + }) + + it('still lets the SDK set it, so the guard above is not measuring nothing', async () => { + // A separate process, not this fork: the assertion has to be about a first + // evaluation of the package, which a cached module registry cannot give. + const { NoDefaultCurrentDirectoryInExePath: _cleared, ...env } = process.env + const probe = spawnProcess({ + program: process.execPath, + args: [ + '-e', + `import(${JSON.stringify(SDK_PACKAGE)}).then(() => console.log(String(process.env.NoDefaultCurrentDirectoryInExePath)))` + ], + cwd: REPO_ROOT, + env: env as Record, + stdio: ['ignore', 'pipe', 'ignore'] + }) + const observed = await new Promise((settle) => { + let output = '' + probe.stdout?.setEncoding('utf8').on('data', (chunk: string) => { + output += chunk + }) + probe.once('close', () => settle(output.trim())) + }) + + expect(observed).toBe('1') + }) +}) diff --git a/src/main/claude/claude-agent-sdk-process-spawn.test.ts b/src/main/claude/claude-agent-sdk-process-spawn.test.ts new file mode 100644 index 00000000000..cd3520cf6d5 --- /dev/null +++ b/src/main/claude/claude-agent-sdk-process-spawn.test.ts @@ -0,0 +1,107 @@ +import { EventEmitter } from 'node:events' +import { PassThrough } from 'node:stream' +import { describe, expect, it, vi } from 'vitest' +import type { SpawnOptions as SdkSpawnOptions } from '@anthropic-ai/claude-agent-sdk' +import { resolveSpawn, type spawnProcess } from '../../shared/child-process/run-process' +import type { ProcessSpec } from '../../shared/child-process/process-spec' +import { createClaudeCodeProcessSpawn } from './claude-agent-sdk-process-spawn' + +type FakeChild = EventEmitter & { + pid: number + stdin: PassThrough + stdout: PassThrough + stderr: PassThrough + kill: ReturnType +} + +function fakeSpawn() { + const child = new EventEmitter() as FakeChild + child.pid = 4321 + child.stdin = new PassThrough() + child.stdout = new PassThrough() + child.stderr = new PassThrough() + child.kill = vi.fn(() => true) + const specs: ProcessSpec[] = [] + const spawnImpl = ((spec: ProcessSpec) => { + specs.push(spec) + return child + }) as unknown as typeof spawnProcess + return { child, spawnImpl, specs } +} + +function sdkOptions(overrides: Partial = {}): SdkSpawnOptions { + return { + command: '/usr/local/bin/claude', + args: ['--output-format', 'stream-json'], + cwd: '/work/repo', + env: { PATH: '/usr/bin', CLAUDE_CONFIG_DIR: '/accounts/one', UNSET: undefined }, + signal: new AbortController().signal, + ...overrides + } +} + +describe('claude agent SDK process spawn', () => { + it('routes the SDK spawn through Orca and retains the pid the lease adjudicates on', () => { + const process = fakeSpawn() + const spawn = createClaudeCodeProcessSpawn(process.spawnImpl) + + expect(spawn.pid).toBeUndefined() + expect(spawn.child).toBeNull() + const child = spawn.spawn(sdkOptions()) + + expect(child).toBe(process.child) + expect(spawn.child).toBe(process.child) + expect(spawn.pid).toBe(4321) + expect(process.specs[0]).toEqual({ + program: '/usr/local/bin/claude', + args: ['--output-format', 'stream-json'], + cwd: '/work/repo', + env: { PATH: '/usr/bin', CLAUDE_CONFIG_DIR: '/accounts/one' }, + stdio: ['pipe', 'pipe', 'pipe'] + }) + }) + + it('keeps the child out of the SDK abort path so exit proof stays Orca-owned', () => { + const process = fakeSpawn() + const controller = new AbortController() + createClaudeCodeProcessSpawn(process.spawnImpl).spawn(sdkOptions({ signal: controller.signal })) + + // Node's spawn({signal}) kills the child on abort; Orca's ladder must be the + // only thing that can end this process, or close() would report an assumed exit. + expect(process.specs[0]).not.toHaveProperty('signal') + }) + + it('drains stderr into a bounded tail so an exit error still carries it', async () => { + const process = fakeSpawn() + const spawn = createClaudeCodeProcessSpawn(process.spawnImpl) + spawn.spawn(sdkOptions()) + + process.child.stderr.write('x'.repeat(9000)) + process.child.stderr.write('claude: not signed in') + await new Promise((resolve) => setImmediate(resolve)) + + expect(spawn.stderrTail).toMatch(/claude: not signed in$/) + expect(spawn.stderrTail.length).toBe(8192) + }) + + it('hands a Windows .cmd shim to Orca\u2019s argument encoder', () => { + const process = fakeSpawn() + createClaudeCodeProcessSpawn(process.spawnImpl).spawn( + sdkOptions({ + command: 'C:\\Users\\dev\\AppData\\npm\\claude.cmd', + args: ['--setting-sources=user,project,local', '--session-id', 'a b&c'] + }) + ) + + // The spec the spawner builds is what Orca's Windows branch encodes; the SDK's + // own spawn would hand `.cmd` straight to Node and mangle the argument. + const resolved = resolveSpawn(process.specs[0] as ProcessSpec, 'win32') + expect(resolved.file.toLowerCase()).toContain('cmd.exe') + expect(resolved.options.windowsVerbatimArguments).toBe(true) + expect(resolved.args).toHaveLength(1) + // `/v:off` plus the quoted argument is what keeps `&` from splitting the line. + expect(resolved.args[0]).toContain('/v:off') + expect(resolved.args[0]).toContain('"a b&c"') + expect(resolved.args[0]).toContain('"--setting-sources=user,project,local"') + }) +}) diff --git a/src/main/claude/claude-agent-sdk-process-spawn.ts b/src/main/claude/claude-agent-sdk-process-spawn.ts new file mode 100644 index 00000000000..a2b1ad7f158 --- /dev/null +++ b/src/main/claude/claude-agent-sdk-process-spawn.ts @@ -0,0 +1,69 @@ +import type { SpawnOptions as ClaudeAgentSdkSpawnOptions } from '@anthropic-ai/claude-agent-sdk' +import { spawnProcess } from '../../shared/child-process/run-process' + +/** Derived rather than imported: only src/shared/child-process may name node:child_process. */ +type ClaudeCodeChild = ReturnType + +const STDERR_TAIL_MAX_BYTES = 8192 + +export type ClaudeCodeProcessSpawn = { + /** Pass as the SDK's `spawnClaudeCodeProcess`; the SDK never learns the pid because it never owns it. */ + spawn: (options: ClaudeAgentSdkSpawnOptions) => ClaudeCodeChild + /** The retained child, so Orca keeps its own tree-kill and exit-proof ladder. Null until the SDK spawns. */ + readonly child: ClaudeCodeChild | null + /** Ownership proof: the durable lease adjudicates on this pid plus start time plus the spawn token. */ + readonly pid: number | undefined + readonly stderrTail: string +} + +function definedEnv(env: Record): Record { + const next: Record = {} + for (const [key, value] of Object.entries(env)) { + if (value !== undefined) { + next[key] = value + } + } + return next +} + +/** + * Orca supplies the Claude Code child rather than letting the SDK spawn it. + * + * Two independent reasons: the SDK's `SpawnedProcess` has no pid, and Orca's + * spawner is the only path that encodes `.cmd` arguments safely on Windows. + */ +export function createClaudeCodeProcessSpawn( + spawnImpl: typeof spawnProcess = spawnProcess +): ClaudeCodeProcessSpawn { + let child: ClaudeCodeChild | null = null + let stderrTail = '' + return { + spawn: (options) => { + // Why `options.signal` is dropped: it would let the SDK kill the child outside + // Orca's ladder, and close() may never report an exit it did not observe. + const spawned = spawnImpl({ + program: options.command, + args: [...options.args], + ...(options.cwd === undefined ? {} : { cwd: options.cwd }), + env: definedEnv(options.env), + stdio: ['pipe', 'pipe', 'pipe'] + }) + child = spawned + // The SDK drains stderr only for its own local spawn, so a custom spawner must: + // otherwise the child blocks on a full pipe and exit errors lose their tail. + spawned.stderr.setEncoding('utf8').on('data', (chunk: string) => { + stderrTail = (stderrTail + chunk).slice(-STDERR_TAIL_MAX_BYTES) + }) + return spawned + }, + get child() { + return child + }, + get pid() { + return child?.pid + }, + get stderrTail() { + return stderrTail + } + } +} diff --git a/src/main/claude/claude-agent-sdk-root-kill-fallback.test.ts b/src/main/claude/claude-agent-sdk-root-kill-fallback.test.ts new file mode 100644 index 00000000000..9f843527e30 --- /dev/null +++ b/src/main/claude/claude-agent-sdk-root-kill-fallback.test.ts @@ -0,0 +1,190 @@ +import { EventEmitter } from 'node:events' +import { PassThrough } from 'node:stream' +import { describe, expect, it, vi } from 'vitest' +import type { SpawnedProcess } from '../../shared/child-process/run-process' +import type { DescendantSnapshot } from '../pty-descendant-termination' +import type { WindowsDescendantSnapshot } from '../windows-descendant-exit-verification' +import { createClaudeChildTreeReaper } from './claude-agent-sdk-exit-proof' +import { mergeClaudeCapturedTrees } from './claude-child-tree-snapshot' + +const ROOT_PID = 424242 +const ROOT_STARTED_AT = 'Mon Jan 1 00:00:00 2026' +const ROOT_FORK_MS = Date.parse(ROOT_STARTED_AT) + +function mockChild(): EventEmitter & + Pick & { kill: ReturnType } { + return Object.assign(new EventEmitter(), { + pid: ROOT_PID, + stdin: new PassThrough(), + kill: vi.fn(() => true) + }) as never +} + +function posixSnapshot(input: { + capturedAtMs: number + descendants?: DescendantSnapshot['descendants'] +}): DescendantSnapshot { + return { + root: { pid: ROOT_PID, startedAt: ROOT_STARTED_AT }, + rootPgid: ROOT_PID, + descendants: input.descendants ?? [], + capturedAtMs: input.capturedAtMs + } +} + +function windowsSnapshot(capturedAtMs = 1): WindowsDescendantSnapshot { + return { + root: { pid: ROOT_PID, creationTimeMs: 1_700_000_000_001 }, + descendants: [{ pid: 4243, creationTimeMs: 1_700_000_000_000 }], + unidentifiedCount: 0, + capturedAtMs + } +} + +describe('Claude root kill fallback', () => { + it('kills the root when the first capture landed in the fork second', async () => { + // The production POSIX verifier declines a root born in its capture second, + // and that verdict must not cost the tree the kill on Node's own handle. + const child = mockChild() + const tree = createClaudeChildTreeReaper(child, { + platform: 'linux', + exited: () => false, + captureDescendants: vi.fn(async () => posixSnapshot({ capturedAtMs: ROOT_FORK_MS + 300 })) + }) + + await expect(tree.reap()).resolves.toBe('exited') + expect(child.kill).toHaveBeenCalledWith('SIGKILL') + }) + + it('kills the root after a recycled descendant pid voided the snapshot', async () => { + const child = mockChild() + const captureDescendants = vi + .fn() + .mockResolvedValueOnce( + posixSnapshot({ + capturedAtMs: ROOT_FORK_MS + 5_000, + descendants: [{ pid: 100, ppid: ROOT_PID, pgid: ROOT_PID, startedAt: ROOT_STARTED_AT }] + }) + ) + .mockResolvedValueOnce( + posixSnapshot({ + capturedAtMs: ROOT_FORK_MS + 6_000, + descendants: [ + { pid: 100, ppid: ROOT_PID, pgid: ROOT_PID, startedAt: 'Mon Jan 1 00:00:30 2026' } + ] + }) + ) + const tree = createClaudeChildTreeReaper(child, { + platform: 'linux', + exited: () => false, + captureDescendants, + terminateDescendants: vi.fn(async () => 'exited' as const), + verifyRootIdentity: vi.fn(async () => true) + }) + + await tree.capture() + await tree.refresh?.() + // The descendant evidence is rightly discarded; the root's never was in doubt. + await expect(tree.reap()).resolves.toBe('unverifiable') + expect(child.kill).toHaveBeenCalledWith('SIGKILL') + }) + + it('keeps an observed live descendant when the root identity probe declined', async () => { + const child = mockChild() + const tree = createClaudeChildTreeReaper(child, { + platform: 'linux', + exited: () => false, + captureDescendants: vi.fn(async () => + posixSnapshot({ + capturedAtMs: ROOT_FORK_MS + 5_000, + descendants: [{ pid: 100, ppid: ROOT_PID, pgid: ROOT_PID, startedAt: ROOT_STARTED_AT }] + }) + ), + terminateDescendants: vi.fn(async () => 'live' as const), + verifyRootIdentity: vi.fn(async () => false) + }) + + await expect(tree.reap()).resolves.toBe('live') + expect(tree.treeVerdict).toBe('live') + expect(child.kill).toHaveBeenCalledWith('SIGKILL') + }) + + it('reports a Windows taskkill that worked as exited, not unverifiable', async () => { + const child = mockChild() + // Probe 1 gates taskkill; a later probe correctly finds the root already dead. + const verifyRootIdentity = vi.fn().mockResolvedValueOnce(true).mockResolvedValue(false) + const tree = createClaudeChildTreeReaper(child, { + platform: 'win32', + exited: () => false, + captureWindowsDescendants: vi.fn(async () => windowsSnapshot()), + terminateWindowsTree: vi.fn(async () => {}), + terminateWindowsDescendants: vi.fn(async () => 'exited' as const), + verifyRootIdentity + }) + + await expect(tree.reap()).resolves.toBe('exited') + }) + + it('kills the root when no POSIX snapshot could be read', async () => { + const child = mockChild() + const tree = createClaudeChildTreeReaper(child, { + platform: 'linux', + exited: () => false, + captureDescendants: vi.fn(async () => null), + terminateDescendants: vi.fn() + }) + + await expect(tree.reap()).resolves.toBe('unverifiable') + expect(child.kill).toHaveBeenCalledWith('SIGKILL') + }) + + it('kills the root when the Windows process table is unreadable', async () => { + const child = mockChild() + const terminateWindowsTree = vi.fn(async () => {}) + const tree = createClaudeChildTreeReaper(child, { + platform: 'win32', + exited: () => false, + captureWindowsDescendants: vi.fn(async () => null), + terminateWindowsTree, + terminateWindowsDescendants: vi.fn() + }) + + await expect(tree.reap()).resolves.toBe('unverifiable') + // No identity means no bare-pid tree kill, but the owned handle is still ours. + expect(terminateWindowsTree).not.toHaveBeenCalled() + expect(child.kill).toHaveBeenCalledWith('SIGKILL') + }) + + it('never signals a root the reaper already saw exit', async () => { + const child = mockChild() + const tree = createClaudeChildTreeReaper(child, { + platform: 'linux', + exited: () => true, + captureDescendants: vi.fn(async () => null) + }) + + await expect(tree.reap()).resolves.toBe('unverifiable') + expect(child.kill).not.toHaveBeenCalled() + }) + + it('chains per-pid Windows boundaries across a second merge', async () => { + const first = windowsSnapshot(1_000) + const second: WindowsDescendantSnapshot = { + ...windowsSnapshot(2_000), + descendants: [ + { pid: 4243, creationTimeMs: 1_700_000_000_000 }, + { pid: 4244, creationTimeMs: 1_700_000_000_002 } + ] + } + const third: WindowsDescendantSnapshot = { ...second, capturedAtMs: 3_000 } + + const merged = mergeClaudeCapturedTrees( + { platform: 'win32', tree: first }, + { platform: 'win32', tree: second } + ) + expect(merged?.tree.capturedAtMsByPid).toEqual({ '4243': 1_000, '4244': 2_000 }) + const rechained = mergeClaudeCapturedTrees(merged!, { platform: 'win32', tree: third }) + + expect(rechained?.tree.capturedAtMsByPid).toEqual({ '4243': 1_000, '4244': 2_000 }) + }) +}) diff --git a/src/main/claude/claude-agent-sdk-user-message-queue.test.ts b/src/main/claude/claude-agent-sdk-user-message-queue.test.ts new file mode 100644 index 00000000000..62fbd8cf203 --- /dev/null +++ b/src/main/claude/claude-agent-sdk-user-message-queue.test.ts @@ -0,0 +1,65 @@ +import type { SDKUserMessage } from '@anthropic-ai/claude-agent-sdk' +import { describe, expect, it } from 'vitest' +import { createClaudeUserMessageQueue } from './claude-agent-sdk-user-message-queue' + +/** + * The SDK's input pump is `for await (const frame of prompt) { await transport.write(frame) }`. + * A rejected write — or an abort — ends that loop abruptly, which calls the + * generator's `return()`. Everything below drives that exact shape, because the + * frame the pump already pulled is the one nothing else can reach. + */ +const frame = (text: string): SDKUserMessage => + ({ + type: 'user', + message: { role: 'user', content: [{ type: 'text', text }] } + }) as unknown as SDKUserMessage + +const settled = (promise: Promise): Promise<'settled' | 'pending'> => + Promise.race([ + promise.then( + () => 'settled' as const, + () => 'settled' as const + ), + new Promise<'pending'>((resolve) => setTimeout(() => resolve('pending'), 100)) + ]) + +describe('claude user message queue', () => { + it('rejects the frame the SDK pulled but abandoned without writing', async () => { + const queue = createClaudeUserMessageQueue() + const pump = queue.messages[Symbol.asyncIterator]() + const sent = queue.push(frame('hello')) + + await pump.next() + await pump.return?.(undefined) + + await expect(settled(sent)).resolves.toBe('settled') + await expect(sent).rejects.toThrow( + 'claude stream-json input ended before the frame was written' + ) + }) + + it('rejects an in-flight frame from fail() when the SDK never resumes the pump', async () => { + const queue = createClaudeUserMessageQueue() + const pump = queue.messages[Symbol.asyncIterator]() + const sent = queue.push(frame('hello')) + + await pump.next() + queue.fail(new Error('claude stream-json exited: child died')) + + await expect(settled(sent)).resolves.toBe('settled') + await expect(sent).rejects.toThrow('claude stream-json exited: child died') + }) + + it('still settles a written frame only once the pump asks for the next one', async () => { + const queue = createClaudeUserMessageQueue() + const pump = queue.messages[Symbol.asyncIterator]() + const sent = queue.push(frame('hello')) + + const pulled = await pump.next() + expect(pulled.value).toMatchObject({ type: 'user' }) + // The write proof is the pump coming back for more, exactly as before. + await expect(settled(sent)).resolves.toBe('pending') + void pump.next() + await expect(sent).resolves.toBeUndefined() + }) +}) diff --git a/src/main/claude/claude-agent-sdk-user-message-queue.ts b/src/main/claude/claude-agent-sdk-user-message-queue.ts new file mode 100644 index 00000000000..87fa6660159 --- /dev/null +++ b/src/main/claude/claude-agent-sdk-user-message-queue.ts @@ -0,0 +1,100 @@ +import type { SDKUserMessage } from '@anthropic-ai/claude-agent-sdk' + +type QueuedMessage = { + message: SDKUserMessage + resolve: () => void + reject: (error: Error) => void +} + +export type ClaudeUserMessageQueue = { + /** The SDK's streaming-input prompt; it stays open until `end`. */ + messages: AsyncIterable + /** Resolves once the SDK has finished writing the frame to the child. */ + push: (message: SDKUserMessage) => Promise + /** Reject every unwritten frame, in-flight included; a caller waiting on a send must not hang past the exit. */ + fail: (error: Error) => void + end: () => void +} + +/** The rejection an abandoned frame carries when nothing else has named a cause yet. */ +const UNWRITTEN_FRAME_MESSAGE = 'claude stream-json input ended before the frame was written' + +export function createClaudeUserMessageQueue(): ClaudeUserMessageQueue { + const queued: QueuedMessage[] = [] + // The frame the SDK has taken but not yet acknowledged. It is out of `queued`, + // so it is unreachable from anywhere else and would otherwise never settle. + let inFlight: QueuedMessage | null = null + let wake: (() => void) | null = null + let ended = false + let failure: Error | null = null + const notify = (): void => { + wake?.() + wake = null + } + const rejectInFlight = (error: Error): void => { + const abandoned = inFlight + inFlight = null + abandoned?.reject(error) + } + + async function* drain(): AsyncGenerator { + for (;;) { + const next = queued.shift() + if (next) { + inFlight = next + let written = false + try { + yield next.message + written = true + } finally { + // The SDK's input pump abandons this iterator when its + // `await transport.write(...)` rejects or the query aborts, and the code + // after a `yield` never runs on that path. Settling here is the only + // place a frame it already took can be reached. + if (written) { + inFlight = null + // Resumed only after the SDK's `await transport.write(...)` settled, so this + // is the same "the frame reached the child" proof the hand-rolled write gave. + next.resolve() + } else { + rejectInFlight(failure ?? new Error(UNWRITTEN_FRAME_MESSAGE)) + } + } + continue + } + if (ended || failure) { + return + } + await new Promise((resolve) => { + wake = resolve + }) + } + } + + return { + messages: drain(), + push: (message) => + new Promise((resolve, reject) => { + if (failure) { + reject(failure) + return + } + queued.push({ message, resolve, reject }) + notify() + }), + fail: (error) => { + failure ??= error + for (const entry of queued.splice(0)) { + entry.reject(error) + } + // A pump that never resumes cannot run the generator's cleanup, so the + // exit path has to reach the in-flight frame itself. + rejectInFlight(error) + notify() + }, + end: () => { + ended = true + notify() + } + } +} diff --git a/src/main/claude/claude-child-exit-proof-ladder.ts b/src/main/claude/claude-child-exit-proof-ladder.ts new file mode 100644 index 00000000000..85ed629f1b9 --- /dev/null +++ b/src/main/claude/claude-child-exit-proof-ladder.ts @@ -0,0 +1,41 @@ +import type { SpawnedProcess } from '../../shared/child-process/run-process' +import { waitForProcessExitUntil } from '../codex/codex-process-exit-deadline' +import type { ClaudeChildTreeReaper } from './claude-agent-sdk-exit-proof' + +const GRACEFUL_EXIT_MS = 1_500 +const FORCED_EXIT_MS = 1_000 + +export type ClaudeChildExitProofInput = { + child: Pick + exitPromise: Promise + exited: () => boolean + tree?: ClaudeChildTreeReaper +} + +export async function proveClaudeChildExitWithReaper( + input: ClaudeChildExitProofInput, + createTree: () => ClaudeChildTreeReaper +): Promise { + const tree = input.tree ?? createTree() + // Arm before stdin closes: only a live root can identify its descendants. + await tree.capture() + try { + input.child.stdin?.end() + } catch { + // The reap below still owns the process. + } + let reaped = false + if (!input.exited()) { + await waitForProcessExitUntil(input.exitPromise, GRACEFUL_EXIT_MS) + if (!input.exited()) { + reaped = true + await tree.refresh?.() + await tree.reap() + await waitForProcessExitUntil(input.exitPromise, FORCED_EXIT_MS) + } + } + if (!reaped && input.exited() && tree.treeVerdict !== 'exited') { + await tree.reap() + } + return input.exited() && tree.treeVerdict === 'exited' +} diff --git a/src/main/claude/claude-child-process-environment.test.ts b/src/main/claude/claude-child-process-environment.test.ts new file mode 100644 index 00000000000..8299fdcdd61 --- /dev/null +++ b/src/main/claude/claude-child-process-environment.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest' +import { applyClaudeEnvPatch } from '../claude-accounts/environment' +import { buildClaudeChildProcessEnv } from './claude-child-process-environment' + +describe('Claude child process environment', () => { + it('strips case-insensitive auth headers through the shared env patch on Windows', () => { + expect( + applyClaudeEnvPatch( + { + anthropic_api_key: 'inherited-key', + Anthropic_Custom_Headers: 'Authorization: inherited', + SAFE_VALUE: 'preserved' + }, + {}, + { stripAuthEnv: true, platform: 'win32' } + ) + ).toEqual({ SAFE_VALUE: 'preserved' }) + }) + + it('strips case-insensitive inherited auth and session stamps on Windows', () => { + const env = buildClaudeChildProcessEnv( + { + ANTHROPIC_AUTH_TOKEN: 'configured-token', + Claude_Code_Session_Id: 'configured-session' + }, + { + platform: 'win32', + inheritedEnv: { + anthropic_api_key: 'inherited-key', + Anthropic_Custom_Headers: 'Authorization: inherited', + claude_code_child_session: '1', + CLAUDE_CODE_SESSION_ID: 'inherited-session', + SAFE_VALUE: 'preserved' + } + } + ) + + expect(env).toEqual({ + ANTHROPIC_AUTH_TOKEN: 'configured-token', + Claude_Code_Session_Id: 'configured-session', + SAFE_VALUE: 'preserved' + }) + }) + + it('can strip child-session stamps reintroduced by a full SDK launch overlay', () => { + expect( + buildClaudeChildProcessEnv( + { + CLAUDE_CODE_CHILD_SESSION: 'configured-child-session', + CLAUDE_CODE_SESSION_ID: 'configured-session', + CLAUDE_CODE_BRIDGE_SESSION_ID: 'configured-bridge-session' + }, + { + scrubConfiguredChildSessionStamps: true, + inheritedEnv: { + CLAUDE_CODE_CHILD_SESSION: 'inherited-child-session', + SAFE_VALUE: 'preserved' + } + } + ) + ).toEqual({ SAFE_VALUE: 'preserved' }) + }) +}) diff --git a/src/main/claude/claude-child-process-environment.ts b/src/main/claude/claude-child-process-environment.ts new file mode 100644 index 00000000000..58f00c5c1b8 --- /dev/null +++ b/src/main/claude/claude-child-process-environment.ts @@ -0,0 +1,69 @@ +import { CLAUDE_AUTH_ENV_VARS, applyClaudeEnvPatch } from '../claude-accounts/environment' + +const CLAUDE_CHILD_SESSION_STAMP_ENV_KEYS = [ + 'CLAUDE_CODE_CHILD_SESSION', + 'CLAUDE_CODE_SESSION_ID', + 'CLAUDE_CODE_BRIDGE_SESSION_ID' +] as const + +function cloneProcessEnv(source: NodeJS.ProcessEnv): Record { + const env: Record = {} + for (const [key, value] of Object.entries(source)) { + if (value !== undefined) { + env[key] = value + } + } + return env +} + +function stripClaudeChildSessionStamps( + env: Record, + platform: NodeJS.Platform +): Record { + for (const key of CLAUDE_CHILD_SESSION_STAMP_ENV_KEYS) { + for (const envKey of Object.keys(env)) { + if (envKey === key || (platform === 'win32' && envKey.toUpperCase() === key)) { + delete env[envKey] + } + } + } + return env +} + +export function buildClaudeChildProcessEnv( + configuredEnv: Record = {}, + options: { + inheritedEnv?: NodeJS.ProcessEnv + platform?: NodeJS.Platform + scrubConfiguredChildSessionStamps?: boolean + } = {} +): Record { + const inheritedEnv = options.inheritedEnv ?? process.env + const platform = options.platform ?? process.platform + const env = applyClaudeEnvPatch( + cloneProcessEnv(inheritedEnv), + {}, + { + stripAuthEnv: true, + platform + } + ) + if (platform === 'win32') { + const authKeys = new Set(CLAUDE_AUTH_ENV_VARS.map((key) => key.toUpperCase())) + for (const [key, value] of Object.entries(env)) { + const normalized = key.toUpperCase() + if ( + authKeys.has(normalized) || + (normalized === 'ANTHROPIC_CUSTOM_HEADERS' && + /authorization|x-api-key|api-key|bearer/i.test(value)) + ) { + delete env[key] + } + } + } + if (options.scrubConfiguredChildSessionStamps) { + return stripClaudeChildSessionStamps({ ...env, ...configuredEnv }, platform) + } + stripClaudeChildSessionStamps(env, platform) + return { ...env, ...configuredEnv } +} diff --git a/src/main/claude/claude-child-root-termination.ts b/src/main/claude/claude-child-root-termination.ts new file mode 100644 index 00000000000..bed422532e1 --- /dev/null +++ b/src/main/claude/claude-child-root-termination.ts @@ -0,0 +1,54 @@ +import type { SpawnedProcess } from '../../shared/child-process/run-process' +import type { PosixProcessIdentity } from '../pty-descendant-termination' +import type { + WindowsDescendantSnapshot, + WindowsProcessIdentity +} from '../windows-descendant-exit-verification' + +export type ClaudeRootIdentity = PosixProcessIdentity | WindowsProcessIdentity + +type RootTerminationInput = { + child: Pick + exited: () => boolean +} + +/** + * Kills the root through the handle Node owns rather than through its pid, which + * is why no identity probe gates it: libuv drops that handle in the same turn it + * reaps, so the signal either reaches the process Orca spawned or reaches + * nothing. A probe here could only let an unreadable process table cost the tree + * the one fallback that still works once every table read has failed. + * + * False means no signal was sent, because the root had already left. + */ +export function terminateClaudeRoot(input: RootTerminationInput): boolean { + return input.exited() ? false : input.child.kill('SIGKILL') +} + +type WindowsRootTerminationInput = { + snapshot: WindowsDescendantSnapshot | null + exited: () => boolean + verifyRoot: (root: WindowsProcessIdentity) => Promise + terminateTree: (root: WindowsProcessIdentity) => Promise + killRoot: () => boolean +} + +/** + * `taskkill /T /F` addresses a bare pid, so a dead root's pid may already belong + * to a stranger whose whole tree it would take down: that one is identity-gated. + * The direct root kill after it runs however the probe decided. + */ +export async function terminateClaudeWindowsRoot( + input: WindowsRootTerminationInput +): Promise<{ rootVerified: boolean }> { + const { snapshot, exited, verifyRoot, terminateTree, killRoot } = input + let rootVerified = false + if (!exited() && snapshot) { + rootVerified = await verifyRoot(snapshot.root).catch(() => false) + if (rootVerified && !exited()) { + await terminateTree(snapshot.root).catch(() => {}) + } + } + killRoot() + return { rootVerified } +} diff --git a/src/main/claude/claude-child-tree-snapshot.ts b/src/main/claude/claude-child-tree-snapshot.ts new file mode 100644 index 00000000000..e0955648b02 --- /dev/null +++ b/src/main/claude/claude-child-tree-snapshot.ts @@ -0,0 +1,128 @@ +import type { DescendantSnapshot } from '../pty-descendant-termination' +import type { WindowsDescendantSnapshot } from '../windows-descendant-exit-verification' + +/** One platform's descendant tree, tagged so neither verifier can be handed the other's rows. */ +export type ClaudeCapturedTree = + | { platform: 'posix'; tree: DescendantSnapshot } + | { platform: 'win32'; tree: WindowsDescendantSnapshot } + +/** + * Process-table reads are not atomic: a refresh can omit a still-live row, but + * it can also observe a new process after the old row exited. Retain rows absent + * from the refresh, but reject a PID whose identity changed between reads. + */ +function mergeRowsByPid( + previous: readonly Row[], + next: readonly Row[], + sameIdentity: (previous: Row, next: Row) => boolean, + previousBoundary: (row: Row) => number, + nextBoundary: (row: Row) => number, + refreshBoundary: number +): { rows: Row[]; capturedAtMsByPid?: Readonly> } | null { + const merged = new Map() + const capturedAtMsByPid: Record = {} + for (const row of previous) { + const prior = merged.get(row.pid) + if (prior && !sameIdentity(prior, row)) { + return null + } + merged.set(row.pid, row) + capturedAtMsByPid[String(row.pid)] = previousBoundary(row) + } + for (const row of next) { + const prior = merged.get(row.pid) + if (prior && !sameIdentity(prior, row)) { + return null + } + if (!prior) { + capturedAtMsByPid[String(row.pid)] = nextBoundary(row) + } + merged.set(row.pid, row) + } + const boundaries = Object.values(capturedAtMsByPid) + const needsBoundaryMap = + new Set(boundaries).size > 1 || boundaries.some((boundary) => boundary !== refreshBoundary) + return { + rows: [...merged.values()], + ...(needsBoundaryMap ? { capturedAtMsByPid } : {}) + } +} + +export function mergeClaudeCapturedTrees( + previous: ClaudeCapturedTree, + next: ClaudeCapturedTree +): ClaudeCapturedTree | null { + if (previous.platform !== next.platform) { + return null + } + if (previous.platform === 'posix' && next.platform === 'posix') { + if (previous.tree.rootPgid !== next.tree.rootPgid) { + return null + } + // A refresh cannot repair an earlier capture that lacked root identity; + // retaining those rows would permit a later numeric-pid kill without proof. + if (!previous.tree.root || !next.tree.root) { + return null + } + if ( + previous.tree.root.pid !== next.tree.root.pid || + previous.tree.root.startedAt !== next.tree.root.startedAt + ) { + return null + } + const descendants = mergeRowsByPid( + previous.tree.descendants, + next.tree.descendants, + (left, right) => left.pgid === right.pgid && left.startedAt === right.startedAt, + (row) => previous.tree.capturedAtMsByPid?.[String(row.pid)] ?? previous.tree.capturedAtMs, + (row) => next.tree.capturedAtMsByPid?.[String(row.pid)] ?? next.tree.capturedAtMs, + next.tree.capturedAtMs + ) + if (!descendants) { + return null + } + return { + platform: 'posix', + tree: { + ...next.tree, + // Retained rows keep their earlier boundary; new rows use the refresh + // boundary. The scalar remains the latest scan for legacy consumers. + descendants: descendants.rows, + ...(descendants.capturedAtMsByPid + ? { capturedAtMsByPid: descendants.capturedAtMsByPid } + : {}) + } + } + } + if (previous.platform === 'win32' && next.platform === 'win32') { + if ( + previous.tree.root.pid !== next.tree.root.pid || + previous.tree.root.creationTimeMs !== next.tree.root.creationTimeMs + ) { + return null + } + const descendants = mergeRowsByPid( + previous.tree.descendants, + next.tree.descendants, + (left, right) => left.creationTimeMs === right.creationTimeMs, + (row) => previous.tree.capturedAtMsByPid?.[String(row.pid)] ?? previous.tree.capturedAtMs, + (row) => next.tree.capturedAtMsByPid?.[String(row.pid)] ?? next.tree.capturedAtMs, + next.tree.capturedAtMs + ) + if (!descendants) { + return null + } + return { + platform: 'win32', + tree: { + ...next.tree, + descendants: descendants.rows, + ...(descendants.capturedAtMsByPid + ? { capturedAtMsByPid: descendants.capturedAtMsByPid } + : {}), + unidentifiedCount: Math.max(previous.tree.unidentifiedCount, next.tree.unidentifiedCount) + } + } + } + return null +} diff --git a/src/main/claude/claude-command-lifecycle-frames.test.ts b/src/main/claude/claude-command-lifecycle-frames.test.ts new file mode 100644 index 00000000000..8126a546ed8 --- /dev/null +++ b/src/main/claude/claude-command-lifecycle-frames.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it, vi } from 'vitest' +import type { + AgentJournalItemBody, + AgentJournalItemIdentity +} from '../../shared/agent-session-journal-types' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { createClaudeJournalTranslator } from './claude-structured-journal-translation' + +function sinkState() { + const items: { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }[] = [] + const sink: StructuredAgentSessionEventSink = { + appendItem: (identity, body) => items.push({ identity, body }), + appendTombstone: () => {}, + publish: vi.fn() + } + return { sink, items } +} + +function providerFrameKinds(items: { body: AgentJournalItemBody }[]): string[] { + return items.flatMap((item) => + item.body.kind === 'status' && item.body.providerFrame ? [item.body.providerFrame.kind] : [] + ) +} + +/** + * The queue-bookkeeping frame Claude Code 2.1.258 emits for every uuid-stamped + * command: `command_uuid` plus a state, and no content of its own. Shape and + * states taken from the CLI's own emission sites. + */ +function commandLifecycle(state: 'started' | 'completed' | 'cancelled', uuid: string) { + return { + type: 'message' as const, + sessionId: 'orca-session', + message: { + type: 'command_lifecycle', + command_uuid: 'command-1', + state, + uuid, + session_id: 'claude-session' + } + } +} + +function userTurn(uuid: string, text: string) { + return { + type: 'message' as const, + sessionId: 'orca-session', + startsTurn: true as const, + message: { + type: 'user', + uuid, + session_id: 'claude-session', + parent_tool_use_id: null, + isReplay: true, + message: { role: 'user', content: text } + } + } +} + +function assistantReply(uuid: string, text: string) { + return { + type: 'message' as const, + sessionId: 'orca-session', + message: { + type: 'assistant', + uuid, + session_id: 'claude-session', + parent_tool_use_id: null, + message: { role: 'assistant', content: [{ type: 'text', text }] } + } + } +} + +describe('Claude command_lifecycle frames', () => { + it('keeps queue bookkeeping off the transcript for a whole turn', () => { + const state = sinkState() + const translator = createClaudeJournalTranslator({ sink: state.sink }) + + translator.handle(userTurn('user-1', 'Reply with exactly PROBE_OK and nothing else.')) + translator.handle(commandLifecycle('started', 'lifecycle-1')) + translator.handle(assistantReply('assistant-1', 'PROBE_OK')) + translator.handle(commandLifecycle('completed', 'lifecycle-2')) + translator.handle(commandLifecycle('completed', 'lifecycle-3')) + translator.handle({ + type: 'message', + sessionId: 'orca-session', + message: { + type: 'result', + subtype: 'success', + uuid: 'result-1', + session_id: 'claude-session', + is_error: false, + result: 'PROBE_OK', + terminal_reason: 'completed' + } + }) + + expect(providerFrameKinds(state.items)).toEqual([]) + // The turn's real content is untouched. + expect( + state.items.flatMap((item) => + item.body.kind === 'message' && item.body.role === 'assistant' ? [item.body.blocks] : [] + ) + ).toEqual([[{ type: 'text', text: 'PROBE_OK' }]]) + }) + + it('keeps a cancelled command off the transcript too', () => { + const state = sinkState() + const translator = createClaudeJournalTranslator({ sink: state.sink }) + + translator.handle(commandLifecycle('cancelled', 'lifecycle-4')) + + expect(providerFrameKinds(state.items)).toEqual([]) + }) +}) diff --git a/src/main/claude/claude-config-dir-pin.test.ts b/src/main/claude/claude-config-dir-pin.test.ts new file mode 100644 index 00000000000..c7be40a6f38 --- /dev/null +++ b/src/main/claude/claude-config-dir-pin.test.ts @@ -0,0 +1,34 @@ +import { homedir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { claudeConfigDirEnvPatch, defaultClaudeConfigDir } from './claude-config-dir-pin' + +describe('claude config dir pin', () => { + it('does not pin the CLI default home, so the macOS Keychain stays reachable', () => { + expect(claudeConfigDirEnvPatch(join(homedir(), '.claude'), { env: {} })).toEqual({}) + expect(claudeConfigDirEnvPatch(`${join(homedir(), '.claude')}/`, { env: {} })).toEqual({}) + expect(claudeConfigDirEnvPatch(' ', { env: {} })).toEqual({}) + }) + + it('pins a managed account home the CLI would not find on its own', () => { + expect(claudeConfigDirEnvPatch('/accounts/claude/managed', { env: {} })).toEqual({ + CLAUDE_CONFIG_DIR: '/accounts/claude/managed' + }) + }) + + it('treats an inherited CLAUDE_CONFIG_DIR as the default the CLI already resolves', () => { + const env = { CLAUDE_CONFIG_DIR: '/inherited/home' } + expect(defaultClaudeConfigDir(env)).toBe('/inherited/home') + expect(claudeConfigDirEnvPatch('/inherited/home', { env })).toEqual({}) + expect(claudeConfigDirEnvPatch('/other/home', { env })).toEqual({ + CLAUDE_CONFIG_DIR: '/other/home' + }) + }) + + it('compares Windows homes case-insensitively', () => { + const env = { CLAUDE_CONFIG_DIR: 'C:\\Users\\Work\\.claude' } + expect(claudeConfigDirEnvPatch('c:\\users\\work\\.claude', { env, platform: 'win32' })).toEqual( + {} + ) + }) +}) diff --git a/src/main/claude/claude-config-dir-pin.ts b/src/main/claude/claude-config-dir-pin.ts new file mode 100644 index 00000000000..d5cc0b8b186 --- /dev/null +++ b/src/main/claude/claude-config-dir-pin.ts @@ -0,0 +1,37 @@ +import { homedir } from 'node:os' +import { join, resolve } from 'node:path' + +/** The config dir the Claude CLI resolves for itself when nothing pins one. */ +export function defaultClaudeConfigDir(env: NodeJS.ProcessEnv = process.env): string { + return env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), '.claude') +} + +function samePath(a: string, b: string, platform: NodeJS.Platform): boolean { + const left = resolve(a) + const right = resolve(b) + return platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right +} + +/** + * An explicit CLAUDE_CONFIG_DIR moves the Claude CLI off the default Keychain item onto + * one derived from the pinned path, so a claude.ai OAuth login stops working even when + * the pin names the CLI's own default. Pin only a home the CLI would not find on its + * own — the same rule the legacy PTY path applies via `ClaudeRuntimePathResolver`. + * + * The pinned value is the account home verbatim: the CLI keys its credential lookup on + * the literal string, so re-spelling an equivalent path (absolute vs `~`, trailing + * separator) selects a different identity. Normalization here is for the equality test + * only and must never reach the env. + */ +export function claudeConfigDirEnvPatch( + accountHome: string, + options: { env?: NodeJS.ProcessEnv; platform?: NodeJS.Platform } = {} +): { CLAUDE_CONFIG_DIR?: string } { + const env = options.env ?? process.env + const platform = options.platform ?? process.platform + const resolved = accountHome.trim() + if (!resolved || samePath(resolved, defaultClaudeConfigDir(env), platform)) { + return {} + } + return { CLAUDE_CONFIG_DIR: resolved } +} diff --git a/src/main/claude/claude-descendant-escalation-boundary.test.ts b/src/main/claude/claude-descendant-escalation-boundary.test.ts new file mode 100644 index 00000000000..55459df0c7f --- /dev/null +++ b/src/main/claude/claude-descendant-escalation-boundary.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it, vi } from 'vitest' +import { terminateDescendantSnapshotWithVerdict } from '../pty-descendant-exit-verification' +import { + collectDescendantRows, + type DescendantSnapshot, + type ProcessTableRow +} from '../pty-descendant-termination' +import { createClaudeChildTreeReaper } from './claude-agent-sdk-exit-proof' + +const ROOT_PID = 500 +const ORCA_PGID = 400 +const ROOT_STARTED_AT = 'Thu Sep 3 18:04:50 2026' +/** The second both close-time walks land in. */ +const WALK_SECOND = 'Thu Sep 3 18:05:04 2026' +const WALK_MS = Date.parse(WALK_SECOND) +const EARLIER_SECOND = 'Thu Sep 3 18:05:03 2026' + +/** The measured split: `s20` at :03.946 died, `s21` at :04.042 leaked. */ +const EARLIER_BORN = [700, 701, 702] +const WALK_SECOND_BORN = [721, 722, 723, 724] + +type Cohort = { pids: number[]; startedAt: string } + +const LIVE_TREE: Cohort[] = [ + { pids: EARLIER_BORN, startedAt: EARLIER_SECOND }, + { pids: WALK_SECOND_BORN, startedAt: WALK_SECOND } +] + +function rowsFor(cohorts: Cohort[]): ProcessTableRow[] { + return [ + { pid: ROOT_PID, ppid: 1, pgid: ORCA_PGID, startedAt: ROOT_STARTED_AT }, + ...cohorts.flatMap((cohort) => + cohort.pids.map((pid) => ({ + pid, + ppid: ROOT_PID, + pgid: ORCA_PGID, + startedAt: cohort.startedAt + })) + ) + ] +} + +/** A real ppid walk from the root, exactly as production captures one. */ +function walk(capturedAtMs: number, cohorts: Cohort[] = LIVE_TREE): DescendantSnapshot { + return collectDescendantRows(ROOT_PID, rowsFor(cohorts), capturedAtMs) +} + +function killedPids(calls: [number, NodeJS.Signals][]): number[] { + return calls.flatMap(([pid, signal]) => (signal === 'SIGKILL' ? [pid] : [])).sort((a, b) => a - b) +} + +function signalledPids(calls: [number, NodeJS.Signals][]): number[] { + return calls.flatMap(([pid, signal]) => (signal === 'SIGTERM' ? [pid] : [])).sort((a, b) => a - b) +} + +/** + * Drives the real reaper and the real verifier against a process table where + * every descendant traps SIGTERM, so only a forced sweep can end them. The root + * is alive for both walks and gone by the sweep, which is the measured teardown. + */ +async function sweep( + captures: DescendantSnapshot[], + liveTree: Cohort[] = LIVE_TREE +): Promise<[number, NodeJS.Signals][]> { + const calls: [number, NodeJS.Signals][] = [] + const captureDescendants = vi.fn() + for (const capture of captures) { + captureDescendants.mockResolvedValueOnce(capture) + } + const tree = createClaudeChildTreeReaper( + { pid: ROOT_PID, kill: vi.fn(() => true) }, + { + platform: 'linux', + exited: () => false, + captureDescendants, + terminateDescendants: (snapshot) => + terminateDescendantSnapshotWithVerdict(snapshot, { + requireIdentityBeforeSignal: true, + graceMs: 0, + verifyMs: 120, + sendSignal: (pid, signal) => calls.push([pid, signal]), + readTable: async () => ({ rows: rowsFor(liveTree), capturedAtMs: Date.now() }) + }) + } + ) + // The close ladder's shape: arm, then re-walk the live root at the boundary. + await tree.capture() + await tree.refresh?.() + await tree.reap() + return calls +} + +describe('Claude descendant forced-sweep fence', () => { + it('escalates a descendant forked in the same second as both close walks', async () => { + // Both walks land inside second :04, one ps duration apart, and the root is + // gone before a third could run. A descendant born at :04.042 is no less + // ours than its sibling born 96ms earlier at :03.946. + const calls = await sweep([walk(WALK_MS + 42), walk(WALK_MS + 140)]) + + expect(signalledPids(calls)).toEqual([...EARLIER_BORN, ...WALK_SECOND_BORN]) + expect(killedPids(calls)).toEqual([...EARLIER_BORN, ...WALK_SECOND_BORN]) + }) + + it('still escalates descendants born before the walk that first saw them', async () => { + const onlyEarlier = [{ pids: EARLIER_BORN, startedAt: EARLIER_SECOND }] + const calls = await sweep([walk(WALK_MS + 42, onlyEarlier)], onlyEarlier) + + expect(killedPids(calls)).toEqual(EARLIER_BORN) + }) + + it('withholds the sweep from a row no walk re-derived, on its start second alone', async () => { + // 900 was seen once, in its own birth second, and the refresh did not find + // it. The merge retains the row, but nothing re-proved it belongs to us, so + // the second-resolution fence is all there is and it still says no. + const retained = { pids: [900], startedAt: WALK_SECOND } + const firstWalk = walk(WALK_MS + 42, [...LIVE_TREE, retained]) + const refresh = walk(WALK_MS + 140) + + const calls = await sweep([firstWalk, refresh], [...LIVE_TREE, retained]) + + expect(signalledPids(calls)).toEqual([...EARLIER_BORN, ...WALK_SECOND_BORN, 900]) + expect(killedPids(calls)).toEqual([...EARLIER_BORN, ...WALK_SECOND_BORN]) + }) +}) diff --git a/src/main/claude/claude-stream-json-connection-close.test.ts b/src/main/claude/claude-stream-json-connection-close.test.ts new file mode 100644 index 00000000000..e824139a776 --- /dev/null +++ b/src/main/claude/claude-stream-json-connection-close.test.ts @@ -0,0 +1,126 @@ +import { EventEmitter } from 'node:events' +import { PassThrough } from 'node:stream' +import type { ChildProcessWithoutNullStreams } from 'node:child_process' +import { describe, expect, it, vi } from 'vitest' +import type { query } from '@anthropic-ai/claude-agent-sdk' +import { + openClaudeStreamJsonConnection, + type ClaudeStreamJsonLaunch +} from './claude-stream-json-connection' + +const mocks = vi.hoisted(() => { + const refresh = vi.fn() + const proveClaudeChildExit = vi.fn() + const tree = { + capture: vi.fn(async () => {}), + refresh: (...args: unknown[]) => refresh(...args), + reap: vi.fn(async () => 'exited' as const), + treeVerdict: 'unverifiable' as const + } + return { proveClaudeChildExit, refresh, tree } +}) + +vi.mock('./claude-agent-sdk-exit-proof', () => ({ + createClaudeChildTreeReaper: vi.fn(() => mocks.tree), + proveClaudeChildExit: (...args: unknown[]) => mocks.proveClaudeChildExit(...args) +})) + +function fakeChild(): ChildProcessWithoutNullStreams { + const child = new EventEmitter() + return Object.assign(child, { + pid: 424242, + stdin: new PassThrough(), + stdout: new PassThrough(), + stderr: new PassThrough(), + kill: vi.fn() + }) as unknown as ChildProcessWithoutNullStreams +} + +describe('Claude stream-json close ordering', () => { + it('waits for the live tree refresh before ending stdin', async () => { + const refreshDone = Promise.withResolvers() + mocks.refresh.mockReturnValueOnce(refreshDone.promise) + mocks.proveClaudeChildExit.mockResolvedValueOnce(true) + const child = fakeChild() + const launch: ClaudeStreamJsonLaunch = { + pathToClaudeCodeExecutable: 'claude', + options: {}, + cwd: '/work/repo' + } + const queryImpl = ((params: Parameters[0]) => { + if (!params.options) { + throw new Error('missing SDK options') + } + params.options.spawnClaudeCodeProcess?.({ + command: 'claude', + args: [], + env: {}, + signal: new AbortController().signal + }) + void (async () => { + for await (const _message of params.prompt) { + // The SDK owns the transport write; the close test only needs its EOF boundary. + } + child.stdin.end() + })() + return (async function* () {})() + }) as typeof query + const connection = await openClaudeStreamJsonConnection(launch, {}, () => child, queryImpl) + + const closing = connection.close() + await new Promise((resolve) => setImmediate(resolve)) + expect(child.stdin.writableEnded).toBe(false) + + refreshDone.resolve() + await expect(closing).resolves.toBe(true) + expect(child.stdin.writableEnded).toBe(true) + }) + + it('requests a fresh close boundary after an output capture starts', async () => { + mocks.refresh.mockReset() + mocks.proveClaudeChildExit.mockReset() + const outputCapture = Promise.withResolvers() + const closeCapture = Promise.withResolvers() + mocks.refresh + .mockReturnValueOnce(outputCapture.promise) + .mockReturnValueOnce(closeCapture.promise) + mocks.proveClaudeChildExit.mockResolvedValueOnce(true) + const child = fakeChild() + const launch: ClaudeStreamJsonLaunch = { + pathToClaudeCodeExecutable: 'claude', + options: {}, + cwd: '/work/repo' + } + const queryImpl = ((params: Parameters[0]) => { + params.options?.spawnClaudeCodeProcess?.({ + command: 'claude', + args: [], + env: {}, + signal: new AbortController().signal + }) + void (async () => { + for await (const _message of params.prompt) { + // The SDK owns the transport write; the close test only needs its EOF boundary. + } + child.stdin.end() + })() + return (async function* () {})() + }) as typeof query + const connection = await openClaudeStreamJsonConnection(launch, {}, () => child, queryImpl) + + child.stderr.emit('data', 'output') + await vi.waitFor(() => expect(mocks.refresh).toHaveBeenCalledTimes(1)) + const closing = connection.close() + await Promise.resolve() + + expect(mocks.refresh).toHaveBeenCalledTimes(2) + expect(child.stdin.writableEnded).toBe(false) + + outputCapture.resolve() + await Promise.resolve() + expect(child.stdin.writableEnded).toBe(false) + closeCapture.resolve() + await expect(closing).resolves.toBe(true) + expect(child.stdin.writableEnded).toBe(true) + }) +}) diff --git a/src/main/claude/claude-stream-json-connection.test.ts b/src/main/claude/claude-stream-json-connection.test.ts new file mode 100644 index 00000000000..c4f1f9a6fca --- /dev/null +++ b/src/main/claude/claude-stream-json-connection.test.ts @@ -0,0 +1,768 @@ +import { execFileSync } from 'node:child_process' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { spawnProcess, type SpawnedProcess } from '../../shared/child-process/run-process' +import { hasLiveClaudePtys } from '../claude-accounts/live-pty-gate' +import type { ProcessSpec } from '../../shared/child-process/process-spec' +import { query, type CanUseTool, type Options } from '@anthropic-ai/claude-agent-sdk' +import { + openClaudeStreamJsonConnection, + type ClaudeStreamJsonConnection, + type ClaudeStreamJsonLaunch +} from './claude-stream-json-connection' +import { openAgentSessionJournal } from '../native-chat/agent-session-journal/journal-store-factory' +import { createDeferredStructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { claudeAuthDiagnostic } from './claude-structured-init-proof' +import { createClaudeJournalTranslator } from './claude-structured-journal-translation' +import { readClaudeStructuredSessionOptions } from './claude-structured-session-options' +import type { ClaudeSession } from './claude-structured-session-state' +import { CLAUDE_STRUCTURED_BASE_OPTIONS } from './claude-structured-launch-resolution' + +// These drive the real SDK against the scripted fake CLI, so every assertion is +// about the environment, argv and frames a real child actually saw. +const FAKE_CLI = join(__dirname, '__fixtures__', 'claude-agent-sdk-scripted-cli.mjs') +const SESSION_ID = '5348c19f-6a54-4c2e-9c68-9c2b1a3d4e5f' +const HOLD_OPEN = { delayMs: 10_000 } + +type ScriptedCliReport = { + argv: string[] + controlRequests: { request_id: string; request: { subtype: string } }[] + controlResponses: { response: { request_id: string; response?: unknown } }[] + userMessages: Record[] + descendantPid: number | null +} + +const scratchDirs: string[] = [] +const openConnections: ClaudeStreamJsonConnection[] = [] + +afterEach(async () => { + for (const connection of openConnections.splice(0)) { + await connection.close() + } + for (const dir of scratchDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } + spawned.splice(0) + spawnedChildren.splice(0) + vi.unstubAllEnvs() +}) + +function scriptScenario( + steps: Record[], + controlResponses: Record = {} +) { + const dir = mkdtempSync(join(tmpdir(), 'claude-sdk-connection-')) + scratchDirs.push(dir) + const scenarioPath = join(dir, 'scenario.json') + const reportPath = join(dir, 'report.json') + writeFileSync(scenarioPath, JSON.stringify({ steps, controlResponses })) + return { + cwd: dir, + env: { + PATH: process.env.PATH ?? '', + ORCA_SDK_CONTRACT_SCENARIO_PATH: scenarioPath, + ORCA_SDK_CONTRACT_REPORT_PATH: reportPath + }, + readReport: () => JSON.parse(readFileSync(reportPath, 'utf8')) as ScriptedCliReport + } +} + +function launchFor( + scenario: { cwd: string; env: Record }, + env: Record = {} +): ClaudeStreamJsonLaunch { + return { + pathToClaudeCodeExecutable: FAKE_CLI, + options: { ...CLAUDE_STRUCTURED_BASE_OPTIONS, sessionId: SESSION_ID }, + cwd: scenario.cwd, + env: { ...scenario.env, ...env } + } +} + +/** The derived child environment, captured where Orca actually hands it to the OS. */ +const spawned: ProcessSpec[] = [] +/** The retained child, so a test can end it the way a crashing CLI would. */ +const spawnedChildren: SpawnedProcess[] = [] + +async function open( + launch: ClaudeStreamJsonLaunch, + handlers: Parameters[1] = {}, + queryImpl?: typeof query +): Promise { + const connection = await openClaudeStreamJsonConnection( + launch, + handlers, + (spec) => { + spawned.push(spec) + const child = spawnProcess(spec) + spawnedChildren.push(child) + return child + }, + queryImpl + ) + openConnections.push(connection) + return connection +} + +function childEnv(): Record { + return (spawned.at(-1)?.env ?? {}) as Record +} + +async function until(read: () => T | null | undefined, label: string): Promise { + for (let attempt = 0; attempt < 400; attempt++) { + const value = read() + if (value !== null && value !== undefined) { + return value + } + await new Promise((resolve) => setTimeout(resolve, 25)) + } + throw new Error(`timed out waiting for ${label}`) +} + +function readReportSafely(scenario: { readReport: () => ScriptedCliReport }) { + try { + return scenario.readReport() + } catch { + return null + } +} + +function processState(pid: number): 'running' | 'exited' { + try { + const state = execFileSync('ps', ['-o', 'state=', '-p', String(pid)], { + encoding: 'utf8', + env: { ...process.env, LANG: 'C', LC_ALL: 'C' } + }).trim() + return state.startsWith('Z') ? 'exited' : 'running' + } catch (error) { + if ((error as { status?: number }).status === 1) { + return 'exited' + } + throw error + } +} + +describe('Claude stream-json connection', () => { + it('passes the Claude Code system-prompt preset through to SDK query', async () => { + const scenario = scriptScenario([HOLD_OPEN]) + let captured: Options | undefined + await open(launchFor(scenario), {}, (params) => { + captured = params.options + return query(params) + }) + + expect(captured?.systemPrompt).toEqual({ type: 'preset', preset: 'claude_code' }) + }) + + it('hands the child a derived environment, the resolved CLI path, and keeps the pid', async () => { + vi.stubEnv('ANTHROPIC_API_KEY', 'sk-ant-SHELL-LEAK') + vi.stubEnv('CLAUDE_CODE_CHILD_SESSION', '1') + vi.stubEnv('NODE_OPTIONS', '--require=/tmp/inject.js') + // An inherited value wins over the SDK's default, so clear it to pin the default. + vi.stubEnv('CLAUDE_CODE_ENTRYPOINT', undefined) + vi.stubEnv('ORCA_CONNECTION_MARKER', 'inherited') + const scenario = scriptScenario([HOLD_OPEN]) + const connection = await open( + launchFor(scenario, { + CLAUDE_CONFIG_DIR: '/accounts/managed/home', + ANTHROPIC_AUTH_TOKEN: 'configured-token', + ORCA_AGENT_SESSION_SPAWN_TOKEN: 'spawn-9', + CLAUDE_CODE_CHILD_SESSION: 'configured-child-session', + CLAUDE_CODE_SESSION_ID: 'configured-session', + CLAUDE_CODE_BRIDGE_SESSION_ID: 'configured-bridge-session' + }) + ) + + // Ownership proof: the pid is a real live process, not a value the SDK reported. + expect(connection.pid).toEqual(expect.any(Number)) + expect(() => process.kill(connection.pid as number, 0)).not.toThrow() + const env = childEnv() + // The managed home is pinned verbatim: the CLI keys credential lookup on the literal string. + expect(env.CLAUDE_CONFIG_DIR).toBe('/accounts/managed/home') + expect(env.ANTHROPIC_AUTH_TOKEN).toBe('configured-token') + expect(env.ORCA_AGENT_SESSION_SPAWN_TOKEN).toBe('spawn-9') + expect(env.ORCA_CONNECTION_MARKER).toBe('inherited') + expect(env.ANTHROPIC_API_KEY).toBeUndefined() + expect(env.CLAUDE_CODE_CHILD_SESSION).toBeUndefined() + expect(env.CLAUDE_CODE_SESSION_ID).toBeUndefined() + expect(env.CLAUDE_CODE_BRIDGE_SESSION_ID).toBeUndefined() + // Two SDK mutations of the child env, pinned so a bump cannot change them unseen. + expect(env.CLAUDE_CODE_ENTRYPOINT).toBe('sdk-ts') + expect(env.NODE_OPTIONS).toBeUndefined() + // The bundled binary is excluded from the install, so the resolved path is mandatory. + const report = await until(() => readReportSafely(scenario), 'the scripted CLI report') + expect(report.argv[0]).toBe(FAKE_CLI) + // The .mjs fixture makes the SDK run it under node; a real CLI path is the program + // itself. Either way the resolved path is what Orca's spawner is asked to execute. + expect([spawned.at(-1)?.program, ...(spawned.at(-1)?.args ?? [])]).toContain(FAKE_CLI) + expect(report.argv).toContain('--replay-user-messages') + expect(report.argv).toContain(`--session-id=${SESSION_ID}`) + }) + + it('leaves the default CLI home unpinned so macOS Keychain OAuth keeps working', async () => { + const scenario = scriptScenario([HOLD_OPEN]) + await open(launchFor(scenario)) + + await until(() => readReportSafely(scenario), 'the scripted CLI report') + expect(childEnv().CLAUDE_CONFIG_DIR).toBeUndefined() + }) + + it('settles a send only once the frame reached the child, and replays reach onMessage', async () => { + const replay = { + type: 'user', + message: { role: 'user', content: [{ type: 'text', text: 'hello' }] }, + parent_tool_use_id: null, + isReplay: true, + session_id: SESSION_ID, + uuid: 'uuid-replay-1' + } + const scenario = scriptScenario([{ awaitUserMessage: true }, { emit: replay }, HOLD_OPEN]) + const messages: Record[] = [] + const connection = await open(launchFor(scenario), { + onMessage: (message) => messages.push(message) + }) + + await connection.send({ + type: 'user', + message: { role: 'user', content: [{ type: 'text', text: 'hello' }] }, + parent_tool_use_id: null, + session_id: SESSION_ID + }) + // The report exists from the child's first line of work, so poll for the frame + // itself: `send` settles on the SDK's completed write, and the child still has + // to read that line before it can record it. + const report = await until( + () => (readReportSafely(scenario)?.userMessages.length ? readReportSafely(scenario) : null), + 'the user frame recorded by the child' + ) + expect(report.userMessages).toHaveLength(1) + + await until(() => messages.find((message) => message.uuid === 'uuid-replay-1'), 'the replay') + // The replay is delivered verbatim, so the dispatch acknowledgement still binds on it. + expect(messages.find((message) => message.uuid === 'uuid-replay-1')).toEqual(replay) + }) + + it('rejects a send the SDK pulled but could not write to a terminated child', async () => { + const scenario = scriptScenario([{ awaitUserMessage: true }, HOLD_OPEN]) + const connection = await open(launchFor(scenario)) + const child = spawnedChildren.at(-1) + + // Same tick as the send, so the liveness guard still passes and the frame + // reaches the SDK's input pump: its `transport.write` is what fails, which is + // the window a child crashing mid-send actually opens. + child?.kill('SIGKILL') + const sent = connection.send({ + type: 'user', + message: { role: 'user', content: [{ type: 'text', text: 'hello' }] }, + parent_tool_use_id: null, + session_id: SESSION_ID + }) + + await expect(sent).rejects.toThrow() + expect(readReportSafely(scenario)?.userMessages ?? []).toHaveLength(0) + }) + + it('delivers an unmodeled frame verbatim so the provider-fallback row survives', async () => { + const unknown = { + type: 'frame_kind_from_the_future', + session_id: SESSION_ID, + uuid: 'uuid-unknown-1', + payload: { nested: { flags: ['a', 'b'] } } + } + const scenario = scriptScenario([{ emit: unknown }, HOLD_OPEN]) + const messages: Record[] = [] + await open(launchFor(scenario), { onMessage: (message) => messages.push(message) }) + + await until(() => messages.find((message) => message.uuid === 'uuid-unknown-1'), 'the frame') + expect(messages.find((message) => message.uuid === 'uuid-unknown-1')).toEqual(unknown) + }) + + it('commits the real partial-message cadence as one assistant item through the translator', async () => { + // The frame order and per-frame uuids are the ones Claude Code 2.1.258 emits + // under --include-partial-messages: every stream_event and the block's final + // assistant frame each carry their own uuid; only message.id ties them. + const stream = (uuid: string, event: Record) => ({ + type: 'stream_event', + uuid, + session_id: SESSION_ID, + parent_tool_use_id: null, + event + }) + const frames = [ + stream('uuid-message-start', { + type: 'message_start', + message: { id: 'msg_01', role: 'assistant', content: [] } + }), + stream('uuid-block-start', { + type: 'content_block_start', + index: 0, + content_block: { type: 'text', text: '' } + }), + stream('uuid-delta-1', { + type: 'content_block_delta', + index: 0, + delta: { type: 'text_delta', text: 'ST' } + }), + stream('uuid-delta-2', { + type: 'content_block_delta', + index: 0, + delta: { type: 'text_delta', text: 'REAMOK_ELEC_64E632' } + }), + { + type: 'assistant', + uuid: 'uuid-assistant-final', + session_id: SESSION_ID, + parent_tool_use_id: null, + message: { + id: 'msg_01', + role: 'assistant', + content: [{ type: 'text', text: 'STREAMOK_ELEC_64E632' }], + stop_reason: null + } + }, + stream('uuid-block-stop', { type: 'content_block_stop', index: 0 }), + stream('uuid-message-delta', { type: 'message_delta', delta: { stop_reason: 'end_turn' } }), + stream('uuid-message-stop', { type: 'message_stop' }), + { + type: 'result', + subtype: 'success', + is_error: false, + duration_ms: 1, + duration_api_ms: 1, + num_turns: 1, + result: 'STREAMOK_ELEC_64E632', + stop_reason: 'end_turn', + session_id: SESSION_ID, + uuid: 'uuid-result' + } + ] + const scenario = scriptScenario([...frames.map((frame) => ({ emit: frame })), HOLD_OPEN]) + const journal = await openAgentSessionJournal({ + identity: { + sessionId: 'session-1', + workspaceId: 'workspace-1', + hostId: 'host-1', + agent: 'claude', + providerHandle: { kind: 'claude', sessionId: SESSION_ID, leafUuid: 'leaf-1' } + }, + journalDir: join(scenario.cwd, 'journal'), + now: () => 1_700_000_000_000, + mintEpoch: () => 'epoch-1' + }) + const deferred = createDeferredStructuredAgentSessionEventSink() + deferred.bind({ journal, fence: 1, publish: vi.fn() }) + const translator = createClaudeJournalTranslator({ sink: deferred.sink }) + let settled = false + await open(launchFor(scenario), { + onMessage: (message) => { + translator.handle({ type: 'message', sessionId: 'session-1', message }) + settled ||= message.type === 'result' + } + }) + + await until(() => (settled ? true : null), 'the result frame') + await deferred.drained() + const items = journal.snapshot().items + const assistant = items.filter( + (item) => item.body.kind === 'message' && item.body.role === 'assistant' + ) + expect(assistant.map((item) => item.body)).toEqual([ + { + kind: 'message', + role: 'assistant', + blocks: [{ type: 'text', text: 'STREAMOK_ELEC_64E632' }] + } + ]) + expect(assistant.map((item) => item.itemId)).toEqual([`claude:${SESSION_ID}:uuid-block-start`]) + expect( + items.flatMap((item) => + item.body.kind === 'status' && item.body.providerFrame ? [item.body.providerFrame.kind] : [] + ) + ).toEqual([]) + // The journal owns a SQLite connection now; afterEach removes this temp root and an open + // handle blocks that on Windows. + await journal.close() + }) + + it('feeds an inbound permission request to canUseTool and writes its answer back on the same id', async () => { + const scenario = scriptScenario([ + { + emit: { + type: 'control_request', + request_id: 'perm-421', + request: { + subtype: 'can_use_tool', + tool_name: 'Bash', + input: { command: 'ls' }, + tool_use_id: 'toolu_1', + permission_suggestions: [{ type: 'addRules' }] + } + } + }, + { awaitControlResponse: 'perm-421' }, + HOLD_OPEN + ]) + const seen: { toolName: string; requestId: string; toolUseID: string; suggestions: unknown }[] = + [] + const canUseTool: CanUseTool = (toolName, _input, options) => { + seen.push({ + toolName, + requestId: options.requestId, + toolUseID: options.toolUseID, + suggestions: options.suggestions + }) + return Promise.resolve({ behavior: 'deny', message: 'No', toolUseID: options.toolUseID }) + } + await open(launchFor(scenario), { canUseTool }) + + await until(() => (seen.length > 0 ? seen : null), 'the inbound permission request') + expect(seen).toEqual([ + { + toolName: 'Bash', + requestId: 'perm-421', + toolUseID: 'toolu_1', + suggestions: [{ type: 'addRules' }] + } + ]) + const written = await until( + () => + readReportSafely(scenario)?.controlResponses.find( + (frame) => frame.response.request_id === 'perm-421' + ), + 'the permission answer' + ) + expect(written.response.response).toMatchObject({ behavior: 'deny', message: 'No' }) + }) + + it('drives Orca control methods onto the SDK and times out with the init proof message', async () => { + const scenario = scriptScenario([HOLD_OPEN], { + initialize: { models: [{ value: 'sonnet' }], account: { tokenSource: 'oauth' } }, + get_settings: { env: { ANTHROPIC_BASE_URL: 'https://settings.example.test' } } + }) + const connection = await open(launchFor(scenario)) + + await expect(connection.initializationResult()).resolves.toMatchObject({ + models: [{ value: 'sonnet' }] + }) + await expect(connection.getSettings()).resolves.toEqual({ + env: { ANTHROPIC_BASE_URL: 'https://settings.example.test' } + }) + await expect(connection.setModel('opus')).resolves.toBeUndefined() + const requests = await until( + () => + readReportSafely(scenario)?.controlRequests.find( + (frame) => frame.request.subtype === 'set_model' + ), + 'the set_model control request' + ) + expect(requests.request.subtype).toBe('set_model') + }) + + it('reads supportedModels from the catalog the running CLI reported', async () => { + const scenario = scriptScenario([HOLD_OPEN], { + initialize: { + models: [ + { value: 'default', resolvedModel: 'claude-opus-5' }, + { + value: 'opus', + displayName: 'Opus 5', + description: 'The live row, not the seed', + resolvedModel: 'claude-opus-5', + supportsEffort: true, + supportedEffortLevels: ['low', 'high'] + } + ] + } + }) + const connection = await open(launchFor(scenario)) + + await expect(connection.supportedModels()).resolves.toMatchObject([ + { value: 'default', resolvedModel: 'claude-opus-5' }, + { value: 'opus', displayName: 'Opus 5', supportedEffortLevels: ['low', 'high'] } + ]) + }) + + it('serves the picker the live catalog rather than falling back to the static seed', async () => { + const scenario = scriptScenario([HOLD_OPEN], { + initialize: { + models: [ + { value: 'default', resolvedModel: 'claude-opus-5' }, + { + value: 'opus', + displayName: 'Opus 5', + description: 'The live row, not the seed', + resolvedModel: 'claude-opus-5', + supportsEffort: true, + supportedEffortLevels: ['low', 'high'] + } + ] + } + }) + const connection = await open(launchFor(scenario)) + const session = { + connection, + options: new Map(), + reportedOptions: {} + } as unknown as ClaudeSession + + const options = await readClaudeStructuredSessionOptions(session, 5_000) + + // The seed carries neither this description nor a two-level effort list, so + // both can only have come from the child. + expect(options.models).toContainEqual({ + id: 'opus', + label: 'Opus 5', + description: 'The live row, not the seed', + isDefault: true, + efforts: [ + { value: 'low', label: 'Low' }, + { value: 'high', label: 'High' } + ] + }) + expect(options.current.model).toBe('opus') + }) + + it('feeds the auth diagnostic from the settings the running child reports', async () => { + for (const key of ['ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_API_KEY']) { + vi.stubEnv(key, undefined) + } + const scenario = scriptScenario([HOLD_OPEN], { + get_settings: { + env: { + ANTHROPIC_BASE_URL: 'https://settings.example.test', + ANTHROPIC_AUTH_TOKEN: 'secret' + } + } + }) + const connection = await open(launchFor(scenario)) + const init = { providerSessionId: SESSION_ID, uuid: null, model: null, message: {} } + + // With no ambient auth, every true below can only have come from the CLI's settings. + expect(claudeAuthDiagnostic(init, null)).toMatchObject({ + baseUrlConfigured: false, + authTokenConfigured: false + }) + const diagnostic = claudeAuthDiagnostic(init, await connection.getSettings()) + expect(diagnostic).toMatchObject({ + baseUrlConfigured: true, + authTokenConfigured: true, + apiKeyConfigured: false + }) + expect(JSON.stringify(diagnostic)).not.toContain('secret') + }) + + it('reports an unauthenticated start through the init deadline instead of hanging', async () => { + // The scripted CLI never answers, which is the shape of a silently unauthenticated CLI. + const scenario = scriptScenario([HOLD_OPEN]) + const connection = await open({ + ...launchFor(scenario), + env: { ...launchFor(scenario).env, ORCA_SDK_CONTRACT_IGNORE_CONTROL_REQUESTS: '1' } + }) + + await expect(connection.initializationResult({ timeoutMs: 200 })).rejects.toThrow( + 'claude initialize request timed out' + ) + }) + + it('reports a self-exit with its status and stderr, and leaves its tree unverifiable', async () => { + const scenario = scriptScenario([{ stderr: 'claude: not signed in\n' }, { exit: 1 }]) + let exit: Error | null = null + const connection = await open(launchFor(scenario), { + onExit: (error) => { + exit = error + } + }) + + await until(() => exit, 'the exit error') + // The status and stderr are the only diagnostic a refused start leaves behind. + expect((exit as unknown as Error).message).toMatch(/exited \(code 1\): claude: not signed in/) + expect(connection.closed).toBe(true) + // The root's exit is first-hand, but it left before a descendant snapshot + // could be armed, so close() has no tree proof to offer and says so. + await expect(connection.close()).resolves.toBe(false) + expect(connection.exitVerdict).toEqual({ root: 'exited', tree: 'unverifiable' }) + }) + + it.runIf(process.platform !== 'win32')( + 'proves a natural SDK exit and cleans up its descendant before recovery', + async () => { + const scenario = scriptScenario([ + { stderr: 'claude: natural exit\n' }, + { delayMs: 500 }, + { exit: 1 } + ]) + let exit: Error | null = null + const connection = await open( + { + ...launchFor(scenario), + env: { ...launchFor(scenario).env, ORCA_SDK_CONTRACT_DESCENDANT: '1' } + }, + { onExit: (error) => (exit = error) } + ) + const report = await until(() => { + const current = readReportSafely(scenario) + return current?.descendantPid ? current : null + }, 'the descendant report') + await until(() => exit, 'the natural exit error') + try { + await expect(connection.close()).resolves.toBe(true) + expect(connection.exitVerdict).toEqual({ root: 'exited', tree: 'exited' }) + expect(processState(report.descendantPid as number)).toBe('exited') + } finally { + try { + process.kill(report.descendantPid as number, 'SIGKILL') + } catch { + // Already gone. + } + } + }, + 20_000 + ) + + it('settles a spawn error followed by close as processless and closes idempotently', async () => { + const scenario = scriptScenario([HOLD_OPEN]) + const missingCli = join(scenario.cwd, 'claude-that-does-not-exist') + let fault: Error | null = null + let exit: Error | null = null + const connection = await open( + { ...launchFor(scenario), pathToClaudeCodeExecutable: missingCli }, + { + onFault: (error) => { + fault = error + }, + onExit: (error) => { + exit = error + } + } + ) + + await until( + () => (connection.exitVerdict.root === 'processless' ? connection.exitVerdict : null), + 'the processless spawn settlement' + ) + expect(connection.pid).toBeUndefined() + expect(fault).toBeInstanceOf(Error) + expect(exit).toBeNull() + await expect(Promise.all([connection.close(), connection.close()])).resolves.toEqual([ + true, + true + ]) + await expect(connection.close()).resolves.toBe(true) + expect(connection.exitVerdict).toEqual({ root: 'processless', tree: 'exited' }) + }) + + it('does not treat a child error event as first-hand root exit proof', async () => { + const scenario = scriptScenario([HOLD_OPEN]) + let exit: Error | null = null + const connection = await open(launchFor(scenario), { + onExit: (error) => { + exit = error + } + }) + const child = spawnedChildren.at(-1) + expect(child).toBeDefined() + + child?.emit('error', new Error('child transport fault')) + + expect(exit).toBeNull() + expect(connection.exitVerdict.root).toBe('live') + await until(() => exit, 'the distinct child exit') + expect(connection.exitVerdict.root).toBe('exited') + }) + + it('proves the exit of a child that ignores a graceful shutdown', async () => { + const scenario = scriptScenario([HOLD_OPEN]) + const connection = await open({ + ...launchFor(scenario), + env: { ...launchFor(scenario).env, ORCA_SDK_CONTRACT_IGNORE_SIGTERM: '1' } + }) + + // Keep the lstart capture boundary outside the child's displayed start second. + await new Promise((resolve) => setTimeout(resolve, 1_100)) + await expect(connection.close()).resolves.toBe(true) + }, 20_000) +}) + +// A structured Claude child owns the account's credentials while it runs, exactly as +// a Claude PTY does. The gate is what makes runtime-auth-sync defer the managed OAuth +// refresh instead of rotating the single-use token out from under a live session, and +// structured sessions used to be invisible to it. +describe('the managed-auth live gate', () => { + it('holds while a structured child runs and releases when it ends', async () => { + // The gate is a process-wide singleton and a sibling test's release lands on its + // child's 'close' event, which can settle after that test's close() resolved. + await until(() => (hasLiveClaudePtys() ? null : true), 'a drained auth gate') + const scenario = scriptScenario([ + { emit: { type: 'system', subtype: 'init', session_id: SESSION_ID, uuid: 'init-1' } }, + { wait: HOLD_OPEN } + ]) + const connection = await open(launchFor(scenario)) + + expect(hasLiveClaudePtys()).toBe(true) + + await connection.close() + + await until(() => (hasLiveClaudePtys() ? null : true), 'the auth gate to drain') + expect(hasLiveClaudePtys()).toBe(false) + }, 30_000) + + it('releases when the child dies on its own rather than through close()', async () => { + await until(() => (hasLiveClaudePtys() ? null : true), 'a drained auth gate') + const scenario = scriptScenario([ + { emit: { type: 'system', subtype: 'init', session_id: SESSION_ID, uuid: 'init-1' } }, + { wait: HOLD_OPEN } + ]) + await open(launchFor(scenario)) + expect(hasLiveClaudePtys()).toBe(true) + + spawnedChildren.at(-1)?.kill('SIGKILL') + + await until(() => (hasLiveClaudePtys() ? null : true), 'the auth gate to drain') + expect(hasLiveClaudePtys()).toBe(false) + }, 30_000) + + // The gate entry is deliberately unpersisted, so confirmSeededClaudeLivePtys can never + // reconcile a stray one: a leak here defers the managed OAuth refresh for the life of + // the process. Entering the gate only after the release handlers are attached makes + // that unreachable regardless of what the setup in between does. + it('leaks no gate entry when setup throws between spawn and handler attachment', async () => { + await until(() => (hasLiveClaudePtys() ? null : true), 'a drained auth gate') + const scenario = scriptScenario([ + { emit: { type: 'system', subtype: 'init', session_id: SESSION_ID, uuid: 'init-1' } }, + { wait: HOLD_OPEN } + ]) + let started: SpawnedProcess | null = null + + try { + await expect( + openClaudeStreamJsonConnection(launchFor(scenario), {}, (spec) => { + const child = spawnProcess(spec) + started = child + const attach = child.stderr.on.bind(child.stderr) + // Measured attach order: the SDK binds stderr 'data' from inside query(), + // before the child is even assigned. The SECOND bind is this connection's own + // armTreeOnOutput — the first statement that runs after the child exists and + // before its 'exit'/'close' release handlers. Throwing on the first is + // vacuous: it escapes before any gate entry could have happened. + let dataAttaches = 0 + child.stderr.on = ((event: string, listener: (...args: unknown[]) => void) => { + if (event === 'data') { + dataAttaches += 1 + if (dataAttaches === 2) { + throw new Error('stderr listener attach failed') + } + } + return attach(event, listener) + }) as typeof child.stderr.on + return child + }) + ).rejects.toThrow('stderr listener attach failed') + + expect(hasLiveClaudePtys()).toBe(false) + } finally { + ;(started as SpawnedProcess | null)?.kill('SIGKILL') + } + }, 30_000) +}) diff --git a/src/main/claude/claude-stream-json-connection.ts b/src/main/claude/claude-stream-json-connection.ts new file mode 100644 index 00000000000..dd6bbc8a5eb --- /dev/null +++ b/src/main/claude/claude-stream-json-connection.ts @@ -0,0 +1,283 @@ +import { randomUUID } from 'node:crypto' +import type * as ClaudeAgentSdk from '@anthropic-ai/claude-agent-sdk' +import type { CanUseTool, OnUserDialog, SDKUserMessage } from '@anthropic-ai/claude-agent-sdk' +import { spawnProcess } from '../../shared/child-process/run-process' +import { + markClaudeStructuredChildExited, + markClaudeStructuredChildSpawned +} from '../claude-accounts/live-pty-gate' +import { buildClaudeChildProcessEnv } from './claude-child-process-environment' +import { + ClaudeControlRequestError, + createClaudeControlSurface, + type ClaudeControlSurface +} from './claude-agent-sdk-control-requests' +import { createClaudeChildTreeReaper, proveClaudeChildExit } from './claude-agent-sdk-exit-proof' +import type { DescendantTreeVerdict } from '../pty-descendant-exit-verification' +import { createClaudeCodeProcessSpawn } from './claude-agent-sdk-process-spawn' +import { createClaudeUserMessageQueue } from './claude-agent-sdk-user-message-queue' +import type { ClaudeStructuredSdkOptions } from './claude-structured-launch-resolution' + +export { ClaudeControlRequestError } + +/** + * The SDK is loaded at the structured-Claude boundary rather than by this module's + * import. The ordinary runtime's class graph statically reaches this file, and the + * SDK sets `process.env.NoDefaultCurrentDirectoryInExePath` at import time — a + * Windows executable-search change that a user who never leaves the terminal/TUI + * path never opted into, and a missing SDK would fail runtime startup. Memoized, + * so a session pays the import once per process rather than once per connection. + */ +let claudeAgentSdk: Promise | null = null + +function loadClaudeAgentSdk(): Promise { + claudeAgentSdk ??= import('@anthropic-ai/claude-agent-sdk') + return claudeAgentSdk +} + +export type ClaudeStreamJsonLaunch = { + /** Orca's resolved user CLI; the SDK falls back to a bundled binary that is not installed. */ + pathToClaudeCodeExecutable: string + options: ClaudeStructuredSdkOptions + cwd: string + env?: Record +} + +export type ClaudeStreamJsonConnectionHandlers = { + onMessage?: (message: Record) => void + /** + * The SDK owns inbound permission control: it hands `can_use_tool` to this callback with + * a stable requestId and an abort signal, dedups duplicate delivery, and matches the + * response by request_id itself. Setting it makes the SDK pass `--permission-prompt-tool + * stdio` automatically; it must not be paired with `permissionPromptToolName`. + */ + canUseTool?: CanUseTool + /** `request_user_dialog` control; the CLI only emits kinds declared in `supportedDialogKinds`. */ + onUserDialog?: OnUserDialog + /** A transport/process fault that is not itself first-hand root exit proof. */ + onFault?: (error: Error) => void + onExit?: (error: Error) => void +} + +/** + * Two questions with their own evidence. The root's verdict is first-hand: Orca's + * own child handle reported exit, or reported error then close before it ever had + * a pid. The tree's comes from bounded descendant verification, and `unverifiable` + * is never collapsed into either neighbour. + */ +export type ClaudeChildExitVerdict = { + root: 'exited' | 'live' | 'processless' + tree: DescendantTreeVerdict +} + +export type ClaudeStreamJsonConnection = ClaudeControlSurface & { + readonly pid: number | undefined + readonly closed: boolean + /** What the ladder has observed so far; read after a `close()` that returned false. */ + readonly exitVerdict: ClaudeChildExitVerdict + send: (message: Record) => Promise + /** Resolves true after processless settlement, or root exit plus observed tree exit. */ + close: () => Promise +} + +type ExitStatus = { code: number | null; signal: NodeJS.Signals | null } + +function exitError(stderrTail: string, status: ExitStatus | null, cause?: Error): Error { + const detail = stderrTail.trim() + // The status is the diagnostic a signed-out or refused start leaves behind; + // it has to survive every wrapper between here and the user. + const how = + status?.signal !== null && status?.signal !== undefined + ? ` (signal ${status.signal})` + : status?.code !== null && status?.code !== undefined + ? ` (code ${status.code})` + : '' + const message = `claude stream-json exited${how}${detail ? `: ${detail}` : ''}` + return cause ? new Error(message, { cause }) : new Error(message) +} + +export async function openClaudeStreamJsonConnection( + launch: ClaudeStreamJsonLaunch, + handlers: ClaudeStreamJsonConnectionHandlers = {}, + spawnImpl: typeof spawnProcess = spawnProcess, + queryImpl?: typeof ClaudeAgentSdk.query +): Promise { + const { query } = await loadClaudeAgentSdk() + const spawner = createClaudeCodeProcessSpawn(spawnImpl) + const inbox = createClaudeUserMessageQueue() + const session = (queryImpl ?? query)({ + prompt: inbox.messages, + options: { + ...launch.options, + cwd: launch.cwd, + // Why env is never omitted: the SDK inherits process.env when it is, which is + // exactly the ambient ANTHROPIC_* auth leak this lane already shipped once. + env: buildClaudeChildProcessEnv(launch.env, { scrubConfiguredChildSessionStamps: true }), + pathToClaudeCodeExecutable: launch.pathToClaudeCodeExecutable, + spawnClaudeCodeProcess: spawner.spawn, + ...(handlers.canUseTool ? { canUseTool: handlers.canUseTool } : {}), + ...(handlers.onUserDialog ? { onUserDialog: handlers.onUserDialog } : {}) + } + }) + const child = spawner.child + if (!child) { + throw new Error('the claude agent SDK returned without spawning a child') + } + // This child owns the account's credentials for as long as it runs, exactly as a + // Claude PTY does — hold the OAuth-refresh gate so a managed refresh cannot rotate + // the single-use token out from under it mid-turn. Entered below, once a release + // path exists. + const authGateKey = randomUUID() + const releaseAuthGate = (): void => markClaudeStructuredChildExited(authGateKey) + let exited = false + let exitStatus: ExitStatus | null = null + let closing = false + let processless = false + let prePidSpawnError = false + let terminalError: Error | null = null + let faultReported = false + let exitReported = false + let closePromise: Promise | null = null + // One reaper per child: every close attempt and error-path reap shares its proof. + const rootSettled = (): boolean => exited || processless + const tree = createClaudeChildTreeReaper(child, { exited: rootSettled }) + + // Arm lazily on actual child output instead of issuing a process-table scan for + // every session at startup. A natural SDK exit can race a later close, while + // output-triggered observation still catches the usual live-child window. + let outputObservationArmed = false + const armTreeOnOutput = (): void => { + if (outputObservationArmed) { + return + } + outputObservationArmed = true + void (tree.refresh?.() ?? tree.capture()) + } + child.stderr.on('data', armTreeOnOutput) + // The SDK may synchronously spawn the CLI and consume an early stderr chunk + // before this connection can attach its listener; the bounded tail preserves + // that observation for the same lazy arm. + if (spawner.stderrTail.length > 0) { + armTreeOnOutput() + } + + let settleExit = (): void => {} + const exitPromise = new Promise((resolve) => { + settleExit = resolve + }) + const markExited = (): void => { + exited = true + releaseAuthGate() + settleExit() + } + child.on('exit', (code, signal) => { + exitStatus = { code, signal } + markExited() + handleUnexpectedEnd() + }) + + const handleUnexpectedEnd = (cause?: Error): void => { + terminalError ??= exitError(spawner.stderrTail, exitStatus, cause) + inbox.fail(terminalError) + if (!closing && !faultReported) { + faultReported = true + handlers.onFault?.(terminalError) + } + if (!closing && exited && !exitReported) { + exitReported = true + handlers.onExit?.(terminalError) + } + } + + void (async () => { + for await (const message of session) { + handlers.onMessage?.(message as unknown as Record) + } + })().catch((error: unknown) => { + // The SDK ends its generator in error when the child dies or the transport + // fails; a transport failure with a live child still has to reap the tree. + if (!closing && !exited) { + void tree.reap() + } + handleUnexpectedEnd(error instanceof Error ? error : new Error(String(error))) + }) + + child.on('error', (error) => { + if (spawner.pid === undefined) { + prePidSpawnError = true + } + if (!closing && !exited) { + void tree.reap() + } + handleUnexpectedEnd(error) + }) + child.on('close', () => { + // Covers the spawn-failure path too, where no 'exit' ever arrives. + releaseAuthGate() + if (prePidSpawnError && spawner.pid === undefined) { + processless = true + settleExit() + } + handleUnexpectedEnd() + }) + child.stdin.on('error', (error) => { + if (!closing) { + void tree.reap() + handleUnexpectedEnd(error) + } + }) + // Why here and not at spawn: a structured gate entry is deliberately unpersisted, so + // confirmSeededClaudeLivePtys can never reconcile a stray one and a leak defers the + // managed OAuth refresh for the life of the process. Entering only after 'exit' and + // 'close' are attached makes that unreachable — any later throw still leaves a + // listener that releases. Nothing between spawn and here can yield, so the child + // cannot end before the gate is entered. + markClaudeStructuredChildSpawned(authGateKey) + + const send = (message: Record): Promise => { + if (closing || exited || terminalError || child.stdin.destroyed || !child.stdin.writable) { + return Promise.reject(terminalError ?? new Error('claude stream-json connection is closed')) + } + return inbox.push(message as unknown as SDKUserMessage) + } + + const close = (): Promise => { + closePromise ??= (async () => { + closing = true + // Arm the descendant proof before ending stdin. The SDK may exit the root + // immediately; a post-exit walk cannot recover descendants that reparented. + await (tree.refresh?.() ?? tree.capture()) + inbox.end() + const proven = await proveClaudeChildExit({ + child, + exitPromise, + exited: rootSettled, + tree + }) + inbox.fail(new Error('claude stream-json connection closed')) + if (!proven) { + closePromise = null + } + return proven + })() + return closePromise + } + + return { + ...createClaudeControlSurface(session), + get pid() { + return spawner.pid + }, + get closed() { + return closing || exited || terminalError !== null + }, + get exitVerdict() { + return { + root: processless ? 'processless' : exited ? 'exited' : 'live', + tree: tree.treeVerdict + } as const + }, + send, + close + } +} diff --git a/src/main/claude/claude-streamed-block-identity.ts b/src/main/claude/claude-streamed-block-identity.ts new file mode 100644 index 00000000000..5cbf6674159 --- /dev/null +++ b/src/main/claude/claude-streamed-block-identity.ts @@ -0,0 +1,110 @@ +import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' +import { claudeRecord, claudeText } from './claude-structured-item-translation' + +// Under --include-partial-messages every stream_event frame carries its own +// uuid, and the block's final `assistant` frame carries yet another; only +// `message.id` ties them together. The block's first stream frame mints the +// journal identity, and the final frame lands on it in block order instead of +// appending a duplicate under its own uuid. + +export type ClaudeStreamedTextDelta = { identity: AgentJournalItemIdentity; text: string } + +type StreamedMessage = { + messageId: string | null + blocks: Map + /** Streamed text blocks whose final assistant frame has not arrived, in block order. */ + awaitingFinal: AgentJournalItemIdentity[] +} + +export type ClaudeStreamedBlockRegistry = { + /** Text a stream_event frame appends to its block, or null when it carries none. */ + observe: (frame: Record) => ClaudeStreamedTextDelta | null + /** The streamed identity a final assistant frame reconciles onto, if its block streamed. */ + reconcile: (frame: { + sessionId: string + parentToolUseId: string | null + messageId: string | null + }) => AgentJournalItemIdentity | null + clear: () => void +} + +function scopeKey(sessionId: string, parentToolUseId: string | null): string { + return `${sessionId}/${parentToolUseId ?? ''}` +} + +export function createClaudeStreamedBlockRegistry(): ClaudeStreamedBlockRegistry { + const messages = new Map() + + const messageFor = (scope: string): StreamedMessage => { + let streamed = messages.get(scope) + if (!streamed) { + streamed = { messageId: null, blocks: new Map(), awaitingFinal: [] } + messages.set(scope, streamed) + } + return streamed + } + + const mint = ( + streamed: StreamedMessage, + sessionId: string, + index: number, + uuid: string + ): AgentJournalItemIdentity => { + const identity: AgentJournalItemIdentity = { provider: 'claude', sessionId, uuid } + streamed.blocks.set(index, identity) + streamed.awaitingFinal.push(identity) + return identity + } + + return { + observe: (frame) => { + const event = claudeRecord(frame.event) + const sessionId = claudeText(frame.session_id) + const uuid = claudeText(frame.uuid) + if (frame.type !== 'stream_event' || !event || !sessionId || !uuid) { + return null + } + const scope = scopeKey(sessionId, claudeText(frame.parent_tool_use_id)) + if (event.type === 'message_start') { + messages.set(scope, { + messageId: claudeText(claudeRecord(event.message)?.id), + blocks: new Map(), + awaitingFinal: [] + }) + return null + } + const index = typeof event.index === 'number' ? event.index : 0 + if (event.type === 'content_block_start') { + const block = claudeRecord(event.content_block) + if (block?.type !== 'text') { + return null + } + const identity = mint(messageFor(scope), sessionId, index, uuid) + const text = claudeText(block.text) + return text ? { identity, text } : null + } + if (event.type !== 'content_block_delta') { + return null + } + const delta = claudeRecord(event.delta) + const text = delta?.type === 'text_delta' ? claudeText(delta.text) : null + if (!text) { + return null + } + const streamed = messageFor(scope) + const identity = streamed.blocks.get(index) ?? mint(streamed, sessionId, index, uuid) + return { identity, text } + }, + reconcile: (frame) => { + const streamed = messages.get(scopeKey(frame.sessionId, frame.parentToolUseId)) + if ( + !streamed || + (frame.messageId && streamed.messageId && frame.messageId !== streamed.messageId) + ) { + return null + } + return streamed.awaitingFinal.shift() ?? null + }, + clear: () => messages.clear() + } +} diff --git a/src/main/claude/claude-streamed-text-checkpoints.test.ts b/src/main/claude/claude-streamed-text-checkpoints.test.ts new file mode 100644 index 00000000000..00a0bc0edd6 --- /dev/null +++ b/src/main/claude/claude-streamed-text-checkpoints.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from 'vitest' +import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' +import { createClaudeStreamedTextCheckpoints } from './claude-streamed-text-checkpoints' + +function identityOf(uuid: string): AgentJournalItemIdentity { + return { provider: 'claude', sessionId: 'claude-session', uuid } +} + +function checkpoints() { + const rows: { uuid: string; text: string }[] = [] + let scheduled: (() => void) | null = null + const store = createClaudeStreamedTextCheckpoints({ + persist: (identity, text) => { + rows.push({ uuid: 'uuid' in identity ? identity.uuid : '', text }) + }, + schedule: (run) => { + scheduled = run + return () => { + scheduled = null + } + } + }) + return { + store, + rows, + runWindow: () => { + const run = scheduled as (() => void) | null + run?.() + } + } +} + +describe('claude streamed text checkpoints', () => { + it('rewrites a block row with the full text accumulated so far', () => { + const { store, rows, runWindow } = checkpoints() + + store.append(identityOf('block-1'), 'hel') + store.append(identityOf('block-1'), 'lo') + runWindow() + + expect(rows).toEqual([{ uuid: 'block-1', text: 'hello' }]) + expect(store.pending).toBe(1) + }) + + it('drops every block still awaiting its final frame at settlement', () => { + const { store, rows, runWindow } = checkpoints() + + store.append(identityOf('block-1'), 'partial answer') + runWindow() + store.settle() + + expect(store.pending).toBe(0) + // The row written before settlement stays; nothing is rewritten afterwards. + store.flush() + expect(rows).toEqual([{ uuid: 'block-1', text: 'partial answer' }]) + }) + + it('keeps a block whose final frame arrived out of the settlement sweep', () => { + const { store } = checkpoints() + + store.append(identityOf('block-1'), 'one') + store.append(identityOf('block-2'), 'two') + store.forget('claude:claude-session:block-1') + + expect(store.pending).toBe(1) + store.settle() + expect(store.pending).toBe(0) + }) + + it('flushes text the widening checkpoint interval has not written yet', () => { + const { store, rows } = checkpoints() + + store.append(identityOf('block-1'), 'x') + store.flush() + + expect(rows).toEqual([{ uuid: 'block-1', text: 'x' }]) + // Already at the row's length: a second flush has nothing to write. + store.flush() + expect(rows).toHaveLength(1) + }) + + it('stops persisting once disposed', () => { + const { store, rows, runWindow } = checkpoints() + + store.append(identityOf('block-1'), 'text') + store.dispose() + runWindow() + store.flush() + + expect(rows).toEqual([]) + expect(store.pending).toBe(0) + }) +}) diff --git a/src/main/claude/claude-streamed-text-checkpoints.ts b/src/main/claude/claude-streamed-text-checkpoints.ts new file mode 100644 index 00000000000..348ecd99558 --- /dev/null +++ b/src/main/claude/claude-streamed-text-checkpoints.ts @@ -0,0 +1,105 @@ +import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { + createAgentSessionDeltaCoalescer, + type AgentSessionDeltaCoalescerDeps +} from '../native-chat/agent-session-wire/agent-session-delta-coalescer' + +export type ClaudeStreamedTextCheckpointDeps = { + /** Rewrites the block's journal row with the text accumulated so far. */ + persist: (identity: AgentJournalItemIdentity, text: string) => void + coalesceMs?: number + schedule?: AgentSessionDeltaCoalescerDeps['schedule'] +} + +export type ClaudeStreamedTextCheckpoints = { + /** Accumulate a delta; the row is rewritten on the coalescer's own cadence. */ + append: (identity: AgentJournalItemIdentity, text: string) => void + /** Write every block whose row is behind the text received for it. */ + flush: () => void + /** Drop one block's state, for a block whose final frame has now landed. */ + forget: (key: string) => void + /** + * Drop every block still awaiting its final frame, at turn settlement. Their + * text is already journaled by the flush that precedes settlement; keeping it + * live would grow with every interrupted turn for the life of the session. + */ + settle: () => void + /** Blocks still awaiting a final frame. A settled turn must leave none. */ + readonly pending: number + dispose: () => void +} + +/** + * Growth of a streamed block's row between its deltas and its final frame. + * + * The row is rewritten on a widening interval rather than per delta: a 200-line + * reply would otherwise rewrite the same journal row once per token. + */ +export function createClaudeStreamedTextCheckpoints( + deps: ClaudeStreamedTextCheckpointDeps +): ClaudeStreamedTextCheckpoints { + const identities = new Map() + const latestText = new Map() + const checkpointLengths = new Map() + + const persist = (key: string, text: string, force: boolean): void => { + latestText.set(key, text) + const checkpointLength = checkpointLengths.get(key) ?? 0 + const nextLength = Math.max(checkpointLength + 32, Math.ceil(checkpointLength * 1.125)) + if (!force && checkpointLength > 0 && text.length < nextLength) { + return + } + const identity = identities.get(key) + if (!identity) { + return + } + checkpointLengths.set(key, text.length) + deps.persist(identity, text) + } + + const coalescer = createAgentSessionDeltaCoalescer({ + ...(deps.coalesceMs === undefined ? {} : { windowMs: deps.coalesceMs }), + ...(deps.schedule ? { schedule: deps.schedule } : {}), + emit: (key, text) => persist(key, text, false) + }) + + const drop = (key: string): void => { + coalescer.forget(key) + identities.delete(key) + latestText.delete(key) + checkpointLengths.delete(key) + } + + return { + append: (identity, text) => { + const key = agentJournalItemKey(identity) + identities.set(key, identity) + coalescer.append(key, text) + }, + flush: () => { + coalescer.flushAll() + for (const [key, text] of latestText) { + if (checkpointLengths.get(key) !== text.length) { + persist(key, text, true) + } + } + }, + forget: drop, + settle: () => { + // Map iteration tolerates deletion of the entry just visited. + for (const key of identities.keys()) { + drop(key) + } + }, + get pending() { + return identities.size + }, + dispose: () => { + coalescer.dispose() + identities.clear() + latestText.clear() + checkpointLengths.clear() + } + } +} diff --git a/src/main/claude/claude-structured-acquisition-release.ts b/src/main/claude/claude-structured-acquisition-release.ts new file mode 100644 index 00000000000..1b633553e89 --- /dev/null +++ b/src/main/claude/claude-structured-acquisition-release.ts @@ -0,0 +1,43 @@ +import { + closeClaudeSession, + claudeAcquisitionCleanupError +} from './claude-structured-session-close' +import type { + ClaudeAcquisitionRegistry, + ClaudeSession, + ClaudeSessionExit, + ClaudeStructuredSessionAdapterDeps +} from './claude-structured-session-state' + +/** + * Cleanup for an acquisition the host could not commit or prove. A session that + * a first-hand exit already removed is not an absence to report as proven: the + * ladder on its connection still answers, and that answer is classified exactly + * as a start-time failure would be. + */ +export async function releaseClaudeAcquisition(input: { + sessionId: string + sessions: Map + acquisitions: ClaudeAcquisitionRegistry + exits: Map + onExitProven?: (sessionId: string, exit: ClaudeSessionExit) => Promise + persistHandle?: ClaudeStructuredSessionAdapterDeps['persistHandle'] + onEvent?: ClaudeStructuredSessionAdapterDeps['onEvent'] +}): Promise { + const exit = input.exits.get(input.sessionId) + if (!exit || input.sessions.has(input.sessionId) || input.acquisitions.get(input.sessionId)) { + return closeClaudeSession(input) + } + const firstProof = exit.closePromise ? await exit.closePromise : false + // A failed exit-path proof is retained as evidence, not as a terminal result; + // a release retry must drive a fresh tree verification on the same connection. + const retriedProof = firstProof || (await exit.connection.close()) + if (retriedProof) { + await input.onExitProven?.(input.sessionId, exit) + // Keep the first-hand exit evidence indexed until the tree proof succeeds; + // a failed close must be retryable and cannot look like an absent session. + input.exits.delete(input.sessionId) + return true + } + throw claudeAcquisitionCleanupError(exit.connection, exit.error) +} diff --git a/src/main/claude/claude-structured-auth-parity.test.ts b/src/main/claude/claude-structured-auth-parity.test.ts new file mode 100644 index 00000000000..ddc69366aad --- /dev/null +++ b/src/main/claude/claude-structured-auth-parity.test.ts @@ -0,0 +1,235 @@ +import { afterEach, describe, expect, it } from 'vitest' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import { beginClaudeAuthSwitch, endClaudeAuthSwitch } from '../claude-accounts/live-pty-gate' +import { + CLAUDE_AUTH_ENV_CONFLICT_MESSAGE, + CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE +} from '../claude-accounts/environment' +import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store' +import { createClaudeStructuredLaunchResolver } from './claude-structured-launch-resolution' +import { ClaudeStructuredSessionAdapter } from './claude-structured-session-adapter' +import { + PROVIDER_SESSION_ID, + adapterFor, + fakeClaude, + identityFor +} from './claude-structured-session-test-support' + +const SESSION_ID = 'orca-session-auth' +const IDENTITY = { sessionId: SESSION_ID } as Parameters< + ReturnType +>[0]['identity'] + +function record(): AgentSessionRecord { + return { + sessionId: SESSION_ID, + provider: 'claude', + location: { + executionHostId: LOCAL_EXECUTION_HOST_ID, + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'folder' + }, + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/work/.claude' }, + providerHandleChain: [] + } as unknown as AgentSessionRecord +} + +function resolverFor(options: { + stripAuthEnv: boolean + overlay?: Record + authSwitchSettleTimeoutMs?: number +}): ReturnType { + return createClaudeStructuredLaunchResolver({ + store: { getRecord: () => record() } as unknown as AgentSessionRecordStore, + resolveWorkspacePath: async (id) => `/repos/${id}`, + resolveCommand: () => '/usr/local/bin/claude', + resolveAuthPolicy: () => ({ stripAuthEnv: options.stripAuthEnv }), + authSwitchSettleTimeoutMs: options.authSwitchSettleTimeoutMs ?? 20, + ...(options.overlay ? { resolveEnv: () => options.overlay as Record } : {}) + }) +} + +/** + * An adapter driven by the REAL launch resolver, not the stub in the shared test + * support — the stub has no auth guard at all, so a teardown-window test built on it + * would pass whatever the guard did. + */ +function realResolverAdapter( + claude: ReturnType, + authSwitchSettleTimeoutMs: number +): ClaudeStructuredSessionAdapter { + const resumable = { + ...record(), + providerHandleChain: [ + { handle: { provider: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } } + ] + } as unknown as AgentSessionRecord + return new ClaudeStructuredSessionAdapter({ + resolveLaunch: createClaudeStructuredLaunchResolver({ + store: { getRecord: () => resumable } as unknown as AgentSessionRecordStore, + resolveWorkspacePath: async (id) => `/repos/${id}`, + resolveCommand: () => '/usr/local/bin/claude', + resolveAuthPolicy: () => ({ stripAuthEnv: false }), + authSwitchSettleTimeoutMs + }), + openConnection: claude.openConnection, + readProcessStartTime: async () => 1_700_000_000_000, + now: () => 1_700_000_000_500, + persistHandle: async () => {} + }) +} + +function withAmbientAuth(value: string, run: () => Promise): Promise { + const restore = process.env.ANTHROPIC_API_KEY + process.env.ANTHROPIC_API_KEY = value + return run().finally(() => { + if (restore === undefined) { + delete process.env.ANTHROPIC_API_KEY + } else { + process.env.ANTHROPIC_API_KEY = restore + } + }) +} + +describe('claude structured auth parity with the terminal preflight', () => { + afterEach(() => { + endClaudeAuthSwitch() + }) + + // Task 1 — the terminal preflight refuses this at spawn-env.ts:25 and + // runtime/spawn-preflight.ts:139; the structured path used to let the override win. + it('refuses an explicit Anthropic auth override while a managed account is pinned', async () => { + await expect( + resolverFor({ stripAuthEnv: true, overlay: { ANTHROPIC_API_KEY: 'sk-ant-CONFIGURED' } })({ + identity: IDENTITY + }) + ).rejects.toThrow(CLAUDE_AUTH_ENV_CONFLICT_MESSAGE) + }) + + it('refuses an auth-like ANTHROPIC_CUSTOM_HEADERS override while a managed account is pinned', async () => { + await expect( + resolverFor({ + stripAuthEnv: true, + overlay: { ANTHROPIC_CUSTOM_HEADERS: 'Authorization: Bearer sk-ant-CONFIGURED' } + })({ identity: IDENTITY }) + ).rejects.toThrow(CLAUDE_AUTH_ENV_CONFLICT_MESSAGE) + }) + + it('still admits a non-auth env overlay under a managed account', async () => { + const launch = await resolverFor({ + stripAuthEnv: true, + overlay: { ANTHROPIC_BASE_URL: 'https://gateway.example.test' } + })({ identity: IDENTITY }) + + expect(launch.env?.ANTHROPIC_BASE_URL).toBe('https://gateway.example.test') + }) + + // Task 2 — legacy computes stripAuthEnv at runtime-auth-preparation.ts:72, so a + // system-auth user's own shell key is their sign-in and must survive. + it('passes an ambient Anthropic key through when no managed account is active', async () => { + await withAmbientAuth('sk-ant-SHELL', async () => { + const launch = await resolverFor({ stripAuthEnv: false })({ identity: IDENTITY }) + + expect(launch.env?.ANTHROPIC_API_KEY).toBe('sk-ant-SHELL') + }) + }) + + it('lets an explicit overlay override the ambient key when no managed account is active', async () => { + await withAmbientAuth('sk-ant-SHELL', async () => { + const launch = await resolverFor({ + stripAuthEnv: false, + overlay: { ANTHROPIC_API_KEY: 'sk-ant-CONFIGURED' } + })({ identity: IDENTITY }) + + expect(launch.env?.ANTHROPIC_API_KEY).toBe('sk-ant-CONFIGURED') + }) + }) + + it('still strips the ambient Anthropic key when a managed account is pinned', async () => { + await withAmbientAuth('sk-ant-SHELL', async () => { + const launch = await resolverFor({ stripAuthEnv: true })({ identity: IDENTITY }) + + expect(launch.env?.ANTHROPIC_API_KEY).toBeUndefined() + }) + }) + + // Task 3 — the terminal preflight guards this at four sites; the structured path had none. + it('refuses launch resolution when an account switch never settles', async () => { + beginClaudeAuthSwitch() + + await expect( + resolverFor({ stripAuthEnv: true, authSwitchSettleTimeoutMs: 20 })({ identity: IDENTITY }) + ).rejects.toThrow(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE) + }) + + it('waits a settling account switch out rather than refusing a resolved launch', async () => { + beginClaudeAuthSwitch() + setTimeout(() => endClaudeAuthSwitch(), 20) + + const launch = await resolverFor({ + stripAuthEnv: true, + authSwitchSettleTimeoutMs: 5_000 + })({ identity: IDENTITY }) + + expect(launch.claudeConfigDir).toBe('/home/work/.claude') + }) + + it('refuses an acquire before it tears the previous session down', async () => { + const claude = fakeClaude() + const adapter = adapterFor(claude) + beginClaudeAuthSwitch() + + await expect( + adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + ).rejects.toThrow(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE) + // Nothing was spawned, so the refusal must not have opened a connection. + expect(claude.connections).toHaveLength(0) + }) + + // The teardown between the entry guard and launch resolution closes the live child + // and proves its tree — seconds, not milliseconds. A switch that begins inside it + // has already cost the user their session, so refusing there produces exactly the + // outcome the entry guard advertises against: a dead chat and no replacement. + it('replaces the session when a switch begins inside the acquire teardown', async () => { + const claude = fakeClaude() + const adapter = realResolverAdapter(claude, 5_000) + await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + const live = claude.connections[0]! + const closeWithSwitch = live.close + live.close = async () => { + beginClaudeAuthSwitch() + setTimeout(() => endClaudeAuthSwitch(), 20) + return closeWithSwitch() + } + + await expect( + adapter.acquire({ identity: identityFor(), fence: 8, spawnToken: 'spawn-10' }) + ).resolves.toMatchObject({ process: { spawnToken: 'spawn-10' } }) + expect(live.closed).toBe(true) + // The replacement child exists: the user's chat came back. + expect(claude.connections).toHaveLength(2) + expect(claude.connections[1]!.closed).toBe(false) + await adapter.closeAll() + }) + + it('still refuses a mid-teardown switch that never settles, leaving nothing half-open', async () => { + const claude = fakeClaude() + const adapter = realResolverAdapter(claude, 20) + await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + const live = claude.connections[0]! + const closeWithSwitch = live.close + live.close = async () => { + beginClaudeAuthSwitch() + return closeWithSwitch() + } + + await expect( + adapter.acquire({ identity: identityFor(), fence: 8, spawnToken: 'spawn-10' }) + ).rejects.toThrow(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE) + // No replacement child was opened, so nothing is left running unowned. + expect(claude.connections).toHaveLength(1) + await adapter.closeAll() + }) +}) diff --git a/src/main/claude/claude-structured-content-parts.test.ts b/src/main/claude/claude-structured-content-parts.test.ts new file mode 100644 index 00000000000..d2142150937 --- /dev/null +++ b/src/main/claude/claude-structured-content-parts.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it, vi } from 'vitest' +import type { + AgentJournalItemBody, + AgentJournalItemIdentity +} from '../../shared/agent-session-journal-types' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { createClaudeJournalTranslator } from './claude-structured-journal-translation' + +function sinkState() { + const items: { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }[] = [] + const sink: StructuredAgentSessionEventSink = { + appendItem: (identity, body) => items.push({ identity, body }), + appendTombstone: () => {}, + publish: vi.fn() + } + return { sink, items } +} + +function providerRows(items: { body: AgentJournalItemBody }[]) { + return items.flatMap((item) => + item.body.kind === 'status' && item.body.providerFrame + ? [{ kind: item.body.providerFrame.kind, text: item.body.text }] + : [] + ) +} + +function userMessageWith(part: unknown) { + return { + type: 'message' as const, + sessionId: 'orca-session', + startsTurn: true as const, + message: { + type: 'user', + uuid: 'user-1', + session_id: 'claude-session', + parent_tool_use_id: null, + isReplay: true, + message: { role: 'user', content: [{ type: 'text', text: 'look at this' }, part] } + } + } +} + +/** Exactly what claudeDispatchMessageContent sends for a local attachment. */ +const BASE64_IMAGE = { + type: 'image', + source: { type: 'base64', media_type: 'image/png', data: 'iVBORw0KGgoAAAANSUhEUg==' } +} + +describe('Claude message content parts', () => { + it('does not leak a wire kind for a locally attached image', () => { + const state = sinkState() + const translator = createClaudeJournalTranslator({ sink: state.sink }) + + translator.handle(userMessageWith(BASE64_IMAGE)) + + expect(providerRows(state.items)).toEqual([]) + }) + + it('still renders an image the CLI sends by url', () => { + const state = sinkState() + const translator = createClaudeJournalTranslator({ sink: state.sink }) + + translator.handle( + userMessageWith({ type: 'image', source: { type: 'url', url: 'https://x.test/a.png' } }) + ) + + expect(providerRows(state.items)).toEqual([]) + expect( + state.items.flatMap((item) => (item.body.kind === 'message' ? item.body.blocks : [])) + ).toContainEqual({ type: 'image-ref', url: 'https://x.test/a.png' }) + }) + + it('says what is true for a content part it cannot render, not the wire kind', () => { + const state = sinkState() + const translator = createClaudeJournalTranslator({ sink: state.sink }) + + translator.handle(userMessageWith({ type: 'some_future_part', payload: { a: 1 } })) + + const rows = providerRows(state.items) + expect(rows).toHaveLength(1) + // The kind stays on the row for debugging, behind the disclosure. + expect(rows[0].kind).toBe('message:user:content:some_future_part') + // ...but the visible text is a sentence, not the opcode. + expect(rows[0].text).not.toContain('message:user:content') + expect(rows[0].text.toLowerCase()).toContain('claude') + }) + + it('prefers a readable sentence the part carries over the placeholder', () => { + const state = sinkState() + const translator = createClaudeJournalTranslator({ sink: state.sink }) + + translator.handle( + userMessageWith({ type: 'some_future_part', message: 'the server refused the upload' }) + ) + + expect(providerRows(state.items)[0].text).toBe('the server refused the upload') + }) +}) diff --git a/src/main/claude/claude-structured-control-actions.test.ts b/src/main/claude/claude-structured-control-actions.test.ts new file mode 100644 index 00000000000..c471a8aca80 --- /dev/null +++ b/src/main/claude/claude-structured-control-actions.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it, vi } from 'vitest' +import { cancelClaudeTurn, answerClaudePrompt } from './claude-structured-control-actions' +import { ClaudeControlRequestError } from './claude-stream-json-connection' +import { ClaudePromptRegistry } from './claude-structured-prompt-replies' +import type { ClaudeSession } from './claude-structured-session-state' + +type InterruptResult = Awaited> + +function sessionWith(input: { + capabilities?: string[] + interrupt: (options?: { cancelQueued?: boolean; timeoutMs?: number }) => Promise + cancelAsyncMessage?: (uuid: string) => Promise + prompts?: ClaudePromptRegistry +}): { + session: ClaudeSession + interrupt: ReturnType + cancelAsyncMessage: ReturnType +} { + const interrupt = vi.fn(input.interrupt) + const cancelAsyncMessage = vi.fn(input.cancelAsyncMessage ?? (async () => {})) + const session = { + capabilities: input.capabilities ?? [], + prompts: input.prompts ?? new ClaudePromptRegistry(), + connection: { interrupt, cancelAsyncMessage } + } as unknown as ClaudeSession + return { session, interrupt, cancelAsyncMessage } +} + +describe('cancelClaudeTurn', () => { + it('interrupts without a receipt on an older CLI and reports the turn cancelled', async () => { + const { session, interrupt, cancelAsyncMessage } = sessionWith({ + interrupt: async () => undefined + }) + + await expect(cancelClaudeTurn(session, 5_000)).resolves.toEqual({ cancelled: true }) + expect(interrupt).toHaveBeenCalledWith({ timeoutMs: 5_000 }) + expect(cancelAsyncMessage).not.toHaveBeenCalled() + }) + + it('withdraws every still-queued message a plain interrupt receipt reports', async () => { + const { session, interrupt, cancelAsyncMessage } = sessionWith({ + capabilities: ['interrupt_receipt_v1'], + interrupt: async () => ({ still_queued: ['queued-1', 'queued-2'] }) + }) + + await expect(cancelClaudeTurn(session, 5_000)).resolves.toEqual({ cancelled: true }) + // No cancel_queued capability, so the queue is swept one uuid at a time. + expect(interrupt).toHaveBeenCalledWith({ timeoutMs: 5_000 }) + expect(cancelAsyncMessage.mock.calls.map((call) => call[0])).toEqual(['queued-1', 'queued-2']) + }) + + it('sends cancel_queued and never sweeps when the CLI advertises the capability', async () => { + const { session, interrupt, cancelAsyncMessage } = sessionWith({ + capabilities: ['interrupt_receipt_v1', 'interrupt_cancel_queued_v1'], + interrupt: async () => ({ still_queued: [], cancelled: ['queued-1'] }) + }) + + await expect(cancelClaudeTurn(session, 5_000)).resolves.toEqual({ cancelled: true }) + expect(interrupt).toHaveBeenCalledWith({ cancelQueued: true, timeoutMs: 5_000 }) + expect(cancelAsyncMessage).not.toHaveBeenCalled() + }) + + it('reports a not-running interrupt as not cancelled without throwing', async () => { + const { session } = sessionWith({ + interrupt: async () => { + throw new ClaudeControlRequestError('interrupt', 'not running') + } + }) + + await expect(cancelClaudeTurn(session, 5_000)).resolves.toEqual({ cancelled: false }) + }) + + it('propagates a transport failure such as an interrupt timeout', async () => { + const { session } = sessionWith({ + interrupt: async () => { + throw new Error('claude interrupt request timed out') + } + }) + + await expect(cancelClaudeTurn(session, 5_000)).rejects.toThrow('timed out') + }) +}) + +describe('answerClaudePrompt', () => { + it('settles the pending prompt callback and forgets it', async () => { + const prompts = new ClaudePromptRegistry() + const settle = vi.fn() + const prompt = prompts.register({ + requestId: 'perm-1', + toolName: 'Bash', + toolUseId: 'tool-1', + input: { command: 'ls' }, + suggestions: [], + settle + })! + prompts.bindJournalItemId('journal-1', prompt.promptKey) + const { session } = sessionWith({ interrupt: async () => undefined, prompts }) + + await answerClaudePrompt(session, { itemId: 'journal-1', kind: 'approval', optionId: 'allow' }) + + expect(settle).toHaveBeenCalledWith( + expect.objectContaining({ behavior: 'allow', toolUseID: 'tool-1' }) + ) + expect(prompts.find('journal-1')).toBeNull() + }) + + it('refuses an answer for a prompt Claude is no longer waiting on', async () => { + const { session } = sessionWith({ interrupt: async () => undefined }) + await expect( + answerClaudePrompt(session, { itemId: 'missing', kind: 'approval', optionId: 'allow' }) + ).rejects.toThrow(/no longer waiting/) + }) +}) diff --git a/src/main/claude/claude-structured-control-actions.ts b/src/main/claude/claude-structured-control-actions.ts new file mode 100644 index 00000000000..d4484963aae --- /dev/null +++ b/src/main/claude/claude-structured-control-actions.ts @@ -0,0 +1,60 @@ +import { applyClaudePromptAnswer } from './claude-structured-prompt-replies' +import { ClaudeControlRequestError } from './claude-stream-json-connection' +import type { ClaudeSession } from './claude-structured-session-state' + +const INTERRUPT_CANCEL_QUEUED_CAPABILITY = 'interrupt_cancel_queued_v1' + +export type ClaudeTurnCancellationGuard = () => boolean + +/** + * Interrupt the running turn, then make sure no queued async user message survives to spawn a + * later unexpected turn. On a CLI advertising `interrupt_cancel_queued_v1` one round trip + * cancels the queue alongside the abort; otherwise the interrupt receipt lists `still_queued` + * uuids, and each is withdrawn best-effort with `cancel_async_message`. Older CLIs resolve no + * receipt, so there is nothing to sweep. + */ +export async function cancelClaudeTurn( + session: ClaudeSession, + timeoutMs: number | undefined, + isCurrent: ClaudeTurnCancellationGuard = () => true +): Promise<{ cancelled: boolean }> { + // The SDK interrupt is session-scoped. Re-check the caller's turn/fence + // immediately before issuing it so a delayed request cannot stop a later turn. + if (!isCurrent()) { + return { cancelled: false } + } + const cancelQueued = session.capabilities.includes(INTERRUPT_CANCEL_QUEUED_CAPABILITY) + try { + const receipt = await session.connection.interrupt({ + ...(cancelQueued ? { cancelQueued: true } : {}), + timeoutMs + }) + if (!cancelQueued) { + for (const uuid of receipt?.still_queued ?? []) { + await session.connection.cancelAsyncMessage(uuid, { timeoutMs }).catch(() => {}) + } + } + return { cancelled: true } + } catch (error) { + if (error instanceof ClaudeControlRequestError) { + return { cancelled: false } + } + throw error + } +} + +export async function answerClaudePrompt( + session: ClaudeSession, + input: { itemId: string; kind: 'approval' | 'question'; optionId: string } +): Promise { + const found = session.prompts.find(input.itemId) + if (!found || found.prompt.kind !== input.kind) { + throw new Error(`claude is no longer waiting on ${input.itemId}`) + } + const response = applyClaudePromptAnswer(found, input.optionId) + if (response === null) { + return + } + session.prompts.forget(found.prompt) + found.prompt.settle(response) +} diff --git a/src/main/claude/claude-structured-dispatch-content.ts b/src/main/claude/claude-structured-dispatch-content.ts new file mode 100644 index 00000000000..71f180bc3ac --- /dev/null +++ b/src/main/claude/claude-structured-dispatch-content.ts @@ -0,0 +1,165 @@ +import { createHash } from 'node:crypto' +import { open } from 'node:fs/promises' +import { extname } from 'node:path' +import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' +import type { NativeChatBlock } from '../../shared/native-chat-types' + +const MAX_IMAGE_BYTES = 5 * 1024 * 1024 +const MAX_IMAGE_COUNT = 20 +const MAX_TOTAL_IMAGE_BYTES = 20 * 1024 * 1024 +const MAX_REPLAY_CONTENT_KEY_BYTES = 256 + +type ImageBudget = { + count: number + localBytes: number +} + +export async function readClaudeImage(path: string, openImpl: typeof open = open): Promise { + const file = await openImpl(path, 'r') + try { + const invalidImage = (): Error => + new Error(`Claude image must be a non-empty file no larger than ${MAX_IMAGE_BYTES} bytes`) + const info = await file.stat() + if (!info.isFile()) { + throw new Error('Claude image must be a file') + } + if (info.size > MAX_IMAGE_BYTES) { + throw invalidImage() + } + const buffer = Buffer.allocUnsafe(info.size + 1) + let bytesRead = 0 + while (bytesRead < buffer.length) { + const result = await file.read(buffer, bytesRead, buffer.length - bytesRead, bytesRead) + if (result.bytesRead === 0) { + break + } + bytesRead += result.bytesRead + } + // A file can grow after the initial stat and after the final read returns + // zero. Prove the descriptor's size matches what was copied before sending. + const finalInfo = await file.stat() + if (bytesRead === 0 || bytesRead > MAX_IMAGE_BYTES || finalInfo.size !== bytesRead) { + throw invalidImage() + } + return buffer.subarray(0, bytesRead) + } finally { + await file.close() + } +} + +const IMAGE_MIME_BY_EXTENSION: Record = { + '.gif': 'image/gif', + '.jpeg': 'image/jpeg', + '.jpg': 'image/jpeg', + '.png': 'image/png', + '.webp': 'image/webp' +} + +async function imageContent( + block: Extract, + budget: ImageBudget +): Promise { + budget.count += 1 + if (budget.count > MAX_IMAGE_COUNT) { + throw new Error(`Claude messages support at most ${MAX_IMAGE_COUNT} images`) + } + if (block.url) { + return { type: 'image', source: { type: 'url', url: block.url } } + } + if (!block.path) { + throw new Error('image reference has neither a path nor a URL') + } + const data = await readClaudeImage(block.path) + budget.localBytes += data.byteLength + if (budget.localBytes > MAX_TOTAL_IMAGE_BYTES) { + throw new Error(`Claude images must total no more than ${MAX_TOTAL_IMAGE_BYTES} bytes`) + } + const mediaType = IMAGE_MIME_BY_EXTENSION[extname(block.path).toLowerCase()] + if (!mediaType) { + throw new Error(`Claude does not support the image type ${extname(block.path)}`) + } + return { + type: 'image', + source: { + type: 'base64', + media_type: mediaType, + data: data.toString('base64') + } + } +} + +export async function claudeDispatchMessageContent( + body: AgentJournalMessageItem +): Promise { + if (body.role !== 'user') { + throw new Error('Claude dispatch accepts only user messages') + } + const content: unknown[] = [] + const imageBudget: ImageBudget = { count: 0, localBytes: 0 } + for (const block of body.blocks as NativeChatBlock[]) { + if (block.type === 'text' && block.text.length > 0) { + content.push({ type: 'text', text: block.text }) + } else if (block.type === 'image-ref') { + content.push(await imageContent(block, imageBudget)) + } + } + if (content.length === 0) { + throw new Error('Claude dispatch requires text or an image') + } + return content +} + +/** + * Keep waiter metadata bounded even when a dispatch contains large base64 images. + * The digest is only diagnostic: replay acknowledgement must use provider identity. + */ +export function claudeDispatchContentKey(content: readonly unknown[]): string { + const digest = createHash('sha256') + const summary = content + .map((part) => { + const record = + typeof part === 'object' && part !== null && !Array.isArray(part) + ? (part as Record) + : null + const type = typeof record?.type === 'string' ? record.type : 'unknown' + if (type === 'text') { + return `text:${typeof record?.text === 'string' ? record.text.length : 0}` + } + const source = + typeof record?.source === 'object' && record.source !== null + ? (record.source as Record) + : null + if (type === 'image' && source?.type === 'base64') { + return `image:${typeof source.media_type === 'string' ? source.media_type : ''}:${typeof source.data === 'string' ? source.data.length : 0}` + } + return type + }) + .join(',') + for (const [index, part] of content.entries()) { + const record = + typeof part === 'object' && part !== null && !Array.isArray(part) + ? (part as Record) + : null + const type = typeof record?.type === 'string' ? record.type : 'unknown' + digest.update(`${index}:${type}:`) + if (type === 'text' && typeof record?.text === 'string') { + digest.update(record.text) + continue + } + const source = + typeof record?.source === 'object' && record.source !== null + ? (record.source as Record) + : null + if (type === 'image' && source?.type === 'base64') { + digest.update(typeof source.media_type === 'string' ? source.media_type : '') + digest.update(':') + if (typeof source.data === 'string') { + digest.update(source.data) + } + continue + } + digest.update(JSON.stringify(part)) + } + const key = `v1:${summary.slice(0, 128)}:${digest.digest('hex')}` + return key.slice(0, MAX_REPLAY_CONTENT_KEY_BYTES) +} diff --git a/src/main/claude/claude-structured-dispatch.test.ts b/src/main/claude/claude-structured-dispatch.test.ts new file mode 100644 index 00000000000..4e8289a89e3 --- /dev/null +++ b/src/main/claude/claude-structured-dispatch.test.ts @@ -0,0 +1,598 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' +import { dispatchClaudeTurn, resolveClaudeReplayWaiter } from './claude-structured-dispatch' +import { readClaudeImage } from './claude-structured-dispatch-content' +import type { ClaudeSession } from './claude-structured-session-state' + +function sessionFor(send = vi.fn().mockResolvedValue(undefined)): ClaudeSession { + return { + connection: { send } as unknown as ClaudeSession['connection'], + providerSessionId: 'provider-session', + claudeConfigDir: '/accounts/claude', + leafUuid: null, + fence: 1, + acquisitionGeneration: 'generation-1', + prompts: {} as ClaudeSession['prompts'], + dispatchWaiters: [], + retiredDispatchWaiters: [], + replayContentFallbackBlocked: false, + dispatchSequence: 0, + optionMutationSequence: 0, + options: new Map(), + reportedOptions: {}, + reportedModelMutation: 0, + confirmedOptions: new Set(), + restoreSkippedOptions: new Set(), + capabilities: [], + events: undefined, + translator: null + } +} + +function userMessage(blocks: AgentJournalMessageItem['blocks']): AgentJournalMessageItem { + return { kind: 'message', role: 'user', blocks } +} + +function userReplayFrame(uuid: string, text: string): Record { + return { + type: 'user', + parent_tool_use_id: null, + session_id: 'provider-session', + uuid, + message: { role: 'user', content: [{ type: 'text', text }] } + } +} + +describe('Claude structured dispatch image limits', () => { + it('recovers the active identity when a timed-out replay arrives late', async () => { + const session = sessionFor() + const dispatched = dispatchClaudeTurn( + session, + { clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: 'one' }]) }, + 500 + ) + await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) + const sentUuid = (session.dispatchWaiters[0] as { sentUuid?: string }).sentUuid + await expect(dispatched).resolves.toMatchObject({ state: 'unknown' }) + + expect(resolveClaudeReplayWaiter(session, userReplayFrame(sentUuid!, 'one'))).toBe(true) + expect(session.activeTurnId).toBe(sentUuid) + expect(session.activeTurnSequence).toBe(session.dispatchSequence) + }) + + it('never lets a late replay for dispatch A resolve dispatch B', async () => { + const session = sessionFor() + const first = dispatchClaudeTurn( + session, + { clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: 'one' }]) }, + 500 + ) + await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) + const firstUuid = (session.dispatchWaiters[0] as { sentUuid?: string }).sentUuid + await expect(first).resolves.toMatchObject({ state: 'unknown' }) + + const second = dispatchClaudeTurn( + session, + { clientMessageId: 'client-2', body: userMessage([{ type: 'text', text: 'two' }]) }, + 100 + ) + await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) + const secondUuid = (session.dispatchWaiters[0] as { sentUuid?: string }).sentUuid + + expect(resolveClaudeReplayWaiter(session, userReplayFrame(firstUuid!, 'one'))).toBe(false) + expect(session.dispatchWaiters[0]).toMatchObject({ sentUuid: secondUuid }) + expect(resolveClaudeReplayWaiter(session, userReplayFrame(secondUuid!, 'two'))).toBe(true) + await expect(second).resolves.toMatchObject({ providerIdentity: { uuid: secondUuid } }) + }) + + it('does not let an identical late replay for dispatch A resolve active dispatch B', async () => { + const session = sessionFor() + const first = dispatchClaudeTurn( + session, + { clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: 'same prompt' }]) }, + 500 + ) + await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) + await expect(first).resolves.toMatchObject({ state: 'unknown' }) + + const second = dispatchClaudeTurn( + session, + { clientMessageId: 'client-2', body: userMessage([{ type: 'text', text: 'same prompt' }]) }, + 100 + ) + await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) + const secondUuid = session.dispatchWaiters[0]!.sentUuid + + expect(resolveClaudeReplayWaiter(session, userReplayFrame('provider-a', 'same prompt'))).toBe( + false + ) + expect(session.dispatchWaiters[0]).toMatchObject({ sentUuid: secondUuid }) + + resolveClaudeReplayWaiter(session, userReplayFrame(secondUuid, 'same prompt')) + await expect(second).resolves.toMatchObject({ providerIdentity: { uuid: secondUuid } }) + }) + + it('does not let a fresh-UUID replay for an evicted dispatch resolve active dispatch B', async () => { + const session = sessionFor() + const first = dispatchClaudeTurn( + session, + { clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: 'same prompt' }]) }, + 100 + ) + await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) + await expect(first).resolves.toMatchObject({ state: 'unknown' }) + const firstUuid = session.retiredDispatchWaiters[0]!.sentUuid + + const fillerDispatches = await Promise.all( + Array.from({ length: 64 }, (_, index) => + dispatchClaudeTurn( + session, + { + clientMessageId: `filler-${index}`, + body: userMessage([{ type: 'text', text: 'same prompt' }]) + }, + 5 + ) + ) + ) + expect(fillerDispatches.every((outcome) => outcome.state === 'unknown')).toBe(true) + expect(session.retiredDispatchWaiters).toHaveLength(64) + expect(session.replayContentFallbackBlocked).toBe(true) + expect(session.retiredDispatchWaiters.some((waiter) => waiter.sentUuid === firstUuid)).toBe( + false + ) + + while (session.retiredDispatchWaiters.length > 0) { + const sentUuid = session.retiredDispatchWaiters[0]!.sentUuid + resolveClaudeReplayWaiter(session, userReplayFrame(sentUuid, 'same prompt')) + } + expect(session.retiredDispatchWaiters).toHaveLength(0) + + const second = dispatchClaudeTurn( + session, + { clientMessageId: 'client-2', body: userMessage([{ type: 'text', text: 'same prompt' }]) }, + 100 + ) + await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) + const secondUuid = session.dispatchWaiters[0]!.sentUuid + + expect( + resolveClaudeReplayWaiter(session, userReplayFrame('provider-a-late', 'same prompt')) + ).toBe(false) + expect(session.dispatchWaiters[0]).toMatchObject({ sentUuid: secondUuid }) + + resolveClaudeReplayWaiter(session, userReplayFrame(secondUuid, 'same prompt')) + await expect(second).resolves.toMatchObject({ providerIdentity: { uuid: secondUuid } }) + }) + + it('does not let a fresh-UUID result for an evicted slash dispatch resolve active dispatch B', async () => { + const session = sessionFor() + const first = dispatchClaudeTurn( + session, + { clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: '/permissions' }]) }, + 100 + ) + await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) + await expect(first).resolves.toMatchObject({ state: 'unknown' }) + const firstUuid = session.retiredDispatchWaiters[0]!.sentUuid + + const fillerDispatches = await Promise.all( + Array.from({ length: 64 }, (_, index) => + dispatchClaudeTurn( + session, + { + clientMessageId: `filler-${index}`, + body: userMessage([{ type: 'text', text: '/permissions' }]) + }, + 5 + ) + ) + ) + expect(fillerDispatches.every((outcome) => outcome.state === 'unknown')).toBe(true) + expect(session.retiredDispatchWaiters).toHaveLength(64) + expect(session.replayContentFallbackBlocked).toBe(true) + expect(session.retiredDispatchWaiters.some((waiter) => waiter.sentUuid === firstUuid)).toBe( + false + ) + + while (session.retiredDispatchWaiters.length > 0) { + const sentUuid = session.retiredDispatchWaiters[0]!.sentUuid + expect( + resolveClaudeReplayWaiter(session, { + type: 'result', + subtype: 'success', + session_id: 'provider-session', + uuid: `result-${sentUuid}`, + user_message_uuid: sentUuid + }) + ).toBe(false) + } + expect(session.retiredDispatchWaiters).toHaveLength(0) + + const second = dispatchClaudeTurn( + session, + { clientMessageId: 'client-2', body: userMessage([{ type: 'text', text: '/permissions' }]) }, + 100 + ) + await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) + const secondUuid = session.dispatchWaiters[0]!.sentUuid + + expect( + resolveClaudeReplayWaiter(session, { + type: 'result', + subtype: 'success', + session_id: 'provider-session', + uuid: 'result-a-late' + }) + ).toBe(false) + expect(session.dispatchWaiters[0]).toMatchObject({ sentUuid: secondUuid }) + + expect( + resolveClaudeReplayWaiter(session, { + type: 'result', + subtype: 'success', + session_id: 'provider-session', + uuid: 'result-b', + user_message_uuid: secondUuid + }) + ).toBe(false) + await expect(second).resolves.toMatchObject({ + providerIdentity: { uuid: 'result-b' } + }) + }) + + it('does not let a legacy result for timed-out ordinary dispatch A resolve slash dispatch B', async () => { + const session = sessionFor() + const first = dispatchClaudeTurn( + session, + { clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: 'ordinary' }]) }, + 100 + ) + await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) + await expect(first).resolves.toMatchObject({ state: 'unknown' }) + + const second = dispatchClaudeTurn( + session, + { clientMessageId: 'client-2', body: userMessage([{ type: 'text', text: '/permissions' }]) }, + 100 + ) + await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) + + expect( + resolveClaudeReplayWaiter(session, { + type: 'result', + subtype: 'success', + session_id: 'provider-session', + uuid: 'legacy-result-a' + }) + ).toBe(false) + await expect(second).resolves.toMatchObject({ state: 'unknown' }) + }) + + it('removes only its own waiter when a later send fails', async () => { + const session = sessionFor() + const first = dispatchClaudeTurn( + session, + { clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: 'one' }]) }, + 100 + ) + await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) + const firstWaiter = session.dispatchWaiters[0] + session.connection.send = vi.fn().mockRejectedValue(new Error('broken pipe')) + + await expect( + dispatchClaudeTurn( + session, + { clientMessageId: 'client-2', body: userMessage([{ type: 'text', text: 'two' }]) }, + 100 + ) + ).resolves.toMatchObject({ state: 'unknown', reason: 'broken pipe' }) + expect(session.dispatchWaiters).toEqual([firstWaiter]) + + const firstUuid = (firstWaiter as { sentUuid?: string }).sentUuid + resolveClaudeReplayWaiter(session, userReplayFrame(firstUuid!, 'one')) + await expect(first).resolves.toMatchObject({ providerIdentity: { uuid: firstUuid } }) + }) + + it('keeps a replay accepted before its send reports failure', async () => { + let session!: ClaudeSession + const send = vi.fn(async (message: Record) => { + resolveClaudeReplayWaiter(session, { ...message, uuid: 'turn-race' }) + throw new Error('write raced provider acknowledgement') + }) + session = sessionFor(send) + + await expect( + dispatchClaudeTurn( + session, + { clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: 'one' }]) }, + 100 + ) + ).resolves.toMatchObject({ state: 'accepted', providerIdentity: { uuid: 'turn-race' } }) + expect(session.dispatchWaiters).toHaveLength(0) + }) + + it('accepts a slash command from its result receipt when Claude omits the user replay', async () => { + const session = sessionFor() + const dispatched = dispatchClaudeTurn( + session, + { clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: '/permissions' }]) }, + 100 + ) + await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) + + expect( + resolveClaudeReplayWaiter(session, { + type: 'result', + subtype: 'success', + session_id: 'provider-session', + uuid: 'command-result-uuid' + }) + ).toBe(false) + + await expect(dispatched).resolves.toEqual({ + state: 'accepted', + providerIdentity: { + provider: 'claude', + sessionId: 'provider-session', + uuid: 'command-result-uuid' + } + }) + }) + + it('correlates a later slash-command result by user_message_uuid despite a timed-out slash waiter', async () => { + const session = sessionFor() + const first = dispatchClaudeTurn( + session, + { clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: '/permissions' }]) }, + 500 + ) + await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) + await expect(first).resolves.toMatchObject({ state: 'unknown' }) + + const second = dispatchClaudeTurn( + session, + { clientMessageId: 'client-2', body: userMessage([{ type: 'text', text: '/permissions' }]) }, + 500 + ) + await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) + const secondUuid = session.dispatchWaiters[0]!.sentUuid + + expect( + resolveClaudeReplayWaiter(session, { + type: 'result', + subtype: 'success', + session_id: 'provider-session', + uuid: 'result-b', + user_message_uuid: secondUuid + }) + ).toBe(false) + await expect(second).resolves.toMatchObject({ + state: 'accepted', + providerIdentity: { uuid: 'result-b' } + }) + }) + + it('does not mistake a normal turn result for its missing user replay', async () => { + const session = sessionFor() + const dispatched = dispatchClaudeTurn( + session, + { clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: 'hello' }]) }, + 100 + ) + await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) + + expect( + resolveClaudeReplayWaiter(session, { + type: 'result', + session_id: 'provider-session', + uuid: 'unrelated-result-uuid' + }) + ).toBe(false) + expect(session.dispatchWaiters).toHaveLength(1) + expect( + resolveClaudeReplayWaiter(session, { + type: 'user', + parent_tool_use_id: null, + session_id: 'provider-session', + uuid: 'user-replay-uuid', + message: { + role: 'user', + content: [{ type: 'text', text: 'hello' }] + } + }) + ).toBe(true) + + await expect(dispatched).resolves.toMatchObject({ + state: 'accepted', + providerIdentity: { uuid: 'user-replay-uuid' } + }) + }) + + it('ignores a top-level tool-result user frame while waiting for a slash command replay', async () => { + const session = sessionFor() + const dispatched = dispatchClaudeTurn( + session, + { clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: '/permissions' }]) }, + 100 + ) + await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) + + resolveClaudeReplayWaiter(session, { + type: 'user', + parent_tool_use_id: null, + session_id: 'provider-session', + uuid: 'tool-result-uuid', + message: { + role: 'user', + content: [{ type: 'tool_result', tool_use_id: 'tool-1', content: 'done' }] + } + }) + expect(session.dispatchWaiters).toHaveLength(1) + + resolveClaudeReplayWaiter(session, { + type: 'user', + parent_tool_use_id: null, + session_id: 'provider-session', + uuid: 'user-replay-uuid', + message: { + role: 'user', + content: [{ type: 'text', text: '/permissions' }] + } + }) + + await expect(dispatched).resolves.toEqual({ + state: 'accepted', + providerIdentity: { + provider: 'claude', + sessionId: 'provider-session', + uuid: 'user-replay-uuid' + } + }) + }) + + it('rejects more than twenty URL images before sending', async () => { + const session = sessionFor() + const body = userMessage( + Array.from({ length: 21 }, (_, index) => ({ + type: 'image-ref' as const, + url: `https://example.test/${index}.png` + })) + ) + + await expect( + dispatchClaudeTurn(session, { clientMessageId: 'client-1', body }, 1) + ).resolves.toEqual({ state: 'rejected', reason: 'Claude messages support at most 20 images' }) + expect(session.connection.send).not.toHaveBeenCalled() + }) + + it('rejects local images whose aggregate size exceeds twenty MiB', async () => { + const directory = await mkdtemp(join(tmpdir(), 'orca-claude-images-')) + try { + const paths = await Promise.all( + Array.from({ length: 5 }, async (_, index) => { + const path = join(directory, `${index}.png`) + await writeFile(path, Buffer.alloc(5 * 1024 * 1024)) + return path + }) + ) + const session = sessionFor() + const body = userMessage(paths.map((path) => ({ type: 'image-ref' as const, path }))) + + await expect( + dispatchClaudeTurn(session, { clientMessageId: 'client-1', body }, 1) + ).resolves.toEqual({ + state: 'rejected', + reason: `Claude images must total no more than ${20 * 1024 * 1024} bytes` + }) + expect(session.connection.send).not.toHaveBeenCalled() + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) + + it('rejects a local image by actual bytes read beyond the per-image cap', async () => { + const directory = await mkdtemp(join(tmpdir(), 'orca-claude-image-')) + try { + const path = join(directory, 'oversized.png') + await writeFile(path, Buffer.alloc(5 * 1024 * 1024 + 1)) + const session = sessionFor() + const body = userMessage([{ type: 'image-ref', path }]) + + await expect( + dispatchClaudeTurn(session, { clientMessageId: 'client-1', body }, 1) + ).resolves.toEqual({ + state: 'rejected', + reason: `Claude image must be a non-empty file no larger than ${5 * 1024 * 1024} bytes` + }) + expect(session.connection.send).not.toHaveBeenCalled() + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) + + it('allocates local image reads from the file size, not the maximum cap', async () => { + const directory = await mkdtemp(join(tmpdir(), 'orca-claude-image-')) + const allocUnsafe = vi.spyOn(Buffer, 'allocUnsafe') + try { + const path = join(directory, 'small.png') + await writeFile(path, Buffer.alloc(64)) + const session = sessionFor() + const dispatched = dispatchClaudeTurn( + session, + { clientMessageId: 'client-1', body: userMessage([{ type: 'image-ref', path }]) }, + 100 + ) + await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) + const sentUuid = (session.dispatchWaiters[0] as { sentUuid?: string }).sentUuid + resolveClaudeReplayWaiter(session, { + ...userReplayFrame(sentUuid!, ''), + message: { + role: 'user', + content: [ + { type: 'image', source: { type: 'base64', media_type: 'image/png', data: '' } } + ] + } + }) + await expect(dispatched).resolves.toMatchObject({ state: 'accepted' }) + expect(allocUnsafe).toHaveBeenCalled() + expect(allocUnsafe.mock.calls.some(([size]) => size === 64 + 1)).toBe(true) + expect(allocUnsafe.mock.calls.some(([size]) => size >= 5 * 1024 * 1024)).toBe(false) + } finally { + allocUnsafe.mockRestore() + await rm(directory, { recursive: true, force: true }) + } + }) + + it('bounds retained waiter identity bytes when image dispatches time out', async () => { + const directory = await mkdtemp(join(tmpdir(), 'orca-claude-image-')) + try { + const path = join(directory, 'large.png') + await writeFile(path, Buffer.alloc(64 * 1024)) + const session = sessionFor() + const body = userMessage([{ type: 'image-ref', path }]) + await Promise.all( + Array.from({ length: 64 }, (_, index) => + dispatchClaudeTurn(session, { clientMessageId: `client-${index}`, body }, 1) + ) + ) + + expect(session.retiredDispatchWaiters).toHaveLength(64) + const retainedKeyBytes = session.retiredDispatchWaiters.reduce( + (total, waiter) => total + waiter.replayContentKey.length, + 0 + ) + expect(retainedKeyBytes).toBeLessThan(64 * 512) + expect( + session.retiredDispatchWaiters.every((waiter) => waiter.replayContentKey.length < 512) + ).toBe(true) + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) + + it('rejects a local image when it grows after the initial stat', async () => { + const stat = vi + .fn() + .mockResolvedValueOnce({ isFile: () => true, size: 64 }) + .mockResolvedValueOnce({ isFile: () => true, size: 128 }) + const read = vi.fn(async (buffer: Buffer, offset: number) => { + if (read.mock.calls.length === 1) { + buffer.fill(1, offset, offset + 64) + return { bytesRead: 64, buffer } + } + return { bytesRead: 0, buffer } + }) + const open = vi.fn().mockResolvedValue({ + stat, + read, + close: vi.fn().mockResolvedValue(undefined) + } as never) + await expect(readClaudeImage('/controlled/growing.png', open)).rejects.toThrow( + `Claude image must be a non-empty file no larger than ${5 * 1024 * 1024} bytes` + ) + }) +}) diff --git a/src/main/claude/claude-structured-dispatch.ts b/src/main/claude/claude-structured-dispatch.ts new file mode 100644 index 00000000000..96271e41d71 --- /dev/null +++ b/src/main/claude/claude-structured-dispatch.ts @@ -0,0 +1,264 @@ +import { randomUUID } from 'node:crypto' +import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' +import type { AgentSessionDispatchOutcome } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { + claudeHasReplayContent, + readClaudeMessageEnvelope +} from './claude-structured-item-translation' +import type { ClaudeDispatchWaiter, ClaudeSession } from './claude-structured-session-state' +import { readClaudeFrameString } from './claude-structured-init-proof' +import { + claudeDispatchContentKey, + claudeDispatchMessageContent +} from './claude-structured-dispatch-content' + +const MAX_RETIRED_DISPATCH_WAITERS = 64 + +export function resolveClaudeReplayWaiter( + session: ClaudeSession, + message: Record +): boolean { + const envelope = readClaudeMessageEnvelope(message) + const isUserReplay = + envelope?.role === 'user' && + message.parent_tool_use_id === null && + claudeHasReplayContent(envelope) + const isCompletedCommand = message.type === 'result' + if ( + (!isUserReplay && !isCompletedCommand) || + readClaudeFrameString(message, 'session_id') !== session.providerSessionId + ) { + return false + } + const uuid = readClaudeFrameString(message, 'uuid') + if (!uuid) { + return false + } + + // Newer SDK frames carry the client uuid that caused a turn. A correlation + // value is authoritative: never fall back to queue order or content, since + // identical prompts may be in flight across a timeout boundary. + const userMessageUuid = readClaudeFrameString(message, 'user_message_uuid') + if (userMessageUuid) { + const exact = session.dispatchWaiters.find( + (candidate) => candidate.sentUuid === userMessageUuid + ) + if (exact) { + settleWaiter(session, exact, uuid) + return isUserReplay && exact.dispatchSequence === session.dispatchSequence + } + const retired = session.retiredDispatchWaiters.find( + (candidate) => candidate.sentUuid === userMessageUuid + ) + if (retired) { + forgetRetiredWaiter(session, retired) + return recoverLateIdentity(session, retired, uuid, isUserReplay) + } + return false + } + + const exact = session.dispatchWaiters.find((candidate) => candidate.sentUuid === uuid) + if (exact) { + settleWaiter(session, exact, uuid) + return isUserReplay && exact.dispatchSequence === session.dispatchSequence + } + const retired = session.retiredDispatchWaiters.find((candidate) => candidate.sentUuid === uuid) + if (retired) { + forgetRetiredWaiter(session, retired) + return recoverLateIdentity(session, retired, uuid, isUserReplay) + } + + if (isUserReplay) { + // Compatibility CLIs may mint a new replay uuid instead of echoing the + // client uuid. Content is an acceptable join only when it is the sole + // candidate on one side of the timeout boundary; with active and retired + // candidates present, identical prompts are intentionally left unknown. + const replayContentKey = claudeDispatchContentKey(envelope.content) + if (!session.replayContentFallbackBlocked && session.retiredDispatchWaiters.length === 0) { + const compatible = session.dispatchWaiters.filter( + (candidate) => candidate.replayContentKey === replayContentKey + ) + if (compatible.length === 1) { + settleWaiter(session, compatible[0]!, uuid) + return compatible[0]!.dispatchSequence === session.dispatchSequence + } + } else if (!session.replayContentFallbackBlocked && session.dispatchWaiters.length === 0) { + const lateCompatible = session.retiredDispatchWaiters.filter( + (candidate) => candidate.replayContentKey === replayContentKey + ) + if (lateCompatible.length === 1) { + const [candidate] = lateCompatible + forgetRetiredWaiter(session, candidate!) + return recoverLateIdentity(session, candidate!, uuid, true) + } + } + return false + } + const current = session.dispatchWaiters[0] + if (isCompletedCommand && !current?.acceptsResult) { + return false + } + // A legacy result has no dispatch correlation. Any retired waiter makes queue order ambiguous, + // even when the retired dispatch was an ordinary turn rather than a slash command. + if (isCompletedCommand && session.retiredDispatchWaiters.length > 0) { + return false + } + // Once an eviction occurred, a fresh result uuid cannot be joined to a waiter by queue order. + if (isCompletedCommand && session.replayContentFallbackBlocked) { + return false + } + const waiter = uuid ? session.dispatchWaiters.shift() : undefined + if (waiter && uuid) { + clearTimeout(waiter.timer) + waiter.settledUuid = uuid + waiter.resolve(uuid) + return isUserReplay + } + return false +} + +function settleWaiter(session: ClaudeSession, waiter: ClaudeDispatchWaiter, uuid: string): void { + const index = session.dispatchWaiters.indexOf(waiter) + if (index !== -1) { + session.dispatchWaiters.splice(index, 1) + } + clearTimeout(waiter.timer) + waiter.settledUuid = uuid + waiter.resolve(uuid) +} + +function forgetRetiredWaiter(session: ClaudeSession, waiter: ClaudeDispatchWaiter): void { + const index = session.retiredDispatchWaiters.indexOf(waiter) + if (index !== -1) { + session.retiredDispatchWaiters.splice(index, 1) + } +} + +function recoverLateIdentity( + session: ClaudeSession, + waiter: ClaudeDispatchWaiter, + uuid: string, + isUserReplay: boolean +): boolean { + if (!isUserReplay && !waiter.acceptsResult) { + return false + } + if (waiter.dispatchSequence === session.dispatchSequence) { + session.activeTurnId = uuid + session.activeTurnSequence = waiter.dispatchSequence + } + return isUserReplay && waiter.dispatchSequence === session.dispatchSequence +} + +function waitForReplay( + session: ClaudeSession, + timeoutMs: number, + acceptsResult: boolean, + sentUuid: string, + replayContentKey: string +): { waiter: ClaudeDispatchWaiter; promise: Promise } { + let waiter!: ClaudeDispatchWaiter + const promise = new Promise((resolve) => { + waiter = { + acceptsResult, + sentUuid, + dispatchSequence: session.dispatchSequence, + replayContentKey, + resolve, + timer: setTimeout(() => { + const index = session.dispatchWaiters.indexOf(waiter) + if (index !== -1) { + session.dispatchWaiters.splice(index, 1) + } + retireWaiter(session, waiter) + resolve(null) + }, timeoutMs) + } + waiter.timer.unref?.() + session.dispatchWaiters.push(waiter) + }) + return { waiter, promise } +} + +function retireWaiter(session: ClaudeSession, waiter: ClaudeDispatchWaiter): void { + const index = session.dispatchWaiters.indexOf(waiter) + if (index !== -1) { + session.dispatchWaiters.splice(index, 1) + } + clearTimeout(waiter.timer) + if (!waiter.retired) { + waiter.retired = true + session.retiredDispatchWaiters.push(waiter) + if (session.retiredDispatchWaiters.length > MAX_RETIRED_DISPATCH_WAITERS) { + session.replayContentFallbackBlocked = true + session.retiredDispatchWaiters.splice( + 0, + session.retiredDispatchWaiters.length - MAX_RETIRED_DISPATCH_WAITERS + ) + } + } +} + +export async function dispatchClaudeTurn( + session: ClaudeSession, + input: { clientMessageId: string; body: AgentJournalMessageItem }, + timeoutMs: number +): Promise { + let content: unknown[] + try { + content = await claudeDispatchMessageContent(input.body) + } catch (error) { + return { state: 'rejected', reason: (error as Error).message } + } + const dispatchSequence = ++session.dispatchSequence + const acceptsResult = input.body.blocks.some( + (block) => block.type === 'text' && block.text.trimStart().startsWith('/') + ) + const sentUuid = randomUUID() + const replay = waitForReplay( + session, + timeoutMs, + acceptsResult, + sentUuid, + claudeDispatchContentKey(content) + ) + const replayed = replay.promise + try { + await session.connection.send({ + type: 'user', + uuid: sentUuid, + message: { role: 'user', content }, + parent_tool_use_id: null, + session_id: session.providerSessionId + }) + } catch (error) { + const waiter = replay.waiter + if (waiter.settledUuid) { + const uuid = await replayed + if (uuid) { + session.activeTurnId = uuid + session.activeTurnSequence = dispatchSequence + return { + state: 'accepted', + providerIdentity: { provider: 'claude', sessionId: session.providerSessionId, uuid } + } + } + } + if (!waiter.retired) { + retireWaiter(session, waiter) + waiter.resolve(null) + } + return { state: 'unknown', reason: (error as Error).message } + } + const uuid = await replayed + if (uuid) { + session.activeTurnId = uuid + session.activeTurnSequence = dispatchSequence + } + return uuid + ? { + state: 'accepted', + providerIdentity: { provider: 'claude', sessionId: session.providerSessionId, uuid } + } + : { state: 'unknown', reason: 'claude accepted a message but did not replay its uuid in time' } +} diff --git a/src/main/claude/claude-structured-effort-reporting.test.ts b/src/main/claude/claude-structured-effort-reporting.test.ts new file mode 100644 index 00000000000..be022d86956 --- /dev/null +++ b/src/main/claude/claude-structured-effort-reporting.test.ts @@ -0,0 +1,257 @@ +import { describe, expect, it } from 'vitest' +import { AgentSessionOptionRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-option-error' +import { + restoreClaudeStructuredSessionOptions, + setClaudeStructuredOption +} from './claude-structured-options' +import { readClaudeSettingsEffort } from './claude-structured-session-options' +import type { ClaudeSession } from './claude-structured-session-state' +import type { ClaudeStructuredSessionEvent } from './claude-structured-session-adapter' +import { acquired, fakeClaude } from './claude-structured-session-test-support' + +/** Verbatim from Claude Code 2.1.258's get_settings response. */ +const REAL_SETTINGS = { + applied: { model: 'claude-opus-5[1m]', effort: 'high', advisor: null, ultracode: false }, + effective: { model: 'claude-opus-5[1m]', effortLevel: 'high', env: {} }, + sources: {} +} + +function sessionWith( + reported: string | null, + calls: string[] = [], + listed?: { model: string; catalog: readonly Record[] } +) { + return { + session: { + options: new Map(listed ? [['model', listed.model]] : []), + reportedOptions: {} as { model?: string; effort?: string }, + optionMutationSequence: 0, + reportedModelMutation: 0, + confirmedOptions: new Set(), + restoreSkippedOptions: new Set(), + connection: { + supportedModels: async () => { + calls.push('list_models') + return [...(listed?.catalog ?? [])] + }, + setModel: async (model: string) => { + calls.push(`set_model:${model}`) + }, + applyFlagSettings: async (settings: { effortLevel?: string }) => { + // The measured behaviour: an unknown effort is accepted and ignored. + calls.push(`apply:${settings.effortLevel}`) + }, + getSettings: async () => { + calls.push('get_settings') + return reported === null + ? { applied: {}, effective: {}, sources: {} } + : { applied: { effort: reported }, effective: { effortLevel: reported }, sources: {} } + } + } + } as unknown as ClaudeSession, + calls + } +} + +describe('Claude effort reporting', () => { + it('reads the effort get_settings reports', () => { + expect(readClaudeSettingsEffort(REAL_SETTINGS)).toBe('high') + }) + + it.each([ + [ + 'the provider stops reporting it', + { applied: { effort: 'high' }, effective: {}, sources: {} } + ], + ['the payload carries no effective block', { applied: { effort: 'high' } }], + ['the request failed outright', null] + ])('reports no effort when %s', (_case, settings) => { + // Never defaulted: an effort nothing measured would be worse than a blank + // pill, and this is the assertion that goes red if the key is renamed. + expect(readClaudeSettingsEffort(settings)).toBeNull() + }) + + it('publishes the effort from get_settings, which system/init never carries', async () => { + const claude = fakeClaude({ settings: REAL_SETTINGS }) + const adapter = await acquired(claude) + + await expect(adapter.readOptions({ sessionId: 'session-1', fence: 7 })).resolves.toMatchObject({ + current: { effort: 'high' } + }) + }) + + it('leaves the effort unreported when the session never learns one', async () => { + const claude = fakeClaude({ settings: { applied: {}, effective: {}, sources: {} } }) + const adapter = await acquired(claude) + + const options = await adapter.readOptions({ sessionId: 'session-1', fence: 7 }) + expect(options.current.effort).toBeUndefined() + expect(options.current.model).toBeTruthy() + }) + + it('keeps the init fixture free of an effort the real frame never sends', async () => { + const events: ClaudeStructuredSessionEvent[] = [] + await acquired(fakeClaude(), {}, events) + const init = events.flatMap((event) => + event.type === 'message' && event.message.subtype === 'init' ? [event.message] : [] + ) + + expect(init).toHaveLength(1) + expect(init[0]).toHaveProperty('model') + // The regression that hid this defect: a fixture inventing `effortLevel` + // kept every gate green over a value that is always empty in production. + expect(Object.keys(init[0])).not.toContain('effortLevel') + }) +}) + +describe('Claude effort readback', () => { + it('records an effort the child did not adopt without vouching for it', async () => { + const { session, calls } = sessionWith('high') + + // The disagreement stops the confirmation, not the write: no other client + // vetoes here, and the pre-flight catalog guard already refuses the levels + // the model cannot run. + await expect( + setClaudeStructuredOption(session, { key: 'effort', value: 'bogus-effort-xyz' }, undefined) + ).resolves.toEqual({ effort: 'bogus-effort-xyz' }) + expect(session.confirmedOptions.has('effort')).toBe(false) + // The child's own answer is kept rather than discarded with the refusal. + expect(session.reportedOptions.effort).toBe('high') + expect(calls).toEqual(['apply:bogus-effort-xyz', 'get_settings']) + }) + + it('records an effort the child confirms', async () => { + const { session } = sessionWith('low') + + await expect( + setClaudeStructuredOption(session, { key: 'effort', value: 'low' }, undefined) + ).resolves.toEqual({ effort: 'low' }) + }) + + it('records the request when the readback is unavailable', async () => { + // No evidence of a refusal is not evidence of one; the apply itself succeeded. + const { session } = sessionWith(null) + + await expect( + setClaudeStructuredOption(session, { key: 'effort', value: 'low' }, undefined) + ).resolves.toEqual({ effort: 'low' }) + }) +}) + +describe('Claude effort against the model that must run it', () => { + const HAIKU = { value: 'haiku', resolvedModel: 'claude-haiku-4-5-20251001', displayName: 'Haiku' } + const SONNET = { + value: 'sonnet', + resolvedModel: 'claude-sonnet-5', + displayName: 'Sonnet', + supportsEffort: true, + supportedEffortLevels: ['low', 'medium', 'high', 'xhigh', 'max'] + } + + it('refuses an effort the current model advertises no control for', async () => { + const { session, calls } = sessionWith('high', [], { model: 'haiku', catalog: [HAIKU, SONNET] }) + + await expect( + setClaudeStructuredOption(session, { key: 'effort', value: 'high' }, undefined) + ).rejects.toBeInstanceOf(AgentSessionOptionRejectedError) + // Measured on Claude Code 2.1.260: apply_flag_settings stores `high` on a + // haiku session and get_settings reads it straight back, so a send here is + // never undone. The refusal has to land before the write. + expect(calls).toEqual(['list_models']) + expect(session.options.has('effort')).toBe(false) + }) + + it('refuses a level outside the ones the current model advertises', async () => { + const { session } = sessionWith('high', [], { + model: 'sonnet', + catalog: [{ ...SONNET, supportedEffortLevels: ['low', 'medium'] }] + }) + + await expect( + setClaudeStructuredOption(session, { key: 'effort', value: 'high' }, undefined) + ).rejects.toBeInstanceOf(AgentSessionOptionRejectedError) + }) + + it('sends an effort the current model advertises', async () => { + const { session, calls } = sessionWith('high', [], { + model: 'sonnet', + catalog: [HAIKU, SONNET] + }) + + await expect( + setClaudeStructuredOption(session, { key: 'effort', value: 'high' }, undefined) + ).resolves.toEqual({ model: 'sonnet', effort: 'high' }) + expect(calls).toEqual(['list_models', 'apply:high', 'get_settings']) + expect(session.confirmedOptions.has('effort')).toBe(true) + }) + + it('sends `max`, which the readback cannot report, when the model advertises it', async () => { + // UNREPORTED_EFFORTS still governs: no get_settings, so no false disagreement. + const { session, calls } = sessionWith('high', [], { model: 'sonnet', catalog: [SONNET] }) + + await expect( + setClaudeStructuredOption(session, { key: 'effort', value: 'max' }, undefined) + ).resolves.toEqual({ model: 'sonnet', effort: 'max' }) + expect(calls).toEqual(['list_models', 'apply:max']) + expect(session.confirmedOptions.has('effort')).toBe(false) + }) + + it('sends the effort when the model is not in the catalog the CLI listed', async () => { + // An unlisted model is an unknown one, not one that refuses effort. + const { session, calls } = sessionWith('high', [], { model: 'sonnet', catalog: [HAIKU] }) + + await expect( + setClaudeStructuredOption(session, { key: 'effort', value: 'high' }, undefined) + ).resolves.toEqual({ model: 'sonnet', effort: 'high' }) + expect(calls).toEqual(['list_models', 'apply:high', 'get_settings']) + }) + + it('sends the effort when list_models is unavailable', async () => { + const calls: string[] = [] + const { session } = sessionWith('high', calls, { model: 'sonnet', catalog: [] }) + session.connection.supportedModels = async () => { + calls.push('list_models') + throw new Error('this CLI predates list_models') + } + + await expect( + setClaudeStructuredOption(session, { key: 'effort', value: 'high' }, undefined) + ).resolves.toEqual({ model: 'sonnet', effort: 'high' }) + expect(calls).toEqual(['list_models', 'apply:high', 'get_settings']) + }) + + it('matches the model the init frame reported, not just the id the user picked', async () => { + const { session } = sessionWith('high', [], { model: 'sonnet', catalog: [HAIKU, SONNET] }) + session.options.delete('model') + session.reportedOptions.model = 'claude-haiku-4-5-20251001' + + await expect( + setClaudeStructuredOption(session, { key: 'effort', value: 'high' }, undefined) + ).rejects.toBeInstanceOf(AgentSessionOptionRejectedError) + }) + + it('keeps a disagreeing effort through restore instead of skipping it', async () => { + const calls: string[] = [] + const { session } = sessionWith('high', calls, { model: 'sonnet', catalog: [SONNET] }) + session.options.set('effort', 'low') + + await restoreClaudeStructuredSessionOptions(session, undefined) + + expect(session.options.get('effort')).toBe('low') + expect(session.restoreSkippedOptions.has('effort')).toBe(false) + expect(session.confirmedOptions.has('effort')).toBe(false) + }) + + it('drops a stale effort on restore instead of replaying it onto the new model', async () => { + const calls: string[] = [] + const { session } = sessionWith('high', calls, { model: 'sonnet', catalog: [HAIKU, SONNET] }) + session.options.set('model', 'haiku') + session.options.set('effort', 'high') + + await restoreClaudeStructuredSessionOptions(session, undefined) + + expect(session.options.has('effort')).toBe(false) + expect(session.restoreSkippedOptions.has('effort')).toBe(true) + expect(calls.filter((call) => call.startsWith('apply:'))).toEqual([]) + }) +}) diff --git a/src/main/claude/claude-structured-inbound-control.test.ts b/src/main/claude/claude-structured-inbound-control.test.ts new file mode 100644 index 00000000000..07be4bbb516 --- /dev/null +++ b/src/main/claude/claude-structured-inbound-control.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it, vi } from 'vitest' +import type { CanUseTool } from '@anthropic-ai/claude-agent-sdk' +import { ClaudePromptRegistry } from './claude-structured-prompt-replies' +import { + buildClaudePermissionCallbacks, + CLAUDE_BLOCKING_CONTROL_CALLBACKS, + CLAUDE_CAN_USE_TOOL_SUBTYPE, + CLAUDE_REQUEST_USER_DIALOG_SUBTYPE +} from './claude-structured-inbound-control' + +type CanUseToolOptions = Parameters[2] + +function permissionOptions( + requestId: string, + toolUseID: string, + signal: AbortSignal, + suggestions?: unknown[] +): CanUseToolOptions { + return { + requestId, + toolUseID, + signal, + ...(suggestions ? { suggestions } : {}) + } as unknown as CanUseToolOptions +} + +function callbacksFor() { + const prompts = new ClaudePromptRegistry() + const emit = vi.fn() + const { canUseTool, onUserDialog } = buildClaudePermissionCallbacks({ + sessionId: 'session-1', + prompts, + emit + }) + return { prompts, emit, canUseTool, onUserDialog } +} + +describe('Claude permission callbacks', () => { + it('registers a decodable can_use_tool as a durable prompt and settles it from the registry', async () => { + const control = callbacksFor() + const answered = control.canUseTool( + 'Bash', + { command: 'git status' }, + permissionOptions('perm-1', 'tool-1', new AbortController().signal, [{ type: 'addRules' }]) + ) + + expect(control.emit).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'prompt', + sessionId: 'session-1', + prompt: expect.objectContaining({ promptKey: 'perm-1', toolName: 'Bash', kind: 'approval' }) + }) + ) + const found = control.prompts.find('perm-1') + expect(found?.prompt.suggestions).toEqual([{ type: 'addRules' }]) + // The prompt's settle is the SDK callback's own resolve — answering resolves this promise. + found?.prompt.settle({ behavior: 'allow', toolUseID: 'tool-1' }) + await expect(answered).resolves.toEqual({ behavior: 'allow', toolUseID: 'tool-1' }) + }) + + it('denies a malformed permission request without registering a prompt', async () => { + const control = callbacksFor() + const answered = control.canUseTool( + '', + {}, + permissionOptions('perm-2', 'tool-2', new AbortController().signal) + ) + + await expect(answered).resolves.toEqual({ + behavior: 'deny', + message: 'Orca could not decode this permission request.', + toolUseID: 'tool-2' + }) + expect(control.prompts.find('perm-2')).toBeNull() + expect(control.emit).not.toHaveBeenCalled() + }) + + it('settles a pending prompt with null and forgets it when the abort signal fires', async () => { + const control = callbacksFor() + const controller = new AbortController() + const answered = control.canUseTool( + 'Bash', + { command: 'ls' }, + permissionOptions('perm-3', 'tool-3', controller.signal) + ) + expect(control.prompts.find('perm-3')).not.toBeNull() + + controller.abort() + + await expect(answered).resolves.toBeNull() + expect(control.emit).toHaveBeenLastCalledWith( + expect.objectContaining({ type: 'prompt-cancelled', promptKey: 'perm-3' }) + ) + // Forgotten: a late answer can no longer find the prompt to authorize the wrong tool. + expect(control.prompts.find('perm-3')).toBeNull() + }) + + it('cancels a request whose abort raced ahead of delivery without emitting a prompt', async () => { + const control = callbacksFor() + const controller = new AbortController() + controller.abort() + + const answered = control.canUseTool( + 'Bash', + { command: 'ls' }, + permissionOptions('perm-4', 'tool-4', controller.signal) + ) + + await expect(answered).resolves.toBeNull() + expect(control.prompts.find('perm-4')).toBeNull() + expect(control.emit).toHaveBeenCalledTimes(1) + expect(control.emit).toHaveBeenCalledWith( + expect.objectContaining({ type: 'prompt-cancelled', promptKey: 'perm-4' }) + ) + }) + + it('settles every in-flight prompt with null when the registry is cleared', async () => { + const control = callbacksFor() + const first = control.canUseTool( + 'Bash', + { command: 'a' }, + permissionOptions('perm-5', 'tool-5', new AbortController().signal) + ) + const second = control.canUseTool( + 'Bash', + { command: 'b' }, + permissionOptions('perm-6', 'tool-6', new AbortController().signal) + ) + + // What session close does: settle each pending callback so no promise dangles. + for (const prompt of control.prompts.clear()) { + prompt.settle(null) + } + + await expect(first).resolves.toBeNull() + await expect(second).resolves.toBeNull() + }) + + it('answers a user dialog deny-safe', async () => { + const control = callbacksFor() + await expect( + control.onUserDialog( + { dialogKind: 'refusal_fallback_prompt', payload: {} }, + { signal: new AbortController().signal, requestId: 'dialog-1' } + ) + ).resolves.toEqual({ behavior: 'cancelled' }) + }) + + it('enumerates every blocking control request and wires a callback for each', () => { + // The stable surface of controls a turn can block on. Adding one here without wiring its + // callback below fails this test rather than silently leaving a control unhandled. + expect(new Set(Object.keys(CLAUDE_BLOCKING_CONTROL_CALLBACKS))).toEqual( + new Set([CLAUDE_CAN_USE_TOOL_SUBTYPE, CLAUDE_REQUEST_USER_DIALOG_SUBTYPE]) + ) + const callbacks = buildClaudePermissionCallbacks({ + sessionId: 'session-1', + prompts: new ClaudePromptRegistry(), + emit: vi.fn() + }) as unknown as Record + for (const callbackName of Object.values(CLAUDE_BLOCKING_CONTROL_CALLBACKS)) { + expect(typeof callbacks[callbackName], `${callbackName} must be wired`).toBe('function') + } + }) +}) diff --git a/src/main/claude/claude-structured-inbound-control.ts b/src/main/claude/claude-structured-inbound-control.ts new file mode 100644 index 00000000000..343e76d4ea5 --- /dev/null +++ b/src/main/claude/claude-structured-inbound-control.ts @@ -0,0 +1,91 @@ +import type { CanUseTool, OnUserDialog, PermissionResult } from '@anthropic-ai/claude-agent-sdk' +import type { ClaudePromptRegistry } from './claude-structured-prompt-replies' +import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state' + +export const CLAUDE_CAN_USE_TOOL_SUBTYPE = 'can_use_tool' +export const CLAUDE_REQUEST_USER_DIALOG_SUBTYPE = 'request_user_dialog' + +/** + * The blocking control requests Orca answers, each mapped to the SDK consumer callback that + * answers it. This is the stable surface a real turn can block on: `can_use_tool` through + * `canUseTool` and `request_user_dialog` through `onUserDialog`. Every other control-request + * subtype the SDK routes (elicitation, oauth/host token refresh, mcp_message, hook_callback) + * is either not surfaced to this consumer or fails closed inside the SDK; adding a new + * blocking control Orca must answer means adding its callback here, and the catalog test + * fails if a named callback is missing. + */ +export const CLAUDE_BLOCKING_CONTROL_CALLBACKS = { + [CLAUDE_CAN_USE_TOOL_SUBTYPE]: 'canUseTool', + [CLAUDE_REQUEST_USER_DIALOG_SUBTYPE]: 'onUserDialog' +} as const + +export type ClaudeBlockingControlSubtype = keyof typeof CLAUDE_BLOCKING_CONTROL_CALLBACKS + +export type ClaudePermissionCallbackDeps = { + sessionId: string + prompts: ClaudePromptRegistry + emit: (event: ClaudeStructuredSessionEvent) => void +} + +function denySafeResult(toolUseId: string | undefined): PermissionResult { + return { + behavior: 'deny', + message: 'Orca could not decode this permission request.', + ...(toolUseId ? { toolUseID: toolUseId } : {}) + } +} + +/** + * Build the SDK permission callbacks from the durable prompt registry. + * + * A decodable `can_use_tool` becomes a durable prompt whose `settle` resolves this callback; + * a malformed one is denied without registering. The SDK's abort signal fires on + * `control_cancel_request` (a cancelled turn), which forgets the prompt and settles it with + * `null` — never authorizing a tool. A late answer after abort finds no prompt and is refused + * by `answerClaudePrompt`. `onUserDialog` is deny-safe; the CLI only emits dialog kinds Orca + * declares in `supportedDialogKinds`, which is empty. + */ +export function buildClaudePermissionCallbacks(deps: ClaudePermissionCallbackDeps): { + canUseTool: CanUseTool + onUserDialog: OnUserDialog +} { + const canUseTool: CanUseTool = (toolName, input, options) => + new Promise((resolve) => { + const prompt = deps.prompts.register({ + requestId: options.requestId, + toolName, + toolUseId: options.toolUseID, + input, + suggestions: options.suggestions ?? [], + settle: resolve as (response: Record | null) => void + }) + if (!prompt) { + resolve(denySafeResult(options.toolUseID)) + return + } + const cancel = (): void => { + if (deps.prompts.forgetIfPending(prompt)) { + deps.emit({ + type: 'prompt-cancelled', + sessionId: deps.sessionId, + promptKey: prompt.promptKey + }) + // Null is the SDK's "no response written" sentinel: a cancelled request must not + // be answered, only forgotten. + resolve(null) + } + } + if (options.signal.aborted) { + // No abort event can still fire, so registering a listener would park the callback + // forever behind a prompt nothing will answer. + cancel() + return + } + options.signal.addEventListener('abort', cancel, { once: true }) + deps.emit({ type: 'prompt', sessionId: deps.sessionId, prompt }) + }) + + const onUserDialog: OnUserDialog = () => Promise.resolve({ behavior: 'cancelled' }) + + return { canUseTool, onUserDialog } +} diff --git a/src/main/claude/claude-structured-init-deadline.ts b/src/main/claude/claude-structured-init-deadline.ts new file mode 100644 index 00000000000..f3acd6c3af9 --- /dev/null +++ b/src/main/claude/claude-structured-init-deadline.ts @@ -0,0 +1,68 @@ +import type { ClaudeInitObservation } from './claude-structured-init-proof' +import { claudeInitializationAuthError } from './claude-structured-init-proof' +import type { ClaudeStreamJsonConnection } from './claude-stream-json-connection' +import { AgentSessionAcquisitionRefusal } from '../native-chat/agent-session-wire/structured-agent-session-adapter' + +export type ClaudeInitDeadline = { + promise: Promise + resolve: (init: ClaudeInitObservation) => void + reject: (error: Error) => void + start: () => void + clear: () => void +} + +export function claudeInitTimeoutError( + sessionId: string, + timeoutMs: number +): AgentSessionAcquisitionRefusal { + return new AgentSessionAcquisitionRefusal( + `Claude did not finish starting session ${sessionId} within ${Math.ceil(timeoutMs / 1000)} seconds. Verify the selected Claude account is signed in and CLAUDE_CONFIG_DIR contains valid credentials, then retry; no SessionStart or system/init proof arrived.` + ) +} + +export async function requestClaudeInitialization( + connection: ClaudeStreamJsonConnection, + sessionId: string, + timeoutMs: number +): Promise { + try { + const result = await connection.initializationResult({ timeoutMs }) + const authError = claudeInitializationAuthError(result) + if (authError) { + throw authError + } + return result + } catch (error) { + if (error instanceof Error && error.message === 'claude initialize request timed out') { + throw claudeInitTimeoutError(sessionId, timeoutMs) + } + throw error + } +} + +export function createClaudeInitDeadline(sessionId: string, timeoutMs: number): ClaudeInitDeadline { + let resolve = (_init: ClaudeInitObservation): void => {} + let reject = (_error: Error): void => {} + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + void promise.catch(() => {}) + let timer: ReturnType | null = null + + return { + promise, + resolve, + reject, + start: () => { + timer = setTimeout(() => reject(claudeInitTimeoutError(sessionId, timeoutMs)), timeoutMs) + timer.unref?.() + }, + clear: () => { + if (timer) { + clearTimeout(timer) + timer = null + } + } + } +} diff --git a/src/main/claude/claude-structured-init-proof.ts b/src/main/claude/claude-structured-init-proof.ts new file mode 100644 index 00000000000..c29cb2d4715 --- /dev/null +++ b/src/main/claude/claude-structured-init-proof.ts @@ -0,0 +1,88 @@ +import { CLAUDE_DEFAULT_SETTING_SOURCES } from './claude-structured-launch-resolution' +import type { ClaudeAuthDiagnostic } from './claude-structured-session-state' +import { AgentSessionAcquisitionRefusal } from '../native-chat/agent-session-wire/structured-agent-session-adapter' + +export type ClaudeInitObservation = { + providerSessionId: string + uuid: string | null + /** The resolved model id the CLI reports it is running; only `system/init` carries it. */ + model: string | null + message: Record +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +export function readClaudeFrameString(source: Record, key: string): string | null { + const value = source[key] + return typeof value === 'string' && value.length > 0 ? value : null +} + +export function readClaudeInit(message: Record): ClaudeInitObservation | null { + const hookName = readClaudeFrameString(message, 'hook_name') + const isInit = message.type === 'system' && message.subtype === 'init' + const isSessionStart = + message.type === 'system' && + (message.subtype === 'hook_started' || message.subtype === 'hook_response') && + hookName?.startsWith('SessionStart:') === true + if (!isInit && !isSessionStart) { + return null + } + const providerSessionId = readClaudeFrameString(message, 'session_id') + return providerSessionId + ? { + providerSessionId, + uuid: isInit ? readClaudeFrameString(message, 'uuid') : null, + model: isInit ? readClaudeFrameString(message, 'model') : null, + message + } + : null +} + +export function readClaudeModels(initialization: unknown): unknown[] { + return isRecord(initialization) && Array.isArray(initialization.models) + ? initialization.models + : [] +} + +/** CLI capabilities advertised on the initialize result or the yielded system/init frame. */ +export function readClaudeCapabilities( + init: ClaudeInitObservation, + initialization: unknown +): string[] { + const fromResult = isRecord(initialization) ? initialization.capabilities : undefined + const fromFrame = init.message.capabilities + const source = Array.isArray(fromResult) ? fromResult : Array.isArray(fromFrame) ? fromFrame : [] + return source.filter((value): value is string => typeof value === 'string') +} + +export function claudeInitializationAuthError( + initialization: unknown +): AgentSessionAcquisitionRefusal | null { + const account = + isRecord(initialization) && isRecord(initialization.account) ? initialization.account : null + return readClaudeFrameString(account ?? {}, 'tokenSource') === 'none' + ? new AgentSessionAcquisitionRefusal( + 'Claude is not signed in for the selected account. Sign in with the Claude CLI for this CLAUDE_CONFIG_DIR, then retry.' + ) + : null +} + +export function claudeAuthDiagnostic( + init: ClaudeInitObservation, + settings: unknown +): ClaudeAuthDiagnostic { + const env = isRecord(settings) && isRecord(settings.env) ? settings.env : {} + const apiKeySource = readClaudeFrameString(init.message, 'apiKeySource') + const configured = (key: string): boolean => + (typeof env[key] === 'string' && (env[key] as string).trim().length > 0) || + Boolean(process.env[key]?.trim()) + return { + apiKeySourceConfigured: apiKeySource !== null && apiKeySource !== 'none', + baseUrlConfigured: configured('ANTHROPIC_BASE_URL'), + authTokenConfigured: configured('ANTHROPIC_AUTH_TOKEN'), + apiKeyConfigured: configured('ANTHROPIC_API_KEY'), + settingSources: CLAUDE_DEFAULT_SETTING_SOURCES + } +} diff --git a/src/main/claude/claude-structured-item-translation.ts b/src/main/claude/claude-structured-item-translation.ts new file mode 100644 index 00000000000..d86093ee0a5 --- /dev/null +++ b/src/main/claude/claude-structured-item-translation.ts @@ -0,0 +1,179 @@ +import type { + AgentJournalItemBody, + AgentJournalItemIdentity, + AgentJournalMessageItem +} from '../../shared/agent-session-journal-types' +import type { NativeChatBlock } from '../../shared/native-chat-types' +import { + boundInlineText, + DEFAULT_JOURNAL_PAYLOAD_LIMITS +} from '../native-chat/agent-session-journal/journal-payload-bounds' + +export type ClaudeMessageEnvelope = { + sessionId: string + uuid: string + role: 'assistant' | 'user' + content: unknown[] + /** Messages API id shared by every frame of one streamed assistant message. */ + messageId: string | null + parentToolUseId: string | null +} + +export type ClaudeToolUse = { id: string; name: string; input: unknown } +export type ClaudeToolResult = { toolUseId: string; output: string; failed: boolean } + +export function claudeRecord(value: unknown): Record | null { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : null +} + +export function claudeText(value: unknown): string | null { + return typeof value === 'string' && value.length > 0 ? value : null +} + +export function readClaudeMessageEnvelope( + frame: Record +): ClaudeMessageEnvelope | null { + if (frame.type !== 'assistant' && frame.type !== 'user') { + return null + } + const message = claudeRecord(frame.message) + const sessionId = claudeText(frame.session_id) + const uuid = claudeText(frame.uuid) + const role = message?.role + return sessionId && uuid && (role === 'assistant' || role === 'user') + ? { + sessionId, + uuid, + role, + content: messageContent(message?.content), + messageId: claudeText(message?.id), + parentToolUseId: claudeText(frame.parent_tool_use_id) + } + : null +} + +// A user replay may carry its text as a bare string (MessageParam), not blocks. +function messageContent(content: unknown): unknown[] { + if (Array.isArray(content)) { + return content + } + const text = claudeText(content) + return text ? [{ type: 'text', text }] : [] +} + +export function claudeMessageIdentity( + envelope: Pick +): AgentJournalItemIdentity { + return { provider: 'claude', sessionId: envelope.sessionId, uuid: envelope.uuid } +} + +function messageBlocks(envelope: ClaudeMessageEnvelope): NativeChatBlock[] { + const blocks: NativeChatBlock[] = [] + for (const value of envelope.content) { + const part = claudeRecord(value) + const text = claudeText(part?.text) + if (part?.type === 'text' && text) { + blocks.push({ type: 'text', text }) + continue + } + const source = claudeRecord(part?.source) + const url = claudeText(source?.url) + if (part?.type === 'image' && source?.type === 'url' && url) { + blocks.push({ type: 'image-ref', url }) + } + } + return blocks +} + +export function claudeMessageBody(envelope: ClaudeMessageEnvelope): AgentJournalMessageItem | null { + const blocks = messageBlocks(envelope) + return blocks.length > 0 ? { kind: 'message', role: envelope.role, blocks } : null +} + +export function claudeHasReplayContent(envelope: ClaudeMessageEnvelope): boolean { + return envelope.content.some((value) => { + const part = claudeRecord(value) + return part !== null && part.type !== 'tool_result' + }) +} + +export function claudeToolUses(envelope: ClaudeMessageEnvelope): ClaudeToolUse[] { + return envelope.content.flatMap((value) => { + const part = claudeRecord(value) + const id = claudeText(part?.id) + const name = claudeText(part?.name) + return part?.type === 'tool_use' && id && name ? [{ id, name, input: part.input ?? null }] : [] + }) +} + +function resultText(value: unknown): string { + if (typeof value === 'string') { + return value + } + if (!Array.isArray(value)) { + return value === undefined ? '' : JSON.stringify(value) + } + return value + .flatMap((entry) => { + if (typeof entry === 'string') { + return [entry] + } + const part = claudeRecord(entry) + return part?.type === 'text' && typeof part.text === 'string' ? [part.text] : [] + }) + .join('\n') +} + +export function claudeToolResults(envelope: ClaudeMessageEnvelope): ClaudeToolResult[] { + return envelope.content.flatMap((value) => { + const part = claudeRecord(value) + const toolUseId = claudeText(part?.tool_use_id) + return part?.type === 'tool_result' && toolUseId + ? [ + { + toolUseId, + output: resultText(part.content), + failed: part.is_error === true + } + ] + : [] + }) +} + +export function claudeThinkingText(envelope: ClaudeMessageEnvelope): string | null { + const parts = envelope.content.flatMap((value) => { + const part = claudeRecord(value) + const thinking = claudeText(part?.thinking) + return part?.type === 'thinking' && thinking ? [thinking] : [] + }) + return parts.length > 0 ? parts.join('\n') : null +} + +export function claudeToolBody(input: { + tool: ClaudeToolUse + result?: ClaudeToolResult +}): AgentJournalItemBody { + return { + kind: 'tool-call', + name: input.tool.name, + input: input.tool.input, + state: input.result ? (input.result.failed ? 'failed' : 'completed') : 'running', + ...(input.result + ? { output: boundInlineText(input.result.output, DEFAULT_JOURNAL_PAYLOAD_LIMITS).bounded } + : {}) + } +} + +export function claudeStreamingMessageBody(text: string): AgentJournalMessageItem { + return { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text }] } +} + +export function claudeToolIdentity(sessionId: string, toolUseId: string): AgentJournalItemIdentity { + return { provider: 'orca', clientMessageId: `claude-tool:${sessionId}:${toolUseId}` } +} + +export function claudeThinkingIdentity(sessionId: string, uuid: string): AgentJournalItemIdentity { + return { provider: 'orca', clientMessageId: `claude-thinking:${sessionId}:${uuid}` } +} diff --git a/src/main/claude/claude-structured-journal-translation.test.ts b/src/main/claude/claude-structured-journal-translation.test.ts new file mode 100644 index 00000000000..f403313dae8 --- /dev/null +++ b/src/main/claude/claude-structured-journal-translation.test.ts @@ -0,0 +1,811 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + AgentJournalItemBody, + AgentJournalItemIdentity, + AgentJournalRenderItem, + AgentSessionJournalIdentity +} from '../../shared/agent-session-journal-types' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { activeStructuredAgentSessionTurnId } from '../../shared/structured-agent-session-projection' +import { openAgentSessionJournal } from '../native-chat/agent-session-journal/journal-store-factory' +import { + createDeferredStructuredAgentSessionEventSink, + type StructuredAgentSessionEventSink +} from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { + boundInlineText, + DEFAULT_JOURNAL_PAYLOAD_LIMITS +} from '../native-chat/agent-session-journal/journal-payload-bounds' +import type { ClaudePendingPrompt } from './claude-structured-prompt-replies' +import { createClaudeJournalTranslator } from './claude-structured-journal-translation' + +function sinkState() { + const items: { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }[] = [] + const tombstones: AgentJournalItemIdentity[] = [] + const sink: StructuredAgentSessionEventSink = { + appendItem: (identity, body) => items.push({ identity, body }), + appendTombstone: (identity) => tombstones.push(identity), + publish: vi.fn() + } + return { sink, items, tombstones } +} + +function message( + type: 'assistant' | 'user', + uuid: string, + content: unknown[], + parentToolUseId: string | null = null +) { + return { + type: 'message' as const, + sessionId: 'orca-session', + ...(type === 'user' && parentToolUseId === null ? { startsTurn: true as const } : {}), + message: { + type, + uuid, + session_id: 'claude-session', + parent_tool_use_id: parentToolUseId, + message: { role: type, content } + } + } +} + +// Frames below follow the Claude Code 2.1.258 / SDK 0.3.251 partial-message +// cadence captured from the real CLI: every stream_event carries its own uuid, +// the final assistant frame for a block carries yet another, and only +// message.id ties them together. +function streamEvent(uuid: string, event: Record) { + return { + type: 'message' as const, + sessionId: 'orca-session', + message: { + type: 'stream_event', + uuid, + session_id: 'claude-session', + parent_tool_use_id: null, + event + } + } +} + +function resultFrame(subtype: string, fields: Record) { + return { + type: 'message' as const, + sessionId: 'orca-session', + message: { + type: 'result', + subtype, + duration_ms: 1200, + duration_api_ms: 1100, + num_turns: 1, + session_id: 'claude-session', + uuid: `result-${subtype}`, + ...fields + } + } +} + +/** One streamed text turn in wire order: message_start, the block's start frame, + * one delta per chunk, the block's final assistant frame, the stop frames and + * the success result. */ +function streamedTextTurn(input: { + messageId: string + startUuid: string + finalUuid: string + chunks: string[] +}) { + const text = input.chunks.join('') + return { + start: [ + streamEvent(`${input.messageId}-message-start`, { + type: 'message_start', + message: { id: input.messageId, role: 'assistant', content: [] } + }), + streamEvent(input.startUuid, { + type: 'content_block_start', + index: 0, + content_block: { type: 'text', text: '' } + }) + ], + deltas: input.chunks.map((chunk, index) => + streamEvent(`${input.messageId}-delta-${index}`, { + type: 'content_block_delta', + index: 0, + delta: { type: 'text_delta', text: chunk } + }) + ), + final: { + type: 'message' as const, + sessionId: 'orca-session', + message: { + type: 'assistant', + uuid: input.finalUuid, + session_id: 'claude-session', + parent_tool_use_id: null, + message: { + id: input.messageId, + role: 'assistant', + content: [{ type: 'text', text }], + stop_reason: null + } + } + }, + stop: [ + streamEvent(`${input.messageId}-block-stop`, { type: 'content_block_stop', index: 0 }), + streamEvent(`${input.messageId}-message-delta`, { + type: 'message_delta', + delta: { stop_reason: 'end_turn' } + }), + streamEvent(`${input.messageId}-message-stop`, { type: 'message_stop' }), + resultFrame('success', { + is_error: false, + result: text, + stop_reason: 'end_turn', + terminal_reason: 'completed' + }) + ], + text + } +} + +function assistantMessages(items: T[]): T[] { + return items.filter((item) => item.body.kind === 'message' && item.body.role === 'assistant') +} + +function providerFrameKinds(items: { body: AgentJournalItemBody }[]): string[] { + return items.flatMap((item) => + item.body.kind === 'status' && item.body.providerFrame ? [item.body.providerFrame.kind] : [] + ) +} + +const JOURNAL_IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-1', + workspaceId: 'workspace-1', + hostId: 'host-1', + agent: 'claude', + providerHandle: { kind: 'claude', sessionId: 'claude-session', leafUuid: 'leaf-1' } +} + +let journalRoot = '' + +beforeEach(async () => { + journalRoot = await mkdtemp(join(tmpdir(), 'orca-claude-journal-translation-')) +}) + +afterEach(async () => { + await rm(journalRoot, { recursive: true, force: true }) +}) + +describe('Claude structured journal translation', () => { + it('coalesces partial deltas onto the block identity and reconciles the final frame onto it', () => { + const state = sinkState() + let scheduled: (() => void) | null = null + const translator = createClaudeJournalTranslator({ + sink: state.sink, + schedule: (run, delay) => { + expect(delay).toBe(60) + scheduled = run + return () => { + scheduled = null + } + } + }) + const turn = streamedTextTurn({ + messageId: 'msg_01', + startUuid: 'block-start-1', + finalUuid: 'assistant-final-1', + chunks: ['ST', 'REAMOK_ELEC_64E632'] + }) + const streamedIdentity = { + provider: 'claude', + sessionId: 'claude-session', + uuid: 'block-start-1' + } + + for (const event of turn.start) { + translator.handle(event) + } + for (const delta of turn.deltas) { + translator.handle(delta) + } + expect(state.items).toEqual([]) + + const run = scheduled as (() => void) | null + run?.() + expect(state.items.at(-1)).toEqual({ + identity: streamedIdentity, + body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: turn.text }] } + }) + + translator.handle(turn.final) + for (const event of turn.stop) { + translator.handle(event) + } + const assistant = assistantMessages(state.items) + expect(assistant.at(-1)).toEqual({ + identity: streamedIdentity, + body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: turn.text }] } + }) + expect(new Set(assistant.map((item) => agentJournalItemKey(item.identity))).size).toBe(1) + expect(providerFrameKinds(state.items)).toEqual([]) + }) + + it('journals a count-to-200 stream as one assistant item carrying the complete reply', async () => { + const journal = await openAgentSessionJournal({ + identity: JOURNAL_IDENTITY, + journalDir: journalRoot, + now: () => 1_700_000_000_000, + mintEpoch: () => 'epoch-1' + }) + const deferred = createDeferredStructuredAgentSessionEventSink() + deferred.bind({ journal, fence: 1, publish: vi.fn() }) + let scheduled: (() => void) | null = null + const translator = createClaudeJournalTranslator({ + sink: deferred.sink, + schedule: (run) => { + scheduled = run + return () => { + scheduled = null + } + } + }) + const numbers = Array.from({ length: 200 }, (_, index) => String(index + 1)) + // The chunk boundaries the real CLI produced for this prompt. + const boundaries = [0, 1, 45, 93, 141, 189, 200] + const chunks = boundaries.slice(1).map((end, index) => { + const slice = numbers.slice(boundaries[index], end).join('\n') + return index === 0 ? slice : `\n${slice}` + }) + const turn = streamedTextTurn({ + messageId: 'msg_count', + startUuid: 'count-start', + finalUuid: 'count-final', + chunks + }) + + for (const event of turn.start) { + translator.handle(event) + } + for (const delta of turn.deltas) { + translator.handle(delta) + // Each chunk lands in its own coalescing window, as it did on the wire. + const run = scheduled as (() => void) | null + run?.() + } + translator.handle(turn.final) + for (const event of turn.stop) { + translator.handle(event) + } + await deferred.drained() + + const items: AgentJournalRenderItem[] = journal.snapshot().items + const assistant = assistantMessages(items) + expect(assistant.map((item) => item.itemId)).toEqual(['claude:claude-session:count-start']) + expect(assistant[0]?.body).toEqual({ + kind: 'message', + role: 'assistant', + blocks: [{ type: 'text', text: numbers.join('\n') }] + }) + expect(providerFrameKinds(items)).toEqual([]) + }) + + it('settles result frames, empty thinking and string user replays without painting a row', () => { + const state = sinkState() + const translator = createClaudeJournalTranslator({ sink: state.sink }) + + translator.handle({ + type: 'message', + sessionId: 'orca-session', + startsTurn: true, + message: { + type: 'user', + uuid: 'user-replay-1', + session_id: 'claude-session', + parent_tool_use_id: null, + isReplay: true, + timestamp: '2026-09-01T00:00:00.000Z', + message: { role: 'user', content: 'Reply with exactly PROBE_OK_1 and nothing else.' } + } + }) + translator.handle( + message('assistant', 'assistant-thinking-empty', [ + { type: 'thinking', thinking: '', signature: 'CAQS6QcKEAgRGAI4AUIIdGhpbmtpbmc' } + ]) + ) + translator.handle( + resultFrame('success', { + is_error: false, + result: 'PROBE_OK_1', + stop_reason: 'end_turn', + terminal_reason: 'completed' + }) + ) + translator.handle( + message('user', 'user-interrupt', [{ type: 'text', text: '[Request interrupted by user]' }]) + ) + translator.handle( + resultFrame('error_during_execution', { + is_error: true, + errors: ['[ede_diagnostic] result_type=user last_content_type=n/a stop_reason=null'], + stop_reason: null, + terminal_reason: 'aborted_streaming', + permission_denials: [] + }) + ) + translator.handle(message('user', 'control-only', [])) + + expect(providerFrameKinds(state.items)).toEqual([]) + expect( + state.items.flatMap((item) => + item.body.kind === 'message' && item.body.role === 'user' ? [item.body.blocks] : [] + ) + ).toEqual([ + [{ type: 'text', text: 'Reply with exactly PROBE_OK_1 and nothing else.' }], + [{ type: 'text', text: '[Request interrupted by user]' }] + ]) + expect( + state.items.some((item) => item.body.kind === 'status' && !item.body.turnLifecycle) + ).toBe(false) + expect( + state.tombstones.flatMap((identity) => + identity.provider === 'legacy' ? [identity.recordId] : [] + ) + ).toEqual(['turn-lifecycle:user-replay-1', 'turn-lifecycle:user-interrupt']) + }) + + it('does not reopen a completed turn when the SDK replays its user row after restart', () => { + const live = sinkState() + const liveTranslator = createClaudeJournalTranslator({ sink: live.sink }) + const replay = { + type: 'message' as const, + sessionId: 'orca-session', + message: { + type: 'user', + uuid: 'picker-command-1', + session_id: 'claude-session', + parent_tool_use_id: null, + isReplay: true, + message: { role: 'user', content: '/model' } + } + } + + liveTranslator.handle({ ...replay, startsTurn: true }) + liveTranslator.handle(resultFrame('success', { is_error: false, result: '' })) + expect(live.tombstones).toContainEqual({ + provider: 'legacy', + agent: 'claude', + sessionId: 'claude-session', + recordId: 'turn-lifecycle:picker-command-1' + }) + liveTranslator.dispose() + + const restarted = sinkState() + const restartedTranslator = createClaudeJournalTranslator({ sink: restarted.sink }) + restartedTranslator.handle(replay) + + expect( + activeStructuredAgentSessionTurnId( + restarted.items.map((item, sequence) => ({ + itemId: agentJournalItemKey(item.identity), + revision: 1, + body: item.body, + sequence, + observedAt: sequence + })) + ) + ).toBeNull() + }) + + it('surfaces an API error carried by a success-subtype result with no assistant frame', () => { + const state = sinkState() + const translator = createClaudeJournalTranslator({ sink: state.sink }) + + translator.handle(message('user', 'user-1', [{ type: 'text', text: 'summarize this' }])) + // The SDK models this as a SUCCESS-subtype result whose `result` string is the + // user-facing API error. Suppressing it as ordinary turn bookkeeping ends the + // turn with nothing shown at all. + translator.handle( + resultFrame('success', { + is_error: true, + result: 'API Error: 529 upstream overloaded', + stop_reason: null, + terminal_reason: 'api_error' + }) + ) + + expect(providerFrameKinds(state.items)).toEqual(['message:result:success']) + expect(state.items.at(-1)?.body).toMatchObject({ + kind: 'status', + text: 'API Error: 529 upstream overloaded' + }) + // The turn still settles: the error is an extra row, not a stuck lifecycle. + expect( + state.tombstones.flatMap((identity) => + identity.provider === 'legacy' ? [identity.recordId] : [] + ) + ).toEqual(['turn-lifecycle:user-1']) + }) + + it('drops the stream state of turns that ended without their final frame', () => { + const state = sinkState() + let scheduled: (() => void) | null = null + const translator = createClaudeJournalTranslator({ + sink: state.sink, + schedule: (run) => { + scheduled = run + return () => { + scheduled = null + } + } + }) + for (let turn = 0; turn < 3; turn += 1) { + const aborted = streamedTextTurn({ + messageId: `msg_abort_${turn}`, + startUuid: `abort-start-${turn}`, + finalUuid: `abort-final-${turn}`, + chunks: ['x'.repeat(4_000)] + }) + for (const event of [...aborted.start, ...aborted.deltas]) { + translator.handle(event) + } + const run = scheduled as (() => void) | null + run?.() + // The user interrupts: the result arrives with no final assistant frame, + // so nothing ever reconciles these blocks. + translator.handle( + resultFrame('error_during_execution', { + is_error: true, + terminal_reason: 'aborted_streaming' + }) + ) + // The partial text is already journaled; only the live state is dropped. + expect(translator.pendingStreamedBlocks).toBe(0) + } + + expect(assistantMessages(state.items)).toHaveLength(3) + }) + + it('keeps an ordinary successful result off the timeline', () => { + const state = sinkState() + const translator = createClaudeJournalTranslator({ sink: state.sink }) + + translator.handle(resultFrame('success', { is_error: false, result: 'done', errors: [] })) + + expect(providerFrameKinds(state.items)).toEqual([]) + }) + + it('surfaces the reason an error-subtype result stopped the turn', () => { + const state = sinkState() + const translator = createClaudeJournalTranslator({ sink: state.sink }) + + translator.handle( + resultFrame('error_max_turns', { is_error: true, errors: ['turn limit reached'] }) + ) + + expect(providerFrameKinds(state.items)).toEqual(['message:result:error_max_turns']) + }) + + it('keeps an unmodeled result subtype on the bounded provider fallback', () => { + const state = sinkState() + const translator = createClaudeJournalTranslator({ sink: state.sink }) + + translator.handle( + resultFrame('error_from_the_future', { is_error: true, errors: ['budget exhausted'] }) + ) + + expect(providerFrameKinds(state.items)).toEqual(['message:result:error_from_the_future']) + }) + + it('journals turn lifecycle and updates one tool row through its result', () => { + const state = sinkState() + const translator = createClaudeJournalTranslator({ sink: state.sink }) + + translator.handle(message('user', 'user-1', [{ type: 'text', text: 'List files' }])) + translator.handle( + message('assistant', 'assistant-tool', [ + { type: 'tool_use', id: 'tool-1', name: 'Bash', input: { command: 'ls' } } + ]) + ) + translator.handle( + message( + 'user', + 'tool-result-1', + [{ type: 'tool_result', tool_use_id: 'tool-1', content: 'a.ts\nb.ts' }], + 'tool-1' + ) + ) + + const keyed = new Map( + state.items.map((item) => [agentJournalItemKey(item.identity), item.body]) + ) + expect(keyed.get('claude:claude-session:user-1')).toMatchObject({ + kind: 'message', + role: 'user' + }) + expect(keyed.get('orca:claude-tool%3Aclaude-session%3Atool-1')).toMatchObject({ + kind: 'tool-call', + name: 'Bash', + state: 'completed', + output: { head: 'a.ts\nb.ts', truncated: false } + }) + expect( + state.items.some( + (item) => item.body.kind === 'status' && item.body.turnLifecycle?.turnId === 'user-1' + ) + ).toBe(true) + + translator.handle( + message( + 'user', + 'tool-result-2', + [{ type: 'tool_result', tool_use_id: 'tool-1', content: 'done again' }], + 'tool-1' + ) + ) + expect(state.items.at(-1)?.body).toMatchObject({ + kind: 'tool-call', + name: 'tool', + input: null, + output: { head: 'done again' } + }) + + translator.handle({ + type: 'message', + sessionId: 'orca-session', + message: { type: 'result', session_id: 'claude-session', uuid: 'result-1' } + }) + expect(state.tombstones.at(-1)).toMatchObject({ + provider: 'legacy', + agent: 'claude', + recordId: 'turn-lifecycle:user-1' + }) + }) + + it('bounds persisted thinking text to the shared journal payload limit', () => { + const state = sinkState() + const translator = createClaudeJournalTranslator({ sink: state.sink }) + const thinking = 'considering '.repeat(20_000) + + translator.handle(message('assistant', 'assistant-thinking', [{ type: 'thinking', thinking }])) + + expect(state.items.at(-1)?.body).toEqual({ + kind: 'status', + text: boundInlineText(thinking, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text + }) + }) + + it('starts a cancellable lifecycle for image-only root user replays', () => { + const state = sinkState() + const translator = createClaudeJournalTranslator({ sink: state.sink }) + + translator.handle( + message('user', 'user-image', [ + { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'AA==' } } + ]) + ) + + expect(state.items.at(-1)?.body).toEqual({ + kind: 'status', + text: 'Claude is working…', + turnLifecycle: { turnId: 'user-image', state: 'running' } + }) + }) + + it('does not start a lifecycle for a top-level user tool result', () => { + const state = sinkState() + const translator = createClaudeJournalTranslator({ sink: state.sink }) + + translator.handle( + message('user', 'tool-result-only', [ + { type: 'tool_result', tool_use_id: 'tool-1', content: 'done' } + ]) + ) + + expect(state.items.map((item) => agentJournalItemKey(item.identity))).toEqual([ + 'orca:claude-tool%3Aclaude-session%3Atool-1' + ]) + expect(state.items[0]?.body).toMatchObject({ + kind: 'tool-call', + state: 'completed', + output: { head: 'done' } + }) + expect( + state.items.some( + (item) => item.body.kind === 'status' && item.body.turnLifecycle !== undefined + ) + ).toBe(false) + }) + + it('paints nothing for a user frame that carries no content', () => { + const state = sinkState() + const translator = createClaudeJournalTranslator({ sink: state.sink }) + + translator.handle(message('user', 'control-only', [])) + + expect(state.items).toEqual([]) + expect(state.tombstones).toEqual([]) + }) + + it('renders unmodeled substantive Claude frames as bounded provider rows', () => { + const state = sinkState() + const translator = createClaudeJournalTranslator({ sink: state.sink }) + + translator.handle({ + type: 'message', + sessionId: 'orca-session', + message: { type: 'system', subtype: 'local_command_output', summary: 'x'.repeat(100_000) } + }) + translator.handle({ + type: 'message', + sessionId: 'orca-session', + message: { type: 'system', subtype: 'hook_response', hook_name: 'PostToolUse' } + }) + translator.handle({ + type: 'message', + sessionId: 'orca-session', + message: { type: 'system', subtype: 'command_started', command: '/compact' } + }) + translator.handle({ + type: 'message', + sessionId: 'orca-session', + message: { type: 'result', usage: { input_tokens: 12 }, total_cost_usd: 0.01 } + }) + translator.handle({ + type: 'message', + sessionId: 'orca-session', + message: { type: 'tool_progress', tool_use_id: 'tool-1', elapsed_time_seconds: 2 } + }) + translator.handle({ + type: 'message', + sessionId: 'orca-session', + message: { type: 'prompt_suggestion', suggestion: '/compact' } + }) + translator.handle( + message('user', 'attachment-1', [ + { type: 'document', source: { type: 'base64', media_type: 'application/pdf' } } + ]) + ) + translator.handle({ + type: 'provider-frame', + sessionId: 'orca-session', + kind: 'control_request:future_control', + payload: { subtype: 'future_control' } + }) + + const frames = state.items.flatMap((item) => + item.body.kind === 'status' && item.body.providerFrame ? [item.body.providerFrame] : [] + ) + expect(frames.map((frame) => frame.kind)).toEqual( + expect.arrayContaining([ + 'message:system:local_command_output', + 'message:system:command_started', + 'message:result', + 'message:user:content:document', + 'control_request:future_control' + ]) + ) + expect(frames.map((frame) => frame.kind)).not.toEqual( + expect.arrayContaining([ + 'message:system:hook_response', + 'message:tool_progress', + 'message:prompt_suggestion' + ]) + ) + expect( + frames.find((frame) => frame.kind === 'message:system:local_command_output')?.payload + ).toEqual(expect.objectContaining({ truncated: true, byteLength: expect.any(Number) })) + }) + + it('preserves a question group as one addressable prompt and cancels it durably', () => { + const state = sinkState() + const bindings: unknown[][] = [] + const translator = createClaudeJournalTranslator({ + sink: state.sink, + bindPromptItemId: (...args) => bindings.push(args) + }) + const approval = prompt({ + requestId: 'permission-1', + promptKey: 'permission-1', + toolUseId: 'tool-1', + toolName: 'Bash', + kind: 'approval', + input: { command: 'git status' }, + questionIds: [] + }) + translator.handle({ type: 'prompt', sessionId: 'orca-session', prompt: approval }) + expect(state.items.at(-1)?.body).toMatchObject({ + kind: 'approval', + title: 'Allow Bash?', + options: expect.arrayContaining([{ id: 'allow', label: 'Allow' }]) + }) + expect(bindings[0]).toEqual([ + 'orca:claude-prompt%3Aorca-session%3Apermission-1', + 'permission-1' + ]) + + const questions = prompt({ + requestId: 'questions-1', + promptKey: 'questions-1', + toolUseId: 'tool-q', + toolName: 'AskUserQuestion', + kind: 'question', + input: { + questions: [ + { question: 'Library?', options: [{ label: 'Luxon' }] }, + { question: 'Ship?', options: [{ label: 'Yes' }] } + ] + }, + questionIds: ['Library?', 'Ship?'] + }) + translator.handle({ type: 'prompt', sessionId: 'orca-session', prompt: questions }) + expect(state.items.filter((item) => item.body.kind === 'question')).toHaveLength(1) + expect(state.items.at(-1)?.body).toMatchObject({ + kind: 'question', + questions: [ + { id: 'q1', question: 'Library?', multiSelect: false }, + { id: 'q2', question: 'Ship?', multiSelect: false } + ] + }) + expect(bindings.at(-1)).toEqual([ + 'orca:claude-prompt%3Aorca-session%3Aquestions-1', + 'questions-1' + ]) + + const multiSelect = prompt({ + requestId: 'questions-multi', + promptKey: 'questions-multi', + toolUseId: 'tool-multi', + toolName: 'AskUserQuestion', + kind: 'question', + input: { + questions: [ + { + question: 'Libraries?', + multiSelect: true, + options: [{ label: 'Luxon' }, { label: 'Temporal' }] + } + ] + }, + questionIds: ['Libraries?'] + }) + translator.handle({ type: 'prompt', sessionId: 'orca-session', prompt: multiSelect }) + expect(state.items.at(-1)?.body).toMatchObject({ + kind: 'question', + question: '1 grouped question from Claude', + options: [], + questions: [ + { + id: 'q1', + question: 'Libraries?', + multiSelect: true, + options: [{ label: 'Luxon' }, { label: 'Temporal' }], + freeTextQuestionId: 'q1' + } + ] + }) + + translator.handle({ + type: 'prompt-cancelled', + sessionId: 'orca-session', + promptKey: 'questions-1' + }) + expect(state.tombstones).toHaveLength(1) + }) +}) + +function prompt( + input: Pick< + ClaudePendingPrompt, + 'requestId' | 'promptKey' | 'toolUseId' | 'toolName' | 'kind' | 'input' | 'questionIds' + > +): ClaudePendingPrompt { + return { + ...input, + suggestions: [], + answers: new Map(), + settle: () => {} + } +} diff --git a/src/main/claude/claude-structured-journal-translation.ts b/src/main/claude/claude-structured-journal-translation.ts new file mode 100644 index 00000000000..ffaad4da570 --- /dev/null +++ b/src/main/claude/claude-structured-journal-translation.ts @@ -0,0 +1,287 @@ +import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import type { AgentSessionDeltaCoalescerDeps } from '../native-chat/agent-session-wire/agent-session-delta-coalescer' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { + boundInlineText, + DEFAULT_JOURNAL_PAYLOAD_LIMITS +} from '../native-chat/agent-session-journal/journal-payload-bounds' +import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state' +import { + claudeMessageBody, + claudeMessageIdentity, + claudeHasReplayContent, + claudeRecord, + claudeStreamingMessageBody, + claudeText, + claudeThinkingIdentity, + claudeThinkingText, + claudeToolBody, + claudeToolIdentity, + claudeToolResults, + claudeToolUses, + readClaudeMessageEnvelope, + type ClaudeToolUse +} from './claude-structured-item-translation' +import { + claudeApprovalItem, + claudePromptIdentity, + claudeQuestionItems +} from './claude-structured-prompt-items' +import type { ClaudePromptRegistry } from './claude-structured-prompt-replies' +import { readableProviderFrameText } from '../native-chat/agent-session-wire/unhandled-provider-frame' +import { + CLAUDE_UNRENDERABLE_CONTENT_TEXT, + claudeProviderFrameKind, + claudeResultFailure, + createClaudeProviderFrameFallback, + isModeledClaudeContent, + isSettledClaudeResultKind +} from './claude-structured-provider-fallback' +import { createClaudeStreamedBlockRegistry } from './claude-streamed-block-identity' +import { createClaudeStreamedTextCheckpoints } from './claude-streamed-text-checkpoints' + +export type ClaudeJournalTranslatorDeps = { + sink: StructuredAgentSessionEventSink + bindPromptItemId?: (journalItemId: string, promptKey: string, questionId?: string) => void + coalesceMs?: number + schedule?: AgentSessionDeltaCoalescerDeps['schedule'] + fallbackIdPrefix?: string +} + +export type ClaudeJournalTranslator = { + handle: (event: ClaudeStructuredSessionEvent) => void + flush: () => void + /** Streamed blocks still awaiting a final frame. A settled turn leaves none. */ + readonly pendingStreamedBlocks: number + dispose: () => void +} + +export function createClaudeSessionJournalTranslator( + sink: StructuredAgentSessionEventSink | undefined, + prompts: ClaudePromptRegistry, + fallbackIdPrefix: string +): ClaudeJournalTranslator | null { + return sink + ? createClaudeJournalTranslator({ + sink, + fallbackIdPrefix, + bindPromptItemId: (itemId, promptKey, questionId) => + prompts.bindJournalItemId(itemId, promptKey, questionId) + }) + : null +} + +function lifecycleIdentity(sessionId: string, turnId: string): AgentJournalItemIdentity { + return { + provider: 'legacy', + agent: 'claude', + sessionId, + recordId: `turn-lifecycle:${turnId}` + } +} + +export function createClaudeJournalTranslator( + deps: ClaudeJournalTranslatorDeps +): ClaudeJournalTranslator { + const tools = new Map() + const promptItems = new Map() + const streamedBlocks = createClaudeStreamedBlockRegistry() + let currentTurn: { sessionId: string; turnId: string } | null = null + const providerFallback = createClaudeProviderFrameFallback( + deps.sink, + deps.fallbackIdPrefix ?? 'acquisition' + ) + const streamedText = createClaudeStreamedTextCheckpoints({ + ...(deps.coalesceMs === undefined ? {} : { coalesceMs: deps.coalesceMs }), + ...(deps.schedule ? { schedule: deps.schedule } : {}), + persist: (identity, text) => { + deps.sink.appendItem(identity, claudeStreamingMessageBody(text)) + deps.sink.publish() + } + }) + + const publishLifecycle = (sessionId: string, turnId: string, running: boolean): void => { + const identity = lifecycleIdentity(sessionId, turnId) + if (running) { + deps.sink.appendItem(identity, { + kind: 'status', + text: 'Claude is working…', + turnLifecycle: { turnId, state: 'running' } + }) + } else { + deps.sink.appendTombstone(identity) + } + deps.sink.publish() + } + + const handleStream = (message: Record): boolean => { + const delta = streamedBlocks.observe(message) + if (!delta) { + return false + } + streamedText.append(delta.identity, delta.text) + return true + } + + const handleMessage = (message: Record, startsTurn: boolean): boolean => { + const envelope = readClaudeMessageEnvelope(message) + if (!envelope) { + return false + } + let changed = false + const body = claudeMessageBody(envelope) + // The final frame of a streamed block lands on the block's identity, not its own uuid. + const identity = + (body && envelope.role === 'assistant' ? streamedBlocks.reconcile(envelope) : null) ?? + claudeMessageIdentity(envelope) + streamedText.forget(agentJournalItemKey(identity)) + if (body) { + deps.sink.appendItem(identity, body) + changed = true + } + for (const tool of claudeToolUses(envelope)) { + tools.set(tool.id, tool) + deps.sink.appendItem( + claudeToolIdentity(envelope.sessionId, tool.id), + claudeToolBody({ tool }) + ) + changed = true + } + for (const result of claudeToolResults(envelope)) { + const tool = tools.get(result.toolUseId) ?? { + id: result.toolUseId, + name: 'tool', + input: null + } + deps.sink.appendItem( + claudeToolIdentity(envelope.sessionId, result.toolUseId), + claudeToolBody({ tool, result }) + ) + // Tool inputs are only needed until their matching result arrives. + tools.delete(result.toolUseId) + changed = true + } + const thinking = claudeThinkingText(envelope) + if (thinking) { + deps.sink.appendItem(claudeThinkingIdentity(envelope.sessionId, envelope.uuid), { + kind: 'status', + text: boundInlineText(thinking, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text + }) + changed = true + } + const unhandledContent = envelope.content.filter((part) => !isModeledClaudeContent(part)) + for (const part of unhandledContent) { + const partType = claudeText(claudeRecord(part)?.type) ?? 'unknown' + providerFallback.append( + `message:${envelope.role}:content:${partType}`, + part, + readableProviderFrameText(part) ?? CLAUDE_UNRENDERABLE_CONTENT_TEXT + ) + changed = true + } + // An empty user frame is a replay with nothing to show, not an unknown kind. + if (envelope.content.length === 0 && envelope.role === 'assistant') { + providerFallback.append(`message:${envelope.role}:empty`, message) + changed = true + } + if ( + envelope.role === 'user' && + startsTurn && + claudeHasReplayContent(envelope) && + message.parent_tool_use_id === null + ) { + if (currentTurn) { + publishLifecycle(currentTurn.sessionId, currentTurn.turnId, false) + } + currentTurn = { sessionId: envelope.sessionId, turnId: envelope.uuid } + publishLifecycle(envelope.sessionId, envelope.uuid, true) + } + if (changed) { + deps.sink.publish() + } + return true + } + + const handlePrompt = (event: Extract): void => { + const identities: AgentJournalItemIdentity[] = [] + if (event.prompt.kind === 'question') { + for (const question of claudeQuestionItems({ + sessionId: event.sessionId, + prompt: event.prompt + })) { + identities.push(question.identity) + deps.sink.appendItem(question.identity, question.body) + deps.bindPromptItemId?.(agentJournalItemKey(question.identity), event.prompt.promptKey) + } + } else { + const identity = claudePromptIdentity({ + sessionId: event.sessionId, + promptKey: event.prompt.promptKey + }) + identities.push(identity) + deps.sink.appendItem(identity, claudeApprovalItem(event.prompt)) + deps.bindPromptItemId?.(agentJournalItemKey(identity), event.prompt.promptKey) + } + promptItems.set(event.prompt.promptKey, identities) + deps.sink.publish() + } + + return { + handle: (event) => { + if (event.type === 'ended') { + streamedText.flush() + if (currentTurn) { + publishLifecycle(currentTurn.sessionId, currentTurn.turnId, false) + currentTurn = null + } + return + } + if (event.type === 'message' && handleStream(event.message)) { + return + } + streamedText.flush() + if (event.type === 'prompt') { + handlePrompt(event) + } else if (event.type === 'prompt-cancelled') { + for (const identity of promptItems.get(event.promptKey) ?? []) { + deps.sink.appendTombstone(identity) + } + promptItems.delete(event.promptKey) + deps.sink.publish() + } else if (event.type === 'message' && event.message.type === 'result') { + if (currentTurn) { + publishLifecycle(currentTurn.sessionId, currentTurn.turnId, false) + currentTurn = null + } + // The turn is over. A block still awaiting its final keeps the text the + // flush above journaled, but its live state goes: an interrupted turn + // would otherwise retain that text for the life of the session. + streamedBlocks.clear() + streamedText.settle() + const kind = claudeProviderFrameKind(event.message) + // Ordinary turn bookkeeping stays suppressed; a reported failure never does. + const failure = claudeResultFailure(event.message) + if (failure || !isSettledClaudeResultKind(kind)) { + providerFallback.append(kind, event.message, failure?.text) + } + } else if (event.type === 'message') { + if (!handleMessage(event.message, event.startsTurn === true)) { + providerFallback.append(claudeProviderFrameKind(event.message), event.message) + } + } else if (event.type === 'provider-frame') { + providerFallback.append(event.kind, event.payload) + } + }, + flush: streamedText.flush, + get pendingStreamedBlocks() { + return streamedText.pending + }, + dispose: () => { + streamedText.dispose() + tools.clear() + promptItems.clear() + streamedBlocks.clear() + } + } +} diff --git a/src/main/claude/claude-structured-launch-resolution.test.ts b/src/main/claude/claude-structured-launch-resolution.test.ts new file mode 100644 index 00000000000..650947cffa1 --- /dev/null +++ b/src/main/claude/claude-structured-launch-resolution.test.ts @@ -0,0 +1,392 @@ +import { chmodSync, mkdtempSync, mkdirSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { delimiter, join } from 'node:path' +import { describe, expect, it } from 'vitest' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store' +import { AgentSessionPreSpawnError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { claudeStructuredAuthPolicyForSettings } from '../claude-accounts/claude-structured-auth-policy' +import type { ClaudeManagedAccountGateSettings } from '../native-chat/claude-structured-managed-account-support' +import { + CLAUDE_DEFAULT_SETTING_SOURCES, + CLAUDE_STRUCTURED_BASE_OPTIONS, + claudeSdkOptionsForLaunchArgs, + claudeSessionIdForOrcaSession, + createClaudeStructuredLaunchResolver +} from './claude-structured-launch-resolution' + +const SESSION_ID = 'orca-session-1' +const IDENTITY = { sessionId: SESSION_ID } as Parameters< + ReturnType +>[0]['identity'] + +function record(overrides: Partial = {}): AgentSessionRecord { + return { + sessionId: SESSION_ID, + provider: 'claude', + location: { + executionHostId: LOCAL_EXECUTION_HOST_ID, + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'folder' + }, + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/work/.claude' }, + providerHandleChain: [], + ...overrides + } as AgentSessionRecord +} + +function identityAt(leafUuid: string | null): typeof IDENTITY { + return { + ...IDENTITY, + providerHandle: { kind: 'claude', sessionId: 'provider-current', leafUuid } + } +} + +function makeExecutable(path: string): void { + mkdirSync(join(path, '..'), { recursive: true }) + writeFileSync(path, '') + if (process.platform !== 'win32') { + chmodSync(path, 0o755) + } +} + +function resolverFor( + value: AgentSessionRecord | null, + resolveEnv?: () => Record, + stripAuthEnv = false +) { + return createClaudeStructuredLaunchResolver({ + store: { getRecord: () => value } as unknown as AgentSessionRecordStore, + resolveWorkspacePath: async (id) => `/repos/${id}`, + resolveCommand: () => '/usr/local/bin/claude', + resolveAuthPolicy: () => ({ stripAuthEnv }), + ...(resolveEnv ? { resolveEnv } : {}) + }) +} + +function managedAccount(id: string, managedAuthRuntime: 'host' | 'wsl') { + return { + id, + email: `${id}@example.com`, + managedAuthPath: `/managed/${id}`, + managedAuthRuntime, + authMethod: 'subscription-oauth' as const, + createdAt: 0, + updatedAt: 0, + lastAuthenticatedAt: 0 + } +} + +const HOST_SELECTED: ClaudeManagedAccountGateSettings = { + claudeManagedAccounts: [managedAccount('host-1', 'host')], + activeClaudeManagedAccountId: 'host-1', + activeClaudeManagedAccountIdsByRuntime: { host: 'host-1', wsl: {} } +} + +/** The normalized steady state of a Windows user whose only Claude account is WSL-managed: the + * prune drops the WSL account out of the host slot and persists that. */ +const WSL_ONLY_NORMALIZED: ClaudeManagedAccountGateSettings = { + claudeManagedAccounts: [managedAccount('wsl-1', 'wsl')], + activeClaudeManagedAccountId: null, + activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'wsl-1' } } +} + +const RESUMABLE = record({ + providerHandleChain: [ + { handle: { provider: 'claude', sessionId: 'provider-current', leafUuid: 'leaf-current' } } + ] as AgentSessionRecord['providerHandleChain'] +}) + +describe('claude structured launch resolution', () => { + it('pre-mints a stable provider id and pins interactive setting sources', async () => { + const first = await resolverFor(record())({ identity: IDENTITY }) + const second = await resolverFor(record())({ identity: IDENTITY }) + + expect(first.providerSessionId).toBe(claudeSessionIdForOrcaSession(SESSION_ID)) + expect(second.providerSessionId).toBe(first.providerSessionId) + expect(first).toMatchObject({ + pathToClaudeCodeExecutable: '/usr/local/bin/claude', + cwd: '/repos/workspace-1', + claudeConfigDir: '/home/work/.claude', + resumeLeafUuid: null, + resumed: false + }) + expect(first.options).toEqual({ + includePartialMessages: true, + settingSources: [...CLAUDE_DEFAULT_SETTING_SOURCES], + supportedDialogKinds: [], + extraArgs: { 'replay-user-messages': null }, + systemPrompt: { type: 'preset', preset: 'claude_code' }, + sessionId: first.providerSessionId + }) + expect(first.options.resume).toBeUndefined() + expect(CLAUDE_STRUCTURED_BASE_OPTIONS.includePartialMessages).toBe(true) + }) + + it('resumes the session and leaf at the durable chain head', async () => { + const launch = await resolverFor( + record({ + providerHandleChain: [ + { handle: { provider: 'claude', sessionId: 'provider-old', leafUuid: 'leaf-old' } }, + { + handle: { + provider: 'claude', + sessionId: 'provider-current', + leafUuid: 'leaf-current' + } + } + ] as AgentSessionRecord['providerHandleChain'] + }) + )({ identity: identityAt('leaf-current') }) + + expect(launch).toMatchObject({ + providerSessionId: 'provider-current', + resumeLeafUuid: 'leaf-current', + resumed: true + }) + expect(launch.options.resume).toBe('provider-current') + expect(launch.options.resumeSessionAt).toBe('leaf-current') + expect(launch.options.sessionId).toBeUndefined() + }) + + it('refuses a durable journal leaf that diverged before resume resolution', async () => { + const resolve = resolverFor( + record({ + providerHandleChain: [ + { + handle: { + provider: 'claude', + sessionId: 'provider-current', + leafUuid: 'leaf-current' + } + } + ] as AgentSessionRecord['providerHandleChain'] + }) + ) + + await expect(resolve({ identity: identityAt('leaf-stale') })).rejects.toThrow( + 'durable resume identity changed before spawn' + ) + }) + + it('keeps session-only resume when the durable handle has no leaf', async () => { + const launch = await resolverFor( + record({ + providerHandleChain: [ + { + handle: { + provider: 'claude', + sessionId: 'provider-current', + leafUuid: null + } + } + ] as AgentSessionRecord['providerHandleChain'] + }) + )({ identity: identityAt(null) }) + + expect(launch.options.resume).toBe('provider-current') + expect(launch.options.resumeSessionAt).toBeUndefined() + }) + + it('preserves durable Claude launch arguments as typed options and extraArgs', async () => { + const launch = await resolverFor( + record({ + launchArgs: [ + '--model', + 'claude-sonnet-4-5', + '--effort', + 'high', + '--dangerously-skip-permissions' + ] + }) + )({ identity: IDENTITY }) + + expect(launch.options.model).toBe('claude-sonnet-4-5') + expect(launch.options.effort).toBe('high') + expect(launch.options.extraArgs).toEqual({ + 'dangerously-skip-permissions': null, + 'replay-user-messages': null + }) + }) + + it('routes durable launch arguments to a typed option first and refuses what neither can carry', () => { + // The catalog's own output: each flag lands in exactly one place, so the SDK + // cannot emit it twice with two different values. + expect(claudeSdkOptionsForLaunchArgs(['--model', 'opus', '--effort', 'xhigh'])).toEqual({ + model: 'opus', + effort: 'xhigh' + }) + // An effort the SDK's union does not name still reaches the CLI, unchanged. + expect(claudeSdkOptionsForLaunchArgs(['--effort', 'ultra'])).toEqual({ + extraArgs: { effort: 'ultra' } + }) + expect(claudeSdkOptionsForLaunchArgs(['--settings=/tmp/s.json'])).toEqual({ + extraArgs: { settings: '/tmp/s.json' } + }) + expect(() => claudeSdkOptionsForLaunchArgs(['-m', 'opus'])).toThrow(/no SDK option/) + }) + + it('keeps the session launch environment pinned after account settings change', async () => { + const resolver = resolverFor(record(), () => ({ + ANTHROPIC_AUTH_TOKEN: 'rotated-token', + ANTHROPIC_BASE_URL: 'https://gateway.example.test' + })) + + expect((await resolver({ identity: IDENTITY })).env).toMatchObject({ + ANTHROPIC_AUTH_TOKEN: 'rotated-token', + ANTHROPIC_BASE_URL: 'https://gateway.example.test' + }) + expect((await resolver({ identity: IDENTITY })).env?.ANTHROPIC_AUTH_TOKEN).toBe('rotated-token') + }) + + // Stripping is the managed-account rule the terminal preflight computes at + // runtime-auth-preparation.ts:72; claude-structured-auth-parity.test.ts covers + // the system-auth half, where the user's own key has to survive. + it('strips ambient Anthropic auth under a managed account but keeps the rest of the env', async () => { + const restore = { + ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY, + ANTHROPIC_AUTH_TOKEN: process.env.ANTHROPIC_AUTH_TOKEN, + CLAUDE_CODE_OAUTH_TOKEN: process.env.CLAUDE_CODE_OAUTH_TOKEN, + ORCA_LAUNCH_RESOLUTION_MARKER: process.env.ORCA_LAUNCH_RESOLUTION_MARKER + } + process.env.ANTHROPIC_API_KEY = 'sk-ant-SHELL-LEAK' + process.env.ANTHROPIC_AUTH_TOKEN = 'tok-SHELL-LEAK' + process.env.CLAUDE_CODE_OAUTH_TOKEN = 'oauth-SHELL-LEAK' + process.env.ORCA_LAUNCH_RESOLUTION_MARKER = 'inherited' + try { + const launch = await resolverFor(record(), undefined, true)({ identity: IDENTITY }) + + expect(launch.env?.ANTHROPIC_API_KEY).toBeUndefined() + expect(launch.env?.ANTHROPIC_AUTH_TOKEN).toBeUndefined() + expect(launch.env?.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined() + // The inherited env is still the base — only auth is removed from it. + expect(launch.env?.ORCA_LAUNCH_RESOLUTION_MARKER).toBe('inherited') + expect(launch.env?.PATH ?? launch.env?.Path).toBeTruthy() + } finally { + for (const [key, value] of Object.entries(restore)) { + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } + } + } + }) + + it('lets an explicit Claude env overlay override ambient auth under system auth', async () => { + const restore = process.env.ANTHROPIC_API_KEY + process.env.ANTHROPIC_API_KEY = 'sk-ant-SHELL-LEAK' + try { + const launch = await resolverFor(record(), () => ({ + ANTHROPIC_API_KEY: 'sk-ant-CONFIGURED' + }))({ identity: IDENTITY }) + + expect(launch.env?.ANTHROPIC_API_KEY).toBe('sk-ant-CONFIGURED') + } finally { + if (restore === undefined) { + delete process.env.ANTHROPIC_API_KEY + } else { + process.env.ANTHROPIC_API_KEY = restore + } + } + }) + + it('pairs a resolved Claude CLI with its sibling Node runtime', async () => { + const root = mkdtempSync(join(tmpdir(), 'orca-claude-launch-')) + const binDir = join(root, 'bin') + const claudeCommand = join(binDir, process.platform === 'win32' ? 'claude.cmd' : 'claude') + const nodeCommand = join(binDir, process.platform === 'win32' ? 'node.cmd' : 'node') + makeExecutable(claudeCommand) + makeExecutable(nodeCommand) + + const launch = await createClaudeStructuredLaunchResolver({ + store: { getRecord: () => record() } as unknown as AgentSessionRecordStore, + resolveWorkspacePath: async (id) => `/repos/${id}`, + resolveCommand: () => claudeCommand, + resolveAuthPolicy: () => ({ stripAuthEnv: false }), + resolveEnv: () => ({ + PATH: '/usr/bin', + CLAUDE_CONFIG_DIR: '/accounts/selected/home' + }) + })({ identity: IDENTITY }) + + expect((launch.env?.PATH ?? launch.env?.Path)?.split(delimiter)[0]).toBe(binDir) + }) + + it('refuses other hosts, WSL, providers, and account-home variables', async () => { + await expect( + resolverFor(record({ location: { ...record().location, executionHostId: 'ssh:build' } }))({ + identity: IDENTITY + }) + ).rejects.toThrow(/local host/) + await expect( + resolverFor(record({ location: { ...record().location, wslDistro: 'Ubuntu' } }))({ + identity: IDENTITY + }) + ).rejects.toThrow(/local host/) + await expect( + resolverFor(record({ provider: 'codex' } as Partial))({ + identity: IDENTITY + }) + ).rejects.toThrow(/codex session/) + await expect( + resolverFor(record({ accountHome: { variable: 'CODEX_HOME', path: '/tmp/codex' } }))({ + identity: IDENTITY + }) + ).rejects.toThrow(/CLAUDE_CONFIG_DIR/) + }) + + /** The account state can change while a session lives, and a reacquire after an unexpected child + * exit re-resolves the launch. Without the gate here, that reacquire spawns under whatever the + * account state has become. */ + describe('managed-account gate on every acquisition', () => { + function resolverWithGate(read: () => ClaudeManagedAccountGateSettings | null) { + return createClaudeStructuredLaunchResolver({ + store: { getRecord: () => RESUMABLE } as unknown as AgentSessionRecordStore, + resolveWorkspacePath: async (id) => `/repos/${id}`, + resolveCommand: () => '/usr/local/bin/claude', + // Derived, not a literal: the gate and the policy must read the SAME account state, so a + // hardcoded value could assert a pairing production cannot produce. + resolveAuthPolicy: () => { + const settings = read() + if (!settings) { + throw new Error('the gate refuses before the auth policy is computed') + } + return claudeStructuredAuthPolicyForSettings(settings) + }, + readManagedAccountGate: read + }) + } + + it('refuses a reacquire once the account state becomes the refused shape', async () => { + let gate: ClaudeManagedAccountGateSettings | null = HOST_SELECTED + const resolve = resolverWithGate(() => gate) + + // Created while supported: the launch resolves and would spawn. + await expect(resolve({ identity: identityAt('leaf-current') })).resolves.toMatchObject({ + providerSessionId: 'provider-current' + }) + + gate = WSL_ONLY_NORMALIZED + + // Reacquire after the account state changed: refused before anything spawns. + await expect(resolve({ identity: identityAt('leaf-current') })).rejects.toBeInstanceOf( + AgentSessionPreSpawnError + ) + }) + + it('fails closed when the account state cannot be read', async () => { + await expect( + resolverWithGate(() => null)({ identity: identityAt('leaf-current') }) + ).rejects.toBeInstanceOf(AgentSessionPreSpawnError) + }) + + it('keeps resolving when no gate is wired, so other embedders are unaffected', async () => { + await expect( + resolverFor(RESUMABLE)({ identity: identityAt('leaf-current') }) + ).resolves.toMatchObject({ providerSessionId: 'provider-current' }) + }) + }) +}) diff --git a/src/main/claude/claude-structured-launch-resolution.ts b/src/main/claude/claude-structured-launch-resolution.ts new file mode 100644 index 00000000000..4f28f14ad65 --- /dev/null +++ b/src/main/claude/claude-structured-launch-resolution.ts @@ -0,0 +1,273 @@ +import { createHash } from 'node:crypto' +import type { EffortLevel, Options as ClaudeAgentSdkOptions } from '@anthropic-ai/claude-agent-sdk' +import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' +import { agentSessionProviderHandleChainHead } from '../../shared/agent-session-provider-handle' +import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import { withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution' +import { + CLAUDE_AUTH_ENV_CONFLICT_MESSAGE, + CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE, + applyClaudeEnvPatch, + hasClaudeAuthEnvConflict +} from '../claude-accounts/environment' +import type { ClaudeStructuredAuthPolicy } from '../claude-accounts/claude-structured-auth-policy' +import { + CLAUDE_AUTH_SWITCH_SETTLE_TIMEOUT_MS, + whenClaudeAuthSwitchSettles +} from '../claude-accounts/live-pty-gate' +import { AgentSessionPreSpawnError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { + structuredClaudeMatchesActiveManagedAccount, + type ClaudeManagedAccountGateSettings +} from '../native-chat/claude-structured-managed-account-support' +import { resolveClaudeCommand } from '../codex-cli/command' +import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store' + +export const CLAUDE_DEFAULT_SETTING_SOURCES = ['user', 'project', 'local'] as const + +export type ClaudeStructuredSdkOptions = Pick< + ClaudeAgentSdkOptions, + | 'includePartialMessages' + | 'systemPrompt' + | 'settingSources' + | 'supportedDialogKinds' + | 'extraArgs' + | 'model' + | 'effort' + | 'sessionId' + | 'resume' + | 'resumeSessionAt' +> + +/** + * The options translation of the flags this transport used to build by hand. + * + * `-p`, `--input-format`, `--output-format` and `--verbose` are implied by + * `query()`; `--permission-prompt-tool stdio` is emitted because a `canUseTool` + * callback is supplied. `--replay-user-messages` has no option — the SDK never + * emits it — and Orca's send acknowledgement depends on the replay. + */ +export const CLAUDE_STRUCTURED_BASE_OPTIONS: ClaudeStructuredSdkOptions = { + includePartialMessages: true, + // Keep the SDK on Claude Code's own system-prompt contract. + systemPrompt: { type: 'preset', preset: 'claude_code' }, + settingSources: [...CLAUDE_DEFAULT_SETTING_SOURCES], + supportedDialogKinds: [], + extraArgs: { 'replay-user-messages': null } +} + +const EFFORT_LEVELS: readonly string[] = ['low', 'medium', 'high', 'xhigh', 'max'] + +function cloneDefinedEnv(env: NodeJS.ProcessEnv | Record): Record { + const next: Record = {} + for (const [key, value] of Object.entries(env)) { + if (value !== undefined) { + next[key] = value + } + } + return next +} + +/** + * Translate the record's durable launch arguments into SDK options. + * + * Typed option first so a flag is never emitted twice; `extraArgs` carries + * anything without one. A token expressible neither way is refused rather than + * dropped — a silent drop is how this lane loses launch flags. + */ +export function claudeSdkOptionsForLaunchArgs( + args: readonly string[] +): Pick { + let model: string | undefined + let effort: EffortLevel | undefined + const extraArgs: Record = {} + for (let index = 0; index < args.length; index += 1) { + const token = args[index] ?? '' + if (!token.startsWith('--') || token.length <= 2) { + throw new Error( + `claude launch argument ${token} has no SDK option; refusing rather than dropping it` + ) + } + const equals = token.indexOf('=') + const flag = equals === -1 ? token : token.slice(0, equals) + let value = equals === -1 ? null : token.slice(equals + 1) + if (value === null) { + const next = args[index + 1] + if (next !== undefined && !next.startsWith('-')) { + value = next + index += 1 + } + } + if (flag === '--model' && value !== null) { + model = value + } else if (flag === '--effort' && value !== null && EFFORT_LEVELS.includes(value)) { + effort = value as EffortLevel + } else { + extraArgs[flag.slice(2)] = value + } + } + return { + ...(model === undefined ? {} : { model }), + ...(effort === undefined ? {} : { effort }), + ...(Object.keys(extraArgs).length > 0 ? { extraArgs } : {}) + } +} + +export type ClaudeStructuredLaunch = { + /** Always Orca's resolved user CLI: the SDK's bundled binaries are excluded from the install. */ + pathToClaudeCodeExecutable: string + options: ClaudeStructuredSdkOptions + cwd: string + env?: Record + claudeConfigDir: string + providerSessionId: string + resumeLeafUuid: string | null + resumed: boolean +} + +export type ClaudeStructuredLaunchResolverDeps = { + store: AgentSessionRecordStore + resolveWorkspacePath: (workspaceId: string) => Promise + resolveCommand?: () => string + resolveEnv?: () => + | Promise | undefined> + | Record + | undefined + /** + * Required, and deliberately not defaulted. `stripAuthEnv` used to be a literal + * `true` here, so a missing dependency could not under-strip. Now it can, and the + * failure is silent — so every caller states the account's policy rather than + * inherit a guess. Build it with claudeStructuredAuthPolicyForSettings. + */ + resolveAuthPolicy: () => Promise | ClaudeStructuredAuthPolicy + /** How long an in-flight account switch may hold a launch before it is refused. */ + authSwitchSettleTimeoutMs?: number + /** Account state for the managed-account gate; null when it cannot be read, which refuses. */ + readManagedAccountGate?: () => ClaudeManagedAccountGateSettings | null +} + +/** + * Wait a running account switch out, and refuse only if it never settles. + * + * Launch resolution is reached from `acquireClaudeSession` *after* the old child has + * been closed and proved, so a plain refusal here would leave the user with a dead + * chat and no replacement — the very harm the acquire-entry guard exists to prevent. + * The entry guard still refuses outright, because nothing has been torn down yet. + */ +export async function assertClaudeAuthSwitchSettled( + timeoutMs = CLAUDE_AUTH_SWITCH_SETTLE_TIMEOUT_MS +): Promise { + if (!(await whenClaudeAuthSwitchSettles(timeoutMs))) { + throw new Error(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE) + } +} + +export function claudeSessionIdForOrcaSession(sessionId: string): string { + const bytes = createHash('sha256').update(`orca-claude:${sessionId}`).digest().subarray(0, 16) + bytes[6] = ((bytes[6] ?? 0) & 0x0f) | 0x40 + bytes[8] = ((bytes[8] ?? 0) & 0x3f) | 0x80 + const hex = bytes.toString('hex') + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}` +} + +export function createClaudeStructuredLaunchResolver( + deps: ClaudeStructuredLaunchResolverDeps +): (input: { identity: AgentSessionJournalIdentity }) => Promise { + return async ({ identity }) => { + await assertClaudeAuthSwitchSettled(deps.authSwitchSettleTimeoutMs) + const record = deps.store.getRecord(identity.sessionId) + if (!record) { + throw new Error(`no durable agent-session record for ${identity.sessionId}`) + } + if (record.provider !== 'claude') { + throw new Error(`session ${identity.sessionId} is a ${record.provider} session`) + } + if ( + record.location.executionHostId !== LOCAL_EXECUTION_HOST_ID || + record.location.wslDistro !== null + ) { + throw new Error( + `claude structured sessions run on the local host, not ${record.location.executionHostId}` + ) + } + if (record.accountHome.variable !== 'CLAUDE_CONFIG_DIR') { + throw new Error(`claude sessions pin CLAUDE_CONFIG_DIR, not ${record.accountHome.variable}`) + } + // Every acquisition, not just the first: the account state can change under a live session, and + // a reacquire after an unexpected exit would otherwise spawn under whatever it has become. + // Codex has no gate here — it resolves its account on a different path. + if ( + deps.readManagedAccountGate && + !structuredClaudeMatchesActiveManagedAccount(deps.readManagedAccountGate()) + ) { + throw new AgentSessionPreSpawnError( + 'structured Claude is not offered under the active managed Claude account' + ) + } + const head = agentSessionProviderHandleChainHead(record.providerHandleChain) + if ( + head?.handle.provider === 'claude' && + (identity.providerHandle.kind !== 'claude' || + identity.providerHandle.sessionId !== head.handle.sessionId || + identity.providerHandle.leafUuid !== head.handle.leafUuid) + ) { + throw new Error('claude durable resume identity changed before spawn') + } + const providerSessionId = + head?.handle.provider === 'claude' + ? head.handle.sessionId + : claudeSessionIdForOrcaSession(identity.sessionId) + const durable = claudeSdkOptionsForLaunchArgs(record.launchArgs ?? []) + const command = (deps.resolveCommand ?? resolveClaudeCommand)() + const auth = await deps.resolveAuthPolicy() + const overlay = await deps.resolveEnv?.() + // A switch can begin while the policy and overlay resolve, exactly as it can + // during the terminal preflight's prepareClaudeAuth — recheck after the awaits. + await assertClaudeAuthSwitchSettled(deps.authSwitchSettleTimeoutMs) + // Under a managed account the pinned credential is the only auth this launch may + // use, so an explicit override is refused rather than silently beating the pin. + if (auth.stripAuthEnv && hasClaudeAuthEnvConflict(overlay)) { + throw new Error(CLAUDE_AUTH_ENV_CONFLICT_MESSAGE) + } + // Why the overlay merges onto the inherited env rather than replacing it: the child + // still needs PATH and the rest of the shell environment, and withCliRuntimeOnPath + // derives PATH from what it is handed. Ambient Anthropic auth is stripped from the + // inherited half only when a managed account owns the credential; a system-auth + // user's own key is their sign-in and must reach the child. + const env = withCliRuntimeOnPath( + command, + { + ...applyClaudeEnvPatch( + cloneDefinedEnv(process.env), + {}, + { + stripAuthEnv: auth.stripAuthEnv, + platform: process.platform + } + ), + ...(overlay ? cloneDefinedEnv(overlay) : {}) + }, + { platform: process.platform } + ) + return { + pathToClaudeCodeExecutable: command, + options: { + ...durable, + ...CLAUDE_STRUCTURED_BASE_OPTIONS, + extraArgs: { ...durable.extraArgs, ...CLAUDE_STRUCTURED_BASE_OPTIONS.extraArgs }, + ...(head?.handle.provider === 'claude' + ? { + resume: providerSessionId, + ...(head.handle.leafUuid === null ? {} : { resumeSessionAt: head.handle.leafUuid }) + } + : { sessionId: providerSessionId }) + }, + cwd: await deps.resolveWorkspacePath(record.location.workspaceId), + env, + claudeConfigDir: record.accountHome.path, + providerSessionId, + resumeLeafUuid: head?.handle.provider === 'claude' ? head.handle.leafUuid : null, + resumed: head?.handle.provider === 'claude' + } + } +} diff --git a/src/main/claude/claude-structured-location-support.test.ts b/src/main/claude/claude-structured-location-support.test.ts new file mode 100644 index 00000000000..1106667d544 --- /dev/null +++ b/src/main/claude/claude-structured-location-support.test.ts @@ -0,0 +1,91 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + __setWindowsProcessTreeLoaderForTests, + resetWindowsProcessTableForTests +} from '../windows/windows-process-table' +import { supportsClaudeStructuredLocation } from './claude-structured-location-support' + +function setPlatform(platform: NodeJS.Platform): PropertyDescriptor | undefined { + const previous = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: platform }) + return previous +} + +describe('supportsClaudeStructuredLocation', () => { + let previousPlatform: PropertyDescriptor | undefined + + beforeEach(() => { + previousPlatform = setPlatform('darwin') + __setWindowsProcessTreeLoaderForTests() + }) + + afterEach(() => { + __setWindowsProcessTreeLoaderForTests() + resetWindowsProcessTableForTests() + if (previousPlatform) { + Object.defineProperty(process, 'platform', previousPlatform) + } + }) + + it('allows local non-WSL locations on macOS and Linux', () => { + expect( + supportsClaudeStructuredLocation({ + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' + }) + ).toBe(true) + }) + + it('rejects Windows local locations until creation-time proof is available', () => { + previousPlatform = setPlatform('win32') + __setWindowsProcessTreeLoaderForTests(() => ({ + ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2 }, + getAllProcesses: () => undefined + })) + expect( + supportsClaudeStructuredLocation({ + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' + }) + ).toBe(false) + }) + + it('accepts Windows local locations once creation-time proof is available', () => { + previousPlatform = setPlatform('win32') + __setWindowsProcessTreeLoaderForTests(() => ({ + ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2, CreationTime: 4 }, + getAllProcesses: () => undefined + })) + expect( + supportsClaudeStructuredLocation({ + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' + }) + ).toBe(true) + }) + + it('rejects WSL and remote locations', () => { + expect( + supportsClaudeStructuredLocation({ + executionHostId: 'local', + wslDistro: 'Ubuntu', + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' + }) + ).toBe(false) + expect( + supportsClaudeStructuredLocation({ + executionHostId: 'runtime:env-1', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' + }) + ).toBe(false) + }) +}) diff --git a/src/main/claude/claude-structured-location-support.ts b/src/main/claude/claude-structured-location-support.ts new file mode 100644 index 00000000000..9c784a91325 --- /dev/null +++ b/src/main/claude/claude-structured-location-support.ts @@ -0,0 +1,11 @@ +import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' +import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import { isWindowsProcessStartTimeAvailable } from '../windows/windows-process-table' + +export function supportsClaudeStructuredLocation(location: AgentSessionExecutionLocation): boolean { + return ( + location.executionHostId === LOCAL_EXECUTION_HOST_ID && + location.wslDistro === null && + (process.platform !== 'win32' || isWindowsProcessStartTimeAvailable()) + ) +} diff --git a/src/main/claude/claude-structured-model-confirmation.test.ts b/src/main/claude/claude-structured-model-confirmation.test.ts new file mode 100644 index 00000000000..7bd8f629119 --- /dev/null +++ b/src/main/claude/claude-structured-model-confirmation.test.ts @@ -0,0 +1,209 @@ +import { describe, expect, it } from 'vitest' +import { setClaudeStructuredOption } from './claude-structured-options' +import type { ClaudeSession } from './claude-structured-session-state' +import { PROVIDER_SESSION_ID, acquired, fakeClaude } from './claude-structured-session-test-support' + +/** Verbatim rows from Claude Code 2.1.258's list_models response. */ +const CATALOG = [ + { + value: 'default', + resolvedModel: 'claude-opus-5[1m]', + displayName: 'Default (recommended)', + supportsEffort: true, + supportedEffortLevels: ['low', 'medium', 'high', 'xhigh', 'max'] + }, + { + value: 'sonnet', + resolvedModel: 'claude-sonnet-5', + displayName: 'Sonnet', + supportsEffort: true, + supportedEffortLevels: ['low', 'medium', 'high', 'xhigh', 'max'] + }, + { value: 'haiku', resolvedModel: 'claude-haiku-4-5-20251001', displayName: 'Haiku' } +] + +function initFrame(model: string): Record { + // Keys mirror the real per-turn system/init frame: it carries `model` as the + // resolved id, and no effort of any kind. + return { + type: 'system', + subtype: 'init', + session_id: PROVIDER_SESSION_ID, + uuid: 'turn-init-uuid', + model, + apiKeySource: 'none' + } +} + +describe('Claude model confirmation', () => { + it('adopts the model a later turn reports when nothing was set since', async () => { + const claude = fakeClaude({ + initModel: 'claude-sonnet-5', + routes: { list_models: () => CATALOG } + }) + const adapter = await acquired(claude) + + await expect(adapter.readOptions({ sessionId: 'session-1', fence: 7 })).resolves.toMatchObject({ + current: { model: 'sonnet' } + }) + + // The CLI's own report of what it is running — the only channel that carries + // it, since set_model answers success for a model it never resolves. + claude.connections[0]!.handlers.onMessage?.(initFrame('claude-haiku-4-5-20251001')) + + await expect(adapter.readOptions({ sessionId: 'session-1', fence: 7 })).resolves.toMatchObject({ + current: { model: 'haiku' } + }) + }) + + it('keeps a just-set model until the next turn reports one', async () => { + const claude = fakeClaude({ + initModel: 'claude-sonnet-5', + routes: { list_models: () => CATALOG } + }) + const adapter = await acquired(claude) + + await adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'haiku', fence: 7 }) + + // No turn has run, so the acquisition-time report is older than the write and + // must not flip the pill back to the model the session started on. + await expect(adapter.readOptions({ sessionId: 'session-1', fence: 7 })).resolves.toMatchObject({ + current: { model: 'haiku' } + }) + }) + + it('corrects the record when the turn runs a different model than was set', async () => { + const claude = fakeClaude({ + initModel: 'claude-sonnet-5', + routes: { list_models: () => CATALOG } + }) + const adapter = await acquired(claude) + + await adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'haiku', fence: 7 }) + claude.connections[0]!.handlers.onMessage?.(initFrame('claude-sonnet-5')) + + await expect(adapter.readOptions({ sessionId: 'session-1', fence: 7 })).resolves.toMatchObject({ + current: { model: 'sonnet' } + }) + }) + + it('guards an effort against the model the turn reported, not the one that was set', async () => { + const claude = fakeClaude({ + initModel: 'claude-sonnet-5', + routes: { list_models: () => CATALOG } + }) + const adapter = await acquired(claude) + + await adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'haiku', fence: 7 }) + // set_model answered success for a model it never resolved; the turn runs sonnet. + claude.connections[0]!.handlers.onMessage?.(initFrame('claude-sonnet-5')) + + // The picker offers sonnet's levels, so refusing one under haiku — a model the + // pill does not show and the child is not running — is the false positive. + await expect( + adapter.setOption({ sessionId: 'session-1', key: 'effort', value: 'high', fence: 7 }) + ).resolves.toMatchObject({ effort: 'high' }) + }) + + it('keeps guarding against the reported model across a second effort write', async () => { + const claude = fakeClaude({ + initModel: 'claude-sonnet-5', + routes: { list_models: () => CATALOG } + }) + const adapter = await acquired(claude) + + await adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'haiku', fence: 7 }) + claude.connections[0]!.handlers.onMessage?.(initFrame('claude-sonnet-5')) + await adapter.setOption({ sessionId: 'session-1', key: 'effort', value: 'high', fence: 7 }) + + // The effort write bumps the option fence but does not change what the child + // runs, so the sonnet report is still current and still governs the guard. + // `max` skips the settings readback by contract, so only the catalog gates it: + // sonnet advertises it, haiku advertises no effort control at all. + await expect( + adapter.setOption({ sessionId: 'session-1', key: 'effort', value: 'max', fence: 7 }) + ).resolves.toMatchObject({ effort: 'max' }) + }) + + it('guards an effort against a just-set model no turn has reported yet', async () => { + const claude = fakeClaude({ + initModel: 'claude-sonnet-5', + routes: { list_models: () => CATALOG } + }) + const adapter = await acquired(claude) + + await adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'haiku', fence: 7 }) + + // The acquisition-time report predates the write, so haiku — which advertises + // no effort control — is still the model the guard must answer for. + await expect( + adapter.setOption({ sessionId: 'session-1', key: 'effort', value: 'high', fence: 7 }) + ).rejects.toThrow('claude model haiku does not accept effort high') + }) + + it('stops vouching for a confirmed effort once the model changes', async () => { + const claude = fakeClaude({ + initModel: 'claude-sonnet-5', + routes: { list_models: () => CATALOG } + }) + const adapter = await acquired(claude) + + await adapter.setOption({ sessionId: 'session-1', key: 'effort', value: 'high', fence: 7 }) + await expect(adapter.readOptions({ sessionId: 'session-1', fence: 7 })).resolves.toMatchObject({ + current: { model: 'sonnet', effort: 'high', confirmed: ['model', 'effort'] } + }) + + await adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'haiku', fence: 7 }) + + // The readback was taken under sonnet; nothing has reported haiku holding it. + const options = await adapter.readOptions({ sessionId: 'session-1', fence: 7 }) + expect(options.current.effort).toBe('high') + expect(options.current.confirmed).toBeUndefined() + }) +}) + +describe('Claude effort the settings readback cannot report', () => { + function sessionWith( + reported: string, + calls: string[] = [] + ): { session: ClaudeSession; calls: string[] } { + return { + session: { + options: new Map([['model', 'sonnet']]), + reportedOptions: {}, + optionMutationSequence: 0, + confirmedOptions: new Set(), + connection: { + supportedModels: async () => { + calls.push('list_models') + return CATALOG + }, + applyFlagSettings: async (settings: { effortLevel?: string }) => { + calls.push(`apply:${settings.effortLevel}`) + }, + getSettings: async () => { + calls.push('get_settings') + return { + applied: { effort: reported }, + effective: { effortLevel: reported }, + sources: {} + } + } + } + } as unknown as ClaudeSession, + calls + } + } + + it('records a session-scoped effort the persisted settings never carry', async () => { + // `max` applies for the session and is deliberately excluded from the + // persisted effortLevel, so the readback reporting `high` is an absence of + // evidence, not a refusal — and the CLI offers `max` in its own catalog. + const { session, calls } = sessionWith('high') + + await expect( + setClaudeStructuredOption(session, { key: 'effort', value: 'max' }, undefined) + ).resolves.toEqual({ model: 'sonnet', effort: 'max' }) + expect(calls).toEqual(['list_models', 'apply:max']) + }) +}) diff --git a/src/main/claude/claude-structured-option-confirmation.test.ts b/src/main/claude/claude-structured-option-confirmation.test.ts new file mode 100644 index 00000000000..ca7b8b70f1c --- /dev/null +++ b/src/main/claude/claude-structured-option-confirmation.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, it } from 'vitest' +import { + applyStructuredAgentSessionOptions, + createStructuredAgentSessionOptionState, + structuredAgentSessionOptionSnapshot +} from '../../shared/structured-agent-session-options' +import { CLAUDE_SESSION_OPTION_CATALOG } from '../../shared/agent-session-option-catalog-claude-codex' +import type { AgentSessionOptionsResult } from '../../shared/agent-session-wire' +import type { SessionOptionDescriptor } from '../../shared/native-chat-session-options' +import { setClaudeStructuredOption } from './claude-structured-options' +import type { ClaudeSession } from './claude-structured-session-state' +import { PROVIDER_SESSION_ID, acquired, fakeClaude } from './claude-structured-session-test-support' + +/** Verbatim rows from Claude Code 2.1.260's list_models response: `haiku` really + * does omit both effort keys, which is what makes an effort under it refusable. */ +const CATALOG = [ + { + value: 'sonnet', + resolvedModel: 'claude-sonnet-5', + displayName: 'Sonnet', + supportsEffort: true, + supportedEffortLevels: ['low', 'medium', 'high', 'xhigh', 'max'] + }, + { value: 'haiku', resolvedModel: 'claude-haiku-4-5-20251001', displayName: 'Haiku' } +] + +function initFrame(model: string): Record { + return { + type: 'system', + subtype: 'init', + session_id: PROVIDER_SESSION_ID, + uuid: 'turn-init-uuid', + model, + apiKeySource: 'none' + } +} + +function modelPill(result: AgentSessionOptionsResult): SessionOptionDescriptor | undefined { + const state = applyStructuredAgentSessionOptions( + createStructuredAgentSessionOptionState('claude'), + CLAUDE_SESSION_OPTION_CATALOG, + result + ) + return structuredAgentSessionOptionSnapshot(state).find((d) => d.category === 'model') +} + +/** Provenance the record keeps. Nothing renders it — the pill shows the value + * either way, and a report that disagrees is what corrects it. */ +function modelSource(result: AgentSessionOptionsResult): string | undefined { + return modelPill(result)?.valueSource +} + +function modelValue(result: AgentSessionOptionsResult): string | undefined { + const kind = modelPill(result)?.kind + return kind?.type === 'select' ? kind.currentValue : undefined +} + +describe('structured option confirmation reaches the pill', () => { + it('shows a just-set model before any turn reports it', async () => { + const claude = fakeClaude({ + initModel: 'claude-sonnet-5', + routes: { list_models: () => CATALOG } + }) + const adapter = await acquired(claude) + await adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'haiku', fence: 7 }) + + const result = await adapter.readOptions({ sessionId: 'session-1', fence: 7 }) + expect(result.current.confirmed ?? []).not.toContain('model') + expect(modelSource(result)).toBe('dispatched') + }) + + it('marks the model reported once the provider names it back', async () => { + const claude = fakeClaude({ + initModel: 'claude-sonnet-5', + routes: { list_models: () => CATALOG } + }) + const adapter = await acquired(claude) + await adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'haiku', fence: 7 }) + claude.connections[0]!.handlers.onMessage?.(initFrame('claude-haiku-4-5-20251001')) + + const result = await adapter.readOptions({ sessionId: 'session-1', fence: 7 }) + expect(result.current.confirmed).toContain('model') + expect(modelSource(result)).toBe('reported') + }) + + it('records an effort the readback could not take without confirming it', async () => { + const claude = fakeClaude({ + initModel: 'claude-sonnet-5', + settings: { applied: {}, effective: {}, sources: {} }, + routes: { list_models: () => CATALOG } + }) + const adapter = await acquired(claude) + // `max` is session-scoped and absent from the persisted settings, so it records + // without a readback — recorded, never vouched for. + await adapter.setOption({ sessionId: 'session-1', key: 'effort', value: 'max', fence: 7 }) + + const result = await adapter.readOptions({ sessionId: 'session-1', fence: 7 }) + expect(result.current.effort).toBe('max') + expect(result.current.confirmed ?? []).not.toContain('effort') + }) + + it('confirms an effort the readback agreed with', async () => { + const claude = fakeClaude({ + initModel: 'claude-sonnet-5', + settings: { applied: { effort: 'low' }, effective: { effortLevel: 'low' }, sources: {} }, + routes: { list_models: () => CATALOG } + }) + const adapter = await acquired(claude) + await adapter.setOption({ sessionId: 'session-1', key: 'effort', value: 'low', fence: 7 }) + + const result = await adapter.readOptions({ sessionId: 'session-1', fence: 7 }) + expect(result.current.confirmed).toContain('effort') + }) + + it('treats a host that reports no confirmation as unconfirmed', () => { + // Wire compatibility: an older host omits `confirmed` entirely. Absence must + // read as unconfirmed provenance, and the pill still shows the host's value. + const result = { + models: [{ id: 'haiku', label: 'Haiku', isDefault: false, efforts: [] }], + current: { model: 'haiku' } + } + expect(modelSource(result)).toBe('dispatched') + expect(modelValue(result)).toBe('haiku') + }) +}) + +describe('the provider report corrects the pill', () => { + it('moves the pill to the model the turn actually ran', async () => { + const claude = fakeClaude({ + initModel: 'claude-sonnet-5', + routes: { list_models: () => CATALOG } + }) + const adapter = await acquired(claude) + await adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'haiku', fence: 7 }) + expect(modelValue(await adapter.readOptions({ sessionId: 'session-1', fence: 7 }))).toBe( + 'haiku' + ) + + claude.connections[0]!.handlers.onMessage?.(initFrame('claude-sonnet-5')) + + const corrected = await adapter.readOptions({ sessionId: 'session-1', fence: 7 }) + expect(modelValue(corrected)).toBe('sonnet') + expect(corrected.current.confirmed).toContain('model') + }) + + it('lets a newer write outrank the report it precedes', async () => { + const claude = fakeClaude({ + initModel: 'claude-sonnet-5', + routes: { list_models: () => CATALOG } + }) + const adapter = await acquired(claude) + claude.connections[0]!.handlers.onMessage?.(initFrame('claude-sonnet-5')) + await adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'haiku', fence: 7 }) + + const result = await adapter.readOptions({ sessionId: 'session-1', fence: 7 }) + expect(modelValue(result)).toBe('haiku') + expect(result.current.confirmed ?? []).not.toContain('model') + }) +}) + +describe('confirmation never outlives the write it belongs to', () => { + it('drops an earlier effort confirmation when the value changes', async () => { + const calls: string[] = [] + let reported = 'low' + const session = { + options: new Map([['model', 'sonnet']]), + reportedOptions: {}, + optionMutationSequence: 0, + confirmedOptions: new Set(), + connection: { + supportedModels: async () => CATALOG, + applyFlagSettings: async (s: { effortLevel?: string }) => { + calls.push(`apply:${s.effortLevel}`) + }, + getSettings: async () => ({ + applied: { effort: reported }, + effective: { effortLevel: reported }, + sources: {} + }) + } + } as unknown as ClaudeSession + + await setClaudeStructuredOption(session, { key: 'effort', value: 'low' }, undefined) + expect(session.confirmedOptions.has('effort')).toBe(true) + + // The provider now reports a level it cannot represent; the stale confirmation + // must not survive into the new value. + await setClaudeStructuredOption(session, { key: 'effort', value: 'max' }, undefined) + expect(session.options.get('effort')).toBe('max') + expect(session.confirmedOptions.has('effort')).toBe(false) + expect(calls).toEqual(['apply:low', 'apply:max']) + }) +}) diff --git a/src/main/claude/claude-structured-options.test.ts b/src/main/claude/claude-structured-options.test.ts new file mode 100644 index 00000000000..bc18a589e10 --- /dev/null +++ b/src/main/claude/claude-structured-options.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it, vi } from 'vitest' +import { setClaudeStructuredOption } from './claude-structured-options' +import type { ClaudeSession } from './claude-structured-session-state' + +function sessionFor(setModel: ClaudeSession['connection']['setModel']): ClaudeSession { + return { + connection: { setModel } as ClaudeSession['connection'], + providerSessionId: 'provider-session', + claudeConfigDir: '/accounts/claude', + leafUuid: null, + fence: 1, + acquisitionGeneration: 'generation-1', + prompts: {} as ClaudeSession['prompts'], + dispatchWaiters: [], + retiredDispatchWaiters: [], + replayContentFallbackBlocked: false, + dispatchSequence: 0, + optionMutationSequence: 0, + options: new Map(), + reportedOptions: {}, + reportedModelMutation: 0, + confirmedOptions: new Set(), + restoreSkippedOptions: new Set(), + capabilities: [], + events: undefined, + translator: null + } +} + +describe('Claude structured option mutation fencing', () => { + it('does not let a delayed earlier apply overwrite a later option', async () => { + let releaseFirst!: () => void + const firstApply = new Promise((resolve) => { + releaseFirst = resolve + }) + const setModel = vi + .fn() + .mockReturnValueOnce(firstApply) + .mockResolvedValue(undefined) + const session = sessionFor(setModel) + + const first = setClaudeStructuredOption(session, { key: 'model', value: 'old' }, undefined) + await vi.waitFor(() => expect(setModel).toHaveBeenCalledTimes(1)) + const second = setClaudeStructuredOption(session, { key: 'model', value: 'new' }, undefined) + await expect(second).resolves.toEqual({ model: 'new' }) + + releaseFirst() + await expect(first).resolves.toEqual({ model: 'new' }) + expect(session.options).toEqual(new Map([['model', 'new']])) + }) +}) diff --git a/src/main/claude/claude-structured-options.ts b/src/main/claude/claude-structured-options.ts new file mode 100644 index 00000000000..3d1377b12c6 --- /dev/null +++ b/src/main/claude/claude-structured-options.ts @@ -0,0 +1,147 @@ +import type { EffortLevel, PermissionMode } from '@anthropic-ai/claude-agent-sdk' +import { ClaudeControlRequestError } from './claude-stream-json-connection' +import { + AgentSessionOptionRejectedError, + isAgentSessionOptionRejectedError +} from '../native-chat/agent-session-wire/structured-agent-session-option-error' +import { + readClaudeCurrentModel, + readClaudeModelEffortLevels, + readClaudeSettingsEffort +} from './claude-structured-session-options' +import type { ClaudeSession } from './claude-structured-session-state' + +const OPTION_ORDER = ['model', 'effort', 'permissionMode'] as const + +/** + * Efforts the settings readback cannot report. `max` applies for the rest of the + * session and is excluded from the persisted `effortLevel` by contract, so + * `get_settings` answers with the level underneath it — an absence of evidence + * that must not be read as the child refusing a level its own catalog offers. + */ +const UNREPORTED_EFFORTS: ReadonlySet = new Set(['max']) + +export function restoredClaudeStructuredSessionOptions( + options: Readonly> | undefined +): Map { + return new Map( + OPTION_ORDER.flatMap((key) => { + const value = options?.[key] + return value ? [[key, value] as const] : [] + }) + ) +} + +export async function setClaudeStructuredOption( + session: ClaudeSession, + input: { key: string; value: string }, + timeoutMs: number | undefined +): Promise>> { + const apply = + input.key === 'model' + ? () => session.connection.setModel(input.value, { timeoutMs }) + : input.key === 'permissionMode' + ? () => session.connection.setPermissionMode(input.value as PermissionMode, { timeoutMs }) + : input.key === 'effort' + ? () => + session.connection.applyFlagSettings( + { effortLevel: input.value as EffortLevel }, + { timeoutMs } + ) + : null + if (!apply) { + throw new AgentSessionOptionRejectedError( + `claude stream-json has no session option named ${input.key}` + ) + } + // The child stores an effort its model has no control for and keeps it across + // every later model switch and restore, so refuse before the write rather than + // read the acceptance back as adoption. Refused here, restore drops the stale + // value instead of replaying it onto a model that cannot use it. + if (input.key === 'effort') { + const { modelId, levels } = await readClaudeModelEffortLevels(session, timeoutMs) + if (levels && !levels.has(input.value)) { + throw new AgentSessionOptionRejectedError( + `claude model ${modelId} does not accept effort ${input.value}` + ) + } + } + const modelWasConfirmed = readClaudeCurrentModel(session).confirmed + const mutationSequence = ++session.optionMutationSequence + // Only a model write can stale the model report — an effort or permission-mode + // write does not change what the child is running. Leaving the stamp behind + // would drop the session back to the written model and refuse, on the next + // effort write, a level the model actually running advertises. + if (modelWasConfirmed && input.key !== 'model') { + session.reportedModelMutation = mutationSequence + } + try { + await apply() + } catch (error) { + if (error instanceof ClaudeControlRequestError) { + throw new AgentSessionOptionRejectedError(error) + } + throw error + } + // apply_flag_settings answers `success` for an effort it then ignores, so the + // absence of a throw proves nothing. Ask what the child actually holds. + const adopted = + input.key === 'effort' && !UNREPORTED_EFFORTS.has(input.value) + ? await session.connection + .getSettings({ timeoutMs }) + .then(readClaudeSettingsEffort) + .catch(() => null) + : null + if (mutationSequence !== session.optionMutationSequence) { + return Object.fromEntries(session.options) + } + // A disagreement stops main vouching for the value, it does not veto the write: + // the pre-flight guard already refuses levels the model advertises no control + // for, and no other client refuses on a readback. Keep the child's own answer so + // the disagreement survives as the level a later read falls back to. + if (adopted !== null && adopted !== input.value) { + session.reportedOptions.effort = adopted + } + session.options.set(input.key, input.value) + // Only a readback that agreed is adoption evidence; one that disagreed or could + // not be taken records the value but must not also claim the provider vouched for it. + if (adopted !== null && adopted === input.value) { + session.confirmedOptions.add(input.key) + } else { + session.confirmedOptions.delete(input.key) + } + // The effort readback was taken under the old model, so a model switch retires + // it: the child keeps the value but nothing has reported the new model holding + // it, and vouching for it would show a confirmed effort no readback covers. + if (input.key === 'model') { + session.confirmedOptions.delete('effort') + } + return Object.fromEntries(session.options) +} + +export async function restoreClaudeStructuredSessionOptions( + session: ClaudeSession, + timeoutMs: number | undefined +): Promise { + // Any write that was already in flight belongs to the previous acquisition + // state and must not repopulate this map after restore starts. + session.optionMutationSequence += 1 + // The fence bump is not a write, so the report the session already holds is still + // current as of this instant; leaving the stamp behind would make every restored + // session read as unconfirmed until its next turn. + session.reportedModelMutation = session.optionMutationSequence + const options = [...session.options.entries()] + session.options.clear() + for (const [key, value] of options) { + try { + await setClaudeStructuredOption(session, { key, value }, timeoutMs) + } catch (error) { + if (!isAgentSessionOptionRejectedError(error)) { + throw error + } + // A stale or unavailable preference must not poison every future acquire; + // the provider's current value remains authoritative and is re-persisted. + session.restoreSkippedOptions.add(key) + } + } +} diff --git a/src/main/claude/claude-structured-owner-identity.test.ts b/src/main/claude/claude-structured-owner-identity.test.ts new file mode 100644 index 00000000000..592b61df338 --- /dev/null +++ b/src/main/claude/claude-structured-owner-identity.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it, vi } from 'vitest' +import { CLAUDE_SPAWN_TOKEN_ENV, claudeProcessIdentity } from './claude-structured-owner-identity' + +const IDENTITY = { + sessionId: 'session-identity', + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'claude' as const, + providerHandle: { kind: 'claude' as const, sessionId: 'session-1', leafUuid: 'leaf-1' } +} + +describe('claude structured owner identity', () => { + it('exports the spawn token env and records the observed process identity', async () => { + expect(CLAUDE_SPAWN_TOKEN_ENV).toBe('ORCA_AGENT_SESSION_SPAWN_TOKEN') + await expect( + claudeProcessIdentity( + { identity: IDENTITY, spawnToken: 'spawn-a', pid: 4242 }, + async () => 123 + ) + ).resolves.toEqual({ + hostId: 'local', + pid: 4242, + processStartTimeMs: 123, + spawnToken: 'spawn-a' + }) + }) + + it('retries a failed start-time read before giving up', async () => { + const readStartTime = vi + .fn<(pid: number) => Promise>() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(456) + await expect( + claudeProcessIdentity({ identity: IDENTITY, spawnToken: 'spawn-a', pid: 4242 }, readStartTime) + ).resolves.toMatchObject({ processStartTimeMs: 456 }) + expect(readStartTime).toHaveBeenCalledTimes(3) + }) +}) diff --git a/src/main/claude/claude-structured-owner-identity.ts b/src/main/claude/claude-structured-owner-identity.ts index 1d13e6ec7c2..e8f251d41f9 100644 --- a/src/main/claude/claude-structured-owner-identity.ts +++ b/src/main/claude/claude-structured-owner-identity.ts @@ -1,4 +1,7 @@ +import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' +import type { AgentSessionProcessIdentity } from '../../shared/agent-session-record' +import { readProcessStartTimeMs } from '../runtime/agent-session-process-identity-probe' export function claudeProviderHandleLink(input: { sessionId: string @@ -19,3 +22,41 @@ export function claudeProviderHandleLink(input: { observedAt: input.observedAt } } + +/** The child echoes its spawn token here so the owner probe can tell a live + * child of this reservation from a same-pid stranger. */ +export const CLAUDE_SPAWN_TOKEN_ENV = 'ORCA_AGENT_SESSION_SPAWN_TOKEN' + +const START_TIME_READ_ATTEMPTS = 3 + +export async function claudeProcessIdentity( + input: { + identity: AgentSessionJournalIdentity + spawnToken: string + pid: number | undefined + }, + readStartTime: (pid: number) => Promise = readProcessStartTimeMs +): Promise { + if (input.pid === undefined) { + throw new Error('claude app-server started without a pid') + } + let processStartTimeMs: number | null = null + for ( + let attempt = 0; + attempt < START_TIME_READ_ATTEMPTS && processStartTimeMs === null; + attempt += 1 + ) { + processStartTimeMs = await readStartTime(input.pid) + } + if (processStartTimeMs === null) { + // Why: recording null makes every later owner probe indeterminate — a durable latch. + // Failing here reaps the child and leaves a retryable refusal instead. + throw new Error(`claude app-server start time for pid ${input.pid} could not be read`) + } + return { + hostId: input.identity.hostId, + pid: input.pid, + processStartTimeMs, + spawnToken: input.spawnToken + } +} diff --git a/src/main/claude/claude-structured-prompt-items.test.ts b/src/main/claude/claude-structured-prompt-items.test.ts new file mode 100644 index 00000000000..79916d6a507 --- /dev/null +++ b/src/main/claude/claude-structured-prompt-items.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from 'vitest' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { encodeAgentSessionQuestionAnswers } from '../../shared/agent-session-question-answer' +import { claudeQuestionItems } from './claude-structured-prompt-items' +import { + applyClaudePromptAnswer, + encodeClaudeQuestionOptionId, + type ClaudePendingPrompt +} from './claude-structured-prompt-replies' + +describe('Claude structured question addressing', () => { + it('keeps wire IDs bounded while returning the original question and choice', () => { + const questionId = 'Which option? '.repeat(100) + const label = 'A detailed choice '.repeat(100) + const prompt: ClaudePendingPrompt = { + requestId: 'question-1', + promptKey: 'question-1', + toolUseId: 'tool-1', + toolName: 'AskUserQuestion', + kind: 'question', + input: { questions: [{ question: questionId, options: [{ label }] }] }, + suggestions: [], + questionIds: [questionId], + answers: new Map(), + settle: () => {} + } + + const item = claudeQuestionItems({ sessionId: 'session-1', prompt })[0]! + expect(agentJournalItemKey(item.identity).length).toBeLessThan(512) + expect(item.body.options[0]!.id.length).toBeLessThan(512) + expect(item.body.freeTextQuestionId).toBe('q1') + expect(applyClaudePromptAnswer({ prompt }, item.body.options[0]!.id)).toMatchObject({ + updatedInput: { answers: { [questionId]: label } } + }) + }) + + it('preserves colon-containing free-text answers', () => { + const questionId = 'Where should this run?' + const prompt: ClaudePendingPrompt = { + requestId: 'question-1', + promptKey: 'question-1', + toolUseId: 'tool-1', + toolName: 'AskUserQuestion', + kind: 'question', + input: { questions: [{ question: questionId }] }, + suggestions: [], + questionIds: [questionId], + answers: new Map(), + settle: () => {} + } + const answer = 'https://example.test:8443/path' + + expect( + applyClaudePromptAnswer({ prompt }, encodeClaudeQuestionOptionId('q1', answer)) + ).toMatchObject({ + updatedInput: { answers: { [questionId]: answer } } + }) + }) + + it('returns arrays for multi-select and preserves mixed single and Other answers', () => { + const multiQuestion = 'Which targets?' + const singleQuestion = 'Which mode?' + const otherQuestion = 'Where should it run?' + const prompt: ClaudePendingPrompt = { + requestId: 'question-1', + promptKey: 'question-1', + toolUseId: 'tool-1', + toolName: 'AskUserQuestion', + kind: 'question', + input: { + questions: [ + { + question: multiQuestion, + multiSelect: true, + options: [{ label: 'frontend' }, { label: 'backend' }] + }, + { + question: singleQuestion, + options: [{ label: 'fast' }, { label: 'safe' }] + }, + { question: otherQuestion, options: [] } + ] + }, + suggestions: [], + questionIds: [multiQuestion, singleQuestion, otherQuestion], + answers: new Map(), + settle: () => {} + } + const item = claudeQuestionItems({ sessionId: 'session-1', prompt })[0]! + const questions = item.body.questions! + const encoded = encodeAgentSessionQuestionAnswers([ + { + questionId: 'q1', + optionIds: [questions[0]!.options[0]!.id, questions[0]!.options[1]!.id] + }, + { questionId: 'q2', optionIds: [questions[1]!.options[1]!.id] }, + { questionId: 'q3', optionIds: [], other: 'remote host' } + ]) + + expect(applyClaudePromptAnswer({ prompt }, encoded)).toMatchObject({ + updatedInput: { + answers: { + [multiQuestion]: ['frontend', 'backend'], + [singleQuestion]: 'safe', + [otherQuestion]: 'remote host' + } + } + }) + }) +}) diff --git a/src/main/claude/claude-structured-prompt-items.ts b/src/main/claude/claude-structured-prompt-items.ts new file mode 100644 index 00000000000..3bdf8ab6091 --- /dev/null +++ b/src/main/claude/claude-structured-prompt-items.ts @@ -0,0 +1,134 @@ +import type { + AgentJournalApprovalItem, + AgentJournalItemIdentity, + AgentJournalPromptOption, + AgentJournalQuestion, + AgentJournalQuestionItem +} from '../../shared/agent-session-journal-types' +import { + boundInlineText, + DEFAULT_JOURNAL_PAYLOAD_LIMITS +} from '../native-chat/agent-session-journal/journal-payload-bounds' +import { claudeRecord, claudeText } from './claude-structured-item-translation' +import { + CLAUDE_APPROVAL_DECISIONS, + encodeClaudeQuestionOptionId, + type ClaudeApprovalDecision, + type ClaudePendingPrompt +} from './claude-structured-prompt-replies' + +const APPROVAL_LABELS: Record = { + allow: 'Allow', + allowForSession: 'Allow for this session', + deny: 'Deny', + cancel: 'Stop' +} + +const PENDING = { + state: 'pending', + selectedOptionId: null, + resolvedBy: null, + resolvedAt: null +} as const + +export function claudePromptIdentity(input: { + sessionId: string + promptKey: string + questionId?: string +}): AgentJournalItemIdentity { + const suffix = input.questionId ? `:${input.questionId}` : '' + return { + provider: 'orca', + clientMessageId: `claude-prompt:${input.sessionId}:${input.promptKey}${suffix}` + } +} + +export function claudeApprovalItem(prompt: ClaudePendingPrompt): AgentJournalApprovalItem { + const serialized = JSON.stringify(prompt.input) + return { + kind: 'approval', + title: `Allow ${prompt.toolName}?`, + detail: serialized ? boundInlineText(serialized, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text : null, + options: CLAUDE_APPROVAL_DECISIONS.map((decision) => ({ + id: decision, + label: APPROVAL_LABELS[decision] + })), + resolution: { ...PENDING } + } +} + +export type ClaudeQuestionItem = { + identity: AgentJournalItemIdentity + body: AgentJournalQuestionItem +} + +function questionOptions( + question: Record, + questionAddress: string +): AgentJournalPromptOption[] { + if (!Array.isArray(question.options)) { + return [] + } + return question.options.flatMap((value, index) => { + const option = claudeRecord(value) + const label = claudeText(option?.label) + const description = claudeText(option?.description) + return label + ? [ + { + id: encodeClaudeQuestionOptionId(questionAddress, `choice-${index + 1}`), + label, + ...(description ? { description } : {}) + } + ] + : [] + }) +} + +export function claudeQuestionItems(input: { + sessionId: string + prompt: ClaudePendingPrompt +}): ClaudeQuestionItem[] { + const values = Array.isArray(input.prompt.input.questions) ? input.prompt.input.questions : [] + const questions = values.flatMap((value, index): AgentJournalQuestion[] => { + const question = claudeRecord(value) + const questionAddress = `q${index + 1}` + const text = claudeText(question?.question) ?? claudeText(question?.header) + const header = claudeText(question?.header) + return question && input.prompt.questionIds[index] && text + ? [ + { + id: questionAddress, + question: text, + ...(header ? { header } : {}), + options: questionOptions(question, questionAddress), + multiSelect: question.multiSelect === true, + freeTextQuestionId: questionAddress + } + ] + : [] + }) + if (questions.length === 0) { + return [] + } + const legacyCompatible = questions.length === 1 && questions[0]?.multiSelect === false + const first = questions[0]! + return [ + { + identity: claudePromptIdentity({ + sessionId: input.sessionId, + promptKey: input.prompt.promptKey + }), + body: { + kind: 'question', + question: legacyCompatible + ? first.question + : `${questions.length} grouped question${questions.length === 1 ? '' : 's'} from Claude`, + options: legacyCompatible ? first.options : [], + ...(legacyCompatible ? { freeTextQuestionId: first.freeTextQuestionId } : {}), + questions, + resolution: { ...PENDING } + } + } + ] +} diff --git a/src/main/claude/claude-structured-prompt-replies.ts b/src/main/claude/claude-structured-prompt-replies.ts new file mode 100644 index 00000000000..deec74b7308 --- /dev/null +++ b/src/main/claude/claude-structured-prompt-replies.ts @@ -0,0 +1,297 @@ +import { decodeAgentSessionQuestionAnswers } from '../../shared/agent-session-question-answer' + +export const CLAUDE_APPROVAL_DECISIONS = ['allow', 'allowForSession', 'deny', 'cancel'] as const +export type ClaudeApprovalDecision = (typeof CLAUDE_APPROVAL_DECISIONS)[number] + +/** Settles the SDK's `canUseTool` promise; `null` is the SDK's "no response written" sentinel. */ +export type ClaudePromptSettle = (response: Record | null) => void + +export type ClaudePendingPrompt = { + requestId: string + promptKey: string + toolUseId: string + toolName: string + kind: 'approval' | 'question' + input: Record + suggestions: unknown[] + questionIds: readonly string[] + answers: Map + settle: ClaudePromptSettle +} + +export type ClaudePromptRegistration = { + requestId: string + toolName: string + toolUseId: string + input: Record + suggestions: unknown[] + settle: ClaudePromptSettle +} + +type PromptBinding = { + address: string + questionId?: string +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function readString(value: unknown): string | null { + return typeof value === 'string' && value.trim().length > 0 ? value : null +} + +function questionsFrom(input: Record): Record[] { + return Array.isArray(input.questions) ? input.questions.filter(isRecord) : [] +} + +function questionIdFromAddress(prompt: ClaudePendingPrompt, address: string): string | null { + const match = /^q([1-9]\d*)$/.exec(address) + const index = match ? Number(match[1]) - 1 : -1 + return index >= 0 ? (prompt.questionIds[index] ?? null) : null +} + +function questionAnswer(prompt: ClaudePendingPrompt, questionId: string, optionId: string): string { + const decoded = decodeClaudeQuestionOptionId(optionId) + if (!decoded) { + return optionId + } + const questionIndex = prompt.questionIds.indexOf(questionId) + if (questionIndex === -1) { + return optionId + } + const choice = /^choice-([1-9]\d*)$/.exec(decoded.answer) + const optionIndex = choice ? Number(choice[1]) - 1 : -1 + const question = questionsFrom(prompt.input)[questionIndex] + const options = Array.isArray(question?.options) ? question.options : [] + const option = options[optionIndex] + const label = isRecord(option) ? readString(option.label) : null + if (decoded.questionId === `q${questionIndex + 1}` && label) { + return label + } + if (decoded.questionId === `q${questionIndex + 1}`) { + return decoded.answer + } + const legacyChoice = options.some( + (candidate) => isRecord(candidate) && readString(candidate.label) === decoded.answer + ) + return decoded.questionId === questionId && (legacyChoice || decoded.answer.trim().length > 0) + ? decoded.answer + : optionId +} + +function questionId(question: Record, index: number): string { + return readString(question.question) ?? readString(question.header) ?? `question-${index + 1}` +} + +export function encodeClaudeQuestionOptionId(questionId: string, answer: string): string { + return `${encodeURIComponent(questionId)}:${encodeURIComponent(answer)}` +} + +export function decodeClaudeQuestionOptionId( + optionId: string +): { questionId: string; answer: string } | null { + const separator = optionId.indexOf(':') + if (separator <= 0) { + return null + } + try { + return { + questionId: decodeURIComponent(optionId.slice(0, separator)), + answer: decodeURIComponent(optionId.slice(separator + 1)) + } + } catch { + return null + } +} + +export class ClaudePromptRegistry { + private readonly prompts = new Map() + private readonly journalBindings = new Map() + + register(registration: ClaudePromptRegistration): ClaudePendingPrompt | null { + const toolUseId = readString(registration.toolUseId) + const toolName = readString(registration.toolName) + const input = isRecord(registration.input) ? registration.input : null + if (!toolUseId || !toolName || !input) { + return null + } + const questions = toolName === 'AskUserQuestion' ? questionsFrom(input) : [] + const prompt: ClaudePendingPrompt = { + requestId: registration.requestId, + promptKey: registration.requestId, + toolUseId, + toolName, + kind: questions.length > 0 ? 'question' : 'approval', + input, + suggestions: Array.isArray(registration.suggestions) ? registration.suggestions : [], + questionIds: questions.map(questionId), + answers: new Map(), + settle: registration.settle + } + this.prompts.set(prompt.promptKey, prompt) + return prompt + } + + /** True only if the prompt was still pending; lets an abort and an answer race settle once. */ + forgetIfPending(prompt: ClaudePendingPrompt): boolean { + if (!this.prompts.has(prompt.promptKey)) { + return false + } + this.forget(prompt) + return true + } + + bindJournalItemId(journalItemId: string, promptKey: string, questionIdForItem?: string): void { + this.journalBindings.set(journalItemId, { + address: promptKey, + ...(questionIdForItem ? { questionId: questionIdForItem } : {}) + }) + } + + find(itemId: string): { prompt: ClaudePendingPrompt; questionId?: string } | null { + const binding = this.journalBindings.get(itemId) + const prompt = this.prompts.get(binding?.address ?? itemId) + return prompt + ? { prompt, ...(binding?.questionId ? { questionId: binding.questionId } : {}) } + : null + } + + cancel(requestId: string): ClaudePendingPrompt | null { + const prompt = this.prompts.get(requestId) ?? null + if (prompt) { + this.forget(prompt) + } + return prompt + } + + forget(prompt: ClaudePendingPrompt): void { + this.prompts.delete(prompt.promptKey) + for (const [itemId, binding] of this.journalBindings) { + if (binding.address === prompt.promptKey) { + this.journalBindings.delete(itemId) + } + } + } + + clear(): ClaudePendingPrompt[] { + const pending = [...this.prompts.values()] + this.prompts.clear() + this.journalBindings.clear() + return pending + } +} + +function approvalResponse(prompt: ClaudePendingPrompt, optionId: string): Record { + if (!(CLAUDE_APPROVAL_DECISIONS as readonly string[]).includes(optionId)) { + throw new Error(`${optionId} is not a Claude approval decision`) + } + const decision = optionId as ClaudeApprovalDecision + if (decision === 'allow' || decision === 'allowForSession') { + return { + behavior: 'allow', + updatedInput: prompt.input, + ...(decision === 'allowForSession' && prompt.suggestions.length > 0 + ? { updatedPermissions: prompt.suggestions } + : {}), + toolUseID: prompt.toolUseId + } + } + return { + behavior: 'deny', + message: decision === 'cancel' ? 'User stopped this turn.' : 'User denied this action.', + ...(decision === 'cancel' ? { interrupt: true } : {}), + toolUseID: prompt.toolUseId + } +} + +function questionResponse( + prompt: ClaudePendingPrompt, + optionId: string, + boundQuestionId?: string +): Record | null { + const decoded = decodeClaudeQuestionOptionId(optionId) + const decodedQuestionId = decoded + ? (questionIdFromAddress(prompt, decoded.questionId) ?? + (prompt.questionIds.includes(decoded.questionId) ? decoded.questionId : null)) + : null + const selectedQuestionId = + boundQuestionId ?? + decodedQuestionId ?? + (prompt.questionIds.length === 1 ? prompt.questionIds[0] : null) + if (!selectedQuestionId || !prompt.questionIds.includes(selectedQuestionId)) { + throw new Error(`${optionId} does not name a question on Claude prompt ${prompt.promptKey}`) + } + const answer = questionAnswer(prompt, selectedQuestionId, optionId) + prompt.answers.set(selectedQuestionId, answer) + if (prompt.questionIds.some((id) => !prompt.answers.has(id))) { + return null + } + const answers: Record = {} + for (const id of prompt.questionIds) { + answers[id] = prompt.answers.get(id) as string + } + return { + behavior: 'allow', + updatedInput: { ...prompt.input, answers }, + toolUseID: prompt.toolUseId + } +} + +function groupedQuestionResponse( + prompt: ClaudePendingPrompt, + optionId: string +): Record | null { + const grouped = decodeAgentSessionQuestionAnswers(optionId) + if (!grouped) { + return null + } + const questions = questionsFrom(prompt.input) + if (grouped.length !== prompt.questionIds.length) { + throw new Error(`Grouped answer does not match Claude prompt ${prompt.promptKey}`) + } + const answers: Record = {} + for (let index = 0; index < questions.length; index += 1) { + const question = questions[index]! + const providerQuestionId = prompt.questionIds[index] + const answer = grouped.find((entry) => entry.questionId === `q${index + 1}`) + if (!providerQuestionId || !answer) { + throw new Error(`Grouped answer does not name question ${index + 1}`) + } + const selected = answer.optionIds.map((selectedId) => + questionAnswer(prompt, providerQuestionId, selectedId) + ) + const other = answer.other?.trim() + if (question.multiSelect === true) { + const values = [...selected, ...(other ? [other] : [])] + if (values.length === 0) { + throw new Error(`Grouped answer leaves question ${index + 1} empty`) + } + answers[providerQuestionId] = values + } else { + const value = other || selected[0] + if (!value || selected.length > 1) { + throw new Error(`Grouped answer is invalid for question ${index + 1}`) + } + answers[providerQuestionId] = value + } + } + return { + behavior: 'allow', + updatedInput: { ...prompt.input, answers }, + toolUseID: prompt.toolUseId + } +} + +export function applyClaudePromptAnswer( + found: { prompt: ClaudePendingPrompt; questionId?: string }, + optionId: string +): Record | null { + if (found.prompt.kind === 'approval') { + return approvalResponse(found.prompt, optionId) + } + return ( + groupedQuestionResponse(found.prompt, optionId) ?? + questionResponse(found.prompt, optionId, found.questionId) + ) +} diff --git a/src/main/claude/claude-structured-provider-fallback.test.ts b/src/main/claude/claude-structured-provider-fallback.test.ts new file mode 100644 index 00000000000..e8b27114da4 --- /dev/null +++ b/src/main/claude/claude-structured-provider-fallback.test.ts @@ -0,0 +1,117 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + AgentJournalItemBody, + AgentSessionJournalIdentity +} from '../../shared/agent-session-journal-types' +import { openAgentSessionJournal } from '../native-chat/agent-session-journal/journal-store-factory' +import { createDeferredStructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { createClaudeJournalTranslator } from './claude-structured-journal-translation' +import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-1', + workspaceId: 'workspace-1', + hostId: 'host-1', + agent: 'claude', + providerHandle: { kind: 'claude', sessionId: 'provider-1', leafUuid: 'leaf-1' } +} + +let root = '' + +function message( + role: 'assistant' | 'user', + uuid: string, + content: unknown[] +): ClaudeStructuredSessionEvent { + return { + type: 'message', + sessionId: 'orca-session', + message: { + type: role, + uuid, + session_id: 'provider-1', + message: { role, content } + } + } +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-claude-provider-fallback-')) +}) + +afterEach(async () => { + await rm(root, { recursive: true, force: true }) +}) + +describe('Claude provider fallback', () => { + it('drops suppressed init frames instead of dereferencing a null translation', () => { + const items: { identity: unknown; body: AgentJournalItemBody }[] = [] + const sink = { + appendItem: (identity: unknown, body: AgentJournalItemBody) => { + items.push({ identity, body }) + }, + appendTombstone: vi.fn(), + publish: vi.fn() + } + const translator = createClaudeJournalTranslator({ sink }) + const initEvent: ClaudeStructuredSessionEvent = { + type: 'message', + sessionId: 'orca-session', + message: { + type: 'system', + subtype: 'init', + session_id: 'provider-1', + uuid: 'init-1' + } + } + + expect(() => translator.handle(initEvent)).not.toThrow() + expect(items).toEqual([]) + }) + + it('keeps provider-fallback rows distinct across acquisitions', async () => { + const journal = await openAgentSessionJournal({ + identity: IDENTITY, + journalDir: root, + now: () => 1_700_000_000_000, + mintEpoch: () => 'epoch-1' + }) + const deferred = createDeferredStructuredAgentSessionEventSink() + deferred.bind({ + journal, + fence: 1, + publish: vi.fn() + }) + + const first = createClaudeJournalTranslator({ sink: deferred.sink, fallbackIdPrefix: '1' }) + const second = createClaudeJournalTranslator({ sink: deferred.sink, fallbackIdPrefix: '2' }) + + first.handle(message('assistant', 'assistant-1', [{ type: 'future_event', message: 'first' }])) + await deferred.drained() + second.handle( + message('assistant', 'assistant-2', [{ type: 'future_event', message: 'second' }]) + ) + await deferred.drained() + + const fallbackRows = journal + .snapshot() + .items.filter( + (item) => + item.body.kind === 'status' && + item.body.providerFrame?.kind === 'message:assistant:content:future_event' + ) + + expect(fallbackRows).toHaveLength(2) + expect(fallbackRows.map(statusText)).toEqual(['first', 'second']) + }) +}) + +function statusText(row: { body: AgentJournalItemBody }): string { + if (row.body.kind !== 'status') { + throw new Error('expected status row') + } + return row.body.text +} diff --git a/src/main/claude/claude-structured-provider-fallback.ts b/src/main/claude/claude-structured-provider-fallback.ts new file mode 100644 index 00000000000..2528ac027df --- /dev/null +++ b/src/main/claude/claude-structured-provider-fallback.ts @@ -0,0 +1,125 @@ +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { + boundInlineText, + DEFAULT_JOURNAL_PAYLOAD_LIMITS +} from '../native-chat/agent-session-journal/journal-payload-bounds' +import { CLAUDE_STREAM_JSON_FRAME_KINDS } from '../native-chat/agent-session-wire/claude-stream-json-frame-schema' +import { unhandledProviderFrameJournalItem } from '../native-chat/agent-session-wire/unhandled-provider-frame' +import { claudeRecord, claudeText } from './claude-structured-item-translation' + +export function claudeProviderFrameKind(message: Record): string { + const type = claudeText(message.type) ?? 'unknown' + const subtype = claudeText(message.subtype) + const eventType = claudeText(claudeRecord(message.event)?.type) + return ['message', type, subtype ?? eventType].filter(Boolean).join(':') +} + +const SETTLED_RESULT_KINDS: ReadonlySet = new Set( + CLAUDE_STREAM_JSON_FRAME_KINDS.filter((kind) => kind.startsWith('message:result:')) +) + +/** A catalogued result subtype is the turn-complete signal the translator settles + * itself; only an unmodeled subtype still needs the provider-fallback row. */ +export function isSettledClaudeResultKind(kind: string): boolean { + return SETTLED_RESULT_KINDS.has(kind) +} + +/** + * The failure a result frame carries that the turn's own frames never showed. + * + * Suppression is by meaning, not by kind. The SDK models an API failure as a + * SUCCESS-subtype result whose `result` string IS the error text and which has + * no assistant frame behind it, so keying on the subtype tombstones the turn and + * shows the user a completed, empty reply. A turn the user aborted is the + * opposite: its interrupt frame already says so, and the diagnostic in `errors` + * would only be noise. + */ +export function claudeResultFailure( + message: Record +): { text: string | null } | null { + if (message.is_error !== true) { + return null + } + const terminalReason = claudeText(message.terminal_reason) + if (terminalReason === 'aborted_streaming' || terminalReason === 'aborted_tools') { + return null + } + const result = claudeText(message.result)?.trim() + if (result) { + return { text: result } + } + const errors = Array.isArray(message.errors) + ? message.errors.flatMap((entry) => { + const text = claudeText(entry)?.trim() + return text ? [text] : [] + }) + : [] + // Nothing readable to lead with, but a reported failure still gets its row. + return { text: errors.length > 0 ? errors.join('\n') : null } +} + +/** + * What a message part that Orca cannot render says for itself. The kinds under + * `message::content:*` are synthesised from whatever `part.type` the CLI + * sends, so they can never be catalogued ahead of time; printing one is leaking + * wire vocabulary at a user who cannot act on it. The frame stays on the row's + * disclosure, so nothing is dropped and the next reader can still name it. + */ +export const CLAUDE_UNRENDERABLE_CONTENT_TEXT = 'Claude sent content Orca cannot display yet' + +export function isModeledClaudeContent(value: unknown): boolean { + const part = claudeRecord(value) + if (!part) { + return false + } + if (part.type === 'text') { + return claudeText(part.text) !== null + } + if (part.type === 'image') { + const source = claudeRecord(part.source) + if (source?.type === 'url') { + return claudeText(source.url) !== null + } + // A local attachment is replayed as the base64 (or file) source Orca itself + // sent, so it is content we recognise -- not an unknown part to surface. + return source?.type === 'base64' || source?.type === 'file' + } + if (part.type === 'tool_use') { + return claudeText(part.id) !== null && claudeText(part.name) !== null + } + if (part.type === 'tool_result') { + return claudeText(part.tool_use_id) !== null + } + // Redacted thinking arrives as an empty string plus a signature. + return part.type === 'thinking' || part.type === 'redacted_thinking' +} + +export function createClaudeProviderFrameFallback( + sink: StructuredAgentSessionEventSink, + acquisitionId: string +): { + /** `displayText` leads the row when Claude knows the sentence the frame itself does not name. */ + append: (kind: string, payload: unknown, displayText?: string | null) => void +} { + let sequence = 0 + return { + append: (kind, payload, displayText) => { + sequence += 1 + const translated = unhandledProviderFrameJournalItem('claude', kind, payload) + if (!translated) { + return + } + const bounded = displayText + ? boundInlineText(displayText, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text + : null + sink.appendItem( + { + provider: 'orca', + clientMessageId: `provider-frame:claude:${acquisitionId}:${sequence}` + }, + bounded ? { ...translated.body, text: bounded } : translated.body + ) + sink.publish() + } + } +} diff --git a/src/main/claude/claude-structured-real-cli.test.ts b/src/main/claude/claude-structured-real-cli.test.ts new file mode 100644 index 00000000000..0f22c175cc6 --- /dev/null +++ b/src/main/claude/claude-structured-real-cli.test.ts @@ -0,0 +1,299 @@ +import { spawnSync } from 'node:child_process' +import { randomUUID } from 'node:crypto' +import { mkdtemp, rm } from 'node:fs/promises' +import { homedir, tmpdir } from 'node:os' +import { basename, join, relative } from 'node:path' +import { describe, expect, it } from 'vitest' +import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' +import { resolveClaudeCommand } from '../codex-cli/command' +import { resolveSessionFilePath } from '../native-chat/session-file-resolver' +import { getSpawnArgsForWindows } from '../win32-utils' +import { CLAUDE_STRUCTURED_BASE_OPTIONS } from './claude-structured-launch-resolution' +import { + ClaudeStructuredSessionAdapter, + type ClaudeStructuredSessionEvent +} from './claude-structured-session-adapter' + +const command = resolveClaudeCommand() +const versionLaunch = getSpawnArgsForWindows(command, ['--version']) +const realClaudeAvailable = + spawnSync(versionLaunch.spawnCmd, versionLaunch.spawnArgs, { + stdio: 'ignore', + windowsHide: true, + timeout: 5_000 + }).status === 0 +const authStatusLaunch = getSpawnArgsForWindows(command, ['auth', 'status', '--json']) +/** The CLI's own account report — the only source of truth for where it writes that + * is not derived from Orca's own path expressions. */ +const realClaudeAuthStatus = (() => { + if (!realClaudeAvailable) { + return null + } + const result = spawnSync(authStatusLaunch.spawnCmd, authStatusLaunch.spawnArgs, { + encoding: 'utf8', + windowsHide: true, + timeout: 5_000 + }) + if (result.status !== 0) { + return null + } + try { + return JSON.parse(result.stdout) as { loggedIn?: boolean; projectsDirectory?: string } + } catch { + return null + } +})() +const realClaudeAuthenticated = realClaudeAuthStatus?.loggedIn === true + +function realAdapter( + providerSessionId: string, + claudeConfigDir: string, + events: ClaudeStructuredSessionEvent[] = [] +): ClaudeStructuredSessionAdapter { + return new ClaudeStructuredSessionAdapter({ + resolveLaunch: async () => ({ + pathToClaudeCodeExecutable: command, + options: { ...CLAUDE_STRUCTURED_BASE_OPTIONS, sessionId: providerSessionId }, + cwd: process.cwd(), + claudeConfigDir, + providerSessionId, + resumeLeafUuid: null, + resumed: false + }), + onEvent: (event) => events.push(event), + readProcessStartTime: async () => 1, + now: () => 2, + initTimeoutMs: 5_000 + }) +} + +function identity(providerSessionId: string): AgentSessionJournalIdentity { + return { + sessionId: 'real-cli-handshake', + workspaceId: 'real-cli-workspace', + hostId: 'local', + agent: 'claude', + providerHandle: { kind: 'claude', sessionId: providerSessionId, leafUuid: null } + } +} + +/** The CLI flushes its transcript on its own schedule; poll rather than race it. */ +async function waitForResolvedTranscript( + providerSessionId: string, + timeoutMs = 15_000 +): Promise { + const deadline = Date.now() + timeoutMs + for (;;) { + // No options: the exact call transcript-read-cache.ts makes for mobile. + const resolved = await resolveSessionFilePath('claude', providerSessionId) + if (resolved || Date.now() >= deadline) { + return resolved + } + await new Promise((resolve) => setTimeout(resolve, 250)) + } +} + +describe.skipIf(!realClaudeAvailable)('Claude structured real CLI handshake', () => { + it.skipIf(!realClaudeAuthenticated)( + 'proves a pre-minted session before the first user message', + async () => { + const providerSessionId = randomUUID() + const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), '.claude') + const events: ClaudeStructuredSessionEvent[] = [] + const adapter = realAdapter(providerSessionId, claudeConfigDir, events) + + try { + const acquisition = await adapter.acquire({ + identity: identity(providerSessionId), + fence: 1, + spawnToken: 'real-cli' + }) + const observedSubtypes = events.flatMap((event) => + event.type === 'message' ? [event.message.subtype] : [] + ) + + expect(acquisition.link.handle).toMatchObject({ + provider: 'claude', + sessionId: providerSessionId, + // Init/SessionStart UUIDs are protocol frames, not resumable + // main-transcript leaves; no cursor exists before the first user turn. + leafUuid: null + }) + expect(observedSubtypes).toContain('hook_started') + } finally { + await adapter.closeAll() + } + }, + 10_000 + ) + + // Unit tests can only pin the shape we read, which is exactly how the blank + // Effort pill survived every gate: the fixture invented an `effortLevel` on a + // frame the CLI does not send. This asserts both halves against the live + // binary — that get_settings reports the effort, and that init does not. + it.skipIf(!realClaudeAuthenticated)( + 'reports the current effort through get_settings and never on the init frame', + async () => { + const providerSessionId = randomUUID() + const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), '.claude') + const events: ClaudeStructuredSessionEvent[] = [] + const adapter = realAdapter(providerSessionId, claudeConfigDir, events) + + try { + await adapter.acquire({ + identity: identity(providerSessionId), + fence: 1, + spawnToken: 'real-cli-effort' + }) + const published = events.flatMap((event) => + event.type === 'message' ? [event.message] : [] + ) + const options = await adapter.readOptions({ sessionId: 'real-cli-handshake', fence: 1 }) + + expect(published.length).toBeGreaterThan(0) + // Not just the init frame: no frame the CLI publishes carries an effort + // at all. Goes red the day one does, which is when the simpler fix + // becomes available. Which frame proves the session varies by host, so + // this asserts over all of them rather than picking one. + expect(published.filter((frame) => 'effortLevel' in frame)).toEqual([]) + // Goes red if `effective.effortLevel` is renamed or dropped, which no + // fixture-backed test can see. + expect(options.current.effort).toEqual(expect.any(String)) + } finally { + await adapter.closeAll() + } + }, + 15_000 + ) + + // Mobile native chat never reads the structured journal — it reads the CLI's own + // transcript through native-chat/session-file-resolver.ts. So this resolves the way + // transcript-read-cache.ts:104 does, with NO root override, and checks the answer + // against the root the CLI itself reports. Deriving the expected root from Orca's own + // `CLAUDE_CONFIG_DIR || ~/.claude` expression — the same one the code under test uses — + // would move both sides together and stay green in exactly the environment that + // blacks mobile out. + // The turn is what creates the file: an init-only handshake writes nothing. + it.skipIf(!realClaudeAuthenticated || !realClaudeAuthStatus?.projectsDirectory)( + 'writes its transcript where the mobile session-file resolver looks for it', + async () => { + const providerSessionId = randomUUID() + const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), '.claude') + const adapter = realAdapter(providerSessionId, claudeConfigDir) + const cliProjectsDir = realClaudeAuthStatus?.projectsDirectory as string + + let transcriptPath: string | null = null + try { + await adapter.acquire({ + identity: identity(providerSessionId), + fence: 1, + spawnToken: 'real-cli-transcript' + }) + await adapter.dispatch({ + sessionId: 'real-cli-handshake', + clientMessageId: 'real-cli-transcript-1', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hi' }] }, + fence: 1 + }) + transcriptPath = await waitForResolvedTranscript(providerSessionId) + } finally { + await adapter.closeAll() + } + + expect(transcriptPath).not.toBeNull() + expect(basename(transcriptPath ?? '')).toBe(`${providerSessionId}.jsonl`) + // `//.jsonl` + expect(relative(cliProjectsDir, transcriptPath ?? '').split(/[\\/]/)).toHaveLength(2) + // And the pinned account home is that same root, so the host-side leaf recovery + // (structured-claude-runtime-adapter.ts:64) and mobile agree. + expect(join(claudeConfigDir, 'projects')).toBe(cliProjectsDir) + }, + 45_000 + ) + + // The model half of the same lesson: a fixture can only pin the shape we read. + // set_model answers success for a model it never resolves — a nonexistent id is + // accepted and only fails once a turn runs — so the CLI's own report is the only + // adoption evidence, and it arrives on the init frame that opens each turn. This + // asserts that frame carries the resolved model against the live binary; it goes + // red the day the CLI stops reporting it, which is the day the confirmation + // silently degrades to echoing back whatever Orca sent. + it.skipIf(!realClaudeAuthenticated)( + 'reports the model it adopted on the init frame that opens each turn', + async () => { + const providerSessionId = randomUUID() + const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), '.claude') + const events: ClaudeStructuredSessionEvent[] = [] + const adapter = realAdapter(providerSessionId, claudeConfigDir, events) + + try { + await adapter.acquire({ + identity: identity(providerSessionId), + fence: 1, + spawnToken: 'real-cli-model' + }) + await adapter.setOption({ + sessionId: 'real-cli-handshake', + key: 'model', + value: 'haiku', + fence: 1 + }) + const before = events.length + await adapter.dispatch({ + sessionId: 'real-cli-handshake', + clientMessageId: 'real-cli-model-1', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Say ok' }] }, + fence: 1 + }) + const deadline = Date.now() + 60_000 + let frames: Record[] = [] + for (;;) { + frames = events + .slice(before) + .flatMap((event) => + event.type === 'message' && + event.message.type === 'system' && + event.message.subtype === 'init' + ? [event.message] + : [] + ) + if (frames.length > 0 || Date.now() >= deadline) { + break + } + await new Promise((resolve) => setTimeout(resolve, 250)) + } + + expect(frames).not.toHaveLength(0) + // Both halves: the field exists, and it names the model the picker asked + // for in the catalog's resolved shape rather than the id Orca sent. + expect(frames[0]?.model).toEqual(expect.any(String)) + expect(frames[0]?.model).toBe('claude-haiku-4-5-20251001') + await expect( + adapter.readOptions({ sessionId: 'real-cli-handshake', fence: 1 }) + ).resolves.toMatchObject({ current: { model: 'haiku' } }) + } finally { + await adapter.closeAll() + } + }, + 90_000 + ) + + it('turns a real silent unauthenticated startup into sign-in guidance', async () => { + const claudeConfigDir = await mkdtemp(join(tmpdir(), 'orca-claude-no-auth-')) + const providerSessionId = randomUUID() + const adapter = realAdapter(providerSessionId, claudeConfigDir) + + try { + await expect( + adapter.acquire({ + identity: identity(providerSessionId), + fence: 1, + spawnToken: 'real-cli-no-auth' + }) + ).rejects.toThrow(/not signed in.*Claude CLI.*CLAUDE_CONFIG_DIR/s) + } finally { + await adapter.closeAll() + await rm(claudeConfigDir, { recursive: true, force: true }) + } + }, 10_000) +}) diff --git a/src/main/claude/claude-structured-session-acquisition-processless.test.ts b/src/main/claude/claude-structured-session-acquisition-processless.test.ts new file mode 100644 index 00000000000..e0996477e84 --- /dev/null +++ b/src/main/claude/claude-structured-session-acquisition-processless.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' +import { AgentSessionPreSpawnError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { + ClaudeStreamJsonConnection, + openClaudeStreamJsonConnection +} from './claude-stream-json-connection' +import { ClaudeStructuredSessionAdapter } from './claude-structured-session-adapter' + +const PROVIDER_SESSION_ID = '819cf9f8-e43c-4ad7-b50f-54aa158a726a' +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-processless', + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'claude', + providerHandle: { kind: 'opaque', agent: 'claude', value: 'pending' } +} + +describe('Claude structured processless acquisition', () => { + it('classifies pre-pid error and close as processless with idempotent cleanup', async () => { + const fault = new Error('spawn claude ENOENT') + const close = vi.fn(async () => true) + const openConnection: typeof openClaudeStreamJsonConnection = async ( + _launch, + handlers = {} + ) => { + const connection: ClaudeStreamJsonConnection = { + pid: undefined, + closed: true, + exitVerdict: { root: 'processless', tree: 'exited' }, + initializationResult: async () => { + handlers.onFault?.(fault) + throw fault + }, + getSettings: async () => ({}), + supportedModels: async () => [], + interrupt: async () => undefined, + cancelAsyncMessage: async () => {}, + setModel: async () => {}, + setPermissionMode: async () => {}, + applyFlagSettings: async () => {}, + send: async () => {}, + close + } + return connection + } + const adapter = new ClaudeStructuredSessionAdapter({ + resolveLaunch: async () => ({ + pathToClaudeCodeExecutable: 'claude', + options: {}, + cwd: '/work/repo', + claudeConfigDir: '/accounts/claude', + providerSessionId: PROVIDER_SESSION_ID, + resumeLeafUuid: null, + resumed: false + }), + openConnection + }) + + const error = await adapter + .acquire({ identity: IDENTITY, fence: 7, spawnToken: 'spawn-9' }) + .catch((cause: unknown) => cause) + + expect(error).toBeInstanceOf(AgentSessionPreSpawnError) + expect(error).toMatchObject({ message: fault.message }) + expect(close).toHaveBeenCalledOnce() + await expect(adapter.releaseAcquisition({ sessionId: IDENTITY.sessionId })).resolves.toBe(true) + expect(close).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/claude/claude-structured-session-acquisition.ts b/src/main/claude/claude-structured-session-acquisition.ts new file mode 100644 index 00000000000..e8d09bd78d9 --- /dev/null +++ b/src/main/claude/claude-structured-session-acquisition.ts @@ -0,0 +1,298 @@ +import { + AgentSessionAcquisitionExitUnprovenError, + AgentSessionPreSpawnError +} from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { + AgentSessionAcquisition, + StructuredAgentSessionAcquireInput +} from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE } from '../claude-accounts/environment' +import { isClaudeAuthSwitchInProgress } from '../claude-accounts/live-pty-gate' +import { openClaudeStreamJsonConnection } from './claude-stream-json-connection' +import { buildClaudePermissionCallbacks } from './claude-structured-inbound-control' +import { resolveClaudeReplayWaiter } from './claude-structured-dispatch' +import { + claudeAuthDiagnostic, + readClaudeCapabilities, + readClaudeFrameString, + readClaudeInit, + readClaudeModels +} from './claude-structured-init-proof' +import { + createClaudeInitDeadline, + requestClaudeInitialization +} from './claude-structured-init-deadline' +import { claudeConfigDirEnvPatch } from './claude-config-dir-pin' +import { CLAUDE_SPAWN_TOKEN_ENV, claudeProcessIdentity } from './claude-structured-owner-identity' +import { + restoreClaudeStructuredSessionOptions, + restoredClaudeStructuredSessionOptions +} from './claude-structured-options' +import { ClaudePromptRegistry } from './claude-structured-prompt-replies' +import { createClaudeSessionJournalTranslator } from './claude-structured-journal-translation' +import { readClaudeSettingsEffort } from './claude-structured-session-options' +import { createClaudeSessionPublication } from './claude-structured-session-publication' +import { + cancelClaudeAcquisitionAttempt, + mintClaudeAcquisitionGeneration, + type ClaudeAcquisitionRegistry, + type ClaudeSession, + type ClaudeSessionExit, + type ClaudeStructuredSessionAdapterDeps, + type ClaudeAcquireCallbacks +} from './claude-structured-session-state' +import { + closeClaudePublishedSessionForDeps, + claudeAcquisitionCleanupError +} from './claude-structured-session-close' +import { readClaudeTranscriptEntryUuid } from './claude-tui-exit' + +export const CLAUDE_STRUCTURED_INIT_TIMEOUT_MS = 10_000 + +export async function acquireClaudeSession({ + input, + deps, + sessions, + acquisitions, + exits, + callbacks +}: { + input: StructuredAgentSessionAcquireInput + deps: ClaudeStructuredSessionAdapterDeps + sessions: Map + acquisitions: ClaudeAcquisitionRegistry + exits: Map + callbacks: ClaudeAcquireCallbacks +}): Promise { + // A managed-account switch is mid-swap of the pinned credential home; refuse here, + // before this acquisition cancels the previous attempt and closes the live session. + if (isClaudeAuthSwitchInProgress()) { + throw new AgentSessionPreSpawnError(new Error(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE)) + } + const sessionId = input.identity.sessionId + const prompts = new ClaudePromptRegistry() + const translator = createClaudeSessionJournalTranslator( + input.events, + prompts, + String(input.fence) + ) + const { previous, attempt } = acquisitions.start(sessionId, prompts) + let liveSession: ClaudeSession | null = null + let observedLeafUuid: string | null = null, + expectedProviderSessionId: string | null = null + // Frames are admitted only after launch resolution proves the provider session + // this acquisition owns. Keep the check ahead of every stateful consumer. + const initTimeoutMs = deps.initTimeoutMs ?? CLAUDE_STRUCTURED_INIT_TIMEOUT_MS + const initDeadline = createClaudeInitDeadline(sessionId, initTimeoutMs) + + const onMessage = (message: Record): void => { + const init = readClaudeInit(message) + if (readClaudeFrameString(message, 'session_id') !== expectedProviderSessionId) { + // An init proof for another (or unnamed) provider must fail acquisition + // promptly, while ordinary foreign frames stay quarantined silently. + if (init || (message.type === 'system' && message.subtype === 'init')) { + initDeadline.reject(new Error('claude provider session expected')) + } + return + } + if (init) { + initDeadline.resolve(init) + // Every turn opens with an init frame naming the model the CLI is actually + // running; set_model answers success for a model it never resolves, so this + // report is the session's only adoption evidence. + if (liveSession && init.model) { + liveSession.reportedOptions.model = init.model + liveSession.reportedModelMutation = liveSession.optionMutationSequence + } + } + observedLeafUuid = readClaudeTranscriptEntryUuid(message) ?? observedLeafUuid + if (liveSession) { + liveSession.leafUuid = observedLeafUuid + } + const startsTurn = liveSession ? resolveClaudeReplayWaiter(liveSession, message) : false + callbacks.deliver(attempt, sessionId, () => + callbacks.emit(liveSession, input.events, { + type: 'message', + sessionId, + message, + ...(startsTurn ? { startsTurn: true } : {}) + }) + ) + } + const { canUseTool, onUserDialog } = buildClaudePermissionCallbacks({ + sessionId, + prompts, + emit: (event) => + callbacks.deliver(attempt, sessionId, () => callbacks.emit(liveSession, input.events, event)) + }) + + try { + if (previous && !(await cancelClaudeAcquisitionAttempt(previous))) { + acquisitions.restoreIfCurrent(sessionId, attempt, previous) + throw new AgentSessionAcquisitionExitUnprovenError( + new Error(`claude acquisition for session ${sessionId} could not be stopped`) + ) + } + acquisitions.assertCurrent(sessionId, attempt) + let resumeSession = sessions.get(sessionId) + if (!(await closeClaudePublishedSessionForDeps(sessions, sessionId, deps))) { + throw new AgentSessionAcquisitionExitUnprovenError( + new Error(`claude session ${sessionId} could not be stopped`) + ) + } + // A first-hand exit that has not yet proved its full tree still owns a cleanup + // obligation; never let a new acquisition hide that evidence by omission. + const retainedExit = exits.get(sessionId) + if (retainedExit) { + const firstProof = retainedExit.closePromise ? await retainedExit.closePromise : false + const proven = firstProof || (await retainedExit.connection.close().catch(() => false)) + if (!proven) { + throw claudeAcquisitionCleanupError(retainedExit.connection, retainedExit.error) + } + // The old child is superseded by this acquisition. Settle its lifecycle + // before discarding the retained proof so its cursor and callbacks are + // cleaned up exactly once. + await callbacks.settleExit(sessionId, retainedExit) + resumeSession ??= retainedExit.session + } + acquisitions.assertCurrent(sessionId, attempt) + // Both close paths persist their final leaf, so launch validates that durable head. + const launchIdentity = resumeSession + ? { + ...input.identity, + providerHandle: { + kind: 'claude' as const, + sessionId: resumeSession.providerSessionId, + leafUuid: resumeSession.leafUuid + } + } + : input.identity + const launch = await deps + .resolveLaunch({ identity: launchIdentity }) + .catch((error: unknown) => { + throw error instanceof AgentSessionPreSpawnError + ? error + : new AgentSessionPreSpawnError(error) + }) + expectedProviderSessionId = launch.providerSessionId + observedLeafUuid = launch.resumeLeafUuid + acquisitions.assertCurrent(sessionId, attempt) + const open = deps.openConnection ?? openClaudeStreamJsonConnection + const connection = await open( + { + pathToClaudeCodeExecutable: launch.pathToClaudeCodeExecutable, + options: launch.options, + cwd: launch.cwd, + env: { + ...launch.env, + [CLAUDE_SPAWN_TOKEN_ENV]: input.spawnToken, + // Compared against what the child would otherwise inherit, so the record's + // account home still wins over a diverging overlay without a needless pin. + // (`process` is shadowed by a local later in this function, so it is not named here.) + ...claudeConfigDirEnvPatch(launch.claudeConfigDir, launch.env ? { env: launch.env } : {}) + } + }, + { + onMessage, + canUseTool, + onUserDialog, + onFault: (error) => { + if (!attempt.published) { + initDeadline.reject(error) + } + }, + onExit: (error) => { + if (!attempt.published) { + initDeadline.reject(error) + } + callbacks.handleExit(sessionId, attempt, error) + } + } + ) + attempt.connection = connection + acquisitions.assertCurrent(sessionId, attempt) + initDeadline.start() + const [initialization, init] = await Promise.all([ + requestClaudeInitialization(connection, sessionId, initTimeoutMs), + initDeadline.promise + ]) + const models = readClaudeModels(initialization) + callbacks.deliver(attempt, sessionId, () => + callbacks.emit(liveSession, input.events, { type: 'options', sessionId, models }) + ) + initDeadline.clear() + acquisitions.assertCurrent(sessionId, attempt) + if (init.providerSessionId !== launch.providerSessionId) { + throw new Error( + `claude proved session ${init.providerSessionId}, expected ${launch.providerSessionId}` + ) + } + const settings = await connection + .getSettings({ timeoutMs: deps.requestTimeoutMs }) + .catch(() => null) + callbacks.deliver(attempt, sessionId, () => + callbacks.emit(liveSession, input.events, { + type: 'auth-diagnostic', + sessionId, + diagnostic: claudeAuthDiagnostic(init, settings) + }) + ) + const process = await claudeProcessIdentity( + { ...input, pid: connection.pid }, + deps.readProcessStartTime + ) + acquisitions.assertCurrent(sessionId, attempt) + if (connection.closed) { + throw new Error(`claude stream-json for session ${sessionId} exited while being acquired`) + } + const publication = createClaudeSessionPublication({ + connection, + init, + claudeConfigDir: launch.claudeConfigDir, + leafUuid: observedLeafUuid, + fence: input.fence, + effort: readClaudeSettingsEffort(settings), + resumed: launch.resumed, + prompts, + translator, + events: input.events, + process, + acquisitionGeneration: mintClaudeAcquisitionGeneration(deps), + options: restoredClaudeStructuredSessionOptions(input.options), + capabilities: readClaudeCapabilities(init, initialization), + ...(deps.mintLinkId ? { linkId: deps.mintLinkId() } : {}), + observedAt: deps.now?.() ?? Date.now() + }) + const acquired: AgentSessionAcquisition = publication.acquisition + liveSession = publication.session + await restoreClaudeStructuredSessionOptions(liveSession, deps.requestTimeoutMs) + acquisitions.assertCurrent(sessionId, attempt) + acquisitions.deleteIfCurrent(sessionId, attempt) + sessions.set(sessionId, liveSession) + attempt.published = true + for (const event of attempt.buffered.splice(0)) { + event() + } + return acquired + } catch (error) { + initDeadline.clear() + let acquisitionError = error + if (sessions.get(sessionId)?.connection !== attempt.connection) { + translator?.dispose() + // Settle any callback that fired before the failure so no SDK promise dangles. + for (const prompt of prompts.clear()) { + prompt.settle(null) + } + const closed = (await attempt.connection?.close()) ?? true + if (attempt.connection?.exitVerdict.root === 'processless') { + acquisitionError = new AgentSessionPreSpawnError(error) + } else if (!closed) { + acquisitionError = claudeAcquisitionCleanupError(attempt.connection, error) + } + } + acquisitions.deleteIfCurrent(sessionId, attempt) + throw acquisitionError + } finally { + attempt.finish() + } +} diff --git a/src/main/claude/claude-structured-session-adapter.test.ts b/src/main/claude/claude-structured-session-adapter.test.ts new file mode 100644 index 00000000000..859ba9a8ed8 --- /dev/null +++ b/src/main/claude/claude-structured-session-adapter.test.ts @@ -0,0 +1,891 @@ +import { homedir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { + AgentSessionAcquisitionExitUnprovenError, + AgentSessionAcquisitionRefusal, + AgentSessionAcquisitionRootExitObservedError +} from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { ClaudeStreamJsonConnection } from './claude-stream-json-connection' +import { ClaudeControlRequestError } from './claude-stream-json-connection' +import { CLAUDE_SPAWN_TOKEN_ENV } from './claude-structured-owner-identity' +import { encodeClaudeQuestionOptionId } from './claude-structured-prompt-replies' +import { + CLAUDE_STRUCTURED_INIT_TIMEOUT_MS, + type ClaudeStructuredSessionAdapter, + type ClaudeStructuredSessionEvent +} from './claude-structured-session-adapter' +import { + acquired, + adapterFor, + fakeClaude, + identityFor, + invokeCanUseTool, + PROVIDER_SESSION_ID, + tick, + USER_MESSAGE, + type FakeConnection +} from './claude-structured-session-test-support' + +describe('ClaudeStructuredSessionAdapter.acquire', () => { + it('finishes its startup deadline before the paired mobile request deadline', () => { + expect(CLAUDE_STRUCTURED_INIT_TIMEOUT_MS).toBeLessThan(30_000) + }) + + it('pins the account and proves init without treating the system-frame uuid as a chain leaf', async () => { + const claude = fakeClaude() + const events: ClaudeStructuredSessionEvent[] = [] + const adapter = adapterFor(claude, {}, events) + + const acquisition = await adapter.acquire({ + identity: identityFor(), + fence: 7, + spawnToken: 'spawn-9' + }) + + expect(claude.connections[0].launch).toMatchObject({ + cwd: '/work/repo', + env: { + [CLAUDE_SPAWN_TOKEN_ENV]: 'spawn-9', + CLAUDE_CONFIG_DIR: '/accounts/claude' + } + }) + // supportedDialogKinds is now a query() launch option, not an initialize request param. + expect(claude.connections[0].calls.slice(0, 2)).toEqual([ + { subtype: 'initialize' }, + { subtype: 'get_settings' } + ]) + expect(acquisition.process).toEqual({ + hostId: 'host-1', + pid: 4321, + processStartTimeMs: 1_700_000_000_000, + spawnToken: 'spawn-9' + }) + expect(acquisition.link).toEqual({ + linkId: `claude-7-${PROVIDER_SESSION_ID}-empty`, + handle: { provider: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null }, + origin: 'created', + mintedAtFence: 7, + observedAt: 1_700_000_000_500 + }) + expect(events[0]).toMatchObject({ type: 'message', message: { subtype: 'init' } }) + }) + + it('restores persisted model and effort before publishing a reacquired session', async () => { + const claude = fakeClaude() + const adapter = adapterFor(claude, { resumed: true }) + + await adapter.acquire({ + identity: identityFor(), + fence: 7, + spawnToken: 'spawn-9', + options: { model: 'opus', effort: 'high' } + }) + + expect(claude.connections[0].calls.slice(-4)).toEqual([ + { subtype: 'set_model', params: { model: 'opus' } }, + // The restored model's advertised levels gate the replay, so a stale effort + // is dropped rather than re-applied to a model with no effort control. + { subtype: 'list_models' }, + { subtype: 'apply_flag_settings', params: { settings: { effortLevel: 'high' } } }, + // The effort is only recorded once the child reports having adopted it. + { subtype: 'get_settings' } + ]) + await expect(adapter.readOptions({ sessionId: 'session-1', fence: 7 })).resolves.toMatchObject({ + current: { model: 'opus', effort: 'high' } + }) + }) + + it.each([ + ['model', 'set_model', { model: 'retired-model' }], + ['effort', 'apply_flag_settings', { effort: 'retired-effort' }], + ['permissionMode', 'set_permission_mode', { permissionMode: 'retired-mode' }] + ] as const)( + 'self-heals a persisted %s rejected during restore', + async (key, subtype, options) => { + const claude = fakeClaude({ + routes: { + [subtype]: () => { + throw new ClaudeControlRequestError(subtype, 'value is no longer available') + } + } + }) + const adapter = adapterFor(claude) + + await expect( + adapter.acquire({ + identity: identityFor(), + fence: 7, + spawnToken: 'spawn-9', + options + }) + ).resolves.toBeDefined() + expect(adapter.readOptionRestoreFailures('session-1')).toEqual([key]) + } + ) + + it('does not treat a transport timeout while restoring an option as recoverable', async () => { + const claude = fakeClaude({ + routes: { + set_model: () => { + throw new Error('claude set_model request timed out') + } + } + }) + const adapter = adapterFor(claude) + const input = { + identity: identityFor(), + fence: 7, + spawnToken: 'spawn-9', + options: { model: 'temporarily-unavailable' } + } + + await expect(adapter.acquire(input)).rejects.toThrow('claude set_model request timed out') + expect(claude.connections[0]?.closeCount).toBe(1) + }) + + it('recovers a cancellable lifecycle when a timed-out replay arrives late', async () => { + const claude = fakeClaude({ replayUuid: null }) + const events: ClaudeStructuredSessionEvent[] = [] + const adapter = await acquired(claude, {}, events) + + await expect( + adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'client-1', + body: USER_MESSAGE, + fence: 7 + }) + ).resolves.toMatchObject({ state: 'unknown' }) + const sent = claude.connections[0]!.sent[0]! + claude.connections[0]!.handlers.onMessage?.({ + ...sent, + uuid: 'late-turn-1' + }) + + expect(events).toContainEqual( + expect.objectContaining({ + type: 'message', + startsTurn: true, + message: expect.objectContaining({ uuid: 'late-turn-1' }) + }) + ) + await expect( + adapter.cancelTurn({ sessionId: 'session-1', turnId: 'late-turn-1', fence: 7 }) + ).resolves.toEqual({ cancelled: true }) + }) + + it('quarantines SDK frames without the acquired session identity', async () => { + const claude = fakeClaude({ replayUuid: null }) + const events: ClaudeStructuredSessionEvent[] = [] + const adapter = await acquired(claude, {}, events) + const connection = claude.connections[0]! + + connection.handlers.onMessage?.({ + type: 'assistant', + uuid: 'foreign-leaf', + session_id: 'foreign-provider-session', + message: { role: 'assistant', content: [{ type: 'text', text: 'do not admit' }] } + }) + connection.handlers.onMessage?.({ + type: 'assistant', + uuid: 'missing-session-leaf', + message: { role: 'assistant', content: [{ type: 'text', text: 'do not admit' }] } + }) + + const dispatch = adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'client-1', + body: USER_MESSAGE, + fence: 7 + }) + await Promise.resolve() + expect(connection.sent).toHaveLength(1) + connection.handlers.onMessage?.({ + ...connection.sent[0], + uuid: 'foreign-replay', + session_id: 'foreign-provider-session' + }) + await Promise.resolve() + expect(events.filter((event) => event.type === 'message')).toHaveLength(1) + + connection.handlers.onMessage?.({ + ...connection.sent[0], + session_id: PROVIDER_SESSION_ID + }) + await expect(dispatch).resolves.toMatchObject({ + state: 'accepted', + providerIdentity: { uuid: connection.sent[0]!.uuid } + }) + }) + + it('forwards configured launch environment while keeping ownership pins authoritative', async () => { + const claude = fakeClaude() + const adapter = adapterFor(claude, { + env: { + ANTHROPIC_AUTH_TOKEN: 'configured-token', + ANTHROPIC_BASE_URL: 'https://gateway.example.test', + CLAUDE_CONFIG_DIR: '/wrong/account', + [CLAUDE_SPAWN_TOKEN_ENV]: 'wrong-token' + } + }) + + await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + + expect(claude.connections[0].launch.env).toEqual({ + ANTHROPIC_AUTH_TOKEN: 'configured-token', + ANTHROPIC_BASE_URL: 'https://gateway.example.test', + CLAUDE_CONFIG_DIR: '/accounts/claude', + [CLAUDE_SPAWN_TOKEN_ENV]: 'spawn-9' + }) + }) + + it('leaves CLAUDE_CONFIG_DIR unset when the account home is the CLI default', async () => { + const claude = fakeClaude() + const adapter = adapterFor(claude, { claudeConfigDir: join(homedir(), '.claude'), env: {} }) + + await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + + // Pinning the CLI's own default suppresses the macOS Keychain and breaks claude.ai login. + expect(claude.connections[0].launch.env).toEqual({ [CLAUDE_SPAWN_TOKEN_ENV]: 'spawn-9' }) + }) + + it('re-pins the account home when the launch env would send the child elsewhere', async () => { + const claude = fakeClaude() + const accountHome = join(homedir(), '.claude') + const adapter = adapterFor(claude, { + claudeConfigDir: accountHome, + env: { CLAUDE_CONFIG_DIR: '/other/account' } + }) + + await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + + expect(claude.connections[0].launch.env).toEqual({ + CLAUDE_CONFIG_DIR: accountHome, + [CLAUDE_SPAWN_TOKEN_ENV]: 'spawn-9' + }) + }) + + it('accepts SessionStart as pre-turn proof without treating its system uuid as a leaf', async () => { + const claude = fakeClaude({ initProof: 'session-start', initUuid: 'session-start-uuid' }) + const events: ClaudeStructuredSessionEvent[] = [] + const adapter = adapterFor(claude, {}, events) + + const acquisition = await adapter.acquire({ + identity: identityFor(), + fence: 7, + spawnToken: 'spawn-9' + }) + + expect(acquisition.link.handle).toEqual({ + provider: 'claude', + sessionId: PROVIDER_SESSION_ID, + leafUuid: null + }) + expect(events[0]).toMatchObject({ + type: 'message', + message: { subtype: 'hook_started', hook_name: 'SessionStart:startup' } + }) + }) + + it('records only non-secret effective auth-lane diagnostics', async () => { + const claude = fakeClaude({ + settings: { + env: { + ANTHROPIC_BASE_URL: 'https://gateway.example.test', + ANTHROPIC_AUTH_TOKEN: 'secret' + } + } + }) + const events: ClaudeStructuredSessionEvent[] = [] + await acquired(claude, {}, events) + + const diagnostic = events.find((event) => event.type === 'auth-diagnostic') + expect(diagnostic).toEqual({ + type: 'auth-diagnostic', + sessionId: 'session-1', + diagnostic: { + apiKeySourceConfigured: false, + baseUrlConfigured: true, + authTokenConfigured: true, + apiKeyConfigured: false, + settingSources: ['user', 'project', 'local'] + } + }) + expect(JSON.stringify(diagnostic)).not.toContain('secret') + expect(JSON.stringify(diagnostic)).not.toContain('gateway.example.test') + }) + + it('resumes the same provider id and refuses an init proof for another session', async () => { + const resumedClaude = fakeClaude() + const resumed = adapterFor(resumedClaude, { + resumed: true, + resumeLeafUuid: 'leaf-before' + }) + const acquisition = await resumed.acquire({ + identity: identityFor(), + fence: 9, + spawnToken: 'spawn-9' + }) + expect(acquisition.link.origin).toBe('resumed') + expect(acquisition.link.handle).toEqual({ + provider: 'claude', + sessionId: PROVIDER_SESSION_ID, + leafUuid: 'leaf-before' + }) + + const wrongClaude = fakeClaude({ initSessionId: 'different-session' }) + const wrong = adapterFor(wrongClaude) + await expect( + wrong.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + ).rejects.toThrow(/expected/) + expect(wrongClaude.connections[0].closeCount).toBe(1) + }) + + it('surfaces a CLI startup failure instead of waiting for the init deadline', async () => { + const claude = fakeClaude({ exitBeforeInit: 'Claude login required' }) + const adapter = adapterFor(claude) + + await expect( + adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + ).rejects.toThrow('Claude login required') + expect(claude.connections[0].closeCount).toBe(1) + }) + + it('closes a silent unauthenticated startup with actionable account guidance', async () => { + const claude = fakeClaude({ initProof: 'none' }) + const adapter = adapterFor(claude, {}, [], [], 20) + + const error = await adapter + .acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + .catch((cause: unknown) => cause) + + expect(error).toBeInstanceOf(AgentSessionAcquisitionRefusal) + expect(error).toMatchObject({ + message: expect.stringMatching(/selected Claude account is signed in.*CLAUDE_CONFIG_DIR/s) + }) + expect(claude.connections[0].calls[0]).toEqual({ subtype: 'initialize' }) + expect(claude.connections[0].closeCount).toBe(1) + }) + + it('refuses an unauthenticated initialize response even when SessionStart runs', async () => { + const claude = fakeClaude({ + initProof: 'session-start', + initAccount: { apiProvider: 'firstParty', tokenSource: 'none' } + }) + const adapter = adapterFor(claude) + + await expect( + adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + ).rejects.toThrow(/not signed in.*Claude CLI.*CLAUDE_CONFIG_DIR/s) + expect(claude.connections[0].closeCount).toBe(1) + }) +}) + +describe('ClaudeStructuredSessionAdapter turns and controls', () => { + it('accepts a dispatch only after Claude replays its provider uuid', async () => { + const claude = fakeClaude({ replayUuid: 'user-provider-uuid' }) + const adapter = await acquired(claude) + + const result = await adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'client-1', + body: USER_MESSAGE, + fence: 7 + }) + + expect(result).toEqual({ + state: 'accepted', + providerIdentity: { + provider: 'claude', + sessionId: PROVIDER_SESSION_ID, + uuid: 'user-provider-uuid' + } + }) + expect(claude.connections[0].sent[0]).toMatchObject({ + type: 'user', + message: { role: 'user', content: [{ type: 'text', text: 'ship it' }] }, + session_id: PROVIDER_SESSION_ID + }) + }) + + it('leaves delivery unconfirmed when no replay uuid arrives', async () => { + const adapter = await acquired(fakeClaude({ replayUuid: null })) + await expect( + adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'client-1', + body: USER_MESSAGE, + fence: 7 + }) + ).resolves.toMatchObject({ state: 'unknown' }) + }) + + it('requires an acknowledged interrupt and supports controlled options', async () => { + const claude = fakeClaude() + const adapter = await acquired(claude) + await expect( + adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-1', fence: 7 }) + ).resolves.toEqual({ cancelled: true }) + await expect( + adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'sonnet', fence: 7 }) + ).resolves.toEqual({ model: 'sonnet' }) + expect(claude.connections[0].calls.slice(-2)).toEqual([ + { subtype: 'interrupt', params: {} }, + { subtype: 'set_model', params: { model: 'sonnet' } } + ]) + + claude.routes.interrupt = () => { + throw new ClaudeControlRequestError('interrupt', 'not running') + } + await expect( + adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-2', fence: 7 }) + ).resolves.toEqual({ cancelled: false }) + + claude.routes.interrupt = () => { + throw new Error('claude interrupt request timed out') + } + await expect( + adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-3', fence: 7 }) + ).rejects.toThrow('timed out') + }) + + it('does not let a delayed cancellation for an earlier turn interrupt the later turn', async () => { + const claude = fakeClaude({ replayUuids: ['turn-T', 'turn-U'] }) + const adapter = await acquired(claude) + + await adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'client-T', + body: USER_MESSAGE, + fence: 7 + }) + await adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'client-U', + body: USER_MESSAGE, + fence: 7 + }) + + await expect( + adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-T', fence: 7 }) + ).resolves.toEqual({ cancelled: false }) + expect(claude.connections[0].calls.filter((call) => call.subtype === 'interrupt')).toHaveLength( + 0 + ) + + await expect( + adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-U', fence: 6 }) + ).resolves.toEqual({ cancelled: false }) + + await expect( + adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-U', fence: 7 }) + ).resolves.toEqual({ cancelled: true }) + expect(claude.connections[0].calls.filter((call) => call.subtype === 'interrupt')).toHaveLength( + 1 + ) + }) + + it('does not cancel an acknowledged turn after a later dispatch returns unknown', async () => { + const claude = fakeClaude({ replayUuids: ['turn-T', null] }) + const adapter = await acquired(claude) + + await expect( + adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'client-T', + body: USER_MESSAGE, + fence: 7 + }) + ).resolves.toMatchObject({ + state: 'accepted', + providerIdentity: { uuid: 'turn-T' } + }) + await expect( + adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'client-U', + body: USER_MESSAGE, + fence: 7 + }) + ).resolves.toMatchObject({ state: 'unknown' }) + expect(claude.connections[0].sent).toHaveLength(2) + + await expect( + adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-T', fence: 7 }) + ).resolves.toEqual({ cancelled: false }) + expect(claude.connections[0].calls.filter((call) => call.subtype === 'interrupt')).toHaveLength( + 0 + ) + }) + + it('classifies provider-declined options without treating timeouts as settled', async () => { + const claude = fakeClaude({ + routes: { + set_model: () => { + throw new ClaudeControlRequestError('set_model', 'model unavailable') + } + } + }) + const adapter = await acquired(claude) + + await expect( + adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'fable', fence: 7 }) + ).rejects.toMatchObject({ name: 'AgentSessionOptionRejectedError' }) + claude.routes.set_model = () => { + throw new Error('claude set_model request timed out') + } + await expect( + adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'opus', fence: 7 }) + ).rejects.toThrow('timed out') + }) + + it('hydrates live model choices and maps the resolved current model to its CLI id', async () => { + const claude = fakeClaude({ + initModel: 'claude-sonnet-5', + routes: { + list_models: () => [ + { value: 'default', resolvedModel: 'claude-opus-5', displayName: 'Default' }, + { + value: 'opus', + resolvedModel: 'claude-opus-5', + displayName: 'Opus', + supportsEffort: true, + supportedEffortLevels: ['low', 'high'] + }, + { + value: 'sonnet', + resolvedModel: 'claude-sonnet-5', + displayName: 'Sonnet' + } + ] + } + }) + const adapter = await acquired(claude) + + await expect(adapter.readOptions({ sessionId: 'session-1', fence: 7 })).resolves.toEqual({ + models: [ + { + id: 'opus', + label: 'Opus', + isDefault: true, + efforts: [ + { value: 'low', label: 'Low' }, + { value: 'high', label: 'High' } + ] + }, + { id: 'sonnet', label: 'Sonnet', isDefault: false, efforts: [] } + ], + current: { model: 'sonnet', effort: 'high', confirmed: ['model', 'effort'] } + }) + }) + + it('keeps the shared Claude seed when live model discovery is unavailable', async () => { + const claude = fakeClaude({ + initModel: 'custom-model', + routes: { + list_models: () => { + throw new Error('unsupported') + } + } + }) + const adapter = await acquired(claude) + const result = await adapter.readOptions({ sessionId: 'session-1', fence: 7 }) + + expect(result.models.map((model) => model.id)).toEqual([ + 'fable', + 'opus', + 'sonnet', + 'haiku', + 'custom-model' + ]) + expect(result.current).toEqual({ + model: 'custom-model', + effort: 'high', + confirmed: ['model', 'effort'] + }) + }) +}) + +describe('ClaudeStructuredSessionAdapter acquisition cleanup', () => { + /** A start that fails after the child self-exited, with its close verdict scripted. */ + function failedStart( + unprovenCloseVerdict: ClaudeStreamJsonConnection['exitVerdict'] + ): Promise { + const claude = fakeClaude({ + exitBeforeInit: 'claude stream-json exited (code 1): not logged in', + unprovenCloseVerdict + }) + return adapterFor(claude) + .acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + .catch((error: unknown) => error) + } + + it('releases on a first-hand root exit while still carrying the CLI diagnostic', async () => { + // The root's pid and start time are the lease's identity, and they are + // provably dead: latching the session would strand a signed-out user. + const error = await failedStart({ root: 'exited', tree: 'unverifiable' }) + + expect(error).toBeInstanceOf(AgentSessionAcquisitionRootExitObservedError) + expect((error as Error).message).toBe('claude stream-json exited (code 1): not logged in') + }) + + it('never releases while a descendant was observed alive', async () => { + const error = await failedStart({ root: 'exited', tree: 'live' }) + + expect(error).toBeInstanceOf(AgentSessionAcquisitionExitUnprovenError) + expect(error).not.toBeInstanceOf(AgentSessionAcquisitionRootExitObservedError) + }) + + it('never releases for a root Orca never saw leave', async () => { + const error = await failedStart({ root: 'live', tree: 'unverifiable' }) + + expect(error).toBeInstanceOf(AgentSessionAcquisitionExitUnprovenError) + expect(error).not.toBeInstanceOf(AgentSessionAcquisitionRootExitObservedError) + }) + + /** A published session whose CLI then exits first-hand, with the verdict its ladder holds. */ + async function exitedAfterPublish( + exitVerdict: ClaudeStreamJsonConnection['exitVerdict'] + ): Promise<{ adapter: ClaudeStructuredSessionAdapter; connection: FakeConnection }> { + const claude = fakeClaude({ unprovenCloseVerdict: exitVerdict }) + const adapter = await acquired(claude) + const connection = claude.connections[0] + connection.handlers.onExit?.(new Error('claude stream-json exited (code 1): crashed')) + return { adapter, connection } + } + + it('classifies cleanup after a first-hand exit removed the session as a root exit, never as proven', async () => { + // The host may still be committing or proving the lease when the child dies; + // its cleanup must find the exit the ladder observed, not an absence. + const { adapter, connection } = await exitedAfterPublish({ + root: 'exited', + tree: 'unverifiable' + }) + const error = await adapter.releaseAcquisition({ sessionId: 'session-1' }).catch((e) => e) + + expect(error).toBeInstanceOf(AgentSessionAcquisitionRootExitObservedError) + expect((error as Error).message).toBe('claude stream-json exited (code 1): crashed') + expect(connection.closeCount).toBe(2) + }) + + it('never releases after an exit that left a descendant observed alive', async () => { + const { adapter } = await exitedAfterPublish({ root: 'exited', tree: 'live' }) + const error = await adapter.releaseAcquisition({ sessionId: 'session-1' }).catch((e) => e) + + expect(error).toBeInstanceOf(AgentSessionAcquisitionExitUnprovenError) + expect(error).not.toBeInstanceOf(AgentSessionAcquisitionRootExitObservedError) + }) + + it('forgets a retained exit once the session is acquired again', async () => { + const options: Parameters[0] = {} + const claude = fakeClaude(options) + const adapter = await acquired(claude) + const first = claude.connections[0] + first.handlers.onExit?.(new Error('claude stream-json exited (code 1): crashed')) + first.exitVerdict = { root: 'exited', tree: 'unverifiable' } + first.close = async () => false + options.exitBeforeInit = 'claude stream-json exited (code 1): not logged in' + + await expect( + adapter.acquire({ identity: identityFor(), fence: 8, spawnToken: 'spawn-10' }) + ).rejects.toThrow('not logged in') + // The second start's own proven close is the answer; the first exit is stale. + await expect(adapter.releaseAcquisition({ sessionId: 'session-1' })).resolves.toBe(true) + expect(first.closeCount).toBe(1) + }) + + it('reports unproven published-session cleanup so callers can retry safely', async () => { + const claude = fakeClaude() + const adapter = await acquired(claude) + const connection = claude.connections[0] + connection.close = vi + .fn<() => Promise>() + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true) as unknown as FakeConnection['close'] + + await expect(adapter.releaseAcquisition({ sessionId: 'session-1' })).resolves.toBe(false) + expect(await adapter.readOptions({ sessionId: 'session-1', fence: 7 })).toMatchObject({ + current: { model: 'claude-sonnet-5' } + }) + await expect(adapter.releaseAcquisition({ sessionId: 'session-1' })).resolves.toBe(true) + expect(() => adapter.readOptions({ sessionId: 'session-1', fence: 7 })).toThrow( + 'no live claude stream-json session' + ) + }) + + it('does not report a second release as successful while retained exit evidence is unproven', async () => { + const claude = fakeClaude({ unprovenCloseVerdict: { root: 'exited', tree: 'unverifiable' } }) + const adapter = await acquired(claude) + const connection = claude.connections[0] + connection.handlers.onExit?.(new Error('claude stream-json exited (code 1): crashed')) + connection.close = vi.fn().mockResolvedValue(false) as unknown as FakeConnection['close'] + + await expect(adapter.releaseAcquisition({ sessionId: 'session-1' })).rejects.toBeInstanceOf( + AgentSessionAcquisitionRootExitObservedError + ) + await expect(adapter.releaseAcquisition({ sessionId: 'session-1' })).rejects.toBeInstanceOf( + AgentSessionAcquisitionRootExitObservedError + ) + expect(connection.close).toHaveBeenCalledTimes(2) + }) + + it('keeps shutdown pending until a retained unexpected-exit proof settles', async () => { + const claude = fakeClaude() + const adapter = await acquired(claude) + const connection = claude.connections[0] + const proof = Promise.withResolvers() + connection.close = vi + .fn<() => Promise>() + .mockImplementationOnce(() => proof.promise) + .mockResolvedValueOnce(true) as unknown as FakeConnection['close'] + + connection.handlers.onExit?.(new Error('crashed')) + await tick() + let settled = false + const closing = adapter.closeAll().then(() => { + settled = true + }) + await tick() + expect(settled).toBe(false) + + proof.resolve(false) + await expect(closing).resolves.toBeUndefined() + expect(connection.close).toHaveBeenCalledTimes(2) + }) + + it('does not claim shutdown success for a retained false exit proof', async () => { + const claude = fakeClaude() + const events: ClaudeStructuredSessionEvent[] = [] + const adapter = await acquired(claude, {}, events) + const connection = claude.connections[0] + connection.close = vi + .fn<() => Promise>() + .mockResolvedValue(false) as unknown as FakeConnection['close'] + + connection.handlers.onExit?.(new Error('crashed')) + await tick() + + await expect(adapter.closeAll()).rejects.toThrow( + 'claude structured session shutdown could not prove every child stopped' + ) + expect(events.filter((event) => event.type === 'ended')).toEqual([]) + expect(connection.close).toHaveBeenCalledTimes(4) + }) +}) + +describe('ClaudeStructuredSessionAdapter prompts', () => { + it('turns can_use_tool into an addressable durable approval that settles the SDK callback', async () => { + const claude = fakeClaude() + const events: ClaudeStructuredSessionEvent[] = [] + const adapter = await acquired(claude, {}, events) + const answered = invokeCanUseTool(claude.connections[0], 'Bash', 'permission-1', 'tool-1', { + input: { command: 'git status' }, + suggestions: [{ type: 'addRules' }] + }) + expect(events.at(-1)).toMatchObject({ + type: 'prompt', + prompt: { kind: 'approval', toolName: 'Bash', promptKey: 'permission-1' } + }) + + adapter.bindPromptItemId('session-1', 'journal-approval', 'permission-1') + await adapter.answerPrompt({ + sessionId: 'session-1', + itemId: 'journal-approval', + kind: 'approval', + optionId: 'allowForSession', + fence: 7 + }) + // The answer resolves the SDK's own callback promise; the SDK writes the wire response. + await expect(answered.promise).resolves.toEqual({ + behavior: 'allow', + updatedInput: { command: 'git status' }, + updatedPermissions: [{ type: 'addRules' }], + toolUseID: 'tool-1' + }) + }) + + it('collects every AskUserQuestion card before settling the one callback', async () => { + const claude = fakeClaude() + const adapter = await acquired(claude) + const answered = invokeCanUseTool( + claude.connections[0], + 'AskUserQuestion', + 'question-1', + 'tool-question', + { + input: { + questions: [ + { question: 'Library?', options: [{ label: 'Luxon' }] }, + { question: 'Ship now?', options: [{ label: 'Yes' }] } + ] + } + } + ) + adapter.bindPromptItemId('session-1', 'journal-q1', 'question-1', 'Library?') + adapter.bindPromptItemId('session-1', 'journal-q2', 'question-1', 'Ship now?') + + await adapter.answerPrompt({ + sessionId: 'session-1', + itemId: 'journal-q1', + kind: 'question', + optionId: encodeClaudeQuestionOptionId('Library?', 'Luxon'), + fence: 7 + }) + await tick() + expect(answered.settled()).toBe(false) + await adapter.answerPrompt({ + sessionId: 'session-1', + itemId: 'journal-q2', + kind: 'question', + optionId: encodeClaudeQuestionOptionId('Ship now?', 'Yes'), + fence: 7 + }) + await expect(answered.promise).resolves.toMatchObject({ + behavior: 'allow', + updatedInput: { answers: { 'Library?': 'Luxon', 'Ship now?': 'Yes' } }, + toolUseID: 'tool-question' + }) + }) + + it('leaves a prompt cancelled and unanswerable once the SDK abort signal fires', async () => { + const claude = fakeClaude() + const events: ClaudeStructuredSessionEvent[] = [] + const adapter = await acquired(claude, {}, events) + const controller = new AbortController() + const answered = invokeCanUseTool(claude.connections[0], 'Bash', 'permission-9', 'tool-9', { + input: { command: 'rm -rf /' }, + signal: controller.signal + }) + adapter.bindPromptItemId('session-1', 'journal-9', 'permission-9') + + controller.abort() + // A cancelled request is forgotten and settled with null — never an authorization. + await expect(answered.promise).resolves.toBeNull() + expect(events.at(-1)).toMatchObject({ type: 'prompt-cancelled', promptKey: 'permission-9' }) + // A late answer after the abort must not authorize the wrong tool. + await expect( + adapter.answerPrompt({ + sessionId: 'session-1', + itemId: 'journal-9', + kind: 'approval', + optionId: 'allow', + fence: 7 + }) + ).rejects.toThrow(/no longer waiting/) + }) + + it('settles an in-flight permission callback when the session closes, leaving no dangling promise', async () => { + const claude = fakeClaude() + const adapter = await acquired(claude) + const answered = invokeCanUseTool(claude.connections[0], 'Bash', 'permission-close', 'tool-c', { + input: { command: 'ls' } + }) + await tick() + expect(answered.settled()).toBe(false) + + await adapter.closeSession('session-1') + + await expect(answered.promise).resolves.toBeNull() + }) +}) diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts new file mode 100644 index 00000000000..f28b6e37f8f --- /dev/null +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -0,0 +1,246 @@ +import type { + AgentSessionAcquisition, + StructuredAgentSessionAcquireInput, + StructuredAgentSessionAdapter +} from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { answerClaudePrompt, cancelClaudeTurn } from './claude-structured-control-actions' +import { dispatchClaudeTurn } from './claude-structured-dispatch' +import { releaseClaudeAcquisition } from './claude-structured-acquisition-release' +import { acquireClaudeSession } from './claude-structured-session-acquisition' +export { CLAUDE_STRUCTURED_INIT_TIMEOUT_MS } from './claude-structured-session-acquisition' +import { supportsClaudeStructuredLocation } from './claude-structured-location-support' +import { setClaudeStructuredOption } from './claude-structured-options' +import { readClaudeStructuredSessionOptions } from './claude-structured-session-options' +import { + ClaudeAcquisitionRegistry, + type ClaudeAcquisitionAttempt, + type ClaudeSession, + type ClaudeSessionExit, + type ClaudeStructuredSessionAdapterDeps, + type ClaudeStructuredSessionEvent +} from './claude-structured-session-state' +import { + closeAllClaudeSessions, + closeClaudeSession, + settleClaudeExitedSession +} from './claude-structured-session-close' +import { readClaudeTranscriptLeafWithReproof } from './claude-transcript-branch-proof' + +export type { ClaudeStructuredLaunch } from './claude-structured-launch-resolution' +export type { + ClaudeAuthDiagnostic, + ClaudeStructuredSessionAdapterDeps, + ClaudeStructuredSessionEvent +} from './claude-structured-session-state' + +const DISPATCH_ACK_TIMEOUT_MS = 10_000 + +export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAdapter { + private readonly sessions = new Map() + private readonly acquisitions = new ClaudeAcquisitionRegistry() + private readonly exits = new Map() + + constructor(private readonly deps: ClaudeStructuredSessionAdapterDeps) {} + + supportsLocation = supportsClaudeStructuredLocation + + acquire = (input: StructuredAgentSessionAcquireInput): Promise => + acquireClaudeSession({ + input, + deps: this.deps, + sessions: this.sessions, + acquisitions: this.acquisitions, + exits: this.exits, + callbacks: { + deliver: (attempt, sessionId, event) => this.deliver(attempt, sessionId, event), + emit: (session, events, event) => this.emit(session, events, event), + handleExit: (sessionId, attempt, error) => this.handleExit(sessionId, attempt, error), + settleExit: (sessionId, exit) => this.settleUnexpectedExit(sessionId, exit) + } + }) + + private deliver(attempt: ClaudeAcquisitionAttempt, sessionId: string, event: () => void): void { + if (!attempt.published) { + attempt.buffered.push(event) + return + } + if (this.sessions.get(sessionId)?.connection === attempt.connection) { + event() + } + } + + private handleExit(sessionId: string, attempt: ClaudeAcquisitionAttempt, error: Error): void { + const session = this.sessions.get(sessionId) + if (!session || session.connection !== attempt.connection) { + return + } + this.sessions.delete(sessionId) + // Re-enter the provider's close ladder before publishing lifecycle recovery. + // An exit callback is root evidence only; the retained tree proof must run + // before the host releases and reacquires this exact child. + const closePromise = session.connection.close().catch(() => false) + const exit: ClaudeSessionExit = { + connection: session.connection, + session, + error, + closePromise + } + this.exits.set(sessionId, exit) + void closePromise + .then((proven) => (proven ? this.settleUnexpectedExit(sessionId, exit) : undefined)) + .catch(() => undefined) + } + + /** Lifecycle recovery is published only after the child tree proof is true. */ + private settleUnexpectedExit(sessionId: string, exit: ClaudeSessionExit): Promise { + exit.settlementPromise ??= (async () => { + if (this.exits.get(sessionId) !== exit) { + settleClaudeExitedSession(exit.session) + return + } + // Persist the transcript-derived cursor before publishing the lifecycle + // event that lets the host release and reacquire this exact child. + await this.persistSessionHandle(sessionId, exit.session).catch(() => undefined) + if (this.exits.get(sessionId) !== exit) { + settleClaudeExitedSession(exit.session) + return + } + this.exits.delete(sessionId) + const ended: ClaudeStructuredSessionEvent = { + type: 'ended', + sessionId, + reason: exit.error.message, + cause: 'unexpected-exit', + fence: exit.session.fence, + acquisitionGeneration: exit.session.acquisitionGeneration + } + try { + this.emit(exit.session, exit.session.events, ended) + } finally { + settleClaudeExitedSession(exit.session) + } + })() + return exit.settlementPromise + } + + private async persistSessionHandle(sessionId: string, session: ClaudeSession): Promise { + try { + const transcriptLeaf = this.deps.readTranscriptLeaf + ? await readClaudeTranscriptLeafWithReproof({ + readTranscriptLeaf: this.deps.readTranscriptLeaf, + providerSessionId: session.providerSessionId, + previousLeafUuid: session.leafUuid, + claudeConfigDir: session.claudeConfigDir + }) + : null + if (transcriptLeaf) { + session.leafUuid = transcriptLeaf + } + } catch { + // A stale or unavailable tail must not overwrite the last observed leaf. + } + await this.deps.persistHandle?.({ + sessionId, + providerSessionId: session.providerSessionId, + leafUuid: session.leafUuid, + fence: session.fence + }) + } + + private emit( + _session: ClaudeSession | null, + _events: StructuredAgentSessionEventSink | undefined, + event: ClaudeStructuredSessionEvent + ): void { + _session?.translator?.handle(event) + this.deps.onEvent?.(event) + } + + bindPromptItemId( + sessionId: string, + journalItemId: string, + promptKey: string, + questionId?: string + ): void { + this.sessions.get(sessionId)?.prompts.bindJournalItemId(journalItemId, promptKey, questionId) + } + + dispatch: StructuredAgentSessionAdapter['dispatch'] = (input) => + dispatchClaudeTurn( + this.session(input.sessionId), + input, + this.deps.dispatchAckTimeoutMs ?? DISPATCH_ACK_TIMEOUT_MS + ) + + cancelTurn: StructuredAgentSessionAdapter['cancelTurn'] = (input) => { + const session = this.session(input.sessionId) + const acquisitionGeneration = session.acquisitionGeneration + return cancelClaudeTurn(session, this.deps.requestTimeoutMs, () => { + // Keep every ownership check adjacent to the provider interrupt. The + // session map check fences a replaced child; the turn check fences a + // delayed cancel after a newer turn was admitted on the same child. + return ( + this.sessions.get(input.sessionId) === session && + session.fence === input.fence && + session.acquisitionGeneration === acquisitionGeneration && + (session.activeTurnId === undefined + ? session.dispatchSequence === 0 + : session.activeTurnId === input.turnId && + session.activeTurnSequence === session.dispatchSequence) + ) + }) + } + answerPrompt: StructuredAgentSessionAdapter['answerPrompt'] = (input) => + answerClaudePrompt(this.session(input.sessionId), input) + setOption: StructuredAgentSessionAdapter['setOption'] = (input) => + setClaudeStructuredOption(this.session(input.sessionId), input, this.deps.requestTimeoutMs) + readOptions = (input: { sessionId: string; fence: number }) => + readClaudeStructuredSessionOptions(this.session(input.sessionId), this.deps.requestTimeoutMs) + + readOptionRestoreFailures = (sessionId: string): readonly string[] => [ + ...(this.sessions.get(sessionId)?.restoreSkippedOptions ?? []) + ] + + releaseAcquisition = (input: { sessionId: string }): Promise => + releaseClaudeAcquisition({ + sessionId: input.sessionId, + sessions: this.sessions, + acquisitions: this.acquisitions, + exits: this.exits, + onExitProven: (sessionId, exit) => this.settleUnexpectedExit(sessionId, exit), + ...(this.deps.persistHandle ? { persistHandle: this.deps.persistHandle } : {}), + ...(this.deps.onEvent ? { onEvent: this.deps.onEvent } : {}) + }) + + closeSession = (sessionId: string): Promise => { + if (this.exits.has(sessionId)) { + return this.releaseAcquisition({ sessionId }) + } + return closeClaudeSession({ + sessionId, + sessions: this.sessions, + acquisitions: this.acquisitions, + ...(this.deps.persistHandle ? { persistHandle: this.deps.persistHandle } : {}), + ...(this.deps.readTranscriptLeaf ? { readTranscriptLeaf: this.deps.readTranscriptLeaf } : {}), + ...(this.deps.onEvent ? { onEvent: this.deps.onEvent } : {}) + }) + } + + closeAll = (): Promise => + closeAllClaudeSessions({ + sessions: this.sessions, + acquisitions: this.acquisitions, + exits: this.exits, + closeSession: this.closeSession, + closeExit: (sessionId) => this.releaseAcquisition({ sessionId }) + }) + + private session(sessionId: string): ClaudeSession { + const session = this.sessions.get(sessionId) + if (!session) { + throw new Error(`no live claude stream-json session for ${sessionId}`) + } + return session + } +} diff --git a/src/main/claude/claude-structured-session-close.test.ts b/src/main/claude/claude-structured-session-close.test.ts new file mode 100644 index 00000000000..f92975e9ef4 --- /dev/null +++ b/src/main/claude/claude-structured-session-close.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it, vi } from 'vitest' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import type { + ClaudeStructuredSessionAdapterDeps, + ClaudeStructuredSessionEvent +} from './claude-structured-session-adapter' +import { adapterFor, fakeClaude, identityFor } from './claude-structured-session-test-support' + +describe('Claude published session close lifecycle', () => { + it('ends the session even when the durable handle write rejects', async () => { + const claude = fakeClaude() + const events: ClaudeStructuredSessionEvent[] = [] + const persistenceError = new Error('store unavailable') + const persistHandle = vi + .fn>() + .mockRejectedValueOnce(persistenceError) + .mockResolvedValueOnce(undefined) + const adapter = adapterFor(claude, {}, events, [], undefined, undefined, persistHandle) + const journalSink: StructuredAgentSessionEventSink = { + appendItem: () => {}, + appendTombstone: () => {}, + publish: () => {} + } + await adapter.acquire({ + identity: identityFor(), + fence: 7, + spawnToken: 'spawn-9', + events: journalSink + }) + const session = ( + adapter as unknown as { + sessions: Map void } | null }> + } + ).sessions.get('session-1') + const disposeTranslator = vi.spyOn(session!.translator!, 'dispose') + + await expect(adapter.closeSession('session-1')).rejects.toBe(persistenceError) + // The child is provably dead; a failed cursor write may not suppress the end. + expect(events.filter((event) => event.type === 'ended')).toHaveLength(1) + expect(events.filter((event) => event.type === 'handle')).toHaveLength(0) + expect(disposeTranslator).toHaveBeenCalledOnce() + + await expect(adapter.closeSession('session-1')).resolves.toBe(true) + expect(persistHandle).toHaveBeenCalledTimes(2) + // The retry persists the same cursor without a second lifecycle end. + expect(events.filter((event) => event.type === 'handle')).toHaveLength(1) + expect(events.filter((event) => event.type === 'ended')).toHaveLength(1) + expect(disposeTranslator).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/claude/claude-structured-session-close.ts b/src/main/claude/claude-structured-session-close.ts new file mode 100644 index 00000000000..d37d3917796 --- /dev/null +++ b/src/main/claude/claude-structured-session-close.ts @@ -0,0 +1,256 @@ +import type { + ClaudeAcquisitionRegistry, + ClaudeSession, + ClaudeSessionExit, + ClaudeStructuredSessionEvent +} from './claude-structured-session-state' +import { cancelClaudeAcquisitionAttempt } from './claude-structured-session-state' +import { + AgentSessionAcquisitionExitUnprovenError, + AgentSessionAcquisitionRootExitObservedError, + AgentSessionPreSpawnError +} from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { ClaudeStreamJsonConnection } from './claude-stream-json-connection' +import { closeProcessRegistry } from '../../shared/child-process/close-process-registry' +import { readClaudeTranscriptLeafWithReproof } from './claude-transcript-branch-proof' + +export function claudeAcquisitionCleanupError( + connection: ClaudeStreamJsonConnection | null | undefined, + cause: unknown +): Error { + const verdict = connection?.exitVerdict + if (verdict?.root === 'processless') { + return new AgentSessionPreSpawnError(cause) + } + return verdict?.root === 'exited' && verdict.tree === 'unverifiable' + ? new AgentSessionAcquisitionRootExitObservedError(cause) + : new AgentSessionAcquisitionExitUnprovenError(cause) +} + +export function settleClaudeDispatchWaiters(session: ClaudeSession): void { + for (const waiter of session.dispatchWaiters.splice(0)) { + clearTimeout(waiter.timer) + waiter.resolve(null) + } +} + +export function settleClaudeExitedSession(session: ClaudeSession): void { + settleClaudeDispatchWaiters(session) + for (const prompt of session.prompts.clear()) { + prompt.settle(null) + } + session.translator?.dispose() +} + +type CloseClaudePublishedSessionInput = { + sessions: Map + sessionId: string + persistHandle?: (handle: { + sessionId: string + providerSessionId: string + leafUuid: string | null + fence: number + }) => Promise + onEvent?: (event: ClaudeStructuredSessionEvent) => void + readTranscriptLeaf?: (input: { + providerSessionId: string + previousLeafUuid: string | null + claudeConfigDir: string + }) => Promise +} + +async function finalizeClaudePublishedSession( + input: CloseClaudePublishedSessionInput, + session: ClaudeSession +): Promise { + settleClaudeDispatchWaiters(session) + // Settle every in-flight permission callback so closing leaves no dangling promise; `null` + // writes no response, and the SDK ignores any post-cleanup answer regardless. + for (const prompt of session.prompts.clear()) { + prompt.settle(null) + } + if ((await session.connection.close()) !== true) { + return false + } + try { + const transcriptLeaf = input.readTranscriptLeaf + ? await readClaudeTranscriptLeafWithReproof({ + readTranscriptLeaf: input.readTranscriptLeaf, + providerSessionId: session.providerSessionId, + previousLeafUuid: session.leafUuid, + claudeConfigDir: session.claudeConfigDir + }) + : null + if (transcriptLeaf) { + session.leafUuid = transcriptLeaf + } + } catch { + // Keep the last observed main-transcript frame when the durable tail is + // unavailable or proves a stale/divergent branch. + } + const persistence = + session.closePersistence ?? + (session.closePersistence = (async () => { + await input.persistHandle?.({ + sessionId: input.sessionId, + providerSessionId: session.providerSessionId, + leafUuid: session.leafUuid, + fence: session.fence + }) + })()) + const ended = { + type: 'ended', + sessionId: input.sessionId, + reason: 'claude session closed' + } as const + let callbackError: unknown + let callbackThrew = false + const deliver = (event: ClaudeStructuredSessionEvent): void => { + try { + input.onEvent?.(event) + } catch (error) { + callbackThrew = true + callbackError ??= error + } + } + let persistenceError: unknown + try { + await persistence + session.closeFinalized = true + input.sessions.delete(input.sessionId) + deliver({ + type: 'handle', + sessionId: input.sessionId, + providerSessionId: session.providerSessionId, + leafUuid: session.leafUuid, + fence: session.fence + }) + } catch (error) { + // Keep the closed session indexed so a retry can persist the same cursor. + // Removing it first would turn a durable-write failure into a no-op retry. + if (session.closePersistence === persistence) { + session.closePersistence = undefined + } + persistenceError = error + } + // The connection already proved the child dead, so the session has ended + // whatever the durable write did: withholding it would strand the renderer on + // a session nothing re-drives. Emitted once, so a retry only re-persists. + if (!session.closeEnded) { + session.closeEnded = true + try { + try { + session.translator?.handle(ended) + } catch (error) { + callbackThrew = true + callbackError ??= error + } + deliver(ended) + } finally { + session.translator?.dispose() + } + } + if (persistenceError) { + throw persistenceError + } + if (callbackThrew) { + throw callbackError + } + return true +} + +export async function closeClaudePublishedSession( + input: CloseClaudePublishedSessionInput +): Promise { + const session = input.sessions.get(input.sessionId) + if (!session) { + return true + } + if (session.closeFinalized) { + return true + } + if (session.closeFinalization) { + return session.closeFinalization + } + const finalization = finalizeClaudePublishedSession(input, session) + session.closeFinalization = finalization + try { + return await finalization + } finally { + if (session.closeFinalization === finalization && !session.closeFinalized) { + session.closeFinalization = undefined + } + } +} + +export function closeClaudePublishedSessionForDeps( + sessions: Map, + sessionId: string, + deps: { + persistHandle?: (handle: { + sessionId: string + providerSessionId: string + leafUuid: string | null + fence: number + }) => Promise + onEvent?: (event: ClaudeStructuredSessionEvent) => void + readTranscriptLeaf?: (input: { + providerSessionId: string + previousLeafUuid: string | null + claudeConfigDir: string + }) => Promise + } +): Promise { + return closeClaudePublishedSession({ sessions, sessionId, ...deps }) +} + +export async function closeClaudeSession(input: { + sessionId: string + sessions: Map + acquisitions: ClaudeAcquisitionRegistry + persistHandle?: (handle: { + sessionId: string + providerSessionId: string + leafUuid: string | null + fence: number + }) => Promise + onEvent?: (event: ClaudeStructuredSessionEvent) => void + readTranscriptLeaf?: (input: { + providerSessionId: string + previousLeafUuid: string | null + claudeConfigDir: string + }) => Promise +}): Promise { + const attempt = input.acquisitions.get(input.sessionId) + if (!(await cancelClaudeAcquisitionAttempt(attempt))) { + return false + } + if (attempt) { + input.acquisitions.deleteIfCurrent(input.sessionId, attempt) + } + return closeClaudePublishedSession(input) +} + +export async function closeAllClaudeSessions(input: { + sessions: Map + acquisitions: ClaudeAcquisitionRegistry + exits: Map + closeSession: (sessionId: string) => Promise + closeExit: (sessionId: string) => Promise +}): Promise { + input.acquisitions.close() + await closeProcessRegistry({ + attempts: 3, + hasEntries: () => + input.sessions.size > 0 || input.acquisitions.size > 0 || input.exits.size > 0, + entryIds: () => + new Set([ + ...input.sessions.keys(), + ...input.acquisitions.sessionIds(), + ...input.exits.keys() + ]), + closeEntry: async (sessionId) => + input.exits.has(sessionId) ? input.closeExit(sessionId) : input.closeSession(sessionId), + failureMessage: 'claude structured session shutdown could not prove every child stopped' + }) +} diff --git a/src/main/claude/claude-structured-session-options.ts b/src/main/claude/claude-structured-session-options.ts new file mode 100644 index 00000000000..afb4fd65076 --- /dev/null +++ b/src/main/claude/claude-structured-session-options.ts @@ -0,0 +1,183 @@ +import type { + AgentSessionModelOption, + AgentSessionOptionChoice, + AgentSessionOptionsResult +} from '../../shared/agent-session-wire' +import { CLAUDE_SESSION_OPTION_CATALOG } from '../../shared/agent-session-option-catalog-claude-codex' +import type { CatalogModel } from '../../shared/agent-session-option-catalog-types' +import type { ClaudeSession } from './claude-structured-session-state' + +type ListedModel = AgentSessionModelOption & { resolvedModel: string | null } + +function record(value: unknown): Record | null { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : null +} + +function text(value: unknown): string | null { + return typeof value === 'string' && value.trim() ? value : null +} + +/** + * The session's current effort, which only `get_settings` reports: the + * `system/init` frame carries `model` but has never carried an effort of any + * kind. Null when the provider stops reporting it, so the pill goes empty + * rather than showing an effort nothing measured. + */ +export function readClaudeSettingsEffort(settings: unknown): string | null { + return text(record(record(settings)?.effective)?.effortLevel) +} + +function effortLabel(value: string): string { + return value === 'xhigh' ? 'Extra high' : `${value.charAt(0).toUpperCase()}${value.slice(1)}` +} + +function listedEfforts(row: Record): AgentSessionOptionChoice[] { + return row.supportsEffort === true && Array.isArray(row.supportedEffortLevels) + ? row.supportedEffortLevels.flatMap((value) => { + const effort = text(value) + return effort ? [{ value: effort, label: effortLabel(effort) }] : [] + }) + : [] +} + +function listedModels(value: unknown): ListedModel[] { + const response = record(value) + const rows = Array.isArray(response?.models) + ? response.models.map(record).filter((row): row is Record => row !== null) + : [] + const defaultRow = rows.find((row) => text(row.value) === 'default') + const defaultResolvedModel = text(defaultRow?.resolvedModel) + const seen = new Set() + return rows.flatMap((row) => { + const id = text(row.value) + if (!id || id === 'default' || seen.has(id)) { + return [] + } + seen.add(id) + const resolvedModel = text(row.resolvedModel) + const description = text(row.description) + return [ + { + id, + label: text(row.displayName) ?? id, + ...(description ? { description } : {}), + isDefault: resolvedModel !== null && resolvedModel === defaultResolvedModel, + efforts: listedEfforts(row), + resolvedModel + } + ] + }) +} + +function seedEfforts(model: CatalogModel): AgentSessionOptionChoice[] { + const effort = model.options.find((option) => option.id === 'effort') + return effort?.kind.type === 'select' ? effort.kind.choices : [] +} + +function seedModels(): ListedModel[] { + return CLAUDE_SESSION_OPTION_CATALOG.models.map((model) => ({ + id: model.id, + label: model.label, + ...(model.description ? { description: model.description } : {}), + isDefault: model.isDefault === true, + efforts: seedEfforts(model), + resolvedModel: null + })) +} + +function currentModelId(models: ListedModel[], reportedModel: string | undefined): string { + const matched = reportedModel + ? models.find((model) => model.id === reportedModel || model.resolvedModel === reportedModel) + : undefined + return ( + matched?.id ?? reportedModel ?? models.find((model) => model.isDefault)?.id ?? models[0]!.id + ) +} + +/** + * The model the session is running. A report the CLI made after the last write + * outranks the write: it names the model the session ran. An older one does not + * — a model set between turns has no report yet, and deferring to the previous + * turn's would flip the pill back. + * + * Sole resolver of that question: every surface that acts on "the current model" + * — the pill, the effort guard, the rejection it names — reads it here, so two + * of them cannot answer it differently and offer an effort a third then refuses. + */ +export function readClaudeCurrentModel(session: ClaudeSession): { + id: string | undefined + confirmed: boolean +} { + const confirmed = + session.reportedModelMutation === session.optionMutationSequence && + session.reportedOptions.model !== undefined + return { + id: confirmed + ? session.reportedOptions.model + : (session.options.get('model') ?? session.reportedOptions.model), + confirmed + } +} + +/** + * The effort levels the session's current model advertises, with the catalog id + * that matched so a refusal names the model the pill shows. Levels are null when + * nothing identified the model: `apply_flag_settings` accepts and stores any + * level for a model with no effort control, so the catalog is the only evidence + * of a refusal — and an absent or unlisted one is not evidence, or a live CLI + * that predates `list_models` would have every effort refused under it. + */ +export async function readClaudeModelEffortLevels( + session: ClaudeSession, + timeoutMs: number | undefined +): Promise<{ modelId: string | undefined; levels: ReadonlySet | null }> { + const modelId = readClaudeCurrentModel(session).id + if (!modelId) { + return { modelId, levels: null } + } + const catalog = await session.connection.supportedModels({ timeoutMs }).catch(() => null) + const matched = catalog + ? listedModels({ models: catalog }).find( + (model) => model.id === modelId || model.resolvedModel === modelId + ) + : undefined + return { + modelId: matched?.id ?? modelId, + levels: matched ? new Set(matched.efforts.map((choice) => choice.value)) : null + } +} + +export async function readClaudeStructuredSessionOptions( + session: ClaudeSession, + timeoutMs: number | undefined +): Promise { + const catalog = await session.connection.supportedModels({ timeoutMs }).catch(() => null) + const discovered = listedModels(catalog ? { models: catalog } : null) + const models = discovered.length > 0 ? discovered : seedModels() + const current = readClaudeCurrentModel(session) + const model = currentModelId(models, current.id) + if (!models.some((entry) => entry.id === model)) { + models.push({ id: model, label: model, isDefault: false, efforts: [], resolvedModel: null }) + } + const effort = session.options.get('effort') ?? session.reportedOptions.effort + const confirmed = [ + ...(current.confirmed ? ['model'] : []), + ...(effort && session.confirmedOptions.has('effort') ? ['effort'] : []) + ] + return { + models: models.map((entry) => ({ + id: entry.id, + label: entry.label, + ...(entry.description ? { description: entry.description } : {}), + isDefault: entry.isDefault, + efforts: entry.efforts + })), + current: { + model, + ...(effort ? { effort } : {}), + ...(confirmed.length > 0 ? { confirmed } : {}) + } + } +} diff --git a/src/main/claude/claude-structured-session-publication.ts b/src/main/claude/claude-structured-session-publication.ts new file mode 100644 index 00000000000..29d1113c814 --- /dev/null +++ b/src/main/claude/claude-structured-session-publication.ts @@ -0,0 +1,68 @@ +import type { AgentSessionAcquisition } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { ClaudeInitObservation } from './claude-structured-init-proof' +import { claudeProviderHandleLink } from './claude-structured-owner-identity' +import type { ClaudePromptRegistry } from './claude-structured-prompt-replies' +import type { ClaudeJournalTranslator } from './claude-structured-journal-translation' +import type { ClaudeSession } from './claude-structured-session-state' + +export function createClaudeSessionPublication(input: { + connection: ClaudeSession['connection'] + init: ClaudeInitObservation + claudeConfigDir: string + leafUuid: string | null + fence: number + acquisitionGeneration: string + resumed: boolean + prompts: ClaudePromptRegistry + translator: ClaudeJournalTranslator | null + events: ClaudeSession['events'] + process: AgentSessionAcquisition['process'] + linkId?: string + observedAt: number + options?: ReadonlyMap + capabilities: readonly string[] + /** Read from `get_settings`; `system/init` never reports an effort. */ + effort: string | null +}): { acquisition: AgentSessionAcquisition; session: ClaudeSession } { + const model = input.init.model + const effort = input.effort + return { + acquisition: { + process: input.process, + link: claudeProviderHandleLink({ + sessionId: input.init.providerSessionId, + leafUuid: input.leafUuid, + resumed: input.resumed, + fence: input.fence, + ...(input.linkId ? { linkId: input.linkId } : {}), + observedAt: input.observedAt + }), + acquisitionGeneration: input.acquisitionGeneration + }, + session: { + connection: input.connection, + providerSessionId: input.init.providerSessionId, + claudeConfigDir: input.claudeConfigDir, + leafUuid: input.leafUuid, + fence: input.fence, + acquisitionGeneration: input.acquisitionGeneration, + prompts: input.prompts, + dispatchWaiters: [], + retiredDispatchWaiters: [], + replayContentFallbackBlocked: false, + dispatchSequence: 0, + optionMutationSequence: 0, + options: new Map(input.options), + capabilities: input.capabilities, + reportedOptions: { + ...(model ? { model } : {}), + ...(effort ? { effort } : {}) + }, + reportedModelMutation: 0, + confirmedOptions: new Set(effort ? ['effort'] : []), + restoreSkippedOptions: new Set(), + translator: input.translator, + events: input.events + } + } +} diff --git a/src/main/claude/claude-structured-session-recovery.test.ts b/src/main/claude/claude-structured-session-recovery.test.ts new file mode 100644 index 00000000000..5bfe56cf156 --- /dev/null +++ b/src/main/claude/claude-structured-session-recovery.test.ts @@ -0,0 +1,619 @@ +import { describe, expect, it, vi } from 'vitest' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { + ClaudeStructuredSessionAdapter, + type ClaudeStructuredSessionAdapterDeps, + type ClaudeStructuredSessionEvent +} from './claude-structured-session-adapter' +import { ClaudeTranscriptPreviousCursorMissingError } from './claude-transcript-branch-proof' +import { + adapterFor, + fakeClaude, + identityFor, + invokeCanUseTool, + PROVIDER_SESSION_ID, + tick +} from './claude-structured-session-test-support' + +describe('ClaudeStructuredSessionAdapter transcript-derived recovery', () => { + it('shares concurrent close finalization and emits lifecycle once', async () => { + const claude = fakeClaude() + const events: ClaudeStructuredSessionEvent[] = [] + const persistence = Promise.withResolvers() + const persistHandle = vi.fn(() => persistence.promise) + const adapter = adapterFor(claude, {}, events, [], undefined, undefined, persistHandle) + const journalSink: StructuredAgentSessionEventSink = { + appendItem: () => {}, + appendTombstone: () => {}, + publish: () => {} + } + await adapter.acquire({ + identity: identityFor(), + fence: 7, + spawnToken: 'spawn-9', + events: journalSink + }) + const session = ( + adapter as unknown as { + sessions: Map void } | null }> + } + ).sessions.get('session-1') + const disposeTranslator = vi.spyOn(session!.translator!, 'dispose') + + const first = adapter.closeSession('session-1') + const second = adapter.closeSession('session-1') + await tick() + expect(persistHandle).toHaveBeenCalledOnce() + expect(claude.connections[0].closeCount).toBe(1) + + persistence.resolve() + await expect(Promise.all([first, second])).resolves.toEqual([true, true]) + expect(events.filter((event) => event.type === 'handle')).toHaveLength(1) + expect(events.filter((event) => event.type === 'ended')).toHaveLength(1) + expect(disposeTranslator).toHaveBeenCalledOnce() + }) + + it('still emits ended and disposes state when handle delivery throws', async () => { + const claude = fakeClaude() + const events: ClaudeStructuredSessionEvent[] = [] + const callbackError = new Error('handle delivery failed') + const adapter = new ClaudeStructuredSessionAdapter({ + resolveLaunch: async () => ({ + pathToClaudeCodeExecutable: 'claude', + options: {}, + cwd: '/work/repo', + claudeConfigDir: '/accounts/claude', + providerSessionId: PROVIDER_SESSION_ID, + resumeLeafUuid: null, + resumed: false + }), + onEvent: (event) => { + events.push(event) + if (event.type === 'handle') { + throw callbackError + } + }, + openConnection: claude.openConnection, + readProcessStartTime: async () => 1_700_000_000_000, + persistHandle: vi.fn(async () => undefined) + }) + const journalSink: StructuredAgentSessionEventSink = { + appendItem: () => {}, + appendTombstone: () => {}, + publish: () => {} + } + await adapter.acquire({ + identity: identityFor(), + fence: 7, + spawnToken: 'spawn-9', + events: journalSink + }) + const session = ( + adapter as unknown as { + sessions: Map void } | null }> + } + ).sessions.get('session-1') + const disposeTranslator = vi.spyOn(session!.translator!, 'dispose') + + await expect(adapter.closeSession('session-1')).rejects.toBe(callbackError) + expect(events.filter((event) => event.type === 'handle')).toHaveLength(1) + expect(events.filter((event) => event.type === 'ended')).toHaveLength(1) + expect(disposeTranslator).toHaveBeenCalledOnce() + }) + + it('retains a closed session until its durable cursor persistence succeeds', async () => { + const claude = fakeClaude() + const persistenceError = new Error('store unavailable') + const persistHandle = vi + .fn>() + .mockRejectedValueOnce(persistenceError) + .mockResolvedValueOnce(undefined) + const adapter = adapterFor(claude, {}, [], [], undefined, undefined, persistHandle) + await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + + await expect(adapter.closeSession('session-1')).rejects.toBe(persistenceError) + expect(persistHandle).toHaveBeenCalledTimes(1) + await expect(adapter.closeSession('session-1')).resolves.toBe(true) + expect(persistHandle).toHaveBeenCalledTimes(2) + }) + + it('persists only the last transcript-entry uuid before graceful close', async () => { + const claude = fakeClaude() + const events: ClaudeStructuredSessionEvent[] = [] + const persistedHandles: unknown[] = [] + const adapter = adapterFor(claude, {}, events, persistedHandles) + await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + claude.connections[0].handlers.onMessage?.({ + type: 'assistant', + session_id: PROVIDER_SESSION_ID, + uuid: 'assistant-leaf' + }) + claude.connections[0].handlers.onMessage?.({ + type: 'result', + session_id: PROVIDER_SESSION_ID, + uuid: 'result-frame-uuid' + }) + claude.connections[0].handlers.onMessage?.({ + type: 'stream_event', + session_id: PROVIDER_SESSION_ID, + uuid: 'stream-event-frame-uuid' + }) + + await adapter.closeSession('session-1') + + expect(persistedHandles).toEqual([ + { + sessionId: 'session-1', + providerSessionId: PROVIDER_SESSION_ID, + leafUuid: 'assistant-leaf', + fence: 7 + } + ]) + expect(events.at(-2)).toEqual({ + type: 'handle', + sessionId: 'session-1', + providerSessionId: PROVIDER_SESSION_ID, + leafUuid: 'assistant-leaf', + fence: 7 + }) + expect(claude.connections[0].closeCount).toBe(1) + }) + + it('prefers a validated durable transcript leaf at graceful close', async () => { + const claude = fakeClaude() + const persistedHandles: unknown[] = [] + const readTranscriptLeaf = vi.fn().mockResolvedValue('durable-tail') + const adapter = adapterFor(claude, {}, [], persistedHandles, undefined, readTranscriptLeaf) + await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + claude.connections[0].handlers.onMessage?.({ + type: 'assistant', + session_id: PROVIDER_SESSION_ID, + uuid: 'observed-tail' + }) + + await adapter.closeSession('session-1') + + expect(readTranscriptLeaf).toHaveBeenCalledWith({ + providerSessionId: PROVIDER_SESSION_ID, + previousLeafUuid: 'observed-tail', + claudeConfigDir: '/accounts/claude' + }) + expect(persistedHandles.at(-1)).toMatchObject({ leafUuid: 'durable-tail' }) + }) + + it('passes the pinned Claude account home to transcript validation', async () => { + const claude = fakeClaude() + const persistedHandles: unknown[] = [] + const readTranscriptLeaf = vi.fn().mockResolvedValue('durable-tail') + const adapter = adapterFor( + claude, + { claudeConfigDir: '/accounts/selected' }, + [], + persistedHandles, + undefined, + readTranscriptLeaf + ) + await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + claude.connections[0].handlers.onMessage?.({ + type: 'assistant', + session_id: PROVIDER_SESSION_ID, + uuid: 'observed-tail' + }) + + await adapter.closeSession('session-1') + + expect(readTranscriptLeaf).toHaveBeenCalledWith({ + providerSessionId: PROVIDER_SESSION_ID, + previousLeafUuid: 'observed-tail', + claudeConfigDir: '/accounts/selected' + }) + }) + + it('re-proves from the transcript root when the observed cursor is missing', async () => { + const claude = fakeClaude() + const persistedHandles: unknown[] = [] + const readTranscriptLeaf = vi + .fn() + .mockRejectedValueOnce(new ClaudeTranscriptPreviousCursorMissingError()) + .mockResolvedValueOnce('reproved-main-leaf') + const adapter = adapterFor(claude, {}, [], persistedHandles, undefined, readTranscriptLeaf) + await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + claude.connections[0].handlers.onMessage?.({ + type: 'assistant', + session_id: PROVIDER_SESSION_ID, + uuid: 'observed-tail' + }) + + await adapter.closeSession('session-1') + + expect(readTranscriptLeaf).toHaveBeenNthCalledWith(1, { + providerSessionId: PROVIDER_SESSION_ID, + previousLeafUuid: 'observed-tail', + claudeConfigDir: '/accounts/claude' + }) + expect(readTranscriptLeaf).toHaveBeenNthCalledWith(2, { + providerSessionId: PROVIDER_SESSION_ID, + previousLeafUuid: null, + claudeConfigDir: '/accounts/claude' + }) + expect(persistedHandles.at(-1)).toMatchObject({ leafUuid: 'reproved-main-leaf' }) + }) + + it('keeps the observed leaf when transcript validation proves a sibling branch', async () => { + const claude = fakeClaude() + const persistedHandles: unknown[] = [] + const readTranscriptLeaf = vi + .fn() + .mockRejectedValue(new Error('latest marker is on a sibling branch')) + const adapter = adapterFor(claude, {}, [], persistedHandles, undefined, readTranscriptLeaf) + await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + claude.connections[0].handlers.onMessage?.({ + type: 'assistant', + session_id: PROVIDER_SESSION_ID, + uuid: 'observed-tail' + }) + + await adapter.closeSession('session-1') + + expect(readTranscriptLeaf).toHaveBeenCalledTimes(1) + expect(persistedHandles.at(-1)).toMatchObject({ leafUuid: 'observed-tail' }) + }) + + it('persists the last transcript leaf before an unexpected first-hand exit', async () => { + const claude = fakeClaude() + const persistedHandles: unknown[] = [] + const events: ClaudeStructuredSessionEvent[] = [] + const adapter = adapterFor(claude, {}, events, persistedHandles) + await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + claude.connections[0].handlers.onMessage?.({ + type: 'assistant', + session_id: PROVIDER_SESSION_ID, + uuid: 'crash-leaf' + }) + + claude.connections[0].handlers.onExit?.( + new Error('claude stream-json exited (code 1): crashed unexpectedly') + ) + await tick() + + expect(persistedHandles).toContainEqual({ + sessionId: 'session-1', + providerSessionId: PROVIDER_SESSION_ID, + leafUuid: 'crash-leaf', + fence: 7 + }) + expect(events.at(-1)).toMatchObject({ + type: 'ended', + cause: 'unexpected-exit', + fence: 7, + acquisitionGeneration: expect.any(String) + }) + }) + + it('derives the crash cursor from the validated transcript tail', async () => { + const claude = fakeClaude() + const persistedHandles: unknown[] = [] + const adapter = adapterFor( + claude, + {}, + [], + persistedHandles, + undefined, + vi.fn().mockResolvedValue('durable-crash-leaf') + ) + await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + claude.connections[0].handlers.onMessage?.({ + type: 'assistant', + session_id: PROVIDER_SESSION_ID, + uuid: 'stale-observed-tail' + }) + claude.connections[0].handlers.onExit?.( + new Error('claude stream-json exited (signal SIGKILL): crashed') + ) + await tick() + + expect(persistedHandles.at(-1)).toMatchObject({ leafUuid: 'durable-crash-leaf' }) + }) + + it('re-proves a first-hand crash cursor from the transcript root after stale validation', async () => { + const claude = fakeClaude() + const persistedHandles: unknown[] = [] + const readTranscriptLeaf = vi + .fn() + .mockRejectedValueOnce(new ClaudeTranscriptPreviousCursorMissingError()) + .mockResolvedValueOnce('reproved-crash-leaf') + const adapter = adapterFor(claude, {}, [], persistedHandles, undefined, readTranscriptLeaf) + await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + claude.connections[0].handlers.onMessage?.({ + type: 'assistant', + session_id: PROVIDER_SESSION_ID, + uuid: 'stale-observed-tail' + }) + claude.connections[0].handlers.onExit?.(new Error('crashed')) + await tick() + + expect(readTranscriptLeaf).toHaveBeenNthCalledWith(1, { + providerSessionId: PROVIDER_SESSION_ID, + previousLeafUuid: 'stale-observed-tail', + claudeConfigDir: '/accounts/claude' + }) + expect(readTranscriptLeaf).toHaveBeenNthCalledWith(2, { + providerSessionId: PROVIDER_SESSION_ID, + previousLeafUuid: null, + claudeConfigDir: '/accounts/claude' + }) + expect(persistedHandles.at(-1)).toMatchObject({ leafUuid: 'reproved-crash-leaf' }) + }) + + it('keeps the observed crash leaf when transcript validation proves a sibling branch', async () => { + const claude = fakeClaude() + const persistedHandles: unknown[] = [] + const readTranscriptLeaf = vi + .fn() + .mockRejectedValue(new Error('latest marker is on a sibling branch')) + const adapter = adapterFor(claude, {}, [], persistedHandles, undefined, readTranscriptLeaf) + await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + claude.connections[0].handlers.onMessage?.({ + type: 'assistant', + session_id: PROVIDER_SESSION_ID, + uuid: 'observed-crash-tail' + }) + claude.connections[0].handlers.onExit?.(new Error('crashed')) + await tick() + + expect(readTranscriptLeaf).toHaveBeenCalledTimes(1) + expect(persistedHandles.at(-1)).toMatchObject({ leafUuid: 'observed-crash-tail' }) + }) + + it('publishes lifecycle recovery even when crash-cursor persistence fails', async () => { + const claude = fakeClaude() + const events: ClaudeStructuredSessionEvent[] = [] + const adapter = adapterFor( + claude, + {}, + events, + [], + undefined, + undefined, + vi.fn().mockRejectedValue(new Error('store unavailable')) + ) + await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + + claude.connections[0].handlers.onExit?.(new Error('crashed')) + await tick() + + expect(events.at(-1)).toMatchObject({ type: 'ended', cause: 'unexpected-exit' }) + }) + + it('runs the child close proof before publishing unexpected-exit recovery', async () => { + const claude = fakeClaude() + const events: ClaudeStructuredSessionEvent[] = [] + const adapter = adapterFor(claude, {}, events) + await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + const close = vi.spyOn(claude.connections[0], 'close').mockResolvedValue(true) + + claude.connections[0].handlers.onExit?.(new Error('crashed')) + await tick() + + expect(close).toHaveBeenCalledOnce() + expect(events.at(-1)).toMatchObject({ type: 'ended', cause: 'unexpected-exit' }) + }) + + it('does not publish recovery while an unexpected-exit close proof is false', async () => { + const claude = fakeClaude() + const events: ClaudeStructuredSessionEvent[] = [] + const persistedHandles: unknown[] = [] + const adapter = adapterFor(claude, {}, events, persistedHandles) + await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + claude.connections[0].close = vi + .fn<() => Promise>() + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true) as unknown as (typeof claude.connections)[0]['close'] + + claude.connections[0].handlers.onExit?.(new Error('crashed')) + await tick() + + expect(events.filter((event) => event.type === 'ended')).toEqual([]) + expect(persistedHandles).toEqual([]) + }) + + it('retains pending prompts while an unexpected-exit proof is unproven', async () => { + const claude = fakeClaude() + const events: ClaudeStructuredSessionEvent[] = [] + const adapter = adapterFor(claude, {}, events) + await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + const answered = invokeCanUseTool(claude.connections[0], 'Bash', 'permission-1', 'tool-1') + claude.connections[0].close = vi + .fn<() => Promise>() + .mockResolvedValue(false) as unknown as (typeof claude.connections)[0]['close'] + + claude.connections[0].handlers.onExit?.(new Error('crashed')) + await tick() + + expect(answered.settled()).toBe(false) + expect(events.filter((event) => event.type === 'ended')).toEqual([]) + }) + + it('publishes unexpected recovery exactly once after a retained proof retries successfully', async () => { + const claude = fakeClaude() + const events: ClaudeStructuredSessionEvent[] = [] + const adapter = adapterFor(claude, {}, events) + await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + claude.connections[0].close = vi + .fn<() => Promise>() + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true) as unknown as (typeof claude.connections)[0]['close'] + + claude.connections[0].handlers.onExit?.(new Error('crashed')) + await tick() + await expect(adapter.releaseAcquisition({ sessionId: 'session-1' })).resolves.toBe(true) + await tick() + + expect(events.filter((event) => event.type === 'ended')).toHaveLength(1) + }) + + it('launches the first replacement from the settled retained transcript cursor', async () => { + const claude = fakeClaude() + const events: ClaudeStructuredSessionEvent[] = [] + const persistedHandles: unknown[] = [] + const journalSink: StructuredAgentSessionEventSink = { + appendItem: () => {}, + appendTombstone: () => {}, + publish: () => {} + } + const readTranscriptLeaf = vi.fn().mockResolvedValue('durable-retained-leaf') + let durableLeafUuid: string | null = null + const resolveLaunch = vi.fn(async ({ identity }) => { + if ( + identity.providerHandle.kind !== 'claude' || + identity.providerHandle.sessionId !== PROVIDER_SESSION_ID || + identity.providerHandle.leafUuid !== durableLeafUuid + ) { + throw new Error('claude durable resume identity changed before spawn') + } + if (durableLeafUuid === null) { + return { + pathToClaudeCodeExecutable: 'claude', + options: { sessionId: PROVIDER_SESSION_ID }, + cwd: '/work/repo', + claudeConfigDir: '/accounts/claude', + providerSessionId: PROVIDER_SESSION_ID, + resumeLeafUuid: null, + resumed: false + } + } + return { + pathToClaudeCodeExecutable: 'claude', + options: { resume: PROVIDER_SESSION_ID, resumeSessionAt: durableLeafUuid }, + cwd: '/work/repo', + claudeConfigDir: '/accounts/claude', + providerSessionId: PROVIDER_SESSION_ID, + resumeLeafUuid: durableLeafUuid, + resumed: true + } + }) + const persistHandle = vi.fn>( + async (handle) => { + durableLeafUuid = handle.leafUuid + persistedHandles.push(handle) + } + ) + const adapter = new ClaudeStructuredSessionAdapter({ + resolveLaunch, + openConnection: claude.openConnection, + onEvent: (event) => events.push(event), + readProcessStartTime: async () => 1_700_000_000_000, + now: () => 1_700_000_000_500, + readTranscriptLeaf, + persistHandle + }) + const firstAcquisition = await adapter.acquire({ + identity: identityFor(), + fence: 7, + spawnToken: 'spawn-9', + events: journalSink + }) + const first = claude.connections[0] + const oldPrompt = invokeCanUseTool(first, 'Bash', 'permission-retained', 'tool-retained') + const oldSession = ( + adapter as unknown as { + sessions: Map< + string, + { + translator: { dispose: () => void } | null + prompts: { + find: (itemId: string) => { prompt: { settle: (value: unknown) => void } } | null + } + } + > + } + ).sessions.get('session-1') + expect(oldSession?.translator).not.toBeNull() + const disposeTranslator = vi.spyOn(oldSession!.translator!, 'dispose') + const pendingPrompt = oldSession?.prompts.find('permission-retained') + expect(pendingPrompt).not.toBeNull() + const settlePrompt = vi.spyOn(pendingPrompt!.prompt, 'settle') + first.handlers.onMessage?.({ + type: 'assistant', + session_id: PROVIDER_SESSION_ID, + uuid: 'observed-retained-leaf' + }) + first.close = vi + .fn<() => Promise>() + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true) as unknown as (typeof first)['close'] + first.handlers.onExit?.(new Error('crashed before replacement')) + await tick() + + expect(oldPrompt.settled()).toBe(false) + expect(events.filter((event) => event.type === 'ended')).toEqual([]) + + const replacement = await adapter.acquire({ + identity: { + ...identityFor(), + providerHandle: { + kind: 'claude', + sessionId: PROVIDER_SESSION_ID, + leafUuid: 'observed-retained-leaf' + } + }, + fence: 8, + spawnToken: 'spawn-10', + events: journalSink + }) + + expect(disposeTranslator).toHaveBeenCalledOnce() + expect(settlePrompt).toHaveBeenCalledOnce() + expect(settlePrompt).toHaveBeenCalledWith(null) + expect(persistHandle).toHaveBeenCalledOnce() + expect(persistedHandles).toEqual([ + { + sessionId: 'session-1', + providerSessionId: PROVIDER_SESSION_ID, + leafUuid: 'durable-retained-leaf', + fence: 7 + } + ]) + expect(readTranscriptLeaf).toHaveBeenCalledOnce() + expect(readTranscriptLeaf).toHaveBeenCalledWith({ + providerSessionId: PROVIDER_SESSION_ID, + previousLeafUuid: 'observed-retained-leaf', + claudeConfigDir: '/accounts/claude' + }) + expect(resolveLaunch).toHaveBeenNthCalledWith(2, { + identity: { + ...identityFor(), + providerHandle: { + kind: 'claude', + sessionId: PROVIDER_SESSION_ID, + leafUuid: 'durable-retained-leaf' + } + } + }) + expect(oldPrompt.settled()).toBe(true) + expect(events.filter((event) => event.type === 'ended')).toEqual([ + { + type: 'ended', + sessionId: 'session-1', + reason: 'crashed before replacement', + cause: 'unexpected-exit', + fence: 7, + acquisitionGeneration: firstAcquisition.acquisitionGeneration + } + ]) + expect(replacement.link).toMatchObject({ + handle: { + provider: 'claude', + sessionId: PROVIDER_SESSION_ID, + leafUuid: 'durable-retained-leaf' + }, + origin: 'resumed', + mintedAtFence: 8 + }) + expect(claude.connections[1]?.launch.options).toMatchObject({ + resume: PROVIDER_SESSION_ID, + resumeSessionAt: 'durable-retained-leaf' + }) + expect(claude.connections).toHaveLength(2) + }) +}) diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts new file mode 100644 index 00000000000..346ff686f76 --- /dev/null +++ b/src/main/claude/claude-structured-session-state.ts @@ -0,0 +1,276 @@ +import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import type { + ClaudeStreamJsonConnection, + openClaudeStreamJsonConnection +} from './claude-stream-json-connection' +import type { ClaudeStructuredLaunch } from './claude-structured-launch-resolution' +import type { ClaudeJournalTranslator } from './claude-structured-journal-translation' +import type { ClaudePendingPrompt, ClaudePromptRegistry } from './claude-structured-prompt-replies' +import { cancelProcessAcquisition } from '../../shared/child-process/cancel-process-acquisition' +import { randomUUID } from 'node:crypto' + +export type ClaudeAuthDiagnostic = { + apiKeySourceConfigured: boolean + baseUrlConfigured: boolean + authTokenConfigured: boolean + apiKeyConfigured: boolean + settingSources: readonly string[] +} + +export type ClaudeStructuredSessionEvent = + | { + type: 'message' + sessionId: string + message: Record + /** Present only when this replay acknowledged Orca's in-flight dispatch. */ + startsTurn?: true + } + | { type: 'provider-frame'; sessionId: string; kind: string; payload: unknown } + | { type: 'prompt'; sessionId: string; prompt: ClaudePendingPrompt } + | { type: 'prompt-cancelled'; sessionId: string; promptKey: string } + | { type: 'options'; sessionId: string; models: unknown[] } + | { + type: 'handle' + sessionId: string + providerSessionId: string + leafUuid: string | null + fence: number + } + | { type: 'auth-diagnostic'; sessionId: string; diagnostic: ClaudeAuthDiagnostic } + | { + type: 'ended' + sessionId: string + reason: string + /** Present for first-hand child exits so the host can fence recovery. */ + cause?: 'unexpected-exit' | 'requested-close' + fence?: number + acquisitionGeneration?: string + settlementRetryRequired?: boolean + } + +export type ClaudeStructuredSessionAdapterDeps = { + resolveLaunch: (input: { + identity: AgentSessionJournalIdentity + }) => Promise + onEvent?: (event: ClaudeStructuredSessionEvent) => void + openConnection?: typeof openClaudeStreamJsonConnection + readProcessStartTime?: (pid: number) => Promise + mintLinkId?: () => string + mintAcquisitionGeneration?: () => string + now?: () => number + requestTimeoutMs?: number + initTimeoutMs?: number + dispatchAckTimeoutMs?: number + persistHandle?: (input: { + sessionId: string + providerSessionId: string + leafUuid: string | null + fence: number + }) => Promise + /** Read the durable transcript branch after a child has flushed its final rows. */ + readTranscriptLeaf?: (input: { + providerSessionId: string + previousLeafUuid: string | null + /** Account-scoped Claude config root that owns this provider session. */ + claudeConfigDir: string + }) => Promise +} + +export type ClaudeDispatchWaiter = { + resolve: (uuid: string | null) => void + timer: ReturnType + acceptsResult: boolean + /** Client uuid echoed by Claude so a replay is tied to its own dispatch. */ + sentUuid: string + /** Sequence used to fence a late identity from a newer dispatch. */ + dispatchSequence: number + /** Set when the provider replay settled this waiter before send returned. */ + settledUuid?: string + /** The waiter timed out or its write failed, but its replay may still arrive. */ + retired?: boolean + /** Bounded digest/summary for compatibility CLIs that mint UUIDs. */ + replayContentKey: string +} + +export type ClaudeSession = { + connection: ClaudeStreamJsonConnection + providerSessionId: string + /** Durable transcript files live under this account's `projects` directory. */ + claudeConfigDir: string + leafUuid: string | null + fence: number + acquisitionGeneration: string + prompts: ClaudePromptRegistry + dispatchWaiters: ClaudeDispatchWaiter[] + /** Bounded identities for dispatches whose ack was unknown when they returned. */ + retiredDispatchWaiters: ClaudeDispatchWaiter[] + /** Once a retired waiter is evicted, legacy content-only replay matching is unsafe. */ + replayContentFallbackBlocked: boolean + options: Map + reportedOptions: { model?: string; effort?: string } + /** `optionMutationSequence` when `reportedOptions.model` was last observed, so a + * write still awaiting its first turn outranks the report it will replace. */ + reportedModelMutation: number + /** Options whose recorded value the provider reported, not merely accepted. */ + confirmedOptions: Set + restoreSkippedOptions: Set + /** CLI-advertised protocol capabilities from init; gates interrupt-receipt handling. */ + capabilities: readonly string[] + /** Provider uuid of the most recently admitted turn, if one is active. */ + activeTurnId?: string + /** Monotonic fence advanced when a dispatch starts, including unresolved dispatches. */ + dispatchSequence: number + /** Dispatch sequence that admitted activeTurnId. */ + activeTurnSequence?: number + /** Fences overlapping option writes so a late completion cannot restore stale state. */ + optionMutationSequence: number + /** Shared durable-close write; a failed write clears this for a retry. */ + closePersistence?: Promise + /** Shared full close/finalization operation; a failed operation clears this for a retry. */ + closeFinalization?: Promise + /** Set only after the durable close write succeeds, before lifecycle emission. */ + closeFinalized?: boolean + /** Set once `ended` has been emitted, so a persistence retry cannot repeat it. */ + closeEnded?: boolean + translator: ClaudeJournalTranslator | null + events: StructuredAgentSessionEventSink | undefined +} + +export function mintClaudeAcquisitionGeneration(deps: ClaudeStructuredSessionAdapterDeps): string { + return deps.mintAcquisitionGeneration?.() ?? randomUUID() +} + +/** + * The first-hand exit that removed a published session. Kept until the session + * is acquired again so acquisition cleanup that arrives after the exit finds + * what the ladder observed, not an absence it would otherwise report as proven. + */ +export type ClaudeSessionExit = { + connection: ClaudeStreamJsonConnection + /** Full session identity retained until its child tree is proven gone. */ + session: ClaudeSession + error: Error + /** The exit path's first proof attempt; retries must observe this result. */ + closePromise?: Promise + /** Shared lifecycle settlement for concurrent proof retries. */ + settlementPromise?: Promise +} + +export type ClaudeAcquisitionAttempt = { + connection: ClaudeStreamJsonConnection | null + prompts: ClaudePromptRegistry + buffered: (() => void)[] + published: boolean + cancelled: boolean + exitProven: boolean + finished: Promise + finish: () => void +} + +export function createClaudeAcquisitionAttempt( + prompts: ClaudePromptRegistry +): ClaudeAcquisitionAttempt { + let finish = (): void => {} + const finished = new Promise((resolve) => { + finish = resolve + }) + return { + connection: null, + prompts, + buffered: [], + published: false, + cancelled: false, + exitProven: false, + finished, + finish + } +} + +export class ClaudeAcquisitionRegistry { + private readonly attempts = new Map() + private closing = false + + get size(): number { + return this.attempts.size + } + + start( + sessionId: string, + prompts: ClaudePromptRegistry + ): { + previous: ClaudeAcquisitionAttempt | undefined + attempt: ClaudeAcquisitionAttempt + } { + if (this.closing) { + throw new Error('claude structured session adapter is closing') + } + const previous = this.attempts.get(sessionId) + const attempt = createClaudeAcquisitionAttempt(prompts) + this.attempts.set(sessionId, attempt) + return { previous, attempt } + } + + assertCurrent(sessionId: string, attempt: ClaudeAcquisitionAttempt): void { + if (this.closing || attempt.cancelled || this.attempts.get(sessionId) !== attempt) { + throw new Error(`claude session ${sessionId} was superseded while being acquired`) + } + } + + get(sessionId: string): ClaudeAcquisitionAttempt | undefined { + return this.attempts.get(sessionId) + } + + deleteIfCurrent(sessionId: string, attempt: ClaudeAcquisitionAttempt): void { + if (this.attempts.get(sessionId) === attempt) { + this.attempts.delete(sessionId) + } + } + + restoreIfCurrent( + sessionId: string, + replacement: ClaudeAcquisitionAttempt, + previous: ClaudeAcquisitionAttempt + ): void { + if (this.attempts.get(sessionId) === replacement) { + this.attempts.set(sessionId, previous) + } + } + + sessionIds(): IterableIterator { + return this.attempts.keys() + } + + close(): void { + this.closing = true + } +} + +export async function cancelClaudeAcquisitionAttempt( + attempt: ClaudeAcquisitionAttempt | undefined +): Promise { + if (!attempt) { + return true + } + return cancelProcessAcquisition({ + cancel: () => { + attempt.cancelled = true + }, + connection: () => attempt.connection, + exitProven: () => attempt.exitProven, + finished: attempt.finished + }) +} + +/** What an acquisition hands back to the adapter that owns the session map: + * event delivery ordered against publication, and the two exit settlements. */ +export type ClaudeAcquireCallbacks = { + deliver: (attempt: ClaudeAcquisitionAttempt, sessionId: string, event: () => void) => void + emit: ( + session: ClaudeSession | null, + events: StructuredAgentSessionEventSink | undefined, + event: ClaudeStructuredSessionEvent + ) => void + handleExit: (sessionId: string, attempt: ClaudeAcquisitionAttempt, error: Error) => void + settleExit: (sessionId: string, exit: ClaudeSessionExit) => Promise +} diff --git a/src/main/claude/claude-structured-session-test-support.ts b/src/main/claude/claude-structured-session-test-support.ts new file mode 100644 index 00000000000..6b0768b5134 --- /dev/null +++ b/src/main/claude/claude-structured-session-test-support.ts @@ -0,0 +1,260 @@ +import type { + AgentJournalMessageItem, + AgentSessionJournalIdentity +} from '../../shared/agent-session-journal-types' +import type { + ClaudeStreamJsonConnection, + ClaudeStreamJsonConnectionHandlers, + ClaudeStreamJsonLaunch, + openClaudeStreamJsonConnection +} from './claude-stream-json-connection' +import { + ClaudeStructuredSessionAdapter, + type ClaudeStructuredSessionAdapterDeps, + type ClaudeStructuredLaunch, + type ClaudeStructuredSessionEvent +} from './claude-structured-session-adapter' + +export const PROVIDER_SESSION_ID = '819cf9f8-e43c-4ad7-b50f-54aa158a726a' + +export const USER_MESSAGE: AgentJournalMessageItem = { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'ship it' }] +} + +export function identityFor(sessionId = 'session-1'): AgentSessionJournalIdentity { + return { + sessionId, + workspaceId: 'workspace-1', + hostId: 'host-1', + agent: 'claude', + providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } + } +} + +type Route = (params: Record | undefined) => unknown + +export type FakeConnection = Omit & { + closed: boolean + exitVerdict: ClaudeStreamJsonConnection['exitVerdict'] + launch: ClaudeStreamJsonLaunch + handlers: ClaudeStreamJsonConnectionHandlers + calls: { subtype: string; params?: Record }[] + sent: Record[] + closeCount: number +} + +export function fakeClaude( + options: { + initSessionId?: string + initUuid?: string + initModel?: string + initProof?: 'init' | 'session-start' | 'none' + initAccount?: unknown + exitBeforeInit?: string + settings?: unknown + replayUuid?: string | null + replayUuids?: (string | null)[] + capabilities?: string[] + unprovenCloseVerdict?: ClaudeStreamJsonConnection['exitVerdict'] + routes?: Record + } = {} +): { + connections: FakeConnection[] + openConnection: typeof openClaudeStreamJsonConnection + routes: Record +} { + const connections: FakeConnection[] = [] + const routes = options.routes ?? {} + let replayIndex = 0 + const routed = (subtype: string, params?: Record): unknown => { + const route = routes[subtype] + return route ? route(params) : undefined + } + const openConnection = (async (launch, handlers = {}) => { + const connection: FakeConnection = { + launch, + handlers, + calls: [], + sent: [], + closeCount: 0, + pid: 4321, + closed: false, + initializationResult: async () => { + connection.calls.push({ subtype: 'initialize' }) + if (options.exitBeforeInit) { + handlers.onExit?.(new Error(options.exitBeforeInit)) + return { models: [] } + } + if (options.initProof === 'session-start') { + handlers.onMessage?.({ + type: 'system', + subtype: 'hook_started', + hook_name: 'SessionStart:startup', + session_id: options.initSessionId ?? PROVIDER_SESSION_ID, + uuid: options.initUuid ?? 'init-uuid' + }) + } else if (options.initProof !== 'none') { + // Keys mirror the real system/init frame, which carries `model` but no + // effort of any kind: the current effort only comes back from + // get_settings. Never add a field the CLI does not send. + handlers.onMessage?.({ + type: 'system', + subtype: 'init', + session_id: options.initSessionId ?? PROVIDER_SESSION_ID, + uuid: options.initUuid ?? 'init-uuid', + model: options.initModel ?? 'claude-sonnet-5', + apiKeySource: 'none', + ...(options.capabilities ? { capabilities: options.capabilities } : {}) + }) + } + return { + models: [{ value: 'claude-sonnet', displayName: 'Sonnet' }], + ...(options.initAccount === undefined ? {} : { account: options.initAccount }) + } + }, + getSettings: async () => { + connection.calls.push({ subtype: 'get_settings' }) + // Shape measured from Claude Code 2.1.258: {applied, effective, sources}, + // and the only place the session's current effort is reported. + return ( + options.settings ?? { + applied: { model: 'claude-sonnet-5', effort: 'high', advisor: null, ultracode: false }, + effective: { model: 'claude-sonnet-5', effortLevel: 'high', env: {} }, + sources: {} + } + ) + }, + supportedModels: async () => { + connection.calls.push({ subtype: 'list_models' }) + return (routed('list_models') as unknown[] | undefined) ?? [] + }, + setModel: async (model) => { + connection.calls.push({ subtype: 'set_model', params: { model } }) + routed('set_model', { model }) + }, + setPermissionMode: async (mode) => { + connection.calls.push({ subtype: 'set_permission_mode', params: { mode } }) + routed('set_permission_mode', { mode }) + }, + applyFlagSettings: async (settings) => { + connection.calls.push({ subtype: 'apply_flag_settings', params: { settings } }) + routed('apply_flag_settings', { settings }) + }, + interrupt: async (interruptOptions) => { + connection.calls.push({ + subtype: 'interrupt', + params: interruptOptions?.cancelQueued ? { cancelQueued: true } : {} + }) + return routed('interrupt', interruptOptions) as + | Awaited> + | undefined + }, + cancelAsyncMessage: async (uuid) => { + connection.calls.push({ subtype: 'cancel_async_message', params: { uuid } }) + routed('cancel_async_message', { uuid }) + }, + send: async (message) => { + connection.sent.push(message) + if (message.type === 'user' && options.replayUuid !== null) { + const configuredReplayUuid = options.replayUuids + ? options.replayUuids[replayIndex++] + : options.replayUuid + const replayUuid = + configuredReplayUuid === undefined ? `user-uuid-${replayIndex}` : configuredReplayUuid + if (replayUuid !== null) { + handlers.onMessage?.({ + ...message, + uuid: replayUuid + }) + } + } + }, + exitVerdict: options.unprovenCloseVerdict ?? { root: 'live', tree: 'unverifiable' }, + close: async () => { + connection.closeCount += 1 + connection.closed = true + return options.unprovenCloseVerdict === undefined + } + } + connections.push(connection) + return connection + }) as typeof openClaudeStreamJsonConnection + return { connections, openConnection, routes } +} + +export function adapterFor( + claude: ReturnType, + launch: Partial = {}, + events: ClaudeStructuredSessionEvent[] = [], + persistedHandles: unknown[] = [], + initTimeoutMs?: number, + readTranscriptLeaf?: ClaudeStructuredSessionAdapterDeps['readTranscriptLeaf'], + persistHandle?: ClaudeStructuredSessionAdapterDeps['persistHandle'] +): ClaudeStructuredSessionAdapter { + return new ClaudeStructuredSessionAdapter({ + resolveLaunch: async () => ({ + pathToClaudeCodeExecutable: 'claude', + options: {}, + cwd: '/work/repo', + claudeConfigDir: '/accounts/claude', + providerSessionId: PROVIDER_SESSION_ID, + resumeLeafUuid: null, + resumed: false, + ...launch + }), + onEvent: (event) => events.push(event), + openConnection: claude.openConnection, + readProcessStartTime: async () => 1_700_000_000_000, + now: () => 1_700_000_000_500, + ...(initTimeoutMs === undefined ? {} : { initTimeoutMs }), + dispatchAckTimeoutMs: 10, + persistHandle: + persistHandle ?? + (async (handle) => { + persistedHandles.push(handle) + }), + ...(readTranscriptLeaf ? { readTranscriptLeaf } : {}) + }) +} + +export async function acquired( + claude: ReturnType, + launch: Partial = {}, + events: ClaudeStructuredSessionEvent[] = [] +): Promise { + const adapter = adapterFor(claude, launch, events) + await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + return adapter +} + +export function tick(): Promise { + return new Promise((resolve) => setImmediate(resolve)) +} + +export function invokeCanUseTool( + connection: FakeConnection, + toolName: string, + requestId: string, + toolUseID: string, + extra: { + input?: Record + suggestions?: unknown[] + signal?: AbortSignal + } = {} +): { promise: Promise; settled: () => boolean } { + const options = { + requestId, + toolUseID, + signal: extra.signal ?? new AbortController().signal, + ...(extra.suggestions ? { suggestions: extra.suggestions } : {}) + } as unknown as Parameters>[2] + let done = false + const promise = Promise.resolve( + connection.handlers.canUseTool?.(toolName, extra.input ?? {}, options) + ).finally(() => { + done = true + }) + return { promise, settled: () => done } +} diff --git a/src/main/claude/claude-transcript-branch-proof.ts b/src/main/claude/claude-transcript-branch-proof.ts index d7065caa275..605f619eb92 100644 --- a/src/main/claude/claude-transcript-branch-proof.ts +++ b/src/main/claude/claude-transcript-branch-proof.ts @@ -5,6 +5,10 @@ const MAX_CLAUDE_TRANSCRIPT_ANCESTRY = 10_000 type TranscriptNode = { parentUuid: string | null sessionId: string | null + /** First line where this UUID was observed in the append-only transcript. */ + lineIndex: number + /** UUIDs from result/init/stream frames and sidechains are never leaves. */ + disallowedLeaf: boolean } export type ClaudeTranscriptBranchProof = { @@ -27,6 +31,54 @@ export class ClaudeTranscriptTailIncompleteError extends Error { } } +/** The sampled cursor is no longer present, so a root proof may still recover safely. */ +export class ClaudeTranscriptPreviousCursorMissingError extends Error { + constructor() { + super( + 'Claude transcript branch proof failed: previous cursor is missing from the session graph' + ) + this.name = 'ClaudeTranscriptPreviousCursorMissingError' + } +} + +function proveMainLineAncestry( + nodes: Map, + startUuid: string, + providerSessionId: string +): void { + const visited = new Set() + let cursor: string | null = startUuid + for (let depth = 0; cursor !== null && depth < MAX_CLAUDE_TRANSCRIPT_ANCESTRY; depth += 1) { + if (visited.has(cursor)) { + throw transcriptError('cycle in parentUuid ancestry') + } + visited.add(cursor) + const node = nodes.get(cursor) + if (!node || node.sessionId !== providerSessionId) { + throw transcriptError(`missing ancestor ${cursor}`) + } + if (node.disallowedLeaf) { + throw transcriptError(`ancestor ${cursor} is not on the main transcript`) + } + cursor = node.parentUuid + } + if (cursor !== null) { + throw transcriptError('ancestry exceeds the bounded proof limit') + } +} + +function proveAppendOrder(nodes: Map): void { + for (const node of nodes.values()) { + if (!node.parentUuid) { + continue + } + const parent = nodes.get(node.parentUuid) + if (parent && parent.lineIndex >= node.lineIndex) { + throw transcriptError('parent row follows descendant') + } + } +} + export function proveClaudeTranscriptBranchFromJsonl(input: { contents: string providerSessionId: string @@ -34,6 +86,7 @@ export function proveClaudeTranscriptBranchFromJsonl(input: { }): ClaudeTranscriptBranchProof { const nodes = new Map() let leafUuid: string | null = null + let leafMarkerLineIndex = -1 const lines = input.contents.split('\n') for (const [index, line] of lines.entries()) { if (!line.trim()) { @@ -59,6 +112,7 @@ export function proveClaudeTranscriptBranchFromJsonl(input: { throw transcriptError('invalid last-prompt marker') } leafUuid = markerLeaf + leafMarkerLineIndex = index } const uuid = nonEmptyString(row.uuid) if (!uuid) { @@ -70,27 +124,60 @@ export function proveClaudeTranscriptBranchFromJsonl(input: { } const sessionId = nonEmptyString(row.sessionId) const existing = nodes.get(uuid) - if (existing && (existing.parentUuid !== parentUuid || existing.sessionId !== sessionId)) { + const disallowedLeaf = + row.isSidechain === true || + row.parent_tool_use_id != null || + row.type === 'result' || + row.type === 'stream_event' || + (row.type === 'system' && row.subtype === 'init') + if ( + existing && + (existing.parentUuid !== parentUuid || + existing.sessionId !== sessionId || + existing.disallowedLeaf !== disallowedLeaf) + ) { throw transcriptError(`record ${uuid} has conflicting ancestry`) } - nodes.set(uuid, { parentUuid, sessionId }) + nodes.set(uuid, { + parentUuid, + sessionId, + lineIndex: existing?.lineIndex ?? index, + disallowedLeaf + }) } if (!leafUuid) { throw transcriptError('missing last-prompt marker') } const leaf = nodes.get(leafUuid) - if (!leaf || leaf.sessionId !== input.providerSessionId) { + if (!leaf || leaf.sessionId !== input.providerSessionId || leaf.disallowedLeaf) { throw transcriptError('marker leaf is missing from the session graph') } + if (leaf.lineIndex > leafMarkerLineIndex) { + throw transcriptError('marker precedes its leaf record') + } const previousLeafUuid = input.previousLeafUuid if (!previousLeafUuid) { + proveMainLineAncestry(nodes, leafUuid, input.providerSessionId) + // A branch proof is based on an append-only snapshot. A child that appears + // before its claimed parent is not a post-snapshot descendant observation; + // accepting that graph would turn reordered/torn rows into durable ancestry. + proveAppendOrder(nodes) return { leafUuid, relation: 'initial' } } const previous = nodes.get(previousLeafUuid) - if (!previous || previous.sessionId !== input.providerSessionId) { - throw transcriptError('previous cursor is missing from the session graph') + if (!previous) { + throw new ClaudeTranscriptPreviousCursorMissingError() } + if (previous.sessionId !== input.providerSessionId || previous.disallowedLeaf) { + throw transcriptError('previous cursor is not on the main transcript') + } + // The latest marker can be equal to, or descend from, a sampled cursor. In + // either case prove the sampled cursor's own ancestry before accepting it; + // otherwise a cursor that descended through a parent-tool-use sidechain + // could be persisted and resumed as if it were on the main transcript. + proveMainLineAncestry(nodes, previousLeafUuid, input.providerSessionId) if (leafUuid === previousLeafUuid) { + proveAppendOrder(nodes) return { leafUuid, relation: 'same' } } const visited = new Set() @@ -104,8 +191,12 @@ export function proveClaudeTranscriptBranchFromJsonl(input: { if (!node || node.sessionId !== input.providerSessionId) { throw transcriptError(`missing ancestor ${cursor}`) } + if (node.disallowedLeaf) { + throw transcriptError(`ancestor ${cursor} is not on the main transcript`) + } cursor = node.parentUuid if (cursor === previousLeafUuid) { + proveAppendOrder(nodes) return { leafUuid, relation: 'descendant' } } } @@ -126,3 +217,37 @@ export async function proveClaudeTranscriptBranch(input: { previousLeafUuid: input.previousLeafUuid }) } + +/** Re-run a durable branch proof from the transcript root when a sampled cursor is stale. */ +export async function readClaudeTranscriptLeafWithReproof(input: { + readTranscriptLeaf: (input: { + providerSessionId: string + previousLeafUuid: string | null + claudeConfigDir: string + }) => Promise + claudeConfigDir: string + providerSessionId: string + previousLeafUuid: string | null +}): Promise { + try { + return await input.readTranscriptLeaf({ + providerSessionId: input.providerSessionId, + previousLeafUuid: input.previousLeafUuid, + claudeConfigDir: input.claudeConfigDir + }) + } catch (error) { + // A missing cursor can be stale after compaction and is safe to re-prove from the root. A torn + // tail is still being written; dropping the cursor would make a later sibling look admissible. + if ( + input.previousLeafUuid === null || + !(error instanceof ClaudeTranscriptPreviousCursorMissingError) + ) { + throw error + } + return input.readTranscriptLeaf({ + providerSessionId: input.providerSessionId, + previousLeafUuid: null, + claudeConfigDir: input.claudeConfigDir + }) + } +} diff --git a/src/main/claude/claude-tui-exit.test.ts b/src/main/claude/claude-tui-exit.test.ts new file mode 100644 index 00000000000..6e1140b0f4d --- /dev/null +++ b/src/main/claude/claude-tui-exit.test.ts @@ -0,0 +1,159 @@ +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' +import { + completeClaudeTuiExit, + readClaudeTranscriptEntryUuid, + readClaudeTranscriptLeafUuid +} from './claude-tui-exit' + +const roots: string[] = [] + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('Claude TUI exit', () => { + it('does not sample UUIDs from subagent stdout frames with a parent tool use', () => { + expect( + readClaudeTranscriptEntryUuid({ + type: 'assistant', + uuid: 'subagent-assistant', + parent_tool_use_id: 'parent-tool' + }) + ).toBeNull() + expect( + readClaudeTranscriptEntryUuid({ + type: 'assistant', + uuid: 'main-assistant', + parent_tool_use_id: null + }) + ).toBe('main-assistant') + }) + + it('reads the authoritative last-prompt leaf from a transcript tail', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-claude-tui-exit-')) + roots.push(root) + const transcriptPath = join(root, 'session.jsonl') + await writeFile( + transcriptPath, + [ + { type: 'user', uuid: 'user-one' }, + { type: 'assistant', uuid: 'assistant-one' }, + { type: 'last-prompt', leafUuid: 'chain-head' }, + { type: 'file-history-snapshot', snapshot: {} } + ] + .map((entry) => JSON.stringify(entry)) + .join('\n') + ) + + await expect(readClaudeTranscriptLeafUuid(transcriptPath)).resolves.toBe('chain-head') + }) + + it('falls back to the last persisted message when last-prompt metadata is absent', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-claude-tui-exit-')) + roots.push(root) + const transcriptPath = join(root, 'session.jsonl') + await writeFile( + transcriptPath, + [ + { type: 'user', uuid: 'user-one' }, + { type: 'assistant', uuid: 'assistant-one' }, + { type: 'system', subtype: 'init', uuid: 'init-frame' }, + { type: 'result', uuid: 'result-frame' }, + { type: 'stream_event', uuid: 'stream-event-frame' } + ] + .map((entry) => JSON.stringify(entry)) + .join('\n') + ) + + await expect(readClaudeTranscriptLeafUuid(transcriptPath)).resolves.toBe('assistant-one') + }) + + it('ignores sidechain messages when selecting a fallback transcript leaf', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-claude-tui-sidechain-leaf-')) + const transcriptPath = join(root, 'session.jsonl') + await writeFile( + transcriptPath, + [ + { type: 'assistant', uuid: 'main-assistant' }, + { type: 'assistant', uuid: 'subagent-assistant', isSidechain: true }, + { type: 'result', uuid: 'result-frame' } + ] + .map((entry) => JSON.stringify(entry)) + .join('\n'), + 'utf8' + ) + + await expect(readClaudeTranscriptLeafUuid(transcriptPath)).resolves.toBe('main-assistant') + }) + + it('persists the resumed chain head only after the exact Claude child exits', async () => { + let resolveExit!: (exit: { + pid: number + exitCode: number | null + signal: string | null + }) => void + const exitPromise = new Promise<{ + pid: number + exitCode: number | null + signal: string | null + }>((resolve) => { + resolveExit = resolve + }) + const persistHandle = vi.fn(async () => undefined) + const completion = completeClaudeTuiExit({ + childPid: 4210, + waitForChildExit: () => exitPromise, + sessionId: 'provider-session', + transcriptPath: '/accounts/claude/session.jsonl', + fence: 7, + persistHandle, + readLeafUuid: async () => 'tui-leaf', + linkId: 'tui-resumed-link', + now: () => 12 + }) + + expect(persistHandle).not.toHaveBeenCalled() + resolveExit({ pid: 4210, exitCode: 0, signal: null }) + + await expect(completion).resolves.toMatchObject({ + link: { + linkId: 'tui-resumed-link', + handle: { provider: 'claude', sessionId: 'provider-session', leafUuid: 'tui-leaf' }, + origin: 'resumed', + mintedAtFence: 7, + observedAt: 12 + } + }) + expect(persistHandle).toHaveBeenCalledTimes(1) + }) + + it('refuses another process exit and a missing transcript leaf', async () => { + const persistHandle = vi.fn(async () => undefined) + await expect( + completeClaudeTuiExit({ + childPid: 4210, + waitForChildExit: async () => ({ pid: 4211, exitCode: 0, signal: null }), + sessionId: 'provider-session', + transcriptPath: '/session.jsonl', + fence: 2, + persistHandle, + readLeafUuid: async () => 'leaf' + }) + ).rejects.toThrow(/did not belong to the Claude child/) + await expect( + completeClaudeTuiExit({ + childPid: 4210, + waitForChildExit: async () => ({ pid: 4210, exitCode: 1, signal: null }), + sessionId: 'provider-session', + transcriptPath: '/session.jsonl', + fence: 2, + persistHandle, + readLeafUuid: async () => null + }) + ).rejects.toThrow(/resumable transcript leaf/) + expect(persistHandle).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/claude/claude-tui-exit.ts b/src/main/claude/claude-tui-exit.ts new file mode 100644 index 00000000000..3772e9c5e52 --- /dev/null +++ b/src/main/claude/claude-tui-exit.ts @@ -0,0 +1,119 @@ +import { open } from 'node:fs/promises' +import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' +import { claudeProviderHandleLink } from './claude-structured-owner-identity' + +const TRANSCRIPT_TAIL_CHUNK_BYTES = 64 * 1024 +const TRANSCRIPT_TAIL_READ_LIMIT_BYTES = 4 * 1024 * 1024 + +type TranscriptLeafCandidate = { leafUuid: string; authoritative: boolean } + +function validLeafUuid(value: unknown): string | null { + if (typeof value !== 'string' || value.length === 0 || value.length > 512) { + return null + } + const hasControlCharacter = [...value].some((character) => { + const code = character.codePointAt(0) ?? 0 + return code <= 0x1f || code === 0x7f + }) + return value === value.trim() && !hasControlCharacter ? value : null +} + +export function readClaudeTranscriptEntryUuid(value: Record): string | null { + return value.isSidechain === true || + value.parent_tool_use_id != null || + (value.type !== 'user' && value.type !== 'assistant') + ? null + : validLeafUuid(value.uuid) +} + +function readLeafCandidate(line: string): TranscriptLeafCandidate | null { + try { + const value = JSON.parse(line) as Record + const lastPromptLeaf = value.type === 'last-prompt' ? validLeafUuid(value.leafUuid) : null + if (lastPromptLeaf) { + return { leafUuid: lastPromptLeaf, authoritative: true } + } + const messageLeaf = readClaudeTranscriptEntryUuid(value) + return messageLeaf ? { leafUuid: messageLeaf, authoritative: false } : null + } catch { + return null + } +} + +export async function readClaudeTranscriptLeafUuid(transcriptPath: string): Promise { + const file = await open(transcriptPath, 'r') + try { + const { size } = await file.stat() + let position = size + let suffix = '' + let fallback: string | null = null + let scanned = 0 + while (position > 0 && scanned < TRANSCRIPT_TAIL_READ_LIMIT_BYTES) { + const length = Math.min(TRANSCRIPT_TAIL_CHUNK_BYTES, position) + position -= length + scanned += length + const buffer = Buffer.alloc(length) + await file.read(buffer, 0, length, position) + const lines = `${buffer.toString('utf8')}${suffix}`.split(/\r?\n/) + suffix = position > 0 ? (lines.shift() ?? '') : '' + for (let index = lines.length - 1; index >= 0; index -= 1) { + const line = lines[index]?.trim() + if (!line) { + continue + } + const candidate = readLeafCandidate(line) + if (!candidate) { + continue + } + if (candidate.authoritative) { + return candidate.leafUuid + } + fallback ??= candidate.leafUuid + } + } + return fallback + } finally { + await file.close() + } +} + +export type ClaudeTuiChildExit = { + pid: number + exitCode: number | null + signal: string | null +} + +export async function completeClaudeTuiExit(input: { + childPid: number + waitForChildExit: () => Promise + sessionId: string + transcriptPath: string + fence: number + persistHandle: (link: AgentSessionProviderHandleLink) => Promise + readLeafUuid?: (transcriptPath: string) => Promise + linkId?: string + now?: () => number +}): Promise<{ + exit: ClaudeTuiChildExit + transcriptPath: string + link: AgentSessionProviderHandleLink +}> { + const exit = await input.waitForChildExit() + if (exit.pid !== input.childPid) { + throw new Error('The observed process exit did not belong to the Claude child.') + } + const leafUuid = await (input.readLeafUuid ?? readClaudeTranscriptLeafUuid)(input.transcriptPath) + if (!leafUuid) { + throw new Error('The exited Claude TUI did not persist a resumable transcript leaf.') + } + const link = claudeProviderHandleLink({ + sessionId: input.sessionId, + leafUuid, + resumed: true, + fence: input.fence, + ...(input.linkId ? { linkId: input.linkId } : {}), + observedAt: input.now?.() ?? Date.now() + }) + await input.persistHandle(link) + return { exit, transcriptPath: input.transcriptPath, link } +} diff --git a/src/main/claude/claude-tui-resume-launch.test.ts b/src/main/claude/claude-tui-resume-launch.test.ts new file mode 100644 index 00000000000..f907d1fcb4e --- /dev/null +++ b/src/main/claude/claude-tui-resume-launch.test.ts @@ -0,0 +1,227 @@ +import { chmodSync, mkdtempSync, mkdirSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { delimiter, join } from 'node:path' +import { describe, expect, it } from 'vitest' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { CLAUDE_AUTH_ENV_CONFLICT_MESSAGE } from '../claude-accounts/environment' +import { CLAUDE_DEFAULT_SETTING_SOURCES } from './claude-structured-launch-resolution' +import { CLAUDE_SPAWN_TOKEN_ENV } from './claude-structured-owner-identity' +import { createClaudeTuiResumeLaunchBuilder } from './claude-tui-resume-launch' + +function record(overrides: Partial = {}): AgentSessionRecord { + return { + sessionId: 'orca-session-1', + provider: 'claude', + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-folder', + workspaceKind: 'folder' + }, + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/accounts/claude-one' }, + providerHandleChain: [ + { + linkId: 'created', + handle: { provider: 'claude', sessionId: 'provider-session', leafUuid: 'leaf-one' }, + origin: 'created', + mintedAtFence: 1, + observedAt: 1 + } + ], + ...overrides + } as AgentSessionRecord +} + +function makeExecutable(path: string): void { + mkdirSync(join(path, '..'), { recursive: true }) + writeFileSync(path, '') + if (process.platform !== 'win32') { + chmodSync(path, 0o755) + } +} + +describe('Claude TUI resume launch', () => { + it('pins the workspace, account home, setting sources, and launch identity', async () => { + const build = createClaudeTuiResumeLaunchBuilder({ + resolveWorkspacePath: async (workspaceId) => `/workspaces/${workspaceId}`, + resolveCommand: () => '/usr/local/bin/claude', + resolveAuthPolicy: () => ({ stripAuthEnv: false }), + resolveEnv: () => ({ + SELECTED_ACCOUNT: 'one', + ANTHROPIC_AUTH_TOKEN: 'selected-account-token' + }), + inheritedEnv: { + ANTHROPIC_API_KEY: 'inherited-gateway-key', + ANTHROPIC_BASE_URL: 'https://inherited-gateway.invalid', + CLAUDE_CODE_SESSION_ID: 'parent-session', + SAFE_PARENT: 'kept' + } + }) + + const launch = await build({ record: record(), spawnToken: 'spawn-one' }) + + expect(launch).toMatchObject({ + command: '/usr/local/bin/claude', + args: [ + '--setting-sources', + CLAUDE_DEFAULT_SETTING_SOURCES.join(','), + '--resume', + 'provider-session' + ], + cwd: '/workspaces/workspace-folder', + providerSessionId: 'provider-session', + resumeLeafUuid: 'leaf-one' + }) + expect(launch.env).toMatchObject({ + SAFE_PARENT: 'kept', + SELECTED_ACCOUNT: 'one', + CLAUDE_CONFIG_DIR: '/accounts/claude-one', + ORCA_AGENT_LAUNCH_TOKEN: 'spawn-one', + [CLAUDE_SPAWN_TOKEN_ENV]: 'spawn-one', + ANTHROPIC_AUTH_TOKEN: 'selected-account-token' + }) + // System auth (the only state an explicit ANTHROPIC_AUTH_TOKEN overlay is legal in): + // the user's own inherited key is their sign-in and survives. The managed-account + // half — where it is stripped — is covered by 'structured-to-TUI handoff auth'. + expect(launch.env.ANTHROPIC_API_KEY).toBe('inherited-gateway-key') + // Endpoint selection is not credential material; the existing adapter pinning preserves it. + expect(launch.env.ANTHROPIC_BASE_URL).toBe('https://inherited-gateway.invalid') + expect(launch.env.CLAUDE_CODE_SESSION_ID).toBeUndefined() + }) + + it('pairs the resumed Claude CLI with its sibling Node runtime', async () => { + const root = mkdtempSync(join(tmpdir(), 'orca-claude-resume-')) + const binDir = join(root, 'bin') + const claudeCommand = join(binDir, process.platform === 'win32' ? 'claude.cmd' : 'claude') + const nodeCommand = join(binDir, process.platform === 'win32' ? 'node.cmd' : 'node') + makeExecutable(claudeCommand) + makeExecutable(nodeCommand) + + const build = createClaudeTuiResumeLaunchBuilder({ + resolveWorkspacePath: async () => '/workspace', + resolveCommand: () => claudeCommand, + resolveAuthPolicy: () => ({ stripAuthEnv: true }), + resolveEnv: () => ({ PATH: '/usr/bin' }), + inheritedEnv: {} + }) + + const launch = await build({ record: record(), spawnToken: 'spawn' }) + + expect((launch.env.PATH ?? launch.env.Path)?.split(delimiter)[0]).toBe(binDir) + }) + + it('uses the durable session environment instead of current account settings', async () => { + const build = createClaudeTuiResumeLaunchBuilder({ + resolveWorkspacePath: async () => '/workspace', + resolveCommand: () => 'claude', + resolveAuthPolicy: () => ({ stripAuthEnv: false }), + resolveEnv: () => ({ ANTHROPIC_AUTH_TOKEN: 'pinned-token' }), + inheritedEnv: {} + }) + + const launch = await build({ record: record(), spawnToken: 'spawn' }) + + expect(launch.env.ANTHROPIC_AUTH_TOKEN).toBe('pinned-token') + }) + + it('resolves the durable chain head instead of an earlier Claude leaf', async () => { + const nextRecord = record({ + providerHandleChain: [ + ...record().providerHandleChain, + { + linkId: 'resumed', + handle: { provider: 'claude', sessionId: 'provider-session', leafUuid: 'leaf-two' }, + origin: 'resumed', + mintedAtFence: 2, + observedAt: 2 + } + ] + }) + const build = createClaudeTuiResumeLaunchBuilder({ + resolveWorkspacePath: async () => '/workspace', + resolveCommand: () => 'claude', + resolveAuthPolicy: () => ({ stripAuthEnv: true }), + inheritedEnv: {} + }) + + await expect(build({ record: nextRecord, spawnToken: 'spawn-two' })).resolves.toMatchObject({ + providerSessionId: 'provider-session', + resumeLeafUuid: 'leaf-two' + }) + }) + + it('preserves durable Claude launch arguments before resume defaults', async () => { + const build = createClaudeTuiResumeLaunchBuilder({ + resolveWorkspacePath: async () => '/workspace', + resolveCommand: () => 'claude', + resolveAuthPolicy: () => ({ stripAuthEnv: true }), + inheritedEnv: {} + }) + + const launch = await build({ + record: record({ launchArgs: ['--model', 'claude-sonnet-4-5'] }), + spawnToken: 'spawn' + }) + + expect(launch.args.slice(0, 3)).toEqual(['--model', 'claude-sonnet-4-5', '--setting-sources']) + }) + + it('rejects missing Claude handles and unpinned account homes', async () => { + const build = createClaudeTuiResumeLaunchBuilder({ + resolveWorkspacePath: async () => '/workspace', + resolveCommand: () => 'claude', + resolveAuthPolicy: () => ({ stripAuthEnv: true }), + inheritedEnv: {} + }) + + await expect( + build({ record: record({ providerHandleChain: [] }), spawnToken: 'spawn' }) + ).rejects.toThrow('claude_tui_resume_handle_required') + await expect( + build({ + record: record({ accountHome: { variable: 'CODEX_HOME', path: '/wrong' } }), + spawnToken: 'spawn' + }) + ).rejects.toThrow(/CLAUDE_CONFIG_DIR/) + }) +}) + +// buildClaudeChildProcessEnv strips its inherited half unconditionally, so this module +// would have signed a system-auth user out of the session the structured path had just +// honoured. It is not wired up yet; the required policy is what stops the next caller +// from inheriting that. +describe('structured-to-TUI handoff auth', () => { + it('carries a system-auth user their own inherited credential', async () => { + const launch = await createClaudeTuiResumeLaunchBuilder({ + resolveWorkspacePath: async () => '/repos/workspace-1', + resolveCommand: () => '/usr/local/bin/claude', + resolveAuthPolicy: () => ({ stripAuthEnv: false }), + inheritedEnv: { ANTHROPIC_API_KEY: 'sk-ant-SHELL', PATH: '/usr/bin' } + })({ record: record(), spawnToken: 'token-1' }) + + expect(launch.env.ANTHROPIC_API_KEY).toBe('sk-ant-SHELL') + }) + + it('still strips it once a managed account owns the credential', async () => { + const launch = await createClaudeTuiResumeLaunchBuilder({ + resolveWorkspacePath: async () => '/repos/workspace-1', + resolveCommand: () => '/usr/local/bin/claude', + resolveAuthPolicy: () => ({ stripAuthEnv: true }), + inheritedEnv: { ANTHROPIC_API_KEY: 'sk-ant-SHELL', PATH: '/usr/bin' } + })({ record: record(), spawnToken: 'token-1' }) + + expect(launch.env.ANTHROPIC_API_KEY).toBeUndefined() + }) + + it('refuses a configured override of a pinned managed account, as the terminal path does', async () => { + await expect( + createClaudeTuiResumeLaunchBuilder({ + resolveWorkspacePath: async () => '/repos/workspace-1', + resolveCommand: () => '/usr/local/bin/claude', + resolveAuthPolicy: () => ({ stripAuthEnv: true }), + resolveEnv: () => ({ ANTHROPIC_API_KEY: 'sk-ant-CONFIGURED' }), + inheritedEnv: {} + })({ record: record(), spawnToken: 'token-1' }) + ).rejects.toThrow(CLAUDE_AUTH_ENV_CONFLICT_MESSAGE) + }) +}) diff --git a/src/main/claude/claude-tui-resume-launch.ts b/src/main/claude/claude-tui-resume-launch.ts new file mode 100644 index 00000000000..8c09335fd9d --- /dev/null +++ b/src/main/claude/claude-tui-resume-launch.ts @@ -0,0 +1,101 @@ +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { agentSessionProviderHandleChainHead } from '../../shared/agent-session-provider-handle' +import { withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution' +import { resolveClaudeCommand } from '../codex-cli/command' +import { getSpawnArgsForWindows } from '../win32-utils' +import type { ClaudeStructuredAuthPolicy } from '../claude-accounts/claude-structured-auth-policy' +import { + CLAUDE_AUTH_ENV_CONFLICT_MESSAGE, + claudeAuthEnvCarriedForward, + hasClaudeAuthEnvConflict +} from '../claude-accounts/environment' +import { buildClaudeChildProcessEnv } from './claude-child-process-environment' +import { claudeConfigDirEnvPatch } from './claude-config-dir-pin' +import { CLAUDE_DEFAULT_SETTING_SOURCES } from './claude-structured-launch-resolution' +import { CLAUDE_SPAWN_TOKEN_ENV } from './claude-structured-owner-identity' + +export const CLAUDE_TUI_RESUME_BASE_ARGS = [ + '--setting-sources', + CLAUDE_DEFAULT_SETTING_SOURCES.join(',') +] as const + +export type ClaudeTuiResumeLaunch = { + command: string + args: string[] + cwd: string + env: Record + providerSessionId: string + resumeLeafUuid: string | null +} + +export type ClaudeTuiResumeLaunchBuilderDeps = { + resolveWorkspacePath: (workspaceId: string) => Promise + resolveCommand?: () => string + resolveEnv?: () => Record + inheritedEnv?: NodeJS.ProcessEnv + /** + * Required so whoever wires this module up has to answer the question rather than + * inherit the wrong default: buildClaudeChildProcessEnv strips its inherited half + * unconditionally, which would sign out a system-auth user whose own ANTHROPIC_* + * is their only credential. Build it with claudeStructuredAuthPolicyForSettings. + */ + resolveAuthPolicy: () => Promise | ClaudeStructuredAuthPolicy +} + +export function createClaudeTuiResumeLaunchBuilder( + deps: ClaudeTuiResumeLaunchBuilderDeps +): (input: { record: AgentSessionRecord; spawnToken: string }) => Promise { + return async ({ record, spawnToken }) => { + if (record.provider !== 'claude') { + throw new Error(`session ${record.sessionId} is a ${record.provider} session`) + } + if (record.accountHome.variable !== 'CLAUDE_CONFIG_DIR') { + throw new Error(`claude sessions pin CLAUDE_CONFIG_DIR, not ${record.accountHome.variable}`) + } + const head = agentSessionProviderHandleChainHead(record.providerHandleChain) + if (head?.handle.provider !== 'claude') { + throw new Error('claude_tui_resume_handle_required') + } + + const command = (deps.resolveCommand ?? resolveClaudeCommand)() + const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(command, [ + ...(record.launchArgs ?? []), + ...CLAUDE_TUI_RESUME_BASE_ARGS, + '--resume', + head.handle.sessionId + ]) + const auth = await deps.resolveAuthPolicy() + const configuredEnv = deps.resolveEnv?.() ?? {} + if (auth.stripAuthEnv && hasClaudeAuthEnvConflict(configuredEnv)) { + throw new Error(CLAUDE_AUTH_ENV_CONFLICT_MESSAGE) + } + // The inherited half is always stripped downstream, so a system-auth user's own + // credential only reaches the resumed TUI if it is carried in the configured half. + const carriedAuth = auth.stripAuthEnv + ? {} + : claudeAuthEnvCarriedForward(deps.inheritedEnv ?? process.env) + // Compared against what the child would otherwise inherit, so the record's account + // home still wins over a diverging overlay without a needless pin. + const inheritedEnv = { ...(deps.inheritedEnv ?? process.env), ...configuredEnv } + const env = buildClaudeChildProcessEnv( + { + ...carriedAuth, + ...configuredEnv, + ...claudeConfigDirEnvPatch(record.accountHome.path, { env: inheritedEnv }), + ORCA_AGENT_LAUNCH_TOKEN: spawnToken, + [CLAUDE_SPAWN_TOKEN_ENV]: spawnToken + }, + { inheritedEnv: deps.inheritedEnv } + ) + const pairedEnv = withCliRuntimeOnPath(command, env, { platform: process.platform }) + + return { + command: spawnCmd, + args: spawnArgs, + cwd: await deps.resolveWorkspacePath(record.location.workspaceId), + env: pairedEnv, + providerSessionId: head.handle.sessionId, + resumeLeafUuid: head.handle.leafUuid + } + } +} diff --git a/src/main/claude/claude-tui-resume-proof.test.ts b/src/main/claude/claude-tui-resume-proof.test.ts new file mode 100644 index 00000000000..6d2e99d4b00 --- /dev/null +++ b/src/main/claude/claude-tui-resume-proof.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest' +import { proveClaudeTuiResume, readClaudeTuiSessionStartEvidence } from './claude-tui-resume-proof' + +const SESSION = '91deba8d-a398-4b69-a05d-35041536fe8e' +const TRANSCRIPT = '/accounts/claude/projects/workspace/transcript.jsonl' + +function envelope(overrides: Record = {}): Record { + return { + launchToken: 'spawn-one', + payload: JSON.stringify({ + hook_event_name: 'SessionStart', + source: 'resume', + session_id: SESSION, + transcript_path: TRANSCRIPT, + ...overrides + }) + } +} + +describe('Claude TUI resume proof', () => { + it('reads SessionStart identity from the hook envelope', () => { + expect(readClaudeTuiSessionStartEvidence(envelope())).toEqual({ + hookEventName: 'SessionStart', + source: 'resume', + sessionId: SESSION, + transcriptPath: TRANSCRIPT, + launchToken: 'spawn-one' + }) + }) + + it('proves the exact launched session and transcript without terminal output', async () => { + await expect( + proveClaudeTuiResume({ + expectedSessionId: SESSION, + expectedTranscriptPath: TRANSCRIPT, + expectedLaunchToken: 'spawn-one', + waitForSessionStart: async () => envelope() + }) + ).resolves.toMatchObject({ sessionId: SESSION, transcriptPath: TRANSCRIPT }) + }) + + it.each([ + ['source', { source: 'startup' }, /resume SessionStart/], + ['session', { session_id: 'other-session' }, /different Claude session/], + ['transcript', { transcript_path: '/other/transcript.jsonl' }, /different Claude transcript/] + ])('rejects a mismatched %s', async (_name, overrides, expected) => { + await expect( + proveClaudeTuiResume({ + expectedSessionId: SESSION, + expectedTranscriptPath: TRANSCRIPT, + expectedLaunchToken: 'spawn-one', + waitForSessionStart: async () => envelope(overrides) + }) + ).rejects.toThrow(expected) + }) + + it('rejects a SessionStart from another launched process', async () => { + await expect( + proveClaudeTuiResume({ + expectedSessionId: SESSION, + expectedTranscriptPath: TRANSCRIPT, + expectedLaunchToken: 'spawn-two', + waitForSessionStart: async () => envelope() + }) + ).rejects.toThrow(/different launched process/) + }) + + it('compares Windows paths using host path semantics', async () => { + await expect( + proveClaudeTuiResume({ + expectedSessionId: SESSION, + expectedTranscriptPath: 'C:\\Users\\Dev\\session.jsonl', + expectedLaunchToken: 'spawn-one', + platform: 'win32', + waitForSessionStart: async () => + envelope({ transcript_path: 'c:\\users\\dev\\session.jsonl' }) + }) + ).resolves.toMatchObject({ sessionId: SESSION }) + }) +}) diff --git a/src/main/claude/claude-tui-resume-proof.ts b/src/main/claude/claude-tui-resume-proof.ts new file mode 100644 index 00000000000..f352423116e --- /dev/null +++ b/src/main/claude/claude-tui-resume-proof.ts @@ -0,0 +1,111 @@ +import { posix, win32 } from 'node:path' + +export type ClaudeTuiSessionStartEvidence = { + hookEventName: 'SessionStart' + source: 'resume' + sessionId: string + transcriptPath: string + launchToken: string +} + +function record(value: unknown): Record | null { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : null +} + +function nonEmptyString(value: unknown): string | null { + return typeof value === 'string' && value.length > 0 ? value : null +} + +function hookPayload(envelope: Record): Record | null { + if (typeof envelope.payload === 'string') { + try { + return record(JSON.parse(envelope.payload)) + } catch { + return null + } + } + return record(envelope.payload) ?? envelope +} + +export function readClaudeTuiSessionStartEvidence( + value: unknown +): ClaudeTuiSessionStartEvidence | null { + const envelope = record(value) + if (!envelope) { + return null + } + const payload = hookPayload(envelope) + if (!payload) { + return null + } + const hookEventName = nonEmptyString(payload.hook_event_name ?? payload.hookEventName) + const source = nonEmptyString(payload.source) + const sessionId = nonEmptyString(payload.session_id ?? payload.sessionId) + const transcriptPath = nonEmptyString(payload.transcript_path ?? payload.transcriptPath) + const launchToken = nonEmptyString(envelope.launchToken ?? payload.launchToken) + return hookEventName === 'SessionStart' && + source === 'resume' && + sessionId && + transcriptPath && + launchToken + ? { hookEventName, source, sessionId, transcriptPath, launchToken } + : null +} + +function comparablePath(value: string, platform: NodeJS.Platform): string | null { + if (value.includes('\0')) { + return null + } + const path = platform === 'win32' ? win32 : posix + if (!path.isAbsolute(value)) { + return null + } + const normalized = path.normalize(value) + return platform === 'win32' ? normalized.toLowerCase() : normalized +} + +export async function proveClaudeTuiResume(input: { + expectedSessionId: string + expectedTranscriptPath: string + expectedLaunchToken: string + waitForSessionStart: () => Promise + timeoutMs?: number + platform?: NodeJS.Platform +}): Promise { + const timeoutMs = input.timeoutMs ?? 15_000 + let timer: ReturnType | undefined + try { + const evidence = readClaudeTuiSessionStartEvidence( + await Promise.race([ + input.waitForSessionStart(), + new Promise((_resolve, reject) => { + timer = setTimeout( + () => reject(new Error('The agent terminal did not prove the expected Claude resume.')), + timeoutMs + ) + timer.unref?.() + }) + ]) + ) + if (!evidence) { + throw new Error('The agent terminal did not emit a Claude resume SessionStart proof.') + } + if (evidence.launchToken !== input.expectedLaunchToken) { + throw new Error('The Claude resume proof came from a different launched process.') + } + if (evidence.sessionId !== input.expectedSessionId) { + throw new Error('The agent terminal resumed a different Claude session.') + } + const platform = input.platform ?? process.platform + const expectedPath = comparablePath(input.expectedTranscriptPath, platform) + const observedPath = comparablePath(evidence.transcriptPath, platform) + if (!expectedPath || !observedPath || observedPath !== expectedPath) { + throw new Error('The agent terminal resumed a different Claude transcript.') + } + return evidence + } finally { + clearTimeout(timer) + } +} diff --git a/src/main/claude/claude-tui-resume-real-binary.integration.test.ts b/src/main/claude/claude-tui-resume-real-binary.integration.test.ts new file mode 100644 index 00000000000..9ba3daf2285 --- /dev/null +++ b/src/main/claude/claude-tui-resume-real-binary.integration.test.ts @@ -0,0 +1,279 @@ +import { spawnSync } from 'node:child_process' +import { randomUUID } from 'node:crypto' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { homedir, tmpdir } from 'node:os' +import { join } from 'node:path' +import * as pty from 'node-pty' +import { afterEach, describe, expect, it } from 'vitest' +import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { resolveClaudeCommand } from '../codex-cli/command' +import { readStructuredTuiProcessIdentity } from '../runtime/structured-tui-process-identity' +import { getSpawnArgsForWindows } from '../win32-utils' +import { CLAUDE_STRUCTURED_BASE_OPTIONS } from './claude-structured-launch-resolution' +import { + ClaudeStructuredSessionAdapter, + type ClaudeStructuredSessionEvent +} from './claude-structured-session-adapter' +import { createClaudeTuiResumeLaunchBuilder } from './claude-tui-resume-launch' +import { proveClaudeTuiResume } from './claude-tui-resume-proof' + +const command = resolveClaudeCommand() +const claudeAvailable = + spawnSync(command, ['--version'], { stdio: 'ignore', timeout: 5_000 }).status === 0 +const authStatusLaunch = getSpawnArgsForWindows(command, ['auth', 'status', '--json']) +const claudeAuthenticated = (() => { + if (!claudeAvailable) { + return false + } + const result = spawnSync(authStatusLaunch.spawnCmd, authStatusLaunch.spawnArgs, { + encoding: 'utf8', + windowsHide: true, + timeout: 5_000 + }) + return result.status === 0 && /"loggedIn"\s*:\s*true/.test(result.stdout) +})() +const roots: string[] = [] +const transcripts: string[] = [] + +function shellQuote(value: string): string { + return process.platform === 'win32' + ? `"${value.replace(/"/g, '""')}"` + : `'${value.replace(/'/g, `'"'"'`)}'` +} + +async function installCaptureHook( + root: string +): Promise<{ eventsPath: string; settingsPath: string }> { + const scriptPath = join(root, 'capture-session-start.cjs') + const eventsPath = join(root, 'session-start.jsonl') + const settingsPath = join(root, 'settings.json') + await writeFile( + scriptPath, + [ + "const { appendFileSync } = require('node:fs')", + "let input = ''", + "process.stdin.setEncoding('utf8')", + "process.stdin.on('data', (chunk) => { input += chunk })", + "process.stdin.on('end', () => {", + ' const payload = JSON.parse(input)', + ' payload.launchToken = process.env.ORCA_AGENT_LAUNCH_TOKEN', + ' appendFileSync(process.argv[2], `${JSON.stringify(payload)}\\n`)', + '})', + '' + ].join('\n') + ) + await writeFile( + settingsPath, + JSON.stringify({ + theme: 'dark', + hooks: { + SessionStart: [ + { + hooks: [ + { + type: 'command', + command: [process.execPath, scriptPath, eventsPath].map(shellQuote).join(' ') + } + ] + } + ] + } + }) + ) + return { eventsPath, settingsPath } +} + +async function waitForHook( + eventsPath: string, + source: 'startup' | 'resume' +): Promise> { + const deadline = Date.now() + 15_000 + while (Date.now() < deadline) { + const contents = await readFile(eventsPath, 'utf8').catch(() => '') + for (const line of contents.split(/\r?\n/)) { + if (!line.trim()) { + continue + } + const event = JSON.parse(line) as Record + if (event.hook_event_name === 'SessionStart' && event.source === source) { + return event + } + } + await new Promise((resolve) => setTimeout(resolve, 50)) + } + throw new Error(`Claude did not emit a ${source} SessionStart hook`) +} + +type RunningTui = { proc: pty.IPty; exited: Promise } + +function spawnResumeTui(args: string[], env: Record): RunningTui { + const direct = process.platform === 'win32' + const proc = pty.spawn( + direct ? command : process.env.SHELL || '/bin/zsh', + direct ? args : ['-l'], + { + name: 'xterm-256color', + cols: 100, + rows: 30, + cwd: process.cwd(), + env: { ...env, TERM: 'xterm-256color' } + } + ) + if (!direct) { + setTimeout(() => { + proc.write(`${[command, ...args].map(shellQuote).join(' ')}\r`) + }, 100).unref() + } + return { proc, exited: new Promise((resolve) => proc.onExit(() => resolve())) } +} + +function structuredIdentity(providerSessionId: string): AgentSessionJournalIdentity { + return { + sessionId: 'orca-real-claude-resume', + workspaceId: 'workspace-real', + hostId: 'local', + agent: 'claude', + providerHandle: { kind: 'claude', sessionId: providerSessionId, leafUuid: null } + } +} + +async function waitForStructuredResult(events: ClaudeStructuredSessionEvent[]): Promise { + const deadline = Date.now() + 30_000 + while (Date.now() < deadline) { + if (events.some((event) => event.type === 'message' && event.message.type === 'result')) { + return + } + await new Promise((resolve) => setTimeout(resolve, 50)) + } + throw new Error('Claude structured session did not finish its product-path turn') +} + +async function stopTui(tui: RunningTui): Promise { + try { + tui.proc.kill('SIGKILL') + } catch { + return + } + await Promise.race([ + tui.exited, + new Promise((_resolve, reject) => + setTimeout(() => reject(new Error('Claude TUI did not exit after cleanup')), 5_000) + ) + ]) +} + +afterEach(async () => { + await Promise.all(transcripts.splice(0).map((path) => rm(path, { force: true }))) + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe.skipIf(!claudeAuthenticated)('real Claude TUI resume proof', () => { + it('resumes a product-created structured session and proves its exact child', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-claude-tui-resume-')) + roots.push(root) + const { eventsPath, settingsPath } = await installCaptureHook(root) + const providerSessionId = randomUUID() + const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), '.claude') + const events: ClaudeStructuredSessionEvent[] = [] + const adapter = new ClaudeStructuredSessionAdapter({ + resolveLaunch: async () => ({ + pathToClaudeCodeExecutable: command, + options: { + ...CLAUDE_STRUCTURED_BASE_OPTIONS, + extraArgs: { ...CLAUDE_STRUCTURED_BASE_OPTIONS.extraArgs, settings: settingsPath }, + sessionId: providerSessionId + }, + cwd: process.cwd(), + claudeConfigDir, + providerSessionId, + resumeLeafUuid: null, + resumed: false + }), + onEvent: (event) => events.push(event), + readProcessStartTime: async () => 1 + }) + let resumed: RunningTui | null = null + try { + const acquisition = await adapter.acquire({ + identity: structuredIdentity(providerSessionId), + fence: 1, + spawnToken: 'real-create' + }) + await expect( + adapter.dispatch({ + sessionId: 'orca-real-claude-resume', + clientMessageId: 'real-product-turn', + fence: 1, + body: { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'Reply only with ORCA_RESUME_READY.' }] + } + }) + ).resolves.toMatchObject({ state: 'accepted' }) + await waitForStructuredResult(events) + const started = await waitForHook(eventsPath, 'startup') + const transcriptPath = String(started.transcript_path) + transcripts.push(transcriptPath) + expect(started.session_id).toBe(providerSessionId) + await adapter.closeAll() + + const record = { + sessionId: 'orca-real-claude-resume', + provider: 'claude', + location: { workspaceId: 'workspace-real' }, + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: claudeConfigDir }, + providerHandleChain: [ + { + linkId: 'created-real', + handle: acquisition.link.handle, + origin: 'created', + mintedAtFence: 1, + observedAt: 1 + } + ] + } as AgentSessionRecord + const launch = await createClaudeTuiResumeLaunchBuilder({ + resolveWorkspacePath: async () => process.cwd(), + resolveCommand: () => command, + // The real binary authenticates from the developer's own environment here, + // which is the system-auth case: stripping it would sign the resume out. + resolveAuthPolicy: () => ({ stripAuthEnv: false }) + })({ record, spawnToken: 'real-resume' }) + resumed = spawnResumeTui([...launch.args, '--settings', settingsPath], launch.env) + let resumedOutput = '' + resumed.proc.onData((data) => { + resumedOutput = `${resumedOutput}${data}`.slice(-4_000) + }) + + const [processIdentity, proof] = await Promise.all([ + readStructuredTuiProcessIdentity({ + hostId: 'local', + rootPid: resumed.proc.pid, + spawnToken: 'real-resume', + agent: 'claude' + }), + proveClaudeTuiResume({ + expectedSessionId: providerSessionId, + expectedTranscriptPath: transcriptPath, + expectedLaunchToken: 'real-resume', + waitForSessionStart: () => waitForHook(eventsPath, 'resume') + }).catch((error) => { + throw new Error(`${String(error)}\nClaude output: ${resumedOutput}`) + }) + ]) + expect(processIdentity).toMatchObject({ + hostId: 'local', + spawnToken: 'real-resume', + pid: expect.any(Number) + }) + expect(proof).toMatchObject({ sessionId: providerSessionId, transcriptPath }) + } finally { + await adapter.closeAll() + if (resumed) { + await stopTui(resumed) + } + } + }, 30_000) +}) diff --git a/src/main/codex/codex-structured-session-close.test.ts b/src/main/codex/codex-structured-session-close.test.ts index 4238c75de9e..b04e7bc2540 100644 --- a/src/main/codex/codex-structured-session-close.test.ts +++ b/src/main/codex/codex-structured-session-close.test.ts @@ -11,6 +11,8 @@ import { } from './codex-structured-session-adapter' import { handleCodexSessionExit } from './codex-structured-session-close' import type { CodexSession } from './codex-structured-session-state' +import type { StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { StructuredAgentSessionAdapterRouter } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router' const THREAD = 'thread-1' @@ -60,6 +62,16 @@ function adapterFixture() { return { adapter, connections, events } } +function claudeAdapterStub(): StructuredAgentSessionAdapter { + return { + acquire: vi.fn(async () => ({ process: { pid: 1 } }) as never), + dispatch: vi.fn(), + cancelTurn: vi.fn(), + answerPrompt: vi.fn(), + setOption: vi.fn() + } +} + describe('Codex structured session close lifecycle', () => { it('forwards a one-shot exit when lifecycle admission is rejected', () => { const connection: CodexAppServerConnection = { @@ -164,4 +176,27 @@ describe('Codex structured session close lifecycle', () => { { cause: 'unexpected-exit', reason: 'sink failed', fence: 7 } ]) }) + + it('routes Codex sink-failure recovery through force-close and preserves unexpected-exit settlement', async () => { + const { adapter, connections, events } = adapterFixture() + const router = new StructuredAgentSessionAdapterRouter( + { claude: claudeAdapterStub(), codex: adapter }, + async () => {} + ) + await router.acquire({ identity: identity('session-1'), fence: 7, spawnToken: 'spawn-1' }) + const current = connections[0] + if (!current) { + throw new Error('missing connection') + } + current.connection.close = async () => { + current.handlers.onExit?.(new Error('journal sink failed')) + return true + } + + const forceCloseSession = router.forceCloseSession + await expect(forceCloseSession('session-1')).resolves.toBe(true) + expect(events.filter((event) => event.type === 'ended')).toMatchObject([ + { cause: 'unexpected-exit', reason: 'journal sink failed', fence: 7 } + ]) + }) }) diff --git a/src/main/ipc/pty/ipc/spawn-env.ts b/src/main/ipc/pty/ipc/spawn-env.ts index af5da3858bd..94f1acf363e 100644 --- a/src/main/ipc/pty/ipc/spawn-env.ts +++ b/src/main/ipc/pty/ipc/spawn-env.ts @@ -7,7 +7,11 @@ import { isRemoteAgentHooksEnabled } from '../../../../shared/agent-hook-relay' import { isOpaqueRemintedPaneKey } from '../../../../shared/pane-key-alias' import { isValidTerminalTabId } from '../../../../shared/terminal-tab-id' import { isClaudeAuthSwitchInProgress } from '../../../claude-accounts/live-pty-gate' -import { hasClaudeAuthEnvConflict } from '../../../claude-accounts/environment' +import { + CLAUDE_AUTH_ENV_CONFLICT_MESSAGE, + CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE, + hasClaudeAuthEnvConflict +} from '../../../claude-accounts/environment' import { LocalPtyProvider } from '../../../providers/local-pty-provider' import { resolvePathEnvKey } from '../../../pty/windows-environment-path' import { routesFreshSpawnsToLocalProvider } from '../host-env/fresh-spawn-routing' @@ -20,12 +24,10 @@ import { assemblePtyIpcSpawnCodexEnv } from './spawn-env-codex' export async function assemblePtyIpcSpawnEnv(ctx: PtyIpcSpawnState): Promise { const args = ctx.args if (ctx.isClaudeLaunch && isClaudeAuthSwitchInProgress()) { - throw new Error('A Claude account switch is in progress. Try again after it finishes.') + throw new Error(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE) } if (ctx.claudeAuth?.stripAuthEnv && hasClaudeAuthEnvConflict(args.env)) { - throw new Error( - 'This Claude launch defines explicit Anthropic auth environment variables. Remove those overrides before using a managed Claude account.' - ) + throw new Error(CLAUDE_AUTH_ENV_CONFLICT_MESSAGE) } // Why: the daemon-backed provider skips LocalPtyProvider's buildSpawnEnv, so assemble the same host-local env here for parity. // Safety: skip entirely for SSH — every injection is a loopback secret or a local path that leaks or misleads on the remote host. diff --git a/src/main/ipc/pty/ipc/spawn-preflight.ts b/src/main/ipc/pty/ipc/spawn-preflight.ts index f40b46e5dd5..f885d6e2f6f 100644 --- a/src/main/ipc/pty/ipc/spawn-preflight.ts +++ b/src/main/ipc/pty/ipc/spawn-preflight.ts @@ -4,6 +4,7 @@ import { } from '../../../../shared/local-windows-terminal-runtime' import { isWslUncPath, toWindowsWslPath } from '../../../../shared/wsl-paths' import { isClaudeAuthSwitchInProgress } from '../../../claude-accounts/live-pty-gate' +import { CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE } from '../../../claude-accounts/environment' import { mintPtySessionId } from '../../../daemon/pty-session-id' import { resolveWslSessionContext } from '../../../daemon/wsl-session-context' import { LocalPtyProvider } from '../../../providers/local-pty-provider' @@ -193,7 +194,7 @@ export async function preparePtyIpcSpawnPreflight(ctx: PtyIpcSpawnState): Promis ctx.isClaudeLaunch = !ctx.preAdoptedStablePane && !args.connectionId && isClaudeLaunchCommand(args.command) if (ctx.isClaudeLaunch && isClaudeAuthSwitchInProgress()) { - throw new Error('A Claude account switch is in progress. Try again after it finishes.') + throw new Error(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE) } ctx.terminalRuntimeOptions = process.platform === 'win32' && !args.connectionId diff --git a/src/main/ipc/pty/runtime/spawn-preflight.ts b/src/main/ipc/pty/runtime/spawn-preflight.ts index aed89b44df8..43fd2778119 100644 --- a/src/main/ipc/pty/runtime/spawn-preflight.ts +++ b/src/main/ipc/pty/runtime/spawn-preflight.ts @@ -23,7 +23,11 @@ import { import { stripRemotePaneEnvWhenHooksDisabled } from '../provider/liveness' import { isTuiAgent } from '../../../../shared/tui-agent-config' import { isClaudeAuthSwitchInProgress } from '../../../claude-accounts/live-pty-gate' -import { hasClaudeAuthEnvConflict } from '../../../claude-accounts/environment' +import { + CLAUDE_AUTH_ENV_CONFLICT_MESSAGE, + CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE, + hasClaudeAuthEnvConflict +} from '../../../claude-accounts/environment' import { isSafePtySessionId, mintPtySessionId, @@ -65,7 +69,7 @@ export async function prepareRuntimePtySpawn( ctx.isClaudeLaunch = !ctx.preAdoptedStablePane && !args.connectionId && isClaudeLaunchCommand(args.command) if (ctx.isClaudeLaunch && isClaudeAuthSwitchInProgress()) { - throw new Error('A Claude account switch is in progress. Try again after it finishes.') + throw new Error(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE) } // Why: runtime-created terminals carry no renderer-computed projectRuntime; resolve from worktreeId to honor the project's Windows runtime. ctx.terminalRuntimeOptions = @@ -134,12 +138,10 @@ export async function prepareRuntimePtySpawn( ? await ctx.deps.prepareClaudeAuth(ctx.codexSelectionTarget) : null if (ctx.isClaudeLaunch && isClaudeAuthSwitchInProgress()) { - throw new Error('A Claude account switch is in progress. Try again after it finishes.') + throw new Error(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE) } if (ctx.claudeAuth?.stripAuthEnv && hasClaudeAuthEnvConflict(args.env)) { - throw new Error( - 'This Claude launch defines explicit Anthropic auth environment variables. Remove those overrides before using a managed Claude account.' - ) + throw new Error(CLAUDE_AUTH_ENV_CONFLICT_MESSAGE) } ctx.shouldPersistHostSessionBinding = args.persistHostSessionBinding === true diff --git a/src/main/ipc/runtime.test.ts b/src/main/ipc/runtime.test.ts index dc7f8a01cd7..07010087363 100644 --- a/src/main/ipc/runtime.test.ts +++ b/src/main/ipc/runtime.test.ts @@ -136,6 +136,40 @@ describe('registerRuntimeHandlers', () => { }) }) + it('projects Claude structured tabs to the same-version desktop client', async () => { + const claudeTab = { + type: 'agent-session', + id: 'agent-session:claude-1', + title: 'Claude Chat', + sessionId: 'claude-1', + agent: 'claude', + isActive: true + } + const runtime = { + getRuntimeId: vi.fn().mockReturnValue('runtime-1'), + restoreStructuredAgentSessionTabs: vi.fn(async () => undefined), + listMobileSessionTabs: vi.fn(async () => ({ + worktree: 'workspace-1', + publicationEpoch: 'epoch-1', + snapshotVersion: 1, + activeGroupId: 'group-1', + activeTabId: claudeTab.id, + activeTabType: 'agent-session', + tabGroups: [{ id: 'group-1', activeTabId: claudeTab.id, tabOrder: [claudeTab.id] }], + tabs: [claudeTab] + })) + } + + registerRuntimeHandlers(runtime as never) + const callRegistration = handleMock.mock.calls.find(([channel]) => channel === 'runtime:call') + const result = await callRegistration![1](runtimeCallEvent(), { + method: 'session.tabs.list', + params: { worktree: 'id:workspace-1' } + }) + + expect(result).toMatchObject({ ok: true, result: { tabs: [claudeTab] } }) + }) + it('registers project group runtime RPC methods for local desktop callers', async () => { const runtime = { syncWindowGraph: vi.fn(), diff --git a/src/main/ipc/runtime.ts b/src/main/ipc/runtime.ts index 901d14bfce6..3901d8b1ffa 100644 --- a/src/main/ipc/runtime.ts +++ b/src/main/ipc/runtime.ts @@ -10,7 +10,10 @@ import type { import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' import type { ClientHostedBrowserRowsEvent } from '../../shared/client-hosted-browser-rows' import { TERMINAL_FIT_RESTORE_DEADLINE_MS } from '../../shared/terminal-fit-restore-deadline' -import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../shared/protocol-version' +import { + CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY +} from '../../shared/protocol-version' import { RpcDispatcher } from '../runtime/rpc/dispatcher' import { ALL_RPC_METHODS } from '../runtime/rpc/methods' import { DesktopRuntimeSenderLifecycle } from './desktop-runtime-sender-lifecycle' @@ -76,7 +79,10 @@ export function registerRuntimeHandlers(runtime: OrcaRuntimeService): void { clientId: 'desktop-renderer', clientKind: 'runtime', connectionId: desktopSenders.connectionIdFor(event.sender), - clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] + clientCapabilities: [ + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY + ] } )) as RuntimeRpcResponse } @@ -121,7 +127,10 @@ export function registerRuntimeHandlers(runtime: OrcaRuntimeService): void { clientId: 'desktop-renderer', clientKind: 'runtime', connectionId, - clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] + clientCapabilities: [ + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY + ] } ) .finally(stop) diff --git a/src/main/native-chat/agent-session-wire/claude-stream-json-frame-schema.ts b/src/main/native-chat/agent-session-wire/claude-stream-json-frame-schema.ts index 4f3ef118af5..4f21285e417 100644 --- a/src/main/native-chat/agent-session-wire/claude-stream-json-frame-schema.ts +++ b/src/main/native-chat/agent-session-wire/claude-stream-json-frame-schema.ts @@ -1,4 +1,4 @@ -// SDKMessage discriminators from Claude Agent SDK 0.3.231 / Claude Code 2.1.231. +// SDKMessage discriminators from Claude Agent SDK 0.3.251 / Claude Code 2.1.258. export const CLAUDE_STREAM_JSON_FRAME_KINDS = [ 'message:assistant', 'message:user', @@ -42,7 +42,15 @@ export const CLAUDE_STREAM_JSON_FRAME_KINDS = [ 'message:prompt_suggestion', 'message:system:mirror_error', 'message:system:informational', - 'message:conversation_reset' + 'message:conversation_reset', + // Queue bookkeeping the CLI emits per client-supplied command uuid. Absent + // from the SDK's SDKMessage union, which is why it reached users as raw JSON. + 'message:command_lifecycle', + 'message:result:success', + 'message:result:error_during_execution', + 'message:result:error_max_turns', + 'message:result:error_max_budget_usd', + 'message:result:error_max_structured_output_retries' ] as const export type ClaudeStreamJsonFrameKind = (typeof CLAUDE_STREAM_JSON_FRAME_KINDS)[number] diff --git a/src/main/native-chat/agent-session-wire/provider-frame-disposition.test.ts b/src/main/native-chat/agent-session-wire/provider-frame-disposition.test.ts index ad9ca66c52a..22bd645d8a6 100644 --- a/src/main/native-chat/agent-session-wire/provider-frame-disposition.test.ts +++ b/src/main/native-chat/agent-session-wire/provider-frame-disposition.test.ts @@ -65,6 +65,29 @@ describe('provider frame classification catalog', () => { ).toBe('error-surface') }) + it('keeps command queue bookkeeping off the transcript without hiding a failed one', () => { + expect( + classifyProviderFrame('claude', 'message:command_lifecycle', { + command_uuid: 'command-1', + state: 'started' + }) + ).toBe('status-chrome') + expect( + classifyProviderFrame('claude', 'message:command_lifecycle', { + command_uuid: 'command-1', + state: 'cancelled' + }) + ).toBe('status-chrome') + // Payload inspection outranks the catalogue, so suppressing the kind cannot + // swallow a state the provider reports as a failure. + expect( + classifyProviderFrame('claude', 'message:command_lifecycle', { + command_uuid: 'command-1', + state: 'failed' + }) + ).toBe('error-surface') + }) + it('keeps unknown future frames on the substantive bounded fallback path', () => { expect(classifyProviderFrame('codex', 'notification:future/event', {})).toBe( 'timeline-substantive' diff --git a/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts b/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts index 8d11df995a6..474b1385a4f 100644 --- a/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts +++ b/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts @@ -131,7 +131,18 @@ export const PROVIDER_FRAME_CLASSIFICATIONS = { 'message:prompt_suggestion': 'status-chrome', 'message:system:mirror_error': 'error-surface', 'message:system:informational': 'timeline-substantive', - 'message:conversation_reset': 'status-chrome' + 'message:conversation_reset': 'status-chrome', + // A `started`/`completed`/`cancelled` state for one queued command uuid and + // nothing else; the CLI keeps it out of its own transcript too. A state that + // reads as a failure still surfaces, via the payload check in classify. + 'message:command_lifecycle': 'status-chrome', + // The turn-complete signal: lifecycle, never a transcript row. Error subtypes + // included — the turn's assistant frames already carry any user-facing text. + 'message:result:success': 'status-chrome', + 'message:result:error_during_execution': 'status-chrome', + 'message:result:error_max_turns': 'status-chrome', + 'message:result:error_max_budget_usd': 'status-chrome', + 'message:result:error_max_structured_output_retries': 'status-chrome' } } as const satisfies ProviderFrameClassificationTable diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts new file mode 100644 index 00000000000..c6566083eac --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it, vi } from 'vitest' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { StructuredAgentSessionAdapterRouter } from './structured-agent-session-adapter-router' + +function adapterOf( + releaseAcquisition: StructuredAgentSessionAdapter['releaseAcquisition'] +): StructuredAgentSessionAdapter { + return { + acquire: vi.fn(async () => ({ process: { pid: 1 } }) as never), + releaseAcquisition, + dispatch: vi.fn(), + cancelTurn: vi.fn(), + answerPrompt: vi.fn(), + setOption: vi.fn() + } as unknown as StructuredAgentSessionAdapter +} + +describe('StructuredAgentSessionAdapterRouter.releaseAcquisition', () => { + it('drops the owner even when its release reports a typed failure', async () => { + const failure = new Error('root exited') + const claude = adapterOf(vi.fn().mockRejectedValueOnce(failure).mockResolvedValue(false)) + const codex = adapterOf(vi.fn(async () => false)) + const router = new StructuredAgentSessionAdapterRouter({ claude, codex }, async () => {}) + const identity = { sessionId: 'session-1', agent: 'claude' } as never + await router.acquire({ identity, fence: 1, spawnToken: 'spawn-1' }) + + await expect(router.releaseAcquisition({ sessionId: 'session-1' })).rejects.toBe(failure) + // With no owner left, a later release asks every adapter instead of the stale one. + await expect(router.releaseAcquisition({ sessionId: 'session-1' })).resolves.toBe(false) + expect(claude.releaseAcquisition).toHaveBeenCalledTimes(2) + expect(codex.releaseAcquisition).toHaveBeenCalledTimes(1) + }) +}) + +describe('StructuredAgentSessionAdapterRouter.closeSession', () => { + it('retains the owner after an unproven close so a later retry reaches the same adapter', async () => { + const claude = adapterOf(vi.fn(async () => true)) + const closeSession = vi.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(true) + const dispatch = vi.fn().mockResolvedValue({ state: 'unknown', reason: 'test' }) + claude.closeSession = closeSession + claude.dispatch = dispatch + const codex = adapterOf(vi.fn(async () => false)) + const router = new StructuredAgentSessionAdapterRouter({ claude, codex }, async () => {}) + const identity = { sessionId: 'session-1', agent: 'claude' } as never + await router.acquire({ identity, fence: 1, spawnToken: 'spawn-1' }) + + await expect(router.closeSession('session-1')).resolves.toBe(false) + await expect( + router.dispatch({ + sessionId: 'session-1', + clientMessageId: 'client-1', + body: {} as never, + fence: 1 + }) + ).resolves.toMatchObject({ state: 'unknown' }) + await expect(router.closeSession('session-1')).resolves.toBe(true) + expect(closeSession).toHaveBeenCalledTimes(2) + expect(dispatch).toHaveBeenCalledTimes(1) + }) +}) + +describe('StructuredAgentSessionAdapterRouter optional lifecycle methods', () => { + it.each([ + ['forceCloseSession', 'forceCloseSession'], + ['disposeSession', 'disposeSession'] + ] as const)( + '%s forwards to the owner and retains it until proven stopped', + async (_label, method) => { + const claude = adapterOf(vi.fn(async () => true)) + const stop = vi.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(true) + claude[method] = stop + const dispatch = vi.fn().mockResolvedValue({ state: 'unknown', reason: 'test' }) + claude.dispatch = dispatch + const codex = adapterOf(vi.fn(async () => false)) + const router = new StructuredAgentSessionAdapterRouter({ claude, codex }, async () => {}) + const identity = { sessionId: 'session-1', agent: 'claude' } as never + await router.acquire({ identity, fence: 1, spawnToken: 'spawn-1' }) + const stopSession = router[method] + + await expect(stopSession('session-1')).resolves.toBe(false) + await expect( + router.dispatch({ + sessionId: 'session-1', + clientMessageId: 'client-1', + body: {} as never, + fence: 1 + }) + ).resolves.toMatchObject({ state: 'unknown' }) + await expect(stopSession('session-1')).resolves.toBe(true) + expect(stop).toHaveBeenCalledTimes(2) + expect(dispatch).toHaveBeenCalledOnce() + } + ) + + it.each(['forceCloseSession', 'disposeSession'] as const)( + 'falls back to closeSession when an owner lacks %s', + async (method) => { + const closeSession = vi.fn().mockResolvedValue(true) + const claude = adapterOf(vi.fn(async () => true)) + claude.closeSession = closeSession + const codex = adapterOf(vi.fn(async () => false)) + const router = new StructuredAgentSessionAdapterRouter({ claude, codex }, async () => {}) + await router.acquire({ + identity: { sessionId: 'session-1', agent: 'claude' } as never, + fence: 1, + spawnToken: 'spawn-1' + }) + const stopSession = router[method] + + await expect(stopSession('session-1')).resolves.toBe(true) + expect(closeSession).toHaveBeenCalledWith('session-1') + } + ) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts new file mode 100644 index 00000000000..6ac0c8e0fbf --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts @@ -0,0 +1,124 @@ +import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' +import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' + +type RoutedAgent = 'claude' | 'codex' + +export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessionAdapter { + private readonly owners = new Map() + + constructor( + private readonly adapters: Record, + private readonly closeAdapters: () => Promise + ) {} + + supportsCreate = (location: AgentSessionExecutionLocation, agent: string): boolean => { + const adapter = this.adapterForAgent(agent) + return adapter ? (adapter.supportsLocation?.(location) ?? false) : false + } + + supportsLocation = (location: AgentSessionExecutionLocation): boolean => + Object.values(this.adapters).some((adapter) => adapter.supportsLocation?.(location) ?? false) + + async acquire(input: Parameters[0]) { + const adapter = this.requireAgent(input.identity) + const acquired = await adapter.acquire(input) + this.owners.set(input.identity.sessionId, adapter) + return acquired + } + + async releaseAcquisition(input: { sessionId: string }): Promise { + const adapter = this.owners.get(input.sessionId) + if (adapter) { + try { + return (await adapter.releaseAcquisition?.(input)) === true + } finally { + this.owners.delete(input.sessionId) + } + } + let released = false + for (const candidate of Object.values(this.adapters)) { + released = (await candidate.releaseAcquisition?.(input)) === true || released + } + return released + } + + dispatch: StructuredAgentSessionAdapter['dispatch'] = (input) => + this.owner(input.sessionId).dispatch(input) + + cancelTurn: StructuredAgentSessionAdapter['cancelTurn'] = (input) => + this.owner(input.sessionId).cancelTurn(input) + + answerPrompt: StructuredAgentSessionAdapter['answerPrompt'] = (input) => + this.owner(input.sessionId).answerPrompt(input) + + setOption: StructuredAgentSessionAdapter['setOption'] = (input) => + this.owner(input.sessionId).setOption(input) + + readOptions = (input: { sessionId: string; fence: number }) => { + const reader = this.owner(input.sessionId).readOptions + if (!reader) { + throw new Error(`structured session ${input.sessionId} does not report options`) + } + return reader(input) + } + + readOptionRestoreFailures = (sessionId: string): readonly string[] => + this.owner(sessionId).readOptionRestoreFailures?.(sessionId) ?? [] + + historyFilePath = (input: { identity: AgentSessionJournalIdentity }) => + this.requireAgent(input.identity).historyFilePath?.(input) ?? Promise.resolve(null) + + closeSession = (sessionId: string): Promise => + this.stopSession(sessionId, (adapter) => adapter.closeSession) + + forceCloseSession = (sessionId: string): Promise => + this.stopSession(sessionId, (adapter) => adapter.forceCloseSession ?? adapter.closeSession) + + disposeSession = (sessionId: string): Promise => + this.stopSession(sessionId, (adapter) => adapter.disposeSession ?? adapter.closeSession) + + private async stopSession( + sessionId: string, + selectStop: ( + adapter: StructuredAgentSessionAdapter + ) => NonNullable | undefined + ): Promise { + const adapter = this.owners.get(sessionId) + if (!adapter) { + return false + } + const stop = selectStop(adapter) + const stopped = await stop?.call(adapter, sessionId) + if (stopped === true) { + this.owners.delete(sessionId) + return true + } + return false + } + + async closeAll(): Promise { + this.owners.clear() + await this.closeAdapters() + } + + private owner(sessionId: string): StructuredAgentSessionAdapter { + const adapter = this.owners.get(sessionId) + if (!adapter) { + throw new Error(`no live structured adapter owns ${sessionId}`) + } + return adapter + } + + private requireAgent(identity: AgentSessionJournalIdentity): StructuredAgentSessionAdapter { + const adapter = this.adapterForAgent(identity.agent) + if (!adapter) { + throw new Error(`structured sessions do not support ${identity.agent}`) + } + return adapter + } + + private adapterForAgent(agent: string): StructuredAgentSessionAdapter | null { + return agent === 'claude' || agent === 'codex' ? this.adapters[agent] : null + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.test.ts index 5f67240c1c6..77cce9153f5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { AgentSessionAcquisitionExitUnprovenError, + AgentSessionAcquisitionRootExitObservedError, rethrowAfterAgentSessionAcquisitionCleanup } from './structured-agent-session-adapter' @@ -28,6 +29,27 @@ describe('failed agent-session acquisition cleanup', () => { ).rejects.toBeInstanceOf(AgentSessionAcquisitionExitUnprovenError) }) + it('keeps a first-hand root exit that cleanup observed, with the provider diagnostic', async () => { + const cause = new Error('proof failed') + const exit = new AgentSessionAcquisitionRootExitObservedError( + new Error('claude stream-json exited (code 1): crashed') + ) + const error = await rethrowAfterAgentSessionAcquisitionCleanup( + { + releaseAcquisition: vi.fn(async () => { + throw exit + }) + }, + 'session-1', + cause + ).catch((thrown: unknown) => thrown) + + expect(error).toBeInstanceOf(AgentSessionAcquisitionRootExitObservedError) + expect(error).not.toBeInstanceOf(AgentSessionAcquisitionExitUnprovenError) + expect((error as Error).message).toBe('claude stream-json exited (code 1): crashed') + expect((error as Error).cause).toMatchObject({ errors: [cause, exit] }) + }) + it('reports unproven exit when cleanup throws', async () => { const error = await rethrowAfterAgentSessionAcquisitionCleanup( { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index cccc8ce6f13..01c16a60e55 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -32,6 +32,20 @@ export class AgentSessionAcquisitionRefusal extends Error { } } +/** + * The provider's own root process was observed to exit, but its descendant tree + * could not be verified. The lease keys on the root's pid and start time, so its + * observed death releases the reservation; nothing is claimed about descendants. + * Never thrown when a descendant was observed still alive — that stays unproven. + */ +export class AgentSessionAcquisitionRootExitObservedError extends Error { + constructor(cause: unknown) { + // The provider's own diagnostic is the only thing the user can act on. + super(cause instanceof Error ? cause.message : String(cause), { cause }) + this.name = 'AgentSessionAcquisitionRootExitObservedError' + } +} + export class AgentSessionAcquisitionExitUnprovenError extends Error { constructor(cause: unknown) { super('agent_session_acquisition_exit_unproven', { cause }) @@ -49,7 +63,7 @@ export type AgentSessionAcquisition = { acquisitionGeneration?: string } -/** Acquisition validation failed before the adapter attempted to spawn. */ +/** Acquisition failed with first-hand proof that no provider process existed. */ export class AgentSessionPreSpawnError extends Error { constructor(cause: unknown) { super(cause instanceof Error ? cause.message : String(cause), { cause }) @@ -105,7 +119,9 @@ export type StructuredAgentSessionAdapter = { * at — the store rejects a link minted at any other fence. */ acquire(input: StructuredAgentSessionAcquireInput): Promise /** Reaps an acquired provider when the host cannot commit or prove its lease. - * Returns true only after provider child exit is proven. */ + * Returns true only after provider child exit is proven. Throws + * `AgentSessionAcquisitionRootExitObservedError` when the provider root's own + * exit was observed first-hand but its descendants could not be verified. */ releaseAcquisition?(input: { sessionId: string }): Promise dispatch(input: { sessionId: string @@ -133,6 +149,8 @@ export type StructuredAgentSessionAdapter = { input: StructuredAgentSessionSetOptionInput ): Promise>> readOptions?(input: { sessionId: string; fence: number }): Promise + /** Option keys skipped after a provider rejected their persisted restore value. */ + readOptionRestoreFailures?(sessionId: string): readonly string[] /** Transcript path for journal recovery. Omit to let the existing session-file * resolver discover it from the provider session id. */ historyFilePath?(input: { identity: AgentSessionJournalIdentity }): Promise @@ -154,9 +172,15 @@ export async function rethrowAfterAgentSessionAcquisitionCleanup( try { released = (await adapter.releaseAcquisition?.({ sessionId })) === true } catch (cleanupError) { - throw new AgentSessionAcquisitionExitUnprovenError( - new AggregateError([cause, cleanupError], 'agent session acquisition cleanup failed') - ) + // A root exit the cleanup observed first-hand keeps its classification and its + // provider diagnostic; the failure that triggered cleanup rides along as cause. + throw cleanupError instanceof AgentSessionAcquisitionRootExitObservedError + ? new AgentSessionAcquisitionRootExitObservedError( + new AggregateError([cause, cleanupError], cleanupError.message) + ) + : new AgentSessionAcquisitionExitUnprovenError( + new AggregateError([cause, cleanupError], 'agent session acquisition cleanup failed') + ) } if (released) { throw cause diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts index 05c3d8c9e5e..7113be8d54b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts @@ -5,7 +5,6 @@ import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire' import type { AgentJournalResetReason } from '../../../shared/agent-session-journal-types' -import type { AgentSessionAttachParams } from './structured-agent-session-attach' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionHostDeps, @@ -30,8 +29,6 @@ export type StructuredAgentSessionAttachContext = { } tasks: StructuredAgentSessionTaskQueue reconcileLeases: (sessionId: string) => Promise - /** Retries a durable provider-exit journal settlement before a new owner is reserved. */ - retryPendingSettlement?: (sessionId: string, params: AgentSessionAttachParams) => Promise serialize: (sessionId: string, task: () => Promise) => Promise now: () => number } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts index a21edcee35c..b08a56ea4d9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts @@ -26,6 +26,7 @@ import type { AgentSessionRecordStore } from '../../runtime/agent-session-record import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { AgentSessionAcquisitionExitUnprovenError, + AgentSessionAcquisitionRootExitObservedError, AgentSessionAcquisitionRefusal, AgentSessionPreSpawnError, isAgentSessionPreSpawnError, @@ -119,7 +120,9 @@ export async function performAttach( ? 'processless' : error instanceof AgentSessionAcquisitionExitUnprovenError ? 'unproven' - : 'exit-proven' + : error instanceof AgentSessionAcquisitionRootExitObservedError + ? 'root-exit-observed' + : 'exit-proven' const outcome = error instanceof AgentSessionAcquisitionExitUnprovenError ? { @@ -209,13 +212,17 @@ async function settlePostAcquisitionAttachFailure( cause: unknown ): Promise { let cleanupError: unknown = cause - let exitProof: 'exit-proven' | 'unproven' = 'unproven' + let exitProof: 'exit-proven' | 'root-exit-observed' | 'unproven' = 'unproven' try { await rethrowAfterAgentSessionAcquisitionCleanup(input.adapter, record.sessionId, cause) } catch (error) { cleanupError = error exitProof = - error instanceof AgentSessionAcquisitionExitUnprovenError ? 'unproven' : 'exit-proven' + error instanceof AgentSessionAcquisitionExitUnprovenError + ? 'unproven' + : error instanceof AgentSessionAcquisitionRootExitObservedError + ? 'root-exit-observed' + : 'exit-proven' } // Why: the close is awaited so the map entry is gone only once its handle is // released, but a failed close must not also cost the store settlement below. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts index f4551ef9313..a22bbdcbb3e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts @@ -17,6 +17,7 @@ import { pinnedAgentSessionLaunchEnv } from './structured-agent-session-launch-env' import { refuseAgentSessionMutation } from './structured-agent-session-mutation-admission' +import { retryPendingStructuredAgentSessionSettlement } from './structured-agent-session-settlement-retry' import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' import type { DeferredStructuredAgentSessionEventSink } from './structured-agent-session-event-sink' import { agentSessionJournalCloseRetries } from '../agent-session-journal/journal-close-retry' @@ -41,14 +42,20 @@ export function attachStructuredAgentSession( return refuseAgentSessionMutation(unreconciled) } await context.runtimeState.resolveRecovery(sessionId) - if (context.retryPendingSettlement) { - const settled = await context.retryPendingSettlement(sessionId, params) - if (!settled) { - return refuseAgentSessionMutation({ - code: 'agent_session_ownership_unknown', - message: 'The provider-exit terminal journal settlement is still pending; retry attach.' - }) - } + // Retries a durable provider-exit journal settlement before a new owner is reserved. Answers + // settled when the record has none pending, so every attach can ask unconditionally. + const settled = await retryPendingStructuredAgentSessionSettlement({ + deps: context.deps, + sessions: context.sessions, + sessionId, + params, + now: () => context.now() + }) + if (!settled) { + return refuseAgentSessionMutation({ + code: 'agent_session_ownership_unknown', + message: 'The provider-exit terminal journal settlement is still pending; retry attach.' + }) } const eventSink = context.runtimeState.eventSinkFor(sessionId) const attached = await performAttach({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts index 83766f25fc5..ce58e31b4ee 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts @@ -5,7 +5,6 @@ // the record store's compare-and-swap, which also owns the idempotency row, so // a retried attach replays instead of reserving a second owner. -import type { AgentType } from '../../../shared/agent-status-types' import type { AgentSessionJournalIdentity, AgentSessionProviderHandle @@ -52,7 +51,7 @@ export type AgentSessionAttachParams = { envelope: AgentSessionMutationEnvelope location: AgentSessionExecutionLocation provider: AgentSessionHandleProvider - agent: AgentType + agent: AgentSessionHandleProvider accountHome: AgentSessionAccountHome runtimeKind: AgentSessionOwnerRuntimeKind /** Omitted only for create-by-intent; the adapter proves the durable handle. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-options-round-trip.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-options-round-trip.test.ts new file mode 100644 index 00000000000..87bc33bc4b9 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-options-round-trip.test.ts @@ -0,0 +1,182 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import type { + AgentSessionHandoffDirection, + AgentSessionHandoffRequest, + AgentSessionMutationEnvelope +} from '../../../shared/agent-session-wire' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + hostTestAttachParams, + hostTestOperationId, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' +import type { + StructuredAgentSessionHandoffTransport, + StructuredTuiOwner +} from './structured-agent-session-handoff-types' + +const CALLER = { callerKey: 'client-claude' } +const CLAUDE_SESSION = '019fd532-7c11-7a90-b6de-4e1a2c3d5f61' +const DEFAULT_MODEL = 'sonnet' +const PICKED_MODEL = 'opus' + +let root: string +let store: AgentSessionRecordStore +let host: StructuredAgentSessionHost +let acquire: Mock +let activeModel: string +let transcriptPath: string + +function envelope(method: string, fields: Record): AgentSessionMutationEnvelope { + return { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? null, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method, + sessionId: SESSION, + fields + }) + } +} + +function handoff(direction: AgentSessionHandoffDirection): AgentSessionHandoffRequest { + const fields = { direction, mode: 'now' as const, action: 'start' as const } + return { envelope: envelope('agentSession.requestHandoff', fields), ...fields } +} + +function owner(fence: number, spawnToken: string): StructuredTuiOwner { + return { + terminal: { + handle: 'term-claude', + tabId: 'tab-claude', + paneKey: 'pane-claude', + ptyId: 'pty-claude' + }, + process: { hostId: 'local', pid: 5200, processStartTimeMs: NOW, spawnToken }, + link: { + linkId: `claude-tui-${fence}`, + handle: { provider: 'claude', sessionId: CLAUDE_SESSION, leafUuid: 'tui-leaf' }, + origin: 'resumed', + mintedAtFence: fence, + observedAt: NOW + }, + transcriptPath + } +} + +function transport(): StructuredAgentSessionHandoffTransport { + return { + hostLabel: 'Test host', + launchTui: async ({ fence, spawnToken }) => owner(fence, spawnToken), + reproveTuiOwner: async ({ owner: current }) => current, + recoverTuiOwner: async (record) => + owner( + record.lease.runtimeFence, + record.lease.ownerProcess?.spawnToken ?? record.lease.reservedSpawnToken ?? 'recovered' + ), + stopRecoveredOwner: async () => undefined, + waitForTuiExit: async (current) => ({ transcriptPath: current.transcriptPath }), + waitForTuiIdleOrExit: async () => 'idle', + tuiStatus: () => 'idle' + } +} + +function adapter(): StructuredAgentSessionAdapter { + acquire = vi.fn(async ({ fence, spawnToken, options }) => { + activeModel = options?.model ?? DEFAULT_MODEL + return { + process: { hostId: 'local', pid: 4200, processStartTimeMs: NOW, spawnToken }, + link: { + linkId: `claude-native-${fence}`, + handle: { provider: 'claude', sessionId: CLAUDE_SESSION, leafUuid: 'native-leaf' }, + origin: acquire.mock.calls.length === 1 ? 'created' : 'resumed', + mintedAtFence: fence, + observedAt: NOW + } + } + }) + return { + acquire, + dispatch: vi.fn(), + cancelTurn: vi.fn(async () => ({ cancelled: true })), + answerPrompt: vi.fn(async () => undefined), + setOption: vi.fn(async ({ value }) => { + activeModel = value + return { model: value } + }), + readOptions: vi.fn(async () => ({ current: { model: activeModel }, models: [] })), + closeSession: vi.fn(async () => { + activeModel = DEFAULT_MODEL + return true + }) + } +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-claude-handoff-options-')) + resetHostTestOperationIds() + activeModel = DEFAULT_MODEL + transcriptPath = join(root, 'claude.jsonl') + await writeFile(transcriptPath, '', 'utf8') + store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + host = new StructuredAgentSessionHost({ + store, + adapter: adapter(), + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-claude', + handoffTransport: transport(), + now: () => NOW + }) + expect( + await host.attach( + CALLER, + hostTestAttachParams(null, { + provider: 'claude', + agent: 'claude', + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: join(root, 'claude-home') }, + providerHandle: { kind: 'claude', sessionId: CLAUDE_SESSION, leafUuid: 'native-leaf' } + }) + ) + ).toMatchObject({ ok: true }) +}) + +afterEach(async () => { + await new Promise((resolve) => setTimeout(resolve, 100)) + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 }) +}) + +describe('Claude structured session handoff options', () => { + it('keeps a directly selected model through chat to TUI to chat', async () => { + const fields = { key: 'model', value: PICKED_MODEL } + expect( + await host.setOption(CALLER, { + envelope: envelope('agentSession.setOption', fields), + ...fields + }) + ).toMatchObject({ ok: true, value: { options: { model: PICKED_MODEL } } }) + + expect(await host.requestHandoff(CALLER, handoff('to-tui'))).toMatchObject({ ok: true }) + await vi.waitFor(async () => + expect(await host.handoffStatus(SESSION)).toMatchObject({ owner: 'tui' }) + ) + expect(await host.requestHandoff(CALLER, handoff('to-native'))).toMatchObject({ ok: true }) + await vi.waitFor(async () => + expect(await host.handoffStatus(SESSION)).toMatchObject({ owner: 'native' }) + ) + + expect(acquire.mock.calls[1]?.[0].options).toEqual({ model: PICKED_MODEL }) + expect(store.getRecord(SESSION)?.options).toEqual({ model: PICKED_MODEL }) + expect(activeModel).toBe(PICKED_MODEL) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts new file mode 100644 index 00000000000..6082ab074f5 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts @@ -0,0 +1,166 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import type { AgentSessionMutationEnvelope } from '../../../shared/agent-session-wire' +import { encodeAgentSessionQuestionAnswers } from '../../../shared/agent-session-question-answer' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { journalDirectoryFor } from '../agent-session-journal/journal-paths' +import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' +import type { + AgentSessionDispatchOutcome, + StructuredAgentSessionAdapter +} from './structured-agent-session-adapter' +import type { AgentSessionAttachParams } from './structured-agent-session-attach' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams, + hostTestOperationId, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' + +const CALLER = { callerKey: 'client-1' } + +function envelope(method: string, fields: Record): AgentSessionMutationEnvelope { + return { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? 1, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method, + sessionId: SESSION, + fields + }) + } +} + +const attachParams = (): AgentSessionAttachParams => hostTestAttachParams(null) + +let root: string +let store: AgentSessionRecordStore +let host: StructuredAgentSessionHost +let acquire: Mock +let answerPrompt: Mock +let ordinal = 0 + +function adapter(): StructuredAgentSessionAdapter { + const dispatch = vi.fn(async (): Promise => { + ordinal += 1 + return { + state: 'accepted', + providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal } + } + }) + return { + acquire, + releaseAcquisition: vi.fn(async () => true), + dispatch, + cancelTurn: vi.fn(async () => ({ cancelled: true })), + answerPrompt, + setOption: vi.fn(async () => undefined) + } +} + +async function seedGroupedQuestion(): Promise<{ itemId: string; revision: number }> { + const journal = await openAgentSessionJournal({ + identity: { + sessionId: SESSION, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: THREAD } + }, + journalDir: journalDirectoryFor(root, { workspaceId: 'workspace-1', sessionId: SESSION }) + }) + const appended = await journal.appendItem( + { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 100 }, + { + kind: 'question', + question: '2 grouped questions from Claude', + options: [], + questions: [ + { + id: 'q1', + question: 'Targets', + multiSelect: true, + options: [ + { id: 'target-web', label: 'Web' }, + { id: 'target-mobile', label: 'Mobile' } + ] + }, + { + id: 'q2', + question: 'Host', + multiSelect: false, + options: [], + freeTextQuestionId: 'q2' + } + ], + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } + }, + { fence: 1 } + ) + return { itemId: appended.itemId, revision: appended.revision } +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-wire-grouped-')) + resetHostTestOperationIds() + ordinal = 0 + acquire = vi.fn(async ({ fence }) => ({ + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken: store.getRecord(SESSION)?.lease.reservedSpawnToken ?? 'spawn-a' + }, + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex', threadId: THREAD }, + origin: store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created', + mintedAtFence: fence, + observedAt: NOW + } + })) + answerPrompt = vi.fn(async () => undefined) + store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + host = new StructuredAgentSessionHost({ + store, + adapter: adapter(), + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-a', + now: () => NOW + }) +}) + +afterEach(async () => { + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +describe('grouped question admission', () => { + it('admits renderer question-group payloads with child ids and multi-select answers', async () => { + const prompt = await seedGroupedQuestion() + const attached = await host.attach(CALLER, attachParams()) + expect(attached.ok).toBe(true) + const optionId = encodeAgentSessionQuestionAnswers([ + { questionId: 'q1', optionIds: ['target-web', 'target-mobile'] }, + { questionId: 'q2', optionIds: [], other: 'SSH host' } + ]) + const fields = { itemId: prompt.itemId, expectedRevision: prompt.revision, optionId } + const result = await host.respondToPrompt(CALLER, { + envelope: envelope('agentSession.respondTo:question', fields), + kind: 'question', + ...fields + }) + expect(result).toMatchObject({ ok: true, value: { resolution: { state: 'resolved' } } }) + expect(answerPrompt).toHaveBeenCalledWith( + expect.objectContaining({ itemId: prompt.itemId, optionId }) + ) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-admission.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-admission.ts new file mode 100644 index 00000000000..b881d55e771 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-admission.ts @@ -0,0 +1,138 @@ +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionOperationOutcome } from '../../../shared/agent-session-operation-ledger' +import type { + AgentSessionHandoffRequest, + AgentSessionHandoffResult, + AgentSessionHandoffStatus, + AgentSessionMutationResult, + AgentSessionWireRefusal +} from '../../../shared/agent-session-wire' +import { AGENT_SESSION_WIRE_REFUSAL_CODES } from '../../../shared/agent-session-wire' +import { + agentSessionFingerprintConflict, + computeAgentSessionPayloadFingerprint +} from '../../../shared/agent-session-mutation-envelope' +import type { StructuredAgentSessionHandoffOperationGuard } from './structured-agent-session-handoff-operation-guard' +import type { StructuredAgentSessionHandoffDeps } from './structured-agent-session-handoff-types' + +export type StructuredHandoffAdmission = + | { decision: 'continue'; record: AgentSessionRecord; fingerprint: string } + | { decision: 'replay'; outcome: AgentSessionOperationOutcome } + | { decision: 'refused'; refusal: AgentSessionWireRefusal } + +export async function admitStructuredHandoffRequest(input: { + deps: StructuredAgentSessionHandoffDeps + operationGuard: StructuredAgentSessionHandoffOperationGuard + callerKey: string + params: AgentSessionHandoffRequest + record: AgentSessionRecord + status?: AgentSessionHandoffStatus +}): Promise { + const action = input.params.action ?? 'start' + const requestFingerprint = computeAgentSessionPayloadFingerprint({ + method: 'agentSession.requestHandoff', + sessionId: input.record.sessionId, + fields: { direction: input.params.direction, mode: input.params.mode, action } + }) + const conflict = agentSessionFingerprintConflict(input.params.envelope, requestFingerprint) + if (conflict) { + return { decision: 'refused', refusal: conflict } + } + const fingerprint = computeAgentSessionPayloadFingerprint({ + method: 'agentSession.requestHandoff.operation', + sessionId: input.record.sessionId, + fields: { direction: input.params.direction } + }) + const operation = await input.operationGuard.check({ + callerKey: input.callerKey, + sessionId: input.record.sessionId, + operationId: input.params.envelope.clientOperationId, + fingerprint, + action, + ...(input.status ? { status: input.status } : {}), + now: input.deps.now() + }) + if (operation.decision === 'replay') { + return { decision: 'replay', outcome: operation.outcome } + } + if (operation.decision === 'refused') { + return { + decision: 'refused', + refusal: { + code: operation.code as 'agent_session_operation_conflict', + message: 'This handoff operation could not be admitted.' + } + } + } + if (input.params.envelope.expectedRuntimeFence !== input.record.lease.runtimeFence) { + await input.deps.store.recordOperationOutcome({ + callerKey: input.callerKey, + operationId: input.params.envelope.clientOperationId, + outcome: { status: 'failed', code: 'agent_session_checkpoint_stale' } + }) + input.operationGuard.finish(input.record.sessionId, input.params.envelope.clientOperationId) + return { + decision: 'refused', + refusal: { + code: 'agent_session_checkpoint_stale', + message: 'The session owner changed before the handoff request arrived.', + currentFence: input.record.lease.runtimeFence + } + } + } + return { decision: 'continue', record: input.record, fingerprint } +} + +export function replayedStructuredHandoffRefusal( + outcome: AgentSessionOperationOutcome +): AgentSessionWireRefusal | null { + if ( + outcome.status !== 'failed' || + !AGENT_SESSION_WIRE_REFUSAL_CODES.includes( + outcome.code as (typeof AGENT_SESSION_WIRE_REFUSAL_CODES)[number] + ) + ) { + return null + } + return { + code: outcome.code as (typeof AGENT_SESSION_WIRE_REFUSAL_CODES)[number], + message: 'This handoff request was previously refused.' + } +} + +export async function refuseAdmittedStructuredHandoff(input: { + deps: StructuredAgentSessionHandoffDeps + callerKey: string + params: AgentSessionHandoffRequest + refusal: AgentSessionWireRefusal +}): Promise> { + await input.deps.store.recordOperationOutcome({ + callerKey: input.callerKey, + operationId: input.params.envelope.clientOperationId, + outcome: { status: 'failed', code: input.refusal.code } + }) + return { ok: false, refusal: input.refusal } +} + +export function structuredHandoffRetryIsAdmissible( + status: AgentSessionHandoffStatus, + params: AgentSessionHandoffRequest +): boolean { + return ( + status.phase === 'failed' && + status.direction === params.direction && + status.operationId === params.envelope.clientOperationId && + status.error?.recoverableOwner !== 'none' + ) +} + +export function structuredHandoffRetryResumesStoppedOwner( + record: AgentSessionRecord, + params: AgentSessionHandoffRequest +): boolean { + return ( + record.lease.claimStatus === 'released' && + record.lease.handoffStage === 'old-owner-stopped' && + record.lease.handoffOperationId === params.envelope.clientOperationId + ) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-flow-runner.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-flow-runner.test.ts new file mode 100644 index 00000000000..7f2bec98962 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-flow-runner.test.ts @@ -0,0 +1,98 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import type { AgentSessionHandoffRequest } from '../../../shared/agent-session-wire' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' +import { StructuredAgentSessionHandoffFlowRunner } from './structured-agent-session-handoff-flow-runner' +import { StructuredAgentSessionHandoffOperationGuard } from './structured-agent-session-handoff-operation-guard' +import type { StructuredAgentSessionHandoffFlowContext } from './structured-agent-session-handoff-types' + +const NOW = 1_800_000_000_000 +const SESSION = 'session-flow-runner-outcome-write-failure' +const THREAD = '019fd532-7c11-7a90-b6de-4e1a2c3d5f61' +const OPERATION = `${NOW}-00000000000000000000000000000002` +const roots: string[] = [] + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('structured handoff flow runner outcome-write failure', () => { + it('still reports the flow failure when the failed-outcome ledger write throws', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-handoff-flow-runner-')) + roots.push(root) + const store = await AgentSessionRecordStore.open({ + directory: join(root, 'store'), + hostId: 'local' + }) + // Materialize the store file so its later disappearance reads as corruption, + // making every subsequent ledger write reject. + await store.admitOperation({ + callerKey: 'seed', + operationId: `${NOW}-00000000000000000000000000000009`, + fingerprint: 'seed', + now: NOW + }) + const journal = await openAgentSessionJournal({ + identity: { + sessionId: SESSION, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: THREAD } + }, + journalDir: join(root, 'journal') + }) + await rm(join(root, 'store'), { recursive: true, force: true }) + const failures: unknown[] = [] + const fields = { + direction: 'to-native' as const, + mode: 'now' as const, + action: 'retry' as const + } + const params: AgentSessionHandoffRequest = { + envelope: { + sessionId: SESSION, + clientOperationId: OPERATION, + expectedRuntimeFence: null, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.requestHandoff', + sessionId: SESSION, + fields + }) + }, + ...fields + } + const runner = new StructuredAgentSessionHandoffFlowRunner({ + deps: { + store, + claimKeyId: 'key-1', + session: () => ({ journal, fence: 1 }), + suspendNative: async () => ({ state: 'stopped' as const }), + acquireNative: async () => { + throw new Error('unused') + }, + importTuiHistory: async () => {}, + publish: () => {}, + schedule: async () => { + throw new Error('scheduling failed') + }, + now: () => NOW + }, + operationGuard: new StructuredAgentSessionHandoffOperationGuard(store), + flowContext: (): StructuredAgentSessionHandoffFlowContext => { + throw new Error('unreachable: scheduling rejects before the flow needs context') + }, + fail: (_params, error) => { + failures.push(error) + } + }) + runner.begin({ callerKey: 'client-1', params, turnId: null, fingerprint: 'fp' }) + await runner.drain() + expect(failures).toHaveLength(1) + expect((failures[0] as Error).message).toBe('scheduling failed') + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-flow-runner.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-flow-runner.ts new file mode 100644 index 00000000000..7278502ce1f --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-flow-runner.ts @@ -0,0 +1,110 @@ +import type { AgentSessionHandoffRequest } from '../../../shared/agent-session-wire' +import { stopStructuredNativeTurn } from './structured-agent-session-handoff-flow-context' +import { handoffStructuredSessionToTui } from './structured-agent-session-handoff-forward' +import type { StructuredAgentSessionHandoffOperationGuard } from './structured-agent-session-handoff-operation-guard' +import { assertScheduledStructuredHandoffIsAdmissible } from './structured-agent-session-handoff-revalidation' +import { handoffStructuredSessionToNative } from './structured-agent-session-handoff-reverse' +import { structuredTuiStatus } from './structured-agent-session-handoff-status' +import type { + StructuredAgentSessionHandoffDeps, + StructuredAgentSessionHandoffFlowContext +} from './structured-agent-session-handoff-types' + +export class StructuredAgentSessionHandoffFlowRunner { + private readonly active = new Set>() + + constructor( + private readonly input: { + deps: StructuredAgentSessionHandoffDeps + operationGuard: StructuredAgentSessionHandoffOperationGuard + flowContext: () => StructuredAgentSessionHandoffFlowContext + fail: (params: AgentSessionHandoffRequest, error: unknown) => void + } + ) {} + + async drain(): Promise { + await Promise.allSettled(this.active) + } + + track(task: Promise): void { + this.active.add(task) + void task.finally(() => this.active.delete(task)) + } + + begin(input: { + callerKey: string + params: AgentSessionHandoffRequest + turnId: string | null + fingerprint: string + tuiAlreadyExited?: boolean + }): void { + const { callerKey, params, turnId, fingerprint, tuiAlreadyExited = false } = input + const sessionId = params.envelope.sessionId + const journalSequence = this.input.deps.session(sessionId).journal.cursor().sequence + this.input.operationGuard.start(sessionId, { + callerKey, + operationId: params.envelope.clientOperationId, + fingerprint + }) + const flow = this.run(params, turnId, tuiAlreadyExited, journalSequence) + .then(() => { + this.input.operationGuard.finish(sessionId, params.envelope.clientOperationId) + return this.input.deps.store.recordOperationOutcome({ + callerKey, + operationId: params.envelope.clientOperationId, + outcome: { status: 'succeeded', sessionId } + }) + }) + .catch(async (error) => { + try { + await this.input.deps.store.recordOperationOutcome({ + callerKey, + operationId: params.envelope.clientOperationId, + outcome: { status: 'failed', code: 'agent_session_handoff_failed' } + }) + } catch { + // Best-effort: a store write failure must not suppress the client's failure + // notification or leak the flow as an unhandled rejection. + } + this.input.operationGuard.finish(sessionId, params.envelope.clientOperationId) + this.input.fail(params, error) + }) + .finally(() => this.input.operationGuard.finish(sessionId, params.envelope.clientOperationId)) + this.track(flow) + } + + private run( + params: AgentSessionHandoffRequest, + turnId: string | null, + tuiAlreadyExited: boolean, + journalSequence: number + ): Promise { + const sessionId = params.envelope.sessionId + return this.input.deps.schedule(sessionId, async () => { + const context = this.input.flowContext() + assertScheduledStructuredHandoffIsAdmissible({ + record: context.requireRecord(sessionId), + journal: this.input.deps.session(sessionId).journal, + params, + turnId, + journalSequence, + tuiAlreadyExited, + tuiStatus: structuredTuiStatus(context.owner(sessionId), this.input.deps.transport) + }) + if (turnId && params.mode === 'stop-turn') { + const stopped = await stopStructuredNativeTurn(this.input.deps, sessionId, turnId) + if (!stopped) { + throw new Error('The current turn did not acknowledge cancellation.') + } + } + await (params.direction === 'to-tui' + ? handoffStructuredSessionToTui(context, params, params.action === 'retry') + : handoffStructuredSessionToNative( + context, + params, + params.action === 'retry', + tuiAlreadyExited + )) + }) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-operation-guard.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-operation-guard.test.ts new file mode 100644 index 00000000000..7c77806ad91 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-operation-guard.test.ts @@ -0,0 +1,221 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../../shared/agent-session-record.test-fixture' +import type { + AgentSessionHandoffRequest, + AgentSessionHandoffStatus +} from '../../../shared/agent-session-wire' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' +import { + queuedStructuredHandoffCanBegin, + StructuredAgentSessionHandoffQueue +} from './structured-agent-session-handoff-queue' +import { StructuredAgentSessionHandoffOperationGuard } from './structured-agent-session-handoff-operation-guard' +import { assertScheduledStructuredHandoffIsAdmissible } from './structured-agent-session-handoff-revalidation' + +const NOW = 1_800_000_000_000 +const SESSION = 'session-alpha-1' +const OPERATION_A = `${NOW}-00000000000000000000000000000001` +const OPERATION_B = `${NOW}-00000000000000000000000000000002` + +let root: string | null = null + +afterEach(async () => { + if (root) { + await rm(root, { recursive: true, force: true }) + root = null + } +}) + +async function createGuard() { + root = await mkdtemp(join(tmpdir(), 'orca-handoff-operation-guard-')) + const store = await AgentSessionRecordStore.open({ directory: root, hostId: 'local' }) + return { guard: new StructuredAgentSessionHandoffOperationGuard(store), store } +} + +function status(phase: 'switching' | 'queued' | 'idle'): AgentSessionHandoffStatus { + return { + owner: phase === 'idle' ? 'native' : 'none', + direction: phase === 'idle' ? null : 'to-tui', + phase, + stage: phase === 'switching' ? 'preparing' : null, + operationId: phase === 'idle' ? null : OPERATION_A + } +} + +describe('structured handoff operation ownership', () => { + it('reserves one winner across concurrent admissions', async () => { + const { guard } = await createGuard() + const check = (operationId: string) => + guard.check({ + callerKey: operationId, + sessionId: SESSION, + operationId, + fingerprint: operationId, + action: 'start', + now: NOW + }) + + const decisions = await Promise.all([check(OPERATION_A), check(OPERATION_B)]) + + expect(decisions.map(({ decision }) => decision).sort()).toEqual(['new', 'refused']) + }) + + it.each(['switching', 'queued'] as const)( + 'durably refuses a distinct operation while the %s operation owns the session', + async (phase) => { + const { guard } = await createGuard() + guard.start(SESSION, { + callerKey: 'client-a', + operationId: OPERATION_A, + fingerprint: 'fingerprint-a' + }) + + expect( + await guard.check({ + callerKey: 'client-b', + sessionId: SESSION, + operationId: OPERATION_B, + fingerprint: 'fingerprint-b', + action: 'start', + status: status(phase), + now: NOW + }) + ).toEqual({ decision: 'refused', code: 'agent_session_operation_conflict' }) + + guard.finish(SESSION, OPERATION_A) + expect( + await guard.check({ + callerKey: 'client-b', + sessionId: SESSION, + operationId: OPERATION_B, + fingerprint: 'fingerprint-b', + action: 'start', + status: status('idle'), + now: NOW + }) + ).toMatchObject({ + decision: 'replay', + outcome: { status: 'failed', code: 'agent_session_operation_conflict' } + }) + } + ) + + it('admits only cancellation beside a queued operation', async () => { + const { guard } = await createGuard() + guard.start(SESSION, { + callerKey: 'client-a', + operationId: OPERATION_A, + fingerprint: 'fingerprint-a' + }) + + await expect( + guard.check({ + callerKey: 'client-b', + sessionId: SESSION, + operationId: OPERATION_B, + fingerprint: 'fingerprint-b', + action: 'cancel-queued', + status: status('queued'), + now: NOW + }) + ).resolves.toEqual({ decision: 'new' }) + }) +}) + +describe('queued handoff fence revalidation', () => { + const params: AgentSessionHandoffRequest = { + envelope: { + sessionId: SESSION, + clientOperationId: OPERATION_A, + expectedRuntimeFence: 7, + payloadFingerprint: 'fingerprint' + }, + direction: 'to-tui', + mode: 'after-turn', + action: 'start' + } + const queued = status('queued') + + it('accepts the same live owner and fence', () => { + const record = agentSessionRecordFixture( + agentSessionLeaseFixture({ runtimeKind: 'native', ownerProcess: null }) + ) + expect(queuedStructuredHandoffCanBegin(record, queued, params)).toBe(true) + }) + + it.each([ + agentSessionLeaseFixture({ runtimeKind: 'native', runtimeFence: 8, ownerProcess: null }), + agentSessionLeaseFixture({ runtimeKind: 'tui' }), + agentSessionLeaseFixture({ + runtimeKind: 'native', + ownerProcess: null, + handoffStage: 'preparing' + }) + ])('refuses a changed durable owner or fence', (lease) => { + expect(queuedStructuredHandoffCanBegin(agentSessionRecordFixture(lease), queued, params)).toBe( + false + ) + }) + + it('cannot cancel after the idle waiter claims the queued operation', async () => { + const queue = new StructuredAgentSessionHandoffQueue() + const ready = vi.fn() + queue.enqueue(SESSION, () => true, ready) + await vi.waitFor(() => expect(ready).toHaveBeenCalledOnce()) + expect(queue.cancel(SESSION)).toBe(false) + }) +}) + +describe('scheduled handoff revalidation', () => { + it('refuses a native turn accepted ahead of the scheduled handoff', async () => { + root = await mkdtemp(join(tmpdir(), 'orca-handoff-revalidation-')) + const journal = await openAgentSessionJournal({ + identity: { + sessionId: SESSION, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'claude', + providerHandle: { kind: 'claude', sessionId: SESSION, leafUuid: null } + }, + journalDir: join(root, 'journal') + }) + const journalSequence = journal.cursor().sequence + await journal.appendItem( + { provider: 'orca', clientMessageId: 'turn-running' }, + { kind: 'status', text: 'running', turnLifecycle: { turnId: 'turn-1', state: 'running' } }, + { fence: 7 } + ) + const params: AgentSessionHandoffRequest = { + envelope: { + sessionId: SESSION, + clientOperationId: OPERATION_A, + expectedRuntimeFence: 7, + payloadFingerprint: 'fingerprint' + }, + direction: 'to-tui', + mode: 'now', + action: 'start' + } + + expect(() => + assertScheduledStructuredHandoffIsAdmissible({ + record: agentSessionRecordFixture( + agentSessionLeaseFixture({ runtimeKind: 'native', ownerProcess: null }) + ), + journal, + params, + turnId: null, + journalSequence, + tuiAlreadyExited: false, + tuiStatus: 'busy' + }) + ).toThrow('session changed') + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-operation-guard.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-operation-guard.ts new file mode 100644 index 00000000000..da8d2eaad3d --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-operation-guard.ts @@ -0,0 +1,125 @@ +import type { AgentSessionHandoffStatus } from '../../../shared/agent-session-wire' +import type { + AgentSessionOperationOutcome, + AgentSessionOperationRefusalCode +} from '../../../shared/agent-session-operation-ledger' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' + +type ActiveOperation = { callerKey: string; operationId: string; fingerprint: string } + +export type HandoffOperationDecision = + | { decision: 'new' } + | { decision: 'replay'; outcome: AgentSessionOperationOutcome } + | { decision: 'retry' } + | { decision: 'refused'; code: AgentSessionOperationRefusalCode } + +export class StructuredAgentSessionHandoffOperationGuard { + private readonly activeBySession = new Map() + + constructor(private readonly store: AgentSessionRecordStore) {} + + async check(input: { + callerKey: string + sessionId: string + operationId: string + fingerprint: string + action: 'start' | 'cancel-queued' | 'retry' | 'recover' + status?: AgentSessionHandoffStatus + now: number + }): Promise { + const ledger = await this.store.admitOperation({ + callerKey: input.callerKey, + operationId: input.operationId, + fingerprint: input.fingerprint, + now: input.now + }) + if (ledger.decision === 'refused') { + return { decision: 'refused', code: ledger.code } + } + const active = this.activeBySession.get(input.sessionId) + const queuedCancellation = + input.action === 'cancel-queued' && + input.status?.phase === 'queued' && + input.status.operationId === active?.operationId + const activeConflict = Boolean( + active && + ((active.operationId === input.operationId && + (active.fingerprint !== input.fingerprint || active.callerKey !== input.callerKey)) || + (active.operationId !== input.operationId && !queuedCancellation)) + ) + const queuedConflict = Boolean( + !active && + input.status?.phase === 'queued' && + input.status.operationId !== input.operationId && + input.action !== 'cancel-queued' + ) + if (activeConflict || queuedConflict) { + if (ledger.decision === 'admit') { + await this.store.recordOperationOutcome({ + callerKey: input.callerKey, + operationId: input.operationId, + outcome: { status: 'failed', code: 'agent_session_operation_conflict' } + }) + } + return { decision: 'refused', code: 'agent_session_operation_conflict' } + } + if (ledger.decision === 'admit') { + this.reserve(input) + return { decision: 'new' } + } + if (input.action === 'retry' && ledger.row.outcome.status === 'failed') { + await this.store.recordOperationOutcome({ + callerKey: input.callerKey, + operationId: input.operationId, + outcome: { status: 'pending' } + }) + this.reserve(input) + return { decision: 'retry' } + } + if ( + ledger.row.outcome.status === 'pending' && + !active && + input.status?.operationId !== input.operationId + ) { + this.reserve(input) + return { decision: 'new' } + } + return { decision: 'replay', outcome: ledger.row.outcome } + } + + start(sessionId: string, operation: ActiveOperation): void { + this.activeBySession.set(sessionId, operation) + } + + private reserve(input: { + action: 'start' | 'cancel-queued' | 'retry' | 'recover' + callerKey: string + sessionId: string + operationId: string + fingerprint: string + }): void { + if (input.action !== 'cancel-queued') { + this.start(input.sessionId, input) + } + } + + finish(sessionId: string, operationId: string): void { + if (this.activeBySession.get(sessionId)?.operationId === operationId) { + this.activeBySession.delete(sessionId) + } + } + + async settle( + sessionId: string, + operationId: string, + outcome: AgentSessionOperationOutcome + ): Promise { + const active = this.activeBySession.get(sessionId) + await this.store.recordOperationOutcome({ + ...(active?.operationId === operationId ? { callerKey: active.callerKey } : {}), + operationId, + outcome + }) + this.finish(sessionId, operationId) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-options.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-options.test.ts new file mode 100644 index 00000000000..e5ee4f7ca9b --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-options.test.ts @@ -0,0 +1,286 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import type { + AgentSessionHandoffDirection, + AgentSessionHandoffRequest, + AgentSessionMutationEnvelope +} from '../../../shared/agent-session-wire' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { AgentSessionOptionRejectedError } from './structured-agent-session-option-error' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams, + hostTestMessage, + hostTestOperationId, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' +import type { + StructuredAgentSessionHandoffTransport, + StructuredTuiOwner +} from './structured-agent-session-handoff-types' + +const CALLER = { callerKey: 'client-1' } +const DEFAULT_MODEL = 'gpt-default' +const PICKED_MODEL = 'gpt-picked' +const PICKED_EFFORT = 'medium' + +let root: string +let store: AgentSessionRecordStore +let host: StructuredAgentSessionHost +let acquire: Mock +let activeModel: string +let activeEffort: string | null +let transcriptPath: string +let optionFailure: Error | null +const dispatchedModels: string[] = [] +const launchedOptions: (Readonly> | undefined)[] = [] +const closedTuiOwners: StructuredTuiOwner[] = [] + +function envelope(method: string, fields: Record): AgentSessionMutationEnvelope { + return { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? null, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method, + sessionId: SESSION, + fields + }) + } +} + +function handoff(direction: AgentSessionHandoffDirection): AgentSessionHandoffRequest { + const fields = { direction, mode: 'now' as const, action: 'start' as const } + return { envelope: envelope('agentSession.requestHandoff', fields), ...fields } +} + +function tuiOwner(fence: number, spawnToken: string): StructuredTuiOwner { + return { + terminal: { handle: 'term-tui', tabId: 'tab-tui', paneKey: 'pane-tui', ptyId: 'pty-tui' }, + process: { + hostId: 'local', + pid: 5200, + processStartTimeMs: NOW, + spawnToken + }, + link: { + linkId: `tui-link-${fence}`, + handle: { provider: 'codex', threadId: THREAD }, + origin: 'resumed', + mintedAtFence: fence, + observedAt: NOW + }, + transcriptPath + } +} + +function handoffTransport(): StructuredAgentSessionHandoffTransport { + return { + hostLabel: 'Test host', + launchTui: async ({ record, fence, spawnToken }) => { + launchedOptions.push(record.options) + return tuiOwner(fence, spawnToken) + }, + reproveTuiOwner: async ({ owner }) => owner, + recoverTuiOwner: async (record) => + tuiOwner( + record.lease.runtimeFence, + record.lease.ownerProcess?.spawnToken ?? record.lease.reservedSpawnToken ?? 'recovered' + ), + stopRecoveredOwner: async () => undefined, + closeTuiOwner: async (owner) => { + closedTuiOwners.push(owner) + return { transcriptPath: owner.transcriptPath } + }, + waitForTuiExit: async (owner) => ({ transcriptPath: owner.transcriptPath }), + waitForTuiIdleOrExit: async () => 'idle', + tuiStatus: () => 'idle' + } +} + +function adapter(): StructuredAgentSessionAdapter { + acquire = vi.fn(async ({ fence, spawnToken, options }) => { + activeModel = options?.model ?? DEFAULT_MODEL + activeEffort = options?.effort ?? null + return { + process: { + hostId: 'local', + pid: 4200 + acquire.mock.calls.length, + processStartTimeMs: NOW, + spawnToken + }, + link: { + linkId: `native-link-${fence}`, + handle: { provider: 'codex', threadId: THREAD }, + origin: acquire.mock.calls.length === 1 ? 'created' : 'resumed', + mintedAtFence: fence, + observedAt: NOW + } + } + }) + return { + acquire, + dispatch: vi.fn(async () => { + dispatchedModels.push(activeModel) + return { + state: 'accepted', + providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 1 } + } + }), + cancelTurn: vi.fn(async () => ({ cancelled: true })), + answerPrompt: vi.fn(async () => undefined), + setOption: vi.fn(async ({ key, value }) => { + if (optionFailure) { + const error = optionFailure + optionFailure = null + throw error + } + if (key === 'model') { + activeModel = value + } else if (key === 'effort') { + activeEffort = value + } + return { + model: activeModel, + ...(activeEffort ? { effort: activeEffort } : {}) + } + }), + readOptions: vi.fn(async () => ({ + current: { model: activeModel, ...(activeEffort ? { effort: activeEffort } : {}) }, + models: [] + })), + closeSession: vi.fn(async () => { + activeModel = DEFAULT_MODEL + return true + }) + } +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-handoff-options-')) + resetHostTestOperationIds() + activeModel = DEFAULT_MODEL + activeEffort = null + optionFailure = null + dispatchedModels.length = 0 + launchedOptions.length = 0 + closedTuiOwners.length = 0 + const accountHome = join(root, 'codex-home') + const sessionsDir = join(accountHome, 'sessions', '2026', '08', '12') + transcriptPath = join(sessionsDir, `rollout-2026-08-12T10-00-00-${THREAD}.jsonl`) + await mkdir(sessionsDir, { recursive: true }) + await writeFile( + transcriptPath, + `${JSON.stringify({ + type: 'session_meta', + timestamp: '2026-08-12T10:00:00.000Z', + payload: { id: THREAD, session_id: THREAD } + })}\n`, + 'utf8' + ) + store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + host = new StructuredAgentSessionHost({ + store, + adapter: adapter(), + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-native', + handoffTransport: handoffTransport(), + now: () => NOW + }) + const attached = await host.attach( + CALLER, + hostTestAttachParams(null, { accountHome: { variable: 'CODEX_HOME', path: accountHome } }) + ) + expect(attached).toMatchObject({ ok: true }) +}) + +afterEach(async () => { + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +describe('structured session handoff options', () => { + it('settles a pre-mutation rejection so a fresh retry can succeed', async () => { + optionFailure = new AgentSessionOptionRejectedError('model list unavailable') + const fields = { key: 'model', value: PICKED_MODEL } + const rejected = { + envelope: envelope('agentSession.setOption', fields), + ...fields + } + + expect(await host.setOption(CALLER, rejected)).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_operation_invalid', message: 'model list unavailable' } + }) + expect(await host.setOption(CALLER, rejected)).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_operation_invalid' } + }) + expect( + await host.setOption(CALLER, { + envelope: envelope('agentSession.setOption', fields), + ...fields + }) + ).toMatchObject({ ok: true, value: { options: { model: PICKED_MODEL } } }) + expect(store.getRecord(SESSION)?.options).toEqual({ model: PICKED_MODEL }) + }) + + it('keeps a picked model through a native to TUI to native round trip', async () => { + const optionFields = { key: 'model', value: PICKED_MODEL } + expect( + await host.setOption(CALLER, { + envelope: envelope('agentSession.setOption', optionFields), + ...optionFields + }) + ).toMatchObject({ ok: true }) + expect(store.getRecord(SESSION)?.options).toEqual({ model: PICKED_MODEL }) + + const effortFields = { key: 'effort', value: PICKED_EFFORT } + expect( + await host.setOption(CALLER, { + envelope: envelope('agentSession.setOption', effortFields), + ...effortFields + }) + ).toMatchObject({ ok: true }) + expect(store.getRecord(SESSION)?.options).toEqual({ + model: PICKED_MODEL, + effort: PICKED_EFFORT + }) + + expect(await host.requestHandoff(CALLER, handoff('to-tui'))).toMatchObject({ ok: true }) + await vi.waitFor(async () => + expect(await host.handoffStatus(SESSION)).toMatchObject({ owner: 'tui' }) + ) + expect(await host.requestHandoff(CALLER, handoff('to-native'))).toMatchObject({ ok: true }) + await vi.waitFor(async () => + expect(await host.handoffStatus(SESSION)).toMatchObject({ owner: 'native' }) + ) + + expect(launchedOptions).toEqual([{ model: PICKED_MODEL, effort: PICKED_EFFORT }]) + expect(closedTuiOwners).toHaveLength(1) + expect(acquire.mock.calls[1]?.[0].options).toEqual({ + model: PICKED_MODEL, + effort: PICKED_EFFORT + }) + expect(store.getRecord(SESSION)?.options).toEqual({ + model: PICKED_MODEL, + effort: PICKED_EFFORT + }) + const body = hostTestMessage('use the selected model') + expect( + await host.send(CALLER, { + envelope: envelope('agentSession.send', { body }), + body + }) + ).toMatchObject({ ok: true }) + expect(dispatchedModels).toEqual([PICKED_MODEL]) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-options.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-options.ts new file mode 100644 index 00000000000..a5afd9891a1 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-options.ts @@ -0,0 +1,23 @@ +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' + +export async function readNativeHandoffSessionOptions(input: { + adapter: Pick + sessionId: string + fence: number + priorOptions?: Readonly> +}): Promise> | undefined> { + const { adapter, sessionId, fence, priorOptions } = input + const reported = await adapter.readOptions?.({ + sessionId, + fence + }) + if (!reported) { + return undefined + } + const { model: _model, effort: _effort, ...restored } = priorOptions ?? {} + return { + ...restored, + model: reported.current.model, + ...(reported.current.effort ? { effort: reported.current.effort } : {}) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-queue-start.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-queue-start.ts new file mode 100644 index 00000000000..f8d6db2bace --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-queue-start.ts @@ -0,0 +1,46 @@ +import type { AgentSessionHandoffRequest } from '../../../shared/agent-session-wire' +import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-projection' +import type { StructuredAgentSessionHandoffQueue } from './structured-agent-session-handoff-queue' +import type { + StructuredAgentSessionHandoffDeps, + StructuredTuiOwner +} from './structured-agent-session-handoff-types' + +export function queueStructuredHandoffAfterTurn(input: { + callerKey: string + params: AgentSessionHandoffRequest + deps: StructuredAgentSessionHandoffDeps + queue: StructuredAgentSessionHandoffQueue + owner: (sessionId: string) => StructuredTuiOwner | undefined + setStatus: ( + sessionId: string, + status: Parameters[1] + ) => void + begin: (callerKey: string, params: AgentSessionHandoffRequest, tuiAlreadyExited?: boolean) => void +}): void { + const { callerKey, params, deps, queue, owner, setStatus, begin } = input + const sessionId = params.envelope.sessionId + let tuiReadiness: 'idle' | 'exited' | null = null + setStatus(sessionId, { + owner: params.direction === 'to-tui' ? 'native' : 'tui', + direction: params.direction, + phase: 'queued', + stage: null, + operationId: params.envelope.clientOperationId, + hostLabel: deps.transport?.hostLabel + }) + const tuiOwner = owner(sessionId) + queue.enqueue( + sessionId, + async (signal) => { + if (params.direction === 'to-tui') { + return !activeStructuredAgentSessionTurnId(deps.session(sessionId).journal.snapshot().items) + } + tuiReadiness = tuiOwner + ? ((await deps.transport?.waitForTuiIdleOrExit(tuiOwner, signal)) ?? null) + : null + return tuiReadiness !== null + }, + () => begin(callerKey, { ...params, mode: 'now' }, tuiReadiness === 'exited') + ) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-queue.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-queue.ts new file mode 100644 index 00000000000..9ea3d7ff08f --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-queue.ts @@ -0,0 +1,133 @@ +import type { + AgentSessionHandoffRequest, + AgentSessionHandoffStatus +} from '../../../shared/agent-session-wire' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-projection' +import type { + StructuredAgentSessionHandoffDeps, + StructuredTuiOwner +} from './structured-agent-session-handoff-types' + +export class StructuredAgentSessionHandoffQueue { + private readonly controllers = new Map() + + cancel(sessionId: string): boolean { + const controller = this.controllers.get(sessionId) + controller?.abort() + this.controllers.delete(sessionId) + return controller !== undefined + } + + enqueue( + sessionId: string, + isIdle: (signal: AbortSignal) => boolean | Promise, + onReady: () => void + ): void { + this.cancel(sessionId) + const controller = new AbortController() + this.controllers.set(sessionId, controller) + void this.waitUntilIdle(sessionId, controller, isIdle).then((ready) => { + if (ready) { + onReady() + } + }) + } + + private async waitUntilIdle( + sessionId: string, + controller: AbortController, + isIdle: (signal: AbortSignal) => boolean | Promise + ): Promise { + while (this.controllers.get(sessionId) === controller && !controller.signal.aborted) { + try { + if (await isIdle(controller.signal)) { + this.controllers.delete(sessionId) + return true + } + } catch { + if (controller.signal.aborted) { + return false + } + } + await new Promise((resolve) => setTimeout(resolve, 150)) + } + return false + } +} + +export function queuedStructuredHandoffCanBegin( + record: AgentSessionRecord, + status: AgentSessionHandoffStatus, + params: AgentSessionHandoffRequest +): boolean { + const expectedOwner = params.direction === 'to-tui' ? 'native' : 'tui' + return ( + record.sessionId === params.envelope.sessionId && + status.phase === 'queued' && + status.direction === params.direction && + status.operationId === params.envelope.clientOperationId && + record.lease.runtimeFence === params.envelope.expectedRuntimeFence && + record.lease.runtimeKind === expectedOwner && + record.lease.claimStatus === 'live' && + record.lease.handoffStage === null && + !record.lease.unreconciled + ) +} + +export function enqueueStructuredHandoffAfterTurn(input: { + deps: StructuredAgentSessionHandoffDeps + queue: StructuredAgentSessionHandoffQueue + params: AgentSessionHandoffRequest + tuiOwner: StructuredTuiOwner | undefined + status: () => AgentSessionHandoffStatus + requireRecord: () => AgentSessionRecord + setStatus: (status: AgentSessionHandoffStatus) => void + begin: (params: AgentSessionHandoffRequest, tuiAlreadyExited: boolean) => void + refuse: (record: AgentSessionRecord) => void +}): void { + const { deps, params, queue, tuiOwner } = input + const sessionId = params.envelope.sessionId + let tuiReadiness: 'idle' | 'exited' | null = null + let observedTuiQueue = false + input.setStatus({ + owner: params.direction === 'to-tui' ? 'native' : 'tui', + direction: params.direction, + phase: 'queued', + stage: null, + operationId: params.envelope.clientOperationId, + hostLabel: deps.transport?.hostLabel + }) + queue.enqueue( + sessionId, + async (signal) => { + if (params.direction === 'to-tui') { + return !activeStructuredAgentSessionTurnId(deps.session(sessionId).journal.snapshot().items) + } + if (!observedTuiQueue) { + observedTuiQueue = true + return false + } + tuiReadiness = tuiOwner + ? ((await deps.transport?.waitForTuiIdleOrExit(tuiOwner, signal)) ?? null) + : null + if (tuiReadiness === 'exited') { + return true + } + if (!activeStructuredAgentSessionTurnId(deps.session(sessionId).journal.snapshot().items)) { + tuiReadiness = 'idle' + return true + } + return false + }, + () => { + const record = input.requireRecord() + const status = input.status() + if (!queuedStructuredHandoffCanBegin(record, status, params)) { + input.refuse(record) + return + } + input.begin(params, tuiReadiness === 'exited') + } + ) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-recover.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-recover.ts new file mode 100644 index 00000000000..0aab4f0335b --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-recover.ts @@ -0,0 +1,30 @@ +import type { + AgentSessionHandoffRequest, + AgentSessionHandoffStatus +} from '../../../shared/agent-session-wire' +import { + beginStructuredManualRecovery, + structuredManualRecoveryIsAdmissible +} from './structured-agent-session-manual-recovery' +import type { StructuredAgentSessionHandoffDeps } from './structured-agent-session-handoff-types' +import type { StructuredAgentSessionHandoffOperationGuard } from './structured-agent-session-handoff-operation-guard' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' + +export async function requestStructuredManualRecovery(input: { + deps: StructuredAgentSessionHandoffDeps + operationGuard: StructuredAgentSessionHandoffOperationGuard + callerKey: string + params: AgentSessionHandoffRequest + fingerprint: string + record: AgentSessionRecord + status: AgentSessionHandoffStatus + requireRecord: (sessionId: string) => AgentSessionRecord + restore: (sessionId: string) => Promise + setStatus: (sessionId: string, status: AgentSessionHandoffStatus) => void +}): Promise { + if (!structuredManualRecoveryIsAdmissible(input.record, input.status)) { + return false + } + beginStructuredManualRecovery(input) + return true +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-result.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-result.ts new file mode 100644 index 00000000000..fe480d6359c --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-result.ts @@ -0,0 +1,32 @@ +import type { + AgentSessionHandoffResult, + AgentSessionMutationResult, + AgentSessionWireRefusal +} from '../../../shared/agent-session-wire' +import type { StructuredAgentSessionHandoffDeps } from './structured-agent-session-handoff-types' + +export function structuredHandoffRefusal( + code: AgentSessionWireRefusal['code'], + message: string +): AgentSessionWireRefusal { + return { code, message } +} + +export function structuredHandoffSuccess( + deps: StructuredAgentSessionHandoffDeps, + sessionId: string, + replayed: boolean, + status: AgentSessionHandoffResult['status'] +): AgentSessionMutationResult { + const record = deps.store.getRecord(sessionId) + if (!record) { + throw new Error('agent_session_identity_required') + } + return { + ok: true, + replayed, + fence: record.lease.runtimeFence, + cursor: deps.session(sessionId).journal.cursor(), + value: { status } + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-revalidation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-revalidation.ts new file mode 100644 index 00000000000..66005959378 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-revalidation.ts @@ -0,0 +1,52 @@ +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionHandoffRequest } from '../../../shared/agent-session-wire' +import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-projection' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { structuredHandoffRetryResumesStoppedOwner } from './structured-agent-session-handoff-admission' +import { structuredSessionHasPendingPrompt } from './structured-agent-session-handoff-status' + +export function assertScheduledStructuredHandoffIsAdmissible(input: { + record: AgentSessionRecord + journal: AgentSessionJournal + params: AgentSessionHandoffRequest + turnId: string | null + journalSequence: number + tuiAlreadyExited: boolean + tuiStatus: 'idle' | 'busy' +}): void { + const { params, record } = input + if (params.action === 'retry' && structuredHandoffRetryResumesStoppedOwner(record, params)) { + return + } + const expectedOwner = params.direction === 'to-tui' ? 'native' : 'tui' + if ( + record.lease.runtimeFence !== params.envelope.expectedRuntimeFence || + record.lease.runtimeKind !== expectedOwner || + record.lease.claimStatus !== 'live' || + record.lease.handoffStage !== null || + record.lease.unreconciled + ) { + throw new Error('agent_session_checkpoint_stale') + } + if (structuredSessionHasPendingPrompt(input.journal)) { + throw new Error('Resolve the pending question or approval before switching.') + } + if (params.mode !== 'stop-turn' && input.journal.cursor().sequence !== input.journalSequence) { + throw new Error('The session changed before the handoff started.') + } + const activeTurn = activeStructuredAgentSessionTurnId(input.journal.snapshot().items) + if (params.direction === 'to-tui') { + const expectedTurn = params.mode === 'stop-turn' ? input.turnId : null + if (activeTurn !== expectedTurn) { + throw new Error('The native turn changed before the handoff started.') + } + return + } + if ( + !input.tuiAlreadyExited && + input.tuiStatus !== 'idle' && + (params.mode !== 'after-turn' || activeTurn !== null) + ) { + throw new Error('The agent terminal became busy before the handoff started.') + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-reverse.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-reverse.test.ts new file mode 100644 index 00000000000..91c6163bd17 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-reverse.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AgentSessionHandoffStatus } from '../../../shared/agent-session-wire' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { handoffStructuredSessionToNative } from './structured-agent-session-handoff-reverse' +import type { StructuredAgentSessionHandoffFlowContext } from './structured-agent-session-handoff-types' + +const OPERATION_ID = 'operation-1' +const SESSION_ID = 'session-1' + +vi.mock('../../runtime/agent-session-handoff-record-transitions', () => ({ + abandonStoredAgentSessionHandoffAttempt: vi.fn(async () => undefined), + reserveStoredAgentSessionHandoffOwner: vi.fn(async () => record()), + rollbackStoredAgentSessionHandoffPreparation: vi.fn(async () => undefined), + stopStoredAgentSessionOwnerForHandoff: vi.fn(async () => record()) +})) + +function record(): AgentSessionRecord { + return { + sessionId: SESSION_ID, + provider: 'claude', + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' + }, + lease: { + runtimeFence: 3, + handoffStage: 'old-owner-stopped', + handoffOperationId: OPERATION_ID + } + } as unknown as AgentSessionRecord +} + +function contextWith( + revealNativeSession: () => Promise, + statuses: AgentSessionHandoffStatus[] +): StructuredAgentSessionHandoffFlowContext { + return { + deps: { + store: {} as never, + claimKeyId: 'key-1', + now: () => 1_800_000_000_000, + importTuiHistory: vi.fn(async () => undefined), + acquireNative: vi.fn(async () => record()), + transport: { revealNativeSession } + } as never, + owner: () => undefined, + retainOwner: vi.fn(), + releaseOwner: vi.fn(), + setStatus: (_sessionId, status) => statuses.push(status), + enterPreparing: vi.fn(async () => undefined), + publishStage: vi.fn(), + requireRecord: () => record() + } +} + +// Why this ordering matters: releaseOwner has already run by the time the reveal fires, +// so a reveal that rejects before the status flip leaves the session released but never +// marked native — a stuck chat with no owner on either side. +describe('handoffStructuredSessionToNative', () => { + it('marks the session native before revealing it', async () => { + const statuses: AgentSessionHandoffStatus[] = [] + const order: string[] = [] + const context = contextWith(async () => { + order.push('reveal') + }, statuses) + const setStatus = context.setStatus + context.setStatus = (sessionId, status) => { + order.push('status') + setStatus(sessionId, status) + } + + await handoffStructuredSessionToNative( + context, + { envelope: { sessionId: SESSION_ID, clientOperationId: OPERATION_ID } } as never, + true + ) + + expect(order).toEqual(['status', 'reveal']) + expect(statuses.at(-1)).toMatchObject({ owner: 'native', direction: null, phase: 'idle' }) + }) + + it('still leaves the session marked native when the reveal rejects', async () => { + const statuses: AgentSessionHandoffStatus[] = [] + const context = contextWith(async () => { + throw new Error('publish failed') + }, statuses) + + await expect( + handoffStructuredSessionToNative( + context, + { envelope: { sessionId: SESSION_ID, clientOperationId: OPERATION_ID } } as never, + true + ) + ).rejects.toThrow('publish failed') + + expect(statuses.at(-1)).toMatchObject({ owner: 'native' }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-reverse.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-reverse.ts index f59f7735245..ebfca81525c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-reverse.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-reverse.ts @@ -130,12 +130,8 @@ export async function handoffStructuredSessionToNative( throw error } context.releaseOwner(sessionId) - await deps.transport?.revealNativeSession?.({ - workspaceId: record.location.workspaceId, - sessionId, - agent: record.provider, - ...(owner?.adoptedTerminal ? { adoptedTerminal: true } : {}) - }) + // Why status lands before the reveal: the native owner is already proven here, and a + // reveal that rejects must not leave the session released but never marked native. context.setStatus(sessionId, { owner: 'native', direction: null, @@ -143,4 +139,10 @@ export async function handoffStructuredSessionToNative( stage: record.lease.handoffStage, operationId: record.lease.handoffOperationId }) + await deps.transport?.revealNativeSession?.({ + workspaceId: record.location.workspaceId, + sessionId, + agent: record.provider, + ...(owner?.adoptedTerminal ? { adoptedTerminal: true } : {}) + }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-test-coordinator.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-test-coordinator.ts new file mode 100644 index 00000000000..e5dd478f719 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-test-coordinator.ts @@ -0,0 +1,80 @@ +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionHandoffStatus } from '../../../shared/agent-session-wire' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { StructuredAgentSessionHandoffCoordinator } from './structured-agent-session-handoff' +import type { + StructuredAgentSessionHandoffTransport, + StructuredTuiOwner +} from './structured-agent-session-handoff-types' + +type TestCoordinatorInput = { + store: AgentSessionRecordStore + journal: AgentSessionJournal + sessionId: string + provider: 'claude' | 'codex' + claudeSessionId: string + codexThreadId: string + now: number + launchTui: StructuredAgentSessionHandoffTransport['launchTui'] + reproveTuiOwner: StructuredAgentSessionHandoffTransport['reproveTuiOwner'] + stopRecoveredOwner: StructuredAgentSessionHandoffTransport['stopRecoveredOwner'] + closeTuiOwner: NonNullable + waitForTuiExit: StructuredAgentSessionHandoffTransport['waitForTuiExit'] + waitForTuiIdleOrExit: StructuredAgentSessionHandoffTransport['waitForTuiIdleOrExit'] + stopFailedTuiLaunch: NonNullable + recoverTuiOwner: (record: AgentSessionRecord) => Promise + tuiStatus: () => 'idle' | 'busy' + acquireNative: (input: { + sessionId: string + fence: number + spawnToken: string + }) => Promise + acquireNativeStop: (turnId: string) => Promise + takeImportFailure: () => Error | null + statuses: AgentSessionHandoffStatus[] +} + +export function createStructuredAgentSessionHandoffTestCoordinator( + input: TestCoordinatorInput +): StructuredAgentSessionHandoffCoordinator { + return new StructuredAgentSessionHandoffCoordinator({ + store: input.store, + claimKeyId: 'key-1', + transport: { + hostLabel: 'Test host', + launchTui: input.launchTui, + reproveTuiOwner: input.reproveTuiOwner, + recoverTuiOwner: input.recoverTuiOwner, + stopRecoveredOwner: input.stopRecoveredOwner, + closeTuiOwner: input.closeTuiOwner, + waitForTuiExit: input.waitForTuiExit, + waitForTuiIdleOrExit: input.waitForTuiIdleOrExit, + tuiStatus: input.tuiStatus, + stopFailedTuiLaunch: input.stopFailedTuiLaunch + }, + session: () => ({ + journal: input.journal, + fence: input.store.getRecord(input.sessionId)?.lease.runtimeFence ?? 1 + }), + suspendNative: async () => ({ state: 'stopped' as const }), + acquireNative: input.acquireNative, + acquireNativeStop: (_sessionId, turnId) => input.acquireNativeStop(turnId), + importTuiHistory: async ({ fence }) => { + const importFailure = input.takeImportFailure() + if (importFailure) { + throw importFailure + } + await input.journal.appendItem( + input.provider === 'claude' + ? { provider: 'claude', sessionId: input.claudeSessionId, uuid: 'tui-turn' } + : { provider: 'codex', threadId: input.codexThreadId, turnId: 'tui-turn', ordinal: 0 }, + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'from tui' }] }, + { fence, recovered: true } + ) + }, + publish: (_sessionId, status) => input.statuses.push(status), + schedule: async (_sessionId, task) => task(), + now: () => input.now + }) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-test-identities.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-test-identities.ts new file mode 100644 index 00000000000..ca33e486999 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-test-identities.ts @@ -0,0 +1,40 @@ +export type StructuredHandoffProviderCase = { + provider: 'claude' | 'codex' + accountHome: { variable: 'CLAUDE_CONFIG_DIR' | 'CODEX_HOME'; pathName: string } +} + +export const STRUCTURED_HANDOFF_PROVIDER_CASES: StructuredHandoffProviderCase[] = [ + { provider: 'codex', accountHome: { variable: 'CODEX_HOME', pathName: 'codex-home' } }, + { + provider: 'claude', + accountHome: { variable: 'CLAUDE_CONFIG_DIR', pathName: 'claude-home' } + } +] + +export function structuredHandoffTestProcess(now: number, spawnToken: string, pid: number) { + return { hostId: 'local', pid, processStartTimeMs: now - 1_000, spawnToken } +} + +export function structuredHandoffTestLink(input: { + provider: 'claude' | 'codex' + fence: number + id: string + now: number + claudeSessionId: string + codexThreadId: string +}) { + return { + linkId: input.id, + handle: + input.provider === 'claude' + ? ({ + provider: 'claude' as const, + sessionId: input.claudeSessionId, + leafUuid: input.id.startsWith('native-link') ? 'tui-exit-leaf' : 'current-leaf' + } as const) + : ({ provider: 'codex' as const, threadId: input.codexThreadId } as const), + origin: 'resumed' as const, + mintedAtFence: input.fence, + observedAt: input.now + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-test-requests.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-test-requests.ts new file mode 100644 index 00000000000..550ebded0da --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-test-requests.ts @@ -0,0 +1,53 @@ +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import type { + AgentSessionHandoffDirection, + AgentSessionHandoffAction, + AgentSessionHandoffMode, + AgentSessionHandoffRequest +} from '../../../shared/agent-session-wire' + +export type StructuredHandoffTestRequestOptions = { + action?: AgentSessionHandoffAction + operationId?: string +} + +export class StructuredHandoffTestRequests { + private operations = 0 + + constructor( + private readonly now: number, + private readonly sessionId: string, + private readonly readFence: () => number + ) {} + + reset(): void { + this.operations = 0 + } + + operationId(): string { + this.operations += 1 + return `${this.now}-${this.operations.toString(16).padStart(32, '0')}` + } + + request( + direction: AgentSessionHandoffDirection, + mode: AgentSessionHandoffMode, + options: StructuredHandoffTestRequestOptions = {} + ): AgentSessionHandoffRequest { + const action = options.action ?? 'start' + const fields = { direction, mode, action } + return { + envelope: { + sessionId: this.sessionId, + clientOperationId: options.operationId ?? this.operationId(), + expectedRuntimeFence: this.readFence(), + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.requestHandoff', + sessionId: this.sessionId, + fields + }) + }, + ...fields + } + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.ts index 0e213921609..57333d1c90c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.ts @@ -1,63 +1,286 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import type { AgentSessionHandoffStatus } from '../../../shared/agent-session-wire' +import type { + AgentSessionHandoffRequest, + AgentSessionHandoffResult, + AgentSessionHandoffStatus, + AgentSessionMutationResult, + AgentSessionWireRefusal +} from '../../../shared/agent-session-wire' +import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-projection' +import { + admitStructuredHandoffRequest, + refuseAdmittedStructuredHandoff, + replayedStructuredHandoffRefusal, + structuredHandoffRetryIsAdmissible +} from './structured-agent-session-handoff-admission' import { createStructuredHandoffFlowContext, requireStructuredHandoffRecord } from './structured-agent-session-handoff-flow-context' -import { restoreStructuredAgentSessionHandoff } from './structured-agent-session-handoff-restart' +import { StructuredAgentSessionHandoffFlowRunner } from './structured-agent-session-handoff-flow-runner' +import { StructuredAgentSessionHandoffOperationGuard } from './structured-agent-session-handoff-operation-guard' +import { StructuredAgentSessionHandoffQueue } from './structured-agent-session-handoff-queue' +import { queueStructuredHandoffAfterTurn } from './structured-agent-session-handoff-queue-start' import { closeRetainedTuiOwner } from './structured-agent-session-handoff-owner-close' +import { requestStructuredManualRecovery } from './structured-agent-session-handoff-recover' +import { restoreStructuredAgentSessionHandoff } from './structured-agent-session-handoff-restart' +import { + structuredHandoffRefusal as refusal, + structuredHandoffSuccess +} from './structured-agent-session-handoff-result' +import { + failedStructuredHandoffStatus, + idleStructuredHandoffStatus, + structuredSessionHasPendingPrompt, + structuredTuiStatus +} from './structured-agent-session-handoff-status' import type { StructuredAgentSessionHandoffDeps, StructuredAgentSessionHandoffFlowContext } from './structured-agent-session-handoff-types' import { StructuredAgentSessionHandoffState } from './structured-agent-session-handoff-state' - export class StructuredAgentSessionHandoffCoordinator { private readonly state: StructuredAgentSessionHandoffState - + private readonly queue = new StructuredAgentSessionHandoffQueue() + private readonly operationGuard: StructuredAgentSessionHandoffOperationGuard + private readonly flowRunner: StructuredAgentSessionHandoffFlowRunner constructor(private readonly deps: StructuredAgentSessionHandoffDeps) { - // oxfmt-ignore - this.state = new StructuredAgentSessionHandoffState({ requireRecord: (sessionId) => this.requireRecord(sessionId), publish: deps.publish, hostLabel: deps.transport?.hostLabel }) - } - - status = (sessionId: string) => this.state.status(sessionId) - - closeRetainedTuiOwner = (sessionId: string): Promise => - closeRetainedTuiOwner({ - sessionId, - deps: this.deps, - owner: this.state.owner, - requireRecord: this.requireRecord, - releaseOwner: this.state.releaseOwner + this.state = new StructuredAgentSessionHandoffState({ + requireRecord: (sessionId) => this.requireRecord(sessionId), + publish: deps.publish, + hostLabel: deps.transport?.hostLabel }) - + this.operationGuard = new StructuredAgentSessionHandoffOperationGuard(deps.store) + this.flowRunner = new StructuredAgentSessionHandoffFlowRunner({ + deps, + operationGuard: this.operationGuard, + flowContext: () => this.flowContext(), + fail: (params, error) => this.fail(params, error) + }) + } + status = (sessionId: string): AgentSessionHandoffStatus => this.state.status(sessionId) + drain = (): Promise => this.flowRunner.drain() + closeRetainedTuiOwner = (sessionId: string): Promise => + this.closeRetainedOwner(sessionId) setStatus = (sessionId: string, status: AgentSessionHandoffStatus): void => this.state.setStatus(sessionId, status) - + async request( + callerKey: string, + params: AgentSessionHandoffRequest + ): Promise> { + const record = this.requireRecord(params.envelope.sessionId) + const currentStatus = this.state.cachedStatus(record.sessionId) + const admission = await admitStructuredHandoffRequest({ + deps: this.deps, + operationGuard: this.operationGuard, + callerKey, + params, + record, + ...(currentStatus ? { status: currentStatus } : {}) + }) + if (admission.decision === 'replay') { + const replayedRefusal = replayedStructuredHandoffRefusal(admission.outcome) + if (replayedRefusal) { + return { ok: false, refusal: replayedRefusal } + } + return this.success(record.sessionId, true) + } + if (admission.decision === 'refused') { + return { ok: false, refusal: admission.refusal } + } + const { fingerprint } = admission + const action = params.action ?? 'start' + if (action === 'cancel-queued') { + if (currentStatus?.phase !== 'queued' || currentStatus?.direction !== params.direction) { + return this.refuseAdmitted( + callerKey, + params, + 'agent_session_operation_conflict', + 'No matching queued handoff exists.' + ) + } + this.queue.cancel(record.sessionId) + this.setStatus(record.sessionId, idleStructuredHandoffStatus(record)) + await this.deps.store.recordOperationOutcome({ + callerKey, + operationId: params.envelope.clientOperationId, + outcome: { status: 'succeeded', sessionId: record.sessionId } + }) + return this.success(record.sessionId, false) + } + if (!this.deps.transport) { + return this.refuseAdmitted( + callerKey, + params, + 'structured_agent_session_unsupported', + 'Agent TUI handoff is unavailable on this host.' + ) + } + if (action === 'recover') { + const status = this.status(record.sessionId) + const started = await requestStructuredManualRecovery({ + deps: this.deps, + operationGuard: this.operationGuard, + callerKey, + params, + fingerprint, + record, + status, + requireRecord: this.requireRecord, + restore: this.restore, + setStatus: this.setStatus + }) + if (!started) { + return this.refuseAdmitted( + callerKey, + params, + 'agent_session_operation_conflict', + 'This handoff is no longer eligible for proof recovery.' + ) + } + return this.success(record.sessionId, false) + } + if (action === 'retry') { + if (!structuredHandoffRetryIsAdmissible(this.status(record.sessionId), params)) { + return this.refuseAdmitted( + callerKey, + params, + 'agent_session_operation_conflict', + 'This handoff is no longer retryable.' + ) + } + this.begin(callerKey, params, null, fingerprint) + return this.success(record.sessionId, false) + } + const expectedOwner = params.direction === 'to-tui' ? 'native' : 'tui' + if (record.lease.runtimeKind !== expectedOwner || record.lease.claimStatus !== 'live') { + return this.refuseAdmitted( + callerKey, + params, + 'agent_session_conflict', + `The ${expectedOwner} runtime does not own this session.` + ) + } + if (structuredSessionHasPendingPrompt(this.deps.session(record.sessionId).journal)) { + return this.refuseAdmitted( + callerKey, + params, + 'agent_session_conflict', + 'Resolve the pending question or approval before switching.' + ) + } + const turnId = activeStructuredAgentSessionTurnId( + this.deps.session(record.sessionId).journal.snapshot().items + ) + const tuiOwner = this.state.owner(record.sessionId) + const busy = + expectedOwner === 'native' + ? turnId !== null + : structuredTuiStatus(tuiOwner, this.deps.transport) !== 'idle' + if (busy && params.mode === 'now') { + return this.refuseAdmitted( + callerKey, + params, + 'agent_session_conflict', + 'The current turn must finish before switching.' + ) + } + if (busy && params.mode === 'after-turn') { + queueStructuredHandoffAfterTurn({ + callerKey, + params, + deps: this.deps, + queue: this.queue, + owner: (sessionId) => this.state.owner(sessionId), + setStatus: this.setStatus, + begin: (key, next, tuiAlreadyExited) => + this.begin(key, next, null, fingerprint, tuiAlreadyExited) + }) + return this.success(record.sessionId, false) + } + if (busy && expectedOwner === 'tui' && params.mode === 'stop-turn') { + return this.refuseAdmitted( + callerKey, + params, + 'structured_agent_session_unsupported', + 'Exit the agent terminal after this turn to continue in chat.' + ) + } + this.begin(callerKey, params, turnId, fingerprint) + return this.success(record.sessionId, false) + } async restore(sessionId: string): Promise { await restoreStructuredAgentSessionHandoff( { deps: this.deps, requireRecord: (id) => this.requireRecord(id), flowContext: () => this.flowContext(), - retainOwner: this.state.retainOwner, - setStatus: this.state.setStatus + retainOwner: (id, owner) => this.state.retainOwner(id, owner), + setStatus: (id, status) => this.state.setStatus(id, status) }, sessionId ) } - + private refuseAdmitted( + callerKey: string, + params: AgentSessionHandoffRequest, + code: AgentSessionWireRefusal['code'], + message: string + ): Promise> { + return refuseAdmittedStructuredHandoff({ + deps: this.deps, + callerKey, + params, + refusal: refusal(code, message) + }) + } + private success( + sessionId: string, + replayed: boolean + ): AgentSessionMutationResult { + return structuredHandoffSuccess(this.deps, sessionId, replayed, this.status(sessionId)) + } + private begin( + callerKey: string, + params: AgentSessionHandoffRequest, + turnId: string | null, + fingerprint: string, + tuiAlreadyExited = false + ): void { + this.flowRunner.begin({ + callerKey, + params, + turnId, + fingerprint, + tuiAlreadyExited + }) + } private flowContext(): StructuredAgentSessionHandoffFlowContext { return createStructuredHandoffFlowContext({ deps: this.deps, - owner: this.state.owner, - retainOwner: this.state.retainOwner, - releaseOwner: this.state.releaseOwner, - setStatus: this.state.setStatus, + owner: (sessionId) => this.state.owner(sessionId), + retainOwner: (sessionId, owner) => this.state.retainOwner(sessionId, owner), + releaseOwner: (sessionId) => this.state.releaseOwner(sessionId), + setStatus: (sessionId, status) => this.state.setStatus(sessionId, status), requireRecord: (sessionId) => this.requireRecord(sessionId) }) } - + private fail(params: AgentSessionHandoffRequest, error: unknown): void { + const record = this.requireRecord(params.envelope.sessionId) + this.setStatus( + record.sessionId, + failedStructuredHandoffStatus(record, params, error, this.deps.transport?.hostLabel) + ) + } + private closeRetainedOwner(sessionId: string): Promise { + return closeRetainedTuiOwner({ + sessionId, + deps: this.deps, + owner: this.state.owner, + requireRecord: this.requireRecord, + releaseOwner: this.state.releaseOwner + }) + } private requireRecord = (sessionId: string): AgentSessionRecord => requireStructuredHandoffRecord(this.deps, sessionId) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 99d6c212c10..c38f64c3318 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -6,6 +6,8 @@ import type { AgentSessionAttachResult, AgentSessionHistoryRequest, AgentSessionHistoryResult, + AgentSessionHandoffRequest, + AgentSessionHandoffResult, AgentSessionHandoffStatus, AgentSessionMutationResult, AgentSessionOptionsResult, @@ -56,7 +58,6 @@ import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' import { readStructuredAgentSessionHistoryResult } from './structured-agent-session-history-result' -import { retryPendingStructuredAgentSessionSettlement } from './structured-agent-session-settlement-retry' import { StructuredAgentSessionEventRecovery } from './structured-agent-session-event-recovery' export type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' export class StructuredAgentSessionHost { @@ -181,14 +182,6 @@ export class StructuredAgentSessionHost { subscribers: this.subscribers, tasks: this.tasks, reconcileLeases: (sessionId) => this.reconcileLeases(sessionId), - retryPendingSettlement: (sessionId, params) => - retryPendingStructuredAgentSessionSettlement({ - deps: this.deps, - sessions: this.sessions, - sessionId, - params, - now: () => this.now() - }), serialize: (sessionId, task) => this.serialize(sessionId, task), now: () => this.now() } @@ -299,6 +292,12 @@ export class StructuredAgentSessionHost { ): ReturnType => setStructuredAgentSessionOption(this.mutationContext(), caller, params) + requestHandoff = ( + caller: StructuredAgentSessionCaller, + params: AgentSessionHandoffRequest + ): Promise> => + this.handoffs.request(caller.callerKey, params) + readOptions = (sessionId: string): Promise => readStructuredAgentSessionOptions(this.mutationContext(), sessionId) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-manual-recovery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-manual-recovery.ts new file mode 100644 index 00000000000..91be1a15aa5 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-manual-recovery.ts @@ -0,0 +1,103 @@ +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { + AgentSessionHandoffRequest, + AgentSessionHandoffStatus +} from '../../../shared/agent-session-wire' +import { setStoredAgentSessionHandoffStage } from '../../runtime/agent-session-handoff-record-transitions' +import type { StructuredAgentSessionHandoffOperationGuard } from './structured-agent-session-handoff-operation-guard' +import { idleStructuredHandoffStatus } from './structured-agent-session-handoff-status' +import type { StructuredAgentSessionHandoffDeps } from './structured-agent-session-handoff-types' + +export function structuredManualRecoveryIsAdmissible( + record: AgentSessionRecord, + status: AgentSessionHandoffStatus | undefined +): boolean { + return ( + record.lease.handoffStage === 'manual-recovery' && + record.lease.runtimeKind === 'tui' && + record.lease.ownerProcess !== null && + status?.error?.canRetryProof === true + ) +} + +export function beginStructuredManualRecovery(input: { + deps: StructuredAgentSessionHandoffDeps + operationGuard: StructuredAgentSessionHandoffOperationGuard + callerKey: string + params: AgentSessionHandoffRequest + fingerprint: string + requireRecord: (sessionId: string) => AgentSessionRecord + restore: (sessionId: string) => Promise + setStatus: (sessionId: string, status: AgentSessionHandoffStatus) => void +}): Promise { + const { + callerKey, + deps, + fingerprint, + operationGuard, + params, + requireRecord, + restore, + setStatus + } = input + const sessionId = params.envelope.sessionId + operationGuard.start(sessionId, { + callerKey, + operationId: params.envelope.clientOperationId, + fingerprint + }) + setStatus(sessionId, { + owner: 'none', + direction: params.direction, + phase: 'switching', + stage: 'recovering', + operationId: params.envelope.clientOperationId, + hostLabel: deps.transport?.hostLabel + }) + return deps + .schedule(sessionId, async () => { + let record = requireRecord(sessionId) + if (record.lease.claimStatus === 'reserved' && record.lease.handoffOperationId !== null) { + record = await setStoredAgentSessionHandoffStage(deps.store, { + sessionId, + fence: record.lease.runtimeFence, + stage: 'new-owner-proving', + handoffOperationId: record.lease.handoffOperationId, + now: deps.now() + }) + } + await restore(record.sessionId) + if (requireRecord(sessionId).lease.handoffStage === 'manual-recovery') { + throw new Error('The TUI owner proof is still unavailable.') + } + }) + .then(() => { + operationGuard.finish(sessionId, params.envelope.clientOperationId) + return deps.store.recordOperationOutcome({ + callerKey, + operationId: params.envelope.clientOperationId, + outcome: { status: 'succeeded', sessionId } + }) + }) + .catch(async (error) => { + await deps.store.recordOperationOutcome({ + callerKey, + operationId: params.envelope.clientOperationId, + outcome: { status: 'failed', code: 'agent_session_handoff_failed' } + }) + operationGuard.finish(sessionId, params.envelope.clientOperationId) + const status = idleStructuredHandoffStatus(requireRecord(sessionId)) + setStatus(sessionId, { + ...status, + ...(status.error + ? { + error: { + ...status.error, + details: error instanceof Error ? error.message : String(error) + } + } + : {}) + }) + }) + .finally(() => operationGuard.finish(sessionId, params.envelope.clientOperationId)) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-option-restoration.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-option-restoration.ts index b9ba03ff327..6e71f822170 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-option-restoration.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-option-restoration.ts @@ -1,7 +1,7 @@ import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' export async function readNativeSessionOptions(input: { - adapter: Pick + adapter: Pick sessionId: string fence: number priorOptions?: Readonly> @@ -11,7 +11,13 @@ export async function readNativeSessionOptions(input: { if (!reported) { return undefined } - const { model: _model, effort: _effort, ...restored } = priorOptions ?? {} + const skipped = new Set(input.adapter.readOptionRestoreFailures?.(sessionId) ?? []) + const restored = priorOptions ? { ...priorOptions } : {} + delete restored.model + delete restored.effort + for (const key of skipped) { + delete restored[key] + } return { ...restored, model: reported.current.model, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-proven-dead-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-proven-dead-retry.test.ts new file mode 100644 index 00000000000..3caba894cb9 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-proven-dead-retry.test.ts @@ -0,0 +1,178 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import type { AgentSessionHandoffRequest } from '../../../shared/agent-session-wire' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { recoverStoredDeadTuiOwnerForHandoff } from '../../runtime/agent-session-handoff-record-transitions' +import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' +import { StructuredAgentSessionHandoffCoordinator } from './structured-agent-session-handoff' +import type { StructuredAgentSessionHandoffTransport } from './structured-agent-session-handoff-types' + +const NOW = 1_800_000_000_000 +const SESSION = 'session-proven-dead-retry' +const THREAD = '019fd532-7c11-7a90-b6de-4e1a2c3d5f60' +const CREATE_OPERATION = `${NOW}-00000000000000000000000000000000` +const OPERATION = `${NOW}-00000000000000000000000000000001` +const roots: string[] = [] + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('structured session proven-dead TUI retry', () => { + it('acquires native ownership without trying to close the dead TUI again', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-handoff-dead-retry-')) + roots.push(root) + const store = await AgentSessionRecordStore.open({ + directory: join(root, 'store'), + hostId: 'local' + }) + const reserved = await store.reserveOwner({ + sessionId: SESSION, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'folder' + }, + provider: 'codex', + accountHome: { variable: 'CODEX_HOME', path: join(root, 'codex-home') }, + runtimeKind: 'tui', + expectedFence: null, + spawnToken: 'tui-spawn', + claimKeyId: 'key-1', + handoffOperationId: null, + probe: { outcome: 'reservation-unused' }, + operation: { callerKey: 'test', operationId: CREATE_OPERATION, fingerprint: 'create' }, + now: NOW + }) + const tuiFence = reserved.record.lease.runtimeFence + await store.commitProcessIdentity({ + sessionId: SESSION, + fence: tuiFence, + process: { + hostId: 'local', + pid: 4200, + processStartTimeMs: NOW - 1_000, + spawnToken: 'tui-spawn' + }, + now: NOW + }) + await store.proveOwner({ + sessionId: SESSION, + fence: tuiFence, + link: { + linkId: 'tui-link', + handle: { provider: 'codex', threadId: THREAD }, + origin: 'created', + mintedAtFence: tuiFence, + observedAt: NOW + }, + now: NOW + }) + await recoverStoredDeadTuiOwnerForHandoff(store, { + sessionId: SESSION, + expectedFence: tuiFence, + operationId: OPERATION, + probe: { outcome: 'pid-absent' }, + now: NOW + }) + const journal = await openAgentSessionJournal({ + identity: { + sessionId: SESSION, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: THREAD } + }, + journalDir: join(root, 'journal') + }) + const closeTuiOwner = + vi.fn>() + const coordinator = new StructuredAgentSessionHandoffCoordinator({ + store, + claimKeyId: 'key-1', + transport: { + hostLabel: 'Test host', + launchTui: vi.fn(), + reproveTuiOwner: vi.fn(), + recoverTuiOwner: vi.fn(), + stopRecoveredOwner: vi.fn(), + closeTuiOwner, + waitForTuiExit: vi.fn(), + waitForTuiIdleOrExit: vi.fn(), + tuiStatus: () => 'busy' + }, + session: () => ({ journal, fence: store.getRecord(SESSION)?.lease.runtimeFence ?? 1 }), + suspendNative: vi.fn(), + acquireNative: async ({ fence, spawnToken }) => { + await store.commitProcessIdentity({ + sessionId: SESSION, + fence, + process: { + hostId: 'local', + pid: 4300, + processStartTimeMs: NOW, + spawnToken + }, + now: NOW + }) + return store.proveOwner({ + sessionId: SESSION, + fence, + link: { + linkId: 'native-link', + handle: { provider: 'codex', threadId: THREAD }, + origin: 'resumed', + mintedAtFence: fence, + observedAt: NOW + }, + now: NOW + }) + }, + acquireNativeStop: vi.fn(async () => true), + importTuiHistory: vi.fn(), + publish: vi.fn(), + schedule: async (_sessionId, task) => task(), + now: () => NOW + }) + const fields = { + direction: 'to-native' as const, + mode: 'now' as const, + action: 'retry' as const + } + const request: AgentSessionHandoffRequest = { + envelope: { + sessionId: SESSION, + clientOperationId: OPERATION, + expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? null, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.requestHandoff', + sessionId: SESSION, + fields + }) + }, + ...fields + } + + expect(coordinator.status(SESSION)).toMatchObject({ phase: 'failed', owner: 'tui' }) + expect( + await ( + coordinator as { + request: (callerKey: string, params: AgentSessionHandoffRequest) => Promise + } + ).request('client-1', request) + ).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(coordinator.status(SESSION).owner).toBe('native')) + // Settle the flow's trailing outcome write before afterEach removes the store root. + await coordinator.drain() + expect(closeTuiOwner).not.toHaveBeenCalled() + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + runtimeKind: 'native', + claimStatus: 'live', + handoffStage: null + }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts index a5927dc0c14..5cd888cc5cc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts @@ -8,7 +8,7 @@ import { spawnProcess } from '../../../shared/child-process/run-process' import { CODEX_SPAWN_TOKEN_ENV } from '../../codex/codex-structured-owner-identity' import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { readProcessStartTimeMs } from '../../runtime/agent-session-process-identity-probe' -import { createStructuredAgentSessionOwnerProbe } from '../../runtime/structured-agent-session-runtime' +import { createStructuredAgentSessionOwnerProbe } from '../../runtime/structured-agent-session-owner-probe' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { StructuredAgentSessionHost } from './structured-agent-session-host' import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-prompt.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-prompt.ts index 9d16e19a7f7..26ad85b5cfb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-prompt.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-prompt.ts @@ -1,6 +1,11 @@ import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { + decodeAgentSessionQuestionAnswers, + isValidAgentSessionQuestionAnswers +} from '../../../shared/agent-session-question-answer' import type { AgentJournalItemBody, + AgentJournalQuestion, AgentJournalResolution } from '../../../shared/agent-session-journal-types' import type { AgentSessionPromptResult } from '../../../shared/agent-session-wire' @@ -14,6 +19,7 @@ function invalid(message: string): TurnOutcome { function promptBodyOf(body: AgentJournalItemBody): { options: readonly { id: string }[] freeTextQuestionId?: string + questions?: AgentJournalQuestion[] resolution: AgentJournalResolution } | null { return body.kind === 'approval' || body.kind === 'question' ? body : null @@ -64,7 +70,19 @@ export async function performPrompt( prompt.freeTextQuestionId !== undefined && freeText?.questionId === prompt.freeTextQuestionId && freeText.answer.trim().length > 0 - if (!acceptsFreeText && !prompt.options.some((option) => option.id === input.optionId)) { + const grouped = + item.body.kind === 'question' && prompt.questions + ? decodeAgentSessionQuestionAnswers(input.optionId) + : null + const acceptsGrouped = + grouped !== null && + prompt.questions !== undefined && + isValidAgentSessionQuestionAnswers(prompt.questions, grouped) + if ( + !acceptsFreeText && + !acceptsGrouped && + !prompt.options.some((option) => option.id === input.optionId) + ) { return invalid(`Option ${input.optionId} is not offered by item ${input.itemId}.`) } const identity = parseAgentJournalItemKey(input.itemId) diff --git a/src/main/native-chat/agent-session-wire/unhandled-provider-frame.ts b/src/main/native-chat/agent-session-wire/unhandled-provider-frame.ts index 804d2900a22..60f34707ac9 100644 --- a/src/main/native-chat/agent-session-wire/unhandled-provider-frame.ts +++ b/src/main/native-chat/agent-session-wire/unhandled-provider-frame.ts @@ -54,7 +54,8 @@ function directReadableMessage(payload: unknown): string | null { return null } -function readableMessage(payload: unknown): string | null { +/** The provider's own sentence for a frame, when it carries one. */ +export function readableProviderFrameText(payload: unknown): string | null { const direct = directReadableMessage(payload) if (direct || typeof payload !== 'object' || payload === null || Array.isArray(payload)) { return direct @@ -89,7 +90,7 @@ export function unhandledProviderFrameJournalItem( // Why: the opcode alone ("codex · notification:warning") tells the user nothing // and reads as protocol noise. Lead with the provider's own sentence when it has // one; the raw frame stays behind the row's disclosure either way. - const message = readableMessage(payload) + const message = readableProviderFrameText(payload) const display = message ? boundInlineText(message, limits) : null return { body: { diff --git a/src/main/native-chat/claude-structured-managed-account-support.test.ts b/src/main/native-chat/claude-structured-managed-account-support.test.ts new file mode 100644 index 00000000000..f647579d03e --- /dev/null +++ b/src/main/native-chat/claude-structured-managed-account-support.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from 'vitest' +import { getSelectedClaudeAccountIdForTarget } from '../claude-accounts/runtime-selection' +import { + structuredClaudeMatchesActiveManagedAccount, + type ClaudeManagedAccountGateSettings +} from './claude-structured-managed-account-support' + +function account(id: string, managedAuthRuntime: 'host' | 'wsl') { + return { + id, + email: `${id}@example.com`, + managedAuthPath: `/managed/${id}`, + managedAuthRuntime, + authMethod: 'subscription-oauth' as const, + createdAt: 0, + updatedAt: 0, + lastAuthenticatedAt: 0 + } +} + +function settings( + overrides: Partial +): ClaudeManagedAccountGateSettings { + return { claudeManagedAccounts: [], activeClaudeManagedAccountId: null, ...overrides } +} + +describe('structuredClaudeMatchesActiveManagedAccount', () => { + it('allows an unmanaged install, where nothing claims an identity', () => { + expect(structuredClaudeMatchesActiveManagedAccount(settings({}))).toBe(true) + }) + + it('allows a selected host account, which the runtime syncs into the ambient config', () => { + expect( + structuredClaudeMatchesActiveManagedAccount( + settings({ + claudeManagedAccounts: [account('host-1', 'host')], + activeClaudeManagedAccountIdsByRuntime: { host: 'host-1', wsl: {} } + }) + ) + ).toBe(true) + }) + + it('refuses a WSL-only managed account, which never reaches the ambient config', () => { + expect( + structuredClaudeMatchesActiveManagedAccount( + settings({ + claudeManagedAccounts: [account('wsl-1', 'wsl')], + activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'wsl-1' } } + }) + ) + ).toBe(false) + }) + + it('refuses when a host selection names an account that is WSL-bound or missing', () => { + expect( + structuredClaudeMatchesActiveManagedAccount( + settings({ + claudeManagedAccounts: [account('wsl-1', 'wsl')], + activeClaudeManagedAccountIdsByRuntime: { host: 'wsl-1', wsl: {} } + }) + ) + ).toBe(false) + expect( + structuredClaudeMatchesActiveManagedAccount( + settings({ + claudeManagedAccounts: [account('host-1', 'host')], + activeClaudeManagedAccountIdsByRuntime: { host: 'gone', wsl: {} } + }) + ) + ).toBe(false) + }) + + /** Absent and empty are the same answer: this user has no managed Claude accounts, so nothing + * claims an identity and the ambient path is legitimate. Only settings that cannot be READ are + * unknown. Treating a missing key as unknown strands profiles that simply never wrote it — the + * auth policy's own predicate takes `(accounts ?? [])` for exactly this reason. */ + it('treats an absent account list the same as an empty one', () => { + expect( + structuredClaudeMatchesActiveManagedAccount(settings({ claudeManagedAccounts: [] })) + ).toBe(true) + expect( + structuredClaudeMatchesActiveManagedAccount({ + activeClaudeManagedAccountId: null + } as unknown as ClaudeManagedAccountGateSettings) + ).toBe(true) + }) + + it('fails closed when the settings cannot be read at all', () => { + expect(structuredClaudeMatchesActiveManagedAccount(null)).toBe(false) + expect(structuredClaudeMatchesActiveManagedAccount(undefined)).toBe(false) + }) + + /** The four states this gate exists to tell apart, pinned together so a change to one is visible + * against the others. */ + it.each([ + ['no managed accounts', [], null, true], + ['accounts present, none active, no WSL account', [account('host-1', 'host')], null, true], + ['host account selected', [account('host-1', 'host')], 'host-1', true], + ['WSL-only, normalized to no host selection', [account('wsl-1', 'wsl')], null, false] + ] as const)('resolves %s', (_name, claudeManagedAccounts, activeId, expected) => { + expect( + structuredClaudeMatchesActiveManagedAccount( + settings({ + claudeManagedAccounts: [...claudeManagedAccounts], + activeClaudeManagedAccountIdsByRuntime: { host: activeId, wsl: {} } + }) + ) + ).toBe(expected) + }) + + /** THE discriminator, and the whole of this rule. With nothing selected for the host runtime the + * settings alone cannot distinguish honest deselection from the WSL-only steady state, because + * `pruneInvalidClaudeRuntimeSelection` empties the host slot in the second case and persists it. + * So the presence of ANY WSL-bound account decides. Simplifying this to "none active -> + * supported" re-opens the auth-identity misrepresentation this gate exists to prevent. */ + it('splits none-active on whether a WSL-bound account exists at all', () => { + const noneActive = (accounts: ReturnType[]) => + structuredClaudeMatchesActiveManagedAccount( + settings({ + claudeManagedAccounts: accounts, + activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: {} } + }) + ) + + expect(noneActive([account('host-1', 'host')])).toBe(true) + expect(noneActive([account('host-1', 'host'), account('host-2', 'host')])).toBe(true) + expect(noneActive([account('wsl-1', 'wsl')])).toBe(false) + // Mixed list still refuses: the WSL account is present and nothing is selected. + expect(noneActive([account('host-1', 'host'), account('wsl-1', 'wsl')])).toBe(false) + }) + + /** The gate and the auth policy must resolve the SAME account. A legacy settings blob carries the + * selection only in the flat `activeClaudeManagedAccountId`, which is where the accessor's + * fall-through lives — reading the runtime map directly silently disagrees with the policy. */ + it('resolves the same account as the auth policy on a legacy flat selection', () => { + const legacy = settings({ + claudeManagedAccounts: [account('host-1', 'host')], + activeClaudeManagedAccountId: 'host-1' + }) + + expect(getSelectedClaudeAccountIdForTarget(legacy, { runtime: 'host' })).toBe('host-1') + expect(structuredClaudeMatchesActiveManagedAccount(legacy)).toBe(true) + }) + + it('agrees with the auth policy that a legacy flat WSL selection is refused', () => { + const legacy = settings({ + claudeManagedAccounts: [account('wsl-1', 'wsl')], + activeClaudeManagedAccountId: 'wsl-1' + }) + + expect(getSelectedClaudeAccountIdForTarget(legacy, { runtime: 'host' })).toBe('wsl-1') + expect(structuredClaudeMatchesActiveManagedAccount(legacy)).toBe(false) + }) +}) diff --git a/src/main/native-chat/claude-structured-managed-account-support.ts b/src/main/native-chat/claude-structured-managed-account-support.ts new file mode 100644 index 00000000000..dccf6216bda --- /dev/null +++ b/src/main/native-chat/claude-structured-managed-account-support.ts @@ -0,0 +1,61 @@ +import type { GlobalSettings } from '../../shared/global-settings-types' +import { getSelectedClaudeAccountIdForTarget } from '../claude-accounts/runtime-selection' + +export type ClaudeManagedAccountGateSettings = Pick< + GlobalSettings, + | 'claudeManagedAccounts' + | 'activeClaudeManagedAccountId' + | 'activeClaudeManagedAccountIdsByRuntime' +> + +/** + * A structured Claude session launches against the ambient Claude config, which the account service + * keeps in sync with the selected HOST account. A WSL-bound managed account lives inside the distro + * and is never synced there, so such a session would authenticate as whatever the ambient identity + * happens to be while the UI names the WSL account — the user is told one identity and given + * another. Refuse the structured path there and let the terminal-backed one, which resolves the + * account per runtime, handle that account shape. + * + * Reads the selection through the same accessor the auth policy uses. Resolving it any other way + * lets the two disagree, and a session admitted by this gate would then run under a policy computed + * from a different account than the one approved here. + * + * Unknown answers refuse, and only genuinely unknown ones: settings that cannot be read at all, or + * an active selection this cannot resolve. An install with no managed accounts — the list empty or + * never written — claims no identity and is fine. + */ +export function structuredClaudeMatchesActiveManagedAccount( + settings: ClaudeManagedAccountGateSettings | null | undefined +): boolean { + if (!settings) { + return false + } + // Absent is the same answer as empty — this user has no managed Claude accounts, so nothing + // claims an identity and ambient auth is the truth. Only settings that cannot be READ are + // unknown, and those refuse above. The auth policy reads the list the same way. + const accounts = settings.claudeManagedAccounts ?? [] + if (accounts.length === 0) { + return true + } + const activeHostId = getSelectedClaudeAccountIdForTarget(settings, { runtime: 'host' }) + if (!activeHostId) { + // Nothing selected for the host runtime is two different states that the settings cannot tell + // apart after the fact: honest deselection, where ambient auth is the truth and the UI names no + // identity, and the WSL-only case, where the prune emptied the host slot and persisted null + // while the UI still names the WSL account. The presence of any WSL-bound account decides. + return !accounts.some((candidate) => candidate.managedAuthRuntime === 'wsl') + } + const active = accounts.find((candidate) => candidate.id === activeHostId) + return active ? active.managedAuthRuntime !== 'wsl' : false +} + +/** Reads the gate's settings, answering null when they cannot be read so callers refuse. */ +export function readClaudeManagedAccountGateSettings( + getSettings: () => ClaudeManagedAccountGateSettings +): ClaudeManagedAccountGateSettings | null { + try { + return getSettings() + } catch { + return null + } +} diff --git a/src/main/native-chat/session-file-resolver-claude-roots.test.ts b/src/main/native-chat/session-file-resolver-claude-roots.test.ts new file mode 100644 index 00000000000..87ebd570280 --- /dev/null +++ b/src/main/native-chat/session-file-resolver-claude-roots.test.ts @@ -0,0 +1,97 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const scanned = vi.hoisted(() => ({ dirs: [] as string[], hits: {} as Record })) +vi.mock('../ai-vault/session-scanner-discovery', () => ({ + walkSessionFiles: async (dir: string) => { + scanned.dirs.push(dir) + const hit = scanned.hits[dir] + return hit ? [hit] : [] + } +})) + +import { homedir } from 'node:os' +import { join } from 'node:path' +import { resolveSessionFilePath } from './session-file-resolver' + +const DEFAULT_ROOT = join(homedir(), '.claude', 'projects') +const CONFIG_DIR = '/opt/claude-home' +const CONFIG_ROOT = join(CONFIG_DIR, 'projects') + +let previousConfigDir: string | undefined + +beforeEach(() => { + previousConfigDir = process.env.CLAUDE_CONFIG_DIR + scanned.dirs = [] + scanned.hits = {} +}) + +afterEach(() => { + if (previousConfigDir === undefined) { + delete process.env.CLAUDE_CONFIG_DIR + } else { + process.env.CLAUDE_CONFIG_DIR = previousConfigDir + } +}) + +/** + * Honouring CLAUDE_CONFIG_DIR fixed new sessions but would otherwise hide every + * transcript written before the user adopted the variable. The Codex resolver in this + * same file already searches managed-then-default and de-dupes; Claude does the same. + */ +describe('claude transcript roots', () => { + it('searches the config-dir root first, then the default home', async () => { + process.env.CLAUDE_CONFIG_DIR = CONFIG_DIR + + await resolveSessionFilePath('claude', 'session-1') + + expect(scanned.dirs).toEqual([CONFIG_ROOT, DEFAULT_ROOT]) + }) + + it('still finds history written before CLAUDE_CONFIG_DIR was adopted', async () => { + process.env.CLAUDE_CONFIG_DIR = CONFIG_DIR + const legacy = join(DEFAULT_ROOT, '-repos-old', 'session-1.jsonl') + scanned.hits[DEFAULT_ROOT] = legacy + + await expect(resolveSessionFilePath('claude', 'session-1')).resolves.toBe(legacy) + }) + + it('prefers the config-dir root when both hold the session', async () => { + process.env.CLAUDE_CONFIG_DIR = CONFIG_DIR + scanned.hits[CONFIG_ROOT] = join(CONFIG_ROOT, '-repos-new', 'session-1.jsonl') + scanned.hits[DEFAULT_ROOT] = join(DEFAULT_ROOT, '-repos-old', 'session-1.jsonl') + + await expect(resolveSessionFilePath('claude', 'session-1')).resolves.toBe( + scanned.hits[CONFIG_ROOT] + ) + // The default root is never reached, so the common case pays for one scan. + expect(scanned.dirs).toEqual([CONFIG_ROOT]) + }) + + it('scans one root when the variable is unset', async () => { + delete process.env.CLAUDE_CONFIG_DIR + + await resolveSessionFilePath('claude', 'session-1') + + expect(scanned.dirs).toEqual([DEFAULT_ROOT]) + }) + + it('de-dupes when CLAUDE_CONFIG_DIR names the default home', async () => { + process.env.CLAUDE_CONFIG_DIR = join(homedir(), '.claude') + + await resolveSessionFilePath('claude', 'session-1') + + expect(scanned.dirs).toEqual([DEFAULT_ROOT]) + }) + + it('honours an explicit root override without adding fallbacks', async () => { + process.env.CLAUDE_CONFIG_DIR = CONFIG_DIR + // The account-home callers (structured-claude-runtime-adapter, the host handoff) + // know the exact tree their session pinned; a fallback there could resolve a + // different account's transcript. + await resolveSessionFilePath('claude', 'session-1', { + claudeProjectsDir: '/accounts/pinned/projects' + }) + + expect(scanned.dirs).toEqual(['/accounts/pinned/projects']) + }) +}) diff --git a/src/main/native-chat/session-file-resolver.test.ts b/src/main/native-chat/session-file-resolver.test.ts index 584d8a25a9d..04946f5b464 100644 --- a/src/main/native-chat/session-file-resolver.test.ts +++ b/src/main/native-chat/session-file-resolver.test.ts @@ -1,9 +1,12 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' -import { ClaudeTranscriptTailIncompleteError } from '../claude/claude-transcript-branch-proof' +import { + ClaudeTranscriptTailIncompleteError, + readClaudeTranscriptLeafWithReproof +} from '../claude/claude-transcript-branch-proof' import { readClaudeTranscriptLeafUuid, resolveSessionFilePath } from './session-file-resolver' let tempRoots: string[] = [] @@ -139,6 +142,263 @@ describe('resolveSessionFilePath', () => { ) }) + it('rejects non-transcript and sidechain UUIDs as the durable leaf', async () => { + const root = await makeRoot('orca-native-chat-resolve-claude-leaf-filter-') + const transcript = join(root, 'session.jsonl') + await writeFile( + transcript, + [ + { type: 'user', uuid: 'main-user', parentUuid: null, sessionId: 'session-1' }, + { + type: 'assistant', + uuid: 'sidechain-assistant', + parentUuid: 'main-user', + sessionId: 'session-1', + isSidechain: true + }, + { type: 'result', uuid: 'result-frame', parentUuid: 'main-user', sessionId: 'session-1' }, + { + type: 'system', + subtype: 'init', + uuid: 'init-frame', + parentUuid: null, + sessionId: 'session-1' + }, + { type: 'stream_event', uuid: 'stream-frame', parentUuid: null, sessionId: 'session-1' }, + { type: 'last-prompt', leafUuid: 'sidechain-assistant', sessionId: 'session-1' } + ] + .map((record) => JSON.stringify(record)) + .join('\n'), + 'utf8' + ) + + await expect(readClaudeTranscriptLeafUuid(transcript, 'session-1')).rejects.toThrow( + 'marker leaf is missing from the session graph' + ) + }) + + it('rejects a main leaf whose ancestry crosses a subagent sidechain', async () => { + const root = await makeRoot('orca-native-chat-resolve-claude-sidechain-ancestry-') + const transcript = join(root, 'session.jsonl') + await writeFile( + transcript, + [ + { type: 'user', uuid: 'main-user', parentUuid: null, sessionId: 'session-1' }, + { + type: 'assistant', + uuid: 'sidechain-assistant', + parentUuid: 'main-user', + sessionId: 'session-1', + isSidechain: true + }, + { + type: 'assistant', + uuid: 'main-after-sidechain', + parentUuid: 'sidechain-assistant', + sessionId: 'session-1' + }, + { type: 'last-prompt', leafUuid: 'main-after-sidechain', sessionId: 'session-1' } + ] + .map((record) => JSON.stringify(record)) + .join('\n'), + 'utf8' + ) + + await expect(readClaudeTranscriptLeafUuid(transcript, 'session-1')).rejects.toThrow( + 'not on the main transcript' + ) + }) + + it('rejects a main leaf whose ancestry crosses a parent-tool-use sidechain', async () => { + const root = await makeRoot('orca-native-chat-resolve-claude-parent-tool-ancestry-') + const transcript = join(root, 'transcript.jsonl') + await writeFile( + transcript, + [ + { type: 'user', uuid: 'main-user', parentUuid: null, sessionId: 'session-1' }, + { + type: 'assistant', + uuid: 'subagent-assistant', + parentUuid: 'main-user', + sessionId: 'session-1', + parent_tool_use_id: 'tool-use-1' + }, + { + type: 'assistant', + uuid: 'main-after-sidechain', + parentUuid: 'subagent-assistant', + sessionId: 'session-1' + }, + { type: 'last-prompt', leafUuid: 'main-after-sidechain', sessionId: 'session-1' } + ] + .map((record) => JSON.stringify(record)) + .join('\n'), + 'utf8' + ) + + await expect(readClaudeTranscriptLeafUuid(transcript, 'session-1')).rejects.toThrow( + 'not on the main transcript' + ) + }) + + it('rejects a previous cursor descended from a parent-tool-use sidechain', async () => { + const root = await makeRoot('orca-native-chat-resolve-claude-parent-tool-cursor-') + const transcript = join(root, 'transcript.jsonl') + await writeFile( + transcript, + [ + { type: 'user', uuid: 'main-user', parentUuid: null, sessionId: 'session-1' }, + { + type: 'assistant', + uuid: 'subagent-assistant', + parentUuid: 'main-user', + sessionId: 'session-1', + parent_tool_use_id: 'tool-use-1' + }, + { + type: 'assistant', + uuid: 'main-after-sidechain', + parentUuid: 'subagent-assistant', + sessionId: 'session-1' + }, + { type: 'last-prompt', leafUuid: 'main-after-sidechain', sessionId: 'session-1' } + ] + .map((record) => JSON.stringify(record)) + .join('\n'), + 'utf8' + ) + + await expect( + readClaudeTranscriptLeafUuid(transcript, 'session-1', 'main-after-sidechain') + ).rejects.toThrow('not on the main transcript') + }) + + it('rejects a latest marker descended from a parent-tool-use cursor sidechain', async () => { + const root = await makeRoot('orca-native-chat-resolve-claude-parent-tool-cursor-descendant-') + const transcript = join(root, 'transcript.jsonl') + await writeFile( + transcript, + [ + { type: 'user', uuid: 'main-user', parentUuid: null, sessionId: 'session-1' }, + { + type: 'assistant', + uuid: 'subagent-assistant', + parentUuid: 'main-user', + sessionId: 'session-1', + parent_tool_use_id: 'tool-use-1' + }, + { + type: 'assistant', + uuid: 'main-after-sidechain', + parentUuid: 'subagent-assistant', + sessionId: 'session-1' + }, + { + type: 'assistant', + uuid: 'latest-after-sidechain', + parentUuid: 'main-after-sidechain', + sessionId: 'session-1' + }, + { type: 'last-prompt', leafUuid: 'latest-after-sidechain', sessionId: 'session-1' } + ] + .map((record) => JSON.stringify(record)) + .join('\n'), + 'utf8' + ) + + await expect( + readClaudeTranscriptLeafUuid(transcript, 'session-1', 'main-after-sidechain') + ).rejects.toThrow('not on the main transcript') + }) + + it('rejects a post-snapshot descendant whose parent row was observed later', async () => { + const root = await makeRoot('orca-native-chat-resolve-claude-post-snapshot-') + const transcript = join(root, 'transcript.jsonl') + await writeFile( + transcript, + [ + { + type: 'assistant', + uuid: 'descendant', + parentUuid: 'previous', + sessionId: 'session-1' + }, + { type: 'assistant', uuid: 'previous', parentUuid: null, sessionId: 'session-1' }, + { type: 'last-prompt', leafUuid: 'descendant', sessionId: 'session-1' } + ] + .map((record) => JSON.stringify(record)) + .join('\n'), + 'utf8' + ) + + await expect(readClaudeTranscriptLeafUuid(transcript, 'session-1', 'previous')).rejects.toThrow( + 'parent row follows descendant' + ) + }) + + it('does not re-prove a divergent sibling after the sampled cursor rejects', async () => { + const root = await makeRoot('orca-native-chat-resolve-claude-sibling-reproof-') + const transcript = join(root, 'transcript.jsonl') + await writeFile( + transcript, + [ + { type: 'user', uuid: 'root', parentUuid: null, sessionId: 'session-1' }, + { type: 'assistant', uuid: 'old', parentUuid: 'root', sessionId: 'session-1' }, + { type: 'assistant', uuid: 'new', parentUuid: 'root', sessionId: 'session-1' }, + { type: 'last-prompt', leafUuid: 'new', sessionId: 'session-1' } + ] + .map((record) => JSON.stringify(record)) + .join('\n'), + 'utf8' + ) + const calls: (string | null)[] = [] + const readTranscriptLeaf = async ({ + previousLeafUuid + }: { + previousLeafUuid: string | null + }) => { + calls.push(previousLeafUuid) + return readClaudeTranscriptLeafUuid(transcript, 'session-1', previousLeafUuid) + } + + await expect(readClaudeTranscriptLeafUuid(transcript, 'session-1', 'old')).rejects.toThrow( + 'sibling branch' + ) + + await expect( + readClaudeTranscriptLeafWithReproof({ + readTranscriptLeaf, + claudeConfigDir: '/accounts/claude', + providerSessionId: 'session-1', + previousLeafUuid: 'old' + }) + ).rejects.toThrow('sibling branch') + expect(calls).toEqual(['old']) + }) + + it('does not accept a divergent sibling after a truncated-tail reproof', async () => { + const calls: (string | null)[] = [] + const readTranscriptLeaf = vi.fn( + async ({ previousLeafUuid }: { previousLeafUuid: string | null }) => { + calls.push(previousLeafUuid) + if (calls.length === 1) { + throw new ClaudeTranscriptTailIncompleteError() + } + return 'divergent-sibling' + } + ) + + await expect( + readClaudeTranscriptLeafWithReproof({ + readTranscriptLeaf, + claudeConfigDir: '/accounts/claude', + providerSessionId: 'session-1', + previousLeafUuid: 'old' + }) + ).rejects.toBeInstanceOf(ClaudeTranscriptTailIncompleteError) + expect(calls).toEqual(['old']) + }) + it('globs Claude project subdirs for .jsonl', async () => { const root = await makeRoot('orca-native-chat-resolve-claude-') const claudeProjectsDir = join(root, 'claude-projects') @@ -410,3 +670,42 @@ describe('resolveSessionFilePath', () => { expect(resolved).toBe(target) }) }) + +// Mobile native chat resolves with no root override (transcript-read-cache.ts:104), +// while the account home a structured Claude session pins is +// `CLAUDE_CONFIG_DIR || ~/.claude` (runtime-paths.ts:15). When the two disagree the +// CLI writes one place and mobile reads another, and the chat goes dark with no +// wire-level error — so the default root has to honour the same variable. +describe('the default Claude transcript root mobile falls back to', () => { + it('follows CLAUDE_CONFIG_DIR, the same variable the pinned account home follows', async () => { + const configDir = await makeRoot('orca-native-chat-claude-config-dir-') + const slugDir = join(configDir, 'projects', '-repos-workspace-1') + await mkdir(slugDir, { recursive: true }) + const transcript = join(slugDir, 'session-under-config-dir.jsonl') + await writeFile(transcript, '', 'utf8') + const previous = process.env.CLAUDE_CONFIG_DIR + process.env.CLAUDE_CONFIG_DIR = configDir + + try { + // No `claudeProjectsDir` override: exactly the call mobile makes. + await expect(resolveSessionFilePath('claude', 'session-under-config-dir')).resolves.toBe( + transcript + ) + } finally { + restoreEnv('CLAUDE_CONFIG_DIR', previous) + } + }) + + it('ignores a blank CLAUDE_CONFIG_DIR rather than resolving against the filesystem root', async () => { + const previous = process.env.CLAUDE_CONFIG_DIR + process.env.CLAUDE_CONFIG_DIR = ' ' + + try { + await expect( + resolveSessionFilePath('claude', 'session-that-does-not-exist') + ).resolves.toBeNull() + } finally { + restoreEnv('CLAUDE_CONFIG_DIR', previous) + } + }) +}) diff --git a/src/main/native-chat/session-file-resolver.ts b/src/main/native-chat/session-file-resolver.ts index 0ee73fddc8d..12d2e615742 100644 --- a/src/main/native-chat/session-file-resolver.ts +++ b/src/main/native-chat/session-file-resolver.ts @@ -31,8 +31,20 @@ import { proveClaudeTranscriptBranch } from '../claude/claude-transcript-branch- // the remote main resolves its local home, so we never hardcode an absolute // user path — homedir()/CODEX_HOME resolution stays runtime-relative and is // computed per call (not at module load) so it tracks the live home. -function claudeProjectsDir(): string { - return join(homedir(), '.claude', 'projects') +// Why CLAUDE_CONFIG_DIR and not just homedir(): a structured Claude session pins its +// account home to `CLAUDE_CONFIG_DIR || ~/.claude` (claude-accounts/runtime-paths.ts), +// and the CLI writes its transcript under whatever home it was given. Mobile native chat +// resolves with no root override, so a default that ignored the variable read a different +// tree than the CLI wrote — a silent blackout, not an error. +// Why both roots and not just that one: adopting the variable would otherwise hide every +// transcript written before it was set. Same managed-then-default shape as +// codexSessionsDirs below, de-duped so the usual case still scans once. +function claudeProjectsDirs(): string[] { + const candidates = [ + join(process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), '.claude'), 'projects'), + join(homedir(), '.claude', 'projects') + ] + return candidates.filter((dir, index) => candidates.indexOf(dir) === index) } // Why: Orca launches Codex with ORCA_CODEX_HOME pointing at its own managed @@ -173,9 +185,11 @@ async function resolveSessionFileById( } if (transcriptAgent === 'claude') { + // An explicit root is the caller naming the exact account tree its session pinned; + // adding a fallback there could resolve a different account's transcript. return resolveClaudeSessionFile( trimmedId, - options.claudeProjectsDir ?? claudeProjectsDir(), + options.claudeProjectsDir ? [options.claudeProjectsDir] : claudeProjectsDirs(), signal ) } @@ -205,16 +219,22 @@ async function resolveSessionFileById( async function resolveClaudeSessionFile( sessionId: string, - projectsDir: string, + projectsDirs: readonly string[], signal?: AbortSignal ): Promise { const targetName = `${sessionId}.jsonl` - const files = await walkSessionFiles(projectsDir, 'claude', [], { - extensions: new Set(['.jsonl']), - filePredicate: (path) => basename(path) === targetName, - signal - }) - return files[0] ?? null + for (const projectsDir of projectsDirs) { + // No existence pre-check: walkSessionFiles already yields [] for a missing root. + const files = await walkSessionFiles(projectsDir, 'claude', [], { + extensions: new Set(['.jsonl']), + filePredicate: (path) => basename(path) === targetName, + signal + }) + if (files[0]) { + return files[0] + } + } + return null } async function resolveCodexSessionFile( diff --git a/src/main/native-chat/structured-agent-session-create-support.test.ts b/src/main/native-chat/structured-agent-session-create-support.test.ts new file mode 100644 index 00000000000..ab19d369bac --- /dev/null +++ b/src/main/native-chat/structured-agent-session-create-support.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest' +import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' +import type { ClaudeManagedAccountGateSettings } from './claude-structured-managed-account-support' +import { resolveStructuredAgentSessionCreateSupport } from './structured-agent-session-create-support' + +const LOCAL: AgentSessionExecutionLocation = { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' +} + +function managedAccount(id: string, managedAuthRuntime: 'host' | 'wsl') { + return { + id, + email: `${id}@example.com`, + managedAuthPath: `/managed/${id}`, + managedAuthRuntime, + authMethod: 'subscription-oauth' as const, + createdAt: 0, + updatedAt: 0, + lastAuthenticatedAt: 0 + } +} + +const HOST_SELECTED: ClaudeManagedAccountGateSettings = { + claudeManagedAccounts: [managedAccount('host-1', 'host')], + activeClaudeManagedAccountId: 'host-1', + activeClaudeManagedAccountIdsByRuntime: { host: 'host-1', wsl: {} } +} + +const WSL_ONLY: ClaudeManagedAccountGateSettings = { + claudeManagedAccounts: [managedAccount('wsl-1', 'wsl')], + activeClaudeManagedAccountId: null, + activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'wsl-1' } } +} + +function support( + overrides: Partial[0]> = {} +) { + return resolveStructuredAgentSessionCreateSupport({ + agent: 'claude', + location: LOCAL, + adapterSupportsCreate: true, + getSettings: () => HOST_SELECTED, + ...overrides + }) +} + +describe('resolveStructuredAgentSessionCreateSupport', () => { + it('supports Claude under a selected host account', () => { + expect(support()).toEqual({ supported: true }) + }) + + it('refuses Claude under a WSL-only managed account', () => { + expect(support({ getSettings: () => WSL_ONLY })).toEqual({ supported: false, reason: 'wsl' }) + }) + + it('fails closed for Claude when the settings throw', () => { + expect( + support({ + getSettings: () => { + throw new Error('no store') + } + }) + ).toEqual({ supported: false, reason: 'wsl' }) + }) + + it('leaves Codex to the adapter answer under the same WSL-only account', () => { + expect(support({ agent: 'codex', getSettings: () => WSL_ONLY })).toEqual({ supported: true }) + }) + + it.each([ + ['remote', { ...LOCAL, executionHostId: 'ssh:host-a' }, 'remote'], + ['wsl workspace', { ...LOCAL, wslDistro: 'Ubuntu' }, 'wsl'], + ['unsupported agent', LOCAL, 'agent'] + ] as const)('keeps the adapter refusal reason for %s', (_name, location, reason) => { + expect(support({ adapterSupportsCreate: false, location })).toEqual({ + supported: false, + reason + }) + }) +}) diff --git a/src/main/native-chat/structured-agent-session-create-support.ts b/src/main/native-chat/structured-agent-session-create-support.ts new file mode 100644 index 00000000000..9b96a1af4be --- /dev/null +++ b/src/main/native-chat/structured-agent-session-create-support.ts @@ -0,0 +1,48 @@ +import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' +import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import { + readClaudeManagedAccountGateSettings, + structuredClaudeMatchesActiveManagedAccount, + type ClaudeManagedAccountGateSettings +} from './claude-structured-managed-account-support' + +export type StructuredAgentSessionCreateSupport = { + supported: boolean + reason?: 'agent' | 'remote' | 'wsl' +} + +/** + * The create-support verdict, kept out of the runtime class file because that file is `@ts-nocheck` + * — a call site there is not typechecked, so an auth-identity decision written inline would compile + * however wrong it was. The runtime hands over the two facts it owns and this decides. + */ +export function resolveStructuredAgentSessionCreateSupport(input: { + agent: 'claude' | 'codex' + location: AgentSessionExecutionLocation + adapterSupportsCreate: boolean + getSettings: () => ClaudeManagedAccountGateSettings +}): StructuredAgentSessionCreateSupport { + if (!input.adapterSupportsCreate) { + return { + supported: false, + reason: + input.location.executionHostId !== LOCAL_EXECUTION_HOST_ID + ? 'remote' + : input.location.wslDistro + ? 'wsl' + : 'agent' + } + } + // Claude only: Codex resolves its account on a different path, so its answer is untouched here. + // `wsl` is the closest existing reason — the cause is a WSL-bound account rather than a WSL + // workspace — and no client reads the field, so it stays as-is. + if ( + input.agent === 'claude' && + !structuredClaudeMatchesActiveManagedAccount( + readClaudeManagedAccountGateSettings(input.getSettings) + ) + ) { + return { supported: false, reason: 'wsl' } + } + return { supported: true } +} diff --git a/src/main/providers/windows-foreground-process-rows.ts b/src/main/providers/windows-foreground-process-rows.ts index 5f462649e6c..e8320a6d00a 100644 --- a/src/main/providers/windows-foreground-process-rows.ts +++ b/src/main/providers/windows-foreground-process-rows.ts @@ -108,6 +108,26 @@ export async function queryWindowsPaneProcessInventory( } } +/** + * The descendant walk over rows the caller already read. + * + * Why exported: a caller that needs a field this module's projection drops — + * process creation time, for a PID-reuse-safe teardown snapshot — would + * otherwise read the whole table a second time to get it. + * Null when the root is absent, which is a stale or filtered snapshot rather + * than a root with no descendants. + */ +export function windowsDescendantsFromRows( + rows: Row[], + rootPid: number +): (Row & { depth: number })[] | null { + const index = getProcessTableIndex(rows) + if (!index.byPid.has(rootPid)) { + return null + } + return collectDescendantsFromIndex(index, rootPid).sort((a, b) => b.depth - a.depth) +} + /** Test-only: clear the shared snapshot so one case's rows never serve the next. */ export function resetWindowsProcessRowsSnapshotForTests(): void { resetWindowsProcessTableForTests() diff --git a/src/main/pty-descendant-exit-verification.ts b/src/main/pty-descendant-exit-verification.ts index c0ed223704a..4c8471fa955 100644 --- a/src/main/pty-descendant-exit-verification.ts +++ b/src/main/pty-descendant-exit-verification.ts @@ -21,50 +21,143 @@ function waitForDelay(ms: number): Promise { function matchingSnapshotRows( snapshot: DescendantSnapshot, - table: readonly ProcessTableRow[] + table: readonly ProcessTableRow[], + rejectDuplicatePids = false ): ProcessTableRow[] { const expected = new Map(snapshot.descendants.map((row) => [row.pid, row])) - return table.filter((live) => { - const row = expected.get(live.pid) - return row?.startedAt === live.startedAt && row.pgid === live.pgid + const rowsByPid = new Map() + for (const live of table) { + const rows = rowsByPid.get(live.pid) + if (rows) { + rows.push(live) + } else { + rowsByPid.set(live.pid, [live]) + } + } + return [...expected.entries()].flatMap(([pid, row]) => { + const rows = rowsByPid.get(pid) + if (rejectDuplicatePids && rows?.length !== 1) { + // Duplicate PID rows make this non-atomic process-table read ambiguous; + // never signal or count either identity as proof of liveness. + return [] + } + return (rows ?? []).filter((live) => live.startedAt === row.startedAt && live.pgid === row.pgid) }) } +function hasDuplicateSnapshotPids( + snapshot: DescendantSnapshot, + table: readonly ProcessTableRow[] +): boolean { + const expected = new Set(snapshot.descendants.map((row) => row.pid)) + const counts = new Map() + for (const live of table) { + if (expected.has(live.pid)) { + counts.set(live.pid, (counts.get(live.pid) ?? 0) + 1) + } + } + return [...counts.values()].some((count) => count > 1) +} + type VerificationDeps = TerminateDeps & { verifyMs?: number + /** Revalidate identities before signaling; used by Claude's close proof. */ + requireIdentityBeforeSignal?: boolean } +/** + * Orca's verdict vocabulary for a snapshotted tree, with no synonyms: `live` is + * an identity-matched descendant still observed at the deadline; `unverifiable` + * is a table that could not be read, which is never evidence either way. + */ +export type DescendantTreeVerdict = 'exited' | 'live' | 'unverifiable' + /** An unreadable process table is never proof that a stopped descendant exited. */ export async function terminateDescendantSnapshotAndWait( snapshot: DescendantSnapshot, deps: VerificationDeps = {} ): Promise { + return (await terminateDescendantSnapshotWithVerdict(snapshot, deps)) === 'exited' +} + +/** Signals the snapshot, then reports what the last table read observed. */ +export async function terminateDescendantSnapshotWithVerdict( + snapshot: DescendantSnapshot, + deps: VerificationDeps = {} +): Promise { const sendSignal = deps.sendSignal ?? sendDescendantSignal const readTable = deps.readTable ?? readProcessTable const graceMs = deps.graceMs ?? DESCENDANT_KILL_GRACE_MS const verifyMs = deps.verifyMs ?? DESCENDANT_KILL_VERIFY_MS const deadline = Date.now() + verifyMs - for (const row of snapshot.descendants) { - sendSignal(row.pid, 'SIGTERM') - } let forced = false + let signalled = !deps.requireIdentityBeforeSignal + let missingObservations = 0 + if (signalled) { + for (const row of snapshot.descendants) { + sendSignal(row.pid, 'SIGTERM') + } + } while (Date.now() < deadline) { const capture = await readProcessTableBeforeDeadline( readTable, deps.timeoutMs ?? DESCENDANT_SNAPSHOT_TIMEOUT_MS ) - if (!capture) { - return false - } - const live = matchingSnapshotRows(snapshot, capture.rows) - if (live.length === 0) { - return true - } - if (!forced && Date.now() >= deadline - verifyMs + graceMs) { - forced = true - for (const row of live) { - if (hasUnambiguousStartIdentity(row, snapshot.capturedAtMs)) { - sendSignal(row.pid, 'SIGKILL') + // A read that missed its own deadline is not an answer, and surrendering on + // the first slow one spends none of the window this verification was given: + // on a loaded host that reported a tree unverifiable without ever seeing it. + if (capture) { + if (deps.requireIdentityBeforeSignal && hasDuplicateSnapshotPids(snapshot, capture.rows)) { + // A duplicate target pid is an ambiguous non-atomic read. Do not signal + // either row and do not turn that uncertainty into an exited verdict. + await waitForDelay(50) + continue + } + const live = matchingSnapshotRows(snapshot, capture.rows, deps.requireIdentityBeforeSignal) + if (live.length === 0) { + // Before a signal has been sent, an empty identity match means the + // snapshotted descendants already exited or were replaced. Signalling + // those old numeric pids would be unsafe. + if (deps.requireIdentityBeforeSignal) { + // A single process-table read can race a fork or return a partial + // view; require two bounded absences before claiming the tree gone. + missingObservations += 1 + if (missingObservations < 2) { + await waitForDelay(50) + continue + } + } + return 'exited' + } + missingObservations = 0 + if (!signalled) { + // Revalidate every identity immediately before the first signal. A PID + // can be recycled between the original walk and close, so never signal + // from the stale snapshot alone. + for (const row of live) { + sendSignal(row.pid, 'SIGTERM') + } + signalled = true + } + if (!forced && Date.now() >= deadline - verifyMs + graceMs) { + forced = true + for (const row of live) { + // A row a walk re-derived from a live root is ours whatever second it + // was born in, which start time alone can never establish for one born + // in its own capture second. Rows no walk re-derived still answer to + // the second-resolution fence, which is all the evidence they have. + // Scoped to the identity-revalidating callers; the same argument holds + // for the rest, but widening it is a deliberate change of its own. + if ( + (deps.requireIdentityBeforeSignal === true && + snapshot.reDerivedPids?.has(row.pid) === true) || + hasUnambiguousStartIdentity( + row, + snapshot.capturedAtMsByPid?.[String(row.pid)] ?? snapshot.capturedAtMs + ) + ) { + sendSignal(row.pid, 'SIGKILL') + } } } } @@ -74,5 +167,19 @@ export async function terminateDescendantSnapshotAndWait( readTable, deps.timeoutMs ?? DESCENDANT_SNAPSHOT_TIMEOUT_MS ) - return finalCapture !== null && matchingSnapshotRows(snapshot, finalCapture.rows).length === 0 + if (!finalCapture) { + return 'unverifiable' + } + if (deps.requireIdentityBeforeSignal && hasDuplicateSnapshotPids(snapshot, finalCapture.rows)) { + return 'unverifiable' + } + const finalLive = matchingSnapshotRows( + snapshot, + finalCapture.rows, + deps.requireIdentityBeforeSignal + ) + if (finalLive.length > 0) { + return 'live' + } + return deps.requireIdentityBeforeSignal && missingObservations < 2 ? 'unverifiable' : 'exited' } diff --git a/src/main/pty-descendant-termination.test.ts b/src/main/pty-descendant-termination.test.ts index e1255a678d8..0c4bea81306 100644 --- a/src/main/pty-descendant-termination.test.ts +++ b/src/main/pty-descendant-termination.test.ts @@ -15,7 +15,10 @@ import { type ProcessTableCapture, type ProcessTableRow } from './pty-descendant-termination' -import { terminateDescendantSnapshotAndWait } from './pty-descendant-exit-verification' +import { + terminateDescendantSnapshotAndWait, + terminateDescendantSnapshotWithVerdict +} from './pty-descendant-exit-verification' const CAPTURED_AT_MS = Date.parse('Tue Jul 14 12:00:00 2026') @@ -53,7 +56,14 @@ function snapshot( rootPgid: number | null = 10, capturedAtMs = CAPTURED_AT_MS ) { - return { rootPgid, descendants, capturedAtMs } + return { + ...(rootPgid === null ? {} : { root: { pid: 10, startedAt: 'Mon Jul 13 12:54:47 2026' } }), + rootPgid, + descendants, + capturedAtMs, + // Everything a walk returns was re-derived by it. + ...(rootPgid === null ? {} : { reDerivedPids: new Set(descendants.map((row) => row.pid)) }) + } } describe('parseProcessTable', () => { @@ -298,6 +308,32 @@ describe('terminateDescendantSnapshot', () => { expect(sendSignal).not.toHaveBeenCalled() expect(vi.getTimerCount()).toBe(0) }) + + it("uses each row's capture boundary when escalating a merged snapshot", async () => { + const oldBoundary = CAPTURED_AT_MS + 900 + const refreshBoundary = CAPTURED_AT_MS + 2_100 + const retained = row(20, 10, 20, 'Tue Jul 14 12:00:00 2026') + const fresh = row(30, 10, 30, 'Tue Jul 14 12:00:01 2026') + const sendSignal = vi.fn() + terminateDescendantSnapshot( + { + ...snapshot([retained, fresh], 10, refreshBoundary), + capturedAtMsByPid: { '20': oldBoundary, '30': refreshBoundary } + }, + { + sendSignal, + readTable: vi.fn().mockResolvedValue(tableCapture([retained, fresh])) + } + ) + sendSignal.mockClear() + + await vi.advanceTimersByTimeAsync(DESCENDANT_KILL_GRACE_MS) + + // PID 20 was retained from the earlier capture and is still in its + // capture second; PID 30 was newly observed by the refresh and is old + // enough for a bounded forced cleanup. + expect(sendSignal.mock.calls).toEqual([[30, 'SIGKILL']]) + }) }) describe('terminateDescendantSnapshotAndWait', () => { @@ -333,14 +369,114 @@ describe('terminateDescendantSnapshotAndWait', () => { it('does not claim exit when the verification table is unavailable', async () => { const sendSignal = vi.fn() - const result = await terminateDescendantSnapshotAndWait(snapshot([row(20, 10, 20)]), { + const pending = terminateDescendantSnapshotAndWait(snapshot([row(20, 10, 20)]), { sendSignal, - readTable: vi.fn().mockRejectedValue(new Error('ps exploded')) + readTable: vi.fn().mockRejectedValue(new Error('ps exploded')), + verifyMs: 200 }) + await vi.advanceTimersByTimeAsync(400) - expect(result).toBe(false) + await expect(pending).resolves.toBe(false) expect(sendSignal).toHaveBeenCalledWith(20, 'SIGTERM') }) + + it('keeps polling past a read that missed its deadline rather than surrendering', async () => { + const survivor = row(20, 10, 20) + const readTable = vi + .fn() + // A loaded host can miss one read's deadline with the window still open. + .mockRejectedValueOnce(new Error('ps timed out')) + .mockResolvedValueOnce(tableCapture([survivor])) + .mockResolvedValueOnce(tableCapture([])) + const sendSignal = vi.fn() + + const pending = terminateDescendantSnapshotWithVerdict(snapshot([survivor]), { + sendSignal, + readTable, + graceMs: 0, + verifyMs: 2_000 + }) + await vi.advanceTimersByTimeAsync(500) + + await expect(pending).resolves.toBe('exited') + expect(sendSignal.mock.calls).toEqual([ + [20, 'SIGTERM'], + [20, 'SIGKILL'] + ]) + }) + + it('names a survivor seen at the deadline live, never unverifiable', async () => { + const survivor = row(20, 10, 20) + const pending = terminateDescendantSnapshotWithVerdict(snapshot([survivor]), { + sendSignal: vi.fn(), + readTable: vi.fn().mockResolvedValue(tableCapture([survivor])), + graceMs: 0, + verifyMs: 100 + }) + await vi.advanceTimersByTimeAsync(200) + + await expect(pending).resolves.toBe('live') + }) + + it('names an unreadable verification table unverifiable', async () => { + const pending = terminateDescendantSnapshotWithVerdict(snapshot([row(20, 10, 20)]), { + sendSignal: vi.fn(), + readTable: vi.fn().mockRejectedValue(new Error('ps exploded')), + verifyMs: 200 + }) + await vi.advanceTimersByTimeAsync(400) + + await expect(pending).resolves.toBe('unverifiable') + }) + + it('does not signal a recycled descendant when identity validation is required', async () => { + const sendSignal = vi.fn() + const recycled = row(20, 10, 20, 'Tue Jul 14 13:00:00 2026') + const pending = terminateDescendantSnapshotWithVerdict( + snapshot([row(20, 10, 20, 'Tue Jul 14 12:00:00 2026')]), + { + sendSignal, + readTable: vi.fn().mockResolvedValue(tableCapture([recycled])), + requireIdentityBeforeSignal: true, + verifyMs: 100 + } + ) + + await vi.advanceTimersByTimeAsync(200) + await expect(pending).resolves.toBe('exited') + expect(sendSignal).not.toHaveBeenCalled() + }) + + it('uses row-scoped boundaries for forced cleanup in the exit verifier', async () => { + const oldBoundary = CAPTURED_AT_MS + 900 + const refreshBoundary = CAPTURED_AT_MS + 2_100 + const retained = row(20, 10, 20, 'Tue Jul 14 12:00:00 2026') + const fresh = row(30, 10, 30, 'Tue Jul 14 12:00:01 2026') + const sendSignal = vi.fn() + const pending = terminateDescendantSnapshotWithVerdict( + { + ...snapshot([retained, fresh], 10, refreshBoundary), + capturedAtMsByPid: { '20': oldBoundary, '30': refreshBoundary }, + // What a merge produces: only the refresh re-derived 30; 20 is retained. + reDerivedPids: new Set([30]) + }, + { + sendSignal, + readTable: vi.fn().mockResolvedValue(tableCapture([retained, fresh])), + requireIdentityBeforeSignal: true, + graceMs: 0, + verifyMs: 100 + } + ) + await vi.advanceTimersByTimeAsync(200) + + await expect(pending).resolves.toBe('live') + expect(sendSignal.mock.calls).toEqual([ + [20, 'SIGTERM'], + [30, 'SIGTERM'], + [30, 'SIGKILL'] + ]) + }) }) describe('createProcessTableSnapshotReader', () => { diff --git a/src/main/pty-descendant-termination.ts b/src/main/pty-descendant-termination.ts index bf254d03b56..4f56c3e977b 100644 --- a/src/main/pty-descendant-termination.ts +++ b/src/main/pty-descendant-termination.ts @@ -21,12 +21,25 @@ export type ProcessTableRow = { startedAt: string } +export type PosixProcessIdentity = Pick + export type DescendantSnapshot = { + /** Identity of the root observed in the same process-table capture. */ + root?: PosixProcessIdentity rootPgid: number | null descendants: ProcessTableRow[] - /** Wall-clock boundary for deciding whether ps's second-resolution lstart - * can safely distinguish this process from a later PID reuse. */ + /** Wall-clock boundary for an unmerged snapshot (or legacy callers). */ capturedAtMs: number + /** Per-PID identity boundaries for merged captures. */ + capturedAtMsByPid?: Readonly> + /** + * PIDs this walk re-derived from a live root. A ppid walk only reaches what + * the root actually parents, so membership is proof of ownership that owes + * nothing to `lstart`'s one-second resolution: a stranger would have to have + * been forked into our own tree, and then it is not a stranger. Rows a merge + * retained from an earlier walk are absent, and still answer to start time. + */ + reDerivedPids?: ReadonlySet } export type ProcessTableCapture = { @@ -156,9 +169,13 @@ export function collectDescendantRows( ): DescendantSnapshot { const childrenByPpid = new Map() let rootRow: ProcessTableRow | null = null + let duplicateRoot = false for (const row of table) { if (row.pid === rootPid) { - rootRow = row + // A non-atomic process-table read can contain both an old and a recycled + // root row. There is no safe identity to retain in that case. + duplicateRoot = rootRow !== null + rootRow ??= row continue } const siblings = childrenByPpid.get(row.ppid) @@ -172,7 +189,7 @@ export function collectDescendantRows( // An absent root has already exited — its real descendants reparent to pid 1 and // become unreachable by ppid, so any rows still pointing at the vacated PID are a // PID-reuse coincidence. Sweeping them could signal an unrelated process, so bail. - if (!rootRow) { + if (!rootRow || duplicateRoot) { return { rootPgid: null, descendants: [], capturedAtMs } } const descendants: ProcessTableRow[] = [] @@ -191,7 +208,13 @@ export function collectDescendantRows( queue.push(child.pid) } } - return { rootPgid: rootRow.pgid, descendants, capturedAtMs } + return { + root: { pid: rootRow.pid, startedAt: rootRow.startedAt }, + rootPgid: rootRow.pgid, + descendants, + capturedAtMs, + reDerivedPids: new Set(descendants.map((row) => row.pid)) + } } type SnapshotDeps = { @@ -316,7 +339,11 @@ export type TerminateDeps = { } export function hasUnambiguousStartIdentity(row: ProcessTableRow, capturedAtMs: number): boolean { - const startedAtMs = Date.parse(row.startedAt) + return hasUnambiguousStartTime(row.startedAt, capturedAtMs) +} + +export function hasUnambiguousStartTime(startedAt: string, capturedAtMs: number): boolean { + const startedAtMs = Date.parse(startedAt) if (!Number.isFinite(startedAtMs)) { return false } @@ -364,7 +391,10 @@ export function terminateDescendantSnapshot( for (const row of snapshot.descendants) { const live = liveTargets.get(row.pid) if ( - hasUnambiguousStartIdentity(row, snapshot.capturedAtMs) && + hasUnambiguousStartIdentity( + row, + snapshot.capturedAtMsByPid?.[String(row.pid)] ?? snapshot.capturedAtMs + ) && live?.startedAt === row.startedAt && live.pgid === row.pgid ) { diff --git a/src/main/runtime/agent-session-acquisition-failure-settlement.ts b/src/main/runtime/agent-session-acquisition-failure-settlement.ts index 7ad20397813..c790a149f31 100644 --- a/src/main/runtime/agent-session-acquisition-failure-settlement.ts +++ b/src/main/runtime/agent-session-acquisition-failure-settlement.ts @@ -4,10 +4,28 @@ import { type AgentSessionOperationOutcome } from '../../shared/agent-session-operation-ledger' import { nextAgentSessionFence } from '../../shared/agent-session-next-fence' -import type { AgentSessionRecord } from '../../shared/agent-session-record' +import type { + AgentSessionDeathEvidence, + AgentSessionRecord +} from '../../shared/agent-session-record' import { assertFence, withLease } from './agent-session-lease-transitions' import type { AgentSessionStoreState } from './agent-session-record-store-file' +/** + * How the failed attempt's provider process was accounted for. + * - `exit-proven`: cleanup observed the whole tree gone. + * - `root-exit-observed`: the owner root's exit was observed first-hand, so the + * identity this lease is keyed on is dead, but its descendants could not be + * verified. Releases the lease and says exactly that, claiming nothing more. + * - `processless`: the attempt failed before a process existed. + * - `unproven`: nothing about the process was observed; the reservation latches. + */ +export type AgentSessionAcquisitionExitProof = + | 'exit-proven' + | 'root-exit-observed' + | 'processless' + | 'unproven' + export type AgentSessionFailedAcquisitionSettlement = { sessionId: string fence: number @@ -15,7 +33,7 @@ export type AgentSessionFailedAcquisitionSettlement = { callerKey: string operationId: string outcome: Extract - exitProof: 'exit-proven' | 'processless' | 'unproven' + exitProof: AgentSessionAcquisitionExitProof now: number } @@ -83,11 +101,18 @@ export function settleFailedAgentSessionPostAcquisitionAttachment( claimStatus: 'released', lastRenewedAt: args.now, handoffOperationId: null, - deathEvidence: { - kind: 'exit-observed', - detail: 'post-acquisition cleanup proved no provider child remains', - observedAt: args.now - } + deathEvidence: + args.exitProof === 'root-exit-observed' + ? { + kind: 'exit-observed', + detail: 'the provider process exited; its descendants were not verifiable', + observedAt: args.now + } + : { + kind: 'exit-observed', + detail: 'post-acquisition cleanup proved no provider child remains', + observedAt: args.now + } }) state.records.set(args.sessionId, next) state.operations = settleAgentSessionOperation(state.operations, args) @@ -127,18 +152,29 @@ function settleFailedLease( claimStatus: 'released', lastRenewedAt: args.now, handoffOperationId: null, - deathEvidence: - args.exitProof === 'processless' - ? { - kind: 'pid-absent', - detail: 'reservation failed before spawn', - observedAt: args.now - } - : { - // Cleanup proved no child of this attempt remains; it may never have spawned. - kind: 'exit-observed', - detail: 'acquisition cleanup proved no provider child remains', - observedAt: args.now - } + deathEvidence: acquisitionDeathEvidence(args.exitProof, args.now) }) } + +/** Records only what was observed: never a tree claim the cleanup did not make. */ +function acquisitionDeathEvidence( + exitProof: AgentSessionAcquisitionExitProof, + observedAt: number +): AgentSessionDeathEvidence { + if (exitProof === 'processless') { + return { kind: 'pid-absent', detail: 'reservation failed before spawn', observedAt } + } + if (exitProof === 'root-exit-observed') { + return { + kind: 'exit-observed', + detail: 'the provider process exited; its descendants were not verifiable', + observedAt + } + } + // Cleanup proved no child of this attempt remains; it may never have spawned. + return { + kind: 'exit-observed', + detail: 'acquisition cleanup proved no provider child remains', + observedAt + } +} diff --git a/src/main/runtime/agent-session-launch-env-backfill.test.ts b/src/main/runtime/agent-session-launch-env-backfill.test.ts new file mode 100644 index 00000000000..c95705f2aa5 --- /dev/null +++ b/src/main/runtime/agent-session-launch-env-backfill.test.ts @@ -0,0 +1,90 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { AgentSessionRecordStore } from './agent-session-record-store' +import type { AgentSessionReserveRequest } from './agent-session-reservation-admission' + +const NOW = 1_800_000_000_000 +const SESSION = 'session-launch-env' +let directory: string + +function request(overrides: Partial = {}): AgentSessionReserveRequest { + return { + sessionId: SESSION, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' + }, + provider: 'claude', + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude' }, + runtimeKind: 'native', + expectedFence: null, + spawnToken: 'spawn-a', + claimKeyId: 'key-1', + handoffOperationId: null, + probe: { outcome: 'reservation-unused' }, + operation: { + callerKey: 'client-1', + operationId: `${NOW}-00000000000000000000000000000001`, + fingerprint: 'fp-1' + }, + now: NOW, + ...overrides + } +} + +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'orca-agent-session-launch-env-')) +}) + +afterEach(async () => { + await rm(directory, { recursive: true, force: true }) +}) + +describe('legacy agent session launch environment', () => { + it('durably pins the first environment resolved by a current reservation', async () => { + const store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + await store.reserveOwner(request()) + await store.reserveOwner( + request({ + expectedFence: 1, + spawnToken: 'spawn-b', + launchEnv: { ANTHROPIC_AUTH_TOKEN: 'pinned-token' }, + operation: { + callerKey: 'client-1', + operationId: `${NOW}-00000000000000000000000000000002`, + fingerprint: 'fp-2' + } + }) + ) + + const reopened = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + expect( + (reopened.getRecord(SESSION) as { launchEnv?: Record } | null)?.launchEnv + ).toBeUndefined() + }) + + it('rejects an environment that could not be reloaded before writing it', async () => { + const store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + const launchEnv = Object.fromEntries( + Array.from({ length: 257 }, (_, index) => [`KEY_${index}`, 'value']) + ) + + await expect(store.reserveOwner(request({ launchEnv }))).rejects.toThrow( + 'agent_session_launch_env_invalid' + ) + expect(store.getRecord(SESSION)).toBeNull() + }) + + it('rejects an overlong environment key before writing it', async () => { + const store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + + await expect( + store.reserveOwner(request({ launchEnv: { ['K'.repeat(513)]: 'value' } })) + ).rejects.toThrow('agent_session_launch_env_invalid') + expect(store.getRecord(SESSION)).toBeNull() + }) +}) diff --git a/src/main/runtime/agent-session-record-options.test.ts b/src/main/runtime/agent-session-record-options.test.ts index a1dfb9ccdef..5795763d96c 100644 --- a/src/main/runtime/agent-session-record-options.test.ts +++ b/src/main/runtime/agent-session-record-options.test.ts @@ -31,6 +31,20 @@ it('fails option hydration before ownership can be proved', async () => { ).rejects.toThrow('model list unavailable') }) +it('drops provider-rejected persisted options before the next owner proof', async () => { + await expect( + readNativeSessionOptions({ + adapter: { + readOptions: async () => ({ models: [], current: { model: 'provider-model' } }), + readOptionRestoreFailures: () => ['permissionMode'] + }, + sessionId: SESSION, + fence: 2, + priorOptions: { permissionMode: 'retired-mode', other: 'keep' } + }) + ).resolves.toEqual({ model: 'provider-model', other: 'keep' }) +}) + it('persists resumed provider options atomically with owner proof', async () => { const store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) const reserved = await store.reserveOwner({ diff --git a/src/main/runtime/agent-session-resume-args.test.ts b/src/main/runtime/agent-session-resume-args.test.ts new file mode 100644 index 00000000000..db4d0b07b8d --- /dev/null +++ b/src/main/runtime/agent-session-resume-args.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import { resolveAgentSessionResumeArgs } from './agent-session-resume-args' + +describe('agent session resume arguments', () => { + it('keeps the session creation arguments after mutable defaults change', () => { + expect( + resolveAgentSessionResumeArgs({ + persistedArgs: ['--model', 'claude-created'], + defaultArgs: '--model claude-current', + shell: 'posix' + }) + ).toBe("'--model' 'claude-created'") + }) + + it('keeps an explicit empty snapshot when defaults are toggled off', () => { + expect( + resolveAgentSessionResumeArgs({ + persistedArgs: [], + defaultArgs: '--dangerously-skip-permissions', + shell: 'posix' + }) + ).toBe('') + }) + + it('uses current defaults for legacy records without a snapshot', () => { + expect( + resolveAgentSessionResumeArgs({ + defaultArgs: '--dangerously-skip-permissions', + shell: 'posix' + }) + ).toBe('--dangerously-skip-permissions') + }) +}) diff --git a/src/main/runtime/agent-session-resume-args.ts b/src/main/runtime/agent-session-resume-args.ts new file mode 100644 index 00000000000..dc276726dc6 --- /dev/null +++ b/src/main/runtime/agent-session-resume-args.ts @@ -0,0 +1,17 @@ +import type { AgentSessionLaunchArgs } from '../../shared/agent-session-record' +import { quoteStartupArg, type AgentStartupShell } from '../../shared/tui-agent-startup-shell' + +export function resolveAgentSessionResumeArgs(input: { + requestArgs?: string | null + persistedArgs?: AgentSessionLaunchArgs + defaultArgs?: string | null + shell: AgentStartupShell +}): string | null | undefined { + if (input.requestArgs !== undefined) { + return input.requestArgs + } + if (input.persistedArgs !== undefined) { + return input.persistedArgs.map((arg) => quoteStartupArg(arg, input.shell)).join(' ') + } + return input.defaultArgs +} diff --git a/src/main/runtime/claude-structured-session-integration.test.ts b/src/main/runtime/claude-structured-session-integration.test.ts new file mode 100644 index 00000000000..712543d0428 --- /dev/null +++ b/src/main/runtime/claude-structured-session-integration.test.ts @@ -0,0 +1,747 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' +import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' +import type { AgentSessionSubscribeEvent } from '../../shared/agent-session-wire' +import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../shared/protocol-version' +import type { + ClaudeStreamJsonConnection, + ClaudeStreamJsonConnectionHandlers, + ClaudeStreamJsonLaunch, + openClaudeStreamJsonConnection +} from '../claude/claude-stream-json-connection' +import { claudeSessionIdForOrcaSession } from '../claude/claude-structured-launch-resolution' +import { + CLAUDE_SPAWN_TOKEN_ENV, + claudeProviderHandleLink +} from '../claude/claude-structured-owner-identity' +import { attachFingerprintFields } from '../native-chat/agent-session-wire/structured-agent-session-attach' +import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' +import type { + StructuredAgentSessionHandoffTransport, + StructuredTuiOwner +} from '../native-chat/agent-session-wire/structured-agent-session-handoff-types' +import type { OrcaRuntimeService } from './orca-runtime' +import type { RpcRequest, RpcResponse } from './rpc/core' +import type { ClaudeStructuredAuthPolicy } from '../claude-accounts/claude-structured-auth-policy' +import { RpcDispatcher } from './rpc/dispatcher' +import { STRUCTURED_AGENT_SESSION_METHODS } from './rpc/methods/structured-agent-session' +import { + ensureStructuredAgentSessionHost, + stopStructuredAgentSessionRuntime +} from './structured-agent-session-runtime' + +const SESSION = 'claude-integration-1' +const PROVIDER_SESSION = claudeSessionIdForOrcaSession(SESSION) +const WORKSPACE = 'workspace-claude' +// Why 'runtime': this file exercises the Claude structured integration over agentSession.*, not the +// mobile surface — nothing here asserts anything mobile-specific, and its sibling integration +// suites use 'runtime' too. Mobile additionally requires the experimental structured-chat setting, +// which structured-agent-session.test.ts pins in both its satisfied and refused states. +const CLIENT = { + clientKind: 'runtime' as const, + clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] +} + +const { readClaudeTranscriptLeafUuid, resolveSessionFilePath } = vi.hoisted(() => ({ + readClaudeTranscriptLeafUuid: vi.fn(), + resolveSessionFilePath: vi.fn() +})) + +vi.mock('../native-chat/session-file-resolver', () => ({ + readClaudeTranscriptLeafUuid, + resolveSessionFilePath +})) + +type FakeClaudeConnection = Omit & { + closed: boolean + exitVerdict: ClaudeStreamJsonConnection['exitVerdict'] + launch: ClaudeStreamJsonLaunch + handlers: ClaudeStreamJsonConnectionHandlers + calls: { subtype: string; params?: Record }[] + sent: Record[] +} + +function fakeClaude() { + const connections: FakeClaudeConnection[] = [] + let initializeAccount: unknown + /** A child that dies during start, with the close verdict its ladder observed. */ + let selfExit: { message: string; exitVerdict: ClaudeStreamJsonConnection['exitVerdict'] } | null = + null + const openConnection = (async (launch, handlers = {}) => { + const connection: FakeClaudeConnection = { + launch, + handlers, + calls: [], + sent: [], + pid: 4321 + connections.length, + closed: false, + initializationResult: async () => { + connection.calls.push({ subtype: 'initialize' }) + if (selfExit) { + handlers.onExit?.(new Error(selfExit.message)) + return { models: [] } + } + handlers.onMessage?.({ + type: 'system', + subtype: 'init', + session_id: PROVIDER_SESSION, + ...(connections.length === 0 ? { uuid: 'init-leaf' } : {}), + model: 'claude-sonnet-5', + apiKeySource: 'none' + }) + return { + models: [{ value: 'sonnet', displayName: 'Sonnet' }], + ...(initializeAccount === undefined ? {} : { account: initializeAccount }) + } + }, + getSettings: async () => { + connection.calls.push({ subtype: 'get_settings' }) + return { env: {} } + }, + supportedModels: async () => { + connection.calls.push({ subtype: 'list_models' }) + return [{ value: 'sonnet', displayName: 'Sonnet' }] + }, + setModel: async (model) => { + connection.calls.push({ subtype: 'set_model', params: { model } }) + }, + setPermissionMode: async (mode) => { + connection.calls.push({ subtype: 'set_permission_mode', params: { mode } }) + }, + applyFlagSettings: async (settings) => { + connection.calls.push({ subtype: 'apply_flag_settings', params: { settings } }) + }, + interrupt: async () => { + connection.calls.push({ subtype: 'interrupt', params: {} }) + return undefined + }, + cancelAsyncMessage: async () => {}, + send: async (message) => { + connection.sent.push(message) + if (message.type === 'user') { + handlers.onMessage?.({ ...message, uuid: 'user-1' }) + } + }, + exitVerdict: selfExit?.exitVerdict ?? { root: 'live', tree: 'unverifiable' }, + close: async () => { + connection.closed = true + return selfExit === null + } + } + connections.push(connection) + return connection + }) as typeof openClaudeStreamJsonConnection + const live = (): FakeClaudeConnection => { + const connection = connections.at(-1) + if (!connection) { + throw new Error('no Claude connection') + } + return connection + } + return { + connections, + openConnection, + live, + setInitializeAccount: (account: unknown) => { + initializeAccount = account + }, + setSelfExit: (exit: typeof selfExit) => { + selfExit = exit + } + } +} + +let operations = 0 +// Keep IDs unique without making each assertion depend on a wall-clock tick. +const TEST_OPERATION_TIMESTAMP = Date.now().toString() + +function operationId(): string { + operations += 1 + return `${TEST_OPERATION_TIMESTAMP}-${operations.toString(16).padStart(32, '0')}` +} + +function envelope(method: string, fields: Record, fence: number | null) { + return { + sessionId: SESSION, + clientOperationId: operationId(), + expectedRuntimeFence: fence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method, + sessionId: SESSION, + fields + }) + } +} + +function createIntentParams() { + const worktree = `id:${WORKSPACE}` + const fields = { worktree, agent: 'claude' } + return { envelope: envelope('agentSession.create', fields, null), ...fields } +} + +function ensureParams(fence: number) { + const params = { + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: WORKSPACE, + workspaceKind: 'git-worktree' as const + }, + provider: 'claude' as const, + agent: 'claude', + accountHome: { variable: 'CLAUDE_CONFIG_DIR' as const, path: join(root, 'claude-home') }, + runtimeKind: 'native' as const, + providerHandle: { + kind: 'claude' as const, + sessionId: PROVIDER_SESSION, + leafUuid: 'assistant-leaf' + } + } + const base = { + sessionId: SESSION, + clientOperationId: operationId(), + expectedRuntimeFence: fence, + payloadFingerprint: '' + } + return { + ...params, + envelope: { + ...base, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.attach', + sessionId: SESSION, + fields: attachFingerprintFields({ ...params, envelope: base } as never) + }) + } + } +} + +function leaseOf(sessionId: string): { + claimStatus: string + runtimeFence: number + handoffStage: string | null + deathEvidence: { kind: string; detail: string } | null +} { + const host = getStructuredAgentSessionHost() as unknown as { + deps: { store: { getRecord: (id: string) => { lease: ReturnType } } } + } + return host.deps.store.getRecord(sessionId).lease +} + +function handoffParams(direction: 'to-native' | 'to-tui', fence: number) { + const fields = { direction, mode: 'now' as const, action: 'start' as const } + return { + envelope: envelope('agentSession.requestHandoff', fields, fence), + ...fields + } +} + +let claude: ReturnType +let root: string +let dispatcher: RpcDispatcher +let cleanups: Map void> +let tuiOwner: StructuredTuiOwner | null +let transcriptPath: string +/** Managed-account state and configured overlay this host installs, per test. */ +let claudeAuthPolicy: ClaudeStructuredAuthPolicy +let claudeLaunchEnv: Record + +async function call(method: string, params: unknown): Promise { + const replies: RpcResponse[] = [] + const request: RpcRequest = { id: `req-${operations}`, authToken: 'token', method, params } + await dispatcher.dispatchStreaming(request, (raw) => replies.push(JSON.parse(raw)), CLIENT) + if (!replies[0]) { + throw new Error(`no reply for ${method}`) + } + return replies[0] +} + +async function ok(method: string, params: unknown): Promise { + const response = await call(method, params) + expect(response, JSON.stringify(response)).toMatchObject({ ok: true }) + const result = (response as { result: { ok: boolean; value?: T } }).result + expect(result).toMatchObject({ ok: true }) + return result.value as T +} + +async function subscribe(): Promise { + const frames: AgentSessionSubscribeEvent[] = [] + await dispatcher.dispatchStreaming( + { + id: 'subscribe-1', + authToken: 'token', + method: 'agentSession.subscribe', + params: { sessionId: SESSION } + }, + (raw) => { + const response = JSON.parse(raw) as { ok: boolean; result?: AgentSessionSubscribeEvent } + if (response.ok && response.result) { + frames.push(response.result) + } + }, + CLIENT + ) + return frames +} + +function itemsOf(frames: AgentSessionSubscribeEvent[]): AgentJournalRenderItem[] { + const items = new Map() + for (const frame of frames) { + const rows = + frame.type === 'snapshot' || frame.type === 'reset' + ? frame.page.items + : frame.type === 'batch' + ? frame.batch.items + : [] + for (const row of rows) { + items.set(row.itemId, row) + } + } + return [...items.values()] +} + +function textOf(item: AgentJournalRenderItem): string { + return item.body?.kind === 'message' + ? item.body.blocks.map((block) => (block.type === 'text' ? block.text : '')).join('') + : '' +} + +beforeEach(async () => { + operations = 0 + claudeAuthPolicy = { stripAuthEnv: false } + claudeLaunchEnv = { + ANTHROPIC_AUTH_TOKEN: 'configured-token', + ANTHROPIC_BASE_URL: 'https://gateway.example.test' + } + root = await mkdtemp(join(tmpdir(), 'orca-claude-structured-integration-')) + transcriptPath = join(root, 'claude-home', 'projects', 'workspace', `${PROVIDER_SESSION}.jsonl`) + await mkdir(join(root, 'claude-home', 'projects', 'workspace'), { recursive: true }) + resolveSessionFilePath.mockResolvedValue(transcriptPath) + // The production branch proof returns the latest descendant of the prior + // cursor; mirror that contract so structured close does not regress to a + // stale mocked head. + readClaudeTranscriptLeafUuid.mockImplementation( + async (_path: string, _providerSessionId: string, previousLeafUuid?: string | null) => + previousLeafUuid ?? 'init-leaf' + ) + claude = fakeClaude() + tuiOwner = null + cleanups = new Map() + const handoffTransport: StructuredAgentSessionHandoffTransport = { + hostLabel: 'Scripted Claude host', + launchTui: async ({ record, fence, spawnToken }) => { + const head = record.providerHandleChain.at(-1)?.handle + tuiOwner = { + terminal: { + handle: 'term-claude-tui', + tabId: 'tab-claude-tui', + paneKey: 'tab-claude-tui:leaf-claude-tui', + ptyId: 'pty-claude-tui' + }, + process: { + hostId: 'local', + pid: 7331, + processStartTimeMs: 100, + spawnToken + }, + link: claudeProviderHandleLink({ + sessionId: PROVIDER_SESSION, + leafUuid: head?.provider === 'claude' ? head.leafUuid : null, + resumed: true, + fence, + observedAt: 1 + }), + transcriptPath + } + return tuiOwner + }, + reproveTuiOwner: async ({ owner }) => { + if (owner.link.handle.provider !== 'claude' || !owner.transcriptPath) { + return owner + } + return { + ...owner, + link: claudeProviderHandleLink({ + sessionId: owner.link.handle.sessionId, + leafUuid: await readClaudeTranscriptLeafUuid(owner.transcriptPath), + resumed: true, + fence: owner.link.mintedAtFence, + observedAt: 1 + }) + } + }, + recoverTuiOwner: async () => { + if (!tuiOwner) { + throw new Error('scripted TUI owner missing') + } + return tuiOwner + }, + stopRecoveredOwner: async () => {}, + waitForTuiExit: async (owner) => ({ transcriptPath: owner.transcriptPath }), + waitForTuiIdleOrExit: async () => 'idle', + tuiStatus: () => 'idle', + stopFailedTuiLaunch: async () => {} + } + const runtime = { + getRuntimeId: () => 'runtime-1', + getStructuredAgentSessionCreateSupport: async () => ({ supported: true }), + resolveStructuredAgentSessionCreateIntent: async (input: { envelope: unknown }) => ({ + ...ensureParams(1), + envelope: input.envelope, + providerHandle: undefined + }), + publishStructuredAgentSessionTab: vi.fn(), + ensureStructuredAgentSessionHost: () => + ensureStructuredAgentSessionHost({ + stateDirectory: root, + hostId: 'local', + claimKeyId: 'key-1', + resolveWorkspacePath: async (workspaceId) => `/repos/${workspaceId}`, + resolveCodexCommand: () => '/usr/local/bin/codex', + resolveClaudeCommand: () => '/usr/local/bin/claude', + readProcessStartTime: async (pid: number) => pid * 10, + resolveClaudeLaunchEnv: () => claudeLaunchEnv, + resolveClaudeAuthPolicy: () => claudeAuthPolicy, + openClaudeConnection: claude.openConnection, + handoffTransport + }).then(() => undefined), + registerSubscriptionCleanup: (id: string, dispose: () => void) => cleanups.set(id, dispose), + cleanupSubscription: (id: string) => cleanups.get(id)?.(), + cleanupSubscriptionsByPrefix: () => {} + } + dispatcher = new RpcDispatcher({ + runtime: runtime as unknown as OrcaRuntimeService, + methods: STRUCTURED_AGENT_SESSION_METHODS + }) +}) + +afterEach(async () => { + vi.unstubAllEnvs() + await stopStructuredAgentSessionRuntime() + await rm(root, { recursive: true, force: true }) +}) + +describe('a structured Claude session over agentSession.*', () => { + it('strips ambient Anthropic auth from the child once a managed account is pinned', async () => { + claudeAuthPolicy = { stripAuthEnv: true } + claudeLaunchEnv = { ANTHROPIC_BASE_URL: 'https://gateway.example.test' } + vi.stubEnv('ANTHROPIC_API_KEY', 'sk-ant-SHELL-LEAK') + vi.stubEnv('ANTHROPIC_AUTH_TOKEN', 'tok-SHELL-LEAK') + + await ok<{ fence: number }>('agentSession.create', createIntentParams()) + + const env = claude.live().launch.env + expect(env).not.toHaveProperty('ANTHROPIC_API_KEY') + expect(env).not.toHaveProperty('ANTHROPIC_AUTH_TOKEN') + expect(env).toMatchObject({ + ANTHROPIC_BASE_URL: 'https://gateway.example.test', + CLAUDE_CONFIG_DIR: join(root, 'claude-home') + }) + }) + + it('refuses a create whose configured env overrides the pinned managed account auth', async () => { + claudeAuthPolicy = { stripAuthEnv: true } + // The default overlay carries ANTHROPIC_AUTH_TOKEN, which the terminal path + // refuses at spawn-env.ts:25 rather than letting it beat the pinned account. + const refused = await call('agentSession.create', createIntentParams()) + + expect(JSON.stringify(refused)).toContain('explicit Anthropic auth environment') + // Refused before spawn: no provider child was ever opened. + expect(claude.connections).toHaveLength(0) + }) + + it('durably returns actionable sign-in guidance when initialization has no credentials', async () => { + claude.setInitializeAccount({ apiProvider: 'firstParty', tokenSource: 'none' }) + const params = createIntentParams() + + const first = await call('agentSession.create', params) + const retry = await call('agentSession.create', params) + + expect(first).toMatchObject({ + ok: true, + result: { + ok: false, + refusal: { + code: 'agent_session_operation_invalid', + message: expect.stringMatching(/not signed in.*Claude CLI.*CLAUDE_CONFIG_DIR/s) + } + } + }) + expect((retry as { result: unknown }).result).toEqual((first as { result: unknown }).result) + expect(claude.connections).toHaveLength(1) + }) + + it('releases a session whose CLI self-exited during create, with its diagnostic intact', async () => { + claude.setSelfExit({ + message: 'claude stream-json exited (code 1): claude: not signed in', + // The root's death is first-hand; its descendants were never snapshottable. + exitVerdict: { root: 'exited', tree: 'unverifiable' } + }) + + const failed = await call('agentSession.create', createIntentParams()) + + expect(JSON.stringify(failed)).toContain('claude: not signed in') + const lease = leaseOf(SESSION) + // Latching here would refuse every later attach with agent_session_ownership_unknown, + // wedging a user who only needs to sign in. + expect(lease).toMatchObject({ claimStatus: 'released', handoffStage: null }) + expect(lease.deathEvidence).toMatchObject({ + kind: 'exit-observed', + detail: 'the provider process exited; its descendants were not verifiable' + }) + + claude.setSelfExit(null) + // Signing in and reopening the chat works: the reservation was not latched. + await ok<{ fence: number }>('agentSession.ensure', ensureParams(lease.runtimeFence)) + }) + + it('keeps a session reserved when a descendant of the failed start was seen alive', async () => { + claude.setSelfExit({ + message: 'claude stream-json exited (code 1): claude: not signed in', + exitVerdict: { root: 'exited', tree: 'live' } + }) + + await call('agentSession.create', createIntentParams()) + + // A live descendant still holds the provider session: releasing would hand a + // second writer to it. + expect(leaseOf(SESSION)).toMatchObject({ + claimStatus: 'reserved', + handoffStage: 'manual-recovery' + }) + claude.setSelfExit(null) + }) + + it('routes a published Claude first-hand exit through fenced host reconciliation', async () => { + await ok<{ fence: number }>('agentSession.create', createIntentParams()) + const connection = claude.live() + connection.exitVerdict = { root: 'exited', tree: 'unverifiable' } + connection.handlers.onExit?.(new Error('claude stream-json exited (code 1): crashed')) + + for ( + let attempt = 0; + attempt < 20 && leaseOf(SESSION).claimStatus !== 'released'; + attempt += 1 + ) { + await new Promise((resolve) => setTimeout(resolve, 5)) + } + expect(leaseOf(SESSION)).toMatchObject({ claimStatus: 'released', handoffStage: null }) + }) + + it('creates, sends, streams, approves, interrupts, and resumes from the chain head', async () => { + vi.stubEnv('ANTHROPIC_API_KEY', 'sk-ant-SHELL-LEAK') + const created = await ok<{ fence: number }>('agentSession.create', createIntentParams()) + expect(claude.live().launch.options).toMatchObject({ sessionId: PROVIDER_SESSION }) + expect(claude.live().launch.options.resume).toBeUndefined() + expect(claude.live().launch.env).toMatchObject({ + ANTHROPIC_AUTH_TOKEN: 'configured-token', + ANTHROPIC_BASE_URL: 'https://gateway.example.test', + CLAUDE_CONFIG_DIR: join(root, 'claude-home'), + [CLAUDE_SPAWN_TOKEN_ENV]: expect.any(String) + }) + // System auth: the user's own shell key is their sign-in, exactly as on the + // terminal path, and the configured overlay still wins over it. + expect(claude.live().launch.env).toMatchObject({ ANTHROPIC_API_KEY: 'sk-ant-SHELL-LEAK' }) + expect(claude.live().launch.env?.PATH ?? claude.live().launch.env?.Path).toBeTruthy() + const history = await call('agentSession.history', { + sessionId: SESSION, + direction: 'tail', + limit: 1 + }) + expect(history).toMatchObject({ + ok: true, + result: { providerSession: { key: 'session_id', id: PROVIDER_SESSION } } + }) + const stream = await subscribe() + + const body = { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'List files' }] } + const sent = await ok<{ + submission: { dispatchState: string; providerItemId: string | null } + }>('agentSession.send', { + envelope: envelope('agentSession.send', { body }, created.fence), + body + }) + expect(sent.submission).toMatchObject({ + dispatchState: 'accepted', + providerItemId: `claude:${PROVIDER_SESSION}:user-1` + }) + + claude.live().handlers.onMessage?.({ + type: 'stream_event', + session_id: PROVIDER_SESSION, + uuid: 'assistant-leaf', + event: { type: 'content_block_delta', delta: { type: 'text_delta', text: 'Two files.' } } + }) + claude.live().handlers.onMessage?.({ + type: 'assistant', + session_id: PROVIDER_SESSION, + uuid: 'assistant-leaf', + parent_tool_use_id: null, + message: { role: 'assistant', content: [{ type: 'text', text: 'Two files.' }] } + }) + claude.live().handlers.onMessage?.({ + type: 'result', + subtype: 'success', + session_id: PROVIDER_SESSION, + uuid: 'result-frame-uuid' + }) + claude.live().handlers.onMessage?.({ + type: 'stream_event', + session_id: PROVIDER_SESSION, + uuid: 'stream-event-frame-uuid', + event: { type: 'message_stop' } + }) + await getStructuredAgentSessionHost()?.flushStreamedEvents(SESSION) + expect(itemsOf(stream).find((item) => textOf(item) === 'Two files.')?.itemId).toBe( + `claude:${PROVIDER_SESSION}:assistant-leaf` + ) + + const answeredPermission = Promise.resolve( + claude.live().handlers.canUseTool?.('Bash', { command: 'ls' }, { + requestId: 'permission-1', + toolUseID: 'tool-1', + signal: new AbortController().signal + } as never) + ) + await getStructuredAgentSessionHost()?.flushStreamedEvents(SESSION) + const approval = itemsOf(stream).find((item) => item.body?.kind === 'approval') + expect(approval?.body).toMatchObject({ title: 'Allow Bash?', detail: '{"command":"ls"}' }) + await ok('agentSession.respondToApproval', { + envelope: envelope( + 'agentSession.respondTo:approval', + { + itemId: approval?.itemId, + expectedRevision: approval?.revision, + optionId: 'allow' + }, + created.fence + ), + itemId: approval?.itemId, + expectedRevision: approval?.revision, + optionId: 'allow' + }) + // Answering resolves the SDK's own canUseTool callback with the allow decision. + await expect(answeredPermission).resolves.toMatchObject({ + behavior: 'allow', + toolUseID: 'tool-1' + }) + + await expect( + ok('agentSession.cancel', { + envelope: envelope('agentSession.cancel', { turnId: 'user-1' }, created.fence), + turnId: 'user-1' + }) + ).resolves.toMatchObject({ turnId: 'user-1', cancelled: true }) + expect(claude.live().calls.at(-1)).toMatchObject({ subtype: 'interrupt' }) + + const host = getStructuredAgentSessionHost() as unknown as { + deps: { + store: { + getRecord: (sessionId: string) => { + providerHandleChain: { handle: { provider: string; leafUuid?: string | null } }[] + } + } + } + } + expect(host.deps.store.getRecord(SESSION).providerHandleChain.at(-1)?.handle).toMatchObject({ + provider: 'claude', + leafUuid: null + }) + const old = claude.live() + const resumed = await ok<{ fence: number }>('agentSession.ensure', ensureParams(created.fence)) + expect(resumed.fence).toBe(created.fence + 1) + expect(old.closed).toBe(true) + expect(resolveSessionFilePath).toHaveBeenCalledWith('claude', PROVIDER_SESSION, { + claudeProjectsDir: join(root, 'claude-home', 'projects') + }) + expect(claude.live().launch.options).toMatchObject({ + resume: PROVIDER_SESSION, + resumeSessionAt: 'assistant-leaf' + }) + expect(host.deps.store.getRecord(SESSION).providerHandleChain.at(-1)).toMatchObject({ + handle: { + provider: 'claude', + sessionId: PROVIDER_SESSION, + leafUuid: 'assistant-leaf' + }, + origin: 'resumed' + }) + }) + + it('completes a scripted native to TUI to native cycle with provider-history rehydration', async () => { + const created = await ok<{ fence: number }>('agentSession.create', createIntentParams()) + await writeFile( + transcriptPath, + [ + { + type: 'user', + uuid: 'native-user', + message: { role: 'user', content: [{ type: 'text', text: 'NATIVE_USER' }] } + }, + { + type: 'assistant', + uuid: 'native-assistant', + message: { role: 'assistant', content: [{ type: 'text', text: 'NATIVE_ASSISTANT' }] } + }, + { + type: 'user', + uuid: 'tui-user', + message: { role: 'user', content: [{ type: 'text', text: 'TUI_USER' }] } + }, + { + type: 'assistant', + uuid: 'tui-assistant', + message: { role: 'assistant', content: [{ type: 'text', text: 'TUI_ASSISTANT' }] } + }, + { type: 'last-prompt', leafUuid: 'tui-assistant' } + ] + .map((entry) => JSON.stringify(entry)) + .join('\n') + ) + + await ok('agentSession.requestHandoff', handoffParams('to-tui', created.fence)) + const host = getStructuredAgentSessionHost()! + await vi.waitFor(async () => + expect(await host.handoffStatus(SESSION)).toMatchObject({ owner: 'tui', phase: 'idle' }) + ) + expect(claude.connections[0]?.closed).toBe(true) + + const tuiFence = ( + host as unknown as { + deps: { store: { getRecord: (id: string) => { lease: { runtimeFence: number } } } } + } + ).deps.store.getRecord(SESSION).lease.runtimeFence + readClaudeTranscriptLeafUuid.mockResolvedValueOnce('tui-assistant') + await ok('agentSession.requestHandoff', handoffParams('to-native', tuiFence)) + await vi.waitFor(async () => + expect(await host.handoffStatus(SESSION)).toMatchObject({ owner: 'native', phase: 'idle' }) + ) + + const frames = await subscribe() + const texts = itemsOf(frames).map(textOf).filter(Boolean) + expect(texts).toEqual( + expect.arrayContaining(['NATIVE_USER', 'NATIVE_ASSISTANT', 'TUI_USER', 'TUI_ASSISTANT']) + ) + expect(new Set(texts).size).toBe(texts.length) + expect(claude.connections).toHaveLength(2) + expect(claude.live().launch.options).toMatchObject({ resume: PROVIDER_SESSION }) + const record = ( + host as unknown as { + deps: { + store: { + getRecord: (id: string) => { + providerHandleChain: { handle: { provider: string; leafUuid?: string | null } }[] + } + } + } + } + ).deps.store.getRecord(SESSION) + expect(record.providerHandleChain.at(-1)?.handle).toMatchObject({ + provider: 'claude', + leafUuid: 'tui-assistant' + }) + }) +}) diff --git a/src/main/runtime/orca-runtime-get-agent-session-execution-namespace.ts b/src/main/runtime/orca-runtime-get-agent-session-execution-namespace.ts index afbb70fc286..f70cf033718 100644 --- a/src/main/runtime/orca-runtime-get-agent-session-execution-namespace.ts +++ b/src/main/runtime/orca-runtime-get-agent-session-execution-namespace.ts @@ -17,6 +17,9 @@ import { resolveTuiAgentLaunchArgs, resolveTuiAgentLaunchEnv } from '../../shared/tui-agent-launch-defaults' +import type { AgentSessionLaunchArgs } from '../../shared/agent-session-record' +import { resolveStartupShell } from '../../shared/tui-agent-startup-shell' +import { resolveAgentSessionResumeArgs } from './agent-session-resume-args' export class OrcaRuntimeWithGetAgentSessionExecutionNamespace extends OrcaRuntimeWithResolveWorktreeRemovalTarget { protected getAgentSessionExecutionNamespace( @@ -88,7 +91,12 @@ export class OrcaRuntimeWithGetAgentSessionExecutionNamespace extends OrcaRuntim async ensureAgentSession( request: RuntimeEnsureAgentSessionRequest, _caller: RuntimeAgentSessionRpcCaller = {}, - handoffAuthority?: { spawnToken: string; providerRoot: string; sessionId: string } + handoffAuthority?: { + spawnToken: string + providerRoot: string + sessionId: string + launchArgs?: AgentSessionLaunchArgs + } ): Promise { if (request.kind === 'automatic') { // Legacy renderer sleep records are migration evidence, not host authority. @@ -134,10 +142,12 @@ export class OrcaRuntimeWithGetAgentSessionExecutionNamespace extends OrcaRuntim agent: request.agent, providerSession: identity.providerSession, cmdOverrides: settings.agentCmdOverrides ?? {}, - agentArgs: - request.agentArgs !== undefined - ? request.agentArgs - : resolveTuiAgentLaunchArgs(request.agent, settings.agentDefaultArgs), + agentArgs: resolveAgentSessionResumeArgs({ + requestArgs: request.agentArgs, + persistedArgs: handoffAuthority?.launchArgs, + defaultArgs: resolveTuiAgentLaunchArgs(request.agent, settings.agentDefaultArgs), + shell: resolveStartupShell(platform, shell) + }), agentEnv: { ...resolveTuiAgentLaunchEnv(request.agent, settings.agentDefaultEnv), ...(handoffAuthority && request.agent === 'codex' @@ -148,6 +158,7 @@ export class OrcaRuntimeWithGetAgentSessionExecutionNamespace extends OrcaRuntim }, ompResumeFilePath: request.ompResumeFilePath, sessionOptions: this.toAgentSessionOptions(request.launchPreferences), + sessionOptionsOverrideAgentArgs: Boolean(request.launchPreferences), platform, shell, isRemote diff --git a/src/main/runtime/orca-runtime-get-worktree-ps.ts b/src/main/runtime/orca-runtime-get-worktree-ps.ts index 42c9c7ff6d3..06391e2ae83 100644 --- a/src/main/runtime/orca-runtime-get-worktree-ps.ts +++ b/src/main/runtime/orca-runtime-get-worktree-ps.ts @@ -10,6 +10,7 @@ import { } from './runtime-worktree-ps-activity' import { attachRuntimeWorktreeAgentRows } from './runtime-worktree-agent-rows' import { compareWorktreePs } from './runtime-worktree-status-projection' +import type { AgentSessionRecord } from '../../shared/agent-session-record' import type { Repo } from '../../shared/repo-types' import { enrichMissingRepoGitRemoteIdentities } from '../repo-git-remote-identity-enrichment' import { ensureStructuredAgentSessionHost as installStructuredAgentSessionHost } from './structured-agent-session-runtime' @@ -21,9 +22,11 @@ import { resolveTuiAgentLaunchEnv } from '../../shared/tui-agent-launch-defaults' import { resolveLocalWindowsAgentStartupShell } from '../../shared/windows-terminal-shell' +import { resolveStartupShell, tokenizeStartupCommand } from '../../shared/tui-agent-startup-shell' import { resolveCodexStructuredAppServerArgs } from '../codex/codex-structured-app-server-args' import type { StructuredAgentSessionHandoffTransport } from '../native-chat/agent-session-wire/structured-agent-session-handoff-types' import { hostname } from 'node:os' +import { claudeStructuredAuthPolicyForSettings } from '../claude-accounts/claude-structured-auth-policy' import { probeAgentSessionProcessIdentity } from './agent-session-process-identity-probe' import { structuredAgentSessionTabId } from '../../shared/structured-agent-session-projection' @@ -144,13 +147,47 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStructuredAgent // in a plain folder lands in the folder rather than failing to resolve. resolveWorkspacePath: async (workspaceId) => (await this.resolveRuntimeFileTarget(`id:${workspaceId}`)).worktree.path, - resolveLaunchArgs: () => this.resolveConfiguredCodexStructuredArgs(), + resolveLaunchArgs: (provider) => this.resolveConfiguredStructuredLaunchArgs(provider), resolveLaunchEnvOverlay: () => resolveTuiAgentLaunchEnv('codex', this.requireStore().getSettings().agentDefaultEnv), + resolveClaudeLaunchEnv: () => + resolveTuiAgentLaunchEnv('claude', this.requireStore().getSettings().agentDefaultEnv), + resolveClaudeAuthPolicy: () => + claudeStructuredAuthPolicyForSettings(this.requireStore().getSettings()), + // Same gate and same settings as agentSession.createSupport, re-read on every acquisition. + getClaudeManagedAccountGateSettings: () => this.requireStore().getSettings(), handoffTransport: this.createStructuredAgentSessionHandoffTransport() }) } + // Why the provider is honoured rather than assumed: Codex app-server flags are not + // Claude CLI flags, and prepending them to `claude` makes it exit on an unknown option. + protected resolveConfiguredStructuredLaunchArgs( + provider: AgentSessionRecord['provider'] + ): string[] { + if (provider === 'claude') { + return this.resolveConfiguredClaudeStructuredArgs() + } + return this.resolveConfiguredCodexStructuredArgs() + } + + protected resolveConfiguredClaudeStructuredArgs(): string[] { + const settings = this.requireStore().getSettings() + const shell = resolveStartupShell( + process.platform, + resolveLocalWindowsAgentStartupShell({ + platform: process.platform, + isRemote: false, + terminalWindowsShell: settings.terminalWindowsShell + }) + ) + const tokenized = tokenizeStartupCommand( + resolveTuiAgentLaunchArgs('claude', settings.agentDefaultArgs), + shell + ) + return tokenized.ok ? tokenized.tokens : [] + } + protected resolveConfiguredCodexStructuredArgs(): string[] { const settings = this.requireStore().getSettings() const shell = resolveLocalWindowsAgentStartupShell({ @@ -195,7 +232,7 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStructuredAgent tuiStatus: (owner) => this.structuredTuiStatus(owner), closeTuiOwner: (owner) => this.closeStructuredTuiOwner(owner), revealNativeSession: async ({ workspaceId, sessionId, agent = 'codex', adoptedTerminal }) => { - if (adoptedTerminal || agent !== 'codex') { + if (adoptedTerminal || (agent !== 'codex' && agent !== 'claude')) { return } await this.publishStructuredAgentSessionTab({ diff --git a/src/main/runtime/orca-runtime-resolve-recovered-structured-tui-transcript.ts b/src/main/runtime/orca-runtime-resolve-recovered-structured-tui-transcript.ts index 5700b71d9a5..cfb8b421966 100644 --- a/src/main/runtime/orca-runtime-resolve-recovered-structured-tui-transcript.ts +++ b/src/main/runtime/orca-runtime-resolve-recovered-structured-tui-transcript.ts @@ -4,6 +4,7 @@ import type { AgentSessionOwnerBinding } from '../../shared/agent-session-host-a import { agentSessionOwnerBindingsEqual } from '../../shared/claimed-agent-pty-owner-snapshot' import { resolvePinnedCodexRolloutProof } from '../codex/codex-tui-rollout-proof' import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' +import { resolveStructuredAgentSessionCreateSupport } from '../native-chat/structured-agent-session-create-support' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import type { AgentStatusIpcPayload } from '../../shared/agent-status-types' import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options' @@ -12,6 +13,8 @@ import { getSystemCodexHomePath } from '../codex/codex-home-paths' import { resolveTuiAgentLaunchEnv } from '../../shared/tui-agent-launch-defaults' import { hasPersistedStructuredAgentSessionStore as hasPersistedStructuredAgentSessionStoreOnDisk } from './structured-agent-session-runtime' import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths' +import { homedir } from 'node:os' +import { join } from 'node:path' export class OrcaRuntimeWithResolveRecoveredStructuredTuiTranscript extends OrcaRuntimeWithStopStructuredSessionProcess { protected async resolveRecoveredStructuredTuiTranscript(input: { @@ -45,22 +48,18 @@ export class OrcaRuntimeWithResolveRecoveredStructuredTuiTranscript extends Orca async getStructuredAgentSessionCreateSupport( worktreeSelector: string, - agent: 'codex' + agent: 'claude' | 'codex' ): Promise<{ supported: boolean; reason?: 'agent' | 'remote' | 'wsl' }> { const location = await this.resolveStructuredAgentSessionLocation(worktreeSelector) await this.ensureStructuredAgentSessionHost() - if (getStructuredAgentSessionHost()?.supportsCreate(location, agent)) { - return { supported: true } - } - return { - supported: false, - reason: - location.executionHostId !== LOCAL_EXECUTION_HOST_ID - ? 'remote' - : location.wslDistro - ? 'wsl' - : 'agent' - } + // The verdict lives in a typechecked module; this file is @ts-nocheck. + return resolveStructuredAgentSessionCreateSupport({ + agent, + location, + adapterSupportsCreate: + getStructuredAgentSessionHost()?.supportsCreate(location, agent) === true, + getSettings: () => this.requireStore().getSettings() + }) } protected hasProviderSessionObservationSource(): boolean { @@ -108,8 +107,23 @@ export class OrcaRuntimeWithResolveRecoveredStructuredTuiTranscript extends Orca async resolveStructuredAgentSessionCreateIntent(input: { envelope: { sessionId: string; clientOperationId: string } worktree: string - agent: 'codex' + agent: 'claude' | 'codex' }): Promise { + if (input.agent === 'claude') { + return this.resolveStructuredAgentSessionIntent(input, async ({ launchEnv, location }) => { + return ( + launchEnv.CLAUDE_CONFIG_DIR?.trim() || + this.accounts + .getClaudeConfigDirectory( + location.wslDistro + ? { runtime: 'wsl', wslDistro: location.wslDistro } + : { runtime: 'host' } + ) + ?.trim() || + join(homedir(), '.claude') + ) + }) + } return this.resolveStructuredAgentSessionIntent(input, async ({ workspacePath, launchEnv }) => { // A create has no process yet, so the current selection is what it must follow. const preparedHome = await this.prepareCodexStructuredLaunchFn?.({ workspacePath, launchEnv }) @@ -126,11 +140,17 @@ export class OrcaRuntimeWithResolveRecoveredStructuredTuiTranscript extends Orca input: { envelope: { sessionId: string; clientOperationId: string } worktree: string - agent: 'codex' + agent: 'claude' | 'codex' }, resolveAccountHomePath: (context: { workspacePath: string launchEnv: NodeJS.ProcessEnv + location: { + executionHostId: string + wslDistro: string | null + workspaceId: string + workspaceKind: 'folder' | 'git-worktree' + } }) => string | Promise ): Promise { const support = await this.getStructuredAgentSessionCreateSupport(input.worktree, input.agent) @@ -152,8 +172,8 @@ export class OrcaRuntimeWithResolveRecoveredStructuredTuiTranscript extends Orca provider: input.agent, agent: input.agent, accountHome: { - variable: 'CODEX_HOME', - path: await resolveAccountHomePath({ workspacePath, launchEnv }) + variable: input.agent === 'claude' ? 'CLAUDE_CONFIG_DIR' : 'CODEX_HOME', + path: await resolveAccountHomePath({ workspacePath, launchEnv, location }) }, runtimeKind: 'native' } diff --git a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts index 4466594b0dc..5b2c160f2c6 100644 --- a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts +++ b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts @@ -45,7 +45,7 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu } this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession() for (const session of host?.listSessionTabs() ?? []) { - if (session.agent !== 'codex') { + if (session.agent !== 'codex' && session.agent !== 'claude') { continue } let sessionId = session.sessionId @@ -54,7 +54,7 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu } await this.publishStructuredAgentSessionTab({ ...session, - agent: 'codex', + agent: session.agent, sessionId, activate: false, notify: false @@ -65,7 +65,7 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu async publishStructuredAgentSessionTab(input: { workspaceId: string sessionId: string - agent: 'codex' + agent: 'claude' | 'codex' activate: boolean notify?: boolean }): Promise { @@ -105,7 +105,7 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu const tab: RuntimeMobileSessionAgentTab = { type: 'agent-session', id, - title: 'Codex Chat', + title: input.agent === 'claude' ? 'Claude Chat' : 'Codex Chat', sessionId: input.sessionId, agent: input.agent, isActive: input.activate diff --git a/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts b/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts index 083c677e39f..af1fbc20372 100644 --- a/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts +++ b/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts @@ -52,4 +52,108 @@ describe('structured agent-session create intent', () => { path: '/accounts/selected/home' }) }) + + it('pins the configured Claude launch home without Codex launch preparation', async () => { + const prepareCodexStructuredLaunch = vi.fn() + const runtime = new OrcaRuntimeService( + { + getSettings: () => ({ + agentDefaultEnv: { + claude: { CLAUDE_CONFIG_DIR: '/configured/claude-home' } + } + }) + } as never, + undefined, + { prepareCodexStructuredLaunch } + ) + vi.spyOn(runtime, 'getStructuredAgentSessionCreateSupport').mockResolvedValue({ + supported: true + }) + const internal = runtime as unknown as { + resolveStructuredAgentSessionLocation: (selector: string) => Promise<{ + executionHostId: string + wslDistro: null + workspaceId: string + workspaceKind: 'git-worktree' + }> + resolveRuntimeFileTarget: (selector: string) => Promise<{ + worktree: { path: string } + }> + } + internal.resolveStructuredAgentSessionLocation = vi.fn(async () => ({ + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' as const + })) + internal.resolveRuntimeFileTarget = vi.fn(async () => ({ + worktree: { path: '/repos/workspace-1' } + })) + + const intent = await runtime.resolveStructuredAgentSessionCreateIntent({ + envelope: { sessionId: 'session-1', clientOperationId: 'operation-1' }, + worktree: 'id:workspace-1', + agent: 'claude' + }) + + expect(prepareCodexStructuredLaunch).not.toHaveBeenCalled() + expect(intent.accountHome).toEqual({ + variable: 'CLAUDE_CONFIG_DIR', + path: '/configured/claude-home' + }) + }) + + it('uses the managed Claude launch home before falling back to ~/.claude', async () => { + const prepareCodexStructuredLaunch = vi.fn() + const getRuntimeConfigDir = vi.fn(() => '/accounts/managed/claude-home') + const runtime = new OrcaRuntimeService( + { + getSettings: () => ({ + agentDefaultEnv: { claude: {} } + }) + } as never, + undefined, + { prepareCodexStructuredLaunch } + ) + runtime.setAccountServices({ + claudeAccounts: { getRuntimeConfigDir } as never, + codexAccounts: {} as never, + rateLimits: {} as never + }) + vi.spyOn(runtime, 'getStructuredAgentSessionCreateSupport').mockResolvedValue({ + supported: true + }) + const internal = runtime as unknown as { + resolveStructuredAgentSessionLocation: (selector: string) => Promise<{ + executionHostId: string + wslDistro: null + workspaceId: string + workspaceKind: 'git-worktree' + }> + resolveRuntimeFileTarget: (selector: string) => Promise<{ + worktree: { path: string } + }> + } + internal.resolveStructuredAgentSessionLocation = vi.fn(async () => ({ + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' as const + })) + internal.resolveRuntimeFileTarget = vi.fn(async () => ({ + worktree: { path: '/repos/workspace-1' } + })) + + const intent = await runtime.resolveStructuredAgentSessionCreateIntent({ + envelope: { sessionId: 'session-1', clientOperationId: 'operation-1' }, + worktree: 'id:workspace-1', + agent: 'claude' + }) + + expect(getRuntimeConfigDir).toHaveBeenCalledTimes(1) + expect(intent.accountHome).toEqual({ + variable: 'CLAUDE_CONFIG_DIR', + path: '/accounts/managed/claude-home' + }) + }) }) diff --git a/src/main/runtime/orca-runtime-structured-agent-session-launch-args.test.ts b/src/main/runtime/orca-runtime-structured-agent-session-launch-args.test.ts new file mode 100644 index 00000000000..c4333fd0a4d --- /dev/null +++ b/src/main/runtime/orca-runtime-structured-agent-session-launch-args.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' + +type InstalledDeps = { + resolveLaunchArgs: (provider: 'claude' | 'codex') => Promise | string[] + resolveLaunchEnvOverlay: () => Record + resolveClaudeLaunchEnv?: () => Record +} + +const { installStructuredAgentSessionHost } = vi.hoisted(() => ({ + installStructuredAgentSessionHost: vi.fn(async (_deps: unknown) => ({}) as never) +})) + +vi.mock('./structured-agent-session-runtime', async (importOriginal) => ({ + ...(await importOriginal()), + ensureStructuredAgentSessionHost: installStructuredAgentSessionHost +})) + +function runtimeWith(settings: Record): OrcaRuntimeService { + return new OrcaRuntimeService({ getSettings: () => settings } as never) +} + +async function installedDeps(settings: Record): Promise { + installStructuredAgentSessionHost.mockClear() + await runtimeWith(settings).ensureStructuredAgentSessionHost() + return installStructuredAgentSessionHost.mock.calls[0]?.[0] as InstalledDeps +} + +describe('structured agent-session launch args wiring', () => { + it('resolves Claude launch args from the Claude agent defaults, not Codex flags', async () => { + const deps = await installedDeps({ + agentDefaultArgs: { + claude: '--dangerously-skip-permissions --model opus', + codex: '--dangerously-bypass-approvals-and-sandbox' + }, + agentDefaultEnv: {} + }) + + expect(await deps.resolveLaunchArgs('claude')).toEqual([ + '--dangerously-skip-permissions', + '--model', + 'opus' + ]) + }) + + it('still resolves Codex app-server args for a Codex session', async () => { + const deps = await installedDeps({ + agentDefaultArgs: { + claude: '--dangerously-skip-permissions', + codex: '--dangerously-bypass-approvals-and-sandbox' + }, + agentDefaultEnv: {} + }) + + const codexArgs = await deps.resolveLaunchArgs('codex') + expect(codexArgs).not.toContain('--dangerously-skip-permissions') + expect(codexArgs.length).toBeGreaterThan(0) + }) + + it('never lets a broken Codex args configuration block a Claude session', async () => { + const deps = await installedDeps({ + agentDefaultArgs: { claude: '--model opus', codex: '--not-a-real-codex-flag' }, + agentDefaultEnv: {} + }) + + expect(await deps.resolveLaunchArgs('claude')).toEqual(['--model', 'opus']) + expect(() => deps.resolveLaunchArgs('codex')).toThrow() + }) + + it('supplies the Claude env overlay so the launch resolver does not fall back to process.env', async () => { + const deps = await installedDeps({ + agentDefaultArgs: {}, + agentDefaultEnv: { + claude: { ORCA_CLAUDE_OVERLAY: 'claude-value' }, + codex: { ORCA_CODEX_OVERLAY: 'codex-value' } + } + }) + + expect(deps.resolveClaudeLaunchEnv).toBeTypeOf('function') + expect(deps.resolveClaudeLaunchEnv?.()).toMatchObject({ + ORCA_CLAUDE_OVERLAY: 'claude-value' + }) + expect(deps.resolveClaudeLaunchEnv?.()).not.toHaveProperty('ORCA_CODEX_OVERLAY') + expect(deps.resolveLaunchEnvOverlay()).toMatchObject({ ORCA_CODEX_OVERLAY: 'codex-value' }) + }) +}) diff --git a/src/main/runtime/orca-runtime-structured-agent-session-launch-tui.ts b/src/main/runtime/orca-runtime-structured-agent-session-launch-tui.ts index 9e70602f91b..89836d1e027 100644 --- a/src/main/runtime/orca-runtime-structured-agent-session-launch-tui.ts +++ b/src/main/runtime/orca-runtime-structured-agent-session-launch-tui.ts @@ -28,7 +28,12 @@ export class OrcaRuntimeWithStructuredAgentSessionLaunchTui extends OrcaRuntimeW presentation: 'background' }, {}, - { spawnToken, providerRoot: record.accountHome.path, sessionId: record.sessionId } + { + spawnToken, + providerRoot: record.accountHome.path, + sessionId: record.sessionId, + ...(record.launchArgs !== undefined ? { launchArgs: record.launchArgs } : {}) + } ) const terminal = launched.terminal let spawnedOwner: StructuredTuiOwner | null = null diff --git a/src/main/runtime/orca-runtime-structured-claude-account-gate.test.ts b/src/main/runtime/orca-runtime-structured-claude-account-gate.test.ts new file mode 100644 index 00000000000..64b451e9ea9 --- /dev/null +++ b/src/main/runtime/orca-runtime-structured-claude-account-gate.test.ts @@ -0,0 +1,112 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' +import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' +import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import type { ClaudeManagedAccountGateSettings } from '../native-chat/claude-structured-managed-account-support' + +vi.mock('electron', () => ({ + BrowserWindow: { fromId: vi.fn(() => null) }, + webContents: { fromId: vi.fn(() => null) }, + ipcMain: { on: vi.fn(), removeListener: vi.fn() }, + app: { getPath: vi.fn(() => '/tmp') } +})) + +function managedAccount(id: string, managedAuthRuntime: 'host' | 'wsl') { + return { + id, + email: `${id}@example.com`, + managedAuthPath: `/managed/${id}`, + managedAuthRuntime, + authMethod: 'subscription-oauth' as const, + createdAt: 0, + updatedAt: 0, + lastAuthenticatedAt: 0 + } +} + +const WSL_ONLY: ClaudeManagedAccountGateSettings = { + claudeManagedAccounts: [managedAccount('wsl-1', 'wsl')], + activeClaudeManagedAccountId: null, + activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'wsl-1' } } +} + +/** Registered Claude accounts with none selected: ambient auth, and the UI names no host identity, + * so this must reach structured rather than silently falling back to a terminal session. */ +const ACCOUNTS_PRESENT_NONE_ACTIVE: ClaudeManagedAccountGateSettings = { + claudeManagedAccounts: [managedAccount('host-1', 'host'), managedAccount('host-2', 'host')], + activeClaudeManagedAccountId: null, + activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: {} } +} + +const HOST_SELECTED: ClaudeManagedAccountGateSettings = { + claudeManagedAccounts: [managedAccount('host-1', 'host')], + activeClaudeManagedAccountId: 'host-1', + activeClaudeManagedAccountIdsByRuntime: { host: 'host-1', wsl: {} } +} + +function runtimeWithAccounts(claude: ClaudeManagedAccountGateSettings | null): OrcaRuntimeService { + // No store at all is the unreadable-settings case the gate must fail closed on. + const runtime = claude + ? new OrcaRuntimeService({ getSettings: () => claude } as never) + : new OrcaRuntimeService() + const internal = runtime as unknown as { + resolveStructuredAgentSessionLocation: (selector: string) => Promise + ensureStructuredAgentSessionHost: () => Promise + } + internal.resolveStructuredAgentSessionLocation = vi.fn(async () => ({ + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' as const + })) + // The adapter's own location answer is irrelevant here; pin it supported so only the account + // gate can refuse. + internal.ensureStructuredAgentSessionHost = vi.fn(async () => {}) + setStructuredAgentSessionHost({ + supportsCreate: () => true + } as unknown as StructuredAgentSessionHost) + return runtime +} + +afterEach(() => { + setStructuredAgentSessionHost(null) +}) + +describe('structured Claude managed-account gate', () => { + it('refuses Claude under a WSL-only managed account', async () => { + const runtime = runtimeWithAccounts(WSL_ONLY) + await expect( + runtime.getStructuredAgentSessionCreateSupport('id:workspace-1', 'claude') + ).resolves.toMatchObject({ supported: false }) + }) + + it('supports Claude when accounts are registered but none is selected', async () => { + const runtime = runtimeWithAccounts(ACCOUNTS_PRESENT_NONE_ACTIVE) + await expect( + runtime.getStructuredAgentSessionCreateSupport('id:workspace-1', 'claude') + ).resolves.toMatchObject({ supported: true }) + }) + + it('still supports Claude under a selected host managed account', async () => { + const runtime = runtimeWithAccounts(HOST_SELECTED) + await expect( + runtime.getStructuredAgentSessionCreateSupport('id:workspace-1', 'claude') + ).resolves.toMatchObject({ supported: true }) + }) + + it('fails closed for Claude when the account runtime cannot be determined', async () => { + const runtime = runtimeWithAccounts(null) + await expect( + runtime.getStructuredAgentSessionCreateSupport('id:workspace-1', 'claude') + ).resolves.toMatchObject({ supported: false }) + }) + + /** The gate is Claude's alone: Codex resolves its account separately and this lane must not + * change any Codex answer. */ + it('leaves Codex supported under the same WSL-only Claude account', async () => { + const runtime = runtimeWithAccounts(WSL_ONLY) + await expect( + runtime.getStructuredAgentSessionCreateSupport('id:workspace-1', 'codex') + ).resolves.toMatchObject({ supported: true }) + }) +}) diff --git a/src/main/runtime/orca-runtime-structured-claude-gate-wiring.test.ts b/src/main/runtime/orca-runtime-structured-claude-gate-wiring.test.ts new file mode 100644 index 00000000000..0d9b12f7c52 --- /dev/null +++ b/src/main/runtime/orca-runtime-structured-claude-gate-wiring.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it, vi } from 'vitest' + +const installed = vi.hoisted(() => ({ deps: null as Record | null })) + +vi.mock('electron', () => ({ + BrowserWindow: { fromId: vi.fn(() => null) }, + webContents: { fromId: vi.fn(() => null) }, + ipcMain: { on: vi.fn(), removeListener: vi.fn() }, + app: { getPath: vi.fn(() => '/tmp') } +})) + +vi.mock('./structured-agent-session-runtime', () => ({ + ensureStructuredAgentSessionHost: vi.fn(async (deps: Record) => { + installed.deps = deps + }) +})) + +import { OrcaRuntimeService } from './orca-runtime' +import { + readClaudeManagedAccountGateSettings, + type ClaudeManagedAccountGateSettings +} from '../native-chat/claude-structured-managed-account-support' + +const SETTINGS = { + claudeManagedAccounts: [], + activeClaudeManagedAccountId: null, + agentDefaultEnv: {}, + agentDefaultArgs: {} +} as unknown as ClaudeManagedAccountGateSettings + +function gateSettingsGetter(): (() => ClaudeManagedAccountGateSettings) | undefined { + const deps: Record = installed.deps ?? {} + const get = deps['getClaudeManagedAccountGateSettings'] + return typeof get === 'function' ? (get as () => ClaudeManagedAccountGateSettings) : undefined +} + +/** The runtime class this wiring lives on does not typecheck its own `this` calls, so a broken or + * missing gate hookup compiles clean. Pin it behaviourally instead. */ +describe('structured Claude managed-account gate wiring', () => { + it('hands the host a gate reader that resolves the live settings', async () => { + installed.deps = null + const runtime = new OrcaRuntimeService({ getSettings: () => SETTINGS } as never) + + await runtime.ensureStructuredAgentSessionHost() + + const get = gateSettingsGetter() + expect(typeof get).toBe('function') + expect(get?.()).toBe(SETTINGS) + }) + + /** The installer composes this getter with the fail-closed reader, which is the shape the + * resolver consumes; pin that composition end to end. */ + it('composes into a null answer instead of throwing when settings cannot be read', async () => { + installed.deps = null + const runtime = new OrcaRuntimeService() + + await runtime.ensureStructuredAgentSessionHost() + + const get = gateSettingsGetter() + expect(typeof get).toBe('function') + expect(() => get?.()).toThrow() + expect(readClaudeManagedAccountGateSettings(get!)).toBeNull() + }) +}) diff --git a/src/main/runtime/orca-runtime-structured-session-restore.test.ts b/src/main/runtime/orca-runtime-structured-session-restore.test.ts index 9e752330445..d6ec1e22782 100644 --- a/src/main/runtime/orca-runtime-structured-session-restore.test.ts +++ b/src/main/runtime/orca-runtime-structured-session-restore.test.ts @@ -325,6 +325,56 @@ describe('structured session cold restoration', () => { expect(closed.tabGroups?.[0]?.tabOrder).toEqual(['terminal-tab']) }) + it('publishes restored Claude tabs with the Claude title', async () => { + const runtime = new OrcaRuntimeService() + const publish = vi.spyOn(runtime, 'publishStructuredAgentSessionTab') + const internal = runtime as unknown as { + hasPersistedStructuredAgentSessionStore(): boolean + getKnownWorkspaceSessionWorktreeIds(): Set + hydrateHeadlessMobileSessionTabsFromWorkspaceSession(): Set + refreshMobileSessionPtyRecords(): Promise | null> + ensureStructuredAgentSessionHost(): Promise + } + internal.hasPersistedStructuredAgentSessionStore = () => true + internal.getKnownWorkspaceSessionWorktreeIds = () => new Set() + internal.hydrateHeadlessMobileSessionTabsFromWorkspaceSession = () => new Set() + internal.refreshMobileSessionPtyRecords = async () => new Set() + internal.ensureStructuredAgentSessionHost = async () => undefined + setStructuredAgentSessionHost({ + reconcileRestartLeases: async () => undefined, + restoreReadableSessions: async () => undefined, + listSessionTabs: () => [ + { + sessionId: 'agent-session:agent-session:restored-claude', + workspaceId: 'workspace-1', + agent: 'claude' + } + ] + } as never) + + await runtime.restoreStructuredAgentSessionTabs() + + expect(publish).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + sessionId: 'restored-claude', + agent: 'claude', + activate: false, + notify: false + }) + + const restored = await runtime.listMobileSessionTabs('id:workspace-1') + expect(restored.tabs).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'agent-session', + id: 'agent-session:restored-claude', + title: 'Claude Chat', + agent: 'claude' + }) + ]) + ) + }) + it('commits the host close when the renderer already removed the structured tab', async () => { const runtime = new OrcaRuntimeService() runtime.setNotifier({ diff --git a/src/main/runtime/orca-runtime-structured-tui-tab-binding.test.ts b/src/main/runtime/orca-runtime-structured-tui-tab-binding.test.ts new file mode 100644 index 00000000000..d15f754b244 --- /dev/null +++ b/src/main/runtime/orca-runtime-structured-tui-tab-binding.test.ts @@ -0,0 +1,730 @@ +import { createHash } from 'node:crypto' +import { describe, expect, it, vi } from 'vitest' +import type { StructuredAgentSessionHandoffTransport } from '../native-chat/agent-session-wire/structured-agent-session-handoff-types' +import { createEphemeralAgentSessionClaimSigner } from './agent-session-claim-identity' +import { agentSessionPtyWriteGate } from './agent-session-pty-write-gate' +import { OrcaRuntimeService } from './orca-runtime' + +const { + probeAgentSessionProcessIdentity, + proveCodexTuiRollout, + readClaudeTranscriptLeafUuid, + readStructuredTuiProcessIdentity, + resolveSessionFilePath, + resolvePinnedCodexRolloutProof +} = vi.hoisted(() => ({ + probeAgentSessionProcessIdentity: vi.fn(), + proveCodexTuiRollout: vi.fn(), + readClaudeTranscriptLeafUuid: vi.fn(), + readStructuredTuiProcessIdentity: vi.fn(), + resolveSessionFilePath: vi.fn(), + resolvePinnedCodexRolloutProof: vi.fn() +})) + +vi.mock('./structured-tui-process-identity', () => ({ readStructuredTuiProcessIdentity })) +vi.mock('../codex/codex-tui-rollout-proof', () => ({ + proveCodexTuiRollout, + resolvePinnedCodexRolloutProof +})) +vi.mock('../native-chat/session-file-resolver', () => ({ + readClaudeTranscriptLeafUuid, + resolveSessionFilePath +})) +vi.mock('./agent-session-process-identity-probe', async (importOriginal) => ({ + ...(await importOriginal()), + probeAgentSessionProcessIdentity +})) + +const WORKTREE_ID = 'repo-1::/tmp/structured-handoff' + +function notifier(revealTerminalSession: ReturnType) { + return { + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + revealTerminalSession, + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + } +} + +describe('structured TUI launch tab binding', () => { + it('recovers a live TUI from durable owner inventory in a fresh runtime', async () => { + const namespace = { + machine: 'native:test', + principal: 'uid:1', + container: 'native', + providerRoot: '/tmp/codex-home' + } + const signer = createEphemeralAgentSessionClaimSigner('profile-test') + const claim = signer.createClaim({ + namespace, + identity: { agent: 'codex', providerSession: { key: 'session_id', id: 'thread-1' } }, + canonicalWorktreeId: WORKTREE_ID + }) + const terminalHandle = 'term_cold_owner' + const leafId = '23013912-13f8-44e5-818f-d40a1ff4e8c5' + resolvePinnedCodexRolloutProof.mockResolvedValue('/tmp/codex-home/sessions/thread-1.jsonl') + const writeAgentSessionProof = vi.fn(() => false) + const runtime = new OrcaRuntimeService(undefined, undefined, { + agentSessionClaimSigner: signer + }) + runtime.setPtyController({ + listProcesses: vi.fn(async () => [ + { + id: 'pty-cold-owner', + incarnationId: 'incarnation-1', + cwd: '/tmp/structured-handoff', + title: 'codex', + worktreeId: WORKTREE_ID, + terminalHandle, + agentSessionOwners: [ + { + claim, + generation: 'generation-1', + phase: 'live' as const, + ptyId: 'pty-cold-owner', + surface: { + worktreeId: WORKTREE_ID, + tabId: 'tab-cold-owner', + leafId, + terminalHandle + } + } + ] + } + ]), + write: () => true, + kill: () => true, + writeAgentSessionProof, + getForegroundProcess: async () => null + }) + const internal = runtime as unknown as { + createStructuredAgentSessionHandoffTransport(): StructuredAgentSessionHandoffTransport + refreshMobileSessionPtyRecords(): Promise | null> + listResolvedWorktrees(): Promise + resolveTerminalWorkspaceLaunchScope(): Promise<{ + id: string + path: string + connectionId: null + repo: null + folderWorkspace: null + }> + getAgentSessionExecutionNamespace(): typeof namespace + ptysById: Map< + string, + { + launchToken: string | null + launchAgent: string | null + agentSessionOwners: unknown[] + tabId?: string | null + paneKey?: string | null + } + > + } + internal.listResolvedWorktrees = vi.fn(async () => [ + { id: WORKTREE_ID, repoId: 'repo-1', path: '/tmp/structured-handoff' } + ]) + internal.resolveTerminalWorkspaceLaunchScope = vi.fn(async () => ({ + id: WORKTREE_ID, + path: '/tmp/structured-handoff', + connectionId: null, + repo: null, + folderWorkspace: null + })) + internal.getAgentSessionExecutionNamespace = () => namespace + proveCodexTuiRollout.mockResolvedValueOnce({ + transcriptPath: '/tmp/codex-home/sessions/thread-1.jsonl' + }) + probeAgentSessionProcessIdentity.mockResolvedValue({ + outcome: 'identity-matched', + matchedOn: ['process-start-time'] + }) + + await internal.refreshMobileSessionPtyRecords() + const coldPty = internal.ptysById.get('pty-cold-owner')! + expect(coldPty).toMatchObject({ launchToken: null, launchAgent: null }) + expect(coldPty.agentSessionOwners).toHaveLength(1) + const runtimeId = (runtime as unknown as { runtimeId: string }).runtimeId + ;( + runtime as unknown as { + handles: Map< + string, + { + handle: string + runtimeId: string + rendererGraphEpoch: number + worktreeId: string + tabId: string + leafId: string + ptyId: string + ptyGeneration: number + } + > + } + ).handles.set(terminalHandle, { + handle: terminalHandle, + runtimeId, + rendererGraphEpoch: 0, + worktreeId: WORKTREE_ID, + tabId: 'pty:pty-cold-owner', + leafId: 'pty:pty-cold-owner', + ptyId: 'pty-cold-owner', + ptyGeneration: 0 + }) + coldPty.tabId = 'tab-cold-owner' + coldPty.paneKey = `tab-cold-owner:${leafId}` + coldPty.launchToken = 'spawn-token' + coldPty.launchAgent = 'codex' + + const owner = await internal.createStructuredAgentSessionHandoffTransport().recoverTuiOwner({ + sessionId: 'session-1', + location: { workspaceId: WORKTREE_ID, executionHostId: 'local' }, + accountHome: { variable: 'CODEX_HOME', path: namespace.providerRoot }, + providerHandleChain: [{ handle: { provider: 'codex', threadId: 'thread-1' }, observedAt: 1 }], + lease: { + ownerProcess: { + hostId: 'local', + pid: 4243, + processStartTimeMs: 10, + spawnToken: 'spawn-token' + }, + runtimeFence: 3 + } + } as never) + + expect(owner.terminal).toEqual({ + handle: terminalHandle, + tabId: 'tab-cold-owner', + paneKey: `tab-cold-owner:${leafId}`, + ptyId: 'pty-cold-owner' + }) + expect(proveCodexTuiRollout).toHaveBeenCalledWith( + expect.objectContaining({ + codexHome: namespace.providerRoot, + threadId: 'thread-1', + readOutput: expect.any(Function), + write: expect.any(Function) + }) + ) + expect(resolvePinnedCodexRolloutProof).not.toHaveBeenCalled() + expect(writeAgentSessionProof).not.toHaveBeenCalled() + expect(agentSessionPtyWriteGate.boundSessionId('pty-cold-owner')).toBe('session-1') + agentSessionPtyWriteGate.unbindPty('pty-cold-owner') + }) + + it('rebuilds a Claude proving link from current launch-token-bound hook evidence', async () => { + const paneKey = 'tab-claude:leaf-claude' + const spawnToken = 'claude-restart-token' + const sessionId = '019fd532-7c11-7a90-b6de-4e1a2c3d5f61' + const transcriptPath = '/tmp/claude-home/projects/worktree/session.jsonl' + const attestAgentHookCompatibilityAuthority = vi.fn(() => ({ + paneKey, + source: 'hydrated_commitment' as const + })) + const runtime = new OrcaRuntimeService(null, undefined, { + attestAgentHookCompatibilityAuthority, + getAgentProviderSessionRowsForPane: () => [ + { + paneKey, + connectionId: null, + state: 'done', + prompt: '', + agentType: 'claude', + receivedAt: Date.now() + 1000, + stateStartedAt: 10, + providerSession: { key: 'session_id', id: sessionId, transcriptPath } + } + ] + }) + const internal = runtime as unknown as { + createStructuredAgentSessionHandoffTransport(): StructuredAgentSessionHandoffTransport + ptysById: Map + restoredOrchestrationAuthorityByPtyId: Map + } + internal.ptysById.set('pty-claude', { + ptyId: 'pty-claude', + worktreeId: WORKTREE_ID, + connectionId: null, + tabId: 'tab-claude', + paneKey, + launchToken: spawnToken, + launchAgent: 'claude', + connected: true + }) + resolveSessionFilePath.mockResolvedValue('/tmp/claude-home/projects/worktree/session.jsonl') + readClaudeTranscriptLeafUuid.mockResolvedValue('leaf-before-resume') + const record = { + sessionId: 'session-1', + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/tmp/claude-home' }, + providerHandleChain: [ + { + linkId: 'claude-old', + handle: { provider: 'claude', sessionId, leafUuid: 'leaf-before-resume' }, + origin: 'created', + mintedAtFence: 1, + observedAt: 1 + } + ], + lease: { + runtimeFence: 3, + ownerProcess: { + hostId: 'local', + pid: 4343, + processStartTimeMs: 20, + spawnToken + }, + provenHandleLinkId: null + } + } as never + probeAgentSessionProcessIdentity.mockResolvedValue({ + outcome: 'identity-matched', + matchedOn: ['process-start-time'] + }) + + const transport = internal.createStructuredAgentSessionHandoffTransport() + const recovered = await transport.recoverTuiOwner(record) + expect(recovered).toMatchObject({ + transcriptPath, + link: { + handle: { provider: 'claude', sessionId, leafUuid: 'leaf-before-resume' }, + origin: 'resumed', + mintedAtFence: 3 + } + }) + expect(recovered.link.linkId).not.toBe('claude-old') + + const pty = internal.ptysById.get('pty-claude') as { + launchToken: string | null + } + pty.launchToken = null + const dispatchAuthority = runtime.getOrchestrationDispatchAuthority(recovered.terminal.handle)! + internal.restoredOrchestrationAuthorityByPtyId.set('pty-claude', { + ptyId: 'pty-claude', + worktreeId: WORKTREE_ID, + terminalHandle: recovered.terminal.handle, + paneKey: recovered.terminal.paneKey, + processIncarnation: dispatchAuthority.processIncarnation, + hostScope: dispatchAuthority.hostScope + }) + + expect( + runtime.verifyOrchestrationCompatibilityCaller({ + terminalHandle: recovered.terminal.handle, + paneKey, + launchToken: spawnToken + }) + ).toMatchObject({ + paneKey, + terminalHandle: recovered.terminal.handle, + processIncarnation: dispatchAuthority.processIncarnation + }) + expect(attestAgentHookCompatibilityAuthority).toHaveBeenCalledWith({ + paneKey, + launchTokenHash: createHash('sha256').update(spawnToken).digest('hex'), + connectionId: null, + terminalProvenance: 'restored' + }) + attestAgentHookCompatibilityAuthority.mockReturnValueOnce(null as never) + expect( + runtime.verifyOrchestrationCompatibilityCaller({ + terminalHandle: recovered.terminal.handle, + paneKey, + launchToken: spawnToken + }) + ).toBeNull() + expect(agentSessionPtyWriteGate.boundSessionId('pty-claude')).toBe('session-1') + agentSessionPtyWriteGate.unbindPty('pty-claude') + }) + + it('requires restored hook attestation after the runtime restarts', async () => { + const paneKey = 'tab-restored:leaf-restored' + const spawnToken = 'restored-token' + const sessionId = '019fd532-7c11-7a90-b6de-4e1a2c3d5f62' + const transcriptPath = '/tmp/claude-home/projects/worktree/restored.jsonl' + const attestAgentHookCompatibilityAuthority = vi.fn(() => ({ + paneKey, + source: 'hydrated_commitment' as const + })) + const runtime = new OrcaRuntimeService(null, undefined, { + attestAgentHookCompatibilityAuthority, + getAgentProviderSessionRowsForPane: () => [ + { + paneKey, + connectionId: null, + state: 'done', + prompt: '', + agentType: 'claude', + receivedAt: Date.now() + 1000, + stateStartedAt: 10, + providerSession: { key: 'session_id', id: sessionId, transcriptPath } + } + ] + }) + const internal = runtime as unknown as { + createStructuredAgentSessionHandoffTransport(): StructuredAgentSessionHandoffTransport + ptysById: Map + restoredOrchestrationAuthorityByPtyId: Map + } + internal.ptysById.set('pty-restored', { + ptyId: 'pty-restored', + worktreeId: WORKTREE_ID, + connectionId: null, + tabId: 'tab-restored', + paneKey, + launchToken: spawnToken, + launchAgent: 'claude', + connected: true + }) + resolveSessionFilePath.mockResolvedValue('/tmp/claude-home/projects/worktree/restored.jsonl') + readClaudeTranscriptLeafUuid.mockResolvedValue('leaf-restored') + const record = { + sessionId: 'session-restored', + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/tmp/claude-home' }, + providerHandleChain: [ + { + linkId: 'claude-restored', + handle: { provider: 'claude', sessionId, leafUuid: 'leaf-restored' }, + origin: 'created', + mintedAtFence: 1, + observedAt: 1 + } + ], + lease: { + runtimeFence: 4, + ownerProcess: { + hostId: 'local', + pid: 4545, + processStartTimeMs: 30, + spawnToken + }, + provenHandleLinkId: null + } + } as never + + const recovered = await internal + .createStructuredAgentSessionHandoffTransport() + .recoverTuiOwner(record) + const restoredPty = internal.ptysById.get('pty-restored') as { + launchToken: string | null + } + restoredPty.launchToken = null + const dispatchAuthority = runtime.getOrchestrationDispatchAuthority(recovered.terminal.handle)! + internal.restoredOrchestrationAuthorityByPtyId.set('pty-restored', { + ptyId: 'pty-restored', + worktreeId: WORKTREE_ID, + terminalHandle: recovered.terminal.handle, + paneKey: recovered.terminal.paneKey, + processIncarnation: dispatchAuthority.processIncarnation, + hostScope: dispatchAuthority.hostScope + }) + + expect( + runtime.verifyOrchestrationCompatibilityCaller({ + terminalHandle: recovered.terminal.handle, + paneKey, + launchToken: spawnToken + }) + ).toMatchObject({ + paneKey, + terminalHandle: recovered.terminal.handle, + processIncarnation: dispatchAuthority.processIncarnation + }) + expect(attestAgentHookCompatibilityAuthority).toHaveBeenCalledWith({ + paneKey, + launchTokenHash: createHash('sha256').update(spawnToken).digest('hex'), + connectionId: null, + terminalProvenance: 'restored' + }) + attestAgentHookCompatibilityAuthority.mockReturnValueOnce(null as never) + await expect( + runtime.verifyOrchestrationCompatibilityCaller({ + terminalHandle: recovered.terminal.handle, + paneKey, + launchToken: spawnToken + }) + ).toBeNull() + agentSessionPtyWriteGate.unbindPty('pty-restored') + }) + + it('proves the published launch tab before returning its revealed renderer binding', async () => { + let explicitStatus: { + state: 'working' | 'done' + prompt: string + receivedAt: number + stateStartedAt: number + paneKey: string + terminalHandle: string + } | null = null + const revealTerminalSession = vi.fn( + (_worktreeId: string, _options: { tabId?: string; leafId?: string; ptyId?: string }) => + Promise.resolve({ tabId: 'tab-renderer' }) + ) + const runtime = new OrcaRuntimeService( + { + getSettings: () => ({ + disabledTuiAgents: [], + agentCmdOverrides: {}, + agentDefaultArgs: { + codex: '-m gpt-5.6-sol -c model_reasoning_effort=high' + }, + agentDefaultEnv: {} + }) + } as never, + undefined, + { + getAgentStatusSnapshot: () => (explicitStatus ? [explicitStatus as never] : []) + } + ) + runtime.setNotifier(notifier(revealTerminalSession) as never) + const spawn = vi.fn().mockResolvedValue({ id: 'pty-structured', pid: 4242 }) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + + const internal = runtime as unknown as { + createStructuredAgentSessionHandoffTransport(): StructuredAgentSessionHandoffTransport + resolveTerminalWorkspaceLaunchScope(): Promise<{ + id: string + path: string + connectionId: null + repo: null + folderWorkspace: null + }> + markLocalWorkspaceTrustedForAgent(): void + waitForTerminal(): Promise + waitForAdoptedStructuredTuiProof(): Promise<{ transcriptPath?: string }> + waitForStructuredTuiPtyExit(): Promise + closeTerminal(handle: string): Promise + handles: Map< + string, + { + rendererGraphEpoch: number + tabId: string + leafId: string + } + > + graphStatus: 'ready' + } + internal.resolveTerminalWorkspaceLaunchScope = vi.fn(async () => ({ + id: WORKTREE_ID, + path: '/tmp/structured-handoff', + connectionId: null, + repo: null, + folderWorkspace: null + })) + internal.markLocalWorkspaceTrustedForAgent = vi.fn() + const waitForTerminal = vi.fn(async () => ({})) + internal.waitForTerminal = waitForTerminal + const waitForAdoptedStructuredTuiProof = vi.fn(async () => { + const snapshot = await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`) + expect(snapshot.tabs).toContainEqual( + expect.objectContaining({ + type: 'terminal', + parentTabId: expect.any(String), + leafId: expect.any(String), + ptyId: 'pty-structured', + terminal: expect.any(String) + }) + ) + expect(revealTerminalSession).not.toHaveBeenCalled() + return { transcriptPath: '/tmp/rollout.jsonl' } + }) + internal.waitForAdoptedStructuredTuiProof = waitForAdoptedStructuredTuiProof + const waitForStructuredTuiPtyExit = vi.fn(async () => {}) + internal.waitForStructuredTuiPtyExit = waitForStructuredTuiPtyExit + const closeTerminal = vi.fn(async () => undefined) + internal.closeTerminal = closeTerminal + readStructuredTuiProcessIdentity.mockResolvedValue({ + hostId: 'local', + pid: 4243, + processStartTimeMs: 10, + spawnToken: 'spawn-token' + }) + probeAgentSessionProcessIdentity.mockResolvedValue({ + outcome: 'identity-matched', + matchedOn: ['process-start-time'] + }) + + const transport = internal.createStructuredAgentSessionHandoffTransport() + const onSpawned = vi.fn(async () => {}) + const owner = await transport.launchTui({ + record: { + sessionId: 'session-1', + location: { workspaceId: WORKTREE_ID, executionHostId: 'local' }, + accountHome: { variable: 'CODEX_HOME', path: '/tmp/codex-home' }, + launchArgs: ['--search'], + options: { model: 'gpt-5.6-terra', effort: 'medium' }, + providerHandleChain: [ + { handle: { provider: 'codex', threadId: 'thread-1' }, observedAt: 1 } + ] + } as never, + fence: 3, + spawnToken: 'spawn-token', + onSpawned + }) + + const reveal = revealTerminalSession.mock.calls[0]?.[1] as { + tabId: string + leafId: string + } + expect(owner.terminal).toMatchObject({ + tabId: 'tab-renderer', + paneKey: `${reveal.tabId}:${reveal.leafId}`, + ptyId: 'pty-structured' + }) + expect(waitForTerminal).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ condition: 'tui-idle' }) + ) + expect(waitForAdoptedStructuredTuiProof).toHaveBeenCalledOnce() + expect(onSpawned).toHaveBeenCalledWith( + expect.objectContaining({ + terminal: expect.objectContaining({ ptyId: 'pty-structured' }), + process: expect.objectContaining({ spawnToken: 'spawn-token' }) + }) + ) + expect(onSpawned.mock.invocationCallOrder[0]).toBeLessThan( + waitForTerminal.mock.invocationCallOrder[0]! + ) + expect(waitForAdoptedStructuredTuiProof.mock.invocationCallOrder[0]).toBeLessThan( + revealTerminalSession.mock.invocationCallOrder[0]! + ) + const launchCommand = spawn.mock.calls[0]?.[0]?.command + expect(launchCommand).toContain("'-m' 'gpt-5.6-terra'") + expect(launchCommand).toContain("'-c' 'model_reasoning_effort=medium'") + expect(launchCommand).toContain("'--search'") + expect(launchCommand).not.toContain('gpt-5.6-sol') + expect(launchCommand).not.toContain('model_reasoning_effort=high') + + Object.assign(internal.handles.get(owner.terminal.handle)!, { + rendererGraphEpoch: -1, + tabId: 'tab-retired', + leafId: 'leaf-retired' + }) + internal.graphStatus = 'ready' + + explicitStatus = { + state: 'working', + prompt: '', + receivedAt: Date.now(), + stateStartedAt: Date.now(), + paneKey: owner.terminal.paneKey, + terminalHandle: owner.terminal.handle + } + expect(transport.tuiStatus(owner)).toBe('busy') + await expect( + transport.waitForTuiIdleOrExit(owner, new AbortController().signal) + ).resolves.toBeNull() + + explicitStatus = { ...explicitStatus, state: 'done', receivedAt: Date.now() } + expect(transport.tuiStatus(owner)).toBe('idle') + await expect(transport.waitForTuiIdleOrExit(owner, new AbortController().signal)).resolves.toBe( + 'idle' + ) + + explicitStatus = null + const livePty = ( + runtime as unknown as { + ptysById: Map< + string, + { + tailBuffer: string[] + tailPartialLine: string + preview: string + lastAgentStatus: null + lastAgentStatusObservedLive: boolean + } + > + } + ).ptysById.get('pty-structured')! + Object.assign(livePty, { + tailBuffer: [ + 'OpenAI Codex (v0.147.0)', + 'model: gpt-5.6-terra', + 'directory: /tmp/structured-handoff' + ], + tailPartialLine: '', + preview: '', + lastAgentStatus: null, + lastAgentStatusObservedLive: false + }) + expect(transport.tuiStatus(owner)).toBe('idle') + await expect(transport.waitForTuiIdleOrExit(owner, new AbortController().signal)).resolves.toBe( + 'idle' + ) + + const pty = ( + runtime as unknown as { + ptysById: Map + } + ).ptysById.get('pty-structured')! + pty.launchToken = null + const persistedRecord = { + sessionId: 'session-1', + providerHandleChain: [{ handle: { provider: 'codex', threadId: 'thread-1' }, observedAt: 1 }], + lease: { ownerProcess: owner.process, provenHandleLinkId: owner.link.linkId } + } as never + + const rebound = await transport.reproveTuiOwner({ record: persistedRecord, owner }) + expect(rebound.terminal).toMatchObject({ + ptyId: 'pty-structured', + tabId: owner.terminal.tabId, + paneKey: owner.terminal.paneKey + }) + expect(rebound.terminal.handle).not.toBe(owner.terminal.handle) + await transport.waitForTuiExit(rebound) + expect(waitForStructuredTuiPtyExit).toHaveBeenCalledWith('pty-structured') + expect(waitForAdoptedStructuredTuiProof).toHaveBeenCalledOnce() + + await expect(transport.closeTuiOwner?.(rebound)).resolves.toEqual({ + transcriptPath: '/tmp/rollout.jsonl' + }) + expect(closeTerminal).toHaveBeenCalledWith(rebound.terminal.handle) + + explicitStatus = null + pty.connected = false + await expect( + transport.waitForTuiIdleOrExit(rebound, new AbortController().signal) + ).resolves.toBe('exited') + await expect(transport.stopFailedTuiLaunch?.(rebound)).resolves.toBeUndefined() + }) + + it('reveals Claude structured native sessions into the mobile graph', async () => { + const runtime = new OrcaRuntimeService() + const publish = vi.spyOn(runtime, 'publishStructuredAgentSessionTab') + const focusEditorTab = vi.fn() + runtime.setNotifier({ focusEditorTab } as never) + const internal = runtime as unknown as { + createStructuredAgentSessionHandoffTransport(): StructuredAgentSessionHandoffTransport + } + + await internal.createStructuredAgentSessionHandoffTransport().revealNativeSession?.({ + workspaceId: WORKTREE_ID, + sessionId: 'session-claude', + agent: 'claude' + }) + + expect(publish).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: WORKTREE_ID, + sessionId: 'session-claude', + agent: 'claude', + activate: false + }) + ) + expect(focusEditorTab).toHaveBeenCalledWith( + 'structured-agent-session-session-claude', + WORKTREE_ID + ) + }) +}) diff --git a/src/main/runtime/rpc/e2ee-channel-v2.test.ts b/src/main/runtime/rpc/e2ee-channel-v2.test.ts index f9e602ced41..b26b057abaf 100644 --- a/src/main/runtime/rpc/e2ee-channel-v2.test.ts +++ b/src/main/runtime/rpc/e2ee-channel-v2.test.ts @@ -156,6 +156,25 @@ describe('E2EEChannel v2', () => { }) }) + it('forwards post-auth capability-shaped frames without mutating authenticated capabilities', () => { + const ctx = setup() + const { schedule } = startV2(ctx) + const onMessage = vi.fn() + ctx.channel.onMessage(onMessage) + authenticate(ctx, schedule) + + const capabilityFrame = JSON.stringify({ + type: 'e2ee_client_capabilities', + v: 1, + clientCapabilities: ['agent-session.structured.v1'] + }) + ctx.channel.handleRawMessage(clientText(capabilityFrame, schedule, 1n)) + + expect(ctx.channel.clientCapabilities).toEqual([]) + expect(onMessage).toHaveBeenCalledOnce() + expect(onMessage.mock.calls[0]?.[0]).toBe(capabilityFrame) + }) + it('rejects legacy downgrade and runtime-only capability metadata when mobile v2 is required', () => { const legacy = setup() legacy.channel.handleRawMessage( diff --git a/src/main/runtime/rpc/methods/clipboard.test.ts b/src/main/runtime/rpc/methods/clipboard.test.ts index 118b21c766a..c0d224b85c6 100644 --- a/src/main/runtime/rpc/methods/clipboard.test.ts +++ b/src/main/runtime/rpc/methods/clipboard.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { RpcDispatcher } from '../dispatcher' -import type { RpcRequest } from '../core' +import type { RpcRequest, RpcResponse } from '../core' import type { OrcaRuntimeService } from '../../orca-runtime' import { CLIPBOARD_IMAGE_MAX_BASE64_CHARS, @@ -21,6 +21,10 @@ import { CLIPBOARD_METHODS, resetClipboardImageUploadsForTest } from './clipboard' +import { + hasMobileClipboardImagePath, + resetMobileClipboardImageProvenanceForTest +} from '../mobile-clipboard-image-provenance' function makeRequest(method: string, params?: unknown): RpcRequest { return { id: 'req-1', authToken: 'tok', method, params } @@ -31,15 +35,36 @@ function makeDispatcher(): RpcDispatcher { return new RpcDispatcher({ runtime, methods: CLIPBOARD_METHODS }) } +async function callMobile( + dispatcher: RpcDispatcher, + method: string, + params: unknown, + clientId = 'device-a' +): Promise { + const replies: RpcResponse[] = [] + await dispatcher.dispatchStreaming( + makeRequest(method, params), + (raw) => replies.push(JSON.parse(raw) as RpcResponse), + { clientKind: 'mobile', clientId } + ) + const response = replies[0] + if (!response) { + throw new Error(`no reply for ${method}`) + } + return response +} + describe('clipboard RPC methods', () => { beforeEach(() => { saveClipboardImageBufferAsTempFile.mockReset() resetClipboardImageUploadsForTest() + resetMobileClipboardImageProvenanceForTest() }) afterEach(() => { vi.useRealTimers() resetClipboardImageUploadsForTest() + resetMobileClipboardImageProvenanceForTest() }) it('saves browser-provided clipboard image bytes on the runtime host', async () => { @@ -64,6 +89,37 @@ describe('clipboard RPC methods', () => { }) }) + it('records a successful direct mobile upload for only the authenticated client', async () => { + const path = '/tmp/orca-paste-image.png' + saveClipboardImageBufferAsTempFile.mockResolvedValue(path) + const dispatcher = makeDispatcher() + + await expect( + callMobile(dispatcher, 'clipboard.saveImageAsTempFile', { + contentBase64: Buffer.from('png-bytes').toString('base64'), + connectionId: null + }) + ).resolves.toMatchObject({ ok: true, result: path }) + + expect(hasMobileClipboardImagePath('device-a', path)).toBe(true) + expect(hasMobileClipboardImagePath('device-b', path)).toBe(false) + }) + + it('does not authorize a remote-host clipboard path for local structured delivery', async () => { + const path = '/tmp/orca-paste-image.png' + saveClipboardImageBufferAsTempFile.mockResolvedValue(path) + const dispatcher = makeDispatcher() + + await expect( + callMobile(dispatcher, 'clipboard.saveImageAsTempFile', { + contentBase64: Buffer.from('png-bytes').toString('base64'), + connectionId: 'ssh-1' + }) + ).resolves.toMatchObject({ ok: true, result: path }) + + expect(hasMobileClipboardImagePath('device-a', path)).toBe(false) + }) + it('rejects non-base64 clipboard image payloads', async () => { const dispatcher = makeDispatcher() @@ -140,6 +196,48 @@ describe('clipboard RPC methods', () => { expect(saveClipboardImageBufferAsTempFile).toHaveBeenCalledWith(Buffer.from('png-bytes'), { connectionId: 'ssh-1' }) + expect(hasMobileClipboardImagePath('device-a', '/tmp/orca-paste-image.png')).toBe(false) + }) + + it('binds chunk mutation and provenance to the mobile client that started the upload', async () => { + saveClipboardImageBufferAsTempFile.mockResolvedValue('/tmp/orca-paste-image.png') + const dispatcher = makeDispatcher() + const contentBase64 = Buffer.from('png-bytes').toString('base64') + const start = await callMobile(dispatcher, 'clipboard.startImageUpload', { + expectedBase64Length: contentBase64.length, + connectionId: null + }) + const uploadId = (start.ok ? start.result : null) as { uploadId: string } + + for (const method of [ + 'clipboard.appendImageUploadChunk', + 'clipboard.commitImageUpload', + 'clipboard.abortImageUpload' + ]) { + const params = + method === 'clipboard.appendImageUploadChunk' + ? { uploadId: uploadId.uploadId, offset: 0, contentBase64 } + : { uploadId: uploadId.uploadId } + await expect(callMobile(dispatcher, method, params, 'device-b')).resolves.toMatchObject({ + ok: false + }) + } + + await expect( + callMobile(dispatcher, 'clipboard.appendImageUploadChunk', { + uploadId: uploadId.uploadId, + offset: 0, + contentBase64 + }) + ).resolves.toMatchObject({ + ok: true, + result: { receivedBase64Length: contentBase64.length } + }) + await expect( + callMobile(dispatcher, 'clipboard.commitImageUpload', { uploadId: uploadId.uploadId }) + ).resolves.toMatchObject({ ok: true, result: '/tmp/orca-paste-image.png' }) + expect(hasMobileClipboardImagePath('device-a', '/tmp/orca-paste-image.png')).toBe(true) + expect(hasMobileClipboardImagePath('device-b', '/tmp/orca-paste-image.png')).toBe(false) }) it('rejects out-of-order chunk offsets', async () => { diff --git a/src/main/runtime/rpc/methods/clipboard.ts b/src/main/runtime/rpc/methods/clipboard.ts index 3d5212c7a52..e6b487d7761 100644 --- a/src/main/runtime/rpc/methods/clipboard.ts +++ b/src/main/runtime/rpc/methods/clipboard.ts @@ -1,11 +1,12 @@ import { z } from 'zod' -import { defineMethod, type RpcMethod } from '../core' +import { defineMethod, type RpcContext, type RpcMethod } from '../core' import { saveClipboardImageBufferAsTempFile } from '../../../window/clipboard-image-temp-file' import { randomUUID } from 'node:crypto' import { CLIPBOARD_IMAGE_MAX_BASE64_CHARS, CLIPBOARD_IMAGE_TOO_LARGE_ERROR } from '../../../../shared/clipboard-image' +import { recordMobileClipboardImagePath } from '../mobile-clipboard-image-provenance' const MAX_CLIPBOARD_IMAGE_BASE64_CHARS = CLIPBOARD_IMAGE_MAX_BASE64_CHARS export const CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS = 512 * 1024 @@ -16,6 +17,7 @@ const BASE64_PATTERN = /^[A-Za-z0-9+/]*={0,2}$/ type ClipboardImageUpload = { expectedBase64Length: number connectionId?: string | null + mobileClientId?: string chunks: string[] receivedBase64Length: number expiresAt: number @@ -69,6 +71,28 @@ function getUpload(uploadId: string): ClipboardImageUpload { return upload } +function mobileClientId(ctx: RpcContext): string | undefined { + if (ctx.clientKind !== 'mobile') { + return undefined + } + const clientId = ctx.clientId?.trim() + if (!clientId) { + throw new Error('Clipboard image upload requires an authenticated mobile client') + } + return clientId +} + +function assertMobileUploadOwner( + upload: ClipboardImageUpload, + ctx: RpcContext +): string | undefined { + const clientId = mobileClientId(ctx) + if (clientId && upload.mobileClientId !== clientId) { + throw new Error('Clipboard image upload was not found') + } + return clientId +} + function assertValidBase64Content(value: string): void { if (!isValidBase64(value)) { throw new Error('Clipboard image content must be base64') @@ -131,15 +155,24 @@ export const CLIPBOARD_METHODS: RpcMethod[] = [ defineMethod({ name: 'clipboard.saveImageAsTempFile', params: SaveImageAsTempFile, - handler: async (params) => - saveClipboardImageBufferAsTempFile(Buffer.from(params.contentBase64, 'base64'), { - connectionId: params.connectionId - }) + handler: async (params, ctx) => { + const clientId = mobileClientId(ctx) + const path = await saveClipboardImageBufferAsTempFile( + Buffer.from(params.contentBase64, 'base64'), + { + connectionId: params.connectionId + } + ) + if (clientId && !params.connectionId) { + recordMobileClipboardImagePath(clientId, path) + } + return path + } }), defineMethod({ name: 'clipboard.startImageUpload', params: StartImageUpload, - handler: (params) => { + handler: (params, ctx) => { pruneExpiredUploads() if (clipboardImageUploads.size >= CLIPBOARD_IMAGE_UPLOAD_MAX_CONCURRENT) { throw new Error('Too many clipboard image uploads are in progress') @@ -148,6 +181,7 @@ export const CLIPBOARD_METHODS: RpcMethod[] = [ clipboardImageUploads.set(uploadId, { expectedBase64Length: params.expectedBase64Length, connectionId: params.connectionId, + mobileClientId: mobileClientId(ctx), chunks: [], receivedBase64Length: 0, expiresAt: Date.now() + CLIPBOARD_IMAGE_UPLOAD_TTL_MS, @@ -159,8 +193,9 @@ export const CLIPBOARD_METHODS: RpcMethod[] = [ defineMethod({ name: 'clipboard.appendImageUploadChunk', params: AppendImageUploadChunk, - handler: (params) => { + handler: (params, ctx) => { const upload = getUpload(params.uploadId) + assertMobileUploadOwner(upload, ctx) if (params.offset !== upload.receivedBase64Length) { throw new Error('Clipboard image chunk offset is out of order') } @@ -177,17 +212,25 @@ export const CLIPBOARD_METHODS: RpcMethod[] = [ defineMethod({ name: 'clipboard.commitImageUpload', params: CommitImageUpload, - handler: async (params) => { + handler: async (params, ctx) => { const upload = getUpload(params.uploadId) + const clientId = assertMobileUploadOwner(upload, ctx) try { if (upload.receivedBase64Length !== upload.expectedBase64Length) { throw new Error('Clipboard image upload is incomplete') } const contentBase64 = upload.chunks.join('') assertValidBase64Content(contentBase64) - return await saveClipboardImageBufferAsTempFile(Buffer.from(contentBase64, 'base64'), { - connectionId: upload.connectionId - }) + const path = await saveClipboardImageBufferAsTempFile( + Buffer.from(contentBase64, 'base64'), + { + connectionId: upload.connectionId + } + ) + if (clientId && !upload.connectionId) { + recordMobileClipboardImagePath(clientId, path) + } + return path } finally { // Why: failed SSH or filesystem commits must not leave bounded upload // memory pinned until TTL cleanup. @@ -198,7 +241,12 @@ export const CLIPBOARD_METHODS: RpcMethod[] = [ defineMethod({ name: 'clipboard.abortImageUpload', params: AbortImageUpload, - handler: (params) => { + handler: (params, ctx) => { + pruneExpiredUploads() + const upload = clipboardImageUploads.get(params.uploadId) + if (upload) { + assertMobileUploadOwner(upload, ctx) + } deleteUpload(params.uploadId) return { aborted: true } } diff --git a/src/main/runtime/rpc/methods/mobile-markdown-tab-methods.ts b/src/main/runtime/rpc/methods/mobile-markdown-tab-methods.ts new file mode 100644 index 00000000000..dcba8b7b64e --- /dev/null +++ b/src/main/runtime/rpc/methods/mobile-markdown-tab-methods.ts @@ -0,0 +1,22 @@ +import { defineMethod, type RpcAnyMethod } from '../core' +import { ActivateTab, SaveMarkdownTab } from './session-tabs-schemas' + +export const MOBILE_MARKDOWN_TAB_METHODS: RpcAnyMethod[] = [ + defineMethod({ + name: 'markdown.readTab', + params: ActivateTab, + handler: async (params, { runtime }) => + runtime.readMobileMarkdownTab(params.worktree, params.tabId) + }), + defineMethod({ + name: 'markdown.saveTab', + params: SaveMarkdownTab, + handler: async (params, { runtime }) => + runtime.saveMobileMarkdownTab( + params.worktree, + params.tabId, + params.baseVersion, + params.content + ) + }) +] diff --git a/src/main/runtime/rpc/methods/session-tab-agent-capability-mutations.test.ts b/src/main/runtime/rpc/methods/session-tab-agent-capability-mutations.test.ts index 226277f6ebd..a2a93533b6f 100644 --- a/src/main/runtime/rpc/methods/session-tab-agent-capability-mutations.test.ts +++ b/src/main/runtime/rpc/methods/session-tab-agent-capability-mutations.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { + CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, type RuntimeCapability } from '../../../../shared/protocol-version' @@ -67,13 +68,24 @@ describe('session tab structured capability mutations', () => { expect(fixture.calls[method.runtimeMethod]).toHaveBeenCalledOnce() }) - it(`rejects ${method.name} for a legacy Claude row`, async () => { + it(`rejects ${method.name} on a Claude row the client never negotiated`, async () => { const fixture = createFixture([STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]) const response = await fixture.dispatch(method.name, method.params('claude-session')) expect(response.ok).toBe(false) expect(fixture.calls[method.runtimeMethod]).not.toHaveBeenCalled() }) + + it(`allows ${method.name} for a client that negotiated Claude rows`, async () => { + const fixture = createFixture([ + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY + ]) + const response = await fixture.dispatch(method.name, method.params('claude-session')) + + expect(response.ok).toBe(true) + expect(fixture.calls[method.runtimeMethod]).toHaveBeenCalledOnce() + }) } it.each(['session.tabs.close', 'session.tabs.closeLifecycle'] as const)( diff --git a/src/main/runtime/rpc/methods/session-tab-agent-status-projection.test.ts b/src/main/runtime/rpc/methods/session-tab-agent-status-projection.test.ts index 4a61a99bc20..7128f756d6e 100644 --- a/src/main/runtime/rpc/methods/session-tab-agent-status-projection.test.ts +++ b/src/main/runtime/rpc/methods/session-tab-agent-status-projection.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, + CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import type { RuntimeMobileSessionTabsSnapshot } from '../../../../shared/runtime-types' @@ -119,44 +120,105 @@ describe('projectSessionTabAgentStatus', () => { expect(capable).toBe(snapshot) }) - it('withholds legacy Claude rows from paired structured clients', () => { - const snapshot = { - ...makeSnapshot(false), - tabs: [ - { - type: 'agent-session', - id: 'agent-session:codex', - title: 'Codex Chat', - sessionId: 'codex', - agent: 'codex', - isActive: true - }, - { - type: 'agent-session', - id: 'agent-session:claude', - title: 'Claude Chat', - sessionId: 'claude', - agent: 'claude', - isActive: false - } - ], - activeTabId: 'agent-session:codex', - activeTabType: 'agent-session' + const claudeSnapshot = { + ...makeSnapshot(false), + tabs: [ + { + type: 'agent-session', + id: 'agent-session:codex', + title: 'Codex Chat', + sessionId: 'codex', + agent: 'codex', + isActive: true + }, + { + type: 'agent-session', + id: 'agent-session:claude', + title: 'Claude Chat', + sessionId: 'claude', + agent: 'claude', + isActive: false + } + ], + activeGroupId: 'group-a', + activeTabId: 'agent-session:codex', + activeTabType: 'agent-session', + tabGroups: [ + { id: 'group-a', activeTabId: 'agent-session:codex', tabOrder: ['agent-session:codex'] }, + { id: 'group-b', activeTabId: 'agent-session:claude', tabOrder: ['agent-session:claude'] } + ], + tabGroupLayout: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', groupId: 'group-a' }, + second: { type: 'leaf', groupId: 'group-b' } + } + } as unknown as RuntimeMobileSessionTabsSnapshot + + const structuredMobile = [ + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY + ] + + it.each([ + ['mobile', 'mobile' as const, [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]], + ['runtime', 'runtime' as const, [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]] + ])( + 'withholds Claude rows from a paired %s client that never negotiated them', + (_name, clientKind, capabilities) => { + const projected = projectSessionTabAgentStatus(claudeSnapshot, clientKind, capabilities, true) + + expect(projected.tabs.map((tab) => tab.id)).toEqual(['agent-session:codex']) + // A row pruned from `tabs` but left in the layout is its own dead tab. + expect(projected.tabGroups?.map((group) => group.id)).toEqual(['group-a']) + expect(projected.tabGroupLayout).toEqual({ type: 'leaf', groupId: 'group-a' }) + expect(projected.activeGroupId).toBe('group-a') + expect(projected.activeTabId).toBe('agent-session:codex') + expect(projected.activeTabType).toBe('agent-session') + } + ) + + it.each([ + ['mobile', 'mobile' as const, structuredMobile], + [ + 'runtime', + 'runtime' as const, + [ + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY + ] + ] + ])( + 'publishes Claude rows to a paired %s client that negotiated them', + (_name, clientKind, capabilities) => { + const projected = projectSessionTabAgentStatus(claudeSnapshot, clientKind, capabilities, true) + + expect(projected).toBe(claudeSnapshot) + expect(projected.tabGroupLayout).toEqual(claudeSnapshot.tabGroupLayout) + } + ) + + it('keeps Claude rows on the local renderer, which negotiates nothing', () => { + expect(projectSessionTabAgentStatus(claudeSnapshot, undefined, undefined)).toBe(claudeSnapshot) + expect(projectSessionTabAgentStatus(claudeSnapshot, undefined, [])).toBe(claudeSnapshot) + }) + + it('leaves Codex rows untouched whether or not the Claude capability is present', () => { + const codexOnly = { + ...claudeSnapshot, + tabs: claudeSnapshot.tabs.filter((tab) => tab.id !== 'agent-session:claude'), + tabGroups: claudeSnapshot.tabGroups?.filter((group) => group.id !== 'group-b'), + tabGroupLayout: { type: 'leaf', groupId: 'group-a' } } as unknown as RuntimeMobileSessionTabsSnapshot - expect( - projectSessionTabAgentStatus(snapshot, 'runtime', [ - STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY - ]).tabs.map((tab) => tab.id) - ).toEqual(['agent-session:codex']) - expect( - projectSessionTabAgentStatus( - snapshot, - 'mobile', - [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY], - true - ).tabs.map((tab) => tab.id) - ).toEqual(['agent-session:codex']) + for (const capabilities of [[STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY], structuredMobile]) { + for (const clientKind of ['mobile', 'runtime'] as const) { + expect(projectSessionTabAgentStatus(codexOnly, clientKind, capabilities, true)).toBe( + codexOnly + ) + } + } + expect(projectSessionTabAgentStatus(codexOnly, undefined, undefined)).toBe(codexOnly) }) it('withholds session boundaries from legacy paired clients', () => { diff --git a/src/main/runtime/rpc/methods/session-tab-agent-status-projection.ts b/src/main/runtime/rpc/methods/session-tab-agent-status-projection.ts index 375b3b499d5..0e0d9c716a5 100644 --- a/src/main/runtime/rpc/methods/session-tab-agent-status-projection.ts +++ b/src/main/runtime/rpc/methods/session-tab-agent-status-projection.ts @@ -1,5 +1,6 @@ import { AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, + CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, type RuntimeCapability } from '../../../../shared/protocol-version' import type { @@ -24,7 +25,14 @@ export function projectSessionTabAgentStatus true) - if (structuredVisible && clientKind !== undefined) { + // Why: a paired client renders only codex structured tabs unless it says otherwise + // (mobile's resolveMobileNativeChat returns null for every other agent), so an + // ungated row would list and select into a pane that shows neither chat nor terminal. + if ( + structuredVisible && + clientKind !== undefined && + !clientCapabilities?.includes(CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) + ) { projected = projectAgentSessionTabsOut(projected, (tab) => tab.agent !== 'codex') } // Why: only paired runtimes have legacy `done` completion side effects; mobile must keep its row without changing the exact v2 auth shape. diff --git a/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts b/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts index 6a9372ed2c1..4f7c1b20cc3 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts @@ -98,7 +98,7 @@ export const CreateIntentParams = z .object({ envelope: MutationEnvelope, worktree: Identifier('Invalid worktree selector'), - agent: z.literal('codex') + agent: z.enum(['claude', 'codex']) }) .strict() @@ -107,7 +107,7 @@ export const CreateParams = z.union([AttachParams, CreateIntentParams]) export const CreateSupportParams = z .object({ worktree: Identifier('Invalid worktree selector'), - agent: z.literal('codex') + agent: z.enum(['claude', 'codex']) }) .strict() @@ -170,6 +170,15 @@ export const SetOptionParams = z }) .strict() +export const HandoffParams = z + .object({ + envelope: MutationEnvelope, + direction: z.enum(['to-tui', 'to-native']), + mode: z.enum(['now', 'after-turn', 'stop-turn']), + action: z.enum(['start', 'cancel-queued', 'retry', 'recover']).optional() + }) + .strict() + export const OptionsParams = z.object({ sessionId: SessionId }).strict() /** One surface's claim on one session. The id names the surface, not the client: two chat views diff --git a/src/main/runtime/rpc/methods/structured-agent-session.test.ts b/src/main/runtime/rpc/methods/structured-agent-session.test.ts index b65e6eff825..155d0aa6768 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.test.ts @@ -99,6 +99,22 @@ function hostStub(): StructuredAgentSessionHost { setSessionTabVisibility: vi.fn(async () => undefined), respondToPrompt: vi.fn(async () => ({ ok: true, replayed: false })), setOption: vi.fn(async () => ({ ok: true, replayed: false })), + requestHandoff: vi.fn(async () => ({ + ok: true, + replayed: false, + fence: 1, + cursor: { epoch: 'epoch-a', sequence: 0 }, + value: { + status: { + owner: 'native', + direction: null, + phase: 'idle', + stage: null, + operationId: null + } + } + })), + supportsCreate: vi.fn(() => true), handoffStatus: vi.fn(async () => ({ owner: 'native' })), readOptions: vi.fn(async () => ({ models: [{ id: 'gpt-live', label: 'GPT Live', isDefault: true, efforts: [] }], @@ -122,9 +138,12 @@ function dispatcher(runtimeOverrides: Record = {}): RpcDispatch workspaceId: 'workspace-1', workspaceKind: 'git-worktree' }, - provider: 'codex', - agent: 'codex', - accountHome: { variable: 'CODEX_HOME', path: '/host/.codex' }, + provider: params.agent, + agent: params.agent, + accountHome: { + variable: params.agent === 'claude' ? 'CLAUDE_CONFIG_DIR' : 'CODEX_HOME', + path: params.agent === 'claude' ? '/host/.claude' : '/host/.codex' + }, runtimeKind: 'native' })), publishStructuredAgentSessionTab: vi.fn() @@ -221,7 +240,7 @@ describe('capability gating', () => { } // Bump deliberately: the whole agentSession.* surface is behind the structured capability, // so an additive method is invisible to old clients and needs no protocol bump. - expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(16) + expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(17) }) it('hides the surface from a declared client that did not advertise it', async () => { @@ -329,6 +348,49 @@ describe('method routing', () => { ) }) + it('routes Claude create support and create through the provider-aware runtime', async () => { + const worktree = 'id:workspace-1' + const support = await call( + 'agentSession.createSupport', + { worktree, agent: 'claude' }, + STRUCTURED_CLIENT + ) + expect(support).toMatchObject({ ok: true, result: { supported: true } }) + expect(runtimeCalls.getStructuredAgentSessionCreateSupport).toHaveBeenCalledWith( + worktree, + 'claude' + ) + + const params = { + envelope: envelope({ + expectedRuntimeFence: null, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.create', + sessionId: SESSION, + fields: { worktree, agent: 'claude' } + }) + }), + worktree, + agent: 'claude' + } + const created = await call('agentSession.create', params, STRUCTURED_CLIENT) + expect(created).toMatchObject({ ok: true, result: { ok: true } }) + expect(runtimeCalls.resolveStructuredAgentSessionCreateIntent).toHaveBeenCalledWith(params) + expect(hostCalls.attach).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/host/.claude' } + }) + ) + expect(runtimeCalls.publishStructuredAgentSessionTab).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: SESSION, + activate: true, + agent: 'claude' + }) + ) + }) + it('reports an unknown create outcome when attach commits before tab publication fails', async () => { const worktree = 'id:workspace-1' const params = { @@ -371,6 +433,25 @@ describe('method routing', () => { expect(ensured).toMatchObject({ ok: true }) }) + /** A client-supplied location skips the worktree-resolving support check, so both attach-shaped + * entries must ask the executing host directly or a host that cannot fence a provider child + * would create one anyway. */ + it.each(['agentSession.create', 'agentSession.ensure'])( + 'refuses %s for a client-supplied location the executing host does not support', + async (method) => { + hostCalls.supportsCreate.mockReturnValue(false) + + const refused = await call(method, attachParams()) + + expect(refused).toMatchObject({ + ok: false, + error: { message: expect.stringContaining('structured_agent_session_unsupported') } + }) + expect(hostCalls.attach).not.toHaveBeenCalled() + expect(hostCalls.supportsCreate).toHaveBeenCalledWith(attachParams().location, 'codex') + } + ) + it('tags the prompt kind from the method name, not from the client', async () => { const params = { envelope: envelope(), @@ -386,7 +467,7 @@ describe('method routing', () => { ]) }) - it('does not register the structured handoff mutation', async () => { + it('routes the structured handoff mutation through the host', async () => { const response = await call('agentSession.requestHandoff', { envelope: envelope(), direction: 'to-tui', @@ -394,7 +475,11 @@ describe('method routing', () => { action: 'start' }) - expect(response).toMatchObject({ ok: false, error: { code: 'method_not_found' } }) + expect(response).toMatchObject({ ok: true }) + expect(hostCalls.requestHandoff).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ direction: 'to-tui', mode: 'now', action: 'start' }) + ) }) }) @@ -434,25 +519,6 @@ describe('parameter validation', () => { ) }) - it('rejects Claude structured create shapes', async () => { - await rejects('agentSession.createSupport', { - worktree: 'id:workspace-1', - agent: 'claude' - }) - const fields = { worktree: 'id:workspace-1', agent: 'claude' } - await rejects('agentSession.create', { - envelope: envelope({ - expectedRuntimeFence: null, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.create', - sessionId: SESSION, - fields - }) - }), - ...fields - }) - }) - it('requires a sha256 fingerprint and a positive fence', async () => { await rejects( 'agentSession.send', diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index ffd23499a3e..3b18f6b0ef1 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -10,6 +10,7 @@ import { agentSessionFingerprintConflict, computeAgentSessionPayloadFingerprint } from '../../../../shared/agent-session-mutation-envelope' +import type { z } from 'zod' import { defineMethod, defineStreamingMethod, type RpcAnyMethod, type RpcContext } from '../core' import { ensureStructuredHostInstalled as ensureHostInstalled, @@ -18,6 +19,7 @@ import { structuredCallerFor as callerFor, supportsStructuredSessions } from './structured-agent-session-gate' +import type { AgentSessionAttachParams } from '../../../native-chat/agent-session-wire/structured-agent-session-attach' import { STRUCTURED_AGENT_SESSION_HOLD_METHODS } from './structured-agent-session-hold' import { AttachParams, @@ -25,6 +27,7 @@ import { CreateParams, CreateSupportParams, HistoryParams, + HandoffParams, HandoffStatusParams, OptionsParams, RespondParams, @@ -43,6 +46,29 @@ function subscriptionIdFor(ctx: RpcContext, sessionId: string): string { return ctx.requestId ? `${base}:${ctx.requestId}` : base } +/** + * The attach-shaped entries take the location from the client instead of resolving it from a + * worktree, so they never reach the worktree-resolving create-support check. Ask the executing + * host the same question directly: the answer includes host-measured facts the client cannot see + * or forge, such as whether this machine can read a provider child's process start time. + */ +async function attachClientSuppliedLocation( + params: z.infer, + ctx: RpcContext +): Promise { + await ensureHostInstalled(ctx) + const host = requireHost(ctx) + if (!host.supportsCreate(params.location, params.agent)) { + throw new Error('structured_agent_session_unsupported') + } + const { agent: _attachAgent, provider: _attachProvider, ...attachWithoutAgent } = params + return host.attach(callerFor(ctx), { + ...attachWithoutAgent, + provider: params.provider as 'claude' | 'codex', + agent: params.agent as 'claude' | 'codex' + } as AgentSessionAttachParams) +} + export const STRUCTURED_AGENT_SESSION_METHODS: RpcAnyMethod[] = [ defineMethod({ name: 'agentSession.createSupport', @@ -86,16 +112,20 @@ export const STRUCTURED_AGENT_SESSION_METHODS: RpcAnyMethod[] = [ } }) await ensureHostInstalled(ctx) - const result = await requireHost(ctx).attach(callerFor(ctx), { - ...resolved, + const { agent: _resolvedAgent, provider: _resolvedProvider, ...resolvedAttach } = resolved + const attachParams: AgentSessionAttachParams = { + ...resolvedAttach, + provider: resolved.provider as 'claude' | 'codex', + agent: resolved.agent as 'claude' | 'codex', envelope: { ...params.envelope, payloadFingerprint: hostFingerprint } - }) - if (result.ok && resolved.agent === 'codex') { + } + const result = await requireHost(ctx).attach(callerFor(ctx), attachParams) + if (result.ok) { try { await ctx.runtime.publishStructuredAgentSessionTab({ workspaceId: resolved.location.workspaceId, sessionId: result.value.sessionId, - agent: 'codex', + agent: resolved.agent as 'claude' | 'codex', activate: true }) } catch (error) { @@ -104,24 +134,20 @@ export const STRUCTURED_AGENT_SESSION_METHODS: RpcAnyMethod[] = [ ok: false, refusal: { code: 'agent_session_operation_unknown', - message: 'The Codex chat may have been created, but its tab could not be confirmed.' + message: 'The chat may have been created, but its tab could not be confirmed.' } } } } return result } - await ensureHostInstalled(ctx) - return requireHost(ctx).attach(callerFor(ctx), params) + return attachClientSuppliedLocation(params, ctx) } }), defineMethod({ name: 'agentSession.ensure', params: AttachParams, - handler: async (params, ctx) => { - await ensureHostInstalled(ctx) - return requireHost(ctx).attach(callerFor(ctx), params) - } + handler: async (params, ctx) => attachClientSuppliedLocation(params, ctx) }), defineMethod({ name: 'agentSession.send', @@ -165,6 +191,11 @@ export const STRUCTURED_AGENT_SESSION_METHODS: RpcAnyMethod[] = [ params: SetOptionParams, handler: async (params, ctx) => requireHost(ctx).setOption(callerFor(ctx), params) }), + defineMethod({ + name: 'agentSession.requestHandoff', + params: HandoffParams, + handler: async (params, ctx) => requireHost(ctx).requestHandoff(callerFor(ctx), params) + }), defineMethod({ name: 'agentSession.handoffStatus', params: HandoffStatusParams, diff --git a/src/main/runtime/rpc/mobile-clipboard-image-provenance.test.ts b/src/main/runtime/rpc/mobile-clipboard-image-provenance.test.ts new file mode 100644 index 00000000000..aa1709fcecb --- /dev/null +++ b/src/main/runtime/rpc/mobile-clipboard-image-provenance.test.ts @@ -0,0 +1,53 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + hasMobileClipboardImagePath, + MOBILE_CLIPBOARD_IMAGE_PROVENANCE_MAX_ENTRIES, + MOBILE_CLIPBOARD_IMAGE_PROVENANCE_TTL_MS, + mobileClipboardImageProvenanceSizeForTest, + recordMobileClipboardImagePath, + resetMobileClipboardImageProvenanceForTest +} from './mobile-clipboard-image-provenance' + +describe('mobile clipboard image provenance', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-01-01T00:00:00Z')) + resetMobileClipboardImageProvenanceForTest() + }) + + afterEach(() => { + resetMobileClipboardImageProvenanceForTest() + vi.useRealTimers() + }) + + it('expires records without consuming them on repeated checks', () => { + recordMobileClipboardImagePath('device-a', '/tmp/image.png') + + expect(hasMobileClipboardImagePath('device-a', '/tmp/image.png')).toBe(true) + expect(hasMobileClipboardImagePath('device-a', '/tmp/image.png')).toBe(true) + vi.advanceTimersByTime(MOBILE_CLIPBOARD_IMAGE_PROVENANCE_TTL_MS + 1) + expect(hasMobileClipboardImagePath('device-a', '/tmp/image.png')).toBe(false) + expect(mobileClipboardImageProvenanceSizeForTest()).toBe(0) + }) + + it('evicts the oldest record at the global bound and supports test cleanup', () => { + for (let index = 0; index <= MOBILE_CLIPBOARD_IMAGE_PROVENANCE_MAX_ENTRIES; index++) { + recordMobileClipboardImagePath(`device-${index}`, `/tmp/image-${index}.png`) + vi.advanceTimersByTime(1) + } + + expect(mobileClipboardImageProvenanceSizeForTest()).toBe( + MOBILE_CLIPBOARD_IMAGE_PROVENANCE_MAX_ENTRIES + ) + expect(hasMobileClipboardImagePath('device-0', '/tmp/image-0.png')).toBe(false) + expect( + hasMobileClipboardImagePath( + `device-${MOBILE_CLIPBOARD_IMAGE_PROVENANCE_MAX_ENTRIES}`, + `/tmp/image-${MOBILE_CLIPBOARD_IMAGE_PROVENANCE_MAX_ENTRIES}.png` + ) + ).toBe(true) + + resetMobileClipboardImageProvenanceForTest() + expect(mobileClipboardImageProvenanceSizeForTest()).toBe(0) + }) +}) diff --git a/src/main/runtime/rpc/mobile-clipboard-image-provenance.ts b/src/main/runtime/rpc/mobile-clipboard-image-provenance.ts new file mode 100644 index 00000000000..117455593c9 --- /dev/null +++ b/src/main/runtime/rpc/mobile-clipboard-image-provenance.ts @@ -0,0 +1,90 @@ +export const MOBILE_CLIPBOARD_IMAGE_PROVENANCE_TTL_MS = 60 * 60 * 1000 +export const MOBILE_CLIPBOARD_IMAGE_PROVENANCE_MAX_ENTRIES = 256 +const MOBILE_CLIPBOARD_IMAGE_PROVENANCE_MAX_PER_CLIENT = 64 + +const pathsByClient = new Map>() +let entryCount = 0 + +function deletePath(clientId: string, path: string): void { + const paths = pathsByClient.get(clientId) + if (!paths?.delete(path)) { + return + } + entryCount-- + if (paths.size === 0) { + pathsByClient.delete(clientId) + } +} + +function pruneExpired(now: number): void { + for (const [clientId, paths] of pathsByClient) { + for (const [path, expiresAt] of paths) { + if (expiresAt <= now) { + deletePath(clientId, path) + } + } + } +} + +function deleteOldestEntry(): void { + let oldest: { clientId: string; path: string; expiresAt: number } | null = null + for (const [clientId, paths] of pathsByClient) { + for (const [path, expiresAt] of paths) { + if (!oldest || expiresAt < oldest.expiresAt) { + oldest = { clientId, path, expiresAt } + } + } + } + if (oldest) { + deletePath(oldest.clientId, oldest.path) + } +} + +export function recordMobileClipboardImagePath(clientId: string | undefined, path: string): void { + const owner = clientId?.trim() + if (!owner) { + return + } + const now = Date.now() + pruneExpired(now) + let paths = pathsByClient.get(owner) + if (!paths) { + paths = new Map() + pathsByClient.set(owner, paths) + } + if (paths.delete(path)) { + entryCount-- + } + while (paths.size >= MOBILE_CLIPBOARD_IMAGE_PROVENANCE_MAX_PER_CLIENT) { + const oldestPath = paths.keys().next().value + if (typeof oldestPath !== 'string') { + break + } + deletePath(owner, oldestPath) + } + while (entryCount >= MOBILE_CLIPBOARD_IMAGE_PROVENANCE_MAX_ENTRIES) { + deleteOldestEntry() + } + paths = pathsByClient.get(owner) ?? new Map() + pathsByClient.set(owner, paths) + paths.set(path, now + MOBILE_CLIPBOARD_IMAGE_PROVENANCE_TTL_MS) + entryCount++ +} + +export function hasMobileClipboardImagePath(clientId: string | undefined, path: string): boolean { + const owner = clientId?.trim() + if (!owner) { + return false + } + pruneExpired(Date.now()) + return pathsByClient.get(owner)?.has(path) ?? false +} + +export function resetMobileClipboardImageProvenanceForTest(): void { + pathsByClient.clear() + entryCount = 0 +} + +export function mobileClipboardImageProvenanceSizeForTest(): number { + return entryCount +} diff --git a/src/main/runtime/rpc/mobile-e2ee-v2-client-capabilities.ts b/src/main/runtime/rpc/mobile-e2ee-v2-client-capabilities.ts new file mode 100644 index 00000000000..3834a417ddb --- /dev/null +++ b/src/main/runtime/rpc/mobile-e2ee-v2-client-capabilities.ts @@ -0,0 +1,21 @@ +import type { RuntimeCapability } from '../../../shared/protocol-version' +import { parseRemoteRuntimeJsonText } from '../../../shared/remote-runtime-request-frames' +import { parseRuntimeClientCapabilities } from './runtime-client-capabilities' + +export function parseMobileE2EEV2ClientCapabilities( + plaintext: string +): readonly RuntimeCapability[] | null { + try { + const message = parseRemoteRuntimeJsonText(plaintext) as Record + if ( + Object.keys(message).sort().join(',') !== 'clientCapabilities,type,v' || + message.type !== 'e2ee_client_capabilities' || + message.v !== 1 + ) { + return null + } + return parseRuntimeClientCapabilities(message.clientCapabilities) + } catch { + return null + } +} diff --git a/src/main/runtime/rpc/mobile-socket-wiring.test.ts b/src/main/runtime/rpc/mobile-socket-wiring.test.ts index 505cf63feb6..14d5beb8cd0 100644 --- a/src/main/runtime/rpc/mobile-socket-wiring.test.ts +++ b/src/main/runtime/rpc/mobile-socket-wiring.test.ts @@ -348,4 +348,86 @@ describe('MobileSocketWiring', () => { expect(transport.setClientId).not.toHaveBeenCalled() expect(ws.close).toHaveBeenCalledWith(4001, 'Unauthorized') }) + + it('keeps post-auth v2 capability-shaped frames on the RPC path', () => { + const desktop = nacl.box.keyPair.fromSecretKey(new Uint8Array(32).fill(1)) + const phone = nacl.box.keyPair.fromSecretKey(new Uint8Array(32).fill(2)) + const ws = new FakeSocket() + const transport = new FakeTransport() + const onText = vi.fn() + const metadata: MobileSocketTransportMetadata = { + transport: 'relay', + relayHostId: 'AbCdEf0123_-xyZ9', + relayDeviceId: 'device-1', + basisConnId: 'connection-1', + credentialKind: 'resume' + } + const wiring = new MobileSocketWiring({ + deviceRegistry: registryFor('device-1', 'valid-token'), + e2eeKeypair: { + publicKey: desktop.publicKey, + secretKey: desktop.secretKey, + publicKeyB64: Buffer.from(desktop.publicKey).toString('base64') + }, + onText, + onBinary: vi.fn(), + onClose: vi.fn() + }) + wiring.attachTransport(transport, () => metadata) + const hello: MobileE2EEV2Hello = { + type: 'e2ee_hello', + v: 2, + clientPublicKeyB64: Buffer.from(phone.publicKey).toString('base64'), + clientNonceB64: Buffer.from(new Uint8Array(32).fill(3)).toString('base64'), + capabilities: { framing: [2], payloadKinds: ['text', 'binary'] }, + context: { + protocol: 'orca-mobile-e2ee', + initiator: 'mobile', + responder: 'desktop', + transport: 'relay', + relayHostId: metadata.relayHostId + } + } + transport.receive(ws, JSON.stringify(hello)) + const ready = JSON.parse(ws.sent[0]!.toString()) as MobileE2EEV2Ready + const handshake = validateMobileE2EEV2Handshake(hello, ready)! + const schedule = deriveMobileE2EEV2KeySchedule({ + sharedSecret: deriveSharedKey(phone.secretKey, desktop.publicKey), + transcript: encodeMobileE2EEV2Transcript(handshake), + clientNonce: handshake.clientNonce, + desktopNonce: handshake.desktopNonce + }) + const send = (value: unknown, counter: bigint): void => { + const frame = sealMobileE2EEV2Frame({ + payload: new TextEncoder().encode(JSON.stringify(value)), + key: schedule.mobileToDesktopKey, + sessionId: schedule.sessionId, + direction: 'mobile-to-desktop', + payloadKind: 'text', + counter + }) + transport.receive(ws, Buffer.from(frame).toString('base64')) + } + send( + { + type: 'e2ee_auth', + v: 2, + transcriptHashB64: Buffer.from(schedule.transcriptHash).toString('base64'), + deviceToken: 'valid-token' + }, + 0n + ) + const capabilityFrame = { + type: 'e2ee_client_capabilities', + v: 1, + clientCapabilities: ['agent-session.structured.v1'] + } + send(capabilityFrame, 1n) + send({ id: 'rpc-1', method: 'agentSession.history', params: {} }, 2n) + + expect(onText).toHaveBeenCalledTimes(2) + expect(onText.mock.calls[0]?.[0].clientCapabilities).toEqual([]) + expect(JSON.parse(onText.mock.calls[0]?.[1] ?? '')).toEqual(capabilityFrame) + expect(onText.mock.calls[1]?.[0].clientCapabilities).toEqual([]) + }) }) diff --git a/src/main/runtime/rpc/mobile-socket-wiring.ts b/src/main/runtime/rpc/mobile-socket-wiring.ts index 43004be4582..384f403e9de 100644 --- a/src/main/runtime/rpc/mobile-socket-wiring.ts +++ b/src/main/runtime/rpc/mobile-socket-wiring.ts @@ -165,7 +165,9 @@ export class MobileSocketWiring { ws, connectionId, device, - clientCapabilities: channel.clientCapabilities, + get clientCapabilities() { + return channel.clientCapabilities + }, transport: metadata } this.authenticatedSockets.set(ws, socket) diff --git a/src/main/runtime/structured-agent-session-integration-replay.test.ts b/src/main/runtime/structured-agent-session-integration-replay.test.ts index 4edec30499b..e5baa032341 100644 --- a/src/main/runtime/structured-agent-session-integration-replay.test.ts +++ b/src/main/runtime/structured-agent-session-integration-replay.test.ts @@ -259,6 +259,7 @@ beforeEach(async () => { claimKeyId: 'key-1', resolveWorkspacePath: async (workspaceId) => `/repos/${workspaceId}`, resolveCodexCommand: () => '/usr/local/bin/codex', + resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), resolveEnvironment: async () => { bootEnvironmentReads += 1 return { @@ -320,6 +321,7 @@ describe('a structured codex session over agentSession.*', () => { claimKeyId: 'key-1', resolveWorkspacePath: async (workspaceId) => `/repos/${workspaceId}`, resolveCodexCommand: () => '/usr/local/bin/codex', + resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), openCodexConnection: codex.openConnection, readProcessStartTime: async () => 1_700_000_000_000 }) diff --git a/src/main/runtime/structured-agent-session-integration.test.ts b/src/main/runtime/structured-agent-session-integration.test.ts index aa5f819e1fb..2982a6530b2 100644 --- a/src/main/runtime/structured-agent-session-integration.test.ts +++ b/src/main/runtime/structured-agent-session-integration.test.ts @@ -307,6 +307,7 @@ beforeEach(async () => { claimKeyId: 'key-1', resolveWorkspacePath: async (workspaceId) => `/repos/${workspaceId}`, resolveCodexCommand: () => '/usr/local/bin/codex', + resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), resolveEnvironment: async () => { bootEnvironmentReads += 1 return { diff --git a/src/main/runtime/structured-agent-session-owner-probe.ts b/src/main/runtime/structured-agent-session-owner-probe.ts new file mode 100644 index 00000000000..47f92a08ca6 --- /dev/null +++ b/src/main/runtime/structured-agent-session-owner-probe.ts @@ -0,0 +1,108 @@ +import type { AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { + probeAgentSessionProcessIdentities, + probeAgentSessionProcessIdentity, + probeAgentSessionReservation +} from './agent-session-process-identity-probe' +import { findAgentSessionSpawnTokenProcesses } from './agent-session-spawn-token-process-scan' +import { readEchoedAgentSessionSpawnToken } from './agent-session-spawn-token-readback' + +/** + * The lease's only source of truth about a previous owner. Everything it cannot + * answer PID-reuse-safely reports `indeterminate`. An exact owner stays fenced in `recovering`; + * an ownerless, unattributable reservation enters `manual-recovery`. + */ +export function createStructuredAgentSessionOwnerProbe( + hostId: string, + probe = probeAgentSessionProcessIdentity, + findSpawnTokenProcesses = findAgentSessionSpawnTokenProcesses +): (record: AgentSessionRecord) => Promise { + return async (record) => { + const owner = record.lease.ownerProcess + if (!owner) { + if (record.lease.processlessAt !== undefined && record.lease.processlessAt !== null) { + return { outcome: 'reservation-unused' } + } + const spawnToken = record.lease.reservedSpawnToken + if (spawnToken === null) { + if (record.lease.claimStatus === 'reserved') { + return { + outcome: 'indeterminate', + reason: 'reservation recorded no spawn token to scan for' + } + } + // The token is minted before the child and is the only thing a child could be carrying. + // No owner and no token means nothing on any host can be holding this lease — answering + // `indeterminate` here is what latches an already-free record into recovery forever. + return { outcome: 'reservation-unused' } + } + // Freeing a reservation needs positive proof that nothing spawned under its token. The scan + // answers null where the platform cannot read another process's environment. + return probeAgentSessionReservation({ + spawnToken, + findProcessesWithSpawnToken: (token) => findSpawnTokenProcesses(token), + hasProviderActivitySinceReservation: async () => + agentSessionReservationTouchedProvider(record) + }) + } + if (owner.hostId !== hostId) { + // Checking a remote host's pid against this machine's process table is + // exactly how a live owner gets declared dead. + return { + outcome: 'indeterminate', + reason: `owner runs on ${owner.hostId}, which this host cannot probe` + } + } + // The env read-back answers on hosts that expose it and null elsewhere, giving the + // probe a PID-reuse-safe element even when no start time was recorded. + return probe({ + identity: owner, + deps: { readEchoedSpawnToken: readEchoedAgentSessionSpawnToken } + }) + } +} + +export function createStructuredAgentSessionOwnerProbes( + hostId: string, + probeMany: typeof probeAgentSessionProcessIdentities = probeAgentSessionProcessIdentities, + probeOne = createStructuredAgentSessionOwnerProbe(hostId) +): (records: readonly AgentSessionRecord[]) => Promise> { + return async (records) => { + const results = new Map() + const localOwners: { + record: AgentSessionRecord + owner: NonNullable + }[] = [] + for (const record of records) { + const owner = record.lease.ownerProcess + if (owner?.hostId === hostId) { + localOwners.push({ record, owner }) + } else { + results.set(record.sessionId, await probeOne(record)) + } + } + const probes = await probeMany({ + identities: localOwners.map(({ owner }) => owner), + deps: { readEchoedSpawnToken: readEchoedAgentSessionSpawnToken } + }) + for (const [index, { record }] of localOwners.entries()) { + results.set( + record.sessionId, + probes[index] ?? { outcome: 'indeterminate', reason: 'owner probe returned no result' } + ) + } + return results + } +} + +/** + * The only provider-side trace a reservation can leave in its own record: a handle link minted at + * this fence. `proveAgentSessionOwner` refuses to append one before an identity is committed, so a + * link at the reservation's fence means a child got far enough to resume the provider thread. It + * cannot see activity the child produced without proving a handle, which is why it is paired with + * the token scan rather than trusted alone. + */ +function agentSessionReservationTouchedProvider(record: AgentSessionRecord): boolean { + return record.providerHandleChain.at(-1)?.mintedAtFence === record.lease.runtimeFence +} diff --git a/src/main/runtime/structured-agent-session-runtime-exit.test.ts b/src/main/runtime/structured-agent-session-runtime-exit.test.ts index a8419176357..5c6e43c2bc0 100644 --- a/src/main/runtime/structured-agent-session-runtime-exit.test.ts +++ b/src/main/runtime/structured-agent-session-runtime-exit.test.ts @@ -84,6 +84,7 @@ describe('structured session runtime provider-exit wiring', () => { hostId: 'local', claimKeyId: 'key-1', resolveWorkspacePath: async () => root!, + resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), resolveCodexCommand: () => 'codex', resolveEnvironment: async () => ({ PATH: process.env.PATH }), openCodexConnection: openConnection, @@ -180,6 +181,7 @@ describe('structured session runtime provider-exit wiring', () => { hostId: 'local', claimKeyId: 'key-1', resolveWorkspacePath: async () => root!, + resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), resolveCodexCommand: () => 'codex', resolveEnvironment: async () => ({ PATH: process.env.PATH }), openCodexConnection: openConnection, @@ -260,6 +262,7 @@ describe('structured session runtime provider-exit wiring', () => { hostId: 'local', claimKeyId: 'key-1', resolveWorkspacePath: async () => root!, + resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), resolveCodexCommand: () => 'codex', resolveEnvironment: async () => ({ PATH: process.env.PATH }), openCodexConnection: openConnection, diff --git a/src/main/runtime/structured-agent-session-runtime.test.ts b/src/main/runtime/structured-agent-session-runtime.test.ts index b03d17cdb3f..3b69a0a4be3 100644 --- a/src/main/runtime/structured-agent-session-runtime.test.ts +++ b/src/main/runtime/structured-agent-session-runtime.test.ts @@ -13,7 +13,9 @@ import type { } from '../../shared/agent-session-record' import { createStructuredAgentSessionOwnerProbe, - createStructuredAgentSessionOwnerProbes, + createStructuredAgentSessionOwnerProbes +} from './structured-agent-session-owner-probe' +import { ensureStructuredAgentSessionHost, hasPersistedStructuredAgentSessionStore, stopStructuredAgentSessionRuntime @@ -226,6 +228,7 @@ describe('structured agent-session runtime install', () => { hostId: HOST_ID, claimKeyId: 'key-1', resolveWorkspacePath: async () => stateDirectory!, + resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), resolveEnvironment: async () => ({}), reapOrphanChildren, onError @@ -252,6 +255,7 @@ describe('structured agent-session runtime install', () => { hostId: HOST_ID, claimKeyId: 'key-1', resolveWorkspacePath: async () => stateDirectory!, + resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), resolveEnvironment: async () => ({}), reapOrphanChildren: async () => { throw failure @@ -301,7 +305,8 @@ describe('a teardown that fails is retried by the next stop', () => { claimKeyId: 'key-1', resolveWorkspacePath: async () => directory!, resolveEnvironment: async () => ({}), - reapOrphanChildren: async () => [] + reapOrphanChildren: async () => [], + resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }) }) const journalDir = join(directory, 'stubborn-journal') diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 52a3bd81818..923d5627f72 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -9,29 +9,33 @@ import { existsSync } from 'node:fs' import { join } from 'node:path' -import type { AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication' import type { AgentSessionRecord } from '../../shared/agent-session-record' import { createCodexStructuredLaunchResolver } from '../codex/codex-structured-launch-resolution' import { CodexStructuredSessionAdapter, type CodexStructuredSessionAdapterDeps } from '../codex/codex-structured-session-adapter' +import type { ClaudeStructuredSessionAdapterDeps } from '../claude/claude-structured-session-adapter' import { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import { StructuredAgentSessionAdapterRouter } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router' import type { StructuredAgentSessionHandoffTransport } from '../native-chat/agent-session-wire/structured-agent-session-handoff-types' import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' +import { + readClaudeManagedAccountGateSettings, + type ClaudeManagedAccountGateSettings +} from '../native-chat/claude-structured-managed-account-support' import { AgentSessionRecordStore } from './agent-session-record-store' import { agentSessionStorePath } from './agent-session-record-store-file' import { stopOrphanAgentSessionChildren } from './agent-session-orphan-child-reaper' import { - probeAgentSessionProcessIdentities, - probeAgentSessionProcessIdentity, - probeAgentSessionReservation -} from './agent-session-process-identity-probe' -import { findAgentSessionSpawnTokenProcesses } from './agent-session-spawn-token-process-scan' -import { readEchoedAgentSessionSpawnToken } from './agent-session-spawn-token-readback' + createStructuredAgentSessionOwnerProbe, + createStructuredAgentSessionOwnerProbes +} from './structured-agent-session-owner-probe' import { agentSessionPtyWriteGate } from './agent-session-pty-write-gate' import { resolveLoginShellEnvironment } from '../startup/login-shell-environment' import { recordAgentSessionProviderHandle } from './agent-session-provider-handle-transition' +import type { ClaudeStructuredAuthPolicy } from '../claude-accounts/claude-structured-auth-policy' +import { createStructuredClaudeRuntimeAdapter } from './structured-claude-runtime-adapter' /** Sibling of the journal tree rather than inside it: one file adjudicates every * session's lease, while a journal is per session. */ @@ -55,13 +59,20 @@ export type StructuredAgentSessionRuntimeDeps = { claimKeyId: string resolveWorkspacePath: (workspaceId: string) => Promise resolveCodexCommand?: (options?: { pathEnv?: string | null; homePath?: string }) => string + resolveClaudeCommand?: () => string /** Provider transports are overridden only to drive the runtime against scripted children. */ openCodexConnection?: CodexStructuredSessionAdapterDeps['openConnection'] + openClaudeConnection?: ClaudeStructuredSessionAdapterDeps['openConnection'] /** Scripted app-servers carry fake pids the real start-time read cannot answer for. */ readProcessStartTime?: CodexStructuredSessionAdapterDeps['readProcessStartTime'] resolveLaunchArgs?: (provider: AgentSessionRecord['provider']) => Promise | string[] resolveLaunchEnv?: () => Promise resolveLaunchEnvOverlay?: () => Promise> | Record + resolveClaudeLaunchEnv?: () => Promise> | Record + /** Required, and asserted at install time — an absent policy must not degrade to a guess. */ + resolveClaudeAuthPolicy: () => Promise | ClaudeStructuredAuthPolicy + /** Raw settings getter; the reader that fails closed around it is built here, in checked code. */ + getClaudeManagedAccountGateSettings?: () => ClaudeManagedAccountGateSettings resolveEnvironment?: () => Promise resolveCodexOverrides?: () => NodeJS.ProcessEnv onError?: (input: { scope: string; error: unknown }) => void @@ -71,13 +82,17 @@ export type StructuredAgentSessionRuntimeDeps = { type InstalledRuntime = { host: StructuredAgentSessionHost - adapter: CodexStructuredSessionAdapter + adapter: { closeAll(): Promise } /** Resolves after every adapter-exit recovery callback has settled. */ waitForRecovery: () => Promise } let installing: Promise | null = null +/** Thrown when the host is installed without a Claude auth policy resolver. */ +export const CLAUDE_STRUCTURED_AUTH_POLICY_REQUIRED = + 'structured agent-session host requires a Claude auth policy resolver' + /** * Runtimes whose teardown did not finish. `installing` is cleared regardless so * nothing new attaches, but dropping the runtime as well would strand every @@ -147,8 +162,14 @@ async function tearDownRuntime(installed: InstalledRuntime): Promise { } async function install(deps: StructuredAgentSessionRuntimeDeps): Promise { + // Why thrown rather than defaulted: the caller is `@ts-nocheck`, so a dropped + // field arrives here as `undefined`. Refusing to install is loud; guessing a + // policy is the silent under-strip this assertion exists to prevent. + if (typeof deps.resolveClaudeAuthPolicy !== 'function') { + throw new Error(CLAUDE_STRUCTURED_AUTH_POLICY_REQUIRED) + } const bootEnvironment = (deps.resolveEnvironment ?? resolveLoginShellEnvironment)() - const resolveEnvironment = async (): Promise => ({ + const resolveCodexEnvironment = async (): Promise => ({ ...(await bootEnvironment), ...(await deps.resolveLaunchEnv?.()), ...(await deps.resolveLaunchEnvOverlay?.()), @@ -181,7 +202,7 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise + readClaudeManagedAccountGateSettings(deps.getClaudeManagedAccountGateSettings!) + } + : {}), + onUnexpectedExit: (event) => { + recoveryChain = recoveryChain.then(async () => { + try { + await host?.handleAdapterEvent(event) + } catch (error) { + deps.onError?.({ scope: `structured-agent-session-exit:${event.sessionId}`, error }) + } + }) + }, + ...(deps.openClaudeConnection ? { openClaudeConnection: deps.openClaudeConnection } : {}), + ...(deps.readProcessStartTime ? { readProcessStartTime: deps.readProcessStartTime } : {}) + }) + const adapter = new StructuredAgentSessionAdapterRouter({ codex, claude }, async () => { + await Promise.all([codex.closeAll(), claude.closeAll()]) + }) host = new StructuredAgentSessionHost({ store, adapter, @@ -246,102 +295,3 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise Promise { - return async (record) => { - const owner = record.lease.ownerProcess - if (!owner) { - if (record.lease.processlessAt !== undefined && record.lease.processlessAt !== null) { - return { outcome: 'reservation-unused' } - } - const spawnToken = record.lease.reservedSpawnToken - if (spawnToken === null) { - if (record.lease.claimStatus === 'reserved') { - return { - outcome: 'indeterminate', - reason: 'reservation recorded no spawn token to scan for' - } - } - // The token is minted before the child and is the only thing a child could be carrying. - // No owner and no token means nothing on any host can be holding this lease — answering - // `indeterminate` here is what latches an already-free record into recovery forever. - return { outcome: 'reservation-unused' } - } - // Freeing a reservation needs positive proof that nothing spawned under its token. The scan - // answers null where the platform cannot read another process's environment. - return probeAgentSessionReservation({ - spawnToken, - findProcessesWithSpawnToken: (token) => findSpawnTokenProcesses(token), - hasProviderActivitySinceReservation: async () => - agentSessionReservationTouchedProvider(record) - }) - } - if (owner.hostId !== hostId) { - // Checking a remote host's pid against this machine's process table is - // exactly how a live owner gets declared dead. - return { - outcome: 'indeterminate', - reason: `owner runs on ${owner.hostId}, which this host cannot probe` - } - } - // The env read-back answers on hosts that expose it and null elsewhere, giving the - // probe a PID-reuse-safe element even when no start time was recorded. - return probe({ - identity: owner, - deps: { readEchoedSpawnToken: readEchoedAgentSessionSpawnToken } - }) - } -} - -export function createStructuredAgentSessionOwnerProbes( - hostId: string, - probeMany: typeof probeAgentSessionProcessIdentities = probeAgentSessionProcessIdentities, - probeOne = createStructuredAgentSessionOwnerProbe(hostId) -): (records: readonly AgentSessionRecord[]) => Promise> { - return async (records) => { - const results = new Map() - const localOwners: { - record: AgentSessionRecord - owner: NonNullable - }[] = [] - for (const record of records) { - const owner = record.lease.ownerProcess - if (owner?.hostId === hostId) { - localOwners.push({ record, owner }) - } else { - results.set(record.sessionId, await probeOne(record)) - } - } - const probes = await probeMany({ - identities: localOwners.map(({ owner }) => owner), - deps: { readEchoedSpawnToken: readEchoedAgentSessionSpawnToken } - }) - for (const [index, { record }] of localOwners.entries()) { - results.set( - record.sessionId, - probes[index] ?? { outcome: 'indeterminate', reason: 'owner probe returned no result' } - ) - } - return results - } -} - -/** - * The only provider-side trace a reservation can leave in its own record: a handle link minted at - * this fence. `proveAgentSessionOwner` refuses to append one before an identity is committed, so a - * link at the reservation's fence means a child got far enough to resume the provider thread. It - * cannot see activity the child produced without proving a handle, which is why it is paired with - * the token scan rather than trusted alone. - */ -function agentSessionReservationTouchedProvider(record: AgentSessionRecord): boolean { - return record.providerHandleChain.at(-1)?.mintedAtFence === record.lease.runtimeFence -} diff --git a/src/main/runtime/structured-claude-auth-policy-wiring.test.ts b/src/main/runtime/structured-claude-auth-policy-wiring.test.ts new file mode 100644 index 00000000000..f018dfd30da --- /dev/null +++ b/src/main/runtime/structured-claude-auth-policy-wiring.test.ts @@ -0,0 +1,59 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { afterEach, describe, expect, it } from 'vitest' +import { + CLAUDE_STRUCTURED_AUTH_POLICY_REQUIRED, + ensureStructuredAgentSessionHost, + stopStructuredAgentSessionRuntime +} from './structured-agent-session-runtime' + +/** + * The structured host's Claude auth policy has exactly one production wiring, and it + * lives in `orca-runtime-get-worktree-ps.ts` — a `@ts-nocheck` file, so neither the + * compiler nor a type test can see the field disappear. Deleting that wiring used to + * leave ~1000 tests green while every `ANTHROPIC_*` variable in the shell reached the + * child, because `stripAuthEnv` silently fell back to `false`. + * + * Two independent guards replace that silence, and this file pins both. + */ +describe('structured Claude auth policy wiring', () => { + // The behavioural version of this assertion — importing the runtime class and + // capturing the installed deps — costs 35s of module transform for the whole + // OrcaRuntime chain (measured), so the wiring itself is pinned by source and the + // policy's meaning by claude-structured-auth-policy.test.ts. + it('passes a settings-derived Claude auth policy to the host installer', () => { + const source = readFileSync(join(__dirname, 'orca-runtime-get-worktree-ps.ts'), 'utf8') + + expect(source).toContain('claudeStructuredAuthPolicyForSettings') + expect(source).toMatch( + /resolveClaudeAuthPolicy:\s*\(\)\s*=>\s*\n?\s*claudeStructuredAuthPolicyForSettings\(/ + ) + }) + + describe('installing without one', () => { + let stateDirectory: string | null = null + + afterEach(async () => { + await stopStructuredAgentSessionRuntime() + if (stateDirectory) { + await rm(stateDirectory, { recursive: true, force: true }) + stateDirectory = null + } + }) + + it('refuses loudly rather than defaulting to a guess', async () => { + stateDirectory = await mkdtemp(join(tmpdir(), 'orca-auth-policy-wiring-')) + + await expect( + ensureStructuredAgentSessionHost({ + stateDirectory, + hostId: 'local', + claimKeyId: 'key-1', + resolveWorkspacePath: async () => stateDirectory as string + } as unknown as Parameters[0]) + ).rejects.toThrow(CLAUDE_STRUCTURED_AUTH_POLICY_REQUIRED) + }) + }) +}) diff --git a/src/main/runtime/structured-claude-runtime-adapter.ts b/src/main/runtime/structured-claude-runtime-adapter.ts new file mode 100644 index 00000000000..398288562b9 --- /dev/null +++ b/src/main/runtime/structured-claude-runtime-adapter.ts @@ -0,0 +1,98 @@ +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { join } from 'node:path' +import { resolveClaudeCommand } from '../codex-cli/command' +import type { ClaudeStructuredAuthPolicy } from '../claude-accounts/claude-structured-auth-policy' +import { createClaudeStructuredLaunchResolver } from '../claude/claude-structured-launch-resolution' +import { + ClaudeStructuredSessionAdapter, + type ClaudeStructuredSessionAdapterDeps +} from '../claude/claude-structured-session-adapter' +import { claudeProviderHandleLink } from '../claude/claude-structured-owner-identity' +import type { StructuredAgentSessionLifecycleEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { + readClaudeTranscriptLeafUuid, + resolveSessionFilePath +} from '../native-chat/session-file-resolver' +import { recordAgentSessionProviderHandle } from './agent-session-provider-handle-transition' +import type { ClaudeManagedAccountGateSettings } from '../native-chat/claude-structured-managed-account-support' +import type { AgentSessionRecordStore } from './agent-session-record-store' + +export type StructuredClaudeRuntimeAdapterDeps = { + store: AgentSessionRecordStore + resolveWorkspacePath: (workspaceId: string) => Promise + resolveClaudeCommand?: () => string + resolveClaudeLaunchEnv?: () => Promise> | Record + /** Managed-account auth state for a Claude launch, mirroring the terminal preflight. + * Required: an absent policy is what silently under-strips. */ + resolveClaudeAuthPolicy: () => Promise | ClaudeStructuredAuthPolicy + readClaudeManagedAccountGate?: () => ClaudeManagedAccountGateSettings | null + openClaudeConnection?: ClaudeStructuredSessionAdapterDeps['openConnection'] + readProcessStartTime?: ClaudeStructuredSessionAdapterDeps['readProcessStartTime'] + onUnexpectedExit: (event: StructuredAgentSessionLifecycleEvent) => void +} + +export function createStructuredClaudeRuntimeAdapter( + deps: StructuredClaudeRuntimeAdapterDeps +): ClaudeStructuredSessionAdapter { + const { store } = deps + return new ClaudeStructuredSessionAdapter({ + resolveLaunch: createClaudeStructuredLaunchResolver({ + store, + resolveWorkspacePath: deps.resolveWorkspacePath, + resolveCommand: deps.resolveClaudeCommand ?? resolveClaudeCommand, + ...(deps.resolveClaudeLaunchEnv ? { resolveEnv: deps.resolveClaudeLaunchEnv } : {}), + resolveAuthPolicy: deps.resolveClaudeAuthPolicy, + ...(deps.readClaudeManagedAccountGate + ? { readManagedAccountGate: deps.readClaudeManagedAccountGate } + : {}) + }), + persistHandle: async ({ sessionId, providerSessionId, leafUuid, fence }) => { + const currentFence = store.getRecord(sessionId)?.lease.runtimeFence ?? fence + const observedAt = Date.now() + await store.transitionHandoff(sessionId, (record: AgentSessionRecord) => + recordAgentSessionProviderHandle({ + record, + fence: currentFence, + link: claudeProviderHandleLink({ + sessionId: providerSessionId, + leafUuid, + resumed: true, + fence: currentFence, + observedAt + }), + now: observedAt + }) + ) + }, + readTranscriptLeaf: async ({ providerSessionId, previousLeafUuid, claudeConfigDir }) => { + const transcriptPath = await resolveSessionFilePath('claude', providerSessionId, { + claudeProjectsDir: join(claudeConfigDir, 'projects') + }) + return transcriptPath + ? await readClaudeTranscriptLeafUuid(transcriptPath, providerSessionId, previousLeafUuid) + : null + }, + onEvent: (event) => { + if ( + event.type === 'ended' && + event.cause === 'unexpected-exit' && + event.fence !== undefined && + event.acquisitionGeneration + ) { + deps.onUnexpectedExit({ + type: 'ended', + sessionId: event.sessionId, + reason: event.reason, + cause: event.cause, + fence: event.fence, + acquisitionGeneration: event.acquisitionGeneration, + ...(event.settlementRetryRequired + ? { settlementRetryRequired: event.settlementRetryRequired } + : {}) + }) + } + }, + ...(deps.openClaudeConnection ? { openConnection: deps.openClaudeConnection } : {}), + ...(deps.readProcessStartTime ? { readProcessStartTime: deps.readProcessStartTime } : {}) + }) +} diff --git a/src/main/runtime/structured-tui-process-identity.test.ts b/src/main/runtime/structured-tui-process-identity.test.ts index 4324f8c5a26..fb5919824e5 100644 --- a/src/main/runtime/structured-tui-process-identity.test.ts +++ b/src/main/runtime/structured-tui-process-identity.test.ts @@ -238,6 +238,41 @@ describe('structured TUI process identity', () => { } }) + it('does not call a child absent after a single look that outlasted the budget', async () => { + // Measured on a 2,085-process host under load: one whole-machine `ps` took 6.2s while the + // shell-delivered child landed at ~3.5s. `ps` reads the table when it STARTS, so that one + // capture reported a t=0 machine and returned with the 5s budget already spent -- the loop + // answered "no exact child" without ever looking again. + let clockMs = 0 + let captures = 0 + await expect( + readStructuredTuiProcessIdentity({ + hostId: 'local', + rootPid: 100, + spawnToken: 'spawn-slow-ps', + agent: 'claude', + platform: 'darwin', + readPosixRows: async () => { + captures += 1 + const observedAtMs = clockMs + clockMs += 6_200 + return [ + { pid: 100, ppid: 1, stat: 'Ss', command: '/bin/zsh' }, + ...(observedAtMs >= 3_500 + ? [{ pid: 101, ppid: 100, stat: 'S+', command: 'claude --resume session-1' }] + : []) + ] + }, + readStartTime: async () => 1_700_000_000_000, + now: () => clockMs, + sleep: async (delayMs) => { + clockMs += delayMs + } + }) + ).resolves.toMatchObject({ pid: 101, spawnToken: 'spawn-slow-ps' }) + expect(captures).toBe(2) + }) + it('fails closed when the process snapshot omitted the PTY root', async () => { await expect( readStructuredTuiProcessIdentity({ diff --git a/src/main/runtime/structured-tui-process-identity.ts b/src/main/runtime/structured-tui-process-identity.ts index 0014351d781..f5ee019882b 100644 --- a/src/main/runtime/structured-tui-process-identity.ts +++ b/src/main/runtime/structured-tui-process-identity.ts @@ -20,6 +20,12 @@ const STRUCTURED_TUI_PROCESS_POLL_MS = 50 // window the added latency is bounded by one interval. const STRUCTURED_TUI_PROCESS_FAST_POLL_WINDOW_MS = 1_000 const STRUCTURED_TUI_PROCESS_MAX_POLL_MS = 500 +// Why a floor and not just the deadline: the first capture races the spawn it is looking for, +// so a null from it is absence of the child's arrival, not evidence the child is missing. The +// budget above assumes a look is nearly free, but one whole-machine `ps` measured 6.2s on a +// 2,085-process host under load -- long enough to spend the entire budget before the child +// (observed landing at ~3.5s) could exist, and answer "no exact child" after a single look. +const STRUCTURED_TUI_PROCESS_MIN_CAPTURES = 2 function descendants(rows: ProcessRow[], rootPid: number): (ProcessRow & { depth: number })[] { const children = new Map() @@ -175,6 +181,7 @@ export async function readStructuredTuiProcessIdentity(input: { const startedAtMs = now() const deadline = startedAtMs + (input.timeoutMs ?? STRUCTURED_TUI_PROCESS_WAIT_MS) let pollDelayMs = input.pollIntervalMs ?? STRUCTURED_TUI_PROCESS_POLL_MS + let captures = 0 while (true) { const rows: ProcessRow[] = @@ -186,6 +193,7 @@ export async function readStructuredTuiProcessIdentity(input: { foreground: false })) : posixRows(await (input.readPosixRows ?? getFreshProcessTableSnapshot)()) + captures += 1 let rootPresent = false for (const row of rows) { if (row.pid === input.rootPid) { @@ -218,11 +226,11 @@ export async function readStructuredTuiProcessIdentity(input: { } } const remainingMs = deadline - now() - if (remainingMs <= 0) { + if (remainingMs <= 0 && captures >= STRUCTURED_TUI_PROCESS_MIN_CAPTURES) { const label = input.agent === 'codex' ? 'Codex' : 'Claude' throw new Error(`The resumed terminal did not expose one exact ${label} child process.`) } - await sleep(Math.min(pollDelayMs, remainingMs)) + await sleep(Math.max(0, Math.min(pollDelayMs, remainingMs))) if (now() - startedAtMs >= STRUCTURED_TUI_PROCESS_FAST_POLL_WINDOW_MS) { // Never below the caller's interval, so an explicitly slow poll stays slow. pollDelayMs = Math.max( diff --git a/src/main/windows-descendant-exit-verification.test.ts b/src/main/windows-descendant-exit-verification.test.ts new file mode 100644 index 00000000000..392c44399e7 --- /dev/null +++ b/src/main/windows-descendant-exit-verification.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, it, vi } from 'vitest' +import { + captureWindowsDescendantSnapshot, + terminateIdentifiedWindowsProcessTree, + verifyWindowsDescendantSnapshotExit, + type WindowsDescendantSnapshot +} from './windows-descendant-exit-verification' + +function snapshot( + descendants: { pid: number; creationTimeMs: number }[], + unidentifiedCount = 0 +): WindowsDescendantSnapshot { + return { + root: { pid: 100, creationTimeMs: 5 }, + descendants, + unidentifiedCount, + capturedAtMs: 1_700_000_000_000 + } +} + +describe('captureWindowsDescendantSnapshot', () => { + it('walks the whole subtree and keeps only rows a later read can re-identify', async () => { + const captured = await captureWindowsDescendantSnapshot(100, { + // 400 is a grandchild; 300 denied a creation-time query, so no later read + // could tell it from a recycled pid and signalling it would risk a stranger. + readTable: vi.fn(async () => [ + { pid: 100, ppid: 1, creationTimeMs: 5 }, + { pid: 200, ppid: 100, creationTimeMs: 7 }, + { pid: 300, ppid: 100 }, + { pid: 400, ppid: 200, creationTimeMs: 9 }, + { pid: 500, ppid: 1, creationTimeMs: 11 } + ]), + now: () => 42 + }) + + expect(captured).toEqual({ + root: { pid: 100, creationTimeMs: 5 }, + descendants: [ + { pid: 400, creationTimeMs: 9 }, + { pid: 200, creationTimeMs: 7 } + ], + // Seen but not re-identifiable: counted, so no later read can prove it gone. + unidentifiedCount: 1, + capturedAtMs: 42 + }) + }) + + it('reports an unreadable or rootless table as no snapshot rather than an empty one', async () => { + await expect( + captureWindowsDescendantSnapshot(100, { + readTable: vi.fn(async () => { + throw new Error('table unavailable') + }) + }) + ).resolves.toBeNull() + // A snapshot without the root is stale or filtered; only an observed root + // can authoritatively have no descendants. + await expect( + captureWindowsDescendantSnapshot(100, { + readTable: vi.fn(async () => [{ pid: 999, ppid: 1, creationTimeMs: 5 }]) + }) + ).resolves.toBeNull() + }) + + it('refuses an invalid root pid', async () => { + const readTable = vi.fn() + await expect(captureWindowsDescendantSnapshot(0, { readTable })).resolves.toBeNull() + expect(readTable).not.toHaveBeenCalled() + }) +}) + +describe('verifyWindowsDescendantSnapshotExit', () => { + it('proves an empty tree without reading the table', async () => { + const readTable = vi.fn() + await expect(verifyWindowsDescendantSnapshotExit(snapshot([]), { readTable })).resolves.toBe( + 'exited' + ) + expect(readTable).not.toHaveBeenCalled() + }) + + it('never proves a tree that held a descendant it could not identify', async () => { + // A descendant that denied the creation-time query was seen in the table; + // being unable to re-identify it is "could not look", never "it is gone". + const readTable = vi.fn() + await expect(verifyWindowsDescendantSnapshotExit(snapshot([], 1), { readTable })).resolves.toBe( + 'unverifiable' + ) + expect(readTable).not.toHaveBeenCalled() + + // The identified sibling leaving proves nothing about the unidentified one. + await expect( + verifyWindowsDescendantSnapshotExit(snapshot([{ pid: 200, creationTimeMs: 7 }], 1), { + readTable: vi.fn(async () => []), + wait: async () => {}, + now: vi.fn().mockReturnValueOnce(0).mockReturnValue(1) + }) + ).resolves.toBe('unverifiable') + }) + + it('reports exited once no identity-matched row remains', async () => { + const readTable = vi + .fn() + .mockResolvedValueOnce([{ pid: 200, ppid: 100, creationTimeMs: 7 }]) + // The pid came back on a different process; that is a recycle, not a survivor. + .mockResolvedValueOnce([{ pid: 200, ppid: 100, creationTimeMs: 99 }]) + + await expect( + verifyWindowsDescendantSnapshotExit(snapshot([{ pid: 200, creationTimeMs: 7 }]), { + readTable, + wait: async () => {}, + now: vi.fn().mockReturnValueOnce(0).mockReturnValue(1) + }) + ).resolves.toBe('exited') + expect(readTable).toHaveBeenCalledTimes(2) + }) + + it('reports live for a descendant still matched at the deadline', async () => { + let clock = 0 + await expect( + verifyWindowsDescendantSnapshotExit(snapshot([{ pid: 200, creationTimeMs: 7 }]), { + readTable: vi.fn(async () => [{ pid: 200, ppid: 100, creationTimeMs: 7 }]), + wait: async () => { + clock += 100 + }, + now: () => clock, + verifyMs: 250 + }) + ).resolves.toBe('live') + }) + + it('reports unverifiable when the table cannot be read at the deadline', async () => { + await expect( + verifyWindowsDescendantSnapshotExit(snapshot([{ pid: 200, creationTimeMs: 7 }]), { + readTable: vi.fn(async () => { + throw new Error('table unavailable') + }), + wait: async () => {}, + now: vi.fn().mockReturnValueOnce(0).mockReturnValue(9_999) + }) + ).resolves.toBe('unverifiable') + }) +}) + +describe('terminateIdentifiedWindowsProcessTree', () => { + it('never taskkills a replacement that reused the captured root pid', async () => { + const terminateTree = vi.fn(async () => {}) + + await expect( + terminateIdentifiedWindowsProcessTree( + { pid: 100, creationTimeMs: 5 }, + { + readTable: vi.fn(async () => [{ pid: 100, ppid: 1, creationTimeMs: 99 }]), + terminateTree + } + ) + ).resolves.toBe(false) + expect(terminateTree).not.toHaveBeenCalled() + }) + + it('rechecks retained-child ownership after the identity read settles', async () => { + const terminateTree = vi.fn(async () => {}) + + await expect( + terminateIdentifiedWindowsProcessTree( + { pid: 100, creationTimeMs: 5 }, + { + readTable: vi.fn(async () => [{ pid: 100, ppid: 1, creationTimeMs: 5 }]), + ownsRoot: () => false, + terminateTree + } + ) + ).resolves.toBe(false) + expect(terminateTree).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/windows-descendant-exit-verification.ts b/src/main/windows-descendant-exit-verification.ts new file mode 100644 index 00000000000..079833a2bd6 --- /dev/null +++ b/src/main/windows-descendant-exit-verification.ts @@ -0,0 +1,156 @@ +import type { DescendantTreeVerdict } from './pty-descendant-exit-verification' +import { windowsDescendantsFromRows } from './providers/windows-foreground-process-rows' +import { readWindowsProcessTableFresh } from './windows/windows-process-table' +import { terminateWindowsProcessTree } from './windows-process-tree-kill' + +export const WINDOWS_DESCENDANT_KILL_VERIFY_MS = 3_500 +const WINDOWS_DESCENDANT_POLL_MS = 100 + +/** + * A Windows descendant tree captured while its root was alive, with the + * PID-reuse guard the POSIX snapshot gets from ps lstart: a row only counts as + * the same process when its creation time still matches. Rows without a + * creation time are never signalled, because a bare pid cannot be re-identified, + * but they are counted: a descendant that was seen and denied identification + * is one no later read can prove gone. + */ +export type WindowsProcessIdentity = { pid: number; creationTimeMs: number } + +export type WindowsDescendantSnapshot = { + root: WindowsProcessIdentity + descendants: WindowsProcessIdentity[] + /** Descendants seen in the walk that denied the creation-time query. */ + unidentifiedCount: number + capturedAtMs: number + /** Per-PID boundaries retained when close refreshes merge snapshots. */ + capturedAtMsByPid?: Readonly> +} + +export type WindowsDescendantVerificationDeps = { + readTable?: () => Promise<{ pid: number; ppid: number; creationTimeMs?: number }[]> + now?: () => number + wait?: (ms: number) => Promise + verifyMs?: number +} + +/** Revalidate a Windows PID/creation-time identity immediately before a kill. */ +export async function verifyWindowsProcessIdentity( + target: WindowsProcessIdentity, + deps: Pick = {} +): Promise { + if (!Number.isInteger(target.pid) || target.pid <= 0 || !Number.isFinite(target.creationTimeMs)) { + return false + } + const table = await (deps.readTable ?? readWindowsProcessTableFresh)().catch(() => null) + const current = table?.filter((row) => row.pid === target.pid) ?? [] + return current.length === 1 && current[0]?.creationTimeMs === target.creationTimeMs +} + +function delay(ms: number): Promise { + return new Promise((resolve) => { + const timer = setTimeout(resolve, ms) + timer.unref?.() + }) +} + +/** + * Snapshot a Windows root's descendants while it is still alive. Resolves null + * (never rejects) when the table is unreadable or the root is absent — the same + * contract as the POSIX walk, because "cannot see" is never "nothing is there". + */ +export async function captureWindowsDescendantSnapshot( + rootPid: number, + deps: WindowsDescendantVerificationDeps = {} +): Promise { + if (!Number.isInteger(rootPid) || rootPid <= 0) { + return null + } + const capturedAtMs = (deps.now ?? Date.now)() + // One table read, not a walk plus an identity read: each is bounded in + // seconds, and this runs inside the close ladder's budget. + const table = await (deps.readTable ?? readWindowsProcessTableFresh)().catch(() => null) + const descendants = table && windowsDescendantsFromRows(table, rootPid) + const root = table?.find((row) => row.pid === rootPid) + if (!descendants || typeof root?.creationTimeMs !== 'number') { + return null + } + return { + root: { pid: root.pid, creationTimeMs: root.creationTimeMs }, + descendants: descendants.flatMap((row) => + // A descendant that denied a creation-time query cannot be told from a + // recycled pid later, so it is never signalled on a bare pid. + typeof row.creationTimeMs === 'number' + ? [{ pid: row.pid, creationTimeMs: row.creationTimeMs }] + : [] + ), + unidentifiedCount: descendants.filter((row) => typeof row.creationTimeMs !== 'number').length, + capturedAtMs + } +} + +export type IdentifiedWindowsTreeTerminationDeps = { + readTable?: WindowsDescendantVerificationDeps['readTable'] + terminateTree?: (target: WindowsProcessIdentity) => Promise + ownsRoot?: () => boolean +} + +/** Revalidate the captured root at the last async boundary before taskkill. */ +export async function terminateIdentifiedWindowsProcessTree( + target: WindowsProcessIdentity, + deps: IdentifiedWindowsTreeTerminationDeps = {} +): Promise { + if (!(await verifyWindowsProcessIdentity(target, { readTable: deps.readTable }))) { + return false + } + if (deps.ownsRoot?.() === false) { + return false + } + await ( + deps.terminateTree ?? + ((identified: WindowsProcessIdentity) => terminateWindowsProcessTree(identified.pid)) + )(target) + return true +} + +/** + * Whether a snapshotted Windows tree is gone, polled to a bounded deadline. + * + * Why a verification pass at all: `taskkill /T /F` resolves the same way on a + * timeout, an access denial and a recycled root as it does on a successful + * kill, so its completion is never evidence. Only a table read that no longer + * shows an identity-matched row is. + */ +export async function verifyWindowsDescendantSnapshotExit( + snapshot: WindowsDescendantSnapshot, + deps: WindowsDescendantVerificationDeps = {} +): Promise { + // The most a read can prove: a descendant that denied identification was seen + // and can never be matched gone, so "could not look" caps the verdict. + const proven: DescendantTreeVerdict = snapshot.unidentifiedCount > 0 ? 'unverifiable' : 'exited' + if (snapshot.descendants.length === 0) { + return proven + } + const now = deps.now ?? Date.now + const readTable = deps.readTable ?? readWindowsProcessTableFresh + const deadline = now() + (deps.verifyMs ?? WINDOWS_DESCENDANT_KILL_VERIFY_MS) + let verdict: DescendantTreeVerdict = 'unverifiable' + do { + const table = await readTable().catch(() => null) + if (!table) { + verdict = 'unverifiable' + } else { + const live = new Map(table.map((row) => [row.pid, row.creationTimeMs])) + verdict = snapshot.descendants.some((row) => live.get(row.pid) === row.creationTimeMs) + ? 'live' + : proven + if (verdict === proven) { + return verdict + } + } + if (now() >= deadline) { + return verdict + } + await (deps.wait ?? delay)(WINDOWS_DESCENDANT_POLL_MS) + } while (now() < deadline) + return verdict +} diff --git a/src/relay/pty-handler-ownership-attestation.test.ts b/src/relay/pty-handler-ownership-attestation.test.ts index 94f462c46d2..ee1c144df09 100644 --- a/src/relay/pty-handler-ownership-attestation.test.ts +++ b/src/relay/pty-handler-ownership-attestation.test.ts @@ -33,7 +33,8 @@ import { endPtyHandlerTest, type MockDispatcher } from './pty-handler-test-harness' -import { PROCESS_TABLE_SNAPSHOT_MAX_STALENESS_MS } from '../shared/process-table-snapshot-reader' +import * as processTableSnapshotReader from '../shared/process-table-snapshot-reader' +import { RELAY_PTY_SWEEP_MAX_EVIDENCE_AGE_MS } from '../shared/ssh-relay-pty-ownership-proof' const PANE_KEY = 'tab-agent:22222222-2222-4222-8222-222222222222' @@ -139,16 +140,41 @@ describe('PtyHandler publishes host-attested PTY ownership', () => { expect(entry?.ownerClientInstanceId).toBe('client-A') }) - it('dates the foreground observation instead of stamping it fresh', async () => { - // `capturedAgeMs` used to be a hardcoded 0 with no reader anywhere, so the one field that - // exists to bound staleness asserted the evidence was never stale. It now carries the - // actual age of the TTL-shared capture the record was derived from. - const { id } = await spawnFrom(7, { env: { ORCA_PANE_KEY: PANE_KEY } }) + it('publishes the age the capture reported, rather than restamping it fresh', async () => { + // This assertion used to read `capturedAgeMs <= PROCESS_TABLE_SNAPSHOT_MAX_STALENESS_MS`, which + // could not fail: `beginPtyHandlerTest` installs fake timers, so `Date.now()` is frozen, the + // real reader reports exactly +0, and `0 <= 500` held identically for a hardcoded zero, for + // completion-stamping and for start-stamping. The one test guarding this field was blind to + // every change to it, while the real reader on a 2,002-process host returns thousands of ms. + // + // So drive a real age in from the reader. That the reader MEASURES the age correctly is + // pinned separately, against a controllable clock, by process-table-snapshot.test.ts; what + // belongs here is that the handler publishes what it was given instead of restamping. + const capturedAgeMs = 6_140 + const snapshot = vi + .spyOn(processTableSnapshotReader, 'getStrictProcessTableSnapshotWithAge') + .mockResolvedValue({ rows: [], capturedAgeMs }) + const { id } = await spawnFrom(7, { env: { ORCA_PANE_KEY: PANE_KEY } }) const entry = (await listProcesses()).find((process) => process.id === id) - expect(entry?.foregroundProcessEvidence?.capturedAgeMs).toBeLessThanOrEqual( - PROCESS_TABLE_SNAPSHOT_MAX_STALENESS_MS + expect(snapshot).toHaveBeenCalled() + expect(entry?.foregroundProcessEvidence?.capturedAgeMs).toBe(capturedAgeMs) + }) + + it('publishes an age a destructive consumer will refuse, rather than one it will trust', async () => { + // The point of the field, stated as the consumer sees it: an observation this old cannot + // authorize a stop, and the whole bug was that it used to arrive claiming it could. + const snapshot = vi + .spyOn(processTableSnapshotReader, 'getStrictProcessTableSnapshotWithAge') + .mockResolvedValue({ rows: [], capturedAgeMs: 6_140 }) + + const { id } = await spawnFrom(7, { env: { ORCA_PANE_KEY: PANE_KEY } }) + const entry = (await listProcesses()).find((process) => process.id === id) + + expect(snapshot).toHaveBeenCalled() + expect(entry?.foregroundProcessEvidence?.capturedAgeMs).toBeGreaterThan( + RELAY_PTY_SWEEP_MAX_EVIDENCE_AGE_MS ) }) }) diff --git a/src/relay/pty-handler-spawn-admission.test.ts b/src/relay/pty-handler-spawn-admission.test.ts index 045ee2e412d..07fe8e9d88f 100644 --- a/src/relay/pty-handler-spawn-admission.test.ts +++ b/src/relay/pty-handler-spawn-admission.test.ts @@ -126,6 +126,46 @@ describe('PtyHandler', () => { expect(hasChildren).toHaveBeenLastCalledWith(mockPtyInstance.pid, { fresh: true }) }) + it('does not re-enter the shared capture after the evidence read gave up on it', async () => { + // The budget is worthless if the compatibility fields answer by joining the very capture the + // evidence read just abandoned: `inspectPtyChildProcesses` and `getForegroundProcessName` + // read the same TTL-shared table with no budget of their own, so on a slow host this call + // would still block for the whole capture -- once, then once per managed PTY in the listing. + const snapshot = vi + .spyOn(processTableSnapshotReader, 'getStrictProcessTableSnapshotWithAge') + .mockRejectedValue(new Error('process table unreadable: capture_over_budget')) + const hasChildren = vi.spyOn(ptyChildProcessInspection, 'inspectPtyChildProcesses') + const foregroundName = vi.spyOn(ptyShellUtils, 'getForegroundProcessName') + + const { id } = (await spawnPty({ cols: 80, rows: 24 })) as { id: string } + hasChildren.mockClear() + foregroundName.mockClear() + + const inspection = (await dispatcher.callRequest('pty.inspectProcess', { id })) as { + hasChildProcesses: boolean + childProcessEvidence?: string + foregroundProcessEvidence?: { verdict: string; reason?: string } + } + + expect(snapshot).toHaveBeenCalled() + expect(hasChildren).not.toHaveBeenCalled() + // The verdict the gates already handle, reached promptly instead of late. + expect(inspection.foregroundProcessEvidence?.verdict).toBe('unverifiable') + expect(inspection.foregroundProcessEvidence?.reason).toBe('process_table_unreadable') + // The honest verdict rather than a fabricated negative, reached without the wait. The + // compatibility boolean still spells `unverifiable` as `false` for older clients. + expect(inspection.childProcessEvidence).toBe('unverifiable') + expect(inspection.hasChildProcesses).toBe(false) + + const listing = (await dispatcher.callRequest('pty.listProcesses', {})) as { + id: string + title: string + }[] + + expect(foregroundName).not.toHaveBeenCalled() + expect(listing.find((entry) => entry.id === id)?.title).toBeTruthy() + }) + it('rejects strict process inspection for a missing relay PTY', async () => { await expect(dispatcher.callRequest('pty.inspectProcess', { id: 'missing' })).rejects.toThrow( 'terminal_gone' diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index 190bcabb3f9..4b7c6dac2d6 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -2663,6 +2663,9 @@ export class PtyHandler { } } let rows: readonly ProcessTableRow[] | null = null + // Set only when the budgeted evidence read gave up, so the compatibility fields below do not + // turn around and ask the same unreadable table again with no budget at all. + let tableUnavailable = false let evidence: RemoteForegroundEvidence | undefined if (process.platform === 'win32') { // Why SSH-to-Windows is always unverifiable: POSIX has a real foreground primitive @@ -2701,6 +2704,7 @@ export class PtyHandler { rows ) } catch { + tableUnavailable = true evidence = { authorityGeneration: this.ptyIdMintEpoch, observationEpoch: ++this.foregroundEvidenceEpoch, @@ -2727,13 +2731,19 @@ export class PtyHandler { // 1.36s CIM scan, and polling that would reinstate exactly the fork storm the shared table // exists to prevent (#15209, #15036). Close and cleanup decisions ask for the scan by name; // a poll gets the honest `unverifiable` instead of a fabricated negative. + // Why `tableUnavailable` first: it means the budgeted evidence read already gave up. Without + // this arm `inspectPtyChildProcesses` re-enters `getProcessTableSnapshot()` and joins the very + // capture this call just abandoned, blocking for all of it and spending the whole latency the + // budget exists to avoid. The destructive `pty.hasChildProcesses` RPC keeps its fresh probe. const childProcessEvidence: PtyChildProcessVerdict = rows ? rows.some((row) => row.ppid === managed.pty.pid) ? 'children' : 'no-children' - : process.platform === 'win32' && params.scanChildProcesses !== true + : tableUnavailable ? 'unverifiable' - : await inspectPtyChildProcesses(managed.pty.pid) + : process.platform === 'win32' && params.scanChildProcesses !== true + ? 'unverifiable' + : await inspectPtyChildProcesses(managed.pty.pid) return { foregroundProcess, // `unverifiable` keeps spelling itself `false` on the compatibility field, which is what @@ -2758,6 +2768,10 @@ export class PtyHandler { // process-table work on the host. const includeForegroundProcessEvidence = params.includeForegroundProcessEvidence !== false let evidenceRows: readonly ProcessTableRow[] | null = null + // Same reason as `inspectProcess`: once the budgeted read has given up, the per-PTY title + // fallback below must not re-enter the same capture without a budget -- and here it would do + // so once per managed PTY. + let evidenceTableUnavailable = false let evidenceResults: BatchedForegroundProcessResult[] = [] const evidenceEpoch = ++this.foregroundEvidenceEpoch // Worst-case capture time for the snapshot below, not the instant its await settled: the @@ -2783,6 +2797,7 @@ export class PtyHandler { } catch { // An unreadable capture is represented as unverifiable evidence below; // existing inventory fields remain available for old clients. + evidenceTableUnavailable = true } } for (const [entryIndex, [id, managed]] of managedEntries.entries()) { @@ -2797,7 +2812,7 @@ export class PtyHandler { const title = (evidenceRows ? (evidenceResults[entryIndex]?.processName ?? managed.pty.process ?? null) - : includeForegroundProcessEvidence + : includeForegroundProcessEvidence && !evidenceTableUnavailable ? await getForegroundProcessName(managed.pty.pid, managed.pty.process || null) : managed.pty.process || null) || 'shell' const foregroundProcessEvidence = diff --git a/src/renderer/src/components/native-chat/NativeChatQuestionCard.test.tsx b/src/renderer/src/components/native-chat/NativeChatQuestionCard.test.tsx index b6748fe7e82..9b1a2967682 100644 --- a/src/renderer/src/components/native-chat/NativeChatQuestionCard.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQuestionCard.test.tsx @@ -28,7 +28,7 @@ afterEach(() => { function render( prompt: AskPrompt, onAnswer: (s: AskAnswerSelection[]) => void, - allowOther = true + allowOther: boolean | readonly boolean[] = true ): void { act(() => { root.render( @@ -155,4 +155,71 @@ describe('NativeChatQuestionCard', () => { expect(container.querySelector('input')).toBeNull() expect(container.textContent).not.toContain('Type your answer') }) + + it('applies free-text capability per question in a grouped prompt', () => { + render( + { + questions: [ + { + header: 'Listed', + question: 'Pick a listed value', + multiSelect: false, + options: [{ label: 'One' }] + }, + { + header: 'Custom', + question: 'Provide a custom value', + multiSelect: false, + options: [] + } + ] + }, + vi.fn(), + [false, true] + ) + + expect(container.querySelector('input')).toBeNull() + clickAction('Skip') + expect(container.querySelector('input')).not.toBeNull() + }) + + it('submits grouped multi-select and free-text answers together', () => { + const onAnswer = vi.fn() + render( + { + questions: [ + { + header: 'Targets', + question: 'Which targets?', + multiSelect: true, + options: [{ label: 'Web' }, { label: 'Mobile' }] + }, + { + header: 'Notes', + question: 'Anything else?', + multiSelect: false, + options: [] + } + ] + }, + onAnswer, + [false, true] + ) + + clickOption('Web') + clickOption('Mobile') + clickAction('Next') + const input = container.querySelector('input')! + act(() => { + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set! + setter.call(input, 'SSH host') + input.dispatchEvent(new Event('input', { bubbles: true })) + }) + clickAction('Submit') + + expect(onAnswer).toHaveBeenCalledWith([ + { indices: [0, 1], other: '' }, + { indices: [], other: 'SSH host' } + ]) + }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatQuestionCard.tsx b/src/renderer/src/components/native-chat/NativeChatQuestionCard.tsx index 4bc881ee3e1..1b1ce5a3547 100644 --- a/src/renderer/src/components/native-chat/NativeChatQuestionCard.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQuestionCard.tsx @@ -10,7 +10,7 @@ export type NativeChatQuestionCardProps = { isSubmitting?: boolean /** Deliver the chosen answer (per-question option indices + free text). */ onAnswer: (selections: AskAnswerSelection[]) => void - allowOther?: boolean + allowOther?: boolean | readonly boolean[] /** Dismiss the prompt (sends Escape to the agent). */ onCancel: () => void /** Exposes the free-text row so pane-level Paste can target it while the @@ -42,6 +42,7 @@ export function NativeChatQuestionCard({ const total = prompt.questions.length const isLast = index === total - 1 const q = prompt.questions[index]! + const questionAllowsOther = Array.isArray(allowOther) ? (allowOther[index] ?? false) : allowOther const setOther = (qi: number, value: string): void => { setOtherText((prev) => { @@ -186,7 +187,7 @@ export function NativeChatQuestionCard({ /> ))}
- {allowOther ? ( + {questionAllowsOther ? ( <> diff --git a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx index d53e8f536d2..031ce4bcd15 100644 --- a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx @@ -165,6 +165,7 @@ function model(overrides: Partial = {}): SessionOptionD ] }, valueSource: 'applied', + transport: 'catalog', settable: true, ...overrides } @@ -183,6 +184,7 @@ const effort: SessionOptionDescriptor = { ] }, valueSource: 'applied', + transport: 'catalog', settable: true } @@ -192,6 +194,7 @@ const fast: SessionOptionDescriptor = { category: 'mode', kind: { type: 'boolean', currentValue: true }, valueSource: 'applied', + transport: 'catalog', settable: true } @@ -342,17 +345,48 @@ describe('NativeChatSessionOptionPickers', () => { expect(screen.queryByRole('button', { name: /^Effort/ })).toBeNull() }) - it('shows the unconfirmed hint for dispatched values', () => { + // The terminal transport typed the value at the agent and has not read it back, + // so the pill says so; the structured transport's own per-turn report is the + // confirmation, which makes the same hedge transient noise there. + it('hedges a dispatched value the terminal transport produced', () => { render( ) - expect(screen.getByText('Sent to the agent — not confirmed')).not.toBeNull() + expect(screen.getByText('Model')).not.toBeNull() + expect(screen.getAllByText('Sent to the agent — not confirmed').length).toBeGreaterThan(0) }) + it('does not hedge a dispatched value the structured transport produced', () => { + render( + + ) + expect(screen.getByText('Model')).not.toBeNull() + expect(screen.queryByText(/not confirmed/)).toBeNull() + }) + + it.each(['catalog', 'agent-session'] as const)( + 'does not hedge a reported value on the %s transport', + (transport) => { + render( + + ) + expect(screen.getByText('Model')).not.toBeNull() + expect(screen.queryByText(/not confirmed/)).toBeNull() + } + ) + it('renders agent-picker routes as one action instead of radio choices', async () => { const invokeAction = vi.fn().mockResolvedValue({ snapshot: [] }) const liveSurface = { ...surface, invokeAction } @@ -449,6 +483,7 @@ describe('NativeChatSessionOptionPickers', () => { category: 'mode', kind: { type: 'boolean' }, valueSource: 'unknown', + transport: 'catalog', settable: true } ]} @@ -463,26 +498,7 @@ describe('NativeChatSessionOptionPickers', () => { await waitFor(() => expect(setOption).toHaveBeenCalledWith('thinking', false)) }) - it('does not show unconfirmed for applied flip-only booleans', () => { - render( - - ) - expect(screen.queryByText('Sent to the agent — not confirmed')).toBeNull() - }) - - it('shows unconfirmed for confirmable dispatched booleans', () => { + it('tooltips a dispatched option pill with the category alone', () => { render( { category: 'mode', kind: { type: 'boolean', currentValue: true }, valueSource: 'dispatched', + transport: 'catalog', settable: true } ]} isWorking={false} /> ) - expect(screen.getByText('Sent to the agent — not confirmed')).not.toBeNull() + expect(screen.getAllByText('Thinking').length).toBeGreaterThan(0) + expect(screen.getAllByText('Sent to the agent — not confirmed').length).toBeGreaterThan(0) }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.tsx b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.tsx index 87a860662d2..31ff2cbdc4e 100644 --- a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.tsx +++ b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.tsx @@ -15,10 +15,11 @@ import { import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { translate } from '@/i18n/i18n' import { sortNativeChatSessionOptions } from '../../../../shared/native-chat-session-option-snapshot' -import type { - SessionOptionDescriptor, - SessionOptionsSurface, - SessionOptionValue +import { + sessionOptionDispatchUnconfirmed, + type SessionOptionDescriptor, + type SessionOptionsSurface, + type SessionOptionValue } from '../../../../shared/native-chat-session-options' import { nativeChatModelPillLabel, @@ -250,7 +251,7 @@ function NativeChatSessionOptionPickersInner({ tooltipLabel={optionsTooltip} disabled={isWorking || pendingId !== null} disabledReason={optionsReason} - dispatched={options.some((descriptor) => descriptor.valueSource === 'dispatched')} + dispatched={options.some(sessionOptionDispatchUnconfirmed)} /> {options.map((descriptor, index) => { @@ -283,7 +284,7 @@ function NativeChatSessionOptionPickersInner({ tooltipLabel={modelTooltip} disabled={isWorking || pendingId !== null} disabledReason={modelReason} - dispatched={model.valueSource === 'dispatched'} + dispatched={sessionOptionDispatchUnconfirmed(model)} /> {modelReason && !model.settable ? ( diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx index bfb3dd1ef52..d3c13a48ef1 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx @@ -3,6 +3,9 @@ import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import React, { forwardRef, useImperativeHandle } from 'react' import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' +import { decodeAgentSessionQuestionAnswers } from '../../../../shared/agent-session-question-answer' +import type { NativeChatQuestionCardProps } from './NativeChatQuestionCard' const mocks = vi.hoisted(() => ({ call: vi.fn(), @@ -13,6 +16,9 @@ const mocks = vi.hoisted(() => ({ onLinkClick?: (...args: unknown[]) => void }, composerProps: null as null | { structuredTransport?: Record }, + questionCardProps: null as NativeChatQuestionCardProps | null, + promptItems: [] as AgentJournalRenderItem[], + respond: vi.fn(), handlePasteEvent: vi.fn(), pasteFromClipboard: vi.fn(), submissions: [] as unknown[] @@ -53,7 +59,7 @@ vi.mock('./use-structured-agent-session', async () => { hasOlder: false, loadingOlder: false, loadOlder: vi.fn(), - prompts: [], + prompts: mocks.promptItems, outbox: outbox.outbox, blockedClientMessageId: outbox.blockedClientMessageId, send: outbox.send, @@ -61,7 +67,7 @@ vi.mock('./use-structured-agent-session', async () => { isWorking: false, turnId: null, cancel: vi.fn(), - respond: vi.fn(), + respond: mocks.respond, optionSnapshot: [ { id: 'model', @@ -125,7 +131,12 @@ vi.mock('./NativeChatComposer', () => ({ })) vi.mock('./NativeChatEmptyState', () => ({ NativeChatEmptyState: () => null })) vi.mock('./NativeChatApprovalCard', () => ({ NativeChatApprovalCard: () => null })) -vi.mock('./NativeChatQuestionCard', () => ({ NativeChatQuestionCard: () => null })) +vi.mock('./NativeChatQuestionCard', () => ({ + NativeChatQuestionCard: (props: NativeChatQuestionCardProps) => { + mocks.questionCardProps = props + return null + } +})) import { NativeChatStructuredSession } from './NativeChatStructuredSession' @@ -136,6 +147,9 @@ describe('NativeChatStructuredSession', () => { mocks.mode = 'static' mocks.messageListProps = null mocks.composerProps = null + mocks.questionCardProps = null + mocks.promptItems = [] + mocks.respond.mockReset() mocks.handlePasteEvent.mockReset() mocks.pasteFromClipboard.mockReset() mocks.submissions = [] @@ -546,4 +560,131 @@ describe('NativeChatStructuredSession', () => { vi.useRealTimers() } }, 30000) + + it('passes Claude grouped questions and one shared answer through the card', () => { + mocks.promptItems = [ + { + itemId: 'question-item', + revision: 1, + sequence: 1, + observedAt: 1, + body: { + kind: 'question', + question: '2 grouped questions from Claude', + options: [], + questions: [ + { + id: 'q1', + header: 'Targets', + question: 'Which targets?', + multiSelect: true, + options: [ + { id: 'target-web', label: 'Web' }, + { id: 'target-mobile', label: 'Mobile' } + ], + freeTextQuestionId: 'q1' + }, + { + id: 'q2', + header: 'Host', + question: 'Where should it run?', + multiSelect: false, + options: [], + freeTextQuestionId: 'q2' + } + ], + resolution: { + state: 'pending', + selectedOptionId: null, + resolvedBy: null, + resolvedAt: null + } + } + } + ] + + render( + + ) + + const card = mocks.questionCardProps + if (!card) { + throw new Error('question card was not rendered') + } + expect(card.prompt.questions).toHaveLength(2) + expect(card.prompt.questions[0]).toMatchObject({ + question: 'Which targets?', + multiSelect: true, + options: [{ label: 'Web' }, { label: 'Mobile' }] + }) + expect(card.allowOther).toEqual([true, true]) + + card.onAnswer([ + { indices: [0, 1], other: '' }, + { indices: [], other: 'SSH host' } + ]) + const encoded = mocks.respond.mock.calls[0]?.[1] + expect(decodeAgentSessionQuestionAnswers(encoded)).toEqual([ + { questionId: 'q1', optionIds: ['target-web', 'target-mobile'] }, + { questionId: 'q2', optionIds: [], other: 'SSH host' } + ]) + }) + + it('keeps legacy single-question option ids and free text behavior', () => { + mocks.promptItems = [ + { + itemId: 'legacy-question-item', + revision: 1, + sequence: 1, + observedAt: 1, + body: { + kind: 'question', + question: 'Pick a library', + options: [ + { id: 'q1:choice-1', label: 'React' }, + { id: 'q1:choice-2', label: 'Vue' } + ], + freeTextQuestionId: 'q1', + resolution: { + state: 'pending', + selectedOptionId: null, + resolvedBy: null, + resolvedAt: null + } + } + } + ] + + render( + + ) + + const card = mocks.questionCardProps + if (!card) { + throw new Error('question card was not rendered') + } + expect(card.prompt.questions).toEqual([ + { + question: 'Pick a library', + multiSelect: false, + options: [{ label: 'React' }, { label: 'Vue' }] + } + ]) + card.onAnswer([{ indices: [1], other: '' }]) + expect(mocks.respond).toHaveBeenCalledWith(mocks.promptItems[0], 'q1:choice-2') + }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index f7d2fd62663..d6464c27760 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -4,6 +4,7 @@ import type { AgentStatusOrchestrationContext, AgentType } from '../../../../shared/agent-status-types' +import { encodeAgentSessionQuestionAnswers } from '../../../../shared/agent-session-question-answer' import { dispatchStructuredAgentSessionComposerCommand } from '../../../../shared/structured-agent-session-composer' import { structuredAgentSessionPaneKey } from '../../../../shared/structured-agent-session-projection' import type { NativeChatLiveSession } from './use-native-chat-live-session' @@ -85,6 +86,21 @@ export function NativeChatStructuredSession(props: { const fileLinkClick = useNativeChatFileLinkClick(props.allowFileUriLinks ? fileLinkContext : null) const prompt = controller.prompts[0] ?? null const questionBody = prompt?.body.kind === 'question' ? prompt.body : null + const questions = + questionBody?.questions ?? + (questionBody + ? [ + { + id: questionBody.freeTextQuestionId ?? 'q1', + question: questionBody.question, + options: questionBody.options, + multiSelect: false, + ...(questionBody.freeTextQuestionId + ? { freeTextQuestionId: questionBody.freeTextQuestionId } + : {}) + } + ] + : []) const retryableOutboxEntry = controller.outbox.find((entry) => entry.state === 'unconfirmed') ?? controller.outbox.find( @@ -166,17 +182,39 @@ export function NativeChatStructuredSession(props: { ) : null} {prompt && questionBody ? ( ({ label: option.label })) - } - ] + questions: questions.map((question) => ({ + question: question.question, + ...(question.header ? { header: question.header } : {}), + multiSelect: question.multiSelect, + options: question.options.map((option) => ({ + label: option.label, + ...(option.description ? { description: option.description } : {}) + })) + })) }} - allowOther={Boolean(questionBody.freeTextQuestionId)} + allowOther={questions.map((question) => Boolean(question.freeTextQuestionId))} onAnswer={(answers) => { + if (questionBody.questions) { + const grouped = questions.map((question, questionIndex) => { + const answer = answers[questionIndex] + const other = answer?.other?.trim() + const optionIds = (answer?.indices ?? []).flatMap((optionIndex) => { + const optionId = question.options[optionIndex]?.id + return optionId ? [optionId] : [] + }) + return { + questionId: question.id, + optionIds: question.multiSelect || !other ? optionIds : [], + ...(other ? { other } : {}) + } + }) + if (grouped.every((answer) => answer.optionIds.length > 0 || answer.other)) { + void controller.respond(prompt, encodeAgentSessionQuestionAnswers(grouped)) + } + return + } const index = answers[0]?.indices[0] const other = answers[0]?.other?.trim() const optionId = diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionHandoffChrome.test.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionHandoffChrome.test.tsx new file mode 100644 index 00000000000..4a185a3d630 --- /dev/null +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionHandoffChrome.test.tsx @@ -0,0 +1,58 @@ +// @vitest-environment happy-dom + +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionHandoffStatus } from '../../../../shared/agent-session-wire' +import { StructuredAgentSessionHandoffChrome } from './StructuredAgentSessionHandoffChrome' + +const IDLE_NATIVE: AgentSessionHandoffStatus = { + owner: 'native', + direction: null, + phase: 'idle', + stage: null, + operationId: null +} + +afterEach(cleanup) + +describe('StructuredAgentSessionHandoffChrome', () => { + it('uses queued-safe admission when the native view still appears idle', () => { + const onRequest = vi.fn() + render( + + ) + + fireEvent.click(screen.getByRole('button', { name: 'Open agent TUI' })) + + expect(onRequest).toHaveBeenCalledWith('to-tui', 'after-turn') + }) + + it('offers one Retry action for a recoverable dead TUI owner', () => { + const onRequest = vi.fn() + render( + + ) + + expect(screen.queryByRole('button', { name: 'Return to chat' })).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Retry' })) + expect(onRequest).toHaveBeenCalledWith('to-native', 'now', 'retry') + }) +}) diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionHandoffChrome.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionHandoffChrome.tsx new file mode 100644 index 00000000000..840039564d8 --- /dev/null +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionHandoffChrome.tsx @@ -0,0 +1,225 @@ +import type { + AgentSessionHandoffDirection, + AgentSessionHandoffMode, + AgentSessionHandoffStatus +} from '../../../../shared/agent-session-wire' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { translate } from '@/i18n/i18n' + +type Props = { + status: AgentSessionHandoffStatus | null + isWorking: boolean + onRequest: ( + direction: AgentSessionHandoffDirection, + mode: AgentSessionHandoffMode, + action?: 'start' | 'cancel-queued' | 'retry' | 'recover' + ) => void +} + +function handoffStageCopy(status: AgentSessionHandoffStatus): string { + if (status.stage === 'preparing') { + return status.direction === 'to-tui' + ? translate('components.native-chat.handoff.stage.finishingChat', 'Finishing chat session…') + : translate( + 'components.native-chat.handoff.stage.finishingTerminal', + 'Finishing agent terminal…' + ) + } + if (status.stage === 'old-owner-stopped') { + return status.direction === 'to-tui' + ? translate('components.native-chat.handoff.stage.openingTerminal', 'Opening agent terminal…') + : translate('components.native-chat.handoff.stage.resumingChat', 'Resuming chat session…') + } + if (status.stage === 'new-owner-proving') { + return status.direction === 'to-tui' + ? translate( + 'components.native-chat.handoff.stage.verifyingTerminal', + 'Verifying agent terminal…' + ) + : translate('components.native-chat.handoff.stage.verifyingChat', 'Verifying chat session…') + } + if (status.stage === 'recovering') { + return translate('components.native-chat.handoff.stage.recovering', 'Recovering agent session…') + } + if (status.stage === 'manual-recovery') { + return translate( + 'components.native-chat.handoff.stage.manualRecovery', + 'Agent session needs recovery' + ) + } + return translate('components.native-chat.handoff.switchingOwner', 'Switching session owner…') +} + +export function StructuredAgentSessionHandoffChrome({ + status, + isWorking, + onRequest +}: Props): React.JSX.Element | null { + if (!status) { + return null + } + const owner = status?.owner ?? 'native' + const phase = status?.phase ?? 'idle' + const switching = phase === 'switching' || phase === 'waiting-for-exit' + return ( + <> +
+ + {switching + ? translate('components.native-chat.handoff.mode.switching', 'Switching') + : owner === 'tui' + ? translate('components.native-chat.handoff.mode.terminal', 'Terminal') + : translate('components.native-chat.handoff.mode.chat', 'Chat')} + +
+ {phase === 'queued' && status?.direction ? ( + <> + + {status.direction === 'to-tui' + ? translate( + 'components.native-chat.handoff.switchingAfterTurn', + 'Switching after this turn' + ) + : translate( + 'components.native-chat.handoff.returningAfterTurn', + 'Returning after this turn' + )} + + + + ) : owner === 'native' && phase === 'idle' ? ( + isWorking ? ( + <> + + + + ) : ( + + ) + ) : owner === 'tui' && phase === 'idle' ? ( + + ) : null} +
+
+ {owner === 'tui' && phase === 'idle' ? ( +
+ + {status?.hostLabel + ? translate( + 'components.native-chat.handoff.agentOpenOnHost', + 'Agent is open in terminal on {{value0}}.', + { value0: status.hostLabel } + ) + : translate('components.native-chat.handoff.agentOpen', 'Agent is open in terminal.')} + + +
+ ) : null} + {switching ? ( +
+ {phase === 'waiting-for-exit' + ? translate( + 'components.native-chat.handoff.exitTerminal', + 'Exit the agent terminal to continue in chat.' + ) + : status?.stage + ? handoffStageCopy(status) + : translate( + 'components.native-chat.handoff.switchingOwner', + 'Switching session owner…' + )} +
+ ) : null} + {phase === 'failed' && status?.error ? ( +
+
+ {status.error.message} + {status.direction && status.error.canRetryProof ? ( + + ) : status.direction && status.error.recoverableOwner !== 'none' ? ( + + ) : null} +
+ {status.error.details ? ( +
+ {translate('components.native-chat.handoff.details', 'Details')} +

{status.error.details}

+
+ ) : null} +
+ ) : null} + + ) +} diff --git a/src/renderer/src/components/native-chat/native-chat-pty-session-options.test.ts b/src/renderer/src/components/native-chat/native-chat-pty-session-options.test.ts index 9dae22da197..8cf82b69ca1 100644 --- a/src/renderer/src/components/native-chat/native-chat-pty-session-options.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-pty-session-options.test.ts @@ -117,6 +117,7 @@ describe('native chat PTY session options', () => { expect(effortResult.snapshot.map(({ id }) => id)).toEqual(['model', 'effort', 'fastMode']) expect(effortResult.snapshot.find(({ id }) => id === 'effort')).toMatchObject({ valueSource: 'dispatched', + transport: 'catalog', kind: { currentValue: 'high' } }) expect(listener).toHaveBeenCalledOnce() diff --git a/src/renderer/src/components/native-chat/native-chat-pty-session-options.ts b/src/renderer/src/components/native-chat/native-chat-pty-session-options.ts index aa7562b5944..3e3587e68d1 100644 --- a/src/renderer/src/components/native-chat/native-chat-pty-session-options.ts +++ b/src/renderer/src/components/native-chat/native-chat-pty-session-options.ts @@ -98,7 +98,8 @@ export function createNativeChatPtySessionOptions( catalog, models: activeModels(), record, - mode: args.mode + mode: args.mode, + liveTransport: 'catalog' }) const listeners = new Set<(value: SessionOptionDescriptor[]) => void>() @@ -108,7 +109,8 @@ export function createNativeChatPtySessionOptions( catalog, models: activeModels(), record, - mode: args.mode + mode: args.mode, + liveTransport: 'catalog' }) for (const listener of listeners) { listener(snapshot) diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-labels.test.ts b/src/renderer/src/components/native-chat/native-chat-session-option-labels.test.ts index 60d0ffe6aba..7cdf621b272 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-option-labels.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-option-labels.test.ts @@ -18,6 +18,7 @@ function modelDescriptor( id: 'model', label: 'Model', valueSource, + transport: 'catalog', settable: true, kind: { type: 'select', diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-snapshot.ts b/src/renderer/src/components/native-chat/native-chat-session-option-snapshot.ts index 9010aa66f27..4acb1a1932a 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-option-snapshot.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-option-snapshot.ts @@ -7,6 +7,7 @@ import { buildNativeChatSessionOptionSnapshot as buildSharedSnapshot, resolveEffectiveNativeChatModelId, withTrackedNativeChatModel, + type NativeChatLiveOptionTransport, type NativeChatSessionOptionMode } from '../../../../shared/native-chat-session-option-snapshot' import { @@ -15,7 +16,7 @@ import { } from '../../../../shared/native-chat-session-option-state' import { translate } from '@/i18n/i18n' -export type { NativeChatSessionOptionMode } +export type { NativeChatLiveOptionTransport, NativeChatSessionOptionMode } export { flattenNativeChatSessionOptionRecord, resolveEffectiveNativeChatModelId, @@ -27,6 +28,7 @@ export function buildNativeChatSessionOptionSnapshot(args: { models: readonly CatalogModel[] record: NativeChatSessionOptionRecord mode: NativeChatSessionOptionMode + liveTransport: NativeChatLiveOptionTransport }): SessionOptionDescriptor[] { return buildSharedSnapshot({ ...args, diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.ts b/src/renderer/src/components/native-chat/use-structured-agent-session.ts index 5bea1af8c50..4f8c37116af 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.ts @@ -136,6 +136,11 @@ export function useStructuredAgentSession(args: { [sessionId, target] ) + // Turns are what confirm an option: the provider names the model it is running + // on the frame that opens each one, so re-read the options as a turn changes + // rather than leaving the last write unconfirmed for the life of the session. + const turnId = activeStructuredAgentSessionTurnId(state.items) + useEffect(() => { if (!isVisible || !optionCatalog) { return @@ -157,7 +162,7 @@ export function useStructuredAgentSession(args: { return () => { stale = true } - }, [isVisible, optionCatalog, sessionId, state.fence, target]) + }, [isVisible, optionCatalog, sessionId, state.fence, target, turnId]) const optionSnapshot = useMemo( () => structuredAgentSessionOptionSnapshot(optionState), @@ -219,7 +224,6 @@ export function useStructuredAgentSession(args: { (item.body.kind === 'approval' || item.body.kind === 'question') && item.body.resolution.state === 'pending' ) - const turnId = activeStructuredAgentSessionTurnId(state.items) return { messages: projectStructuredAgentSessionMessages( state.items, diff --git a/src/renderer/src/components/settings/ExperimentalPane.test.tsx b/src/renderer/src/components/settings/ExperimentalPane.test.tsx index b421b77bfc2..ba8e1518921 100644 --- a/src/renderer/src/components/settings/ExperimentalPane.test.tsx +++ b/src/renderer/src/components/settings/ExperimentalPane.test.tsx @@ -258,8 +258,12 @@ describe('ExperimentalPane', () => { }) expect(container.textContent).toContain('Use updated structured native chat') + // The one opt-in gates both providers, so its copy must not name only Codex. expect(container.textContent).toContain( - 'Local macOS and Linux sessions only for now. Windows, WSL, and remote execution hosts (including SSH) continue to use terminal chat.' + 'Opt in to the host-owned structured chat runtime for Codex and Claude.' + ) + expect(container.textContent).toContain( + 'Local sessions only for now. WSL and remote execution hosts (including SSH) continue to use terminal chat, and Windows falls back to it unless Orca can read process start times.' ) expect(container.textContent).toContain('Default view') root.unmount() diff --git a/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx b/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx index 93c4b1c899d..85d27dae2e3 100644 --- a/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx +++ b/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx @@ -126,13 +126,13 @@ export function NativeChatExperimentalSetting({

{translate( 'auto.components.settings.ExperimentalPane.nativeChat.structuredCopy', - 'Opt in to the host-owned structured Codex runtime. Off keeps the existing terminal-backed chat path.' + 'Opt in to the host-owned structured chat runtime for Codex and Claude. Off keeps the existing terminal-backed chat path.' )}

{translate( 'auto.components.settings.ExperimentalPane.nativeChat.structuredScope', - 'Local macOS and Linux sessions only for now. Windows, WSL, and remote execution hosts (including SSH) continue to use terminal chat.' + 'Local sessions only for now. WSL and remote execution hosts (including SSH) continue to use terminal chat, and Windows falls back to it unless Orca can read process start times.' )}

diff --git a/src/renderer/src/components/sidebar/NonGitFolderDialog.tsx b/src/renderer/src/components/sidebar/NonGitFolderDialog.tsx index 5709dc87f90..13f3a0c883d 100644 --- a/src/renderer/src/components/sidebar/NonGitFolderDialog.tsx +++ b/src/renderer/src/components/sidebar/NonGitFolderDialog.tsx @@ -17,8 +17,9 @@ import { markOnboardingProjectAdded } from '@/lib/onboarding-project-checklist' import { translate } from '@/i18n/i18n' import { upsertAddedRepoWithProjectHostSetup } from './add-repo-store-upsert' import { worktreeRefreshOptions } from './add-repo-runtime-owner' -import { startStructuredCodexLaunch } from '@/lib/structured-agent-session-launch' -import { StructuredAgentSessionCreateRefusalError } from '@/lib/launch-structured-codex-session' +import { startStructuredAgentLaunch } from '@/lib/structured-agent-session-launch' +import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle' +import { StructuredAgentSessionCreateRefusalError } from '@/lib/launch-structured-agent-session' const NonGitFolderDialog = React.memo(function NonGitFolderDialog() { const activeModal = useAppStore((s) => s.activeModal) @@ -101,8 +102,11 @@ const NonGitFolderDialog = React.memo(function NonGitFolderDialog() { ...(launch.startup ? { startup: launch.startup } : {}), ...(launch.route === 'structured-native-chat' ? { providesInitialSurface: true } : {}) }) - if (launch.route === 'structured-native-chat' && launch.agent === 'codex') { - const structured = startStructuredCodexLaunch(folderWorktree.id) + if ( + launch.route === 'structured-native-chat' && + isAgentSessionHandleProvider(launch.agent) + ) { + const structured = startStructuredAgentLaunch(folderWorktree.id, launch.agent) const fallback = structured.claimDefinitiveRefusalFallback(() => { activateAndRevealWorktree(folderWorktree.id, { sidebarRevealBehavior: 'auto', diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts index 661bf623880..b6f1a1654f3 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts @@ -28,8 +28,9 @@ import { resolveAgentLaunchRoute } from '@/lib/agent-launch-routing' import { readLocalRuntimeCapabilities } from '@/runtime/local-runtime-capabilities' -import { startStructuredCodexLaunch } from '@/lib/structured-agent-session-launch' -import { StructuredAgentSessionCreateRefusalError } from '@/lib/launch-structured-codex-session' +import { startStructuredAgentLaunch } from '@/lib/structured-agent-session-launch' +import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle' +import { StructuredAgentSessionCreateRefusalError } from '@/lib/launch-structured-agent-session' import { useAppStore } from '@/store' import { buildFolderWorkspaceLinkedStartupPlan, @@ -232,8 +233,8 @@ export async function submitFolderWorkspaceCreate({ runtimeEnvironmentId }) let structuredLaunchAccepted = structuredLaunch - if (structuredLaunch && quickAgent === 'codex') { - const launch = startStructuredCodexLaunch(folderWorkspaceKey(workspace.id), { + if (structuredLaunch && isAgentSessionHandleProvider(quickAgent)) { + const launch = startStructuredAgentLaunch(folderWorkspaceKey(workspace.id), quickAgent, { prompt: launchDraftPrompt ?? note }) const refusalFallback = launch.claimDefinitiveRefusalFallback(async () => { diff --git a/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx b/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx index 85d978e3bf6..6d5b523c2af 100644 --- a/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx +++ b/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx @@ -8,6 +8,7 @@ import { useAgentDetectionTargetForWorktree } from '@/hooks/useAgentDetectionTar import { useDetectedAgents } from '@/hooks/useDetectedAgents' import { useOptionalShortcutLabel } from '@/hooks/useShortcutLabel' import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab' +import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle' import type { TuiAgent } from '../../../../shared/tui-agent' import type { LaunchSource } from '../../../../shared/telemetry-events' import { @@ -15,7 +16,7 @@ import { filterEnabledTuiAgents } from '../../../../shared/tui-agent-selection' import { translate } from '@/i18n/i18n' -import { useStructuredCodexLaunchStatus } from '@/lib/structured-agent-session-launch' +import { useStructuredAgentLaunchStatus } from '@/lib/structured-agent-session-launch' export type QuickLaunchAgentMenuItemsProps = { worktreeId: string @@ -117,7 +118,12 @@ function QuickLaunchAgentMenuItemsInner({ const openSettingsPage = useAppStore((s) => s.openSettingsPage) const openSettingsTarget = useAppStore((s) => s.openSettingsTarget) const newAgentShortcut = useOptionalShortcutLabel('tab.newAgent') - const structuredCodexLaunchStatus = useStructuredCodexLaunchStatus(worktreeId) + // One hook per structured provider: the launch registry is keyed by agent, and hooks cannot run + // inside the agent list's render loop. + const structuredLaunchStatusByAgent = { + claude: useStructuredAgentLaunchStatus(worktreeId, 'claude'), + codex: useStructuredAgentLaunchStatus(worktreeId, 'codex') + } const openAgentSettings = useCallback(() => { openSettingsTarget({ pane: 'agents', repoId: null }) @@ -199,26 +205,33 @@ function QuickLaunchAgentMenuItemsInner({ {agents.map((agent) => { const entry = getCatalogEntry(agent) const label = entry?.label ?? agent - const isStructuredCodexPending = - agent === 'codex' && structuredCodexLaunchStatus === 'pending' - const menuLabel = isStructuredCodexPending ? 'Starting Codex chat…' : label + const isStructuredLaunchPending = + isAgentSessionHandleProvider(agent) && structuredLaunchStatusByAgent[agent] === 'pending' + const pendingLabel = translate( + 'components.native-chat.structuredSessionLaunchPending', + 'Starting {{value0}} chat…', + { value0: label } + ) + const menuLabel = isStructuredLaunchPending ? pendingLabel : label const showsDefaultAgentShortcut = newAgentShortcut !== null && defaultAgent !== 'blank' && agent === defaultAgent return ( runLaunch(agent)} className="gap-2 rounded-[7px] px-2 py-1.5 text-[12px] leading-5 font-medium" - title={translate( - 'auto.components.tab.bar.QuickLaunchButton.ec2adf093e', - isStructuredCodexPending - ? 'Starting Codex chat…' - : 'Launch {{value0}} in a new terminal', - isStructuredCodexPending ? undefined : { value0: label } - )} + title={ + isStructuredLaunchPending + ? pendingLabel + : translate( + 'auto.components.tab.bar.QuickLaunchButton.ec2adf093e', + 'Launch {{value0}} in a new terminal', + { value0: label } + ) + } > - {isStructuredCodexPending ? ( + {isStructuredLaunchPending ? (