mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
49752477a680ff3bb59bcd6ffe2999ff8a22097e
8875
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
49752477a6 |
build(xterm): restore the patch regeneration harness and gate it in CI (#15223)
* build(xterm): restore the patch regeneration harness and gate it in CI docs/reference/ime-architecture.md says "Never hand-edit the bundles in the patch" and links to docs/reference/xterm-patch-regeneration.md. That doc does not exist, and neither does the harness it describes. Both landed in |
||
|
|
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. |
||
|
|
604169f4af |
Filter automations list by agents (#15224)
* Add agent filter to automation list Allows filtering automations by one or more agents with search support. Status and last-run filters are reorganized into submenus. External automation entries are excluded from agent filtering scope. * Fix translation keys for agent filter in automation list Move agent search text from AgentCombobox keys to component-specific AutomationListFilterMenu keys. Adds translations across all locales. |
||
|
|
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.
|
||
|
|
e2b567363b | ci: stop refreshing every apt repo three times to install fish (#15217) | ||
|
|
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 (
|
||
|
|
3697d68f21 |
fix(cmd-j): decline a GitLab iid match when the repo remote names a different project (STA-4450) (#15193)
* fix(cmd-j): decline a GitLab iid match when the repo remote names a different project (STA-4450) `repoMatchesGitLabSlug` laundered a definite project-path mismatch into `'unknown'` whenever the resolved identity came from a remote named `upstream`, and `worktreeMatchesGitLabUrl` treats `'unknown'` as permission to accept a bare iid. Since `deriveGitRemoteIdentity` ranks `upstream` above `origin`, any repo whose top-ranked remote is `upstream` lost GitLab project gating entirely, so an exact URL for an unrelated project could surface that workspace. Return the `matchGitRemoteKeyParts` verdict directly. Resolved identities are re-probed on a 6h TTL, so a remote naming a different project is current evidence. `'unknown'` now means only "no identity" or "unexpanded SSH host alias", both of which stay permissive as before. * docs(cmd-j): correct the identity-freshness comments and drop a duplicate test The GitLab why-comment implied resolved identities refresh unconditionally. They only refresh when a repo/project list sweep finds one past its ~6h TTL (`selectEnrichmentCandidates` runs from `repos:list`/`projects:list`/ `projectHostSetups:list`, four refreshes per sweep, after a 5m startup delay); there is no background timer. State the accepted cost instead of implying the gate is loss-free. The GitHub-side comment still claimed the identity is "chosen when the repo was added and never re-probed" — the exact claim this PR disproves. Rewrite it to the reason that still holds (one stored remote hides a fork's `origin`). Behavior on the GitHub path is unchanged; it stays with the twin ticket. Delete `does not surface an upstream-identified repo for an unrelated project iid`: the inverted test above it already asserts both halves (mismatched project declines, the named project still matches) against the same upstream-derived identity. |
||
|
|
13b10e0b54 |
ci: cut PR wall clock by caching what CI recomputes every run (#15211)
None of these change what CI checks — they remove work the runners repeated on every PR. - install-node-dependencies installed with --no-frozen-lockfile, so every job re-resolved the graph against the registry to recompute what the lockfile already pins. Measured at ~62 MB of packument metadata per job; the pnpm store cache does not cover the metadata cache, so this was paid ~39 times per run. The `git diff` guard that made the re-resolution redundant stays. - --ignore-scripts leaves node-pty with no build/Release, so ensure-native-runtime node-gyp-compiled it in every job asking for a runtime. Cache the build under an ABI-bound key (runtime, resolved Node version, node-pty patch) with no restore-keys, since a partial match is exactly the mismatched build that would be recompiled anyway. - The four fetch-depth: 0 checkouts pulled full history including every historical blob (blobs are ~89% of this repo's pack). They only need the commit graph for a merge-base diff, so fetch them blobless. Measured 30-43s each today versus 8s for the shallow checkouts. One of them, e2e-paths, gates the entire E2E chain. - E2E jobs ordered setup-node before pnpm, which meant setup-node could not find the store and no E2E job cached dependencies at all. Reorder and cache; this sits on the critical path in both the build job and each shard. - git_compatibility rebuilt Git 2.25.5 from a pinned tarball on every PR. Cache the build; the sha256 assertion still guards the miss path. - typecheck ran three independent tsc passes back to back and discarded the .tsbuildinfo each project already emits. Run them concurrently and cache the incremental state. - package (windows) built the electron-vite targets serially via build:release. Use a :parallel variant that overlaps them, matching what the Linux package job already packages and smoke-tests from. Contract tests cover each new cache's ordering and key so none of them can silently start serving a stale or ABI-mismatched artifact. |
||
|
|
a54c27f00d |
Restructure automation editor dialog into three-column layout (#14803)
* Restructure automation editor dialog into three-column layout - Separate prompt editing from settings configuration - Add Monaco editor for prompt with find widget support - Extract settings into right sidebar for better organization - Move automation name into prompt section for context - Simplify header and footer to focus on key actions - Settings controls now smoothly collapse when switching between Orca and Hermes targets * Fix React Doctor leak on automation prompt Escape listener. Move addEventListener into a helper that returns cleanup so the changed-code quality gate can see the subscription is released. * Fix stale ref closures in automation prompt editor - Move `onDismissRef.current` update into useLayoutEffect with `[onDismiss]` dependency to prevent stale closures in event listeners - Move `contentRef.current` update into the layout effect that syncs it, ensuring editor has current value when effects reference it |
||
|
|
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 |
||
|
|
6ee265e579 |
feat(agent-status): surface the model each Codex subagent is running (#8251) (#14627)
Codex child rows have carried a model field end-to-end since #9637, but the transcript reader never populated it, so every transcript-discovered child rendered with an empty model chip. Read the child's own turn_context.model from the rollout records already fetched for completion detection, so the sidebar can distinguish an orchestrator model from a subagent model. No added file I/O and no added rows: the model is parsed from records the reconcile pass already read, and both row components already render entry.model. |
||
|
|
b2163f9a1d |
test(e2e): pin pty input bytes for Hangul runs that cross a wrap boundary (#15080)
Every CJK byte-exactness spec in the suite types a handful of characters, so none of them ever reaches the right edge of a row. This adds a run long enough to wrap at the pane's real width, driven at the pane width that actually sticks (splits, not `terminal.resize`, which the fit pass springs back). Investigated #15066 while here; it does not reproduce as input corruption. |
||
|
|
9b8e9dc226 |
Prevent tab search results from jumping while typing (#15133)
* Prevent tab search results from jumping while typing - Retain deferred results that still match the current query - Add retainOpenTabResultsForQuery utility with query matching logic - Refactor TabBarCreateEntry to use useTabCreateEntrySearchResults hook * Re-check retained tab search rows with full search engines Instead of checking if row text contains the query, retention now re-runs the search engines on deferred results. This respects all matching rules (type aliases, paths, workspace labels, agent snippets) and ensures stale or mismatched rows don't linger on screen as the user types. |
||
|
|
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> |
||
|
|
9f4ea42493 |
fix(agent-status): say what an OpenCode permission request is waiting on (STA-3160) (#14614)
* fix(agent-status): say what an OpenCode permission request is waiting on (STA-3160)
A permission.asked arrives as hook_event_name PermissionRequest, but
extractOpenCodeToolFields had no branch for it, so the pane reported a bare
{state:'waiting'} with no tool or command. The user could see that OpenCode was
blocked but not on what.
Read the fields @opencode-ai/sdk fixes for EventPermissionAsked: 'permission'
names the request, and metadata/patterns carry the command or paths it covers.
The normalizer is shared with mimo-code, so both are covered.
* fix(agent-status): show the OpenCode permission on the row, and retire it after (STA-3160)
Live validation against opencode 1.18.18 showed the original change populated
toolName/toolInput on a `waiting` entry that no surface rendered, while leaving
the answered permission cached for the rest of the pane's session.
Retire the tool fields on every OpenCode-family event except PermissionRequest.
isNewTurnEvent is false for this family, so resolveToolState otherwise inherits
one answered permission onto every later frame and the row reads a resolved
command as the live tool. Reproduced end to end: after approving `rm -rf build/`,
an unrelated later turn still reported it.
Read `filepath` from permission metadata. The SDK types metadata as
Record<string, unknown>, so its keys come from each tool; a live opencode 1.18.18
sends `filepath` (one word) for `edit`, which the previous key list missed. The
fallback to `patterns` covered it by accident, and the test that claimed to cover
it used `metadata: {}` — a shape OpenCode never emits. Tests now use captured
payloads for bash, edit and webfetch.
Show tool fields on `waiting` as well as `working`. All three consumers gated on
`working`, so a permission request rendered nothing at all; before/after of the
sidebar was pixel-identical. The rule now lives in one place (showsAgentToolPreview)
because a gate duplicated across three surfaces is a gate that drifts.
|
||
|
|
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. |
||
|
|
8b9307301c | fix(computer-macos): allow final click to dismiss target (#15169) | ||
|
|
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. |
||
|
|
a963a7f462 | test(folder-workspaces): await routed update admission (#15179) | ||
|
|
a3a2c44edf |
Split browser pane (#14861)
* refactor: split BrowserPane.tsx under 400 lines * rm plan * refactor(browser-pane): reorganize into lifecycle folders Cut/paste + import rewrites only; no intentional behavior change. - annotate/, assemble-chrome/, host-guest/, navigate/, stream-remote/, describe-page/ (foundation sink, zero outgoing edges) - BrowserPane.tsx is now a pure re-export barrel; its component body moved verbatim to assemble-chrome/browser-workspace-pane.tsx so no dest file imports the barrel - browser-runtime.ts -> describe-page/live-browser-url-registry.ts (banned name; relocating the contract collapsed the host-guest/navigate mutual pair) - repath browser-pane test paths in config/reliability-gates.jsonc * refactor: sync addressBarValueRef with useEffect Move ref synchronization into useEffect hook with proper dependency tracking to ensure the ref updates are handled through React's lifecycle. Consolidate related imports from browser-page-types. * refactor(browser-pane): fix React lifecycle and external store patterns - Replace local state + effects with useSyncExternalStore for external subscriptions (draw hint, address bar, slot viewport) - Fix React StrictMode double-invoke issues in pointer handlers and state updates - Add keyboard navigation to context menu (arrows, Home, End, Escape) with focus management - Improve error handling for mobile driver reclaim and grab action IPC failures - Add test coverage for BrowserFind session flags, keyboard behavior, viewport lifecycle - Remove react-doctor/no-adjust-state-on-prop-change lint disables (root causes now fixed) * i18n: extract grab and download UI messages Move hardcoded toast notifications and error messages to translation system for both grab annotations and file drop handling. Also apply lazy initialization to address bar value and remove duplicate event recording. * fix(browser-pane): stop mutating refs during render React Doctor fails static analysis when refs are written in render. Mirror latest values in useLayoutEffect, and read the current page id from the latest grab callbacks. * fix(browser-pane): drop unused grab-mode exit dependency exit already reads the page id from a ref, so listing browserPageId trips the changed-code exhaustive-deps gate. * test(e2e): hide the window when Linux minimize is a no-op Xvfb has no window manager, so BrowserWindow.minimize() never sets isMinimized() on the frameless Linux CI window. Hide still occludes the guest compositor so restore coverage can run. |
||
|
|
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. |
||
|
|
2aebcfe288 |
Improve cmd j search keyword match (#15170)
* Implement multi-keyword palette matching with evidence-based ranking Replaces the single-match-per-field scoring with a comprehensive matcher that: - Validates token coverage across multiple query keywords - Normalizes Unicode text consistently across all sections - Classifies matches by quality for cross-section leadership - Supports evidence-based matching with hidden supporting fields - Includes typo matching for letter-only words - Performance-gated against a synthetic corpus of 800+ candidates Result structure now carries match ranges per field (not per row), quality class, and document rank so sections can compare relative strength. This enables worktree/open-tab/intent section ordering based on match intent rather than hardcoded defaults. * Improve cmd-j palette selection after deferred query commits Instead of clearing selection when the deferred query commits, intelligently select the next available item using the standard selection logic. Also remove unnecessary array index from React key generation to prevent spurious re-renders. |
||
|
|
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. |
||
|
|
7b4e10b104 |
fix(mobile-ios): pin fastlane and gate the Fastfile in CI (#15092)
* fix(mobile-ios): pin fastlane and gate the Fastfile in CI The ios-distribute job failed on every run from 2026-08-10 to 2026-08-13 because distribute_testflight passed distribute_only without app_platform, so pilot fell through to an interactive platform prompt on ubuntu. No CI check loads the Fastfile, so external testers got nothing for six days. - Pin fastlane 2.238.0 and commit mobile/Gemfile.lock so ios-build (macos) and ios-distribute (ubuntu) cannot resolve different versions ~25 minutes apart. Fixes the Gemfile comment's dead mobile-build.yml reference. - Add a Fastfile smoke check (bundle exec fastlane lanes) plus a static contract test for the TestFlight lane arguments to Mobile Checks. - Set reject_build_waiting_for_review so a superseded same-train build in beta review stops blocking the submission. * fix(mobile-ios): install the pinned Gemfile.lock in frozen mode Without frozen, a lockfile that drifts from the Gemfile is silently re-resolved per job, which is the version split the pin exists to prevent. * test(mobile-ios): anchor the TestFlight argument contract against an empty selection * chore(mobile-ios): canonicalize the lockfile platforms Bundler's own normalization drops arm64-darwin-25 as redundant with the versionless arm64-darwin, and the ubuntu runners resolve x86_64-linux-gnu. |
||
|
|
4a6de51ad8 |
fix(native-chat): enforce each pending send's own boundary in glue matching (STA-4477) (#14935)
* fix(native-chat): enforce each pending send's own boundary in glue matching Glue matching filtered candidate rows against the OLDEST still-open send and then matched the entire open queue against them. A prompt queued after a glued row landed could therefore be judged "already delivered" by that older row and pruned — the queued prompt disappeared with no bubble and no transcript turn. Each send now carries its own transcript boundary into the match: `gluedCandidateRows` tags every candidate row with the set of pending indices it actually landed after, and the matcher stops a run at the first send the row predates rather than skipping over it (adjacency is what makes a row glue). Exact single matches still belong to the occurrence path, unchanged. `native-chat-pending.ts` sat at 299 of its 300 effective-line budget, so the slash-command marker cache — a separate rule that never took part in pending pruning — moves verbatim to `native-chat-command-marker.ts`. Pure move: no behavior change, imports only. (max-lines is never bumped or disabled.) Refs STA-4477. Original PR #14663. * test(native-chat): cover the glue adjacency break and unmask the render path The `break` on a send the row cannot represent is the fix's central semantic choice, and swapping it for `continue` was passing the whole suite: nothing exercised a queue whose middle send is unrepresentable. Add that case. The mixed-age case also asserted both call sites in one `it`, so a prune-path failure masked the render-path assertion — and the render path is the one that makes a queued bubble visually vanish. Split it. Skip the per-send boundary scans when fewer than two sends are open: the glue matcher already returns nothing there, so a lone queued echo was walking the transcript twice per render for a discarded result. * fix(native-chat): migrate the live-session benchmark off the renamed glue exports Renaming the glue matcher's exports left this caller behind, and it crashed at runtime after printing six result rows: TypeError: matchingNativeChatUserTexts is not a function No gate caught it. config/scripts/** is in no tsconfig include and the file is not a *.test.ts, so neither typecheck nor vitest ever loads it. The empty-pending arm passes no pending sends, so the matcher takes its empty-queue exit without ever reading the rows — which is also why the renderer skips candidate-row construction entirely in that case. Escaping the row scan directly keeps what this arm actually measures identical to before, rather than fabricating per-row boundary sets that no production path builds. |
||
|
|
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) | ||
|
|
a1cd7eaa7e |
refactor(terminal): redesign quick command dialog with expanded layout (#15011)
* refactor(terminal): redesign quick command dialog with expanded layout a - Enlarge dialog to 52rem width and add scrollable content area for improved editing experience - Restructure textarea with header (label, status badge) and footer (hints, controls) - Compact action toggle to use shorter labels with grid layout - Move append enter switch to textarea footer (compact mode) for terminal commands - Add scope summary to Advanced toggle when collapsed for quick reference - Update dialog description and add contextual hints for user guidance * fix(terminal): restore defaultAdvancedOpen on quick command dialog Settings still opens the Advanced section from settings; the redesign dropped the prop and failed typecheck. * Improve accessibility of terminal quick command dialog - Add inert attribute to prevent keyboard/screen reader access to hidden advanced section - Add aria-label to textarea for clearer form field labeling - Extract label text to variable to avoid duplication and ensure consistency * refactor: remove status badge from quick command header Simplifies the dialog header layout by removing the "sent to agent" / "runs in terminal" badge and its associated translation strings. |
||
|
|
b2612de157 | Update README downloads badge | ||
|
|
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 |
||
|
|
b0e27354b5 | fix(mobile): escalate continuous Relay outages (STA-4587) (#15071) | ||
|
|
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. |
||
|
|
1aa51f6914 |
fix(computer): preserve accessibility value types (#15031)
* fix(computer): preserve AX value types * fix(computer): preserve exact integer values * fix(computer): preserve exact integer exponent forms |
||
|
|
c7995a66ae |
fix(mobile-native-chat): reland glued pending retirement without the two revert causes (STA-4482, STA-4492) (#14936)
* fix(mobile-native-chat): reland glued pending retirement without the two revert causes Relands #14665 (reverted by #14819). #14665 retired mobile pending bubbles when two fast sends landed as one transcript row, but shipped two regressions; both are fixed here rather than re-applied and hoped for. 1. A rejected send restored a TRIMMED composer. #14665 reassigned `text` to `text.trimEnd()` at the top of `sendMessage` and then used that one value for both the bytes on the wire and the composer restore, so a rejection put back less than the user typed. The draft and the payload are now separate values: `draftText` is what the user typed and is what `clearDraftForSend` / `restoreRejectedDraft` see; only the transported `text` is trimmed. 2. Sends issued during hydration were stranded forever. #14665 persisted `glueBaselineTrusted: false` on any send captured while the transcript was still loading and never cleared it, so that send could never retire and stood as a permanent glue barrier for its neighbours. A hydration-time baseline is now a placeholder (`baselineResolved: false`) that the first authoritative read rebases onto real rows, ordinals included, instead of a permanent disqualification. That is STA-4492. The intended behavior is unchanged: one transcript user turn retires a run of 2+ adjacent text-only pending sends only when it exactly spells their normalized concatenation, every send is bounded by its OWN transcript tail, and exact landings, image echoes and unresolved tails stay barriers. No wire change: `baselineResolved` and the baseline tail are client-local React state in `pendingBySession` and are never exchanged with a host. The only client->host difference is trailing whitespace no longer being written onto the agent's input line, over the existing `terminal.send` params. Refs STA-4482, STA-4492. Original PR #14665, revert #14819. * fix(mobile-native-chat): let the untrimmed draft reach the send seam The composer sent `value.trimEnd()`, so the raw draft never reached `sendMessage` and a rejected send still handed back a trimmed composer — the split of `draftText` from the transported `text` had nothing to restore. Pass the draft through; the seam already owns the wire trim. Also pins the array-identity contract of `retireLandedMobileNativeChatPending`: the drafts effect early-outs on `next === current`, and nothing tested it. * docs(mobile-native-chat): name the hydration rebase's residual ambiguity * fix(mobile-native-chat): stop the hydration rebase stranding a send on its own echo Rebasing recounted the send's ordinal against the first authoritative read. That read can already carry the send's own echo — a re-subscribe after a tab switch or reconnect returns whatever exists now — so the ordinal landed one past anything the transcript could supply. The bubble never cleared, it stayed a live segment at the head of its run so no later pair could glue either, and `earlierOutstanding` carried the inflation onto the next send of the same text. Only the tail needs recovering; the ordinal was already counted against an empty transcript, which is right for "no history was known". A caption-less image echo keeps its captured tail, since it counts turns after it. `baselineResolved` also has to mean "captured against a settled read", not merely "not loading": a read that failed hands back an empty list that reads as an empty conversation, and the null tail then let any row the successful read finally brought glue-retire those sends. * test(mobile-native-chat): pin that a resolved hydration send leaves its run glue-capable A held send sits as a live segment at the head of its run, so the cursor can never reach a later pair — the stuck bubble takes the whole feature down with it. Goes red against the ordinal recount. * fix(mobile-native-chat): pin an image echo that captured no tail, and require the settled flag A caption-less image echo keeps its captured tail because it counts image turns after it — but a send issued before any history was known captured null, which counts from the top of the transcript. An old image turn then claimed the send and bound the user's fresh photo to it, leaving the just-sent turn with no preview. A null tail is not a boundary worth preserving, so pin those too. `transcriptSettled` was optional and defaulted to the gate it replaced, so any caller that omitted it silently got the pre-fix behaviour. Required now, and threaded through every harness. * fix(mobile-native-chat): stop an unbounded send claiming an image turn already in the read The image-preview pass runs before the rebase, so a send captured with no boundary matched any image turn the settled read carried — binding the user's freshly attached photo to an old one and retiring the bubble through landedImagePendingIds, which short-circuits the retirement path entirely. Pinning the tail in the rebase could not help: the claim was already made. Such an entry now waits one tick and claims against a real tail. * fix(mobile-native-chat): never move a boundary the send already captured An unsettled read still shows this session's own retained history — a reconnect or a failed read keeps the conversation on screen rather than blanking it — so sends made across one already own a correct tail. The rebase overwrote it with the tail of the read that followed, which sits at or after their own glued row, so `turn.index <= segment.tail` rejected every turn and the pair stayed queued for the session, blocking every later pair in the run. Pin only a send that captured no tail at all. A captioned image echo is now left alone entirely: it binds its preview by an ordinal counted over the whole transcript, so supplying a tail without recounting left it matching nothing, forever. * fix(mobile-native-chat): supply a boundary only to a text-bearing send An image echo reconciles by counting turns AFTER its tail and has no other retirement path, so the tail supplied from a read that already carried its own echo excluded the very row it was waiting for: the "Queued" photo bubble stuck for the life of the session and the transcript row rendered as bare marker text with no photo. A regression against main, and against the earlier revision of this fix that pinned only captioned echoes. The glue matcher is the only consumer a supplied tail helps. Everything that reconciles relative to its own tail keeps whatever it captured. * fix(mobile-native-chat): stop one unmatchable send freezing glue for the session The match cursor only advanced on a hit, so a head that could never match — a pair whose glued row arrived with the read, or a send the count pass claimed against an older row — froze the run behind it and every later rapid pair became permanently unretirable. Two cases previously disclosed as bounded were not bounded at all. Slide past a non-matching head, keeping the cursor monotonic so a later turn can never take a send an earlier one claimed. The slide widens the search, so a span cap keeps the work linear in the run length instead of quadratic; the existing budget test now asserts that bound rather than the old one it silently broke. Re-fuzzed at 250k seeds: the boundary guarantee still holds. Also corrects a comment that claimed the preview-pass filter made a photo claim against a real tail. It does not — an image echo keeps whatever tail it captured, so a caption-less photo can still bind to an older photo turn, as on main. * fix(mobile-native-chat): stop the span cap stranding a long glued run Capping each match attempt at 8 segments did not truncate a longer glue, it rejected it outright: a row spelling 9+ sends exhausted the loop without reaching the end of the text and returned zero, so none of the nine retired — and each stuck send then inflated `earlierOutstanding` for the next send of the same text. Nothing bounds how many sends pile onto the agent's input line; accumulation ends when the agent accepts input again, not at any fixed count. One inspection budget now covers the whole slide instead. The first attempt spans the entire run and always fits, so a genuine glue is never truncated; only a run of identical prefix-matching sends can exhaust the budget, which is exactly the case that should be cheap. The in-flight attempt may overshoot the remainder — that is what makes the guarantee hold — so the budget test asserts the real ceiling. Re-fuzzed at 250k seeds with runs past the budget. |
||
|
|
47a53b694b |
fix(ui): ignore IME composition Enter in the comment composer and replace field (#15057)
The keydown that exits a CJK composition carries isComposing: true (UI Events 3.6.5, row 5) and, when the IME is processing key input, keyCode 229 (7.3.1). Two handlers acted on it: - right-panel-comment-composer: Cmd/Ctrl+Enter posted the comment while the last syllable was still composing, so it went out truncated. - RichMarkdownSearchBar: Enter ran replace-current, mutating the document from a keystroke aimed at the candidate list; Escape closed the bar instead of letting the IME cancel the composition. Same in the find field. Guard all three with the existing isImeCompositionKeyDown helper, matching the rename and title inputs already on it. The held modifier does not change ownership: a composing Ctrl+Enter is still the IME's keydown, which is why useImeEnterGestureOwnership also owns it and only lets the post-compositionend redispatch chord through. Co-authored-by: BaeTab <bhwoo48@gmail.com> |
||
|
|
876e5b88a4 |
fix(ime): scope the composition route and deferred newline to owned sessions (#15056)
Two ways a terminal composition went wrong, both from state that was not scoped to the session it belonged to. The route called preventDefault() before checking whether it owned the session. The patched xterm treats that cancellation as "someone else will deliver this" and skips its own triggerDataEvent, so a route installed mid-composition — the connection effect re-runs, a reconnect swaps the transport, StrictMode remounts — suppressed the insertion and then returned without delivering anything. The commit vanished. Ownership is now decided first; preventDefault stays for sessions the route does own, including the ones it deliberately drops after a transport swap, since that drop is its decision to make. Pending-composition state was a bare per-element count, so a waiter could only ask "is anything composing", not "is what I was waiting for still composing". A composition the user starts after pressing Enter is behind that Enter, not in front of it, but it kept the count non-zero and held the newline anyway — `한` Enter `글` reaching the terminal as `한글\n`. The count is now reference-counted per session id, and the deferred send snapshots the sessions open when it starts and waits only for those. Reference counts rather than a set, so two overlapping routes owning the same session cannot clear each other's pending state. Co-authored-by: yeongjunyoo <47925973+yeongjunyoo@users.noreply.github.com> |
||
|
|
6387e5b8d3 |
fix(folder-workspaces): keep the broken-folder marker when a host sends a new reason (#15027)
FolderWorkspacePathStatus is cast, not decoded, off the runtime RPC wire -- runtime-rpc-envelope declares result: z.unknown(), so unwrapRuntimeRpcResult hands back whatever the host sent. Both title and description switch on status.reason with no runtime guard, so a newer host publishing a fifth reason matched nothing and returned undefined. FolderPathStatusIndicator's `!title` check then dropped the whole indicator, and a broken folder workspace rendered as healthy -- worse than the blank toast #15002 fixed, because there the warning was empty and here it is gone. Guard before each switch, the shape #15002 landed. A default: arm is not available: the type-aware config sets allowDefaultCaseForExhaustiveSwitch:false and rejects one with switch-exhaustiveness-check. Extract that guard into isHandledWireDiscriminant instead of hand-writing a third and fourth copy, and move #15002's two bespoke guards onto it. It takes unknown and checks typeof before Object.hasOwn -- hasOwn coerces its key, so a host that widened the field to an array sends ['missing'], which a hasOwn-only guard admits before the switch drops it straight back out. That was the P1 found in review on #15002; one implementation makes it structural instead of tribal. An unrecognized reason gets its own copy rather than reusing 'unavailable'. The unavailable remedy -- "Check the runtime or SSH connection and try again" -- is a false lead here: the host did check and reported the folder unusable, so retrying and inspecting a healthy connection wastes the user's time. Update Orca is the real remedy. Adding a fifth reason still fails typecheck in two places: TS2741 on the Record and TS2366 plus switch-exhaustiveness-check on both switches. |