mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
fc8b92e507af3ce9c150bd43a2da278c8bd41999
3244
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
26bdfc0fe4 |
feat(agent-status): stamp observation provenance at every status ingress (STA-4293) (#14706)
Add an optional `observation` facet to agent status rows recording the origin (hook | osc | title | process | launch | orchestration), the authority that sequenced it, a per-pane incarnation, a monotonic revision, and the authority's own clock. Stamp it at every ingress; no consumer reads it. Boundary is stamped from the hook listener's existing per-provider `isNewTurnEvent`, not a second list of event-name literals. Identity-only (`providerSessionOnly`) rows are tagged `kind: 'identity-only'` so future consumers do not each rediscover that they are not turn transitions. The staleness-decay contract is documented at the type: staleness must be computed against the same authority clock that stamped `observedAt`, or replicas must decay on local receipt time. Not fixed here. Behavior-neutral: optional field on existing JSON, never persisted, never published to paired clients, and never inherited across writes. |
||
|
|
8ea5dd80c3 |
fix(antigravity): install a PreToolUse status hook without deciding tool permissions (#14701)
* fix(antigravity): install a PreToolUse status hook without deciding tool permissions
Antigravity is the only supported agent with no pre-tool signal, so its panes
show a bare "Working" spinner for the whole tool call instead of the live
"Working - <tool>(<input>)" readout every other agent gets.
The consumer side already handles it — extractAntigravityToolFields and
normalizeAntigravityEvent parse PreToolUse (including the `waiting` state for
ask_question/ask_permission) and are covered by tests. Only the installer was
missing the event.
PreToolUse was installed originally and removed in
|
||
|
|
8e13485c9b |
fix(stats): count agent sessions from hook transitions, not OSC titles (STA-2445) (#14657)
* test(stats): dual-record the OSC-title detector against canonical hook transitions
Adds an AgentSessionTransitionRecorder that derives agent-session start/stop
boundaries from agent-hook status transitions, and a side-by-side comparison
that feeds both pipelines into a real StatsCollector.
Nothing is rewired yet — this commit only measures the delta:
title detector canonical
hook-only agent 0 1
braille-spinner non-agent TUI 1 0
one agent, one reconnect 2 1
totals 3 2
Refs #10201, STA-2445.
* fix(stats): count agent sessions from hook transitions and delete the title detector
Switches StatsCollector off AgentDetector and onto the canonical agent-hook
status stream, then removes the detector and its raw-PTY invocation.
- main/index.ts subscribes the recorder to subscribeEnrichedStatus and
subscribePaneStatusClear, next to where StatsCollector is constructed.
- orca-runtime.ts no longer feeds raw PTY bytes to a stats detector.
- StatsCollector keys sessions on a stable pane key, not a per-spawn ptyId.
Fixes #10201, refs STA-2445.
|
||
|
|
eb0ec39242 |
fix(runtime): stop one unreachable relay from freezing every workspace as active (STA-517) (#14649)
* fix(runtime): stop one unreachable relay from freezing every workspace as active The worktree.ps liveness refresh is the only thing that retires an exited PTY, and mobile renders "active" straight off the summary it produces. Its aggregate inventory ran every provider through Promise.all with no per-provider deadline, so a single SSH relay that rejected — or simply did not answer inside the 3s budget, since a relay list runs to the mux's own 30s default — cost the runtime the whole inventory. Nothing was ever proven dead, so every retained pane kept reporting hasHostSidebarActivity/liveTerminalCount, and the SSH workspaces stayed "active" on mobile for as long as the connection stayed unreachable. Settle each SSH provider independently and forward the caller's deadline, so local and healthy relays are still reconciled. A provider that does not answer is unknown, not empty: the runtime's existing hasPty rescue keeps its panes. A local failure still fails the aggregate, matching pty:listSessions. The restored-orchestration-authority sweep now runs after that rescue, so a pane the controller still vouches for keeps its handle instead of losing it to a listing that merely omitted it. STA-517 * test(runtime): assert the provider scope, not the exact arity, of inventory calls These assertions exist to prove which provider scope the inventory asked for. Forwarding the caller's deadline added a second argument, which broke them on arity alone. Match the scope argument and require a numeric deadline beside it, so the intent is preserved and the budget is covered too. * test(runtime): type the inventory mock's scope parameter A bare `async () =>` mock types mock.calls as an empty tuple, so reading the scope argument off it fails typecheck. Declare the parameter the runtime actually passes. |
||
|
|
545b3fba08 |
refactor(shell): build every zsh startup wrapper from one shared builder (#15245)
* test(shell): pin every generated shell wrapper file with byte snapshots Captures .zshenv/.zprofile/.zshrc/.zlogin, the bash rcfile, and the fish init command for all three transports (local PTY, daemon/SSH, relay) so the upcoming wrapper unification can be proven byte-for-byte identical. * refactor(shell): build every zsh startup wrapper from one shared builder Local PTY, daemon/SSH, and relay each had their own copy of the zsh ZDOTDIR wrapper templates, and the copies had drifted. buildZshStartupWrapperFiles now produces .zshenv/.zprofile/.zshrc/.zlogin for all three, with every real difference expressed as a field on ZshStartupWrapperSpec. No behavior change: the generated text is byte-for-byte identical for every configuration, pinned by the snapshots committed in the previous commit (captured from the pre-refactor generators). |
||
|
|
7a695c70f1 |
test(e2e): harden triaged CI failures (#14656)
* test(e2e): harden triaged failures
* test(e2e): ship relay bundle to reusable shards
* test(e2e): tolerate expected IPC closures in daemon shutdown
A normal client exit can close the IPC channel before the finish ack
lands. Distinguish this from real failures by checking error codes,
only throwing if forced cleanup occurred or the error is not an IPC
closure.
* rm doc
* test(e2e): return termination status from legacy close handler
- terminateLegacyCloseClient now returns a discriminated union indicating
whether the process had already exited ('already-exited') or termination
was actually attempted ('termination-attempted')
- Allows finishLegacyCloseClient to only set forcedCleanup when termination
was genuinely needed, not when the process exited cleanly on its own
* test(e2e): fix dispatch contract and voice mic locator
Point the release E2E contract at the renamed build step, and assert the
relabeled microphone through the Voice pane combobox even when Radix
leaves the listbox open.
* test(e2e): add contract test for relay artifact dispatch
Validate that the relay artifact built in CI is properly uploaded,
downloaded, and passed via ORCA_RELAY_PATH to E2E test runs.
* Distinguish between terminated and already-exited processes
Detect when processes have already exited instead of always reporting
termination success. Return booleans from cleanup functions to indicate
whether they actually signalled a process, catch tree-capture failures
when the root process exits before recording completes, and use these
signals to return accurate exit status from termination handlers.
* test(e2e): stabilize file creation and voice microphone tests
Use stable locators (aria-autocomplete, named triggers) and add retry
logic to handle file scans and device events that can interfere with
listbox state. Increase timeouts to allow async operations to complete.
* Add retry logic for transient GitHub API errors in PR body updates
GitHub API occasionally returns transient 5xx errors. Retry up to 3 times
with exponential backoff (1s, 2s, 4s) to improve reliability during
temporary service disruptions. Export updatePullRequest and add sleepImpl
parameter for test injection.
* Add tab search result retention during typing
Keep search results on screen while the deferred query catches up with
the live query. Re-validates results against the current input without
dropping rows prematurely, ensuring the user can select from what they see.
* Add proper types to tab search mock
Replace `unknown` with concrete types (`OpenTabSearchResult`,
`OpenTabSearchEntries`, `SearchableWorkspaceTab`) and use type guards
for discriminated unions to improve test type safety.
|
||
|
|
640e8a4322 |
test(updater): await the linux install re-proof instead of budgeting turns (#15246)
`settleQuitAndInstall` gave the pre-install digest re-proof a fixed budget of 40 real event-loop turns. That budget is wall-clock, not work: the re-proof does two realpaths, an lstat and a streamed sha512, and on a loaded CI runner those outlast ~40ms of setTimeout(0) turns. When they did, the test asserted early and its unfinished install continued inside the *next* test — against the same mock singletons, since `vi.resetModules()` only affects later imports and leaves the old module instance running. Hence the reported pair: one case missing `post_commit_cleanup_failed`, the next seeing killAllPty called twice. Wrap `revalidateLinuxPackageForInstall` for every test in this describe (the wrapper delegates to the real implementation, so artifact state stays real) and await the promise it hands back, then drain again in `afterEach` so no re-proof can outlive the test that started it. `holdRevalidation` folds into the same probe as an opt-in mode. The turn loop stays as slack for microtask-only tails, but is no longer load-bearing: with the loop set to zero turns the suite still passes, where before the fix 11 of 19 cases failed with the reported assertions. Fixes #15243 |
||
|
|
9b1f0373eb |
fix(relay): scope shell history for Windows -> WSL panes (STA-4682) (#15236)
`injectRelayHistoryEnv` matched only bash*/zsh*, so a relay pane launched through `wsl.exe` got no HISTFILE at all and every WSL worktree shared one global history. The history file stays on the relay host under the existing flat root, so `deleteRelayHistory` remains the deletion counterpart unchanged; only the exported path is translated to drvfs for the guest, and WSLENV carries it across the boundary. Guest fish stays out of scope on purpose: its history file lives inside the distro, where the relay has no deletion path. |
||
|
|
c40b0ab96b | fix(dev): stop macOS Keychain password prompts on pnpm dev (#15183) | ||
|
|
9a41119a99 |
feat(crash-reporting): read-only Windows install-dir DACL probe breadcrumb (#15107)
* feat(crash-reporting): read-only Windows install-dir DACL probe Records whether the install tree carries an orphan S-1-15-2-* package ACE with no S-1-15-2-1/-2 to satisfy it — the state that reproduces the 0x80000003 GPU/renderer init crash 10/10 (electron/electron#51761). Diagnostic only: never writes an ACL, never changes behavior. * fix(crash-reporting): evaluate the ACL signature per target and flag locale risk Three readiness-review findings: - the signature was merged across targets, so a grant on the directory masked its absence on the module file - the exact per-file state the probe exists to detect - the well-known package name check is English-only and icacls localizes it, so a non-English box could false-positive silently; report whether the check could be trusted - the serve-mode test omitted platform, so the gate was never exercised Also switch to the durable recorder (this runs after initObservability, so the span lands in the diagnostics bundle) and correct two comments that misstated where the probe runs. |
||
|
|
63dbf12d14 |
Split github client (#15214)
* refactor(github-client): reorganize client into lifecycle folders * refactor(github-client): extract PR refresh data and outcome assembly Separate the derived data calculation and outcome assembly logic from branch-lookup-resolution into dedicated modules for better separation of concerns. Modernize type import syntax and format exports consistently. * refactor(github-client): improve error handling and resilience Defensive GraphQL parsing prevents partial responses from breaking REST fallbacks. Cache failures now use shorter TTLs for faster recovery. PR operations have dedicated error classification. GraphQL mutations track rate limit usage to prevent quota exhaustion. Data validation improved to reject spurious values. * Extract check rerun error classification with operation context Create classifyRerunChecksError() to provide operation-specific error messages when check reruns fail. This replaces generic GitHub error copy with context appropriate to what the user attempted (rerun checks). Follows the pattern of classifyListPrsError and improves error handling by delegating extraction to extractExecError. * Make check-rerun not-found error message resource-neutral Error handling for failed check reruns now covers both workflow-run reruns and standalone check-run rerequests. Tests verify the neutral message works for both scenarios. |
||
|
|
3d29a2604e |
fix(terminal-history): drop an inherited Orca fish_history so nested Orca panes stop merging worktree histories (STA-4682) (#15195)
* fix(terminal-history): drop an inherited Orca fish_history (STA-4682) fish EXPORTS `fish_history`, so an Orca launched from a fish pane keeps the launching worktree's session name in process.env. Every fish pane of the nested app then hit the check-before-set early return and wrote into that one worktree's history file, in every worktree. Drop Orca-minted names (desktop and relay prefixes) wherever the session is injected, and in the history-disabled and daemon spawn paths; a genuine user value still wins. * fix(relay): drop an inherited Orca fish_history on every spawn path (STA-4682) injectRelayFishHistoryEnv runs only for a fish pane with history isolation on and a worktreeId, so a relay that inherited an Orca-minted fish_history kept it on every other path — scoping those panes to another worktree's history file. The desktop drops it on both branches; scrub it in buildSpawnEnv so relay spawn and revive match. Also record why injectWslFishHistoryEnv keeps its own drop (redundant with both current callers, kept as the function's precondition). |
||
|
|
e39def3825 |
fix(repo-identity): bound git remote-identity probes and retire them with their repo (#15196)
* fix(repo-identity): bound git remote-identity probes and retire them with their repo
The local `git remote -v` probe ran with no timeout and no signal, and the
runner only arms its kill timer when a timeout is passed, so a hung NFS/SMB
cwd or a wedged `wsl.exe -d <distro>` left the promise unsettled and the child
alive. Because the sweep is sequential and dedupes per location, that one
wedged location stalled enrichment for every other repo.
- probeGitRemoteIdentity/detectGitRemoteIdentity take `{ signal, timeoutMs }`;
local reads get the 5s background local-git-read budget, SSH gets a budget
under the relay's 30s request timeout. Timeouts/aborts still map to
`unavailable`, never `no-remote`, so they cannot clear a resolved identity.
- In-flight probes are tracked with an AbortController and retired (aborted +
dropped) when their location is no longer backed by a repo, so a re-added
repo is not poisoned by the dead entry and a retired probe cannot re-seed a
retry deadline.
- Added the missing sweep-level guard so repos:list / projects:list /
projectHostSetups:list coalesce into one pass instead of stacking one
sequential sweep per list IPC.
STA-4452
* refactor(repo-identity): bound the enrichment listener set to stable caller references
Every call site allocated a fresh onChanged closure, so the Set that notifies
coalesced sweeps deduped nothing: during a chain that never quiesces it grew one
entry per list IPC and multiplied the repos:changed broadcast. Hoist the closures
to stable references in ipc/repos.ts and OrcaRuntimeService, and state the
contract on the set.
Also: guard the synchronous retirement call so the fire-and-forget entry point
keeps its no-throw contract, drop the placeholder promise in favour of building
the in-flight entry in one shot, and make the coalescing test use a shared list
reference plus a distinct runtime reference so it detects both stacked passes and
a dropped caller.
|
||
|
|
ffb695b958 |
fix(daemon): stop a failed spawn cancel from tearing down the shared connection (STA-4663) (#15194)
* fix(daemon): stop a failed spawn cancel from tearing down the shared connection (STA-4663)
`onCreateCancellationFailure` fired on ANY rejection of the `cancelCreateOrAttach`
RPC, including its own 5s timeout and application-level `ok:false` replies. That
called `handleDisconnect`, which rejects every in-flight request and destroys both
sockets — killing every sibling session on the daemon.
Only an undeliverable cancel now escalates, signalled by the new
`DaemonConnectionLostError`. A refused or timed-out cancel falls back to the
existing bounded `unmatchedCancelGraceMs` wait and then rejects just its own request.
Also wraps the control-socket write so a synchronous throw drops the pending entry
and its timer instead of leaking them.
STA-4663's premise — that legacy daemons reject `cancelCreateOrAttach` as an unknown
request type — is incorrect; the handler has existed since protocol v11 (
|
||
|
|
f8e728bb8b |
fix(watcher): watch the resolved worktree root so symlinked and differently-cased paths work (#15077)
* fix(watcher): keep macOS FSEvents paths under the subscribed worktree root
macOS FSEvents reports OS-canonical paths: symlinks resolved and every
directory in its on-disk spelling. Linux (inotify) and Windows both rebuild
event paths from the directory that was subscribed, so only macOS observes
the mismatch.
Orca's watcher contract is "event paths live under worktreePath". Consumers
derive a worktree-relative path with relativePathInsideRoot(), which returns
null when the event falls outside the root -- and a null relative path drops
the event silently. So on a Mac whose worktree or folder path traverses a
symlink (~/code -> /Volumes/..., anything under /tmp or /var), or is spelled
with different casing than disk on a case-insensitive volume, every watcher
event was discarded: the editor never reloaded an agent's edit, the File
Explorer never refreshed, and Source Control never re-ran status. Nothing
errored, which is why this looked like "the file watcher stopped working"
on some machines and not others.
Rewrite event paths back onto the subscribed root inside
subscribeThroughWatcherSupervisor -- the single boundary every desktop,
runtime-environment, and SSH-relay watch passes through -- so one change
covers all three transports.
The resolution runs alongside the subscribe rather than before it: an await
ahead of the subscribe call lets a caller's abort land in a window where no
watcher-process subscription exists to cancel, which hangs the existing
cancellation contracts. The subscribe promise settles only after the rewrite
is installed, and only after the subscription itself is recorded, so
teardown never waits on a realpath.
Matching folds per path segment (NFC + case) instead of by prefix length,
because both folds change length and a folded-prefix length would slice the
raw event path mid-character. Byte-exact fast paths run first, so unaliased
roots -- every Linux and Windows watch, and most macOS ones -- cost one
string comparison per event and allocate nothing.
* fix(watcher): watch the resolved root so symlinked worktrees work on Linux too
Verified on a real Linux host: @parcel/watcher passes IN_DONT_FOLLOW |
IN_ONLYDIR to inotify_add_watch, so a symlinked worktree root fails outright
with ENOTDIR ('Not a directory'). The watch never installs and Orca caches the
root in unwatchableRoots, so it is never retried for that session. That is a
worse symptom than the macOS path-spelling mismatch and hits Linux users of
symlinked checkouts on every machine.
Hand the backend the resolved directory instead of the caller's spelling, and
keep mapping delivered paths back. Resolving the root also lets
@parcel/watcher's own ignore paths match again on macOS, where they were
computed from the unresolved root and silently excluded nothing.
The resolve is synchronous on purpose. Every caller reserves and forks its
watcher child in the same tick as the subscribe call -- capacity accounting and
cancellation ordering both depend on it, and 30+ existing tests encode it -- so
an await here would open a window where a subscribe is issued but no
cancellable child exists.
* test(watcher): use a directory junction on Windows so the alias repro runs there
Creating a directory symlink on Windows needs elevation or Developer Mode, so
the alias tests failed with EPERM on a real Windows host. A junction needs
neither, is what users actually have (a junctioned C:\dev), and realpath
resolves it identically -- so one fixture now covers all three platforms and the
end-to-end repro no longer skips outside Linux and macOS.
* test(watcher): pin the fabricated-path failure modes of the root rewrite
A rewrite that returns a WRONG path is worse than no fix -- a consumer would
act on the wrong file -- so pin the cases that could produce one: sibling
directories that share a prefix with the root (POSIX and UNC), the root itself
versus a shorter path, drive-letter casing, a root-only canonical path, and a
script where toLowerCase changes length. Found by running the rewrite over an
adversarial table; all already passed, so these lock in behaviour rather than
fix it.
* docs(watcher): drop an unverified claim about ignore paths
I claimed resolving the root also repairs @parcel/watcher's ignore-path
matching for aliased roots. Probing it on macOS shows the node_modules write is
excluded either way: FSEvents resolves symlinks in its own exclusion paths, so
the daemon filters at the source regardless of which spelling we subscribe with.
On Linux the exclusions are userspace globs relative to the watched directory
and there was no watch at all before this change, so there is nothing to
compare. Removing the claim rather than leaving a plausible-but-wrong rationale
in the module header.
* refactor(watcher): simplify root path rewriter
* test(palette): build searchable fixture documents
|
||
|
|
0e96b82e44 |
fix(mobile): keep phone tab selection across host snapshots
* fix(mobile): keep phone tab selection across host snapshots Preserve device-owned tab focus across ordinary host republications while explicit follow navigation remains authoritative. Retire closed selections across clients so stale snapshots cannot resurrect tabs. * fix(mobile): acknowledge session tab closes * fix(mobile): avoid tombstones for uncommitted closes * fix(web): implement session close IPC stubs * refactor: simplify mobile tab close flow * fix: bound session tab close confirmation |
||
|
|
24e662adc1 |
feat(ssh): verify host keys, and restore panes correctly across a reconnect (#14844)
* docs(ssh): design for real host key verification (STA-4319)
Today's ssh2 verifier records a fingerprint and returns true — every host key is
accepted, with no known_hosts consult and no change detection anywhere in
src/main/ssh/. Scope is per-connection, so exec, SFTP, port forwarding, the
watcher and relay deploy all ride that one unverified handshake, and the
ProxyJump path puts the final hop — the topology most likely to cross untrusted
network — on ssh2 specifically.
Decisions worth calling out:
- Read the user's known_hosts as a trust source but NEVER write to it. That file
is shared with every other SSH tool on the machine; appending means line
endings, permissions, concurrent writers and a corruption blast radius well
beyond us. Accepted keys go to our own per-target store. Reading theirs is also
the entire migration story: most developers already have their hosts there.
- Mismatch is scoped to the SAME key type. A host with only an RSA entry that
presents ed25519 is unknown, not changed. ssh2 negotiates ed25519 first, so
without this we would fire a change-of-key alarm at nearly every existing user
on their first upgraded connect — training them to dismiss the one warning that
is supposed to mean something. Flagged in review as the decision I am least
sure of; a downgrade-vector argument against it is being tested.
- Changed key hard-fails with no override button; recovery is a separate explicit
action, offered only when OUR store is what disagreed, because forgetting our
record cannot unblock a known_hosts conflict.
- Background reconnects deny rather than prompt. A dialog the user cannot place
in context only teaches click-through.
Two traps are documented because either would make the fix silently do nothing:
an async verifier returns a Promise, which ssh2 reads as truthy and accepts
immediately; and the existing test mock invokes hostVerifier with one argument
and ignores the return, so it would pass against a verifier that never decides.
Design only — no behaviour change. The doc is added to the tracked-reference
allowlist in .gitignore alongside the other docs/reference entries.
* docs(ssh): revise the host key design after security and migration review
Three things the reviews changed, kept visible rather than quietly edited out.
THREAT MODEL WAS WRONG IN THREE PLACES. Jump hosts are not the worst case — they
are already safe: shouldUseSystemSshTransport branches on exactly the inputs
resolveEffectiveProxy does, and attemptConnect returns after the system probe, so
ProxyJump goes through OpenSSH and is verified. Agent forwarding was overstated
(gated on the user's ForwardAgent). Credential theft was understated: any auth
error counts as agent fallback, so a MITM walks the user to the password AND
private-key passphrase prompts, and cachedPassword replays without prompting. The
relay claim was backwards — the attacker owns their own machine; the real impact
is the return direction, where they become the host our workspace trusts.
TYPE SCOPING IS A DOWNGRADE VECTOR WITHOUT ALGORITHM ORDERING. This was the
decision I flagged as least certain and asked to have argued both ways. OpenSSH
is safe only because order_hostkeyalgs() puts known types first and RFC 4253
gives the client's order priority. ssh2 negotiates ed25519 first regardless, so
an attacker who cannot forge the RSA key on file just presents ed25519 and gets a
friendly first-contact prompt instead of a hard failure. Keep scoping, but set
algorithms.serverHostKey to lead with the types on file — and add a sixth
outcome for 'unknown type, known host', which must never read as first contact.
SHIP THE DEFENCE BEFORE THE DIALOG. Startup restore fires eager connects for all
targets in parallel with a 15s timeout while a prompt would live 120s; ephemeral
VM targets present a new key every launch; paired-web connects run on the host
desktop, so the dialog opens on someone else's screen. Phase 1 is therefore no
modal at all: consult known_hosts and our store, match connects, unknown persists
with accept-new semantics, mismatch and revoked hard-fail. That is the whole MITM
defence with none of the migration risk.
Also folded in, verified live against OpenSSH 10.2p1: the without-port fallback
(bracketed lookup first, then bare, where the second pass can only yield match or
unknown — otherwise a bare line plus a non-default port produces a spurious
prompt); hashed entries hash the candidate form; multiple files union; a
cert-authority line does not match a plain key. IPv6 and bracket parsing moved
INTO scope — that is a parser requirement, not a scope call, and getting it wrong
produces the prompt-training harm the design exists to avoid.
* feat(ssh): parse and match OpenSSH known_hosts
The matcher half of STA-4319. No behaviour change yet — nothing calls this.
Hand-rolled because no maintained JS implementation exists, and written against
behaviour observed from OpenSSH 10.2p1 rather than inferred from the man page.
Three of those behaviours a reasonable reading gets wrong:
- A non-default port is TWO ordered lookups, not one candidate set: '[host]:port'
first, then bare host ('checking without port identifier' in ssh -v). The
fallback pass can only yield match or unknown — OpenSSH downgrades a wrong key
there rather than reporting a change. Collapse them and anyone holding a bare
line who connects off-port gets a spurious first-contact result; treat the
fallback as authoritative and they get a false change-of-key alarm.
- Revocation resolves in its own pass so the verdict cannot depend on line order.
Verified both orderings.
- A cert-authority line never matches a plain host key; it only validates
certificates. A normal line alongside it still decides.
Mismatch is scoped to the same key type, and a host known by a DIFFERENT type
returns unknown-type-known-host rather than plain unknown — an attacker who
cannot forge the key on file must not get a friendly first-contact result by
presenting another type. That outcome is only half the defence; the other half
(leading serverHostKey with known types) lands with the wiring.
47 tests from vectors executed against real sshd, including ssh-keygen -H hashed
entries. Each of six mutations reddens it: collapsing the passes, letting the
fallback report mismatch, dropping type scoping, resolving revocation in line
order, honouring an unrecognised marker, and skipping the blob/type agreement
check.
* feat(ssh): decide what to do with a presented host key
The policy half of STA-4319, kept separate from the ssh2 wiring so it is testable
without a handshake and injected rather than importing its sources, so a test
states its own trust state instead of writing files.
Phase 1 ships no dialog — a test asserts the decision is never 'prompt'. Startup
restore opens every previously-active target at once, ephemeral VM targets would
ask every launch, and paired-web connects run on the host desktop where the
dialog would appear on someone else's screen.
Ordering that matters: revocation outranks everything including
StrictHostKeyChecking=no, because a revoked key is a statement that this key is
known-bad rather than merely unrecognised. known_hosts is named before our own
store on a change, because its remedy (ssh-keygen -R) is the one that also
unblocks ssh and git — pointing at a remedy that cannot work is worse than none.
Two carve-outs with reasons: an ephemeral runtime target accepts WITHOUT
recording, since a fresh VM presents a new key every launch and a stored record
would accumulate per launch and eventually read as a spurious change; and when
ssh -G ran on the HOME-divergent path that suppresses /etc/ssh/ssh_config, an
unknown host is denied, because a site-wide policy may forbid it and being laxer
than ssh is the one outcome that is never acceptable.
Rejection text deliberately avoids 'authentication failed' and 'permission
denied': the reconnect ladder classifies on those substrings, so a denial phrased
that way is retried forever against a decision that will never change. Pinned by
a test.
* feat(ssh): build the host key verifier and the algorithm order that makes it safe
Still not wired into the handshake — that lands next. This is the piece that
turns a decision into an ssh2 callback, plus the half of the design that is easy
to forget because it lives in a different config field.
The verifier MUST be a plain function returning undefined. ssh2 does
'const ret = verifier(key, verify); if (ret !== undefined) verify(ret)', so an
async function returns a Promise — neither undefined nor falsy — and ssh2 accepts
the key immediately while ignoring whatever the callback later decides. Making
this async would silently restore exactly the accept-everything behaviour the
module exists to remove, so a test asserts the return value is undefined.
orderServerHostKeyAlgorithms is what makes type-scoped matching safe rather than
a downgrade. RFC 4253 gives the client's algorithm order priority, so leading
with the types we already hold for a host denies a server the choice of
presenting some other type to convert a hard failure into first contact. Without
it, an attacker who cannot forge the key on file just offers a different
algorithm. Revoked entries never contribute to that order.
Also fails closed on two paths that would otherwise hang or over-trust: a key
whose own length-prefixed header cannot be read is refused rather than reasoned
about, and a throw from any dependency denies, because ssh2 may not catch an
exception raised inside the verifier and the handshake would hang instead of
failing.
18 tests. Includes the two negative cases that matter — first-contact keys are
recorded, but keys we already know, rejected keys, ephemeral runtime targets and
a lax StrictHostKeyChecking are not.
* fix(ssh): promote every RSA signature algorithm for a known ssh-rsa key
A known_hosts entry names the KEY type, which is not the negotiated ALGORITHM
name. One ssh-rsa key is offered as rsa-sha2-512, rsa-sha2-256 or ssh-rsa
depending on the signature algorithm, so matching the literal name only would
leave a host we know by RSA ordered behind ed25519 — precisely the ordering this
function exists to prevent, and precisely the population (RSA-era known_hosts
entries) it was written for.
Verified from ssh2's own negotiation while wiring this: kex.js iterates the
CLIENT list and takes the first entry the server also offers, so client order
does decide, as RFC 4253 says. ssh2's default order leads with ed25519 and places
the RSA algorithms fifth through seventh.
* fix(ssh): verify host keys instead of accepting every one (STA-4319)
The actual fix. ssh-connection's verifier recorded a fingerprint and returned
true, so every ssh2 connection accepted every host key — no known_hosts consult,
no change detection. It now consults the user's known_hosts plus our own store
and refuses a changed, revoked or unverifiable key.
Phase 1 by design: no dialog. Unknown hosts are accepted and recorded
(accept-new semantics), because startup restore opens every previously-active
target at once, ephemeral VM targets present a new key each launch, and
paired-web connects run on the host desktop where a prompt would appear on
someone else's screen. The MITM defence lands now; the prompt is Phase 2.
Also sets algorithms.serverHostKey to lead with the types already known for the
host. Without it the type-scoped matching is a downgrade — an attacker who cannot
forge the key on file just presents another type and turns a hard failure into
first contact. Verified from ssh2's kex.js that the client list decides.
Denial replaces ssh2's generic handshake error with the specific reason, because
the reconnect ladder cannot distinguish a generic failure from a transient fault
and would retry forever against a decision that will never change.
An unreadable trust store degrades to known_hosts only rather than failing the
connect: a changed key is still refused, and a host trusted only by us falls back
to first contact and is re-recorded, reaching the same decision.
The ssh2 mock now uses the callback form and aborts the handshake on denial. As
written it called hostVerifier(key) with one argument and ignored the result, so
it would have passed against a verifier that never decides — flagged in the
design as a mock that had to change, not a test to quietly rewrite. Two new tests
pin the wiring rather than the module: an unidentifiable blob is refused, and a
well-formed key is accepted.
Note for review: commit
|
||
|
|
15efc87e35 |
fix(agent-hooks): bind agent status to the pane its session was spawned into (STA-2069) (#14615)
* fix(agent-hooks): bind agent status to the pane the session was spawned into (STA-2069) Claude Code >= 2.1.206 hosts TUI sessions as workers under a shared daemon, and the daemon forwards only its own allowlisted env — so hook posts carry whichever pane first started the daemon, not the pane the user is in. Pin a minted --session-id at spawn where Orca still knows the pane, record sessionId -> pane, and correct the posted key at both hook ingest seams. Co-authored-by: Brian Dai <43929761+BrianDai22@users.noreply.github.com> * fix(agent-hooks): pin the session id in root-option position, not appended Appending `--session-id <uuid>` broke every `claude <subcommand>` launch: `--session-id` is a ROOT option, so `claude mcp list --session-id <uuid>` exits with "error: unknown option '--session-id'". Splice it immediately after the executable token instead, which is valid for both a bare session and a subcommand, and is already before claude's own `--` terminator. Also write the binding-key separator as an escape rather than a raw NUL byte, which made the file a binary blob in git. Close three hunks that no test could fail on: the pty.ts spawn call site that records the binding, the relay seam's worktreeId override, and the already-correct-pane early-return that suppresses a worktree restamp. --------- Co-authored-by: Brian Dai <43929761+BrianDai22@users.noreply.github.com> |
||
|
|
8cd338357e |
fix(runtime): tear down terminal subscriptions only through their owning registration (#14992)
* fix(runtime): tear down terminal subscriptions only through their owning registration
A terminal.subscribe teardown was keyed on `${terminal}:${clientId}`, which is
stable across reconnects. cleanupSubscription invokes whichever cleanup currently
owns that key, so after a mobile reconnect rebound the id, a late teardown from the
dead connection killed the replacement stream and the terminal froze (STA-4510).
Add registerOwnedSubscriptionCleanup, returning a registration handle whose
releaseIfCurrent no-ops once the id has been rebound, and route all 12 teardown
call sites in the three terminal.subscribe branches through it. Register-time
eviction now also targets the owner it captured rather than re-resolving the key.
terminal.unsubscribe gains the same ownership rule via connectionId, matching the
runtime.clientEvents.unsubscribe precedent: make-before-break migration sends the
unsubscribe over the old session after the new one has already rebound the id.
The teardown-by-key pattern predates the bug; #7490 made it reachable by binding
the exit-waiter to the per-socket abort signal, so every socket close now runs it.
Tests use a faithful subscription-registry double; the ad-hoc Map stubs they
replace never evicted the prior generation, which is why no test caught this.
* test(runtime): migrate the remaining subscription stubs to the faithful registry
streaming.test.ts and terminal-provider-snapshot-sequence.test.ts still stubbed
registerSubscriptionCleanup only, so terminal.subscribe's owned registration was
undefined and teardown never fired. Assert the registration is retired rather than
spying on cleanupSubscription, which the owned path now calls internally.
* fix(runtime): guard the lease-only presence release and use the shared registry double
Review findings on this PR:
- The lease-only branch's compensating handleMobileUnsubscribe ran unconditionally
after a rebind. Presence is keyed (ptyId, clientId) with no refcount and `closed`
is exactly the post-rebind state, so a superseded handler deleted the replacement
subscriber's presence. Gate it on the registration still being current. This also
gives SubscriptionRegistration.isCurrent its production caller.
- The STA-4510 regression test hand-rolled a second registry copy that diverged from
the shared double (no try/catch around cleanup, no in-flight join). Import the
shared double instead, so the test proving the bug uses the same fidelity as the
rest of the suite.
- cleanupSubscriptionIfOwnedByConnection treats an absent connectionId as authority.
That is the connection-less local unix-socket path, not an oversight; say so.
* fix(runtime): drop the lease-only presence guard; it disabled a real compensation
Review pass 2 showed the guard added in the previous commit was wrong.
registration.isCurrent() is always false whenever `closed` is true: either our own
cleanup ran, in which case cleanupSubscriptionAndWait already deleted the map entry,
or a rebind replaced it. So the guard did not distinguish the two cases — it made the
compensating handleMobileUnsubscribe unreachable, which is precisely the
resurrect-after-cleanup case that line exists to handle.
The scenario the guard was meant to fix is also not reachable: the lease-only call
passes no viewport, and both !viewport paths in handleMobileSubscribeInternal return
with no await, so the subscribe resolves in microtasks and a socket close cannot win it.
Revert the guard, and drop SubscriptionRegistration.isCurrent with it — it had no
remaining production caller and shipping unused runtime API invites exactly this.
Also from review:
- cleanupSubscriptionIfOwnedByConnection reported false for an id with no registration,
conflating 'refused, another connection owns it' with 'already gone'. Report true.
- Note on the registry double that it mirrors production and can drift; the runtime
tests pin real behavior, the doubles only pin routing.
* fix(runtime): make the unsubscribe refusal observable and restore test-double parity
Review pass 3:
- The registry test double omitted production's 'unregistered id is already gone'
early-out, so it returned refused where production returns gone. The legacy
terminal.unsubscribe path reaches that branch with a never-registered bare id, so a
routing test would have locked in the inverse of production.
- terminal.unsubscribe ORed the bare-id and composite results. Registrations always use
the composite, so the bare id reported 'already gone' and masked a real ownership
refusal: a stale connection was correctly refused but told unsubscribed: true. The
composite answer is authoritative when we try it.
- Pin why the lease-only compensating handleMobileUnsubscribe is deliberately unguarded:
it is safe only because that call passes no viewport and returns with no await.
- Cover the no-registration branch, which had no test.
* fix(runtime): report an unsubscribe refusal without masking a real teardown
Review pass 4 disproved the previous commit's rationale. A clientless legacy-JSON
stream registers under the bare terminal id, not the composite, so either call in
terminal.unsubscribe can be the real teardown. Overwriting reported false after a
destructive success; the earlier OR reported true after a refusal. AND over the calls
that actually ran is the honest aggregate: false needs a genuine refusal.
Tests:
- cover the bare-id path, which the ownership tests never exercised
- pin the microtask invariant the unguarded lease-only compensation depends on. The
first version of that test was vacuous: it raced two setTimeout(0) timers, and the
earlier-registered one always won, so it passed with an await injected. It now drains
microtasks only and goes red under that mutation.
|
||
|
|
64de8dd637 |
fix(workspaces): delete on the confirmed host, and make both hosts' rows selectable (STA-4343) (#15013)
* fix(workspaces): host-qualified workspace deletion (STA-4343, STA-4448) Squashed integration of PR #14606 + the codex review-loop output, replayed onto current main. Granular history preserved on brennanb2025/sta-4343-review-full. Fixes the regression from #13413: a workspace id is repoId::path with no host component, so the same repo at the same path on two hosts published one id for two workspaces, and deletion routed by that id landed on whichever host routing preferred - usually the ACTIVE one, not the row the user confirmed. - removeWorktree takes a REQUIRED host-qualified WorktreeRemovalTarget; omitting the host is a type error. All destructive callers migrated. - Projections dedup on (host, id), so two hosts render as two selectable rows while the createWorktree/fetchWorktrees race duplicate still collapses. - Ephemeral VM cleanup is host-scoped. It matched on bare workspaceId, so the host-scoped delete path destroyed the SURVIVING host's VM and its unpushed filesystem - a leak fix that had become data destruction. - Selection, keyboard routing, lineage grouping and Space rows carry host identity end to end; fixing the executor dedupe alone would have turned one-row intent into deleting both hosts. Files split to stay under max-lines rather than raising any cap. * refactor: split files that crossed max-lines The review-loop commits used --no-verify, so the pre-commit hook never enforced the caps. Extracted cohesive units rather than raising any limit: renderer teardown, delete-with-toast, pinned-group rows, host-scope helpers, workspace-kind predicates, filter actions, kanban drag selection, the renderer removal result type, and the native-chat persistence tests. * refactor(workspaces): extract cleanup deletion-phase selector Clears the last max-lines violation and the import-type side effect the changed-code gate flagged. * refactor(sidebar): track the delete-dialog extraction modules * fix(workspaces): preserve host identity across remaining surfaces * fix(sidebar): re-carry host through the rewritten palette result model #15170 replaced PaletteSearchResult while this PR was open. Re-applied the host qualification on top of the new model instead of taking either side: results carry worktreeHostId again, and the board filter keys its matched set on host identity rather than the bare id. Known gap, documented in the board test rather than deleted: searchWorktrees resolves evidence through a `documents` map keyed by BARE worktree id, so two same-id host rows collapse before this code sees them. Closing that belongs with the palette work. * test(cmd-j): pin the palette collision gap instead of asserting the old model The palette collision test asserted two host-qualified rows, which #15170's rewrite made unreachable: item ids are bare again and worktreeMap is id-keyed. Rewritten to assert what holds — activation always names a host — and to pin the defect it exposes: two same-id rows render on ONE command value, so React sees duplicate keys and a click on the first row activates the second row's host. That reproduces on main, so it is pre-existing, not from this PR. Pinned rather than deleted so fixing it must update this test. --------- Co-authored-by: QA <qa@local> |
||
|
|
1412ae2d91 |
Revert "fix(terminal): inset the grid inside the xterm surface (#14583)" (#15181)
This reverts commit
|
||
|
|
1a04d292b6 |
fix(agents): lift the pane retirement fence when a live PTY re-attaches (STA-4114) (#14624)
* fix(agents): lift the pane retirement fence when a live PTY re-attaches (STA-4114) A detach/reattach cycle retires the pane on both sides — the main hook server's closedAgentStatusPaneKeys and the renderer's recentlyRetiredAgentStatusPaneKeys — and nothing ever cleared either one. The pane then rejected every later working/done event for the rest of its life while Pi kept running normally in the same PTY. Bind the fence to the fact it asserts: retirement claims the pane is gone, and binding a live PTY to that exact pane disproves it. Clear both tombstones at the spawn/attach chokepoint and at the daemon-backed reattach path, so recovery does not depend on the agent starting another turn — a pane re-attached mid-turn only has agent_end left to report, and one re-attached while idle emits nothing at all. Closed-tab tombstones are a separate, stronger claim and are deliberately left standing. * test(agent-hooks): re-arm the idle re-attach test against a turn-boundary fix The idle re-attach assertion posted only before_agent_start, which #14626 turns into a fence-lifting turn boundary. Under that change the test passes whether or not restorePaneAuthority runs, so it stops pinning this PR's mechanism. Assert first on agent_end — a non-turn event — so the test proves the fence was already down when the hook arrived. Verified: with restorePaneAuthority neutered AND before_agent_start added to the restart predicate, the old assertion passes and the new one fails. * fix(agents): lift a retired pane's whole fence, aliases included (STA-4114) Retirement fences the pane, its resolved owner, and every alias of it, then deletes those aliases. Restoring only the key handed to us left the rest standing — and a detached pane's process keeps posting the key it launched under (server.ts:1614), so the canonical re-attach case stayed suppressed with the fence apparently lifted. Verified against the real omp binary: the row came back under the stale launch pane instead of the detached owner. Record what each retirement fenced and replay it as a unit, rebuilding the aliases it deleted. Keys and aliases belonging to a closed tab are skipped, so the stronger claim survives and a live process is never routed back into a closed tab. The record is indexed by every fenced key and bounded at 1024 like the maps it mirrors; an evicted record degrades to the old behaviour. Also records why the renderer's restore IPC is deliberately unguarded: that map is not a mirror of main's (retirePtyAgentLaunchAuthority fences main directly on command-finished and PTY exit, and nothing pushes it back), and it is per-window and non-persisted, so gating the send on a local tombstone reintroduces this bug for exactly those panes. |
||
|
|
c303d36228 | fix(opencode): keep the pane working while a background subagent runs (#9692) (#14712) | ||
|
|
7c79a0f9e3 |
fix(persistence): harden persistence edge cases (#15171)
* fix persistence edge cases * Persist original folderPath value without trimming The guard validates that the trimmed path is non-empty; persist the original input value that passed validation rather than a transformed version. * Fix cross-host pane conflicts and persistence edge cases Prevent ambiguous routing when tab IDs are shared across host partitions by skipping alias registration for colliding tabs. Ensure repaired null lineage maps are marked as changed so they're re-saved on reload. Use execution host instead of connectionId for git username enrichment to handle runtime repos correctly. |
||
|
|
c4e188a25f |
fix(opencode): emit a default export the plugin loader accepts (STA-3097) (#14612)
OpenCode resolves a plugin file through either a named factory export or the
module default export. The generated orca-opencode-status.js only carried the
named export, so the default-export loader had nothing to read.
Verified against opencode 1.18.18: a default of { id, setup } is refused with
"must default export an object with server()", while { id, server } loads. Emit
that shape and keep the named export so the factory loader is unaffected.
|
||
|
|
619ee2cc90 | fix(agent-hooks): detect IDS-truncated hook POSTs instead of failing open silently (STA-2870) (#14625) | ||
|
|
5652fb7469 |
fix(codex): stop resuming a session under the wrong account when a sessions tree is locked (#15093)
* fix(codex): stop resuming a session under the wrong account when a sessions tree is locked Two probes reported "this rollout is not bridged here" for any filesystem error, not just a genuine absence: - codex-session-resume-home.ts used existsSync on each ranked home's sessions directory. existsSync returns false on EBUSY/EPERM, so a briefly locked tree made the scan continue to the next ranked home — and the winning home becomes the resumed pane's CODEX_HOME, so it picks the account. - codex-legacy-session-resume.ts caught every lstat failure for the selected account's candidate rollout and returned null, after which the caller kept the source per-account home. Either way the session resumed under a different account's credentials while the UI still showed the selected one. Only a definitive ENOENT/ENOTDIR now means "not bridged here". Any other error raises the typed temporary-unavailability refusal the ownership gate already uses, which both PTY paths convert into a clean abort before spawn. The refusal is scoped to the SELECTED account's home. An unreadable home that is not the selected account cannot cause a wrong-account resume, so it is still skipped rather than stranding the user. These are pre-existing and independent of the STA-4422 ownership-marker failure: they route to another account today with the gate uninvolved. Fixes STA-4607 * fix(codex): refuse a resume when the selected sessions tree is locked mid-listing Review found the first pass incomplete in two places, both the same category error one layer further down. The preliminary statSync on the selected sessions root was guarded, but the real directory read happens later and listCodexSessionRolloutFilesIncrementally swallows every opendir error. A lock held during enumeration — where nearly all the I/O is, and so the far more likely case — still yielded nothing for the selected account and fell through to another one. The listing now reports directory errors through its existing onDirectoryError hook, and a non-definitive error anywhere under the selected sessions root raises the typed refusal. Non-selected homes and definitive absence still skip. Separately, index.ts wrapped prepareLegacySharedCodexSessionResume in a blanket catch and fell back to the source home, so the typed refusal from the candidate lstat was swallowed and the resume still ran under the peer account's credentials. That catch now rethrows ManagedCodexHomeTemporarilyUnavailableError while ordinary migration failures keep warning and falling back, since a genuine migration failure legitimately should not block a resume. A typed refusal is only as strong as the narrowest catch between the throw and the spawn. The frames between both throw sites and the PTY spawn were audited: findTrustedCodexSessionResume, resolveCodexSessionResumeProvenance and prepareCodexSessionResume have no catches, and the PTY layer already maps the typed error before spawn. Both fixes are mutation-checked. Disabling the listing hook makes the resume resolve to the other account again; the index.ts rethrow is covered only by typecheck, because src/main/index.ts has no unit-test entry point in this repo. * test(codex): pin nested-directory lock coverage; document the resume repin contract Review flagged the listing guard as matching only the exact sessions root, so a nested dated directory would leak. It does not — the guard keys on the root being listed, not the failing directory — but nothing pinned that. Added a test that faults only sessions/2026/07/20 while the root stats fine; it fails under mutation alongside the root case. Also documented why the index.ts rethrow cannot fire today. That launch path pins CODEX_HOME to the account that owns the rollout and deliberately refuses to repin onto whichever account is selected now (#10793), so it does not wire the selected-home resolver. The branch stays as a contract guard so the blanket catch below can never silently swallow a typed refusal if that changes. |
||
|
|
39260d16c7 |
test: properly clean up in-flight checkpoints before disposal (#15010)
Release stalled operations, wait for pending checkpoint work to complete, and stop checkpoint timers before disposing the adapter. This prevents abandoned checkpoint tmp/rename operations from recreating files under the temp directory before it's deleted. |
||
|
|
0bedeea642 | fix(orchestration): expose unsupervised dispatch lanes (#15105) | ||
|
|
32ee3b0536 |
reland(browser): route every cookie-import write through CDP identities, and never clear what it will not write back (#15030)
* reland(browser): restore CDP-identity cookie-import writes (#14729) Reverts the revert |
||
|
|
a7f1653415 |
fix(worktrees): keep retirement tombstones across project and SSH target re-add (#14917)
* fix(worktrees): keep retirement tombstones across project and SSH target re-add Generated workspace names are retired so a name is never reissued onto a cwd that still holds another workspace's Claude/Codex history. Two re-add paths lost that record. STA-4449 (local): retirement was stored only under `repo.id`. Removing a project deletes that row and re-adding the same path mints a new id, so the new repo starts with an empty registry. The on-disk backfill normally re-seeds local repos, but it cannot recover a name whose only surviving evidence is a Codex rollout JSONL — those are deliberately not scanned — so a name spent under Codex with its workspace directory gone came back. STA-4491 (SSH): the second, path-derived copy embedded the SSH target row id. Row ids are minted fresh on every re-add, so `ssh:ssh-old:...` became `ssh:ssh-new:...` and `reassignSshTargetId` migrated other carrier state but not the retirement namespaces. Keying the store on the namespace instead of `repo.id` was rejected in `nestWorkspaces`, `worktreeBasePath` and `repo.path`, so a settings toggle would orphan every retirement at once. `repo.id` therefore stays primary and the path-derived namespace stays a mirror — a settings toggle loses the mirror but keeps the repo row, a re-add loses the repo row but keeps the mirror, and reads union both. - Mirror local repos into the namespace too, not just remote ones. - Key the namespace's host half on the SSH endpoint (host+port+username), the thing that actually decides which filesystem a path lands on, instead of the target row id. Reads also accept the pre-identity key so an upgrade keeps tombstones it already wrote, and `reassignSshTargetId` re-keys the rest. - Cap the namespace map, which by design outlives the repos that wrote it and so has nothing to prune it per repo. Endpoint identity is extracted from ssh-target-readoption.ts, which already compared these fields for exactly the same reason, so re-adoption and retirement cannot drift apart. * fix(worktrees): copy shared SSH endpoint retirements instead of moving them An endpoint identity is not owned by the target row that rotates: nothing dedupes SSH targets by host|port|username, so a second live target can still resolve to the same host. Moving the bucket stripped that target's tombstones and reissued a path whose agent history is still on disk. Row-id identities stay a move — reassignment leaves nothing pointing at them. * fix(worktrees): carry retirement mirror across in-place SSH endpoint edits Config sync rewrites host/port/username on the existing target row and a runtime-owned target takes a fresh address from every provision, both keeping the row id. No re-adoption runs, so nothing carried the endpoint-keyed mirror across and it stranded — strictly worse than the pre-change key, which was the row id and was invariant under these edits. Also bound the map after a migration: a retained source bucket grows it, so the cap has to be applied there too, and compare registries by membership rather than size so an uncompacted destination cannot trade a folded name for a new one and read as unchanged. * fix(worktrees): skip retirement migration for on-demand runtime targets An on-demand VM is discarded between provisions, so its fresh address reaches an empty filesystem and a reissued name collides with nothing. Migrating there would spend names against history that no longer exists, and because each provision mints another address it would add a namespace bucket per run, evicting the real tombstones of local and ordinary SSH repos. * fix(worktrees): stop the namespace cap evicting what a migration just wrote Two defects with one root cause. Assigning to an existing key leaves it in its original insertion slot, so a merged destination kept the oldest position and the trim deleted the bucket it had just enriched. Retained source buckets are older than the destinations a copy appends, so at the cap the trim removed exactly the sources the copy existed to keep — silently turning it back into a move. The trim now exempts the keys the migration wrote or deliberately kept. Also stop on-demand runtime workspaces writing namespace mirrors at all: each provision reaches a discarded filesystem under a fresh address, so the entry can never be read back and only spends a capped slot that a local or SSH project needs. The repo-id row still records the name for the live session. * fix(worktrees): re-insert migrated namespaces so the cap cannot undo a migration Exempting keys from the trim protected them for that one call and no other. A merged destination keeps its original insertion slot, so it sat at the front of the eviction queue and the next unrelated retirement write dropped it — losing both the migrated name and the name the destination already held, on a host that had just been re-added. Re-insert what the migration writes instead, the same discipline the ordinary writer already follows, so insertion order reflects use. That also removes the exemption, which could otherwise leave the map stuck at twice the cap until one later write evicted the whole excess at once. Corrects the runtime-gate comment as well: the mirror is unreadable after the next provision, not immediately, so a remove/re-add inside one provision is a real if narrow loss. * fix(worktrees): refresh a retained namespace source even when its merge adds nothing Replacing the trim exemption with re-insertion narrowed the protection: the exemption covered every retained source, the re-insertion only covered sources whose merge actually wrote. A copy whose destination already held the same names was then neither re-inserted nor exempt, so the migration's own trim evicted the shared source bucket ahead of hundreds of untouched ones — losing the tombstones of a live sibling target still on that endpoint, which is what copying exists to prevent. A move's destination gets the same treatment: deleting the source makes it the only remaining copy, so it has been used. Both are order-only and deliberately do not set the changed flag, keeping an import that moved nothing from scheduling a save. |
||
|
|
60805f5c45 |
fix(agent-status): preserve restored child provenance (#15082)
* fix(agent-status): preserve restored child provenance * fix(agent-status): preserve restored completion context * fix(agent-status): retain child boundary across OSC |
||
|
|
646e9b692b | fix(mobile): render pairing QR at scanner-safe scale (#15058) | ||
|
|
7ae6aedc02 |
fix(codex): stop a transient filesystem error from logging out the active account (#15046)
* fix(codex): stop a transient filesystem error from logging out the active account A single unreadable read of a managed Codex home's ownership marker cleared the user's active account selection, permanently. On Windows any exclusive lock — Defender real-time scanning, a backup agent, a sync client — makes every read of that marker fail with EBUSY, and the background rate-limit poll runs every 15 minutes plus once at every app start. Root cause: the ownership gate answered two very different questions through one channel. "This home is not ours" (a successful observation that failed a trust check) and "we could not read it" both surfaced as a throw, which the caller flattened to null, which three call sites took as proof the home was untrustworthy and wrote activeCodexManagedAccountId: null. Refusing to USE an unverified home is correct. Erasing the user's account selection because a file was briefly locked is not. The gate now returns a tri-state verdict. `untrusted` comes only from a proven trust failure or a definitive ENOENT/ENOTDIR where absence is itself the verdict; every other filesystem exception is `indeterminate`. Only `untrusted` may touch persisted state. Because `null` already meant "fall through to the system default" on both the launch and poll paths, not-clearing on its own would have run a DIFFERENT account behind a UI still showing the selected one. So the refusal needed real channels rather than a sentinel: - the poll returns an explicit skip; returning null would not have skipped at all, since the fetcher maps null to ~/.codex and would have spawned a token-refreshing app-server inside the user's real credential home - pane launch throws a typed temporary-unavailability error that both PTY implementations convert into a clean refusal with a retry message, including the re-resolution after the async auth-readiness wait - automatic session resume resolves the selected home eagerly, so an unreadable account can no longer be silently replaced by another one in the ranking - config-sync status reports a distinct managed-home-unavailable stall instead of "synced", with a bounded renderer retry so it clears on its own Also fixes the ticket's second symptom. The status bar's Sign in button called a re-auth that captured the selection before login and restored it after, so re-authenticating a deselected account restored `null` — a successful login that left the account inactive, with no success toast to distinguish it from failure. It now activates the account it just signed in, but only when the pre-login selection was empty, so it cannot silently switch accounts for multi-account users, and it runs the same restart prompt an explicit switch does. No retry or grace window inside the synchronous gate: it runs on the Electron main process in a loop over accounts, so a sleep there would freeze the UI. Recovery is simply the next readable evaluation. The WSL lane has the same class of defect, including one path that deletes a credential mirror. It is pre-existing, unreachable from these host code paths, and deliberately left for its own change; the host clearing sites cannot reach a WSL account because getSelfContainedManagedHostAccount excludes them. Fixes STA-4422 * test(codex): cover pending reset home ownership |
||
|
|
be07b43a2b | fix(orchestration): enforce honest recipient routing (#14964) | ||
|
|
7fad71e448 |
fix(worktree): skip retirement backfill on every non-local host (#15023)
The backfill guard tested repo.connectionId, but a runtime-owned repo carries executionHostId with no connectionId, so it read as local. The scan then walked this machine's workspace and agent-transcript directories and filed the result under the runtime host's namespace — retiring names never used there while missing the ones that were. Guard on the execution host id instead. Ongoing retirement was already correct for these repos; only the one-time historical seed was wrong. |
||
|
|
7c798907c5 | fix(skills): harden cross-host bundle installs (#15000) | ||
|
|
30b76e6edb | fix(orchestration): enforce task dispatch state invariant (#14961) | ||
|
|
8e9b5c908c |
fix(github): fail closed instead of running client git against a remote repoPath when the SSH provider is unregistered (#14945)
* fix(github): fail closed when the SSH git provider is gone getCurrentHeadOid and probeTrackedUpstreamBranches only routed through the SSH provider when one was registered. With connectionId set but the provider unregistered (dropped connection, not yet reattached) they fell through to client-side git with cwd pointing at the remote repoPath — on a machine with a same-named local path that silently answers for the wrong repository. getCurrentHeadOid feeds shouldHideMergedImplicitPR, so a wrong OID changes which PR the UI attributes to a worktree. Both now take their existing unknown path (null / probeFailed) instead, matching repo-default-branch.ts. Local and WSL routing is unchanged. * fix(github): preserve PR state when SSH probes fail * fix(github): keep failed SSH discovery unverifiable * fix(github): propagate SSH identity failures * fix(github): scope verified SSH identity probes * test(github): preserve tolerant resolver calls * fix(github): preserve indeterminate auth discovery * fix(github): isolate SSH repository probe generations * test(github): expose SSH probe generation in mocks |
||
|
|
7afce2ea41 |
fix(ssh): stop reporting a confirmed kill when the SSH provider is gone (#14977)
* fix(ssh): stop reporting a confirmed kill when the SSH provider is gone A detached relay PTY is designed to outlive the provider that addressed it (it ignores SIGHUP and ships with an unlimited grace), so "the SSH provider is no longer registered" is lost contact, never evidence the remote process stopped. Both stop primitives in the PTY controller returned `true` from that branch, and every caller downstream reported the fabricated success: the CLI printed "PTY killed.", worker-stop settled the dispatch as stopped, and — because the stop "succeeded" — the unstopped-PTY gate never ran, so worktree removal walked straight past a live remote agent. `kill`/`stopAndWait` now still tombstone the local lease but report an unconfirmed stop and record why, using the three-verdict vocabulary the worktree teardown gate already spoke (`live` / `unverifiable` / `exited`), promoted out of that module into `src/shared/pty-liveness-verdict.ts`. The close receipt, the CLI wording, worker-stop and the removal gate all read that verdict instead of inferring an exit from silence. The same rule fixes the mirror-image defect: the aggregate inventory only enumerates registered providers, so a dropped relay clears `connected` for every remote PTY at once. The sweep now separates the provider answering "absent" (an exit) from no provider being able to answer (lost contact), so worker-stop stops claiming `exited` from a disconnect. The `connected` wire field is unchanged in meaning and shape. * fix(orchestration): apply the same honesty to the federation stop path The federation host runs its own copy of the worker observation and stop logic, with the same two defects: `inspectRemoteAttachment` read a dropped relay's `connected: false` as `exited`, and `federationStop` settled the dispatch as stopped from a close it never confirmed — relaying a fabricated success all the way home to the coordinator. Two guards also had to move so the honest verdict does not become a new refusal. `federationRead` gated on `status !== 'running'`, which would have rejected a connected terminal the moment a stop lost contact with it; it now gates on `status === 'exited'`, which is equivalent for every pre-existing status given the two guards beside it. Local `workerStop` likewise still attempts the close when the verdict is `unverifiable` — losing contact is a reason to report the outcome honestly, never a reason to stop trying. The show observations now carry the reason alongside the status, so a bare `unverifiable` is actionable. Both are new optional fields. * fix(ssh): preserve unconfirmed stop verdicts across consumers * fix(ssh): use canonical live verdict wording * fix(ssh): refuse wrong-host teardown verification * test(orchestration): confirm worker release teardown * fix(orchestration): negotiate honest worker stop receipts * fix(agent-teams): fence uncertain teammate respawns * fix(ssh): avoid duplicate missing-provider teardown * fix(orchestration): preserve archives across release retries * fix(ssh): preserve verdicts across synthetic kill exits * fix(ssh): preserve liveness evidence across teardown * fix(agent-teams): replace panes only after confirmed stop * fix(ssh): distinguish host exits from relay loss * fix(ssh): narrow concurrent inventory verdicts * fix(orchestration): serve archives after uncertain release * fix(orchestration): expose unverifiable read liveness * test(ssh): align liveness assertions with verdicts * fix(ssh): preserve host scope across inventory failures |
||
|
|
3bb87ff93b |
reland(shell): one portable Unix startup dialect, with both revert causes fixed (#15018)
* reland: portable startup-shell dialect, with the two revert causes fixed Relands #14863 (reverted by #14975) with fixes for both regressions the revert cited. 1. History GC deleted folder-workspace shell history. The live set was built from `getAllWorktreeMeta()` alone, but a folder workspace's PTY carries `folder:<id>` as its worktree id, so every live folder workspace looked orphaned. `getKnownWorktreeIdsForHistoryGc` now unions in `getFolderWorkspaces()`. Both consumers — the history-directory prune and the fish-history sweep — read that one set, so the fix covers bash, zsh and fish history alike. The directory prune had this gap since #1524; #14863 only widened its blast radius to fish files. 2. A copied Codex resume command aborted under `set -u`. Its leading clear statement has to test `$fish_pid`, and that unbound expansion takes the whole line — including the agent launch — down with it. Copied text runs in a shell Orca never spawned, so nothing can seed that variable first. The removal now rides on the agent itself as `env -u`, which needs no shell syntax and no expansion. Verified byte-identical under `set -u` in sh, bash, zsh, dash, ksh and fish. `env` cannot run the `cd` builtin, and a child `cd` would not move the agent, so the prefix is placed on the agent rather than on the whole `cd … && agent` chain. cmd and PowerShell have no nounset hazard and keep their clear ahead of the `cd`, which preserves `cd … && agent` — a failed `cd` still cannot launch the agent in the wrong directory. * fix(history-gc): stop three more paths from deleting live shell history Found by adversarial review of the reland. All three are the same class as the bug that caused the revert: a live set that is missing a category of real workspace, so the GC reads it as orphaned. 1. Profiles. The history root is `userData/terminal-history`, which has no profile segment, but the Store the GC consults is per-profile. So after a profile switch the live set condemned every other profile's history — and fish history, which lands in the user's own fish data dir, is shared by every profile on the machine. The live set now unions in the inactive profiles' worktrees and folder workspaces, read from their data files. A profile whose ids cannot be read reports the empty set rather than one that condemns real history. 2. No empty-set guard on the tree scan. `sweepOrphanedFishHistoryFiles` refuses an empty live set because it cannot be told apart from a store that failed to hydrate; the directory scan, which deletes more, had no such guard. A store that fell back to default state would have taken every worktree's bash and zsh history with it, across all roots including WSL. Four existing tests passed `new Set()` and relied on "empty means everything is orphaned" — exactly the behavior being removed — so they now pass a real live set. 3. Relay fish history. The relay isolates its history tree under its own root but wrote fish history into the shared fish data dir under the desktop naming, keyed by the CLIENT's worktree ids. On a machine running both Orca and a relay host, the desktop sweep deleted remote sessions' history once it went stale. Relay files are now `orca_relay_<hash>`, which the sweep's pattern deliberately does not match; the relay still deletes them by exact name when the worktree goes away. * fix(resume): enforce the env-removal invariants instead of documenting them Both found by adversarial review; both were unreachable from today's callers and silent if reached, which is exactly how they would survive to a caller that does reach them. - A pinned CODEX_HOME and the removal named the same variable, and `env -u` strips what the assignment just set — so the agent would have resumed against the real home and not found the session. The removal list now excludes any name the prefix pins, keeping the assignment authoritative as the old `clear…; CODEX_HOME=x agent` ordering did. Same fix in the git-bash twin. The PowerShell branch already clears before it assigns, so it was never affected. - Placement was keyed on the platform while the grammar it selects is keyed on the shell, so `platform: 'linux'` with `shell: 'powershell'` emitted POSIX `env -u` into a PowerShell line. PowerShell now routes to the PowerShell builder whatever the host, and the POSIX/cmd split below asks the shell rather than the platform. |
||
|
|
77ef6bb9ee | fix(terminal): verify agent prompt submission (#14962) | ||
|
|
cc74436b3c |
fix(skills): keep an unanswered skill root's last known skills instead of reporting none (#15015)
* fix(skills): keep an unanswered skill root's last known skills instead of reporting none
An aborted or shed root scan degraded to `{skills: [], unavailable: true}`. Every
consumer derives "installed" from the skill list, so an unreadable root read as
proof the skill was gone and an already-installed skill offered Install again.
- discovery: retain the last answered scan per root (5 min, LRU-bounded, dropped
on install/update invalidation and when a root answers as absent) and serve it
when a later scan goes unavailable.
- source inventory: an unanswered root reports `exists: true`, so check
`skippedReason` first rather than presenting it as a successful scan.
- useInstalledAgentSkills: when an in-scope root did not answer and the skill was
not found, say the status may be incomplete instead of a bare "Not installed".
* fix(skills): do not let install verification accept an unanswered root's retained scan
Retention makes discovery serve a stalled root's last completed scan, which is the
state before an install wrote. Verification read that as proof, so reinstalling a
skill into a root that stalled could report success without reading what it wrote —
turning a retryable false negative into a false positive.
A match now counts only when a root that actually answered reached the skill, so a
symlinked placement co-owned by a healthy root still verifies.
|
||
|
|
81d7f9b24e |
refactor: split db.ts under 400 lines (#14979)
* refactor: split db.ts under 400 lines * rm plan * fix(orchestration-db): add safety guards to database operations Add status guards to UPDATE statements to prevent late operations from overwriting changes made by concurrent requests. Validate mutation results to surface silent no-ops. Extract circuit-break threshold, add transaction wrapping, and sanitize untrusted input. Bump schema version to v28. * Add transactional safety to dispatch and message operations Wrap dispatch failures and batched message updates with SAVEPOINTs to ensure atomicity and idempotency: - Dispatch failures now check status guards and roll back if the related task update fails, preventing partial state corruption - Message batches (across multiple 500-id chunks) roll back entirely if any batch fails, avoiding partial mutations - Add tests verifying idempotency and atomicity under failure conditions * Handle concurrent writes and improve transactional safety - Remote question answering: add classification check before and after UPDATE to safely detect concurrent modifications. Prevents false success when the UPDATE loses a race. - Transaction rollback: wrap in try-catch to prevent errors from masking the original failure. - Question thread reset: use status update instead of deletion to preserve message references. * test: add answer replay and race condition edge case coverage Add test cases for answer replay idempotency, conflict detection, and a race condition between concurrent answer updates in federation relay. Also verify local question state transitions during orchestration reset. |
||
|
|
f660aaab0d |
Remap SSH leases per execution host, not globally (#15009)
* Remap SSH leases per execution host, not globally SSH lease leaf IDs are now remapped within their execution host partition, preventing silently empty tabs when the same tab name exists on multiple machines after restart. Also preserve folder workspace paths exactly as provided without trimming whitespace. * rm review file --------- Co-authored-by: m4air <m4air@m4airs-Air.localdomain> |
||
|
|
8ca4ed945e |
feat(terminal): report execution host and listing scope in terminal list (#14973)
* feat(terminal): report execution host and listing scope in terminal list `orca terminal list` returned rows with no host identity and no statement of what the listing covered, so a scoped listing that saw nothing read as "nothing exists anywhere" — an agent reported a live remote worker dead. Each row now carries an optional `executionHostId` derived from the PTY id (SSH and paired-runtime ids embed their owner), and the result carries an optional `hostScope` naming the hosts covered and the known hosts skipped. Both are surfaced in `--json` and in the human-readable CLI output, where an absent field renders as `unknown` rather than `local`. Both row builders route through one resolver, so the rule lives in one place. * fix(terminal): preserve unverifiable host scope * fix(terminal): fail closed on unverifiable hosts * test(terminal): name unverifiable scope explicitly * perf(terminal): keep graph hydration host scans narrow * fix(terminal): reject blank foreign host owners * fix(terminal): validate inferred inventory hosts * fix(terminal): preserve paired folder host scope * fix(terminal): keep inventory host inference typed * fix(terminal): disclose paired folder hosts |
||
|
|
b36456007e |
fix(repos): never probe the client filesystem for a remote repo icon (#14947)
detectRepoFileIcon fell back to a LOCAL read whenever the SSH filesystem provider was absent, so a disconnected/not-yet-reattached remote repo whose path also exists on the client picked up the wrong repository's icon. Thread the connection identity through and fail closed, matching the rule already stated in connection-context.ts and repo-default-branch.ts. |
||
|
|
0ac2e77db1 |
fix(agent-hooks): default-form managed hook vars so a static precheck cannot reject them (#14994)
The managed hook command embedded a bare $SYSTEMROOT. Grok loads Claude's
settings.json hooks and statically prechecks env vars across the whole command
string, so the reference inside the never-taken Windows branch made it refuse
the hook on macOS on every event:
hook not executed: required env var(s) not set: ${SYSTEMROOT}
Grok fails these open, and Orca installs Grok's native hook separately, so no
status was lost -- the symptom is a swallowed failure line per tool call.
$VAR and ${VAR-} expand identically in POSIX shells absent set -u, so this has
no execution-time effect; only the static precheck observes it. Verified in Git
Bash on Windows that both guard forms resolve identically ($SYSTEMROOT is set
there and uppercase is the correct spelling -- $SystemRoot is undefined).
Also converts the three bare $HOME references so the regression test can assert
zero bare variable references with no exemption. A $SYSTEMROOT-specific check
would not have caught this class of bug being introduced elsewhere.
|
||
|
|
a9f93172cf |
Revert "fix(crash-reporting): give the parking census a retained breadcrumb slot without starving memory highwaters (#14867)" (#15005)
This reverts commit
|
||
|
|
71bbab72e1 |
fix(commit-message): keep Windows paths intact in agent command overrides (#14984)
* fix(commit-message): keep Windows paths intact in agent command overrides `tokenizeCustomCommandTemplate` applies POSIX backslash-escape rules on every platform. On Windows `\` is the path separator, so a native absolute path in an agent command override is silently destroyed: C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe -> C:WindowsSystem32WindowsPowerShellv1.0powershell.exe which is then reported as not found on PATH. The agent *startup* path already routes Windows shells to the Windows tokenizer, but the commit-message AI path still calls the generic tokenizer directly, so overrides, extra CLI args and custom commands there are all affected. The tokenizer gains an explicit `'escape' | 'literal'` mode rather than reading `process.platform`, because the same template can be parsed on one host and executed on another. `'escape'` stays the default, so POSIX behaviour — where `foo\ bar` is deliberately one token — is unchanged. `'literal'` is selected only where the command provably runs on native Windows: a LOCAL target, on win32, with no WSL distro. A WSL target runs a Linux binary inside the distro, and a remote target runs on a host whose platform this process cannot see; both keep POSIX escaping. Fixes #11375 * test: pin the platform decision for literal-backslash parsing commandBackslashMode is the only place that reads the platform, so it is where this can be wrong in the direction that matters — applying Windows rules to a command that will actually run under a POSIX shell. WSL and remote targets are pinned explicitly; both were previously untested. |