mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 16:03:47 +00:00
f2070df6dbd367b62e211f8dc4692274bea54df2
8688
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
f2070df6db |
test(ssh): pin the parser against real ssh -G output, and accept-new against ask
Two gaps the audit named. The ssh -G fixtures were all hand-written, which means they encode what I expect ssh to print. This one is verbatim OpenSSH_10.2p1 output for a Host block using HostName, HostKeyAlias, StrictHostKeyChecking accept-new, two UserKnownHostsFile paths and a non-default port. The format detail that matters: each file list arrives space-separated on ONE line, so reading it as a single path would consult nothing for anyone with more than one file configured. And accept-new was untested despite being a real OpenSSH value. It currently behaves identically to ask, which is exactly right while no dialog exists and is what lets the defence ship without a modal — so the equivalence is now pinned, and Phase 2 has to break it deliberately rather than discover it. Plus a guard that accept-new never falls into the strict branch. |
||
|
|
7c788020e5 |
test(ssh): pin the ephemeral carve-out where it is actually observable
The first version of this test asserted an on-demand runtime connects on first contact, which every target does — it would have passed with the carve-out deleted. Pointing HOME at a home whose known_hosts exists and will not open makes the two cases diverge: a normal target is refused there, an on-demand runtime is not. Removing the carve-out now fails three tests instead of none. Also covers the wiring for the unreadable-source refusal itself, which until now was only pinned at the decision level. |
||
|
|
efcb407c89 |
docs(ssh): record what review changed and the STA-4319 follow-ups
The design survived implementation; every defect found afterwards was in the wiring. Worth recording the pattern, because it repeated five times: each one made us either blind or unusable, never subtly wrong — and four of the five broke legitimate hosts rather than admitting bad ones. Action items separate the things Phase 2 must decide (UpdateHostKeys, which is now the likeliest way a legitimate user meets a rejection; the web client never seeing the reason; RPC fail-fast) from the gaps Phase 1 knowingly accepts (WSL, CheckHostIP, ca-only hosts, the hand-copied ssh2 algorithm list). Also flags the rollout risk plainly: this is the first release in which Orca can refuse an SSH connection at all. |
||
|
|
03264f2d82 |
fix(ssh): let an ephemeral runtime outrank sources we could not read
Three things, all about the same question: when we cannot see everything that decides a host key, what does that actually license us to refuse? 1. ON-DEMAND RUNTIMES WERE REFUSED FOR A POLICY THEY COULD NEVER SATISFY. A machine provisioned a minute ago cannot be in known_hosts, by construction — which is why it has a carve-out at all. But the carve-out sat BELOW the incomplete-sources check, so anyone whose HOME diverges from their passwd home (sandboxes, our own E2E isolation) took the `-F` path, and every on-demand runtime connection was refused, pointing at a config file the user cannot fix. Refusing there buys nothing. No policy, seen or unseen, is satisfiable by a host that did not exist yesterday; the trust comes from the provisioning channel. So the carve-out now outranks it. An EXPLICIT StrictHostKeyChecking=yes still wins over both — that one we can read, and the user asked for it. 2. THE FLAG WAS NAMED FOR ONE OF ITS TWO MEANINGS. `siteConfigSuppressed` started as "-F hid /etc/ssh/ssh_config" and had since grown "a known_hosts file exists and would not open" — which is not a site config, and reading the name in the decision function told you nothing about why an unreadable file landed there. It is `verificationSourcesIncomplete` now: we could not see something that decides this, so do not extend NEW trust. A host we already know still connects, because a match is decided before this is reached, and that is now pinned by name. 3. THE COPIED ssh2 ALGORITHM LIST HAD NO DRIFT ALARM. We reorder ssh2's default host-key proposal, which meant hand-copying a list ssh2 exports only from a deep internal path. ssh2 throws `Unsupported algorithm` on anything outside its supported list, so drift does not degrade — every target stops connecting, before a socket opens, with a message about an algorithm the user never chose. Worth knowing that the list is also built conditionally on ed25519 support. Kept as a literal rather than a deep import, since silently adopting a new proposal order is the wrong default — the order is what makes type-scoped matching safe, so a change deserves review. A test now compares it against ssh2's real constant and checks every entry is one ssh2 accepts. Moved next to the ordering function it feeds, and out from between the import statements. 1,488 SSH tests pass. The 3 in ssh-connection-sftp-namespace.test.ts are pre-existing on origin/main. |
||
|
|
6dcfcdface |
fix(ssh): stop treating an absent known_hosts as a source we failed to read
The previous commit's "no readable source is not first contact" guard was right about the danger and wrong about how to detect it, and the version that shipped would have refused every connection a new profile ever makes. A file that does not exist and a file that refuses to open both arrive as a rejected readFile, and I counted them the same way. They are opposites. An absent known_hosts is the normal state — ssh creates it on its own first connect, and an Orca profile that has never connected has none — and it is real evidence that no host is known. A file that exists and will not open is evidence withheld: the entry that would have said "this key changed" may be sitting in it. The default list is the reason this was fatal rather than obscure. It always names known_hosts2, which essentially never exists, so on a machine with a normal known_hosts the count was 1-of-2 and everything worked; on a fresh profile it was 0-of-2 and every connect failed with "the system SSH configuration could not be read" — a message about a file the user does not have and an error they cannot act on. The suite passed only because the machine running it happens to have a known_hosts. Pointing HOME at an empty directory fails 62 connection tests, which is what the new wiring test does. So the count is now unreadableFileCount: files that exist and could not be read, which is the condition the guard was always trying to express. Any one of them takes the strict path; an absent or empty file takes none. The store clause is gone with it — a store hit already returns accept before this is consulted, so it never changed an outcome. Tests: absent, empty, permission-denied, directory, and all-parsed at the source; first contact with no known_hosts at all at the wiring level. 1,482 SSH tests pass. The 3 failures in ssh-connection-sftp-namespace.test.ts are pre-existing on origin/main and untouched here. |
||
|
|
5839ac2d84 |
fix(ssh): stop offering credentials to a host we just refused
A refused host key ended the handshake and then fell into the credential ladder, because ssh2 reports a denied key as a generic auth failure and the passphrase branch is eligible on message shape alone whenever an encrypted identity file is configured. So the sequence was: decide this host may not be who it claims to be, then ask the user for their passphrase and hand it to it. Failing that, prompt for a password. Failing that, retry over the system ssh binary, which for a disagreement with our own store rather than known_hosts would simply connect. That inverts the point of checking at all. A denied key is now final for the attempt: recognised by type before any fallback runs, and again inside the agent-fallback retry, which re-runs the handshake and so can be the attempt that denies. Two things had to change for that to hold. The rejection is now a HostKeyVerificationError rather than a rebuilt Error, so the connect path recognises it by type. Substring matching would have worked today and quietly stopped working the first time a reason string was reworded — and these strings are already worded around the auth-error classifier, so they are exactly the kind that get edited. And the verifier now reports the denials that skipped the policy: an unreadable key blob and an internal failure both denied without calling onDecision, so the connect path saw only ssh2's generic failure and walked the ladder. That was the actual path the new test hit first. The report carries no fingerprint, since there is no host key to identify, and the connection no longer overwrites the fingerprint it holds with an empty one — the relay keys install-lock isolation on that value. The reconnect ladder also refuses to retry it. Its classifier is otherwise substring-driven, so a reason containing "connection reset" would have been retried until the ladder gave up, burying the reason under nine attempts. Tests: no credential prompt after a refusal, an encrypted key configured so the passphrase branch is eligible; refused reports as 'error', not 'auth-failed', which would invite the user to re-enter credentials that are not the problem; no retry; both bypassing denials report; a throwing listener still denies rather than hanging the handshake. Also drops three test files a bisect resurrected from before the revert that deleted them. 172 tests pass across the three touched files; typecheck and lint clean. Pre-existing on origin/main and untouched here: 3 failures in ssh-connection-sftp-namespace.test.ts. |
||
|
|
11b2f93cf8 |
fix(ssh): close two downgrades the implementation review found
Both were in my wiring, not the design, and two reviewers found the first independently. 1. OUR STORE WAS TYPE-DOWNGRADABLE. The inline lookup filtered by key type first and could only answer match/mismatch/unknown, so a record of a DIFFERENT type for the same endpoint read as "unknown". A host learned on first contact — ed25519, since ssh2 proposes it first — and absent from known_hosts could then be impersonated by presenting RSA: both sources say unknown, so accept-and-remember, silently. That is exactly the downgrade D3 says the design cannot ship without, applied to the records we create ourselves. The store's own isTrusted already computed the right answer and had no production caller. Stored types now also feed the algorithm ordering, without which the guard is only half present. 2. WE KEYED ON THE ORCA LABEL, NOT THE DIALED HOST. "ssh -G" echoes its own argument back as its hostname field when no Host block matches, so for a manual target that field IS the Orca label — the one name D2 forbids keying on, and one ssh never wrote. We consulted no entries at all, so an impersonated host read as first contact. Now keys on the dialed host, which buildConnectConfig has already resolved through HostName, with HostKeyAlias still winning. That inverted an existing test rather than deleting it: "uses the resolved hostname, never the Orca label" encoded an assumption disproved against OpenSSH 10.2p1, so it is renamed and reversed with the reason recorded in the test. 3. NO READABLE SOURCE IS NOT FIRST CONTACT. Every known_hosts file failing to read was indistinguishable from "this host is unknown", so a changed key would be accepted the one time we could not check. The loader now reports how many files it could read, and zero readable sources with an empty store takes the strict path instead of recording trust. 4. A superseded attempt's rejection could replace the live attempt's error, and substituting a new Error drops ssh2's code, so a transient ECONNRESET would stop being classified as retryable. The rejection is now local to its attempt. 5. displayHost was the Orca label, so a mismatch could print "ssh-keygen -R <label>" — a remedy that removes nothing. Also adds the tests that would have caught 1 and 2, the stale-attempt denial, and IPv6 literals, which the design moved into scope and nothing covered. 1,469 SSH tests pass. |
||
|
|
0f37005de0 |
fix(ssh): bind the host key store to the active profile at startup
Without this the store reports nothing trusted on every launch. Safe — known_hosts still decides, and a host trusted only by us degrades to first contact and is re-recorded — but it silently discarded our own accept records, so the store the design calls for was not actually in use. Bound beside the profile Store, since it is a sidecar of the same data file. Also records why the paired-web carve-out the migration review asked for is NOT implemented in Phase 1, rather than leaving it looking forgotten. That carve-out exists to stop a web client waiting out the 120s prompt timeout — a hang only reachable if a prompt exists, and Phase 1 has none, which the decision function pins with a test asserting it never returns 'prompt'. An RPC connect therefore behaves exactly like a local one. Adding a fail-fast path now would introduce a failure mode for a hang that cannot occur; it becomes load-bearing when the dialog lands and is listed under Phase 2. Noted there for whoever builds Phase 2: runtime/rpc/methods/ssh.ts already swallows the specific error and rethrows a generic one, so the host-key reason will not reach a web user without a change there too. Full unit suite: 52,352 pass. The 8 failures are the known environment baseline (5 osc8, 2 IME) plus one browser-cookie suite-ordering flake that passes in isolation — none in src/main/ssh, and none related to this change. |
||
|
|
a1331d2602 |
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
|
||
|
|
43ed05509b |
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. |
||
|
|
2d2a0880ba |
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. |
||
|
|
b5e27ab7b7 |
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. |
||
|
|
bee19b083f |
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.
|
||
|
|
8be1696629 |
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. |
||
|
|
2a99a95b9c |
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. |
||
|
|
cb42b60849 |
fix(linear): label Start workspace and add Open on Linear (#14492)
* fix(linear): label Start workspace and add Open on Linear The issue page header used three unlabeled icons. Match the GitHub issue header: copy stays quiet, Open on Linear is an external-link control, and Start workspace is a labeled primary button. * Address PR review feedback (#14492) - Make the header source contract ignore Start workspace formatting |
||
|
|
87bafb5e6a |
fix(terminal): ground the emulator to the snapshot's baseline after a byte gap (#14374)
A hidden-delivery byte gap can strand more than the SGR pen, and the reset #14241 added to the split alt-screen replay is undone before any content is painted: xterm answers `?1049l` with restoreCursor(), which reloads the pen, all four G-set designations, GL, origin mode and wraparound from the register saved at `?1049h`. - Bracket the buffer switch with the baseline: before, so `?1049h` banks grounded state rather than the gap's; after, so `?1049l`'s restore cannot reapply it. - Ground everything a serialized payload is diffed against, not just the pen: SGR, GL, all four G-sets, origin, autowrap, insert, the per-buffer scroll region, and the saved-cursor register. - Switch buffers only when the pane is actually on the other one. `?1049` is not a no-op otherwise — it still swaps the kitty flag registers, which would park the flags of an agent that negotiated them on the normal screen. - Return to the normal buffer when the gap ate the TUI's exit sequence; the restored history was painting into the alt buffer with scrollback left empty. - Restore the CAN #14241 dropped, so a control string the gap truncated is discarded instead of committed by the next ESC. - Ground the abandon path exactly once instead of twice. - Derive the parity/fuzz preambles from the same builder; they had drifted and were asserting against bytes production no longer emits. |
||
|
|
41ba29d79a |
test(e2e): headless preedit-geometry coverage for Korean and CJK terminal input (#14500)
* test(e2e): headless preedit-geometry coverage for Korean and CJK terminal input Both IME defects that shipped and were reverted passed a suite of ~3000 IME assertions, because every one of them checked bytes reaching the pty and a preedit rendered into a hidden overlay satisfies all of them while the user composes blind. The one arm that asserted real geometry was headful-gated and macOS-only, so it never ran in CI. Drives composition through CDP Input.imeSetComposition rather than a native input source, which removes the accessibility grant, the system input source and the visible window that forced that gate, so this runs in the ordinary electron-headless project. The load-bearing assertion is the composition overlay's real bounding rect. Verified to have teeth: with max-width 0 and overflow hidden injected, the active class, the textContent, display block and checkVisibility all still pass, and only the rect assertion fails. * test(e2e): restore the CDP composition drivers the preedit specs need The trimmed copy on main kept only the key-dispatch helpers, so the composition drivers the geometry specs import were missing. Adds them back: setImeComposition, commitImeText, dispatchImeProcessKey, composeHangulSyllable and dispatchResumedCompositionUpdate. The shared helpers are unchanged. Co-authored-by: Orca <help@stably.ai> --------- Co-authored-by: Orca <help@stably.ai> |
||
|
|
92b6ffd17d |
Terminate renderer graph reload generations and contain disposed-frame notifications (#14070)
* fix(runtime): terminate renderer graph reload generations * fix(runtime): harden renderer reload teardown * fix(runtime): fence renderer graph publication ownership * test(runtime): register renderer graph reload gate * test(runtime): record live reload validation * fix(runtime): ignore cancelled renderer navigations * chore: preserve main formatting during branch sync * chore: satisfy changed-code quality gate * fix(runtime): restore cancelled renderer reloads * fix(runtime): preserve committed reload fencing * test(runtime): prove cancelled reload timeout * docs(reliability): record reload cancellation oracle --------- Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com> |
||
|
|
46e1c3eee4 |
fix(terminal): strip the captured shim dir across trailing-separator spellings (#14439)
* fix(terminal): strip the captured shim dir across trailing-separator spellings The scrub compared the captured ORCA_ATTRIBUTION_SHIM_DIR to PATH entries literally, so a trailing-separator difference left the legacy shim directory on the spawned PATH. Same class already fixed in the generated wrappers. Also drops a dead default in the POSIX filter, consolidates comments that had accumulated across fixes, and normalizes the legacy directory once rather than per PATH entry in the cmd wrapper. The boundary scanner stays: a shim path can contain the PATH delimiter, which splitting would fragment. * fix(terminal): keep the git shim tombstone parseable on Windows The cmd wrapper carried two em dashes in comments. cmd.exe seeks through a batch file in bytes but advances by decoded character count, so those four extra UTF-8 bytes made it drop the first four characters of every line and the wrapper died with "The syntax of the command is incorrect." Also move the legacy-dir trailing-separator strip into a CALL body: cmd expands a whole line before evaluating `if defined`, so inline it ran its substring syntax against an unset variable and mangled the line. Rooted-path checks are shared by a single subroutine, a relative or drive-relative captured ORCA_REAL_* is rejected, and relative PATH entries are dropped from the exported PATH so the cwd cannot select spawned tools. The POSIX tombstone is deleted rather than written when no absolute interpreter can be verified. Verified on Windows 11 (cmd and PowerShell 5.1): normal lookup, relative and drive-relative ORCA_REAL_GIT, relative and drive-relative PATH entries, trailing separators, legacy shim dir, empty PATH, and a cwd-only PATH with a planted git.cmd/git.bat. Co-authored-by: Orca <help@stably.ai> * fix(terminal): stop the git shim tombstone re-expanding PATH data cmd re-expands a CALL command line, so path data handed to a subroutine as an argument got a second round of percent expansion. A PATH entry holding a literal %CD% became the current directory before the rooted-path guard saw it, and the wrapper then ran a planted git.cmd from that directory (exit 66, reproduced on Windows 11). Callers now pass the value in a variable, which is expanded once. Re-verified on Windows 11 across 15 cases: the %CD% entry is now dropped and the real git runs, and a PATH entry spelled 'C:\paren9 (x86)\cmd' still resolves, so the new for-block did not regress paths with parentheses. Co-authored-by: Orca <help@stably.ai> * fix(terminal): keep percent expressions out of the shim tombstone comments cmd expands variables inside rem, so a comment naming the working directory substituted a path into itself. Verified on Windows 11 that rem does not re-parse the result -- a cwd of 'C:\x&pwned&rem' executed nothing and the real git still ran -- so this was not exploitable, but rem handles separators differently inside a parenthesized block and this script now has some. A test now rejects any percent sign in an emitted rem line. Co-authored-by: Orca <help@stably.ai> * fix(terminal): pin shim tombstone shell state and directory identity Three fixes, each proven before and after. Delayed expansion: bare setlocal inherits the caller's state. Under a parent shell started with /V:ON, a literal !CD! PATH entry became the current directory and a planted git.cmd ran (exit 66), and a legitimate directory containing ! stopped resolving (exit 127). Both on Windows 11, both gone with setlocal DisableDelayedExpansion. Directory identity: the POSIX filter compared the legacy shim directory lexically while comparing its own directory with -ef, so a symlink or a <legacy>/../<legacy> spelling escaped the filter and the live attribution wrapper won the lookup. It now tests both. The env scrub had the same gap and now normalizes before its suffix test. Retained POSIX wrappers: with no absolute bash verifiable the wrapper was deleted, which strands a shell that already hashed the path on 127 instead of falling through to PATH. It now reuses the shebang of the wrapper it replaces, which is known to work on that host, and rejects /usr/bin/env so the ambient lookup stays closed. Deleting is the last resort. Two Windows test pins matched the wrong occurrence and stayed green with the guard they claimed to protect removed; they now assert the subroutine body. All five fixes were mutation-tested. Co-authored-by: Orca <help@stably.ai> * fix(terminal): require bash for a reused shebang and exclude slash-spelled dirs The retained-shebang fallback accepted any absolute executable that was not env, but the rendered body needs BASH_SOURCE, [[ and local, so a #!/bin/zsh wrapper was accepted and then exited 1 on 'BASH_SOURCE[0]: parameter not set'. It now requires bash, which also rejects /usr/bin/env as before. The PowerShell filter trimmed only backslashes while its rooted-path regex accepts forward slashes, so a wrapper or legacy directory spelled with a trailing / missed the lexical exclusion. Verified on Windows 11 that both spellings are now excluded and the real git still runs. The test that claimed to cover the shebang fallback only called the resolver directly, so deleting the wiring left the suite green on any host with /bin/bash. It now mocks the resolver to null and asserts through neutralizeLegacyTerminalShimDir that the wrapper survives with the retained shebang, and that a wrapper without a reusable one is still deleted. Both mutations are now killed. The Windows wrapper text assertions move to their own file rather than taking a max-lines exemption. Co-authored-by: Orca <help@stably.ai> * fix(terminal): stop the shim PATH scrub deleting a legitimate directory The previous round collapsed '..' lexically before classifying a PATH entry. That is not the same as resolving it: when <shim>/posix is a symlink, <shim>/posix/../posix lands elsewhere, so a legitimate directory was classified as the shim and removed, leaving git unresolvable. Reproduced with a symlinked shim/posix and a real git behind it. Resolving for real is not available here either -- this env is also built for remote and WSL panes whose paths name nothing on the local filesystem -- so the classifier is lexical again, deliberately. A '..' spelling that slips through costs nothing at runtime: that directory holds the pass-through tombstone, and the tombstone excludes its own directory by -ef, so the lookup still reaches the real git. Separately, pathEntrySpellings can only enumerate one added separator, so a captured directory spelled with two or more survived the literal removal. The split filter now also compares separator-stripped forms, which covers any number. Both changes are mutation-tested. Co-authored-by: Orca <help@stably.ai> * fix(terminal): stop a relative shim dir letting the cwd pick the binary The -ef identity test added two rounds ago resolves a relative right-hand operand against the wrapper's current directory, so a relative ORCA_ATTRIBUTION_SHIM_DIR let the cwd decide which PATH entry counted as the legacy directory and got a legitimate one skipped. Reproduced as SAFE vs LATER purely by changing the cwd. Identity is now attempted only for an absolute target; the lexical compare still covers the rest. The cmd wrapper had the same shape: full-path expansion made a relative captured value absolute against the cwd before PATH filtering. It now requires a rooted value and leaves the normalized form unset otherwise, which makes the reject subroutine a no-op. Verified on Windows 11 that two runs differing only in cwd now agree. Separately, trailing-separator stripping treated a backslash as a separator on POSIX, where it is a legal filename character, so '/tmp/captured\' and '/tmp/captured' compared equal and a real directory was deleted from PATH. The rule is platform-specific now; the cross-platform classifier still understands both styles because a Windows PATH reaches it through the remote env. Both fixes are mutation-tested. Co-authored-by: Orca <help@stably.ai> --------- Co-authored-by: Orca <help@stably.ai> |
||
|
|
b82a8791f5 |
perf(runtime): resolve an explicit worktree id without scanning every repo (#14399)
`resolveWorktreeSelector` resolved every selector kind from the whole-fleet snapshot, so a targeted `id:<repoId>::<path>` lookup fanned `git worktree list` across every registered repo to answer a question about one of them. With a cold scan cache -- app startup, or the first lookup after a mutation clears the snapshot -- that is one subprocess per repo, ~17ms each, to find a worktree whose owning repo the id already names. Measured on a ten-repo fleet: one `id:` lookup scans 10 repos before and 1 after. Scope only `id:`. Every other selector kind is matched across the fleet and its `selector_ambiguous` contract is defined over all repos, so scoping `branch:`, `name:`, `issue:`, or a bare selector would silently pick a winner where they correctly refuse today. A test pins that: `branch:main` across ten repos still throws `selector_ambiguous` and still scans all ten. Lineage stays correct because edges are intra-repo by construction. The scoped path returns null and falls back whenever that does not hold: a repo id registered on several execution hosts, an unknown repo id, or a worktree the scoped scan does not contain. A warm fleet snapshot always wins. Row resolution moves out of orca-runtime.ts into repo-worktree-row-resolution.ts, which owns no state -- the cache-aware scan and folder-workspace stamping are injected. orca-runtime.ts ends up 65 lines shorter than before despite the added feature. |
||
|
|
a6a64439a0 |
fix(terminal): keep split error when rejected cleanup throws (#14463)
Wrap kill and retireRejectedPty so a cleanup failure cannot replace the original split-authority error or skip the remaining teardown. |
||
|
|
78ca45e2ae |
fix(renderer): remove duplicate git history tooltips (#14453)
* fix(renderer): remove duplicate git history tooltips * test(renderer): harden tooltip regression coverage |
||
|
|
d03eb3218e |
Revert "fix(renderer): stop git history hover from showing two tooltips (#14468)" (#14485)
This reverts commit
|
||
|
|
2100fb2553 |
fix(runtime): cap remote git.diff and file previews at the transport budget (#14160)
* fix(runtime): cap remote git.diff and file previews at the transport budget A remote or mobile user who opens the diff of a large image loses their whole WebSocket, not just that request: the E2EE channel closes with 1013 when a reply exceeds the 4 MiB outbound envelope. Two producers can exceed it unaided. git.diff/branchDiff/commitDiff cap text with MAX_RENDERED_DIFF_COMBINED_CHARACTERS (6M chars) -- a *renderer* budget that sits above the transport limit -- and return base64 for previewable binaries bounded only by MAX_GIT_SHOW_BYTES, so a 10 MiB PNG changed in place is ~26.7 MiB in one envelope. files.readPreview inlines base64 up to 10 MiB, and mobile calls it for every image tab. Both now measure against a budget derived from the outbound limit. The check sits in orca-runtime-git.ts, downstream of the dedupe and of both the SSH-provider and local branches, so a payload forwarded verbatim by an old relay is covered by the same code and src/relay needs no change. Local and in-process callers pass no budget and keep full fidelity. Measuring raw bytes would not work, which is the whole reason this needs a module. JSON escaping turns one control byte into six (\u00XX), and binary-buffer.ts sniffs only for NUL in the first 8 KiB -- so a NUL-free file of 0x01-0x1f bytes is classified as *text*, would pass a raw-byte cap, and would then blow the envelope. The budget is escape-aware, with a three-branch fast path that keeps normal diffs at two native byteLength calls and scans only the ambiguous band. The SSH branch of readFileExplorerPreview had the same raw-vs-escaped gap: its stat gate sizes base64 binaries, but text crossed unbounded. It now honours the same decoded-text limit the local branch already enforced. No wire change: GitDiffResult is untouched -- no third kind, no new field. Old clients see an error for one request instead of a dropped connection. diff_too_large joins the structured passthrough codes and lands on an existing error arm in both mobile consumers and the desktop remote path; file_too_large was already handled on both. Instruments the 1013 close, which nothing measured before, so the incidence this cap is meant to drive to zero is finally observable. `emitter` separates a producer size bug from a wedged link. Known regression: remote image previews between ~3.096 and ~3.146 MB now return file_too_large. They only intermittently worked before -- above ~3.0 MB they killed the socket -- so this trades intermittent connection loss for a consistent error. Test: 10281 passed in src/main/runtime + src/shared + src/main/git; mobile 3427 passed. Each of the six budget-enforcement sites is independently mutation-killed. Escaping fixtures cover newline-dense, control-char, CJK, lone-surrogate and base64 content against native JSON.stringify. tsc clean for node, web and cli; oxlint clean. Co-authored-by: Orca <help@stably.ai> * fix(runtime): harden remote reply transport budgets * test(runtime): cover desktop remote preview budgets * test(runtime): close telemetry review gaps * chore(shared): repoint budget imports after the shared/types barrel removal Upstream #14447 dropped the shared/types barrel; GitDiffResult now lives in git-diff-compare-types and GlobalSettings in global-settings-types. Co-authored-by: Orca <help@stably.ai> * fix(ssh): surface an over-cap preview read as file_too_large The stream reader aborts an over-cap read with StreamProtocolError, whose numeric code falls through mapRuntimeError to a generic runtime_error carrying the raw "Reported totalSize N exceeds client cap M" string. Neither preview client recognizes that: runtime-file-client.ts and mobile-file-preview-response.ts both key on file_too_large. It also made the two file_too_large guards directly below the read unreachable on the streaming path. Gives the cap its own error type so the caller can translate it, keeping the bandwidth saving the cap exists for. A genuine protocol fault still propagates unmasked. Found by the readiness review. Mutation-verified: removing the translation fails exactly the new test. Co-authored-by: Orca <help@stably.ai> --------- Co-authored-by: Orca <help@stably.ai> |
||
|
|
d137bb93e1 |
fix(agent-status): stop start-less child stops from minting phantom working (#14375)
* fix(agent-status): stop start-less child stops from minting phantom working buildClaudeCachedLeadStatusPayload fell back to 'working' whenever the pane had no cached lead-turn state. That default is right for a spawn or a child tool call, but the same helper serves SubagentStop and TeammateIdle, which end work and prove the opposite. claudeLeadStateByPaneKey is in-memory only, so every app restart empties it. A Claude session that outlives the restart reports its next child event into an empty map and the pane latches 'working' with an empty roster -- no Stop ever clears it, and the 30-minute window only decays the sidebar dot, never the stored state. Fall back by the event's evidence: terminating child events resolve to 'done', which still gates up through resolveClaudePaneState when the roster or background work proves the pane is busy. * fix(agent-status): require evidence for child completion * fix(agent-status): publish matched teammate idle * fix(agent-status): preserve confirmed child work * fix(agent-status): retain live restored teammates * fix(agent-status): reap unconfirmed siblings after child drain * fix(agent-status): preserve unmatched restored children * fix(agent-status): wait for lead completion after child stop * fix(agent-status): persist restored child transitions --------- Co-authored-by: Brennan Benson <brennan@stably.ai> |
||
|
|
b6d36c26f4 |
fix(terminal): resolve Ctrl chords positionally on a non-Latin layout (#14469)
macOS maps a control chord by physical key regardless of layout: measured with UCKeyTranslate, physical A/U under Control produce U+0001/U+0015 on 2SetHangul, Russian and Greek exactly as on ABC, though unmodified those keys give ㅁ/ㅕ, ф/г, α/θ. A native terminal inherits this by passing the OS characters through. The browser does not expose that translation. xterm's legacy encoder works anyway because it reads keyCode, which Chromium reports from the physical key, but its kitty encoder derives the key number from `key` and only consults `code` when Shift or Option is held. Ctrl is not in that gate, so a pane with the kitty protocol negotiated reports CSI-u for U+3141 rather than 'a' and the chord does nothing. Ctrl+C escaped this only via its hand-written ETX bypass. Recover the byte from `code` when `key` is non-ASCII, which reproduces the OS control table. An ASCII `key` stays authoritative so a Dvorak remap is honoured. KeyC is excluded: the interrupt policy owns it, and off macOS that policy declines to a selection so the copy binding wins. Fixes #13331 |
||
|
|
b3efd48c7f |
test(e2e): stabilize terminal failure coverage (#14466)
* test(e2e): stabilize terminal failure coverage * docs(e2e): classify failed-run findings * rm report |
||
|
|
9e5ee5ef8e |
feat(workspaces): rework cleanup discovery and dialog (#13413)
* fix(workspaces): support full cleanup scans * feat(workspaces): persist cleanup snapshots * feat(workspaces): add cleanup filter model * refactor(workspaces): remove cleanup presets * feat(workspaces): rework cleanup dialog * fix(workspaces): keep cleanup row ordering render-pure * refactor(workspaces): simplify cleanup browsing * refactor(workspaces): show cleanup facts * refactor(workspaces): surface cleanup row facts * fix(workspaces): remove misleading cleanup count * fix(workspaces): preserve full scan semantics * fix(workspaces): scope snapshot persistence * fix(workspaces): preserve cleanup browse compatibility * fix(workspaces): reconcile cleanup dialog state * test(workspaces): update snapshot store fixtures * test(workspaces): preserve cleanup scan modes * perf(workspace-cleanup): stream scan progress and size results * fix(workspace-cleanup): address review feedback * fix(workspace-cleanup): preserve host-scoped cleanup metadata * fix(workspace-cleanup): declare review source dependencies * fix(workspace-cleanup): align size scan banner * fix(workspace-cleanup): shorten scan action * perf(workspace-cleanup): avoid redundant scan IO * perf(workspace-cleanup): bound restarted evidence scans * fix(workspace-cleanup): satisfy scan queue lint * perf(workspace-cleanup): bound scan and snapshot work * perf(workspace-cleanup): serialize final enrichment * test(workspace-cleanup): assert final enrichment drain * fix(workspace-cleanup): stop progress after renderer teardown * perf: batch workspace cleanup git evidence scans * perf(workspace-cleanup): stop redundant snapshot and scan work * fix(workspace-cleanup): resolve review findings across scan, store, and dialog Correctness: - Chunk git-evidence dispatches at the shared 500-target limit and exclude queued/in-flight ids from target selection, so fleets past the limit can no longer strand rows permanently mislabeled as checked-but-unknown. - Key destructive selection pruning on the user's filter state instead of the per-tick matched-set identity; streaming reclassification no longer silently deselects rows. - Clamp the facet clock to max(scannedAt, open time): a stale hydrated snapshot no longer misbuckets idle thresholds or keeps dead agents fresh; row labels use the same clock. - Supersede and cancel the previous broad scan when a new one starts (renderer registry and same-sender guard in main) instead of racing two fleet scans. - Gate snapshot persistence on hasTargetedWorkspaceCleanupScan so worktreeIds: [] can never persist an empty fleet snapshot. - Re-apply dismissals at set-time in progress application so a dismissal landing mid-enrichment is not clobbered. - Record a one-off local snapshot prune for single (unbatched) remote deletes so removed workspaces cannot resurrect from cache. - Strip .exe when normalizing foreground process names so Windows agent processes match. Performance: - Cache per-candidate facet and review-info objects on candidate identity; no-op streaming ticks reuse the previous rows array and skip every downstream pass; matched-set identity is stable under equal membership. - Compute facet counts/options only while the filter popover is open. - Equality-bail git-evidence publishes; structural (non-stringify) facet-group comparison memoized in the toolbar. - Identity-token fast path for the enrichment cache (cache hits skip both JSON.stringify signatures); prune viewed/dismissal records on removal and expiry; bound the superseded-scan-id set. - Restore the no-op bail in removeWorkspaceSpaceWorktrees (regression). - Abort main-side scans when the renderer is destroyed; module-scope controller maps survive handler re-registration. - Batch removal preflight into one targeted scan (with refreshActivity) per 500 ids instead of one scan per row. - Scan repos at concurrency 2, report discovered counts upfront for honest progress, share fs-activity probes per path (folder workspaces), read only the reflog tail, and skip the snapshot read-before-write via a remembered scannedAt. Split workspace-cleanup-worktree-listing, workspace-cleanup-facet-row-caches, and workspace-cleanup-selection-model out of files that crossed max-lines. * fix(workspace-cleanup): address verifier findings - Fall back to a full reflog read when the newest record exceeds the 8KB tail window, so an oversized subject cannot hide recent ref activity. - Bound the single-removal snapshot prune batch id with a UUID; embedding the unbounded worktreeId silently failed main's 128-char validation and skipped the prune for long remote ids. - Key the main-side broad-scan supersession by sender AND scan mode so legacy suggestion-only and full-workspace scans stay isolated, matching the renderer registry. * fix(workspace-cleanup): own facet caches with useMemo instead of render-time ref writes React Doctor (CI changed-lines gate) correctly flagged the three cache refs written during render. Each per-candidate cache now lives in one memo with the derived context it is keyed on, so the memo deps are the invalidation and interior fills stay content-addressed; the matched-set identity stabilization is dropped since its only consumer reads through a useEffectEvent and never keys on identity. |
||
|
|
f4e4b5aded | Update README downloads badge | ||
|
|
2e91a74deb |
fix(renderer): stop git history hover from showing two tooltips (#14468)
Remove native title attributes from commit rows, subjects, and ref badges so only the managed tooltip appears. |
||
|
|
fcd99fc032 | fix(sidebar): satisfy extracted hook quality gates (#14467) | ||
|
|
08027d0d23 |
refactor(sidebar): split WorktreeList into a worktree-list module (#14465)
WorktreeList.tsx was 6.8k lines behind an `eslint-disable max-lines`. Break it into `sidebar/worktree-list/`: the container keeps store wiring and composition, the virtualized viewport keeps layout, and the drag, reveal, virtualization, row-model, and row-render concerns each get their own file. Every file now fits the oxlint budget, so the suppression and its baseline entry are gone. Behaviour-preserving. The only deliberate cleanups are duplicate branches folded into shared helpers (drop-preview state updates, status-hover fallback, the two identical scroll-to-index reveal branches) and a dead sticky-header-index ref. Tests that asserted on WorktreeList.tsx source text or imported its named helpers now point at the module that owns them. |
||
|
|
e2e7768cc8 |
fix(terminal): deliver Ctrl+C when a non-Latin input source rewrites the key (#14462)
With a non-Latin input source the OS reports the layout's own glyph for `key` — a Hangul jamo on Korean 2-Set, Cyrillic es on Russian — while `code` stays KeyC. The interrupt policy read `key` for identity and only consulted `code` when `key` was empty or Unidentified, so a jamo short-circuited it to false: the press missed the ETX path and was CSI-u encoded instead, leaving a TUI running. Trust `key` only when it is a Latin letter, which keeps a Dvorak remap of C authoritative. Otherwise ask the layout map what the physical key produces unmodified: an IME layered over a Latin layout answers 'c', and over a Dvorak base answers 'j', which correctly declines. When the map is itself non-Latin it cannot answer either, so fall back to physical position — how terminals have always resolved control chords. Fixes #14460 |
||
|
|
3035aa9211 |
fix(browser): restore replaced cookies through CDP identities (#14383)
* fix(browser): restore replaced cookies through CDP identities Both remaining callers of the imported-domain replacement rolled back by rebuilding cookies with cookies.set, which silently drops partitionKey. The rollback in importValidatedCookies puts back the user's ORIGINAL cookies that the import already deleted, so a CHIPS cookie came back as an ordinary one and no restart recovered it. Snapshot CDP identities before the first removal and undo through them, the same machinery removeTransplantableCookies already uses. The store type omits 'set' so the lossy reconstruction cannot be reintroduced, and restoreImportedDomainCookies is deleted now that both callers are gone. * fix(browser): skip the CDP rollback when nothing was replaced restoreClearIdentities attaches the debugger before it iterates, so an empty restore set would spin up a hidden BrowserWindow to put nothing back. The old cookies.set restore was a no-op loop in that case. |
||
|
|
b9627e91a9 |
perf(renderer): parallelize runtime catalog and worktree refresh (#14138)
Co-authored-by: Orca <help@stably.ai> |
||
|
|
77f23b013f |
refactor(shared): drop the shared/types barrel and import from the real modules (#14447)
#14397 split `shared/types.ts` into 46 per-domain modules but kept the path as a re-export barrel so the import sites did not have to change. This removes the barrel: every consumer now imports from the module that actually declares the type, and `src/shared/types.ts` is deleted. Barrels hide where a type lives, make every consumer look like it depends on the whole domain, and let an unrelated edit invalidate a module that ~2,000 files transitively import. 2,323 import declarations across 2,321 files. Rewritten mechanically: each specifier was resolved to an absolute path via the TypeScript AST and recomputed, rather than string-substituted, so alias forms (`@/../../shared/ types`) and per-specifier `type` modifiers survive. Four cases the mechanical pass had to handle, each found by a gate rather than by reading the diff: - Modules inside `src/shared` import the barrel as `./types`, not `shared/types`. A pre-filter on the latter string skipped 176 of them and left imports dangling at a deleted file, which surfaced as confusing `Property 'x' is optional in type 'Repo' but required in Pick<Repo, ...>` errors rather than "module not found". - The barrel RENAMED one type on the way through (`WorkspaceSource as WorkspaceCreateTelemetrySource`), so the original name in the owning module has to be re-aliased at each consumer. - Three test files put `;(globalThis as ...)` on the line after the import. TypeScript parses that `;` as the import statement's terminator, so replacing through `statement.getEnd()` deletes it and breaks ASI. The rewrite now stops at the module specifier. - A file that already imported directly from a module got a SECOND import from it, because the barrel re-exported those same names — which trips `import/no-duplicates` under `--deny-warnings`. A post-pass merges declarations sharing a specifier and type-only-ness; the `import type` plus `import` pair from one module is left alone, since that form is allowed. Splitting one barrel import into several genuinely adds lines, which pushed `terminal-layout-pty-ownership.ts` to 301 counted lines: its 107-character import must wrap, and neither local type collapses onto one line (101 and 116 characters). Rather than contort a type declaration to fit a line budget, `collectLeafIds` and `pruneLeaves` move to `terminal-pane-layout-tree.ts` — they are pure structural operations on the layout tree and independent of PTY ownership. `visible-worktrees.ts` similarly loses its own mini-barrel re-export of `isDefaultBranchWorkspace`, with the four real consumers repointed at the declaring module. No `max-lines` bypass added. Verified: cold `tsc --noEmit` green on node, cli, and web (buildinfo deleted first — these projects are `composite: true` and reuse stale caches); the full `pnpm lint` green, not just bare oxlint — the narrower local check is what let the duplicate imports reach CI; max-lines ratchet OK at 344. |
||
|
|
cd6114ab7e | fix(browser): acknowledge paired tab before navigation (#14402) | ||
|
|
b04da03ffc |
Require an absolute Orca CLI for the agent-teams tmux shim (#14438)
The generated tmux shim fell back to a bare orca / orca.cmd / orca-ide, and cmd.exe resolves an unqualified command against the current directory before PATH (sh does the same via ./empty PATH entries), so a stray orca.cmd in an agent's checkout could run with the agent-teams team id and token in its environment. Resolve only absolute paths, honor the Windows Path env spelling, degrade to in-process teammates when no CLI can be qualified, and make both shims exit 127 instead of guessing. Verified on macOS, Linux (dash + bash), and Windows (cmd.exe + Git Bash). Fixes STA-4215. |
||
|
|
bac21ee64d |
fix(worktree-scan): keep the admin-fingerprint probe inside the caller's per-repo budget (#14454)
* fix(worktree-scan): keep the admin-fingerprint wait inside the caller's per-repo budget The awaited probe was capped at 10s while `computeResolvedWorktrees` gives each repo 5s, so a slow mount always blew the budget: the caller gave up and republished persisted rows. The resolved snapshot was then stamped from the *start* of the compute, so a compute longer than its 1s TTL published an already-expired entry and the next poll repeated the whole 5s wait — deterministically, on every TTL expiry. Cap the probe at 2s so the remaining budget still covers the fallback `git worktree list`, and stamp the snapshot on completion. * fix(worktree-scan): derive the probe deadline from the caller budget A flat 2s cut reuse for hosts whose probe lands between 2s and 5s, which used to fit the caller's budget — trading the stall for a repeating `git worktree list`. Subtract a fallback-scan allowance from RESOLVED_WORKTREE_REPO_TIMEOUT_MS instead, so the invariant holds by construction and only probes that could not have fitted are cut. Tests now pin both ends: too large fails the budget invariant, too small fails reuse for a slow-but-healthy probe. |
||
|
|
5a6837fce4 |
refactor(store): unify the duplicated catalog equality and identity-key helpers (#13804)
* refactor(store): unify the catalog structural-equality walks Three near-identical structural deep-equality walks had landed independently in the same window: areValuesEqual (#13744, repo-identity-reconcile.ts), areCatalogEntriesEqual (#13770, repos.ts — already folded into the first on this branch's base) and catalogValuesEqual (#13662, worktree-catalog-reconciliation.ts). All three walk plain records and arrays and fall back to reference equality for anything exotic. They are not interchangeable. Two axes genuinely differ, and each caller depends on its own side: - Own-key set. #13744/#13770 require equal own-key counts plus hasOwnProperty, so an absent key differs from a key present and holding `undefined`. #13662 compares the union of both sides' keys, so those are equal. The strict side is load-bearing: the repo/project merges branch on `'localWindowsRuntimePreference' in project` (repos-project-runtime.test.ts "clears stale local runtime preferences"), and projects are now reconciled with this comparator. The loose side is test-pinned by worktree-catalog-reconciliation.test.ts "reuses rows with equivalent nested catalog data", where a locally built row carries `optional: undefined` that the host omits. - Leaf comparison. #13744/#13770 use `===` (NaN never equal, 0 equals -0); #13662 uses `Object.is` (the reverse). So instead of picking a winner, src/shared/structural-value-equality.ts holds one walk parameterised by those two axes and exports the two policies as `structuralValuesEqual` and `structuralValuesEqualIgnoringUndefined`. Every caller keeps its exact current semantics; the ~40 duplicated lines and the silent divergence go away. src/shared/persisted-ui-equality.ts (a fourth copy with a Set branch and no plain-object guard) is deliberately left alone: it gates a disk write in main with no direct test coverage. Also folded, all provably behaviour-identical: - The `${hostId}\0${repoId}` composite key had three copies (getRepoHostIdentityForParts, repoOwnerKey, getEntryKey) that must agree or repos silently stop reconciling. Moved to src/shared/repo-host-identity.ts because one of them lives in src/shared; the renderer module re-exports it. - mergeFetchedReposForHost's hand-inlined upsert loop now calls mergeByIdentity. mergeByIdentity additionally skips replacing a structurally equal row, which cannot change the result here: reconcileFetchedRepos runs immediately after over the same identities in the same order and restores exactly those rows. - Renamed repos.ts's `catalogRowsUnchanged` to `arrayElementsUnchanged`. It is a pure element-identity compare, two files away from `catalogRowsEqual`, which is a full structural compare. src/shared/structural-value-equality.test.ts pins both policies over arrays, nested records, null-prototype records, absent-vs-undefined keys, symbol keys, and non-plain objects (Date/Map/Set/class) falling back to reference equality. * fix(store): keep merged sourceRepoIds order host-independent Prefixing the cross-host remainder made a cross-host project's sourceRepoIds order a function of the refreshing host, so the projects reconcile never reused the row. Also pins the repo-derived host-id contribution the new per-project slice feeds the host-id resolvers. Co-authored-by: Orca <help@stably.ai> * refactor(store): migrate call sites that landed after this branch github.ts and ai-vault-session-identity.ts began using areValuesEqual on main while this branch was stale, and repo-identity-reconcile's record reconciler still called its own deleted walker. All three now use structuralValuesEqual; reuseEqualCatalogRows keeps its duplicate-id cap and calls the ignoring-undefined variant, which is the key-union semantics catalogValuesEqual had. --------- Co-authored-by: Orca <help@stably.ai> |
||
|
|
d6e6b9e303 |
fix(native-chat): keep caller abort ahead of WSL gate refusal (#14445)
Why: a hook-path or scan refusal that races the caller's abort was converted into a WSL unavailability error, so cancellation looked like a stalled distro. |
||
|
|
eb9ac440aa | Revert "Center newly opened tabs at end, keep close controls visible" (#14450) | ||
|
|
aecce221bd |
fix(test): deflake relay exec env, project boundary, and speech resume tests (#14446)
Three test-suite problems, all root-caused in the tests rather than in
production behavior.
1. src/relay/agent-exec-handler.test.ts (real failure, not a flake)
The two spawn-argument assertions failed with "Number of calls: 1" — spawn
ran, but the env differed. Cause: both assert
`expect.objectContaining({ ...process.env, ... })`, which demands that every
ambient variable reach the child verbatim. #7986 (
|
||
|
|
25abb9368d | fix(stats): wrap usage breakdown model names (#14065) | ||
|
|
4221f8d429 |
refactor(preload): split the preload contract into per-domain api modules (#14403)
`src/preload/api-types.ts` was 3,752 raw lines (3,533 counted, 11.8x the 300-line budget) behind an `eslint-disable max-lines`. Almost all of it was a single `PreloadApi` object type whose ~83 namespace properties were declared inline, so any IPC surface change meant editing one 2,600-line type. Give each namespace a named type in its own module under `src/preload/api/` (`pty-api.ts`, `filesystem-api.ts`, `github-pull-request-api.ts`, ...) and recompose `PreloadApi` from those names. `api-types.ts` keeps the `declare global` Window augmentation and re-exports every moved name, so all 52 import sites are untouched. Two shapes needed care to stay type-identical rather than merely compatible: - Three keys (`gh`, `git`, `ui`) are composed from two modules each. A plain intersection is NOT identical to the original flat object literal, so those use a `Merged<T>` mapped type; a negative control confirmed that dropping it fails the parity assertion. - Keys whose module groups several namespaces use indexed access (`fs: FilesystemApi['fs']`) to preserve exact identity and source order. `config/tsconfig.web.json` and `tsconfig.tc.web.json` enumerate files by path, so they need `src/preload/api/**/*` alongside the existing `api-types.ts` seed or the web projects fail TS6307. Verified by exact type identity, not assignability: 41 assertions of the form `Equals<Now.X, Before.X>` against a frozen pre-split snapshot, covering every exported name, plus a per-key pass over all 83 `PreloadApi` keys. All three projects typecheck clean with those assertions active. Verification note: these tsconfigs are `composite: true`, and `tsc --noEmit` will reuse a stale `.tsbuildinfo` and report clean for a state that genuinely fails. Every result above was produced after deleting the buildinfo, including a negative control confirming the gate still fails on deliberate drift. Drops the `max-lines` bypass and its baseline entry (ratchet 346 -> 345). |
||
|
|
583ab1601b |
refactor(shared): group worktree, github, and linear modules into folders (#14437)
`src/shared` is a flat directory of ~1,150 entries. The worktree, github, and
linear domains accounted for 71 of them, so finding the module you wanted meant
scanning a wall of same-prefixed filenames.
Move each domain into its own folder and drop the now-redundant prefix:
src/shared/github-pr-types.ts -> src/shared/github/pull-request-types.ts
src/shared/worktree-id.ts -> src/shared/worktree/id.ts
src/shared/linear-links.ts -> src/shared/linear/links.ts
This follows the existing `network/` and `new-workspace/` convention in the
same directory, which also drop the prefix inside the folder.
Whole clusters move, including tests. Foldering only part of a domain would be
worse than flat: a reader would have to check both `github/` and the flat
directory, and `github-auth-types.ts` / `github-project-types.ts` are type
modules that belong with the rest. No files with these prefixes remain flat.
Import specifiers were rewritten by resolving each one to an absolute path and
recomputing it, not by string substitution, so the `@/../../shared/...` alias
forms are handled correctly. 501 specifiers across 298 files.
Two things `tsc` cannot catch, handled explicitly:
- `github-project-types.ts` carries its own `max-lines` bypass, so its baseline
entry is REPOINTED to the new path rather than pruned. Pruning would drop the
bypass and then flag the new path as a fresh violation. Ratchet stays at 345.
- `mobile/` is outside `pnpm typecheck` and cannot be typechecked here
(`mobile/node_modules` is empty). Instead every relative specifier in the repo
was resolved against the filesystem: 174 unresolved before this change and 174
after — identical, so nothing broke in mobile either.
The pinned `tests/e2e/.cross-version-checkouts` fixtures are deliberately NOT
rewritten; they are a snapshot of an older release and still reference the old
paths.
Verified: cold `tsc --noEmit` green on node, cli, and web (buildinfo deleted
first — these projects are `composite: true` and reuse stale caches).
|
||
|
|
070572bfd7 |
refactor(shared): split shared/types.ts into per-domain type modules (#14397)
`src/shared/types.ts` was 3,981 raw lines (2,825 counted, 9.4x the 300-line budget) behind an `eslint-disable max-lines`, and is imported by 2,092 files — the single widest contract surface in the repo. Move all 320 top-level declarations into 46 per-domain modules (`repo-types.ts`, `worktree-types.ts`, `github-pr-types.ts`, ...) and reduce `types.ts` to an explicit re-export barrel, so the 2,092 import sites are untouched. `HostSettingOverrides` moves into the pre-existing `host-setting-overrides.ts` alongside the accessors that operate on it, which also removes that module's circular import back into `types.ts`. Named re-exports only, never `export type *`: with star re-exports a name exported by two modules is silently dropped, which would surface as a confusing "has no exported member" at a random call site. Verified lossless mechanically, not by inspection: - export parity — the module's resolved export set through the TS checker is identical before and after (396 names, no additions, no removals) - declaration parity — all 320 declarations compare character-identical modulo comments and whitespace, so no optionality, union order, or generic parameter drifted - `tsc --noEmit` green on the node, cli, and web projects - `oxfmt --write` is byte-identical, so the barrel is format-stable Drops the `max-lines` bypass and its baseline entry (ratchet 346 -> 345). |
||
|
|
400321edcd |
fix(workspaces): gate the GitHub palette number match on repo remote identity (#14413)
* fix(workspaces): gate the GitHub palette number match on repo identity `repoMatchesGitHubSlug` returned the permissive `'unknown'` whenever the repo displayName was not in `owner/repo` form and no upstream metadata existed — the common basename-named non-fork case. The caller only rejects on `false`, so a pasted issue/PR URL could activate a workspace in a different repo that happened to share the number, since issue/PR numbers are per-repo. Mirror the GitLab gate from #14381: fall back to the probed `gitRemoteIdentity.canonicalKey` before giving up, comparing host and owner/repo after normalizing port, `www.`, and case. An `upstream`-derived identity stays `'unknown'` because `deriveGitRemoteIdentity` ranks `upstream` above `origin`, so a fork's own origin is invisible and rejecting would drop URLs from the fork the user actually checked out. The canonicalKey compare runs after the displayName branch: displayName is compared host-agnostically, so mirrors and host aliases of the same owner/repo keep matching as they do today, and the probed remote only fills in where no name evidence exists. Refs STA-4237 * fix(workspaces): keep SSH host aliases matching in the palette identity gate `git remote -v` reports ssh.github.com, www., and ~/.ssh/config `Host` aliases verbatim, so comparing a probed canonicalKey against a pasted URL host rejected legitimate GitHub/GitLab remotes. Normalize the alias hosts both sides can fold offline, and downgrade a host-only mismatch to 'unknown' when the probed host is dotless (an unexpandable OpenSSH alias); dotted hosts like ghe.example.com still lose. Lifts the GitHub host normalizer into shared instead of a third copy. * fix(repos): keep the www host fold out of the derived project identity getProjectIdentityKey feeds the persisted Project id, so folding www. there re-keyed existing projects on upgrade and dropped localWindowsRuntimePreference. Restrict the fold to the palette's URL-vs-remote comparison, and pin the derived id for a www. remote so it cannot drift silently again. |
||
|
|
17690d49ea |
fix(repos): re-probe git remote identity so a stale snapshot stops misjudging identity gates (#14414)
* fix(repo-identity): re-probe resolved git remote identities on a long TTL A resolved gitRemoteIdentity was written once and frozen for the life of the repo record, so adding an `upstream` remote later — or a project rename or transfer — left identity gates judging against the path the repo had when it was added. Re-probe resolved repos on a 6h TTL, seeded 5 minutes after a repo is first seen in a process and capped at 4 refreshes per sweep so a restart cannot fan out a subprocess per repo. Only a successful probe that yields a different canonicalKey overwrites; failures and no-remote answers leave the existing identity alone. Also explain why the worktree-scan admin fingerprint timeout deliberately exceeds its caller budget, and log when that probe expires — expiry was silent and indistinguishable from "fingerprint unavailable". Refs STA-4247 * fix(projects): carry project state across derived project id changes A project id is derived from repo identity, so a remote re-probe (or a repo:->git:->github: promotion) rewrites it. The compatibility merge matched prior rows by id only, dropping the user's localWindowsRuntimePreference and leaving a ghost project row that independent host setups still pointed at. Both merge sites now fall back to the prior row whose sourceRepoIds overlap and re-point independent setups at the surviving project. |