diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 40e20fb2671..01efbe01e25 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -3087,15 +3087,16 @@ "providers": ["paired-runtime", "ssh"], "coveredPlatforms": ["macos"], "coveredProviders": ["paired-runtime"], - "coverageNotes": "Deterministic headed macOS runs launch an isolated Orca desktop server and a separate paired web client. A byte-identical headless run uses an isolated `orca serve` host. Both create six real paired host PTYs with bounded high-output scrollback, ordinary-park five client xterms without enabling the lossy retention budget, assert bounded cells/heap/timer lag, then restore 5,000 rows on the original PTY including output produced while parked and continued input/output. Separate headed/headless ACK-starvation tests recover one stalled stream without replacing its PTY. A capability-disabled run proves legacy hosts retain the prior lossy limit/TTL fallback. Unit tests cover exact-owner capability routing, raw-stream release, singleton side-effect facts with timed handoff cleanup, provider-authoritative snapshots, 128 active streams plus retry after a 129th-stream capacity rejection, capacity-pressure backoff, full split-leaf remint reconciliation, truncation, and manual-disconnect queue fencing. Linux, Windows, mixed-version paired hosts, live SSH, and production-scale paired hosts remain gaps.", + "coverageNotes": "Deterministic headed macOS runs launch an isolated Orca desktop server and a separate paired web client. A byte-identical headless run uses an isolated `orca serve` host. Both create six real paired host PTYs with bounded high-output scrollback, ordinary-park five client xterms without enabling the lossy retention budget, assert bounded cells/heap/timer lag, then restore 5,000 rows on the original PTY including output produced while parked and continued input/output. The headed oracle additionally keeps every remote target workspace and terminal unmounted in the host renderer before and after client recovery. Separate headed/headless ACK-starvation tests recover one stalled stream without replacing its PTY. A capability-disabled run proves legacy hosts retain the prior lossy limit/TTL fallback. Unit tests cover exact-owner capability routing, raw-stream release, singleton side-effect facts with timed handoff cleanup, provider-authoritative snapshots, 128 active streams plus retry after a 129th-stream capacity rejection, capacity-pressure backoff, full split-leaf remint reconciliation, truncation, and manual-disconnect queue fencing. Linux, Windows, mixed-version paired hosts, live SSH, and production-scale paired hosts remain gaps.", "motivatingLinks": [ "https://github.com/stablyai/orca/issues/8652", "https://github.com/stablyai/orca/pull/10625" ], - "invariant": "A host advertising terminal.paired-parking.v1 keeps the PTY and bounded authoritative history alive while an ordinary hidden-view park destroys the client xterm and releases its raw per-PTY stream. Reveal must restore up to the requested 5,000 rows, parked-time side effects/output, the same PTY identity, and continued input/output. Hosts without the capability must retain the existing limit/TTL force-parking fallback. A paired terminal stream whose delivery credits stop progressing must replace only that stream; command silence must first probe authoritative state and replace only if that probe times out. Manual disconnect must retain pairing while preventing queued or passive calls and subscriptions from recreating transport until explicit Connect.", - "oracle": "Run one byte-identical six-terminal oracle against an isolated headed desktop host and an isolated headless `orca serve` host. Stage at least 1,000,000 xterm cells, enable ordinary parking with the lossy retention budget disabled, require exactly one mounted manager and five parked tabs, at most 45% retained cells, no more than 16 MiB heap growth, and under 500 ms timer drift. While a tab is parked, require authoritative terminal.read to observe new PTY output; reveal it and require the original PTY, a marker within the requested 5,000-row history, the parked marker, and post-reveal input/output. Admit 128 active streams, reject the 129th as retryable, release one stream, then require the retry to attach and publish a snapshot without multiplying retained subscribers. Disable terminal.paired-parking.v1 and require the same oracle to fail before parking, while the legacy limit-one fallback separately passes. Also preserve truncated first paint, ACK-starved same-PTY recovery, responsive silent-command snapshot probes, dead-stream probe timeout recovery, and queued manual-disconnect fencing.", + "invariant": "A host advertising terminal.paired-parking.v1 keeps the PTY and bounded authoritative history alive while an ordinary hidden-view park destroys the client xterm and releases its raw per-PTY stream. Reveal must restore up to the requested 5,000 rows, parked-time side effects/output, the same PTY identity, and continued input/output. Hosts without the capability must retain the existing limit/TTL force-parking fallback. A paired terminal stream whose delivery credits stop progressing must replace only that stream; command silence must first probe authoritative state and replace only if that probe times out. A successful status probe may replace a pre-ready shared-control socket without rejecting or duplicating calls already waiting for that transport. Manual disconnect must retain pairing while preventing queued or passive calls and subscriptions from recreating transport until explicit Connect.", + "oracle": "Run one byte-identical six-terminal oracle against an isolated headed desktop host and an isolated headless `orca serve` host. Stage at least 1,000,000 xterm cells, enable ordinary parking with the lossy retention budget disabled, require exactly one mounted manager and five parked tabs, at most 45% retained cells, no more than 16 MiB heap growth, and under 500 ms timer drift. In headed mode, require the host renderer to remain on its original workspace with zero target terminal managers mounted throughout client park, reveal, and live I/O. While a tab is parked, require authoritative terminal.read to observe new PTY output; reveal it and require the original PTY, a marker within the requested 5,000-row history, the parked marker, and post-reveal input/output. Withhold the first encrypted shared-control ready frame, start one RPC, trigger a status-probe refresh, and require two connections, one host delivery, successful response, and zero retained request bytes. Admit 128 active streams, reject the 129th as retryable, release one stream, then require the retry to attach and publish a snapshot without multiplying retained subscribers. Disable terminal.paired-parking.v1 and require the same oracle to fail before parking, while the legacy limit-one fallback separately passes. Also preserve truncated first paint, ACK-starved same-PTY recovery, responsive silent-command snapshot probes, dead-stream probe timeout recovery, and queued manual-disconnect fencing.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/terminal-multiplex.test.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts src/renderer/src/components/terminal-pane/terminal-parked-watcher-reconciliation.test.ts src/renderer/src/components/terminal-pane/terminal-parked-watcher-partial-reconciliation.test.ts src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.test.ts src/renderer/src/runtime/remote-runtime-terminal-stall-recovery.test.ts src/renderer/src/runtime/runtime-client-events.test.ts src/renderer/src/web/web-preload-api.test.ts src/main/ipc/runtime-environments.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/shared/remote-runtime-shared-control-connection.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/runtime/remote-runtime-terminal-parse-backpressure.test.ts", "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-truncated-tail-first-paint.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_DISABLE_PAIRED_TERMINAL_PARKING=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-truncated-tail-first-paint.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", @@ -3124,6 +3125,7 @@ "src/renderer/src/runtime/remote-runtime-terminal-parse-backpressure.test.ts", "src/renderer/src/runtime/runtime-client-events.test.ts", "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts", + "src/shared/remote-runtime-shared-control-connection.test.ts", "tests/e2e/paired-remote-terminal-truncated-tail-first-paint.spec.ts", "tests/e2e/paired-remote-terminal-stall-recovery.spec.ts", "tests/e2e/headless-paired-remote-terminal-stall-recovery.spec.ts", @@ -3152,7 +3154,8 @@ { "file": "tests/e2e/paired-remote-terminal-retention-memory.spec.ts", "assertions": [ - "ordinary-parks paired terminals and restores authoritative host scrollback" + "ordinary-parks paired terminals and restores authoritative host scrollback", + "client recovery completes while every target terminal remains unmounted in the host renderer" ] }, { @@ -3276,7 +3279,7 @@ "command": "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-retention-memory.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", "result": "passed", "durationSeconds": 14.5, - "summary": "Six real paired worktrees and host PTYs ordinary-parked five client xterms with the lossy budget disabled, released at least 55% of staged cells within heap and timer-lag budgets, then restored requested row 4,000, parked-time output, and live I/O on the original PTY." + "summary": "Six real paired worktrees and host PTYs ordinary-parked five client xterms with the lossy budget disabled, released at least 55% of staged cells within heap and timer-lag budgets, then restored requested row 4,000, parked-time output, and live I/O on the original PTY while every target terminal remained unmounted in the host renderer." }, { "date": "2026-07-29", @@ -3304,6 +3307,15 @@ "result": "passed", "durationSeconds": 18.6, "summary": "A host without terminal.paired-parking.v1 preserved the existing lossy limit-one fallback, restored retained output, and continued PTY input/output." + }, + { + "date": "2026-07-29", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/shared/remote-runtime-shared-control-connection.test.ts", + "result": "passed", + "durationSeconds": 3.16, + "summary": "All 27 encrypted shared-control tests passed. The new oracle was red on rc.1-equivalent behavior with the exact Refreshing remote runtime control transport error, then passed with one replacement connection, one delivered host RPC, and zero retained pending bytes." } ], "runtimeBudget": { @@ -4301,7 +4313,7 @@ "providers": ["ssh", "remote-runtime", "wsl"], "coveredPlatforms": ["macos"], "coveredProviders": ["ssh", "remote-runtime"], - "coverageNotes": "Deterministic renderer coverage proves startup publishes the state returned by ssh.connect, retained native and runtime SSH payloads are admitted through production routes only with valid complete authority, stale cleanup cannot unregister a replacement runtime terminal, direct SSH Git and folder panes clear and retry by exact authority, one authority chain stops after two automatic attempts even when each timeout exceeds the rolling window, rejected acknowledgements mutate no store maps, and one shared exact attempt admits every concurrent split-pane spawn and reattach while preserving the first PTY as the tab fallback. A later sibling failure rotates the tab once, stale callbacks from the prior attempt mutate no state, split remount activity suppression is counted per leaf, primary PTY exit promotes a bound survivor or preserves an empty continuation gap for a late sibling, and primary, non-primary, or null-PTY detach preserves exact authority on both resulting tabs. Intentional pane disposal cancels its settlement timer without breaking StrictMode remount timeout ownership. Target snapshot hydration/reconnect preserves sibling SSH/local/WSL/runtime state, and a mounted remote-runtime terminal survives repeated transport partitions without changing PTY identity. Direct SSH coordinator tests cover immediate terminal finalization, hydration correction, damping, bounded retry, and telemetry non-interference. Client/server heartbeat tests cover timer suspension, socket generations fence stale callbacks, cold restored-terminal attachment retries, cached pixels remain unhealthy until authoritative replay, automatic retries stop after one minute, manual reconnect preserves the PTY, and pane closure releases recovery UI state. Current macOS Electron journeys against an ephemeral Linux Docker SSH target cover exact-authority repo/worktree hydration, live terminal recovery after disconnect/reconnect, and eager six-terminal remount after renderer reload. A Windows remote-runtime smoke covers reachability and PTY round-trip. Multi-target live fanout, paired-close, WSL, and patched live partition journeys remain gaps.", + "coverageNotes": "Deterministic renderer coverage proves startup publishes the state returned by ssh.connect, retained native and runtime SSH payloads are admitted through production routes only with valid complete authority, stale cleanup cannot unregister a replacement runtime terminal, direct SSH Git and folder panes clear and retry by exact authority, one authority chain stops after two automatic attempts even when each timeout exceeds the rolling window, rejected acknowledgements mutate no store maps, and one shared exact attempt admits every concurrent split-pane spawn and reattach while preserving the first PTY as the tab fallback. A later sibling failure rotates the tab once, stale callbacks from the prior attempt mutate no state, split remount activity suppression is counted per leaf, primary PTY exit promotes a bound survivor or preserves an empty continuation gap for a late sibling, and primary, non-primary, or null-PTY detach preserves exact authority on both resulting tabs. Intentional pane disposal cancels its settlement timer without breaking StrictMode remount timeout ownership. Target snapshot hydration/reconnect preserves sibling SSH/local/WSL/runtime state, and a mounted remote-runtime terminal survives repeated transport partitions without changing PTY identity. A real encrypted-WebSocket oracle proves a successful reachability probe can replace a pre-ready shared-control socket without rejecting or duplicating the waiting RPC. Direct SSH coordinator tests cover immediate terminal finalization, hydration correction, damping, bounded retry, and telemetry non-interference. Client/server heartbeat tests cover timer suspension, socket generations fence stale callbacks, cold restored-terminal attachment retries, cached pixels remain unhealthy until authoritative replay, automatic retries stop after one minute, manual reconnect preserves the PTY, and pane closure releases recovery UI state. Current macOS Electron journeys against an ephemeral Linux Docker SSH target cover exact-authority repo/worktree hydration, live terminal recovery after disconnect/reconnect, and eager six-terminal remount after renderer reload. A Windows remote-runtime smoke covers reachability and PTY round-trip. Multi-target live fanout, paired-close, WSL, and patched live partition journeys remain gaps.", "motivatingLinks": [ "https://github.com/stablyai/orca/pull/6951", "https://github.com/stablyai/orca/pull/6955", @@ -4309,8 +4321,8 @@ "https://github.com/stablyai/orca/pull/7009", "https://github.com/stablyai/orca/pull/8597" ], - "invariant": "SSH, WSL, and remote-runtime restore paths must treat provider listing failures and unknown liveness as unknown, not dead, while still avoiding duplicate spawn and clearing expired relay leases exactly once. Direct SSH reconnect must atomically clear only exact-target live PTY bindings, preserve relay identity, retry Git and folder panes without paired close or provider shutdown, and allow at most two automatic attempts in one authority chain even when each settlement exceeds the rolling window. A rejected acknowledgement mutates no store map. A successful exact split-pane spawn or reattach must retain that attempt as shared live authority until sibling leaves settle; the first success cannot consume sibling authority, a sibling failure can start at most one second tab-wide attempt, and prior-attempt callbacks become inert after rotation. Once the retry budget is exhausted, a failure cannot start attempt three or revoke attempt-two authority from siblings that may still settle. Primary PTY exit must promote a bound survivor or preserve exact authority through an empty activation gap, and split detach must project that authority to both resulting tabs. Hydrated PTY hints cannot supersede a current exact-attempt owner, and target snapshot hydration/reconnect cannot reset sibling SSH, local, WSL, or runtime-owned state. Every restored remote terminal must preserve its provider PTY identity, including the authoritative incarnation returned by a successful session-ID reattach. After a recoverable partition the same authenticated runtime must reattach the same PTY, reject detached input, apply the latest viewport, and report healthy only after authoritative replay. Automatic PTY recovery stops after one bounded minute without a fatal terminal error; a manual reconnect starts a newly fenced epoch against the same PTY, and closed panes retain no recovery UI state. One capability-gated terminal-create mutation must produce at most one host PTY across an unknown response outcome, remain manually retryable after cutoff, and never let a stale completion replace a newer pane lifecycle.", - "oracle": "Deterministic tests cover bounded stale-handle replacement, suspended heartbeat clocks, cold and established subscription failure, ten partition/recovery cycles, automatic-recovery cutoff, manual reconnect, and exact direct SSH binding recovery. They assert one atomic store publication clears only exact-target PTY indexes, null-PTY activation remains unchanged, relay identity survives, Git and folder panes retry symmetrically, another target/local/WSL/runtime panes remain byte-identical through target snapshot hydration and reconnect, only an accepted exact failure or timeout starts the second attempt, two 31-second timeouts cannot start a third settlement-triggered attempt, rejected stale/mismatched acknowledgements preserve every store map, and concurrent split-pane spawn and reattach callbacks both commit under the same attempt ID after the first success replaces pending state with live shared authority. A sibling failure revokes that shared authority and starts exactly one second attempt; duplicate failures and late first-attempt PTY callbacks preserve the second attempt and every state map. Attempt-two failure retains continuation authority for later siblings, primary exit promotes a bound survivor or preserves the lease until a late sibling binds, and primary plus non-primary detach retain exact authority and history on both resulting tabs. Both remount callbacks consume split-count activity suppression, intentional dispose emits no failure/timeout, and a same-attempt StrictMode remount still owns one timeout. Hydration clears an untrusted PTY hint without clearing its current pending owner, healthy current-authority bindings suppress correction, hydration finalizes once, and reconnect emits no paired close lifecycle. A provider-level session-ID reattach returns an incarnation, then a legacy exit without an incarnation must resolve to that returned identity rather than minting a fallback identity. Tests also assert one unsubscribe per remote-runtime epoch, observable recovery phases, stable PTY identity, resumed snapshot/output/input, no healthy state before replay, no retry or input after cutoff, a new manual epoch against the same PTY, quiet recovery UI with an explicit Reconnect action, pane-close state cleanup, one stable create mutation id, old-runtime no-retry behavior, cross-process PTY adoption, and bounded in-flight coordination.", + "invariant": "SSH, WSL, and remote-runtime restore paths must treat provider listing failures and unknown liveness as unknown, not dead, while still avoiding duplicate spawn and clearing expired relay leases exactly once. Direct SSH reconnect must atomically clear only exact-target live PTY bindings, preserve relay identity, retry Git and folder panes without paired close or provider shutdown, and allow at most two automatic attempts in one authority chain even when each settlement exceeds the rolling window. A rejected acknowledgement mutates no store map. A successful exact split-pane spawn or reattach must retain that attempt as shared live authority until sibling leaves settle; the first success cannot consume sibling authority, a sibling failure can start at most one second tab-wide attempt, and prior-attempt callbacks become inert after rotation. Once the retry budget is exhausted, a failure cannot start attempt three or revoke attempt-two authority from siblings that may still settle. Primary PTY exit must promote a bound survivor or preserve exact authority through an empty activation gap, and split detach must project that authority to both resulting tabs. Hydrated PTY hints cannot supersede a current exact-attempt owner, and target snapshot hydration/reconnect cannot reset sibling SSH, local, WSL, or runtime-owned state. Every restored remote terminal must preserve its provider PTY identity, including the authoritative incarnation returned by a successful session-ID reattach. After a recoverable partition the same authenticated runtime must reattach the same PTY, reject detached input, apply the latest viewport, and report healthy only after authoritative replay. A successful one-shot reachability probe may replace a pre-ready shared-control socket, but waiting RPCs must continue onto the replacement under their original deadline without duplicate host delivery or retained request bytes. Automatic PTY recovery stops after one bounded minute without a fatal terminal error; a manual reconnect starts a newly fenced epoch against the same PTY, and closed panes retain no recovery UI state. One capability-gated terminal-create mutation must produce at most one host PTY across an unknown response outcome, remain manually retryable after cutoff, and never let a stale completion replace a newer pane lifecycle.", + "oracle": "Deterministic tests cover bounded stale-handle replacement, suspended heartbeat clocks, cold and established subscription failure, ten partition/recovery cycles, automatic-recovery cutoff, manual reconnect, and exact direct SSH binding recovery. They assert one atomic store publication clears only exact-target PTY indexes, null-PTY activation remains unchanged, relay identity survives, Git and folder panes retry symmetrically, another target/local/WSL/runtime panes remain byte-identical through target snapshot hydration and reconnect, only an accepted exact failure or timeout starts the second attempt, two 31-second timeouts cannot start a third settlement-triggered attempt, rejected stale/mismatched acknowledgements preserve every store map, and concurrent split-pane spawn and reattach callbacks both commit under the same attempt ID after the first success replaces pending state with live shared authority. A sibling failure revokes that shared authority and starts exactly one second attempt; duplicate failures and late first-attempt PTY callbacks preserve the second attempt and every state map. Attempt-two failure retains continuation authority for later siblings, primary exit promotes a bound survivor or preserves the lease until a late sibling binds, and primary plus non-primary detach retain exact authority and history on both resulting tabs. Both remount callbacks consume split-count activity suppression, intentional dispose emits no failure/timeout, and a same-attempt StrictMode remount still owns one timeout. Hydration clears an untrusted PTY hint without clearing its current pending owner, healthy current-authority bindings suppress correction, hydration finalizes once, and reconnect emits no paired close lifecycle. A provider-level session-ID reattach returns an incarnation, then a legacy exit without an incarnation must resolve to that returned identity rather than minting a fallback identity. The shared-control oracle withholds the first encrypted ready frame, starts one RPC, triggers the successful-probe refresh, then requires exactly two client connections, one host request, a successful response, zero pending calls, and zero retained request bytes. Tests also assert one unsubscribe per remote-runtime epoch, observable recovery phases, stable PTY identity, resumed snapshot/output/input, no healthy state before replay, no retry or input after cutoff, a new manual epoch against the same PTY, quiet recovery UI with an explicit Reconnect action, pane-close state cleanup, one stable create mutation id, old-runtime no-retry behavior, cross-process PTY adoption, and bounded in-flight coordination.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/ssh-pty-provider-reattach-incarnation.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/startup/ssh-startup-reconnect.test.ts src/renderer/src/lib/resolved-worktree-execution-host.test.ts src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts src/renderer/src/runtime/sync-runtime-graph-scheduling.test.ts src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts src/renderer/src/runtime/remote-runtime-session-tabs-inflight.test.ts src/renderer/src/runtime/web-session-terminal-handle-events.test.ts src/renderer/src/store/slices/terminal-pty-identity-replacement.test.ts", diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 7e972280832..646d612e215 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -12,6 +12,8 @@ "../src/main/agent-hooks/local-agent-cli-presence.ts", "../src/main/agent-hooks/managed-agent-hook-controls.ts", "../src/main/agent-hooks/managed-agent-hook-registry.ts", + "../src/main/ipc/local-agent-install-dir-detection.ts", + "../src/main/ipc/tui-agent-detection-commands.ts", "../src/main/amp/hook-service.ts", "../src/main/antigravity/hook-service.ts", "../src/main/claude/hook-settings.ts", diff --git a/docs/reference/headless-linux-server.md b/docs/reference/headless-linux-server.md index 1f9ccd162cc..cdee2a4ca41 100644 --- a/docs/reference/headless-linux-server.md +++ b/docs/reference/headless-linux-server.md @@ -729,6 +729,69 @@ deliberately. The post-upgrade binary and version record are retained in artifacts and remove them according to your retention policy after the rollback is resolved. +## Installing Agent Skills Without A Desktop + +Orca's agent skills (CLI usage, orchestration, computer use, etc.) are normally +installed from Orca Settings, which pre-fills an `npx skills add ... --global` +command in a terminal for you to run. A headless host has no Settings UI, so +use `orca skills install` instead: + +```bash +orca skills install # list installable skills +orca skills install --skill orca-cli --skill orchestration # install globally (default) +orca skills install --skill orca-cli --local # install into the current project only +orca skills install --all # install every bundled skill +orca skills install --all --dry-run # print the npx command without running it +``` + +This resolves the same `npx skills add --skill ...` command +Settings would show you (adding `--global` unless `--local` is passed), then +runs it and forwards its output and exit code. It requires `node`/`npx` on the +host; it does not need a running Orca runtime. + +Unlike the command Settings shows, the spawned one adds `npx --yes` and `-y`. +Without them the `skills` CLI opens an interactive agent picker and blocks +forever on any allocated TTY — which includes a normal `ssh` session. Use +`--dry-run` to see the exact command that will run. + +Settings keeps that picker deliberately, because choosing which agents get a +skill is a real decision. A headless run cannot answer it, so instead of dropping +the choice Orca makes it explicitly: it passes an `--agent` list built from the +coding agents it detects on the host, plus the shared `.agents/skills` directory +it reads itself. Left to decide on its own with no agent detected, the `skills` +CLI installs into all ~75 agents it knows and leaves a config directory for each. +Override the targets yourself, or narrow to the shared directory alone: + +```bash +orca skills install --skill orca-cli --agent claude-code,codex +orca skills install --skill orca-cli --agent universal +``` + +If Orca detects no agent at all, `orca skills install` stops and asks for +`--agent` rather than guessing. + +To refresh already-installed skills, `orca skills update` mirrors the same +selection flags (`--skill`, `--all`, `--local`, `--dry-run`) and resolves to +`npx skills update ` with a matching scope flag — `--global`, or +`--project` when you pass `--local`: + +```bash +orca skills update --all # update every bundled skill globally +orca skills update --skill orca-cli --dry-run # print the npx command without running it +``` + +`orca skills update` only refreshes skills that are already installed — it exits +0 without doing anything for a skill that is missing, so install it first. More +generally, a 0 exit means the `skills` CLI ran without erroring, not that it +wrote anything; read its output to confirm what changed. + +`--json` covers the skill listing and `--dry-run`. A real run streams the +`skills` CLI's own non-JSON output and rejects `--json`. + +Both commands install onto the machine that runs them. In an Orca SSH workspace +or the WSL bridge the `orca` shim forwards commands to the Orca host, so they +refuse to run there and print the command to run on the machine you want. + ## Troubleshooting - `dlopen(): error loading libfuse.so.2`: install `libfuse2`. diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index cc491d1c072..c396522ba38 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -3682,6 +3682,7 @@ export default function SessionScreen() { getActiveWorktreeConnectionId, beforeTerminalSend: flushPendingLiveInputBeforeAttachmentSend, nativeChatBaseSend: nativeChatController.handleNativeChatSendWithOutcome, + readSeededLaunchDraft: nativeChatController.readSeededLaunchDraft, showToast, onNativeChatSendError: nativeChatSendError.show, onSuccess: triggerSelection, diff --git a/mobile/app/h/[hostId]/session/mobile-session-route-types.ts b/mobile/app/h/[hostId]/session/mobile-session-route-types.ts index 219dd58dc7b..c5a94e9d38f 100644 --- a/mobile/app/h/[hostId]/session/mobile-session-route-types.ts +++ b/mobile/app/h/[hostId]/session/mobile-session-route-types.ts @@ -28,6 +28,7 @@ export type MobileSessionTab = launchAgent?: TuiAgent /** Host-provided launch context still parked as an unsent TUI-input draft. */ launchDraft?: string + launchDraftCreatedAt?: number terminalTheme?: MobileTerminalTheme isActive: boolean } diff --git a/mobile/src/session/mobile-native-chat-eligibility.ts b/mobile/src/session/mobile-native-chat-eligibility.ts index 988e56c67a4..2f997c2cdc5 100644 --- a/mobile/src/session/mobile-native-chat-eligibility.ts +++ b/mobile/src/session/mobile-native-chat-eligibility.ts @@ -27,6 +27,7 @@ export type MobileNativeChatTab = { agentStatus?: AgentStatusEntry | null /** Host-provided launch context still parked as an unsent TUI-input draft. */ launchDraft?: string + launchDraftCreatedAt?: number } /** Resolve a session tab to the transcript identity native chat needs, or diff --git a/mobile/src/session/mobile-native-chat-image-send.test.ts b/mobile/src/session/mobile-native-chat-image-send.test.ts index c987e3f9774..46ac4730f2e 100644 --- a/mobile/src/session/mobile-native-chat-image-send.test.ts +++ b/mobile/src/session/mobile-native-chat-image-send.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import type { RpcClient } from '../transport/rpc-client' import type { RpcResponse, RpcSuccess } from '../transport/types' import { pasteMobileNativeChatImagePaths } from './mobile-native-chat-image-send' +import { buildAgentTuiClearInputForText } from '../../../src/shared/agent-tui-input-clear' function sendResult(accepted: boolean, id = 'send'): RpcSuccess { return { id, ok: true, result: { send: { accepted } }, _meta: { runtimeId: 'r' } } @@ -101,3 +102,52 @@ describe('pasteMobileNativeChatImagePaths', () => { } }) }) + +describe('clearing a parked multi-line launch draft before the image paste', () => { + it('leads with the caller-sized burst instead of one Ctrl+U', async () => { + // One Ctrl+U kills only the LAST line, so the draft's earlier lines would + // survive and ride along with the image as part of the prompt body. + const client = clientWithResponses([sendResult(true), sendResult(true)]) + const clearInput = buildAgentTuiClearInputForText('Linked Linear issue: ABC-123\nhttps://x') + + await pasteMobileNativeChatImagePaths({ + client, + terminal: 'term-1', + deviceToken: null, + imagePaths: ['/tmp/a.png'], + clearInput + }) + + expect(client.calls[0]?.params.text).toBe(clearInput) + expect(client.calls[0]?.params.text).not.toBe('\x15') + }) + + it('clears once, before the paste — never between or after the image writes', async () => { + const client = clientWithResponses([sendResult(true), sendResult(true), sendResult(true)]) + const clearInput = buildAgentTuiClearInputForText('a\nb\nc') + + await pasteMobileNativeChatImagePaths({ + client, + terminal: 'term-1', + deviceToken: null, + imagePaths: ['/tmp/a.png', '/tmp/b.png'], + clearInput + }) + + expect(client.calls.filter((call) => call.params.text === clearInput)).toHaveLength(1) + expect(client.calls[0]?.params.text).toBe(clearInput) + }) + + it('falls back to a single Ctrl+U when no draft is parked', async () => { + const client = clientWithResponses([sendResult(true), sendResult(true)]) + + await pasteMobileNativeChatImagePaths({ + client, + terminal: 'term-1', + deviceToken: null, + imagePaths: ['/tmp/a.png'] + }) + + expect(client.calls[0]?.params.text).toBe('\x15') + }) +}) diff --git a/mobile/src/session/mobile-native-chat-image-send.ts b/mobile/src/session/mobile-native-chat-image-send.ts index 4d52c59b0bb..adb996b7612 100644 --- a/mobile/src/session/mobile-native-chat-image-send.ts +++ b/mobile/src/session/mobile-native-chat-image-send.ts @@ -26,6 +26,10 @@ type PasteImagesArgs = { /** Budget shared with the rest of the user action (the text body that follows, or * the send this is healing for). Omit to open a fresh one for this paste alone. */ readonly deadline?: number + /** Bytes for the leading clear. Defaults to a single Ctrl+U, which clears only + * ONE logical line — callers holding a parked multi-line launch draft must + * pass a burst, or its earlier lines survive and glue onto the message. */ + readonly clearInput?: string } /** Clears the agent's unsubmitted input line, then pastes each uploaded image @@ -38,7 +42,8 @@ export async function pasteMobileNativeChatImagePaths({ terminal, deviceToken, imagePaths, - deadline: sharedDeadline + deadline: sharedDeadline, + clearInput }: PasteImagesArgs): Promise { const mobileClient: MobileTerminalClient | null = deviceToken ? { id: deviceToken, type: 'mobile' } @@ -49,7 +54,7 @@ export async function pasteMobileNativeChatImagePaths({ // once and let each write draw from what's left. const deadline = sharedDeadline ?? openMobileNativeChatSendBudget() for (const text of [ - MOBILE_NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT, + clearInput ?? MOBILE_NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT, ...imagePaths.map(buildMobileImagePastePayload) ]) { const remainingMs = deadline - Date.now() diff --git a/mobile/src/session/mobile-native-chat-send.test.ts b/mobile/src/session/mobile-native-chat-send.test.ts index 1f3d57985ac..ccdacfd7b3e 100644 --- a/mobile/src/session/mobile-native-chat-send.test.ts +++ b/mobile/src/session/mobile-native-chat-send.test.ts @@ -5,9 +5,11 @@ import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-clien import { MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS, openMobileNativeChatSendBudget, + clearMobileNativeChatInput, sendMobileNativeChatMessage, sendMobileNativeChatMessageWithOutcome } from './mobile-native-chat-send' +import { buildAgentTuiClearInputForText } from '../../../src/shared/agent-tui-input-clear' function clientWithResponse(response: unknown): RpcClient { return { @@ -29,6 +31,7 @@ describe('sendMobileNativeChatMessage', () => { client, terminal: 'term', text: 'hello', + resolvedLaunchDraft: { text: 'seed', createdAt: 7 }, mobileClient: { id: 'device', type: 'mobile' } }) ).resolves.toBe(true) @@ -38,6 +41,7 @@ describe('sendMobileNativeChatMessage', () => { terminal: 'term', text: 'hello', enter: true, + resolvedLaunchDraft: { text: 'seed', createdAt: 7 }, client: { id: 'device', type: 'mobile' } }, { timeoutMs: MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS, budgetSpansConnect: true } @@ -285,3 +289,83 @@ describe('sendMobileNativeChatMessage', () => { expect(budget).toBeLessThanOrEqual(MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS) }) }) + +describe('clearMobileNativeChatInput', () => { + const accepted = { + id: 'request', + ok: true, + result: { send: { accepted: true } }, + _meta: { runtimeId: 'runtime' } + } + const params = (client: RpcClient) => + vi.mocked(client.sendRequest).mock.calls[0]![1] as { text: string; enter: boolean } + + it('writes the burst as its OWN non-submitting write', async () => { + // Bundling the burst into the body write reached the agent as LITERAL Ctrl+U + // text and the parked draft concatenated (observed live). + const client = clientWithResponse(accepted) + const clearInput = buildAgentTuiClearInputForText('Linked Linear issue: ABC-123\nhttps://x') + await expect( + clearMobileNativeChatInput({ client, terminal: 'term', clearInput }) + ).resolves.toBe(true) + expect(params(client)).toMatchObject({ text: clearInput, enter: false }) + }) + + it('reports failure when the host rejects the clear', async () => { + const client = clientWithResponse({ + id: 'request', + ok: true, + result: { send: { accepted: false } }, + _meta: { runtimeId: 'runtime' } + }) + await expect( + clearMobileNativeChatInput({ client, terminal: 'term', clearInput: '\x15' }) + ).resolves.toBe(false) + }) + + it('refuses to start an underfunded clear rather than half-clearing', async () => { + const client = clientWithResponse(accepted) + await expect( + clearMobileNativeChatInput({ + client, + terminal: 'term', + clearInput: '\x15', + deadline: Date.now() + 10 + }) + ).resolves.toBe(false) + expect(client.sendRequest).not.toHaveBeenCalled() + }) +}) + +describe('the body write never carries a multi-line burst', () => { + const accepted = { + id: 'request', + ok: true, + result: { send: { accepted: true } }, + _meta: { runtimeId: 'runtime' } + } + const sentText = (client: RpcClient): string => + (vi.mocked(client.sendRequest).mock.calls[0]![1] as { text: string }).text + + it('still prefixes only a single Ctrl+U when asked to clear first', async () => { + const client = clientWithResponse(accepted) + await sendMobileNativeChatMessage({ + client, + terminal: 'term', + text: 'hello', + clearInputFirst: true + }) + expect(sentText(client)).toBe('\x15hello') + }) + + it('never prefixes a clear when the caller already pasted (image sends)', async () => { + const client = clientWithResponse(accepted) + await sendMobileNativeChatMessage({ + client, + terminal: 'term', + text: 'caption', + clearInputFirst: false + }) + expect(sentText(client)).toBe('caption') + }) +}) diff --git a/mobile/src/session/mobile-native-chat-send.ts b/mobile/src/session/mobile-native-chat-send.ts index ec8a0f97861..ce432c3efb9 100644 --- a/mobile/src/session/mobile-native-chat-send.ts +++ b/mobile/src/session/mobile-native-chat-send.ts @@ -11,6 +11,11 @@ type MobileTerminalClient = { // Why: Ctrl+U kills the TUI's current input line (desktop native chat sends the // same byte before its body), so a launch-context prefill parked there cannot // concatenate with a mobile chat message. The host writes text bytes verbatim. +// +// One Ctrl+U clears ONE logical line, which is all this prefix can do. A parked +// launch draft is routinely multi-line (every Linear block is); callers that know +// one is parked must call clearMobileNativeChatInput FIRST — see +// src/shared/agent-tui-input-clear.ts for the measured 2N-1 law. const CLEAR_UNSUBMITTED_INPUT = '\x15' type MobileNativeChatSendArgs = { @@ -19,6 +24,8 @@ type MobileNativeChatSendArgs = { text: string enter?: boolean clearInputFirst?: boolean + /** Exact host launch draft this submitting write resolves when accepted. */ + resolvedLaunchDraft?: { text: string; createdAt: number } mobileClient?: MobileTerminalClient /** Shared budget for a whole user action (heal → paste → text, or one selector's * keystroke sequence). Omit to give this write its own full budget. */ @@ -59,6 +66,7 @@ export async function sendMobileNativeChatMessageWithOutcome( terminal: args.terminal, text: args.clearInputFirst ? `${CLEAR_UNSUBMITTED_INPUT}${args.text}` : args.text, enter: args.enter ?? true, + ...(args.resolvedLaunchDraft ? { resolvedLaunchDraft: args.resolvedLaunchDraft } : {}), ...(args.mobileClient ? { client: args.mobileClient } : {}) }, // The budget covers this whole write, reconnect wait included — a chat send @@ -84,3 +92,42 @@ export async function sendMobileNativeChatMessage( ): Promise { return (await sendMobileNativeChatMessageWithOutcome(args)) === 'accepted' } + +/** + * Clear the agent's input line as its OWN write, before any body. + * + * Why not prefix it onto the body write: a multi-line clear burst bundled into + * the same `terminal.send` as the text reached the agent as LITERAL Ctrl+U + * characters — the draft survived and the burst landed in the middle of the + * message (observed live: draft + 21 literal \x15 + body). A standalone write is + * the shape the image paste has always used, and it clears as intended. + */ +export async function clearMobileNativeChatInput(args: { + client: RpcClient + terminal: string + clearInput: string + mobileClient?: MobileTerminalClient + deadline?: number +}): Promise { + const timeoutMs = + args.deadline === undefined ? MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS : args.deadline - Date.now() + if (timeoutMs < MOBILE_NATIVE_CHAT_MIN_WRITE_TIMEOUT_MS) { + return false + } + try { + const response = await args.client.sendRequest( + 'terminal.send', + { + terminal: args.terminal, + text: args.clearInput, + enter: false, + ...(args.mobileClient ? { client: args.mobileClient } : {}) + }, + { timeoutMs, budgetSpansConnect: true } + ) + return isTerminalSendRpcAccepted(response) + } catch { + // A failed clear must not send the body on top of an uncleared line. + return false + } +} diff --git a/mobile/src/session/mobile-terminal-records.test.ts b/mobile/src/session/mobile-terminal-records.test.ts index fba0774261c..cdcbfaafade 100644 --- a/mobile/src/session/mobile-terminal-records.test.ts +++ b/mobile/src/session/mobile-terminal-records.test.ts @@ -102,12 +102,14 @@ describe('mobile terminal records', () => { } const seeded: MobileTerminalSessionTab = { ...base, - launchDraft: 'https://github.com/o/r/issues/12' + launchDraft: 'https://github.com/o/r/issues/12', + launchDraftCreatedAt: 1 } expect(mobileSessionTabsEqual([base], [seeded])).toBe(false) expect(mobileSessionTabsEqual([seeded], [base])).toBe(false) expect(mobileSessionTabsEqual([seeded], [{ ...seeded }])).toBe(true) + expect(mobileSessionTabsEqual([seeded], [{ ...seeded, launchDraftCreatedAt: 2 }])).toBe(false) }) it('treats terminal agent-status changes as session-tab changes', () => { diff --git a/mobile/src/session/mobile-terminal-records.ts b/mobile/src/session/mobile-terminal-records.ts index b90f679b0a1..821d8b5e08b 100644 --- a/mobile/src/session/mobile-terminal-records.ts +++ b/mobile/src/session/mobile-terminal-records.ts @@ -20,6 +20,7 @@ export type MobileTerminalSessionTab = { agentStatus?: AgentStatusEntry | null /** Host-provided launch context still parked as an unsent TUI-input draft. */ launchDraft?: string + launchDraftCreatedAt?: number terminalTheme?: MobileTerminalTheme isActive: boolean } @@ -90,6 +91,7 @@ function mobileSessionTabEqual( // A frame whose only delta is the launch draft appearing or retracting // still has to reach the chat composer. a.launchDraft === b.launchDraft && + a.launchDraftCreatedAt === b.launchDraftCreatedAt && JSON.stringify(a.agentStatus ?? null) === JSON.stringify(b.agentStatus ?? null) && JSON.stringify(a.terminalTheme ?? null) === JSON.stringify(b.terminalTheme ?? null) ) diff --git a/mobile/src/session/use-mobile-native-chat-controller.test.ts b/mobile/src/session/use-mobile-native-chat-controller.test.ts index db285aecb42..2b4fb0e9d0f 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.test.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.test.ts @@ -35,6 +35,8 @@ vi.mock('./use-mobile-native-chat-drafts', () => ({ setComposerText: vi.fn(), pending: [], captureSendOrigin, + readSeededLaunchDraft: () => null, + readSeededLaunchDraftSeed: () => null, clearDraftForSend, restoreRejectedDraft, acceptSend, @@ -327,6 +329,7 @@ describe('useMobileNativeChatController launch-draft wiring', () => { terminal: 'term-1', launchAgent: 'claude', launchDraft: 'https://github.com/o/r/issues/12', + launchDraftCreatedAt: 7, isActive: true } @@ -385,6 +388,7 @@ describe('useMobileNativeChatController launch-draft wiring', () => { expect(draftsArgs.at(-1)).toMatchObject({ tabId: 'tab-1', launchDraft: 'https://github.com/o/r/issues/12', + launchDraftCreatedAt: 7, chatActive: true, transcriptLoading: false }) diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts index 255e28981d9..fd0a564b19c 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.ts @@ -73,6 +73,9 @@ export type MobileNativeChatController = { images?: string[], deadline?: number ) => Promise + /** Launch-context text still parked on the agent's TUI input line, or null. + * Image sends read it to size their leading clear (one Ctrl+U per line). */ + readSeededLaunchDraft: () => string | null } /** Owns mobile native-chat state and teardown outside the already dense session @@ -134,6 +137,8 @@ export function useMobileNativeChatController(args: { setComposerText: setChatComposerText, pending: chatPending, captureSendOrigin, + readSeededLaunchDraft, + readSeededLaunchDraftSeed, clearDraftForSend, restoreRejectedDraft, acceptSend, @@ -145,6 +150,7 @@ export function useMobileNativeChatController(args: { sessionId: activeChatSessionId, messages: nativeChatSession.messages, launchDraft: activeSessionTab?.launchDraft ?? null, + launchDraftCreatedAt: activeSessionTab?.launchDraftCreatedAt ?? null, // Why: pass the raw draft plus this flag rather than nulling it off-chat — // a null is indistinguishable from a host retraction, and peeking at the // terminal view would permanently decline the prefill. @@ -243,6 +249,7 @@ export function useMobileNativeChatController(args: { handleRef: activeHandleRef, deviceTokenRef, captureSendOrigin, + readSeededLaunchDraftSeed, clearDraftForSend, restoreRejectedDraft, acceptSend, @@ -278,6 +285,7 @@ export function useMobileNativeChatController(args: { loadNativeChatFiles, handleNativeChatQuestionAnswer, handleNativeChatSend, - handleNativeChatSendWithOutcome + handleNativeChatSendWithOutcome, + readSeededLaunchDraft } } diff --git a/mobile/src/session/use-mobile-native-chat-drafts-launch-draft.test.ts b/mobile/src/session/use-mobile-native-chat-drafts-launch-draft.test.ts index 3b830c0384b..91c84814c41 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts-launch-draft.test.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts-launch-draft.test.ts @@ -38,6 +38,7 @@ describe('useMobileNativeChatDrafts launch draft', () => { sessionId = `session-${tabId}`, messages = [], launchDraft = null, + launchDraftCreatedAt = null, chatActive = true, transcriptLoading = false }: { @@ -45,6 +46,7 @@ describe('useMobileNativeChatDrafts launch draft', () => { sessionId?: string | null messages?: NativeChatMessage[] launchDraft?: string | null + launchDraftCreatedAt?: number | null chatActive?: boolean transcriptLoading?: boolean }): null { @@ -55,6 +57,7 @@ describe('useMobileNativeChatDrafts launch draft', () => { sessionId, messages, launchDraft, + launchDraftCreatedAt, chatActive, transcriptLoading }) @@ -97,6 +100,21 @@ describe('useMobileNativeChatDrafts launch draft', () => { expect(state?.composerText).toBe('') }) + it('captures the generation paired with the adopted text', async () => { + await mount('a') + await act(async () => + renderer?.update( + createElement(Harness, { + tabId: 'a', + launchDraft: 'issue link', + launchDraftCreatedAt: 7 + }) + ) + ) + + expect(state?.readSeededLaunchDraftSeed()).toEqual({ text: 'issue link', createdAt: 7 }) + }) + it('does not overwrite typed composer text with a launch draft', async () => { await mount('a') act(() => state?.setComposerText('typed first')) diff --git a/mobile/src/session/use-mobile-native-chat-drafts.ts b/mobile/src/session/use-mobile-native-chat-drafts.ts index 79b82ad5cc6..a43bc8b286f 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts.ts @@ -8,6 +8,8 @@ import { type UnconfirmedSend } from './mobile-native-chat-draft-reconcile' import { mobileNativeChatScopeKey } from './mobile-native-chat-scope-key' +import { useMobileNativeChatLaunchDraftSeed } from './use-mobile-native-chat-launch-draft-seed' +import type { MobileNativeChatLaunchDraftSeed } from './use-mobile-native-chat-launch-draft-seed' export type MobileNativeChatPendingMessage = { id: string @@ -43,6 +45,7 @@ export function useMobileNativeChatDrafts(args: { messages: readonly NativeChatMessage[] /** Host-provided launch context still parked as an unsent TUI-input draft. */ launchDraft?: string | null + launchDraftCreatedAt?: number | null /** Whether the tab is currently resolved to the chat view. Off-chat the * launch-draft effects hold their state instead of acting on it. */ chatActive?: boolean @@ -55,6 +58,11 @@ export function useMobileNativeChatDrafts(args: { setComposerText: Dispatch> pending: MobileNativeChatPendingMessage[] captureSendOrigin: (text: string) => MobileNativeChatSendOrigin | null + /** Launch-context text still believed to be parked on the agent's TUI input + * line, or null once it has been declined or retired. Send paths size their + * pre-clear from it, since one Ctrl+U clears only one logical line. */ + readSeededLaunchDraft: () => string | null + readSeededLaunchDraftSeed: () => MobileNativeChatLaunchDraftSeed | null /** Clear the composer at send time, before the RPC settles. */ clearDraftForSend: (origin: MobileNativeChatSendOrigin, text: string) => void /** Put the text back after a definite rejection, unless newer edits exist. */ @@ -73,6 +81,7 @@ export function useMobileNativeChatDrafts(args: { sessionId, messages, launchDraft, + launchDraftCreatedAt, chatActive = true, transcriptLoading } = args @@ -91,62 +100,15 @@ export function useMobileNativeChatDrafts(args: { activePendingKeyRef.current = pendingKey const mountedRef = useRef(false) - // Seeded launch-context text per tab; '' marks a permanent decline so a - // cleared composer never resurrects the prefill. - const seededLaunchDraftByKeyRef = useRef(new Map()) - - // Why: launch context delivered as a TUI-input prefill is invisible in chat; - // adopt it once as the composer draft so mobile shows the same context. - useEffect(() => { - if ( - !draftKey || - !chatActive || - !launchDraft?.trim() || - seededLaunchDraftByKeyRef.current.has(draftKey) - ) { - return - } - // Why: `session.tabs` carries launchDraft before the transcript read settles, - // and an empty (or previous tab's) list would let the decline below misjudge - // an already-submitted prefill — long enough for a send to duplicate it. - if (transcriptLoading) { - return - } - // A user turn already in the transcript means the one-line TUI prefill was - // submitted or deliberately cleared; decline instead of resurrecting it. - if (messages.some((message) => normalizedUserText(message) !== null)) { - seededLaunchDraftByKeyRef.current.set(draftKey, '') - return - } - seededLaunchDraftByKeyRef.current.set(draftKey, launchDraft) - setDrafts((previous) => - (previous[draftKey] ?? '') === '' ? { ...previous, [draftKey]: launchDraft } : previous - ) - }, [chatActive, draftKey, launchDraft, messages, transcriptLoading]) - - // Drop an untouched adopted copy once the prefill is resolved elsewhere — a - // user turn landed (sent or cleared TUI-side) or the host stopped publishing - // it (desktop sent or reconciled it). User edits are always kept. - useEffect(() => { - // Same gates as the seed: off-chat there is no retraction to read (the tab - // publishes no draft to us), and an untrusted transcript would wipe an - // untouched copy on the strength of another tab's user turns. - if (!draftKey || !chatActive || transcriptLoading) { - return - } - const seeded = seededLaunchDraftByKeyRef.current.get(draftKey) - if (!seeded) { - return - } - const hasUserTurn = messages.some((message) => normalizedUserText(message) !== null) - if (!hasUserTurn && launchDraft?.trim()) { - return - } - seededLaunchDraftByKeyRef.current.set(draftKey, '') - setDrafts((previous) => - (previous[draftKey] ?? '') === seeded ? { ...previous, [draftKey]: '' } : previous - ) - }, [chatActive, draftKey, launchDraft, messages, transcriptLoading]) + const { readSeededLaunchDraft, readSeededLaunchDraftSeed } = useMobileNativeChatLaunchDraftSeed({ + draftKey, + messages, + launchDraft, + launchDraftCreatedAt, + chatActive, + transcriptLoading, + setDrafts + }) const setComposerText: Dispatch> = useCallback( (value) => { @@ -358,6 +320,8 @@ export function useMobileNativeChatDrafts(args: { setComposerText, pending, captureSendOrigin, + readSeededLaunchDraft, + readSeededLaunchDraftSeed, clearDraftForSend, restoreRejectedDraft, acceptSend, diff --git a/mobile/src/session/use-mobile-native-chat-image-attachments.test.ts b/mobile/src/session/use-mobile-native-chat-image-attachments.test.ts index 68ae3e2dc05..be2f9a3730e 100644 --- a/mobile/src/session/use-mobile-native-chat-image-attachments.test.ts +++ b/mobile/src/session/use-mobile-native-chat-image-attachments.test.ts @@ -1,5 +1,6 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { buildAgentTuiClearInputForText } from '../../../src/shared/agent-tui-input-clear' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { RpcClient } from '../transport/rpc-client' import type { RpcResponse, RpcSuccess } from '../transport/types' @@ -69,6 +70,7 @@ function baseArgs(overrides: Partial & Pick): Hook showToast: vi.fn(), onSendError: vi.fn(), baseSend: vi.fn().mockResolvedValue('accepted'), + readSeededLaunchDraft: () => null, sleep: async () => {}, ...overrides } @@ -201,6 +203,40 @@ describe('useMobileNativeChatImageAttachments', () => { expect(hook!.attachments).toEqual([]) }) + it('leads the image paste with a clear sized to a parked multi-line launch draft', async () => { + // A single Ctrl+U kills only the last line, so the draft's earlier lines + // would survive the clear and ride along with the image as prompt body. + pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + const client = makeClient([ + methodNotFound('start'), + ok('save', '/tmp/a.png'), + sendResult(true), + sendResult(true) + ]) + const draft = 'Linked Linear issue: ABC-123\nhttps://linear.app/x/issue/ABC-123' + mount( + baseArgs({ + client: client as unknown as RpcClient, + deviceTokenRef: { current: 'device-1' }, + readSeededLaunchDraft: () => draft + }) + ) + + await act(async () => { + await hook!.attachImage('library') + }) + await act(async () => { + await hook!.sendNativeChat('look at this') + }) + + const firstSend = client.calls.find((c) => c.method === 'terminal.send') + expect(firstSend?.params).toMatchObject({ + text: buildAgentTuiClearInputForText(draft), + enter: false + }) + expect(firstSend?.params.text).not.toBe('\x15') + }) + it('spends one budget across the image paste and the text body that follows', async () => { vi.useFakeTimers() try { diff --git a/mobile/src/session/use-mobile-native-chat-image-attachments.ts b/mobile/src/session/use-mobile-native-chat-image-attachments.ts index a3216b13502..12d7b20178f 100644 --- a/mobile/src/session/use-mobile-native-chat-image-attachments.ts +++ b/mobile/src/session/use-mobile-native-chat-image-attachments.ts @@ -1,5 +1,6 @@ import { useCallback, useRef, useState } from 'react' import { CLIPBOARD_IMAGE_TOO_LARGE_ERROR } from '../../../src/shared/clipboard-image' +import { buildAgentTuiClearInputForText } from '../../../src/shared/agent-tui-input-clear' import type { RpcClient } from '../transport/rpc-client' import type { ConnectionState } from '../transport/types' import { @@ -57,6 +58,10 @@ type Args = { imagePreviewUris?: string[], deadline?: number ) => Promise + /** Launch-context text parked on the agent's TUI input line, or null. The + * paste's leading clear must cover every line of it, or the draft's earlier + * lines survive and ride along with the image. */ + readonly readSeededLaunchDraft: () => string | null readonly onAttachSuccess?: () => void readonly onError?: () => void // Injected so the settle between image paste and submit is instant in tests. @@ -107,6 +112,7 @@ export function useMobileNativeChatImageAttachments({ showToast, onSendError, baseSend, + readSeededLaunchDraft, onAttachSuccess, onError, sleep = defaultSleep @@ -270,12 +276,16 @@ export function useMobileNativeChatImageAttachments({ return false } try { + const seededLaunchDraft = readSeededLaunchDraft() const pasted = await pasteMobileNativeChatImagePaths({ client, terminal: handle, deviceToken: deviceTokenRef.current, imagePaths: pendingImages.map((attachment) => attachment.path), - deadline + deadline, + ...(seededLaunchDraft + ? { clearInput: buildAgentTuiClearInputForText(seededLaunchDraft) } + : {}) }) if (!pasted) { // Keep the chips so the user can retry; the failed paste never submitted. @@ -352,6 +362,7 @@ export function useMobileNativeChatImageAttachments({ enabled, onError, onSendError, + readSeededLaunchDraft, scopeKey, sleep ] diff --git a/mobile/src/session/use-mobile-native-chat-launch-draft-seed.ts b/mobile/src/session/use-mobile-native-chat-launch-draft-seed.ts new file mode 100644 index 00000000000..b75dbf12767 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-launch-draft-seed.ts @@ -0,0 +1,122 @@ +import { useCallback, useEffect, useRef, type Dispatch, type SetStateAction } from 'react' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { normalizedUserText } from './mobile-native-chat-draft-reconcile' + +export type MobileNativeChatLaunchDraftSeed = { + text: string + createdAt: number | null +} + +/** + * Adopting the host's launch-context prefill as the mobile composer draft, and + * retiring it again once it is resolved elsewhere. Split out of the drafts hook + * so the general draft/pending accounting stays separate from this one concern. + */ +export function useMobileNativeChatLaunchDraftSeed(args: { + draftKey: string | null + messages: readonly NativeChatMessage[] + /** Host-provided launch context still parked as an unsent TUI-input draft. */ + launchDraft?: string | null + launchDraftCreatedAt?: number | null + chatActive: boolean + transcriptLoading?: boolean + setDrafts: Dispatch>> +}): { + /** Text still believed to be parked on the agent's TUI input line, or null + * once declined or retired. Send paths size their pre-clear from it, since + * one Ctrl+U clears only one logical line. */ + readSeededLaunchDraft: () => string | null + readSeededLaunchDraftSeed: () => MobileNativeChatLaunchDraftSeed | null +} { + const { + draftKey, + messages, + launchDraft, + launchDraftCreatedAt, + chatActive, + transcriptLoading, + setDrafts + } = args + + // Seeded launch-context text per tab; null marks a permanent decline so a + // cleared composer never resurrects the prefill. + const seededLaunchDraftByKeyRef = useRef( + new Map() + ) + + // Why: launch context delivered as a TUI-input prefill is invisible in chat; + // adopt it once as the composer draft so mobile shows the same context. + useEffect(() => { + if ( + !draftKey || + !chatActive || + !launchDraft?.trim() || + seededLaunchDraftByKeyRef.current.has(draftKey) + ) { + return + } + // Why: `session.tabs` carries launchDraft before the transcript read settles, + // and an empty (or previous tab's) list would let the decline below misjudge + // an already-submitted prefill — long enough for a send to duplicate it. + if (transcriptLoading) { + return + } + // A user turn already in the transcript means the TUI prefill was submitted + // or deliberately cleared; decline instead of resurrecting it. + if (messages.some((message) => normalizedUserText(message) !== null)) { + seededLaunchDraftByKeyRef.current.set(draftKey, null) + return + } + seededLaunchDraftByKeyRef.current.set(draftKey, { + text: launchDraft, + createdAt: launchDraftCreatedAt ?? null + }) + setDrafts((previous) => + (previous[draftKey] ?? '') === '' ? { ...previous, [draftKey]: launchDraft } : previous + ) + }, [ + chatActive, + draftKey, + launchDraft, + launchDraftCreatedAt, + messages, + setDrafts, + transcriptLoading + ]) + + // Drop an untouched adopted copy once the prefill is resolved elsewhere — a + // user turn landed (sent or cleared TUI-side) or the host stopped publishing + // it (desktop sent or reconciled it). User edits are always kept. + useEffect(() => { + // Same gates as the seed: off-chat there is no retraction to read (the tab + // publishes no draft to us), and an untrusted transcript would wipe an + // untouched copy on the strength of another tab's user turns. + if (!draftKey || !chatActive || transcriptLoading) { + return + } + const seeded = seededLaunchDraftByKeyRef.current.get(draftKey) + if (!seeded) { + return + } + const hasUserTurn = messages.some((message) => normalizedUserText(message) !== null) + if (!hasUserTurn && launchDraft?.trim()) { + return + } + seededLaunchDraftByKeyRef.current.set(draftKey, null) + setDrafts((previous) => + (previous[draftKey] ?? '') === seeded.text ? { ...previous, [draftKey]: '' } : previous + ) + }, [chatActive, draftKey, launchDraft, messages, setDrafts, transcriptLoading]) + + // A missing or declined entry means there is nothing of ours on the TUI line. + const readSeededLaunchDraft = useCallback( + () => (draftKey ? (seededLaunchDraftByKeyRef.current.get(draftKey)?.text ?? null) : null), + [draftKey] + ) + const readSeededLaunchDraftSeed = useCallback( + () => (draftKey ? (seededLaunchDraftByKeyRef.current.get(draftKey) ?? null) : null), + [draftKey] + ) + + return { readSeededLaunchDraft, readSeededLaunchDraftSeed } +} diff --git a/mobile/src/session/use-mobile-native-chat-message-send.test.ts b/mobile/src/session/use-mobile-native-chat-message-send.test.ts new file mode 100644 index 00000000000..da36cdb925b --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-message-send.test.ts @@ -0,0 +1,176 @@ +// Covers the wiring the image-attachments suite structurally cannot: that hook +// injects its own baseSend stub, so it never observes the real send params. + +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const sendWithOutcome = vi.fn() +const clearInputWrite = vi.fn() +vi.mock('./mobile-native-chat-send', () => ({ + sendMobileNativeChatMessageWithOutcome: (...args: unknown[]) => sendWithOutcome(...args), + clearMobileNativeChatInput: (...args: unknown[]) => clearInputWrite(...args), + openMobileNativeChatSendBudget: () => Date.now() + 15_000, + MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS: 15_000, + MOBILE_NATIVE_CHAT_MIN_WRITE_TIMEOUT_MS: 2_000 +})) +vi.mock('./mobile-native-chat-stale-input', () => ({ + healMobileNativeChatStaleInput: () => Promise.resolve(true) +})) + +import { useMobileNativeChatMessageSend } from './use-mobile-native-chat-message-send' +import { buildAgentTuiClearInputForText } from '../../../src/shared/agent-tui-input-clear' + +type Send = ReturnType + +const DRAFT = 'Linked Linear issue: ABC-123\nhttps://linear.app/x/issue/ABC-123' + +describe('useMobileNativeChatMessageSend', () => { + let renderer: ReactTestRenderer | null = null + let api: Send | null = null + + const mount = ( + readSeededLaunchDraftSeed: () => { text: string; createdAt: number | null } | null + ): void => { + function Probe(): null { + api = useMobileNativeChatMessageSend({ + client: { sendRequest: vi.fn() } as never, + enabled: true, + handleRef: { current: 'term' }, + deviceTokenRef: { current: 'device' }, + captureSendOrigin: () => ({ draftKey: 'k', pendingKey: 'p' }) as never, + readSeededLaunchDraftSeed, + clearDraftForSend: () => {}, + restoreRejectedDraft: () => {}, + acceptSend: () => {}, + holdUnconfirmedSend: () => {}, + onSendError: () => {} + }) + return null + } + act(() => { + renderer = create(createElement(Probe)) + }) + } + + const sentArgs = (): { + clearInputFirst?: boolean + resolvedLaunchDraft?: { text: string; createdAt: number } + } => + sendWithOutcome.mock.calls[0]![0] as { + clearInputFirst?: boolean + resolvedLaunchDraft?: { text: string; createdAt: number } + } + const clearArgs = (): { clearInput?: string } => + (clearInputWrite.mock.calls[0]?.[0] ?? {}) as { clearInput?: string } + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + sendWithOutcome.mockReset() + sendWithOutcome.mockResolvedValue('accepted') + clearInputWrite.mockReset() + clearInputWrite.mockResolvedValue(true) + }) + afterEach(() => { + act(() => { + renderer?.unmount() + }) + renderer = null + api = null + }) + + it('sizes the pre-clear to every line of a parked launch draft', async () => { + mount(() => ({ text: DRAFT, createdAt: 1 })) + await act(async () => { + await api!.send('hello') + }) + expect(clearArgs().clearInput).toBe(buildAgentTuiClearInputForText(DRAFT)) + }) + + it('issues the burst as its OWN write, before the body', async () => { + // Bundled into the body write it arrived as literal Ctrl+U text. + mount(() => ({ text: DRAFT, createdAt: 1 })) + await act(async () => { + await api!.send('hello') + }) + expect(clearInputWrite).toHaveBeenCalledTimes(1) + expect(sendWithOutcome).toHaveBeenCalledTimes(1) + expect(clearInputWrite.mock.invocationCallOrder[0]).toBeLessThan( + sendWithOutcome.mock.invocationCallOrder[0]! + ) + }) + + it('aborts without sending the body when the clear is rejected', async () => { + // Sending on top of an uncleared line is exactly the concatenation bug. + clearInputWrite.mockResolvedValue(false) + mount(() => ({ text: DRAFT, createdAt: 1 })) + let result: boolean | undefined + await act(async () => { + result = await api!.send('hello') + }) + expect(result).toBe(false) + expect(sendWithOutcome).not.toHaveBeenCalled() + }) + + it('drops the body write\u2019s own Ctrl+U prefix once the dedicated clear ran', async () => { + // A Ctrl+U written immediately before body text in the SAME write arrives as + // a literal control character, so it would head the received message. + mount(() => ({ text: DRAFT, createdAt: 1 })) + await act(async () => { + await api!.send('hello') + }) + expect(sentArgs().clearInputFirst).toBe(false) + expect(sentArgs().resolvedLaunchDraft).toEqual({ text: DRAFT, createdAt: 1 }) + }) + + it('keeps the single-Ctrl+U prefix when no dedicated clear ran', async () => { + mount(() => null) + await act(async () => { + await api!.send('hello') + }) + expect(sentArgs().clearInputFirst).toBe(true) + expect(sentArgs().resolvedLaunchDraft).toBeUndefined() + }) + + it('writes no clear at all when nothing is parked on the line', async () => { + mount(() => null) + await act(async () => { + await api!.send('hello') + }) + expect(clearInputWrite).not.toHaveBeenCalled() + }) + + it('reads the draft at send time, so a retired seed stops widening the clear', async () => { + let parked: { text: string; createdAt: number } | null = { text: DRAFT, createdAt: 1 } + mount(() => parked) + await act(async () => { + await api!.send('first') + }) + parked = null + await act(async () => { + await api!.send('second') + }) + expect(sendWithOutcome.mock.calls[1]![0]).toMatchObject({ clearInputFirst: true }) + expect(clearInputWrite).toHaveBeenCalledTimes(1) + expect(sendWithOutcome.mock.calls[0]![0]).toMatchObject({ clearInputFirst: false }) + }) + + it('does not clear an image send after the image was pasted', async () => { + // A second clear here would wipe the image that was just pasted. + mount(() => ({ text: DRAFT, createdAt: 1 })) + await act(async () => { + await api!.send('caption', ['file:///a.png']) + }) + expect(clearInputWrite).not.toHaveBeenCalled() + expect(sentArgs().clearInputFirst).toBe(false) + expect(sentArgs().resolvedLaunchDraft).toEqual({ text: DRAFT, createdAt: 1 }) + }) + + it('does not resolve a composer seed from a question-card answer', async () => { + mount(() => ({ text: DRAFT, createdAt: 1 })) + await act(async () => { + await api!.answerQuestion('1') + }) + expect(sentArgs().resolvedLaunchDraft).toBeUndefined() + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-message-send.ts b/mobile/src/session/use-mobile-native-chat-message-send.ts index 3fb55a7db3a..591f347c4d7 100644 --- a/mobile/src/session/use-mobile-native-chat-message-send.ts +++ b/mobile/src/session/use-mobile-native-chat-message-send.ts @@ -1,6 +1,7 @@ import { useCallback, type MutableRefObject } from 'react' import type { RpcClient } from '../transport/rpc-client' import { + clearMobileNativeChatInput, openMobileNativeChatSendBudget, sendMobileNativeChatMessageWithOutcome, type MobileNativeChatSendOutcome @@ -8,6 +9,8 @@ import { import { healMobileNativeChatStaleInput } from './mobile-native-chat-stale-input' import type { MobileNativeChatSendOrigin } from './use-mobile-native-chat-drafts' import { t } from '@/i18n/mobile-i18n' +import type { MobileNativeChatLaunchDraftSeed } from './use-mobile-native-chat-launch-draft-seed' +import { buildAgentTuiClearInputForText } from '../../../src/shared/agent-tui-input-clear' export type MobileNativeChatMessageSend = { /** Composer send that syncs the draft (clear on send, restore on rejection). */ @@ -33,6 +36,9 @@ export function useMobileNativeChatMessageSend(args: { handleRef: MutableRefObject deviceTokenRef: MutableRefObject captureSendOrigin: (text: string) => MobileNativeChatSendOrigin | null + /** Launch-context text Orca parked on the agent's TUI input line, or null. Read + * at send time so the pre-clear can be sized to every line it occupies. */ + readSeededLaunchDraftSeed: () => MobileNativeChatLaunchDraftSeed | null clearDraftForSend: (origin: MobileNativeChatSendOrigin, text: string) => void restoreRejectedDraft: (origin: MobileNativeChatSendOrigin, text: string) => void acceptSend: (origin: MobileNativeChatSendOrigin, text: string, images?: string[]) => void @@ -49,6 +55,7 @@ export function useMobileNativeChatMessageSend(args: { handleRef, deviceTokenRef, captureSendOrigin, + readSeededLaunchDraftSeed, clearDraftForSend, restoreRejectedDraft, acceptSend, @@ -96,6 +103,36 @@ export function useMobileNativeChatMessageSend(args: { if (syncComposer) { clearDraftForSend(origin, text) } + // Why: a parked launch draft is routinely multi-line, and one Ctrl+U clears + // only one logical line. Size the clear to the text Orca injected, with + // slack — the user can also have typed into the TUI line directly, so that + // line count is a lower bound. Mobile cannot read the agent's screen, so + // there is no empty-line observable to confirm against here; the upper + // bound plus the host's write acceptance is what makes it safe. + // + // The burst goes out as its OWN write: bundled into the body write it + // arrived as literal Ctrl+U text and the draft concatenated (see + // clearMobileNativeChatInput). A rejected clear aborts the send rather + // than pasting on top of an uncleared line. + const seededLaunchDraft = readSeededLaunchDraftSeed() + if (seededLaunchDraft && !images?.length) { + const cleared = await clearMobileNativeChatInput({ + client, + terminal: handle, + clearInput: buildAgentTuiClearInputForText(seededLaunchDraft.text), + deadline, + ...(deviceTokenRef.current + ? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } } + : {}) + }) + if (!cleared) { + if (syncComposer) { + restoreRejectedDraft(origin, text) + } + onSendError(t('m.ZkABdzg')) + return 'rejected' + } + } const outcome = await sendMobileNativeChatMessageWithOutcome({ client, terminal: handle, @@ -106,7 +143,20 @@ export function useMobileNativeChatMessageSend(args: { // this message. An image send already led its own paste with Ctrl+U, and a // second one here would wipe the image it just pasted (desktop's image path // likewise clears once, before the paste, and never again). - clearInputFirst: !images?.length, + // + // Also skipped once the dedicated clear above ran: the line is already + // empty, and a Ctrl+U written immediately before body text in the SAME + // write reaches the agent as a literal control character rather than a + // keypress (observed live as a stray \x15 heading the received message). + clearInputFirst: !images?.length && !seededLaunchDraft, + ...(syncComposer && typeof seededLaunchDraft?.createdAt === 'number' + ? { + resolvedLaunchDraft: { + text: seededLaunchDraft.text, + createdAt: seededLaunchDraft.createdAt + } + } + : {}), deadline, ...(deviceTokenRef.current ? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } } @@ -140,6 +190,7 @@ export function useMobileNativeChatMessageSend(args: { handleRef, holdUnconfirmedSend, onSendError, + readSeededLaunchDraftSeed, restoreRejectedDraft ] ) diff --git a/mobile/src/session/use-mobile-session-image-attachments.ts b/mobile/src/session/use-mobile-session-image-attachments.ts index ad4343ac87d..9b5a010df9f 100644 --- a/mobile/src/session/use-mobile-session-image-attachments.ts +++ b/mobile/src/session/use-mobile-session-image-attachments.ts @@ -31,6 +31,9 @@ type Args = { images?: string[], deadline?: number ) => Promise + /** Launch-context text parked on the agent's TUI input line, or null — sizes + * the image paste's leading clear so a multi-line draft cannot ride along. */ + readonly readSeededLaunchDraft: () => string | null readonly showToast: (message: string, durationMs?: number) => void /** Native-chat send failures — rendered in the composer's inline banner. */ readonly onNativeChatSendError: (message: string) => void @@ -54,6 +57,7 @@ export function useMobileSessionImageAttachments({ getActiveWorktreeConnectionId, beforeTerminalSend, nativeChatBaseSend, + readSeededLaunchDraft, showToast, onNativeChatSendError, onSuccess, @@ -86,6 +90,7 @@ export function useMobileSessionImageAttachments({ showToast, onSendError: onNativeChatSendError, baseSend: nativeChatBaseSend, + readSeededLaunchDraft, onAttachSuccess: onSuccess, onError }) diff --git a/src/cli/args.ts b/src/cli/args.ts index 3d37517e795..a5150c980e5 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -30,6 +30,7 @@ export const BOOLEAN_FLAGS = new Set([ 'include-archived', 'interrupt', 'json', + 'local', 'messages', 'me', 'mobile', @@ -55,7 +56,7 @@ export const BOOLEAN_FLAGS = new Set([ ]) export const REPEATED_FLAG_SEPARATOR = '\u0000' -const REPEATABLE_STRING_FLAGS = new Set(['label']) +const REPEATABLE_STRING_FLAGS = new Set(['label', 'skill']) function setFlagValue(flags: Map, name: string, value: string): void { const existing = flags.get(name) diff --git a/src/cli/handler-group-manifest.ts b/src/cli/handler-group-manifest.ts index ffe608ccde7..6b2c7410c49 100644 --- a/src/cli/handler-group-manifest.ts +++ b/src/cli/handler-group-manifest.ts @@ -218,7 +218,7 @@ export const HANDLER_GROUPS: readonly HandlerGroup[] = [ }, { name: 'skills', - keys: ['skills list', 'skills get'], + keys: ['skills list', 'skills get', 'skills install', 'skills update'], load: async () => (await import('./handlers/skills.js')).SKILL_HANDLERS } ] diff --git a/src/cli/handlers/orchestration-run-cli.test.ts b/src/cli/handlers/orchestration-run-cli.test.ts index 4ed57bcb169..7159e802490 100644 --- a/src/cli/handlers/orchestration-run-cli.test.ts +++ b/src/cli/handlers/orchestration-run-cli.test.ts @@ -63,6 +63,41 @@ describe('lightweight Run CLI handlers', () => { }) }) + it('passes Run pagination flags to the runtime', async () => { + callMock.mockResolvedValue({ + result: { runs: [], nextCursor: null } + }) + + await ORCHESTRATION_HANDLERS['orchestration run-list']({ + flags: new Map([ + ['limit', '25'], + ['cursor', 'next-page'] + ]), + client: { call: callMock }, + json: true + } as never) + + expect(callMock).toHaveBeenCalledWith('orchestration.runList', { + limit: 25, + cursor: 'next-page' + }) + }) + + it('opts into bounded Run pagination by default', async () => { + callMock.mockResolvedValue({ result: { runs: [], nextCursor: null } }) + + await ORCHESTRATION_HANDLERS['orchestration run-list']({ + flags: new Map(), + client: { call: callMock }, + json: true + } as never) + + expect(callMock).toHaveBeenCalledWith('orchestration.runList', { + limit: 100, + cursor: undefined + }) + }) + it('passes explicit legacy takeover only when requested', async () => { callMock.mockResolvedValue({ result: { run: { id: 'run_adopted', objective: 'Recovered work' } } diff --git a/src/cli/handlers/orchestration.ts b/src/cli/handlers/orchestration.ts index 0e87bc54d46..92a149cbcde 100644 --- a/src/cli/handlers/orchestration.ts +++ b/src/cli/handlers/orchestration.ts @@ -22,6 +22,7 @@ import type { import type { NativeChatMessage } from '../../shared/native-chat-types' import type { RuntimeTerminalRead } from '../../shared/runtime-types' import { orchestrationMigrationData } from '../../shared/orchestration-rpc-contract' +import { ORCHESTRATION_RUN_PAGE_LIMIT } from '../../shared/orchestration-run-pagination' import { formatMessageReadOnlyTag, formatOrchestrationCheckText, @@ -463,19 +464,25 @@ export const ORCHESTRATION_HANDLERS: Record = { ) }, - 'orchestration run-list': async ({ client, json }) => { + 'orchestration run-list': async ({ flags, client, json }) => { const result = await client.call<{ runs: { id: string; objective: string; legacy: number }[] - }>('orchestration.runList', {}) - printResult(result, json, (r) => - r.runs.length === 0 - ? 'No Runs found.' - : r.runs - .map( - (run) => `${run.id}${run.legacy ? ' [legacy, inspect only]' : ''} ${run.objective}` - ) - .join('\n') - ) + nextCursor: string | null + }>('orchestration.runList', { + limit: getOptionalPositiveIntegerFlag(flags, 'limit') ?? ORCHESTRATION_RUN_PAGE_LIMIT, + cursor: getOptionalStringFlag(flags, 'cursor') + }) + printResult(result, json, (r) => { + const rows = + r.runs.length === 0 + ? 'No Runs found.' + : r.runs + .map( + (run) => `${run.id}${run.legacy ? ' [legacy, inspect only]' : ''} ${run.objective}` + ) + .join('\n') + return r.nextCursor ? `${rows}\nMore Runs: --cursor ${r.nextCursor}` : rows + }) }, 'orchestration run-show': async ({ flags, client, json }) => { diff --git a/src/cli/handlers/skills.ts b/src/cli/handlers/skills.ts index d4049605055..26e9ea5a734 100644 --- a/src/cli/handlers/skills.ts +++ b/src/cli/handlers/skills.ts @@ -1,5 +1,21 @@ +import { spawn } from 'node:child_process' import type { CommandHandler } from '../dispatch' import { RuntimeClientError } from '../runtime-client' +import { delimiter, dirname } from 'node:path' +import { getRepeatedStringFlag } from '../flags' +import { resolveCliCommand } from '../../main/codex-cli/command' +import { detectCommandsInInstallDirs } from '../../main/ipc/local-agent-install-dir-detection' +import { + getTuiAgentDetectionProbeCommands, + KNOWN_TUI_AGENT_DETECTION_COMMANDS, + resolveDetectedTuiAgentIds +} from '../../main/ipc/tui-agent-detection-commands' +import { getSpawnArgsForWindows, UnsafeWindowsBatchArgumentsError } from '../../main/win32-utils' +import { isSkillsCliAgentKeyShaped, toSkillsCliAgentKeys } from '../../shared/skills-cli-agent-keys' +import { + buildAgentFeatureSkillInstallArgs, + buildAgentFeatureSkillUpdateArgs +} from '../../shared/agent-feature-install-commands' type BundledSkillGuide = { name: string @@ -46,6 +62,259 @@ function writeStdout(value: string): void { process.stdout.write(value.endsWith('\n') ? value : `${value}\n`) } +function resolveSelectedSkillNames( + flags: Map, + guides: BundledSkillGuide[] +): string[] { + const requestedSkills = getRepeatedStringFlag(flags, 'skill') + const selectAll = flags.get('all') === true + if (flags.has('skill') && requestedSkills.length === 0) { + throw new RuntimeClientError('invalid_argument', 'Missing required --skill') + } + if (selectAll && requestedSkills.length > 0) { + throw new RuntimeClientError('invalid_argument', 'Use either --all or --skill, not both.') + } + if (!selectAll && requestedSkills.length === 0) { + return [] + } + if (selectAll) { + return guides.map((guide) => guide.name) + } + const availableTopics = guides.map((guide) => guide.name).join(', ') + const guideByTopic = new Map( + guides.flatMap((guide) => [guide.name, ...guide.aliases].map((name) => [name, guide])) + ) + const canonicalNames = new Set() + for (const requested of requestedSkills) { + const guide = guideByTopic.get(requested) + if (!guide) { + throw new RuntimeClientError( + 'invalid_argument', + `Unknown skill "${requested}". Available skills: ${availableTopics}` + ) + } + canonicalNames.add(guide.name) + } + return [...canonicalNames].sort() +} + +/** PATH with the resolved npx's own directory first, so its `env node` shebang resolves. */ +function buildNpxPath(resolvedNpx: string): string { + // Why: resolveCliCommand falls back to the bare name when it finds nothing, and + // dirname('npx') is '.', so prepending it blindly would run ./npx out of the + // caller's checkout instead of reporting that npx is missing. + const own = dirname(resolvedNpx) + const existing = process.env.PATH ?? process.env.Path ?? '' + // Why: every version manager ships node beside npx in the same bin directory, + // so the resolved sibling is all the child needs to run npx's shebang. + return [own === '.' ? '' : own, existing].filter(Boolean).join(delimiter) +} + +function runNpxSkills(args: string[]): Promise { + return new Promise((resolve, reject) => { + // Why: a bare PATH lookup misses nvm/fnm/volta installs, and hardcoding + // `npx.cmd` both misses `npx.exe` shims and hides a missing npx behind + // cmd.exe's own exit code, so the friendly error below never fires. + const resolved = resolveCliCommand('npx') + let spawnCmd: string + let spawnArgs: string[] + try { + ;({ spawnCmd, spawnArgs } = getSpawnArgsForWindows(resolved, args)) + } catch (error) { + // Why: the guard rejects cmd metacharacters, and only the resolved npx + // path can carry them here — a username like `A&B` puts them in it. + if (!(error instanceof UnsafeWindowsBatchArgumentsError)) { + reject(error) + return + } + reject( + new RuntimeClientError( + 'invalid_environment', + `Cannot run npx from "${resolved}": the path contains characters cmd.exe would ` + + 'reinterpret. Install Node.js somewhere without & | < > ^ " % ! in the path.' + ) + ) + return + } + // Why: npx is an `#!/usr/bin/env node` script, so resolving it off PATH is + // not enough — without node alongside it the child exits 127 with no + // 'error' event and the message below never fires. + const child = spawn(spawnCmd, spawnArgs, { + stdio: 'inherit', + env: { ...process.env, PATH: buildNpxPath(resolved) } + }) + // Why: a missing npx/Node on a headless host surfaces as a raw spawn ENOENT; + // wrap it so the CLI reports an actionable message like every other failure here. + child.once('error', (error) => { + const detail = error instanceof Error ? error.message : String(error) + reject( + new RuntimeClientError( + 'invalid_environment', + `Could not run npx: ${detail}. Install Node.js and ensure npx is on PATH.` + ) + ) + }) + child.once('exit', (code, signal) => { + resolve(typeof code === 'number' ? code : signal ? 1 : 0) + }) + }) +} + +type SkillMutationVerb = 'install' | 'update' + +/** Agents Orca can see on this host, as `skills --agent` keys. */ +function detectSkillsCliAgentKeys(): string[] { + const runtime = process.platform + const probes = getTuiAgentDetectionProbeCommands(KNOWN_TUI_AGENT_DETECTION_COMMANDS, runtime) + const detected = resolveDetectedTuiAgentIds( + KNOWN_TUI_AGENT_DETECTION_COMMANDS, + detectCommandsInInstallDirs(probes), + runtime + ) + return detected.length === 0 ? [] : toSkillsCliAgentKeys(detected) +} + +function resolveInstallAgentKeys(flags: Map): string[] { + const requested = flags.get('agent') + if (flags.has('agent') && typeof requested !== 'string') { + throw new RuntimeClientError('invalid_argument', 'Missing required --agent') + } + if (typeof requested === 'string') { + // Why: one comma-separated value rather than a repeatable flag — `agent` is a + // single-value flag on other commands and the repeatable set is process-wide, + // so making it repeatable here would change how those parse a second --agent. + const keys = [ + ...new Set( + requested + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean) + ) + ] + // Why: a value like "," parses to nothing. Falling through to detection would + // be surprising, and emitting no --agent would restore the all-agents install. + if (keys.length === 0) { + throw new RuntimeClientError('invalid_argument', 'Missing required --agent') + } + const unusable = keys.find((key) => !isSkillsCliAgentKeyShaped(key)) + if (unusable !== undefined) { + // Why: the skills CLI drops a value starting with `-`, which leaves it with + // no target and installs into every agent it knows. + throw new RuntimeClientError( + 'invalid_argument', + `Invalid --agent value "${unusable}". Pass agent names such as claude-code, ` + + 'codex, or universal.' + ) + } + return keys + } + const detected = detectSkillsCliAgentKeys() + if (detected.length > 0) { + return detected + } + // Why: without --agent, `skills add -y` falls into its own zero-detected branch + // and installs into every agent it knows (~75), creating config directories for + // agents this host does not have. Say so instead. + throw new RuntimeClientError( + 'invalid_environment', + 'No coding agent detected on this host, so there is no install target. Pass ' + + '--agent [,...] to choose targets explicitly — --agent universal ' + + 'writes only the shared .agents/skills directory that Orca reads.' + ) +} + +function buildNpxSkillsArgs( + verb: SkillMutationVerb, + skillNames: string[], + global: boolean, + agents: string[] +): string[] { + const skillArgs = + verb === 'install' + ? buildAgentFeatureSkillInstallArgs(skillNames, { global, yes: true, agents }) + : buildAgentFeatureSkillUpdateArgs(skillNames, { global, yes: true }) + // Why: a cold package cache makes bare `npx` prompt before it will fetch + // `skills`, which strands an unattended host just like the picker does. + return ['--yes', ...skillArgs] +} + +/** Render the exact argv a real run spawns, so --dry-run can never drift from it. */ +function formatNpxCommand(args: string[]): string { + return `npx ${args.join(' ')}` +} + +function formatSkillSelectionHelp(verb: SkillMutationVerb, skillNames: string[]): string { + return [ + `Choose one or more skills to ${verb}:`, + ...skillNames.map((name) => ` ${name}`), + '', + `Usage: orca skills ${verb} --skill [--skill ...]`, + ` or: orca skills ${verb} --all` + ].join('\n') +} + +function createSkillMutationHandler(verb: SkillMutationVerb): CommandHandler { + return async ({ flags, json }) => { + // Why: keep the large generated table off the eager handler registry path. + const { BUNDLED_SKILL_GUIDES } = await import('../bundled-skill-guides.js') + const guides = canonicalGuides(BUNDLED_SKILL_GUIDES) + const skillNames = resolveSelectedSkillNames(flags, guides) + + if (skillNames.length === 0) { + const names = guides.map((guide) => guide.name) + writeStdout( + json + ? JSON.stringify({ availableSkills: names }, null, 2) + : formatSkillSelectionHelp(verb, names) + ) + return + } + + // Why: this runs before target resolution because the answer belongs to the + // other machine — agents detected here would be the wrong host's, and a host + // that detects none would hide the forwarding problem behind that error. + if (process.env.ORCA_CLI_CWD) { + throw new RuntimeClientError( + 'invalid_environment', + `orca skills ${verb} writes to the machine that runs it, but this shell forwards ` + + `orca to the Orca host. Run the same orca skills ${verb} command on the machine ` + + "you want it on, where it can detect that host's agents." + ) + } + + const global = flags.get('local') !== true + // Why: install scopes its targets; update only refreshes what is already placed. + const agents = verb === 'install' ? resolveInstallAgentKeys(flags) : [] + const npxArgs = buildNpxSkillsArgs(verb, skillNames, global, agents) + const command = formatNpxCommand(npxArgs) + const dryRun = flags.get('dry-run') === true + + if (dryRun) { + writeStdout( + json + ? JSON.stringify({ command, skills: skillNames, global, executed: false }, null, 2) + : `${command}\n\nRerun without --dry-run to ${verb} now.` + ) + return + } + + if (json) { + // Why: a real run inherits npx's own stdout so progress stays visible live; + // that stream is not JSON, so --json can't be honored here. + throw new RuntimeClientError( + 'invalid_argument', + `orca skills ${verb} --json only supports --dry-run. Real ${verb}s stream ` + + "npx's own output, which isn't JSON." + ) + } + + // Why: stdio is inherited for the child below, so this status line must go to + // stderr — stdout is npx's own output, not this command's JSON channel. + process.stderr.write(`Running: ${command}\n`) + process.exitCode = await runNpxSkills(npxArgs) + } +} + export const SKILL_HANDLERS: Record = { 'skills list': async ({ json }) => { // Why: the embedded guide table is large, so unrelated CLI commands must not @@ -72,5 +341,7 @@ export const SKILL_HANDLERS: Record = { const full = flags.has('full') const markdown = full ? guide.fullMarkdown : guide.markdown writeStdout(json ? JSON.stringify({ name: guide.name, full, markdown }, null, 2) : markdown) - } + }, + 'skills install': createSkillMutationHandler('install'), + 'skills update': createSkillMutationHandler('update') } diff --git a/src/cli/help.ts b/src/cli/help.ts index 7dd39615ec9..c9d98603c63 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -21,6 +21,8 @@ Agent Discovery: Skills: skills list List version-matched skill guides bundled with this Orca CLI skills get Print a version-matched skill guide as Markdown + skills install Install bundled Orca skills globally via the community skills CLI + skills update Update already-installed Orca skills via the community skills CLI Environments: environment add Save a remote Orca runtime from a pairing code @@ -412,6 +414,9 @@ export function formatGroupHelp(specs: CommandSpec[], group: string): string { function formatCommandFlagHelp(flag: string, commandPath: string[]): string { const command = commandPath.join(' ') + if (command === 'skills install' && flag === 'agent') { + return '--agent Comma-separated install targets; default is detected agents' + } if (command === 'terminal close' && flag === 'tab') { return '--tab Close the whole tab and wait for durable persistence' } @@ -521,6 +526,8 @@ export function formatFlagHelp(flag: string): string { json: '--json Emit machine-readable JSON', key: '--key Key argument for this command', limit: '--limit Maximum number of rows to return', + local: '--local Target the current project instead of the global install', + skill: '--skill Bundled skill to act on; repeat for several', mode: '--mode Mode such as edit, diff, or both', 'mouse-button': '--mouse-button Mouse button: left, right, or middle', modifiers: '--modifiers Modifier keys held only for this click', diff --git a/src/cli/skills.test.ts b/src/cli/skills.test.ts index d80c486f805..1cdee5aad4b 100644 --- a/src/cli/skills.test.ts +++ b/src/cli/skills.test.ts @@ -1,8 +1,36 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { EventEmitter } from 'node:events' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { delimiter } from 'node:path' +import type * as CodexCliCommandModule from '../main/codex-cli/command' -const { guideModuleLoadMock, runtimeClientConstructorMock } = vi.hoisted(() => ({ +const { + detectCommandsMock, + guideModuleLoadMock, + resolveCliCommandMock, + runtimeClientConstructorMock, + spawnMock +} = vi.hoisted(() => ({ + detectCommandsMock: vi.fn(() => new Set(['claude'])), guideModuleLoadMock: vi.fn(), - runtimeClientConstructorMock: vi.fn() + resolveCliCommandMock: vi.fn(() => 'npx'), + runtimeClientConstructorMock: vi.fn(), + spawnMock: vi.fn() +})) + +// Why: agent detection probes the real machine, so pin it or every install +// assertion depends on what the test runner happens to have installed. +vi.mock('../main/ipc/local-agent-install-dir-detection', () => ({ + detectCommandsInInstallDirs: detectCommandsMock +})) + +// Why: override only the npx lookup so the real Windows .cmd rail still runs. +vi.mock('../main/codex-cli/command', async (importOriginal) => ({ + ...(await importOriginal()), + resolveCliCommand: resolveCliCommandMock +})) + +vi.mock('node:child_process', () => ({ + spawn: spawnMock })) vi.mock('./bundled-skill-guides.js', () => { @@ -22,38 +50,33 @@ vi.mock('./bundled-skill-guides.js', () => { markdown: '# Alpha\n\nShort.\n', fullMarkdown: '# Alpha\n\nShort.\n\n## References\n\nFull.\n', aliases: ['legacy-alpha'] + }, + { + name: 'gamma', + description: + 'Use when gamma work spans several sentences describing exactly how a ' + + 'coding agent should decide whether gamma applies to the current task at hand.', + markdown: '# Gamma\n', + fullMarkdown: '# Gamma\n\n## References\n\nGamma reference.\n', + aliases: [] } ] } }) -vi.mock('./runtime-client', () => { +vi.mock('./runtime-client', async () => { + // Why: re-export the REAL error classes rather than redefining them. format.ts + // narrows with `instanceof` against ./runtime/types, so a look-alike class + // here would make every CLI error fall through to the generic `runtime_error` + // shape — mirroring the barrel keeps the mock faithful to production. + const { RuntimeClientError, RuntimeRpcFailureError } = await import('./runtime/types.js') + class RuntimeClient { constructor() { runtimeClientConstructorMock() } } - class RuntimeClientError extends Error { - readonly code: string - readonly data?: unknown - - constructor(code: string, message: string, data?: unknown) { - super(message) - this.code = code - this.data = data - } - } - - class RuntimeRpcFailureError extends RuntimeClientError { - readonly response: unknown - - constructor(response: unknown) { - super('runtime_error', 'runtime_error') - this.response = response - } - } - return { RuntimeClient, RuntimeClientError, @@ -70,9 +93,19 @@ describe('orca skills CLI', () => { beforeEach(() => { vi.restoreAllMocks() runtimeClientConstructorMock.mockClear() + resolveCliCommandMock.mockReset() + resolveCliCommandMock.mockReturnValue('npx') + detectCommandsMock.mockReset() + detectCommandsMock.mockReturnValue(new Set(['claude'])) + spawnMock.mockReset() process.exitCode = undefined }) + afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllEnvs() + }) + it('keeps the bundled table off the eager command-registry path', async () => { vi.spyOn(console, 'log').mockImplementation(() => {}) @@ -102,7 +135,10 @@ describe('orca skills CLI', () => { await main(['skills', 'list'], '/tmp/repo') expect(stdoutText(stdoutSpy)).toBe( - 'alpha: Use when alpha work is needed.\nzeta: Use when zeta work spans lines.\n' + 'alpha: Use when alpha work is needed.\n' + + 'gamma: Use when gamma work spans several sentences describing exactly how a ' + + 'coding agent should decide whether gamma applies to the current task at hand.\n' + + 'zeta: Use when zeta work spans lines.\n' ) expect(runtimeClientConstructorMock).not.toHaveBeenCalled() }) @@ -133,6 +169,12 @@ describe('orca skills CLI', () => { { topics: [ { name: 'alpha', description: 'Use when alpha work is needed.' }, + { + name: 'gamma', + description: + 'Use when gamma work spans several sentences describing exactly how a ' + + 'coding agent should decide whether gamma applies to the current task at hand.' + }, { name: 'zeta', description: 'Use when zeta work spans lines.' } ] }, @@ -176,7 +218,14 @@ describe('orca skills CLI', () => { expect(String(logSpy.mock.calls[1]?.[0])).toContain( 'get Print a version-matched skill guide' ) + expect(String(logSpy.mock.calls[1]?.[0])).toContain( + 'install Install bundled Orca skills' + ) + expect(String(logSpy.mock.calls[1]?.[0])).toContain( + 'update Update already-installed Orca skills' + ) expect(String(logSpy.mock.calls[2]?.[0])).toContain('Skills:\n skills list') + expect(String(logSpy.mock.calls[2]?.[0])).toContain('skills update') expect(runtimeClientConstructorMock).not.toHaveBeenCalled() }) @@ -187,12 +236,715 @@ describe('orca skills CLI', () => { expect(process.exitCode).toBe(1) expect(errorSpy).toHaveBeenCalledWith( - 'Unknown skill topic "missing". Available topics: alpha, zeta' + 'Unknown skill topic "missing". Available topics: alpha, gamma, zeta' ) expect(runtimeClientConstructorMock).not.toHaveBeenCalled() }) + + it('lists installable skills when no --skill/--all is given', async () => { + const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + + await main(['skills', 'install'], '/tmp/repo') + + expect(stdoutText(stdoutSpy)).toBe( + [ + 'Choose one or more skills to install:', + ' alpha', + ' gamma', + ' zeta', + '', + 'Usage: orca skills install --skill [--skill ...]', + ' or: orca skills install --all', + '' + ].join('\n') + ) + expect(spawnMock).not.toHaveBeenCalled() + expect(runtimeClientConstructorMock).not.toHaveBeenCalled() + }) + + it('gives install --json (no selection) a stable schema', async () => { + const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + + await main(['skills', 'install', '--json'], '/tmp/repo') + + expect(stdoutText(stdoutSpy)).toBe( + `${JSON.stringify({ availableSkills: ['alpha', 'gamma', 'zeta'] }, null, 2)}\n` + ) + }) + + it('rejects combining --all with --skill', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + await main(['skills', 'install', '--all', '--skill', 'alpha'], '/tmp/repo') + + expect(process.exitCode).toBe(1) + expect(errorSpy).toHaveBeenCalledWith('Use either --all or --skill, not both.') + expect(spawnMock).not.toHaveBeenCalled() + }) + + it('rejects an unknown --skill name', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + await main(['skills', 'install', '--skill', 'missing'], '/tmp/repo') + + expect(process.exitCode).toBe(1) + expect(errorSpy).toHaveBeenCalledWith( + 'Unknown skill "missing". Available skills: alpha, gamma, zeta' + ) + expect(spawnMock).not.toHaveBeenCalled() + }) + + it('rejects --skill without a value', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + await main(['skills', 'install', '--skill'], '/tmp/repo') + + expect(process.exitCode).toBe(1) + expect(errorSpy).toHaveBeenCalledWith('Missing required --skill') + expect(spawnMock).not.toHaveBeenCalled() + }) + + it('rejects --json for a real (non-dry-run) install', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['skills', 'install', '--skill', 'alpha', '--json'], '/tmp/repo') + + expect(process.exitCode).toBe(1) + expect(logSpy).toHaveBeenCalledWith( + JSON.stringify( + { + id: 'local', + ok: false, + error: { + code: 'invalid_argument', + message: + "orca skills install --json only supports --dry-run. Real installs stream npx's " + + "own output, which isn't JSON." + }, + _meta: { runtimeId: null } + }, + null, + 2 + ) + ) + expect(spawnMock).not.toHaveBeenCalled() + }) + + it('prints the resolved install command without running it for --dry-run', async () => { + const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + + await main(['skills', 'install', '--skill', 'alpha', '--dry-run'], '/tmp/repo') + + expect(stdoutText(stdoutSpy)).toBe( + 'npx --yes skills add https://github.com/stablyai/orca --skill alpha --global --agent claude-code --agent universal -y\n\n' + + 'Rerun without --dry-run to install now.\n' + ) + expect(spawnMock).not.toHaveBeenCalled() + }) + + it('gives dry-run --json a stable schema', async () => { + const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + + await main(['skills', 'install', '--skill', 'legacy-alpha', '--dry-run', '--json'], '/tmp/repo') + + expect(stdoutText(stdoutSpy)).toBe( + `${JSON.stringify( + { + command: + 'npx --yes skills add https://github.com/stablyai/orca --skill alpha --global --agent claude-code --agent universal -y', + skills: ['alpha'], + global: true, + executed: false + }, + null, + 2 + )}\n` + ) + }) + + it('drops --global for --local in the dry-run command and JSON', async () => { + const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + + await main(['skills', 'install', '--skill', 'alpha', '--local', '--dry-run'], '/tmp/repo') + + expect(stdoutText(stdoutSpy)).toBe( + 'npx --yes skills add https://github.com/stablyai/orca --skill alpha --agent claude-code --agent universal -y\n\n' + + 'Rerun without --dry-run to install now.\n' + ) + + stdoutSpy.mockClear() + await main( + ['skills', 'install', '--skill', 'alpha', '--local', '--dry-run', '--json'], + '/tmp/repo' + ) + + expect(stdoutText(stdoutSpy)).toBe( + `${JSON.stringify( + { + command: + 'npx --yes skills add https://github.com/stablyai/orca --skill alpha --agent claude-code --agent universal -y', + skills: ['alpha'], + global: false, + executed: false + }, + null, + 2 + )}\n` + ) + }) + + it('runs npx without --global for --local', async () => { + const child = createFakeChild() + spawnMock.mockReturnValue(child) + vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + + const resultPromise = main(['skills', 'install', '--skill', 'alpha', '--local'], '/tmp/repo') + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled()) + child.emit('exit', 0, null) + await resultPromise + + expect(spawnMock).toHaveBeenCalledWith( + 'npx', + [ + '--yes', + 'skills', + 'add', + 'https://github.com/stablyai/orca', + '--skill', + 'alpha', + '--agent', + 'claude-code', + '--agent', + 'universal', + '-y' + ], + expect.objectContaining({ stdio: 'inherit' }) + ) + }) + + it('routes a resolved Windows .cmd shim through cmd.exe', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + vi.stubEnv('ComSpec', 'C:\\Windows\\System32\\cmd.exe') + resolveCliCommandMock.mockReturnValue('C:\\Program Files\\nodejs\\npx.cmd') + const child = createFakeChild() + spawnMock.mockReturnValue(child) + vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + + const resultPromise = main(['skills', 'install', '--skill', 'alpha'], '/tmp/repo') + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled()) + child.emit('exit', 0, null) + await resultPromise + + expect(spawnMock).toHaveBeenCalledWith( + 'C:\\Windows\\System32\\cmd.exe', + [ + '/d', + '/c', + 'C:\\Program Files\\nodejs\\npx.cmd', + '--yes', + 'skills', + 'add', + 'https://github.com/stablyai/orca', + '--skill', + 'alpha', + '--global', + '--agent', + 'claude-code', + '--agent', + 'universal', + '-y' + ], + expect.objectContaining({ stdio: 'inherit' }) + ) + }) + + it('spawns a resolved npx path directly when it is not a .cmd shim', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + resolveCliCommandMock.mockReturnValue('C:\\Program Files\\nodejs\\npx.exe') + const child = createFakeChild() + spawnMock.mockReturnValue(child) + vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + + const resultPromise = main(['skills', 'install', '--skill', 'alpha'], '/tmp/repo') + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled()) + child.emit('exit', 0, null) + await resultPromise + + // Why: an .exe shim must stay a direct spawn so a missing npx still raises + // ENOENT on the child instead of hiding inside cmd.exe's own exit code. + expect(spawnMock.mock.calls[0]?.[0]).toBe('C:\\Program Files\\nodejs\\npx.exe') + expect(spawnMock.mock.calls[0]?.[1]?.[0]).toBe('--yes') + }) + + it('resolves a legacy topic alias to the canonical skill name for install', async () => { + const child = createFakeChild() + spawnMock.mockReturnValue(child) + vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + + const resultPromise = main(['skills', 'install', '--skill', 'legacy-alpha'], '/tmp/repo') + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled()) + child.emit('exit', 0, null) + await resultPromise + + expect(spawnMock).toHaveBeenCalledWith( + 'npx', + [ + '--yes', + 'skills', + 'add', + 'https://github.com/stablyai/orca', + '--skill', + 'alpha', + '--global', + '--agent', + 'claude-code', + '--agent', + 'universal', + '-y' + ], + expect.objectContaining({ stdio: 'inherit' }) + ) + }) + + it('runs npx for --all and forwards its exit code', async () => { + const child = createFakeChild() + spawnMock.mockReturnValue(child) + vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + + const resultPromise = main(['skills', 'install', '--all'], '/tmp/repo') + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled()) + child.emit('exit', 1, null) + await resultPromise + + expect(spawnMock).toHaveBeenCalledWith( + 'npx', + [ + '--yes', + 'skills', + 'add', + 'https://github.com/stablyai/orca', + '--skill', + 'alpha', + '--skill', + 'gamma', + '--skill', + 'zeta', + '--global', + '--agent', + 'claude-code', + '--agent', + 'universal', + '-y' + ], + expect.objectContaining({ stdio: 'inherit' }) + ) + expect(process.exitCode).toBe(1) + }) + + it('propagates a spawn error as a nonzero exit', async () => { + const child = createFakeChild() + spawnMock.mockReturnValue(child) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + + const resultPromise = main(['skills', 'install', '--skill', 'alpha'], '/tmp/repo') + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled()) + child.emit('error', new Error('spawn npx ENOENT')) + await resultPromise + + expect(process.exitCode).toBe(1) + expect(errorSpy).toHaveBeenCalledWith( + 'Could not run npx: spawn npx ENOENT. Install Node.js and ensure npx is on PATH.' + ) + }) + + it('lists updatable skills when no --skill/--all is given', async () => { + const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + + await main(['skills', 'update'], '/tmp/repo') + + expect(stdoutText(stdoutSpy)).toBe( + [ + 'Choose one or more skills to update:', + ' alpha', + ' gamma', + ' zeta', + '', + 'Usage: orca skills update --skill [--skill ...]', + ' or: orca skills update --all', + '' + ].join('\n') + ) + expect(spawnMock).not.toHaveBeenCalled() + }) + + it('prints the resolved update command without running it for --dry-run', async () => { + const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + + await main(['skills', 'update', '--skill', 'legacy-alpha', '--dry-run'], '/tmp/repo') + + expect(stdoutText(stdoutSpy)).toBe( + 'npx --yes skills update alpha --global -y\n\nRerun without --dry-run to update now.\n' + ) + expect(spawnMock).not.toHaveBeenCalled() + }) + + it('selects project scope for --local on update', async () => { + const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + + await main( + ['skills', 'update', '--skill', 'alpha', '--local', '--dry-run', '--json'], + '/tmp/repo' + ) + + expect(stdoutText(stdoutSpy)).toBe( + `${JSON.stringify( + { + command: 'npx --yes skills update alpha --project -y', + skills: ['alpha'], + global: false, + executed: false + }, + null, + 2 + )}\n` + ) + }) + + it('runs local updates with explicit project scope', async () => { + const child = createFakeChild() + spawnMock.mockReturnValue(child) + vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + + const resultPromise = main(['skills', 'update', '--skill', 'alpha', '--local'], '/tmp/repo') + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled()) + child.emit('exit', 0, null) + await resultPromise + + expect(spawnMock).toHaveBeenCalledWith( + 'npx', + ['--yes', 'skills', 'update', 'alpha', '--project', '-y'], + expect.objectContaining({ stdio: 'inherit' }) + ) + }) + + it('refuses a real run when the shell forwards orca to the Orca host', async () => { + vi.stubEnv('ORCA_CLI_CWD', '/home/alice/wt') + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + await main(['skills', 'install', '--skill', 'alpha'], '/tmp/repo') + + // Why: the SSH relay and WSL bridge run argv on the Orca host, so a real + // install there would silently skip the machine the user is sitting on. + expect(spawnMock).not.toHaveBeenCalled() + expect(process.exitCode).toBe(1) + expect(String(errorSpy.mock.calls[0]?.[0])).toContain('writes to the machine that runs it') + }) + + it('refuses --dry-run through the host-forwarding shim too', async () => { + vi.stubEnv('ORCA_CLI_CWD', '/home/alice/wt') + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + await main(['skills', 'install', '--skill', 'alpha', '--dry-run'], '/tmp/repo') + + // Why: the targets are resolved from THIS host's agents, so a command printed + // here would name the wrong machine's agents. Point at the target instead. + expect(spawnMock).not.toHaveBeenCalled() + expect(process.exitCode).toBe(1) + expect(String(errorSpy.mock.calls[0]?.[0])).toContain('writes to the machine that runs it') + }) + + it('puts the resolved npx directory on the child PATH', async () => { + const child = createFakeChild() + spawnMock.mockReturnValue(child) + resolveCliCommandMock.mockReturnValue('/home/alice/.nvm/versions/node/v22/bin/npx') + vi.stubEnv('PATH', `/usr/bin${delimiter}/bin`) + vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + + const resultPromise = main(['skills', 'install', '--skill', 'alpha'], '/tmp/repo') + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled()) + child.emit('exit', 0, null) + await resultPromise + + // Why: npx is an `env node` script, so an off-PATH npx exits 127 with no + // 'error' event unless node ships alongside it on the child's PATH. + const env = spawnMock.mock.calls[0]?.[2]?.env + // Why: the child still needs the inherited PATH and the rest of the parent + // environment; replacing it outright breaks git, node, HOME and npm config. + expect(env?.PATH).toBe( + `/home/alice/.nvm/versions/node/v22/bin${delimiter}/usr/bin${delimiter}/bin` + ) + expect(env?.HOME ?? env?.USERPROFILE).toBe(process.env.HOME ?? process.env.USERPROFILE) + }) + + it('reports a Windows npx path cmd.exe would reinterpret', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + vi.stubEnv('ComSpec', 'C:\\Windows\\System32\\cmd.exe') + resolveCliCommandMock.mockReturnValue('C:\\Users\\A&B\\npx.cmd') + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + + await main(['skills', 'install', '--skill', 'alpha'], '/tmp/repo') + + expect(spawnMock).not.toHaveBeenCalled() + expect(process.exitCode).toBe(1) + expect(String(errorSpy.mock.calls[0]?.[0])).toContain('cmd.exe would reinterpret') + }) + + it('never puts the current directory on the child PATH when npx is unresolvable', async () => { + const child = createFakeChild() + spawnMock.mockReturnValue(child) + // Why: resolveCliCommand returns the bare name when it finds nothing, and + // dirname('npx') is '.', which would run ./npx out of the caller's checkout. + resolveCliCommandMock.mockReturnValue('npx') + vi.stubEnv('PATH', `/usr/bin${delimiter}/bin`) + vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + + const resultPromise = main(['skills', 'install', '--skill', 'alpha'], '/tmp/repo') + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled()) + child.emit('exit', 0, null) + await resultPromise + + // Why: assert the constructed value, not the ambient one — a dev PATH with a + // trailing separator carries its own '' entry and would fake a failure here. + expect(spawnMock.mock.calls[0]?.[2]?.env?.PATH).toBe(`/usr/bin${delimiter}/bin`) + }) + + it('refuses to install when Orca detects no agent, instead of targeting them all', async () => { + detectCommandsMock.mockReturnValue(new Set()) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + await main(['skills', 'install', '--skill', 'alpha'], '/tmp/repo') + + // Why: `skills add -y` with nothing detected installs into every agent it + // knows (~75), creating config dirs for agents the host does not have. + expect(spawnMock).not.toHaveBeenCalled() + expect(process.exitCode).toBe(1) + expect(String(errorSpy.mock.calls[0]?.[0])).toContain('No coding agent detected') + }) + + it('honours an explicit --agent list without probing the host', async () => { + const child = createFakeChild() + spawnMock.mockReturnValue(child) + detectCommandsMock.mockReturnValue(new Set()) + vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + + const resultPromise = main( + ['skills', 'install', '--skill', 'alpha', '--agent', 'codex, claude-code ,codex'], + '/tmp/repo' + ) + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled()) + child.emit('exit', 0, null) + await resultPromise + + const argv = spawnMock.mock.calls[0]?.[1] ?? [] + const agents = argv.filter((_: string, i: number) => argv[i - 1] === '--agent') + // Why: trimmed and de-duplicated, and detection is not consulted at all. + expect(agents).toEqual(['codex', 'claude-code']) + expect(detectCommandsMock).not.toHaveBeenCalled() + }) + + it('maps detected agents onto the skills CLI namespace, not Orca ids', async () => { + const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + detectCommandsMock.mockReturnValue(new Set(['claude', 'cursor-agent', 'rovo'])) + + await main(['skills', 'install', '--skill', 'alpha', '--dry-run'], '/tmp/repo') + + // Why: `skills add` exits 1 on an unknown --agent, and the ids differ — + // Orca's `claude` is `claude-code` and its `rovo` is `rovodev`. + expect(stdoutText(stdoutSpy)).toContain( + '--agent claude-code --agent cursor --agent rovodev --agent universal' + ) + }) + + it('never sends --agent for an update, and never refuses on a bare host', async () => { + const child = createFakeChild() + spawnMock.mockReturnValue(child) + // Why: update refreshes what is already placed, so the no-agent refusal that + // guards install must not reach it. + detectCommandsMock.mockReturnValue(new Set()) + vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + + const resultPromise = main(['skills', 'update', '--skill', 'alpha'], '/tmp/repo') + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled()) + child.emit('exit', 0, null) + await resultPromise + + // Why: update refreshes what is already placed; it chooses no new targets. + expect(spawnMock.mock.calls[0]?.[1]).not.toContain('--agent') + }) + + it.each([ + ['a bare --agent', ['skills', 'install', '--skill', 'alpha', '--agent']], + ['an empty --agent', ['skills', 'install', '--skill', 'alpha', '--agent', '']], + ['a separator-only --agent', ['skills', 'install', '--skill', 'alpha', '--agent', ' , ,']] + ])('rejects %s instead of installing to every agent', async (_label, argv) => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + await main(argv, '/tmp/repo') + + // Why: an --agent that resolves to nothing must not fall back to detection or + // emit no --agent at all — the latter restores the ~75-agent install. + expect(spawnMock).not.toHaveBeenCalled() + expect(process.exitCode).toBe(1) + expect(String(errorSpy.mock.calls[0]?.[0])).toContain('Missing required --agent') + }) + + it.each([ + ['a dash-leading value', ['skills', 'install', '--skill', 'alpha', '--agent', '-y']], + ['an inline dash value', ['skills', 'install', '--skill', 'alpha', '--agent=--copy']], + ['a value with a space', ['skills', 'install', '--skill', 'alpha', '--agent', 'a b']] + ])('rejects %s the skills CLI would silently drop', async (_label, argv) => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + await main(argv, '/tmp/repo') + + // Why: the skills CLI drops such a value, leaving it with no target — the same + // all-agents install as omitting --agent entirely. + expect(spawnMock).not.toHaveBeenCalled() + expect(process.exitCode).toBe(1) + expect(String(errorSpy.mock.calls[0]?.[0])).toContain('Invalid --agent value') + }) + + it('reports forwarding, not missing agents, when a forwarded host detects none', async () => { + vi.stubEnv('ORCA_CLI_CWD', '/home/alice/wt') + detectCommandsMock.mockReturnValue(new Set()) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + await main(['skills', 'install', '--skill', 'alpha'], '/tmp/repo') + + // Why: resolving targets first would hide the forwarding problem behind a + // no-agent error about the wrong machine. + expect(String(errorSpy.mock.calls[0]?.[0])).toContain('writes to the machine that runs it') + }) + + it('documents --agent for skills install rather than the terminal-launch flag', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['skills', 'install', '--help'], '/tmp/repo') + + const help = String(logSpy.mock.calls[0]?.[0]) + expect(help).toContain('--agent ') + expect(help).not.toContain('Launch a known TUI agent') + }) + + it('accumulates a repeated --skill instead of keeping only the last one', async () => { + const child = createFakeChild() + spawnMock.mockReturnValue(child) + vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + + const resultPromise = main( + ['skills', 'install', '--skill', 'zeta', '--skill', 'alpha'], + '/tmp/repo' + ) + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled()) + child.emit('exit', 0, null) + await resultPromise + + // Why: the documented primary invocation. Dropping 'skill' from the + // repeatable-flag set silently installs one skill instead of two. + expect(spawnMock).toHaveBeenCalledWith( + 'npx', + [ + '--yes', + 'skills', + 'add', + 'https://github.com/stablyai/orca', + '--skill', + 'alpha', + '--skill', + 'zeta', + '--global', + '--agent', + 'claude-code', + '--agent', + 'universal', + '-y' + ], + expect.objectContaining({ stdio: 'inherit' }) + ) + }) + + it('collapses an alias and its canonical name into one --skill', async () => { + const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + + await main( + ['skills', 'install', '--skill', 'alpha', '--skill', 'legacy-alpha', '--dry-run'], + '/tmp/repo' + ) + + expect(stdoutText(stdoutSpy)).toBe( + 'npx --yes skills add https://github.com/stablyai/orca --skill alpha --global --agent claude-code --agent universal -y\n\n' + + 'Rerun without --dry-run to install now.\n' + ) + expect(spawnMock).not.toHaveBeenCalled() + }) + + it('reports the command it is about to run on stderr', async () => { + const child = createFakeChild() + spawnMock.mockReturnValue(child) + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + + const resultPromise = main(['skills', 'install', '--skill', 'alpha'], '/tmp/repo') + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled()) + child.emit('exit', 0, null) + await resultPromise + + // Why: stdout belongs to the child, so this record has to go to stderr. + expect(stderrSpy).toHaveBeenCalledWith( + 'Running: npx --yes skills add https://github.com/stablyai/orca --skill alpha --global --agent claude-code --agent universal -y\n' + ) + }) + + it('runs npx skills update for --all and forwards its exit code', async () => { + const child = createFakeChild() + spawnMock.mockReturnValue(child) + vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + + const resultPromise = main(['skills', 'update', '--all'], '/tmp/repo') + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled()) + child.emit('exit', 2, null) + await resultPromise + + expect(spawnMock).toHaveBeenCalledWith( + 'npx', + ['--yes', 'skills', 'update', 'alpha', 'gamma', 'zeta', '--global', '-y'], + expect.objectContaining({ stdio: 'inherit' }) + ) + expect(process.exitCode).toBe(2) + }) + + it('rejects --json for a real (non-dry-run) update', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['skills', 'update', '--skill', 'alpha', '--json'], '/tmp/repo') + + expect(process.exitCode).toBe(1) + expect(logSpy).toHaveBeenCalledWith( + JSON.stringify( + { + id: 'local', + ok: false, + error: { + code: 'invalid_argument', + message: + "orca skills update --json only supports --dry-run. Real updates stream npx's " + + "own output, which isn't JSON." + }, + _meta: { runtimeId: null } + }, + null, + 2 + ) + ) + expect(spawnMock).not.toHaveBeenCalled() + }) }) function stdoutText(spy: ReturnType): string { return spy.mock.calls.map((call) => String(call[0])).join('') } + +function createFakeChild(): EventEmitter { + return new EventEmitter() +} diff --git a/src/cli/specs/orchestration.ts b/src/cli/specs/orchestration.ts index d2bf2d4e2ed..f90f6f99872 100644 --- a/src/cli/specs/orchestration.ts +++ b/src/cli/specs/orchestration.ts @@ -32,8 +32,8 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [ { path: ['orchestration', 'run-list'], summary: 'List lightweight orchestration Runs', - usage: 'orca orchestration run-list [--json]', - allowedFlags: [...GLOBAL_FLAGS] + usage: 'orca orchestration run-list [--limit ] [--cursor ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'limit', 'cursor'] }, { path: ['orchestration', 'run-show'], diff --git a/src/cli/specs/skills.ts b/src/cli/specs/skills.ts index 522e6efec8f..a78ede618a8 100644 --- a/src/cli/specs/skills.ts +++ b/src/cli/specs/skills.ts @@ -9,7 +9,8 @@ export const SKILL_COMMAND_SPECS: CommandSpec[] = [ allowedFlags: [...GLOBAL_FLAGS], notes: [ 'Reads bundled guide metadata locally without contacting the Orca runtime.', - 'With --json, prints a topics array of canonical names and one-line descriptions.' + 'With --json, prints a topics array of canonical names and one-line descriptions.', + 'Use `orca skills get ` for the full guide, or `orca skills install` to install skills.' ] }, { @@ -25,5 +26,65 @@ export const SKILL_COMMAND_SPECS: CommandSpec[] = [ 'Use --json for a deterministic object containing canonical topic metadata and content.' ], examples: ['orca skills get orca-cli', 'orca skills get orchestration --full'] + }, + { + path: ['skills', 'install'], + summary: 'Install bundled Orca skills via the community skills CLI', + usage: + 'orca skills install [--skill ]... [--all] [--agent [,]] ' + + '[--local] [--dry-run] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'skill', 'all', 'agent', 'local', 'dry-run'], + notes: [ + 'Reads the bundled skill registry locally without contacting the Orca runtime.', + 'Resolves to the same `npx skills add --skill ...` command used by ' + + 'Orca Settings, plus the non-interactive flags an unattended host needs ' + + '(`npx --yes` and `-y`), then runs it and forwards its output and exit code.', + 'Installs globally (all projects, adds --global) by default. Use --local to install ' + + 'into the current project instead.', + 'Targets the coding agents Orca detects on this host, plus the shared ' + + '.agents/skills directory. Without an explicit target the skills CLI installs ' + + 'into every agent it knows about, which litters a host with config ' + + 'directories for agents it does not have.', + 'Use --agent [,...] to choose targets yourself, or --agent universal ' + + 'for the shared directory alone. Required when Orca detects no agent.', + 'Use --dry-run to print the resolved command without running it.', + 'With --json, the skill listing and --dry-run emit JSON; a real install streams ' + + "npx's own non-JSON output live and rejects --json.", + 'Omit --skill and --all to list installable skill names.', + 'Intended for headless hosts (SSH, containers, CI) with no desktop Settings UI to copy the install command from.' + ], + examples: [ + 'orca skills install', + 'orca skills install --skill orca-cli --skill orchestration', + 'orca skills install --skill orca-cli --local', + 'orca skills install --skill orca-cli --agent claude-code,codex', + 'orca skills install --all --dry-run' + ] + }, + { + path: ['skills', 'update'], + summary: 'Update already-installed Orca skills via the community skills CLI', + usage: 'orca skills update [--skill ]... [--all] [--local] [--dry-run] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'skill', 'all', 'local', 'dry-run'], + notes: [ + 'Reads the bundled skill registry locally without contacting the Orca runtime.', + 'Resolves to the same `npx skills update ` command used by Orca Settings, ' + + 'plus the non-interactive flags an unattended host needs (`npx --yes` and `-y`), ' + + 'then runs it and forwards its output and exit code.', + 'Updates the global install (all projects, adds --global) by default. Use --local to ' + + 'update the current project instead.', + 'Only refreshes skills that are already installed; use `orca skills install` first.', + 'Use --dry-run to print the resolved command without running it.', + 'With --json, the skill listing and --dry-run emit JSON; a real update streams ' + + "npx's own non-JSON output live and rejects --json.", + 'Omit --skill and --all to list updatable skill names.', + 'Intended for headless hosts (SSH, containers, CI) with no desktop Settings UI to copy the update command from.' + ], + examples: [ + 'orca skills update', + 'orca skills update --skill orca-cli --skill orchestration', + 'orca skills update --skill orca-cli --local', + 'orca skills update --all --dry-run' + ] } ] diff --git a/src/main/daemon/daemon-pty-adapter-subscription-fanout.ts b/src/main/daemon/daemon-pty-adapter-subscription-fanout.ts new file mode 100644 index 00000000000..e4855640ada --- /dev/null +++ b/src/main/daemon/daemon-pty-adapter-subscription-fanout.ts @@ -0,0 +1,73 @@ +import type { PtyBackgroundStreamEvent } from '../providers/types' +import { combineUnsubscribes } from './combine-unsubscribes' +import type { DaemonPtyAdapter } from './daemon-pty-adapter' +import type { DaemonPtyRouterDataEvent, DaemonPtyRouterExitEvent } from './daemon-pty-router-events' + +export class DaemonPtyAdapterSubscriptionFanout { + private unsubscribers: (() => void)[] = [] + private dataListeners: ((payload: DaemonPtyRouterDataEvent) => void)[] = [] + private exitListeners: ((payload: DaemonPtyRouterExitEvent) => void)[] = [] + + constructor( + private readonly adapters: readonly DaemonPtyAdapter[], + onAdapterExit: (id: string) => void + ) { + for (const adapter of adapters) { + this.unsubscribers.push( + adapter.onData((payload) => { + for (const listener of this.dataListeners) { + listener(payload) + } + }), + adapter.onExit((payload) => { + onAdapterExit(payload.id) + for (const listener of this.exitListeners) { + listener(payload) + } + }) + ) + } + } + + onData(callback: (payload: DaemonPtyRouterDataEvent) => void): () => void { + this.dataListeners.push(callback) + return () => { + const index = this.dataListeners.indexOf(callback) + if (index !== -1) { + this.dataListeners.splice(index, 1) + } + } + } + + onBackgroundStreamEvent(callback: (payload: PtyBackgroundStreamEvent) => void): () => void { + return combineUnsubscribes( + this.adapters.map((adapter) => adapter.onBackgroundStreamEvent(callback)) + ) + } + + // Why: main subscribes on the routed provider, so without this the dead-endpoint + // fan-out never reaches the renderer and only the written pane recovers (STA-2373). + onWriteUnavailable(callback: (payload: { id: string }) => void): () => void { + return combineUnsubscribes(this.adapters.map((adapter) => adapter.onWriteUnavailable(callback))) + } + + onReplay(_callback: (payload: { id: string; data: string }) => void): () => void { + return () => {} + } + + onExit(callback: (payload: DaemonPtyRouterExitEvent) => void): () => void { + this.exitListeners.push(callback) + return () => { + const index = this.exitListeners.indexOf(callback) + if (index !== -1) { + this.exitListeners.splice(index, 1) + } + } + } + + dispose(): void { + for (const unsubscribe of this.unsubscribers.splice(0)) { + unsubscribe() + } + } +} diff --git a/src/main/daemon/daemon-pty-router-events.ts b/src/main/daemon/daemon-pty-router-events.ts new file mode 100644 index 00000000000..41c1d560286 --- /dev/null +++ b/src/main/daemon/daemon-pty-router-events.ts @@ -0,0 +1,15 @@ +import type { PtyIncarnationId } from '../../shared/pty-incarnation' + +export type DaemonPtyRouterDataEvent = { + id: string + data: string + sequenceChars?: number + transformed?: boolean + seq?: number +} + +export type DaemonPtyRouterExitEvent = { + id: string + code: number + incarnationId?: PtyIncarnationId +} diff --git a/src/main/daemon/daemon-pty-router.ts b/src/main/daemon/daemon-pty-router.ts index a5c86aaa6ab..0f07da4dc18 100644 --- a/src/main/daemon/daemon-pty-router.ts +++ b/src/main/daemon/daemon-pty-router.ts @@ -1,5 +1,5 @@ import type { DaemonPtyAdapter } from './daemon-pty-adapter' -import { combineUnsubscribes } from './combine-unsubscribes' +import { DaemonPtyAdapterSubscriptionFanout } from './daemon-pty-adapter-subscription-fanout' import type { IPtyProvider, PtyBackgroundStreamEvent, @@ -8,41 +8,23 @@ import type { PtySpawnOptions, PtySpawnResult } from '../providers/types' -import type { PtyIncarnationId } from '../../shared/pty-incarnation' import type { PtyProcessInspection } from '../providers/pty-process-inspection' import { probePtyOwners } from './daemon-pty-liveness-probe' import { shouldHandoffDaemonHistory } from './daemon-history-handoff' - -type PtyDataListener = Parameters[0] -type PtyExitListener = Parameters[0] +import type { DaemonPtyRouterDataEvent, DaemonPtyRouterExitEvent } from './daemon-pty-router-events' export class DaemonPtyRouter implements IPtyProvider { private current: DaemonPtyAdapter private legacy: DaemonPtyAdapter[] private sessionAdapters = new Map() - private unsubscribers: (() => void)[] = [] - private dataListeners: PtyDataListener[] = [] - private exitListeners: PtyExitListener[] = [] + private readonly subscriptions: DaemonPtyAdapterSubscriptionFanout constructor(opts: { current: DaemonPtyAdapter; legacy: DaemonPtyAdapter[] }) { this.current = opts.current this.legacy = opts.legacy - - for (const adapter of this.allAdapters()) { - this.unsubscribers.push( - adapter.onData((payload) => { - for (const listener of this.dataListeners) { - listener(payload) - } - }), - adapter.onExit((payload) => { - this.sessionAdapters.delete(payload.id) - for (const listener of this.exitListeners) { - listener(payload) - } - }) - ) - } + this.subscriptions = new DaemonPtyAdapterSubscriptionFanout(this.allAdapters(), (id) => { + this.sessionAdapters.delete(id) + }) } async discoverLegacySessions(): Promise { @@ -224,52 +206,24 @@ export class DaemonPtyRouter implements IPtyProvider { return this.current.getProfiles() } - onData( - callback: (payload: { - id: string - data: string - sequenceChars?: number - transformed?: boolean - seq?: number - }) => void - ): () => void { - this.dataListeners.push(callback) - return () => { - const idx = this.dataListeners.indexOf(callback) - if (idx !== -1) { - this.dataListeners.splice(idx, 1) - } - } + onData(callback: (payload: DaemonPtyRouterDataEvent) => void): () => void { + return this.subscriptions.onData(callback) } onBackgroundStreamEvent(callback: (payload: PtyBackgroundStreamEvent) => void): () => void { - return combineUnsubscribes( - this.allAdapters().map((adapter) => adapter.onBackgroundStreamEvent(callback)) - ) + return this.subscriptions.onBackgroundStreamEvent(callback) } - // Why: main subscribes on the routed provider, so without this the dead-endpoint - // fan-out never reaches the renderer and only the written pane recovers (STA-2373). onWriteUnavailable(callback: (payload: { id: string }) => void): () => void { - return combineUnsubscribes( - this.allAdapters().map((adapter) => adapter.onWriteUnavailable(callback)) - ) + return this.subscriptions.onWriteUnavailable(callback) } - onReplay(_callback: (payload: { id: string; data: string }) => void): () => void { - return () => {} + onReplay(callback: (payload: { id: string; data: string }) => void): () => void { + return this.subscriptions.onReplay(callback) } - onExit( - callback: (payload: { id: string; code: number; incarnationId?: PtyIncarnationId }) => void - ): () => void { - this.exitListeners.push(callback) - return () => { - const idx = this.exitListeners.indexOf(callback) - if (idx !== -1) { - this.exitListeners.splice(idx, 1) - } - } + onExit(callback: (payload: DaemonPtyRouterExitEvent) => void): () => void { + return this.subscriptions.onExit(callback) } ackColdRestore(sessionId: string): void { @@ -307,9 +261,7 @@ export class DaemonPtyRouter implements IPtyProvider { } dispose(): void { - for (const unsubscribe of this.unsubscribers.splice(0)) { - unsubscribe() - } + this.subscriptions.dispose() for (const adapter of this.allAdapters()) { adapter.dispose() } @@ -323,15 +275,11 @@ export class DaemonPtyRouter implements IPtyProvider { // Without this, each restart leaked a router instance pinned by the legacy // adapters' listener arrays (one pair per adapter per restart). disposeRouterOnly(): void { - for (const unsubscribe of this.unsubscribers.splice(0)) { - unsubscribe() - } + this.subscriptions.dispose() } async disconnectOnly(): Promise { - for (const unsubscribe of this.unsubscribers.splice(0)) { - unsubscribe() - } + this.subscriptions.dispose() await Promise.all([...this.allAdapters()].map((adapter) => adapter.disconnectOnly())) } diff --git a/src/main/daemon/json-utf8-byte-length.test.ts b/src/main/daemon/json-utf8-byte-length.test.ts deleted file mode 100644 index fd05398907d..00000000000 --- a/src/main/daemon/json-utf8-byte-length.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { jsonUtf8ByteLength } from './json-utf8-byte-length' - -describe('jsonUtf8ByteLength', () => { - it('matches JSON.stringify for escapes, Unicode, surrogates, and nested values', () => { - const values: unknown[] = [ - '', - '"\\\b\t\n\f\r\u0000\u001f', - 'plain ASCII', - 'é漢😀', - '\ud800 lone high \udc00 lone low', - { - omitted: undefined, - finite: -1.25e100, - nonFinite: Number.POSITIVE_INFINITY, - nested: ['😀', undefined, null, { control: '\u0001' }] - } - ] - - for (const value of values) { - const json = JSON.stringify(value) - expect(jsonUtf8ByteLength(value)).toBe(Buffer.byteLength(json, 'utf8')) - } - }) - - it('rejects the same unsupported structural values as JSON.stringify', () => { - const circular: Record = {} - circular.self = circular - - expect(() => jsonUtf8ByteLength(circular)).toThrow('circular') - expect(() => jsonUtf8ByteLength(1n)).toThrow('BigInt') - }) -}) diff --git a/src/main/daemon/json-utf8-byte-length.ts b/src/main/daemon/json-utf8-byte-length.ts deleted file mode 100644 index dafc8b6970a..00000000000 --- a/src/main/daemon/json-utf8-byte-length.ts +++ /dev/null @@ -1,94 +0,0 @@ -function jsonStringUtf8Bytes(value: string): number { - let bytes = 2 - for (let index = 0; index < value.length; index += 1) { - const codeUnit = value.charCodeAt(index) - if (codeUnit === 0x22 || codeUnit === 0x5c || codeUnit === 0x08 || codeUnit === 0x09) { - bytes += 2 - } else if (codeUnit === 0x0a || codeUnit === 0x0c || codeUnit === 0x0d) { - bytes += 2 - } else if (codeUnit < 0x20) { - bytes += 6 - } else if (codeUnit < 0x80) { - bytes += 1 - } else if (codeUnit < 0x800) { - bytes += 2 - } else if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) { - const next = value.charCodeAt(index + 1) - if (next >= 0xdc00 && next <= 0xdfff) { - bytes += 4 - index += 1 - } else { - bytes += 6 - } - } else if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) { - bytes += 6 - } else { - bytes += 3 - } - } - return bytes -} - -export function jsonUtf8ByteLength(value: unknown): number { - const activeObjects = new Set() - - const measure = (current: unknown, arrayElement: boolean): number | null => { - if (current === null) { - return 4 - } - switch (typeof current) { - case 'string': - return jsonStringUtf8Bytes(current) - case 'boolean': - return current ? 4 : 5 - case 'number': - return Number.isFinite(current) ? JSON.stringify(current).length : 4 - case 'undefined': - case 'function': - case 'symbol': - return arrayElement ? 4 : null - case 'bigint': - throw new TypeError('Do not know how to serialize a BigInt') - case 'object': - break - } - - const object = current as object - if (activeObjects.has(object)) { - throw new TypeError('Converting circular structure to JSON') - } - activeObjects.add(object) - try { - if (Array.isArray(object)) { - let bytes = 2 - for (let index = 0; index < object.length; index += 1) { - if (index > 0) { - bytes += 1 - } - bytes += measure(object[index], true) ?? 4 - } - return bytes - } - - let bytes = 2 - let entries = 0 - for (const key of Object.keys(object)) { - const propertyBytes = measure((object as Record)[key], false) - if (propertyBytes === null) { - continue - } - bytes += (entries > 0 ? 1 : 0) + jsonStringUtf8Bytes(key) + 1 + propertyBytes - entries += 1 - } - return bytes - } finally { - activeObjects.delete(object) - } - } - - const bytes = measure(value, false) - if (bytes === null) { - throw new TypeError('Value is not JSON serializable') - } - return bytes -} diff --git a/src/main/daemon/terminal-checkpoint-serializer.test.ts b/src/main/daemon/terminal-checkpoint-serializer.test.ts new file mode 100644 index 00000000000..00be8f39eee --- /dev/null +++ b/src/main/daemon/terminal-checkpoint-serializer.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it, vi } from 'vitest' +import type { TerminalSnapshot } from './types' +import { serializeTerminalCheckpointWithinLimit } from './terminal-checkpoint-serializer' + +function snapshot(overrides: Partial = {}): TerminalSnapshot { + return { + snapshotAnsi: 'visible', + scrollbackAnsi: '', + rehydrateSequences: '', + cwd: '/workspace', + modes: { + bracketedPaste: false, + mouseTracking: false, + applicationCursor: false, + alternateScreen: false + }, + cols: 80, + rows: 24, + scrollbackLines: 0, + ...overrides + } +} + +const metadata = { + cwd: '/workspace', + generation: 1, + checkpointedAt: '2026-07-29T00:00:00.000Z' +} + +describe('terminal checkpoint serializer', () => { + it('matches JSON.stringify exactly at the UTF-8 byte limit', async () => { + const input = snapshot({ + snapshotAnsi: `é漢😀${String.fromCharCode(0xd800, 0xdc00)}"\\${String.fromCharCode( + 0x00, + 0x08, + 0x09, + 0x0a, + 0x0c, + 0x0d, + 0x1f, + 0xd800, + 0xdc00 + )}`, + oscLinks: [{ row: 0, startCol: 0, endCol: 1, uri: 'https://example.com/😀\n' }] + }) + const expected = JSON.stringify({ + snapshotAnsi: input.snapshotAnsi, + scrollbackAnsi: input.scrollbackAnsi, + oscLinks: input.oscLinks, + rehydrateSequences: input.rehydrateSequences, + cwd: metadata.cwd, + cols: input.cols, + rows: input.rows, + modes: input.modes, + scrollbackLines: input.scrollbackLines, + generation: metadata.generation, + checkpointedAt: metadata.checkpointedAt + }) + const exactBytes = Buffer.byteLength(expected, 'utf8') + + await expect(serializeTerminalCheckpointWithinLimit(input, metadata, exactBytes)).resolves.toBe( + expected + ) + }) + + it('rejects multibyte input whose code-unit length fits under the byte cap', async () => { + const input = snapshot({ + scrollbackAnsi: 'é\r\n'.repeat(100), + scrollbackLines: 100 + }) + const expected = JSON.stringify({ + snapshotAnsi: input.snapshotAnsi, + scrollbackAnsi: input.scrollbackAnsi, + oscLinks: input.oscLinks, + rehydrateSequences: input.rehydrateSequences, + cwd: metadata.cwd, + cols: input.cols, + rows: input.rows, + modes: input.modes, + scrollbackLines: input.scrollbackLines, + generation: metadata.generation, + checkpointedAt: metadata.checkpointedAt + }) + const maxBytes = expected.length + 1 + + expect(expected.length).toBeLessThan(maxBytes) + expect(Buffer.byteLength(expected, 'utf8')).toBeGreaterThan(maxBytes) + + const serialized = await serializeTerminalCheckpointWithinLimit(input, metadata, maxBytes) + + expect(serialized).not.toBe(expected) + expect(Buffer.byteLength(serialized, 'utf8')).toBeLessThanOrEqual(maxBytes) + }) + + it('does not materialize and rescan a passing candidate', async () => { + let reads = 0 + const input = snapshot() + Object.defineProperty(input, 'snapshotAnsi', { + enumerable: true, + get: () => { + reads += 1 + return 'visible' + } + }) + const stringify = vi.spyOn(JSON, 'stringify') + const byteLength = vi.spyOn(Buffer, 'byteLength') + + try { + await serializeTerminalCheckpointWithinLimit(input, metadata, 20 * 1024) + + expect({ + fullCandidateStringifies: stringify.mock.calls.filter( + ([value]) => typeof value === 'object' && value !== null + ).length, + byteLengthCalls: byteLength.mock.calls.length, + snapshotReads: reads + }).toEqual({ fullCandidateStringifies: 0, byteLengthCalls: 0, snapshotReads: 1 }) + } finally { + stringify.mockRestore() + byteLength.mockRestore() + } + }) + + it('rejects an oversized escaped candidate without materializing it', async () => { + const oversized = String.fromCharCode(0).repeat(100_000) + const stringify = vi.spyOn(JSON, 'stringify') + + try { + await serializeTerminalCheckpointWithinLimit( + snapshot({ snapshotAnsi: oversized }), + metadata, + 512 + ) + + const materializedOversizedCandidate = stringify.mock.calls.some(([value]) => { + return (value as { snapshotAnsi?: unknown })?.snapshotAnsi === oversized + }) + expect(materializedOversizedCandidate).toBe(false) + } finally { + stringify.mockRestore() + } + }) +}) diff --git a/src/main/daemon/terminal-checkpoint-serializer.ts b/src/main/daemon/terminal-checkpoint-serializer.ts index 595d42fd6a2..afa852a1bc0 100644 --- a/src/main/daemon/terminal-checkpoint-serializer.ts +++ b/src/main/daemon/terminal-checkpoint-serializer.ts @@ -1,7 +1,6 @@ import type { TerminalCheckpointFile, TerminalSnapshot } from './types' import { ColdRestoreReplayWriter } from './cold-restore-replay-writer' import { HeadlessEmulator } from './headless-emulator' -import { jsonUtf8ByteLength } from './json-utf8-byte-length' type CheckpointMetadata = { cwd: string | null @@ -32,15 +31,187 @@ function checkpointFile( } } +class BoundedJsonWriter { + private output = '' + private chunk = '' + private bytes = 0 + private exceeded = false + + constructor(private readonly maxBytes: number) {} + + append(value: string, bytes: number): boolean { + if (this.bytes + bytes > this.maxBytes) { + this.exceeded = true + this.output = '' + this.chunk = '' + return false + } + this.bytes += bytes + this.chunk += value + if (this.chunk.length >= 16 * 1024) { + this.output += this.chunk + this.chunk = '' + } + return true + } + + result(): string | null { + return this.exceeded ? null : this.output + this.chunk + } +} + +function escapedCodeUnit(codeUnit: number): string | null { + switch (codeUnit) { + case 0x08: + return '\\b' + case 0x09: + return '\\t' + case 0x0a: + return '\\n' + case 0x0c: + return '\\f' + case 0x0d: + return '\\r' + case 0x22: + return '\\"' + case 0x5c: + return '\\\\' + default: + return codeUnit < 0x20 ? `\\u${codeUnit.toString(16).padStart(4, '0')}` : null + } +} + +function appendJsonString(writer: BoundedJsonWriter, value: string): boolean { + if (!writer.append('"', 1)) { + return false + } + let spanStart = 0 + let spanBytes = 0 + for (let index = 0; index < value.length; index += 1) { + const codeUnit = value.charCodeAt(index) + let escaped = escapedCodeUnit(codeUnit) + if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) { + const next = value.charCodeAt(index + 1) + if (next >= 0xdc00 && next <= 0xdfff) { + spanBytes += 4 + index += 1 + } else { + escaped = `\\u${codeUnit.toString(16)}` + } + } else if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) { + escaped = `\\u${codeUnit.toString(16)}` + } else if (escaped === null) { + spanBytes += codeUnit < 0x80 ? 1 : codeUnit < 0x800 ? 2 : 3 + } + + if (escaped !== null) { + if ( + (index > spanStart && !writer.append(value.slice(spanStart, index), spanBytes)) || + !writer.append(escaped, escaped.length) + ) { + return false + } + spanStart = index + 1 + spanBytes = 0 + } else if (index + 1 - spanStart >= 16 * 1024) { + if (!writer.append(value.slice(spanStart, index + 1), spanBytes)) { + return false + } + spanStart = index + 1 + spanBytes = 0 + } + } + if (spanStart < value.length && !writer.append(value.slice(spanStart), spanBytes)) { + return false + } + return writer.append('"', 1) +} + +function omittedByJson(value: unknown): boolean { + return value === undefined || typeof value === 'function' || typeof value === 'symbol' +} + +function appendJsonValue( + writer: BoundedJsonWriter, + value: unknown, + activeObjects: Set +): boolean { + if (value === null) { + return writer.append('null', 4) + } + switch (typeof value) { + case 'string': + return appendJsonString(writer, value) + case 'boolean': + return writer.append(value ? 'true' : 'false', value ? 4 : 5) + case 'number': { + const json = Number.isFinite(value) ? JSON.stringify(value) : 'null' + return writer.append(json, json.length) + } + case 'bigint': + throw new TypeError('Do not know how to serialize a BigInt') + case 'undefined': + case 'function': + case 'symbol': + return false + case 'object': + break + } + + if (activeObjects.has(value)) { + throw new TypeError('Converting circular structure to JSON') + } + activeObjects.add(value) + try { + if (Array.isArray(value)) { + if (!writer.append('[', 1)) { + return false + } + for (let index = 0; index < value.length; index += 1) { + if (index > 0 && !writer.append(',', 1)) { + return false + } + const entry = value[index] + if (omittedByJson(entry)) { + if (!writer.append('null', 4)) { + return false + } + } else if (!appendJsonValue(writer, entry, activeObjects)) { + return false + } + } + return writer.append(']', 1) + } + + if (!writer.append('{', 1)) { + return false + } + let entries = 0 + for (const key of Object.keys(value)) { + const entry = (value as Record)[key] + if (omittedByJson(entry)) { + continue + } + if ( + (entries > 0 && !writer.append(',', 1)) || + !appendJsonString(writer, key) || + !writer.append(':', 1) || + !appendJsonValue(writer, entry, activeObjects) + ) { + return false + } + entries += 1 + } + return writer.append('}', 1) + } finally { + activeObjects.delete(value) + } +} + function stringifyWithinLimit(checkpoint: TerminalCheckpointFile, maxBytes: number): string | null { - if (jsonUtf8ByteLength(checkpoint) > maxBytes) { - return null - } - const json = JSON.stringify(checkpoint) - if (Buffer.byteLength(json, 'utf8') > maxBytes) { - throw new Error('Terminal checkpoint size estimator mismatch') - } - return json + const writer = new BoundedJsonWriter(maxBytes) + appendJsonValue(writer, checkpoint, new Set()) + return writer.result() } async function replaySnapshot(snapshot: TerminalSnapshot): Promise { diff --git a/src/main/destination-serialized-local-rename.test.ts b/src/main/destination-serialized-local-rename.test.ts new file mode 100644 index 00000000000..49fd190e469 --- /dev/null +++ b/src/main/destination-serialized-local-rename.test.ts @@ -0,0 +1,248 @@ +import path from 'node:path' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as FsPromises from 'node:fs/promises' + +const { copyFileMock, handleMock, lstatMock, realpathMock, renameMock } = vi.hoisted(() => ({ + copyFileMock: vi.fn(), + handleMock: vi.fn(), + lstatMock: vi.fn(), + realpathMock: vi.fn(), + renameMock: vi.fn() +})) +const handlers = new Map Promise>() + +vi.mock('electron', () => ({ + ipcMain: { handle: handleMock } +})) + +vi.mock('fs/promises', async () => { + const actual = await vi.importActual('fs/promises') + return { + ...actual, + copyFile: copyFileMock, + lstat: lstatMock, + realpath: realpathMock, + rename: renameMock + } +}) + +import { renameLocalPathSerializedByDestination } from './destination-serialized-local-rename' +import { registerFilesystemMutationHandlers } from './ipc/filesystem-mutations' +import { RuntimeFileCommands } from './runtime/orca-runtime-files' + +const REPO_PATH = path.resolve('/workspace/repo') +const store = { + getRepos: () => [ + { id: 'repo-1', path: REPO_PATH, displayName: 'repo', badgeColor: '#000', addedAt: 0 } + ], + getSettings: () => ({ workspaceDir: path.resolve('/workspace') }) +} + +function enoent(): Error { + return Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) +} + +function mockStats(dev: number, ino: number) { + return { dev, ino, isDirectory: () => false } +} + +function createRuntimeCommands(): RuntimeFileCommands { + return new RuntimeFileCommands({ + requireStore: () => store, + resolveRuntimeFileTarget: async () => ({ + worktree: { id: 'wt-1', repoId: 'repo-1', path: REPO_PATH } + }) + } as never) +} + +function createGate(): { promise: Promise; release: () => void } { + let release!: () => void + const promise = new Promise((resolve) => { + release = resolve + }) + return { promise, release } +} + +async function reachNextMacrotask(): Promise { + await new Promise((resolve) => setTimeout(resolve, 0)) +} + +describe('renameLocalPathSerializedByDestination', () => { + beforeEach(() => { + handlers.clear() + copyFileMock.mockReset() + handleMock.mockReset() + lstatMock.mockReset() + realpathMock.mockReset() + renameMock.mockReset() + handleMock.mockImplementation((channel: string, handler: never) => { + handlers.set(channel, handler) + }) + lstatMock.mockRejectedValue(enoent()) + realpathMock.mockImplementation(async (filePath: string) => filePath) + renameMock.mockResolvedValue(undefined) + registerFilesystemMutationHandlers(store as never) + }) + + it('serializes exact Unicode aliases across IPC and runtime entry points', async () => { + const ipcSource = path.join(REPO_PATH, 'ipc-source.md') + const runtimeSource = path.join(REPO_PATH, 'runtime-source.md') + const sharpSDestination = path.join(REPO_PATH, 'Straße.md') + const expandedDestination = path.join(REPO_PATH, 'STRASSE.md') + const firstRenameGate = createGate() + let destinationExists = false + lstatMock.mockImplementation(async (filePath: string) => { + if ( + destinationExists && + (filePath === sharpSDestination || filePath === expandedDestination) + ) { + return mockStats(1, 30) + } + if (filePath === ipcSource) { + return mockStats(1, 10) + } + if (filePath === runtimeSource) { + return mockStats(1, 20) + } + throw enoent() + }) + renameMock.mockImplementation(async () => { + if (renameMock.mock.calls.length === 1) { + await firstRenameGate.promise + } + destinationExists = true + }) + + const ipcRename = handlers.get('fs:rename')!(null, { + oldPath: ipcSource, + newPath: sharpSDestination + }) + await vi.waitFor(() => expect(renameMock).toHaveBeenCalledTimes(1)) + const runtimeRename = createRuntimeCommands().renameFileExplorerPath( + 'id:wt-1', + 'runtime-source.md', + 'STRASSE.md', + undefined, + undefined, + 'local' + ) + await reachNextMacrotask() + const callsWhileFirstBlocked = renameMock.mock.calls.length + firstRenameGate.release() + const results = await Promise.allSettled([ipcRename, runtimeRename]) + + expect(callsWhileFirstBlocked).toBe(1) + expect(results.map(({ status }) => status).sort()).toEqual(['fulfilled', 'rejected']) + expect(renameMock).toHaveBeenCalledTimes(1) + }) + + it('releases an alias queue after the leading rename errors', async () => { + const firstRenameGate = createGate() + renameMock.mockImplementation(async () => { + if (renameMock.mock.calls.length === 1) { + await firstRenameGate.promise + throw new Error('EACCES') + } + }) + const destination = path.join(REPO_PATH, 'destination.md') + + const firstRename = renameLocalPathSerializedByDestination( + path.join(REPO_PATH, 'first.md'), + destination + ) + await vi.waitFor(() => expect(renameMock).toHaveBeenCalledTimes(1)) + const secondRename = renameLocalPathSerializedByDestination( + path.join(REPO_PATH, 'second.md'), + destination + ) + await reachNextMacrotask() + expect(renameMock).toHaveBeenCalledTimes(1) + + firstRenameGate.release() + await expect(firstRename).rejects.toThrow('EACCES') + await expect(secondRename).resolves.toBeUndefined() + expect(renameMock).toHaveBeenCalledTimes(2) + }) + + it('does not serialize destinations in unrelated parent directories', async () => { + const firstRenameGate = createGate() + renameMock.mockImplementation(async () => { + if (renameMock.mock.calls.length === 1) { + await firstRenameGate.promise + } + }) + + const firstRename = renameLocalPathSerializedByDestination( + path.join(REPO_PATH, 'first', 'source.md'), + path.join(REPO_PATH, 'first', 'alpha.md') + ) + await vi.waitFor(() => expect(renameMock).toHaveBeenCalledTimes(1)) + const secondRename = renameLocalPathSerializedByDestination( + path.join(REPO_PATH, 'second', 'source.md'), + path.join(REPO_PATH, 'second', 'beta.md') + ) + await vi.waitFor(() => expect(renameMock).toHaveBeenCalledTimes(2)) + + firstRenameGate.release() + await expect(Promise.all([firstRename, secondRename])).resolves.toEqual([undefined, undefined]) + }) + + it('serializes symlink aliases of one destination parent', async () => { + const firstRenameGate = createGate() + renameMock.mockImplementation(async () => { + if (renameMock.mock.calls.length === 1) { + await firstRenameGate.promise + } + }) + const firstParent = path.join(REPO_PATH, 'parent-link-a') + const secondParent = path.join(REPO_PATH, 'parent-link-b') + const canonicalParent = path.join(REPO_PATH, 'canonical-parent') + realpathMock.mockImplementation(async (filePath: string) => { + if (filePath === firstParent || filePath === secondParent) { + return canonicalParent + } + return filePath + }) + + const firstRename = renameLocalPathSerializedByDestination( + path.join(firstParent, 'source.md'), + path.join(firstParent, 'alpha.md') + ) + await vi.waitFor(() => expect(renameMock).toHaveBeenCalledTimes(1)) + const secondRename = renameLocalPathSerializedByDestination( + path.join(secondParent, 'source.md'), + path.join(secondParent, 'beta.md') + ) + await reachNextMacrotask() + expect(renameMock).toHaveBeenCalledTimes(1) + + firstRenameGate.release() + await expect(Promise.all([firstRename, secondRename])).resolves.toEqual([undefined, undefined]) + expect(renameMock).toHaveBeenCalledTimes(2) + }) + + it('scopes conservative serialization to one destination parent', async () => { + const firstRenameGate = createGate() + renameMock.mockImplementation(async () => { + if (renameMock.mock.calls.length === 1) { + await firstRenameGate.promise + } + }) + + const firstRename = renameLocalPathSerializedByDestination( + path.join(REPO_PATH, 'first.md'), + path.join(REPO_PATH, 'dotless-ı.md') + ) + await vi.waitFor(() => expect(renameMock).toHaveBeenCalledTimes(1)) + const secondRename = renameLocalPathSerializedByDestination( + path.join(REPO_PATH, 'second.md'), + path.join(REPO_PATH, 'dotless-I.md') + ) + await reachNextMacrotask() + expect(renameMock).toHaveBeenCalledTimes(1) + + firstRenameGate.release() + await expect(Promise.all([firstRename, secondRename])).resolves.toEqual([undefined, undefined]) + expect(renameMock).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/main/destination-serialized-local-rename.ts b/src/main/destination-serialized-local-rename.ts new file mode 100644 index 00000000000..74e313c8e98 --- /dev/null +++ b/src/main/destination-serialized-local-rename.ts @@ -0,0 +1,50 @@ +import { realpath, rename } from 'node:fs/promises' +import { dirname, normalize } from 'node:path' +import { assertNoClobberRenameDestinationAvailable } from '../shared/filesystem-rename-collision' + +// Why: parent scope covers native Unicode aliases without guessing each filesystem's collation. +const pendingRenamesByParent = new Map>() + +function isENOENT(error: unknown): boolean { + return ( + error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code === 'ENOENT' + ) +} + +async function destinationParentKey(filePath: string): Promise { + const parentPath = dirname(filePath) + try { + return normalize(await realpath(parentPath)) + } catch (error) { + // A missing parent will fail the rename; retain its path so that failure + // does not prevent unrelated destinations from progressing. + if (isENOENT(error)) { + return normalize(parentPath) + } + throw error + } +} + +export async function renameLocalPathSerializedByDestination( + oldPath: string, + newPath: string +): Promise { + const key = await destinationParentKey(newPath) + const previous = pendingRenamesByParent.get(key) ?? Promise.resolve() + let release!: () => void + const current = new Promise((resolve) => { + release = resolve + }) + pendingRenamesByParent.set(key, current) + + await previous + try { + await assertNoClobberRenameDestinationAvailable(oldPath, newPath) + await rename(oldPath, newPath) + } finally { + release() + if (pendingRenamesByParent.get(key) === current) { + pendingRenamesByParent.delete(key) + } + } +} diff --git a/src/main/ipc/filesystem-mutations.test.ts b/src/main/ipc/filesystem-mutations.test.ts index dcaeb74b624..97f62de565c 100644 --- a/src/main/ipc/filesystem-mutations.test.ts +++ b/src/main/ipc/filesystem-mutations.test.ts @@ -171,6 +171,36 @@ describe('registerFilesystemMutationHandlers', () => { expect(renameMock).toHaveBeenCalledWith(oldPath, newPath) }) + it('serializes concurrent local renames targeting the same destination', async () => { + const firstPath = path.resolve('/workspace/repo/first.ts') + const secondPath = path.resolve('/workspace/repo/second.ts') + const destinationPath = path.resolve('/workspace/repo/destination.ts') + let destinationExists = false + lstatMock.mockImplementation(async (filePath: string) => { + if (filePath === destinationPath && destinationExists) { + return mockStats(1, 30) + } + if (filePath === firstPath) { + return mockStats(1, 10) + } + if (filePath === secondPath) { + return mockStats(1, 20) + } + throw enoent() + }) + renameMock.mockImplementation(async () => { + destinationExists = true + }) + + const results = await Promise.allSettled([ + handlers.get('fs:rename')!(null, { oldPath: firstPath, newPath: destinationPath }), + handlers.get('fs:rename')!(null, { oldPath: secondPath, newPath: destinationPath }) + ]) + + expect(results.map(({ status }) => status).sort()).toEqual(['fulfilled', 'rejected']) + expect(renameMock).toHaveBeenCalledTimes(1) + }) + it('rejects rename when destination already exists as a true collision', async () => { const oldPath = path.resolve('/workspace/repo/old.ts') const resolvedNewPath = path.resolve('/workspace/repo/new.ts') @@ -197,12 +227,30 @@ describe('registerFilesystemMutationHandlers', () => { it('allows case-only rename when destination is the same entry in the same parent', async () => { const oldPath = path.resolve('/workspace/repo/README.md') const newPath = path.resolve('/workspace/repo/readme.md') + const canonicalPath = path.resolve('/workspace/repo/README.md') lstatMock.mockImplementation(async (p: string) => { if (p === oldPath || p === newPath) { return mockStats(2, 20) } throw enoent() }) + mockRealpath({ [oldPath]: canonicalPath, [newPath]: canonicalPath }) + + await handlers.get('fs:rename')!(null, { oldPath, newPath }) + + expect(renameMock).toHaveBeenCalledWith(oldPath, newPath) + }) + + it('allows a native Unicode alias rename for the same directory entry', async () => { + const oldPath = path.resolve('/workspace/repo/Straße.md') + const newPath = path.resolve('/workspace/repo/STRASSE.md') + lstatMock.mockImplementation(async (p: string) => { + if (p === oldPath || p === newPath) { + return mockStats(2, 21) + } + throw enoent() + }) + mockRealpath({ [oldPath]: oldPath, [newPath]: oldPath }) await handlers.get('fs:rename')!(null, { oldPath, newPath }) @@ -226,6 +274,25 @@ describe('registerFilesystemMutationHandlers', () => { expect(renameMock).not.toHaveBeenCalled() }) + it('fails closed when same-entry realpath encounters a permission error', async () => { + const oldPath = path.resolve('/workspace/repo/README.md') + const newPath = path.resolve('/workspace/repo/readme.md') + lstatMock.mockImplementation(async (p: string) => { + if (p === oldPath || p === newPath) { + return mockStats(3, 30) + } + throw enoent() + }) + realpathMock.mockRejectedValue( + Object.assign(new Error('permission denied'), { code: 'EACCES' }) + ) + + await expect(handlers.get('fs:rename')!(null, { oldPath, newPath })).rejects.toMatchObject({ + code: 'EACCES' + }) + expect(renameMock).not.toHaveBeenCalled() + }) + it('rejects cross-parent case-only rename collisions even when dev and ino match', async () => { const oldPath = path.resolve('/workspace/repo/src/README.md') const newPath = path.resolve('/workspace/repo/docs/readme.md') diff --git a/src/main/ipc/filesystem-mutations.ts b/src/main/ipc/filesystem-mutations.ts index e89ff761b63..27c29460bd6 100644 --- a/src/main/ipc/filesystem-mutations.ts +++ b/src/main/ipc/filesystem-mutations.ts @@ -9,7 +9,6 @@ import { open, readdir, realpath, - rename, rm, unlink, writeFile @@ -21,9 +20,9 @@ import { authorizeExternalPath, resolveAuthorizedPath, isENOENT } from './filesy import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { resolveLocalDroppedPathsForAgent } from './dropped-path-resolution' import { importExternalPathsSsh } from './filesystem-import-ssh' -import { assertNoClobberRenameDestinationAvailable } from '../../shared/filesystem-rename-collision' import type { SshMutationExpectation } from '../../shared/ssh-types' import { assertSshMutationExpectation } from '../ssh/ssh-connection-generation' +import { renameLocalPathSerializedByDestination } from '../destination-serialized-local-rename' /** * Re-throw filesystem errors with user-friendly messages. @@ -146,8 +145,7 @@ export function registerFilesystemMutationHandlers(store: Store): void { // accidentally write into a symlinked destination name. const oldPath = await resolveAuthorizedPath(args.oldPath, store, { preserveSymlink: true }) const newPath = await resolveAuthorizedPath(args.newPath, store, { preserveSymlink: true }) - await assertNoClobberRenameDestinationAvailable(oldPath, newPath) - await rename(oldPath, newPath) + await renameLocalPathSerializedByDestination(oldPath, newPath) } ) diff --git a/src/main/ipc/mobile.test.ts b/src/main/ipc/mobile.test.ts index 40e2c2f7091..be6f07ff2c4 100644 --- a/src/main/ipc/mobile.test.ts +++ b/src/main/ipc/mobile.test.ts @@ -151,7 +151,6 @@ describe('registerMobileHandlers', () => { pairingUrl: 'orca://pair#mobile', endpoint: 'ws://100.102.47.57:6768', deviceId: 'mobile-1', - // The encoded mode passes through so the UI can flag a degraded mint. connectionMode: 'automatic' }) @@ -163,6 +162,32 @@ describe('registerMobileHandlers', () => { }) }) + it('forwards structured Relay mint failures to the renderer', async () => { + networkInterfacesMock.mockReturnValue({ + en0: [{ family: 'IPv4', internal: false, address: '192.168.1.24' }] + }) + const relayFailure = { + code: 'relay_mint_failed', + stage: 'create_pairing_relay', + message: 'Relay pairing invite request failed' + } + const createMobilePairingOffer = vi.fn().mockResolvedValue({ + available: false, + reason: 'relay_mint_failed', + guidance: 'Use LAN or retry Relay.', + relayFailure + }) + + registerMobileHandlers({ createMobilePairingOffer } as never) + + await expect(handlers.get('mobile:getPairingQR')?.(null, {})).resolves.toEqual({ + available: false, + reason: 'relay_mint_failed', + guidance: 'Use LAN or retry Relay.', + relayFailure + }) + }) + it('forwards an explicit local-only pairing choice', async () => { networkInterfacesMock.mockReturnValue({ en0: [{ family: 'IPv4', internal: false, address: '192.168.1.24' }] diff --git a/src/main/ipc/mobile.ts b/src/main/ipc/mobile.ts index 21c09e83206..1e9e4d99a6d 100644 --- a/src/main/ipc/mobile.ts +++ b/src/main/ipc/mobile.ts @@ -126,7 +126,12 @@ export function registerMobileHandlers( // ZeroTier) where the default LAN IP isn't reachable from the phone. const ip = args?.address ?? getDefaultPairingAddress() if (!ip) { - return { available: false as const } + return { + available: false as const, + reason: 'invalid_advertised_endpoint', + guidance: + 'No reachable network address is available for pairing. Connect to Wi‑Fi or Tailscale, or pick an address manually.' + } } // Why: coalesce repeated QR regenerations onto a single never-scanned @@ -143,7 +148,14 @@ export function registerMobileHandlers( name: `Mobile ${new Date().toLocaleDateString()}` }) if (!offer.available) { - return { available: false as const } + // Why: surface Relay mint failures (and other pairing unavailability) + // so the UI can refuse a silent LAN QR under the Relay label. + return { + available: false as const, + reason: offer.reason, + guidance: offer.guidance, + ...(offer.relayFailure ? { relayFailure: offer.relayFailure } : {}) + } } const qr = await (dependencies.encodePairingQr ?? encodeMobilePairingQr)(offer.pairingUrl) @@ -155,9 +167,6 @@ export function registerMobileHandlers( pairingUrl: offer.pairingUrl, endpoint: offer.endpoint, deviceId: offer.deviceId, - // Why: an automatic request can degrade to a local-only offer when - // Relay provisioning fails; the UI needs the encoded mode to avoid - // labeling a LAN-only code as Relay. connectionMode: offer.connectionMode } } diff --git a/src/main/ipc/runtime-environment-connectivity-handlers.ts b/src/main/ipc/runtime-environment-connectivity-handlers.ts index 7f2f49e84d3..28d303d28a6 100644 --- a/src/main/ipc/runtime-environment-connectivity-handlers.ts +++ b/src/main/ipc/runtime-environment-connectivity-handlers.ts @@ -12,6 +12,7 @@ import { import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' import type { RuntimeStatus } from '../../shared/runtime-types' import type { Store } from '../persistence' +import { verifyAndAddRuntimeEnvironmentFromPairingCode } from './runtime-environment-pairing-verification' import { closeRemoteRuntimeRequestConnection } from './runtime-environment-request-connections' import { callRuntimeEnvironment, @@ -64,6 +65,16 @@ export function registerRuntimeEnvironmentConnectivityHandlers({ return { environment: redactRuntimeEnvironment(environment) } } ) + ipcMain.handle( + 'runtimeEnvironments:verifyAndAddFromPairingCode', + async (_event, args: { name: string; pairingCode: string; allowLoopback?: boolean }) => { + const result = await verifyAndAddRuntimeEnvironmentFromPairingCode(getUserDataPath(), args) + if (result.ok) { + manuallyDisconnectedEnvironmentIds.delete(result.environment.id) + } + return result + } + ) ipcMain.handle('runtimeEnvironments:resolve', (_event, args: { selector: string }) => redactRuntimeEnvironment(resolveEnvironment(getUserDataPath(), args.selector)) ) diff --git a/src/main/ipc/runtime-environment-handler-channels.ts b/src/main/ipc/runtime-environment-handler-channels.ts new file mode 100644 index 00000000000..2c74a79c9f4 --- /dev/null +++ b/src/main/ipc/runtime-environment-handler-channels.ts @@ -0,0 +1,13 @@ +export const RUNTIME_ENVIRONMENT_HANDLER_CHANNELS = [ + 'runtimeEnvironments:list', + 'runtimeEnvironments:addFromPairingCode', + 'runtimeEnvironments:verifyAndAddFromPairingCode', + 'runtimeEnvironments:resolve', + 'runtimeEnvironments:remove', + 'runtimeEnvironments:disconnect', + 'runtimeEnvironments:connect', + 'runtimeEnvironments:getStatus', + 'runtimeEnvironments:call', + 'runtimeEnvironments:subscribe', + 'runtimeEnvironments:unsubscribe' +] as const diff --git a/src/main/ipc/runtime-environment-pairing-verification.ts b/src/main/ipc/runtime-environment-pairing-verification.ts new file mode 100644 index 00000000000..e39d727d1b0 --- /dev/null +++ b/src/main/ipc/runtime-environment-pairing-verification.ts @@ -0,0 +1,140 @@ +import { + addEnvironmentFromPairingCode, + RuntimeEnvironmentStoreError +} from '../../shared/runtime-environment-store' +import { parseHostAccessLink } from '../../shared/remote-pairing-address' +import { + verifyRemotePairingRuntimeStatus, + type VerifyAndAddRuntimeEnvironmentResult +} from '../../shared/remote-pairing-verification' +import { RemoteRuntimeClientError } from '../../shared/remote-runtime-client-error' +import { sendRemoteRuntimeRequest } from '../../shared/remote-runtime-client' +import { redactRuntimeEnvironment } from '../../shared/runtime-environments' +import type { RuntimeStatus } from '../../shared/runtime-types' + +type VerifyAndAddRuntimeEnvironmentArgs = { + name: string + pairingCode: string + allowLoopback?: boolean +} + +export async function verifyAndAddRuntimeEnvironmentFromPairingCode( + userDataPath: string, + args: VerifyAndAddRuntimeEnvironmentArgs +): Promise { + const parsed = parseHostAccessLink(args.pairingCode) + if (!parsed.ok) { + return { ok: false, kind: 'access-link-invalid', message: parsed.message } + } + if (parsed.value.endpointKind === 'loopback' && !args.allowLoopback) { + return { + ok: false, + kind: 'host-unreachable', + message: 'This access link points back to this device.' + } + } + + let runtimeStatus: RuntimeStatus + try { + const response = await sendRemoteRuntimeRequest( + parsed.value.pairing, + 'status.get', + undefined, + 15_000 + ) + if (!response.ok) { + return { + ok: false, + kind: 'connection-interrupted', + message: response.error.message + } + } + const statusVerification = verifyRemotePairingRuntimeStatus(response.result) + if (!statusVerification.ok) { + return statusVerification + } + runtimeStatus = statusVerification.runtimeStatus + } catch (error) { + return classifyPairingVerificationError(error, parsed.value.displayEndpoint) + } + + const usesSshTunnel = parsed.value.endpointKind === 'loopback' && args.allowLoopback === true + let environment: ReturnType + try { + environment = addEnvironmentFromPairingCode(userDataPath, { + ...args, + ...(usesSshTunnel ? { connectionDependency: 'ssh-tunnel' as const } : {}) + }) + } catch (error) { + return { + ok: false, + kind: 'environment-save-failed', + message: + error instanceof RuntimeEnvironmentStoreError && error.code === 'invalid_argument' + ? error.message + : 'Orca verified the host but could not save it. Check local settings storage and try again.' + } + } + return { + ok: true, + environment: redactRuntimeEnvironment(environment), + runtimeStatus + } +} + +function classifyPairingVerificationError( + error: unknown, + endpoint: string +): VerifyAndAddRuntimeEnvironmentResult { + if (error instanceof RemoteRuntimeClientError) { + if (error.code === 'invalid_argument') { + return invalidAccessLinkResult() + } + if (error.code === 'unauthorized' || error.pairingStage === 'access-grant') { + return { + ok: false, + kind: 'access-link-invalid', + message: 'This access link is no longer valid. Generate a new link on the other host.' + } + } + if (error.pairingStage === 'host-identity') { + return { + ok: false, + kind: 'host-identity-mismatch', + message: `Orca reached ${endpoint}, but that host does not match this access link.` + } + } + if (error.pairingStage === 'runtime') { + return { + ok: false, + kind: 'connection-interrupted', + message: `The connection to ${endpoint} was interrupted during verification.` + } + } + if (error.pairingStage === 'connect') { + return unreachableHostResult(endpoint) + } + } + if (error instanceof Error) { + return error.message.startsWith('Invalid public key') + ? invalidAccessLinkResult() + : { ok: false, kind: 'connection-interrupted', message: error.message } + } + return unreachableHostResult(endpoint) +} + +function invalidAccessLinkResult(): VerifyAndAddRuntimeEnvironmentResult { + return { + ok: false, + kind: 'access-link-invalid', + message: 'This access link contains invalid connection details.' + } +} + +function unreachableHostResult(endpoint: string): VerifyAndAddRuntimeEnvironmentResult { + return { + ok: false, + kind: 'host-unreachable', + message: `Cannot reach Orca at ${endpoint}. Confirm the other host is running and reachable.` + } +} diff --git a/src/main/ipc/runtime-environments.test.ts b/src/main/ipc/runtime-environments.test.ts index 83326570582..d0101ee3968 100644 --- a/src/main/ipc/runtime-environments.test.ts +++ b/src/main/ipc/runtime-environments.test.ts @@ -5,8 +5,12 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { encodePairingOffer } from '../../shared/pairing' -import { REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY } from '../../shared/protocol-version' +import { + MIN_COMPATIBLE_RUNTIME_SERVER_VERSION, + REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY +} from '../../shared/protocol-version' import * as environmentStore from '../../shared/runtime-environment-store' +import { RemoteRuntimeClientError } from '../../shared/remote-runtime-client-error' const { handleMock, @@ -79,6 +83,18 @@ function pairingCode(endpoint = 'ws://127.0.0.1:6768'): string { }) } +function runtimeStatus(): Record { + return { + runtimeId: 'runtime-a', + rendererGraphEpoch: 1, + graphStatus: 'ready', + authoritativeWindowId: 1, + liveTabCount: 0, + liveLeafCount: 0, + protocolVersion: 999_999 + } +} + function handler( channel: string ): (_event: unknown, args: TArgs) => TResult | Promise { @@ -132,6 +148,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { expect(handleMock.mock.calls.map((call) => call[0])).toEqual([ 'runtimeEnvironments:list', 'runtimeEnvironments:addFromPairingCode', + 'runtimeEnvironments:verifyAndAddFromPairingCode', 'runtimeEnvironments:resolve', 'runtimeEnvironments:remove', 'runtimeEnvironments:disconnect', @@ -153,6 +170,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { expect(removeHandlerMock.mock.calls.map((call) => call[0])).toEqual([ 'runtimeEnvironments:list', 'runtimeEnvironments:addFromPairingCode', + 'runtimeEnvironments:verifyAndAddFromPairingCode', 'runtimeEnvironments:resolve', 'runtimeEnvironments:remove', 'runtimeEnvironments:disconnect', @@ -211,6 +229,173 @@ describe('registerRuntimeEnvironmentHandlers', () => { expect(await list(null, undefined)).toEqual([]) }) + it('blocks loopback before verification unless an SSH tunnel is declared', async () => { + registerRuntimeEnvironmentHandlers(store as never) + const verifyAndAdd = handler< + { name: string; pairingCode: string; allowLoopback?: boolean }, + { ok: boolean; kind?: string } + >('runtimeEnvironments:verifyAndAddFromPairingCode') + + await expect( + verifyAndAdd(null, { name: 'desk', pairingCode: pairingCode() }) + ).resolves.toMatchObject({ + ok: false, + kind: 'host-unreachable' + }) + expect(sendRemoteRuntimeRequestMock).not.toHaveBeenCalled() + expect(environmentStore.listEnvironments(userDataPath)).toEqual([]) + }) + + it('verifies identity, access, status, and compatibility before saving', async () => { + registerRuntimeEnvironmentHandlers(store as never) + sendRemoteRuntimeRequestMock.mockResolvedValue({ + id: 'status', + ok: true, + result: runtimeStatus(), + _meta: { runtimeId: 'runtime-a' } + }) + const verifyAndAdd = handler< + { name: string; pairingCode: string; allowLoopback?: boolean }, + { ok: boolean; environment?: { name: string; connectionDependency?: string } } + >('runtimeEnvironments:verifyAndAddFromPairingCode') + + const result = await verifyAndAdd(null, { + name: 'desk', + pairingCode: pairingCode(), + allowLoopback: true + }) + + expect(result).toMatchObject({ + ok: true, + environment: { name: 'desk', connectionDependency: 'ssh-tunnel' } + }) + expect(sendRemoteRuntimeRequestMock).toHaveBeenCalledWith( + expect.objectContaining({ endpoint: 'ws://127.0.0.1:6768' }), + 'status.get', + undefined, + 15_000 + ) + expect(environmentStore.listEnvironments(userDataPath)).toHaveLength(1) + }) + + it('does not mark non-loopback hosts as SSH-tunnel dependent', async () => { + registerRuntimeEnvironmentHandlers(store as never) + sendRemoteRuntimeRequestMock.mockResolvedValue({ + id: 'status', + ok: true, + result: runtimeStatus(), + _meta: { runtimeId: 'runtime-a' } + }) + const verifyAndAdd = handler< + { name: string; pairingCode: string; allowLoopback?: boolean }, + { ok: boolean; environment?: { connectionDependency?: string } } + >('runtimeEnvironments:verifyAndAddFromPairingCode') + + const result = await verifyAndAdd(null, { + name: 'desk', + pairingCode: pairingCode('ws://100.76.32.125:6768'), + allowLoopback: true + }) + + expect(result).toMatchObject({ ok: true }) + expect(result.environment).not.toHaveProperty('connectionDependency') + }) + + it.each([ + [{ protocolVersion: MIN_COMPATIBLE_RUNTIME_SERVER_VERSION - 1 }, 'protocol-incompatible'], + [{ protocolVersion: 999_999, deviceScope: 'mobile' }, 'access-link-invalid'], + [null, 'connection-interrupted'] + ])('does not save a host with rejected status %o', async (status, expectedKind) => { + registerRuntimeEnvironmentHandlers(store as never) + sendRemoteRuntimeRequestMock.mockResolvedValue({ + id: 'status', + ok: true, + result: status, + _meta: { runtimeId: 'runtime-a' } + }) + const verifyAndAdd = handler< + { name: string; pairingCode: string }, + { ok: boolean; kind?: string } + >('runtimeEnvironments:verifyAndAddFromPairingCode') + + await expect( + verifyAndAdd(null, { + name: 'desk', + pairingCode: pairingCode('ws://100.76.32.125:6768') + }) + ).resolves.toMatchObject({ ok: false, kind: expectedKind }) + expect(environmentStore.listEnvironments(userDataPath)).toEqual([]) + }) + + it.each([ + [ + new RemoteRuntimeClientError('remote_runtime_unavailable', 'closed', { + pairingStage: 'host-identity', + closeCode: 4001 + }), + 'host-identity-mismatch' + ], + [ + new RemoteRuntimeClientError('unauthorized', 'rejected', { + pairingStage: 'access-grant' + }), + 'access-link-invalid' + ], + [ + new RemoteRuntimeClientError('remote_runtime_unavailable', 'offline', { + pairingStage: 'connect' + }), + 'host-unreachable' + ], + [new Error('Invalid public key: expected 32 bytes, got 3'), 'access-link-invalid'] + ] as const)('returns a structured pairing failure for %s', async (error, expectedKind) => { + registerRuntimeEnvironmentHandlers(store as never) + sendRemoteRuntimeRequestMock.mockRejectedValue(error) + const verifyAndAdd = handler< + { name: string; pairingCode: string }, + { ok: boolean; kind?: string; message?: string } + >('runtimeEnvironments:verifyAndAddFromPairingCode') + + const result = await verifyAndAdd(null, { + name: 'desk', + pairingCode: pairingCode('ws://100.76.32.125:6768') + }) + + expect(result).toMatchObject({ ok: false, kind: expectedKind }) + expect(result.message).not.toContain('4001') + expect(environmentStore.listEnvironments(userDataPath)).toEqual([]) + }) + + it('returns a structured failure when a verified host cannot be persisted', async () => { + registerRuntimeEnvironmentHandlers(store as never) + environmentStore.addEnvironmentFromPairingCode(userDataPath, { + name: 'desk', + pairingCode: pairingCode('ws://100.76.32.125:6768') + }) + sendRemoteRuntimeRequestMock.mockResolvedValue({ + id: 'status', + ok: true, + result: runtimeStatus(), + _meta: { runtimeId: 'runtime-a' } + }) + const verifyAndAdd = handler< + { name: string; pairingCode: string }, + { ok: boolean; kind?: string; message?: string } + >('runtimeEnvironments:verifyAndAddFromPairingCode') + + await expect( + verifyAndAdd(null, { + name: 'desk', + pairingCode: pairingCode('ws://100.76.32.125:6768') + }) + ).resolves.toMatchObject({ + ok: false, + kind: 'environment-save-failed', + message: 'A server named "desk" already exists.' + }) + expect(environmentStore.listEnvironments(userDataPath)).toHaveLength(1) + }) + it('requires an explicit Advanced selection before removing the Active Server', async () => { registerRuntimeEnvironmentHandlers(store as never) const add = handler< diff --git a/src/main/ipc/runtime-environments.ts b/src/main/ipc/runtime-environments.ts index e01a5d9ae63..6bf71edd634 100644 --- a/src/main/ipc/runtime-environments.ts +++ b/src/main/ipc/runtime-environments.ts @@ -19,19 +19,7 @@ import { resetSharedControlSupport, subscribeRuntimeEnvironment } from './runtime-environment-transport-routing' - -const RUNTIME_ENVIRONMENT_HANDLER_CHANNELS = [ - 'runtimeEnvironments:list', - 'runtimeEnvironments:addFromPairingCode', - 'runtimeEnvironments:resolve', - 'runtimeEnvironments:remove', - 'runtimeEnvironments:disconnect', - 'runtimeEnvironments:connect', - 'runtimeEnvironments:getStatus', - 'runtimeEnvironments:call', - 'runtimeEnvironments:subscribe', - 'runtimeEnvironments:unsubscribe' -] as const +import { RUNTIME_ENVIRONMENT_HANDLER_CHANNELS } from './runtime-environment-handler-channels' type RetainedRemoteRuntimeSubscription = RemoteRuntimeSubscription & { environmentId: string diff --git a/src/main/runtime/device-registry.ts b/src/main/runtime/device-registry.ts index d246b1f7309..959b52c9474 100644 --- a/src/main/runtime/device-registry.ts +++ b/src/main/runtime/device-registry.ts @@ -102,13 +102,15 @@ export class DeviceRegistry { } removeDevice(deviceId: string): boolean { - const before = this.devices.length - this.devices = this.devices.filter((d) => d.deviceId !== deviceId) - if (this.devices.length < before) { - this.save() - return true + const nextDevices = this.devices.filter((d) => d.deviceId !== deviceId) + if (nextDevices.length === this.devices.length) { + return false } - return false + // Why: persist before memory swap so a failed write does not drop a device + // only in-process while disk still lists it (and vice versa on reload). + this.save(nextDevices) + this.devices = nextDevices + return true } getDevice(deviceId: string): DeviceEntry | null { @@ -120,22 +122,30 @@ export class DeviceRegistry { } setRelayBinding(deviceId: string, binding: RelayDeviceBinding): boolean { - const device = this.devices.find((candidate) => candidate.deviceId === deviceId) - if (!device || binding.relayDeviceId !== deviceId) { + const index = this.devices.findIndex((candidate) => candidate.deviceId === deviceId) + if (index < 0 || binding.relayDeviceId !== deviceId) { return false } - device.relayBinding = binding - this.save() + const nextDevices = this.devices.map((device, candidateIndex) => + candidateIndex === index ? { ...device, relayBinding: binding } : device + ) + this.save(nextDevices) + this.devices = nextDevices return true } setMobilePairingConnectionMode(deviceId: string, mode: MobilePairingConnectionMode): boolean { - const device = this.devices.find((candidate) => candidate.deviceId === deviceId) - if (!device || device.scope !== 'mobile') { + const index = this.devices.findIndex((candidate) => candidate.deviceId === deviceId) + if (index < 0 || this.devices[index]?.scope !== 'mobile') { return false } - device.mobilePairingConnectionMode = mode - this.save() + // Why: persist before swapping memory so a failed write does not leave a + // mode the UI/runtime believe was stored. + const nextDevices = this.devices.map((device, candidateIndex) => + candidateIndex === index ? { ...device, mobilePairingConnectionMode: mode } : device + ) + this.save(nextDevices) + this.devices = nextDevices return true } @@ -158,11 +168,18 @@ export class DeviceRegistry { } updateLastSeen(deviceId: string): void { - const device = this.devices.find((d) => d.deviceId === deviceId) - if (device) { - device.lastSeenAt = Date.now() - this.save() + const index = this.devices.findIndex((d) => d.deviceId === deviceId) + if (index < 0) { + return } + // Why: persist before memory swap so a failed write cannot leave a scanned + // device looking never-scanned on disk, where rotation would drop it. + const seenAt = Date.now() + const nextDevices = this.devices.map((device, candidateIndex) => + candidateIndex === index ? { ...device, lastSeenAt: seenAt } : device + ) + this.save(nextDevices) + this.devices = nextDevices } private load(): void { @@ -187,7 +204,7 @@ export class DeviceRegistry { } } - private save(devices: DeviceEntry[] = this.devices): void { + private save(devices: DeviceEntry[]): void { writeSecureJsonFile(this.registryPath, devices) } } diff --git a/src/main/runtime/missing-worktree-terminal-reconciliation.test.ts b/src/main/runtime/missing-worktree-terminal-reconciliation.test.ts new file mode 100644 index 00000000000..f218f3a2c4f --- /dev/null +++ b/src/main/runtime/missing-worktree-terminal-reconciliation.test.ts @@ -0,0 +1,210 @@ +import { describe, expect, it, vi } from 'vitest' +import type { IPtyProvider } from '../providers/types' +import type { Repo } from '../../shared/types' +import type { OrcaRuntimeService } from './orca-runtime' +import { stopMissingWorktreeTerminals } from './missing-worktree-terminal-reconciliation' + +function createProvider(sessionIds: string[]): IPtyProvider { + return { + listProcesses: vi.fn(async () => + sessionIds.map((id) => ({ id, cwd: '/workspace', title: 'shell' })) + ), + shutdown: vi.fn(async () => {}) + } as unknown as IPtyProvider +} + +function createRuntime(): OrcaRuntimeService { + return { + stopTerminalsForWorktree: vi.fn(async () => ({ stopped: 0 })) + } as unknown as OrcaRuntimeService +} + +const localRepo: Repo = { + id: 'repo-1', + path: '/workspace/repo', + displayName: 'Repo', + badgeColor: '#000', + addedAt: 1 +} + +describe('stopMissingWorktreeTerminals', () => { + it('stops only locally owned worktrees absent from the authoritative scan', async () => { + const deletedId = 'repo-1::/workspace/deleted' + const survivingId = 'repo-1::/workspace/surviving' + const provider = createProvider([ + `${deletedId}@@deleted-session`, + `${survivingId}@@surviving-session` + ]) + const runtime = createRuntime() + + const result = await stopMissingWorktreeTerminals( + localRepo, + [deletedId, survivingId, 'repo-2::/workspace/other'], + [survivingId], + { + runtime, + getLocalProvider: () => provider, + getSshProvider: () => undefined + } + ) + + expect(result).toEqual({ stoppedWorktreeIds: [deletedId] }) + expect(provider.shutdown).toHaveBeenCalledWith( + `${deletedId}@@deleted-session`, + expect.objectContaining({ immediate: true }) + ) + expect(provider.shutdown).not.toHaveBeenCalledWith( + `${survivingId}@@surviving-session`, + expect.anything() + ) + }) + + it('uses the owning SSH provider without consulting the local provider', async () => { + const deletedId = 'repo-1::/workspace/deleted' + const localProvider = createProvider([`${deletedId}@@local-session`]) + const sshProvider = createProvider([`${deletedId}@@ssh-session`]) + const getSshProvider = vi.fn(() => sshProvider) + + await stopMissingWorktreeTerminals({ ...localRepo, connectionId: 'ssh-1' }, [deletedId], [], { + runtime: createRuntime(), + getLocalProvider: () => localProvider, + getSshProvider + }) + + expect(getSshProvider).toHaveBeenCalledWith('ssh-1') + expect(sshProvider.shutdown).toHaveBeenCalledWith( + `${deletedId}@@ssh-session`, + expect.objectContaining({ immediate: true }) + ) + expect(localProvider.listProcesses).not.toHaveBeenCalled() + }) + + it('still stops graph-visible sessions when the owning provider is unavailable', async () => { + const deletedId = 'repo-1::/workspace/deleted' + const runtime = createRuntime() + + const result = await stopMissingWorktreeTerminals( + { ...localRepo, connectionId: 'ssh-1' }, + [deletedId], + [], + { + runtime, + getLocalProvider: () => null, + getSshProvider: () => undefined + } + ) + + expect(result).toEqual({ stoppedWorktreeIds: [deletedId] }) + expect(runtime.stopTerminalsForWorktree).toHaveBeenCalledWith(deletedId) + }) + + // Why: an agent cleaning up workspaces deletes many at once. Enumerating the + // host once per missing worktree is O(N) relay round-trips carrying O(N^2) + // rows — on SSH that stalls teardown for minutes. + it('enumerates the host once for the whole sweep', async () => { + const ids = Array.from({ length: 25 }, (_, index) => `repo-1::/workspace/wt-${index}`) + const provider = createProvider(ids.map((id) => `${id}@@session`)) + + const result = await stopMissingWorktreeTerminals( + { ...localRepo, connectionId: 'ssh-1' }, + ids, + [], + { runtime: createRuntime(), getLocalProvider: () => null, getSshProvider: () => provider } + ) + + expect(result.stoppedWorktreeIds).toHaveLength(ids.length) + expect(provider.listProcesses).toHaveBeenCalledTimes(1) + expect(provider.shutdown).toHaveBeenCalledTimes(ids.length) + }) + + // Why: real providers put listProcesses/shutdown on the prototype and use `this`; + // batching them behind a wrapper must not break that binding. + it('keeps provider method binding intact while batching', async () => { + class PrototypeProvider { + listCalls = 0 + shutdownCalls: string[] = [] + private readonly sessions: { id: string; cwd: string; title: string }[] + constructor(sessionIds: string[]) { + this.sessions = sessionIds.map((id) => ({ id, cwd: '/workspace', title: 'shell' })) + } + async listProcesses(): Promise<{ id: string; cwd: string; title: string }[]> { + this.listCalls += 1 + return this.sessions + } + async shutdown(sessionId: string): Promise { + this.shutdownCalls.push(sessionId) + } + } + const ids = ['repo-1::/workspace/a', 'repo-1::/workspace/b', 'repo-1::/workspace/c'] + const provider = new PrototypeProvider(ids.map((id) => `${id}@@session`)) + + const result = await stopMissingWorktreeTerminals( + { ...localRepo, connectionId: 'ssh-1' }, + ids, + [], + { + runtime: createRuntime(), + getLocalProvider: () => null, + getSshProvider: () => provider as unknown as IPtyProvider + } + ) + + expect(result.stoppedWorktreeIds).toHaveLength(ids.length) + expect(provider.listCalls).toBe(1) + expect(provider.shutdownCalls).toHaveLength(ids.length) + }) + + // Why: the batching must not leak past the calls it was built for. If the proxy + // were the receiver, a provider whose own method called `this.listProcesses()` + // would silently read this sweep's cached snapshot instead of the live host. + it('does not serve the shared snapshot to provider-internal listProcesses', async () => { + class SelfListingProvider { + listCalls = 0 + constructor(private readonly sessions: { id: string; cwd: string; title: string }[]) {} + async listProcesses(): Promise<{ id: string; cwd: string; title: string }[]> { + this.listCalls += 1 + return this.sessions + } + async shutdown(): Promise { + // A provider that re-reads live state as part of stopping. + await this.listProcesses() + } + } + const ids = ['repo-1::/workspace/a', 'repo-1::/workspace/b'] + const provider = new SelfListingProvider( + ids.map((id) => ({ id: `${id}@@session`, cwd: '/workspace', title: 'shell' })) + ) + + await stopMissingWorktreeTerminals({ ...localRepo, connectionId: 'ssh-1' }, ids, [], { + runtime: createRuntime(), + getLocalProvider: () => null, + getSshProvider: () => provider as unknown as IPtyProvider + }) + + // One shared sweep scan, plus each shutdown's own live re-read. + expect(provider.listCalls).toBe(1 + ids.length) + }) + + // Why: caching a rejected scan would let one transient failure suppress + // teardown for every remaining worktree in the sweep. + it('does not reuse a failed process scan', async () => { + const ids = ['repo-1::/workspace/a', 'repo-1::/workspace/b'] + const listProcesses = vi + .fn() + .mockRejectedValueOnce(new Error('relay dropped')) + .mockResolvedValue([{ id: `${ids[1]}@@session`, cwd: '/workspace', title: 'shell' }]) + const provider = { listProcesses, shutdown: vi.fn(async () => {}) } as unknown as IPtyProvider + + await stopMissingWorktreeTerminals({ ...localRepo, connectionId: 'ssh-1' }, ids, [], { + runtime: createRuntime(), + getLocalProvider: () => null, + getSshProvider: () => provider + }) + + expect(listProcesses.mock.calls.length).toBeGreaterThan(1) + expect(provider.shutdown).toHaveBeenCalledWith( + `${ids[1]}@@session`, + expect.objectContaining({ immediate: true }) + ) + }) +}) diff --git a/src/main/runtime/missing-worktree-terminal-reconciliation.ts b/src/main/runtime/missing-worktree-terminal-reconciliation.ts new file mode 100644 index 00000000000..b25c4fc9f83 --- /dev/null +++ b/src/main/runtime/missing-worktree-terminal-reconciliation.ts @@ -0,0 +1,122 @@ +import type { IPtyProvider } from '../providers/types' +import type { Repo } from '../../shared/types' +import { splitWorktreeId } from '../../shared/worktree-id' +import { mapWithConcurrency } from '../../shared/map-with-concurrency' +import type { OrcaRuntimeService } from './orca-runtime' +import { killAllProcessesForWorktree } from './worktree-teardown' + +const MISSING_WORKTREE_TEARDOWN_CONCURRENCY = 4 + +// Why: every worktree in one sweep is already known-missing, so they can all be +// served by a single point-in-time process list. Without this, an agent that +// deletes N workspaces costs N full host enumerations — O(N) relay round-trips +// carrying O(N^2) rows, which is minutes of stalled teardown on a remote host. +// +// Safe only because this sweep is best-effort. Do NOT reuse this wrapper on a +// `requirePhysicalStop` path: that re-lists *after* shutdown to prove a PTY +// exited, and a pre-shutdown snapshot would make the proof read stale rows. +function withSharedProcessSnapshot(provider: IPtyProvider): IPtyProvider { + let snapshot: Promise>> | null = null + return new Proxy(provider, { + get(target, property) { + if (property !== 'listProcesses') { + // Why: bind other members to `target`, not the proxy. With the proxy as + // receiver, a provider whose own method called `this.listProcesses()` + // would silently read this sweep's cached snapshot instead of the live + // host — the batching must not leak past the calls it was built for. + const member: unknown = Reflect.get(target, property) + return typeof member === 'function' ? member.bind(target) : member + } + return async (opts?: { deadlineMs?: number }) => { + const pending = (snapshot ??= target.listProcesses(opts)) + try { + return await pending + } catch { + // Why: a shared *rejection* would let one transient relay failure suppress + // the provider sweep for every worktree in the batch — the exact leak this + // reconciliation exists to prevent. Failures fall back to a per-caller scan, + // restoring pre-batching isolation while success still costs one round-trip. + if (snapshot === pending) { + snapshot = null + } + return target.listProcesses(opts) + } + } + } + }) +} + +type MissingWorktreeTerminalReconciliationDeps = { + runtime: OrcaRuntimeService + getLocalProvider: () => IPtyProvider | null + getSshProvider: (connectionId: string) => IPtyProvider | undefined + onPtyStopped?: (ptyId: string) => void +} + +export async function stopMissingWorktreeTerminals( + repo: Repo, + knownWorktreeIds: readonly string[], + detectedWorktreeIds: readonly string[], + deps: MissingWorktreeTerminalReconciliationDeps +): Promise<{ stoppedWorktreeIds: string[] }> { + const detectedIds = new Set(detectedWorktreeIds) + const missingIds = [ + ...new Set( + knownWorktreeIds.filter( + (worktreeId) => + splitWorktreeId(worktreeId)?.repoId === repo.id && !detectedIds.has(worktreeId) + ) + ) + ] + if (missingIds.length === 0) { + return { stoppedWorktreeIds: [] } + } + + const ownedProvider = repo.connectionId + ? deps.getSshProvider(repo.connectionId) + : deps.getLocalProvider() + const provider = ownedProvider ? withSharedProcessSnapshot(ownedProvider) : ownedProvider + if (!provider) { + const stoppedWorktreeIds = ( + await mapWithConcurrency( + missingIds, + MISSING_WORKTREE_TEARDOWN_CONCURRENCY, + async (worktreeId) => { + try { + await deps.runtime.stopTerminalsForWorktree(worktreeId) + return worktreeId + } catch { + return null + } + } + ) + ).filter((worktreeId): worktreeId is string => worktreeId !== null) + return { stoppedWorktreeIds } + } + + const stoppedWorktreeIds = ( + await mapWithConcurrency( + missingIds, + MISSING_WORKTREE_TEARDOWN_CONCURRENCY, + async (worktreeId) => { + try { + await killAllProcessesForWorktree(worktreeId, { + runtime: deps.runtime, + localProvider: provider, + onPtyStopped: deps.onPtyStopped, + // Why: the shared process snapshot is only valid while nothing needs a + // post-shutdown re-list, so this sweep stays explicitly best-effort. + requirePhysicalStop: false, + ...(repo.connectionId ? { includeLocalRegistry: false } : {}) + }) + return worktreeId + } catch (error) { + console.warn(`[worktree-teardown] Failed to stop missing workspace ${worktreeId}`, error) + return null + } + } + ) + ).filter((worktreeId): worktreeId is string => worktreeId !== null) + + return { stoppedWorktreeIds } +} diff --git a/src/main/runtime/mobile-pairing-qr.test.ts b/src/main/runtime/mobile-pairing-qr.test.ts index c61366d81c3..3c860cbf6eb 100644 --- a/src/main/runtime/mobile-pairing-qr.test.ts +++ b/src/main/runtime/mobile-pairing-qr.test.ts @@ -3,6 +3,9 @@ import { encodePairingOffer } from '../../shared/pairing' import type { PairingOffer } from '../../shared/mobile-relay-pairing-offer' import { encodeMobilePairingQr } from './mobile-pairing-qr' +// Keep every capacity probe in the same encoded payload family. +const FIXED_INVITE_EXPIRES_AT = Date.now() + 5 * 60_000 + function pairingUrl(endpointLength: number, relay: boolean): string { const prefix = 'wss://pair.example/' const offer: PairingOffer = { @@ -20,7 +23,7 @@ function pairingUrl(endpointLength: number, relay: boolean): string { assignmentEpoch: 1, relayHostId: 'a'.repeat(16), inviteToken: 'b'.repeat(43), - inviteExpiresAt: Date.now() + 60_000, + inviteExpiresAt: FIXED_INVITE_EXPIRES_AT, e2eeFraming: 2 } } diff --git a/src/main/runtime/orca-runtime-files.ts b/src/main/runtime/orca-runtime-files.ts index adbae1adf7e..1ea6769ff4c 100644 --- a/src/main/runtime/orca-runtime-files.ts +++ b/src/main/runtime/orca-runtime-files.ts @@ -80,7 +80,6 @@ import { isWatcherProcessFailure, WatcherProcessFailure } from '../ipc/parcel-watcher-process-failure' -import { assertNoClobberRenameDestinationAvailable } from '../../shared/filesystem-rename-collision' import { joinWorktreeRelativePath, normalizeRuntimeRelativePath } from './runtime-relative-paths' import { rankRuntimeMobileFilePaths, @@ -89,6 +88,7 @@ import { import { beginWatcherInstall } from '../ipc/watcher-removal-gate' import { assertSshMutationExpectation } from '../ssh/ssh-connection-generation' import { toSshExecutionHostId } from '../../shared/execution-host' +import { renameLocalPathSerializedByDestination } from '../destination-serialized-local-rename' const MOBILE_FILE_LIST_LIMIT = 5000 const MOBILE_FILE_PATH_SEARCH_CACHE_LIMIT = 20_000 @@ -1728,8 +1728,7 @@ export class RuntimeFileCommands { const store = this.host.requireStore() const oldPath = await resolveAuthorizedPath(oldTarget.path, store, { preserveSymlink: true }) const newPath = await resolveAuthorizedPath(newTarget.path, store, { preserveSymlink: true }) - await assertNoClobberRenameDestinationAvailable(oldPath, newPath) - await rename(oldPath, newPath) + await renameLocalPathSerializedByDestination(oldPath, newPath) return { ok: true } } diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 72663c7f908..2fd94df751f 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -73,6 +73,7 @@ import { HeadlessEmulator } from '../daemon/headless-emulator' import { HEADLESS_RUNTIME_WINDOW_ID, type RuntimeMobileSessionTabsResult, + type RuntimeSyncWindowGraph, type RuntimeTerminalCreate } from '../../shared/runtime-types' import type { TerminalSideEffectBatch } from '../../shared/terminal-side-effect-facts' @@ -2514,6 +2515,130 @@ describe('OrcaRuntimeService', () => { }) }) + it('routes a launch-draft resolution to the handle-owning local and remote renderers', async () => { + const runtime = new OrcaRuntimeService(store) + const nativeChatLaunchDraftResolved = vi.fn() + const events: RuntimeClientEvent[] = [] + runtime.setNotifier({ nativeChatLaunchDraftResolved } as never) + runtime.onClientEvent((event) => events.push(event)) + runtime.attachWindow(1) + const graph: RuntimeSyncWindowGraph = { + tabs: [ + { + tabId: 'tab-1', + worktreeId: 'repo-1::/tmp/worktree-a', + title: 'Claude', + activeLeafId: 'pane:1', + layout: null + } + ], + leaves: [ + { + tabId: 'tab-1', + worktreeId: 'repo-1::/tmp/worktree-a', + leafId: 'pane:1', + paneRuntimeId: 1, + ptyId: 'pty-1' + } + ], + mobileSessionTabs: [ + { + worktree: 'repo-1::/tmp/worktree-a', + publicationEpoch: 'launch-draft-epoch', + snapshotVersion: 1, + activeGroupId: 'group-1', + activeTabId: 'tab-1::pane:1', + activeTabType: 'terminal', + tabs: [ + { + type: 'terminal', + id: 'tab-1::pane:1', + parentTabId: 'tab-1', + leafId: 'pane:1', + title: 'Claude', + launchDraft: 'seed', + launchDraftCreatedAt: 7, + isActive: true + } + ] + } + ] + } + runtime.syncWindowGraph(1, graph) + const listed = await runtime.listMobileSessionTabs('branch:feature/foo') + const mobileTab = listed.tabs.find((tab) => tab.type === 'terminal') + if (!mobileTab?.terminal) { + throw new Error('expected mobile terminal handle') + } + expect(mobileTab).toMatchObject({ launchDraft: 'seed', launchDraftCreatedAt: 7 }) + + runtime.notifyNativeChatLaunchDraftResolved(mobileTab.terminal, { + text: 'seed', + createdAt: 7 + }) + + expect(nativeChatLaunchDraftResolved).toHaveBeenCalledWith('tab-1', { + text: 'seed', + createdAt: 7 + }) + expect(events).toContainEqual({ + type: 'nativeChatLaunchDraftResolved', + tabId: 'tab-1', + text: 'seed', + createdAt: 7 + }) + expect(runtime.getNativeChatLaunchDraftResolutionClientEventSnapshot()).toContainEqual({ + type: 'nativeChatLaunchDraftResolved', + tabId: 'tab-1', + text: 'seed', + createdAt: 7 + }) + const retired = (await runtime.listMobileSessionTabs('branch:feature/foo')).tabs.find( + (tab) => tab.type === 'terminal' + ) + expect(retired).not.toHaveProperty('launchDraft') + expect(retired).not.toHaveProperty('launchDraftCreatedAt') + + runtime.markRendererReloading(1) + const replay = runtime.syncWindowGraph(1, { + ...graph, + mobileSessionTabs: graph.mobileSessionTabs?.map((snapshot) => ({ + ...snapshot, + publicationEpoch: 'launch-draft-reload', + snapshotVersion: 2 + })) + }) + expect(replay.nativeChatLaunchDraftResolutions).toEqual([ + { tabId: 'tab-1', text: 'seed', createdAt: 7 } + ]) + expect( + (await runtime.listMobileSessionTabs('branch:feature/foo')).tabs.find( + (tab) => tab.type === 'terminal' + ) + ).not.toHaveProperty('launchDraft') + + const reconciled = runtime.syncWindowGraph(1, { + ...graph, + mobileSessionTabs: graph.mobileSessionTabs?.map((snapshot) => ({ + ...snapshot, + publicationEpoch: 'launch-draft-reload', + snapshotVersion: 3, + tabs: snapshot.tabs.map((tab) => { + if (tab.type !== 'terminal') { + return tab + } + return { ...tab, launchDraftCreatedAt: 8 } + }) + })) + }) + expect(reconciled.nativeChatLaunchDraftResolutions).toBeUndefined() + expect( + (await runtime.listMobileSessionTabs('branch:feature/foo')).tabs.find( + (tab) => tab.type === 'terminal' + ) + ).toMatchObject({ launchDraft: 'seed', launchDraftCreatedAt: 8 }) + }) + it('surfaces stale terminal handles for stranded panes and recovers after same-pane wake', async () => { const runtime = new OrcaRuntimeService(store) const tabId = 'tab-1' @@ -34409,6 +34534,191 @@ describe('OrcaRuntimeService', () => { expect(removeWorktreeLineage).not.toHaveBeenCalled() }) + it('revalidates missing worktrees before stopping their local provider sessions', async () => { + const deletedId = `${TEST_REPO_ID}::/tmp/deleted` + const survivingId = `${TEST_REPO_ID}::/tmp/surviving` + const localProvider = { + listProcesses: vi.fn(async () => [ + { id: `${deletedId}@@deleted-session`, cwd: '/tmp/deleted', title: 'shell' }, + { id: `${survivingId}@@surviving-session`, cwd: '/tmp/surviving', title: 'shell' } + ]), + shutdown: vi.fn(async () => {}) + } + const runtime = new OrcaRuntimeService(store, undefined, { + getLocalProvider: () => localProvider as never + }) + vi.spyOn( + runtime as unknown as { listDetectedWorktreesForResolvedRepo: () => unknown }, + 'listDetectedWorktreesForResolvedRepo' + ).mockResolvedValue({ + repoId: TEST_REPO_ID, + authoritative: true, + source: 'git', + worktrees: [{ id: survivingId }] as never + }) + + const result = await runtime.teardownMissingManagedWorktreeTerminals(`id:${TEST_REPO_ID}`, [ + deletedId, + survivingId + ]) + + expect(result).toEqual({ stoppedWorktreeIds: [deletedId] }) + expect(localProvider.shutdown).toHaveBeenCalledWith( + `${deletedId}@@deleted-session`, + expect.objectContaining({ immediate: true }) + ) + expect(localProvider.shutdown).not.toHaveBeenCalledWith( + `${survivingId}@@surviving-session`, + expect.anything() + ) + }) + + // Why (#10562): the renderer purges its state regardless of the sweep result, so + // revalidating against a cached scan (30s TTL) that still lists an already-deleted + // directory would strand those PTYs permanently — nothing asks a second time. + it('revalidates against a fresh scan instead of a warm worktree-scan cache', async () => { + const deletedId = `${TEST_REPO_ID}::/tmp/deleted` + const localProvider = { + listProcesses: vi.fn(async () => [ + { id: `${deletedId}@@deleted-session`, cwd: '/tmp/deleted', title: 'shell' } + ]), + shutdown: vi.fn(async () => {}) + } + const runtime = new OrcaRuntimeService(store, undefined, { + getLocalProvider: () => localProvider as never + }) + + // Warm the scan cache while the worktree still exists. + vi.mocked(listWorktrees).mockResolvedValueOnce([ + { path: '/tmp/deleted', head: 'abc', branch: 'd', isBare: false, isMainWorktree: false } + ]) + await runtime.teardownMissingManagedWorktreeTerminals(`id:${TEST_REPO_ID}`, [deletedId]) + expect(localProvider.shutdown).not.toHaveBeenCalled() + + // The worktree is now gone; the cached scan must not mask that. + vi.mocked(listWorktrees).mockResolvedValue([]) + const result = await runtime.teardownMissingManagedWorktreeTerminals(`id:${TEST_REPO_ID}`, [ + deletedId + ]) + + expect(result).toEqual({ stoppedWorktreeIds: [deletedId] }) + expect(localProvider.shutdown).toHaveBeenCalledWith( + `${deletedId}@@deleted-session`, + expect.objectContaining({ immediate: true }) + ) + }) + + it('does not stop sessions after a non-authoritative revalidation', async () => { + const localProvider = { + listProcesses: vi.fn(async () => []), + shutdown: vi.fn(async () => {}) + } + const runtime = new OrcaRuntimeService(store, undefined, { + getLocalProvider: () => localProvider as never + }) + vi.spyOn( + runtime as unknown as { listDetectedWorktreesForResolvedRepo: () => unknown }, + 'listDetectedWorktreesForResolvedRepo' + ).mockResolvedValue({ + repoId: TEST_REPO_ID, + authoritative: false, + source: 'metadata-fallback', + worktrees: [] + }) + + await runtime.teardownMissingManagedWorktreeTerminals(`id:${TEST_REPO_ID}`, [ + `${TEST_REPO_ID}::/tmp/deleted` + ]) + + expect(localProvider.listProcesses).not.toHaveBeenCalled() + }) + + // Why: an explicit connection identity only narrows the selector, it must not + // change the grammar. Treating the selector as a bare repo id made every + // `path:`/`name:` selector fail repo_not_found on this path alone. + it('resolves non-id selectors when a connection identity is supplied', async () => { + const deletedId = `${TEST_REPO_ID}::/tmp/deleted` + const localProvider = { + listProcesses: vi.fn(async () => [ + { id: `${deletedId}@@deleted-session`, cwd: '/tmp/deleted', title: 'shell' } + ]), + shutdown: vi.fn(async () => {}) + } + const localRepo = store.getRepos()[0] + const runtime = new OrcaRuntimeService( + { ...store, getRepos: () => [localRepo, { ...localRepo, connectionId: 'ssh-1' }] } as never, + undefined, + { getLocalProvider: () => localProvider as never } + ) + vi.spyOn( + runtime as unknown as { listDetectedWorktreesForResolvedRepo: () => unknown }, + 'listDetectedWorktreesForResolvedRepo' + ).mockResolvedValue({ + repoId: TEST_REPO_ID, + authoritative: true, + source: 'git', + worktrees: [] + }) + + // `path:` is ambiguous across the two rows; connectionId null selects the local one. + const result = await runtime.teardownMissingManagedWorktreeTerminals( + `path:${localRepo.path}`, + [deletedId], + null + ) + + expect(result).toEqual({ stoppedWorktreeIds: [deletedId] }) + expect(localProvider.shutdown).toHaveBeenCalledWith( + `${deletedId}@@deleted-session`, + expect.objectContaining({ immediate: true }) + ) + }) + + it('uses the connection-scoped repo when local and SSH repo ids collide', async () => { + const deletedId = `${TEST_REPO_ID}::/tmp/deleted` + const localProvider = { + listProcesses: vi.fn(async () => []), + shutdown: vi.fn(async () => {}) + } + const sshProvider = { + listProcesses: vi.fn(async () => [ + { id: `${deletedId}@@ssh-session`, cwd: '/tmp/deleted', title: 'shell' } + ]), + shutdown: vi.fn(async () => {}) + } + const localRepo = store.getRepos()[0] + const sshRepo = { ...localRepo, connectionId: 'ssh-1' } + const runtime = new OrcaRuntimeService( + { + ...store, + getRepos: () => [localRepo, sshRepo] + } as never, + undefined, + { + getLocalProvider: () => localProvider as never, + getSshProvider: (connectionId) => + connectionId === 'ssh-1' ? (sshProvider as never) : undefined + } + ) + vi.spyOn( + runtime as unknown as { listDetectedWorktreesForResolvedRepo: () => unknown }, + 'listDetectedWorktreesForResolvedRepo' + ).mockResolvedValue({ + repoId: TEST_REPO_ID, + authoritative: true, + source: 'git', + worktrees: [] + }) + + await runtime.teardownMissingManagedWorktreeTerminals(TEST_REPO_ID, [deletedId], 'ssh-1') + + expect(sshProvider.shutdown).toHaveBeenCalledWith( + `${deletedId}@@ssh-session`, + expect.objectContaining({ immediate: true }) + ) + expect(localProvider.listProcesses).not.toHaveBeenCalled() + }) + it('hydrates runtime detected lists with instance-validated legacy lineage', async () => { const parentPath = join(tmpdir(), 'worktree-parent') const childPath = join(tmpdir(), 'worktree-child') @@ -36064,10 +36374,13 @@ describe('OrcaRuntimeService', () => { (spawn.mock.calls[1]?.[0] as { env?: Record } | undefined)?.env ?? {} expectStablePaneKeyEnv(setupSpawnEnv) const setupLeafId = setupSpawnEnv.ORCA_PANE_KEY.slice(`${setupSpawnEnv.ORCA_TAB_ID}:`.length) + // Why: a background CLI create adopts its tabs silently — surfaceOwner:false + // keeps the sidebar from scrolling to a workspace the user never asked for. expect(revealTerminalSession).toHaveBeenLastCalledWith(result.worktree.id, { ptyId: 'pty-setup', title: 'Setup', activate: false, + surfaceOwner: false, tabId: setupSpawnEnv.ORCA_TAB_ID, leafId: setupLeafId }) @@ -36303,15 +36616,194 @@ describe('OrcaRuntimeService', () => { const initialLeafId = initialSpawnEnv.ORCA_PANE_KEY.slice( `${initialSpawnEnv.ORCA_TAB_ID}:`.length ) + // Why: the renderer treats a missing surfaceOwner as "reveal the owner", which + // scrolled the sidebar to background CLI creates. expect(revealTerminalSession).toHaveBeenCalledWith(result.worktree.id, { ptyId: 'pty-created-worktree', title: null, activate: false, + surfaceOwner: false, tabId: initialSpawnEnv.ORCA_TAB_ID, leafId: initialLeafId }) }) + it('does not surface the new workspace when a background CLI create launches its agent', async () => { + const runtime = new OrcaRuntimeService(store) + const activateWorktree = vi.fn() + const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-background-agent' }) + const spawn = vi.fn().mockResolvedValue({ id: 'pty-background-agent' }) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree, + 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() + }) + runtime.attachWindow(1) + + computeWorktreePathMock.mockReturnValue('/tmp/workspaces/runtime-background-agent') + ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/runtime-background-agent') + vi.mocked(getEffectiveHooks).mockReturnValue(null) + vi.mocked(listWorktrees).mockResolvedValue([ + { + path: '/tmp/workspaces/runtime-background-agent', + head: 'def', + branch: 'runtime-background-agent', + isBare: false, + isMainWorktree: false + } + ]) + + const result = await runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'runtime-background-agent', + createdWithAgent: 'claude', + startup: { command: 'claude' } + }) + + // `orca worktree create --agent claude` without --activate: the agent tab is + // adopted, but the sidebar must stay on whatever the user was reading. + expect(activateWorktree).not.toHaveBeenCalled() + expect(revealTerminalSession).toHaveBeenCalledWith( + result.worktree.id, + expect.objectContaining({ + ptyId: 'pty-background-agent', + activate: false, + surfaceOwner: false + }) + ) + }) + + it('still surfaces the new workspace when the caller explicitly activates it', async () => { + const runtime = new OrcaRuntimeService(store) + const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-activated-agent' }) + const spawn = vi.fn().mockResolvedValue({ id: 'pty-activated-agent' }) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.setNotifier({ + 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() + }) + runtime.attachWindow(1) + + computeWorktreePathMock.mockReturnValue('/tmp/workspaces/runtime-activated-agent') + ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/runtime-activated-agent') + vi.mocked(getEffectiveHooks).mockReturnValue(null) + vi.mocked(listWorktrees).mockResolvedValue([ + { + path: '/tmp/workspaces/runtime-activated-agent', + head: 'def', + branch: 'runtime-activated-agent', + isBare: false, + isMainWorktree: false + } + ]) + + const result = await runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'runtime-activated-agent', + activate: true, + createdWithAgent: 'claude', + startup: { command: 'claude' } + }) + + // No-regression guard: an explicit --activate still reveals the workspace, so + // surfaceOwner must stay absent. + const revealPayload = revealTerminalSession.mock.calls[0]?.[1] as + | { surfaceOwner?: boolean } + | undefined + expect(revealTerminalSession).toHaveBeenCalledWith( + result.worktree.id, + expect.objectContaining({ ptyId: 'pty-activated-agent' }) + ) + expect(revealPayload).not.toHaveProperty('surfaceOwner') + }) + + it('still surfaces the new workspace when hooks run in the foreground', async () => { + const runtime = new OrcaRuntimeService(store) + const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-hooks-agent' }) + const spawn = vi.fn().mockResolvedValue({ id: 'pty-hooks-agent' }) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.setNotifier({ + 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() + }) + runtime.attachWindow(1) + + computeWorktreePathMock.mockReturnValue('/tmp/workspaces/runtime-hooks-agent') + ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/runtime-hooks-agent') + vi.mocked(getEffectiveHooks).mockReturnValue(null) + vi.mocked(listWorktrees).mockResolvedValue([ + { + path: '/tmp/workspaces/runtime-hooks-agent', + head: 'def', + branch: 'runtime-hooks-agent', + isBare: false, + isMainWorktree: false + } + ]) + + const result = await runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'runtime-hooks-agent', + runHooks: true, + createdWithAgent: 'claude', + startup: { command: 'claude' } + }) + + const revealPayload = revealTerminalSession.mock.calls[0]?.[1] as + | { surfaceOwner?: boolean } + | undefined + expect(revealTerminalSession).toHaveBeenCalledWith( + result.worktree.id, + expect.objectContaining({ ptyId: 'pty-hooks-agent' }) + ) + expect(revealPayload).not.toHaveProperty('surfaceOwner') + }) + it('honors split setup placement for CLI-created worktrees without startup agents', async () => { const runtimeStore = { ...store, @@ -36400,6 +36892,172 @@ describe('OrcaRuntimeService', () => { ) }) + it('does not surface the new workspace when a background create splits its setup pane', async () => { + const runtimeStore = { + ...store, + getSettings: () => ({ + ...store.getSettings(), + setupScriptLaunchMode: 'split-horizontal' as const + }) + } + const runtime = new OrcaRuntimeService(runtimeStore as never) + const activateWorktree = vi.fn() + const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-bg-setup-split' }) + const spawn = vi + .fn() + .mockResolvedValueOnce({ id: 'pty-bg-split-main' }) + .mockResolvedValueOnce({ id: 'pty-bg-split-setup' }) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree, + 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() + }) + runtime.attachWindow(1) + + computeWorktreePathMock.mockReturnValue('/tmp/workspaces/runtime-bg-split-setup') + ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/runtime-bg-split-setup') + vi.mocked(getEffectiveHooks).mockReturnValue({ scripts: { setup: 'pnpm install' } }) + vi.mocked(shouldRunSetupForCreate).mockReturnValue(true) + vi.mocked(createSetupRunnerScript).mockReturnValue({ + runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh', + envVars: { + ORCA_ROOT_PATH: '/tmp/repo', + ORCA_WORKTREE_PATH: '/tmp/workspaces/runtime-bg-split-setup' + } + }) + vi.mocked(listWorktrees).mockResolvedValue([ + { + path: '/tmp/workspaces/runtime-bg-split-setup', + head: 'def', + branch: 'runtime-bg-split-setup', + isBare: false, + isMainWorktree: false + } + ]) + + const result = await runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'runtime-bg-split-setup', + setupDecision: 'run' + }) + + // Split launch modes reach the renderer through splitTerminal, not + // createTerminal — that path must suppress owner surfacing too. + expect(activateWorktree).not.toHaveBeenCalled() + await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2)) + const mainEnv = (spawn.mock.calls[0]![0] as { env?: Record }).env ?? {} + await vi.waitFor(() => + expect(revealTerminalSession).toHaveBeenLastCalledWith( + result.worktree.id, + expect.objectContaining({ + ptyId: 'pty-bg-split-setup', + tabId: mainEnv.ORCA_TAB_ID, + activate: false, + surfaceOwner: false, + splitDirection: 'horizontal' + }) + ) + ) + }) + + it('still surfaces the new workspace when an activating create splits its setup pane', async () => { + const runtimeStore = { + ...store, + getSettings: () => ({ + ...store.getSettings(), + setupScriptLaunchMode: 'split-horizontal' as const + }) + } + const runtime = new OrcaRuntimeService(runtimeStore as never) + const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-active-setup-split' }) + const spawn = vi + .fn() + .mockResolvedValueOnce({ id: 'pty-active-split-main' }) + .mockResolvedValueOnce({ id: 'pty-active-split-setup' }) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.setNotifier({ + 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() + }) + runtime.attachWindow(1) + + computeWorktreePathMock.mockReturnValue('/tmp/workspaces/runtime-active-split-setup') + ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/runtime-active-split-setup') + vi.mocked(getEffectiveHooks).mockReturnValue({ scripts: { setup: 'pnpm install' } }) + vi.mocked(shouldRunSetupForCreate).mockReturnValue(true) + vi.mocked(createSetupRunnerScript).mockReturnValue({ + runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh', + envVars: { + ORCA_ROOT_PATH: '/tmp/repo', + ORCA_WORKTREE_PATH: '/tmp/workspaces/runtime-active-split-setup' + } + }) + vi.mocked(listWorktrees).mockResolvedValue([ + { + path: '/tmp/workspaces/runtime-active-split-setup', + head: 'def', + branch: 'runtime-active-split-setup', + isBare: false, + isMainWorktree: false + } + ]) + + const result = await runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'runtime-active-split-setup', + activate: true, + setupDecision: 'run', + createdWithAgent: 'claude', + startup: { command: 'claude' } + }) + + // No-regression guard: an explicit --activate still reveals the workspace. + await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2)) + await vi.waitFor(() => + expect(revealTerminalSession).toHaveBeenLastCalledWith( + result.worktree.id, + expect.objectContaining({ + ptyId: 'pty-active-split-setup', + splitDirection: 'horizontal' + }) + ) + ) + const splitRevealPayload = revealTerminalSession.mock.lastCall?.[1] as + | { surfaceOwner?: boolean } + | undefined + expect(splitRevealPayload).not.toHaveProperty('surfaceOwner') + }) + it('does not warn when setup is explicitly skipped for CLI-created worktrees', async () => { const metaById: Record = {} const runtimeStore = { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 161314dbb16..b972e482cc9 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -324,6 +324,7 @@ import { type RuntimeMobileSessionTabsRemovedResult, type RuntimeMobileSessionTabsResult, type RuntimeMobileSessionTabsSnapshot, + type RuntimeNativeChatLaunchDraftResolution, type RuntimeSessionTabCloseReason, type RuntimeBrowserDriverState, type RuntimeTerminalDriverState, @@ -929,6 +930,7 @@ import { registerTerminalViewAttributesApplier } from './terminal-view-attribute-store' import { killAllProcessesForWorktree, teardownRpcDeadline } from './worktree-teardown' +import { stopMissingWorktreeTerminals } from './missing-worktree-terminal-reconciliation' import { MobileNotificationReplayBuffer, type ReplayableMobileNotification @@ -1271,6 +1273,10 @@ type TerminalCreateOptions = { rendererBacked?: boolean activate?: boolean presentation?: RuntimeTerminalPresentation + // Why: `false` adopts the terminal without pointing the user at it — no + // sidebar reveal, no tab focus. Distinct from 'background' presentation, + // which skips renderer adoption entirely. + surfaceOwner?: false tabId?: string leafId?: string sessionId?: string @@ -1654,6 +1660,12 @@ function createTerminalRevealWarning(handle: string, error?: unknown): string { ].join(' ') } +// Why: an absent `surfaceOwner` means "default", so surfacing callers must omit +// the key rather than send `true`. +function ownerSurfacing(shouldSurface: boolean): { surfaceOwner?: false } { + return shouldSurface ? {} : { surfaceOwner: false } +} + function resolveTerminalPresentation(opts: { presentation?: RuntimeTerminalPresentation focus?: boolean @@ -1702,6 +1714,7 @@ type RuntimeNotifier = { viewMode?: 'terminal' | 'chat' activate?: boolean presentation?: RuntimeTerminalPresentation + surfaceOwner?: false tabId?: string leafId?: string splitFromLeafId?: string @@ -1777,6 +1790,10 @@ type RuntimeNotifier = { // and so a future write coordinator can use the same signal as scheduling // input. See docs/mobile-presence-lock.md. terminalDriverChanged(ptyId: string, driver: DriverState): void + nativeChatLaunchDraftResolved?( + tabId: string, + resolution: { text: string; createdAt: number } + ): void browserDriverChanged?(browserPageId: string, driver: RuntimeBrowserDriverState): void } @@ -2582,6 +2599,12 @@ type LayoutQueueEntry = { }[] } +type NativeChatLaunchDraftResolutionTombstone = RuntimeNativeChatLaunchDraftResolution & { + worktreeId: string +} + +const MAX_NATIVE_CHAT_LAUNCH_DRAFT_RESOLUTION_TOMBSTONES = 200 + async function hasLocalWorktreeBaseRef( repoPath: string, baseRef: string, @@ -2702,6 +2725,10 @@ export class OrcaRuntimeService { private ptyController: RuntimePtyController | null = null private notifier: RuntimeNotifier | null = null private clientEventListeners = new Set<(event: RuntimeClientEvent) => void>() + private nativeChatLaunchDraftResolutionByTabId = new Map< + string, + NativeChatLaunchDraftResolutionTombstone + >() private worktreeLifecycleListeners = new Set<(event: RuntimeWorktreeLifecycleEvent) => void>() private forkBackfillStarted = false private agentBrowserBridge: AgentBrowserBridge | null = null @@ -4705,12 +4732,146 @@ export class OrcaRuntimeService { return events } + getNativeChatLaunchDraftResolutionClientEventSnapshot(): Extract< + RuntimeClientEvent, + { type: 'nativeChatLaunchDraftResolved' } + >[] { + return [...this.nativeChatLaunchDraftResolutionByTabId.values()] + .sort((a, b) => a.tabId.localeCompare(b.tabId)) + .map(({ tabId, text, createdAt }) => ({ + type: 'nativeChatLaunchDraftResolved', + tabId, + text, + createdAt + })) + } + private emitClientEvent(event: RuntimeClientEvent): void { // Why: a throwing subscriber here once escaped acquireWorktreeTerminalSpawn after it took the // per-worktree terminal mutation, leaking it and wedging that worktree's sleep until restart. notifyRuntimeListeners(this.clientEventListeners, (listener) => listener(event), 'client-event') } + notifyNativeChatLaunchDraftResolved( + handle: string, + resolution: { text: string; createdAt: number } + ): void { + const owner = this.resolveNativeChatLaunchDraftOwner(handle) + if (!owner) { + return + } + const tombstone = { ...owner, ...resolution } + this.nativeChatLaunchDraftResolutionByTabId.delete(owner.tabId) + this.nativeChatLaunchDraftResolutionByTabId.set(owner.tabId, tombstone) + while ( + this.nativeChatLaunchDraftResolutionByTabId.size > + MAX_NATIVE_CHAT_LAUNCH_DRAFT_RESOLUTION_TOMBSTONES + ) { + const oldestTabId = this.nativeChatLaunchDraftResolutionByTabId.keys().next().value + if (typeof oldestTabId !== 'string') { + break + } + this.nativeChatLaunchDraftResolutionByTabId.delete(oldestTabId) + } + this.retireResolvedNativeChatLaunchDraftFromMobileSnapshot(tombstone) + this.notifier?.nativeChatLaunchDraftResolved?.(owner.tabId, resolution) + this.emitClientEvent({ + type: 'nativeChatLaunchDraftResolved', + tabId: owner.tabId, + ...resolution + }) + } + + private resolveNativeChatLaunchDraftOwner( + handle: string + ): { tabId: string; worktreeId: string } | null { + const record = this.handles.get(handle) + if (!record) { + return null + } + if (!record.tabId.startsWith('pty:')) { + return { tabId: record.tabId, worktreeId: record.worktreeId } + } + const pty = record.ptyId ? this.ptysById.get(record.ptyId) : null + const tabId = + pty?.tabId && !pty.tabId.startsWith('pty:') + ? pty.tabId + : parsePaneKey(pty?.paneKey ?? '')?.tabId + if (!pty || !tabId || tabId.startsWith('pty:')) { + return null + } + return { tabId, worktreeId: pty.worktreeId } + } + + private retireResolvedNativeChatLaunchDraftFromMobileSnapshot( + resolution: NativeChatLaunchDraftResolutionTombstone + ): void { + for (const [worktreeId, snapshot] of this.mobileSessionTabsByWorktree) { + if (!runtimeWorktreeIdsEqual(worktreeId, resolution.worktreeId)) { + continue + } + const next = this.applyNativeChatLaunchDraftResolutionFence(snapshot) + if (next === snapshot) { + return + } + this.mobileSessionTabsByWorktree.set(worktreeId, { + ...next, + snapshotVersion: snapshot.snapshotVersion + 1 + }) + this.mobileSessionTabsNotifyCoalescer.schedule(worktreeId) + return + } + } + + private applyNativeChatLaunchDraftResolutionFence( + snapshot: RuntimeMobileSessionTabsSnapshot + ): RuntimeMobileSessionTabsSnapshot { + let changed = false + const tabs = snapshot.tabs.map((tab) => { + if (tab.type !== 'terminal') { + return tab + } + const resolution = this.nativeChatLaunchDraftResolutionByTabId.get(tab.parentTabId) + if ( + !resolution || + !runtimeWorktreeIdsEqual(snapshot.worktree, resolution.worktreeId) || + tab.launchDraft !== resolution.text || + tab.launchDraftCreatedAt !== resolution.createdAt + ) { + return tab + } + changed = true + const next = { ...tab } + delete next.launchDraft + delete next.launchDraftCreatedAt + return next + }) + return changed ? { ...snapshot, tabs } : snapshot + } + + private reconcileNativeChatLaunchDraftResolutionTombstones( + snapshot: RuntimeMobileSessionTabsSnapshot + ): void { + for (const [tabId, resolution] of this.nativeChatLaunchDraftResolutionByTabId) { + if (!runtimeWorktreeIdsEqual(snapshot.worktree, resolution.worktreeId)) { + continue + } + const surfaces = snapshot.tabs.filter( + (tab): tab is RuntimeMobileSessionTerminalTab => + tab.type === 'terminal' && tab.parentTabId === tabId + ) + if ( + surfaces.length === 0 || + !surfaces.some( + (tab) => + tab.launchDraft === resolution.text && tab.launchDraftCreatedAt === resolution.createdAt + ) + ) { + this.nativeChatLaunchDraftResolutionByTabId.delete(tabId) + } + } + } + private notifyWorktreesChanged(repoId: string): void { this.notifier?.worktreesChanged(repoId) this.emitClientEvent({ type: 'worktreesChanged', repoId }) @@ -5174,9 +5335,14 @@ export class OrcaRuntimeService { } const agentOrchestrationByPaneKey = this.buildAgentOrchestrationByPaneKey() + const nativeChatLaunchDraftResolutions = + this.getNativeChatLaunchDraftResolutionClientEventSnapshot().map( + ({ tabId, text, createdAt }) => ({ tabId, text, createdAt }) + ) return { ...this.getStatus(), - ...(agentOrchestrationByPaneKey ? { agentOrchestrationByPaneKey } : {}) + ...(agentOrchestrationByPaneKey ? { agentOrchestrationByPaneKey } : {}), + ...(nativeChatLaunchDraftResolutions.length > 0 ? { nativeChatLaunchDraftResolutions } : {}) } } @@ -19425,8 +19591,18 @@ export class OrcaRuntimeService { } } - async listDetectedManagedWorktrees(repoSelector: string): Promise { - const repo = await this.resolveRepoSelector(repoSelector) + async listDetectedManagedWorktrees( + repoSelector: string, + connectionId?: string | null + ): Promise { + return this.listDetectedWorktreesForResolvedRepo( + await this.resolveRepoSelectorForConnection(repoSelector, connectionId) + ) + } + + private async listDetectedWorktreesForResolvedRepo( + repo: Repo + ): Promise { const store = this.requireStore() if (isFolderRepo(repo)) { const worktrees = listRuntimeFolderWorkspaces(store, repo) @@ -19476,6 +19652,56 @@ export class OrcaRuntimeService { } } + async teardownMissingManagedWorktreeTerminals( + repoSelector: string, + knownWorktreeIds: readonly string[], + connectionId?: string | null + ): Promise<{ stoppedWorktreeIds: string[] }> { + const repo = await this.resolveRepoSelectorForConnection(repoSelector, connectionId) + // Why: killing PTYs must be proven against the host right now — a cached scan + // (30s TTL) can still list a directory git already dropped, and the renderer + // purges its state either way, so a stale miss strands those processes for good. + this.invalidateWorktreeScanCacheForRepo(repo.id) + // Why: rescanning by `id:` would re-resolve the already-resolved repo, and a + // duplicate id across hosts makes that second lookup throw selector_ambiguous + // even though the caller's selector was unique — losing the sweep entirely. + const detected = await this.listDetectedWorktreesForResolvedRepo(repo) + if (!detected.authoritative) { + return { stoppedWorktreeIds: [] } + } + return stopMissingWorktreeTerminals( + repo, + knownWorktreeIds, + detected.worktrees.map((worktree) => worktree.id), + { + runtime: this, + getLocalProvider: () => this.getLocalProvider(), + getSshProvider: (connectionId) => this.getSshProviderFn?.(connectionId), + onPtyStopped: this.onPtyStopped ?? undefined + } + ) + } + + private resolveRepoSelectorForConnection( + repoSelector: string, + connectionId?: string | null + ): Promise { + if (connectionId === undefined) { + return this.resolveRepoSelector(repoSelector) + } + // Why: an explicit connection identity only *narrows* the selector; it must not + // change the grammar. Matching the selector as a bare repo id would make + // `path:`/`name:` selectors resolve to repo_not_found on this path alone. + const wanted = connectionId?.trim() || null + const matches = this.selectReposBySelector(repoSelector).filter( + (repo) => (repo.connectionId?.trim() || null) === wanted + ) + if (matches.length !== 1) { + throw new Error(matches.length > 1 ? 'selector_ambiguous' : 'repo_not_found') + } + return Promise.resolve(matches[0]) + } + private isRuntimeWorktreeVisible( worktree: Worktree, agentScratchWorktreePathMatcher?: AgentScratchWorktreePathMatcher @@ -19942,7 +20168,8 @@ export class OrcaRuntimeService { private async createDefaultTabTerminals( worktreeSelector: string, worktreeId: string, - defaultTabs: CreateWorktreeResult['defaultTabs'] | undefined + defaultTabs: CreateWorktreeResult['defaultTabs'] | undefined, + surfacing: { surfaceOwner?: false } = {} ): Promise { if (!defaultTabs || defaultTabs.tabs.length === 0 || !this.ptyController?.spawn) { return [] @@ -19953,7 +20180,8 @@ export class OrcaRuntimeService { const command = template.command?.trim() const terminal = await this.createTerminal(worktreeSelector, { ...(template.title ? { title: template.title } : {}), - ...(command && defaultTabs.runCommands ? { command } : {}) + ...(command && defaultTabs.runCommands ? { command } : {}), + ...surfacing }) handles.push(terminal.handle) if (template.color && terminal.tabId) { @@ -19984,17 +20212,22 @@ export class OrcaRuntimeService { // the setup command. Pass that wrapped command through so the Setup tab runs // the same script the agent is waiting on instead of a bare runner. wrappedSetupCommand?: string + // Why: a workspace provisioned in the background must not pull the sidebar + // to itself; the user never asked to look at these tabs. + surfaceOwner?: false }): Promise<{ setupSpawned: boolean; setupTerminalHandle: string | null }> { if (!this.ptyController?.spawn) { return { setupSpawned: false, setupTerminalHandle: null } } + const surfacing = ownerSurfacing(args.surfaceOwner !== false) let setupSpawned = false let setupTerminalHandle: string | null = null try { const defaultTabHandles = await this.createDefaultTabTerminals( args.worktreeSelector, args.worktreeId, - args.defaultTabs + args.defaultTabs, + surfacing ) let primaryTerminalHandle = args.primaryTerminalHandle ?? defaultTabHandles[0] ?? null const setupLaunchMode = @@ -20004,7 +20237,7 @@ export class OrcaRuntimeService { > ).setupScriptLaunchMode ?? 'new-tab' if (!args.hasStartupTerminal && !primaryTerminalHandle) { - const terminal = await this.createTerminal(args.worktreeSelector) + const terminal = await this.createTerminal(args.worktreeSelector, surfacing) primaryTerminalHandle = terminal.handle } if (args.setup) { @@ -20030,12 +20263,14 @@ export class OrcaRuntimeService { direction: setupLaunchMode === 'split-horizontal' ? 'horizontal' : 'vertical', command: setupCommand, env: setupEnv, - activate: false + activate: false, + ...surfacing }) : this.createTerminal(args.worktreeSelector, { title: 'Setup', command: setupCommand, - env: setupEnv + env: setupEnv, + ...surfacing })) setupTerminalHandle = setupTerminal.handle setupSpawned = true @@ -20314,7 +20549,8 @@ export class OrcaRuntimeService { ...(effectiveCreatedWithAgent ? { launchAgent: effectiveCreatedWithAgent } : {}), ...(effectiveStartup.viewMode ? { viewMode: effectiveStartup.viewMode } : {}), startupCommandDelivery: effectiveStartup.startupCommandDelivery, - telemetry: effectiveStartup.telemetry + telemetry: effectiveStartup.telemetry, + ...ownerSurfacing(shouldActivate) }) if (effectiveDraftPaste) { this.pasteStartupDraftWhenReady(terminal.handle, effectiveDraftPaste) @@ -20345,7 +20581,7 @@ export class OrcaRuntimeService { } } else if (this.ptyController?.spawn && !didSpawnStartup) { try { - await this.createTerminal(`id:${worktree.id}`) + await this.createTerminal(`id:${worktree.id}`, { surfaceOwner: false }) } catch (err) { const message = err instanceof Error ? err.message : String(err) warning = warning @@ -21054,7 +21290,8 @@ export class OrcaRuntimeService { ...(effectiveCreatedWithAgent ? { launchAgent: effectiveCreatedWithAgent } : {}), ...(sequencedStartup.viewMode ? { viewMode: sequencedStartup.viewMode } : {}), startupCommandDelivery: sequencedStartup.startupCommandDelivery, - telemetry: sequencedStartup.telemetry + telemetry: sequencedStartup.telemetry, + ...ownerSurfacing(shouldActivate) }) if (effectiveDraftPaste) { this.pasteStartupDraftWhenReady(terminal.handle, effectiveDraftPaste) @@ -21155,7 +21392,8 @@ export class OrcaRuntimeService { : 'posix' : 'posix', observeSetupCompletion: args.observeSetupCompletion, - ...(wrappedSetupCommandStr ? { wrappedSetupCommand: wrappedSetupCommandStr } : {}) + ...(wrappedSetupCommandStr ? { wrappedSetupCommand: wrappedSetupCommandStr } : {}), + surfaceOwner: false }) // Why: runtime owns setup spawning here, so the RPC result must omit setup // to keep the headless/mobile caller from launching it a second time. @@ -21171,7 +21409,7 @@ export class OrcaRuntimeService { } } else if (this.ptyController?.spawn) { try { - await this.createTerminal(`id:${worktree.id}`) + await this.createTerminal(`id:${worktree.id}`, { surfaceOwner: false }) } catch (err) { const message = err instanceof Error ? err.message : String(err) warning = warning @@ -21355,6 +21593,7 @@ export class OrcaRuntimeService { this.invalidateWorktreeScanCacheForRepo(repo.id) this.notifyWorktreesChanged(repo.id) + const shouldActivate = args.activate === true || args.runHooks === true let warning = result.warning let didSpawnStartup = false // Why: same no-double-spawn contract as the local path — once runtime @@ -21403,7 +21642,8 @@ export class OrcaRuntimeService { ...(args.createdWithAgent ? { launchAgent: args.createdWithAgent } : {}), ...(sequencedStartup.viewMode ? { viewMode: sequencedStartup.viewMode } : {}), startupCommandDelivery: sequencedStartup.startupCommandDelivery, - telemetry: sequencedStartup.telemetry + telemetry: sequencedStartup.telemetry, + ...ownerSurfacing(shouldActivate) }) if (args.startupDraftPaste) { this.pasteStartupDraftWhenReady(terminal.handle, args.startupDraftPaste) @@ -21424,7 +21664,6 @@ export class OrcaRuntimeService { } } - const shouldActivate = args.activate === true || args.runHooks === true if (shouldActivate) { const runtimeWillProvisionTerminals = didSpawnStartup && Boolean(result.setup || result.defaultTabs) @@ -21506,7 +21745,8 @@ export class OrcaRuntimeService { : 'posix' : 'posix', observeSetupCompletion: args.observeSetupCompletion, - ...(wrappedSetupCommandStr ? { wrappedSetupCommand: wrappedSetupCommandStr } : {}) + ...(wrappedSetupCommandStr ? { wrappedSetupCommand: wrappedSetupCommandStr } : {}), + surfaceOwner: false }) // Why: runtime owns setup spawning here, so omit setup from the RPC result // to keep the headless/mobile caller from launching it a second time. @@ -21522,7 +21762,7 @@ export class OrcaRuntimeService { } } else if (!shouldActivate && this.ptyController?.spawn) { try { - await this.createTerminal(`path:${result.worktree.path}`) + await this.createTerminal(`path:${result.worktree.path}`, { surfaceOwner: false }) } catch (err) { const message = err instanceof Error ? err.message : String(err) warning = warning @@ -23948,6 +24188,7 @@ export class OrcaRuntimeService { ...(launchOpts.viewMode ? { viewMode: launchOpts.viewMode } : {}), activate: presentation === 'focused', ...(presentation ? { presentation } : {}), + ...ownerSurfacing(opts.surfaceOwner !== false), tabId, leafId }) @@ -24032,7 +24273,8 @@ export class OrcaRuntimeService { startupCommandDelivery: launchOpts.startupCommandDelivery, title: launchOpts.title, activate: presentation === 'focused', - ...(presentation ? { presentation } : {}) + ...(presentation ? { presentation } : {}), + ...ownerSurfacing(opts.surfaceOwner !== false) }) }) @@ -25172,6 +25414,9 @@ export class OrcaRuntimeService { env?: Record envToDelete?: string[] activate?: boolean + // Why: same split as createTerminal — adopt the pane without revealing its + // workspace, for splits the user never asked to see. + surfaceOwner?: false telemetrySource?: TerminalPaneSplitSource } = {} ): Promise { @@ -25209,6 +25454,9 @@ export class OrcaRuntimeService { env?: Record envToDelete?: string[] activate?: boolean + // Why: same split as createTerminal — adopt the pane without revealing its + // workspace, for splits the user never asked to see. + surfaceOwner?: false telemetrySource?: TerminalPaneSplitSource } = {} ): Promise { @@ -25259,6 +25507,7 @@ export class OrcaRuntimeService { ptyId: result.id, title: null, activate: opts.activate !== false, + ...ownerSurfacing(opts.surfaceOwner !== false), tabId: parentTabId, leafId, splitFromLeafId: parsedPaneKey.leafId, @@ -26684,27 +26933,32 @@ export class OrcaRuntimeService { return this.store?.getAllWorkspaceLineage?.() ?? {} } + // Why: one selector grammar, so connection-scoped resolution can narrow the same + // candidate set instead of reimplementing (and diverging from) the matching rules. + private selectReposBySelector(selector: string): Repo[] { + const repos = this.store?.getRepos() ?? [] + if (selector.startsWith('id:')) { + return repos.filter((repo) => repo.id === selector.slice(3)) + } + if (selector.startsWith('path:')) { + return repos.filter((repo) => runtimePathsEqual(repo.path, selector.slice(5))) + } + if (selector.startsWith('name:')) { + return repos.filter((repo) => repo.displayName === selector.slice(5)) + } + return repos.filter( + (repo) => + repo.id === selector || + runtimePathsEqual(repo.path, selector) || + repo.displayName === selector + ) + } + private async resolveRepoSelector(selector: string): Promise { if (!this.store) { throw new Error('repo_not_found') } - const repos = this.store.getRepos() - let candidates: Repo[] - - if (selector.startsWith('id:')) { - candidates = repos.filter((repo) => repo.id === selector.slice(3)) - } else if (selector.startsWith('path:')) { - candidates = repos.filter((repo) => runtimePathsEqual(repo.path, selector.slice(5))) - } else if (selector.startsWith('name:')) { - candidates = repos.filter((repo) => repo.displayName === selector.slice(5)) - } else { - candidates = repos.filter( - (repo) => - repo.id === selector || - runtimePathsEqual(repo.path, selector) || - repo.displayName === selector - ) - } + const candidates = this.selectReposBySelector(selector) if (candidates.length === 1) { return candidates[0] @@ -27790,7 +28044,9 @@ export class OrcaRuntimeService { ) { continue } - const fencedSnapshot = this.applyMobileSessionRetirementFences(snapshot) + this.reconcileNativeChatLaunchDraftResolutionTombstones(snapshot) + const launchDraftFencedSnapshot = this.applyNativeChatLaunchDraftResolutionFence(snapshot) + const fencedSnapshot = this.applyMobileSessionRetirementFences(launchDraftFencedSnapshot) const nextSnapshot = this.mergePreservedHeadlessMobileSessionTabs(fencedSnapshot, existing) // Why: clients drop same-epoch frames whose version isn't strictly newer, // and main-local touches may already have emitted a higher version than @@ -28405,6 +28661,9 @@ export class OrcaRuntimeService { ...(tab.isPinned ? { isPinned: true } : {}), ...(tab.viewMode ? { viewMode: tab.viewMode } : {}), ...(tab.launchDraft ? { launchDraft: tab.launchDraft } : {}), + ...(tab.launchDraftCreatedAt !== undefined + ? { launchDraftCreatedAt: tab.launchDraftCreatedAt } + : {}), isActive: tab.isActive, ...(terminalHandle ? { status: 'ready' as const, terminal: terminalHandle } diff --git a/src/main/runtime/orchestration/db.ts b/src/main/runtime/orchestration/db.ts index a80ed4ac780..145aa862ee1 100644 --- a/src/main/runtime/orchestration/db.ts +++ b/src/main/runtime/orchestration/db.ts @@ -42,6 +42,7 @@ import { ORCHESTRATION_LEGACY_RUN_ID } from '../../../shared/orchestration-rpc-c import { parsePaneKey } from '../../../shared/stable-pane-id' import { OrchestrationError } from './orchestration-error' import { resolveOrchestrationMigrationStartVersion } from './orchestration-schema-version-skew' +import { ORCHESTRATION_RUN_PAGE_LIMIT } from '../../../shared/orchestration-run-pagination' import { ORCHESTRATION_CONTRACT_VERSION } from '../../../shared/protocol-version' // Why: leaf UUID is the remint-stable pane identity (tab half changes on break-out); exact match covers legacy/unparseable keys. @@ -158,6 +159,28 @@ function exposeRunTimestamps(run: RunRow): RunRow { } } +function encodeRunListCursor(run: RunRow): string { + const cursor: RunListCursor = { createdAt: run.created_at, id: run.id } + return Buffer.from(JSON.stringify(cursor)).toString('base64url') +} + +function decodeRunListCursor(value: string): RunListCursor { + try { + const parsed: unknown = JSON.parse(Buffer.from(value, 'base64url').toString('utf8')) + if ( + !parsed || + typeof parsed !== 'object' || + typeof (parsed as RunListCursor).createdAt !== 'string' || + typeof (parsed as RunListCursor).id !== 'string' + ) { + throw new Error('invalid cursor shape') + } + return parsed as RunListCursor + } catch { + throw new OrchestrationError('cursor_invalid', 'The Run list cursor is invalid.') + } +} + function exposeDeliveryTimestamps(delivery: DeliveryRow): DeliveryRow { return { ...delivery, @@ -213,8 +236,21 @@ export const LEGACY_RUN_ID = ORCHESTRATION_LEGACY_RUN_ID export const LEGACY_CONTRACT_VERSION = 0 export const CURRENT_CONTRACT_VERSION = ORCHESTRATION_CONTRACT_VERSION -// Schema versions: v2 'heartbeat'+last_heartbeat_at, v3 delivered_at, v4 task-creator terminal, v5 task_title/display_name, v6 pane identity, v7 lightweight Runs, v8 crash-safe Run deliveries, v9 durable question threads, v10 Dispatch capabilities, v11 durable mutation receipts, v12 composed worker state, v18 post-v6 version-skew repair, v19 adopted legacy Runs and compatibility receipts, v20 legacy question backfill, v21 legacy scheduler-loss provenance. -const SCHEMA_VERSION = 21 +const MUTATION_RECEIPT_MAX_ROWS = 10_000 +const MUTATION_RECEIPT_MAX_AGE_DAYS = 30 + +export type RunListPage = { + runs: RunRow[] + nextCursor: string | null +} + +type RunListCursor = { + createdAt: string + id: string +} + +// Schema versions: v2 'heartbeat'+last_heartbeat_at, v3 delivered_at, v4 task-creator terminal, v5 task_title/display_name, v6 pane identity, v7 lightweight Runs, v8 crash-safe Run deliveries, v9 durable question threads, v10 Dispatch capabilities, v11 durable mutation receipts, v12 composed worker state, v18 post-v6 version-skew repair, v19 adopted legacy Runs and compatibility receipts, v20 legacy question backfill, v21 legacy scheduler-loss provenance, v22 dispatch assignee lookup. +const SCHEMA_VERSION = 22 function hardenOrchestrationDatabaseFiles(dbPath: string | ':memory:'): void { if (dbPath === ':memory:' || process.platform === 'win32') { @@ -457,6 +493,7 @@ export class OrchestrationDb { CREATE INDEX IF NOT EXISTS idx_dispatch_task ON dispatch_contexts(task_id); CREATE INDEX IF NOT EXISTS idx_dispatch_status ON dispatch_contexts(status); + CREATE INDEX IF NOT EXISTS idx_dispatch_assignee_handle ON dispatch_contexts(assignee_handle); CREATE TABLE IF NOT EXISTS decision_gates ( id TEXT PRIMARY KEY, @@ -835,6 +872,12 @@ export class OrchestrationDb { if (current < 21) { this.migrateLegacySchedulerLossProvenance() } + if (current < 22) { + this.db.exec(` + CREATE INDEX IF NOT EXISTS idx_dispatch_assignee_handle + ON dispatch_contexts(assignee_handle); + `) + } this.createUndeliveredInboxIndexIfPossible() this.db.pragma(`user_version = ${SCHEMA_VERSION}`) @@ -1245,6 +1288,44 @@ export class OrchestrationDb { // ── Durable mutation receipts ── + private ensureMutationReceiptCapacity(): void { + this.db + .prepare( + `DELETE FROM mutation_receipts + WHERE state = 'completed' + AND updated_at < datetime('now', ?)` + ) + .run(`-${MUTATION_RECEIPT_MAX_AGE_DAYS} days`) + + const row = this.db.prepare('SELECT COUNT(*) AS count FROM mutation_receipts').get() as { + count: number + } + const completedToRemove = row.count - MUTATION_RECEIPT_MAX_ROWS + 1 + if (completedToRemove > 0) { + this.db + .prepare( + `DELETE FROM mutation_receipts + WHERE rowid IN ( + SELECT rowid FROM mutation_receipts + WHERE state = 'completed' + ORDER BY updated_at ASC, rowid ASC + LIMIT ? + )` + ) + .run(completedToRemove) + } + + const retained = this.db.prepare('SELECT COUNT(*) AS count FROM mutation_receipts').get() as { + count: number + } + if (retained.count >= MUTATION_RECEIPT_MAX_ROWS) { + throw new OrchestrationError( + 'mutation_ledger_full', + 'The durable mutation ledger is full of unresolved operations. Resolve or inspect them before starting another mutation.' + ) + } + } + beginMutationReceipt(params: { callerFingerprint: string requestId: string @@ -1267,6 +1348,7 @@ export class OrchestrationDb { this.db.exec('COMMIT') return { disposition: existing.state, row: existing } } + this.ensureMutationReceiptCapacity() this.db .prepare( `INSERT INTO mutation_receipts ( @@ -2221,10 +2303,38 @@ export class OrchestrationDb { return run ? exposeRunTimestamps(run) : undefined } - listRuns(): RunRow[] { - return (this.db.prepare('SELECT * FROM runs ORDER BY created_at DESC').all() as RunRow[]).map( - exposeRunTimestamps + listRuns(params: { limit?: number; cursor?: string } = {}): RunListPage { + if (params.limit === undefined && params.cursor === undefined) { + const rows = this.db + .prepare('SELECT * FROM runs ORDER BY created_at DESC, id DESC') + .all() as RunRow[] + return { runs: rows.map(exposeRunTimestamps), nextCursor: null } + } + const limit = Math.min( + Math.max(1, params.limit ?? ORCHESTRATION_RUN_PAGE_LIMIT), + ORCHESTRATION_RUN_PAGE_LIMIT ) + const cursor = params.cursor ? decodeRunListCursor(params.cursor) : undefined + const rows = ( + cursor + ? this.db + .prepare( + `SELECT * FROM runs + WHERE created_at < ? OR (created_at = ? AND id < ?) + ORDER BY created_at DESC, id DESC + LIMIT ?` + ) + .all(cursor.createdAt, cursor.createdAt, cursor.id, limit + 1) + : this.db + .prepare('SELECT * FROM runs ORDER BY created_at DESC, id DESC LIMIT ?') + .all(limit + 1) + ) as RunRow[] + const hasMore = rows.length > limit + const pageRows = hasMore ? rows.slice(0, limit) : rows + return { + runs: pageRows.map(exposeRunTimestamps), + nextCursor: hasMore ? encodeRunListCursor(pageRows.at(-1) as RunRow) : null + } } getCurrentRunForPane(paneKey: string): RunRow | undefined { @@ -3748,6 +3858,7 @@ export class OrchestrationDb { `Mutation ${receipt.requestId} already has a durable acceptance record.` ) } + this.ensureMutationReceiptCapacity() this.db .prepare( `INSERT INTO mutation_receipts ( @@ -4335,6 +4446,7 @@ export class OrchestrationDb { `Remote attachment request ${params.mutationReceipt.requestId} already exists.` ) } + this.ensureMutationReceiptCapacity() this.db .prepare( `INSERT INTO mutation_receipts ( diff --git a/src/main/runtime/orchestration/orchestration-db-retention-pagination.test.ts b/src/main/runtime/orchestration/orchestration-db-retention-pagination.test.ts new file mode 100644 index 00000000000..914321e0646 --- /dev/null +++ b/src/main/runtime/orchestration/orchestration-db-retention-pagination.test.ts @@ -0,0 +1,260 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import Database from '../../sqlite/sync-database' +import { OrchestrationDb } from './db' + +const MUTATION_RECEIPT_MAX_ROWS = 10_000 + +function sqliteFor(db: OrchestrationDb): Database.Database { + return (db as unknown as { db: Database.Database }).db +} + +function insertMutationReceipts( + db: OrchestrationDb, + count: number, + state: 'pending' | 'completed' +): void { + sqliteFor(db) + .prepare( + `WITH RECURSIVE receipt_numbers(value) AS ( + VALUES (1) + UNION ALL + SELECT value + 1 FROM receipt_numbers WHERE value < ? + ) + INSERT INTO mutation_receipts ( + caller_fingerprint, request_id, method, payload_hash, state + ) + SELECT 'caller', printf('request_%05d', value), 'orchestration.send', + printf('hash_%05d', value), ? + FROM receipt_numbers` + ) + .run(count, state) +} + +describe('OrchestrationDb bounded mutation receipts', () => { + let db: OrchestrationDb | undefined + + afterEach(() => db?.close()) + + it('prunes expired completed receipts but preserves unresolved receipts', () => { + db = new OrchestrationDb(':memory:') + const sqlite = sqliteFor(db) + sqlite.exec(` + INSERT INTO mutation_receipts ( + caller_fingerprint, request_id, method, payload_hash, state, updated_at + ) VALUES + ('caller', 'expired', 'orchestration.send', 'hash_expired', 'completed', '2000-01-01 00:00:00'), + ('caller', 'unresolved', 'orchestration.send', 'hash_unresolved', 'pending', '2000-01-01 00:00:00'); + `) + + db.beginMutationReceipt({ + callerFingerprint: 'caller', + requestId: 'new', + method: 'orchestration.send', + payloadHash: 'hash_new' + }) + + expect(db.getMutationReceipt('caller', 'expired')).toBeUndefined() + expect(db.getMutationReceipt('caller', 'unresolved')).toMatchObject({ state: 'pending' }) + }) + + it('caps completed receipt count while retaining the newest replay records', () => { + db = new OrchestrationDb(':memory:') + insertMutationReceipts(db, MUTATION_RECEIPT_MAX_ROWS, 'completed') + + db.beginMutationReceipt({ + callerFingerprint: 'caller', + requestId: 'new', + method: 'orchestration.send', + payloadHash: 'hash_new' + }) + + const count = sqliteFor(db) + .prepare('SELECT COUNT(*) AS count FROM mutation_receipts') + .get() as { count: number } + expect(count.count).toBe(MUTATION_RECEIPT_MAX_ROWS) + expect(db.getMutationReceipt('caller', 'request_00001')).toBeUndefined() + expect(db.getMutationReceipt('caller', 'request_10000')).toMatchObject({ state: 'completed' }) + expect(db.getMutationReceipt('caller', 'new')).toMatchObject({ state: 'pending' }) + }) + + it('fails closed when unresolved receipts alone fill the ledger', () => { + db = new OrchestrationDb(':memory:') + insertMutationReceipts(db, MUTATION_RECEIPT_MAX_ROWS, 'pending') + + expect(() => + db!.beginMutationReceipt({ + callerFingerprint: 'caller', + requestId: 'overflow', + method: 'orchestration.send', + payloadHash: 'hash_overflow' + }) + ).toThrowError(expect.objectContaining({ code: 'mutation_ledger_full' })) + expect(db.getMutationReceipt('caller', 'request_00001')).toMatchObject({ state: 'pending' }) + expect(db.getMutationReceipt('caller', 'overflow')).toBeUndefined() + }) + + it('prunes completed receipts before accepting a remote attachment', () => { + db = new OrchestrationDb(':memory:') + insertMutationReceipts(db, MUTATION_RECEIPT_MAX_ROWS, 'completed') + + db.createRemoteDispatchAttachment({ + dispatchId: 'ctx_remote_pruned', + taskId: 'task_remote_pruned', + homePeerFingerprint: 'caller', + protocolVersion: 1, + runtimeEpoch: 'worker_epoch', + mutationReceipt: { + callerFingerprint: 'caller', + requestId: 'remote_pruned', + method: 'orchestration.federationAttachStart', + payloadHash: 'hash_remote_pruned' + } + }) + + const count = sqliteFor(db) + .prepare('SELECT COUNT(*) AS count FROM mutation_receipts') + .get() as { count: number } + expect(count.count).toBe(MUTATION_RECEIPT_MAX_ROWS) + expect(db.getMutationReceipt('caller', 'request_00001')).toBeUndefined() + expect(db.getMutationReceipt('caller', 'remote_pruned')).toMatchObject({ state: 'pending' }) + expect(db.getRemoteDispatchAttachment('ctx_remote_pruned')).toBeDefined() + }) + + it('rejects a remote attachment when pending receipts fill the ledger', () => { + db = new OrchestrationDb(':memory:') + insertMutationReceipts(db, MUTATION_RECEIPT_MAX_ROWS, 'pending') + + expect(() => + db!.createRemoteDispatchAttachment({ + dispatchId: 'ctx_remote_overflow', + taskId: 'task_remote_overflow', + homePeerFingerprint: 'caller', + protocolVersion: 1, + runtimeEpoch: 'worker_epoch', + mutationReceipt: { + callerFingerprint: 'caller', + requestId: 'remote_overflow', + method: 'orchestration.federationAttachStart', + payloadHash: 'hash_remote_overflow' + } + }) + ).toThrowError(expect.objectContaining({ code: 'mutation_ledger_full' })) + expect(db.getMutationReceipt('caller', 'request_00001')).toMatchObject({ state: 'pending' }) + expect(db.getMutationReceipt('caller', 'remote_overflow')).toBeUndefined() + expect(db.getRemoteDispatchAttachment('ctx_remote_overflow')).toBeUndefined() + }) + + it('guards atomic worker acceptance without changing task state', () => { + db = new OrchestrationDb(':memory:') + const task = db.createTask({ spec: 'capacity check' }) + insertMutationReceipts(db, MUTATION_RECEIPT_MAX_ROWS, 'pending') + + expect(() => + db!.createStartingWorkerDispatch({ + taskId: task.id, + startOptions: {}, + mutationReceipt: { + callerFingerprint: 'caller', + requestId: 'worker_overflow', + method: 'orchestration.workerStart', + payloadHash: 'hash_worker_overflow' + } + }) + ).toThrowError(expect.objectContaining({ code: 'mutation_ledger_full' })) + expect(db.getTask(task.id)).toMatchObject({ status: 'ready' }) + expect(db.getMutationReceipt('caller', 'worker_overflow')).toBeUndefined() + }) +}) + +describe('OrchestrationDb Run pagination', () => { + let db: OrchestrationDb | undefined + + afterEach(() => db?.close()) + + it('returns stable bounded pages without skipping Runs sharing a timestamp', () => { + db = new OrchestrationDb(':memory:') + const createdIds = Array.from({ length: 5 }, (_, index) => `run_page_${index}`) + const insertRun = sqliteFor(db).prepare( + `INSERT INTO runs ( + id, objective, coordinator_handle, coordinator_pane_key, + consumer_generation, legacy, created_at + ) VALUES (?, ?, ?, ?, 1, 0, '2025-01-01 00:00:00')` + ) + for (const [index, id] of createdIds.entries()) { + insertRun.run( + id, + `Run ${index}`, + `term_coord_${index}`, + `tab_coord_${index}:11111111-1111-4111-8111-111111111111` + ) + } + const seen: string[] = [] + let cursor: string | undefined + + do { + const page = db.listRuns({ limit: 2, cursor }) + expect(page.runs.length).toBeLessThanOrEqual(2) + expect(page.runs.every((run) => !seen.includes(run.id))).toBe(true) + seen.push(...page.runs.map((run) => run.id)) + cursor = page.nextCursor ?? undefined + } while (cursor) + + expect(seen).toEqual(expect.arrayContaining(createdIds)) + expect(new Set(seen).size).toBe(createdIds.length + 1) + }) +}) + +describe('OrchestrationDb dispatch assignee index migration', () => { + let db: OrchestrationDb | undefined + let tempDir: string | undefined + + afterEach(() => { + db?.close() + if (tempDir) { + rmSync(tempDir, { recursive: true, force: true }) + } + }) + + it('migrates a populated v21 database idempotently', () => { + tempDir = mkdtempSync(join(tmpdir(), 'orca-dispatch-index-migration-')) + const dbPath = join(tempDir, 'orchestration.db') + db = new OrchestrationDb(dbPath) + const task = db.createTask({ spec: 'indexed lookup' }) + const dispatch = db.createDispatchContext(task.id, 'term_worker') + db.close() + db = undefined + + const oldDb = new Database(dbPath) + oldDb.exec('DROP INDEX IF EXISTS idx_dispatch_assignee_handle') + oldDb.pragma('user_version = 21') + oldDb.close() + + db = new OrchestrationDb(dbPath) + const sqlite = sqliteFor(db) + expect(sqlite.pragma('user_version', { simple: true })).toBe(22) + expect(db.getDispatchContextById(dispatch.id)).toMatchObject({ assignee_handle: 'term_worker' }) + expect( + sqlite + .prepare("SELECT name FROM sqlite_master WHERE type = 'index' AND name = ?") + .get('idx_dispatch_assignee_handle') + ).toBeDefined() + const plan = sqlite + .prepare( + `EXPLAIN QUERY PLAN + SELECT * FROM dispatch_contexts + WHERE assignee_handle = ? AND status IN ('pending', 'dispatched') LIMIT 1` + ) + .all('term_worker') as { detail: string }[] + expect(plan.map((row) => row.detail).join('\n')).toContain( + 'USING INDEX idx_dispatch_assignee_handle' + ) + + db.close() + db = new OrchestrationDb(dbPath) + expect(sqliteFor(db).pragma('user_version', { simple: true })).toBe(22) + expect(db.getDispatchContextById(dispatch.id)).toBeDefined() + }) +}) diff --git a/src/main/runtime/orchestration/orchestration-legacy-storage-db.test.ts b/src/main/runtime/orchestration/orchestration-legacy-storage-db.test.ts index 91f8fcacf39..b6889d20c4e 100644 --- a/src/main/runtime/orchestration/orchestration-legacy-storage-db.test.ts +++ b/src/main/runtime/orchestration/orchestration-legacy-storage-db.test.ts @@ -151,7 +151,7 @@ describe('OrchestrationDb legacy contract storage', () => { expect(db.getLegacyAdoption()).toBeUndefined() expect(db.listLegacyCompatibilityPrincipals(LEGACY_RUN_ID)).toEqual([]) - expect(db.listRuns()).toEqual([expect.objectContaining({ id: LEGACY_RUN_ID, legacy: 1 })]) + expect(db.listRuns().runs).toEqual([expect.objectContaining({ id: LEGACY_RUN_ID, legacy: 1 })]) }) it('keeps current Delivery disjoint from adopted direct and audit-only mail', () => { diff --git a/src/main/runtime/orchestration/orchestration-reset-db.test.ts b/src/main/runtime/orchestration/orchestration-reset-db.test.ts index e98ec4230b5..1e0c62e5306 100644 --- a/src/main/runtime/orchestration/orchestration-reset-db.test.ts +++ b/src/main/runtime/orchestration/orchestration-reset-db.test.ts @@ -53,7 +53,7 @@ describe('OrchestrationDb reset scopes', () => { db!.resetAll() - expect(db!.listRuns()).toEqual([expect.objectContaining({ id: LEGACY_RUN_ID, legacy: 1 })]) + expect(db!.listRuns().runs).toEqual([expect.objectContaining({ id: LEGACY_RUN_ID, legacy: 1 })]) expect(db!.getTask(state.task.id)).toBeUndefined() expect(db!.getWorkerDispatch(state.started.dispatch.id)).toBeUndefined() expect(db!.getFederatedDispatch(state.started.dispatch.id)).toBeUndefined() diff --git a/src/main/runtime/orchestration/orchestration-run-list-compatibility.test.ts b/src/main/runtime/orchestration/orchestration-run-list-compatibility.test.ts new file mode 100644 index 00000000000..13fd4bd2a3b --- /dev/null +++ b/src/main/runtime/orchestration/orchestration-run-list-compatibility.test.ts @@ -0,0 +1,49 @@ +import { afterEach, describe, expect, it } from 'vitest' +import type Database from '../../sqlite/sync-database' +import { OrchestrationDb } from './db' +import { ORCHESTRATION_RUN_METHODS } from '../rpc/methods/orchestration-runs' + +function sqliteFor(db: OrchestrationDb): Database.Database { + return (db as unknown as { db: Database.Database }).db +} + +describe('orchestration Run list compatibility', () => { + let db: OrchestrationDb | undefined + + afterEach(() => db?.close()) + + it('rejects malformed pagination cursors', () => { + const method = ORCHESTRATION_RUN_METHODS.find( + (candidate) => candidate.name === 'orchestration.runList' + )! + + expect(() => method.params!.parse({ cursor: {} })).toThrow() + expect(() => method.params!.parse({ cursor: '' })).toThrow() + }) + + it('preserves the unpaginated RPC result for an old client request', async () => { + db = new OrchestrationDb(':memory:') + const runCount = 101 + sqliteFor(db) + .prepare( + `WITH RECURSIVE run_numbers(value) AS ( + VALUES (1) + UNION ALL + SELECT value + 1 FROM run_numbers WHERE value < ? + ) + INSERT INTO runs (id, objective) + SELECT printf('run_compat_%03d', value), printf('Run %03d', value) + FROM run_numbers` + ) + .run(runCount) + const method = ORCHESTRATION_RUN_METHODS.find( + (candidate) => candidate.name === 'orchestration.runList' + )! + const params = method.params!.parse({}) + const result = (await method.handler(params, { + runtime: { getOrchestrationDb: () => db } + } as never)) as { runs: unknown[] } + + expect(result.runs).toHaveLength(runCount + 1) + }) +}) diff --git a/src/main/runtime/relay/relay-revoke-outbox.test.ts b/src/main/runtime/relay/relay-revoke-outbox.test.ts index 54ab0473049..2ee9e515886 100644 --- a/src/main/runtime/relay/relay-revoke-outbox.test.ts +++ b/src/main/runtime/relay/relay-revoke-outbox.test.ts @@ -1,12 +1,29 @@ import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type * as SecureFileModule from '../../../shared/secure-file' import { RelayRevokeOutbox } from './relay-revoke-outbox' +const secureFileMocks = vi.hoisted(() => ({ failWrites: false })) + +vi.mock('../../../shared/secure-file', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + writeSecureJsonFile: (targetPath: string, value: unknown) => { + if (secureFileMocks.failWrites) { + throw new Error('disk full') + } + actual.writeSecureJsonFile(targetPath, value) + } + } +}) + describe('RelayRevokeOutbox', () => { const paths: string[] = [] afterEach(() => { + secureFileMocks.failWrites = false for (const path of paths.splice(0)) { rmSync(path, { recursive: true, force: true }) } @@ -29,4 +46,40 @@ describe('RelayRevokeOutbox', () => { new RelayRevokeOutbox(path).pendingFor(binding.ownerIdentityKey, binding.relayHostId) ).toEqual([]) }) + + it('does not retain an enqueue that failed to reach disk', () => { + const path = mkdtempSync(join(tmpdir(), 'orca-relay-revoke-')) + paths.push(path) + const binding = { + relayHostId: 'AbCdEf0123_-xyZ9', + relayDeviceId: 'device-1', + ownerIdentityKey: 'user-1\0profile-1\0org-1' + } + const outbox = new RelayRevokeOutbox(path) + secureFileMocks.failWrites = true + expect(() => outbox.enqueue(binding)).toThrow('disk full') + + secureFileMocks.failWrites = false + const persisted = outbox.enqueue(binding) + expect( + new RelayRevokeOutbox(path).pendingFor(binding.ownerIdentityKey, binding.relayHostId) + ).toEqual([persisted]) + }) + + it('does not remove an item in memory when the durable removal fails', () => { + const path = mkdtempSync(join(tmpdir(), 'orca-relay-revoke-')) + paths.push(path) + const binding = { + relayHostId: 'AbCdEf0123_-xyZ9', + relayDeviceId: 'device-1', + ownerIdentityKey: 'user-1\0profile-1\0org-1' + } + const outbox = new RelayRevokeOutbox(path) + const item = outbox.enqueue(binding) + secureFileMocks.failWrites = true + expect(() => outbox.remove(item.reqId)).toThrow('disk full') + + secureFileMocks.failWrites = false + expect(outbox.pendingFor(binding.ownerIdentityKey, binding.relayHostId)).toEqual([item]) + }) }) diff --git a/src/main/runtime/relay/relay-revoke-outbox.ts b/src/main/runtime/relay/relay-revoke-outbox.ts index 8a7e8cc19bb..473f619e134 100644 --- a/src/main/runtime/relay/relay-revoke-outbox.ts +++ b/src/main/runtime/relay/relay-revoke-outbox.ts @@ -54,8 +54,9 @@ export class RelayRevokeOutbox { return existing } const item = { ...binding, reqId: randomUUID(), createdAt: Date.now() } - this.items.push(item) - this.save() + const next = [...this.items, item] + this.save(next) + this.items = next return item } @@ -70,8 +71,8 @@ export class RelayRevokeOutbox { if (next.length === this.items.length) { return } + this.save(next) this.items = next - this.save() } private load(): RelayRevokeOutboxItem[] { @@ -87,7 +88,7 @@ export class RelayRevokeOutbox { } } - private save(): void { - writeSecureJsonFile(this.path, this.items) + private save(items: readonly RelayRevokeOutboxItem[]): void { + writeSecureJsonFile(this.path, items) } } diff --git a/src/main/runtime/remote-runtime-request-connection.integration.test.ts b/src/main/runtime/remote-runtime-request-connection.integration.test.ts index 98c295c2548..a4c10384735 100644 --- a/src/main/runtime/remote-runtime-request-connection.integration.test.ts +++ b/src/main/runtime/remote-runtime-request-connection.integration.test.ts @@ -332,6 +332,14 @@ describe('remote runtime request connection integration', () => { const worktreeId = 'repo-1::C:\\repo\\feature' const ptyId = `${worktreeId}@@pty-1` let sleepSnapshot: RuntimeClientEvent[] = [] + const launchDraftResolutionSnapshot: RuntimeClientEvent[] = [ + { + type: 'nativeChatLaunchDraftResolved', + tabId: 'tab-1', + text: 'seed', + createdAt: 7 + } + ] const emit = (event: RuntimeClientEvent): void => { for (const listener of clientEventListeners) { listener(event) @@ -362,6 +370,7 @@ describe('remote runtime request connection integration', () => { return () => clientEventListeners.delete(listener) }, getTerminalSleepClientEventSnapshot: () => sleepSnapshot, + getNativeChatLaunchDraftResolutionClientEventSnapshot: () => launchDraftResolutionSnapshot, sleepTerminalsForWorktree: async () => { emit({ type: 'worktreeTerminalSleepState', @@ -440,6 +449,9 @@ describe('remote runtime request connection integration', () => { await waitFor(() => clientEvents.every((events) => events.some((e) => e.type === 'ready')) ) + for (const events of clientEvents) { + expect(events).toContainEqual(launchDraftResolutionSnapshot[0]) + } await expect( requester.request( 'terminal.sleep', @@ -487,6 +499,7 @@ describe('remote runtime request connection integration', () => { .filter((event) => event.type === 'worktreeTerminalSleepState') .map((event) => event.phase) ).toEqual(['committed']) + expect(reconnectedEvents).toContainEqual(launchDraftResolutionSnapshot[0]) sleepSnapshot = [] emit({ diff --git a/src/main/runtime/rpc/errors.ts b/src/main/runtime/rpc/errors.ts index 3cf66666ddf..930a056a6ac 100644 --- a/src/main/runtime/rpc/errors.ts +++ b/src/main/runtime/rpc/errors.ts @@ -88,6 +88,7 @@ const STRUCTURED_RUNTIME_PASSTHROUGH_CODES: ReadonlySet = new Set([ 'agent_unconfigured', 'terminal_worktree_mismatch', 'request_mismatch', + 'mutation_ledger_full', 'legacy_read_only', 'orchestration_migration_required', 'operation_unknown', diff --git a/src/main/runtime/rpc/methods/client-events.ts b/src/main/runtime/rpc/methods/client-events.ts index 9a73948255a..956594dd5e3 100644 --- a/src/main/runtime/rpc/methods/client-events.ts +++ b/src/main/runtime/rpc/methods/client-events.ts @@ -38,6 +38,10 @@ export const CLIENT_EVENT_METHODS: readonly RpcAnyMethod[] = [ for (const event of runtime.getTerminalSleepClientEventSnapshot?.() ?? []) { emit(event) } + for (const event of runtime.getNativeChatLaunchDraftResolutionClientEventSnapshot?.() ?? + []) { + emit(event) + } const sshStates = listRegisteredSshTargets().flatMap((target) => { const state = getPublicSshState(getRegisteredSshState(target.id) ?? null) return state ? [{ targetId: target.id, state }] : [] diff --git a/src/main/runtime/rpc/methods/orchestration-runs.ts b/src/main/runtime/rpc/methods/orchestration-runs.ts index 020ec0a4f67..b246cf80e60 100644 --- a/src/main/runtime/rpc/methods/orchestration-runs.ts +++ b/src/main/runtime/rpc/methods/orchestration-runs.ts @@ -1,6 +1,7 @@ import { z } from 'zod' import { defineMethod, type RpcMethod } from '../core' import { OptionalBoolean, OptionalString, requiredString } from '../schemas' +import { ORCHESTRATION_RUN_PAGE_LIMIT } from '../../../../shared/orchestration-run-pagination' import type { OrcaRuntimeService } from '../../orca-runtime' import { OrchestrationError } from '../../orchestration/orchestration-error' @@ -16,7 +17,10 @@ const RunUseParams = z.object({ }) const RunCurrentParams = z.object({ from: requiredString('Missing coordinator terminal') }) -const RunListParams = z.object({}) +const RunListParams = z.object({ + limit: z.number().int().min(1).max(ORCHESTRATION_RUN_PAGE_LIMIT).optional(), + cursor: z.string().min(1).optional() +}) const RunShowParams = z.object({ id: requiredString('Missing --id'), from: OptionalString }) function requireCallerPane(runtime: OrcaRuntimeService, handle: string): string { @@ -104,7 +108,7 @@ export const ORCHESTRATION_RUN_METHODS: RpcMethod[] = [ defineMethod({ name: 'orchestration.runList', params: RunListParams, - handler: (_params, { runtime }) => ({ runs: runtime.getOrchestrationDb().listRuns() }) + handler: (params, { runtime }) => runtime.getOrchestrationDb().listRuns(params) }), defineMethod({ name: 'orchestration.runShow', diff --git a/src/main/runtime/rpc/methods/orchestration-worker-topology.ts b/src/main/runtime/rpc/methods/orchestration-worker-topology.ts index 91e92845963..474d3cb0316 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-topology.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-topology.ts @@ -60,7 +60,10 @@ export async function createExistingWorktreeWorkerTerminal(args: { }): Promise<{ handle: string; warning?: string }> { const terminal = await args.runtime.createTerminal(`id:${args.worktreeId}`, { command: args.agent, - title: `worker-${args.taskId}` + title: `worker-${args.taskId}`, + // Why: dispatching a worker is background work; it must not pull the sidebar + // to the worker's workspace while the user is reading somewhere else. + surfaceOwner: false }) args.effects.push({ kind: 'terminal', diff --git a/src/main/runtime/rpc/methods/orchestration.test.ts b/src/main/runtime/rpc/methods/orchestration.test.ts index b5a850a7781..51adc620901 100644 --- a/src/main/runtime/rpc/methods/orchestration.test.ts +++ b/src/main/runtime/rpc/methods/orchestration.test.ts @@ -164,7 +164,7 @@ describe('orchestration RPC methods', () => { await expect( call('orchestration.runCreate', { objective: 'No pane', from: 'term_stale' }) ).rejects.toMatchObject({ code: 'stable_pane_required' }) - expect(db.listRuns().filter((run) => run.legacy === 0)).toHaveLength(0) + expect(db.listRuns().runs.filter((run) => run.legacy === 0)).toHaveLength(0) }) it('rebinds explicitly, lists Runs, and keeps the legacy Run inspect-only', async () => { @@ -2176,9 +2176,12 @@ describe('orchestration RPC methods', () => { ) expect(db.getTask(task.id)?.status).toBe('dispatched') expect(db.getWorkerDispatch(result.dispatchId)?.state).toBe('ready') + // Why: dispatching a worker is background work — surfaceOwner:false adopts + // the tab without scrolling the sidebar to the worker's workspace. expect(runtime.createTerminal).toHaveBeenCalledWith('id:repo::worktree', { command: 'codex', - title: `worker-${task.id}` + title: `worker-${task.id}`, + surfaceOwner: false }) expect(runtime.sendTerminalAgentPrompt).toHaveBeenCalledWith( 'term_worker', @@ -2277,7 +2280,9 @@ describe('orchestration RPC methods', () => { ) expect(runtime.createTerminal).toHaveBeenCalledWith( 'id:repo::other', - expect.objectContaining({ command: 'codex' }) + // Why: starting a worker in an existing worktree must not pull the sidebar + // away from whatever the user is looking at. + expect.objectContaining({ command: 'codex', surfaceOwner: false }) ) expect(createWorktree).not.toHaveBeenCalled() }) diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index 2f874ea9f8c..4ca479ea5cc 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -863,6 +863,12 @@ const TerminalSend = TerminalHandle.extend({ text: OptionalString, enter: z.unknown().optional(), interrupt: z.unknown().optional(), + resolvedLaunchDraft: z + .object({ + text: z.string(), + createdAt: z.number().finite() + }) + .optional(), requireAgentStatus: z.enum(['sendable']).optional(), // Why: terminal-generated replies are valid input but must not transfer the shared terminal floor. inputKind: z.enum(['query-reply']).optional(), @@ -1183,6 +1189,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ params: TerminalSend, handler: async (params, { runtime, clientId }) => { await assertTerminalSendTextWithinLimit(params.text) + await assertTerminalSendTextWithinLimit(params.resolvedLaunchDraft?.text) const queryReplyClientId = clientId ?? params.client?.id if ( params.inputKind === 'query-reply' && @@ -1361,6 +1368,14 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ if (result.accepted !== true) { mobileFloorClaim.current?.rollback() } + if ( + result.accepted === true && + params.enter === true && + params.client?.type === 'mobile' && + params.resolvedLaunchDraft + ) { + runtime.notifyNativeChatLaunchDraftResolved(params.terminal, params.resolvedLaunchDraft) + } // Why: deliberate mobile input takes the floor (drives `* → mobile{clientId}`); clientless sends fall back to the current mobile driver. return { send: result } } diff --git a/src/main/runtime/rpc/methods/worktree-missing-terminal-teardown.test.ts b/src/main/runtime/rpc/methods/worktree-missing-terminal-teardown.test.ts new file mode 100644 index 00000000000..9ea904af68d --- /dev/null +++ b/src/main/runtime/rpc/methods/worktree-missing-terminal-teardown.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it, vi } from 'vitest' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { RpcDispatcher } from '../dispatcher' +import { WORKTREE_METHODS } from './worktree' + +describe('worktree missing-terminal teardown RPC', () => { + it('routes the connection-scoped request through the runtime owner', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + teardownMissingManagedWorktreeTerminals: vi + .fn() + .mockResolvedValue({ stoppedWorktreeIds: ['repo-1::/workspace/deleted'] }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) + + const response = await dispatcher.dispatch({ + id: 'request-1', + authToken: 'token', + method: 'worktree.teardownMissingTerminals', + params: { + repo: 'repo-1', + worktreeIds: ['repo-1::/workspace/deleted'], + connectionId: 'ssh-1' + } + }) + + expect(runtime.teardownMissingManagedWorktreeTerminals).toHaveBeenCalledWith( + 'repo-1', + ['repo-1::/workspace/deleted'], + 'ssh-1' + ) + expect(response).toMatchObject({ ok: true }) + }) +}) diff --git a/src/main/runtime/rpc/methods/worktree-schemas.ts b/src/main/runtime/rpc/methods/worktree-schemas.ts index 00b6c986f7b..caf8dd025ff 100644 --- a/src/main/runtime/rpc/methods/worktree-schemas.ts +++ b/src/main/runtime/rpc/methods/worktree-schemas.ts @@ -51,6 +51,11 @@ export const WorktreeDetectedListParams = z.object({ .pipe(z.string().min(1, 'Missing repo selector')) }) +export const WorktreeTeardownMissingTerminalsParams = WorktreeDetectedListParams.extend({ + worktreeIds: z.array(z.string().min(1)).max(10_000), + connectionId: z.string().nullable().optional() +}) + export const WorktreePsParams = z.object({ limit: OptionalFiniteNumber }) diff --git a/src/main/runtime/rpc/methods/worktree.ts b/src/main/runtime/rpc/methods/worktree.ts index c2d96214994..9c6205ed86d 100644 --- a/src/main/runtime/rpc/methods/worktree.ts +++ b/src/main/runtime/rpc/methods/worktree.ts @@ -19,7 +19,8 @@ import { WorktreeResolvePrBase, WorktreeSelector, WorktreeSet, - WorktreeSortOrder + WorktreeSortOrder, + WorktreeTeardownMissingTerminalsParams } from './worktree-schemas' export const WORKTREE_METHODS: RpcMethod[] = [ @@ -38,6 +39,16 @@ export const WORKTREE_METHODS: RpcMethod[] = [ params: WorktreeDetectedListParams, handler: async (params, { runtime }) => runtime.listDetectedManagedWorktrees(params.repo) }), + defineMethod({ + name: 'worktree.teardownMissingTerminals', + params: WorktreeTeardownMissingTerminalsParams, + handler: async (params, { runtime }) => + runtime.teardownMissingManagedWorktreeTerminals( + params.repo, + params.worktreeIds, + params.connectionId + ) + }), defineMethod({ name: 'worktree.lineageList', params: null, diff --git a/src/main/runtime/rpc/terminal-send-launch-draft-resolution.test.ts b/src/main/runtime/rpc/terminal-send-launch-draft-resolution.test.ts new file mode 100644 index 00000000000..9e5cf52384a --- /dev/null +++ b/src/main/runtime/rpc/terminal-send-launch-draft-resolution.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it, vi } from 'vitest' +import type { OrcaRuntimeService } from '../orca-runtime' +import type { RpcRequest } from './core' +import { RpcDispatcher } from './dispatcher' +import { TERMINAL_METHODS } from './methods/terminal' + +const RESOLUTION = { text: 'seed', createdAt: 1 } + +function makeRequest(params: unknown): RpcRequest { + return { id: 'request', authToken: 'token', method: 'terminal.send', params } +} + +function makeRuntime(accepted: boolean): OrcaRuntimeService { + return { + getRuntimeId: () => 'runtime', + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + getDriver: vi.fn().mockReturnValue({ kind: 'idle' }), + beginMobileInputFloor: vi.fn().mockReturnValue({ commit: vi.fn(), rollback: vi.fn() }), + sendTerminal: vi.fn().mockResolvedValue({ + handle: 'terminal-1', + accepted, + bytesWritten: accepted ? 1 : 0 + }), + notifyNativeChatLaunchDraftResolved: vi.fn() + } as unknown as OrcaRuntimeService +} + +async function send( + runtime: OrcaRuntimeService, + options: { enter: boolean; clientType: 'mobile' | 'desktop' } +): Promise { + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + await dispatcher.dispatch( + makeRequest({ + terminal: 'terminal-1', + text: 'hello', + enter: options.enter, + resolvedLaunchDraft: RESOLUTION, + client: { id: 'client-1', type: options.clientType } + }) + ) +} + +describe('terminal.send launch-draft resolution', () => { + it('notifies after an accepted mobile submit', async () => { + const runtime = makeRuntime(true) + + await send(runtime, { enter: true, clientType: 'mobile' }) + + expect(runtime.notifyNativeChatLaunchDraftResolved).toHaveBeenCalledWith( + 'terminal-1', + RESOLUTION + ) + }) + + it.each([ + ['rejected submit', false, true, 'mobile'], + ['clear-only write', true, false, 'mobile'], + ['desktop submit', true, true, 'desktop'] + ] as const)('does not notify after a %s', async (_case, accepted, enter, clientType) => { + const runtime = makeRuntime(accepted) + + await send(runtime, { enter, clientType }) + + expect(runtime.notifyNativeChatLaunchDraftResolved).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/runtime-rpc.test.ts b/src/main/runtime/runtime-rpc.test.ts index 308c8ae2010..4339679355c 100644 --- a/src/main/runtime/runtime-rpc.test.ts +++ b/src/main/runtime/runtime-rpc.test.ts @@ -746,7 +746,74 @@ describe('OrcaRuntimeRpcServer', () => { } }) - it('falls back to a valid direct-only GUI offer when relay invite minting fails', async () => { + it('queues the old Relay binding when a stable provider changes accounts', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const server = new OrcaRuntimeRpcServer({ + runtime: new OrcaRuntimeService(), + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + let relayHostId = 'AbCdEf0123_-xyZ9' + let ownerIdentityKey = 'user-a\0profile-a\0org' + const onDeviceRevokeQueued = vi.fn() + server.setMobileRelayPairingProvider({ + createPairingRelay: async (relayDeviceId) => ({ + relay: { + v: 1, + directorUrl: 'https://relay.example.com', + cellUrl: 'https://cell.example.com', + assignmentEpoch: 7, + relayHostId, + inviteToken: 'A'.repeat(43), + inviteExpiresAt: Date.now() + 60_000, + e2eeFraming: 2 + }, + binding: { + relayHostId, + relayDeviceId, + ownerIdentityKey + } + }), + onDeviceRevokeQueued, + getEndpoints: vi.fn(), + provisionRelay: vi.fn() + }) + + await server.start() + try { + const first = await server.createMobilePairingOffer({ address: '100.64.1.20' }) + expect(first.available).toBe(true) + if (!first.available) { + throw new Error('WebSocket pairing unavailable') + } + const firstBinding = server.getDeviceRegistry()?.getDevice(first.deviceId)?.relayBinding + expect(firstBinding).toBeTruthy() + if (!firstBinding) { + throw new Error('Relay binding unavailable') + } + relayHostId = 'ZyXwVu9876_-abcD' + ownerIdentityKey = 'user-b\0profile-b\0org' + + const second = await server.createMobilePairingOffer({ address: '100.64.1.20' }) + expect(second.available).toBe(true) + if (!second.available) { + throw new Error('WebSocket pairing unavailable') + } + expect(second.deviceId).toBe(first.deviceId) + expect(onDeviceRevokeQueued).toHaveBeenCalledOnce() + expect(onDeviceRevokeQueued).toHaveBeenCalledWith(expect.objectContaining(firstBinding)) + expect(server.getDeviceRegistry()?.getDevice(second.deviceId)?.relayBinding).toEqual({ + relayHostId, + relayDeviceId: second.deviceId, + ownerIdentityKey + }) + } finally { + await server.stop() + } + }) + + it('refuses a silent LAN QR when relay invite minting fails under Anywhere', async () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) const server = new OrcaRuntimeRpcServer({ runtime: new OrcaRuntimeService(), @@ -764,18 +831,576 @@ describe('OrcaRuntimeRpcServer', () => { await server.start() try { const offer = await server.createMobilePairingOffer({ address: '100.64.1.20' }) - expect(offer.available).toBe(true) - if (!offer.available) { + // Why: Anywhere must not ship a scannable local-only code under the Relay label. + expect(offer.available).toBe(false) + if (offer.available) { + throw new Error('expected relay mint failure') + } + expect(offer.reason).toBe('relay_mint_failed') + expect(offer.relayFailure).toMatchObject({ + code: 'relay_mint_failed', + stage: 'create_pairing_relay' + }) + expect(server.getDeviceRegistry()?.getPendingDevice('mobile')).toBeNull() + } finally { + await server.stop() + } + }) + + it('reports a missing Relay provider without creating a fallback QR', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const server = new OrcaRuntimeRpcServer({ + runtime: new OrcaRuntimeService(), + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + + await server.start() + try { + const offer = await server.createMobilePairingOffer({ address: '100.64.1.20' }) + expect(offer).toMatchObject({ + available: false, + reason: 'relay_mint_failed', + relayFailure: { + code: 'relay_provider_unavailable', + stage: 'provider_missing', + message: 'Orca Relay is not available on this desktop' + } + }) + expect(server.getDeviceRegistry()?.getPendingDevice('mobile')).toBeNull() + } finally { + await server.stop() + } + }) + + it('preserves an existing Relay QR when a same-mode remint fails', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const server = new OrcaRuntimeRpcServer({ + runtime: new OrcaRuntimeService(), + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + const createPairingRelay = vi + .fn() + .mockImplementationOnce(async (relayDeviceId: string) => ({ + relay: { + v: 1 as const, + directorUrl: 'https://relay.example.com', + cellUrl: 'https://cell.example.com', + assignmentEpoch: 7, + relayHostId: 'AbCdEf0123_-xyZ9', + inviteToken: 'A'.repeat(43), + inviteExpiresAt: Date.now() + 60_000, + e2eeFraming: 2 as const + }, + binding: { + relayHostId: 'AbCdEf0123_-xyZ9', + relayDeviceId, + ownerIdentityKey: 'user\0profile\0org' + } + })) + .mockRejectedValueOnce(new Error('relay offline')) + server.setMobileRelayPairingProvider({ + createPairingRelay, + onDeviceRevokeQueued: vi.fn(), + getEndpoints: vi.fn(), + provisionRelay: vi.fn() + }) + + await server.start() + try { + const first = await server.createMobilePairingOffer({ address: '100.64.1.20' }) + expect(first.available).toBe(true) + if (!first.available) { throw new Error('WebSocket pairing unavailable') } - expect(parsePairingCode(offer.pairingUrl)).toMatchObject({ - endpoint: offer.endpoint, - scope: 'mobile' + const second = await server.createMobilePairingOffer({ address: '100.64.1.20' }) + expect(second.available).toBe(false) + expect(server.getDeviceRegistry()?.getDevice(first.deviceId)?.relayBinding).toBeTruthy() + expect(server.getDeviceRegistry()?.getPendingDevice('mobile')?.deviceId).toBe(first.deviceId) + } finally { + await server.stop() + } + }) + + it('coalesces concurrent mobile Relay mints for the shared pending credential', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const server = new OrcaRuntimeRpcServer({ + runtime: new OrcaRuntimeService(), + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + let resolveFirst: (() => void) | undefined + const firstMint = new Promise((resolve) => { + resolveFirst = resolve + }) + const createPairingRelay = vi.fn(async (relayDeviceId: string) => { + await firstMint + return { + relay: { + v: 1 as const, + directorUrl: 'https://relay.example.com', + cellUrl: 'https://cell.example.com', + assignmentEpoch: 7, + relayHostId: 'AbCdEf0123_-xyZ9', + inviteToken: 'A'.repeat(43), + inviteExpiresAt: Date.now() + 60_000, + e2eeFraming: 2 as const + }, + binding: { + relayHostId: 'AbCdEf0123_-xyZ9', + relayDeviceId, + ownerIdentityKey: 'user\0profile\0org' + } + } + }) + server.setMobileRelayPairingProvider({ + createPairingRelay, + onDeviceRevokeQueued: vi.fn(), + getEndpoints: vi.fn(), + provisionRelay: vi.fn() + }) + + await server.start() + try { + const first = server.createMobilePairingOffer({ address: '100.64.1.20', rotate: true }) + const second = server.createMobilePairingOffer({ address: '100.64.1.20', rotate: true }) + await vi.waitFor(() => expect(createPairingRelay).toHaveBeenCalledTimes(1)) + resolveFirst?.() + const [firstOffer, secondOffer] = await Promise.all([first, second]) + expect(firstOffer.available).toBe(true) + expect(secondOffer.available).toBe(true) + if (!firstOffer.available || !secondOffer.available) { + throw new Error('WebSocket pairing unavailable') + } + expect(secondOffer.deviceId).toBe(firstOffer.deviceId) + expect(secondOffer.pairingUrl).toBe(firstOffer.pairingUrl) + expect(createPairingRelay).toHaveBeenCalledTimes(1) + } finally { + await server.stop() + } + }) + + it('supersedes an older concurrent Relay rotation for a different address', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const server = new OrcaRuntimeRpcServer({ + runtime: new OrcaRuntimeService(), + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + let resolveFirst: (() => void) | undefined + const firstMint = new Promise((resolve) => { + resolveFirst = resolve + }) + const createPairingRelay = vi.fn(async (relayDeviceId: string) => { + if (createPairingRelay.mock.calls.length === 1) { + await firstMint + } + return { + relay: { + v: 1 as const, + directorUrl: 'https://relay.example.com', + cellUrl: 'https://cell.example.com', + assignmentEpoch: 7, + relayHostId: 'AbCdEf0123_-xyZ9', + inviteToken: 'A'.repeat(43), + inviteExpiresAt: Date.now() + 60_000, + e2eeFraming: 2 as const + }, + binding: { + relayHostId: 'AbCdEf0123_-xyZ9', + relayDeviceId, + ownerIdentityKey: 'user\0profile\0org' + } + } + }) + const onDeviceRevokeQueued = vi.fn() + server.setMobileRelayPairingProvider({ + createPairingRelay, + onDeviceRevokeQueued, + getEndpoints: vi.fn(), + provisionRelay: vi.fn() + }) + + await server.start() + try { + const first = server.createMobilePairingOffer({ + address: '100.64.1.20', + rotate: true }) - expect(parsePairingCode(offer.pairingUrl)).not.toHaveProperty('relay') - // Why: the result reports what the offer actually encodes so the UI can - // flag the degraded mint instead of labeling it as Relay. - expect(offer.connectionMode).toBe('local-only') + await vi.waitFor(() => expect(createPairingRelay).toHaveBeenCalledOnce()) + const second = server.createMobilePairingOffer({ + address: '100.64.1.21', + rotate: true + }) + resolveFirst?.() + await expect(first).resolves.toMatchObject({ + available: false, + relayFailure: { code: 'relay_request_superseded' } + }) + const secondOffer = await second + expect(secondOffer.available).toBe(true) + if (!secondOffer.available) { + throw new Error('WebSocket pairing unavailable') + } + expect(secondOffer.endpoint).toContain('100.64.1.21') + expect(createPairingRelay).toHaveBeenCalledTimes(2) + expect(onDeviceRevokeQueued).toHaveBeenCalledOnce() + } finally { + await server.stop() + } + }) + + it('supersedes an older concurrent Relay mint for a different address without rotate', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const server = new OrcaRuntimeRpcServer({ + runtime: new OrcaRuntimeService(), + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + let resolveFirst: (() => void) | undefined + const firstMint = new Promise((resolve) => { + resolveFirst = resolve + }) + const createPairingRelay = vi.fn(async (relayDeviceId: string) => { + if (createPairingRelay.mock.calls.length === 1) { + await firstMint + } + return { + relay: { + v: 1 as const, + directorUrl: 'https://relay.example.com', + cellUrl: 'https://cell.example.com', + assignmentEpoch: 7, + relayHostId: 'AbCdEf0123_-xyZ9', + inviteToken: 'A'.repeat(43), + inviteExpiresAt: Date.now() + 60_000, + e2eeFraming: 2 as const + }, + binding: { + relayHostId: 'AbCdEf0123_-xyZ9', + relayDeviceId, + ownerIdentityKey: 'user\0profile\0org' + } + } + }) + const onDeviceRevokeQueued = vi.fn() + server.setMobileRelayPairingProvider({ + createPairingRelay, + onDeviceRevokeQueued, + getEndpoints: vi.fn(), + provisionRelay: vi.fn() + }) + + await server.start() + try { + const first = server.createMobilePairingOffer({ address: '100.64.1.20' }) + await vi.waitFor(() => expect(createPairingRelay).toHaveBeenCalledOnce()) + const second = server.createMobilePairingOffer({ address: '100.64.1.21' }) + resolveFirst?.() + await expect(first).resolves.toMatchObject({ + available: false, + relayFailure: { code: 'relay_request_superseded' } + }) + const secondOffer = await second + expect(secondOffer.available).toBe(true) + if (!secondOffer.available) { + throw new Error('WebSocket pairing unavailable') + } + expect(secondOffer.endpoint).toContain('100.64.1.21') + expect(onDeviceRevokeQueued).toHaveBeenCalledOnce() + } finally { + await server.stop() + } + }) + + it('lets LAN supersede a pending Relay mint without waiting for it', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const server = new OrcaRuntimeRpcServer({ + runtime: new OrcaRuntimeService(), + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + let resolveRelay: (() => void) | undefined + const relayGate = new Promise((resolve) => { + resolveRelay = resolve + }) + const onDeviceRevokeQueued = vi.fn() + server.setMobileRelayPairingProvider({ + createPairingRelay: async (relayDeviceId) => { + await relayGate + return { + relay: { + v: 1, + directorUrl: 'https://relay.example.com', + cellUrl: 'https://cell.example.com', + assignmentEpoch: 7, + relayHostId: 'AbCdEf0123_-xyZ9', + inviteToken: 'A'.repeat(43), + inviteExpiresAt: Date.now() + 60_000, + e2eeFraming: 2 + }, + binding: { + relayHostId: 'AbCdEf0123_-xyZ9', + relayDeviceId, + ownerIdentityKey: 'user\0profile\0org' + } + } + }, + onDeviceRevokeQueued, + getEndpoints: vi.fn(), + provisionRelay: vi.fn() + }) + + await server.start() + try { + const relayOffer = server.createMobilePairingOffer({ address: '100.64.1.20' }) + await vi.waitFor(() => + expect(server.getDeviceRegistry()?.getPendingDevice('mobile')).not.toBeNull() + ) + const localOffer = await server.createMobilePairingOffer({ + address: '100.64.1.20', + connectionMode: 'local-only' + }) + expect(localOffer.available).toBe(true) + if (!localOffer.available) { + throw new Error('LAN pairing unavailable') + } + expect(localOffer.connectionMode).toBe('local-only') + resolveRelay?.() + const staleRelayOffer = await relayOffer + expect(staleRelayOffer.available).toBe(false) + expect(server.getDeviceRegistry()?.getPendingDevice('mobile')?.deviceId).toBe( + localOffer.deviceId + ) + expect(onDeviceRevokeQueued).toHaveBeenCalledOnce() + } finally { + await server.stop() + } + }) + + it('revokes a Relay invite when binding persistence throws', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const server = new OrcaRuntimeRpcServer({ + runtime: new OrcaRuntimeService(), + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + const onDeviceRevokeQueued = vi.fn() + server.setMobileRelayPairingProvider({ + createPairingRelay: async (relayDeviceId) => ({ + relay: { + v: 1, + directorUrl: 'https://relay.example.com', + cellUrl: 'https://cell.example.com', + assignmentEpoch: 7, + relayHostId: 'AbCdEf0123_-xyZ9', + inviteToken: 'A'.repeat(43), + inviteExpiresAt: Date.now() + 60_000, + e2eeFraming: 2 + }, + binding: { + relayHostId: 'AbCdEf0123_-xyZ9', + relayDeviceId, + ownerIdentityKey: 'user\0profile\0org' + } + }), + onDeviceRevokeQueued, + getEndpoints: vi.fn(), + provisionRelay: vi.fn() + }) + + await server.start() + try { + const registry = server.getDeviceRegistry() + if (!registry) { + throw new Error('Device registry unavailable') + } + vi.spyOn(registry, 'setRelayBinding').mockImplementation(() => { + throw new Error('disk full') + }) + const offer = await server.createMobilePairingOffer({ address: '100.64.1.20' }) + expect(offer.available).toBe(false) + expect(onDeviceRevokeQueued).toHaveBeenCalledOnce() + expect(registry.getPendingDevice('mobile')).toBeNull() + } finally { + await server.stop() + } + }) + + it('revokes a Relay result from a provider replaced during minting', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const server = new OrcaRuntimeRpcServer({ + runtime: new OrcaRuntimeService(), + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + let resolveRelay: (() => void) | undefined + const relayGate = new Promise((resolve) => { + resolveRelay = resolve + }) + server.setMobileRelayPairingProvider({ + createPairingRelay: async (relayDeviceId) => { + await relayGate + return { + relay: { + v: 1, + directorUrl: 'https://relay.example.com', + cellUrl: 'https://cell.example.com', + assignmentEpoch: 7, + relayHostId: 'AbCdEf0123_-xyZ9', + inviteToken: 'A'.repeat(43), + inviteExpiresAt: Date.now() + 60_000, + e2eeFraming: 2 + }, + binding: { + relayHostId: 'AbCdEf0123_-xyZ9', + relayDeviceId, + ownerIdentityKey: 'user\0profile\0org' + } + } + }, + onDeviceRevokeQueued: vi.fn(), + getEndpoints: vi.fn(), + provisionRelay: vi.fn() + }) + + await server.start() + try { + const offerPromise = server.createMobilePairingOffer({ address: '100.64.1.20' }) + await vi.waitFor(() => + expect(server.getDeviceRegistry()?.getPendingDevice('mobile')).not.toBeNull() + ) + const onDeviceRevokeQueued = vi.fn() + server.setMobileRelayPairingProvider({ + createPairingRelay: vi.fn(), + onDeviceRevokeQueued, + getEndpoints: vi.fn(), + provisionRelay: vi.fn() + }) + resolveRelay?.() + await expect(offerPromise).resolves.toMatchObject({ available: false }) + expect(onDeviceRevokeQueued).toHaveBeenCalledOnce() + expect(server.getDeviceRegistry()?.getPendingDevice('mobile')).toBeNull() + } finally { + await server.stop() + } + }) + + it('retains a minted Relay binding on the device when cleanup cannot be queued', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const server = new OrcaRuntimeRpcServer({ + runtime: new OrcaRuntimeService(), + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + let resolveRelay: (() => void) | undefined + const relayGate = new Promise((resolve) => { + resolveRelay = resolve + }) + server.setMobileRelayPairingProvider({ + createPairingRelay: async (relayDeviceId) => { + await relayGate + return { + relay: { + v: 1, + directorUrl: 'https://relay.example.com', + cellUrl: 'https://cell.example.com', + assignmentEpoch: 7, + relayHostId: 'AbCdEf0123_-xyZ9', + inviteToken: 'A'.repeat(43), + inviteExpiresAt: Date.now() + 60_000, + e2eeFraming: 2 + }, + binding: { + relayHostId: 'AbCdEf0123_-xyZ9', + relayDeviceId, + ownerIdentityKey: 'user\0profile\0org' + } + } + }, + onDeviceRevokeQueued: vi.fn(), + getEndpoints: vi.fn(), + provisionRelay: vi.fn() + }) + + await server.start() + try { + const registry = server.getDeviceRegistry() + if (!registry) { + throw new Error('Device registry unavailable') + } + const offerPromise = server.createMobilePairingOffer({ address: '100.64.1.20' }) + await vi.waitFor(() => expect(registry.getPendingDevice('mobile')).not.toBeNull()) + const deviceId = registry.getPendingDevice('mobile')?.deviceId + vi.spyOn(server.getRelayRevokeOutbox(), 'enqueue').mockImplementation(() => { + throw new Error('disk full') + }) + // Why: swapping the provider supersedes the in-flight mint. + server.setMobileRelayPairingProvider({ + createPairingRelay: vi.fn(), + onDeviceRevokeQueued: vi.fn(), + getEndpoints: vi.fn(), + provisionRelay: vi.fn() + }) + resolveRelay?.() + await expect(offerPromise).resolves.toMatchObject({ available: false }) + expect(registry.getDevice(deviceId ?? '')?.relayBinding).toMatchObject({ + relayHostId: 'AbCdEf0123_-xyZ9', + relayDeviceId: deviceId + }) + } finally { + await server.stop() + } + }) + + it('queues cloud cleanup when a minted Relay binding cannot be persisted', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const server = new OrcaRuntimeRpcServer({ + runtime: new OrcaRuntimeService(), + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + const onDeviceRevokeQueued = vi.fn() + server.setMobileRelayPairingProvider({ + createPairingRelay: async () => ({ + relay: { + v: 1, + directorUrl: 'https://relay.example.com', + cellUrl: 'https://cell.example.com', + assignmentEpoch: 7, + relayHostId: 'AbCdEf0123_-xyZ9', + inviteToken: 'A'.repeat(43), + inviteExpiresAt: Date.now() + 60_000, + e2eeFraming: 2 + }, + binding: { + relayHostId: 'AbCdEf0123_-xyZ9', + relayDeviceId: 'wrong-device', + ownerIdentityKey: 'user\0profile\0org' + } + }), + onDeviceRevokeQueued, + getEndpoints: vi.fn(), + provisionRelay: vi.fn() + }) + + await server.start() + try { + const offer = await server.createMobilePairingOffer({ address: '100.64.1.20' }) + expect(offer.available).toBe(false) + expect(onDeviceRevokeQueued).toHaveBeenCalledOnce() + expect(server.getDeviceRegistry()?.getPendingDevice('mobile')).toBeNull() } finally { await server.stop() } @@ -833,6 +1458,28 @@ describe('OrcaRuntimeRpcServer', () => { enableWebSocket: true, wsPort: 0 }) + server.setMobileRelayPairingProvider({ + createPairingRelay: async (relayDeviceId) => ({ + relay: { + v: 1, + directorUrl: 'https://relay.example.com', + cellUrl: 'https://cell.example.com', + assignmentEpoch: 7, + relayHostId: 'AbCdEf0123_-xyZ9', + inviteToken: 'A'.repeat(43), + inviteExpiresAt: Date.now() + 60_000, + e2eeFraming: 2 + }, + binding: { + relayHostId: 'AbCdEf0123_-xyZ9', + relayDeviceId, + ownerIdentityKey: 'user\0profile\0org' + } + }), + onDeviceRevokeQueued: vi.fn(), + getEndpoints: vi.fn(), + provisionRelay: vi.fn() + }) await server.start() try { @@ -843,6 +1490,7 @@ describe('OrcaRuntimeRpcServer', () => { if (!offer.available) { throw new Error('WebSocket pairing unavailable') } + expect(offer.connectionMode).toBe('automatic') expect(server.getDeviceRegistry()?.getMobilePairingConnectionMode(offer.deviceId)).toBe( 'automatic' ) diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index 54734e693c5..48903b5a1fe 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -29,6 +29,10 @@ import { } from './rpc/mobile-socket-wiring' import type { PairingRelay } from '../../shared/mobile-relay-pairing-offer' import type { MobilePairingConnectionMode } from '../../shared/mobile-pairing-connection-mode' +import { + mobileRelayMintFailureFromUnknown, + type MobileRelayMintFailure +} from '../../shared/mobile-relay-mint-failure' import { RelayRevokeOutbox, type RelayDeviceBinding, @@ -71,13 +75,28 @@ export type PairingOfferUnavailableReason = | 'device_registry_unavailable' | 'e2ee_key_unavailable' | 'invalid_advertised_endpoint' + | 'relay_mint_failed' export type PairingOfferUnavailable = { available: false reason: PairingOfferUnavailableReason guidance: string + /** Present when an Anywhere mint refused to silently fall back to LAN-only. */ + relayFailure?: MobileRelayMintFailure } +type MobilePairingOfferAvailable = { + available: true + pairingUrl: string + endpoint: string + deviceId: string + webClientUrl: string | null + /** Mode the offer actually encodes. */ + connectionMode: MobilePairingConnectionMode +} + +type MobilePairingOffer = PairingOfferUnavailable | MobilePairingOfferAvailable + type PairingIdentityInitialization = | { ok: true; deviceRegistry: DeviceRegistry; e2eeKeypair: E2EEKeypair } | { ok: false; failure: PairingOfferUnavailable } @@ -469,6 +488,14 @@ export class OrcaRuntimeRpcServer { private metadataOwnershipWatch: RuntimeMetadataOwnershipWatch | null = null private mobileSocketWiring: MobileSocketWiring | null = null private mobileRelayPairingProvider: MobileRelayPairingProvider | null = null + private mobileRelayPairingOfferQueue: Promise = Promise.resolve() + private mobileRelayPairingOfferInFlight: { + generation: number + address: string | null + rotate: boolean + request: Promise + } | null = null + private mobilePairingOfferGeneration = 0 private onUnpairedDeviceAuthFailure: (() => void) | null = null private unpairedDeviceAuthThrottle: UnpairedDeviceAuthThrottle | null = null private readonly binaryStreamHandlers = new Map< @@ -552,7 +579,9 @@ export class OrcaRuntimeRpcServer { current.relayBinding.ownerIdentityKey !== binding.ownerIdentityKey) ) { // Why: switching the owning account/host must not strand the old cloud credential family, even if that account is offline. - this.queueRelayDeviceRevoke(current.relayBinding) + if (!this.queueRelayDeviceRevoke(current.relayBinding)) { + return false + } } const updated = this.deviceRegistry?.setRelayBinding(deviceId, binding) ?? false if (updated) { @@ -576,7 +605,9 @@ export class OrcaRuntimeRpcServer { return false } if (device.relayBinding) { - this.queueRelayDeviceRevoke(device.relayBinding) + if (!this.queueRelayDeviceRevoke(device.relayBinding)) { + return false + } } if (!this.deviceRegistry?.removeDevice(deviceId)) { return false @@ -672,18 +703,57 @@ export class OrcaRuntimeRpcServer { connectionMode?: MobilePairingConnectionMode name?: string rotate?: boolean - }): Promise< - | PairingOfferUnavailable - | { - available: true - pairingUrl: string - endpoint: string - deviceId: string - webClientUrl: string | null - /** Mode the offer actually encodes — 'local-only' when an automatic request degraded (Relay couldn't attach). */ - connectionMode: MobilePairingConnectionMode + }): Promise { + if (args.connectionMode === 'local-only') { + this.mobilePairingOfferGeneration += 1 + return this.createMobilePairingOfferSerial(args, this.mobilePairingOfferGeneration) + } + const address = args.address ?? null + const rotate = args.rotate === true + const inFlight = this.mobileRelayPairingOfferInFlight + if ( + inFlight?.generation === this.mobilePairingOfferGeneration && + inFlight.address === address && + (inFlight.rotate || !rotate) + ) { + return inFlight.request + } + // Why: every request that is not coalesced above supersedes the older one, rotating or not. + const generation = ++this.mobilePairingOfferGeneration + const request = this.mobileRelayPairingOfferQueue.then(() => + generation === this.mobilePairingOfferGeneration + ? this.createMobilePairingOfferSerial(args, generation) + : this.relayPairingRequestSuperseded() + ) + this.mobileRelayPairingOfferQueue = request.then( + () => undefined, + () => undefined + ) + this.mobileRelayPairingOfferInFlight = { generation, address, rotate, request } + void request.then( + () => { + if (this.mobileRelayPairingOfferInFlight?.request === request) { + this.mobileRelayPairingOfferInFlight = null + } + }, + () => { + if (this.mobileRelayPairingOfferInFlight?.request === request) { + this.mobileRelayPairingOfferInFlight = null + } } - > { + ) + return request + } + + private async createMobilePairingOfferSerial( + args: { + address?: string | null + connectionMode?: MobilePairingConnectionMode + name?: string + rotate?: boolean + }, + generation: number + ): Promise { // Why: the renderer is outside the trust boundary, so only an explicit local-only value may suppress Relay provisioning. const connectionMode = args.connectionMode === 'local-only' ? 'local-only' : 'automatic' const pending = this.deviceRegistry?.getPendingDevice('mobile') @@ -694,7 +764,12 @@ export class OrcaRuntimeRpcServer { if (args.rotate || switchingPendingMode) { if (pending?.relayBinding) { // Why: record the durable cloud revoke before rotating the local token so an old relay invite can't outlive the QR. - this.queueRelayDeviceRevoke(pending.relayBinding) + if (!this.queueRelayDeviceRevoke(pending.relayBinding)) { + return pairingUnavailable( + 'device_registry_unavailable', + 'Could not persist Relay cleanup before rotating the pairing code.' + ) + } } } const direct = this.createPairingOffer({ @@ -705,42 +780,178 @@ export class OrcaRuntimeRpcServer { if (!direct.available) { return direct } - this.deviceRegistry?.setMobilePairingConnectionMode(direct.deviceId, connectionMode) - if (connectionMode === 'local-only' || !this.mobileRelayPairingProvider) { + const createdNewPendingDevice = pending?.deviceId !== direct.deviceId + let connectionModeStored = false + try { + connectionModeStored = + this.deviceRegistry?.setMobilePairingConnectionMode(direct.deviceId, connectionMode) ?? + false + } catch (error) { + console.error('[runtime] Failed to persist the pairing connection mode:', error) + } + // Why: the mode is part of the credential — a QR whose policy was never stored must not pair under the default one. + if (!connectionModeStored) { + if (createdNewPendingDevice) { + this.discardPendingMobilePairingDevice(direct.deviceId) + } + return pairingUnavailable('device_registry_unavailable', DEVICE_REGISTRY_UNAVAILABLE_GUIDANCE) + } + // Why: explicit LAN path never needs Relay; mint the direct-only offer as selected. + if (connectionMode === 'local-only') { return { ...direct, connectionMode: 'local-only' } } + // Why: Anywhere must not silently ship a LAN-only QR under the Relay label. + // Fail closed, drop the unused pending credential, and let the UI offer Use LAN. + const refuseAutomaticWithoutRelay = ( + relayFailure: MobileRelayMintFailure + ): PairingOfferUnavailable => { + if (createdNewPendingDevice) { + this.discardPendingMobilePairingDevice(direct.deviceId) + } + return { + available: false, + reason: 'relay_mint_failed', + guidance: + 'Orca Relay could not create a pairing invite. Use LAN (Tailscale or same Wi‑Fi) or retry Relay.', + relayFailure + } + } + const relayProvider = this.mobileRelayPairingProvider + if (!relayProvider) { + return refuseAutomaticWithoutRelay({ + code: 'relay_provider_unavailable', + stage: 'provider_missing', + message: 'Orca Relay is not available on this desktop' + }) + } const device = this.deviceRegistry?.getDevice(direct.deviceId) const publicKeyB64 = this.getE2EEPublicKey() if (!device || !publicKeyB64) { - return { ...direct, connectionMode: 'local-only' } + return refuseAutomaticWithoutRelay({ + code: 'e2ee_key_unavailable', + stage: 'e2ee_missing', + message: 'E2EE public key unavailable for Relay pairing' + }) + } + let relayPairing: Awaited> + try { + relayPairing = await relayProvider.createPairingRelay(device.deviceId) + } catch (error) { + // Why: the raw provider error can carry request metadata or credentials — log only the validated code. + const relayFailure = mobileRelayMintFailureFromUnknown({ + stage: 'create_pairing_relay', + error, + fallbackCode: 'relay_mint_failed', + fallbackMessage: 'Relay pairing invite request failed' + }) + console.warn(`[runtime] Failed to create Relay pairing invite: ${relayFailure.code}`) + return refuseAutomaticWithoutRelay(relayFailure) + } + const currentDevice = this.deviceRegistry?.getDevice(device.deviceId) + if ( + generation !== this.mobilePairingOfferGeneration || + relayProvider !== this.mobileRelayPairingProvider || + currentDevice?.token !== device.token || + this.deviceRegistry?.getMobilePairingConnectionMode(device.deviceId) !== 'automatic' + ) { + this.queueOrRetainRelayDeviceRevoke(device.deviceId, relayPairing.binding) + if (createdNewPendingDevice) { + this.discardPendingMobilePairingDevice(direct.deviceId) + } + return this.relayPairingRequestSuperseded() } try { - const relayPairing = await this.mobileRelayPairingProvider.createPairingRelay(device.deviceId) - if (!this.deviceRegistry?.setRelayBinding(device.deviceId, relayPairing.binding)) { - return { ...direct, connectionMode: 'local-only' } - } - this.mobileRelayPairingProvider.onDemandStateChanged?.() - return { - ...direct, - connectionMode: 'automatic', - pairingUrl: encodePairingOffer({ - v: PAIRING_OFFER_VERSION, - endpoint: direct.endpoint, - deviceToken: device.token, - publicKeyB64, - scope: 'mobile', - relay: relayPairing.relay + if (!this.setMobileRelayBinding(device.deviceId, relayPairing.binding)) { + this.queueOrRetainRelayDeviceRevoke(device.deviceId, relayPairing.binding) + return refuseAutomaticWithoutRelay({ + code: 'relay_binding_failed', + stage: 'binding_failed', + message: 'Could not store Relay binding for the pairing device' }) } - } catch { - // Why: relay is additive — a transient outage must still yield the valid LAN/Tailscale pairing offer. - return { ...direct, connectionMode: 'local-only' } + } catch (error) { + console.warn('[runtime] Failed to persist Relay pairing binding:', error) + this.queueOrRetainRelayDeviceRevoke(device.deviceId, relayPairing.binding) + return refuseAutomaticWithoutRelay({ + code: 'relay_binding_failed', + stage: 'binding_failed', + message: 'Could not store Relay binding for the pairing device' + }) + } + return { + ...direct, + connectionMode: 'automatic', + pairingUrl: encodePairingOffer({ + v: PAIRING_OFFER_VERSION, + endpoint: direct.endpoint, + deviceToken: device.token, + publicKeyB64, + scope: 'mobile', + relay: relayPairing.relay + }) } } - private queueRelayDeviceRevoke(binding: RelayDeviceBinding): void { - const item = this.relayRevokeOutbox.enqueue(binding) - this.mobileRelayPairingProvider?.onDeviceRevokeQueued(item) + private relayPairingRequestSuperseded(): PairingOfferUnavailable { + return { + available: false, + reason: 'relay_mint_failed', + guidance: 'The Relay pairing request was replaced by a newer connection choice.', + relayFailure: { + code: 'relay_request_superseded', + stage: 'binding_failed', + message: 'Relay pairing request superseded' + } + } + } + + /** Drop a never-scanned mobile pending credential after a failed Anywhere mint. */ + private discardPendingMobilePairingDevice(deviceId: string): void { + const device = this.deviceRegistry?.getDevice(deviceId) + if (!device || device.scope !== 'mobile' || device.lastSeenAt !== 0) { + return + } + if (device.relayBinding) { + if (!this.queueRelayDeviceRevoke(device.relayBinding)) { + return + } + } + try { + this.deviceRegistry?.removeDevice(deviceId) + } catch (error) { + console.error('[runtime] Failed to drop an unused mobile pairing credential:', error) + } + } + + /** + * Why: the outbox is the only durable cleanup record for a minted invite. When it can't be + * written, keep the binding on the device so cleanup keeps a reference instead of orphaning it. + */ + private queueOrRetainRelayDeviceRevoke(deviceId: string, binding: RelayDeviceBinding): void { + if (this.queueRelayDeviceRevoke(binding)) { + return + } + try { + this.deviceRegistry?.setRelayBinding(deviceId, binding) + } catch (error) { + console.error('[runtime] Failed to retain an unrevoked Relay binding:', error) + } + } + + private queueRelayDeviceRevoke(binding: RelayDeviceBinding): boolean { + let item: RelayRevokeOutboxItem + try { + item = this.relayRevokeOutbox.enqueue(binding) + } catch (error) { + console.error('[runtime] Failed to persist Relay device cleanup:', error) + return false + } + try { + this.mobileRelayPairingProvider?.onDeviceRevokeQueued(item) + } catch (error) { + console.warn('[runtime] Failed to notify Relay cleanup worker:', error) + } + return true } private registerBinaryStreamHandler( diff --git a/src/main/runtime/windows-drive-listing.test.ts b/src/main/runtime/windows-drive-listing.test.ts index 1d7387e7932..4ceef94bb55 100644 --- a/src/main/runtime/windows-drive-listing.test.ts +++ b/src/main/runtime/windows-drive-listing.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { Stats } from 'node:fs' import { isServerDriveListRequest, listWindowsDrives } from './windows-drive-listing' @@ -20,6 +20,10 @@ describe('isServerDriveListRequest', () => { }) describe('listWindowsDrives', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + const statOnly = (mounted: string[]) => async (p: string) => { if (!mounted.includes(p)) { throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) @@ -51,14 +55,62 @@ describe('listWindowsDrives', () => { expect(result.entries.map((e) => e.name)).toEqual(['C:\\']) }) - it.each(['EIO', 'EMFILE'])('surfaces systemic %s failures', async (code) => { + it('skips an unreadable drive with an unexpected errno and logs it', async () => { + const error = Object.assign(new Error('EIO'), { code: 'EIO' }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) const statPath = async (p: string): Promise => { if (p === 'D:\\') { - throw Object.assign(new Error(code), { code }) + throw error } throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) } - await expect(listWindowsDrives(statPath)).rejects.toMatchObject({ code }) + const result = await listWindowsDrives(statPath) + + expect(result.entries).toEqual([]) + expect(warn).toHaveBeenCalledOnce() + expect(warn).toHaveBeenCalledWith('[windows-drive-listing] Failed to stat drive', { + root: 'D:\\', + error + }) + }) + + it('surfaces programming errors from isDirectory', async () => { + const error = new TypeError('programmer bug') + const statPath = async (p: string): Promise => { + if (p === 'D:\\') { + return { + isDirectory: () => { + throw error + } + } as unknown as Stats + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + } + + await expect(listWindowsDrives(statPath)).rejects.toBe(error) + }) + + it('returns healthy drives among tolerated and unexpected stat failures', async () => { + const error = Object.assign(new Error('EIO'), { code: 'EIO' }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const statPath = async (p: string): Promise => { + if (p === 'C:\\' || p === 'F:\\') { + return { isDirectory: () => true } as Stats + } + if (p === 'E:\\') { + throw error + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + } + + const result = await listWindowsDrives(statPath) + + expect(result.entries.map((entry) => entry.name)).toEqual(['C:\\', 'F:\\']) + expect(warn).toHaveBeenCalledOnce() + expect(warn).toHaveBeenCalledWith('[windows-drive-listing] Failed to stat drive', { + root: 'E:\\', + error + }) }) }) diff --git a/src/main/runtime/windows-drive-listing.ts b/src/main/runtime/windows-drive-listing.ts index 9b2bbcfdbc0..1b9dbac062c 100644 --- a/src/main/runtime/windows-drive-listing.ts +++ b/src/main/runtime/windows-drive-listing.ts @@ -26,15 +26,16 @@ export async function listWindowsDrives( const roots = await Promise.all( [...DRIVE_LETTERS].map(async (letter) => { const root = `${letter}:\\` + let stats: Stats try { - const stats = await statPath(root) - return stats.isDirectory() ? root : null + stats = await statPath(root) } catch (error) { - if (isExpectedUnavailableDriveError(error)) { - return null + if (!isExpectedUnavailableDriveError(error)) { + console.warn('[windows-drive-listing] Failed to stat drive', { root, error }) } - throw error + return null } + return stats.isDirectory() ? root : null }) ) return { diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index 05790e88a9d..ba51231c02a 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -402,6 +402,7 @@ function registerRuntimeWindowLifecycle( ...(opts.viewMode ? { viewMode: opts.viewMode } : {}), activate: opts.activate !== false, ...(opts.presentation ? { presentation: opts.presentation } : {}), + ...(opts.surfaceOwner === false ? { surfaceOwner: false } : {}), // Why: pre-minted tabId aligns the renderer tab id with the paneKey baked into the PTY env, so hook events route right. ...(opts.tabId !== undefined ? { tabId: opts.tabId } : {}), ...(opts.leafId !== undefined ? { leafId: opts.leafId } : {}), @@ -472,6 +473,8 @@ function registerRuntimeWindowLifecycle( send('runtime:terminalFitOverrideChanged', { ptyId, mode, cols, rows }), terminalDriverChanged: (ptyId, driver) => send('runtime:terminalDriverChanged', { ptyId, driver }), + nativeChatLaunchDraftResolved: (tabId, resolution) => + send('runtime:nativeChatLaunchDraftResolved', { tabId, ...resolution }), browserDriverChanged: (browserPageId, driver) => send('runtime:browserDriverChanged', { browserPageId, driver }) }) diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index e0f86d43761..f52d07fd770 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -47,6 +47,8 @@ import type { } from '../shared/terminal-render-desync-evidence' import type { MobileRelayStatus } from '../shared/mobile-relay-status' import type { MobilePairingConnectionMode } from '../shared/mobile-pairing-connection-mode' +import type { MobileRelayMintFailure } from '../shared/mobile-relay-mint-failure' +import type { VerifyAndAddRuntimeEnvironmentResult } from '../shared/remote-pairing-verification' import type { SshMutationExpectation, SshConnectionState, @@ -3156,6 +3158,7 @@ export type PreloadApi = { activate?: boolean focus?: boolean presentation?: RuntimeTerminalPresentation + surfaceOwner?: false tabId?: string leafId?: string splitFromLeafId?: string @@ -3304,6 +3307,9 @@ export type PreloadApi = { onTerminalDriverChanged: ( callback: (event: { ptyId: string; driver: RuntimeTerminalDriverState }) => void ) => () => void + onNativeChatLaunchDraftResolved?: ( + callback: (event: { tabId: string; text: string; createdAt: number }) => void + ) => () => void onBrowserDriverChanged: ( callback: (event: { browserPageId: string; driver: RuntimeBrowserDriverState }) => void ) => () => void @@ -3314,6 +3320,11 @@ export type PreloadApi = { name: string pairingCode: string }) => Promise<{ environment: PublicKnownRuntimeEnvironment }> + verifyAndAddFromPairingCode: (args: { + name: string + pairingCode: string + allowLoopback?: boolean + }) => Promise resolve: (args: { selector: string }) => Promise remove: (args: { selector: string }) => Promise<{ removed: PublicKnownRuntimeEnvironment }> disconnect: (args: { @@ -3566,7 +3577,12 @@ export type PreloadApi = { connectionMode?: MobilePairingConnectionMode rotate?: boolean }) => Promise< - | { available: false } + | { + available: false + reason?: string + guidance?: string + relayFailure?: MobileRelayMintFailure + } | { available: true qrDataUrl: string | null @@ -3574,7 +3590,7 @@ export type PreloadApi = { pairingUrl: string endpoint: string deviceId: string - /** Mode the QR actually encodes; 'local-only' when Relay could not be attached. */ + /** Mode the QR actually encodes. */ connectionMode: MobilePairingConnectionMode } > diff --git a/src/preload/index.ts b/src/preload/index.ts index 63db42aeddd..fa42ae400fe 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -22,6 +22,8 @@ import type { } from '../shared/agent-session-resume' import type { MobileRelayStatus } from '../shared/mobile-relay-status' import type { MobilePairingConnectionMode } from '../shared/mobile-pairing-connection-mode' +import type { MobileRelayMintFailure } from '../shared/mobile-relay-mint-failure' +import type { VerifyAndAddRuntimeEnvironmentResult } from '../shared/remote-pairing-verification' import type { SshMutationExpectation, SshConnectionState, @@ -3721,6 +3723,7 @@ const api = { activate?: boolean focus?: boolean presentation?: RuntimeTerminalPresentation + surfaceOwner?: false tabId?: string leafId?: string splitFromLeafId?: string @@ -3746,6 +3749,7 @@ const api = { activate?: boolean focus?: boolean presentation?: RuntimeTerminalPresentation + surfaceOwner?: false tabId?: string leafId?: string splitFromLeafId?: string @@ -4250,6 +4254,16 @@ const api = { ipcRenderer.on('runtime:terminalDriverChanged', listener) return () => ipcRenderer.removeListener('runtime:terminalDriverChanged', listener) }, + onNativeChatLaunchDraftResolved: ( + callback: (event: { tabId: string; text: string; createdAt: number }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { tabId: string; text: string; createdAt: number } + ) => callback(data) + ipcRenderer.on('runtime:nativeChatLaunchDraftResolved', listener) + return () => ipcRenderer.removeListener('runtime:nativeChatLaunchDraftResolved', listener) + }, onBrowserDriverChanged: ( callback: (event: { browserPageId: string; driver: RuntimeBrowserDriverState }) => void ): (() => void) => { @@ -4273,6 +4287,12 @@ const api = { pairingCode: string }): Promise<{ environment: PublicKnownRuntimeEnvironment }> => ipcRenderer.invoke('runtimeEnvironments:addFromPairingCode', args), + verifyAndAddFromPairingCode: (args: { + name: string + pairingCode: string + allowLoopback?: boolean + }): Promise => + ipcRenderer.invoke('runtimeEnvironments:verifyAndAddFromPairingCode', args), resolve: (args: { selector: string }): Promise => ipcRenderer.invoke('runtimeEnvironments:resolve', args), remove: (args: { selector: string }): Promise<{ removed: PublicKnownRuntimeEnvironment }> => @@ -4567,7 +4587,12 @@ const api = { connectionMode?: MobilePairingConnectionMode rotate?: boolean }): Promise< - | { available: false } + | { + available: false + reason?: string + guidance?: string + relayFailure?: MobileRelayMintFailure + } | { available: true qrDataUrl: string | null diff --git a/src/renderer/src/assets/mobile-page.css b/src/renderer/src/assets/mobile-page.css index 6c0d16ee55e..2303b6a9d78 100644 --- a/src/renderer/src/assets/mobile-page.css +++ b/src/renderer/src/assets/mobile-page.css @@ -52,10 +52,12 @@ --m-mono: 'Menlo', 'SF Mono', monospace; position: relative; + container-type: inline-size; display: block; width: 100%; height: 100%; - overflow: hidden; + overflow-x: hidden; + overflow-y: auto; color: var(--foreground); } @@ -72,8 +74,7 @@ gap: clamp(56px, 7vw, 112px); padding: 56px; width: 100%; - height: 100%; - overflow: hidden; + min-height: 100%; background-color: transparent; /* Why: the page should feel branded without putting copy over decorative blobs; a token-based dot texture stays quiet in both themes. */ @@ -612,6 +613,20 @@ font-weight: 600; } +/* Why: empty QR frames need readable copy without looking like a stuck loader overlay. */ +.mobile-page-root .mp-qr-empty { + position: absolute; + inset: 10px; + display: grid; + place-items: center; + border-radius: 4px; + color: var(--muted-foreground); + font-size: 12px; + font-weight: 500; + line-height: 1.35; + text-wrap: balance; +} + .mobile-page-root .mp-qr-stack { display: flex; flex-direction: column; @@ -681,6 +696,14 @@ align-items: start; } +.mobile-page-root .mp-pairing-layout.has-failure { + grid-template-areas: + 'copy qr' + 'relay qr' + 'failure failure' + 'controls controls'; +} + .mobile-page-root .mp-pairing-copy { grid-area: copy; min-width: 0; @@ -703,6 +726,10 @@ min-width: 0; } +.mobile-page-root .mp-pairing-failure { + grid-area: failure; +} + /* Why: align the QR's top with the body copy ("Scan the QR…") instead of the eyebrow row above it. The eyebrow row is ~22px tall + 22px margin-bottom, and the h2 is ~38px line-height with no top margin — @@ -2102,3 +2129,33 @@ font-size: 17px; } } + +/* Why: the app sidebar narrows this page without changing viewport media + queries; keep this after viewport rules so real page width wins. */ +@container (max-width: 1080px) { + .mobile-page-root .mp-hero { + --mp-flow-card-padding: 20px; + + grid-template-columns: 1fr; + grid-template-rows: auto minmax(0, 1fr); + align-items: start; + gap: 24px; + padding: 52px 28px 28px; + } + + .mobile-page-root .mp-hero-copy { + min-height: 0; + } + + .mobile-page-root .mp-h1 { + font-size: 44px; + } + + .mobile-page-root .mp-stage { + height: min(420px, 42vh); + } + + .mobile-page-root .mp-phone-frame { + width: min(290px, 100%); + } +} diff --git a/src/renderer/src/components/editor/rich-markdown-editor-config.test.ts b/src/renderer/src/components/editor/rich-markdown-editor-config.test.ts index dd58ff40838..73c626a9ba8 100644 --- a/src/renderer/src/components/editor/rich-markdown-editor-config.test.ts +++ b/src/renderer/src/components/editor/rich-markdown-editor-config.test.ts @@ -1,7 +1,7 @@ import type { Dispatch, MutableRefObject, SetStateAction } from 'react' import type { Editor } from '@tiptap/react' import type { DocLinkMenuState } from './rich-markdown-commands' -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { LinkBubbleState } from './RichMarkdownLinkBubble' import { createRichMarkdownEditorConfig, @@ -88,6 +88,10 @@ function createConfigParams(overrides: Partial = {}): Editor } describe('createRichMarkdownEditorConfig', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + it('disables browser spellcheck when the rich Markdown setting is off', () => { const config = createRichMarkdownEditorConfig( createConfigParams({ richMarkdownSpellcheckEnabled: false }) @@ -101,4 +105,22 @@ describe('createRichMarkdownEditorConfig', () => { expect(getSpellcheckAttribute(config)).toBe('true') }) + + it('flushes pending serialization when the rich editor blurs', () => { + const setMarkdownEditorFocused = vi.fn() + vi.stubGlobal('window', { + api: { ui: { setMarkdownEditorFocused } } + }) + const flushPendingSerialization = vi.fn() + const clearAnnotationTarget = vi.fn() + const config = createRichMarkdownEditorConfig( + createConfigParams({ clearAnnotationTarget, flushPendingSerialization }) + ) + + config.onBlur?.({} as never) + + expect(setMarkdownEditorFocused).toHaveBeenCalledWith(false) + expect(clearAnnotationTarget).toHaveBeenCalledOnce() + expect(flushPendingSerialization).toHaveBeenCalledOnce() + }) }) diff --git a/src/renderer/src/components/editor/rich-markdown-editor-config.ts b/src/renderer/src/components/editor/rich-markdown-editor-config.ts index 5c0f93b69f1..275720cb5ab 100644 --- a/src/renderer/src/components/editor/rich-markdown-editor-config.ts +++ b/src/renderer/src/components/editor/rich-markdown-editor-config.ts @@ -204,6 +204,7 @@ export function createRichMarkdownEditorConfig(params: EditorConfigParams): UseE onBlur: () => { window.api.ui.setMarkdownEditorFocused(false) clearAnnotationTarget() + params.flushPendingSerialization() }, onCreate: ({ editor: nextEditor }) => { // Why: normalizeEmptyListItems (not normalizeSoftBreaks) so hard-wrapped diff --git a/src/renderer/src/components/mobile/MobileHero.test.tsx b/src/renderer/src/components/mobile/MobileHero.test.tsx index 4d47bcdefcb..88db885a548 100644 --- a/src/renderer/src/components/mobile/MobileHero.test.tsx +++ b/src/renderer/src/components/mobile/MobileHero.test.tsx @@ -2,7 +2,7 @@ import '@testing-library/jest-dom/vitest' -import { cleanup, render, screen } from '@testing-library/react' +import { cleanup, render, screen, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('@/i18n/i18n', () => ({ @@ -27,6 +27,7 @@ vi.mock('./WindowsFirewallNotice', () => ({ })) import { HeroFlow, type StepIndex } from './MobileHero' +import { MobileHeroPairingStep } from './MobileHeroPairingStep' class MockResizeObserver { observe = vi.fn() @@ -75,7 +76,10 @@ describe('HeroFlow height', () => { pairQrDataUrl={null} pairingUrl={null} pairingQrError={false} - relayDegraded={false} + relayMintFailure={null} + onUseLan={vi.fn()} + onRetryRelay={vi.fn()} + onCopyRelayDiagnostics={vi.fn()} pairLoading={false} connectionMode="automatic" onConnectionModeChange={vi.fn()} @@ -115,7 +119,10 @@ describe('HeroFlow height', () => { pairQrDataUrl={null} pairingUrl={null} pairingQrError={false} - relayDegraded={false} + relayMintFailure={null} + onUseLan={vi.fn()} + onRetryRelay={vi.fn()} + onCopyRelayDiagnostics={vi.fn()} pairLoading={false} connectionMode="automatic" onConnectionModeChange={vi.fn()} @@ -137,23 +144,69 @@ describe('HeroFlow height', () => { expect(screen.getByText('Step 1 of 2').closest('.mp-flow-screen')).toHaveAttribute('inert') }) - it('flags a degraded Anywhere code and always shows the Relay beta note', () => { + it('shows Relay mint failure with no QR and the beta note', () => { renderFlow(1, { - pairQrDataUrl: 'data:image/png;base64,qr', - relayDegraded: true + pairQrDataUrl: null, + relayMintFailure: { + code: 'relay offline', + stage: 'create_pairing_relay', + message: 'relay offline' + } }) - const notice = screen.getByTestId('relay-degraded-notice') - expect(notice).toHaveTextContent('only works on your LAN or Tailscale') - // Why: wrap-capable text item inside the fixed QR track (#9700); bare text - // nodes in a flex row cannot shrink below max-content and overflow the track. - expect(notice.querySelector('.min-w-0')).not.toBeNull() - expect(notice.className).toMatch(/\bmin-w-0\b/) + const notice = screen.getByTestId('relay-mint-failure-notice') + expect(notice).toHaveTextContent('Couldn’t create a Relay pairing code') + expect(notice).toHaveTextContent('Use LAN') + expect(screen.getByText('No pairing code available')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Generate code' })).not.toBeInTheDocument() expect(screen.getByText('Orca Relay is in beta.')).toBeInTheDocument() }) - it('hides the degradation notice when the code encodes what was selected', () => { + it('explains an empty QR frame when no code has been generated yet', () => { + renderFlow(1, { pairQrDataUrl: null, canGeneratePairing: true }) + expect(screen.getByText('Generate a pairing code to continue')).toBeInTheDocument() + }) + + it('explains an empty QR frame when Relay sign-in is required', () => { + renderFlow(1, { + pairQrDataUrl: null, + canGeneratePairing: false, + connectionMode: 'automatic' + }) + expect(screen.getByText('Sign in to create a Relay pairing code')).toBeInTheDocument() + }) + + it('disables Relay recovery immediately and delays visible retry feedback', async () => { + renderFlow(1, { + pairLoading: true, + relayMintFailure: { + code: 'relay_mint_failed', + stage: 'create_pairing_relay', + message: 'Relay pairing invite request failed' + } + }) + expect(screen.getByRole('button', { name: 'Retry Relay' })).toBeDisabled() + expect(screen.getByRole('button', { name: 'Use LAN' })).toBeEnabled() + await waitFor(() => expect(screen.getByText(/Creating a new pairing code/)).toBeVisible()) + }) + + it('does not offer a futile retry when Relay is unavailable on the desktop', () => { + renderFlow(1, { + relayMintFailure: { + code: 'relay_provider_unavailable', + stage: 'provider_missing', + message: 'Orca Relay is not available on this desktop' + } + }) + expect(screen.getByRole('alert')).toHaveTextContent( + 'Orca Relay isn’t available on this desktop' + ) + expect(screen.queryByRole('button', { name: 'Retry Relay' })).toBeNull() + expect(screen.getByRole('button', { name: 'Use LAN' })).toBeEnabled() + }) + + it('hides the mint-failure notice when a Relay QR is shown', () => { renderFlow(1, { pairQrDataUrl: 'data:image/png;base64,qr' }) - expect(screen.queryByTestId('relay-degraded-notice')).not.toBeInTheDocument() + expect(screen.queryByTestId('relay-mint-failure-notice')).not.toBeInTheDocument() }) it('shows an encoder error while keeping the copy fallback enabled', () => { @@ -165,4 +218,83 @@ describe('HeroFlow height', () => { expect(screen.getByRole('alert')).toHaveTextContent('couldn’t be rendered as a QR code') expect(screen.getByRole('button', { name: /Copy pairing code/ })).toBeEnabled() }) + + it('moves focus to the pairing-code action after recovery succeeds', () => { + const props: React.ComponentProps = { + pairQrDataUrl: null, + pairingUrl: null, + pairingQrError: false, + relayMintFailure: { + code: 'relay_mint_failed', + stage: 'create_pairing_relay', + message: 'Relay pairing invite request failed' + }, + onUseLan: vi.fn(), + onRetryRelay: vi.fn(), + onCopyRelayDiagnostics: vi.fn(), + pairLoading: false, + connectionMode: 'automatic', + onConnectionModeChange: vi.fn(), + onRegeneratePairing: vi.fn(), + canGeneratePairing: true, + onCopyPairingCode: vi.fn(), + networkInterfaces: [], + selectedAddress: undefined, + onSelectedAddressChange: vi.fn(), + beforeCustomAddressChange: vi.fn().mockResolvedValue(true), + onRefreshNetworkInterfaces: vi.fn(), + refreshingNetworkInterfaces: false + } + const { rerender } = render() + screen.getByRole('button', { name: 'Retry Relay' }).focus() + + rerender( + + ) + + expect(screen.getByRole('button', { name: 'Copy pairing code' })).toHaveFocus() + }) + + it('keeps focus on a persistent control when an ordinary remint finishes', () => { + const props: React.ComponentProps = { + pairQrDataUrl: null, + pairingUrl: null, + pairingQrError: false, + relayMintFailure: null, + onUseLan: vi.fn(), + onRetryRelay: vi.fn(), + onCopyRelayDiagnostics: vi.fn(), + pairLoading: true, + connectionMode: 'local-only', + onConnectionModeChange: vi.fn(), + onRegeneratePairing: vi.fn(), + canGeneratePairing: true, + onCopyPairingCode: vi.fn(), + networkInterfaces: [], + selectedAddress: undefined, + onSelectedAddressChange: vi.fn(), + beforeCustomAddressChange: vi.fn().mockResolvedValue(true), + onRefreshNetworkInterfaces: vi.fn(), + refreshingNetworkInterfaces: false + } + const { rerender } = render() + const refresh = screen.getByRole('button', { name: 'Refresh network interfaces' }) + refresh.focus() + + rerender( + + ) + + expect(refresh).toHaveFocus() + }) }) diff --git a/src/renderer/src/components/mobile/MobileHero.tsx b/src/renderer/src/components/mobile/MobileHero.tsx index 291a6ffcbc3..078ee157175 100644 --- a/src/renderer/src/components/mobile/MobileHero.tsx +++ b/src/renderer/src/components/mobile/MobileHero.tsx @@ -1,14 +1,12 @@ import { useLayoutEffect, useRef, useState } from 'react' -import { ArrowLeft, ArrowRight, CircleAlert, Copy, RefreshCw } from 'lucide-react' +import { ArrowLeft, ArrowRight, Copy } from 'lucide-react' import { cn } from '../../lib/utils' import type { MobileNetworkInterface } from '../settings/mobile-network-interface-selection' import { AndroidLogo, IosBrandIcon } from './MobileBrandIcons' -import { NetworkInterfacePicker } from './NetworkInterfacePicker' -import { MobilePairingConnectionOptions } from '../settings/MobilePairingConnectionOptions' -import { MobileRelayBetaNotice } from '../settings/MobileRelayBetaNotice' import { getChannelTagline, type InstallCopy, type IosChannel } from './mobile-platform-copy' -import { WindowsFirewallNotice } from './WindowsFirewallNotice' import type { MobilePairingConnectionMode } from '../../../../shared/mobile-pairing-connection-mode' +import type { MobileRelayMintFailure } from '../../../../shared/mobile-relay-mint-failure' +import { MobileHeroPairingStep } from './MobileHeroPairingStep' export { HeroIntro } from './MobileHeroIntro' export { HeroPaired, type PairedDevice } from './MobileHeroPairedDevices' import { translate } from '@/i18n/i18n' @@ -16,18 +14,6 @@ import { translate } from '@/i18n/i18n' export type Platform = 'ios' | 'android' export type StepIndex = 0 | 1 -// Why: header copy needs to refer to the *user's* device by its native name. -function getDeviceLabel(): string { - const ua = navigator.userAgent - if (ua.includes('Mac')) { - return 'Mac' - } - if (ua.includes('Windows')) { - return 'PC' - } - return 'computer' -} - type HeroFlowProps = { stepIdx: StepIndex platform: Platform @@ -41,8 +27,10 @@ type HeroFlowProps = { pairQrDataUrl: string | null pairingUrl: string | null pairingQrError: boolean - /** True when the shown QR degraded to local-only under an Anywhere selection. */ - relayDegraded: boolean + relayMintFailure: MobileRelayMintFailure | null + onUseLan: () => void + onRetryRelay: () => void + onCopyRelayDiagnostics: () => void pairLoading: boolean connectionMode: MobilePairingConnectionMode onConnectionModeChange: (mode: MobilePairingConnectionMode) => void @@ -73,7 +61,10 @@ export function HeroFlow({ pairQrDataUrl, pairingUrl, pairingQrError, - relayDegraded, + relayMintFailure, + onUseLan, + onRetryRelay, + onCopyRelayDiagnostics, pairLoading, connectionMode, onConnectionModeChange, @@ -202,13 +193,7 @@ export function HeroFlow({ -
+
{installQrUrl ? ( -
-
-
-
2
- - {translate('auto.components.mobile.MobileHero.3960f5c339', 'Step 2 of 2')} - -
-

- {translate('auto.components.mobile.MobileHero.901c98bb93', 'Pair this')}{' '} - {getDeviceLabel()}. -

-

- {translate('auto.components.mobile.MobileHero.d1495e5e64', 'Open Orca Mobile, tap')}{' '} - - {translate('auto.components.mobile.MobileHero.3aa7bb2d8b', 'Pair Desktop')} - - {translate('auto.components.mobile.MobileHero.2f077ef4eb', ', and scan the code.')} -

-
-
- - -
-
-
- {pairQrDataUrl ? ( - {translate('auto.components.mobile.MobileHero.27735e5f4e', - ) : null} - {pairLoading ? ( - - {translate('auto.components.mobile.MobileHero.65b3f2e8bc', 'Generating…')} - - ) : null} -
- - {relayDegraded ? ( -

- - {/* Why: min-w-0 so the flex text item can wrap inside the fixed QR track (#9700). */} - - {translate( - 'auto.components.mobile.MobileHero.relayDegradedNotice', - 'Relay couldn’t be reached — this code only works on your LAN or Tailscale.' - )} - -

- ) : null} - {pairingQrError ? ( -

- - - {translate( - 'auto.components.mobile.MobileHero.pairingQrError', - 'This pairing code couldn’t be rendered as a QR code. Copy it into Orca Mobile instead.' - )} - -

- ) : null} -
-
-
- - {translate('auto.components.mobile.MobileHero.dfd2aa9d5d', 'Network')} - - - -
- -
- - {translate('auto.components.mobile.MobileHero.4c1df4eba7', "Can't scan?")} - - -
- -
-
+
diff --git a/src/renderer/src/components/mobile/MobileHeroPairingStep.tsx b/src/renderer/src/components/mobile/MobileHeroPairingStep.tsx new file mode 100644 index 00000000000..80bc79399cc --- /dev/null +++ b/src/renderer/src/components/mobile/MobileHeroPairingStep.tsx @@ -0,0 +1,268 @@ +import { useEffect, useRef } from 'react' +import { CircleAlert, Copy, RefreshCw } from 'lucide-react' +import { cn } from '../../lib/utils' +import type { MobileNetworkInterface } from '../settings/mobile-network-interface-selection' +import { NetworkInterfacePicker } from './NetworkInterfacePicker' +import { MobilePairingConnectionOptions } from '../settings/MobilePairingConnectionOptions' +import { MobileRelayBetaNotice } from '../settings/MobileRelayBetaNotice' +import { MobileRelayMintFailureNotice } from './mobile-relay-mint-failure-notice' +import { WindowsFirewallNotice } from './WindowsFirewallNotice' +import type { MobilePairingConnectionMode } from '../../../../shared/mobile-pairing-connection-mode' +import type { MobileRelayMintFailure } from '../../../../shared/mobile-relay-mint-failure' +import { translate } from '@/i18n/i18n' + +/** Why: one full sentence per device kind so translators own word order and punctuation. */ +function pairDeviceHeading(): string { + const ua = navigator.userAgent + if (ua.includes('Mac')) { + return translate('auto.components.mobile.MobileHero.pairThisMac', 'Pair this Mac.') + } + if (ua.includes('Windows')) { + return translate('auto.components.mobile.MobileHero.pairThisPc', 'Pair this PC.') + } + return translate('auto.components.mobile.MobileHero.pairThisComputer', 'Pair this computer.') +} + +/** Short copy for the QR frame when no image can be shown. */ +function emptyPairingQrMessage(args: { + relayMintFailure: MobileRelayMintFailure | null + canGeneratePairing: boolean + connectionMode: MobilePairingConnectionMode + pairingQrError: boolean + pairingUrl: string | null +}): string { + if (args.relayMintFailure != null) { + return translate('auto.components.mobile.MobileHero.noRelayCode', 'No pairing code available') + } + if (!args.canGeneratePairing && args.connectionMode === 'automatic') { + return translate( + 'auto.components.mobile.MobileHero.qrSignInRequired', + 'Sign in to create a Relay pairing code' + ) + } + if (args.pairingQrError && args.pairingUrl != null) { + return translate( + 'auto.components.mobile.MobileHero.qrRenderFailed', + 'QR couldn’t be rendered — copy the code below' + ) + } + if (!args.canGeneratePairing) { + return translate('auto.components.mobile.MobileHero.noPairingCode', 'No pairing code available') + } + return translate( + 'auto.components.mobile.MobileHero.qrGeneratePrompt', + 'Generate a pairing code to continue' + ) +} + +export function MobileHeroPairingStep({ + pairQrDataUrl, + pairingUrl, + pairingQrError, + relayMintFailure, + onUseLan, + onRetryRelay, + onCopyRelayDiagnostics, + pairLoading, + connectionMode, + onConnectionModeChange, + onRegeneratePairing, + canGeneratePairing, + onCopyPairingCode, + networkInterfaces, + selectedAddress, + onSelectedAddressChange, + beforeCustomAddressChange, + onRefreshNetworkInterfaces, + refreshingNetworkInterfaces +}: { + pairQrDataUrl: string | null + pairingUrl: string | null + pairingQrError: boolean + relayMintFailure: MobileRelayMintFailure | null + onUseLan: () => void + onRetryRelay: () => void + onCopyRelayDiagnostics: () => void + pairLoading: boolean + connectionMode: MobilePairingConnectionMode + onConnectionModeChange: (mode: MobilePairingConnectionMode) => void + onRegeneratePairing: () => void + canGeneratePairing: boolean + onCopyPairingCode: () => void + networkInterfaces: readonly MobileNetworkInterface[] + selectedAddress: string | undefined + onSelectedAddressChange: (address: string) => void + beforeCustomAddressChange: (address: string) => Promise + onRefreshNetworkInterfaces: () => void + refreshingNetworkInterfaces: boolean +}): React.JSX.Element { + const copyPairingCodeRef = useRef(null) + const pairingWasReadyRef = useRef(pairingUrl != null && !pairLoading) + const emptyQrMessage = + !pairLoading && pairQrDataUrl == null + ? emptyPairingQrMessage({ + relayMintFailure, + canGeneratePairing, + connectionMode, + pairingQrError, + pairingUrl + }) + : null + + useEffect(() => { + const pairingReady = pairingUrl != null && !pairLoading + const becameReady = !pairingWasReadyRef.current && pairingReady + pairingWasReadyRef.current = pairingReady + if (becameReady && document.activeElement === document.body) { + copyPairingCodeRef.current?.focus() + } + }, [pairLoading, pairingUrl]) + + return ( +
+
+
+
2
+ + {translate('auto.components.mobile.MobileHero.3960f5c339', 'Step 2 of 2')} + +
+

{pairDeviceHeading()}

+

+ {translate('auto.components.mobile.MobileHero.d1495e5e64', 'Open Orca Mobile, tap')}{' '} + + {translate('auto.components.mobile.MobileHero.3aa7bb2d8b', 'Pair Desktop')} + + {translate('auto.components.mobile.MobileHero.2f077ef4eb', ', and scan the code.')} +

+
+
+ + +
+ {relayMintFailure != null ? ( + + ) : null} +
+
+ {pairQrDataUrl ? ( + {translate('auto.components.mobile.MobileHero.27735e5f4e', + ) : null} + {pairLoading ? ( + + {translate('auto.components.mobile.MobileHero.65b3f2e8bc', 'Generating…')} + + ) : null} + {emptyQrMessage != null ? ( + + {emptyQrMessage} + + ) : null} +
+ + {pairQrDataUrl != null && !pairLoading + ? translate('auto.components.mobile.MobileHero.pairingCodeReady', 'Pairing code ready') + : ''} + + {relayMintFailure == null ? ( + + ) : null} + {pairingQrError ? ( +

+ + + {translate( + 'auto.components.mobile.MobileHero.pairingQrError', + 'This pairing code couldn’t be rendered as a QR code. Copy it into Orca Mobile instead.' + )} + +

+ ) : null} +
+
+
+ + {translate('auto.components.mobile.MobileHero.dfd2aa9d5d', 'Network')} + + + +
+ +
+ + {translate('auto.components.mobile.MobileHero.4c1df4eba7', "Can't scan?")} + + +
+ +
+
+ ) +} diff --git a/src/renderer/src/components/mobile/MobilePage.test.tsx b/src/renderer/src/components/mobile/MobilePage.test.tsx index 9c2b7628869..a070d813e12 100644 --- a/src/renderer/src/components/mobile/MobilePage.test.tsx +++ b/src/renderer/src/components/mobile/MobilePage.test.tsx @@ -6,6 +6,7 @@ import { cleanup, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { MobilePairingConnectionMode } from '../../../../shared/mobile-pairing-connection-mode' +import type { MobileRelayMintFailure } from '../../../../shared/mobile-relay-mint-failure' type StoreState = { closeMobilePage: () => void @@ -48,6 +49,8 @@ vi.mock('./MobilePageContent', () => ({ pairQrDataUrl: string | null pairingUrl: string | null pairingQrError: boolean + relayMintFailure: MobileRelayMintFailure | null + onRetryRelay: () => void stage: string | null stepIdx: number }) => ( @@ -59,6 +62,7 @@ vi.mock('./MobilePageContent', () => ({ {props.pairQrDataUrl ?? 'none'} {props.pairingUrl ?? 'none'} {String(props.pairingQrError)} + {props.relayMintFailure?.stage ?? 'none'} @@ -71,6 +75,9 @@ vi.mock('./MobilePageContent', () => ({ + @@ -310,7 +317,90 @@ describe('MobilePage pairing connection mode', () => { expect(screen.getByTestId('pairing-url')).toHaveTextContent('copy-fallback') }) - it('does not commit a custom address when its real QR preflight fails', async () => { + it('surfaces Relay failure and retries with a rotated credential', async () => { + getPairingQR.mockResolvedValueOnce({ + available: false, + reason: 'relay_mint_failed', + relayFailure: { + code: 'relay_control_not_active', + stage: 'create_pairing_relay', + message: 'Relay pairing invite request failed' + } + }) + const user = userEvent.setup() + await openPairingStep() + await waitFor(() => + expect(screen.getByTestId('relay-failure')).toHaveTextContent('create_pairing_relay') + ) + + getPairingQR.mockResolvedValueOnce({ + available: true, + qrDataUrl: 'data:image/png;base64,retried', + pairingUrl: 'orca://pair#retried', + endpoint: 'ws://host', + connectionMode: 'automatic' + }) + await user.click(screen.getByRole('button', { name: 'Retry Relay' })) + + await waitFor(() => + expect(getPairingQR).toHaveBeenLastCalledWith({ + connectionMode: 'automatic', + rotate: true + }) + ) + await waitFor(() => expect(screen.getByTestId('pairing-qr')).toHaveTextContent('retried')) + expect(screen.getByTestId('relay-failure')).toHaveTextContent('none') + }) + + it('switches to LAN while a Relay retry is still unresolved', async () => { + getPairingQR.mockResolvedValueOnce({ + available: false, + reason: 'relay_mint_failed', + relayFailure: { + code: 'relay_mint_failed', + stage: 'create_pairing_relay', + message: 'Relay pairing invite request failed' + } + }) + const user = userEvent.setup() + await openPairingStep() + await waitFor(() => + expect(screen.getByTestId('relay-failure')).toHaveTextContent('create_pairing_relay') + ) + + let resolveRetry: ((value: Record) => void) | undefined + getPairingQR.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveRetry = resolve + }) + ) + getPairingQR.mockResolvedValueOnce({ + available: true, + qrDataUrl: 'data:image/png;base64,local', + pairingUrl: 'orca://pair#local', + endpoint: 'ws://host', + connectionMode: 'local-only' + }) + await user.click(screen.getByRole('button', { name: 'Retry Relay' })) + await user.click(screen.getByRole('button', { name: 'LAN' })) + + await waitFor(() => + expect(getPairingQR).toHaveBeenLastCalledWith({ connectionMode: 'local-only' }) + ) + await waitFor(() => expect(screen.getByTestId('pairing-qr')).toHaveTextContent('base64,local')) + resolveRetry?.({ + available: true, + qrDataUrl: 'data:image/png;base64,stale-relay', + pairingUrl: 'orca://pair#stale-relay', + endpoint: 'ws://relay', + connectionMode: 'automatic' + }) + await waitFor(() => expect(screen.getByTestId('pairing-qr')).toHaveTextContent('base64,local')) + expect(screen.getByTestId('mode')).toHaveTextContent('local-only') + }) + + it('does not commit a custom direct address when its QR preflight fails', async () => { const user = userEvent.setup() await openPairingStep() await waitFor(() => expect(getPairingQR).toHaveBeenCalledTimes(1)) @@ -331,7 +421,6 @@ describe('MobilePage pairing connection mode', () => { connectionMode: 'automatic' }) ) - await new Promise((resolve) => setTimeout(resolve, 20)) expect(getPairingQR).toHaveBeenCalledTimes(2) }) }) diff --git a/src/renderer/src/components/mobile/MobilePage.tsx b/src/renderer/src/components/mobile/MobilePage.tsx index f058f5198e6..d45083bc1d4 100644 --- a/src/renderer/src/components/mobile/MobilePage.tsx +++ b/src/renderer/src/components/mobile/MobilePage.tsx @@ -21,6 +21,7 @@ import { useMobilePairingGeneration } from './use-mobile-pairing-generation' import { useMobilePairingQrInvalidation } from './use-mobile-pairing-qr-invalidation' import { useMobileInstallActions } from './use-mobile-install-actions' import { useMobilePagePairedDevices } from './use-mobile-page-paired-devices' +import type { MobileRelayMintFailure } from '../../../../shared/mobile-relay-mint-failure' export default function MobilePage(): React.JSX.Element { const [stepIdx, setStepIdx] = useState(0) @@ -33,10 +34,7 @@ export default function MobilePage(): React.JSX.Element { const [pairQrDataUrl, setPairQrDataUrl] = useState(null) const [pairingUrl, setPairingUrl] = useState(null) const [pairingQrError, setPairingQrError] = useState(false) - // Mode the displayed QR actually encodes; can be 'local-only' under an - // Anywhere selection when Relay provisioning degraded server-side. - const [encodedConnectionMode, setEncodedConnectionMode] = - useState(null) + const [relayMintFailure, setRelayMintFailure] = useState(null) const [pairLoading, setPairLoading] = useState(false) const signedIn = useAppStore((state) => state.orcaProfileAuthStatus?.state === 'connected') const [connectionMode, setConnectionMode] = useMobilePairingConnectionMode() @@ -77,7 +75,7 @@ export default function MobilePage(): React.JSX.Element { setPairingUrl, setPairingQrError, setPairLoading, - setEncodedConnectionMode + setRelayMintFailure }) const handleConnectionModeChange = useCallback( @@ -88,12 +86,43 @@ export default function MobilePage(): React.JSX.Element { // Why: persist the pick and update local state. The QR invalidation + // rotate-regenerate is handled centrally by useMobilePairingQrInvalidation // (below), which also covers cross-window preference syncs. + setRelayMintFailure(null) setConnectionMode(nextMode) void updateSettings({ mobilePairingConnectionMode: nextMode }) }, [connectionMode, updateSettings, setConnectionMode] ) + const copyRelayDiagnostics = useCallback(async (): Promise => { + if (relayMintFailure == null) { + return + } + // Why: users share this payload — the selected address would leak a LAN/Tailscale IP or hostname. + const payload = { + kind: 'mobile_pairing_relay_failure', + preferredConnectionMode: connectionMode, + failure: relayMintFailure, + at: new Date().toISOString() + } + try { + await window.api.ui.writeClipboardText(JSON.stringify(payload, null, 2)) + if (mountedRef.current) { + toast.success( + translate('auto.components.mobile.MobilePage.diagnosticsCopied', 'Diagnostics copied') + ) + } + } catch { + if (mountedRef.current) { + toast.error( + translate( + 'auto.components.mobile.MobilePage.diagnosticsCopyFailed', + 'Failed to copy diagnostics' + ) + ) + } + } + }, [connectionMode, mountedRef, relayMintFailure]) + useMobilePairingQrInvalidation({ connectionMode, signedIn, @@ -104,6 +133,7 @@ export default function MobilePage(): React.JSX.Element { setPairingUrl, setPairingQrError, setPairLoading, + setRelayMintFailure, regenerate: (mode, opts) => void generatePairing(opts.rotate, undefined, mode) }) @@ -168,8 +198,6 @@ export default function MobilePage(): React.JSX.Element { // not snap it back to a tailnet/LAN fallback. const isManual = !networkInterfaces.some((iface) => iface.address === address) setAddressIsManual(isManual) - // Switching network must remint so the QR encodes the new endpoint — - // but only when the selected path may honestly mint (not signed-out Anywhere). if (canMintMobilePairingOffer({ connectionMode, signedIn })) { void generatePairing(true, address) } @@ -237,7 +265,7 @@ export default function MobilePage(): React.JSX.Element { setPairQrDataUrl(null) setPairingUrl(null) setPairingQrError(false) - setEncodedConnectionMode(null) + setRelayMintFailure(null) showFirstPairingFlow() } @@ -248,7 +276,7 @@ export default function MobilePage(): React.JSX.Element { setPairQrDataUrl(null) setPairingUrl(null) setPairingQrError(false) - setEncodedConnectionMode(null) + setRelayMintFailure(null) showPairAnotherDeviceFlow() } @@ -299,11 +327,12 @@ export default function MobilePage(): React.JSX.Element { pairQrDataUrl={pairQrDataUrl} pairingUrl={pairingUrl} pairingQrError={pairingQrError} - relayDegraded={ - pairQrDataUrl != null && - connectionMode === 'automatic' && - encodedConnectionMode === 'local-only' + relayMintFailure={ + connectionMode === 'automatic' && pairQrDataUrl == null ? relayMintFailure : null } + onUseLan={() => handleConnectionModeChange('local-only')} + onRetryRelay={() => void generatePairing(true)} + onCopyRelayDiagnostics={() => void copyRelayDiagnostics()} platform={platform} refreshingNetworkInterfaces={refreshingNetworkInterfaces} revokeDevice={(id) => void revokeDevice(id)} diff --git a/src/renderer/src/components/mobile/MobilePageContent.tsx b/src/renderer/src/components/mobile/MobilePageContent.tsx index 5e63d57fd93..a5f4078b0c0 100644 --- a/src/renderer/src/components/mobile/MobilePageContent.tsx +++ b/src/renderer/src/components/mobile/MobilePageContent.tsx @@ -13,6 +13,7 @@ import type { MobilePageStage } from './mobile-page-stage' import { MobilePageToolbar } from './MobilePageToolbar' import { PhoneCarousel } from './PhoneCarousel' import type { MobilePairingConnectionMode } from '../../../../shared/mobile-pairing-connection-mode' +import type { MobileRelayMintFailure } from '../../../../shared/mobile-relay-mint-failure' type MobilePageContentProps = { closeMobilePage: () => void @@ -39,7 +40,10 @@ type MobilePageContentProps = { pairQrDataUrl: string | null pairingUrl: string | null pairingQrError: boolean - relayDegraded: boolean + relayMintFailure: MobileRelayMintFailure | null + onUseLan: () => void + onRetryRelay: () => void + onCopyRelayDiagnostics: () => void platform: Platform refreshingNetworkInterfaces: boolean revokeDevice: (id: string) => void @@ -78,7 +82,10 @@ export function MobilePageContent({ pairQrDataUrl, pairingUrl, pairingQrError, - relayDegraded, + relayMintFailure, + onUseLan, + onRetryRelay, + onCopyRelayDiagnostics, platform, refreshingNetworkInterfaces, revokeDevice, @@ -92,7 +99,7 @@ export function MobilePageContent({ toggleMobileSidebarButton }: MobilePageContentProps): React.JSX.Element { return ( -
+
void + onRetry: () => void + onCopyDiagnostics: () => void + className?: string + compact?: boolean + busy?: boolean +}): React.JSX.Element { + const providerMissing = failure.stage === 'provider_missing' + const [showBusyFeedback, setShowBusyFeedback] = useState(false) + useEffect(() => { + if (!busy) { + setShowBusyFeedback(false) + return + } + const timer = window.setTimeout(() => setShowBusyFeedback(true), 200) + return () => window.clearTimeout(timer) + }, [busy]) + const visibleBusy = busy && showBusyFeedback + const title = visibleBusy + ? translate( + 'auto.components.mobile.MobileRelayMintFailureNotice.retryingTitle', + 'Retrying Orca Relay…' + ) + : providerMissing + ? translate( + 'auto.components.mobile.MobileRelayMintFailureNotice.unavailableTitle', + 'Orca Relay isn’t available on this desktop.' + ) + : translate( + 'auto.components.mobile.MobileRelayMintFailureNotice.title', + 'Couldn’t create a Relay pairing code.' + ) + const body = visibleBusy + ? translate( + 'auto.components.mobile.MobileRelayMintFailureNotice.retryingBody', + 'Creating a new pairing code. This can take a moment over a remote connection.' + ) + : providerMissing + ? translate( + 'auto.components.mobile.MobileRelayMintFailureNotice.unavailableBody', + 'Use LAN to pair over Tailscale or the same Wi‑Fi.' + ) + : translate( + 'auto.components.mobile.MobileRelayMintFailureNotice.body', + 'Retry, or use LAN to pair over Tailscale or the same Wi‑Fi.' + ) + + return ( +
+ {visibleBusy ? ( + + ) : ( + + )} +
+

+ {title} {body} +

+
+ + {!providerMissing ? ( + + ) : null} + +
+
+
+ ) +} diff --git a/src/renderer/src/components/mobile/use-mobile-pairing-generation.ts b/src/renderer/src/components/mobile/use-mobile-pairing-generation.ts index 2d6f57c6598..92db1931f84 100644 --- a/src/renderer/src/components/mobile/use-mobile-pairing-generation.ts +++ b/src/renderer/src/components/mobile/use-mobile-pairing-generation.ts @@ -5,13 +5,15 @@ import { canMintMobilePairingOffer, type MobilePairingConnectionMode } from '../../../../shared/mobile-pairing-connection-mode' +import type { MobileRelayMintFailure } from '../../../../shared/mobile-relay-mint-failure' type MutableRef = { current: T } /** * Mints (or rotates) a pairing QR. Every caller must go through this path so * signed-out Anywhere is refused rather than silently degraded to a local-only - * code under the Relay label. + * code under the Relay label. Anywhere mint failures surface relayFailure and + * clear any QR. */ export function useMobilePairingGeneration(params: { connectionMode: MobilePairingConnectionMode @@ -24,9 +26,7 @@ export function useMobilePairingGeneration(params: { setPairingUrl: (value: string | null) => void setPairingQrError: (value: boolean) => void setPairLoading: (value: boolean) => void - /** Mode the minted QR actually encodes (degraded Relay mints report - * 'local-only'); null while no QR is shown. */ - setEncodedConnectionMode: (value: MobilePairingConnectionMode | null) => void + setRelayMintFailure: (value: MobileRelayMintFailure | null) => void }): { generatePairing: ( rotate: boolean, @@ -45,7 +45,7 @@ export function useMobilePairingGeneration(params: { setPairingUrl, setPairingQrError, setPairLoading, - setEncodedConnectionMode + setRelayMintFailure } = params const generatePairing = useCallback( @@ -55,22 +55,16 @@ export function useMobilePairingGeneration(params: { connectionModeOverride?: MobilePairingConnectionMode ) => { const preferredMode = connectionModeOverride ?? connectionMode - // Why: every mint path (auto-generate, regenerate, address change, path - // invalidation) must refuse signed-out Anywhere rather than degrade to a - // local-only QR under the Relay label. if (!canMintMobilePairingOffer({ connectionMode: preferredMode, signedIn })) { return } const requestId = ++pairingRequestIdRef.current - // Mark the request synchronously so state changes cannot make the - // Step 2 auto-generate effect start a second offer in parallel. hasGeneratedRef.current = true if (mountedRef.current) { setPairLoading(true) } try { const address = addressOverride ?? selectedAddress - // canMint already requires sign-in for Anywhere, so preferred is honest. const result = await window.api.mobile.getPairingQR({ ...(address ? { address } : {}), connectionMode: preferredMode, @@ -84,21 +78,28 @@ export function useMobilePairingGeneration(params: { setPairQrDataUrl(result.qrDataUrl) setPairingUrl(result.pairingUrl) setPairingQrError(result.qrDataUrl === null) - setEncodedConnectionMode(result.connectionMode) + setRelayMintFailure(null) } } else { - hasGeneratedRef.current = false + // Why: keep hasGenerated so step-2 auto-mint does not loop on failure. if (mountedRef.current) { setPairQrDataUrl(null) setPairingUrl(null) setPairingQrError(false) - setEncodedConnectionMode(null) - toast.error( - translate( - 'auto.components.mobile.MobilePage.b353e18de1', - 'WebSocket transport is not running' + if (result.reason === 'relay_mint_failed' && result.relayFailure) { + setRelayMintFailure(result.relayFailure) + } else { + setRelayMintFailure(null) + // Why: IPC now forwards reason/guidance for all unavailability paths; + // prefer that copy over a hard-coded WebSocket-only message. + toast.error( + result.guidance ?? + translate( + 'auto.components.mobile.MobilePage.b353e18de1', + 'WebSocket transport is not running' + ) ) - ) + } } } } catch { @@ -107,7 +108,7 @@ export function useMobilePairingGeneration(params: { setPairQrDataUrl(null) setPairingUrl(null) setPairingQrError(false) - setEncodedConnectionMode(null) + setRelayMintFailure(null) toast.error( translate( 'auto.components.mobile.MobilePage.4c8bd11c1a', @@ -127,11 +128,11 @@ export function useMobilePairingGeneration(params: { mountedRef, pairingRequestIdRef, selectedAddress, - setEncodedConnectionMode, setPairLoading, setPairQrDataUrl, setPairingUrl, setPairingQrError, + setRelayMintFailure, signedIn ] ) diff --git a/src/renderer/src/components/mobile/use-mobile-pairing-qr-invalidation.ts b/src/renderer/src/components/mobile/use-mobile-pairing-qr-invalidation.ts index 77ae564186e..69cf51eb734 100644 --- a/src/renderer/src/components/mobile/use-mobile-pairing-qr-invalidation.ts +++ b/src/renderer/src/components/mobile/use-mobile-pairing-qr-invalidation.ts @@ -23,6 +23,7 @@ export function useMobilePairingQrInvalidation(params: { setPairingUrl: (value: string | null) => void setPairingQrError: (value: boolean) => void setPairLoading: (value: boolean) => void + setRelayMintFailure?: (value: null) => void regenerate: (mode: MobilePairingConnectionMode, opts: { rotate: boolean }) => void }): void { const { @@ -35,6 +36,7 @@ export function useMobilePairingQrInvalidation(params: { setPairingUrl, setPairingQrError, setPairLoading, + setRelayMintFailure, regenerate } = params const wasSignedInRef = useRef(signedIn) @@ -57,6 +59,7 @@ export function useMobilePairingQrInvalidation(params: { setPairingUrl(null) setPairingQrError(false) setPairQrDataUrl(null) + setRelayMintFailure?.(null) if (signedIn && canMintMobilePairingOffer({ connectionMode, signedIn })) { // Why: rotate on the sign-in edge — the token behind the QR cleared at // sign-out may have been exposed, so the fresh session mints fresh. @@ -73,6 +76,7 @@ export function useMobilePairingQrInvalidation(params: { setPairingUrl, setPairingQrError, setPairLoading, + setRelayMintFailure, regenerate ]) @@ -93,6 +97,7 @@ export function useMobilePairingQrInvalidation(params: { setPairingUrl(null) setPairingQrError(false) setPairQrDataUrl(null) + setRelayMintFailure?.(null) if (shouldRegenerate && canMintMobilePairingOffer({ connectionMode, signedIn })) { // Why: no rotate here — the main process rotates exactly once when the // requested mode differs from the pending token's minted mode, so the @@ -113,6 +118,7 @@ export function useMobilePairingQrInvalidation(params: { setPairingUrl, setPairingQrError, setPairLoading, + setRelayMintFailure, regenerate ]) } diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index d58cc2b4094..6e74880594b 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -8,6 +8,7 @@ import { submitNativeChatPrompt } from './native-chat-runtime-send' import type { NativeChatSendHandle } from './native-chat-runtime-send' +import { resolveNativeChatLaunchDraftSend } from './native-chat-launch-draft-send' import { getVerifiedNativeChatCommands } from '../../../../shared/native-chat-agent-profiles' import { emitNativeChatMessageSent } from '@/lib/native-chat-telemetry' import { @@ -250,21 +251,29 @@ export const NativeChatComposer = forwardRef readTerminalScreen?.() + }) let pendingHandle: NativeChatSendHandle | null = null // Why: image attachments take the attachment send path even for a // command/unknown send, otherwise `clearImageAttachments()` below drops // them silently when the text starts with the agent's slash/skill prefix. if (classification !== 'chat' && imagePaths.length === 0) { - pendingHandle = sendNativeChatMessage(target.settings, target.ptyId, text) + pendingHandle = sendNativeChatMessage(target.settings, target.ptyId, text, sendOptions) } else if (imagePaths.length > 0) { pendingHandle = sendNativeChatMessageWithImageAttachments( target.settings, target.ptyId, text, - imagePaths + imagePaths, + sendOptions ) } else if (text.trim().length > 0) { - pendingHandle = sendNativeChatMessage(target.settings, target.ptyId, text) + pendingHandle = sendNativeChatMessage(target.settings, target.ptyId, text, sendOptions) } else { submitNativeChatPrompt(target.settings, target.ptyId) } @@ -296,8 +305,7 @@ export const NativeChatComposer = forwardRef[0]> = {}) => + planNativeChatLaunchDraftSend({ + seededText: SEEDED, + ...over + }) + +describe('planNativeChatLaunchDraftSend', () => { + it('replaces the parked draft even when the composer copy is unchanged', () => { + expect(plan()).toEqual({ + kind: 'replace-draft', + clearInput: buildAgentTuiClearInputForText(SEEDED), + seededText: SEEDED + }) + }) + + it('does not submit a terminal-side edit that preserves the old short prefix', () => { + const samePrefixEdit = [ + '────────────────────────────────────────', + '❯ Linked Linear but terminal-side text changed', + '────────────────────────────────────────' + ].join('\n') + + const result = resolveNativeChatLaunchDraftSend({ + launchDraft: { agent: 'codex', text: SEEDED }, + launchDraftResolved: false, + agent: 'codex', + readScreen: () => samePrefixEdit + }) + expect(result.plan.kind).toBe('replace-draft') + }) + + it('keeps the ordinary send path when nothing is parked on the line', () => { + expect(plan({ seededText: null })).toEqual({ kind: 'default' }) + expect(plan({ seededText: ' ' })).toEqual({ kind: 'default' }) + }) + + it('sizes a multi-line clear well past a single Ctrl+U', () => { + const result = plan() + expect(result.kind === 'replace-draft' && result.clearInput.length).toBeGreaterThan( + AGENT_TUI_CLEAR_INPUT_LINE.length + ) + }) +}) + +describe('agentInputLineCleared', () => { + it('confirms only an observably empty prompt', () => { + expect(agentInputLineCleared('› \n gpt-5.6 · ~/repo')).toBe(true) + }) + + it('does not call a different nonempty prompt cleared', () => { + const edited = [ + '────────────────────────────────────────', + '❯ issue: ABC-123 residue after a cursor-middle clear', + '────────────────────────────────────────' + ].join('\n') + expect(agentInputLineCleared(edited)).toBe(false) + }) + + it('does not ignore nonempty continuation rows after an empty prompt row', () => { + const residue = [ + '────────────────────────────────────────', + '❯ ', + ' suffix after a cursor-middle clear', + '────────────────────────────────────────' + ].join('\n') + expect(agentInputLineCleared(residue)).toBe(false) + }) + + it('treats placeholders and parked drafts as unconfirmed', () => { + expect(agentInputLineCleared(screenPlaceholder)).toBe(false) + expect(agentInputLineCleared(screenHoldingDraft)).toBe(false) + }) + + it('treats an unreadable screen as unconfirmed', () => { + expect(agentInputLineCleared(null)).toBe(false) + expect(agentInputLineCleared('unparseable')).toBe(false) + }) + + it('reads an empty prompt through serializer ANSI', () => { + expect(agentInputLineCleared(`❯ `)).toBe(true) + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-launch-draft-send.ts b/src/renderer/src/components/native-chat/native-chat-launch-draft-send.ts new file mode 100644 index 00000000000..481540b035f --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-launch-draft-send.ts @@ -0,0 +1,99 @@ +// Choosing how a chat send lands when the agent's TUI input line still holds a +// launch-context draft that Orca itself injected. + +import { buildAgentTuiClearInputForText } from '../../../../shared/agent-tui-input-clear' +import { stripScrollbackAnsi } from './native-chat-scrape-fallback' + +export type NativeChatLaunchDraftSendPlan = + /** Input line holds a stale injected draft — replace it, clearing every line. */ + | { kind: 'replace-draft'; clearInput: string; seededText: string } + /** No injected draft is parked on the line; keep the ordinary send path. */ + | { kind: 'default' } + +/** Prompt glyphs both supported agent TUIs draw at the start of the input line. */ +const COMPOSER_PROMPT_LINE = /^\s*([❯›])\s?(.*)$/ +const CLAUDE_FRAME_LINE = /^\s*─{3,}\s*$/ +const CODEX_FOOTER_LINE = /^\s*\S.*\s[·•]\s.*$/ + +function composerContinuationIsEmpty(lines: string[], promptIndex: number, glyph: string): boolean { + for (let index = promptIndex + 1; index < lines.length; index += 1) { + const line = lines[index]! + if ( + (glyph === '❯' && CLAUDE_FRAME_LINE.test(line)) || + (glyph === '›' && CODEX_FOOTER_LINE.test(line)) + ) { + return true + } + if (line.trim() !== '') { + return false + } + } + return true +} + +/** + * Whether the rendered composer prompt is observably empty. Placeholder text, + * unrelated edits, and unreadable screens are all unconfirmed. + */ +export function agentInputLineCleared(screen: string | null | undefined): boolean { + if (!screen) { + return false + } + const lines = stripScrollbackAnsi(screen).split('\n') + for (let index = lines.length - 1; index >= 0; index -= 1) { + const match = COMPOSER_PROMPT_LINE.exec(lines[index]!) + if (match) { + return match[2]!.trim() === '' && composerContinuationIsEmpty(lines, index, match[1]!) + } + } + return false +} + +/** + * A serialized screen cannot prove the whole parked draft still matches: visual + * wrapping loses logical-line boundaries. Always replace from the composer copy. + */ +export function planNativeChatLaunchDraftSend(args: { + /** Text Orca injected into the TUI line, or null when nothing is parked there. */ + seededText: string | null | undefined +}): NativeChatLaunchDraftSendPlan { + const seededText = args.seededText + if (!seededText || seededText.trim() === '') { + return { kind: 'default' } + } + return { + kind: 'replace-draft', + clearInput: buildAgentTuiClearInputForText(seededText), + seededText + } +} + +/** What a composer send needs: which path to take, and the clear/confirm bytes + * the ordinary send paths should use when it is a draft replacement. */ +export function resolveNativeChatLaunchDraftSend(args: { + launchDraft: { agent: string; text: string } | null | undefined + launchDraftResolved: boolean + agent: string + readScreen: () => string | null | undefined +}): { + plan: NativeChatLaunchDraftSendPlan + sendOptions: { clearInput: string; confirmCleared: () => boolean } | undefined +} { + const { launchDraft, launchDraftResolved, agent, readScreen } = args + // A resolved draft was already submitted or cleared TUI-side, so nothing of + // ours is on the line any more — treating it as parked would clear or submit + // a buffer that no longer holds it. + const seededText = + launchDraft && launchDraft.agent === agent && !launchDraftResolved ? launchDraft.text : null + const plan = planNativeChatLaunchDraftSend({ seededText }) + if (plan.kind !== 'replace-draft') { + return { plan, sendOptions: undefined } + } + return { + plan, + sendOptions: { + clearInput: plan.clearInput, + confirmCleared: () => agentInputLineCleared(readScreen()) + } + } +} diff --git a/src/renderer/src/components/native-chat/native-chat-pty-send-queue.ts b/src/renderer/src/components/native-chat/native-chat-pty-send-queue.ts index 1ccaefc42a5..4ae0ce9fe78 100644 --- a/src/renderer/src/components/native-chat/native-chat-pty-send-queue.ts +++ b/src/renderer/src/components/native-chat/native-chat-pty-send-queue.ts @@ -9,6 +9,7 @@ export type NativeChatPtySendQueueHandle = { cancel: () => void settleAfterMs: number + settled: Promise bodyStarted: () => boolean finished: () => boolean } @@ -97,6 +98,16 @@ export function enqueueNativeChatPtySend( const timers: ReturnType[] = [] let release: (() => void) | null = null + const finishEntry = (): void => { + if (finished) { + return + } + finished = true + const resolve = release + release = null + resolve?.() + } + const delay = (ms: number, fn: () => void): void => { const timer = setTimeout(() => { if (!cancelled) { @@ -106,19 +117,17 @@ export function enqueueNativeChatPtySend( timers.push(timer) } - const markFinished = (): void => { - finished = true - } - const markSubmitted = (): void => { submitted = true + finishEntry() } const execute = (): Promise => new Promise((resolve) => { release = resolve if (cancelled) { - markFinished() + release = null + finished = true resolve() return } @@ -126,17 +135,7 @@ export function enqueueNativeChatPtySend( start({ isCancelled: () => cancelled, delay, markSubmitted }) if (durationMs <= 0) { markSubmitted() - markFinished() - resolve() - return } - // Why: always release after the declared duration so a cancel mid-flight - // cannot stall the per-pty queue forever. - const done = setTimeout(() => { - markFinished() - resolve() - }, durationMs) - timers.push(done) }) const runPromise = @@ -148,7 +147,7 @@ export function enqueueNativeChatPtySend( const settleQueueEntry = (): void => { state.depth = Math.max(0, state.depth - 1) - markFinished() + finished = true dropHandle() // Why: drop the per-pty record once nothing is in flight so the map does not // accumulate one permanent entry per pty over a long, multi-pane session. @@ -157,7 +156,8 @@ export function enqueueNativeChatPtySend( } } - state.tail = runPromise.then(settleQueueEntry, settleQueueEntry) + const settled = runPromise.then(settleQueueEntry, settleQueueEntry) + state.tail = settled const handle: NativeChatPtySendQueueHandle = { cancel: () => { @@ -169,20 +169,19 @@ export function enqueueNativeChatPtySend( clearTimeout(timer) } const shouldClear = bodyStarted && !submitted - markFinished() // Why: refund only THIS sequence's charged window rather than collapsing // freeAt to now — later queued sends still hold the line, so a blanket // reset would understate the next enqueue's settle time and let a send // card drop while a queued Enter is still pending. state.freeAt = Math.max(Date.now(), state.freeAt - Math.max(0, durationMs)) - release?.() - release = null + finishEntry() dropHandle() if (shouldClear) { options?.onCancelUnsubmitted?.() } }, settleAfterMs, + settled, bodyStarted: () => bodyStarted, finished: () => finished } diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-send-launch-draft.test.ts b/src/renderer/src/components/native-chat/native-chat-runtime-send-launch-draft.test.ts new file mode 100644 index 00000000000..32f4947c3d6 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-runtime-send-launch-draft.test.ts @@ -0,0 +1,195 @@ +// Send-path behaviour when a launch-context draft is still parked on the agent's +// TUI input line: the multi-line clear and its confirmation step. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const sendRuntimePtyInput = vi.fn() +const sendRuntimePtyInputVerified = vi.fn() +vi.mock('@/runtime/runtime-terminal-inspection', () => ({ + sendRuntimePtyInput: (...args: unknown[]) => sendRuntimePtyInput(...args), + sendRuntimePtyInputVerified: (...args: unknown[]) => sendRuntimePtyInputVerified(...args) +})) + +import { + NATIVE_CHAT_CLEAR_CONFIRM_MS, + NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT, + NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS, + NATIVE_CHAT_SUBMIT_DELAY_MS, + resetNativeChatPtySendQueuesForTests, + sendNativeChatMessage, + sendNativeChatMessageWithImageAttachments +} from './native-chat-runtime-send' +import { buildNativeChatPasteBytes, NATIVE_CHAT_SUBMIT } from './native-chat-send' +import { + AGENT_TUI_CLEAR_INPUT_MAX, + buildAgentTuiClearInputForText +} from '../../../../shared/agent-tui-input-clear' + +const SETTINGS = {} as Parameters[0] +const PTY = 'pty-launch-draft' +const DRAFT = 'Linked Linear issue: ABC-123\nhttps://linear.app/x/issue/ABC-123' + +const writes = (): string[] => sendRuntimePtyInput.mock.calls.map((call) => call[2] as string) + +beforeEach(() => { + vi.useFakeTimers() + sendRuntimePtyInput.mockClear() + sendRuntimePtyInput.mockReturnValue(true) + resetNativeChatPtySendQueuesForTests() +}) +afterEach(() => { + vi.useRealTimers() + resetNativeChatPtySendQueuesForTests() +}) + +describe('sendNativeChatMessage with a parked multi-line draft', () => { + it('leads with a clear sized to every line of the draft, not one Ctrl+U', () => { + const clearInput = buildAgentTuiClearInputForText(DRAFT) + sendNativeChatMessage(SETTINGS, PTY, 'edited text', { clearInput }) + expect(writes()).toEqual([clearInput, buildNativeChatPasteBytes('edited text')]) + expect(clearInput).not.toBe(NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT) + }) + + it('still defaults to a single Ctrl+U when no draft is parked', () => { + sendNativeChatMessage(SETTINGS, PTY, 'plain') + expect(writes()[0]).toBe(NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT) + }) + + it('holds the body until the clear is confirmed, then submits after the gap', () => { + const clearInput = buildAgentTuiClearInputForText(DRAFT) + sendNativeChatMessage(SETTINGS, PTY, 'edited', { + clearInput, + confirmCleared: () => true + }) + // Body must NOT ride out with the clear — the confirm happens in between. + expect(writes()).toEqual([clearInput]) + vi.advanceTimersByTime(NATIVE_CHAT_CLEAR_CONFIRM_MS) + expect(writes()).toEqual([clearInput, buildNativeChatPasteBytes('edited')]) + vi.advanceTimersByTime(NATIVE_CHAT_SUBMIT_DELAY_MS) + expect(writes()).toEqual([clearInput, buildNativeChatPasteBytes('edited'), NATIVE_CHAT_SUBMIT]) + }) + + it('preserves the body-to-Enter gap when the renderer stalls past both nominal deadlines', async () => { + vi.useRealTimers() + const writeTimes = new Map() + sendRuntimePtyInput.mockImplementation((_settings, _pty, bytes: string) => { + writeTimes.set(bytes, performance.now()) + return true + }) + sendNativeChatMessage(SETTINGS, PTY, 'edited', { + clearInput: buildAgentTuiClearInputForText(DRAFT), + confirmCleared: () => true + }) + sendNativeChatMessage(SETTINGS, PTY, 'queued') + + const blockedUntil = + performance.now() + NATIVE_CHAT_CLEAR_CONFIRM_MS + NATIVE_CHAT_SUBMIT_DELAY_MS + 50 + while (performance.now() < blockedUntil) { + // Simulate a renderer long task delaying both nominal deadlines. + } + + await vi.waitFor(() => expect(writeTimes.has(NATIVE_CHAT_SUBMIT)).toBe(true), { + timeout: NATIVE_CHAT_SUBMIT_DELAY_MS + 1_000 + }) + await vi.waitFor(() => expect(writeTimes.has(buildNativeChatPasteBytes('queued'))).toBe(true)) + expect( + writeTimes.get(NATIVE_CHAT_SUBMIT)! - writeTimes.get(buildNativeChatPasteBytes('edited'))! + ).toBeGreaterThanOrEqual(NATIVE_CHAT_SUBMIT_DELAY_MS - 20) + expect(writes().indexOf(NATIVE_CHAT_SUBMIT)).toBeLessThan( + writes().indexOf(buildNativeChatPasteBytes('queued')) + ) + }) + + it('widens to a maximal burst when the draft is still observed on the line', () => { + const clearInput = buildAgentTuiClearInputForText(DRAFT) + sendNativeChatMessage(SETTINGS, PTY, 'edited', { + clearInput, + confirmCleared: () => false + }) + vi.advanceTimersByTime(NATIVE_CHAT_CLEAR_CONFIRM_MS) + expect(writes()).toEqual([ + clearInput, + AGENT_TUI_CLEAR_INPUT_MAX, + buildNativeChatPasteBytes('edited') + ]) + }) + + it('re-clears before the body, never after it', () => { + sendNativeChatMessage(SETTINGS, PTY, 'edited', { + clearInput: buildAgentTuiClearInputForText(DRAFT), + confirmCleared: () => false + }) + vi.advanceTimersByTime(NATIVE_CHAT_CLEAR_CONFIRM_MS + NATIVE_CHAT_SUBMIT_DELAY_MS) + const order = writes() + expect(order.indexOf(AGENT_TUI_CLEAR_INPUT_MAX)).toBeLessThan( + order.indexOf(buildNativeChatPasteBytes('edited')) + ) + }) + + it('charges the confirm gap to the handle so the send card outlives the Enter', () => { + const withConfirm = sendNativeChatMessage(SETTINGS, PTY, 'a', { + clearInput: '\x15', + confirmCleared: () => true + }) + expect(withConfirm.settleAfterMs).toBe( + NATIVE_CHAT_SUBMIT_DELAY_MS + NATIVE_CHAT_CLEAR_CONFIRM_MS + ) + }) + + it('submits before a queued send starts after clear confirmation', async () => { + const clearInput = buildAgentTuiClearInputForText(DRAFT) + sendNativeChatMessage(SETTINGS, PTY, 'first', { + clearInput, + confirmCleared: () => true + }) + sendNativeChatMessage(SETTINGS, PTY, 'second') + + await vi.advanceTimersByTimeAsync(NATIVE_CHAT_CLEAR_CONFIRM_MS + NATIVE_CHAT_SUBMIT_DELAY_MS) + + expect(writes()).toEqual([ + clearInput, + buildNativeChatPasteBytes('first'), + NATIVE_CHAT_SUBMIT, + NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT, + buildNativeChatPasteBytes('second') + ]) + }) +}) + +describe('image sends with a parked multi-line draft', () => { + it('clears every draft line before pasting, so no line rides along with the image', () => { + const clearInput = buildAgentTuiClearInputForText(DRAFT) + sendNativeChatMessageWithImageAttachments(SETTINGS, PTY, 'caption', ['/tmp/a.png'], { + clearInput + }) + expect(writes()[0]).toBe(clearInput) + }) + + it('clears exactly once — a second Ctrl+U would wipe the just-pasted image', () => { + const clearInput = buildAgentTuiClearInputForText(DRAFT) + sendNativeChatMessageWithImageAttachments(SETTINGS, PTY, 'caption', ['/tmp/a.png'], { + clearInput + }) + vi.advanceTimersByTime(10_000) + expect(writes().filter((write) => write === clearInput)).toHaveLength(1) + }) + + it('submits the image send before a queued message starts', async () => { + const clearInput = buildAgentTuiClearInputForText(DRAFT) + sendNativeChatMessageWithImageAttachments(SETTINGS, PTY, 'caption', ['/tmp/a.png'], { + clearInput, + confirmCleared: () => true + }) + sendNativeChatMessage(SETTINGS, PTY, 'second') + + await vi.advanceTimersByTimeAsync( + NATIVE_CHAT_CLEAR_CONFIRM_MS + + NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS + + NATIVE_CHAT_SUBMIT_DELAY_MS + ) + + expect(writes().indexOf(NATIVE_CHAT_SUBMIT)).toBeLessThan( + writes().indexOf(NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT) + ) + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts b/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts index e688c225458..93a63765a46 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts @@ -142,6 +142,23 @@ describe('sendNativeChatMessage', () => { expect(sendRuntimePtyInput).toHaveBeenCalledTimes(6) }) + it('does not let a canceled queued send stall the sends behind it', async () => { + sendNativeChatMessage(SETTINGS, PTY, 'first') + const canceled = sendNativeChatMessage(SETTINGS, PTY, 'canceled') + sendNativeChatMessage(SETTINGS, PTY, 'third') + canceled.cancel() + + await vi.advanceTimersByTimeAsync(NATIVE_CHAT_SUBMIT_DELAY_MS) + + expectWriteOrder(sendRuntimePtyInput.mock.calls, [ + NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT, + buildNativeChatPasteBytes('first'), + NATIVE_CHAT_SUBMIT, + NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT, + buildNativeChatPasteBytes('third') + ]) + }) + it('does not serialize sends across different PTYs', () => { sendNativeChatMessage(SETTINGS, 'pty-a', 'one') sendNativeChatMessage(SETTINGS, 'pty-b', 'two') diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-send.ts b/src/renderer/src/components/native-chat/native-chat-runtime-send.ts index 33e653d073a..a2974474b2b 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-send.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-send.ts @@ -8,6 +8,7 @@ import { } from '@/runtime/runtime-terminal-inspection' import type { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft' import type { AskAnswerKeyGroup } from './native-chat-interactive-prompt' +import { AGENT_TUI_CLEAR_INPUT_MAX } from '../../../../shared/agent-tui-input-clear' import { NATIVE_CHAT_ADVANCE_BUFFER_MS, NATIVE_CHAT_QUESTION_STEP_MS, @@ -34,20 +35,86 @@ export const NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS = 300 // start from an empty line so a prior cancelled paste cannot glue onto the next // prompt. Not used on verified option commands — model-switch confirmation // observes the PTY and Ctrl+U can miss confirmation markers. +// +// One Ctrl+U only ever clears ONE logical line. When the line may hold an +// injected multi-line launch draft, callers pass `clearInput` built by +// buildAgentTuiClearInputForText — see agent-tui-input-clear.ts for the measured +// 2N-1 law and the sequences that do NOT work. export const NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT = '\x15' +/** Gap before re-reading the agent's input line to confirm a clear landed. */ +export const NATIVE_CHAT_CLEAR_CONFIRM_MS = 140 + +export type NativeChatSendOptions = { + /** Bytes that empty the agent's input line. Defaults to a single Ctrl+U. */ + clearInput?: string + /** + * Observed check that the input line is now empty. + * Supplied only for launch-draft replacement; when it reports "not cleared" + * the send widens to a maximal burst before writing the body rather than + * pasting on top of residue. + */ + confirmCleared?: () => boolean +} + /** Cancels an in-flight send's pending pty writes (the delayed Enter, and any * later question bodies/Enters). Safe to call after the send completes. */ export type NativeChatSendHandle = { cancel: () => void /** Time after which every scheduled write has fired and the handle can drop. */ settleAfterMs: number + /** Actual completion, which can outlive the nominal schedule if the renderer stalls. */ + settled?: Promise } type RuntimeSettings = ReturnType -function clearUnsubmittedAgentInput(settings: RuntimeSettings, ptyId: string): void { - sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT) +function clearUnsubmittedAgentInput( + settings: RuntimeSettings, + ptyId: string, + options?: NativeChatSendOptions +): void { + sendRuntimePtyInput(settings, ptyId, options?.clearInput ?? NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT) +} + +/** + * Run `writeBody` once the input line is clear. With no `confirmCleared` the + * clear is a plain in-order write on the same byte stream, so the TUI consumes + * it before the body and the body follows immediately. With one, we pause to + * actually look at the agent's input line, and widen to a maximal burst when the + * draft is still visible — the injected line count is only a lower bound on what + * the buffer holds, since the user can type into the TUI directly. + */ +function clearThenWrite( + settings: RuntimeSettings, + ptyId: string, + options: NativeChatSendOptions | undefined, + delay: (ms: number, fn: () => void) => void, + writeBody: () => void +): void { + clearUnsubmittedAgentInput(settings, ptyId, options) + const confirmCleared = options?.confirmCleared + if (!confirmCleared) { + writeBody() + return + } + delay(NATIVE_CHAT_CLEAR_CONFIRM_MS, () => { + let cleared = false + try { + cleared = confirmCleared() + } catch { + // An unreadable terminal is unconfirmed; the maximal clear remains safe. + } + if (!cleared) { + sendRuntimePtyInput(settings, ptyId, AGENT_TUI_CLEAR_INPUT_MAX) + } + writeBody() + }) +} + +/** Extra time a send needs when it stops to confirm the clear before the body. */ +function clearConfirmDurationMs(options?: NativeChatSendOptions): number { + return options?.confirmCleared ? NATIVE_CHAT_CLEAR_CONFIRM_MS : 0 } /** @@ -61,27 +128,31 @@ function clearUnsubmittedAgentInput(settings: RuntimeSettings, ptyId: string): v export function sendNativeChatMessage( settings: RuntimeSettings, ptyId: string, - text: string + text: string, + options?: NativeChatSendOptions ): NativeChatSendHandle { return enqueueNativeChatPtySend( ptyId, - NATIVE_CHAT_SUBMIT_DELAY_MS, + NATIVE_CHAT_SUBMIT_DELAY_MS + clearConfirmDurationMs(options), ({ isCancelled, delay, markSubmitted }) => { if (isCancelled()) { return } - clearUnsubmittedAgentInput(settings, ptyId) - if (isCancelled()) { - return - } - sendRuntimePtyInput(settings, ptyId, buildNativeChatPasteBytes(text)) - delay(NATIVE_CHAT_SUBMIT_DELAY_MS, () => { - sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT) - markSubmitted() + clearThenWrite(settings, ptyId, options, delay, () => { + if (isCancelled()) { + return + } + sendRuntimePtyInput(settings, ptyId, buildNativeChatPasteBytes(text)) + // Schedule from the actual body write: an overdue clear-confirm callback + // must not collapse the required body-to-Enter gap after a renderer stall. + delay(NATIVE_CHAT_SUBMIT_DELAY_MS, () => { + sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT) + markSubmitted() + }) }) }, { - onCancelUnsubmitted: () => clearUnsubmittedAgentInput(settings, ptyId) + onCancelUnsubmitted: () => clearUnsubmittedAgentInput(settings, ptyId, options) } ) } @@ -146,16 +217,17 @@ export function sendNativeChatMessageWithImageAttachments( settings: RuntimeSettings, ptyId: string, text: string, - imagePaths: readonly string[] + imagePaths: readonly string[], + options?: NativeChatSendOptions ): NativeChatSendHandle { if (imagePaths.length === 0) { - return sendNativeChatMessage(settings, ptyId, text) + return sendNativeChatMessage(settings, ptyId, text, options) } const trimmedText = text.trim() const durationMs = - trimmedText.length > 0 + (trimmedText.length > 0 ? NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS + NATIVE_CHAT_SUBMIT_DELAY_MS - : NATIVE_CHAT_SUBMIT_DELAY_MS + : NATIVE_CHAT_SUBMIT_DELAY_MS) + clearConfirmDurationMs(options) return enqueueNativeChatPtySend( ptyId, durationMs, @@ -163,30 +235,31 @@ export function sendNativeChatMessageWithImageAttachments( if (isCancelled()) { return } - clearUnsubmittedAgentInput(settings, ptyId) - if (isCancelled()) { - return - } - for (const imagePath of imagePaths) { - sendRuntimePtyInput(settings, ptyId, buildNativeChatImagePasteBytes(imagePath)) - } - if (trimmedText.length > 0) { - delay(NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS, () => { - sendRuntimePtyInput(settings, ptyId, buildNativeChatPasteBytes(text)) - }) - delay(NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS + NATIVE_CHAT_SUBMIT_DELAY_MS, () => { + clearThenWrite(settings, ptyId, options, delay, () => { + if (isCancelled()) { + return + } + for (const imagePath of imagePaths) { + sendRuntimePtyInput(settings, ptyId, buildNativeChatImagePasteBytes(imagePath)) + } + if (trimmedText.length > 0) { + delay(NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS, () => { + sendRuntimePtyInput(settings, ptyId, buildNativeChatPasteBytes(text)) + delay(NATIVE_CHAT_SUBMIT_DELAY_MS, () => { + sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT) + markSubmitted() + }) + }) + return + } + delay(NATIVE_CHAT_SUBMIT_DELAY_MS, () => { sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT) markSubmitted() }) - return - } - delay(NATIVE_CHAT_SUBMIT_DELAY_MS, () => { - sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT) - markSubmitted() }) }, { - onCancelUnsubmitted: () => clearUnsubmittedAgentInput(settings, ptyId) + onCancelUnsubmitted: () => clearUnsubmittedAgentInput(settings, ptyId, options) } ) } diff --git a/src/renderer/src/components/native-chat/use-native-chat-launch-draft-adoption.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-launch-draft-adoption.test.tsx index 94c6252db59..2c0bfaf6570 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-launch-draft-adoption.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-launch-draft-adoption.test.tsx @@ -175,6 +175,17 @@ describe('useNativeChatLaunchDraftSignal', () => { expect(result.current.launchDraft).not.toBeNull() }) + it('resolves immediately from an accepted mobile submission', () => { + mocks.storeState.nativeChatLaunchDraftByTabId = { + 'tab-1': launchDraft({ adopted: true, resolved: true, createdAt: SEEDED_AT }) + } + + const { result } = renderSignal([], true) + + expect(result.current.launchDraftResolved).toBe(true) + expect(result.current.launchDraft?.resolved).toBe(true) + }) + it('ignores a draft seeded for another agent', () => { mocks.storeState.nativeChatLaunchDraftByTabId = { 'tab-1': launchDraft({ agent: 'codex', createdAt: SEEDED_AT }) diff --git a/src/renderer/src/components/native-chat/use-native-chat-launch-draft-adoption.ts b/src/renderer/src/components/native-chat/use-native-chat-launch-draft-adoption.ts index aa4eb0a6d19..f82d5ab1278 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-launch-draft-adoption.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-launch-draft-adoption.ts @@ -51,9 +51,10 @@ export function useNativeChatLaunchDraftSignal(args: { const baseline = held?.baseline ?? null const launchDraftResolved = useMemo( () => - paneLaunchDraft && !transcriptLoading + paneLaunchDraft?.resolved === true || + (paneLaunchDraft && !transcriptLoading ? launchDraftResolvedByTranscript(paneLaunchDraft, messages, baseline) - : false, + : false), [paneLaunchDraft, messages, baseline, transcriptLoading] ) return { launchDraft: paneLaunchDraft, launchDraftResolved } diff --git a/src/renderer/src/components/native-chat/use-native-chat-send-lifecycle.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-send-lifecycle.test.tsx index 63525c1e3e7..a9bb8c3be33 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-send-lifecycle.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-send-lifecycle.test.tsx @@ -66,4 +66,30 @@ describe('useNativeChatSendLifecycle', () => { expect(settled.cancel).not.toHaveBeenCalled() expect(onPendingSendCanceled).not.toHaveBeenCalled() }) + + it('keeps a renderer-stalled send cancelable past its nominal schedule', async () => { + vi.useFakeTimers() + let resolveSettled!: () => void + const stalled = { + ...handle(640), + settled: new Promise((resolve) => { + resolveSettled = resolve + }) + } + const onPendingSendCanceled = vi.fn() + const { result, rerender } = renderHook( + ({ targetPtyId }) => useNativeChatSendLifecycle('tab-1', targetPtyId, onPendingSendCanceled), + { initialProps: { targetPtyId: 'pty-1' as string | null } } + ) + + act(() => result.current.trackPendingSend(stalled, 'pending-1')) + act(() => vi.advanceTimersByTime(stalled.settleAfterMs + 1_000)) + rerender({ targetPtyId: 'pty-2' }) + + expect(stalled.cancel).toHaveBeenCalledOnce() + expect(onPendingSendCanceled).toHaveBeenCalledWith('pending-1') + await act(async () => { + resolveSettled() + }) + }) }) diff --git a/src/renderer/src/components/native-chat/use-native-chat-send-lifecycle.ts b/src/renderer/src/components/native-chat/use-native-chat-send-lifecycle.ts index 41e46148bfe..c325cb2200e 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-send-lifecycle.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-send-lifecycle.ts @@ -14,13 +14,15 @@ export function useNativeChatSendLifecycle( const pendingSendHandlesRef = useRef( new Map< NativeChatSendHandle, - { cleanupTimer: ReturnType; pendingId?: string } + { cleanupTimer: ReturnType | null; pendingId?: string } >() ) const cancelPendingSends = useCallback(() => { for (const [handle, entry] of pendingSendHandlesRef.current) { const { cleanupTimer, pendingId } = entry - clearTimeout(cleanupTimer) + if (cleanupTimer !== null) { + clearTimeout(cleanupTimer) + } handle.cancel() if (pendingId) { onPendingSendCanceled?.(pendingId) @@ -29,13 +31,22 @@ export function useNativeChatSendLifecycle( pendingSendHandlesRef.current.clear() }, [onPendingSendCanceled]) const trackPendingSend = useCallback((handle: NativeChatSendHandle, pendingId?: string) => { - const cleanupTimer = setTimeout(() => { + const entry = { + cleanupTimer: null as ReturnType | null, + ...(pendingId ? { pendingId } : {}) + } + pendingSendHandlesRef.current.set(handle, entry) + if (handle.settled) { + void handle.settled.then(() => { + if (pendingSendHandlesRef.current.get(handle) === entry) { + pendingSendHandlesRef.current.delete(handle) + } + }) + return + } + entry.cleanupTimer = setTimeout(() => { pendingSendHandlesRef.current.delete(handle) }, handle.settleAfterMs) - pendingSendHandlesRef.current.set(handle, { - cleanupTimer, - ...(pendingId ? { pendingId } : {}) - }) }, []) // Why: delayed Enter/image writes belong to the exact PTY target. A pane diff --git a/src/renderer/src/components/new-workspace/ProjectCombobox.tsx b/src/renderer/src/components/new-workspace/ProjectCombobox.tsx index a4cabb0087b..c305964ca0e 100644 --- a/src/renderer/src/components/new-workspace/ProjectCombobox.tsx +++ b/src/renderer/src/components/new-workspace/ProjectCombobox.tsx @@ -204,7 +204,7 @@ export default function ProjectCombobox({
diff --git a/src/renderer/src/components/onboarding/onboarding-feature-setup.test.ts b/src/renderer/src/components/onboarding/onboarding-feature-setup.test.ts index 2db80aa353c..9e40f19d903 100644 --- a/src/renderer/src/components/onboarding/onboarding-feature-setup.test.ts +++ b/src/renderer/src/components/onboarding/onboarding-feature-setup.test.ts @@ -119,7 +119,7 @@ describe('onboarding feature setup runner', () => { expect(text).toBe(ALL_SKILL_INSTALL_COMMAND) expect(text).toBe( - 'npx skills add https://github.com/stablyai/orca --skill orca-cli computer-use orchestration orca-linear --global' + 'npx skills add https://github.com/stablyai/orca --skill orca-cli --skill computer-use --skill orchestration --skill orca-linear --global' ) }) diff --git a/src/renderer/src/components/right-sidebar/file-explorer-operation-generation.test.ts b/src/renderer/src/components/right-sidebar/file-explorer-operation-generation.test.ts index 771dbed72b2..0a6c2606b01 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-operation-generation.test.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-operation-generation.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it } from 'vitest' import type { Worktree } from '../../../../shared/types' import { useAppStore } from '@/store' import { folderWorkspaceKey } from '../../../../shared/workspace-scope' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import { captureFileExplorerOperationGuard, getFileExplorerOperationOwner @@ -26,6 +27,15 @@ afterEach(() => { }) describe('file explorer operation generations', () => { + it('routes floating workspace file mutations to the local host', () => { + const owner = getFileExplorerOperationOwner(FLOATING_TERMINAL_WORKTREE_ID) + const guard = captureFileExplorerOperationGuard(FLOATING_TERMINAL_WORKTREE_ID, owner) + + expect(owner).toEqual({ kind: 'local' }) + expect(guard.route.expectedExecutionHostId).toBe('local') + expect(() => guard.assertCurrent()).not.toThrow() + }) + it('invalidates a nested SSH mutation when that target reconnects', () => { useAppStore.setState({ repos: [], diff --git a/src/renderer/src/components/right-sidebar/file-explorer-operation-owner.ts b/src/renderer/src/components/right-sidebar/file-explorer-operation-owner.ts index 1d301916c4d..cb94f03c978 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-operation-owner.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-operation-owner.ts @@ -14,6 +14,7 @@ import { type WorktreeOperationRoute } from '@/lib/worktree-operation-route' import { captureWorktreeOperationGenerationGuard } from '@/lib/worktree-operation-generation' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' export type FileExplorerOperationRoute = { settings: { activeRuntimeEnvironmentId: string | null } @@ -43,6 +44,9 @@ export function getFileExplorerOperationOwnerFromState( state: FileExplorerOwnerState, worktreeId: string | null | undefined ): FileExplorerOperationOwner { + if (worktreeId === FLOATING_TERMINAL_WORKTREE_ID) { + return { kind: 'local' } + } const parsedWorkspace = worktreeId ? parseWorkspaceKey(worktreeId) : null if (worktreeId && parsedWorkspace?.type !== 'folder') { const route = resolveWorktreeOperationRoute(state, worktreeId) diff --git a/src/renderer/src/components/settings/BrowserUseSkillStep.test.tsx b/src/renderer/src/components/settings/BrowserUseSkillStep.test.tsx index 0b4dad98baf..90e9aa9f0c4 100644 --- a/src/renderer/src/components/settings/BrowserUseSkillStep.test.tsx +++ b/src/renderer/src/components/settings/BrowserUseSkillStep.test.tsx @@ -16,7 +16,7 @@ vi.mock('./AgentSkillSetupPanel', () => ({ describe('BrowserUseSkillStep', () => { it('forwards a single-skill installed command even when setup installs a bundle', () => { const bundleInstallCommand = - 'npx skills add https://github.com/stablyai/orca --skill orca-cli orchestration --global' + 'npx skills add https://github.com/stablyai/orca --skill orca-cli --skill orchestration --global' const updateCommand = 'npx skills update orca-cli --global' renderToStaticMarkup( diff --git a/src/renderer/src/components/settings/CloudVmSetupGuide.test.tsx b/src/renderer/src/components/settings/CloudVmSetupGuide.test.tsx new file mode 100644 index 00000000000..b347dda0892 --- /dev/null +++ b/src/renderer/src/components/settings/CloudVmSetupGuide.test.tsx @@ -0,0 +1,38 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot } from 'react-dom/client' +import { afterEach, describe, expect, it } from 'vitest' +import { useAppStore } from '@/store' +import { CloudVmSetupGuide } from './CloudVmSetupGuide' + +describe('CloudVmSetupGuide', () => { + afterEach(() => { + useAppStore.setState({ settingsNavigationTarget: null }) + document.body.replaceChildren() + }) + + it('explains creation and opens environment recipe setup', () => { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + + act(() => root.render()) + + expect(container.textContent).toContain('Create a Cloud VM') + expect(container.textContent).toContain('Create a workspace') + + const button = container.querySelector('button') + if (!button) { + throw new Error('Cloud VM setup button was not rendered') + } + act(() => button.dispatchEvent(new MouseEvent('click', { bubbles: true }))) + + expect(useAppStore.getState().settingsNavigationTarget).toEqual({ + pane: 'experimental', + repoId: null, + sectionId: 'ephemeral-vms' + }) + act(() => root.unmount()) + }) +}) diff --git a/src/renderer/src/components/settings/CloudVmSetupGuide.tsx b/src/renderer/src/components/settings/CloudVmSetupGuide.tsx new file mode 100644 index 00000000000..9ed1a794657 --- /dev/null +++ b/src/renderer/src/components/settings/CloudVmSetupGuide.tsx @@ -0,0 +1,54 @@ +import type React from 'react' +import { useAppStore } from '@/store' +import { translate } from '@/i18n/i18n' +import { Button } from '../ui/button' + +export function CloudVmSetupGuide(): React.JSX.Element { + const openSettingsTarget = useAppStore((state) => state.openSettingsTarget) + + return ( +
+
+
+ {translate('auto.components.settings.CloudVmSetupGuide.title', 'Create a Cloud VM')} +
+

+ {translate( + 'auto.components.settings.CloudVmSetupGuide.description', + 'Cloud VMs are created from environment recipes when you create a workspace.' + )} +

+
+
    +
  1. + {translate( + 'auto.components.settings.CloudVmSetupGuide.setupRecipe', + 'Set up an environment recipe for your cloud provider.' + )} +
  2. +
  3. + {translate( + 'auto.components.settings.CloudVmSetupGuide.createWorkspace', + 'Create a workspace and select that recipe under Run on.' + )} +
  4. +
+ +
+ ) +} diff --git a/src/renderer/src/components/settings/EphemeralVmRuntimesSection.test.tsx b/src/renderer/src/components/settings/EphemeralVmRuntimesSection.test.tsx index 401a88277f5..4a6221e866a 100644 --- a/src/renderer/src/components/settings/EphemeralVmRuntimesSection.test.tsx +++ b/src/renderer/src/components/settings/EphemeralVmRuntimesSection.test.tsx @@ -113,7 +113,7 @@ describe('EphemeralVmRuntimesSection', () => { document.body.replaceChildren() }) - it('renders active temporary VM runtimes and cleans one up', async () => { + it('renders active Cloud VM runtimes and cleans one up', async () => { const container = await renderSection() await vi.waitFor(() => expect(container.textContent).toContain('Fix Login Race')) @@ -139,7 +139,9 @@ describe('EphemeralVmRuntimesSection', () => { const container = await renderSection() await vi.waitFor(() => - expect(container.textContent).toContain('No temporary VM runtimes need cleanup.') + expect(container.textContent).toContain( + 'No Cloud VM runtimes yet. Create one from a workspace using an environment recipe.' + ) ) }) diff --git a/src/renderer/src/components/settings/EphemeralVmRuntimesSection.tsx b/src/renderer/src/components/settings/EphemeralVmRuntimesSection.tsx index 876730fc5d2..f5fee751dd5 100644 --- a/src/renderer/src/components/settings/EphemeralVmRuntimesSection.tsx +++ b/src/renderer/src/components/settings/EphemeralVmRuntimesSection.tsx @@ -68,8 +68,8 @@ export function EphemeralVmRuntimesSection(): React.JSX.Element { error instanceof Error ? error.message : translate( - 'auto.components.settings.EphemeralVmRuntimesSection.loadFailed', - 'Couldn’t load temporary VM runtimes.' + 'auto.components.settings.EphemeralVmRuntimesSection.cloudVmLoadFailed', + 'Couldn’t load Cloud VM runtimes.' ) ) } @@ -92,8 +92,8 @@ export function EphemeralVmRuntimesSection(): React.JSX.Element { throw new Error( cleaned.cleanupLastError ?? translate( - 'auto.components.settings.EphemeralVmRuntimesSection.cleanupFailedToast', - 'Couldn’t clean up temporary VM runtime.' + 'auto.components.settings.EphemeralVmRuntimesSection.cloudVmCleanupFailedToast', + 'Couldn’t clean up Cloud VM runtime.' ) ) } @@ -101,12 +101,12 @@ export function EphemeralVmRuntimesSection(): React.JSX.Element { toast.success( cleaned.cleanupStatus === 'disabled' ? translate( - 'auto.components.settings.EphemeralVmRuntimesSection.markedCleaned', - 'Marked temporary VM runtime as cleaned.' + 'auto.components.settings.EphemeralVmRuntimesSection.cloudVmMarkedCleaned', + 'Marked Cloud VM runtime as cleaned.' ) : translate( - 'auto.components.settings.EphemeralVmRuntimesSection.cleaned', - 'Cleaned up temporary VM runtime.' + 'auto.components.settings.EphemeralVmRuntimesSection.cloudVmCleaned', + 'Cleaned up Cloud VM runtime.' ) ) } @@ -117,8 +117,8 @@ export function EphemeralVmRuntimesSection(): React.JSX.Element { error instanceof Error ? error.message : translate( - 'auto.components.settings.EphemeralVmRuntimesSection.cleanupFailedToast', - 'Couldn’t clean up temporary VM runtime.' + 'auto.components.settings.EphemeralVmRuntimesSection.cloudVmCleanupFailedToast', + 'Couldn’t clean up Cloud VM runtime.' ) ) await refresh() @@ -171,8 +171,8 @@ export function EphemeralVmRuntimesSection(): React.JSX.Element {
{translate( - 'auto.components.settings.EphemeralVmRuntimesSection.title', - 'Temporary VM runtimes' + 'auto.components.settings.EphemeralVmRuntimesSection.cloudVmTitle', + 'Cloud VM runtimes' )}

@@ -187,12 +187,12 @@ export function EphemeralVmRuntimesSection(): React.JSX.Element { variant="outline" size="icon-sm" aria-label={translate( - 'auto.components.settings.EphemeralVmRuntimesSection.refresh', - 'Refresh temporary VM runtimes' + 'auto.components.settings.EphemeralVmRuntimesSection.cloudVmRefresh', + 'Refresh Cloud VM runtimes' )} title={translate( - 'auto.components.settings.EphemeralVmRuntimesSection.refresh', - 'Refresh temporary VM runtimes' + 'auto.components.settings.EphemeralVmRuntimesSection.cloudVmRefresh', + 'Refresh Cloud VM runtimes' )} onClick={() => void refresh()} disabled={isLoading || cleaningId !== null} @@ -206,12 +206,12 @@ export function EphemeralVmRuntimesSection(): React.JSX.Element {

{isLoading ? translate( - 'auto.components.settings.EphemeralVmRuntimesSection.loading', - 'Checking temporary VM runtimes…' + 'auto.components.settings.EphemeralVmRuntimesSection.cloudVmLoading', + 'Checking Cloud VM runtimes…' ) : translate( - 'auto.components.settings.EphemeralVmRuntimesSection.empty', - 'No temporary VM runtimes need cleanup.' + 'auto.components.settings.EphemeralVmRuntimesSection.cloudVmEmptyWithSetup', + 'No Cloud VM runtimes yet. Create one from a workspace using an environment recipe.' )}
) : ( diff --git a/src/renderer/src/components/settings/EphemeralVmsExperimentalSetting.tsx b/src/renderer/src/components/settings/EphemeralVmsExperimentalSetting.tsx index f4cebdd89c6..543744136cc 100644 --- a/src/renderer/src/components/settings/EphemeralVmsExperimentalSetting.tsx +++ b/src/renderer/src/components/settings/EphemeralVmsExperimentalSetting.tsx @@ -29,10 +29,7 @@ export function EphemeralVmsExperimentalSetting({

{translate( @@ -44,8 +41,8 @@ export function EphemeralVmsExperimentalSetting({ updateSettings({ experimentalEphemeralVms: !enabled })} /> diff --git a/src/renderer/src/components/settings/EphemeralVmsPane.test.tsx b/src/renderer/src/components/settings/EphemeralVmsPane.test.tsx index a72b2f4550b..8a68688f624 100644 --- a/src/renderer/src/components/settings/EphemeralVmsPane.test.tsx +++ b/src/renderer/src/components/settings/EphemeralVmsPane.test.tsx @@ -120,9 +120,7 @@ describe('EphemeralVmsPane', () => { const container = await renderPane() await vi.waitFor(() => expect(container.textContent).toContain('Cloud Sandbox')) - await vi.waitFor(() => - expect(container.textContent).toContain('Per-Workspace Environments skill') - ) + await vi.waitFor(() => expect(container.textContent).toContain('Cloud VM setup skill')) expect(container.textContent).toContain('What the skill does, with you') const useButton = [...container.querySelectorAll('button')].find( (button) => button.textContent === 'Use in workspace' diff --git a/src/renderer/src/components/settings/EphemeralVmsPane.tsx b/src/renderer/src/components/settings/EphemeralVmsPane.tsx index ba441a15d6e..e2fb92278c5 100644 --- a/src/renderer/src/components/settings/EphemeralVmsPane.tsx +++ b/src/renderer/src/components/settings/EphemeralVmsPane.tsx @@ -144,8 +144,8 @@ export function EphemeralVmsPane(): React.JSX.Element {

void refresh()} disabled={isLoading} diff --git a/src/renderer/src/components/settings/ExperimentalPane.test.tsx b/src/renderer/src/components/settings/ExperimentalPane.test.tsx index 1663e35948d..852f1d84337 100644 --- a/src/renderer/src/components/settings/ExperimentalPane.test.tsx +++ b/src/renderer/src/components/settings/ExperimentalPane.test.tsx @@ -15,9 +15,7 @@ vi.mock('../../store', () => ({ })) vi.mock('./EphemeralVmsPane', () => ({ - EphemeralVmsPane: () => ( -
Per-Workspace Environments pane
- ) + EphemeralVmsPane: () =>
Cloud VM pane
})) vi.mock('../ui/select', async () => { @@ -191,23 +189,23 @@ describe('ExperimentalPane', () => { root.unmount() }) - it('renders per-workspace environments as an off-by-default experimental subsection', () => { + it('renders Cloud VM as an off-by-default experimental subsection', () => { const settings = getDefaultSettings('/tmp') const markup = renderToStaticMarkup( ) const entry = getExperimentalPaneSearchEntries().find( - (searchEntry) => searchEntry.title === 'Per-Workspace Environments' + (searchEntry) => searchEntry.title === 'Cloud VM' ) expect(settings.experimentalEphemeralVms).toBe(false) - expect(markup).toContain('Per-Workspace Environments') + expect(markup).toContain('Cloud VM') expect(markup).toContain('aria-checked="false"') - expect(markup).not.toContain('Per-Workspace Environments pane') + expect(markup).not.toContain('Cloud VM pane') expect(entry?.targetSectionId).toBe('ephemeral-vms') }) - it('enables per-workspace environments through the experimental switch', async () => { + it('enables Cloud VM through the experimental switch', async () => { const updateSettings = vi.fn() const { root, container } = await renderExperimentalPane({ updateSettings }) @@ -215,7 +213,7 @@ describe('ExperimentalPane', () => { '#ephemeral-vms button[role="switch"]' ) if (!switchButton) { - throw new Error('Per-workspace environments switch was not rendered') + throw new Error('Cloud VM switch was not rendered') } await act(async () => { @@ -226,7 +224,7 @@ describe('ExperimentalPane', () => { root.unmount() }) - it('shows per-workspace environment setup controls when enabled', () => { + it('shows Cloud VM setup controls when enabled', () => { const markup = renderToStaticMarkup( { /> ) - expect(markup).toContain('Per-Workspace Environments pane') + expect(markup).toContain('Cloud VM pane') expect(markup).toContain('aria-checked="true"') }) diff --git a/src/renderer/src/components/settings/MobilePairingConnectionOptions.test.tsx b/src/renderer/src/components/settings/MobilePairingConnectionOptions.test.tsx index 41d16c39802..ece7b2c56ec 100644 --- a/src/renderer/src/components/settings/MobilePairingConnectionOptions.test.tsx +++ b/src/renderer/src/components/settings/MobilePairingConnectionOptions.test.tsx @@ -123,7 +123,9 @@ describe('MobilePairingConnectionOptions', () => { screen.getByText('Phone can be on cellular or any Wi‑Fi. Sign-in required.') ).toBeVisible() expect( - screen.getByText('Phone must be on this Wi‑Fi or your Tailscale. No sign-in.') + screen.getByText( + 'Phone must be on this Wi‑Fi or connected through Tailscale. No sign-in required.' + ) ).toBeVisible() await user.click(screen.getByRole('radio', { name: /Orca Relay/i })) @@ -163,4 +165,34 @@ describe('MobilePairingConnectionOptions', () => { expect(onChange).toHaveBeenCalledWith('local-only') statusListener?.('standby') }) + + it('keeps LAN available while Relay is retrying', async () => { + mocks.state = { + ...mocks.state, + orcaProfileAuthStatus: { + activeProfileId: 'profile-1', + configured: true, + state: 'connected', + persistence: 'encrypted' + } + } + const onChange = vi.fn() + const user = userEvent.setup() + render( + + ) + + expect(screen.getByText('Retrying')).toBeVisible() + const relay = screen.getByRole('radio', { name: /Orca Relay/i }) + const lan = screen.getByRole('radio', { name: /^LAN\b/i }) + expect(relay).toHaveAttribute('aria-disabled', 'true') + expect(lan).toHaveAttribute('aria-disabled', 'false') + await user.click(lan) + expect(onChange).toHaveBeenCalledWith('local-only') + }) }) diff --git a/src/renderer/src/components/settings/MobilePairingConnectionOptions.tsx b/src/renderer/src/components/settings/MobilePairingConnectionOptions.tsx index 3314ea70f4d..a1a88548793 100644 --- a/src/renderer/src/components/settings/MobilePairingConnectionOptions.tsx +++ b/src/renderer/src/components/settings/MobilePairingConnectionOptions.tsx @@ -43,6 +43,7 @@ type PathOptionProps = { description: string trailing?: ReactNode tabIndex: number + disabled?: boolean optionRef?: (el: HTMLDivElement | null) => void } @@ -58,6 +59,7 @@ function PathOption({ description, trailing, tabIndex, + disabled = false, optionRef }: PathOptionProps): React.JSX.Element { return ( @@ -66,8 +68,12 @@ function PathOption({ role="radio" tabIndex={tabIndex} aria-checked={selected} - onClick={onSelect} + aria-disabled={disabled} + onClick={disabled ? undefined : onSelect} onKeyDown={(event) => { + if (disabled) { + return + } if (event.key === ' ' || event.key === 'Enter') { event.preventDefault() onSelect() @@ -78,6 +84,7 @@ function PathOption({ // Why: match SettingsFormControls focus ring so keyboard focus is visible // even when the selected row already uses bg-accent/40. 'focus-visible:bg-accent/50 focus-visible:ring-[3px] focus-visible:ring-ring/50', + disabled && 'cursor-not-allowed opacity-60', selected ? 'bg-accent/40' : 'hover:bg-accent/20' )} > @@ -104,11 +111,16 @@ function PathOption({ export function MobilePairingConnectionOptions({ value, onChange, - compact = false + compact = false, + relayMintFailed = false, + relayMintRetrying = false }: { value: MobilePairingConnectionMode onChange: (value: MobilePairingConnectionMode) => void compact?: boolean + /** When true, show Unavailable on the Relay row (mint failed; no QR). */ + relayMintFailed?: boolean + relayMintRetrying?: boolean }): React.JSX.Element { const authStatus = useAppStore((state) => state.orcaProfileAuthStatus) const connecting = useAppStore((state) => state.orcaProfileConnecting) @@ -134,8 +146,12 @@ export function MobilePairingConnectionOptions({ if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(event.key)) { return } + if (relayMintRetrying && value !== 'automatic') { + return + } event.preventDefault() - const next: MobilePairingConnectionMode = value === 'automatic' ? 'local-only' : 'automatic' + const next: MobilePairingConnectionMode = + relayMintRetrying || value === 'automatic' ? 'local-only' : 'automatic' onChange(next) optionRefs.current[next]?.focus() } @@ -182,7 +198,8 @@ export function MobilePairingConnectionOptions({ > { optionRefs.current.automatic = el }} @@ -198,7 +215,17 @@ export function MobilePairingConnectionOptions({ trailing={ signedIn && value === 'automatic' ? ( - {relayStatusLabel(relayStatus)} + {relayMintRetrying + ? translate( + 'auto.components.settings.MobilePairingConnectionOptions.retrying', + 'Retrying' + ) + : relayMintFailed + ? translate( + 'auto.components.settings.MobilePairingConnectionOptions.unavailable', + 'Unavailable' + ) + : relayStatusLabel(relayStatus)} ) : null } @@ -206,7 +233,7 @@ export function MobilePairingConnectionOptions({
{ optionRefs.current['local-only'] = el }} @@ -217,7 +244,7 @@ export function MobilePairingConnectionOptions({ )} description={translate( 'auto.components.settings.MobilePairingConnectionOptions.localDescription', - 'Phone must be on this Wi‑Fi or your Tailscale. No sign-in.' + 'Phone must be on this Wi‑Fi or connected through Tailscale. No sign-in required.' )} />
diff --git a/src/renderer/src/components/settings/MobilePairingQrSection.test.tsx b/src/renderer/src/components/settings/MobilePairingQrSection.test.tsx index 832f7571bb7..9d62603a9c0 100644 --- a/src/renderer/src/components/settings/MobilePairingQrSection.test.tsx +++ b/src/renderer/src/components/settings/MobilePairingQrSection.test.tsx @@ -35,7 +35,67 @@ describe('MobilePairingQrSection', () => { ) expect(screen.getByRole('alert')).toHaveTextContent('couldn’t be rendered as a QR code') - await userEvent.click(screen.getByRole('button', { name: /copy-fallback/ })) + await userEvent.click(screen.getByRole('button', { name: 'Copy pairing code' })) expect(writeClipboardText).toHaveBeenCalledWith('orca://pair?code=copy-fallback') }) + + it('moves focus to the copy action when a pairing code becomes ready', () => { + const props = { + qrDataUrl: null, + qrError: false, + pairingUrl: null, + endpoint: null, + qrEnlarged: false, + codeCopied: false, + onQrEnlargedChange: vi.fn(), + onCodeCopiedChange: vi.fn(), + onClearCodeCopiedTimer: vi.fn() + } + const { rerender } = render() + + rerender( + + ) + + expect(screen.getByRole('button', { name: 'Copy pairing code' })).toHaveFocus() + }) + + it('does not steal focus from a control that remains mounted', () => { + const props = { + qrDataUrl: null, + qrError: false, + pairingUrl: null, + endpoint: null, + qrEnlarged: false, + codeCopied: false, + onQrEnlargedChange: vi.fn(), + onCodeCopiedChange: vi.fn(), + onClearCodeCopiedTimer: vi.fn() + } + const { rerender } = render( + <> + + + + ) + const persistentAction = screen.getByRole('button', { name: 'Persistent action' }) + persistentAction.focus() + + rerender( + <> + + + + ) + + expect(persistentAction).toHaveFocus() + }) }) diff --git a/src/renderer/src/components/settings/MobilePairingQrSection.tsx b/src/renderer/src/components/settings/MobilePairingQrSection.tsx index b9741c02776..b444d3412f7 100644 --- a/src/renderer/src/components/settings/MobilePairingQrSection.tsx +++ b/src/renderer/src/components/settings/MobilePairingQrSection.tsx @@ -1,4 +1,4 @@ -import { useCallback, useRef } from 'react' +import { useCallback, useEffect, useRef } from 'react' import { Check, CircleAlert, Copy, Maximize2 } from 'lucide-react' import { toast } from 'sonner' import { Button } from '../ui/button' @@ -29,18 +29,38 @@ export function MobilePairingQrSection({ onClearCodeCopiedTimer }: MobilePairingQrSectionProps): React.JSX.Element | null { const pairingCodeButtonMountedRef = useRef(false) + const pairingCodeButtonRef = useRef(null) + const hadPairingUrlRef = useRef(pairingUrl != null) const codeCopiedResetTimerRef = useRef(null) + // Why: the reset timeout is owned here, so clearing only the parent's timer would leave it running. + const clearCodeCopiedResetTimer = useCallback(() => { + if (codeCopiedResetTimerRef.current !== null) { + window.clearTimeout(codeCopiedResetTimerRef.current) + codeCopiedResetTimerRef.current = null + } + onClearCodeCopiedTimer() + }, [onClearCodeCopiedTimer]) + const setPairingCodeButtonRef = useCallback( (node: HTMLButtonElement | null) => { pairingCodeButtonMountedRef.current = node !== null + pairingCodeButtonRef.current = node if (node === null) { - onClearCodeCopiedTimer() + clearCodeCopiedResetTimer() } }, - [onClearCodeCopiedTimer] + [clearCodeCopiedResetTimer] ) + useEffect(() => { + const becameReady = !hadPairingUrlRef.current && pairingUrl != null + hadPairingUrlRef.current = pairingUrl != null + if (becameReady && document.activeElement === document.body) { + pairingCodeButtonRef.current?.focus() + } + }, [pairingUrl]) + async function copyPairingCode() { if (!pairingUrl) { return @@ -50,7 +70,7 @@ export function MobilePairingQrSection({ if (!pairingCodeButtonMountedRef.current) { return } - onClearCodeCopiedTimer() + clearCodeCopiedResetTimer() onCodeCopiedChange(true) codeCopiedResetTimerRef.current = window.setTimeout(() => { codeCopiedResetTimerRef.current = null @@ -119,6 +139,10 @@ export function MobilePairingQrSection({ variant="outline" size="sm" onClick={() => void copyPairingCode()} + aria-label={translate( + 'auto.components.settings.MobilePane.copyPairingCode', + 'Copy pairing code' + )} className="font-mono text-[11px] leading-tight whitespace-normal break-all h-auto py-2 px-3" > {pairingUrl} diff --git a/src/renderer/src/components/settings/MobilePairingSetupSection.test.tsx b/src/renderer/src/components/settings/MobilePairingSetupSection.test.tsx index 59ed1b9a3c9..30ce241be06 100644 --- a/src/renderer/src/components/settings/MobilePairingSetupSection.test.tsx +++ b/src/renderer/src/components/settings/MobilePairingSetupSection.test.tsx @@ -64,9 +64,17 @@ describe('MobilePairingSetupSection', () => { expect(screen.getByText(/must be able to reach this address/i)).toBeVisible() }) - it('describes address role when Anywhere is selected', () => { + it('explains the direct address when Orca Relay is selected', () => { renderSection({ connectionMode: 'automatic' }) + expect(screen.getByText('This computer’s address')).toBeVisible() + expect(screen.getByRole('combobox')).toBeVisible() expect(screen.getByText(/faster direct path when nearby/i)).toBeVisible() + expect(screen.getByRole('button', { name: 'Generate QR code' })).toBeEnabled() + }) + + it('can move retry recovery into the persistent failure notice', () => { + renderSection({ showGenerateAction: false }) + expect(screen.queryByRole('button', { name: 'Generate QR code' })).toBeNull() }) it('disables generate when sign-in is required', () => { diff --git a/src/renderer/src/components/settings/MobilePairingSetupSection.tsx b/src/renderer/src/components/settings/MobilePairingSetupSection.tsx index 2f5453013b9..12d63791f61 100644 --- a/src/renderer/src/components/settings/MobilePairingSetupSection.tsx +++ b/src/renderer/src/components/settings/MobilePairingSetupSection.tsx @@ -19,6 +19,7 @@ type MobilePairingSetupSectionProps = { onRefreshNetworkInterfaces: () => void loading: boolean hasQrCode: boolean + showGenerateAction?: boolean onGenerateQr: () => void } @@ -33,6 +34,7 @@ export function MobilePairingSetupSection({ onRefreshNetworkInterfaces, loading, hasQrCode, + showGenerateAction = true, onGenerateQr }: MobilePairingSetupSectionProps): React.JSX.Element { const usingRelay = connectionMode === 'automatic' @@ -106,31 +108,33 @@ export function MobilePairingSetupSection({ ) : translate( 'auto.components.settings.MobilePairingSetupSection.step2LocalDescription', - 'The phone must be able to reach this address on Wi‑Fi or Tailscale.' + 'The phone must be able to reach this address on Tailscale or Wi‑Fi.' )}

-
- -
+ {showGenerateAction ? ( +
+ +
+ ) : null} ) } diff --git a/src/renderer/src/components/settings/MobilePane.test.tsx b/src/renderer/src/components/settings/MobilePane.test.tsx index 9c0fa0066e5..03bc813188b 100644 --- a/src/renderer/src/components/settings/MobilePane.test.tsx +++ b/src/renderer/src/components/settings/MobilePane.test.tsx @@ -161,7 +161,8 @@ describe('MobilePane pairing connection mode', () => { listDevices: mocks.listDevices, listNetworkInterfaces: mocks.listNetworkInterfaces, revokeDevice: mocks.revokeDevice - } + }, + ui: { writeClipboardText: vi.fn().mockResolvedValue(undefined) } } }) }) @@ -197,39 +198,49 @@ describe('MobilePane pairing connection mode', () => { expect(getPairingQR).not.toHaveBeenCalled() }) - it('flags an Anywhere mint that degraded to a local-only code', async () => { + it('surfaces Relay mint failure without a QR and offers Use LAN', async () => { getPairingQR.mockResolvedValue({ - available: true, - qrDataUrl: 'data:image/png;base64,qr', - pairingUrl: 'orca://pair#degraded', - endpoint: 'ws://host', - // Relay provisioning failed server-side; the offer encodes local-only. - connectionMode: 'local-only' + available: false, + reason: 'relay_mint_failed', + guidance: 'Use LAN or retry', + relayFailure: { + code: 'relay offline', + stage: 'create_pairing_relay', + message: 'relay offline' + } }) const user = userEvent.setup() render() await user.click(screen.getByRole('button', { name: 'Generate' })) await waitFor(() => - expect(screen.getByTestId('relay-degraded-notice')).toHaveTextContent( - 'only works on your LAN or Tailscale' + expect(screen.getByTestId('relay-mint-failure-notice')).toHaveTextContent( + 'Couldn’t create a Relay pairing code' ) ) + expect(screen.getByTestId('qr')).toHaveTextContent('none') - // Switching to LAN clears the mismatch along with the QR. - await user.click(screen.getByRole('button', { name: 'choose-local' })) + getPairingQR.mockResolvedValue({ + available: true, + qrDataUrl: 'data:image/png;base64,local', + pairingUrl: 'orca://pair#local', + endpoint: 'ws://host', + connectionMode: 'local-only' + }) + await user.click(screen.getByRole('button', { name: 'Use LAN' })) + await waitFor(() => expect(screen.getByTestId('mode')).toHaveTextContent('local-only')) await waitFor(() => - expect(screen.queryByTestId('relay-degraded-notice')).not.toBeInTheDocument() + expect(screen.queryByTestId('relay-mint-failure-notice')).not.toBeInTheDocument() ) }) - it('does not flag an honest Relay mint', async () => { + it('does not show mint failure after an honest Relay mint', async () => { const user = userEvent.setup() render() await user.click(screen.getByRole('button', { name: 'Generate' })) await waitFor(() => expect(screen.getByTestId('qr')).toHaveTextContent('base64,qr')) - expect(screen.queryByTestId('relay-degraded-notice')).not.toBeInTheDocument() + expect(screen.queryByTestId('relay-mint-failure-notice')).not.toBeInTheDocument() }) it('keeps the copy fallback when QR encoding fails', async () => { @@ -257,6 +268,103 @@ describe('MobilePane pairing connection mode', () => { await user.click(screen.getByRole('button', { name: 'choose-local' })) expect(updateSettings).toHaveBeenCalledWith({ mobilePairingConnectionMode: 'local-only' }) expect(screen.getByTestId('mode')).toHaveTextContent('local-only') + expect(getPairingQR).not.toHaveBeenCalled() + }) + + it('disables Relay recovery while a retry is in flight', async () => { + getPairingQR.mockResolvedValueOnce({ + available: false, + reason: 'relay_mint_failed', + relayFailure: { + code: 'relay_mint_failed', + stage: 'create_pairing_relay', + message: 'Relay pairing invite request failed' + } + }) + const user = userEvent.setup() + render() + await user.click(screen.getByRole('button', { name: 'Generate' })) + await screen.findByTestId('relay-mint-failure-notice') + + let resolveRetry: ((value: Record) => void) | undefined + getPairingQR.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveRetry = resolve + }) + ) + await user.click(screen.getByRole('button', { name: 'Retry Relay' })) + expect(screen.getByRole('button', { name: 'Retry Relay' })).toBeDisabled() + expect(screen.getByRole('button', { name: 'Use LAN' })).toBeEnabled() + await user.dblClick(screen.getByRole('button', { name: 'Retry Relay' })) + expect(getPairingQR).toHaveBeenCalledTimes(2) + await waitFor(() => expect(screen.getByRole('button', { name: /Retrying/ })).toBeDisabled()) + + resolveRetry?.({ + available: true, + qrDataUrl: 'data:image/png;base64,relay', + pairingUrl: 'orca://relay', + endpoint: 'ws://relay', + connectionMode: 'automatic' + }) + await waitFor(() => expect(screen.getByTestId('qr')).toHaveTextContent('base64,relay')) + expect(screen.getByRole('status')).toHaveTextContent('Pairing code ready') + }) + + it('lets LAN recover immediately while a Relay retry is unresolved', async () => { + mocks.listNetworkInterfaces.mockResolvedValue({ + interfaces: [{ name: 'Ethernet', address: '10.0.0.2' }] + }) + getPairingQR.mockResolvedValueOnce({ + available: false, + reason: 'relay_mint_failed', + relayFailure: { + code: 'relay_mint_failed', + stage: 'create_pairing_relay', + message: 'Relay pairing invite request failed' + } + }) + const user = userEvent.setup() + render() + await waitFor(() => expect(mocks.listNetworkInterfaces).toHaveBeenCalledOnce()) + await user.click(screen.getByRole('button', { name: 'Generate' })) + await screen.findByTestId('relay-mint-failure-notice') + + let resolveRetry: ((value: Record) => void) | undefined + getPairingQR.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveRetry = resolve + }) + ) + getPairingQR.mockResolvedValueOnce({ + available: true, + qrDataUrl: 'data:image/png;base64,local', + pairingUrl: 'orca://local', + endpoint: 'ws://10.0.0.2', + connectionMode: 'local-only' + }) + await user.click(screen.getByRole('button', { name: 'Retry Relay' })) + await user.click(screen.getByRole('button', { name: 'Use LAN' })) + + await waitFor(() => + expect(getPairingQR).toHaveBeenLastCalledWith({ + address: '10.0.0.2', + connectionMode: 'local-only' + }) + ) + await waitFor(() => expect(screen.getByTestId('qr')).toHaveTextContent('base64,local')) + act(() => { + resolveRetry?.({ + available: true, + qrDataUrl: 'data:image/png;base64,stale-relay', + pairingUrl: 'orca://stale-relay', + endpoint: 'ws://relay', + connectionMode: 'automatic' + }) + }) + await waitFor(() => expect(screen.getByTestId('qr')).toHaveTextContent('base64,local')) + expect(screen.getByTestId('mode')).toHaveTextContent('local-only') }) it('restores a saved local-only preference without user interaction', () => { @@ -366,7 +474,7 @@ describe('MobilePane pairing connection mode', () => { expect(screen.getByTestId('qr')).toHaveTextContent('none') }) - it('discards a QR that resolves after switching path mid-generate', async () => { + it('discards a Relay QR that resolves after switching path mid-generate', async () => { const user = userEvent.setup() let resolveQr: ((value: Record) => void) | undefined getPairingQR.mockImplementationOnce( @@ -379,18 +487,27 @@ describe('MobilePane pairing connection mode', () => { await user.click(screen.getByRole('button', { name: 'Generate' })) await waitFor(() => expect(getPairingQR).toHaveBeenCalledWith({ connectionMode: 'automatic' })) - // Switch to LAN before the Relay mint resolves. + // Switch to LAN before the Relay mint resolves — LAN may auto-mint a new code. + getPairingQR.mockResolvedValue({ + available: true, + qrDataUrl: 'data:image/png;base64,local', + pairingUrl: 'orca://pair#local', + endpoint: 'ws://host', + connectionMode: 'local-only' + }) await user.click(screen.getByRole('button', { name: 'choose-local' })) resolveQr?.({ available: true, qrDataUrl: 'data:image/png;base64,relay', pairingUrl: 'orca://relay', - endpoint: 'ws://relay' + endpoint: 'ws://relay', + connectionMode: 'automatic' }) - await new Promise((resolve) => setTimeout(resolve, 10)) - expect(screen.getByTestId('qr')).toHaveTextContent('none') - expect(screen.getByTestId('mode')).toHaveTextContent('local-only') + await waitFor(() => expect(screen.getByTestId('mode')).toHaveTextContent('local-only')) + await waitFor(() => + expect(screen.getByTestId('pairing-url')).not.toHaveTextContent('orca://relay') + ) }) }) diff --git a/src/renderer/src/components/settings/MobilePane.tsx b/src/renderer/src/components/settings/MobilePane.tsx index 56e9350c037..f7f1ae0c8be 100644 --- a/src/renderer/src/components/settings/MobilePane.tsx +++ b/src/renderer/src/components/settings/MobilePane.tsx @@ -1,5 +1,4 @@ import { useCallback, useEffect, useRef, useState } from 'react' -import { CircleAlert } from 'lucide-react' import { toast } from 'sonner' import { useAppStore } from '../../store' import { useMountedRef } from '@/hooks/useMountedRef' @@ -18,12 +17,14 @@ import { MobilePairedDevicesSection } from './MobilePairedDevicesSection' import { MobileAutoRestoreFitSection } from './MobileAutoRestoreFitSection' import { MobilePairingConnectionOptions } from './MobilePairingConnectionOptions' import { MobilePairingSetupSection } from './MobilePairingSetupSection' +import { MobileRelayMintFailureNotice } from '../mobile/mobile-relay-mint-failure-notice' import { WindowsFirewallNotice } from '../mobile/WindowsFirewallNotice' import { translate } from '@/i18n/i18n' import { canMintMobilePairingOffer, type MobilePairingConnectionMode } from '../../../../shared/mobile-pairing-connection-mode' +import type { MobileRelayMintFailure } from '../../../../shared/mobile-relay-mint-failure' import { useMobilePairingConnectionMode } from '../mobile/use-mobile-pairing-connection-mode' export { getMobilePaneSearchEntries } from './mobile-pane-search' @@ -33,9 +34,7 @@ export function MobilePane(): React.JSX.Element { const [qrDataUrl, setQrDataUrl] = useState(null) const [pairingUrl, setPairingUrl] = useState(null) const [qrError, setQrError] = useState(false) - // Mode the displayed QR actually encodes; can be 'local-only' under an - // Anywhere selection when Relay provisioning degraded server-side. - const [qrEncodedMode, setQrEncodedMode] = useState(null) + const [relayMintFailure, setRelayMintFailure] = useState(null) const [endpoint, setEndpoint] = useState(null) const [loading, setLoading] = useState(false) const [qrEnlarged, setQrEnlarged] = useState(false) @@ -87,7 +86,7 @@ export function MobilePane(): React.JSX.Element { setQrDataUrl(null) setPairingUrl(null) setQrError(false) - setQrEncodedMode(null) + setRelayMintFailure(null) setEndpoint(null) // Why: a superseded in-flight generate no longer clears loading in its // finally (the epoch bump skips it), so drop the spinner here or Generate @@ -167,10 +166,16 @@ export function MobilePane(): React.JSX.Element { ) const generateQR = useCallback( - async (opts: { rotate?: boolean } = {}) => { + async ( + opts: { + rotate?: boolean + connectionModeOverride?: MobilePairingConnectionMode + } = {} + ) => { + const preferredMode = opts.connectionModeOverride ?? connectionMode // Why: refuse signed-out Anywhere rather than degrading to a local-only QR // under the Relay label (canMint is the shared honesty gate). - if (!canMintMobilePairingOffer({ connectionMode, signedIn })) { + if (!canMintMobilePairingOffer({ connectionMode: preferredMode, signedIn })) { return } const requestId = ++pairingRequestIdRef.current @@ -179,9 +184,7 @@ export function MobilePane(): React.JSX.Element { try { const result = await window.api.mobile.getPairingQR({ ...(selectedAddress ? { address: selectedAddress } : {}), - // canMint already requires sign-in for Anywhere, so the preferred path - // is the honest encoded mode. - connectionMode, + connectionMode: preferredMode, ...(opts.rotate || rotateNextQr ? { rotate: true } : {}) }) // Why: sign-out, a mode switch, or an address change bump the epoch. @@ -196,7 +199,7 @@ export function MobilePane(): React.JSX.Element { setQrDataUrl(result.qrDataUrl) setPairingUrl(result.pairingUrl) setQrError(result.qrDataUrl === null) - setQrEncodedMode(result.connectionMode) + setRelayMintFailure(null) setEndpoint(result.endpoint) setDeviceCountAtQr(getPairedMobileDevicesSnapshot().length) clearCodeCopiedResetTimer() @@ -204,18 +207,29 @@ export function MobilePane(): React.JSX.Element { setRotateNextQr(false) void loadDevices() } - } else { - if (mountedRef.current) { + } else if (mountedRef.current) { + setQrDataUrl(null) + setPairingUrl(null) + setQrError(false) + setEndpoint(null) + if (result.reason === 'relay_mint_failed' && result.relayFailure) { + setRelayMintFailure(result.relayFailure) + } else { + setRelayMintFailure(null) + // Why: IPC now forwards reason/guidance for all unavailability paths; + // prefer that copy over a hard-coded WebSocket-only message. toast.error( - translate( - 'auto.components.settings.MobilePane.cb9067c1c1', - 'WebSocket transport is not running' - ) + result.guidance ?? + translate( + 'auto.components.settings.MobilePane.cb9067c1c1', + 'WebSocket transport is not running' + ) ) } } } catch { if (mountedRef.current && requestId === pairingRequestIdRef.current) { + setRelayMintFailure(null) toast.error( translate( 'auto.components.settings.MobilePane.e3c427e020', @@ -250,13 +264,66 @@ export function MobilePane(): React.JSX.Element { handledModeRef.current = nextMode setConnectionMode(nextMode) void updateSettings({ mobilePairingConnectionMode: nextMode }) + // Why: after a Relay mint failure, LAN should mint immediately — including + // when the renderer has not chosen an address yet (main picks the default). + const shouldRecoverWithLan = relayMintFailure != null && nextMode === 'local-only' // A displayed or in-flight code encodes the old connection policy. The // main process rotates on the mode mismatch, so don't arm a second rotate. invalidatePairing({ armRotate: false }) + // Why: switching to LAN after a Relay failure should mint immediately. + if ( + shouldRecoverWithLan && + canMintMobilePairingOffer({ connectionMode: nextMode, signedIn }) + ) { + void generateQR({ rotate: false, connectionModeOverride: 'local-only' }) + } }, - [connectionMode, invalidatePairing, updateSettings, setConnectionMode] + [ + connectionMode, + generateQR, + invalidatePairing, + relayMintFailure, + signedIn, + updateSettings, + setConnectionMode + ] ) + const copyRelayDiagnostics = useCallback(async (): Promise => { + if (relayMintFailure == null) { + return + } + try { + await window.api.ui.writeClipboardText( + JSON.stringify( + { + kind: 'mobile_pairing_relay_failure', + preferredConnectionMode: connectionMode, + failure: relayMintFailure, + selectedAddress: selectedAddress ?? null, + at: new Date().toISOString() + }, + null, + 2 + ) + ) + if (mountedRef.current) { + toast.success( + translate('auto.components.settings.MobilePane.diagnosticsCopied', 'Diagnostics copied') + ) + } + } catch { + if (mountedRef.current) { + toast.error( + translate( + 'auto.components.settings.MobilePane.diagnosticsCopyFailed', + 'Failed to copy diagnostics' + ) + ) + } + } + }, [connectionMode, mountedRef, relayMintFailure, selectedAddress]) + const handleSelectedAddressChange = useCallback( (address: string): void => { setSelectedAddress(address) @@ -332,7 +399,14 @@ export function MobilePane(): React.JSX.Element { connectionMode={connectionMode} canGenerate={canMintMobilePairingOffer({ connectionMode, signedIn })} connectionPathControl={ - + } networkInterfaces={networkInterfaces} selectedAddress={selectedAddress} @@ -341,26 +415,26 @@ export function MobilePane(): React.JSX.Element { onRefreshNetworkInterfaces={() => void loadNetworkInterfaces({ notifyOnError: true })} loading={loading} hasQrCode={qrDataUrl != null} + showGenerateAction={relayMintFailure == null} onGenerateQr={() => void generateQR({ rotate: qrDataUrl != null })} /> - {qrDataUrl != null && connectionMode === 'automatic' && qrEncodedMode === 'local-only' ? ( - // Why: an Anywhere mint can degrade server-side when Relay provisioning - // fails; say so instead of letting the Relay label overclaim the code. -
- -

- {translate( - 'auto.components.settings.MobilePane.relayDegradedNotice', - 'Relay couldn’t be reached — this code only works on your LAN or Tailscale. Regenerate to try again.' - )} -

-
+ {relayMintFailure != null && connectionMode === 'automatic' ? ( + changeConnectionMode('local-only')} + onRetry={() => void generateQR({ rotate: true })} + onCopyDiagnostics={() => void copyRelayDiagnostics()} + busy={loading} + /> ) : null} + + {pairingUrl != null && !loading + ? translate('auto.components.settings.MobilePane.pairingCodeReady', 'Pairing code ready') + : ''} + + (null) const [pendingRemove, setPendingRemove] = useState(null) const [addServerFormOpen, setAddServerFormOpen] = useState(false) - const [shareServerFormOpen, setShareServerFormOpen] = useState(false) + const [shareServerFormOpen, setShareServerFormOpen] = useState(true) const [advancedOpen, setAdvancedOpen] = useState(false) + const [workflow, setWorkflow] = useState('connect') const [switchError, setSwitchError] = useState(null) const [removeError, setRemoveError] = useState(null) const [name, setName] = useState('') const [pairingCode, setPairingCode] = useState('') + const [addServerFailure, setAddServerFailure] = useState(null) const remoteServerUpdates = useAppStore((state) => state.remoteServerUpdates) const remoteServerUpdatesChecking = useAppStore((state) => state.remoteServerUpdatesChecking) const remoteServerUpdatesRunning = useAppStore((state) => state.remoteServerUpdatesRunning) @@ -308,96 +312,115 @@ export function RuntimeEnvironmentsPane({ ? getRuntimeEnvironmentsSearchEntry() : getWebRuntimeEnvironmentsSearchEntry() - const loadEnvironments = useCallback(async (): Promise => { - if (mountedRef.current) { - setIsLoading(true) - } - try { - const nextEnvironments = await window.api.runtimeEnvironments.list() - const visibleEnvironments = nextEnvironments.filter(isUserManagedRuntimeEnvironment) - // Why: drop store status for servers no longer saved so stale hosts don't - // linger in the sidebar registry. - useAppStore.getState().setRuntimeEnvironments(nextEnvironments) + const loadEnvironments = useCallback( + async (verified?: { environmentId: string; runtimeStatus: RuntimeStatus }): Promise => { if (mountedRef.current) { - setEnvironments(visibleEnvironments) - setDetailsByEnvironmentId((current) => { - const next: Record = {} - for (const environment of visibleEnvironments) { - next[environment.id] = current[environment.id] ?? { - status: 'loading', - runtimeStatus: null, - compatibility: null, - error: null - } - } - return next - }) + setIsLoading(true) } - await Promise.allSettled( - visibleEnvironments.map(async (environment) => { - try { - const response = await window.api.runtimeEnvironments.getStatus({ - selector: environment.id, - timeoutMs: 10_000 - }) - const runtimeStatus = unwrapRuntimeRpcResult(response) - // Why: feed the live status into the store so sidebar host pickers - // reflect manual refreshes, not just the settings pane. - useAppStore.getState().setRuntimeEnvironmentStatus(environment.id, { - status: runtimeStatus, - checkedAt: Date.now() - }) - if (!mountedRef.current) { - return + try { + const nextEnvironments = await window.api.runtimeEnvironments.list() + const visibleEnvironments = nextEnvironments.filter(isUserManagedRuntimeEnvironment) + // Why: drop store status for servers no longer saved so stale hosts don't + // linger in the sidebar registry. + useAppStore.getState().setRuntimeEnvironments(nextEnvironments) + if (verified) { + useAppStore.getState().setRuntimeEnvironmentStatus(verified.environmentId, { + status: verified.runtimeStatus, + checkedAt: Date.now() + }) + } + if (mountedRef.current) { + setEnvironments(visibleEnvironments) + setDetailsByEnvironmentId((current) => { + const next: Record = {} + for (const environment of visibleEnvironments) { + next[environment.id] = + verified?.environmentId === environment.id + ? { + status: 'ready', + runtimeStatus: verified.runtimeStatus, + compatibility: evaluateHostDetails(verified.runtimeStatus), + error: null + } + : (current[environment.id] ?? { + status: 'loading', + runtimeStatus: null, + compatibility: null, + error: null + }) } - setDetailsByEnvironmentId((current) => ({ - ...current, - [environment.id]: { - status: 'ready', - runtimeStatus, - compatibility: evaluateHostDetails(runtimeStatus), - error: null + return next + }) + } + await Promise.allSettled( + visibleEnvironments + .filter((environment) => environment.id !== verified?.environmentId) + .map(async (environment) => { + try { + const response = await window.api.runtimeEnvironments.getStatus({ + selector: environment.id, + timeoutMs: 10_000 + }) + const runtimeStatus = unwrapRuntimeRpcResult(response) + // Why: feed the live status into the store so sidebar host pickers + // reflect manual refreshes, not just the settings pane. + useAppStore.getState().setRuntimeEnvironmentStatus(environment.id, { + status: runtimeStatus, + checkedAt: Date.now() + }) + if (!mountedRef.current) { + return + } + setDetailsByEnvironmentId((current) => ({ + ...current, + [environment.id]: { + status: 'ready', + runtimeStatus, + compatibility: evaluateHostDetails(runtimeStatus), + error: null + } + })) + } catch (error) { + // Why: record the failed probe (null status) so the sidebar can + // distinguish unreachable from never-checked. + useAppStore.getState().setRuntimeEnvironmentStatus(environment.id, { + status: null, + checkedAt: Date.now() + }) + if (!mountedRef.current) { + return + } + setDetailsByEnvironmentId((current) => ({ + ...current, + [environment.id]: { + status: 'error', + runtimeStatus: null, + compatibility: null, + error: error instanceof Error ? error.message : String(error) + } + })) } - })) - } catch (error) { - // Why: record the failed probe (null status) so the sidebar can - // distinguish unreachable from never-checked. - useAppStore.getState().setRuntimeEnvironmentStatus(environment.id, { - status: null, - checkedAt: Date.now() }) - if (!mountedRef.current) { - return - } - setDetailsByEnvironmentId((current) => ({ - ...current, - [environment.id]: { - status: 'error', - runtimeStatus: null, - compatibility: null, - error: error instanceof Error ? error.message : String(error) - } - })) - } - }) - ) - } catch (error) { - if (mountedRef.current) { - toast.error( - error instanceof Error - ? error.message - : translate( - 'auto.components.settings.RuntimeEnvironmentsPane.e6410d72c3', - 'Failed to load runtime environments.' - ) ) + } catch (error) { + if (mountedRef.current) { + toast.error( + error instanceof Error + ? error.message + : translate( + 'auto.components.settings.RuntimeEnvironmentsPane.e6410d72c3', + 'Failed to load runtime environments.' + ) + ) + } + } finally { + if (mountedRef.current) { + setIsLoading(false) + } } - } finally { - if (mountedRef.current) { - setIsLoading(false) - } - } - }, [mountedRef]) + }, + [mountedRef] + ) useEffect(() => { void loadEnvironments() @@ -427,9 +450,10 @@ export function RuntimeEnvironmentsPane({ setAddServerFormOpen(false) setName('') setPairingCode('') + setAddServerFailure(null) } - const addEnvironment = async (): Promise => { + const addEnvironment = async (allowLoopback: boolean): Promise => { const trimmedName = name.trim() const trimmedPairingCode = pairingCode.trim() if (!trimmedName || !trimmedPairingCode) { @@ -454,17 +478,28 @@ export function RuntimeEnvironmentsPane({ ) return } + setAddServerFailure(null) setIsSaving(true) try { - const result = await window.api.runtimeEnvironments.addFromPairingCode({ + const result = await window.api.runtimeEnvironments.verifyAndAddFromPairingCode({ name: trimmedName, - pairingCode: trimmedPairingCode + pairingCode: trimmedPairingCode, + allowLoopback }) + if (!result.ok) { + if (mountedRef.current) { + setAddServerFailure({ kind: result.kind, message: result.message }) + } + return + } if (mountedRef.current) { setName('') setPairingCode('') } - await loadEnvironments() + await loadEnvironments({ + environmentId: result.environment.id, + runtimeStatus: result.runtimeStatus + }) if (!allowLocalRuntime) { const connected = await connectEnvironment(result.environment) if (!connected) { @@ -477,7 +512,7 @@ export function RuntimeEnvironmentsPane({ toast.success( translate( 'auto.components.settings.RuntimeEnvironmentsPane.7b5986c8df', - 'Saved {{value0}}. Use Advanced > Active Server to make it the default.', + 'Connected to {{value0}}. Use Advanced > Active Server to make it the default.', { value0: result.environment.name } ) ) @@ -723,6 +758,7 @@ export function RuntimeEnvironmentsPane({ } return environments.find((environment) => environment.id === value)?.name ?? 'remote server' } + const visibleWorkflow: RemoteServerWorkflow = addServerFormOpen ? 'connect' : workflow return ( -
+
+ {( + [ + [ + 'connect', + translate( + 'auto.components.settings.RuntimeEnvironmentsPane.connectWorkflow', + 'Connect to a host' + ), + translate( + 'auto.components.settings.RuntimeEnvironmentsPane.connectWorkflowHelp', + 'This app joins another machine' + ) + ], + [ + 'share', + translate( + 'auto.components.settings.RuntimeEnvironmentsPane.shareWorkflow', + 'Share this host' + ), + translate( + 'auto.components.settings.RuntimeEnvironmentsPane.shareWorkflowHelp', + 'Other devices join this machine' + ) + ], + [ + 'cloud-vm', + translate( + 'auto.components.settings.RuntimeEnvironmentsPane.cloudVmWorkflow', + 'Cloud VM' + ), + translate( + 'auto.components.settings.RuntimeEnvironmentsPane.cloudVmWorkflowHelp', + 'Manage recipe-created cloud machines' + ) + ] + ] as const + ) + .filter(([value]) => value !== 'share' || canGeneratePairingUrl) + .map(([value, label, description]) => ( + + ))} +
+ +
{addServerFormOpen ? ( -
{ - event.preventDefault() - void addEnvironment() + { + setPairingCode(value) + setAddServerFailure(null) }} - > -
-
- - setName(event.target.value)} - placeholder={translate( - 'auto.components.settings.RuntimeEnvironmentsPane.e038625857', - 'Dev box' - )} - className="h-8 text-xs" - autoFocus - /> -
-
- - setPairingCode(event.target.value)} - placeholder={translate( - 'auto.components.settings.RuntimeEnvironmentsPane.c3d772c514', - 'orca://pair?code=...' - )} - className="h-8 min-w-0 font-mono text-xs" - /> -

- {translate('auto.components.settings.RuntimeEnvironmentsPane.163671f7b5', 'Run')}{' '} - - {translate( - 'auto.components.settings.RuntimeEnvironmentsPane.960e901ae4', - 'orca serve --pairing-address ' - )} - {' '} - {translate( - 'auto.components.settings.RuntimeEnvironmentsPane.55fcc964cd', - 'on the server and paste the printed pairing URL.' - )} -

-
-
-
- - -
- + onCancel={closeAddServerForm} + onSubmit={(allowLoopback) => void addEnvironment(allowLoopback)} + /> ) : null}
@@ -936,12 +979,17 @@ export function RuntimeEnvironmentsPane({ ) : null}

- {isActive + {environment.connectionDependency === 'ssh-tunnel' ? translate( - 'auto.components.settings.RuntimeEnvironmentsPane.activeServerRowHelp', - 'Active server for server-routed projects, terminals, and provider checks.' + 'auto.components.settings.RuntimeEnvironmentsPane.sshTunnelRequired', + 'SSH tunnel required' ) - : getHostDetailsSummary(details)} + : isActive + ? translate( + 'auto.components.settings.RuntimeEnvironmentsPane.activeServerRowHelp', + 'Active server for server-routed projects, terminals, and provider checks.' + ) + : getHostDetailsSummary(details)}

{detailsDescription ? (

- +
+ + +
-
+
+ +
+ + ) +} diff --git a/src/renderer/src/components/settings/RuntimePairingGeneratorForm.test.tsx b/src/renderer/src/components/settings/RuntimePairingGeneratorForm.test.tsx new file mode 100644 index 00000000000..7515b204883 --- /dev/null +++ b/src/renderer/src/components/settings/RuntimePairingGeneratorForm.test.tsx @@ -0,0 +1,68 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' +import { TooltipProvider } from '../ui/tooltip' +import { + RuntimePairingGeneratorForm, + type RuntimePairingIntent +} from './RuntimePairingGeneratorForm' + +function renderForm( + intent: RuntimePairingIntent, + selectedAddress: string, + generated?: { + address: string + runtimePairingUrl: string + webClientUrl: string + } +): string { + return renderToStaticMarkup( + + + + ) +} + +describe('RuntimePairingGeneratorForm', () => { + it('uses detected interfaces for another-device intent', () => { + const markup = renderForm('another', '100.76.32.125') + expect(markup).toContain('role="combobox"') + expect(markup).not.toContain('id="runtime-pairing-custom-address"') + }) + + it('requires a dedicated value for custom-address intent', () => { + const emptyMarkup = renderForm('custom', '') + expect(emptyMarkup).toContain('id="runtime-pairing-custom-address"') + expect(emptyMarkup).toContain('disabled=""') + + const populatedMarkup = renderForm('custom', 'openclaw.example.ts.net') + expect(populatedMarkup).toContain('value="openclaw.example.ts.net"') + expect(populatedMarkup).not.toContain('disabled=""') + }) + + it('hides generated links after the selected address changes', () => { + const markup = renderForm('another', '100.76.32.125', { + address: '192.168.1.10', + runtimePairingUrl: 'orca://pair?code=stale-secret', + webClientUrl: 'https://example.test/?pair=stale-secret' + }) + + expect(markup).toContain('The connection address changed.') + expect(markup).not.toContain('stale-secret') + }) +}) diff --git a/src/renderer/src/components/settings/RuntimePairingGeneratorForm.tsx b/src/renderer/src/components/settings/RuntimePairingGeneratorForm.tsx index 5557f61765f..1c5176a999c 100644 --- a/src/renderer/src/components/settings/RuntimePairingGeneratorForm.tsx +++ b/src/renderer/src/components/settings/RuntimePairingGeneratorForm.tsx @@ -1,13 +1,18 @@ import { Loader2, RefreshCw } from 'lucide-react' import { Button } from '../ui/button' +import { Input } from '../ui/input' import { Label } from '../ui/label' import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip' import { AddressPicker, type AddressOption } from '../network/AddressPicker' import { parseServerShareAddress } from '../../../../shared/network/server-share-address' import { GeneratedUrlRow, UnavailableUrlRow } from './RuntimePairingGeneratedUrlRows' +import type { RuntimePairingIntent } from './runtime-pairing-link-state' import { translate } from '@/i18n/i18n' +export type { RuntimePairingIntent } from './runtime-pairing-link-state' + type RuntimePairingGeneratorFormProps = { + intent: RuntimePairingIntent loopbackAddress: string networkInterfaces: { name: string; address: string }[] selectedAddress: string @@ -16,6 +21,8 @@ type RuntimePairingGeneratorFormProps = { webClientUrl: string | null runtimePairingUrl: string | null copiedTarget: 'web' | 'pairing' | null + generatedAddress: string | null + onIntentChange: (intent: RuntimePairingIntent) => void onSelectedAddressChange: (address: string) => void onRefreshNetworkInterfaces: () => void onGenerate: () => void @@ -23,6 +30,7 @@ type RuntimePairingGeneratorFormProps = { } export function RuntimePairingGeneratorForm({ + intent, loopbackAddress, networkInterfaces, selectedAddress, @@ -31,136 +39,279 @@ export function RuntimePairingGeneratorForm({ webClientUrl, runtimePairingUrl, copiedTarget, + generatedAddress, + onIntentChange, onSelectedAddressChange, onRefreshNetworkInterfaces, onGenerate, onCopy }: RuntimePairingGeneratorFormProps): React.JSX.Element { - const options: AddressOption[] = [ - { - value: loopbackAddress, - label: `${translate( - 'auto.components.settings.RuntimePairingUrlGenerator.de6d5cff95', - 'This computer (' - )}${loopbackAddress})` - }, - ...networkInterfaces.map((networkInterface) => ({ - value: networkInterface.address, - label: `${networkInterface.name} (${networkInterface.address})` - })) - ] + const options: AddressOption[] = networkInterfaces.map((networkInterface) => ({ + value: networkInterface.address, + label: `${networkInterface.name} (${networkInterface.address})` + })) + const generatedIsCurrent = generatedAddress === selectedAddress + const staleGeneratedLink = generatedAddress !== null && !generatedIsCurrent + const customAddressResult = + intent === 'custom' ? parseServerShareAddress(selectedAddress) : { ok: true as const } + const customAddressInvalid = selectedAddress !== '' && !customAddressResult.ok + const canGenerate = selectedAddress !== '' && (intent !== 'custom' || customAddressResult.ok) return ( <>
-
-
-

- {translate( - 'auto.components.settings.RuntimePairingUrlGenerator.279e0dcb57', - '127.0.0.1 only works on this computer. Use a LAN, Tailscale, or custom address for another device.' - )} -

+ )} + {staleGeneratedLink && selectedAddress !== '' ? ( +
+ {translate( + 'auto.components.settings.RuntimePairingUrlGenerator.staleAddress', + 'The connection address changed. Generate a new link for {{address}}.', + { address: selectedAddress } + )} +
+ ) : null}
- {webClientUrl ? ( + {generatedIsCurrent && webClientUrl ? ( onCopy('web', webClientUrl)} /> - ) : runtimePairingUrl ? ( + ) : generatedIsCurrent && runtimePairingUrl ? ( ) : null} - {runtimePairingUrl ? ( + {generatedIsCurrent && runtimePairingUrl ? ( ({ + listNetworkInterfaces: vi.fn(), + listRuntimeAccessGrants: vi.fn() +})) + +vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) +vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } })) +vi.mock('./RuntimeAccessGrantList', () => ({ RuntimeAccessGrantList: () => null })) +vi.mock('./RuntimePairingGeneratorForm', () => ({ + RuntimePairingGeneratorForm: (props: { selectedAddress: string }) => ( +
{props.selectedAddress}
+ ) +})) + +import { RuntimePairingUrlGenerator } from './RuntimePairingUrlGenerator' +import { runtimePairingLinkCache } from './runtime-pairing-link-state' + +describe('RuntimePairingUrlGenerator', () => { + beforeEach(() => { + runtimePairingLinkCache.selectedAddress = '100.76.32.125' + runtimePairingLinkCache.customAddress = '' + runtimePairingLinkCache.intent = 'another' + runtimePairingLinkCache.generatedAddress = null + runtimePairingLinkCache.runtimePairingUrl = null + runtimePairingLinkCache.webClientUrl = null + runtimePairingLinkCache.runtimePairingDeviceId = null + mocks.listNetworkInterfaces.mockReset() + mocks.listRuntimeAccessGrants.mockReset().mockResolvedValue({ grants: [] }) + Object.defineProperty(window, 'api', { + configurable: true, + value: { + mobile: { + listNetworkInterfaces: mocks.listNetworkInterfaces, + listRuntimeAccessGrants: mocks.listRuntimeAccessGrants + } + } + }) + }) + + afterEach(() => { + cleanup() + }) + + it('keeps the cached address while interfaces are loading', async () => { + let resolveInterfaces!: (value: { interfaces: { name: string; address: string }[] }) => void + mocks.listNetworkInterfaces.mockReturnValue( + new Promise((resolve) => { + resolveInterfaces = resolve + }) + ) + + render() + await waitFor(() => expect(mocks.listNetworkInterfaces).toHaveBeenCalledOnce()) + expect(screen.getByTestId('selected-address')).toHaveTextContent('100.76.32.125') + + resolveInterfaces({ + interfaces: [{ name: 'tailscale0', address: '100.76.32.125' }] + }) + await waitFor(() => + expect(screen.getByTestId('selected-address')).toHaveTextContent('100.76.32.125') + ) + }) +}) diff --git a/src/renderer/src/components/settings/RuntimePairingUrlGenerator.tsx b/src/renderer/src/components/settings/RuntimePairingUrlGenerator.tsx index 590081874ca..7ab34224727 100644 --- a/src/renderer/src/components/settings/RuntimePairingUrlGenerator.tsx +++ b/src/renderer/src/components/settings/RuntimePairingUrlGenerator.tsx @@ -6,28 +6,15 @@ import { Label } from '../ui/label' import { RuntimeAccessGrantList } from './RuntimeAccessGrantList' import { translate } from '@/i18n/i18n' import { RuntimePairingGeneratorForm } from './RuntimePairingGeneratorForm' - -const LOOPBACK_ADDRESS = '127.0.0.1' - -// Why: runtime pairing tokens stay valid in the main-process registry; keep the -// last displayed URL across settings collapse/navigation without less-protected storage. -const runtimePairingUrlCache: { - selectedAddress: string - runtimePairingUrl: string | null - webClientUrl: string | null - runtimePairingDeviceId: string | null -} = { - selectedAddress: LOOPBACK_ADDRESS, - runtimePairingUrl: null, - webClientUrl: null, - runtimePairingDeviceId: null -} - -type RuntimePairingUrlGeneratorProps = { - framed?: boolean - showHeader?: boolean - showGeneratorForm?: boolean -} +import { + RUNTIME_PAIRING_LOOPBACK_ADDRESS, + cacheGeneratedRuntimePairingLink, + clearGeneratedRuntimePairingLink, + runtimePairingLinkCache, + selectRuntimePairingIntent, + type RuntimePairingIntent, + type RuntimePairingUrlGeneratorProps +} from './runtime-pairing-link-state' export function RuntimePairingUrlGenerator({ framed = true, @@ -37,15 +24,19 @@ export function RuntimePairingUrlGenerator({ const [networkInterfaces, setNetworkInterfaces] = useState<{ name: string; address: string }[]>( [] ) - const [selectedAddress, setSelectedAddress] = useState(runtimePairingUrlCache.selectedAddress) + const [selectedAddress, setSelectedAddress] = useState(runtimePairingLinkCache.selectedAddress) + const [intent, setIntent] = useState(runtimePairingLinkCache.intent) + const [generatedAddress, setGeneratedAddress] = useState( + runtimePairingLinkCache.generatedAddress + ) const [runtimePairingUrl, setRuntimePairingUrl] = useState( - runtimePairingUrlCache.runtimePairingUrl + runtimePairingLinkCache.runtimePairingUrl ) const [webClientUrl, setWebClientUrl] = useState( - runtimePairingUrlCache.webClientUrl + runtimePairingLinkCache.webClientUrl ) const [runtimePairingDeviceId, setRuntimePairingDeviceId] = useState( - runtimePairingUrlCache.runtimePairingDeviceId + runtimePairingLinkCache.runtimePairingDeviceId ) const [runtimeAccessGrants, setRuntimeAccessGrants] = useState([]) const [isLoadingAccessGrants, setIsLoadingAccessGrants] = useState(false) @@ -154,6 +145,20 @@ export function RuntimePairingUrlGenerator({ } }, [loadNetworkInterfaces]) + useEffect(() => { + if (intent !== 'another' || networkInterfaces.length === 0) { + return + } + const addressStillAvailable = networkInterfaces.some( + (networkInterface) => networkInterface.address === selectedAddress + ) + if (!addressStillAvailable) { + const nextAddress = networkInterfaces[0]?.address ?? '' + runtimePairingLinkCache.selectedAddress = nextAddress + setSelectedAddress(nextAddress) + } + }, [intent, networkInterfaces, selectedAddress]) + useEffect(() => { void loadRuntimeAccessGrants() return () => { @@ -162,21 +167,26 @@ export function RuntimePairingUrlGenerator({ }, [loadRuntimeAccessGrants]) const clearGeneratedUrls = (): void => { - runtimePairingUrlCache.runtimePairingUrl = null - runtimePairingUrlCache.webClientUrl = null - runtimePairingUrlCache.runtimePairingDeviceId = null + clearGeneratedRuntimePairingLink() if (mountedRef.current) { setRuntimePairingUrl(null) setWebClientUrl(null) setRuntimePairingDeviceId(null) + setGeneratedAddress(null) } } const generateRuntimePairingUrl = async (): Promise => { + const address = selectedAddress.trim() + runtimePairingLinkCache.selectedAddress = address + setSelectedAddress(address) + if (intent === 'custom') { + runtimePairingLinkCache.customAddress = address + } setIsGeneratingPairing(true) try { const result = await window.api.mobile.getRuntimePairingUrl({ - address: selectedAddress, + address, rotate: true }) if (!result.available) { @@ -191,13 +201,17 @@ export function RuntimePairingUrlGenerator({ } return } - runtimePairingUrlCache.runtimePairingUrl = result.pairingUrl - runtimePairingUrlCache.webClientUrl = result.webClientUrl - runtimePairingUrlCache.runtimePairingDeviceId = result.deviceId + cacheGeneratedRuntimePairingLink({ + address, + pairingUrl: result.pairingUrl, + webClientUrl: result.webClientUrl, + deviceId: result.deviceId + }) if (mountedRef.current) { setRuntimePairingUrl(result.pairingUrl) setWebClientUrl(result.webClientUrl) setRuntimePairingDeviceId(result.deviceId) + setGeneratedAddress(address) } await loadRuntimeAccessGrants() if (mountedRef.current) { @@ -325,8 +339,29 @@ export function RuntimePairingUrlGenerator({ const sharedAccessClassName = showGeneratorForm ? 'border-t border-border/40 pt-3' : '' const updateSelectedAddress = (address: string): void => { - runtimePairingUrlCache.selectedAddress = address + runtimePairingLinkCache.selectedAddress = address setSelectedAddress(address) + if ( + intent === 'another' && + !networkInterfaces.some((networkInterface) => networkInterface.address === address) + ) { + runtimePairingLinkCache.customAddress = address + runtimePairingLinkCache.intent = 'custom' + setIntent('custom') + } else if (intent === 'custom') { + runtimePairingLinkCache.customAddress = address + } + } + + const updateIntent = (nextIntent: RuntimePairingIntent): void => { + setIntent(nextIntent) + setSelectedAddress( + selectRuntimePairingIntent( + nextIntent, + networkInterfaces, + runtimePairingLinkCache.customAddress + ) + ) } return ( @@ -349,7 +384,8 @@ export function RuntimePairingUrlGenerator({ ) : null} {showGeneratorForm ? ( void loadNetworkInterfaces({ showToastOnError: true })} onGenerate={() => void generateRuntimePairingUrl()} diff --git a/src/renderer/src/components/settings/TerminalPaneAppearanceSection.tsx b/src/renderer/src/components/settings/TerminalPaneAppearanceSection.tsx index eb77800dcb3..bb4e3f79397 100644 --- a/src/renderer/src/components/settings/TerminalPaneAppearanceSection.tsx +++ b/src/renderer/src/components/settings/TerminalPaneAppearanceSection.tsx @@ -1,4 +1,5 @@ import type { GlobalSettings } from '../../../../shared/types' +import { DEFAULT_TERMINAL_INACTIVE_PANE_OPACITY } from '../../../../shared/constants' import { NumberField, SettingsSubsectionHeader } from './SettingsFormControls' import { SearchableSetting } from './SearchableSetting' import { clampNumber, resolvePaneStyleOptions } from '@/lib/terminal-theme' @@ -47,7 +48,7 @@ export function TerminalPaneAppearanceSection({ 'Dim unfocused panes.' )} value={paneStyleOptions.inactivePaneOpacity} - defaultValue={0.8} + defaultValue={DEFAULT_TERMINAL_INACTIVE_PANE_OPACITY} min={0} max={1} step={0.05} diff --git a/src/renderer/src/components/settings/ephemeral-vms-search.ts b/src/renderer/src/components/settings/ephemeral-vms-search.ts index 16e4f2063ee..654c5d52dda 100644 --- a/src/renderer/src/components/settings/ephemeral-vms-search.ts +++ b/src/renderer/src/components/settings/ephemeral-vms-search.ts @@ -5,10 +5,7 @@ import { createLocalizedCatalog } from '@/i18n/localized-catalog' export const getEphemeralVmsSearchEntry = createLocalizedCatalog( (): SettingsSearchEntry => ({ - title: translate( - 'auto.components.settings.ephemeralVms.search.title', - 'Per-Workspace Environments' - ), + title: translate('auto.components.settings.ephemeralVms.search.cloudVmTitle', 'Cloud VM'), description: translate( 'auto.components.settings.ephemeralVms.search.description', 'Learn how repo-owned recipes give each workspace its own on-demand, disposable environment.' diff --git a/src/renderer/src/components/settings/experimental-search.ts b/src/renderer/src/components/settings/experimental-search.ts index b15536edd2b..e4ffd8f1a28 100644 --- a/src/renderer/src/components/settings/experimental-search.ts +++ b/src/renderer/src/components/settings/experimental-search.ts @@ -275,7 +275,7 @@ export function getExperimentalSearchEntry() { ) ), ephemeralVms: findEntry( - translate('auto.components.settings.ephemeralVms.search.title', 'Per-Workspace Environments') + translate('auto.components.settings.ephemeralVms.search.cloudVmTitle', 'Cloud VM') ) } as const } diff --git a/src/renderer/src/components/settings/runtime-pairing-link-state.ts b/src/renderer/src/components/settings/runtime-pairing-link-state.ts new file mode 100644 index 00000000000..0b3a1230fb9 --- /dev/null +++ b/src/renderer/src/components/settings/runtime-pairing-link-state.ts @@ -0,0 +1,64 @@ +export const RUNTIME_PAIRING_LOOPBACK_ADDRESS = '127.0.0.1' + +export type RuntimePairingIntent = 'another' | 'local' | 'custom' + +export type RuntimePairingUrlGeneratorProps = { + framed?: boolean + showHeader?: boolean + showGeneratorForm?: boolean +} + +// Why: pairing tokens remain in the main-process registry, so the last link can +// survive settings navigation without writing credential material to storage. +export const runtimePairingLinkCache: { + selectedAddress: string + customAddress: string + intent: RuntimePairingIntent + generatedAddress: string | null + runtimePairingUrl: string | null + webClientUrl: string | null + runtimePairingDeviceId: string | null +} = { + selectedAddress: '', + customAddress: '', + intent: 'another', + generatedAddress: null, + runtimePairingUrl: null, + webClientUrl: null, + runtimePairingDeviceId: null +} + +export function clearGeneratedRuntimePairingLink(): void { + runtimePairingLinkCache.runtimePairingUrl = null + runtimePairingLinkCache.webClientUrl = null + runtimePairingLinkCache.runtimePairingDeviceId = null + runtimePairingLinkCache.generatedAddress = null +} + +export function cacheGeneratedRuntimePairingLink(args: { + address: string + pairingUrl: string + webClientUrl: string | null + deviceId: string +}): void { + runtimePairingLinkCache.runtimePairingUrl = args.pairingUrl + runtimePairingLinkCache.webClientUrl = args.webClientUrl + runtimePairingLinkCache.runtimePairingDeviceId = args.deviceId + runtimePairingLinkCache.generatedAddress = args.address +} + +export function selectRuntimePairingIntent( + intent: RuntimePairingIntent, + networkInterfaces: { address: string }[], + customAddress: string +): string { + runtimePairingLinkCache.intent = intent + const selectedAddress = + intent === 'local' + ? RUNTIME_PAIRING_LOOPBACK_ADDRESS + : intent === 'another' + ? (networkInterfaces[0]?.address ?? '') + : customAddress + runtimePairingLinkCache.selectedAddress = selectedAddress + return selectedAddress +} diff --git a/src/renderer/src/components/sidebar/AddRemoteHostDialog.tsx b/src/renderer/src/components/sidebar/AddRemoteHostDialog.tsx index 1c31dfebe79..5e2527bf0d6 100644 --- a/src/renderer/src/components/sidebar/AddRemoteHostDialog.tsx +++ b/src/renderer/src/components/sidebar/AddRemoteHostDialog.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useMemo, useState } from 'react' import { toast } from 'sonner' import { Dialog, @@ -19,6 +19,11 @@ import { type EditingTarget } from '../settings/ssh-target-draft' import { MAX_SSH_RELAY_GRACE_PERIOD_SECONDS, type SshTarget } from '../../../../shared/ssh-types' +import { parseHostAccessLink } from '../../../../shared/remote-pairing-address' +import { + translateHostAccessLinkError, + translateRemotePairingFailureDescription +} from '@/lib/remote-pairing-copy' import { RemoteServerFields, SshHostFields } from './AddRemoteHostFields' export type AddRemoteHostMode = 'ssh' | 'server' @@ -43,12 +48,18 @@ export function AddRemoteHostDialog({ const [sshForm, setSshForm] = useState(EMPTY_FORM) const [serverName, setServerName] = useState('') const [pairingCode, setPairingCode] = useState('') + const [allowLoopback, setAllowLoopback] = useState(false) const [isSaving, setIsSaving] = useState(false) const [isImporting, setIsImporting] = useState(false) + const parsedServerLink = useMemo(() => parseHostAccessLink(pairingCode), [pairingCode]) + const serverFormCanSubmit = + serverName.trim() !== '' && + parsedServerLink.ok && + (parsedServerLink.value.endpointKind !== 'loopback' || allowLoopback) const setSshTargetsMetadata = useAppStore((s) => s.setSshTargetsMetadata) const recordSshRepoReadoptions = useAppStore((s) => s.recordSshRepoReadoptions) const setRuntimeEnvironments = useAppStore((s) => s.setRuntimeEnvironments) - const refreshRuntimeEnvironmentStatus = useAppStore((s) => s.refreshRuntimeEnvironmentStatus) + const setRuntimeEnvironmentStatus = useAppStore((s) => s.setRuntimeEnvironmentStatus) const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction) const close = () => { @@ -62,6 +73,7 @@ export function AddRemoteHostDialog({ setSshForm(EMPTY_FORM) setServerName('') setPairingCode('') + setAllowLoopback(false) } const refreshSshTargetMetadata = async () => { @@ -195,16 +207,44 @@ export function AddRemoteHostDialog({ ) return } + if (!parsedServerLink.ok) { + toast.error(translateHostAccessLinkError(parsedServerLink.kind)) + return + } + if (parsedServerLink.value.endpointKind === 'loopback' && !allowLoopback) { + toast.error( + translate( + 'auto.components.sidebar.AddRemoteHostDialog.loopbackBlocked', + 'Enable the SSH tunnel override or create a new link using the other host’s Tailscale or LAN address.' + ) + ) + return + } setIsSaving(true) try { - const result = await window.api.runtimeEnvironments.addFromPairingCode({ + const result = await window.api.runtimeEnvironments.verifyAndAddFromPairingCode({ name: trimmedName, - pairingCode: trimmedPairingCode + pairingCode: trimmedPairingCode, + allowLoopback }) + if (!result.ok) { + toast.error( + result.kind === 'environment-save-failed' + ? result.message + : translateRemotePairingFailureDescription( + result.kind, + parsedServerLink.value.displayEndpoint + ) + ) + return + } const environments = await window.api.runtimeEnvironments.list() setRuntimeEnvironments(environments) - await refreshRuntimeEnvironmentStatus(result.environment.id) + setRuntimeEnvironmentStatus(result.environment.id, { + status: result.runtimeStatus, + checkedAt: Date.now() + }) toast.success( translate('auto.components.sidebar.AddRemoteHostDialog.serverSaved', 'Remote server added.') ) @@ -260,9 +300,15 @@ export function AddRemoteHostDialog({ { + setPairingCode(value) + setAllowLoopback(false) + }} + allowLoopback={allowLoopback} + onAllowLoopbackChange={setAllowLoopback} onSubmit={() => void saveRemoteServer()} /> ) : ( @@ -306,7 +352,9 @@ export function AddRemoteHostDialog({ onClick={ renderMode === 'server' ? () => void saveRemoteServer() : () => void saveSshHost() } - disabled={isSaving || isImporting} + disabled={ + isSaving || isImporting || (renderMode === 'server' && !serverFormCanSubmit) + } > {isSaving ? translate('auto.components.sidebar.AddRemoteHostDialog.saving', 'Saving...') diff --git a/src/renderer/src/components/sidebar/AddRemoteHostFields.test.tsx b/src/renderer/src/components/sidebar/AddRemoteHostFields.test.tsx new file mode 100644 index 00000000000..631eb7b3025 --- /dev/null +++ b/src/renderer/src/components/sidebar/AddRemoteHostFields.test.tsx @@ -0,0 +1,38 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' +import { encodePairingOffer, PAIRING_OFFER_VERSION } from '../../../../shared/pairing' +import { parseHostAccessLink } from '../../../../shared/remote-pairing-address' +import { RemoteServerFields } from './AddRemoteHostFields' + +function loopbackAccessLink(): string { + return encodePairingOffer({ + v: PAIRING_OFFER_VERSION, + endpoint: 'ws://127.0.0.1:6768', + deviceToken: 'token', + publicKeyB64: 'key', + scope: 'runtime' + }) +} + +describe('RemoteServerFields', () => { + it('associates blocked loopback guidance with the access-link input', () => { + const pairingCode = loopbackAccessLink() + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('aria-invalid="true"') + expect(markup).toContain('aria-describedby="add-server-loopback-blocked"') + expect(markup).toContain('id="add-server-loopback-blocked"') + }) +}) diff --git a/src/renderer/src/components/sidebar/AddRemoteHostFields.tsx b/src/renderer/src/components/sidebar/AddRemoteHostFields.tsx index 370e847ebee..60a940dd0e0 100644 --- a/src/renderer/src/components/sidebar/AddRemoteHostFields.tsx +++ b/src/renderer/src/components/sidebar/AddRemoteHostFields.tsx @@ -1,7 +1,14 @@ import { useState } from 'react' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' +import { Badge } from '@/components/ui/badge' +import { Checkbox } from '@/components/ui/checkbox' import { translate } from '@/i18n/i18n' +import { + translateHostAccessLinkError, + translateRemotePairingEndpointKind +} from '@/lib/remote-pairing-copy' +import type { ParseHostAccessLinkResult } from '../../../../shared/remote-pairing-address' import { applyParsedSshHostInput, type EditingTarget } from '../settings/ssh-target-draft' import { SshHostAdvancedFields } from '../settings/SshHostAdvancedFields' @@ -120,18 +127,32 @@ export function SshHostFields({ export function RemoteServerFields({ name, pairingCode, + parsedLink, disabled, onNameChange, onPairingCodeChange, + allowLoopback, + onAllowLoopbackChange, onSubmit }: { name: string pairingCode: string + parsedLink: ParseHostAccessLinkResult disabled: boolean onNameChange: (value: string) => void onPairingCodeChange: (value: string) => void + allowLoopback: boolean + onAllowLoopbackChange: (value: boolean) => void onSubmit: () => void }) { + const inputError = pairingCode.trim() !== '' && !parsedLink.ok + const loopbackBlocked = + parsedLink.ok && parsedLink.value.endpointKind === 'loopback' && !allowLoopback + const pairingCodeDescriptionId = inputError + ? 'add-server-pairing-code-error' + : loopbackBlocked + ? 'add-server-loopback-blocked' + : 'add-server-pairing-code-help' return (
onPairingCodeChange(event.target.value)} @@ -171,20 +194,63 @@ export function RemoteServerFields({ )} className="font-mono" /> -

- {translate('auto.components.sidebar.AddRemoteHostDialog.pairingHelpPrefix', 'Run')}{' '} - - {translate( - 'auto.components.sidebar.AddRemoteHostDialog.pairingCommand', - 'orca serve --pairing-address ' - )} - {' '} +

{translate( 'auto.components.sidebar.AddRemoteHostDialog.pairingHelpSuffix', - 'on the server and paste the printed pairing URL.' + 'Create this under Settings → Remote Orca Servers → Share this host on the other computer.' )}

+ {inputError ? ( + + ) : null}
+ {parsedLink.ok ? ( +
+
+ {translate( + 'auto.components.sidebar.AddRemoteHostDialog.linkDestination', + 'Link destination' + )} + + {translateRemotePairingEndpointKind(parsedLink.value.endpointKind)} + +
+
{parsedLink.value.displayEndpoint}
+ {parsedLink.value.endpointKind === 'loopback' ? ( + + ) : null} +
+ ) : null} + {loopbackBlocked ? ( + + ) : null} ) } diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts index b89aa08a000..4f129edf853 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts @@ -756,7 +756,7 @@ describe('submitFolderWorkspaceCreate native-chat launch draft', () => { expect(seededDraftFor('tab-1')?.text).toBe(ISSUE_URL) }) - it('leaves a multi-line draft in the terminal only', async () => { + it('mirrors a multi-line draft into chat', async () => { await submitFolderWorkspaceCreate({ projectGroup: makeProjectGroup(), name: '', @@ -770,8 +770,6 @@ describe('submitFolderWorkspaceCreate native-chat launch draft', () => { onOpenChange: vi.fn() }) - // Terminal still gets it; the chat mirror is withheld until multi-line send - // is safe, and decideInitialAgentTabViewMode keeps this launch in terminal. expect(mocks.ensureAgentStartupInTerminal).toHaveBeenCalledWith( expect.objectContaining({ startup: expect.objectContaining({ @@ -779,7 +777,7 @@ describe('submitFolderWorkspaceCreate native-chat launch draft', () => { }) }) ) - expect(seededDraftFor('tab-1')).toBeUndefined() + expect(seededDraftFor('tab-1')?.text).toBe(`Reproduce on Windows first\n\n${ISSUE_URL}`) }) it('does not mirror an unlinked note, which is submitted rather than drafted', async () => { @@ -832,9 +830,9 @@ describe('folder-workspace draft: seeded set == chat-opening set', () => { // view-mode gate, and both must agree with what the composer actually holds. it.each([ ['argv-prefill', 'claude' as const, '', true], - ['argv-prefill multi-line', 'claude' as const, 'Reproduce on Windows first', false], + ['argv-prefill multi-line', 'claude' as const, 'Reproduce on Windows first', true], ['startup-paste', 'codex' as const, '', true], - ['startup-paste multi-line', 'codex' as const, 'Reproduce on Windows first', false] + ['startup-paste multi-line', 'codex' as const, 'Reproduce on Windows first', true] ])('%s', async (_label, quickAgent, note, expectMirrored) => { await submitFolderWorkspaceCreate({ projectGroup: makeProjectGroup(), diff --git a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx index e316c79da4e..de06fcac580 100644 --- a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx +++ b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx @@ -875,6 +875,12 @@ export function ResourceUsageStatusSegment({ } }, [open, fetchSnapshot, refreshSessions]) + useEffect(() => { + if (!open) { + clearSessionsError() + } + }, [open, clearSessionsError]) + const repoDisplayNameById = useMemo(() => { const map = new Map() for (const repo of repos) { diff --git a/src/renderer/src/hooks/ipc-events-test-harness.ts b/src/renderer/src/hooks/ipc-events-test-harness.ts index 0c3557654c6..994001efea7 100644 --- a/src/renderer/src/hooks/ipc-events-test-harness.ts +++ b/src/renderer/src/hooks/ipc-events-test-harness.ts @@ -8,12 +8,24 @@ export type CreateTerminalRequest = { command?: string title?: string ptyId?: string + activate?: boolean presentation?: 'background' | 'focused' + surfaceOwner?: boolean tabId?: string leafId?: string splitFromLeafId?: string } +export type RequestTerminalCreateRequest = { + requestId: string + worktreeId?: string + command?: string + title?: string + activate?: boolean + presentation?: 'background' | 'focused' + surfaceOwner?: boolean +} + export type HarnessTab = { id: string; ptyId?: string | null; title?: string } export type HarnessStoreState = { @@ -108,6 +120,7 @@ export type IpcEventsHarness = { /** Call inside the test body: useIpcEvents runs its effects eagerly here. */ useIpcEvents: () => void createTerminal: (request: CreateTerminalRequest) => void + requestTerminalCreate: (request: RequestTerminalCreateRequest) => void replyTerminalCreate: ReturnType } @@ -120,6 +133,7 @@ export async function loadIpcEventsHarness( ): Promise { const replyTerminalCreate = vi.fn() let createTerminalListener: ((request: CreateTerminalRequest) => void) | null = null + let requestTerminalCreateListener: ((request: RequestTerminalCreateRequest) => void) | null = null vi.resetModules() vi.unstubAllGlobals() @@ -170,6 +184,10 @@ export async function loadIpcEventsHarness( onCreateTerminal: (listener: (request: CreateTerminalRequest) => void) => { createTerminalListener = listener return () => {} + }, + onRequestTerminalCreate: (listener: (request: RequestTerminalCreateRequest) => void) => { + requestTerminalCreateListener = listener + return () => {} } }), rateLimits: { @@ -219,6 +237,12 @@ export async function loadIpcEventsHarness( } createTerminalListener(request) }, + requestTerminalCreate: (request) => { + if (typeof requestTerminalCreateListener !== 'function') { + throw new Error('Expected the request-terminal-create listener to be registered') + } + requestTerminalCreateListener(request) + }, replyTerminalCreate } } diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index 0765019ca83..1f9b1763851 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -11,6 +11,11 @@ import { resolveBrowserSessionTabTarget, resolveZoomTarget } from './useIpcEvents' +import { + createHarnessStoreState, + loadIpcEventsHarness, + type HarnessStoreState +} from './ipc-events-test-harness' import type { SleepingAgentLaunchConfig } from '../../../shared/agent-session-resume' import type { AgentStatusClearIpcPayload } from '../../../shared/agent-status-types' import type { TuiAgent } from '../../../shared/types' @@ -8555,3 +8560,125 @@ describe('parked terminal recovery on repos:changed', () => { clearTerminalTabsParkedOnUnresolvedHost() }) }) + +describe('useIpcEvents silent terminal adoption (surfaceOwner: false)', () => { + const asMock = (value: unknown): ReturnType => value as ReturnType + + function createBackgroundWorkspaceState(): HarnessStoreState { + return createHarnessStoreState({ + tabsByWorktree: {}, + // Honour the pre-minted tab id so adoption resolves the same tab main asked for. + createTab: vi.fn( + (_worktreeId: string, _groupId?: string, _tabType?: string, options?: { id?: string }) => ({ + id: options?.id ?? 'tab-minted' + }) + ), + worktreesByRepo: { + 'repo-1': [ + { id: 'wt-1', repoId: 'repo-1' }, + { id: 'wt-2', repoId: 'repo-1' } + ] + } + }) + } + + it('adopts a background workspace terminal without scrolling the sidebar to it', async () => { + const storeState = createBackgroundWorkspaceState() + const harness = await loadIpcEventsHarness(storeState) + harness.useIpcEvents() + + // Control: `orca terminal create` (no surfaceOwner) keeps its reveal so the + // user can find the terminal they just asked for. + harness.createTerminal({ + worktreeId: 'wt-2', + ptyId: 'pty-cli', + activate: false, + tabId: 'tab-cli' + }) + + expect(storeState.createTab).toHaveBeenCalledWith('wt-2', undefined, undefined, { + initialPtyId: 'pty-cli', + activate: false, + id: 'tab-cli' + }) + expect(storeState.revealWorktreeInSidebar).toHaveBeenCalledWith('wt-2') + + asMock(storeState.createTab).mockClear() + asMock(storeState.revealWorktreeInSidebar).mockClear() + asMock(storeState.setActiveView).mockClear() + asMock(storeState.setActiveWorktree).mockClear() + asMock(storeState.setActiveTabType).mockClear() + asMock(storeState.setActiveTab).mockClear() + + // Regression: a workspace created in the background spawns its agent terminal + // here; the tab must still be adopted, but the sidebar must not scroll to it. + harness.createTerminal({ + worktreeId: 'wt-2', + ptyId: 'pty-background-create', + activate: false, + surfaceOwner: false, + tabId: 'tab-background-create' + }) + + expect(storeState.createTab).toHaveBeenCalledWith('wt-2', undefined, undefined, { + initialPtyId: 'pty-background-create', + activate: false, + id: 'tab-background-create' + }) + expect(storeState.revealWorktreeInSidebar).not.toHaveBeenCalled() + expect(storeState.setActiveView).not.toHaveBeenCalled() + expect(storeState.setActiveWorktree).not.toHaveBeenCalled() + expect(storeState.setActiveTabType).not.toHaveBeenCalled() + expect(storeState.setActiveTab).not.toHaveBeenCalled() + }) + + it('replies to a requested terminal create without scrolling the sidebar when surfaceOwner is false', async () => { + const storeState = createBackgroundWorkspaceState() + const harness = await loadIpcEventsHarness(storeState) + harness.useIpcEvents() + + // Control: the same request without surfaceOwner still reveals its workspace. + harness.requestTerminalCreate({ + requestId: 'req-cli', + worktreeId: 'wt-2', + title: 'Claude' + }) + + expect(harness.replyTerminalCreate).toHaveBeenCalledWith({ + requestId: 'req-cli', + tabId: 'tab-minted', + title: 'Claude' + }) + expect(storeState.revealWorktreeInSidebar).toHaveBeenCalledWith('wt-2') + + asMock(storeState.createTab).mockClear() + asMock(storeState.revealWorktreeInSidebar).mockClear() + asMock(storeState.setActiveView).mockClear() + asMock(storeState.setActiveWorktree).mockClear() + asMock(storeState.setActiveTabType).mockClear() + asMock(storeState.setActiveTab).mockClear() + harness.replyTerminalCreate.mockClear() + + harness.requestTerminalCreate({ + requestId: 'req-background-create', + worktreeId: 'wt-2', + title: 'Claude', + surfaceOwner: false + }) + + expect(storeState.createTab).toHaveBeenCalledWith('wt-2', undefined, undefined, { + activate: false, + recordInteraction: false + }) + expect(harness.replyTerminalCreate).toHaveBeenCalledWith({ + requestId: 'req-background-create', + tabId: 'tab-minted', + title: 'Claude' + }) + expect(storeState.revealWorktreeInSidebar).not.toHaveBeenCalled() + expect(storeState.setActiveView).not.toHaveBeenCalled() + expect(storeState.setActiveWorktree).not.toHaveBeenCalled() + expect(storeState.setActiveTabType).not.toHaveBeenCalled() + expect(storeState.setActiveTab).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 03d3a64b743..915df3dc05a 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -88,6 +88,7 @@ import { attachMobileMarkdownBridge } from '@/runtime/mobile-markdown-bridge' import { closeMobileSessionTabInStore } from '@/runtime/mobile-session-tab-close' import { createWorktreeChangeRefreshQueue } from './worktree-change-refresh-queue' import { subscribeRuntimeClientEvents } from '@/runtime/runtime-client-events' +import { applyNativeChatLaunchDraftResolved } from '@/runtime/native-chat-launch-draft-runtime-resolution' import { toRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream' import { dispatchTerminalSideEffectBatch } from '@/components/terminal-pane/terminal-side-effect-facts-handler' import { subscribeToUnpairedDeviceAuthNotification } from './unpaired-device-auth-notification' @@ -936,6 +937,10 @@ export function useIpcEvents(): void { }) return } + if (event.type === 'nativeChatLaunchDraftResolved') { + applyNativeChatLaunchDraftResolved(useAppStore.getState(), event) + return + } if (event.type === 'reposChanged') { runtimeProjectRefreshScheduler.request(environmentId) return @@ -1416,6 +1421,7 @@ export function useIpcEvents(): void { activate, focus, presentation, + surfaceOwner, tabId, leafId, splitFromLeafId, @@ -1430,7 +1436,8 @@ export function useIpcEvents(): void { focus }) const shouldActivate = terminalPresentation === 'focused' - const shouldSurfaceOwner = terminalPresentation !== 'background' + const shouldSurfaceOwner = + terminalPresentation !== 'background' && surfaceOwner !== false if (shouldActivate) { activateTerminalInitiatedWorktree(store, worktreeId) } @@ -1682,7 +1689,8 @@ export function useIpcEvents(): void { } const terminalPresentation = resolveTerminalPresentation(data) const shouldActivate = terminalPresentation === 'focused' - const shouldSurfaceOwner = terminalPresentation !== 'background' + const shouldSurfaceOwner = + terminalPresentation !== 'background' && data.surfaceOwner !== false if (shouldActivate) { activateTerminalInitiatedWorktree(store, worktreeId) } @@ -3472,6 +3480,18 @@ export function useIpcEvents(): void { }) ) + const unsubscribeLaunchDraftResolution = window.api.runtime.onNativeChatLaunchDraftResolved?.( + (event) => { + applyNativeChatLaunchDraftResolved(useAppStore.getState(), { + type: 'nativeChatLaunchDraftResolved', + ...event + }) + } + ) + if (unsubscribeLaunchDraftResolution) { + unsubs.push(unsubscribeLaunchDraftResolution) + } + unsubs.push( window.api.runtime.onBrowserDriverChanged((event) => { if (isRuntimeEnvironmentActive()) { diff --git a/src/renderer/src/hooks/useSettingsNavigationMetadata.test.ts b/src/renderer/src/hooks/useSettingsNavigationMetadata.test.ts index fe4dc94da12..0343bb65bc6 100644 --- a/src/renderer/src/hooks/useSettingsNavigationMetadata.test.ts +++ b/src/renderer/src/hooks/useSettingsNavigationMetadata.test.ts @@ -116,7 +116,7 @@ describe('settings navigation metadata', () => { expect(sections.find((section) => section.id === 'voice')?.badge).toBeUndefined() }) - it('places per-workspace environments under Experimental instead of as a beta sidebar item', () => { + it('places Cloud VM under Experimental instead of as a beta sidebar item', () => { const sections = buildSettingsNavigationMetadata({ isMac: false, isWindows: false, @@ -125,7 +125,7 @@ describe('settings navigation metadata', () => { }) const experimental = sections.find((section) => section.id === 'experimental') const entry = experimental?.searchEntries.find( - (searchEntry) => searchEntry.title === 'Per-Workspace Environments' + (searchEntry) => searchEntry.title === 'Cloud VM' ) expect(sections.map((section) => section.id)).not.toContain('ephemeral-vms') diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index e6f91319871..5b74621aad7 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -172,7 +172,11 @@ }, "webPreloadApi": { "aiVaultUnavailableForHost": "Agent Session History is not available for this execution host.", - "runtimeEnvironmentManuallyDisconnected": "Runtime environment is manually disconnected." + "runtimeEnvironmentManuallyDisconnected": "Runtime environment is manually disconnected.", + "loopbackPairingBlocked": "This access link points back to this device.", + "remotePairingUnreachable": "Cannot reach Orca at {{endpoint}}.", + "remotePairingInvalidDetails": "This access link contains invalid connection details.", + "remotePairingSaveFailed": "Orca verified the host but could not save it. Check browser storage and try again." }, "web": { "preload": { @@ -374,6 +378,18 @@ } } }, + "remotePairingCopy": { + "invalidInput": "Enter an Orca access link or bare pairing code.", + "mobileOnly": "This link grants mobile-only access. Generate a link for another Orca client.", + "invalidDestination": "This access link contains an invalid destination.", + "unsupportedDestination": "This access link contains an unsupported destination.", + "nonConnectableDestination": "This access link contains a non-connectable destination.", + "loopback": "Loopback", + "tailscale": "Tailscale address", + "lan": "Private LAN address", + "public": "Public address", + "custom": "Custom hostname" + }, "ensure": { "simulator": { "tab": { @@ -5033,7 +5049,11 @@ "sshImportFailed": "Failed to import SSH config.", "importing": "Importing...", "importSshConfig": "Import ~/.ssh/config", - "advanced": "Advanced" + "advanced": "Advanced", + "linkDestination": "Link destination", + "sshTunnel": "I am using an SSH tunnel", + "sshTunnelHelp": "Otherwise, this link points back to this device and cannot identify the other computer.", + "loopbackBlocked": "Enable the SSH tunnel override or create a new link using the other host’s Tailscale or LAN address." }, "ForgetSshWorkspaceDialog": { "reconnectFailed": "Reconnection failed", @@ -6248,7 +6268,11 @@ "1b1b70279a": "No devices paired yet.", "1592afcc7a": "No devices paired yet. Scan the QR code with the Orca mobile app.", "relayDegradedNotice": "Relay couldn’t be reached — this code only works on your LAN or Tailscale. Regenerate to try again.", - "pairingQrError": "This pairing code couldn’t be rendered as a QR code. Copy it into Orca Mobile instead." + "pairingQrError": "This pairing code couldn’t be rendered as a QR code. Copy it into Orca Mobile instead.", + "pairingCodeReady": "Pairing code ready", + "copyPairingCode": "Copy pairing code", + "diagnosticsCopied": "Diagnostics copied", + "diagnosticsCopyFailed": "Failed to copy diagnostics" }, "MobileSettingsPane": { "9a3c280e49": "GitHub Releases", @@ -6740,7 +6764,22 @@ "updatingServers": "Updating servers…", "orcaVersion": "Orca v{{value0}}", "removeActiveServerBlocked": "Choose another Active Server in Advanced before removing this server.", - "removeActiveServerDescription": "Choose another Active Server in Advanced before removing this server. Existing host sessions are left alone." + "removeActiveServerDescription": "Choose another Active Server in Advanced before removing this server. Existing host sessions are left alone.", + "workflow": "Remote server workflow", + "connectWorkflow": "Connect to a host", + "connectWorkflowHelp": "This app joins another machine", + "shareWorkflow": "Share this host", + "shareWorkflowHelp": "Other devices join this machine", + "troubleshootWorkflow": "Connection troubleshooting", + "sshTunnelRequired": "SSH tunnel required", + "troubleshootTitle": "Create a new link on the other host", + "troubleshootDescription": "A link that uses 127.0.0.1 points back to the device opening it, not the computer that created it.", + "troubleshootStepShare": "On the other computer, open Share this host.", + "troubleshootStepAddress": "Choose Another device and select its Tailscale or LAN address.", + "troubleshootStepRegenerate": "Generate a new access link and use only the newest link here.", + "troubleshootTunnel": "Using an SSH local forward? Return to Connect to a host, paste the loopback link, then enable “I am using an SSH tunnel” under Advanced.", + "cloudVmWorkflow": "Cloud VM", + "cloudVmWorkflowHelp": "Manage recipe-created cloud machines" }, "RuntimePairingGeneratedUrlRows": { "0495f68959": "Copy {{value0}}" @@ -6779,7 +6818,20 @@ "custom-description": "Advertise an address another device can reach — a LAN or Tailscale host, or a full ws(s):// URL.", "custom-hint": "Enter a host, host:port, or a ws(s):// URL.", "custom-cancel": "Cancel", - "custom-use": "Use address" + "custom-use": "Use address", + "intentQuestion": "Where will this link be opened?", + "anotherDevice": "Another device", + "anotherDeviceHelp": "Tailscale, LAN, or another reachable address", + "localOnly": "This computer only", + "localOnlyHelp": "A browser or Orca client on this computer", + "customAddress": "Custom address", + "customAddressHelp": "SSH tunnel, reverse proxy, or custom hostname", + "recommended": "Recommended", + "localLink": "Local-only link", + "localLinkHelp": "This link only works in a browser or Orca client running on this computer.", + "noExternalAddress": "No address for another device was found. Connect this computer to a LAN or Tailscale, refresh, or choose Custom address.", + "staleAddress": "The connection address changed. Generate a new link for {{address}}.", + "customInvalid": "Enter a valid host, host:port, IPv6 address, or ws(s):// URL." }, "Settings": { "3bf149e873": "Project Settings > {{value0}}", @@ -9403,18 +9455,25 @@ "empty": "No temporary VM runtimes need cleanup.", "copyCleanup": "Copy command", "retry": "Retry cleanup", - "cleanup": "Cleanup" + "cleanup": "Cleanup", + "cloudVmLoadFailed": "Couldn’t load Cloud VM runtimes.", + "cloudVmCleanupFailedToast": "Couldn’t clean up Cloud VM runtime.", + "cloudVmMarkedCleaned": "Marked Cloud VM runtime as cleaned.", + "cloudVmCleaned": "Cleaned up Cloud VM runtime.", + "cloudVmTitle": "Cloud VM runtimes", + "cloudVmRefresh": "Refresh Cloud VM runtimes", + "cloudVmLoading": "Checking Cloud VM runtimes…", + "cloudVmEmptyWithSetup": "No Cloud VM runtimes yet. Create one from a workspace using an environment recipe." }, "ephemeralVms": { "search": { - "title": "Per-Workspace Environments", - "description": "Learn how repo-owned recipes give each workspace its own on-demand, disposable environment." + "description": "Learn how repo-owned recipes give each workspace its own on-demand, disposable environment.", + "cloudVmTitle": "Cloud VM" } }, "EphemeralVmsPane": { "loadError": "Could not load recipes.", "copyError": "Could not copy the prompt.", - "skillTitle": "Per-Workspace Environments skill", "skillDescription": "Sets up, builds, authenticates, and validates repo-owned environment recipes.", "whatTitle": "What the skill does, with you", "whatScaffold": "Writes the recipe & scripts for your provider — connected over an Orca server or SSH.", @@ -9425,13 +9484,16 @@ "copied": "Copied", "recipes": "Recipes", "recipesHelp": "Recipes from orca.yaml and enabled plugins show up here, ready to launch a workspace on.", - "refresh": "Refresh ephemeral VM recipes", "checking": "Checking recipes...", - "none": "No recipes found yet." + "none": "No recipes found yet.", + "cloudVmSkillTitle": "Cloud VM setup skill", + "cloudVmRefresh": "Refresh Cloud VM recipes", + "cloudVmTerminalTitle": "Cloud VM setup", + "cloudVmTerminalAriaLabel": "Cloud VM skill install terminal" }, "ephemeralVmsExperimentalSetting": { "description": "Shows setup controls and workspace run targets for repo-owned, on-demand environments.", - "toggleLabel": "Toggle per-workspace environments" + "cloudVmToggleLabel": "Toggle Cloud VM" }, "SourceControlActionRepoOverrideNote": { "agent": "Agent", @@ -9524,7 +9586,8 @@ "signIn": "Sign in", "signInAgain": "Sign in again", "localTitle": "LAN", - "localDescription": "Phone must be on this Wi‑Fi or your Tailscale. No sign-in." + "localDescription": "Phone must be on this Wi‑Fi or connected through Tailscale. No sign-in required.", + "retrying": "Retrying" }, "MobilePairingSetupSection": { "title": "Pair a phone", @@ -9877,6 +9940,52 @@ "logs": "plugin logs", "development": "development plugins" } + }, + "RuntimeHostAccessForm": { + "getLink": "Get an access link from the other host", + "stepOpenShare": "Open Settings → Remote Orca Servers → Share this host.", + "stepChooseAddress": "Choose Another device and select a reachable address.", + "stepCopyLink": "Generate the link, then copy the “Pair another Orca client” link.", + "name": "Name in Orca", + "namePlaceholder": "Linux workstation", + "nameHelp": "This only changes how the computer appears in Orca.", + "accessLink": "Access link", + "accessLinkPlaceholder": "orca://pair?code=...", + "accessLinkHelp": "Orca shows the destination before connecting. Credentials stay hidden.", + "destination": "Link destination", + "loopbackTitle": "This link points back to this device", + "loopbackDescription": "It uses {{endpoint}}, which points back to the device opening the link—not the other computer that created it.", + "loopbackRecovery": "On the other computer, create a new link using Another device and choose its Tailscale or LAN address.", + "identityMismatch": "The reached Orca host does not match this access link", + "identityMismatchHelp": "Orca reached {{endpoint}}, but that host does not match this link. Generate a new link on the other host.", + "invalidLink": "This access link is no longer valid", + "invalidLinkHelp": "Generate a new access link on the other host and try again.", + "incompatible": "Orca versions are not compatible", + "incompatibleHelp": "Update Orca on this device and the other host, then try again.", + "interrupted": "Connection interrupted", + "interruptedHelp": "The connection stopped during verification. Check the network or SSH tunnel and try again.", + "unavailable": "Host unavailable", + "unavailableHelp": "Make sure Orca is running on the other host and that the network or SSH tunnel can reach {{endpoint}}.", + "advanced": "Advanced", + "sshTunnel": "I am using an SSH tunnel to this local address", + "sshTunnelHelp": "Keep the tunnel active while using this connection.", + "headlessHelp": "Using headless orca serve? Run orca serve --pairing-address on the other computer.", + "cancel": "Cancel", + "addWithTunnel": "Add host using tunnel", + "addHost": "Add host", + "connectionDetails": "Connection details", + "endpointKind": "Endpoint kind", + "networkConnection": "Network connection", + "notAttempted": "Not attempted", + "saveFailed": "Could not save the host", + "saveFailedHelp": "The host was verified, but Orca could not save it. Check the name and local settings storage, then try again." + }, + "CloudVmSetupGuide": { + "title": "Create a Cloud VM", + "description": "Cloud VMs are created from environment recipes when you create a workspace.", + "setupRecipe": "Set up an environment recipe for your cloud provider.", + "createWorkspace": "Create a workspace and select that recipe under Run on.", + "openSetup": "Set up environment recipes" } }, "right": { @@ -11530,7 +11639,6 @@ "2f077ef4eb": ", and scan the code.", "3aa7bb2d8b": "Pair Desktop", "d1495e5e64": "Open Orca Mobile, tap", - "901c98bb93": "Pair this", "3960f5c339": "Step 2 of 2", "3241f3c26a": "Install QR", "7af266b80d": "Install QR code", @@ -11561,7 +11669,16 @@ "stable": "Stable" }, "relayDegradedNotice": "Relay couldn’t be reached — this code only works on your LAN or Tailscale.", - "pairingQrError": "This pairing code couldn’t be rendered as a QR code. Copy it into Orca Mobile instead." + "pairingQrError": "This pairing code couldn’t be rendered as a QR code. Copy it into Orca Mobile instead.", + "noRelayCode": "No pairing code available", + "noPairingCode": "No pairing code available", + "qrSignInRequired": "Sign in to create a Relay pairing code", + "qrRenderFailed": "QR couldn’t be rendered — copy the code below", + "qrGeneratePrompt": "Generate a pairing code to continue", + "pairingCodeReady": "Pairing code ready", + "pairThisMac": "Pair this Mac.", + "pairThisPc": "Pair this PC.", + "pairThisComputer": "Pair this computer." }, "MobilePage": { "e17393c6a3": "Phone preview", @@ -11574,7 +11691,9 @@ "4e1eb5d55c": "Failed to revoke device", "255372e6e8": "Device revoked", "1b4509a8a1": "paired", - "c5909374cf": "intro" + "c5909374cf": "intro", + "diagnosticsCopied": "Diagnostics copied", + "diagnosticsCopyFailed": "Failed to copy diagnostics" }, "MobilePageToolbar": { "ad2284a9e2": "Close · Esc", @@ -11705,6 +11824,18 @@ "blocked-title": "Windows may be blocking Orca Mobile", "blocked-description": "An existing inbound Block rule can override the pairing exception. Repair removes conflicting TCP rules for this Orca app, then allows port {{port}} on Private networks.", "repair": "Repair firewall access" + }, + "MobileRelayMintFailureNotice": { + "retryingTitle": "Retrying Orca Relay…", + "unavailableTitle": "Orca Relay isn’t available on this desktop.", + "title": "Couldn’t create a Relay pairing code.", + "retryingBody": "Creating a new pairing code. This can take a moment over a remote connection.", + "unavailableBody": "Use LAN to pair over Tailscale or the same Wi‑Fi.", + "body": "Retry, or use LAN to pair over Tailscale or the same Wi‑Fi.", + "useLan": "Use LAN", + "retrying": "Retrying…", + "retry": "Retry Relay", + "copyDiagnostics": "Copy diagnostics" } }, "gitlab": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index b925b8c47c3..e29d1ebcee4 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -149,7 +149,10 @@ }, "webPreloadApi": { "aiVaultUnavailableForHost": "El historial de sesiones de Agents no está disponible para este host de ejecución.", - "runtimeEnvironmentManuallyDisconnected": "El entorno de ejecución se ha desconectado manualmente." + "runtimeEnvironmentManuallyDisconnected": "El entorno de ejecución se ha desconectado manualmente.", + "loopbackPairingBlocked": "Este enlace de acceso apunta de nuevo a este dispositivo.", + "remotePairingUnreachable": "No se puede acceder a Orca en {{endpoint}}.", + "remotePairingInvalidDetails": "Este enlace de acceso contiene datos de conexión no válidos." }, "web": { "preload": { @@ -351,6 +354,18 @@ } } }, + "remotePairingCopy": { + "invalidInput": "Introduce un enlace de acceso de Orca o un código de emparejamiento.", + "mobileOnly": "Este enlace concede acceso solo para dispositivos móviles. Genera un enlace para otro cliente de Orca.", + "invalidDestination": "Este enlace de acceso contiene un destino no válido.", + "unsupportedDestination": "Este enlace de acceso contiene un destino no compatible.", + "nonConnectableDestination": "Este enlace de acceso contiene un destino al que no se puede conectar.", + "loopback": "Bucle local", + "tailscale": "Dirección de Tailscale", + "lan": "Dirección LAN privada", + "public": "Dirección pública", + "custom": "Nombre de host personalizado" + }, "ensure": { "simulator": { "tab": { @@ -5006,7 +5021,11 @@ "importing": "Importando...", "importSshConfig": "Importar ~/.ssh/config", "sshPersistenceDefault": "Los terminales remotos en este host seguirán activos hasta que los cierres o restablezcas el relay.", - "advanced": "Advanced" + "advanced": "Advanced", + "linkDestination": "Destino del enlace", + "sshTunnel": "Estoy usando un túnel SSH", + "sshTunnelHelp": "De lo contrario, este enlace apunta a este dispositivo y no puede identificar el otro equipo.", + "loopbackBlocked": "Activa la opción de túnel SSH o crea un enlace nuevo con la dirección de Tailscale o LAN del otro host." }, "ForgetSshWorkspaceDialog": { "reconnectFailed": "Error de reconexión", @@ -6676,7 +6695,22 @@ "orcaVersion": "Orca v{{value0}}", "removeActiveServerBlocked": "Choose another Active Server in Advanced before removing this server.", "removeActiveServerDescription": "Choose another Active Server in Advanced before removing this server. Existing host sessions are left alone.", - "updatingServers": "Updating servers…" + "updatingServers": "Updating servers…", + "workflow": "Flujo de servidor remoto", + "connectWorkflow": "Conectarse a un host", + "connectWorkflowHelp": "Esta aplicación se conecta a otra máquina", + "shareWorkflow": "Compartir este host", + "shareWorkflowHelp": "Otros dispositivos se conectan a esta máquina", + "troubleshootWorkflow": "Solución de problemas de conexión", + "sshTunnelRequired": "Se requiere un túnel SSH", + "troubleshootTitle": "Crear un enlace nuevo en el otro host", + "troubleshootDescription": "Un enlace que usa 127.0.0.1 apunta al dispositivo que lo abre, no al equipo que lo creó.", + "troubleshootStepShare": "En el otro equipo, abre Compartir este host.", + "troubleshootStepAddress": "Elige Otro dispositivo y selecciona su dirección de Tailscale o LAN.", + "troubleshootStepRegenerate": "Genera un enlace de acceso nuevo y usa aquí solo el más reciente.", + "troubleshootTunnel": "¿Usas un reenvío local SSH? Vuelve a Conectarse a un host, pega el enlace de bucle local y activa «Estoy usando un túnel SSH» en Avanzado.", + "cloudVmWorkflow": "VM en la nube", + "cloudVmWorkflowHelp": "Gestionar máquinas en la nube creadas mediante recetas" }, "RuntimePairingGeneratedUrlRows": { "0495f68959": "Copiar {{value0}}" @@ -6715,7 +6749,20 @@ "custom-description": "Anuncia una dirección que otro dispositivo pueda alcanzar: un host de LAN o Tailscale, o una URL ws(s):// completa.", "custom-hint": "Introduce un host, host:puerto o una URL ws(s)://.", "custom-cancel": "Cancelar", - "custom-use": "Usar dirección" + "custom-use": "Usar dirección", + "intentQuestion": "¿Dónde se abrirá este enlace?", + "anotherDevice": "Otro dispositivo", + "anotherDeviceHelp": "Tailscale, LAN u otra dirección accesible", + "localOnly": "Solo este equipo", + "localOnlyHelp": "Un navegador o cliente Orca en este equipo", + "customAddress": "Dirección personalizada", + "customAddressHelp": "Túnel SSH, proxy inverso o nombre de host personalizado", + "recommended": "Recomendado", + "localLink": "Enlace solo local", + "localLinkHelp": "Este enlace solo funciona en un navegador o cliente Orca que se ejecute en este equipo.", + "noExternalAddress": "No se encontró una dirección para otro dispositivo. Conecta este equipo a una LAN o a Tailscale, actualiza o elige Dirección personalizada.", + "staleAddress": "La dirección de conexión cambió. Genera un enlace nuevo para {{address}}.", + "customInvalid": "Introduce un host, host:puerto, dirección IPv6 o URL ws(s):// válidos." }, "Settings": { "3bf149e873": "Configuración del proyecto > {{value0}}", @@ -9375,18 +9422,25 @@ "empty": "No hay runtimes de VM temporales que limpiar.", "copyCleanup": "Copiar comando", "retry": "Reintentar limpieza", - "cleanup": "Limpiar" + "cleanup": "Limpiar", + "cloudVmLoadFailed": "No se pudieron cargar los runtimes de VM en la nube.", + "cloudVmCleanupFailedToast": "No se pudo limpiar el runtime de VM en la nube.", + "cloudVmMarkedCleaned": "El runtime de VM en la nube se marcó como limpiado.", + "cloudVmCleaned": "Se limpió el runtime de VM en la nube.", + "cloudVmTitle": "Runtimes de VM en la nube", + "cloudVmRefresh": "Actualizar runtimes de VM en la nube", + "cloudVmLoading": "Comprobando runtimes de VM en la nube…", + "cloudVmEmptyWithSetup": "Aún no hay runtimes de VM en la nube. Crea uno desde un espacio de trabajo mediante una receta de entorno." }, "ephemeralVms": { "search": { - "title": "Entornos por espacio de trabajo", - "description": "Aprende cómo las recetas del repositorio dan a cada espacio de trabajo su propio entorno desechable bajo demanda." + "description": "Aprende cómo las recetas del repositorio dan a cada espacio de trabajo su propio entorno desechable bajo demanda.", + "cloudVmTitle": "VM en la nube" } }, "EphemeralVmsPane": { "loadError": "No se pudieron cargar las recetas.", "copyError": "No se pudo copiar el prompt.", - "skillTitle": "Skill de entornos por espacio de trabajo", "skillDescription": "Configura, construye, autentica y valida recetas de entornos del repositorio.", "whatTitle": "Lo que la skill hace contigo", "whatScaffold": "Escribe la receta y los scripts para tu proveedor, conectados mediante un servidor Orca o SSH.", @@ -9397,13 +9451,16 @@ "copied": "Copiado", "recipes": "Recetas", "recipesHelp": "Las recetas de orca.yaml y de los plugins activados aparecen aquí, listas para iniciar un espacio de trabajo.", - "refresh": "Actualizar recetas de VMs efímeras", "checking": "Comprobando recetas…", - "none": "Aún no se han encontrado recetas." + "none": "Aún no se han encontrado recetas.", + "cloudVmSkillTitle": "Skill de configuración de VM en la nube", + "cloudVmRefresh": "Actualizar recetas de VM en la nube", + "cloudVmTerminalTitle": "Configuración de VM en la nube", + "cloudVmTerminalAriaLabel": "Terminal de instalación de la skill de VM en la nube" }, "ephemeralVmsExperimentalSetting": { "description": "Muestra controles de configuración y destinos de ejecución de espacios de trabajo para entornos bajo demanda del repositorio.", - "toggleLabel": "Activar o desactivar entornos por espacio de trabajo" + "cloudVmToggleLabel": "Activar o desactivar VM en la nube" }, "SourceControlActionRepoOverrideNote": { "agent": "Agente", @@ -9849,6 +9906,50 @@ "logs": "registros de plugins", "development": "plugins de desarrollo" } + }, + "RuntimeHostAccessForm": { + "getLink": "Obtener un enlace de acceso del otro host", + "stepOpenShare": "Abre Ajustes → Servidores remotos de Orca → Compartir este host.", + "stepChooseAddress": "Elige Otro dispositivo y selecciona una dirección accesible.", + "stepCopyLink": "Genera el enlace y, a continuación, copia el enlace «Emparejar otro cliente de Orca».", + "name": "Nombre en Orca", + "namePlaceholder": "Estación de trabajo Linux", + "nameHelp": "Esto solo cambia cómo aparece el equipo en Orca.", + "accessLink": "Enlace de acceso", + "accessLinkPlaceholder": "orca://pair?code=...", + "accessLinkHelp": "Orca muestra el destino antes de conectarse. Las credenciales permanecen ocultas.", + "destination": "Destino del enlace", + "loopbackTitle": "Este enlace apunta de nuevo a este dispositivo", + "loopbackDescription": "Usa {{endpoint}}, que apunta al dispositivo que abre el enlace, no al otro equipo que lo creó.", + "loopbackRecovery": "En el otro equipo, crea un enlace nuevo con Otro dispositivo y elige su dirección de Tailscale o LAN.", + "identityMismatch": "El host Orca alcanzado no coincide con este enlace de acceso", + "identityMismatchHelp": "Orca llegó a {{endpoint}}, pero ese host no coincide con este enlace. Genera un enlace nuevo en el otro host.", + "invalidLink": "Este enlace de acceso ya no es válido", + "invalidLinkHelp": "Genera un enlace de acceso nuevo en el otro host e inténtalo de nuevo.", + "incompatible": "Las versiones de Orca no son compatibles", + "incompatibleHelp": "Actualiza Orca en este dispositivo y en el otro host e inténtalo de nuevo.", + "interrupted": "Conexión interrumpida", + "interruptedHelp": "La conexión se detuvo durante la verificación. Comprueba la red o el túnel SSH e inténtalo de nuevo.", + "unavailable": "Host no disponible", + "unavailableHelp": "Asegúrate de que Orca se esté ejecutando en el otro host y de que la red o el túnel SSH puedan acceder a {{endpoint}}.", + "advanced": "Avanzado", + "sshTunnel": "Estoy usando un túnel SSH hacia esta dirección local", + "sshTunnelHelp": "Mantén el túnel activo mientras usas esta conexión.", + "headlessHelp": "¿Usas orca serve sin interfaz? Ejecuta orca serve --pairing-address en el otro equipo.", + "cancel": "Cancelar", + "addWithTunnel": "Añadir host mediante túnel", + "addHost": "Añadir host", + "connectionDetails": "Detalles de conexión", + "endpointKind": "Tipo de destino", + "networkConnection": "Conexión de red", + "notAttempted": "No se intentó" + }, + "CloudVmSetupGuide": { + "title": "Crear una VM en la nube", + "description": "Las VMs en la nube se crean a partir de recetas de entorno al crear un espacio de trabajo.", + "setupRecipe": "Configura una receta de entorno para tu proveedor de nube.", + "createWorkspace": "Crea un espacio de trabajo y selecciona esa receta en Ejecutar en.", + "openSetup": "Configurar recetas de entorno" } }, "right": { @@ -11501,7 +11602,6 @@ "2f077ef4eb": ", y escanea el código.", "3aa7bb2d8b": "Emparejar escritorio", "d1495e5e64": "Abre Orca Mobile, toca", - "901c98bb93": "Empareja este", "3960f5c339": "Paso 2 de 2", "3241f3c26a": "QR de instalación", "7af266b80d": "Código QR de instalación", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 3e371187419..8def5244244 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -149,7 +149,10 @@ }, "webPreloadApi": { "aiVaultUnavailableForHost": "この実行ホストでは Agent セッション履歴を使用できません。", - "runtimeEnvironmentManuallyDisconnected": "ランタイム環境は手動で切断されています。" + "runtimeEnvironmentManuallyDisconnected": "ランタイム環境は手動で切断されています。", + "loopbackPairingBlocked": "このアクセスリンクはこのデバイス自身を指しています。", + "remotePairingUnreachable": "{{endpoint}} の Orca に接続できません。", + "remotePairingInvalidDetails": "このアクセスリンクには無効な接続情報が含まれています。" }, "web": { "preload": { @@ -351,6 +354,18 @@ } } }, + "remotePairingCopy": { + "invalidInput": "Orca のアクセスリンクまたはペアリングコードを入力してください。", + "mobileOnly": "このリンクはモバイル専用アクセスを許可します。別の Orca クライアント用のリンクを生成してください。", + "invalidDestination": "このアクセスリンクには無効な接続先が含まれています。", + "unsupportedDestination": "このアクセスリンクには未対応の接続先が含まれています。", + "nonConnectableDestination": "このアクセスリンクには接続できない接続先が含まれています。", + "loopback": "ループバック", + "tailscale": "Tailscale アドレス", + "lan": "プライベート LAN アドレス", + "public": "パブリックアドレス", + "custom": "カスタムホスト名" + }, "ensure": { "simulator": { "tab": { @@ -5006,7 +5021,11 @@ "importing": "インポート中...", "importSshConfig": "~/.ssh/config をインポートします", "sshPersistenceDefault": "このホスト上のリモートターミナルは、終了するかリレーをリセットするまで動作し続けます。", - "advanced": "Advanced" + "advanced": "詳細", + "linkDestination": "リンク先", + "sshTunnel": "SSH トンネルを使用しています", + "sshTunnelHelp": "それ以外の場合、このリンクはこのデバイス自身を指すため、もう一方のコンピューターを識別できません。", + "loopbackBlocked": "SSH トンネルの上書きを有効にするか、もう一方のホストの Tailscale または LAN アドレスを使って新しいリンクを作成してください。" }, "ForgetSshWorkspaceDialog": { "reconnectFailed": "再接続に失敗しました", @@ -6698,7 +6717,22 @@ "orcaVersion": "Orca v{{value0}}", "removeActiveServerBlocked": "Choose another Active Server in Advanced before removing this server.", "removeActiveServerDescription": "Choose another Active Server in Advanced before removing this server. Existing host sessions are left alone.", - "updatingServers": "Updating servers…" + "updatingServers": "Updating servers…", + "workflow": "リモートサーバーワークフロー", + "connectWorkflow": "ホストに接続", + "connectWorkflowHelp": "このアプリを別のマシンに接続します", + "shareWorkflow": "このホストを共有", + "shareWorkflowHelp": "他のデバイスをこのマシンに接続します", + "troubleshootWorkflow": "接続のトラブルシューティング", + "sshTunnelRequired": "SSH トンネルが必要です", + "troubleshootTitle": "もう一方のホストで新しいリンクを作成", + "troubleshootDescription": "127.0.0.1 を使うリンクは、作成したコンピューターではなく、リンクを開いたデバイス自身を指します。", + "troubleshootStepShare": "もう一方のコンピューターで「このホストを共有」を開きます。", + "troubleshootStepAddress": "「別のデバイス」を選び、Tailscale または LAN アドレスを選択します。", + "troubleshootStepRegenerate": "新しいアクセスリンクを生成し、ここでは最新のリンクだけを使います。", + "troubleshootTunnel": "SSH ローカルフォワードを使う場合は、「ホストに接続」に戻ってループバックリンクを貼り付け、「詳細」で「SSH トンネルを使用しています」を有効にします。", + "cloudVmWorkflow": "クラウド VM", + "cloudVmWorkflowHelp": "レシピで作成したクラウドマシンを管理" }, "RuntimePairingGeneratedUrlRows": { "0495f68959": "{{value0}}をコピー" @@ -6737,7 +6771,20 @@ "custom-description": "他のデバイスから到達できるアドレスを指定します。LAN や Tailscale のホスト、または完全な ws(s):// URL です。", "custom-hint": "ホスト、host:port、または ws(s):// URL を入力してください。", "custom-cancel": "キャンセル", - "custom-use": "アドレスを使用" + "custom-use": "アドレスを使用", + "intentQuestion": "このリンクはどこで開きますか?", + "anotherDevice": "別のデバイス", + "anotherDeviceHelp": "Tailscale、LAN、または到達可能な別のアドレス", + "localOnly": "このコンピューターのみ", + "localOnlyHelp": "このコンピューター上のブラウザーまたは Orca クライアント", + "customAddress": "カスタムアドレス", + "customAddressHelp": "SSH トンネル、リバースプロキシ、またはカスタムホスト名", + "recommended": "推奨", + "localLink": "ローカル専用リンク", + "localLinkHelp": "このリンクは、このコンピューターで実行中のブラウザーまたは Orca クライアントでのみ使えます。", + "noExternalAddress": "別のデバイス用のアドレスが見つかりません。このコンピューターを LAN または Tailscale に接続して更新するか、カスタムアドレスを選んでください。", + "staleAddress": "接続アドレスが変更されました。{{address}} 用の新しいリンクを生成してください。", + "customInvalid": "有効なホスト、ホスト:ポート、IPv6 アドレス、または ws(s):// URL を入力してください。" }, "Settings": { "3bf149e873": "プロジェクト設定 > {{value0}}", @@ -9375,18 +9422,25 @@ "empty": "一時的な VM ランタイムにはクリーンアップが必要ありません。", "copyCleanup": "コピーコマンド", "retry": "クリーンアップを再試行します", - "cleanup": "掃除" + "cleanup": "クリーンアップ", + "cloudVmLoadFailed": "クラウド VM ランタイムを読み込めませんでした。", + "cloudVmCleanupFailedToast": "クラウド VM ランタイムをクリーンアップできませんでした。", + "cloudVmMarkedCleaned": "クラウド VM ランタイムをクリーンアップ済みとして記録しました。", + "cloudVmCleaned": "クラウド VM ランタイムをクリーンアップしました。", + "cloudVmTitle": "クラウド VM ランタイム", + "cloudVmRefresh": "クラウド VM ランタイムを更新", + "cloudVmLoading": "クラウド VM ランタイムを確認中…", + "cloudVmEmptyWithSetup": "クラウド VM ランタイムはまだありません。環境レシピを使用してワークスペースから作成してください。" }, "ephemeralVms": { "search": { - "title": "ワークスペースごとの環境", - "description": "repo 所有のレシピが各ワークスペースに独自のオンデマンドの使い捨て環境を与える方法を学びましょう。" + "description": "repo 所有のレシピが各ワークスペースに独自のオンデマンドの使い捨て環境を与える方法を学びましょう。", + "cloudVmTitle": "クラウド VM" } }, "EphemeralVmsPane": { "loadError": "レシピを読み込めませんでした。", "copyError": "プロンプトをコピーできませんでした。", - "skillTitle": "ワークスペースごとの環境スキル", "skillDescription": "repo が所有する環境レシピをセットアップ、構築、認証、および検証します。", "whatTitle": "スキルが何をするのか、あなたと一緒に", "whatScaffold": "Orca サーバーまたは SSH 経由で接続されているプロバイダーのレシピとスクリプトを作成します。", @@ -9397,13 +9451,16 @@ "copied": "コピーされました", "recipes": "レシピ", "recipesHelp": "orca.yaml と有効なプラグインのレシピがここに表示され、ワークスペースを起動できます。", - "refresh": "一時的な VM レシピを更新する", "checking": "レシピを確認中...", - "none": "レシピはまだ見つかりません。" + "none": "レシピはまだ見つかりません。", + "cloudVmSkillTitle": "クラウド VM セットアップスキル", + "cloudVmRefresh": "クラウド VM レシピを更新", + "cloudVmTerminalTitle": "クラウド VM のセットアップ", + "cloudVmTerminalAriaLabel": "クラウド VM スキルインストールターミナル" }, "ephemeralVmsExperimentalSetting": { "description": "repo が所有するオンデマンド環境のセットアップ コントロールとワークスペース実行ターゲットを示します。", - "toggleLabel": "ワークスペースごとの環境を切り替える" + "cloudVmToggleLabel": "クラウド VM を切り替える" }, "SourceControlActionRepoOverrideNote": { "agent": "エージェント", @@ -9849,6 +9906,50 @@ "logs": "プラグインログ", "development": "開発プラグイン" } + }, + "RuntimeHostAccessForm": { + "getLink": "もう一方のホストからアクセスリンクを取得", + "stepOpenShare": "設定 → リモート Orca サーバー → このホストを共有 を開きます。", + "stepChooseAddress": "「別のデバイス」を選び、到達可能なアドレスを選択します。", + "stepCopyLink": "リンクを生成し、「別の Orca クライアントをペアリング」リンクをコピーします。", + "name": "Orca での名前", + "namePlaceholder": "Linux ワークステーション", + "nameHelp": "これは Orca でのコンピューターの表示名だけを変更します。", + "accessLink": "アクセスリンク", + "accessLinkPlaceholder": "orca://pair?code=...", + "accessLinkHelp": "Orca は接続前に接続先を表示します。認証情報は非表示のままです。", + "destination": "リンク先", + "loopbackTitle": "このリンクはこのデバイス自身を指しています", + "loopbackDescription": "{{endpoint}} はリンクを開いたデバイス自身を指し、リンクを作成したもう一方のコンピューターには接続しません。", + "loopbackRecovery": "もう一方のコンピューターで「別のデバイス」を使って新しいリンクを作成し、Tailscale または LAN アドレスを選んでください。", + "identityMismatch": "接続した Orca ホストがこのアクセスリンクと一致しません", + "identityMismatchHelp": "{{endpoint}} の Orca に接続しましたが、そのホストはこのリンクと一致しません。もう一方のホストで新しいリンクを生成してください。", + "invalidLink": "このアクセスリンクは無効になっています", + "invalidLinkHelp": "もう一方のホストで新しいアクセスリンクを生成し、もう一度お試しください。", + "incompatible": "Orca のバージョンに互換性がありません", + "incompatibleHelp": "このデバイスともう一方のホストの Orca を更新し、もう一度お試しください。", + "interrupted": "接続が中断されました", + "interruptedHelp": "検証中に接続が停止しました。ネットワークまたは SSH トンネルを確認して、もう一度お試しください。", + "unavailable": "ホストを利用できません", + "unavailableHelp": "もう一方のホストで Orca が実行中であり、ネットワークまたは SSH トンネルから {{endpoint}} に到達できることを確認してください。", + "advanced": "詳細", + "sshTunnel": "このローカルアドレスへの SSH トンネルを使用しています", + "sshTunnelHelp": "この接続を使用している間はトンネルを維持してください。", + "headlessHelp": "ヘッドレスの orca serve を使う場合は、もう一方のコンピューターで orca serve --pairing-address を実行してください。", + "cancel": "キャンセル", + "addWithTunnel": "トンネルを使ってホストを追加", + "addHost": "ホストを追加", + "connectionDetails": "接続の詳細", + "endpointKind": "接続先の種類", + "networkConnection": "ネットワーク接続", + "notAttempted": "未試行" + }, + "CloudVmSetupGuide": { + "title": "クラウド VM を作成", + "description": "クラウド VM は、ワークスペースの作成時に環境レシピから作成されます。", + "setupRecipe": "クラウドプロバイダー用の環境レシピをセットアップします。", + "createWorkspace": "ワークスペースを作成し、「実行先」でそのレシピを選択します。", + "openSetup": "環境レシピをセットアップ" } }, "right": { @@ -11501,7 +11602,6 @@ "2f077ef4eb": "をクリックしてコードをスキャンします。", "3aa7bb2d8b": "デスクトップのペアリング", "d1495e5e64": "Orca モバイルを開き、 をタップします", - "901c98bb93": "これをペアリングします", "3960f5c339": "ステップ 2/2", "3241f3c26a": "QRをインストールする", "7af266b80d": "QRコードをインストールする", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 5310100e4be..e1cc97e3a0d 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -149,7 +149,10 @@ }, "webPreloadApi": { "aiVaultUnavailableForHost": "이 실행 호스트에서는 Agent 세션 기록을 사용할 수 없습니다.", - "runtimeEnvironmentManuallyDisconnected": "런타임 환경이 수동으로 연결 해제되었습니다." + "runtimeEnvironmentManuallyDisconnected": "런타임 환경이 수동으로 연결 해제되었습니다.", + "loopbackPairingBlocked": "이 액세스 링크는 이 기기 자체를 가리킵니다.", + "remotePairingUnreachable": "{{endpoint}}의 Orca에 연결할 수 없습니다.", + "remotePairingInvalidDetails": "이 액세스 링크에 잘못된 연결 정보가 포함되어 있습니다." }, "web": { "preload": { @@ -351,6 +354,18 @@ } } }, + "remotePairingCopy": { + "invalidInput": "Orca 액세스 링크 또는 페어링 코드를 입력하세요.", + "mobileOnly": "이 링크는 모바일 전용 액세스를 허용합니다. 다른 Orca 클라이언트용 링크를 생성하세요.", + "invalidDestination": "이 액세스 링크에 잘못된 대상이 포함되어 있습니다.", + "unsupportedDestination": "이 액세스 링크에 지원되지 않는 대상이 포함되어 있습니다.", + "nonConnectableDestination": "이 액세스 링크에 연결할 수 없는 대상이 포함되어 있습니다.", + "loopback": "루프백", + "tailscale": "Tailscale 주소", + "lan": "사설 LAN 주소", + "public": "공용 주소", + "custom": "사용자 지정 호스트 이름" + }, "ensure": { "simulator": { "tab": { @@ -5006,7 +5021,11 @@ "importing": "가져오는 중...", "importSshConfig": "~/.ssh/config 가져오기", "sshPersistenceDefault": "이 호스트의 원격 터미널은 종료하거나 릴레이를 재설정할 때까지 계속 실행됩니다.", - "advanced": "Advanced" + "advanced": "고급", + "linkDestination": "링크 대상", + "sshTunnel": "SSH 터널을 사용하고 있습니다", + "sshTunnelHelp": "그렇지 않으면 이 링크는 이 기기 자체를 가리키므로 다른 컴퓨터를 식별할 수 없습니다.", + "loopbackBlocked": "SSH 터널 재정의를 활성화하거나 다른 호스트의 Tailscale 또는 LAN 주소를 사용해 새 링크를 만드세요." }, "ForgetSshWorkspaceDialog": { "reconnectFailed": "재연결 실패", @@ -6661,7 +6680,22 @@ "orcaVersion": "Orca v{{value0}}", "removeActiveServerBlocked": "Choose another Active Server in Advanced before removing this server.", "removeActiveServerDescription": "Choose another Active Server in Advanced before removing this server. Existing host sessions are left alone.", - "updatingServers": "Updating servers…" + "updatingServers": "Updating servers…", + "workflow": "원격 서버 워크플로", + "connectWorkflow": "호스트에 연결", + "connectWorkflowHelp": "이 앱을 다른 머신에 연결합니다", + "shareWorkflow": "이 호스트 공유", + "shareWorkflowHelp": "다른 기기를 이 머신에 연결합니다", + "troubleshootWorkflow": "연결 문제 해결", + "sshTunnelRequired": "SSH 터널 필요", + "troubleshootTitle": "다른 호스트에서 새 링크 만들기", + "troubleshootDescription": "127.0.0.1을 사용하는 링크는 링크를 만든 컴퓨터가 아니라 링크를 여는 기기 자체를 가리킵니다.", + "troubleshootStepShare": "다른 컴퓨터에서 ‘이 호스트 공유’를 엽니다.", + "troubleshootStepAddress": "‘다른 기기’를 선택하고 Tailscale 또는 LAN 주소를 선택합니다.", + "troubleshootStepRegenerate": "새 액세스 링크를 생성하고 여기에서는 가장 최신 링크만 사용합니다.", + "troubleshootTunnel": "SSH 로컬 포워딩을 사용하나요? ‘호스트에 연결’로 돌아가 루프백 링크를 붙여 넣은 다음 ‘고급’에서 ‘SSH 터널을 사용하고 있습니다’를 활성화하세요.", + "cloudVmWorkflow": "클라우드 VM", + "cloudVmWorkflowHelp": "레시피로 생성한 클라우드 머신 관리" }, "RuntimePairingGeneratedUrlRows": { "0495f68959": "{{value0}} 복사" @@ -6700,7 +6734,20 @@ "custom-description": "다른 기기가 연결할 수 있는 주소를 알립니다. LAN 또는 Tailscale 호스트, 또는 전체 ws(s):// URL입니다.", "custom-hint": "호스트, host:port 또는 ws(s):// URL을 입력하세요.", "custom-cancel": "취소", - "custom-use": "주소 사용" + "custom-use": "주소 사용", + "intentQuestion": "이 링크를 어디에서 열 예정인가요?", + "anotherDevice": "다른 기기", + "anotherDeviceHelp": "Tailscale, LAN 또는 연결 가능한 다른 주소", + "localOnly": "이 컴퓨터만", + "localOnlyHelp": "이 컴퓨터의 브라우저 또는 Orca 클라이언트", + "customAddress": "사용자 지정 주소", + "customAddressHelp": "SSH 터널, 리버스 프록시 또는 사용자 지정 호스트 이름", + "recommended": "권장", + "localLink": "로컬 전용 링크", + "localLinkHelp": "이 링크는 이 컴퓨터에서 실행 중인 브라우저 또는 Orca 클라이언트에서만 작동합니다.", + "noExternalAddress": "다른 기기용 주소를 찾지 못했습니다. 이 컴퓨터를 LAN 또는 Tailscale에 연결한 후 새로 고치거나 사용자 지정 주소를 선택하세요.", + "staleAddress": "연결 주소가 변경되었습니다. {{address}}용 새 링크를 생성하세요.", + "customInvalid": "올바른 호스트, 호스트:포트, IPv6 주소 또는 ws(s):// URL을 입력하세요." }, "Settings": { "3bf149e873": "프로젝트 설정 > {{value0}}", @@ -9375,18 +9422,25 @@ "empty": "임시 VM 런타임은 정리가 필요하지 않습니다.", "copyCleanup": "복사 명령", "retry": "정리 다시 시도", - "cleanup": "대청소" + "cleanup": "정리", + "cloudVmLoadFailed": "클라우드 VM 런타임을 불러올 수 없습니다.", + "cloudVmCleanupFailedToast": "클라우드 VM 런타임을 정리할 수 없습니다.", + "cloudVmMarkedCleaned": "클라우드 VM 런타임을 정리됨으로 표시했습니다.", + "cloudVmCleaned": "클라우드 VM 런타임을 정리했습니다.", + "cloudVmTitle": "클라우드 VM 런타임", + "cloudVmRefresh": "클라우드 VM 런타임 새로 고침", + "cloudVmLoading": "클라우드 VM 런타임 확인 중…", + "cloudVmEmptyWithSetup": "아직 클라우드 VM 런타임이 없습니다. 환경 레시피를 사용해 워크스페이스에서 생성하세요." }, "ephemeralVms": { "search": { - "title": "워크스페이스별 환경", - "description": "repo 소유 레시피가 각 워크스페이스에 고유한 주문형 일회용 환경을 제공하는 방법을 알아보세요." + "description": "repo 소유 레시피가 각 워크스페이스에 고유한 주문형 일회용 환경을 제공하는 방법을 알아보세요.", + "cloudVmTitle": "클라우드 VM" } }, "EphemeralVmsPane": { "loadError": "레시피를 로드할 수 없습니다.", "copyError": "프롬프트를 복사할 수 없습니다.", - "skillTitle": "워크스페이스별 환경 기술", "skillDescription": "repo 소유 환경 레시피를 설정, 빌드, 인증 및 검증합니다.", "whatTitle": "당신과 함께 기술이 하는 일", "whatScaffold": "Orca 서버 또는 SSH를 통해 연결된 공급자를 위한 레시피 및 스크립트를 작성합니다.", @@ -9397,13 +9451,16 @@ "copied": "복사됨", "recipes": "조리법", "recipesHelp": "orca.yaml과 활성화된 플러그인의 레시피가 여기에 표시되어 워크스페이스를 시작할 수 있습니다.", - "refresh": "임시 VM 레시피 새로 고침", "checking": "레시피 확인 중...", - "none": "아직 레시피를 찾을 수 없습니다." + "none": "아직 레시피를 찾을 수 없습니다.", + "cloudVmSkillTitle": "클라우드 VM 설정 스킬", + "cloudVmRefresh": "클라우드 VM 레시피 새로 고침", + "cloudVmTerminalTitle": "클라우드 VM 설정", + "cloudVmTerminalAriaLabel": "클라우드 VM 스킬 설치 터미널" }, "ephemeralVmsExperimentalSetting": { "description": "repo 소유 온디맨드 환경에 대한 설정 제어 및 워크스페이스 실행 대상을 표시합니다.", - "toggleLabel": "워크스페이스별 환경 전환" + "cloudVmToggleLabel": "클라우드 VM 전환" }, "SourceControlActionRepoOverrideNote": { "agent": "에이전트", @@ -9849,6 +9906,50 @@ "logs": "플러그인 로그", "development": "개발 플러그인" } + }, + "RuntimeHostAccessForm": { + "getLink": "다른 호스트에서 액세스 링크 가져오기", + "stepOpenShare": "설정 → 원격 Orca 서버 → 이 호스트 공유를 엽니다.", + "stepChooseAddress": "‘다른 기기’를 선택하고 연결 가능한 주소를 선택합니다.", + "stepCopyLink": "링크를 생성한 다음 “다른 Orca 클라이언트 페어링” 링크를 복사하세요.", + "name": "Orca에서 사용할 이름", + "namePlaceholder": "Linux 워크스테이션", + "nameHelp": "Orca에서 이 컴퓨터가 표시되는 이름만 변경합니다.", + "accessLink": "액세스 링크", + "accessLinkPlaceholder": "orca://pair?code=...", + "accessLinkHelp": "Orca는 연결하기 전에 대상을 표시하며 자격 증명은 숨겨 둡니다.", + "destination": "링크 대상", + "loopbackTitle": "이 링크는 이 기기 자체를 가리킵니다", + "loopbackDescription": "{{endpoint}}는 링크를 연 기기 자체를 가리키며, 링크를 만든 다른 컴퓨터에는 연결되지 않습니다.", + "loopbackRecovery": "다른 컴퓨터에서 ‘다른 기기’를 사용해 새 링크를 만들고 Tailscale 또는 LAN 주소를 선택하세요.", + "identityMismatch": "연결된 Orca 호스트가 이 액세스 링크와 일치하지 않습니다", + "identityMismatchHelp": "{{endpoint}}의 Orca에 연결했지만 해당 호스트가 이 링크와 일치하지 않습니다. 다른 호스트에서 새 링크를 생성하세요.", + "invalidLink": "이 액세스 링크는 더 이상 유효하지 않습니다", + "invalidLinkHelp": "다른 호스트에서 새 액세스 링크를 생성한 후 다시 시도하세요.", + "incompatible": "Orca 버전이 호환되지 않습니다", + "incompatibleHelp": "이 기기와 다른 호스트의 Orca를 업데이트한 후 다시 시도하세요.", + "interrupted": "연결이 중단되었습니다", + "interruptedHelp": "확인 중 연결이 중단되었습니다. 네트워크 또는 SSH 터널을 확인한 후 다시 시도하세요.", + "unavailable": "호스트를 사용할 수 없습니다", + "unavailableHelp": "다른 호스트에서 Orca가 실행 중이고 네트워크 또는 SSH 터널을 통해 {{endpoint}}에 연결할 수 있는지 확인하세요.", + "advanced": "고급", + "sshTunnel": "이 로컬 주소에 SSH 터널을 사용하고 있습니다", + "sshTunnelHelp": "이 연결을 사용하는 동안 터널을 유지하세요.", + "headlessHelp": "헤드리스 orca serve를 사용하나요? 다른 컴퓨터에서 orca serve --pairing-address 를 실행하세요.", + "cancel": "취소", + "addWithTunnel": "터널을 사용해 호스트 추가", + "addHost": "호스트 추가", + "connectionDetails": "연결 세부 정보", + "endpointKind": "대상 유형", + "networkConnection": "네트워크 연결", + "notAttempted": "시도하지 않음" + }, + "CloudVmSetupGuide": { + "title": "클라우드 VM 만들기", + "description": "클라우드 VM은 워크스페이스를 만들 때 환경 레시피에서 생성됩니다.", + "setupRecipe": "클라우드 공급자를 위한 환경 레시피를 설정하세요.", + "createWorkspace": "워크스페이스를 만들고 실행 위치에서 해당 레시피를 선택하세요.", + "openSetup": "환경 레시피 설정" } }, "right": { @@ -11501,7 +11602,6 @@ "2f077ef4eb": "을 클릭하고 코드를 스캔하세요.", "3aa7bb2d8b": "데스크탑 페어링", "d1495e5e64": "Orca Mobile을 열고", - "901c98bb93": "페어링", "3960f5c339": "2/2단계", "3241f3c26a": "QR 설치", "7af266b80d": "QR코드 설치", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 3a948e98e79..40b1c6b3cc4 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -149,7 +149,10 @@ }, "webPreloadApi": { "aiVaultUnavailableForHost": "此执行主机不支持 Agent 会话历史记录。", - "runtimeEnvironmentManuallyDisconnected": "运行时环境已手动断开连接。" + "runtimeEnvironmentManuallyDisconnected": "运行时环境已手动断开连接。", + "loopbackPairingBlocked": "此访问链接会指回此设备。", + "remotePairingUnreachable": "无法连接 {{endpoint}} 上的 Orca。", + "remotePairingInvalidDetails": "此访问链接包含无效的连接信息。" }, "web": { "preload": { @@ -351,6 +354,18 @@ } } }, + "remotePairingCopy": { + "invalidInput": "请输入 Orca 访问链接或配对代码。", + "mobileOnly": "此链接仅授予移动端访问权限。请为另一个 Orca 客户端生成链接。", + "invalidDestination": "此访问链接包含无效的目标地址。", + "unsupportedDestination": "此访问链接包含不受支持的目标地址。", + "nonConnectableDestination": "此访问链接包含无法连接的目标地址。", + "loopback": "环回地址", + "tailscale": "Tailscale 地址", + "lan": "专用 LAN 地址", + "public": "公网地址", + "custom": "自定义主机名" + }, "ensure": { "simulator": { "tab": { @@ -5006,7 +5021,11 @@ "importing": "正在导入...", "importSshConfig": "导入 ~/.ssh/config", "sshPersistenceDefault": "此主机上的远程终端会保持运行,直到你结束它们或重置中继。", - "advanced": "Advanced" + "advanced": "Advanced", + "linkDestination": "链接目标", + "sshTunnel": "我正在使用 SSH 隧道", + "sshTunnelHelp": "否则,此链接会指回此设备,无法识别另一台计算机。", + "loopbackBlocked": "启用 SSH 隧道选项,或使用另一台主机的 Tailscale 或 LAN 地址创建新链接。" }, "ForgetSshWorkspaceDialog": { "reconnectFailed": "重新连接失败", @@ -6653,15 +6672,30 @@ "advertiseThisApp": "将此应用作为服务器公布", "advertiseThisAppHelp": "创建访问链接,让浏览器、移动客户端或另一个 Orca 客户端连接回这个正在运行的应用。", "runtimeReachable": "可连接到 {{value0}}。", - "updateAvailableOne": "1 update available", - "updatesAvailable": "{{value0}} updates available", - "versionUnavailable": "Orca version unavailable", - "updateServer": "Update", - "reviewServerUpdates": "Server updates", + "updateAvailableOne": "1 个更新可用", + "updatesAvailable": "{{value0}} 个更新可用", + "versionUnavailable": "Orca 版本不可用", + "updateServer": "更新", + "reviewServerUpdates": "服务器更新", "orcaVersion": "Orca v{{value0}}", - "removeActiveServerBlocked": "Choose another Active Server in Advanced before removing this server.", - "removeActiveServerDescription": "Choose another Active Server in Advanced before removing this server. Existing host sessions are left alone.", - "updatingServers": "Updating servers…" + "removeActiveServerBlocked": "请先在“高级”中选择另一个活动服务器,然后再移除此服务器。", + "removeActiveServerDescription": "请先在“高级”中选择另一个活动服务器,然后再移除此服务器。现有的主机会话将保留。", + "updatingServers": "正在更新服务器…", + "workflow": "远程服务器工作流程", + "connectWorkflow": "连接到主机", + "connectWorkflowHelp": "此应用加入另一台机器", + "shareWorkflow": "共享此主机", + "shareWorkflowHelp": "其他设备加入此机器", + "troubleshootWorkflow": "连接故障排除", + "sshTunnelRequired": "需要 SSH 隧道", + "troubleshootTitle": "在另一台主机上创建新链接", + "troubleshootDescription": "使用 127.0.0.1 的链接会指向打开它的设备,而不是创建它的计算机。", + "troubleshootStepShare": "在另一台计算机上,打开“共享此主机”。", + "troubleshootStepAddress": "选择“其他设备”,然后选择其 Tailscale 或 LAN 地址。", + "troubleshootStepRegenerate": "生成新的访问链接,并仅在此处使用最新的链接。", + "troubleshootTunnel": "正在使用 SSH 本地转发?返回“连接到主机”,粘贴环回链接,然后在“高级”下启用“我正在使用 SSH 隧道”。", + "cloudVmWorkflow": "云虚拟机", + "cloudVmWorkflowHelp": "管理由配方创建的云端机器" }, "RuntimePairingGeneratedUrlRows": { "0495f68959": "复制 {{value0}}" @@ -6700,7 +6734,20 @@ "custom-description": "公布其他设备可以访问的地址:LAN 或 Tailscale 主机,或完整的 ws(s):// URL。", "custom-hint": "请输入主机、host:port 或 ws(s):// URL。", "custom-cancel": "取消", - "custom-use": "使用地址" + "custom-use": "使用地址", + "intentQuestion": "此链接将在哪里打开?", + "anotherDevice": "其他设备", + "anotherDeviceHelp": "Tailscale、LAN 或其他可达地址", + "localOnly": "仅此计算机", + "localOnlyHelp": "此计算机上的浏览器或 Orca 客户端", + "customAddress": "自定义地址", + "customAddressHelp": "SSH 隧道、反向代理或自定义主机名", + "recommended": "推荐", + "localLink": "仅本地链接", + "localLinkHelp": "此链接仅在运行于此计算机上的浏览器或 Orca 客户端中有效。", + "noExternalAddress": "未找到其他设备的地址。将此计算机连接到 LAN 或 Tailscale,刷新,或选择“自定义地址”。", + "staleAddress": "连接地址已更改。为 {{address}} 生成新链接。", + "customInvalid": "请输入有效的主机、主机:端口、IPv6 地址或 ws(s):// URL。" }, "Settings": { "3bf149e873": "项目设置 > {{value0}}", @@ -6777,7 +6824,7 @@ "dev": "开发工具", "devDescription": "仅用于调试 UI 状态的开发工具。", "linearTitle": "Linear", - "linearDescription": "Give agents the skill to read and update your linked Linear tickets." + "linearDescription": "赋予智能体读取和更新您关联的 Linear 工单的能力。" }, "SettingsFormControls": { "42a4d15a30": "没有匹配的字体。", @@ -9375,18 +9422,25 @@ "empty": "没有需要清理的临时 VM 运行时。", "copyCleanup": "复制命令", "retry": "重试清理", - "cleanup": "清理" + "cleanup": "清理", + "cloudVmLoadFailed": "无法加载云虚拟机运行时。", + "cloudVmCleanupFailedToast": "无法清理云虚拟机运行时。", + "cloudVmMarkedCleaned": "已将云虚拟机运行时标记为已清理。", + "cloudVmCleaned": "已清理云虚拟机运行时。", + "cloudVmTitle": "云虚拟机运行时", + "cloudVmRefresh": "刷新云虚拟机运行时", + "cloudVmLoading": "正在检查云虚拟机运行时…", + "cloudVmEmptyWithSetup": "还没有云虚拟机运行时。请在创建工作区时使用环境模板来创建。" }, "ephemeralVms": { "search": { - "title": "工作区专属环境", - "description": "了解仓库自带的环境模板如何为每个工作区提供按需创建、可丢弃的独立环境。" + "description": "了解仓库自带的环境模板如何为每个工作区提供按需创建、可丢弃的独立环境。", + "cloudVmTitle": "云虚拟机" } }, "EphemeralVmsPane": { "loadError": "无法加载环境模板。", "copyError": "无法复制提示词。", - "skillTitle": "工作区专属环境技能", "skillDescription": "设置、构建、认证并验证仓库自带的环境模板。", "whatTitle": "此技能会与你一起完成", "whatScaffold": "为你的提供商编写环境模板和脚本,可通过 Orca 服务器或 SSH 连接。", @@ -9397,13 +9451,16 @@ "copied": "已复制", "recipes": "环境模板", "recipesHelp": "orca.yaml 和已启用插件中的配方会显示在这里,可用于启动工作区。", - "refresh": "刷新临时 VM 环境模板", "checking": "正在检查环境模板...", - "none": "还没有找到环境模板。" + "none": "还没有找到环境模板。", + "cloudVmSkillTitle": "云虚拟机设置技能", + "cloudVmRefresh": "刷新云虚拟机环境模板", + "cloudVmTerminalTitle": "云虚拟机设置", + "cloudVmTerminalAriaLabel": "云虚拟机技能安装终端" }, "ephemeralVmsExperimentalSetting": { "description": "显示仓库自带按需环境的设置控件和工作区运行目标。", - "toggleLabel": "切换工作区专属环境" + "cloudVmToggleLabel": "切换云虚拟机" }, "SourceControlActionRepoOverrideNote": { "agent": "智能体", @@ -9558,25 +9615,25 @@ "reviewUpdateOne": "1 update available" }, "RemoteServerUpdateDialog": { - "versionUnavailable": "Version unavailable", - "restartingHelp": "Waiting for the replacement server to reconnect on the new version.", - "retry": "Retry", - "update": "Update", - "title": "Update Remote Orca Servers", - "description": "Review paired servers and update supported installs from this Orca client.", - "restartWarning": "Updating restarts these servers. {{value0}} live tabs and {{value1}} terminal panes may briefly disconnect.", - "checking": "Checking paired servers…", - "empty": "No paired Remote Orca Servers.", - "checkAgain": "Check again", - "updating": "Updating servers…", - "updateAll": "Update {{value0}} servers", + "versionUnavailable": "版本不可用", + "restartingHelp": "正在等待替代服务器在新版本上重新连接。", + "retry": "重试", + "update": "更新", + "title": "更新远程 Orca 服务器", + "description": "查看已配对的服务器,并从此 Orca 客户端更新受支持的安装。", + "restartWarning": "更新将重启这些服务器。{{value0}} 个活动标签页和 {{value1}} 个终端窗格可能会短暂断开连接。", + "checking": "正在检查已配对的服务器…", + "empty": "没有已配对的远程 Orca 服务器。", + "checkAgain": "再次检查", + "updating": "正在更新服务器…", + "updateAll": "更新 {{value0}} 台服务器", "noUpdates": "没有可用更新", - "updateOne": "Update server", - "downloadProgress": "{{value0}} download progress", - "liveTabOne": "1 live tab", - "liveTabs": "{{value0}} live tabs", - "livePaneOne": "1 live pane", - "livePanes": "{{value0}} live panes" + "updateOne": "更新服务器", + "downloadProgress": "{{value0}} 下载进度", + "liveTabOne": "1 个活动标签页", + "liveTabs": "{{value0}} 个活动标签页", + "livePaneOne": "1 个活动窗格", + "livePanes": "{{value0}} 个活动窗格" }, "RemoteServerUpdateStatus": { "checking": "检查中…", @@ -9586,13 +9643,13 @@ "offline": "离线", "queued": "已排队", "checkingUpdate": "正在检查更新…", - "downloading": "Downloading…", - "restarting": "Restarting…", - "updated": "Updated", - "failed": "Update failed", - "serviceManagerHelp": "Update Orca through the service manager that starts this server.", - "unpackedHelp": "Development builds must be updated from their source checkout.", - "legacyHelp": "Update this server manually once to enable remote updates." + "downloading": "正在下载…", + "restarting": "正在重启…", + "updated": "已更新", + "failed": "更新失败", + "serviceManagerHelp": "通过启动此服务器的服务管理器更新 Orca。", + "unpackedHelp": "开发版本必须从其源代码检出进行更新。", + "legacyHelp": "手动更新此服务器一次以启用远程更新。" }, "BrowserLinkRoutingModifierSetting": { "titleSystem": "Hold Shift to open in your web browser", @@ -9849,6 +9906,50 @@ "logs": "插件日志", "development": "开发插件" } + }, + "RuntimeHostAccessForm": { + "getLink": "从另一台主机获取访问链接", + "stepOpenShare": "打开设置 → 远程 Orca 服务器 → 共享此主机。", + "stepChooseAddress": "选择“其他设备”,然后选择一个可达地址。", + "stepCopyLink": "生成链接,然后复制“配对另一个 Orca 客户端”链接。", + "name": "在 Orca 中的名称", + "namePlaceholder": "Linux 工作站", + "nameHelp": "这仅更改计算机在 Orca 中的显示方式。", + "accessLink": "访问链接", + "accessLinkPlaceholder": "orca://pair?code=...", + "accessLinkHelp": "Orca 会在连接前显示目标。凭据保持隐藏。", + "destination": "链接目标", + "loopbackTitle": "此链接指向此设备", + "loopbackDescription": "{{endpoint}} 会指向打开链接的设备,而不是创建链接的另一台计算机。", + "loopbackRecovery": "在另一台计算机上,使用“其他设备”创建新链接,并选择其 Tailscale 或 LAN 地址。", + "identityMismatch": "到达的 Orca 主机与此访问链接不匹配", + "identityMismatchHelp": "已连接到 {{endpoint}} 上的 Orca,但该主机与此链接不匹配。请在另一台主机上生成新链接。", + "invalidLink": "此访问链接不再有效", + "invalidLinkHelp": "请在另一台主机上生成新的访问链接,然后重试。", + "incompatible": "Orca 版本不兼容", + "incompatibleHelp": "请更新此设备和另一台主机上的 Orca,然后重试。", + "interrupted": "连接中断", + "interruptedHelp": "验证期间连接已中断。请检查网络或 SSH 隧道,然后重试。", + "unavailable": "主机不可用", + "unavailableHelp": "请确保 Orca 正在另一台主机上运行,并且网络或 SSH 隧道可以连接到 {{endpoint}}。", + "advanced": "高级", + "sshTunnel": "我正在使用 SSH 隧道连接到此本地地址", + "sshTunnelHelp": "使用此连接时保持隧道活跃。", + "headlessHelp": "正在使用无头 orca serve?在另一台计算机上运行 orca serve --pairing-address <可达主机>。", + "cancel": "取消", + "addWithTunnel": "使用隧道添加主机", + "addHost": "添加主机", + "connectionDetails": "连接详情", + "endpointKind": "端点类型", + "networkConnection": "网络连接", + "notAttempted": "未尝试" + }, + "CloudVmSetupGuide": { + "title": "创建云虚拟机", + "description": "云虚拟机会在创建工作区时通过环境模板创建。", + "setupRecipe": "为你的云提供商设置环境模板。", + "createWorkspace": "创建工作区,并在“运行于”中选择该模板。", + "openSetup": "设置环境模板" } }, "right": { @@ -11501,7 +11602,6 @@ "2f077ef4eb": ",然后扫码。", "3aa7bb2d8b": "配对桌面", "d1495e5e64": "打开 Orca 手机端,点击", - "901c98bb93": "配对这个", "3960f5c339": "第 2 步(共 2 步)", "3241f3c26a": "安装二维码", "7af266b80d": "安装二维码", diff --git a/src/renderer/src/lib/agent-launch-prompt-delivery.test.ts b/src/renderer/src/lib/agent-launch-prompt-delivery.test.ts index 8672c681941..4d93a29dbe3 100644 --- a/src/renderer/src/lib/agent-launch-prompt-delivery.test.ts +++ b/src/renderer/src/lib/agent-launch-prompt-delivery.test.ts @@ -31,17 +31,19 @@ describe('seedNativeChatLaunchDraftForAgentTab', () => { vi.clearAllMocks() }) - it('rejects multi-line text at the helper, not just at the delivery caller', () => { - // Worktree-create and work-item launches seed through this helper directly - // (a Linear draft is always `Linked Linear issue: …\n\n`), so the - // Ctrl+U kill-to-start-of-LINE constraint has to live here. - seedNativeChatLaunchDraftForAgentTab({ + it('mirrors multi-line text — the majority of real drafts', () => { + // A Linear draft is always `Linked Linear issue: …\n\n`, so rejecting + // newlines made every Linear launch invisible in chat. Send now clears every + // parked line first, so there is nothing left to glue. + const text = 'Linked Linear issue: STA-1234\nhttps://linear.app/o/issue/STA-1234\n' + seedNativeChatLaunchDraftForAgentTab({ tabId: 'linear-tab', agent: 'codex', text }) + + expect(mocks.seedNativeChatLaunchDraft).toHaveBeenCalledWith({ tabId: 'linear-tab', agent: 'codex', - text: 'Linked Linear issue: STA-1234\nhttps://linear.app/o/issue/STA-1234\n' + text, + createdAt: expect.any(Number) }) - - expect(mocks.seedNativeChatLaunchDraft).not.toHaveBeenCalled() }) it('seeds single-line text', () => { @@ -138,19 +140,23 @@ describe('deliverLaunchPromptToAgentTab', () => { expect(mocks.seedNativeChatLaunchPrompt).not.toHaveBeenCalled() }) - it('does not seed a launch draft for multi-line content', async () => { - // The chat send pre-clears the TUI with Ctrl+U (kill-to-start-of-LINE), so a - // multi-line prefill (e.g. scraped session-fork context) would leave earlier - // lines behind to glue onto the next message. + it('seeds a launch draft for multi-line content', async () => { + // Note+URL launches join with a blank line, so this shape is common too. + const content = 'Forked from session\n\nhttps://example.test/context' await deliverLaunchPromptToAgentTab({ tabId: 'fork-tab', agent: 'codex', - content: 'Forked from session\n\nhttps://example.test/context', + content, submit: false, forcePaste: false }) - expect(mocks.seedNativeChatLaunchDraft).not.toHaveBeenCalled() + expect(mocks.seedNativeChatLaunchDraft).toHaveBeenCalledWith({ + tabId: 'fork-tab', + agent: 'codex', + text: content, + createdAt: expect.any(Number) + }) }) it('does not seed a launch draft for submitted, unsupported, or empty content', async () => { diff --git a/src/renderer/src/lib/i18n-jsx-spacing-guard.test.ts b/src/renderer/src/lib/i18n-jsx-spacing-guard.test.ts index f2dde24571e..1e2438dbce5 100644 --- a/src/renderer/src/lib/i18n-jsx-spacing-guard.test.ts +++ b/src/renderer/src/lib/i18n-jsx-spacing-guard.test.ts @@ -58,11 +58,6 @@ const CASES: GuardCase[] = [ afterFallback: 'such as', label: 'such as main' }, - { - file: 'components/settings/RuntimeEnvironmentsPane.tsx', - afterFallback: 'Run', - label: 'Run orca serve' - }, { file: 'components/settings/AutoRenameBranchFromWorkSetting.tsx', afterFallback: 'Use', diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts index 518b23f347b..97621ec1c87 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts @@ -308,7 +308,7 @@ describe('launchAgentInNewTab', () => { ) }) - it('keeps a multi-line draft out of chat entirely', async () => { + it('mirrors a multi-line draft into chat and opens the tab there', async () => { store.settings = { agentCmdOverrides: {}, agentDefaultArgs: {}, @@ -319,21 +319,22 @@ describe('launchAgentInNewTab', () => { } const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + const prompt = 'Reproduce first\n\nhttps://github.com/o/r/issues/12' launchAgentInNewTab({ agent: 'claude', worktreeId: 'wt-1', - prompt: 'Reproduce first\n\nhttps://github.com/o/r/issues/12', + prompt, promptDelivery: 'draft' }) - // Unseedable and un-opened must move together: a chat view here would be - // an empty composer beside a filled TUI input. - expect(mockSeedNativeChatLaunchDraft).not.toHaveBeenCalled() + expect(mockSeedNativeChatLaunchDraft).toHaveBeenCalledWith( + expect.objectContaining({ tabId: 'tab-1', agent: 'claude', text: prompt }) + ) expect(mockCreateTab).toHaveBeenCalledWith( 'wt-1', undefined, undefined, - expect.not.objectContaining({ viewMode: 'chat' }) + expect.objectContaining({ viewMode: 'chat' }) ) }) diff --git a/src/renderer/src/lib/launch-work-item-direct.test.ts b/src/renderer/src/lib/launch-work-item-direct.test.ts index 218011215fa..52243fcc679 100644 --- a/src/renderer/src/lib/launch-work-item-direct.test.ts +++ b/src/renderer/src/lib/launch-work-item-direct.test.ts @@ -467,10 +467,9 @@ describe('launchWorkItemDirect', () => { expect(startup?.launchDraftText).toBe('https://github.com/acme/repo/issues/12') }) - it('withholds the chat-composer launch draft for a multi-line Linear draft launch', async () => { - // A Linear draft is always `Linked Linear issue: ENG-42\n\n`. The chat - // send pre-clears the TUI with Ctrl+U (kill-to-start-of-LINE), so seeding it - // would leave the first line parked to glue onto the next message. + it('seeds the chat-composer launch draft for a multi-line Linear draft launch', async () => { + // A Linear draft is always `Linked Linear issue: ENG-42\n\n`, so withholding + // multi-line drafts made every Linear launch invisible in the chat view. mocks.ensureDetectedAgents.mockResolvedValue(['claude']) const { launchWorkItemDirect } = await import('./launch-work-item-direct') @@ -490,7 +489,12 @@ describe('launchWorkItemDirect', () => { }) ).resolves.toBe(true) - expect(mocks.seedNativeChatLaunchDraft).not.toHaveBeenCalled() + expect(mocks.seedNativeChatLaunchDraft).toHaveBeenCalledWith({ + tabId: 'tab-1', + agent: 'claude', + text: 'Linked Linear issue: ENG-42\nhttps://linear.app/acme/issue/ENG-42/ship-linear-parity\n', + createdAt: expect.any(Number) + }) }) it('preserves explicit Linear paste content submit-after-ready behavior', async () => { diff --git a/src/renderer/src/lib/native-chat-initial-view-mode.test.ts b/src/renderer/src/lib/native-chat-initial-view-mode.test.ts index 37c792970a9..5744c7684f1 100644 --- a/src/renderer/src/lib/native-chat-initial-view-mode.test.ts +++ b/src/renderer/src/lib/native-chat-initial-view-mode.test.ts @@ -104,7 +104,21 @@ describe('decideInitialAgentTabViewMode', () => { it.each([ ['multi-line', 'Reproduce first\n\nhttps://github.com/o/r/issues/12'], - ['trailing-newline', 'https://github.com/o/r/issues/12\n'], + ['trailing-newline', 'https://github.com/o/r/issues/12\n'] + ])('opens a %s draft in chat with its mirrored composer text', (_label, launchDraftText) => { + expect( + decideInitialAgentTabViewMode({ + experimentalNativeChat: true, + openAgentTabsInChatByDefault: true, + agent: 'claude', + promptDelivery: 'draft', + launchDraftText + }) + ).toBe('chat') + }) + + it.each([ + ['Unicode-line-separator', 'one\u2028two'], ['blank', ' '], ['absent', undefined] ])('keeps a %s draft in the terminal, where its text actually is', (_label, launchDraftText) => { diff --git a/src/renderer/src/lib/native-chat-launch-draft-mirrorability.test.ts b/src/renderer/src/lib/native-chat-launch-draft-mirrorability.test.ts index 33dd6f9a30f..5cdc89ca936 100644 --- a/src/renderer/src/lib/native-chat-launch-draft-mirrorability.test.ts +++ b/src/renderer/src/lib/native-chat-launch-draft-mirrorability.test.ts @@ -13,6 +13,10 @@ vi.mock('@/store', () => ({ import { seedNativeChatLaunchDraftForAgentTab } from './agent-launch-prompt-delivery' import { canMirrorLaunchDraftToNativeChat } from './native-chat-launch-draft-mirrorability' import { decideInitialAgentTabViewMode } from './native-chat-initial-view-mode' +import { AGENT_TUI_CLEAR_MAX_LINES } from '../../../shared/agent-tui-input-clear' + +const maxLineDraft = Array.from({ length: AGENT_TUI_CLEAR_MAX_LINES }, () => 'line').join('\n') +const overMaxLineDraft = `${maxLineDraft}\nline` /** * Every shape a launch draft takes today. `formatDraftContextBlock` appends a @@ -28,6 +32,8 @@ const DRAFT_TEXTS = [ 'ORC-123: Restore linked quick-create\nhttps://linear.app/o/issue/ORC-123', 'ORC-123 https://linear.app/o/issue/ORC-123\n', ' spaced but single line ', + maxLineDraft, + overMaxLineDraft, '', ' ', '\n' @@ -69,18 +75,21 @@ describe('launch draft mirrorability', () => { expect(opensInChat(text)).toBe(expected) }) - // The only test that pins the rule itself; the agreement tests above adapt on - // their own. Multi-line send work updates the predicate body and this test. - it('accepts single-line text and rejects any line separator', () => { + it('accepts CR/LF drafts and rejects unsupported Unicode line separators', () => { expect(canMirrorLaunchDraftToNativeChat('https://github.com/o/r/issues/12')).toBe(true) - expect(canMirrorLaunchDraftToNativeChat('one\ntwo')).toBe(false) - expect(canMirrorLaunchDraftToNativeChat('one\rtwo')).toBe(false) + expect(canMirrorLaunchDraftToNativeChat('one\ntwo')).toBe(true) + expect(canMirrorLaunchDraftToNativeChat('one\rtwo')).toBe(true) expect(canMirrorLaunchDraftToNativeChat('one\u2028two')).toBe(false) expect(canMirrorLaunchDraftToNativeChat('one\u2029two')).toBe(false) - expect(canMirrorLaunchDraftToNativeChat('trailing\n')).toBe(false) + expect(canMirrorLaunchDraftToNativeChat('trailing\n')).toBe(true) expect(canMirrorLaunchDraftToNativeChat(' ')).toBe(false) }) + it('rejects drafts beyond the bounded TUI-clear budget', () => { + expect(canMirrorLaunchDraftToNativeChat(maxLineDraft)).toBe(true) + expect(canMirrorLaunchDraftToNativeChat(overMaxLineDraft)).toBe(false) + }) + it('withholds the mirror from agents without a native-chat renderer', () => { // The view mode already returns undefined for these, so the sets still // agree — but only the seeding side enforces it. diff --git a/src/renderer/src/lib/native-chat-launch-draft-mirrorability.ts b/src/renderer/src/lib/native-chat-launch-draft-mirrorability.ts index 4afd4e6c2c0..23e7395c853 100644 --- a/src/renderer/src/lib/native-chat-launch-draft-mirrorability.ts +++ b/src/renderer/src/lib/native-chat-launch-draft-mirrorability.ts @@ -1,3 +1,8 @@ +import { + AGENT_TUI_CLEAR_MAX_LINES, + countAgentTuiInputLines +} from '../../../shared/agent-tui-input-clear' + /** * Single source of truth for whether unsent launch context can be mirrored from * the agent's TUI input into the native-chat composer. @@ -7,12 +12,13 @@ * predicate, so a draft launch can never open in chat with a composer that * chat then refuses to fill. * - * Multi-line is rejected because the chat send pre-clears the TUI input with - * Ctrl+U (\x15) — kill-to-start-of-LINE, not of the whole buffer — so earlier - * lines of a multi-line mirror would survive and concatenate onto the message - * being sent. Relaxing that rule belongs here and nowhere else: teaching this - * predicate to accept multi-line flips seeding and view mode together. + * CR/LF drafts are safe within the bounded TUI-clear budget. Unicode line + * separators and drafts beyond that budget remain terminal-only. */ export function canMirrorLaunchDraftToNativeChat(text: string): boolean { - return text.trim().length > 0 && !/[\r\n\u2028\u2029]/.test(text) + return ( + text.trim().length > 0 && + !/[\u2028\u2029]/.test(text) && + countAgentTuiInputLines(text) <= AGENT_TUI_CLEAR_MAX_LINES + ) } diff --git a/src/renderer/src/lib/native-chat-launch-prompt.ts b/src/renderer/src/lib/native-chat-launch-prompt.ts index bffbd1537a3..b279730c812 100644 --- a/src/renderer/src/lib/native-chat-launch-prompt.ts +++ b/src/renderer/src/lib/native-chat-launch-prompt.ts @@ -20,4 +20,6 @@ export type NativeChatLaunchDraft = { createdAt: number /** Set once a composer copied the text into its draft; blocks re-adoption after the user clears it. */ adopted?: boolean + /** Accepted mobile submission consumed the TUI-side copy. */ + resolved?: boolean } diff --git a/src/renderer/src/lib/remote-pairing-copy.ts b/src/renderer/src/lib/remote-pairing-copy.ts new file mode 100644 index 00000000000..72d74ad494b --- /dev/null +++ b/src/renderer/src/lib/remote-pairing-copy.ts @@ -0,0 +1,91 @@ +import type { + HostAccessLinkErrorKind, + RemotePairingEndpointKind +} from '../../../shared/remote-pairing-address' +import type { RemotePairingFailureKind } from '../../../shared/remote-pairing-verification' +import { translate } from '@/i18n/i18n' + +export function translateHostAccessLinkError(kind: HostAccessLinkErrorKind): string { + switch (kind) { + case 'invalid-input': + return translate( + 'auto.lib.remotePairingCopy.invalidInput', + 'Enter an Orca access link or bare pairing code.' + ) + case 'mobile-only': + return translate( + 'auto.lib.remotePairingCopy.mobileOnly', + 'This link grants mobile-only access. Generate a link for another Orca client.' + ) + case 'invalid-destination': + return translate( + 'auto.lib.remotePairingCopy.invalidDestination', + 'This access link contains an invalid destination.' + ) + case 'unsupported-destination': + return translate( + 'auto.lib.remotePairingCopy.unsupportedDestination', + 'This access link contains an unsupported destination.' + ) + case 'non-connectable-destination': + return translate( + 'auto.lib.remotePairingCopy.nonConnectableDestination', + 'This access link contains a non-connectable destination.' + ) + } +} + +export function translateRemotePairingEndpointKind(kind: RemotePairingEndpointKind): string { + switch (kind) { + case 'loopback': + return translate('auto.lib.remotePairingCopy.loopback', 'Loopback') + case 'tailscale': + return translate('auto.lib.remotePairingCopy.tailscale', 'Tailscale address') + case 'lan': + return translate('auto.lib.remotePairingCopy.lan', 'Private LAN address') + case 'public': + return translate('auto.lib.remotePairingCopy.public', 'Public address') + case 'custom': + return translate('auto.lib.remotePairingCopy.custom', 'Custom hostname') + } +} + +export function translateRemotePairingFailureDescription( + kind: RemotePairingFailureKind, + endpoint: string | null +): string { + switch (kind) { + case 'host-identity-mismatch': + return translate( + 'auto.components.settings.RuntimeHostAccessForm.identityMismatchHelp', + 'Orca reached {{endpoint}}, but that host does not match this link. Generate a new link on the other host.', + { endpoint: endpoint ?? 'Orca' } + ) + case 'access-link-invalid': + return translate( + 'auto.components.settings.RuntimeHostAccessForm.invalidLinkHelp', + 'Generate a new access link on the other host and try again.' + ) + case 'protocol-incompatible': + return translate( + 'auto.components.settings.RuntimeHostAccessForm.incompatibleHelp', + 'Update Orca on this device and the other host, then try again.' + ) + case 'connection-interrupted': + return translate( + 'auto.components.settings.RuntimeHostAccessForm.interruptedHelp', + 'The connection stopped during verification. Check the network or SSH tunnel and try again.' + ) + case 'environment-save-failed': + return translate( + 'auto.components.settings.RuntimeHostAccessForm.saveFailedHelp', + 'The host was verified, but Orca could not save it. Check the name and local settings storage, then try again.' + ) + case 'host-unreachable': + return translate( + 'auto.components.settings.RuntimeHostAccessForm.unavailableHelp', + 'Make sure Orca is running on the other host and that the network or SSH tunnel can reach {{endpoint}}.', + { endpoint: endpoint ?? 'Orca' } + ) + } +} diff --git a/src/renderer/src/lib/worktree-activation.test.ts b/src/renderer/src/lib/worktree-activation.test.ts index 15623116fc6..b6f111c4d29 100644 --- a/src/renderer/src/lib/worktree-activation.test.ts +++ b/src/renderer/src/lib/worktree-activation.test.ts @@ -501,11 +501,10 @@ describe('ensureWorktreeHasInitialTerminal', () => { }) }) - // A draft opens in chat only when the composer can actually show it; the - // multi-line case would otherwise be an empty composer beside a filled TUI. it.each([ ['mirrorable', 'https://github.com/o/r/issues/12', { viewMode: 'chat' }], - ['multi-line', 'Review this\n\nhttps://github.com/o/r/issues/12', {}] + ['multi-line', 'Review this\n\nhttps://github.com/o/r/issues/12', { viewMode: 'chat' }], + ['unsupported-separator', 'Review this\u2028https://github.com/o/r/issues/12', {}] ])('opens a %s draft startup payload accordingly', (_label, draftPrompt, expectedViewMode) => { const store = createMockStore({ settings: { @@ -538,7 +537,8 @@ describe('ensureWorktreeHasInitialTerminal', () => { // nothing mirrored — an empty composer beside a filled TUI input. it.each([ ['mirrorable', 'https://github.com/o/r/issues/12', { viewMode: 'chat' }], - ['multi-line', 'Review this\n\nhttps://github.com/o/r/issues/12', {}] + ['multi-line', 'Review this\n\nhttps://github.com/o/r/issues/12', { viewMode: 'chat' }], + ['unsupported-separator', 'Review this\u2028https://github.com/o/r/issues/12', {}] ])( 'gates a %s argv-prefill draft on launchDraftText alone', (_label, launchDraftText, expectedViewMode) => { @@ -599,7 +599,8 @@ describe('ensureWorktreeHasInitialTerminal', () => { it.each([ ['mirrorable', 'https://github.com/o/r/issues/12', { viewMode: 'chat' }], - ['multi-line', 'Review this\n\nhttps://github.com/o/r/issues/12', {}] + ['multi-line', 'Review this\n\nhttps://github.com/o/r/issues/12', { viewMode: 'chat' }], + ['unsupported-separator', 'Review this\u2028https://github.com/o/r/issues/12', {}] ])( 'opens a %s draft startup default tab accordingly', (_label, draftPrompt, expectedViewMode) => { diff --git a/src/renderer/src/lib/worktree-creation-agent-seeds.test.ts b/src/renderer/src/lib/worktree-creation-agent-seeds.test.ts index a3280957cdc..43c48eef25e 100644 --- a/src/renderer/src/lib/worktree-creation-agent-seeds.test.ts +++ b/src/renderer/src/lib/worktree-creation-agent-seeds.test.ts @@ -133,7 +133,7 @@ describe('seedAgentTabStateAfterWorktreeCreate', () => { setTabs([{ id: 'agent-tab', launchAgent: 'claude', viewMode: 'chat' }]) seedAgentTabStateAfterWorktreeCreate({ - request: { ...request, launchDraftPrompt: 'note\rhttps://github.com/o/r/issues/12' }, + request: { ...request, launchDraftPrompt: 'note\u2028https://github.com/o/r/issues/12' }, worktreeId: 'wt-1', primaryTabId: 'agent-tab', startupTerminalTabId: 'agent-tab', diff --git a/src/renderer/src/lib/worktree-creation-flow.test.ts b/src/renderer/src/lib/worktree-creation-flow.test.ts index 3640d3ae611..ad24a825076 100644 --- a/src/renderer/src/lib/worktree-creation-flow.test.ts +++ b/src/renderer/src/lib/worktree-creation-flow.test.ts @@ -735,7 +735,7 @@ describe('staged background worktree creation', () => { it.each([ ['mirrorable local Grok', 'grok', 'https://github.com/o/r/issues/12', 'chat'], - ['multi-line Claude', 'claude', 'note\nhttps://github.com/o/r/issues/12', 'terminal'] + ['multi-line Claude', 'claude', 'note\nhttps://github.com/o/r/issues/12', 'chat'] ] as const)('passes %s draft mode to backend startup', async (_label, agent, draft, viewMode) => { store.settings.experimentalNativeChat = true store.settings.openAgentTabsInChatByDefault = true diff --git a/src/renderer/src/runtime/native-chat-launch-draft-runtime-resolution.test.ts b/src/renderer/src/runtime/native-chat-launch-draft-runtime-resolution.test.ts new file mode 100644 index 00000000000..205392eebe8 --- /dev/null +++ b/src/renderer/src/runtime/native-chat-launch-draft-runtime-resolution.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it, vi } from 'vitest' +import { applyNativeChatLaunchDraftResolved } from './native-chat-launch-draft-runtime-resolution' + +describe('applyNativeChatLaunchDraftResolved', () => { + it('routes the exact generation to the store action', () => { + const resolveNativeChatLaunchDraft = vi.fn() + + applyNativeChatLaunchDraftResolved( + { resolveNativeChatLaunchDraft }, + { type: 'nativeChatLaunchDraftResolved', tabId: 'tab-1', text: 'seed', createdAt: 7 } + ) + + expect(resolveNativeChatLaunchDraft).toHaveBeenCalledWith('tab-1', { + text: 'seed', + createdAt: 7 + }) + }) +}) diff --git a/src/renderer/src/runtime/native-chat-launch-draft-runtime-resolution.ts b/src/renderer/src/runtime/native-chat-launch-draft-runtime-resolution.ts new file mode 100644 index 00000000000..8bee4656f94 --- /dev/null +++ b/src/renderer/src/runtime/native-chat-launch-draft-runtime-resolution.ts @@ -0,0 +1,19 @@ +import type { AppState } from '@/store' +import type { RuntimeClientEvent } from '../../../shared/runtime-client-events' + +type LaunchDraftResolvedEvent = Extract< + RuntimeClientEvent, + { type: 'nativeChatLaunchDraftResolved' } +> + +type LaunchDraftResolutionState = Pick + +export function applyNativeChatLaunchDraftResolved( + state: LaunchDraftResolutionState, + event: LaunchDraftResolvedEvent +): void { + state.resolveNativeChatLaunchDraft(event.tabId, { + text: event.text, + createdAt: event.createdAt + }) +} diff --git a/src/renderer/src/runtime/runtime-client-events.test.ts b/src/renderer/src/runtime/runtime-client-events.test.ts index 4f61c56b68f..983ec41ca87 100644 --- a/src/renderer/src/runtime/runtime-client-events.test.ts +++ b/src/renderer/src/runtime/runtime-client-events.test.ts @@ -54,17 +54,32 @@ describe('subscribeRuntimeClientEvents', () => { batch: { ptyId: 'pty-1', seq: 7, facts: [{ kind: 'bell' }] } } }) + capturedOnResponse({ + ok: true, + result: { + type: 'nativeChatLaunchDraftResolved', + tabId: 'tab-1', + text: 'seed', + createdAt: 7 + } + }) capturedOnResponse({ ok: false, error: { code: 'method_not_found', message: 'missing' } }) - expect(onEvent).toHaveBeenCalledTimes(2) + expect(onEvent).toHaveBeenCalledTimes(3) expect(onEvent).toHaveBeenCalledWith({ type: 'worktreesChanged', repoId: 'repo-1' }) expect(onEvent).toHaveBeenCalledWith({ type: 'terminalSideEffects', batch: { ptyId: 'pty-1', seq: 7, facts: [{ kind: 'bell' }] } }) + expect(onEvent).toHaveBeenCalledWith({ + type: 'nativeChatLaunchDraftResolved', + tabId: 'tab-1', + text: 'seed', + createdAt: 7 + }) expect(onError).toHaveBeenCalledWith({ code: 'method_not_found', message: 'missing' }) subscription.unsubscribe() diff --git a/src/renderer/src/runtime/runtime-client-events.ts b/src/renderer/src/runtime/runtime-client-events.ts index 2a41dbb1c57..2631166379c 100644 --- a/src/renderer/src/runtime/runtime-client-events.ts +++ b/src/renderer/src/runtime/runtime-client-events.ts @@ -86,6 +86,7 @@ function isRuntimeClientEvent( return ( message.type === 'reposChanged' || message.type === 'worktreesChanged' || + message.type === 'nativeChatLaunchDraftResolved' || message.type === 'terminalSideEffects' || message.type === 'sshStateChanged' || message.type === 'linearLinkedIssueUpdated' || diff --git a/src/renderer/src/runtime/sync-runtime-graph.test.ts b/src/renderer/src/runtime/sync-runtime-graph.test.ts index 8d3a149e583..0ca21521f12 100644 --- a/src/renderer/src/runtime/sync-runtime-graph.test.ts +++ b/src/renderer/src/runtime/sync-runtime-graph.test.ts @@ -741,11 +741,40 @@ describe('buildMobileSessionTabSnapshots', () => { expect.objectContaining({ type: 'terminal', parentTabId: 'term-1', - launchDraft: 'https://github.com/o/r/issues/12' + launchDraft: 'https://github.com/o/r/issues/12', + launchDraftCreatedAt: 1 }) ]) }) + it('retracts a launch draft as soon as mobile resolves it', () => { + const leafId = '11111111-1111-4111-8111-111111111111' + const state = makeState({ + tabsByWorktree: { + 'wt-1': [{ id: 'term-1', title: 'Terminal 1', launchAgent: 'claude' }] + } as unknown as AppState['tabsByWorktree'], + terminalLayoutsByTabId: { + 'term-1': { + root: { type: 'leaf', leafId }, + activeLeafId: leafId, + expandedLeafId: null, + ptyIdsByLeafId: { [leafId]: 'pty-1' } + } + } as unknown as AppState['terminalLayoutsByTabId'], + nativeChatLaunchDraftByTabId: { + 'term-1': { + tabId: 'term-1', + agent: 'claude', + text: 'issue link', + createdAt: 1, + resolved: true + } + } + }) + + expect(buildMobileSessionTabSnapshots(state)[0]?.tabs[0]).not.toHaveProperty('launchDraft') + }) + it('withholds a launch draft seeded for a different agent than the tab runs', () => { // The seed is keyed by tab id, which survives an agent switch. Desktop's // consumer declines on mismatch; publishing anyway would prefill the new diff --git a/src/renderer/src/runtime/sync-runtime-graph.ts b/src/renderer/src/runtime/sync-runtime-graph.ts index 33cb27ed74a..3b256b2e884 100644 --- a/src/renderer/src/runtime/sync-runtime-graph.ts +++ b/src/renderer/src/runtime/sync-runtime-graph.ts @@ -40,6 +40,7 @@ import { } from '../components/tab-bar/group-tab-order' import { resolveTerminalLayoutRoot } from './remote-terminal-layout-resolution' import { parseRemoteRuntimePtyId } from './runtime-terminal-stream' +import { applyNativeChatLaunchDraftResolved } from './native-chat-launch-draft-runtime-resolution' type RegisteredTerminalTab = { tabId: string @@ -734,9 +735,16 @@ async function syncRuntimeGraph(): Promise { try { const result = await window.api.runtime.syncWindowGraph(graph) - getStoreState()?.setRuntimeAgentOrchestrationByPaneKey?.( - result?.agentOrchestrationByPaneKey ?? {} - ) + const currentState = getStoreState() + currentState?.setRuntimeAgentOrchestrationByPaneKey?.(result?.agentOrchestrationByPaneKey ?? {}) + for (const resolution of result?.nativeChatLaunchDraftResolutions ?? []) { + if (currentState) { + applyNativeChatLaunchDraftResolved(currentState, { + type: 'nativeChatLaunchDraftResolved', + ...resolution + }) + } + } } catch (error) { console.error('[runtime] Failed to sync renderer graph:', error) } @@ -1371,8 +1379,12 @@ function buildMobileTerminalSurfaceTabs( // tab id, so an unmatched seed would prefill the new agent's chat with stale text. const seededLaunchDraft = state.nativeChatLaunchDraftByTabId?.[terminal.id] const launchDraftEntry = - seededLaunchDraft && seededLaunchDraft.agent === terminal.launchAgent ? seededLaunchDraft : null - const launchDraftText = launchDraftEntry?.text.trim() ? launchDraftEntry.text : null + seededLaunchDraft && + !seededLaunchDraft.resolved && + seededLaunchDraft.agent === terminal.launchAgent + ? seededLaunchDraft + : null + const publishedLaunchDraft = launchDraftEntry?.text.trim() ? launchDraftEntry : null const container = registered?.getContainer() const firstChild = container?.firstElementChild const liveLayoutRoot = serializePaneTree( @@ -1435,7 +1447,12 @@ function buildMobileTerminalSurfaceTabs( ...(terminal.launchAgent ? { launchAgent: terminal.launchAgent } : {}), // Launch context that exists only as an unsent TUI-input draft; mobile // prefills its chat composer from it (desktop keeps its own seed store). - ...(launchDraftText ? { launchDraft: launchDraftText } : {}), + ...(publishedLaunchDraft + ? { + launchDraft: publishedLaunchDraft.text, + launchDraftCreatedAt: publishedLaunchDraft.createdAt + } + : {}), parentLayout, isActive: isDesktopTabActive && leafId === activeLeafId } diff --git a/src/renderer/src/store/slices/native-chat-launch-draft-teardown.test.ts b/src/renderer/src/store/slices/native-chat-launch-draft-teardown.test.ts index 79b1fd4162b..08408c343fc 100644 --- a/src/renderer/src/store/slices/native-chat-launch-draft-teardown.test.ts +++ b/src/renderer/src/store/slices/native-chat-launch-draft-teardown.test.ts @@ -127,6 +127,21 @@ describe('nativeChatLaunchDraftByTabId teardown', () => { expect(TAB1 in store.getState().nativeChatLaunchDraftByTabId).toBe(false) }) + it('resolves only the exact draft generation', () => { + const store = createTestStore() + const entry = draft(TAB1, 'same text') + store.getState().seedNativeChatLaunchDraft(entry) + + store.getState().resolveNativeChatLaunchDraft(TAB1, { text: entry.text, createdAt: 0 }) + expect(store.getState().nativeChatLaunchDraftByTabId[TAB1]?.resolved).toBeUndefined() + + store.getState().resolveNativeChatLaunchDraft(TAB1, { + text: entry.text, + createdAt: entry.createdAt + }) + expect(store.getState().nativeChatLaunchDraftByTabId[TAB1]?.resolved).toBe(true) + }) + it('the orphan terminal cleanup patch drops swept tabs’ drafts only', () => { const store = createTestStore() seedDrafts(store) diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 09911ad8ba9..27fd094bad7 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -569,6 +569,10 @@ export type TerminalSlice = { nativeChatLaunchDraftByTabId: Record seedNativeChatLaunchDraft: (draft: NativeChatLaunchDraft) => void markNativeChatLaunchDraftAdopted: (tabId: string) => void + resolveNativeChatLaunchDraft: ( + tabId: string, + resolution: Pick + ) => void clearNativeChatLaunchDraft: (tabId: string) => void pendingStartupByTabId: Record< string, @@ -1127,6 +1131,26 @@ export const createTerminalSlice: StateCreator }) }, + resolveNativeChatLaunchDraft: (tabId, resolution) => { + set((s) => { + const current = s.nativeChatLaunchDraftByTabId[tabId] + if ( + !current || + current.resolved || + current.createdAt !== resolution.createdAt || + current.text !== resolution.text + ) { + return {} + } + return { + nativeChatLaunchDraftByTabId: { + ...s.nativeChatLaunchDraftByTabId, + [tabId]: { ...current, resolved: true } + } + } + }) + }, + clearNativeChatLaunchDraft: (tabId) => { set((s) => { if (!s.nativeChatLaunchDraftByTabId[tabId]) { diff --git a/src/renderer/src/store/slices/worktrees.test.ts b/src/renderer/src/store/slices/worktrees.test.ts index f87ad733294..dbcbff9f74e 100644 --- a/src/renderer/src/store/slices/worktrees.test.ts +++ b/src/renderer/src/store/slices/worktrees.test.ts @@ -123,6 +123,13 @@ const mockApi = { runtimeEnvironments: { call: runtimeEnvironmentTransportCall }, + runtime: { + call: vi.fn().mockResolvedValue({ + id: 'runtime-call', + ok: true, + result: { stoppedWorktreeIds: [] } + }) + }, ephemeralVm: { cancelProvision: vi.fn().mockResolvedValue({ cancelled: true }), cleanup: vi.fn().mockResolvedValue({}), @@ -703,6 +710,126 @@ describe('fetchWorktrees', () => { expect(mockApi.worktrees.listDetected).toHaveBeenCalledTimes(1) }) + // Why (#10562): the scan coalesces but each caller carries its own known-id + // snapshot, so a caller that joined an in-flight scan must still request + // teardown — otherwise it purges renderer state and strands live PTYs. + it('stops terminals for a caller that coalesced onto an in-flight scan', async () => { + const store = createTestStore() + const deleted = makeWorktree({ id: 'repo1::/p/deleted', repoId: 'repo1', path: '/p/deleted' }) + const surviving = makeWorktree({ id: 'repo1::/p/surv', repoId: 'repo1', path: '/p/surv' }) + + // Caller A starts before hydration, so its known-id snapshot is empty. + store.setState({ + repos: [{ id: 'repo1', path: '/p/repo1', displayName: 'R', badgeColor: '#000', addedAt: 0 }], + worktreesByRepo: {}, + detectedWorktreesByRepo: {}, + tabsByWorktree: {} + } as unknown as Partial) + + let releaseScan!: () => void + const scanStarted = new Promise((resolve) => { + mockApi.worktrees.listDetected.mockImplementationOnce(async ({ repoId }) => { + resolve() + await new Promise((release) => { + releaseScan = release + }) + return makeDetectedResult(repoId, [surviving]) + }) + }) + + const callerA = store.getState().fetchDetectedWorktrees('repo1') + await scanStarted + + // Hydration lands mid-scan: the renderer now owns `deleted` and its tabs. + store.setState({ + worktreesByRepo: { repo1: [deleted, surviving] }, + detectedWorktreesByRepo: { repo1: makeDetectedResult('repo1', [deleted, surviving]) }, + tabsByWorktree: { [deleted.id]: [{ id: 'tab-d', worktreeId: deleted.id }] } + } as unknown as Partial) + + const callerB = store.getState().fetchWorktrees('repo1') + releaseScan() + await Promise.all([callerA, callerB]) + + // One shared scan, but the coalesced caller still asked the host to sweep. + expect(mockApi.worktrees.listDetected).toHaveBeenCalledTimes(1) + expect(mockApi.runtime.call).toHaveBeenCalledWith({ + method: 'worktree.teardownMissingTerminals', + params: { repo: 'repo1', worktreeIds: [deleted.id], connectionId: null } + }) + expect(store.getState().tabsByWorktree[deleted.id]).toBeUndefined() + }) + + // Why: teardown rides outside the scan coalescer, so identical fan-out requests + // must share one host sweep instead of re-scanning the host per caller. + it('shares one teardown sweep across identical concurrent refreshes', async () => { + const store = createTestStore() + const deleted = makeWorktree({ id: 'repo1::/p/deleted', repoId: 'repo1', path: '/p/deleted' }) + const surviving = makeWorktree({ id: 'repo1::/p/surv', repoId: 'repo1', path: '/p/surv' }) + + store.setState({ + repos: [{ id: 'repo1', path: '/p/repo1', displayName: 'R', badgeColor: '#000', addedAt: 0 }], + worktreesByRepo: { repo1: [deleted, surviving] }, + detectedWorktreesByRepo: { repo1: makeDetectedResult('repo1', [deleted, surviving]) }, + tabsByWorktree: { [deleted.id]: [{ id: 'tab-d', worktreeId: deleted.id }] } + } as unknown as Partial) + + mockApi.worktrees.listDetected.mockImplementationOnce(async (args) => + qualifyDetectedResult(args, makeDetectedResult('repo1', [surviving])) + ) + + await Promise.all([ + store.getState().fetchWorktrees('repo1'), + store.getState().fetchDetectedWorktrees('repo1'), + store.getState().fetchWorktrees('repo1') + ]) + + expect(mockApi.runtime.call).toHaveBeenCalledTimes(1) + expect(store.getState().tabsByWorktree[deleted.id]).toBeUndefined() + }) + + // Why (#10562): "I can't reach the host" must never be read as "the worktree is + // gone". A disconnected SSH target has no authoritative scan, so it must not + // purge and must not kill anything on the far side. + it('never stops terminals for a disconnected SSH target', async () => { + const store = createTestStore() + const deleted = makeWorktree({ id: 'repo1::/p/deleted', repoId: 'repo1', path: '/p/deleted' }) + const surviving = makeWorktree({ id: 'repo1::/p/surv', repoId: 'repo1', path: '/p/surv' }) + + store.setState({ + repos: [ + { + id: 'repo1', + path: '/p/repo1', + displayName: 'R', + badgeColor: '#000', + addedAt: 0, + connectionId: TEST_SSH_AUTHORITY.targetId + } + ], + // Why: the target dropped — no connected authority for this host. + sshConnectionStates: new Map([ + [ + TEST_SSH_AUTHORITY.targetId, + { + targetId: TEST_SSH_AUTHORITY.targetId, + status: 'disconnected', + error: null, + reconnectAttempt: 1 + } + ] + ]), + worktreesByRepo: { repo1: [deleted, surviving] }, + detectedWorktreesByRepo: { repo1: makeDetectedResult('repo1', [deleted, surviving]) }, + tabsByWorktree: { [deleted.id]: [{ id: 'tab-d', worktreeId: deleted.id }] } + } as unknown as Partial) + + await store.getState().fetchWorktrees('repo1') + + expect(mockApi.runtime.call).not.toHaveBeenCalled() + expect(store.getState().tabsByWorktree[deleted.id]).toBeDefined() + }) + it('coalesces fetchDetectedWorktrees with a matching fetchWorktrees refresh', async () => { const store = createTestStore() const refreshed = makeWorktree({ @@ -1500,6 +1627,68 @@ describe('fetchWorktrees', () => { expect(hasDismissedHugeRepoWarning(beginHugeRepoWarningProbe(hidden))).toBe(false) }) + it('awaits missing-worktree terminal teardown before purging renderer state', async () => { + const store = createTestStore() + const deleted = makeWorktree({ + id: 'repo1::/path/deleted', + repoId: 'repo1', + path: '/path/deleted' + }) + const surviving = makeWorktree({ + id: 'repo1::/path/surviving', + repoId: 'repo1', + path: '/path/surviving' + }) + let finishTeardown!: () => void + mockApi.runtime.call.mockImplementationOnce( + () => + new Promise((resolve) => { + finishTeardown = () => + resolve({ + id: 'teardown', + ok: true, + result: { stoppedWorktreeIds: [deleted.id] } + }) + }) + ) + mockApi.worktrees.listDetected.mockImplementationOnce(async (args) => + qualifyDetectedResult(args, makeDetectedResult('repo1', [surviving])) + ) + store.setState({ + repos: [ + { + id: 'repo1', + path: '/path/repo1', + displayName: 'Repo 1', + badgeColor: '#000', + addedAt: 0, + connectionId: 'ssh-1' + } + ], + worktreesByRepo: { repo1: [deleted, surviving] }, + detectedWorktreesByRepo: { + repo1: makeDetectedResult('repo1', [deleted, surviving]) + }, + tabsByWorktree: { + [deleted.id]: [{ id: 'tab-deleted', worktreeId: deleted.id }] + } + } as unknown as Partial) + + const refresh = store.getState().fetchWorktrees('repo1') + await vi.waitFor(() => expect(mockApi.runtime.call).toHaveBeenCalledTimes(1)) + + expect(mockApi.runtime.call).toHaveBeenCalledWith({ + method: 'worktree.teardownMissingTerminals', + params: { repo: 'repo1', worktreeIds: [deleted.id], connectionId: 'ssh-1' } + }) + expect(store.getState().tabsByWorktree[deleted.id]).toBeDefined() + + finishTeardown() + await refresh + + expect(store.getState().tabsByWorktree[deleted.id]).toBeUndefined() + }) + it('clears a hidden dismissal across hydrated fetch-all delete and recreation', async () => { const store = createTestStore() const visible = makeWorktree({ @@ -1557,7 +1746,9 @@ describe('fetchWorktrees', () => { path: '/path/surviving' }) - mockApi.worktrees.listDetected.mockResolvedValueOnce(makeDetectedResult('repo1', [surviving])) + mockApi.worktrees.listDetected.mockImplementationOnce(async (args) => + qualifyDetectedResult(args, makeDetectedResult('repo1', [surviving])) + ) store.setState({ worktreesByRepo: { repo1: [] }, detectedWorktreesByRepo: { @@ -1636,6 +1827,7 @@ describe('fetchWorktrees', () => { expect(store.getState().worktreesByRepo.repo1).toEqual([fallback]) expect(store.getState().sortEpoch).toBe(8) expect(result).toBe(false) + expect(mockApi.runtime.call).not.toHaveBeenCalled() }) it('does not purge remembered right sidebar tabs on a transient empty refresh', async () => { diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 2600c4def8b..27f04a3c8eb 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -169,6 +169,10 @@ type DetectedWorktreeRefreshOptions = BackgroundRuntimeRefreshOptions & { executionHostId: ExecutionHostId requireAuthoritative?: boolean directSshAuthority?: DirectSshAuthority + // Why (#10562): the caller's own view of what it is about to purge. Teardown is + // requested per caller, so this must never be shared with a coalesced scan. + connectionId?: string | null + knownWorktreeIds?: readonly string[] } type AdmittedDetectedWorktreeRefresh = { @@ -695,6 +699,64 @@ function isRuntimeMethodNotFoundError(error: unknown): boolean { return error instanceof RuntimeRpcCallError && error.code === 'method_not_found' } +// Why: teardown cannot ride the scan's coalescing (each caller has its own known-id +// snapshot), so dedupe on the request it actually produces — identical fan-out +// requests share one host sweep instead of re-scanning per caller. +const missingWorktreeTeardownsInFlight = new Map>() + +async function teardownMissingWorktreeTerminalsBestEffort( + settings: AppState['settings'], + repoId: string, + connectionId: string | null | undefined, + // Why: refreshes that never purge omit this; an absent snapshot means + // "nothing to reconcile", never "purge everything". + knownWorktreeIds: readonly string[] | undefined, + detected: DetectedWorktreeListResult +): Promise { + if (!detected.authoritative || !knownWorktreeIds || knownWorktreeIds.length === 0) { + return + } + const detectedIds = new Set(detected.worktrees.map((worktree) => worktree.id)) + const missingIds = knownWorktreeIds.filter((worktreeId) => !detectedIds.has(worktreeId)) + if (missingIds.length === 0) { + return + } + const target = getActiveRuntimeTarget(settings) + const normalizedConnectionId = connectionId ?? null + const key = [ + target.kind === 'local' ? 'local' : `runtime:${target.environmentId}`, + repoId, + normalizedConnectionId ?? '', + [...missingIds].sort().join('\n') + ].join('\0') + const existing = missingWorktreeTeardownsInFlight.get(key) + if (existing) { + return existing + } + const teardown = (async () => { + try { + await callRuntimeRpc( + target, + 'worktree.teardownMissingTerminals', + { repo: repoId, worktreeIds: missingIds, connectionId: normalizedConnectionId }, + { timeoutMs: 30_000 } + ) + } catch (error) { + if (!isRuntimeMethodNotFoundError(error)) { + console.warn(`Failed to stop terminals for missing worktrees in repo ${repoId}:`, error) + } + } + })() + missingWorktreeTeardownsInFlight.set(key, teardown) + try { + await teardown + } finally { + if (missingWorktreeTeardownsInFlight.get(key) === teardown) { + missingWorktreeTeardownsInFlight.delete(key) + } + } +} + // Why: a mobile-scope web pairing is denied worktree/repo RPCs (else silently empty workspaces); surface one deduped toast (stable id) instead of spamming per-repo. const RUNTIME_SCOPE_FORBIDDEN_TOAST_ID = 'runtime-scope-forbidden' @@ -1264,6 +1326,16 @@ async function listDetectedWorktreesForRepoCoalesced( ) { throw new Error('runtime_environment_generation_changed') } + // Why (#10562): the scan coalesces, but teardown must not — each caller carries + // its own known-id snapshot and purges its own state, so a caller that joined + // an in-flight scan would otherwise purge without ever stopping those terminals. + await teardownMissingWorktreeTerminalsBestEffort( + settings, + repoId, + options.connectionId, + options.knownWorktreeIds, + result + ) return { status: 'admitted', result, @@ -1311,6 +1383,13 @@ async function listDetectedWorktreesForRepoCoalesced( directSshAuthority: options.directSshAuthority } } + await teardownMissingWorktreeTerminalsBestEffort( + settings, + repoId, + options.connectionId, + options.knownWorktreeIds, + providerResult.result + ) return { status: 'admitted', result: providerResult.result, @@ -2955,6 +3034,10 @@ export const createWorktreeSlice: StateCreator const hostId = repoHostId(ownerState, repoId) const ownerWasMissingAtStart = !ownerState.repos.some((repo) => repo.id === repoId) const setup = getProjectHostSetupForRepoHost(ownerState, repoId, hostId) + const repoOwner = findRepoForHost(ownerState.repos, repoId, { + hostId, + settings: ownerState.settings + }) const parsedHost = parseExecutionHostId(hostId) const directSshAuthority = parsedHost?.kind === 'ssh' @@ -2966,7 +3049,12 @@ export const createWorktreeSlice: StateCreator const refresh = await listDetectedWorktreesForRepoCoalesced( settingsForRepoOwner(ownerState, repoId, hostId), repoId, - { executionHostId: hostId, directSshAuthority } + { + executionHostId: hostId, + directSshAuthority, + connectionId: repoOwner?.connectionId, + knownWorktreeIds: getKnownWorktreeIdsForPurge(ownerState, repoId, hostId) + } ) if (refresh.status !== 'admitted') { return null @@ -3034,6 +3122,10 @@ export const createWorktreeSlice: StateCreator ? LOCAL_EXECUTION_HOST_ID : repoHostId(ownerState, repoId, options?.executionHostId) const setup = getProjectHostSetupForRepoHost(ownerState, repoId, hostId) + const repoOwner = findRepoForHost(ownerState.repos, repoId, { + hostId, + settings: ownerState.settings + }) const ownerSettings = settingsForRepoOwner(ownerState, repoId, hostId) const settings = useLocalOwner && ownerSettings?.activeRuntimeEnvironmentId @@ -3050,7 +3142,9 @@ export const createWorktreeSlice: StateCreator const refresh = await listDetectedWorktreesForRepoCoalesced(settings, repoId, { executionHostId: hostId, requireAuthoritative: options?.requireAuthoritative, - directSshAuthority + directSshAuthority, + connectionId: repoOwner?.connectionId, + knownWorktreeIds: getKnownWorktreeIdsForPurge(ownerState, repoId, hostId) }) if (refresh.status !== 'admitted') { return directCallerAuthority ? refresh.providerResult : false @@ -3108,7 +3202,9 @@ export const createWorktreeSlice: StateCreator const refresh = await listDetectedWorktreesForRepoCoalesced(settings, r.id, { executionHostId: hostId, reuseRecentCompatibilityFailure: true, - directSshAuthority + directSshAuthority, + connectionId: r.connectionId, + knownWorktreeIds: getKnownWorktreeIdsForPurge(requestStartedState, r.id, hostId) }) if (refresh.status !== 'admitted') { return @@ -3160,7 +3256,9 @@ export const createWorktreeSlice: StateCreator { executionHostId: hostId, reuseRecentCompatibilityFailure: true, - directSshAuthority + directSshAuthority, + connectionId: r.connectionId, + knownWorktreeIds: getKnownWorktreeIdsForPurge(requestStartedState, r.id, hostId) } ) if (refresh.status !== 'admitted') { diff --git a/src/renderer/src/web/web-preload-api.test.ts b/src/renderer/src/web/web-preload-api.test.ts index a8636b50ea6..6b5b3a093e3 100644 --- a/src/renderer/src/web/web-preload-api.test.ts +++ b/src/renderer/src/web/web-preload-api.test.ts @@ -5,6 +5,7 @@ import type { PreloadApi } from '../../../preload/api-types' import type { FeatureInteractionState } from '../../../shared/feature-interactions' import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' import type { TaskSourceContext } from '../../../shared/task-source-context' +import { MIN_COMPATIBLE_RUNTIME_SERVER_VERSION } from '../../../shared/protocol-version' const TEST_COMMIT_OID = '0123456789abcdef0123456789abcdef01234567' @@ -200,6 +201,7 @@ describe('web before-unload persistence', () => { afterEach(() => { vi.unstubAllGlobals() + vi.doUnmock('./web-runtime-client') }) it('persists final UI and host-partitioned sessions synchronously', async () => { @@ -531,6 +533,187 @@ describe('web runtime environment identity', () => { ) } ) + it('keeps the current host when verification rejects an incompatible replacement', async () => { + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + call(): Promise> { + return Promise.resolve({ + id: 'status', + ok: true, + result: { runtimeProtocolVersion: MIN_COMPATIBLE_RUNTIME_SERVER_VERSION - 1 }, + _meta: { runtimeId: 'runtime-old' } + }) + } + + close(): void {} + } + })) + const globals = installBrowserGlobals('Linux') + writeStoredRuntimeEnvironment(globals.storage, 'web-server-a') + const previousStored = globals.storage.getItem('orca.web.runtimeEnvironment.v1') + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + + await expect( + globals.window.api.runtimeEnvironments.verifyAndAddFromPairingCode({ + name: 'Incompatible server', + pairingCode: encodePairingCode() + }) + ).resolves.toMatchObject({ ok: false, kind: 'protocol-incompatible' }) + expect(globals.storage.getItem('orca.web.runtimeEnvironment.v1')).toBe(previousStored) + await expect(globals.window.api.runtimeEnvironments.list()).resolves.toMatchObject([ + { id: 'web-server-a' } + ]) + }) + + it('keeps the current host when browser storage rejects a verified replacement', async () => { + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + call(): Promise> { + return Promise.resolve({ + id: 'status', + ok: true, + result: { + runtimeId: 'runtime-new', + rendererGraphEpoch: 1, + graphStatus: 'ready', + authoritativeWindowId: 1, + liveTabCount: 0, + liveLeafCount: 0, + runtimeProtocolVersion: MIN_COMPATIBLE_RUNTIME_SERVER_VERSION + }, + _meta: { runtimeId: 'runtime-new' } + }) + } + + close(): void {} + } + })) + const globals = installBrowserGlobals('Linux') + writeStoredRuntimeEnvironment(globals.storage, 'web-server-a') + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + vi.spyOn(globals.storage, 'setItem').mockImplementation(() => { + throw new Error('Browser storage is full.') + }) + + await expect( + globals.window.api.runtimeEnvironments.verifyAndAddFromPairingCode({ + name: 'Verified replacement', + pairingCode: encodePairingCode() + }) + ).resolves.toMatchObject({ + ok: false, + kind: 'environment-save-failed', + message: 'Orca verified the host but could not save it. Check browser storage and try again.' + }) + await expect(globals.window.api.runtimeEnvironments.list()).resolves.toMatchObject([ + { id: 'web-server-a' } + ]) + }) + + it('requires an explicit loopback override and persists the SSH dependency', async () => { + const call = vi.fn().mockResolvedValue({ + id: 'status', + ok: true, + result: { + runtimeId: 'runtime-new', + rendererGraphEpoch: 1, + graphStatus: 'ready', + authoritativeWindowId: 1, + liveTabCount: 0, + liveLeafCount: 0, + runtimeProtocolVersion: MIN_COMPATIBLE_RUNTIME_SERVER_VERSION + }, + _meta: { runtimeId: 'runtime-new' } + }) + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + call = call + close(): void {} + } + })) + const globals = installBrowserGlobals('Linux') + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + const pairingCode = encodePairingCode({ endpoint: 'ws://127.0.0.1:6768' }) + + await expect( + globals.window.api.runtimeEnvironments.verifyAndAddFromPairingCode({ + name: 'Tunnel server', + pairingCode + }) + ).resolves.toMatchObject({ ok: false, kind: 'host-unreachable' }) + expect(call).not.toHaveBeenCalled() + + await expect( + globals.window.api.runtimeEnvironments.verifyAndAddFromPairingCode({ + name: 'Tunnel server', + pairingCode, + allowLoopback: true + }) + ).resolves.toMatchObject({ + ok: true, + environment: { connectionDependency: 'ssh-tunnel' } + }) + expect(call).toHaveBeenCalledOnce() + expect( + JSON.parse(globals.storage.getItem('orca.web.runtimeEnvironment.v1') ?? '{}') + ).toMatchObject({ connectionDependency: 'ssh-tunnel' }) + }) + + it('returns a structured failure when the browser client cannot be constructed', async () => { + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + constructor() { + throw new Error('Invalid public key: expected 32 bytes, got 3') + } + } + })) + const globals = installBrowserGlobals('Linux') + writeStoredRuntimeEnvironment(globals.storage, 'web-server-a') + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + + await expect( + globals.window.api.runtimeEnvironments.verifyAndAddFromPairingCode({ + name: 'Broken server', + pairingCode: encodePairingCode() + }) + ).resolves.toMatchObject({ ok: false, kind: 'access-link-invalid' }) + await expect(globals.window.api.runtimeEnvironments.list()).resolves.toMatchObject([ + { id: 'web-server-a' } + ]) + }) + + it('classifies coded browser authorization failures without relying on copy', async () => { + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + call(): Promise> { + return Promise.reject( + Object.assign(new Error('Access grant rejected.'), { code: 'unauthorized' }) + ) + } + + close(): void {} + } + })) + const globals = installBrowserGlobals('Linux') + writeStoredRuntimeEnvironment(globals.storage, 'web-server-a') + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + + await expect( + globals.window.api.runtimeEnvironments.verifyAndAddFromPairingCode({ + name: 'Expired server', + pairingCode: encodePairingCode() + }) + ).resolves.toMatchObject({ + ok: false, + kind: 'access-link-invalid', + message: 'Access grant rejected.' + }) + }) }) describe('web browser-local port capability', () => { diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index d8e3ef432e2..2ae7a4d50c3 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -7,6 +7,8 @@ import type { NativeChatAppendedMessages } from '../../../preload/api-types' import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' +import { parseHostAccessLink } from '../../../shared/remote-pairing-address' +import { verifyRemotePairingRuntimeStatus } from '../../../shared/remote-pairing-verification' import type { AiVaultListArgs, AiVaultListResult } from '../../../shared/ai-vault-types' import type { AiVaultPrepareSessionResumeArgs, @@ -113,6 +115,7 @@ import { import { parseWebPairingInput } from './web-pairing' import { copyClipboardTextViaExecCommand } from './web-clipboard-copy-fallback' import { WebRuntimeClient } from './web-runtime-client' +import { isWebRuntimeUnauthorizedError } from './web-runtime-client-error' import { RuntimeRpcCallQueuePool } from '../../../shared/runtime-rpc-call-queue' import { assertClipboardTextWriteWithinLimitWithYield, @@ -135,6 +138,7 @@ import { } from '../../../shared/feature-interactions' import { normalizeContextualTourIds, type ContextualTourId } from '../../../shared/contextual-tours' import { translate } from '@/i18n/i18n' +import { translateHostAccessLinkError } from '@/lib/remote-pairing-copy' import { getDefaultCreateProjectParent } from '@/components/sidebar/create-project-defaults' import { parseRuntimeNativeChatReadSessionResult, @@ -1339,6 +1343,7 @@ function createRuntimeApi(): NonNullable['runtime']> { reclaimBrowserForDesktop: () => Promise.resolve({ reclaimed: false }), onTerminalFitOverrideChanged: () => noopUnsubscribe, onTerminalDriverChanged: () => noopUnsubscribe, + onNativeChatLaunchDraftResolved: () => noopUnsubscribe, onBrowserDriverChanged: () => noopUnsubscribe } } @@ -1361,6 +1366,106 @@ function createRuntimeEnvironmentsApi(): NonNullable['runtim saveStoredWebRuntimeEnvironment(activeEnvironment) return { environment: redactStoredWebRuntimeEnvironment(activeEnvironment) } }, + verifyAndAddFromPairingCode: async ({ name, pairingCode, allowLoopback }) => { + const parsed = parseHostAccessLink(pairingCode) + if (!parsed.ok) { + return { + ok: false, + kind: 'access-link-invalid', + message: translateHostAccessLinkError(parsed.kind) + } + } + if (parsed.value.endpointKind === 'loopback' && !allowLoopback) { + return { + ok: false, + kind: 'host-unreachable', + message: translate( + 'auto.web.webPreloadApi.loopbackPairingBlocked', + 'This access link points back to this device.' + ) + } + } + let client: WebRuntimeClient | null = null + let runtimeStatus: RuntimeStatus + try { + client = new WebRuntimeClient(parsed.value.pairing) + const response = (await client.call('status.get', undefined, { + timeoutMs: 15_000 + })) as RuntimeRpcResponse + if (!response.ok) { + return { + ok: false, + kind: 'connection-interrupted', + message: response.error.message + } + } + const statusVerification = verifyRemotePairingRuntimeStatus(response.result) + if (!statusVerification.ok) { + return statusVerification + } + runtimeStatus = statusVerification.runtimeStatus + } catch (error) { + if (error instanceof Error && error.message.startsWith('Invalid public key')) { + return { + ok: false, + kind: 'access-link-invalid', + message: translate( + 'auto.web.webPreloadApi.remotePairingInvalidDetails', + 'This access link contains invalid connection details.' + ) + } + } + if ( + isWebRuntimeUnauthorizedError(error) || + (error instanceof Error && error.message.startsWith('Unauthorized.')) + ) { + return { + ok: false, + kind: 'access-link-invalid', + message: error.message + } + } + return { + ok: false, + kind: 'host-unreachable', + message: translate( + 'auto.web.webPreloadApi.remotePairingUnreachable', + 'Cannot reach Orca at {{endpoint}}.', + { endpoint: parsed.value.displayEndpoint } + ) + } + } finally { + client?.close() + } + const usesSshTunnel = parsed.value.endpointKind === 'loopback' && allowLoopback === true + const nextEnvironment = createStoredWebRuntimeEnvironment({ + name, + offer: parsed.value.pairing, + previousEnvironment: activeEnvironment, + ...(usesSshTunnel ? { connectionDependency: 'ssh-tunnel' as const } : {}) + }) + // Why: a browser storage failure must leave the currently active host usable. + try { + saveStoredWebRuntimeEnvironment(nextEnvironment) + } catch { + return { + ok: false, + kind: 'environment-save-failed', + message: translate( + 'auto.web.webPreloadApi.remotePairingSaveFailed', + 'Orca verified the host but could not save it. Check browser storage and try again.' + ) + } + } + manuallyDisconnectedEnvironmentIds.clear() + closeActiveRuntimeClients() + activeEnvironment = nextEnvironment + return { + ok: true, + environment: redactStoredWebRuntimeEnvironment(nextEnvironment), + runtimeStatus + } + }, resolve: async ({ selector }) => redactStoredWebRuntimeEnvironment(resolveEnvironment(selector)), remove: async ({ selector }) => { diff --git a/src/renderer/src/web/web-runtime-client-error.ts b/src/renderer/src/web/web-runtime-client-error.ts new file mode 100644 index 00000000000..c09daa02bdc --- /dev/null +++ b/src/renderer/src/web/web-runtime-client-error.ts @@ -0,0 +1,25 @@ +const UNAUTHORIZED_MESSAGE = 'Unauthorized. Pair this web client again.' + +export class WebRuntimeClientError extends Error { + constructor( + message: string, + readonly code: 'unauthorized' + ) { + super(message) + this.name = 'WebRuntimeClientError' + } +} + +export function createWebRuntimeUnauthorizedError(): WebRuntimeClientError { + return new WebRuntimeClientError(UNAUTHORIZED_MESSAGE, 'unauthorized') +} + +export function isWebRuntimeUnauthorizedError( + error: unknown +): error is Error & { code: 'unauthorized' } { + return ( + error instanceof Error && + 'code' in error && + (error as { code?: unknown }).code === 'unauthorized' + ) +} diff --git a/src/renderer/src/web/web-runtime-client.ts b/src/renderer/src/web/web-runtime-client.ts index 9f61eefe37f..12507f6225f 100644 --- a/src/renderer/src/web/web-runtime-client.ts +++ b/src/renderer/src/web/web-runtime-client.ts @@ -15,6 +15,7 @@ import { publicKeyToBase64 } from './web-e2ee' import { SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' +import { createWebRuntimeUnauthorizedError } from './web-runtime-client-error' type WebRuntimeConnectionState = | 'disconnected' @@ -478,7 +479,7 @@ export class WebRuntimeClient { } else if (control.type === 'e2ee_error' || control.error?.code === 'unauthorized') { this.intentionallyClosed = true this.setState('auth-failed') - this.rejectAllPending('Unauthorized. Pair this web client again.') + this.rejectAllPending(createWebRuntimeUnauthorizedError()) this.notifySubscriptionsError('unauthorized', 'Unauthorized. Pair this web client again.') this.ws?.close() } @@ -530,7 +531,7 @@ export class WebRuntimeClient { if (isRuntimeFailureResponse(response) && response.error.code === 'unauthorized') { this.intentionallyClosed = true this.setState('auth-failed') - this.rejectAllPending('Unauthorized. Pair this web client again.') + this.rejectAllPending(createWebRuntimeUnauthorizedError()) this.notifySubscriptionsError('unauthorized', 'Unauthorized. Pair this web client again.') this.ws?.close() return @@ -584,7 +585,7 @@ export class WebRuntimeClient { return Promise.resolve() } if (this.state === 'auth-failed') { - return Promise.reject(new Error('Unauthorized. Pair this web client again.')) + return Promise.reject(createWebRuntimeUnauthorizedError()) } if (this.intentionallyClosed) { return Promise.reject(new Error('Remote Orca runtime connection closed.')) @@ -658,7 +659,7 @@ export class WebRuntimeClient { waiter.resolve() } } else if (next === 'auth-failed') { - this.rejectAllWaiters(new Error('Unauthorized. Pair this web client again.')) + this.rejectAllWaiters(createWebRuntimeUnauthorizedError()) } } @@ -667,8 +668,8 @@ export class WebRuntimeClient { return `web-rpc-${this.requestCounter}-${Date.now()}` } - private rejectAllPending(reason: string): void { - const error = new Error(reason) + private rejectAllPending(reason: string | Error): void { + const error = typeof reason === 'string' ? new Error(reason) : reason for (const [id, pending] of this.pending) { this.pending.delete(id) window.clearTimeout(pending.timeout) diff --git a/src/renderer/src/web/web-runtime-environment.ts b/src/renderer/src/web/web-runtime-environment.ts index 7aa48f5711d..4f97c416960 100644 --- a/src/renderer/src/web/web-runtime-environment.ts +++ b/src/renderer/src/web/web-runtime-environment.ts @@ -59,6 +59,7 @@ export function createStoredWebRuntimeEnvironment(args: { name: string offer: WebPairingOffer previousEnvironment?: StoredWebRuntimeEnvironment | null + connectionDependency?: 'ssh-tunnel' }): StoredWebRuntimeEnvironment { const id = `web-${createBrowserUuid()}` const now = Date.now() @@ -70,6 +71,7 @@ export function createStoredWebRuntimeEnvironment(args: { updatedAt: now, lastUsedAt: null, runtimeId: null, + ...(args.connectionDependency ? { connectionDependency: args.connectionDependency } : {}), ...(compatibleEnvironmentIds.length > 0 ? { compatibleEnvironmentIds } : {}), preferredEndpointId: `ws-${id}`, endpoints: [ diff --git a/src/shared/agent-feature-install-commands.test.ts b/src/shared/agent-feature-install-commands.test.ts index e242f9f5fd7..33843953560 100644 --- a/src/shared/agent-feature-install-commands.test.ts +++ b/src/shared/agent-feature-install-commands.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from 'vitest' import { + buildAgentFeatureSkillInstallArgs, buildAgentFeatureSkillInstallCommand, + ORCA_CLI_SKILL_INSTALL_COMMAND, + buildAgentFeatureSkillUpdateArgs, buildAgentFeatureSkillUpdateCommand, COMPUTER_USE_SKILL_UPDATE_COMMAND, EPHEMERAL_VMS_SKILL_UPDATE_COMMAND, @@ -12,6 +15,78 @@ import { } from './agent-feature-install-commands' describe('agent feature skill commands', () => { + it('builds a global install command by default', () => { + expect(buildAgentFeatureSkillInstallCommand(['orca-cli'])).toBe( + 'npx skills add https://github.com/stablyai/orca --skill orca-cli --global' + ) + }) + + it('drops --global when installing locally', () => { + expect(buildAgentFeatureSkillInstallCommand(['orca-cli'], { global: false })).toBe( + 'npx skills add https://github.com/stablyai/orca --skill orca-cli' + ) + }) + + it('repeats --skill per name for multi-skill installs', () => { + expect(buildAgentFeatureSkillInstallCommand(['orca-cli', 'orchestration'])).toBe( + 'npx skills add https://github.com/stablyai/orca --skill orca-cli --skill orchestration --global' + ) + expect(buildAgentFeatureSkillInstallArgs(['orca-cli', 'orchestration'])).toEqual([ + 'skills', + 'add', + 'https://github.com/stablyai/orca', + '--skill', + 'orca-cli', + '--skill', + 'orchestration', + '--global' + ]) + }) + + it('keeps the copyable Settings commands interactive by default', () => { + // Why: -y skips the agent picker. A human pasting from Settings should still + // get it; only an unattended spawn opts in. + expect(buildAgentFeatureSkillInstallCommand(['orca-cli'])).not.toContain('-y') + expect(buildAgentFeatureSkillUpdateCommand('orca-cli')).not.toContain('-y') + expect(ORCA_CLI_SKILL_INSTALL_COMMAND).not.toContain('-y') + expect(ORCA_CLI_SKILL_UPDATE_COMMAND).not.toContain('-y') + }) + + it('refuses to skip prompts without an install target', () => { + // Why: -y with no --agent is the one combination that makes `skills add` + // install into every agent it knows (~75). No caller may express it. + expect(() => buildAgentFeatureSkillInstallCommand(['orca-cli'], { yes: true })).toThrow( + 'An install target is required when skipping prompts.' + ) + }) + + it('refuses a target the skills CLI would drop', () => { + // Why: defence in depth behind the CLI's own check — the skills CLI silently + // drops a `-`-leading --agent value, which empties its target list and + // installs into every agent it knows. + expect(() => + buildAgentFeatureSkillInstallCommand(['orca-cli'], { yes: true, agents: ['-y'] }) + ).toThrow('"-y" is not a usable install target.') + expect(() => + buildAgentFeatureSkillInstallArgs(['orca-cli'], { yes: true, agents: ['universal', 'a b'] }) + ).toThrow('"a b" is not a usable install target.') + }) + + it('appends -y and the targets for an unattended run', () => { + expect( + buildAgentFeatureSkillInstallCommand(['orca-cli'], { yes: true, agents: ['universal'] }) + ).toBe( + 'npx skills add https://github.com/stablyai/orca --skill orca-cli --global --agent universal -y' + ) + expect(buildAgentFeatureSkillUpdateCommand(['orca-cli'], { global: false, yes: true })).toBe( + 'npx skills update orca-cli --project -y' + ) + expect( + buildAgentFeatureSkillInstallArgs(['orca-cli'], { yes: true, agents: ['universal'] }).at(-1) + ).toBe('-y') + expect(buildAgentFeatureSkillUpdateArgs(['orca-cli'], { yes: true }).at(-1)).toBe('-y') + }) + it('builds single-skill update commands', () => { expect(buildAgentFeatureSkillUpdateCommand('orchestration')).toBe( 'npx skills update orchestration --global' @@ -25,6 +100,22 @@ describe('agent feature skill commands', () => { expect(() => buildAgentFeatureSkillUpdateCommand(' ')).toThrow('A skill name is required.') }) + it('builds multi-skill update commands and selects project scope for --local', () => { + expect(buildAgentFeatureSkillUpdateCommand(['orca-cli', 'orchestration'])).toBe( + 'npx skills update orca-cli orchestration --global' + ) + expect(buildAgentFeatureSkillUpdateCommand(['orca-cli'], { global: false })).toBe( + 'npx skills update orca-cli --project' + ) + expect(buildAgentFeatureSkillUpdateArgs(['orca-cli'], { global: false })).toEqual([ + 'skills', + 'update', + 'orca-cli', + '--project' + ]) + expect(() => buildAgentFeatureSkillUpdateCommand([])).toThrow('A skill name is required.') + }) + it('exports single-skill update constants without changing install bundles', () => { expect(ORCA_CLI_SKILL_UPDATE_COMMAND).toBe('npx skills update orca-cli --global') expect(COMPUTER_USE_SKILL_UPDATE_COMMAND).toBe('npx skills update computer-use --global') diff --git a/src/shared/agent-feature-install-commands.ts b/src/shared/agent-feature-install-commands.ts index 60bdc6d6baf..14f3e6d84dc 100644 --- a/src/shared/agent-feature-install-commands.ts +++ b/src/shared/agent-feature-install-commands.ts @@ -1,3 +1,5 @@ +import { isSkillsCliAgentKeyShaped } from './skills-cli-agent-keys' + export const ORCA_SKILLS_REPOSITORY_URL = 'https://github.com/stablyai/orca' export const ORCA_CLI_SKILL_NAME = 'orca-cli' @@ -8,19 +10,84 @@ export const ORCA_LINEAR_SKILL_NAME = 'orca-linear' export const LINEAR_TICKETS_SKILL_NAME = 'linear-tickets' export const LINEAR_AGENT_SKILL_NAMES = [ORCA_LINEAR_SKILL_NAME, LINEAR_TICKETS_SKILL_NAME] as const -export function buildAgentFeatureSkillInstallCommand(skillNames: readonly string[]): string { +// Why: `yes` and `agents` default off so every Settings/onboarding string a human +// pastes keeps its interactive prompts and the CLI's own agent detection. Only an +// unattended spawn, which nothing can answer, opts in. +export type AgentFeatureSkillCommandOptions = { + global?: boolean + yes?: boolean + agents?: readonly string[] +} + +export function buildAgentFeatureSkillInstallArgs( + skillNames: readonly string[], + options: AgentFeatureSkillCommandOptions = {} +): string[] { if (skillNames.length === 0) { throw new Error('At least one skill name is required.') } - return `npx skills add ${ORCA_SKILLS_REPOSITORY_URL} --skill ${skillNames.join(' ')} --global` + const global = options.global ?? true + // Why: -y with no --agent is the one combination that makes `skills add` install + // into every agent it knows. Refuse it here so no caller can express it. + const agents = options.agents ?? [] + if (options.yes && agents.length === 0) { + throw new Error('An install target is required when skipping prompts.') + } + // Why: a value the skills CLI would drop leaves it with no target at all, which + // is the same all-agents install as passing no --agent. + const unusable = agents.find((agent) => !isSkillsCliAgentKeyShaped(agent)) + if (unusable !== undefined) { + throw new Error(`"${unusable}" is not a usable install target.`) + } + // Why: one flag per name remains compatible with both single-value and variadic parsers. + const skillArgs = skillNames.flatMap((name) => ['--skill', name]) + return [ + 'skills', + 'add', + ORCA_SKILLS_REPOSITORY_URL, + ...skillArgs, + ...(global ? ['--global'] : []), + // Why: an explicit --agent stops `skills add` calling its own detection, whose + // zero-detected branch installs into all ~75 known agents and litters a bare + // host with agent config directories it has no agent for. + ...agents.flatMap((agent) => ['--agent', agent]), + // Why: without -y `skills add` opens an interactive agent picker and blocks + // forever on any TTY, which is every ssh session. + ...(options.yes ? ['-y'] : []) + ] } -export function buildAgentFeatureSkillUpdateCommand(skillName: string): string { - const trimmedSkillName = skillName.trim() - if (!trimmedSkillName) { +export function buildAgentFeatureSkillInstallCommand( + skillNames: readonly string[], + options: AgentFeatureSkillCommandOptions = {} +): string { + return `npx ${buildAgentFeatureSkillInstallArgs(skillNames, options).join(' ')}` +} + +export function buildAgentFeatureSkillUpdateArgs( + skillNames: string | readonly string[], + options: AgentFeatureSkillCommandOptions = {} +): string[] { + const rawNames = typeof skillNames === 'string' ? [skillNames] : skillNames + const names = rawNames.map((name) => name.trim()).filter((name) => name.length > 0) + if (names.length === 0) { throw new Error('A skill name is required.') } - return `npx skills update ${trimmedSkillName} --global` + const global = options.global ?? true + return [ + 'skills', + 'update', + ...names, + global ? '--global' : '--project', + ...(options.yes ? ['-y'] : []) + ] +} + +export function buildAgentFeatureSkillUpdateCommand( + skillNames: string | readonly string[], + options: AgentFeatureSkillCommandOptions = {} +): string { + return `npx ${buildAgentFeatureSkillUpdateArgs(skillNames, options).join(' ')}` } export const ORCA_CLI_SKILL_INSTALL_COMMAND = buildAgentFeatureSkillInstallCommand([ diff --git a/src/shared/agent-tui-input-clear.test.ts b/src/shared/agent-tui-input-clear.test.ts new file mode 100644 index 00000000000..67715ce2df2 --- /dev/null +++ b/src/shared/agent-tui-input-clear.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest' +import { + AGENT_TUI_CLEAR_INPUT_FORWARD, + AGENT_TUI_CLEAR_INPUT_LINE, + AGENT_TUI_CLEAR_INPUT_MAX, + AGENT_TUI_CLEAR_LINE_SLACK, + AGENT_TUI_CLEAR_MAX_LINES, + buildAgentTuiClearInput, + buildAgentTuiClearInputForText, + countAgentTuiInputLines +} from './agent-tui-input-clear' + +const countCtrlU = (bytes: string): number => + bytes.split('').filter((char) => char === AGENT_TUI_CLEAR_INPUT_LINE).length +const countCtrlK = (bytes: string): number => + bytes.split('').filter((char) => char === AGENT_TUI_CLEAR_INPUT_FORWARD).length + +describe('buildAgentTuiClearInput', () => { + // The measured law: N kills + (N-1) joins. A constant here silently under-clears + // (5 clears 3 lines but leaves residue at 4), which is what glues onto the next + // message — so pin every small N, not just one. + it.each([ + [1, 1], + [2, 3], + [3, 5], + [4, 7], + [10, 19] + ])('clears %i logical lines with %i Ctrl+U', (lines, expected) => { + const clearInput = buildAgentTuiClearInput(lines) + expect(countCtrlU(clearInput)).toBe(expected) + expect(countCtrlK(clearInput)).toBe(expected) + }) + + it('clears before the cursor before clearing the suffix after it', () => { + expect(buildAgentTuiClearInput(4)).toBe( + AGENT_TUI_CLEAR_INPUT_LINE.repeat(7) + AGENT_TUI_CLEAR_INPUT_FORWARD.repeat(7) + ) + }) + + it('still clears one line for a zero or negative count', () => { + expect(countCtrlU(buildAgentTuiClearInput(0))).toBe(1) + expect(countCtrlU(buildAgentTuiClearInput(-5))).toBe(1) + }) + + it('caps the burst so a pathological draft cannot emit an unbounded write', () => { + expect(countCtrlU(buildAgentTuiClearInput(10_000))).toBe(2 * AGENT_TUI_CLEAR_MAX_LINES - 1) + expect(AGENT_TUI_CLEAR_INPUT_MAX).toBe(buildAgentTuiClearInput(AGENT_TUI_CLEAR_MAX_LINES)) + }) +}) + +describe('countAgentTuiInputLines', () => { + it.each([ + ['one line', 1], + ['a\nb', 2], + ['a\r\nb\r\nc', 3], + ['a\rb', 2], + ['trailing\n', 2] + ])('counts %j as %i logical lines', (text, expected) => { + expect(countAgentTuiInputLines(text)).toBe(expected) + }) + + it('ignores visual wrapping — only logical newlines cost a Ctrl+U', () => { + expect(countAgentTuiInputLines('x'.repeat(5_000))).toBe(1) + }) +}) + +describe('buildAgentTuiClearInputForText', () => { + it('sizes the burst from the text plus slack for TUI-side edits', () => { + // The injected text is a LOWER bound: the user can type into the TUI line too. + expect(countCtrlU(buildAgentTuiClearInputForText('a\nb'))).toBe( + 2 * (2 + AGENT_TUI_CLEAR_LINE_SLACK) - 1 + ) + }) + + it('clears strictly more than the draft needs, never less', () => { + const draft = 'Linked Linear issue: ABC-123\nhttps://linear.app/x/issue/ABC-123\n' + expect(countCtrlU(buildAgentTuiClearInputForText(draft))).toBeGreaterThan( + 2 * countAgentTuiInputLines(draft) - 1 + ) + }) + + it('a long wrapped single line does not inflate the burst', () => { + expect(buildAgentTuiClearInputForText('y'.repeat(5_000))).toBe( + buildAgentTuiClearInputForText('y') + ) + }) +}) diff --git a/src/shared/agent-tui-input-clear.ts b/src/shared/agent-tui-input-clear.ts new file mode 100644 index 00000000000..6d3bfc88e09 --- /dev/null +++ b/src/shared/agent-tui-input-clear.ts @@ -0,0 +1,45 @@ +// Clearing an agent TUI's input buffer when it may hold MORE THAN ONE line. +// +// Shared by desktop native chat and mobile: the law below is a property of the +// agent TUIs (Claude Code, codex), not of either client. + +/** Ctrl+U — clears toward the start of the input buffer. */ +export const AGENT_TUI_CLEAR_INPUT_LINE = '\x15' + +/** Ctrl+K — clears toward the end of the input buffer. */ +export const AGENT_TUI_CLEAR_INPUT_FORWARD = '\x0b' + +/** Clear up to `lineCount` logical lines from any cursor position. */ +export function buildAgentTuiClearInput(lineCount: number): string { + const lines = Math.max(1, Math.min(AGENT_TUI_CLEAR_MAX_LINES, Math.floor(lineCount))) + const repetitions = 2 * lines - 1 + return ( + AGENT_TUI_CLEAR_INPUT_LINE.repeat(repetitions) + + AGENT_TUI_CLEAR_INPUT_FORWARD.repeat(repetitions) + ) +} + +/** + * Headroom over the line count Orca knows about. The text Orca injected is a + * LOWER BOUND on what the buffer holds — the user can also type straight into + * the TUI line — so the count is deliberately biased upward. Overshoot is free: + * 41 Ctrl+U against a 1-line buffer measured perfectly clean on both agents, + * and an undershoot is what leaves residue to glue onto the next message. + */ +export const AGENT_TUI_CLEAR_LINE_SLACK = 8 + +/** Bounds the burst so a pathological draft cannot emit an unbounded write. */ +export const AGENT_TUI_CLEAR_MAX_LINES = 40 + +/** Widest burst we ever send — the remedy when a clear is not observed to land. */ +export const AGENT_TUI_CLEAR_INPUT_MAX = buildAgentTuiClearInput(AGENT_TUI_CLEAR_MAX_LINES) + +/** Logical lines in `text`. Visual wrapping is irrelevant to the clear cost. */ +export function countAgentTuiInputLines(text: string): number { + return text.split(/\r\n|\r|\n/).length +} + +/** Clear bytes for a buffer believed to hold `text`, with slack for TUI-side edits. */ +export function buildAgentTuiClearInputForText(text: string): string { + return buildAgentTuiClearInput(countAgentTuiInputLines(text) + AGENT_TUI_CLEAR_LINE_SLACK) +} diff --git a/src/shared/constants.test.ts b/src/shared/constants.test.ts index 88ea5c1282f..f6bd48c8fde 100644 --- a/src/shared/constants.test.ts +++ b/src/shared/constants.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { + DEFAULT_TERMINAL_INACTIVE_PANE_OPACITY, getDefaultNotificationSettings, getDefaultPrimarySelectionMiddleClickPaste, getDefaultTerminalRightClickToPaste, @@ -45,6 +46,12 @@ describe('getDefaultSettings', () => { expect(getDefaultSettings('/tmp').terminalUseSeparateLightTheme).toBe(true) }) + it('keeps inactive terminal panes readable by default', () => { + expect(getDefaultSettings('/tmp').terminalInactivePaneOpacity).toBe( + DEFAULT_TERMINAL_INACTIVE_PANE_OPACITY + ) + }) + it('asks before closing terminals with running processes by default', () => { expect(getDefaultSettings('/tmp').skipCloseTerminalWithRunningProcessConfirm).toBe(false) }) diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 541444f6d23..536663e1d6a 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -51,6 +51,7 @@ export const DEFAULT_APP_FONT_FAMILY = 'Geist' export const DEFAULT_SHOW_SLEEPING_WORKSPACES = true export const DEFAULT_HIDE_SLEEPING_WORKSPACES = false export const DEFAULT_AGENT_ACTIVITY_DISPLAY_MODE: AgentActivityDisplayMode = 'compact' +export const DEFAULT_TERMINAL_INACTIVE_PANE_OPACITY = 0.9 export function normalizeAgentActivityDisplayMode(value: unknown): AgentActivityDisplayMode { return value === 'full' || value === 'compact' ? value : DEFAULT_AGENT_ACTIVITY_DISPLAY_MODE @@ -223,7 +224,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings { terminalThemeLight: 'Builtin Tango Light', terminalCustomThemes: [], terminalDividerColorLight: '#d4d4d8', - terminalInactivePaneOpacity: 0.8, + terminalInactivePaneOpacity: DEFAULT_TERMINAL_INACTIVE_PANE_OPACITY, terminalActivePaneOpacity: 1, terminalPaneOpacityTransitionMs: 140, terminalDividerThicknessPx: 3, diff --git a/src/shared/filesystem-rename-collision.ts b/src/shared/filesystem-rename-collision.ts index e2674d0e618..6d79cf6f0d7 100644 --- a/src/shared/filesystem-rename-collision.ts +++ b/src/shared/filesystem-rename-collision.ts @@ -1,4 +1,4 @@ -import { lstat } from 'node:fs/promises' +import { lstat, realpath } from 'node:fs/promises' import type { Stats } from 'node:fs' import { basename, dirname } from 'node:path' @@ -18,14 +18,33 @@ function hasSameFilesystemIdentity(oldStat: Stats, newStat: Stats): boolean { return oldStat.dev === newStat.dev && oldStat.ino === newStat.ino } -function isCaseOnlySameParentRename(oldPath: string, newPath: string): boolean { +async function isSameDirectoryEntryRename( + oldPath: string, + newPath: string, + oldStat: Stats, + newStat: Stats +): Promise { const oldBasename = basename(oldPath) const newBasename = basename(newPath) - return ( - dirname(oldPath) === dirname(newPath) && - oldBasename !== newBasename && - caseFoldFileExplorerBasename(oldBasename) === caseFoldFileExplorerBasename(newBasename) - ) + if (!hasSameFilesystemIdentity(oldStat, newStat) || dirname(oldPath) !== dirname(newPath)) { + return false + } + if (oldPath === newPath) { + return true + } + try { + const [oldRealPath, newRealPath] = await Promise.all([realpath(oldPath), realpath(newPath)]) + return oldRealPath === newRealPath + } catch (error) { + // Preserve case-only renames for dangling symlinks, which realpath cannot resolve. + if (!isENOENT(error)) { + throw error + } + return ( + oldBasename !== newBasename && + caseFoldFileExplorerBasename(oldBasename) === caseFoldFileExplorerBasename(newBasename) + ) + } } export async function assertNoClobberRenameDestinationAvailable( @@ -43,7 +62,7 @@ export async function assertNoClobberRenameDestinationAvailable( } const oldStat = await lstat(oldPath) - if (hasSameFilesystemIdentity(oldStat, newStat) && isCaseOnlySameParentRename(oldPath, newPath)) { + if (await isSameDirectoryEntryRename(oldPath, newPath, oldStat, newStat)) { return } diff --git a/src/shared/mobile-relay-mint-failure.test.ts b/src/shared/mobile-relay-mint-failure.test.ts new file mode 100644 index 00000000000..d0d8a768a8b --- /dev/null +++ b/src/shared/mobile-relay-mint-failure.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest' +import { mobileRelayMintFailureFromUnknown } from './mobile-relay-mint-failure' + +describe('mobileRelayMintFailureFromUnknown', () => { + it('keeps known machine-readable Relay codes for diagnostics', () => { + expect( + mobileRelayMintFailureFromUnknown({ + stage: 'create_pairing_relay', + error: new Error('relay_control_not_active'), + fallbackCode: 'relay_mint_failed', + fallbackMessage: 'Relay pairing invite request failed' + }) + ).toEqual({ + code: 'relay_control_not_active', + stage: 'create_pairing_relay', + message: 'Relay pairing invite request failed' + }) + }) + + it('keeps known codes carried on structured error objects', () => { + expect( + mobileRelayMintFailureFromUnknown({ + stage: 'create_pairing_relay', + error: { code: 'relay_control_not_active' }, + fallbackCode: 'relay_mint_failed', + fallbackMessage: 'Relay pairing invite request failed' + }) + ).toEqual({ + code: 'relay_control_not_active', + stage: 'create_pairing_relay', + message: 'Relay pairing invite request failed' + }) + }) + + it('falls back for error values that are neither objects nor Errors', () => { + expect( + mobileRelayMintFailureFromUnknown({ + stage: 'create_pairing_relay', + error: 'relay_control_not_active', + fallbackCode: 'relay_mint_failed', + fallbackMessage: 'Relay pairing invite request failed' + }) + ).toEqual({ + code: 'relay_mint_failed', + stage: 'create_pairing_relay', + message: 'Relay pairing invite request failed' + }) + }) + + it('redacts free-form error messages', () => { + expect( + mobileRelayMintFailureFromUnknown({ + stage: 'create_pairing_relay', + error: new Error('request failed for https://relay.example/token/secret'), + fallbackCode: 'relay_mint_failed', + fallbackMessage: 'Relay pairing invite request failed' + }) + ).toEqual({ + code: 'relay_mint_failed', + stage: 'create_pairing_relay', + message: 'Relay pairing invite request failed' + }) + }) +}) diff --git a/src/shared/mobile-relay-mint-failure.ts b/src/shared/mobile-relay-mint-failure.ts new file mode 100644 index 00000000000..431990a79bf --- /dev/null +++ b/src/shared/mobile-relay-mint-failure.ts @@ -0,0 +1,41 @@ +/** + * Structured failure when an Anywhere (Orca Relay) pairing mint cannot attach Relay. + * Returned instead of silently degrading to a LAN-only QR under the Relay label. + */ +export type MobileRelayMintFailureStage = + | 'provider_missing' + | 'e2ee_missing' + | 'binding_failed' + | 'create_pairing_relay' + +export type MobileRelayMintFailure = { + code: string + stage: MobileRelayMintFailureStage + message: string +} + +export function mobileRelayMintFailureFromUnknown(args: { + stage: MobileRelayMintFailureStage + error: unknown + fallbackCode: string + fallbackMessage: string +}): MobileRelayMintFailure { + const candidateCode = + typeof args.error === 'object' && + args.error !== null && + 'code' in args.error && + typeof args.error.code === 'string' + ? args.error.code + : args.error instanceof Error + ? args.error.message + : null + const code = + candidateCode != null && /^relay_[a-z0-9_]{1,74}$/.test(candidateCode) + ? candidateCode + : args.fallbackCode + return { + code, + stage: args.stage, + message: args.fallbackMessage + } +} diff --git a/src/shared/network/server-share-address.test.ts b/src/shared/network/server-share-address.test.ts index 933ef09ff75..e7be14d14d3 100644 --- a/src/shared/network/server-share-address.test.ts +++ b/src/shared/network/server-share-address.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from 'vitest' +import { PAIRING_ENDPOINT_MAX_CHARACTERS } from '../mobile-pairing-protocol-limits' import { parseServerShareAddress } from './server-share-address' describe('parseServerShareAddress', () => { @@ -16,19 +17,41 @@ describe('parseServerShareAddress', () => { expect(parseServerShareAddress('my-host:443').ok).toBe(true) }) + it('accepts IPv6 literals with an optional port', () => { + expect(parseServerShareAddress('fd7a:115c:a1e0::1').ok).toBe(true) + expect(parseServerShareAddress('[fd7a:115c:a1e0::1]:6768').ok).toBe(true) + }) + it('accepts ws:// and wss:// URLs', () => { expect(parseServerShareAddress('wss://my-host/path').ok).toBe(true) expect(parseServerShareAddress('ws://192.168.1.50:6768').ok).toBe(true) }) + it('rejects WebSocket URLs with fragments', () => { + expect(parseServerShareAddress('wss://my-host/path#fragment').ok).toBe(false) + }) + it('trims surrounding whitespace', () => { expect(parseServerShareAddress(' my-host:8080 ')).toEqual({ ok: true, value: 'my-host:8080' }) }) it('rejects empty, whitespace-containing, and malformed input', () => { - for (const bad of ['', ' ', 'has space', 'http://my-host', 'wss://', ':6768']) { + for (const bad of [ + '', + ' ', + 'has space', + 'http://my-host', + 'wss://', + ':6768', + '0.0.0.0', + '::', + 'my-host:0', + '999.999.999.999', + 'wss://user:password@my-host' + ]) { expect(parseServerShareAddress(bad).ok).toBe(false) } + expect(parseServerShareAddress('a'.repeat(PAIRING_ENDPOINT_MAX_CHARACTERS + 1)).ok).toBe(false) }) it('rejects an out-of-range port', () => { diff --git a/src/shared/network/server-share-address.ts b/src/shared/network/server-share-address.ts index 1afe5f728b7..35b84b9fb1d 100644 --- a/src/shared/network/server-share-address.ts +++ b/src/shared/network/server-share-address.ts @@ -1,42 +1,12 @@ -// Why: shared validator for the "Share this Orca server" custom address. The -// field accepts a bare host, host:port, or a full ws(s):// URL — looser than -// the mobile pairing grammar because the target is a transport endpoint, not -// just an IP. Kept pure so the renderer and any future caller agree. +import { parseManualNetworkAddress } from './manual-address' export type ParseServerShareAddressResult = { ok: true; value: string } | { ok: false } -const HOST_LABEL = '[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?' -const HOSTNAME = `${HOST_LABEL}(?:\\.${HOST_LABEL})*` -const IPV4 = '(?:\\d{1,3}\\.){3}\\d{1,3}' -const HOST = `(?:${HOSTNAME}|${IPV4})` -const PORT = '[0-9]{1,5}' - -const HOST_OR_HOST_PORT = new RegExp(`^${HOST}(?::${PORT})?$`) - export function parseServerShareAddress(input: string): ParseServerShareAddressResult { const trimmed = input.trim() - if (trimmed === '' || /\s/.test(trimmed)) { + if (/^https?:\/\//i.test(trimmed)) { return { ok: false } } - - // Full ws(s):// URL — defer to the URL parser, which validates host/port/path. - if (/^wss?:\/\//i.test(trimmed)) { - try { - const url = new URL(trimmed) - return url.hostname !== '' ? { ok: true, value: trimmed } : { ok: false } - } catch { - return { ok: false } - } - } - - // Bare host or host:port. Reject an out-of-range port early. - const match = trimmed.match(HOST_OR_HOST_PORT) - if (!match) { - return { ok: false } - } - const portPart = trimmed.includes(':') ? trimmed.slice(trimmed.lastIndexOf(':') + 1) : null - if (portPart !== null && Number(portPart) > 65535) { - return { ok: false } - } - return { ok: true, value: trimmed } + const result = parseManualNetworkAddress(trimmed) + return result.ok ? { ok: true, value: result.address } : { ok: false } } diff --git a/src/shared/orchestration-run-pagination.ts b/src/shared/orchestration-run-pagination.ts new file mode 100644 index 00000000000..6035a9e19a5 --- /dev/null +++ b/src/shared/orchestration-run-pagination.ts @@ -0,0 +1 @@ +export const ORCHESTRATION_RUN_PAGE_LIMIT = 100 diff --git a/src/shared/pairing.test.ts b/src/shared/pairing.test.ts index ff9eaed0c81..1d1879587be 100644 --- a/src/shared/pairing.test.ts +++ b/src/shared/pairing.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { encodePairingOffer, decodePairingOffer, @@ -22,6 +22,19 @@ describe('pairing offer', () => { expect(decoded).toEqual(offer) }) + it('decodes in browser runtimes without Buffer', () => { + const url = encodePairingOffer(offer) + const nodeBuffer = Buffer + let decoded: PairingOffer | null = null + try { + vi.stubGlobal('Buffer', undefined) + decoded = decodePairingOffer(url) + } finally { + vi.stubGlobal('Buffer', nodeBuffer) + } + expect(decoded).toEqual(offer) + }) + it('preserves optional device scope metadata', () => { const scopedOffer: PairingOffer = { ...offer, scope: 'mobile' } expect(decodePairingOffer(encodePairingOffer(scopedOffer))).toEqual(scopedOffer) diff --git a/src/shared/pairing.ts b/src/shared/pairing.ts index 00a3e7e0fdd..3c8335162a7 100644 --- a/src/shared/pairing.ts +++ b/src/shared/pairing.ts @@ -89,6 +89,11 @@ function decodePairingBase64(base64url: string): PairingOffer { throw new Error('Invalid pairing code') } const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/') - const json = Buffer.from(base64, 'base64').toString('utf-8') + const json = + typeof Buffer === 'undefined' + ? new TextDecoder().decode( + Uint8Array.from(atob(base64), (character) => character.charCodeAt(0)) + ) + : Buffer.from(base64, 'base64').toString('utf-8') return PairingOfferSchema.parse(JSON.parse(json)) } diff --git a/src/shared/remote-pairing-address.test.ts b/src/shared/remote-pairing-address.test.ts new file mode 100644 index 00000000000..0b38be5a985 --- /dev/null +++ b/src/shared/remote-pairing-address.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest' +import { encodePairingOffer, PAIRING_OFFER_VERSION } from './pairing' +import { classifyRemotePairingHostname, parseHostAccessLink } from './remote-pairing-address' + +function accessLink(endpoint: string): string { + return encodePairingOffer({ + v: PAIRING_OFFER_VERSION, + endpoint, + deviceToken: 'token', + publicKeyB64: 'key', + scope: 'runtime' + }) +} + +describe('remote pairing address', () => { + it.each([ + ['127.0.0.1', 'loopback'], + ['localhost', 'loopback'], + ['localhost.', 'loopback'], + ['api.localhost', 'loopback'], + ['api.localhost.', 'loopback'], + ['localhost.localdomain', 'loopback'], + ['localhost6', 'loopback'], + ['ip6-localhost', 'loopback'], + ['::1', 'loopback'], + ['::ffff:7f00:1', 'loopback'], + ['100.76.32.125', 'tailscale'], + ['::ffff:644c:207d', 'tailscale'], + ['192.168.1.20', 'lan'], + ['10.0.0.8', 'lan'], + ['fd7a:115c:a1e0::1', 'lan'], + ['fe80::1', 'lan'], + ['orca.example.com', 'public'], + ['devbox', 'custom'] + ] as const)('classifies %s as %s', (hostname, expected) => { + expect(classifyRemotePairingHostname(hostname)).toBe(expected) + }) + + it('extracts a sanitized display endpoint without credentials', () => { + expect(parseHostAccessLink(accessLink('wss://orca.example.com/runtime'))).toEqual({ + ok: true, + value: { + pairing: expect.objectContaining({ endpoint: 'wss://orca.example.com/runtime' }), + displayEndpoint: 'orca.example.com', + endpointKind: 'public' + } + }) + }) + + it('keeps IPv6 brackets in the display endpoint', () => { + const result = parseHostAccessLink(accessLink('ws://[fd7a:115c:a1e0::1]:6768')) + expect(result.ok && result.value.displayEndpoint).toBe('[fd7a:115c:a1e0::1]:6768') + }) + + it('rejects invalid and unsupported endpoints', () => { + expect(parseHostAccessLink('not-a-link')).toMatchObject({ + ok: false, + kind: 'invalid-input' + }) + expect(parseHostAccessLink(accessLink('https://orca.example.com'))).toMatchObject({ + ok: false, + kind: 'unsupported-destination' + }) + expect(parseHostAccessLink(accessLink('wss://orca.example.com/#fragment'))).toMatchObject({ + ok: false, + kind: 'unsupported-destination' + }) + expect(parseHostAccessLink(accessLink('ws://[::ffff:0.0.0.0]:6768'))).toMatchObject({ + ok: false, + kind: 'non-connectable-destination' + }) + expect(parseHostAccessLink(accessLink('wss://orca.example.com:0'))).toMatchObject({ + ok: false, + kind: 'non-connectable-destination' + }) + }) + + it('blocks absolute localhost names used in access links', () => { + expect(parseHostAccessLink(accessLink('ws://localhost.:6768'))).toMatchObject({ + ok: true, + value: { endpointKind: 'loopback' } + }) + expect(parseHostAccessLink(accessLink('ws://api.localhost.:6768'))).toMatchObject({ + ok: true, + value: { endpointKind: 'loopback' } + }) + }) + + it('rejects mobile-only access grants', () => { + const link = encodePairingOffer({ + v: PAIRING_OFFER_VERSION, + endpoint: 'wss://orca.example.com', + deviceToken: 'token', + publicKeyB64: 'key', + scope: 'mobile' + }) + expect(parseHostAccessLink(link)).toMatchObject({ ok: false, kind: 'mobile-only' }) + }) +}) diff --git a/src/shared/remote-pairing-address.ts b/src/shared/remote-pairing-address.ts new file mode 100644 index 00000000000..50b3241272d --- /dev/null +++ b/src/shared/remote-pairing-address.ts @@ -0,0 +1,147 @@ +import { parsePairingCode, type PairingOffer } from './pairing' +import { isTailnetIPv4Address } from './tailnet-address' + +export type RemotePairingEndpointKind = 'loopback' | 'tailscale' | 'lan' | 'public' | 'custom' + +export type ParsedHostAccessLink = { + pairing: PairingOffer + displayEndpoint: string + endpointKind: RemotePairingEndpointKind +} + +export type HostAccessLinkErrorKind = + | 'invalid-input' + | 'mobile-only' + | 'invalid-destination' + | 'unsupported-destination' + | 'non-connectable-destination' + +export type ParseHostAccessLinkResult = + | { ok: true; value: ParsedHostAccessLink } + | { ok: false; kind: HostAccessLinkErrorKind; message: string } + +const LOOPBACK_HOSTS = new Set([ + 'localhost', + 'localhost.localdomain', + 'localhost6', + 'localhost6.localdomain6', + 'ip6-localhost', + 'ip6-loopback', + '127.0.0.1', + '::1' +]) + +function isPrivateIPv4Address(hostname: string): boolean { + const octets = hostname.split('.').map(Number) + if (octets.length !== 4 || octets.some((octet) => !Number.isInteger(octet))) { + return false + } + return ( + octets[0] === 10 || + (octets[0] === 172 && octets[1]! >= 16 && octets[1]! <= 31) || + (octets[0] === 192 && octets[1] === 168) + ) +} + +function isPrivateIPv6Address(hostname: string): boolean { + const firstHextet = Number.parseInt(hostname.split(':')[0] ?? '', 16) + return ( + Number.isInteger(firstHextet) && + ((firstHextet & 0xfe00) === 0xfc00 || (firstHextet & 0xffc0) === 0xfe80) + ) +} + +function getEmbeddedIPv4Address(hostname: string): string | null { + const match = hostname.match(/^::(?:ffff:)?([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i) + if (!match) { + return null + } + const high = Number.parseInt(match[1]!, 16) + const low = Number.parseInt(match[2]!, 16) + return `${high >> 8}.${high & 0xff}.${low >> 8}.${low & 0xff}` +} + +export function classifyRemotePairingHostname(hostname: string): RemotePairingEndpointKind { + const normalized = hostname + .toLowerCase() + .replace(/^\[|\]$/g, '') + .replace(/\.$/, '') + const embeddedIPv4 = getEmbeddedIPv4Address(normalized) + if (embeddedIPv4) { + return classifyRemotePairingHostname(embeddedIPv4) + } + if ( + LOOPBACK_HOSTS.has(normalized) || + normalized.endsWith('.localhost') || + normalized.startsWith('127.') + ) { + return 'loopback' + } + if (isTailnetIPv4Address(normalized)) { + return 'tailscale' + } + if (isPrivateIPv4Address(normalized) || isPrivateIPv6Address(normalized)) { + return 'lan' + } + return normalized.includes('.') || normalized.includes(':') ? 'public' : 'custom' +} + +export function parseHostAccessLink(input: string): ParseHostAccessLinkResult { + const pairing = parsePairingCode(input) + if (!pairing) { + return { + ok: false, + kind: 'invalid-input', + message: 'Enter an Orca access link or bare pairing code.' + } + } + if (pairing.scope === 'mobile') { + return { + ok: false, + kind: 'mobile-only', + message: 'This link grants mobile-only access. Generate a link for another Orca client.' + } + } + let endpoint: URL + try { + endpoint = new URL(pairing.endpoint) + } catch { + return { + ok: false, + kind: 'invalid-destination', + message: 'This access link contains an invalid destination.' + } + } + if ( + (endpoint.protocol !== 'ws:' && endpoint.protocol !== 'wss:') || + !endpoint.hostname || + endpoint.hash !== '' + ) { + return { + ok: false, + kind: 'unsupported-destination', + message: 'This access link contains an unsupported destination.' + } + } + const normalizedHostname = endpoint.hostname.toLowerCase().replace(/^\[|\]$/g, '') + if ( + normalizedHostname === '0.0.0.0' || + normalizedHostname === '::' || + getEmbeddedIPv4Address(normalizedHostname) === '0.0.0.0' || + endpoint.port === '0' + ) { + return { + ok: false, + kind: 'non-connectable-destination', + message: 'This access link contains a non-connectable destination.' + } + } + return { + ok: true, + value: { + pairing, + displayEndpoint: endpoint.host, + endpointKind: classifyRemotePairingHostname(endpoint.hostname) + } + } +} diff --git a/src/shared/remote-pairing-verification.test.ts b/src/shared/remote-pairing-verification.test.ts new file mode 100644 index 00000000000..8fae1dfb82d --- /dev/null +++ b/src/shared/remote-pairing-verification.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest' +import { MIN_COMPATIBLE_RUNTIME_SERVER_VERSION, RUNTIME_PROTOCOL_VERSION } from './protocol-version' +import { verifyRemotePairingRuntimeStatus } from './remote-pairing-verification' + +function runtimeStatus(overrides: Record = {}): Record { + return { + runtimeId: 'runtime-a', + rendererGraphEpoch: 1, + graphStatus: 'ready', + authoritativeWindowId: 1, + liveTabCount: 0, + liveLeafCount: 0, + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + ...overrides + } +} + +describe('verifyRemotePairingRuntimeStatus', () => { + it('rejects malformed status responses', () => { + expect(verifyRemotePairingRuntimeStatus(null)).toMatchObject({ + ok: false, + kind: 'connection-interrupted' + }) + expect( + verifyRemotePairingRuntimeStatus( + runtimeStatus({ + runtimeProtocolVersion: Number.NaN + }) + ) + ).toMatchObject({ ok: false, kind: 'connection-interrupted' }) + expect( + verifyRemotePairingRuntimeStatus({ runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION }) + ).toMatchObject({ ok: false, kind: 'connection-interrupted' }) + expect( + verifyRemotePairingRuntimeStatus(runtimeStatus({ capabilities: 'runtime.status.compat.v1' })) + ).toMatchObject({ ok: false, kind: 'connection-interrupted' }) + expect(verifyRemotePairingRuntimeStatus(runtimeStatus({ deviceScope: 'admin' }))).toMatchObject( + { + ok: false, + kind: 'connection-interrupted' + } + ) + }) + + it('rejects mobile-only access grants', () => { + expect( + verifyRemotePairingRuntimeStatus( + runtimeStatus({ + deviceScope: 'mobile' + }) + ) + ).toMatchObject({ ok: false, kind: 'access-link-invalid' }) + }) + + it('rejects incompatible hosts', () => { + expect( + verifyRemotePairingRuntimeStatus( + runtimeStatus({ + runtimeProtocolVersion: MIN_COMPATIBLE_RUNTIME_SERVER_VERSION - 1 + }) + ) + ).toMatchObject({ ok: false, kind: 'protocol-incompatible' }) + }) + + it('accepts a compatible runtime status', () => { + expect(verifyRemotePairingRuntimeStatus(runtimeStatus())).toMatchObject({ ok: true }) + }) +}) diff --git a/src/shared/remote-pairing-verification.ts b/src/shared/remote-pairing-verification.ts new file mode 100644 index 00000000000..02d58b97bed --- /dev/null +++ b/src/shared/remote-pairing-verification.ts @@ -0,0 +1,114 @@ +import { evaluateRuntimeCompat } from './protocol-compat' +import { MIN_COMPATIBLE_RUNTIME_SERVER_VERSION, RUNTIME_PROTOCOL_VERSION } from './protocol-version' +import type { PublicKnownRuntimeEnvironment } from './runtime-environments' +import type { RuntimeStatus } from './runtime-types' + +export type RemotePairingFailureKind = + | 'host-unreachable' + | 'host-identity-mismatch' + | 'access-link-invalid' + | 'protocol-incompatible' + | 'connection-interrupted' + | 'environment-save-failed' + +export type RemotePairingFailure = { + ok: false + kind: RemotePairingFailureKind + message: string +} + +export type VerifyAndAddRuntimeEnvironmentResult = + | { + ok: true + environment: PublicKnownRuntimeEnvironment + runtimeStatus: RuntimeStatus + } + | RemotePairingFailure + +const RUNTIME_GRAPH_STATUSES = new Set(['ready', 'reloading', 'unavailable']) + +function isNonNegativeSafeInteger(value: unknown): value is number { + return Number.isSafeInteger(value) && Number(value) >= 0 +} + +function hasValidRuntimeStatusShape(status: Record): boolean { + return ( + typeof status.runtimeId === 'string' && + status.runtimeId.length > 0 && + isNonNegativeSafeInteger(status.rendererGraphEpoch) && + typeof status.graphStatus === 'string' && + RUNTIME_GRAPH_STATUSES.has(status.graphStatus) && + (status.authoritativeWindowId === null || + isNonNegativeSafeInteger(status.authoritativeWindowId)) && + isNonNegativeSafeInteger(status.liveTabCount) && + isNonNegativeSafeInteger(status.liveLeafCount) && + (status.deviceScope === undefined || + status.deviceScope === 'mobile' || + status.deviceScope === 'runtime') && + (status.capabilities === undefined || + (Array.isArray(status.capabilities) && + status.capabilities.every((capability) => typeof capability === 'string'))) + ) +} + +export function verifyRemotePairingRuntimeStatus( + value: unknown +): { ok: true; runtimeStatus: RuntimeStatus } | RemotePairingFailure { + if (typeof value !== 'object' || value === null) { + return { + ok: false, + kind: 'connection-interrupted', + message: 'The remote host returned an invalid status response.' + } + } + const status = value as Partial & Record + if (status.deviceScope === 'mobile') { + return { + ok: false, + kind: 'access-link-invalid', + message: 'This link grants mobile-only access. Generate a link for another Orca client.' + } + } + const versionFields = [ + status.runtimeProtocolVersion, + status.protocolVersion, + status.minCompatibleRuntimeClientVersion, + status.minCompatibleMobileVersion + ] + if ( + versionFields.some( + (version) => version !== undefined && (!Number.isSafeInteger(version) || Number(version) < 0) + ) + ) { + return { + ok: false, + kind: 'connection-interrupted', + message: 'The remote host returned an invalid protocol version.' + } + } + const compatibility = evaluateRuntimeCompat({ + clientProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleServerProtocolVersion: MIN_COMPATIBLE_RUNTIME_SERVER_VERSION, + serverProtocolVersion: status.runtimeProtocolVersion ?? status.protocolVersion, + serverMinCompatibleClientProtocolVersion: + status.minCompatibleRuntimeClientVersion ?? status.minCompatibleMobileVersion + }) + if (compatibility.kind === 'blocked') { + return { + ok: false, + kind: 'protocol-incompatible', + message: + compatibility.reason === 'client-too-old' + ? 'Update this Orca client before adding the remote host.' + : 'Update Orca on the remote host before adding it.' + } + } + if (!hasValidRuntimeStatusShape(status)) { + return { + ok: false, + kind: 'connection-interrupted', + message: 'The remote host returned an invalid status response.' + } + } + return { ok: true, runtimeStatus: value as RuntimeStatus } +} diff --git a/src/shared/remote-runtime-client-error.ts b/src/shared/remote-runtime-client-error.ts index b6429620c04..487a1108b87 100644 --- a/src/shared/remote-runtime-client-error.ts +++ b/src/shared/remote-runtime-client-error.ts @@ -1,3 +1,5 @@ +export type RemoteRuntimePairingStage = 'connect' | 'host-identity' | 'access-grant' | 'runtime' + /** * Error type for the remote-runtime client, split out from * `remote-runtime-client.ts` so type-only consumers can reference it without @@ -7,10 +9,21 @@ */ export class RemoteRuntimeClientError extends Error { readonly code: string + readonly pairingStage?: RemoteRuntimePairingStage + readonly closeCode?: number - constructor(code: string, message: string) { + constructor( + code: string, + message: string, + details?: { + pairingStage?: RemoteRuntimePairingStage + closeCode?: number + } + ) { super(message) this.name = 'RemoteRuntimeClientError' this.code = code + this.pairingStage = details?.pairingStage + this.closeCode = details?.closeCode } } diff --git a/src/shared/remote-runtime-client.test.ts b/src/shared/remote-runtime-client.test.ts index d16b3e34807..43a2a982d73 100644 --- a/src/shared/remote-runtime-client.test.ts +++ b/src/shared/remote-runtime-client.test.ts @@ -197,6 +197,28 @@ describe('sendRemoteRuntimeRequest', () => { ) }) + it('classifies a non-Orca handshake as a host identity mismatch', async () => { + const server = await createInvalidHandshakeServer() + + await expect( + sendRemoteRuntimeRequest(server.pairing, 'status.get', {}, 1000) + ).rejects.toMatchObject({ + code: 'invalid_runtime_response', + pairingStage: 'host-identity' + }) + }) + + it('classifies an undecryptable post-auth frame as a runtime failure', async () => { + const server = await createOneShotServer({ sendUndecryptableResponse: true }) + + await expect( + sendRemoteRuntimeRequest(server.pairing, 'status.get', {}, 1000) + ).rejects.toMatchObject({ + code: 'invalid_runtime_response', + pairingStage: 'runtime' + }) + }) + it('refreshes the per-call timeout when the runtime sends keepalive frames', async () => { const server = await createOneShotServer() @@ -425,10 +447,35 @@ async function createClosingServer( return { pairing } } +async function createInvalidHandshakeServer(): Promise<{ pairing: PairingOffer }> { + const serverKeyPair = generateKeyPair() + const wss = new WebSocketServer({ port: 0 }) + servers.push(wss) + wss.on('connection', (ws) => { + ws.once('message', () => ws.send(JSON.stringify({ type: 'not_orca' }))) + }) + + await new Promise((resolve) => wss.once('listening', resolve)) + const address = wss.address() as AddressInfo + const pairing = parsePairingCode( + encodePairingOffer({ + v: 2, + endpoint: `ws://127.0.0.1:${address.port}`, + deviceToken: 'device-token', + publicKeyB64: publicKeyToBase64(serverKeyPair.publicKey) + }) + ) + if (!pairing) { + throw new Error('Failed to create test pairing') + } + return { pairing } +} + async function createOneShotServer( options: { response?: (requestId: string) => unknown onRequest?: (request: Record) => void + sendUndecryptableResponse?: boolean } = {} ): Promise<{ pairing: PairingOffer }> { const serverKeyPair = generateKeyPair() @@ -466,6 +513,10 @@ async function createOneShotServer( const request = JSON.parse(plaintext) as { id: string } & Record options.onRequest?.(request) + if (options.sendUndecryptableResponse) { + ws.send('not-an-encrypted-frame') + return + } const key = sharedKey const keepalive = setInterval(() => { sendEncrypted(ws, key, { _keepalive: true }) diff --git a/src/shared/remote-runtime-client.ts b/src/shared/remote-runtime-client.ts index dafa2261feb..8cc5c14046e 100644 --- a/src/shared/remote-runtime-client.ts +++ b/src/shared/remote-runtime-client.ts @@ -121,6 +121,12 @@ export async function sendRemoteRuntimeRequest( let state: HandshakeState = 'awaiting_ready' let settled = false let ws: WebSocket | null = null + const getPairingStage = (): 'connect' | 'host-identity' | 'runtime' => + state === 'awaiting_ready' + ? 'connect' + : state === 'awaiting_authenticated' + ? 'host-identity' + : 'runtime' const cleanupSocketListeners = (): void => { const socket = ws @@ -145,7 +151,8 @@ export async function sendRemoteRuntimeRequest( ok: false, error: new RemoteRuntimeClientError( 'runtime_timeout', - 'Timed out waiting for the remote Orca runtime to respond.' + 'Timed out waiting for the remote Orca runtime to respond.', + { pairingStage: getPairingStage() } ) }) } @@ -210,7 +217,8 @@ export async function sendRemoteRuntimeRequest( ok: false, error: new RemoteRuntimeClientError( 'remote_runtime_unavailable', - 'Could not connect to the remote Orca runtime.' + 'Could not connect to the remote Orca runtime.', + { pairingStage: getPairingStage() } ) }) } @@ -221,7 +229,11 @@ export async function sendRemoteRuntimeRequest( ok: false, error: new RemoteRuntimeClientError( 'remote_runtime_unavailable', - formatRemoteRuntimeCloseMessage(code, reason) + formatRemoteRuntimeCloseMessage(code, reason), + { + pairingStage: getPairingStage(), + closeCode: code + } ) }) } @@ -236,7 +248,10 @@ export async function sendRemoteRuntimeRequest( ok: false, error: new RemoteRuntimeClientError( 'invalid_runtime_response', - 'Remote Orca runtime returned an unexpected binary frame.' + 'Remote Orca runtime returned an unexpected binary frame.', + { + pairingStage: state === 'awaiting_ready' ? 'host-identity' : getPairingStage() + } ) }) return @@ -254,7 +269,10 @@ export async function sendRemoteRuntimeRequest( ok: false, error: new RemoteRuntimeClientError( 'invalid_runtime_response', - 'Remote Orca runtime returned an undecryptable frame.' + 'Remote Orca runtime returned an undecryptable frame.', + { + pairingStage: state === 'awaiting_authenticated' ? 'host-identity' : getPairingStage() + } ) }) return @@ -282,7 +300,8 @@ export async function sendRemoteRuntimeRequest( ok: false, error: new RemoteRuntimeClientError( 'invalid_runtime_response', - 'Remote Orca runtime returned an invalid E2EE handshake frame.' + 'Remote Orca runtime returned an invalid E2EE handshake frame.', + { pairingStage: 'host-identity' } ) }) return @@ -296,7 +315,8 @@ export async function sendRemoteRuntimeRequest( ok: false, error: new RemoteRuntimeClientError( 'invalid_runtime_response', - 'Remote Orca runtime returned an unexpected E2EE handshake frame.' + 'Remote Orca runtime returned an unexpected E2EE handshake frame.', + { pairingStage: 'host-identity' } ) }) return @@ -314,7 +334,8 @@ export async function sendRemoteRuntimeRequest( ok: false, error: new RemoteRuntimeClientError( 'invalid_runtime_response', - 'Remote Orca runtime returned an invalid E2EE auth frame.' + 'Remote Orca runtime returned an invalid E2EE auth frame.', + { pairingStage: 'host-identity' } ) }) return @@ -331,7 +352,8 @@ export async function sendRemoteRuntimeRequest( ok: false, error: new RemoteRuntimeClientError( code, - 'Remote Orca runtime rejected the pairing token.' + 'Remote Orca runtime rejected the pairing token.', + { pairingStage: code === 'unauthorized' ? 'access-grant' : 'host-identity' } ) }) return @@ -361,7 +383,8 @@ export async function sendRemoteRuntimeRequest( ok: false, error: new RemoteRuntimeClientError( 'invalid_runtime_response', - 'Remote Orca runtime returned an invalid response frame.' + 'Remote Orca runtime returned an invalid response frame.', + { pairingStage: 'runtime' } ) }) return @@ -376,7 +399,8 @@ export async function sendRemoteRuntimeRequest( ok: false, error: new RemoteRuntimeClientError( 'invalid_runtime_response', - 'Remote Orca runtime returned an invalid response frame.' + 'Remote Orca runtime returned an invalid response frame.', + { pairingStage: 'runtime' } ) }) return @@ -387,7 +411,8 @@ export async function sendRemoteRuntimeRequest( ok: false, error: new RemoteRuntimeClientError( 'invalid_runtime_response', - 'Remote Orca runtime returned a mismatched response id.' + 'Remote Orca runtime returned a mismatched response id.', + { pairingStage: 'runtime' } ) }) return diff --git a/src/shared/remote-runtime-shared-control-connection.test.ts b/src/shared/remote-runtime-shared-control-connection.test.ts index 29d8e8a3936..43ec4473c2a 100644 --- a/src/shared/remote-runtime-shared-control-connection.test.ts +++ b/src/shared/remote-runtime-shared-control-connection.test.ts @@ -138,6 +138,29 @@ describe('RemoteRuntimeSharedControlConnection', () => { expect(open).toHaveBeenCalledOnce() }) + it('keeps a waiting request alive when a reachability probe replaces its pre-ready socket', async () => { + const server = await createServer({ suppressReadyFrameCount: 1 }) + const connection = new RemoteRuntimeSharedControlConnection(server.pairing) + + const response = connection.request('worktree.ps', undefined, 1000) + await vi.waitFor(() => expect(server.connectionCount()).toBe(1)) + + connection.reconnectNow() + + await expect(response).resolves.toMatchObject({ + ok: true, + result: { method: 'worktree.ps' } + }) + expect(server.connectionCount()).toBe(2) + expect(server.requests.map(({ method }) => method)).toEqual(['worktree.ps']) + expect(connection.getDiagnostics().pendingRequestCount).toBe(0) + expect(getRemoteRuntimeRequestAdmissionEvidence()).toEqual({ + pendingRequestCount: 0, + retainedBytes: 0 + }) + connection.close() + }) + it('logs unknown response ids without breaking pending requests', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) const server = await createServer({ sendUnknownResponseBeforeResponse: true }) @@ -665,6 +688,7 @@ async function createServer( closeAfterFirstStreamingResponse?: boolean closeBeforeResponse?: boolean suppressReadyFrame?: boolean + suppressReadyFrameCount?: number // Why: half-open simulation — the socket stays open but never answers // protocol pings, like a wedged tunnel that swallows frames silently. disableAutoPong?: boolean @@ -696,7 +720,10 @@ async function createServer( serverKeyPair.secretKey, publicKeyFromBase64(hello.publicKeyB64) ) - if (options.suppressReadyFrame) { + if ( + options.suppressReadyFrame || + connectionCount <= (options.suppressReadyFrameCount ?? 0) + ) { return } ws.send(JSON.stringify({ type: 'e2ee_ready' })) diff --git a/src/shared/remote-runtime-shared-control-connection.ts b/src/shared/remote-runtime-shared-control-connection.ts index 5ad18d4eb12..8ba1de1c55a 100644 --- a/src/shared/remote-runtime-shared-control-connection.ts +++ b/src/shared/remote-runtime-shared-control-connection.ts @@ -130,7 +130,10 @@ export class RemoteRuntimeSharedControlConnection { return } // Why: a successful one-shot status probe proves the restarted endpoint is reachable; replace even a stuck CONNECTING/awaiting-ready socket instead of waiting behind stale backoff. - this.closeSocket(remoteRuntimeUnavailableError('Refreshing remote runtime control transport.')) + this.closeSocket( + remoteRuntimeUnavailableError('Refreshing remote runtime control transport.'), + true + ) this.open() } @@ -208,7 +211,11 @@ export class RemoteRuntimeSharedControlConnection { sendEncrypted: (payload) => this.sendEncrypted(payload), markReady: () => { this.lastConnectedAt = Date.now() - this.scheduleReconnectAttemptReset() + this.readyStableReset.schedule({ + getState: () => this.state, + getSocket: () => this.ws, + reset: () => this.reconnect.resetAttempt() + }) }, replaySubscriptions: () => this.replaySubscriptions() }) @@ -293,7 +300,7 @@ export class RemoteRuntimeSharedControlConnection { } } - private closeSocket(error?: Error): void { + private closeSocket(error?: Error, preserveReadyWaitersAndPendingRequests = false): void { closeSharedControlSocket({ environmentId: this.options.environmentId, state: this.state, @@ -304,18 +311,11 @@ export class RemoteRuntimeSharedControlConnection { socketCleanup: this.socketCleanup, ws: this.ws, error, + preserveReadyWaitersAndPendingRequests, clearReadyStableTimer: () => this.readyStableReset.clear() }) this.ws = this.sharedKey = null this.socketCleanup = null this.state = 'closed' } - - private scheduleReconnectAttemptReset(): void { - this.readyStableReset.schedule({ - getState: () => this.state, - getSocket: () => this.ws, - reset: () => this.reconnect.resetAttempt() - }) - } } diff --git a/src/shared/remote-runtime-shared-control-socket-close.ts b/src/shared/remote-runtime-shared-control-socket-close.ts index 752f48feb64..ab3817f22ce 100644 --- a/src/shared/remote-runtime-shared-control-socket-close.ts +++ b/src/shared/remote-runtime-shared-control-socket-close.ts @@ -19,6 +19,7 @@ export function closeSharedControlSocket(args: { readyWaiters: SharedControlReadyWaiter[] lastClose: { code: number; reason: string } | null error?: Error + preserveReadyWaitersAndPendingRequests?: boolean clearReadyStableTimer: () => void }): void { if (args.ws || args.socketCleanup) { @@ -39,6 +40,7 @@ export function closeSharedControlSocket(args: { subscriptions: args.subscriptions, socketCleanup: args.socketCleanup, ws: args.ws, - error: args.error + error: args.error, + preserveReadyWaitersAndPendingRequests: args.preserveReadyWaitersAndPendingRequests }) } diff --git a/src/shared/remote-runtime-shared-control-state.ts b/src/shared/remote-runtime-shared-control-state.ts index 7a6a32437cd..4f62d8298ff 100644 --- a/src/shared/remote-runtime-shared-control-state.ts +++ b/src/shared/remote-runtime-shared-control-state.ts @@ -186,9 +186,15 @@ export function closeSharedControlSocketState(args: { socketCleanup: (() => void) | null ws: { close: () => void } | null error?: Error + preserveReadyWaitersAndPendingRequests?: boolean }): void { - rejectSharedControlReadyWaiters(args.readyWaiters, args.error ?? remoteRuntimeUnavailableError()) - rejectAllSharedControlPendingRequests(args.pendingRequests, args.error) + if (!args.preserveReadyWaitersAndPendingRequests) { + rejectSharedControlReadyWaiters( + args.readyWaiters, + args.error ?? remoteRuntimeUnavailableError() + ) + rejectAllSharedControlPendingRequests(args.pendingRequests, args.error) + } markSharedControlSubscriptionsUnsent(args.subscriptions) try { args.socketCleanup?.() diff --git a/src/shared/runtime-client-events.ts b/src/shared/runtime-client-events.ts index 3e0b1a255e7..242dd44af24 100644 --- a/src/shared/runtime-client-events.ts +++ b/src/shared/runtime-client-events.ts @@ -6,10 +6,12 @@ import type { } from './types' import type { SshConnectionState } from './ssh-types' import type { TerminalSideEffectBatch } from './terminal-side-effect-facts' +import type { RuntimeNativeChatLaunchDraftResolution } from './runtime-types' export type RuntimeClientEvent = | { type: 'reposChanged' } | { type: 'worktreesChanged'; repoId: string } + | ({ type: 'nativeChatLaunchDraftResolved' } & RuntimeNativeChatLaunchDraftResolution) | { type: 'terminalSideEffects'; batch: TerminalSideEffectBatch } // Why: SSH connections live on the runtime host; paired clients have no IPC // channel for ssh:state-changed, so without this event their reconnect diff --git a/src/shared/runtime-environment-store.test.ts b/src/shared/runtime-environment-store.test.ts index f58093337fa..3e34f5846b2 100644 --- a/src/shared/runtime-environment-store.test.ts +++ b/src/shared/runtime-environment-store.test.ts @@ -87,6 +87,29 @@ describe('runtime environment store', () => { ]).toEqual([101, 102, 200]) }) + it('keeps SSH-tunnel metadata only while the pairing endpoint is loopback', () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-env-store-')) + tempDirs.push(userDataPath) + const environment = addEnvironmentFromPairingCode(userDataPath, { + name: 'tunneled box', + pairingCode: pairingCode(), + connectionDependency: 'ssh-tunnel' + }) + expect(environment.connectionDependency).toBe('ssh-tunnel') + + const updated = updateEnvironmentFromPairingCode(userDataPath, environment.id, { + pairingCode: pairingCode('ws://192.0.2.10:6768') + }) + expect(updated).not.toHaveProperty('connectionDependency') + + const direct = addEnvironmentFromPairingCode(userDataPath, { + name: 'direct box', + pairingCode: pairingCode('ws://192.0.2.11:6768'), + connectionDependency: 'ssh-tunnel' + }) + expect(direct).not.toHaveProperty('connectionDependency') + }) + it('throttles lastUsedAt writes so it does not rewrite the store on every runtime call', () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-env-store-')) tempDirs.push(userDataPath) diff --git a/src/shared/runtime-environment-store.ts b/src/shared/runtime-environment-store.ts index 4211eb18da8..85f90f8bef6 100644 --- a/src/shared/runtime-environment-store.ts +++ b/src/shared/runtime-environment-store.ts @@ -4,6 +4,7 @@ import { join } from 'node:path' import { JsonStringifyByteLimitError } from './node-bounded-json-stringify' import { readNodeFileSyncWithinLimit } from './node-bounded-file-reader' import { parsePairingCode, type PairingOffer } from './pairing' +import { classifyRemotePairingHostname } from './remote-pairing-address' import { writeSecureJsonFileWithinLimit } from './bounded-secure-json-file' import { hardenExistingSecureFile } from './secure-file' import { @@ -41,7 +42,13 @@ export function listEnvironments(userDataPath: string): KnownRuntimeEnvironment[ export function addEnvironmentFromPairingCode( userDataPath: string, - args: { name: string; pairingCode: string; now?: number; source?: RuntimeEnvironmentSource } + args: { + name: string + pairingCode: string + now?: number + source?: RuntimeEnvironmentSource + connectionDependency?: 'ssh-tunnel' + } ): KnownRuntimeEnvironment { const offer = parsePairingCode(args.pairingCode) if (!offer) { @@ -65,7 +72,8 @@ export function addEnvironmentFromPairingCode( now, offer, runtimeId: null, - ...(args.source ? { source: args.source } : {}) + ...(args.source ? { source: args.source } : {}), + ...getPairingConnectionDependency(args.connectionDependency, offer) }) const next = { version: 1 as const, @@ -110,7 +118,8 @@ export function updateEnvironmentFromPairingCode( now: existing.createdAt, offer, runtimeId: existing.runtimeId, - ...(existing.source ? { source: existing.source } : {}) + ...(existing.source ? { source: existing.source } : {}), + ...getPairingConnectionDependency(existing.connectionDependency, offer) }) const next = { ...environment, @@ -128,6 +137,23 @@ export function updateEnvironmentFromPairingCode( return next } +function getPairingConnectionDependency( + dependency: 'ssh-tunnel' | undefined, + offer: PairingOffer +): { connectionDependency?: 'ssh-tunnel' } { + if (!dependency) { + return {} + } + try { + const endpoint = new URL(offer.endpoint) + return classifyRemotePairingHostname(endpoint.hostname) === 'loopback' + ? { connectionDependency: dependency } + : {} + } catch { + return {} + } +} + export function resolveEnvironment( userDataPath: string, selector: string diff --git a/src/shared/runtime-environments.ts b/src/shared/runtime-environments.ts index dd3e3b357ce..131414631c2 100644 --- a/src/shared/runtime-environments.ts +++ b/src/shared/runtime-environments.ts @@ -31,6 +31,7 @@ export const KnownRuntimeEnvironmentSchema = z.object({ lastUsedAt: z.number().finite().nullable(), runtimeId: z.string().min(1).nullable(), source: RuntimeEnvironmentSourceSchema.optional(), + connectionDependency: z.literal('ssh-tunnel').optional(), endpoints: z.array(RuntimeAccessEndpointSchema).min(1), preferredEndpointId: z.string().min(1) }) @@ -66,6 +67,7 @@ export function createEnvironmentFromPairingOffer(args: { offer: PairingOffer runtimeId?: string | null source?: RuntimeEnvironmentSource + connectionDependency?: 'ssh-tunnel' }): KnownRuntimeEnvironment { const endpointId = `ws-${args.id}` return KnownRuntimeEnvironmentSchema.parse({ @@ -77,6 +79,7 @@ export function createEnvironmentFromPairingOffer(args: { lastUsedAt: null, runtimeId: args.runtimeId ?? null, ...(args.source ? { source: args.source } : {}), + ...(args.connectionDependency ? { connectionDependency: args.connectionDependency } : {}), endpoints: [ { id: endpointId, diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index 62d8293bcc8..5eca1063ab8 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -146,10 +146,17 @@ export type RuntimeSyncWindowGraph = { mobileSessionTabs?: RuntimeMobileSessionTabsSnapshot[] } +export type RuntimeNativeChatLaunchDraftResolution = { + tabId: string + text: string + createdAt: number +} + export type RuntimeSyncWindowGraphResult = RuntimeStatus & { /** Main owns terminal handles/dispatches, so renderer graph sync returns the * parent metadata needed by title-derived agent rows without name guessing. */ agentOrchestrationByPaneKey?: Record + nativeChatLaunchDraftResolutions?: RuntimeNativeChatLaunchDraftResolution[] } export type RuntimeMobileSessionTerminalTab = { @@ -174,6 +181,8 @@ export type RuntimeMobileSessionTerminalTab = { /** Launch context delivered only into the TUI input as an unsent draft; the * mobile chat composer adopts it so the context isn't invisible in chat. */ launchDraft?: string + /** Identity of the launch draft text, used to retire only the adopted generation. */ + launchDraftCreatedAt?: number isActive: boolean } @@ -622,6 +631,13 @@ type RuntimeTerminalCreateBaseRequestPayload = { title?: string activate?: boolean presentation?: RuntimeTerminalPresentation + /** + * Why: adopting a terminal is separate from pointing the user at it. `false` + * keeps the tab silent — no sidebar reveal, no tab focus — for terminals the + * user never asked to see (e.g. a workspace created in the background). + * Absent means "surface it", so this is a suppression switch, never `true`. + */ + surfaceOwner?: false } export type RuntimeTerminalCreateRequestPayload = diff --git a/src/shared/skills-cli-agent-keys.test.ts b/src/shared/skills-cli-agent-keys.test.ts new file mode 100644 index 00000000000..4359d879c48 --- /dev/null +++ b/src/shared/skills-cli-agent-keys.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from 'vitest' +import { TUI_AGENT_CONFIG } from './tui-agent-config' +import { + SKILLS_CLI_AGENT_KEY_BY_TUI_AGENT, + isSkillsCliAgentKeyShaped, + SKILLS_CLI_UNIVERSAL_AGENT_KEY, + toSkillsCliAgentKeys +} from './skills-cli-agent-keys' + +// Why: the community `skills` CLI validates --agent against this namespace and +// exits 1 on anything else, so a typo here breaks installs outright. Captured +// from `skills add --agent `, which prints its own valid list (v1.5.20). +const SKILLS_CLI_VALID_AGENT_KEYS = new Set([ + 'aider-desk', + 'amp', + 'antigravity', + 'antigravity-cli', + 'astrbot', + 'autohand-code', + 'augment', + 'bob', + 'claude-code', + 'openclaw', + 'cline', + 'codearts-agent', + 'codebuddy', + 'codemaker', + 'codestudio', + 'codex', + 'command-code', + 'continue', + 'cortex', + 'crush', + 'cursor', + 'deepagents', + 'devin', + 'dexto', + 'droid', + 'eve', + 'firebender', + 'forgecode', + 'gemini-cli', + 'github-copilot', + 'goose', + 'grok', + 'hermes-agent', + 'inference-sh', + 'jazz', + 'junie', + 'iflow-cli', + 'kilo', + 'kimchi', + 'kimi-code-cli', + 'kiro-cli', + 'kode', + 'lingma', + 'loaf', + 'mcpjam', + 'mistral-vibe', + 'moxby', + 'mux', + 'opencode', + 'openhands', + 'ona', + 'pi', + 'qoder', + 'qoder-cn', + 'qwen-code', + 'replit', + 'reasonix', + 'rovodev', + 'roo', + 'tabnine-cli', + 'terramind', + 'tinycloud', + 'trae', + 'trae-cn', + 'warp', + 'windsurf', + 'zed', + 'zcode', + 'zencoder', + 'zenflow', + 'neovate', + 'pochi', + 'promptscript', + 'adal', + 'universal' +]) + +describe('skills CLI agent keys', () => { + it('only maps onto keys the skills CLI accepts', () => { + for (const [agent, key] of Object.entries(SKILLS_CLI_AGENT_KEY_BY_TUI_AGENT)) { + if (key !== null) { + expect(SKILLS_CLI_VALID_AGENT_KEYS, `${agent} -> ${key}`).toContain(key) + } + } + expect(SKILLS_CLI_VALID_AGENT_KEYS).toContain(SKILLS_CLI_UNIVERSAL_AGENT_KEY) + }) + + it('covers every agent Orca can detect', () => { + // Why: a new TuiAgent must be considered here, even if the answer is null — + // otherwise it silently falls back to universal-only with no decision made. + expect(Object.keys(SKILLS_CLI_AGENT_KEY_BY_TUI_AGENT).sort()).toEqual( + Object.keys(TUI_AGENT_CONFIG).sort() + ) + }) + + it("follows Orca's own evidence for the two non-obvious mappings", () => { + // Why: src/shared/native-chat-agent-profiles.ts states OpenClaude reads + // Claude-owned roots, so it is not unmappable. + expect(SKILLS_CLI_AGENT_KEY_BY_TUI_AGENT.openclaude).toBe('claude-code') + // Why: Orca detects trae via `traecli`, which tui-agent-config calls an alias + // only TRAE CN ships, so the CN directory is the right target. + expect(SKILLS_CLI_AGENT_KEY_BY_TUI_AGENT.trae).toBe('trae-cn') + }) + + it('rejects values the skills CLI would drop, and allows the explicit wildcard', () => { + for (const bad of ['-y', '--copy', '', ' ', 'a b', 'a,b']) { + expect(isSkillsCliAgentKeyShaped(bad), bad).toBe(false) + } + for (const good of ['claude-code', 'universal', 'trae-cn', 'inference-sh', '*']) { + expect(isSkillsCliAgentKeyShaped(good), good).toBe(true) + } + }) + + it('always includes the shared directory and drops unmappable agents', () => { + expect(toSkillsCliAgentKeys(['claude', 'rovo'])).toEqual([ + 'claude-code', + 'rovodev', + 'universal' + ]) + // Why: `omp` has no skills-CLI equivalent, so it must not reach the argv. + expect(toSkillsCliAgentKeys(['omp'])).toEqual(['universal']) + expect(toSkillsCliAgentKeys([])).toEqual(['universal']) + }) +}) diff --git a/src/shared/skills-cli-agent-keys.ts b/src/shared/skills-cli-agent-keys.ts new file mode 100644 index 00000000000..ecd4a7d96c2 --- /dev/null +++ b/src/shared/skills-cli-agent-keys.ts @@ -0,0 +1,84 @@ +import type { TuiAgent } from './types' + +/** + * The community `skills` CLI's own `--agent` key for each agent Orca detects. + * + * Why: `skills add` validates `--agent` against its own namespace and exits 1 on + * an unknown key, so anything we are not certain of maps to null and is dropped + * rather than guessed. Orca ids and skills keys agree less often than they look + * (`claude` is `claude-code`, `rovo` is `rovodev`, `aug` is `augment`), and some + * near-matches are different products — Orca's `aider` CLI is not the CLI's + * `aider-desk`, and Orca's `openclaude` is its `openclaw` in name only, so it + * follows Orca's own rule that OpenClaude reads Claude-owned roots. + */ +export const SKILLS_CLI_AGENT_KEY_BY_TUI_AGENT = { + claude: 'claude-code', + 'claude-agent-teams': 'claude-code', + // Why: Orca states OpenClaude reads Claude-owned roots (native-chat-agent-profiles). + openclaude: 'claude-code', + codex: 'codex', + autohand: 'autohand-code', + opencode: 'opencode', + 'mimo-code': null, + pi: 'pi', + omp: null, + gemini: 'gemini-cli', + antigravity: 'antigravity', + aider: null, + goose: 'goose', + amp: 'amp', + kilo: 'kilo', + kiro: 'kiro-cli', + crush: 'crush', + aug: 'augment', + cline: 'cline', + codebuff: null, + 'command-code': 'command-code', + continue: 'continue', + cursor: 'cursor', + droid: 'droid', + kimi: 'kimi-code-cli', + 'mistral-vibe': 'mistral-vibe', + 'qwen-code': 'qwen-code', + rovo: 'rovodev', + hermes: 'hermes-agent', + openclaw: 'openclaw', + copilot: 'github-copilot', + grok: 'grok', + devin: 'devin', + ante: null, + // Why: Orca detects trae by `traecli`, an alias only TRAE CN ships. + trae: 'trae-cn' +} satisfies Record + +/** + * The shared `.agents/skills` target every universal agent reads. Always included + * so agents Orca cannot map still receive the skill. + */ +export const SKILLS_CLI_UNIVERSAL_AGENT_KEY = 'universal' + +/** + * Whether a value is shaped like a `skills --agent` key, or its explicit all-agents + * wildcard. + * + * Why: the skills CLI silently DROPS a `--agent` value that starts with `-`, which + * empties its target list and drops it into the same all-agents branch an omitted + * --agent does. `--agent -y` is enough to trigger it, so shape is checked, not just + * emptiness. An unknown-but-plausible key is left to the CLI, which rejects it + * loudly with its own valid list before writing anything. + */ +export function isSkillsCliAgentKeyShaped(value: string): boolean { + return /^(?:\*|[a-z0-9][a-z0-9.-]*)$/i.test(value) +} + +/** Map detected Orca agents onto `skills --agent` keys, plus the universal target. */ +export function toSkillsCliAgentKeys(detectedAgents: readonly TuiAgent[]): string[] { + const keys = new Set([SKILLS_CLI_UNIVERSAL_AGENT_KEY]) + for (const agent of detectedAgents) { + const key = SKILLS_CLI_AGENT_KEY_BY_TUI_AGENT[agent] + if (key) { + keys.add(key) + } + } + return [...keys].sort() +} diff --git a/tests/e2e/floating-tab-rename-filesystem-alias.spec.ts b/tests/e2e/floating-tab-rename-filesystem-alias.spec.ts new file mode 100644 index 00000000000..1ba01d0a59c --- /dev/null +++ b/tests/e2e/floating-tab-rename-filesystem-alias.spec.ts @@ -0,0 +1,75 @@ +import path from 'node:path' +import { expect, test } from './helpers/orca-app' + +test.describe('floating Markdown filesystem aliases', () => { + test.skip(process.platform !== 'darwin', 'Requires native APFS alias behavior') + + test('renames one APFS entry through its Unicode alias', async ({ orcaPage }) => { + const directory = await orcaPage.evaluate(() => window.api.app.getFloatingMarkdownDirectory()) + const suffix = Date.now().toString(36) + const originalPath = path.join(directory, `floating-alias-${suffix}-straße.md`) + const renamedPath = path.join(directory, `floating-alias-${suffix}-STRASSE.MD`) + const renamedName = path.basename(renamedPath) + + const result = await orcaPage.evaluate( + async ({ directory, originalPath, renamedPath, renamedName }) => { + await window.api.fs.createFile({ filePath: originalPath }) + await window.api.fs.writeFile({ filePath: originalPath, content: 'same entry\n' }) + const settled = await Promise.allSettled([ + window.api.fs.rename({ oldPath: originalPath, newPath: renamedPath }) + ]) + return { + status: settled[0].status, + reason: settled[0].status === 'rejected' ? String(settled[0].reason) : null, + content: (await window.api.fs.readFile({ filePath: renamedPath })).content, + renamedEntryExists: (await window.api.fs.readDir({ dirPath: directory })).some( + ({ name }) => name === renamedName + ) + } + }, + { directory, originalPath, renamedPath, renamedName } + ) + + expect(result).toEqual({ + status: 'fulfilled', + reason: null, + content: 'same entry\n', + renamedEntryExists: true + }) + }) + + test('keeps dotless and ASCII I destinations distinct through IPC', async ({ orcaPage }) => { + const directory = await orcaPage.evaluate(() => window.api.app.getFloatingMarkdownDirectory()) + const suffix = Date.now().toString(36) + const firstPath = path.join(directory, `floating-dotless-first-${suffix}.md`) + const secondPath = path.join(directory, `floating-dotless-second-${suffix}.md`) + const dotlessDestination = path.join(directory, `floating-destination-${suffix}-ı.md`) + const asciiDestination = path.join(directory, `floating-destination-${suffix}-I.md`) + + const result = await orcaPage.evaluate( + async ({ firstPath, secondPath, dotlessDestination, asciiDestination }) => { + await window.api.fs.createFile({ filePath: firstPath }) + await window.api.fs.createFile({ filePath: secondPath }) + await window.api.fs.writeFile({ filePath: firstPath, content: 'dotless\n' }) + await window.api.fs.writeFile({ filePath: secondPath, content: 'ascii\n' }) + + const settled = await Promise.allSettled([ + window.api.fs.rename({ oldPath: firstPath, newPath: dotlessDestination }), + window.api.fs.rename({ oldPath: secondPath, newPath: asciiDestination }) + ]) + return { + statuses: settled.map(({ status }) => status), + dotlessContent: (await window.api.fs.readFile({ filePath: dotlessDestination })).content, + asciiContent: (await window.api.fs.readFile({ filePath: asciiDestination })).content + } + }, + { firstPath, secondPath, dotlessDestination, asciiDestination } + ) + + expect(result).toEqual({ + statuses: ['fulfilled', 'fulfilled'], + dotlessContent: 'dotless\n', + asciiContent: 'ascii\n' + }) + }) +}) diff --git a/tests/e2e/floating-tab-rename.spec.ts b/tests/e2e/floating-tab-rename.spec.ts new file mode 100644 index 00000000000..7f9dca4b7b9 --- /dev/null +++ b/tests/e2e/floating-tab-rename.spec.ts @@ -0,0 +1,295 @@ +import path from 'node:path' +import type { ElectronApplication, Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { waitForSessionReady } from './helpers/store' +import { createRestartSession } from './helpers/orca-restart' + +// Why: mirrors FLOATING_TERMINAL_WORKTREE_ID in src/shared/constants.ts. +// E2E specs avoid importing renderer/shared modules into the Playwright runner. +const FLOATING_WORKTREE_ID = 'global-floating-terminal' +const OPEN_PANEL_SELECTOR = '[data-floating-terminal-panel][aria-hidden="false"]' +const PANEL_SELECTOR = '[data-floating-terminal-panel]' + +async function seedFloatingMarkdownFile(page: Page): Promise<{ + originalName: string + originalPath: string + intermediateName: string + intermediatePath: string + renamedName: string + renamedPath: string + tabId: string +}> { + const directory = await page.evaluate(() => window.api.app.getFloatingMarkdownDirectory()) + const suffix = Date.now().toString(36) + const originalName = `floating-rename-${suffix}.md` + const intermediateName = `floating-entered-${suffix}.md` + const renamedName = `floating-renamed-${suffix}.md` + const originalPath = path.join(directory, originalName) + const intermediatePath = path.join(directory, intermediateName) + const renamedPath = path.join(directory, renamedName) + const tabId = await page.evaluate( + async ({ filePath, originalName, worktreeId }) => { + const store = window.__store + if (!store) { + throw new Error('Store unavailable') + } + + await store.getState().updateSettings({ floatingTerminalEnabled: true }) + await window.api.fs.createFile({ filePath }) + await window.api.fs.writeFile({ filePath, content: '# Floating rename\n' }) + store.getState().openFile( + { + filePath, + relativePath: originalName, + worktreeId, + language: 'markdown', + mode: 'edit', + runtimeEnvironmentId: null + }, + { preview: false, suppressActiveRuntimeFallback: true } + ) + + const state = store.getState() + const file = state.openFiles.find( + (candidate) => candidate.filePath === filePath && candidate.worktreeId === worktreeId + ) + const tab = state.unifiedTabsByWorktree[worktreeId]?.find( + (candidate) => candidate.contentType === 'editor' && candidate.entityId === file?.id + ) + if (!file || !tab) { + throw new Error('Floating Markdown tab unavailable') + } + return tab.id + }, + { filePath: originalPath, originalName, worktreeId: FLOATING_WORKTREE_ID } + ) + return { + originalName, + originalPath, + intermediateName, + intermediatePath, + renamedName, + renamedPath, + tabId + } +} + +async function openFloatingPanel(page: Page): Promise { + await page.waitForFunction( + (selector) => Boolean(document.querySelector(selector)), + PANEL_SELECTOR, + { timeout: 30_000 } + ) + await page.evaluate(() => window.dispatchEvent(new Event('orca-toggle-floating-terminal'))) + await expect(page.locator(OPEN_PANEL_SELECTOR)).toBeVisible() +} + +test('concurrent floating Markdown renames do not clobber the destination', async ({ + orcaPage +}) => { + const directory = await orcaPage.evaluate(() => window.api.app.getFloatingMarkdownDirectory()) + const suffix = Date.now().toString(36) + const firstPath = path.join(directory, `floating-first-${suffix}.md`) + const secondPath = path.join(directory, `floating-second-${suffix}.md`) + const destinationPath = path.join(directory, `floating-destination-${suffix}.md`) + + const result = await orcaPage.evaluate( + async ({ firstPath, secondPath, destinationPath }) => { + await window.api.fs.createFile({ filePath: firstPath }) + await window.api.fs.createFile({ filePath: secondPath }) + await window.api.fs.writeFile({ filePath: firstPath, content: 'first\n' }) + await window.api.fs.writeFile({ filePath: secondPath, content: 'second\n' }) + + const settled = await Promise.allSettled([ + window.api.fs.rename({ oldPath: firstPath, newPath: destinationPath }), + window.api.fs.rename({ oldPath: secondPath, newPath: destinationPath }) + ]) + const firstExists = await window.api.fs.pathExists({ filePath: firstPath }) + const secondExists = await window.api.fs.pathExists({ filePath: secondPath }) + return { + statuses: settled.map(({ status }) => status).sort(), + rejectionMessages: settled.flatMap((outcome) => + outcome.status === 'rejected' ? [String(outcome.reason)] : [] + ), + destinationContent: (await window.api.fs.readFile({ filePath: destinationPath })).content, + firstExists, + secondExists, + firstContent: firstExists + ? (await window.api.fs.readFile({ filePath: firstPath })).content + : null, + secondContent: secondExists + ? (await window.api.fs.readFile({ filePath: secondPath })).content + : null + } + }, + { firstPath, secondPath, destinationPath } + ) + + expect(result.statuses).toEqual(['fulfilled', 'rejected']) + expect(result.rejectionMessages[0]).toContain('already exists') + expect(Number(result.firstExists) + Number(result.secondExists)).toBe(1) + expect( + [result.destinationContent, result.firstContent ?? result.secondContent].toSorted() + ).toEqual(['first\n', 'second\n']) +}) + +test('Electron serializes native Unicode rename aliases', async ({ orcaPage }) => { + test.skip(process.platform !== 'darwin', 'Requires native Unicode aliasing') + const directory = await orcaPage.evaluate(() => window.api.app.getFloatingMarkdownDirectory()) + const suffix = Date.now().toString(36) + const firstPath = path.join(directory, `floating-unicode-first-${suffix}.md`) + const secondPath = path.join(directory, `floating-unicode-second-${suffix}.md`) + const sharpSDestination = path.join(directory, `floating-destination-${suffix}-straße.md`) + const expandedDestination = path.join(directory, `floating-destination-${suffix}-STRASSE.MD`) + + const result = await orcaPage.evaluate( + async ({ firstPath, secondPath, sharpSDestination, expandedDestination }) => { + await window.api.fs.createFile({ filePath: firstPath }) + await window.api.fs.createFile({ filePath: secondPath }) + await window.api.fs.writeFile({ filePath: firstPath, content: 'first\n' }) + await window.api.fs.writeFile({ filePath: secondPath, content: 'second\n' }) + + const settled = await Promise.allSettled([ + window.api.fs.rename({ oldPath: firstPath, newPath: sharpSDestination }), + window.api.fs.rename({ oldPath: secondPath, newPath: expandedDestination }) + ]) + const firstExists = await window.api.fs.pathExists({ filePath: firstPath }) + const secondExists = await window.api.fs.pathExists({ filePath: secondPath }) + return { + statuses: settled.map(({ status }) => status).sort(), + destinationContent: (await window.api.fs.readFile({ filePath: sharpSDestination })).content, + firstExists, + secondExists, + remainingContent: firstExists + ? (await window.api.fs.readFile({ filePath: firstPath })).content + : (await window.api.fs.readFile({ filePath: secondPath })).content + } + }, + { firstPath, secondPath, sharpSDestination, expandedDestination } + ) + + expect(result.statuses).toEqual(['fulfilled', 'rejected']) + expect(Number(result.firstExists) + Number(result.secondExists)).toBe(1) + expect([result.destinationContent, result.remainingContent].toSorted()).toEqual([ + 'first\n', + 'second\n' + ]) +}) + +test('floating workspace Markdown renames survive an app restart', async (// oxlint-disable-next-line no-empty-pattern -- This persistence test owns both Electron launches. +{}, testInfo) => { + test.setTimeout(300_000) + const session = createRestartSession(testInfo) + let firstApp: ElectronApplication | null = null + let secondApp: ElectronApplication | null = null + + try { + const first = await session.launch() + firstApp = first.app + await waitForSessionReady(first.page) + const seeded = await seedFloatingMarkdownFile(first.page) + await openFloatingPanel(first.page) + + const panel = first.page.locator(OPEN_PANEL_SELECTOR) + const tab = panel.locator(`[data-tab-id="${seeded.tabId}"]`) + await expect(tab).toContainText(seeded.originalName) + await tab.click({ button: 'right' }) + const renameMenuItem = first.page.getByRole('menuitem').filter({ hasText: 'Rename' }).first() + await expect(renameMenuItem).toBeVisible() + await renameMenuItem.click() + + const enterInput = panel.getByRole('textbox', { + name: `Rename file ${seeded.originalName}`, + exact: true + }) + await enterInput.fill(seeded.intermediateName) + await enterInput.press('Enter') + await expect(tab).toContainText(seeded.intermediateName) + + await tab.getByText(seeded.intermediateName, { exact: true }).dispatchEvent('dblclick') + const blurInput = panel.getByRole('textbox', { + name: `Rename file ${seeded.intermediateName}`, + exact: true + }) + await blurInput.fill(seeded.renamedName) + await panel.getByRole('radio', { name: 'Rich Editor' }).click() + await expect(tab).toContainText(seeded.renamedName) + await expect + .poll(() => + first.page.evaluate( + async ({ renamedPath, originalPath, intermediatePath }) => ({ + content: (await window.api.fs.readFile({ filePath: renamedPath })).content, + originalExists: await window.api.fs.pathExists({ filePath: originalPath }), + intermediateExists: await window.api.fs.pathExists({ filePath: intermediatePath }) + }), + { + renamedPath: seeded.renamedPath, + originalPath: seeded.originalPath, + intermediatePath: seeded.intermediatePath + } + ) + ) + .toEqual({ + content: '# Floating rename\n', + originalExists: false, + intermediateExists: false + }) + + await session.close(firstApp) + firstApp = null + + const second = await session.launch() + secondApp = second.app + await waitForSessionReady(second.page) + await openFloatingPanel(second.page) + + const restoredPanel = second.page.locator(OPEN_PANEL_SELECTOR) + const restoredTab = restoredPanel.locator('[data-tab-id]').filter({ + hasText: seeded.renamedName + }) + await expect(restoredTab).toContainText(seeded.renamedName) + await expect + .poll(() => + second.page.evaluate( + async ({ renamedPath, originalPath, intermediatePath, worktreeId }) => { + const file = window.__store + ?.getState() + .openFiles.find( + (candidate) => + candidate.worktreeId === worktreeId && candidate.filePath === renamedPath + ) + return { + restoredPath: file?.filePath ?? null, + content: (await window.api.fs.readFile({ filePath: renamedPath })).content, + originalExists: await window.api.fs.pathExists({ filePath: originalPath }), + intermediateExists: await window.api.fs.pathExists({ filePath: intermediatePath }) + } + }, + { + renamedPath: seeded.renamedPath, + originalPath: seeded.originalPath, + intermediatePath: seeded.intermediatePath, + worktreeId: FLOATING_WORKTREE_ID + } + ) + ) + .toEqual({ + restoredPath: seeded.renamedPath, + content: '# Floating rename\n', + originalExists: false, + intermediateExists: false + }) + } finally { + for (const app of [secondApp, firstApp]) { + if (!app) { + continue + } + try { + await session.close(app) + } catch { + // best-effort cleanup + } + } + await session.dispose() + } +}) diff --git a/tests/e2e/helpers/paired-terminal-parking-oracle.ts b/tests/e2e/helpers/paired-terminal-parking-oracle.ts index 30970e61044..e976a55b0d9 100644 --- a/tests/e2e/helpers/paired-terminal-parking-oracle.ts +++ b/tests/e2e/helpers/paired-terminal-parking-oracle.ts @@ -1,7 +1,10 @@ import type { Page } from '@stablyai/playwright-test' import type { RuntimeTerminalRead } from '../../../src/shared/runtime-types' import { TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version' -import { toWebTerminalSurfaceTabId } from '../../../src/shared/terminal-surface-id' +import { + toHostSessionTabId, + toWebTerminalSurfaceTabId +} from '../../../src/shared/terminal-surface-id' import { readPairedRetentionSample, startRendererLagProbe @@ -16,11 +19,6 @@ const MAX_RETAINED_CELL_FRACTION = 0.45 const MAX_EVICTION_LAG_MS = 500 const MAX_HEAP_GROWTH_BYTES = 16 * 1024 * 1024 -type PairedTerminalParkingSeed = { - fallbackWorktreeId: string - repoId: string -} - type RemoteTab = { marker: string originalPtyId: string @@ -44,7 +42,8 @@ async function callRuntime(page: Page, method: string, params: unknown) export async function runPairedTerminalParkingOracle( page: Page, - seed: PairedTerminalParkingSeed + seed: { fallbackWorktreeId: string; repoId: string }, + options: { hostPage?: Page } = {} ): Promise { const fixture = createPairedTerminalParkingFixture() const createdWorktreeIds: string[] = [] @@ -132,6 +131,7 @@ export async function runPairedTerminalParkingOracle( .toContain(`FILLED:${created.marker}`) remoteTabs.push({ ...created, originalPtyId }) } + await expectHostTerminalsUnmounted(options.hostPage, seed.fallbackWorktreeId, remoteTabs) await page.evaluate(() => window.__store?.getState().setActiveView('tasks')) await expect @@ -263,6 +263,7 @@ export async function runPairedTerminalParkingOracle( await expect .poll(() => getTerminalContent(page), { timeout: 30_000 }) .toContain(`LIVE:${liveMarker}`) + await expectHostTerminalsUnmounted(options.hostPage, seed.fallbackWorktreeId, remoteTabs) } finally { for (const tab of remoteTabs) { await callRuntime(page, 'terminal.closeTab', { terminal: tab.terminal }).catch( @@ -282,3 +283,26 @@ export async function runPairedTerminalParkingOracle( fixture.dispose() } } + +async function expectHostTerminalsUnmounted( + hostPage: Page | undefined, + activeWorktreeId: string, + remoteTabs: RemoteTab[] +): Promise { + if (!hostPage) { + return + } + await expect + .poll( + () => + hostPage.evaluate( + (tabIds) => ({ + activeWorktreeId: window.__store?.getState().activeWorktreeId, + mountedCount: tabIds.filter((tabId) => window.__paneManagers?.has(tabId)).length + }), + remoteTabs.map(({ tabId }) => toHostSessionTabId(tabId)) + ), + { timeout: 30_000 } + ) + .toEqual({ activeWorktreeId, mountedCount: 0 }) +} diff --git a/tests/e2e/new-workspace-linked-item-project-switch.spec.ts b/tests/e2e/new-workspace-linked-item-project-switch.spec.ts index f8308e62a17..9970f3696f0 100644 --- a/tests/e2e/new-workspace-linked-item-project-switch.spec.ts +++ b/tests/e2e/new-workspace-linked-item-project-switch.spec.ts @@ -20,6 +20,7 @@ import type { Page } from '@stablyai/playwright-test' import { test, expect } from './helpers/orca-app' import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' import type { LinkedWorkItemSummary } from '../../src/renderer/src/lib/new-workspace' +import type { TaskSourceContext } from '../../src/shared/task-source-context' const SECOND_PROJECT_NAME = 'linked-item-second-project' @@ -53,20 +54,52 @@ async function addSecondProject(page: Page, repoPath: string): Promise { async function openComposerWithLinkedWorkItem( page: Page, linkedWorkItem: LinkedWorkItemSummary, - prefilledName: string + prefilledName: string, + taskSourceContext: TaskSourceContext | null = null ): Promise { await page.evaluate( - ({ linkedWorkItem, prefilledName }) => { + ({ linkedWorkItem, prefilledName, taskSourceContext }) => { const store = window.__store if (!store) { throw new Error('window.__store is not available') } - store.getState().openModal('new-workspace-composer', { linkedWorkItem, prefilledName }) + store.getState().openModal('new-workspace-composer', { + linkedWorkItem, + prefilledName, + taskSourceContext + }) }, - { linkedWorkItem, prefilledName } + { linkedWorkItem, prefilledName, taskSourceContext } ) } +async function getJiraSourceContext(page: Page): Promise { + return page.evaluate(() => { + const state = window.__store?.getState() + const activeWorktreeId = state?.activeWorktreeId + const setup = state?.projectHostSetups.find((candidate) => + state.worktreesByRepo[candidate.repoId]?.some((worktree) => worktree.id === activeWorktreeId) + ) + if (!setup) { + throw new Error('Active project host setup is unavailable') + } + return { + kind: 'task-source', + provider: 'jira', + projectId: setup.projectId, + hostId: setup.hostId, + projectHostSetupId: setup.id, + repoId: setup.repoId, + providerIdentity: { + provider: 'jira', + siteId: 'e2e-jira-site', + siteUrl: 'https://example.atlassian.net', + projectKey: 'RDG' + } + } + }) +} + async function switchComposerProject(page: Page, projectName: string): Promise { const composer = page.getByRole('dialog') const combobox = composer.getByRole('combobox', { name: 'Project' }) @@ -94,6 +127,7 @@ test.describe('New workspace composer linked item across project switches', () = }) test('keeps a Jira issue linked when the project changes', async ({ orcaPage }) => { + const jiraSourceContext = await getJiraSourceContext(orcaPage) await openComposerWithLinkedWorkItem( orcaPage, { @@ -104,7 +138,8 @@ test.describe('New workspace composer linked item across project switches', () = url: 'https://example.atlassian.net/browse/RDG-344', jiraIdentifier: 'RDG-344' }, - 'rdg-344-nuxtjs-nextjs' + 'rdg-344-nuxtjs-nextjs', + jiraSourceContext ) const composer = orcaPage.getByRole('dialog') diff --git a/tests/e2e/orchestration-legacy-worker-restart-recovery.spec.ts b/tests/e2e/orchestration-legacy-worker-restart-recovery.spec.ts index 5d17f1c0712..a5d4b4631ec 100644 --- a/tests/e2e/orchestration-legacy-worker-restart-recovery.spec.ts +++ b/tests/e2e/orchestration-legacy-worker-restart-recovery.spec.ts @@ -23,6 +23,7 @@ import { } from '../../src/main/runtime/orchestration/db' import { DEFAULT_LOCAL_ORCA_PROFILE_ID } from '../../src/shared/orca-profiles' import type { RuntimeTerminalListResult, RuntimeTerminalRead } from '../../src/shared/runtime-types' +import { listAllOrchestrationRuns } from './orchestration-run-pages' const PROVIDER_SESSION_ID = 'e2e-legacy-orchestration-worker' const fakeCliDir = mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-legacy-worker-')) @@ -622,10 +623,8 @@ for (const contractVersion of [LEGACY_CONTRACT_VERSION, CURRENT_CONTRACT_VERSION let assignmentRunId = run.result.run.id if (contractVersion === LEGACY_CONTRACT_VERSION) { - const runs = await secondClient.call<{ - runs: { id: string; objective: string }[] - }>('orchestration.runList') - assignmentRunId = runs.result.runs.find( + const runs = await listAllOrchestrationRuns(secondClient) + assignmentRunId = runs.find( (candidate) => candidate.objective === 'Recovered orchestration work from a contract update' )!.id diff --git a/tests/e2e/orchestration-run-pages.ts b/tests/e2e/orchestration-run-pages.ts new file mode 100644 index 00000000000..437066526f0 --- /dev/null +++ b/tests/e2e/orchestration-run-pages.ts @@ -0,0 +1,29 @@ +import { ORCHESTRATION_RUN_PAGE_LIMIT } from '../../src/shared/orchestration-run-pagination' + +export type OrchestrationRunSummary = { + id: string + objective: string +} + +type RunListClient = { + call(method: string, params?: unknown): Promise<{ result: TResult }> +} + +export async function listAllOrchestrationRuns( + client: RunListClient +): Promise { + const runs: OrchestrationRunSummary[] = [] + let cursor: string | undefined + do { + const page = await client.call<{ + runs: OrchestrationRunSummary[] + nextCursor?: string | null + }>('orchestration.runList', { + limit: ORCHESTRATION_RUN_PAGE_LIMIT, + ...(cursor ? { cursor } : {}) + }) + runs.push(...page.result.runs) + cursor = page.result.nextCursor ?? undefined + } while (cursor) + return runs +} diff --git a/tests/e2e/orchestration-run-pagination.unit.test.ts b/tests/e2e/orchestration-run-pagination.unit.test.ts new file mode 100644 index 00000000000..81a261c3120 --- /dev/null +++ b/tests/e2e/orchestration-run-pagination.unit.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it, vi } from 'vitest' +import { listAllOrchestrationRuns } from './orchestration-run-pages' + +describe('orchestration Run pagination compatibility', () => { + it('stops after an old server response without nextCursor', async () => { + const call = vi.fn().mockResolvedValue({ + result: { runs: [{ id: 'run_old', objective: 'Old server Run' }] } + }) + + await expect(listAllOrchestrationRuns({ call })).resolves.toEqual([ + { id: 'run_old', objective: 'Old server Run' } + ]) + expect(call).toHaveBeenCalledTimes(1) + expect(call).toHaveBeenCalledWith('orchestration.runList', { limit: 100 }) + }) +}) diff --git a/tests/e2e/paired-remote-terminal-retention-memory.spec.ts b/tests/e2e/paired-remote-terminal-retention-memory.spec.ts index 621f4c2486a..3d1a4a9a5f1 100644 --- a/tests/e2e/paired-remote-terminal-retention-memory.spec.ts +++ b/tests/e2e/paired-remote-terminal-retention-memory.spec.ts @@ -17,17 +17,21 @@ test('ordinary-parks paired terminals and restores authoritative host scrollback if (!active) { throw new Error('Paired retention host has no active seeded worktree') } - return { repoId: active.repoId, worktreeIds: worktrees.map((worktree) => worktree.id) } + return { activeWorktreeId: active.id, repoId: active.repoId } }) const offer = await createRuntimeDesktopPairingOffer(orcaPage) const client = await launchPairedWebClient(electronApp, offer, { terminalParkingDelayMs: 100 }) try { - await runPairedTerminalParkingOracle(client.page, { - fallbackWorktreeId: seed.worktreeIds[0]!, - repoId: seed.repoId - }) + await runPairedTerminalParkingOracle( + client.page, + { + fallbackWorktreeId: seed.activeWorktreeId, + repoId: seed.repoId + }, + { hostPage: orcaPage } + ) } finally { await client.dispose() } diff --git a/tests/e2e/terminal-long-table-fixtures.ts b/tests/e2e/terminal-long-table-fixtures.ts index 0ebbab7ea2a..f68ed4e9a3d 100644 --- a/tests/e2e/terminal-long-table-fixtures.ts +++ b/tests/e2e/terminal-long-table-fixtures.ts @@ -89,7 +89,7 @@ await writeStdout('LONG_TABLE_SCROLL_RESTORE_${runId}\\n') export function emojiFixtureMarkdownTableScript(table: string, runId: string): string { const marker = `EMOJI_FIXTURE_TABLE_RESTORE_${runId}` - const widthMarker = `EMOJI_FIXTURE_TABLE_WIDTH_${runId}` + const widthMarker = `${marker} WIDTH` return ` const table = ${JSON.stringify(table)} const minimumWidths = [2, 5, 4, 7, 7, 4, 3, 4] @@ -215,12 +215,11 @@ for (const line of rendered) { } await writeStdout('\\x1b[?2026l') await writeStdout('${widthMarker}:' + generatedTableWidth + '\\r\\n') -await writeStdout('${marker}\\r\\n') ` } export function emojiFixtureTableWidthMarker(runId: string): string { - return `EMOJI_FIXTURE_TABLE_WIDTH_${runId}:` + return `EMOJI_FIXTURE_TABLE_RESTORE_${runId} WIDTH:` } export function narrowSignerMarkdownTableScript(runId: string): string {