diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index de4592af128d..85a77f0c3d23 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -195,7 +195,7 @@ jobs: # The Azure archive took 16 minutes for one font package; bound setup # separately so a slow mirror cannot consume the shard's test budget. env: &e2e_tool_packages - ORCA_E2E_APT_PACKAGES: build-essential fonts-noto-cjk openssh-client python3 ripgrep xvfb zsh openbox x11-utils + ORCA_E2E_APT_PACKAGES: build-essential fonts-noto-cjk openssh-client python3 ripgrep xvfb zsh openbox x11-utils xdotool run: *install_e2e_tools - uses: ./.github/actions/install-node-dependencies diff --git a/config/fork-ownership.json b/config/fork-ownership.json index 9ef2f227ca90..4f8e528e3d39 100644 --- a/config/fork-ownership.json +++ b/config/fork-ownership.json @@ -5427,18 +5427,6 @@ "status": "pending-upstream", "ledger": "docs/fork-upstreaming.md#federated-rejection-replay" }, - { - "path": "src/main/claude-accounts/runtime-auth/runtime-auth-sync.ts", - "reason": "Clear the host runtime account selection together with the legacy selection when managed credentials are missing, invalid, or unowned; otherwise structured launches strip valid system authentication after fallback.", - "status": "pending-upstream", - "ledger": "docs/fork-upstreaming.md#claude-host-auth-fallback-selection" - }, - { - "path": "src/main/claude-accounts/runtime-auth-service-materialization.test.ts", - "reason": "Regression coverage for host managed-auth fallback clearing the effective account selection while preserving unrelated WSL selections and allowing system authentication.", - "status": "pending-upstream", - "ledger": "docs/fork-upstreaming.md#claude-host-auth-fallback-selection" - }, { "path": "docs/reference/remote-wire-compatibility.md", "reason": "Upstream deleted this contract in v1.4.224, but the fork's AGENTS.md still points at it; it keeps the fork's strict-schema correction to Rule 1 (a `.strict()` schema rejects an unknown key) and upstream's last viewer-activation section.", @@ -5495,6 +5483,11 @@ "reason": "Generates the fork's own README download-count badge.", "status": "permanent" }, + { + "path": ".github/workflows/relay-windows-process-tree.yml", + "reason": "The fork still ships the SSH relay, and release-cut's relay-windows-process-tree job calls this workflow to build the Windows relay addon; upstream deleted it when its main dropped the relay.", + "status": "permanent" + }, { "path": ".github/workflows/release-cut.yml", "reason": "Fork release-cut pipeline; upstream has no equivalent workflow.", diff --git a/config/scripts/ci-shard-timings.json b/config/scripts/ci-shard-timings.json index 41039fa66f34..07249c0af224 100644 --- a/config/scripts/ci-shard-timings.json +++ b/config/scripts/ci-shard-timings.json @@ -1262,7 +1262,7 @@ "src/main/codex/codex-structured-dispatch-admission.test.ts": 507, "src/main/codex/codex-structured-dispatch-boundary.test.ts": 414, "src/main/codex/codex-structured-dispatch-echo.test.ts": 57, - "src/main/codex/codex-structured-fast-mode.test.ts": 320, + "src/main/codex/codex-structured-service-tier.test.ts": 320, "src/main/codex/codex-structured-item-translation.test.ts": 161, "src/main/codex/codex-structured-journal-compactions.test.ts": 267, "src/main/codex/codex-structured-journal-goal-admission.test.ts": 258, diff --git a/config/scripts/claude-account-windows-spawn-repro.mjs b/config/scripts/claude-account-windows-spawn-repro.mjs deleted file mode 100644 index eaf04762e5ba..000000000000 --- a/config/scripts/claude-account-windows-spawn-repro.mjs +++ /dev/null @@ -1,342 +0,0 @@ -import { spawn } from 'node:child_process' -import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { buildWindowsCommandInvocation } from '../../src/main/claude-accounts/windows-command-invocation.ts' - -const strategy = process.argv[2] -if (!['baseline', 'candidate', 'explicit-cmd'].includes(strategy)) { - throw new Error( - 'Usage: node config/scripts/claude-account-windows-spawn-repro.mjs ' - ) -} -if (process.platform !== 'win32') { - throw new Error('This reproduction requires a physical Windows host.') -} - -const expectedArgs = [ - '', - 'two words', - 'amp&ersand', - 'pipe|value', - 'lessvalue', - 'caret^value', - 'trailing\\', - 'two-trailing\\\\', - '(parentheses)', - '100%', - '%ORCA_ARG_TRAP%', - 'bang!value', - '한글-λ' -] -const tempRoot = await mkdtemp(join(tmpdir(), 'orca-claude-spawn-')) -const reportedDir = join(tempRoot, 'Profile with spaces 한글') -const reportedCapturePath = join(reportedDir, 'capture.json') -const reportedPidPath = join(reportedDir, 'pids.json') -const reportedShimPath = join(reportedDir, 'claude fixture.cmd') -const reportedFixturePath = join(reportedDir, 'capture-child.cjs') -const fixtureDir = join(tempRoot, 'Profile space & ^ (paren) %ORCA_PATH_TRAP% !bang! 한글') -const capturePath = join(fixtureDir, 'capture.json') -const pidPath = join(fixtureDir, 'pids.json') -const shimPath = join(fixtureDir, 'claude fixture.cmd') -const fixturePath = join(fixtureDir, 'capture-child.cjs') -const fixtureEnv = { - ...process.env, - CLAUDE_CONFIG_DIR: join(fixtureDir, 'config space & ^ (paren) %ORCA_ENV_LITERAL% !bang! 한글'), - ORCA_ARG_TRAP: 'EXPANDED_ARG', - ORCA_PATH_TRAP: 'EXPANDED_PATH', - ORCA_FIXTURE_CAPTURE: capturePath, - ORCA_FIXTURE_PIDS: pidPath, - ORCA_FIXTURE_NODE: process.execPath -} -const reportedEnv = { - ...fixtureEnv, - CLAUDE_CONFIG_DIR: join(reportedDir, 'config with spaces 한글'), - ORCA_FIXTURE_CAPTURE: reportedCapturePath, - ORCA_FIXTURE_PIDS: reportedPidPath -} - -function quoteForCandidate(value) { - return `"${value.replace(/"/g, '""')}"` -} - -function launch(args, command = shimPath, env = fixtureEnv) { - if (strategy === 'baseline') { - return spawn(command, args, { cwd: tempRoot, env, shell: true, windowsHide: true }) - } - if (strategy === 'candidate') { - return spawn(quoteForCandidate(command), args, { - cwd: tempRoot, - env, - shell: true, - windowsHide: true - }) - } - const invocation = buildWindowsCommandInvocation(command, args) - return spawn(invocation.command, invocation.args, { - cwd: tempRoot, - env, - shell: false, - windowsVerbatimArguments: invocation.windowsVerbatimArguments, - windowsHide: true - }) -} - -function collect(child) { - return new Promise((resolve) => { - let stdout = '' - let stderr = '' - child.stdout?.on('data', (chunk) => (stdout += chunk.toString())) - child.stderr?.on('data', (chunk) => (stderr += chunk.toString())) - child.on('error', (error) => resolve({ code: null, stdout, stderr, error: error.message })) - child.on('close', (code) => resolve({ code, stdout, stderr, error: null })) - }) -} - -async function waitForFile(path, timeoutMs = 5_000) { - const deadline = Date.now() + timeoutMs - while (Date.now() < deadline) { - try { - return JSON.parse(await readFile(path, 'utf8')) - } catch { - await new Promise((resolve) => setTimeout(resolve, 25)) - } - } - throw new Error(`Timed out waiting for fixture output: ${path}`) -} - -async function taskExists(pid) { - const result = await collect( - spawn('tasklist.exe', ['/fi', `PID eq ${pid}`, '/fo', 'csv', '/nh'], { - windowsHide: true - }) - ) - if (result.error || result.code !== 0) { - throw new Error(`tasklist failed for PID ${pid}: ${result.error ?? result.stderr}`) - } - return result.stdout.includes(`"${pid}"`) -} - -async function killTree(pid) { - const result = await collect( - spawn('taskkill.exe', ['/pid', String(pid), '/t', '/f'], { windowsHide: true }) - ) - if (result.error || result.code !== 0) { - throw new Error(`taskkill failed for PID ${pid}: ${result.error ?? result.stderr}`) - } -} - -async function waitForTreeExit(pids, timeoutMs = 5_000) { - const deadline = Date.now() + timeoutMs - let alive = {} - do { - alive = Object.fromEntries( - await Promise.all( - Object.entries(pids).map(async ([name, pid]) => [name, await taskExists(pid)]) - ) - ) - if (!Object.values(alive).some(Boolean)) { - return alive - } - await new Promise((resolve) => setTimeout(resolve, 50)) - } while (Date.now() < deadline) - return alive -} - -const results = { - strategy, - reportedPath: null, - pathMatrix: {}, - argvMatrix: {}, - hostilePathAndArgv: null, - error: null, - cancellation: null -} -const fixtureSource = - `const { spawn } = require('node:child_process')\n` + - `const { writeFileSync } = require('node:fs')\n` + - `if (process.argv[2] === '--exit-error') { process.stderr.write('fixture error: 한글 & ^ % !\\n'); process.exit(23) }\n` + - `if (process.argv[2] === '--linger') {\n` + - ` const grandchild = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { windowsHide: true })\n` + - ` writeFileSync(process.env.ORCA_FIXTURE_PIDS, JSON.stringify({ child: process.pid, grandchild: grandchild.pid }))\n` + - ` setInterval(() => {}, 1000)\n` + - `} else {\n` + - ` writeFileSync(process.env.ORCA_FIXTURE_CAPTURE, JSON.stringify({ argv: process.argv.slice(2), configDir: process.env.CLAUDE_CONFIG_DIR }))\n` + - `}\n` -const shimSource = '@echo off\r\n"%ORCA_FIXTURE_NODE%" "%~dp0capture-child.cjs" %*\r\n' -let lingeringShellPid = null -try { - await mkdir(fixtureDir, { recursive: true }) - await mkdir(reportedDir, { recursive: true }) - await writeFile(fixturePath, fixtureSource, 'utf8') - await writeFile(shimPath, shimSource, 'utf8') - await writeFile(reportedFixturePath, fixtureSource, 'utf8') - await writeFile(reportedShimPath, shimSource, 'utf8') - - const reportedArgs = ['auth', 'status', '--json'] - const reportedRun = await collect(launch(reportedArgs, reportedShimPath, reportedEnv)) - let reportedCapture = null - try { - reportedCapture = await waitForFile(reportedCapturePath, 1_000) - } catch {} - results.reportedPath = { - ...reportedRun, - actual: reportedCapture, - expected: { argv: reportedArgs, configDir: reportedEnv.CLAUDE_CONFIG_DIR }, - pass: - reportedRun.code === 0 && - JSON.stringify(reportedCapture) === - JSON.stringify({ argv: reportedArgs, configDir: reportedEnv.CLAUDE_CONFIG_DIR }) - } - - for (const [name, segment] of Object.entries({ - spaces: 'profile space', - ampersand: 'profile&name', - caret: 'profile^name', - parentheses: 'profile(name)', - percent: 'profile%ORCA_PATH_TRAP%', - bang: 'profile!name', - unicode: 'profile-한글-λ' - })) { - const directory = join(tempRoot, segment) - const captureFile = join(directory, 'capture.json') - const command = join(directory, 'claude fixture.cmd') - await mkdir(directory, { recursive: true }) - await writeFile(join(directory, 'capture-child.cjs'), fixtureSource, 'utf8') - await writeFile(command, shimSource, 'utf8') - const env = { - ...fixtureEnv, - CLAUDE_CONFIG_DIR: join(directory, 'config'), - ORCA_FIXTURE_CAPTURE: captureFile - } - const run = await collect(launch(reportedArgs, command, env)) - let actual = null - try { - actual = await waitForFile(captureFile, 500) - } catch {} - results.pathMatrix[name] = { - code: run.code, - stderr: run.stderr.trim(), - actual: actual?.argv ?? null, - pass: run.code === 0 && JSON.stringify(actual?.argv) === JSON.stringify(reportedArgs) - } - } - - for (const [name, value] of Object.entries({ - empty: '', - spaces: 'two words', - ampersand: 'amp&ersand', - pipe: 'pipe|value', - lessThan: 'lessvalue', - caret: 'caret^value', - trailingBackslash: 'trailing\\', - twoTrailingBackslashes: 'two-trailing\\\\', - parentheses: '(parentheses)', - percent: '%ORCA_ARG_TRAP%', - bang: 'bang!value', - unicode: '한글-λ' - })) { - const args = ['prefix', value, 'suffix'] - const captureFile = join(reportedDir, `capture-${name}.json`) - const env = { ...reportedEnv, ORCA_FIXTURE_CAPTURE: captureFile } - const run = await collect(launch(args, reportedShimPath, env)) - let actual = null - try { - actual = await waitForFile(captureFile, 500) - } catch {} - results.argvMatrix[name] = { - code: run.code, - stderr: run.stderr.trim(), - actual: actual?.argv ?? null, - pass: run.code === 0 && JSON.stringify(actual?.argv) === JSON.stringify(args) - } - } - - const argvRun = await collect(launch(expectedArgs)) - let capture = null - try { - capture = await waitForFile(capturePath, 1_000) - } catch {} - results.hostilePathAndArgv = { - ...argvRun, - actual: capture, - expected: { argv: expectedArgs, configDir: fixtureEnv.CLAUDE_CONFIG_DIR }, - pass: - argvRun.code === 0 && - JSON.stringify(capture) === - JSON.stringify({ argv: expectedArgs, configDir: fixtureEnv.CLAUDE_CONFIG_DIR }) - } - - results.error = await collect(launch(['--exit-error'], reportedShimPath, reportedEnv)) - results.error.pass = - results.error.code === 23 && results.error.stderr.includes('fixture error: 한글 & ^ % !') - - const lingering = launch(['--linger'], reportedShimPath, reportedEnv) - lingeringShellPid = lingering.pid - const lingeringResult = collect(lingering) - try { - const pids = await waitForFile(reportedPidPath) - await killTree(lingering.pid) - const alive = await waitForTreeExit({ - shell: lingering.pid, - child: pids.child, - grandchild: pids.grandchild - }) - results.cancellation = { - shell: lingering.pid, - ...pids, - alive, - pass: !Object.values(alive).some(Boolean) - } - } catch (error) { - if (await taskExists(lingering.pid)) { - await killTree(lingering.pid) - } - const launchResult = await Promise.race([ - lingeringResult, - new Promise((resolve) => - setTimeout( - () => resolve({ code: null, error: 'fixture did not exit after cleanup' }), - 5_000 - ) - ) - ]) - results.cancellation = { - shell: lingering.pid, - launchResult, - pass: false, - error: error instanceof Error ? error.message : String(error) - } - } -} finally { - if (lingeringShellPid && (await taskExists(lingeringShellPid))) { - await killTree(lingeringShellPid) - } - try { - let pids - try { - pids = JSON.parse(await readFile(reportedPidPath, 'utf8')) - } catch { - pids = JSON.parse(await readFile(pidPath, 'utf8')) - } - for (const pid of [pids.child, pids.grandchild]) { - if (await taskExists(pid)) { - await killTree(pid) - } - } - } catch {} - await rm(tempRoot, { recursive: true, force: true }) -} - -console.log(JSON.stringify(results, null, 2)) -process.exitCode = - results.reportedPath?.pass && - Object.values(results.pathMatrix).every((result) => result.pass) && - Object.values(results.argvMatrix).every((result) => result.pass) && - results.hostilePathAndArgv?.pass && - results.error?.pass && - results.cancellation?.pass - ? 0 - : 1 diff --git a/config/scripts/locale-ko-key-overrides.json b/config/scripts/locale-ko-key-overrides.json index 6f4948712dd7..5a84d4ef5cbd 100644 --- a/config/scripts/locale-ko-key-overrides.json +++ b/config/scripts/locale-ko-key-overrides.json @@ -3621,7 +3621,7 @@ "ko": "클라이언트" }, "auto.components.settings.settingOwnership.agentLaunchDefaults": { - "ko": "기본 agent, 명령 재정의, CLI 인수, 실행 환경은 클라이언트 환경설정입니다. SSH 및 원격 서버 실행은 실행 시점에 여전히 호스트 사용 가능 여부를 확인합니다." + "ko": "기본 agent, 명령 재정의, CLI 인수 및 실행 환경은 선택한 호스트에 저장됩니다." }, "auto.components.settings.settingOwnership.clientDefault": { "ko": "클라이언트 기본값" diff --git a/config/scripts/mobile-web-app-render-harness.mjs b/config/scripts/mobile-web-app-render-harness.mjs index f07e0c130a1a..66d05b0bffbb 100644 --- a/config/scripts/mobile-web-app-render-harness.mjs +++ b/config/scripts/mobile-web-app-render-harness.mjs @@ -103,7 +103,7 @@ export async function readBridgeProtocolVersion() { export async function readBridgeWindowCaps() { const sources = await Promise.all( [ - 'mobile/src/mobile-web-shell/bridge/bridge-caps.ts', + 'src/shared/mobile-bridge-frame-bytes.ts', 'mobile/src/mobile-web-shell/bridge-host-subscriptions.ts' ].map((path) => readFile(join(projectDir, path), 'utf8')) ) @@ -119,7 +119,7 @@ export async function readBridgeWindowCaps() { .reduce((product, factor) => product * factor, 1) } return { - maxMessageBytes: read('BRIDGE_MAX_MESSAGE_BYTES'), + maxMessageBytes: read('MOBILE_BRIDGE_MAX_FRAME_BYTES'), maxUnackedFrames: read('BRIDGE_MAX_UNACKED_FRAMES'), maxUnackedBytes: read('BRIDGE_MAX_UNACKED_BYTES') } diff --git a/config/scripts/node-pty-windows-pty-teardown-patch.test.mjs b/config/scripts/node-pty-windows-pty-teardown-patch.test.mjs index 74d2304b187a..26428153c0b0 100644 --- a/config/scripts/node-pty-windows-pty-teardown-patch.test.mjs +++ b/config/scripts/node-pty-windows-pty-teardown-patch.test.mjs @@ -10,8 +10,8 @@ // Those numbers are the `!useConptyDll` branch, which is the branch a RELAY runs. Every desktop // site that opens a terminal pane sets `useConptyDll: true` and takes the other branch, where // upstream already destroys the input socket. The hidden rate-limit probe -// (`src/main/rate-limits/claude-pty.ts`) does omit the option and so does run this hunk, but no -// user-visible pane does. The divergence pinned below is about which +// (`src/main/rate-limits/codex-pty-rate-limit-probe.ts`) does omit the option and so does run this +// hunk, but no user-visible pane does. The divergence pinned below is about which // branch each host runs for terminals -- not about a regression in the panes users open. import { createHash } from 'node:crypto' import { createRequire } from 'node:module' diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs index 3a0dd1509703..802fe71bc485 100644 --- a/config/scripts/pr-code-change-scope.mjs +++ b/config/scripts/pr-code-change-scope.mjs @@ -126,7 +126,7 @@ const SHELL_PREFIXES = [ 'src/main/zsh-', 'src/main/runtime/structured-session-cli-login-shell', 'src/main/runtime/structured-session-login-shell-test-harness', - 'src/main/runtime/structured-session-child-identity-env', + 'src/main/runtime/structured-session-child-env', 'src/renderer/src/components/terminal-pane/fish-color-scheme', 'src/shared/fish-', 'src/shared/pty-reply-echo-shapes', diff --git a/config/scripts/pr-code-change-scope.test.mjs b/config/scripts/pr-code-change-scope.test.mjs index fccc569afc58..7e4250dff09e 100644 --- a/config/scripts/pr-code-change-scope.test.mjs +++ b/config/scripts/pr-code-change-scope.test.mjs @@ -281,7 +281,7 @@ describe('per-job path classification', () => { ) for (const file of [ 'src/main/runtime/structured-session-login-shell-test-harness.ts', - 'src/main/runtime/structured-session-child-identity-env.ts' + 'src/main/runtime/structured-session-child-env.ts' ]) { expectClassification([file], { shell_contracts: true, diff --git a/config/scripts/vitest-node-runtime-files.mjs b/config/scripts/vitest-node-runtime-files.mjs index 0303479c7790..06df63fcaf24 100644 --- a/config/scripts/vitest-node-runtime-files.mjs +++ b/config/scripts/vitest-node-runtime-files.mjs @@ -47,6 +47,10 @@ export const NODE_RUNTIME_INCLUDE = [ 'src/main/ai-vault-search/session-search-fts5-contract.test.ts', 'src/main/foreign-sqlite-readers/**/*.test.ts', 'src/main/browser/*cookie*.test.ts', + // Playwright's CDP transport needs Node's WebSocket implementation. + 'src/main/browser/browser-popup-compatibility.electron.test.ts', + 'src/main/browser/browser-popup-native-compatibility.electron.test.ts', + 'src/main/browser/browser-popup-background-compatibility.electron.test.ts', 'src/main/ai-vault/session-scanner-devin-db.test.ts', 'src/shared/workspace-session-salvage.test.ts', 'src/main/git/repo-detection.test.ts', @@ -70,6 +74,8 @@ export const NODE_RUNTIME_INCLUDE = [ 'src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-conversation-name.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-status-first-input-identity.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-permission-mode.test.ts', 'src/main/native-chat/structured-chat-naming-command-first.test.ts', 'src/main/native-chat/structured-chat-naming-status-hook.test.ts', 'src/main/runtime/agent-session-conversation-name-store.test.ts', diff --git a/config/scripts/vitest-sqlite-database-runtime-files.mjs b/config/scripts/vitest-sqlite-database-runtime-files.mjs index d33348671932..dfc8f9978ce4 100644 --- a/config/scripts/vitest-sqlite-database-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-database-runtime-files.mjs @@ -1,7 +1,9 @@ // Database opening and lifecycle fixtures exercise Node SQLite behavior. export const SQLITE_DATABASE_RUNTIME_INCLUDE = [ + 'src/main/ai-vault-search/session-search-dependent-transcripts.test.ts', 'src/main/ai-vault-search/session-search-file-write.test.ts', 'src/main/ai-vault-search/session-search-live-transcript.test.ts', + 'src/main/ai-vault-search/session-search-list-reparse.test.ts', 'src/main/ai-vault/session-scanner-devin-contention.test.ts', 'src/main/ai-vault/session-scanner-opencode-native-worker.test.ts', 'src/main/ai-vault/session-scanner-opencode-sqlite-bounds.test.ts', @@ -75,6 +77,7 @@ export const SQLITE_DATABASE_RUNTIME_INCLUDE = [ 'src/main/runtime/orchestration/db/schema/federated-home-run-migration.test.ts', 'src/main/runtime/orchestration/db/schema/structured-pointer-schema-migration.test.ts', 'src/main/runtime/orchestration/db/schema/structured-worker-orca-session-backfill.test.ts', + 'src/main/runtime/orchestration/db/worker-dispatch/failed-start-circuit-breaker.test.ts', 'src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-abandon.test.ts', 'src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-assignee-orca-session.test.ts', 'src/main/runtime/orchestration/db/writer-run-required.test.ts', @@ -145,6 +148,7 @@ export const SQLITE_DATABASE_RUNTIME_INCLUDE = [ 'src/main/runtime/rpc/methods/orchestration/messaging/check-worker-federated-attachment.test.ts', 'src/main/runtime/rpc/methods/orchestration/runs/migration-behavior.test.ts', 'src/main/runtime/rpc/methods/orchestration/worker/agent-status-producer-census.test.ts', + 'src/main/runtime/rpc/methods/orchestration/worker/failed-start-retry-release.test.ts', 'src/main/runtime/rpc/methods/orchestration/worker/manual-dispatch-observation.test.ts', 'src/main/runtime/rpc/methods/orchestration/worker/manual-dispatch-release.test.ts', 'src/main/runtime/rpc/methods/orchestration/worker/worker-abandon-caller.test.ts', @@ -194,6 +198,9 @@ export const SQLITE_DATABASE_RUNTIME_INCLUDE = [ 'src/main/runtime/structured-claude-auth-policy-wiring.test.ts', 'src/main/runtime/structured-codex-child-work-runtime.test.ts', 'src/main/runtime/structured-worker-at-rest.test.ts', + 'src/main/runtime/worker-terminal-archive-release.test.ts', + 'src/main/runtime/worker-terminal-archive-screen.test.ts', + 'src/main/runtime/worker-terminal-wrap-provenance.test.ts', 'src/main/ssh/ssh-remote-orca-cli.test.ts', 'src/main/ssh/ssh-remote-orchestration-compatibility.test.ts', 'tests/e2e/completed-worker-retirement-resume.unit.test.ts', diff --git a/config/scripts/vitest-sqlite-runtime-boundary.test.ts b/config/scripts/vitest-sqlite-runtime-boundary.test.ts index 2f5c7c59077f..4c86e8a57b82 100644 --- a/config/scripts/vitest-sqlite-runtime-boundary.test.ts +++ b/config/scripts/vitest-sqlite-runtime-boundary.test.ts @@ -8,6 +8,7 @@ import { NODE_RUNTIME_INCLUDE } from './vitest-node-runtime-files.mjs' const sqliteHarnesses = new Set([ 'persistence-test-harness', 'agent-session-record-store-test-harness', + 'structured-agent-session-rest-test-rig', 'journal-host-database-test-support' ]) const sqliteModules = new Set([ @@ -53,6 +54,8 @@ function importsSqliteRuntime(file: string, source: string): boolean { } it.each([ + ['import { createRestTestRig } from "./structured-agent-session-rest-test-rig"', true], + ['import type { RestTestRig } from "./structured-agent-session-rest-test-rig"', false], ['import Database, { type Options } from "./sync-database"', true], ['import {} from "./sync-database"', true], ['import "./sync-database"', true], diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index 85a6f048eb5b..10a5ccd3915c 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -5,6 +5,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ ...SQLITE_DATABASE_RUNTIME_INCLUDE, 'src/main/acp/acp-structured-host-live-listing.test.ts', 'src/main/acp/acp-structured-host-configured-default.test.ts', + 'src/main/pi/rpc-configured-default.test.ts', 'src/main/acp/acp-structured-omp-recordings.test.ts', 'src/main/acp/acp-text-stream-identity.test.ts', 'src/main/acp/acp-text-message-ownership.test.ts', @@ -59,6 +60,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/ipc/ssh-managed-server-move-conversion.test.ts', 'src/main/native-chat/agent-session-attachments/agent-session-attachment-claims.test.ts', 'src/main/native-chat/agent-session-attachments/agent-session-attachment-sweep.test.ts', + 'src/main/native-chat/agent-session-wire/agent-session-bounded-frames.test.ts', 'src/main/native-chat/agent-session-wire/agent-session-history-byte-accounting.test.ts', 'src/main/native-chat/agent-session-wire/agent-session-history-conversation-window.test.ts', 'src/main/native-chat/agent-session-wire/agent-session-history-forward-read-budget.test.ts', @@ -81,6 +83,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-exit-ends-record.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-claude-subagent-row.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-note-end.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-turn-end.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-close-aborts-start.test.ts', @@ -88,10 +91,13 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-note-end.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-command-publication.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-conversations.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-crash-mid-start.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-create-adoption.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-create.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-drivability.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts', @@ -104,6 +110,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-journal-corruption.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-late-proof-tool-ends.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts', @@ -132,11 +139,16 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/native-chat/agent-session-wire/structured-agent-session-repeat-press.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-repeated-stop.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-resend-answer.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-replaced-server-turn.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-resend-ledger-refusal.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-resolved-append.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-newer-orca.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-restart-provider-output.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-restart-recovery-output.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-restart-stream-duration.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-rewind-live-send.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts', @@ -149,6 +161,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/native-chat/agent-session-wire/structured-agent-session-shutdown-cut-row.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-signed-out-child.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-open.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-registrations.test.ts', @@ -165,6 +178,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-withdrawn-send.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-stopping-journal.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-subagent-recency.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-subagent-rows.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-subscriber-background-tasks.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts', @@ -190,6 +204,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/runtime/agent-session-record-options.test.ts', 'src/main/runtime/agent-session-record-store-tab-table.test.ts', 'src/main/runtime/agent-session-record-store.test.ts', + 'src/main/runtime/agent-session-permission-backfill.test.ts', 'src/main/runtime/agent-session-released-reservation-replay.test.ts', 'src/main/runtime/agent-session-runtime-attribution.test.ts', 'src/main/runtime/agent-session-store-transactions.test.ts', @@ -209,8 +224,11 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/runtime/rpc/methods/agent-launch-session-reservation.test.ts', 'src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts', 'src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts', + 'src/main/runtime/rpc/methods/structured-agent-session-create-message.test.ts', 'src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts', + 'src/main/runtime/rpc/methods/structured-agent-session-options-workspace-scope.test.ts', 'src/main/runtime/rpc/methods/structured-agent-session-restart-unregistered-agent.test.ts', + 'src/main/runtime/rpc/methods/structured-agent-session-unsubscribe.test.ts', 'src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts', 'src/main/runtime/runtime-managed-worktree-metadata-sweep.test.ts', 'src/main/runtime/runtime-orchestration-sender-names.test.ts', @@ -218,10 +236,12 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts', 'src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts', 'src/main/runtime/structured-agent-session-codex-opening-send-hold.test.ts', + 'src/main/runtime/runtime-client-agent-launch-settings-durability.test.ts', 'src/main/runtime/structured-agent-session-codex-settlement-order.test.ts', 'src/main/runtime/structured-agent-session-integration-replay.test.ts', 'src/main/runtime/structured-agent-session-integration.test.ts', 'src/main/runtime/structured-agent-session-mobile-restore.test.ts', + 'src/main/runtime/structured-agent-session-paired-caller.test.ts', 'src/main/runtime/structured-agent-session-rollback-compatibility.test.ts', 'src/main/runtime/structured-agent-session-spawn-token-descendants.test.ts', 'src/main/runtime/structured-agent-session-startup-reconcile.test.ts', @@ -247,6 +267,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts', 'tests/e2e/cross-version-wire/submission-positions-downgrade.unit.test.ts', 'tests/e2e/folder-upgrade-identity-persistence.unit.test.ts', + 'tests/e2e/host-agent-launch-settings-hook-bookkeeping.unit.test.ts', 'tests/e2e/structured-agent-session-read-owner.unit.test.ts', 'tests/e2e/structured-chat-owner-status-activation.unit.test.ts' ] diff --git a/docs/fork-upstreaming.md b/docs/fork-upstreaming.md index ce183e920fb8..7023a69572d3 100644 --- a/docs/fork-upstreaming.md +++ b/docs/fork-upstreaming.md @@ -109,23 +109,6 @@ and their import swaps. The database change is generic. **Status:** pending-upstream. Not yet submitted. -## Claude host auth fallback selection - -**What:** clears both the legacy host account selection and its runtime-map entry when a managed -account's auth path is unowned or its credentials are missing or invalid. Previously the runtime map -still selected the unusable account, so structured Claude launches stripped ambient authentication -even after the auth service had fallen back to the system default. Other WSL selections are preserved. - -**Why upstream, not isolated:** this is a shared account-selection consistency bug affecting ordinary -structured sessions as well as Heimdall owners; a fork-only launch workaround would leave it intact. - -**Paths:** - -- `src/main/claude-accounts/runtime-auth/runtime-auth-sync.ts` -- `src/main/claude-accounts/runtime-auth-service-materialization.test.ts` - -**Status:** pending-upstream. Not yet submitted. - ## Relay workflow inventory v1.4.224 **What:** v1.4.224's release cut (`6ad7ac240e`) copied `cloud-operate-relay-production-cell-flags.yml` diff --git a/docs/reference/agent-browser-focus-isolation.md b/docs/reference/agent-browser-focus-isolation.md new file mode 100644 index 000000000000..579ba1bb5535 --- /dev/null +++ b/docs/reference/agent-browser-focus-isolation.md @@ -0,0 +1,93 @@ +# Agent browser input and the user's keyboard focus + +Read this before changing how agent browser commands deliver mouse, key or text input to a +browser page, or before adding a `webContents.focus()` call on a page an agent drives. Background: +issue [#20595](https://github.com/stablyai/orca/issues/20595), with #16674, #12912, #16410, #19297, +#20533, #26338 and #8139. + +## The rule + +Agent input reaches the page it targets and nothing else. It never moves the user's keyboard focus, +and it never lands in whatever the user is typing into. Only an explicit user-facing focus request +(`tab switch --focus`) may reveal a page, and even that does not hand it OS focus. + +## Why `` makes this hard (measured, Electron 43) + +A `` guest is an inner WebContents in the window's frame tree. Chromium routes input by +that tree's focus, not by which debugger the command arrived on. A hidden-window probe (host +` + + + +` + +async function startProbeServer(): Promise<{ url: string; close: () => Promise }> { + const server: Server = createServer((_request, response) => { + response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }) + response.end(PAGE_HTML) + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + if (address === null || typeof address === 'string') { + throw new Error('Probe server did not bind a TCP port') + } + return { + url: `http://127.0.0.1:${address.port}/`, + close: () => + new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())) + ) + } +} + +function readProperty(value: unknown, key: string): unknown { + return value !== null && typeof value === 'object' && key in value + ? Object.getOwnPropertyDescriptor(value, key)?.value + : undefined +} + +async function rpc(page: Page, method: string, params: Record): Promise { + const response: unknown = await page.evaluate( + ({ targetMethod, targetParams }) => + window.api.runtime.call({ method: targetMethod, params: targetParams }), + { targetMethod: method, targetParams: params } + ) + expect( + readProperty(response, 'ok'), + `${method} failed: ${JSON.stringify(readProperty(response, 'error'))}` + ).toBe(true) + return readProperty(response, 'result') +} + +export async function evalInPage(page: Page, pageId: string, expression: string): Promise { + return readProperty(await rpc(page, 'browser.eval', { page: pageId, expression }), 'result') +} + +/** Focuses the terminal and records every host keydown and focus move from here on. */ +async function focusTerminalAndWatchHost(page: Page): Promise { + await page.evaluate(() => { + const textarea = document.querySelector('.xterm-helper-textarea') + if (!textarea) { + throw new Error('No terminal input to focus') + } + textarea.focus() + // Why: kept on the document so the read side needs no untyped window property. + const log: string[] = [] + const record = (entry: string): void => { + log.push(entry) + document.documentElement.dataset.agentFocusProbeLog = JSON.stringify(log) + } + document.documentElement.dataset.agentFocusProbeLog = '[]' + window.addEventListener('keydown', (event) => record(`key:${event.key}`), true) + window.addEventListener('focusin', (event) => { + const target = event.target + record(`focus:${target instanceof Element ? target.tagName : 'window'}`) + }) + }) +} + +export async function readHost(page: Page): Promise<{ log: string[]; terminalFocused: boolean }> { + return page.evaluate(() => ({ + log: ((): string[] => { + const parsed: unknown = JSON.parse( + document.documentElement.dataset.agentFocusProbeLog ?? '[]' + ) + return Array.isArray(parsed) + ? parsed.filter((entry): entry is string => typeof entry === 'string') + : [] + })(), + terminalFocused: document.activeElement?.classList.contains('xterm-helper-textarea') === true + })) +} + +/** Opens the probe in a browser tab, then puts the terminal back in front of it. */ +async function openBackgroundProbeTab(page: Page, url: string): Promise { + await waitForActiveWorktree(page) + await ensureTerminalVisible(page) + const worktreeId = await getActiveWorktreeId(page) + expect(worktreeId).toBeTruthy() + // Why: shown once so the page gets a viewport; a tab never shown can stay 0x0, and clicks miss. + const pageId = await page.evaluate( + ({ targetWorktreeId, targetUrl }) => + window.__store?.getState().createBrowserTab(targetWorktreeId, targetUrl, { + title: 'Focus isolation probe', + activate: true + })?.activePageId ?? null, + { targetWorktreeId: worktreeId!, targetUrl: url } + ) + if (!pageId) { + throw new Error('Failed to create the probe browser page') + } + // Why: the title exists before the probe's script runs; input sent earlier goes unrecorded. + const readiness = `document.readyState === 'complete' && innerWidth > 0 && 'clicks' + window.__clicks` + await expect.poll(() => evalInPage(page, pageId, readiness), { timeout: 20_000 }).toBe('clicks0') + await ensureTerminalVisible(page) + await expect.poll(() => evalInPage(page, pageId, readiness)).toBe('clicks0') + return pageId +} + +export async function runAgentKeysScenario( + page: Page, + afterAgentInput?: AfterAgentInput +): Promise { + const server = await startProbeServer() + try { + const pageId = await openBackgroundProbeTab(page, server.url) + + await focusTerminalAndWatchHost(page) + await evalInPage(page, pageId, `document.getElementById('probe').focus()`) + + const probeValue = (): Promise => + evalInPage(page, pageId, `document.getElementById('probe').value`) + await rpc(page, 'browser.keypress', { page: pageId, key: 'Z' }) + await rpc(page, 'browser.type', { page: pageId, input: 'qr' }) + await expect.poll(probeValue).toBe('Zqr') + await rpc(page, 'browser.keypress', { page: pageId, key: 'Enter' }) + await rpc(page, 'browser.fill', { page: pageId, element: '#probe', value: 'filled' }) + await expect.poll(probeValue).toBe('filled') + + const host = await readHost(page) + expect(host.log).toEqual([]) + expect(host.terminalFocused).toBe(true) + await expect + .poll(() => + evalInPage( + page, + pageId, + `JSON.stringify({ submits: window.__submits, keys: window.__keys })` + ) + ) + // Why: `type` inserts text without key events, so only the keypresses log keydowns. + .toBe(JSON.stringify({ submits: 1, keys: ['Z', 'Enter'] })) + await afterAgentInput?.(pageId) + } finally { + await server.close() + } +} + +export async function runAgentClicksScenario( + page: Page, + afterAgentInput?: AfterAgentInput +): Promise { + const server = await startProbeServer() + try { + const pageId = await openBackgroundProbeTab(page, server.url) + await focusTerminalAndWatchHost(page) + const clicks = (): Promise => evalInPage(page, pageId, 'window.__clicks') + + await rpc(page, 'browser.click', { page: pageId, element: '#target' }) + await expect.poll(clicks).toBe('1') + expect((await readHost(page)).terminalFocused).toBe(true) + + await rpc(page, 'browser.mouseMove', { page: pageId, x: 100, y: 100 }) + await rpc(page, 'browser.mouseDown', { page: pageId, button: 'left' }) + await rpc(page, 'browser.mouseUp', { page: pageId, button: 'left' }) + await expect.poll(clicks).toBe('2') + expect((await readHost(page)).terminalFocused).toBe(true) + + // Why: the page must still see the field the agent clicked as focused, or menus close on blur. + await rpc(page, 'browser.click', { page: pageId, element: '#probe' }) + await expect + .poll(() => + evalInPage( + page, + pageId, + `JSON.stringify({ active: document.activeElement.id, focused: document.hasFocus(), blurs: window.__blurs })` + ) + ) + .toBe(JSON.stringify({ active: 'probe', focused: true, blurs: 0 })) + expect((await readHost(page)).terminalFocused).toBe(true) + + // Why: text with a newline cannot be typed as key events; CDP delivers it with focus borrowed. + await rpc(page, 'browser.fill', { page: pageId, element: '#notes', value: 'a\nb' }) + await expect + .poll(() => evalInPage(page, pageId, `document.getElementById('notes').value`)) + .toBe('a\nb') + expect((await readHost(page)).terminalFocused).toBe(true) + + // Why: Tab from the page's last field moves focus out of the page into Orca's window. + await evalInPage(page, pageId, `document.getElementById('notes').focus()`) + await rpc(page, 'browser.keypress', { page: pageId, key: 'Tab' }) + await expect.poll(async () => (await readHost(page)).terminalFocused).toBe(true) + + // Why: the user's next keystroke is what the bug stole; it must reach the terminal, not the page. + await page.keyboard.press('x') + const host = await readHost(page) + expect(host.log.filter((entry) => entry.startsWith('key:'))).toEqual(['key:x']) + expect(host.terminalFocused).toBe(true) + await expect + .poll(() => evalInPage(page, pageId, 'JSON.stringify(window.__keys)')) + // Why: only the agent's Tab; the user's x must not reach the page. + .toBe('["Tab"]') + await afterAgentInput?.(pageId) + } finally { + await server.close() + } +} + +export async function runAgentShortcutsScenario( + page: Page, + afterAgentInput?: AfterAgentInput +): Promise { + const server = await startProbeServer() + try { + const pageId = await openBackgroundProbeTab(page, server.url) + await focusTerminalAndWatchHost(page) + const activeTabBefore = await getActiveTabId(page) + const mod = process.platform === 'darwin' ? 'Meta' : 'Control' + + // Why: new tab, close tab and next tab are Orca shortcuts; an agent's copies must not run them. + for (const key of [`${mod}+t`, `${mod}+w`, 'Control+Tab']) { + await rpc(page, 'browser.keypress', { page: pageId, key }) + } + + await expect.poll(() => evalInPage(page, pageId, 'window.__keys.length')).toBe('3') + expect(await getActiveTabId(page)).toBe(activeTabBefore) + const host = await readHost(page) + expect(host.log.filter((entry) => entry.startsWith('key:'))).toEqual([]) + expect(host.terminalFocused).toBe(true) + await afterAgentInput?.(pageId) + } finally { + await server.close() + } +} diff --git a/tests/e2e/host-agent-launch-settings-hook-bookkeeping.unit.test.ts b/tests/e2e/host-agent-launch-settings-hook-bookkeeping.unit.test.ts new file mode 100644 index 000000000000..590d9936c469 --- /dev/null +++ b/tests/e2e/host-agent-launch-settings-hook-bookkeeping.unit.test.ts @@ -0,0 +1,100 @@ +// @vitest-environment happy-dom +import { createElement } from 'react' +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + deferred, + fixture +} from '../../src/main/persistence/loading-store/profile-state-delayed-authority-fixture' +import { RuntimeClientSettingsController } from '../../src/main/runtime/runtime-client-settings' +import { HostAgentLaunchSettings } from '../../src/renderer/src/components/settings/HostAgentLaunchSettings' +import { TooltipProvider } from '../../src/renderer/src/components/ui/tooltip' +import { getAgentCatalog } from '../../src/renderer/src/lib/agent-catalog' + +const transport = vi.hoisted(() => ({ read: vi.fn(), mutate: vi.fn() })) +const hooks = vi.hoisted(() => ({ apply: vi.fn(async () => {}) })) +vi.mock('../../src/main/telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../src/main/telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../src/main/ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) +vi.mock('../../src/main/agent-hooks/managed-agent-hook-controls', () => ({ + applyAgentStatusHooksEnabled: hooks.apply +})) +vi.mock('../../src/renderer/src/components/settings/agent-launch-settings-transport', () => ({ + readHostAgentLaunchSettings: transport.read, + mutateHostAgentLaunchSettings: transport.mutate +})) +vi.mock('@/hooks/useDetectedAgents', () => ({ + useDetectedAgents: () => ({ + detectedIds: ['claude'], + detectionFailed: false, + isRefreshing: false, + refresh: vi.fn() + }) +})) + +afterEach(cleanup) +describe('host form and committed-settings contract', () => { + it('commits a following form edit while availability hook bookkeeping is still pending', async () => { + const { store, readState } = await fixture() + const controller = new RuntimeClientSettingsController(store) + await controller.mutateAgentLaunch({ + type: 'arguments', + agent: 'claude', + value: '--host-marker' + }) + transport.read.mockResolvedValue(controller.getAgentLaunch()) + transport.mutate.mockImplementation((_owner, mutation) => + controller.mutateAgentLaunch(mutation) + ) + const started = deferred() + const finish = deferred() + hooks.apply.mockImplementationOnce(async () => { + started.resolve() + await finish.promise + }) + render( + createElement( + TooltipProvider, + {}, + createElement(HostAgentLaunchSettings, { + environmentId: 'ssh-host', + pairingRevision: 17, + hostName: 'SSH host', + renderPermissions: () => createElement('span') + }) + ) + ) + await screen.findByDisplayValue('--host-marker') + const label = getAgentCatalog().find((agent) => agent.id === 'claude')?.label + fireEvent.click( + within(screen.getByRole('radiogroup', { name: `${label} availability` })).getByRole('radio', { + name: 'Disabled' + }) + ) + try { + await started.promise + expect(readState().settings.disabledTuiAgents).toContain('claude') + const args = screen.getByDisplayValue('--host-marker') + fireEvent.change(args, { target: { value: '--unblocked-followup' } }) + fireEvent.blur(args) + await waitFor(() => + expect(readState().settings.agentDefaultArgs.claude).toBe('--unblocked-followup') + ) + await waitFor(() => + expect(screen.getByDisplayValue('--unblocked-followup')).toHaveProperty('readOnly', false) + ) + expect(transport.mutate).toHaveBeenCalledTimes(2) + expect(screen.queryByRole('alert')).toBeNull() + } finally { + finish.resolve() + await waitFor(() => + expect(readState().settings.agentDefaultArgs.claude).toBe('--unblocked-followup') + ) + } + }) +}) diff --git a/tests/e2e/structured-attention-host-read-retirement.unit.test.ts b/tests/e2e/structured-attention-host-read-retirement.unit.test.ts index 87734e999424..dd2909e23305 100644 --- a/tests/e2e/structured-attention-host-read-retirement.unit.test.ts +++ b/tests/e2e/structured-attention-host-read-retirement.unit.test.ts @@ -53,9 +53,15 @@ function prompt(id: string, sequence: number): AgentJournalRenderItem { } } } -function attentionPath(directory: string) { +const controllers: RuntimeMobileNotificationController[] = [] +function storeController(directory: string): RuntimeMobileNotificationController { const controller = new RuntimeMobileNotificationController() controller.configureDismissalStore(directory) + controllers.push(controller) + return controller +} +function attentionPath(directory: string) { + const controller = storeController(directory) const events: MobileNotificationEvent[] = [] controller.onDispatched((event) => events.push(event)) let items: AgentJournalRenderItem[] = [ @@ -131,8 +137,9 @@ beforeEach(() => { installStructuredHostStub() vi.clearAllMocks() }) -afterEach(() => { +afterEach(async () => { clearStructuredHostStub() + await Promise.all(controllers.splice(0).map((controller) => controller.closeDismissals())) rmSync(directory, { recursive: true, force: true }) }) @@ -213,8 +220,8 @@ it('a restart sends the original delivery fence in the existing native gateway f if (!original) { throw new Error('prompt not delivered') } - const restarted = new RuntimeMobileNotificationController() - restarted.configureDismissalStore(directory) + await h.controller.closeDismissals() + const restarted = storeController(directory) const gateway = createHarness({ devices: [{ deviceId: 'phone', pushRegistration: registration() }] }) diff --git a/tests/e2e/structured-attention-prompt-relay.unit.test.ts b/tests/e2e/structured-attention-prompt-relay.unit.test.ts index 4fdabfab82ce..b9a023d1907a 100644 --- a/tests/e2e/structured-attention-prompt-relay.unit.test.ts +++ b/tests/e2e/structured-attention-prompt-relay.unit.test.ts @@ -152,7 +152,10 @@ async function mountPausedRelay({ viewed = true, away = false } = {}) { result: edge }) }, - liveDeliveries: () => new MobileNotificationDismissalStore(relayDirectory).liveDeliveries() + liveDeliveries: async () => { + await relay.flushDismissals() + return new MobileNotificationDismissalStore(relayDirectory).liveDeliveries() + } } } @@ -202,7 +205,7 @@ it.each(['visible', 'read then away', 'tab/store rerender', 'failed retirement'] await act(async () => relay.release()) expect(transport.dispatch).not.toHaveBeenCalled() expect(relay.relayEvents).toEqual([]) - expect(relay.liveDeliveries()).toEqual([]) + expect(await relay.liveDeliveries()).toEqual([]) expect(useAppStore.getState().unreadAgentCompletionPanes[SUBJECT]).toBeUndefined() } ) @@ -227,7 +230,7 @@ it('reading A does not cover unseen B on the same remote subject', async () => { expect(relay.relayEvents).toMatchObject([ { type: 'notification', notificationId: agentSessionPromptAttentionKey(SCOPE, SESSION, 'B') } ]) - expect(relay.liveDeliveries()).toHaveLength(1) + expect(await relay.liveDeliveries()).toHaveLength(1) expect(useAppStore.getState().unreadAgentCompletionPanes[SUBJECT]).toBe('agent-completion') }) @@ -268,7 +271,7 @@ it('accepting a transcript while away does not fabricate a read frontier', async expect(dismissIds()).toEqual([]) await act(async () => relay.release()) expect(relay.relayEvents).toMatchObject([{ type: 'notification' }]) - expect(relay.liveDeliveries()).toHaveLength(1) + expect(await relay.liveDeliveries()).toHaveLength(1) }) it('a genuine read during unread writes prevents the final relay delivery', async () => { @@ -291,7 +294,7 @@ it('a genuine read during unread writes prevents the final relay delivery', asyn expect(readDuringUnread).toBe(true) await waitFor(() => expect(dismissIds()).toHaveLength(1)) expect(relay.relayEvents).toEqual([]) - expect(relay.liveDeliveries()).toEqual([]) + expect(await relay.liveDeliveries()).toEqual([]) } finally { stop() } @@ -348,7 +351,7 @@ it.each(['missing cursor', 'different epoch', 'different target'] as const)( ) } expect(relay.relayEvents).toMatchObject([{ type: 'notification' }]) - expect(relay.liveDeliveries()).toHaveLength(1) + expect(await relay.liveDeliveries()).toHaveLength(1) } ) @@ -392,6 +395,6 @@ it.each(['success', 'failure'] as const)( notificationId: `${agentSessionAttentionSubjectPrefix(SCOPE, SESSION)}turn:turn-1` } ]) - expect(relay.liveDeliveries()).toHaveLength(1) + expect(await relay.liveDeliveries()).toHaveLength(1) } ) diff --git a/tests/e2e/structured-attention-read-retirement.test-fixture.tsx b/tests/e2e/structured-attention-read-retirement.test-fixture.tsx index 13335a973f3a..1479bb8b9f82 100644 --- a/tests/e2e/structured-attention-read-retirement.test-fixture.tsx +++ b/tests/e2e/structured-attention-read-retirement.test-fixture.tsx @@ -338,7 +338,7 @@ beforeEach(() => { settings: createGlobalSettingsFixture({ experimentalTerminalAttention: true }) }) }) -afterEach(() => { +afterEach(async () => { cleanup() clearStructuredHostStub() resetStructuredAgentSessionReadOwnersForTests() @@ -346,5 +346,6 @@ afterEach(() => { resetStructuredAgentSessionStatusFeedsForTests() vi.unstubAllGlobals() vi.restoreAllMocks() + await fixture.controller.closeDismissals() rmSync(fixture.directory, { recursive: true, force: true }) }) diff --git a/tests/e2e/structured-attention-read-retirement.unit.test.ts b/tests/e2e/structured-attention-read-retirement.unit.test.ts index 22a1a80fc567..7d3aad207bf7 100644 --- a/tests/e2e/structured-attention-read-retirement.unit.test.ts +++ b/tests/e2e/structured-attention-read-retirement.unit.test.ts @@ -225,13 +225,13 @@ it.each(['transport-error', 'false-result'] as const)( it('retries a failed local desktop relay withdrawal on a later explicit read', async () => { const relayDirectory = mkdtempSync(join(tmpdir(), 'orca-relay-read-retry-')) + const relay = new RuntimeMobileNotificationController() try { addPrompt('A') const sent = fixture.events.find((event) => event.type === 'notification') if (!sent?.notificationId || sent.type !== 'notification') { throw new Error('prompt not sent') } - const relay = new RuntimeMobileNotificationController() relay.configureDismissalStore(relayDirectory) relay.dispatch(sent) const withdrawals: string[] = [] @@ -267,6 +267,7 @@ it('retries a failed local desktop relay withdrawal on a later explicit read', a 2 ) } finally { + await relay.closeDismissals() rmSync(relayDirectory, { recursive: true, force: true }) } }) diff --git a/tests/e2e/structured-attention-relay-settlement.unit.test.ts b/tests/e2e/structured-attention-relay-settlement.unit.test.ts index 9230707900c0..9f20015dfdcf 100644 --- a/tests/e2e/structured-attention-relay-settlement.unit.test.ts +++ b/tests/e2e/structured-attention-relay-settlement.unit.test.ts @@ -106,10 +106,12 @@ async function mountRemoteRelay(options?: { promptStatus?: false; raise?: false await waitFor(() => expect(fixture.completion).toBeTypeOf('function')) await waitFor(() => expect(fixture.status).toBeTypeOf('function')) act(() => publishStatus()) - const live = (kind: 'prompt' | 'completion') => - new MobileNotificationDismissalStore(relayDirectory) + const live = async (kind: 'prompt' | 'completion') => { + await relay.flushDismissals() + return new MobileNotificationDismissalStore(relayDirectory) .liveDeliveries() .filter((entry) => entry.structuredOrigin?.cause.kind === kind) + } if (options?.raise === false) { return { live } } @@ -120,7 +122,7 @@ async function mountRemoteRelay(options?: { promptStatus?: false; raise?: false } publishView() }) - await waitFor(() => expect(live('prompt')).toHaveLength(1)) + await waitFor(async () => expect(await live('prompt')).toHaveLength(1)) return { live } } @@ -195,7 +197,7 @@ it.each([ fixture.hostFeed.observe(SESSION) publishStatus() }) - await waitFor(() => expect(relay.live('prompt')).toEqual([])) + await waitFor(async () => expect(await relay.live('prompt')).toEqual([])) expect(readCalls()).toBe(0) expect(transport.settle).toHaveBeenCalledOnce() // Prompt settlement never touches a completion alert. @@ -203,7 +205,7 @@ it.each([ ([request]: [NotificationDispatchRequest]) => request.structuredOrigin?.cause.kind === 'completion' ) - expect(relay.live('completion')).toHaveLength(relayedCompletions.length) + expect(await relay.live('completion')).toHaveLength(relayedCompletions.length) } ) @@ -214,7 +216,7 @@ it('withdraws it when a restarted remote host comes back with the prompt cancell revise((item) => interrupt(resolveA('cancelled')(item))) await waitFor(() => expect(fixture.status).not.toBe(before)) act(() => publishStatus()) - await waitFor(() => expect(relay.live('prompt')).toEqual([])) + await waitFor(async () => expect(await relay.live('prompt')).toEqual([])) }) it('keeps the relayed prompt alert while the host still reports a prompt pending', async () => { @@ -225,7 +227,7 @@ it('keeps the relayed prompt alert while the host still reports a prompt pending }) await act(async () => {}) expect(transport.settle).not.toHaveBeenCalled() - expect(relay.live('prompt')).toHaveLength(1) + expect(await relay.live('prompt')).toHaveLength(1) }) it('never settles from a status the desktop lost contact with', async () => { @@ -234,7 +236,7 @@ it('never settles from a status the desktop lost contact with', async () => { act(() => endStatusStream()) await act(async () => {}) expect(transport.settle).not.toHaveBeenCalled() - expect(relay.live('prompt')).toHaveLength(1) + expect(await relay.live('prompt')).toHaveLength(1) }) it('settles once per relayed prompt, not on every later status update', async () => { @@ -261,7 +263,7 @@ it('keeps the pending prompt alert when the edge outruns its status and another act(() => publishStatus()) await act(async () => {}) expect(transport.settle).not.toHaveBeenCalled() - expect(relay.live('prompt')).toHaveLength(1) + expect(await relay.live('prompt')).toHaveLength(1) }) it('keeps it when a queued frame lands in the same task as the edge', async () => { @@ -272,7 +274,7 @@ it('keeps it when a queued frame lands in the same task as the edge', async () = }) await act(async () => {}) expect(transport.settle).not.toHaveBeenCalled() - expect(relay.live('prompt')).toHaveLength(2) + expect(await relay.live('prompt')).toHaveLength(2) }) it('retries a settlement that failed on the next qualifying row', async () => { @@ -285,13 +287,13 @@ it('retries a settlement that failed on the next qualifying row', async () => { publishStatus() }) await waitFor(() => expect(transport.settle).toHaveBeenCalledOnce()) - expect(relay.live('prompt')).toHaveLength(1) + expect(await relay.live('prompt')).toHaveLength(1) transport.settle.mockImplementation(settle ?? (async () => {})) act(() => { revise((item) => item) publishStatus() }) - await waitFor(() => expect(relay.live('prompt')).toEqual([])) + await waitFor(async () => expect(await relay.live('prompt')).toEqual([])) expect(transport.settle).toHaveBeenCalledTimes(2) }) @@ -314,7 +316,7 @@ it('settles a prompt answered while the previous settle call was still out', asy addPrompt('B') publishStatus() }) - await waitFor(() => expect(relay.live('prompt')).toHaveLength(1)) + await waitFor(async () => expect(await relay.live('prompt')).toHaveLength(1)) act(() => { revise(resolvePrompt('B', 'resolved')) fixture.hostFeed.observe(SESSION) @@ -324,7 +326,7 @@ it('settles a prompt answered while the previous settle call was still out', asy expect(replies).toHaveLength(1) // The host goes quiet; the reply alone must re-judge the mirror. await act(async () => replies[0]?.()) - await waitFor(() => expect(relay.live('prompt')).toEqual([])) + await waitFor(async () => expect(await relay.live('prompt')).toEqual([])) }) it('settles a prompt whose edge arrives after its own resolution row', async () => { @@ -349,6 +351,6 @@ it('settles a prompt whose edge arrives after its own resolution row', async () ) ).toBe(true) ) - await waitFor(() => expect(relay.live('prompt')).toEqual([])) + await waitFor(async () => expect(await relay.live('prompt')).toEqual([])) expect(transport.settle).toHaveBeenCalledOnce() })