mirror of
https://github.com/stablyai/orca.git
synced 2026-10-24 08:03:58 +00:00
17954a1e46edfafee4342a0aa4faee321076faeb
79
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
15fd723bc4 | fix(terminal): drop conda's orphaned CONDA_SHLVL sentinel (#15885) | ||
|
|
c92f394cde |
fix(pty): delete the reply-withholding scheduler (#15578)
* fix(pty): answer a terminal colour query in its own turn Root-cause follow-up to #15559, which stopped a CPR overtaking a deferred colour reply but left the deferral itself in place. Orca answers terminal queries by writing to the PTY master, which a line discipline in ECHO copies straight back out as junk on a cooked prompt (#12112). The guard was to withhold the write until an `stty` subprocess proved ECHO clear — and forking is what forced the decision to be async. Any deferral, however short, lets a reply written later in the same turn overtake this one, so the async probe was the bug's root cause. Read the bit synchronously instead. Linux and the BSDs redirect a master's mode ioctls to the slave, so a `tcgetattr` on the master fd node-pty already owns answers for the slave with no fork: measured 0.26us against 2403us for the subprocess. With a verdict available inline, a querying program that already cleared ECHO — every raw-mode prober, including the colour probe behind the `gh auth login` report — is answered in its own turn and can never be reordered. The deferral stays for the genuinely cooked case, and the ordering guarantee stays underneath it: hosts whose node-pty predates this patch get no sync probe and fall back to the deferred path, which mixed client/host versions make a live production path. Reply routing is all-or-nothing: a payload needing neither containment nor ordering stays on the host's own path, so a CPR answered during shell startup cannot pass the daemon's post-ready flush gate and splice into the buffered startup command. Native side is fail-safe: a kernel that did not redirect would answer from the master's own termios, whose ECHO defaults set, so the degraded verdict is "echoing" — never a false "quiet". The JS half ships in the pnpm patch while the binding needs a source build, so ORCA_REQUIRE_NODE_PTY_ECHO_STATE=1 makes CI fail rather than silently skip when it is handed an upstream prebuild. Co-authored-by: Brennan <brennanb2025@users.noreply.github.com> * fix(pty): keep the flush ordered under synchronous re-entry Three defects found in external review of the reply-ordering work. node-pty delivers onData inside the master write, so a query can be answered while the queue is mid-flush. `flushPendingWrites` spliced the array off before writing, so that reply saw an empty queue, took the same-turn path, and landed ahead of entries the loop had not written yet — reproduced as 01, 99, 02, 03. It now shifts one entry at a time so a re-entrant reply queues behind the rest, bounded by the length at entry so a re-entrant push cannot spin the loop. An overflow flush can re-enter as far as teardown. `answer` did not re-check `closed` afterwards, so it queued behind a closed delivery, returned true, and the reply was never written and never reported. The payload router's ownership comment overstated its guarantee. The `any` semantics are deliberate — returning false after a constituent was already written would have the caller re-write the whole payload and duplicate it into the child's stdin — so the residual mixed-failure drop is now documented rather than implied away. * fix(pty): delete the reply-withholding scheduler Orca answered a terminal query by withholding the write until a probe proved the slave's ECHO bit was clear. That was the wrong mechanism, and it is now gone: replies are written in the caller's turn and their echo is contained on the output side, where it always was. Withholding never removed an echo. The wait was bounded and always ended in a write, so the output-side projections were doing the work the whole time — including the readline rewrite, which happens with the tty already raw and which therefore no reading of the ECHO bit can predict. What withholding did add was an asynchronous write path, and that is what let one reply overtake another and land in the next program's stdin (#15559), what produced a re-entrancy inversion inside its own flush, and what four rounds of regressions have lived in. The last thing it covered was the verbatim echo of a `stty -echoctl` tty. That shape is now projected directly. It starts with ESC, so it is matched only when complete and never held as a partial: holding it would take a bare trailing ESC from the query parser and an expired hold would release it raw, so a query torn at its own ESC would never be answered. Complete-match-only is what makes the shape safe to project at all. Measured on a real pty: a cooked-mode master write is both echoed AND delivered — ECHO copies the bytes without consuming them from the slave's input queue, so a program arming raw mode with TCSANOW/TCSADRAIN (libuv's setRawMode, hence every Node agent) still reads them. Only a TCSAFLUSH switcher discards it, which it does on every terminal, none of which gates a reply on termios state. Deletes the pending-write queue, the async stty probe, the poll budget and probe rate limit, the deadline-driven flush, and the answer/ answerInOrder split. Replies now leave in call order by construction. No packaging, native or CI surface is touched. * test(pty): restore stty-probe coverage and pin the duplicate-query retry Archaeology on how withholding got here, and what its tests were really protecting. Deleting the ECHO probe took four tests with it that were not about the probe at all: they cover createSttyProbe, which the shell-readiness line-editor probe still uses — in-flight sharing, the per-platform stty flag, and transient-versus-permanent failure latching. Restored against the line-editor probe, which is now their only caller. Also pins the property that answers the one case an immediate write cannot serve. A program that queries while cooked and then arms raw mode with TCSAFLUSH discards the reply with the rest of its input queue. Nothing can prevent that from the terminal side, and no terminal tries. What matters is that such a program re-queries after its own timeout: the ingress declines to answer an already-answered slot but forwards the duplicate downstream, so the renderer's emulator answers the retry, by which point the program is raw. The retry path is the recovery, not withholding. * ci(pty): keep the fish real-PTY test in the shell-contracts lane only Reverting pr.yml to main dropped the exclusion for the fish query-reply test, which this branch keeps, so it would have run in the sharded lane as well. Restores it to the shell-contracts include list and the shard exclude list, and drops the parallelism expectations for the deleted cooked-querier suite and the echo-state env guard. --------- Co-authored-by: Brennan <brennanb2025@users.noreply.github.com> |
||
|
|
d7a23c84a9 |
fix(pty): keep a CPR reply from overtaking a deferred colour reply (#15559)
A background-colour probe writes `OSC 11 ;? ST` then `CSI 6n` and reads exactly one response, using the CPR as its sentinel: a non-OSC first response means "unsupported" and it stops draining. #13309 routed live cooked-echo-risk replies through the ECHO-probe deferral while CPR kept the immediate path, so the CPR overtook the colour reply, the prober gave up, and the stray `ESC ]` was left in the tty for the next program — `gh auth login` died on it with an escape-sequence error. Queue a reply that needs no echo containment behind ones that do, FIFO, and only while something is actually deferred, so latency-critical replies stay immediate on every other path. Windows is unaffected: only posix-pty defers, so the queue is always empty there. Also: an in-flight echo probe is already the write continuation, so re-arming the timer for a queued reply would fork a second stty and throw away the first verdict; and teardown now hands queued uncontained writes to the pty best-effort instead of dropping bytes the caller was told were sent. Known scope limit, pinned by tests and tracked for follow-up: the guarantee is FIFO among recognised query replies, not over every byte — a reply coalesced with a keystroke, and ordinary typed input, still bypass the queue. Both were unordered before this change too. |
||
|
|
1be6a035b9 |
fix(shell): carry a WSL pane's shell across relay revive and stop forking to probe zsh emulation (STA-4682) (#15361)
* fix(shell): carry a WSL pane's shell across relay revive and stop forking to probe zsh emulation (STA-4682) Two residuals left behind by #15236 and #15258. Relay revive called resolveDefaultShell() and ignored the entry's shell override, so a restarted relay handed the user a PowerShell pane where a WSL one had been -- and with it the host default shell's history rather than the worktree-scoped one #15236 injects on spawn. The override and the requested distro are now serialized (optional fields; state from an older relay still revives the default shell) and re-resolved through the same allowlist a fresh spawn uses, so revive re-launches the same shell with the same guest-visible HISTFILE and the same WSLENV carrier. A bad override degrades that one pane instead of failing the whole batch. The wrapper's three `$(emulate)` probes each fork a zsh carrying everything the user's config has loaded, and since wrapping widened to every zsh pane every pane pays for them. Each probe now sits behind a fork-free option test that is true whenever `emulate sh`/`emulate ksh` has run; the exact probe still runs when that test passes, so no answer changes. Measured on zsh 5.9 / macOS: 9.97 ms/run unwrapped, 14.20 ms/run wrapped, 12.27 ms/run wrapped after -- about half the wrapper's cost. * fix(shell): harden the revive override path and silence the option probe on an exotic zsh Readiness-review follow-ups on this branch. - `[[ -o <name> ]]` prints "no such option" to stderr and returns false rather than aborting, so a zsh lacking one of the three Bourne option names would put that text in the user's pane. All three predate every supported zsh, so `2>/dev/null` is belt and braces -- but the belt costs nothing. - Bound the WSL distro name revive replays into `wsl.exe -d <name>`. It is the one field this branch newly routes from untrusted serialized state into argv, and reviveEntry's stated job is to re-apply fresh-spawn bounds. - Skip a pane whose overridden shell can no longer spawn instead of letting the throw escape the revive loop and cost every later entry its state. Skipping, not falling back to the host default shell: substituting a different shell is the defect the override exists to fix, and the args and history env are built for the shell that is gone. The worktree-removal fence throws before this, outside reviveEntry, so it stays a hard failure. |
||
|
|
fe95698b95 |
fix(relay): let one owner hold pty.ackData instead of relying on construction order (#15079)
PtyHandler registered a no-op pty.ackData handler and SshPtyConsumerSessionAdapter registered the real sourceCredit.acknowledge for the same method. onNotification is a single slot, so only the adapter's survived — and only because relay.ts constructs it second. Reversing those two lines would have made every credit-mode delivery wedge permanently once the 256KB window emptied, with no error and no log. Delete the dead no-op, and make onNotification throw on a duplicate registration the way registerPtyDataPublicationAdmission already does for the admission slot. pty.ackData was the only double-registered method in the tree, so nothing else changes behavior. The existing test asserted only that pty.ackData appeared in the handler map, which stays true when the real handler is shadowed. It now asserts PtyHandler does not own the method. |
||
|
|
66a5e5d245 |
fix(shell): repair worktree HISTFILE in plain zsh panes via one positive feature channel (#15258)
* fix(shell): repair worktree HISTFILE in plain zsh panes via one positive feature channel
A plain zsh pane — no startup command, no agent overlay — was never wrapped, so
Orca's HISTFILE repair never ran in it. macOS `/etc/zshrc` assigns
`HISTFILE=${ZDOTDIR:-$HOME}/.zsh_history` with no check-before-set and runs
before any file Orca controls, so per-worktree history was a silent no-op for
every ordinary pane on the primary platform.
Wrapping those panes needs a way to say which wrapper features a shell should
turn on. That channel is one exported variable, ORCA_SHELL_FEATURES, carrying a
comma-separated positive allowlist from a closed set (history, markers, ready,
identity, overlay). The wrapper .zshenv reads it into a plain, non-exported
array and unsets it in its first executable lines, before the user's own
.zshenv — so the selection survives .zshenv -> .zprofile -> .zshrc -> .zlogin in
this process but physically cannot reach a child. There is no negative or
suppression variable anywhere; an absent or inherited value can only ever mean
fewer features. ORCA_HISTFILE is consumed and destroyed the same way, which
removes the root cause of #11146 instead of patching it.
All order-sensitive wrapper work now lives in one `__orca_shell_epilogue`
defined in .zshenv and invoked exactly once, from .zshrc for a non-login shell
and .zlogin for a login shell, with each feature an independent guard.
Selection is a pure function of spawn env and launch intent, so a pane wrapped
only for history gets no OSC 133 and is observably identical to the unwrapped
pane it used to be.
Generation is now fail-closed: wrapper files are written to a temp name and
renamed, every required path is verified non-empty, and ZDOTDIR is only set when
that holds. Previously a failed write still pointed ZDOTDIR at an empty dir and
the user silently lost their entire zsh config.
Orca also recognised only its own `*/shell-ready/zsh` dir shape when deciding
what the user's ZDOTDIR was, so being launched from any other terminal that had
hijacked ZDOTDIR captured that as the user's config dir. Ownership is now
established positively — a stamped marker file, or Orca's own dir shape for
wrappers written by older builds — and an inherited ZDOTDIR holding no zsh
startup file is ignored. No vendor is detected by name.
* fix(shell): make the zsh epilogue option-proof and stop history widening relay wrapping
Review follow-ups on the feature-channel PR.
- `emulate -L zsh` as the epilogue's first statement. It runs after the user's
own config, so `setopt no_unset` made the precmd_functions append a fatal
error that returned from the whole function (no ready widget, ZDOTDIR left at
Orca's wrapper dir), and `setopt ksh_arrays` made the 1-based feature
subscript drop whichever feature is listed first.
- The `/etc/zshrc` HISTFILE repair is no longer behind the `history` guard: it
undoes damage Orca's own ZDOTDIR caused, so it must also run for a shell that
re-enters the wrapper after the allowlist was consumed.
- The relay keeps its own wrapping gate. Its .zshenv resolves the user's config
dir from a ZDOTDIR Orca has already overwritten, so wrapping a remote pane
just for `history` cost a relocated-ZDOTDIR user their whole shell config.
- A failed primary spawn no longer leaks the primary shell's launch env
(wrapper ZDOTDIR + feature channel) into an unwrapped fallback pane.
* fix(shell): drop the relay wrapping gate and stop HISTFILE inheriting across Orca instances
The relay-specific gate added last round rested on a false premise:
main's hasOverlayRestoreEnv already included ORCA_REMOTE_CLI_BIN_DIR, and
ssh-pty-spawn-env sets that on every SSH pane whose session has a CLI
bridge — so ordinary remote zsh panes were already wrapped. The gate only
bit where remoteCliBridgeEnv is null (a host too old to report its
platform), where it silently dropped that pane's worktree history. All
three transports now share the features.length rule.
HISTFILE stays exported, so a newly wrapped pane handed the worktree
history path to every child, including a nested Orca whose panes then all
hit injectHistoryEnv's check-before-set and appended into the launching
worktree's file. Same class as the fish_history fix in #15195: recognise a
path Orca minted and drop it before the check, on the desktop, daemon and
relay injection paths and both history-disabled branches.
Also: run the epilogue from the wrapper .zshrc when zsh is in sh/ksh
emulation, since sourcehome() then reads $HOME/.zlogin and the wrapper's
.zlogin never runs; track fallback launch-env keys per attempt rather than
once from the primary; and guard the cross-file epilogue call so a wrapper
dir shared by two builds degrades quietly.
* fix(shell): make every wrapper file self-sufficient and retire the deleted marker vars from tests
- .zprofile/.zshrc/.zlogin each define __orca_resolve_user_config_dir. They
called it on line 2 while only .zshenv defined it, so a wrapper dir written by
two concurrently installed builds printed three "command not found" and
skipped the user's entire zsh config. New live-shell test covers it.
- Retarget every remaining ORCA_SHELL_READY_MARKER/ORCA_SHELL_STARTUP_IDENTITY
reference onto ORCA_SHELL_FEATURES, or delete it where the key is now dead.
- isOrcaMintedHistFile requires a leading '/', so a relative path of the same
shape stays the user's.
- Drop an unused no-control-regex disable, and register the two real-zsh suites
in the dedicated shell-contracts lane.
* fix(shell): stop the zsh wrapper colliding on REPLY and degrade under sh emulation
Widening wrapping from overlay/startup panes to every zsh pane turned three
latent wrapper defects into user-visible ones.
- The config-dir resolver used `REPLY`, zsh's shared scratch global, as its
out-parameter. `typeset -r REPLY` in a user config made the wrapper's first
executable assignment fatal, `typeset -i REPLY` silently resolved every path
to 0; both left HISTFILE inside Orca's wrapper dir. It now writes an
Orca-private `_orca_resolved_config_dir`, declared `typeset -g` so the
rename introduces no `warn_create_global` noise. A new rule test fails on any
generated wrapper file that writes a global outside Orca's namespace.
- The daemon dropped an inherited HISTFILE but never an inherited
ORCA_HISTFILE, which now both wraps a pane the client scoped nothing for and
re-exports another worktree's history path. The relay had the same gap on its
isolation-off and revive paths. Both now mirror the desktop.
- A user .zshenv or .zprofile ending in `emulate sh` makes zsh ignore ZDOTDIR,
so no later wrapper file is read and the epilogue never runs. Nothing can
repair HISTFILE from there, so the wrapper now detects the emulation and
hands the pane back unwrapped instead of leaving history somewhere invisible.
Also `typeset -g __orca_in_command` so the OSC 133 preexec hook prints no
warning under `setopt warn_create_global`.
|
||
|
|
9b1f0373eb |
fix(relay): scope shell history for Windows -> WSL panes (STA-4682) (#15236)
`injectRelayHistoryEnv` matched only bash*/zsh*, so a relay pane launched through `wsl.exe` got no HISTFILE at all and every WSL worktree shared one global history. The history file stays on the relay host under the existing flat root, so `deleteRelayHistory` remains the deletion counterpart unchanged; only the exported path is translated to drvfs for the guest, and WSLENV carries it across the boundary. Guest fish stays out of scope on purpose: its history file lives inside the distro, where the relay has no deletion path. |
||
|
|
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). |
||
|
|
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
|
||
|
|
3bb87ff93b |
reland(shell): one portable Unix startup dialect, with both revert causes fixed (#15018)
* reland: portable startup-shell dialect, with the two revert causes fixed Relands #14863 (reverted by #14975) with fixes for both regressions the revert cited. 1. History GC deleted folder-workspace shell history. The live set was built from `getAllWorktreeMeta()` alone, but a folder workspace's PTY carries `folder:<id>` as its worktree id, so every live folder workspace looked orphaned. `getKnownWorktreeIdsForHistoryGc` now unions in `getFolderWorkspaces()`. Both consumers — the history-directory prune and the fish-history sweep — read that one set, so the fix covers bash, zsh and fish history alike. The directory prune had this gap since #1524; #14863 only widened its blast radius to fish files. 2. A copied Codex resume command aborted under `set -u`. Its leading clear statement has to test `$fish_pid`, and that unbound expansion takes the whole line — including the agent launch — down with it. Copied text runs in a shell Orca never spawned, so nothing can seed that variable first. The removal now rides on the agent itself as `env -u`, which needs no shell syntax and no expansion. Verified byte-identical under `set -u` in sh, bash, zsh, dash, ksh and fish. `env` cannot run the `cd` builtin, and a child `cd` would not move the agent, so the prefix is placed on the agent rather than on the whole `cd … && agent` chain. cmd and PowerShell have no nounset hazard and keep their clear ahead of the `cd`, which preserves `cd … && agent` — a failed `cd` still cannot launch the agent in the wrong directory. * fix(history-gc): stop three more paths from deleting live shell history Found by adversarial review of the reland. All three are the same class as the bug that caused the revert: a live set that is missing a category of real workspace, so the GC reads it as orphaned. 1. Profiles. The history root is `userData/terminal-history`, which has no profile segment, but the Store the GC consults is per-profile. So after a profile switch the live set condemned every other profile's history — and fish history, which lands in the user's own fish data dir, is shared by every profile on the machine. The live set now unions in the inactive profiles' worktrees and folder workspaces, read from their data files. A profile whose ids cannot be read reports the empty set rather than one that condemns real history. 2. No empty-set guard on the tree scan. `sweepOrphanedFishHistoryFiles` refuses an empty live set because it cannot be told apart from a store that failed to hydrate; the directory scan, which deletes more, had no such guard. A store that fell back to default state would have taken every worktree's bash and zsh history with it, across all roots including WSL. Four existing tests passed `new Set()` and relied on "empty means everything is orphaned" — exactly the behavior being removed — so they now pass a real live set. 3. Relay fish history. The relay isolates its history tree under its own root but wrote fish history into the shared fish data dir under the desktop naming, keyed by the CLIENT's worktree ids. On a machine running both Orca and a relay host, the desktop sweep deleted remote sessions' history once it went stale. Relay files are now `orca_relay_<hash>`, which the sweep's pattern deliberately does not match; the relay still deletes them by exact name when the worktree goes away. * fix(resume): enforce the env-removal invariants instead of documenting them Both found by adversarial review; both were unreachable from today's callers and silent if reached, which is exactly how they would survive to a caller that does reach them. - A pinned CODEX_HOME and the removal named the same variable, and `env -u` strips what the assignment just set — so the agent would have resumed against the real home and not found the session. The removal list now excludes any name the prefix pins, keeping the assignment authoritative as the old `clear…; CODEX_HOME=x agent` ordering did. Same fix in the git-bash twin. The PowerShell branch already clears before it assigns, so it was never affected. - Placement was keyed on the platform while the grammar it selects is keyed on the shell, so `platform: 'linux'` with `shell: 'powershell'` emitted POSIX `env -u` into a PowerShell line. PowerShell now routes to the PowerShell builder whatever the host, and the POSIX/cmd split below asks the shell rather than the platform. |
||
|
|
f070033156 |
Revert "refactor(shell): one portable Unix startup dialect instead of shell d…" (#14975)
This reverts commit
|
||
|
|
b6ea3f17a9 |
refactor(shell): one portable Unix startup dialect instead of shell detection (#14863)
Orca had to guess which shell would parse a queued command line, then emit syntax for it. Guessing is unreliable for a remote or WSL host, and every dialect-dependent function is a place to get it wrong. Replace the guess. Everything emitted for a Unix shell is now built to be correct in sh, bash, zsh, dash, ksh and fish alike, so no detection is needed: - quoteStartupArg emits backslashes as "\\" and apostrophes as "'" between single-quoted runs. Both families read that identically, unlike the sh '\'' idiom, which fish silently halves and which makes a trailing backslash a hard syntax error. - clearEnvCommand emits a self-contained fish/sh branch. It deliberately does NOT call a helper defined by Orca's shell wrappers: Orca wraps only zsh, bash and fish, so an `sh`/`dash`/`ksh` login shell launches unwrapped — and the same text is copied to the clipboard and pasted into shells Orca never spawned. In both, a helper would be `command not found`, which is the exact failure this exists to avoid. Two guarded statements rather than `A && B || C`, because fish's `set -e` returns non-zero for an already-unset variable and would fall through to the sh branch; a trailing `true` pins the status, since this is the last statement of a launch line and the prompt renders it. - One tokenizer for Unix. The input is a settings string the shell never parses, so parsing it per-shell only made the same setting mean different things in different workspaces. AgentStartupShell loses its 'fish' and 'unix' members, and the three login-shell resolvers, the fish tokenizer and the agentEnv.SHELL probe go with them. Per-worktree shell history now actually works: - zsh on macOS was a no-op. /etc/zshrc assigns HISTFILE unconditionally before any wrapper Orca controls, so the injected value was already gone — and with ZDOTDIR still pointing at Orca's wrapper dir, history landed inside it. The intended path rides ORCA_HISTFILE and is restored after user config. Fixes #11044. - fish keeps history in its own data dir keyed by session name, since it ignores HISTFILE and has no custom-directory knob. Files are deleted rather than truncated, a symlinked ~/.local/share no longer disables cleanup, and a GC sweep reclaims orphans whose meta.json is gone. The sweep refuses an empty live-worktree set (indistinguishable from a store that failed to hydrate) and skips files younger than GC_MIN_AGE_MS, mirroring the tree GC's guard against the live-set snapshot race. Verified against real shells rather than asserted as strings: startup-shell-portability.live-shell.test.ts runs 194 assertions across sh/bash/zsh/dash/ksh/fish, and zsh-scoped-histfile.live-shell.test.ts drives a real login zsh through /etc/zshrc. Both are vacuity-checked. The same quoting corpus was replayed byte-exact on Linux, where /bin/sh is dash. |
||
|
|
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. |
||
|
|
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).
|
||
|
|
eb22e497bb | Revert "fix(ssh): reapply the reattach-identity work and stop the fallback fence stranding moved panes" (#14395) | ||
|
|
6a0c8fa541 |
fix(ssh): reapply the reattach-identity work and stop the fallback fence stranding moved panes (#14384)
* Reapply #13326 and #13928 (un-revert #14361) Restores the SSH reattach-identity and daemon-occupancy fixes. Reverting them reintroduced their P0s, filed as STA-4224, STA-4225, STA-4227, STA-4230, STA-4232, STA-4233 and STA-4234 against #14361. The tab loss that motivated the revert is fixed in the commits that follow, so this reapplication is not a straight redo. * fix(relay): stop the fallback attach fence refusing a pane that moved tabs The primary fence was moved to the shell's own incarnation precisely because paneKey/tabId froze the pane's LOCATION at spawn and refused panes that had merely moved. The fallback that older clients fall into kept the old rule, so the correction never reached it — the same 'the rule exists, but this path does not ask it' leak this work has hit repeatedly. A refusal here is not recoverable: an identity mismatch never grounds a respawn, so the pane keeps a live shell it can no longer reach and renders blank. Narrowed to paneKey, which is the identity; the tab is a location. Restoring the tabId comparison reddens the new test. |
||
|
|
11cd2b4310 |
revert(ssh): back out #13326 and #13928 — reconnect loses every tab (#14361)
* Revert "fix(daemon): stop killing live coding agents when the daemon can't report its sessions (#13928)" This reverts commit |
||
|
|
3ab8b6a117 |
fix(ssh): stop SSH reconnect from multiplying terminals and resuming agents twice (STA-3077) (#13326)
* fix(ssh): stop reconnect from grafting panes and stacking remote leases Reconnecting an SSH-backed workspace added terminal panes the user never opened, and the remote host accumulated shells nobody was using — one report went from 2 to 19 to 20 relay PTYs across three reconnects (STA-3077). Two root causes, both in the store. Reattach could create UI. `persistPtyBinding` has four creating branches — mint a tab, mint a root leaf, split the root and graft a leaf, mint a layout. All four are load-bearing for `pty:spawn`, which can beat the renderer's debounced layout writer, but none of them is appropriate on reattach, where the pane either already exists or is gone for good. Add `mayCreate`, defaulting true so the spawn path is untouched; every creating branch already sets `terminalMembershipChanged`, so refusing is a check rather than a new code path. Lease identity had no pane key. `upsertSshRemotePtyLease` matched on `(targetId, ptyId)` alone, so a pane that re-leased under a new relay id left its predecessor live with nothing to retire it, and the next reattach fanned out over both. One pane now keeps at most one live lease. Superseded leases are marked `expired` rather than terminated: losing a lease is not proof the shell died, so the remote process is deliberately left running. Tests assert observable behavior rather than mechanism, so they stay valid under any implementation that fixes this. Co-authored-by: Orca <help@stably.ai> * docs(terminal): record the terminal session behavior contract Properties stated as observable behavior rather than mechanism, so an oracle written against them survives a change of implementation. Records the weaker, correct form of the timer rule — a timer may never be the sole cause of a destructive action — because recovery budgets and scratch-file age gates are correct code that an absolute ban would condemn. Also notes which mechanisms are deliberately not required, so each has to earn its place rather than arrive with an architecture. Co-authored-by: Orca <help@stably.ai> * fix(ssh): heal duplicate pane leases that predate pane-keyed supersession Pane-keyed supersession stops new duplicates, but it does nothing for installs that already carry the ones STA-3077 accumulated — the report behind this reached 20 live leases across a handful of panes, and every reconnect fanned out over all of them. Retire the stale duplicates once per reattach pass, keeping the newest lease for each pane under a total order so two hosts resolve a tie the same way. As with supersession, retired leases are marked `expired` rather than terminated: their remote shells are deliberately left running, because a lease we chose not to revive is not evidence the shell died. The relay-session store stubs gain the new method. Note the gap this leaves open: those shells keep running and are no longer reachable from the app, so the "accumulates unused shells" half of the report needs a visible recovery surface rather than a silent kill. Co-authored-by: Orca <help@stably.ai> * fix(terminal): stop respawning a shell that is still running A pane that failed to reattach spawned a fresh shell. Because the restored session id came along, the replacement resumed the same agent session, and two processes appended to one transcript — reported repeatedly, up to five concurrent resumes of a single session. Two defects fed it. The relay reported a source that merely needed re-establishing as `SSH_SESSION_EXPIRED`. The shell was still running; only its output source was gone. Give that outcome its own error so it stops reading as "the session no longer exists". The reattach failure handler then treated every error as proof of death. It checked for expiry and, in the else branch, took the identical action — so the check bought nothing and a transport fault, a timed-out call, or a wedged relay all respawned. Respawn now requires proof: an explicit host expiry or a not-found PTY. Anything else, including an error we have never seen before, is unresolved, leaves the shell running, and keeps the binding for a later reattach. Two existing tests asserted the old behavior. One threw a bare error as scaffolding to reach the spawn-adoption door; it now throws proof, which is what it meant. The other pinned the expiry mapping itself, and now asserts the outcome fails closed *without* being reported as expiry. Co-authored-by: Orca <help@stably.ai> * docs(terminal): record what makes a retention bound safe Shortening a grace period is the wrong lever. Measuring process time and gating reclamation on an independent observation are what make one safe, and they are what deployed systems actually do. Also records that lifecycle belongs in the attach reply rather than a delivered event — that is what removes the need for a durable per-consumer cursor to guarantee an exit is never lost. Co-authored-by: Orca <help@stably.ai> * test(terminal): assert the empty-failure case without an empty Error A thrown empty value exercises the same property — a failure carrying no usable message is not proof the session is gone — and does not trip the empty-error-message lint. Co-authored-by: Orca <help@stably.ai> * fix(ssh): let the durable pane binding outrank recency when retiring leases Choosing the newest lease for a pane is wrong whenever a newer lease exists that no pane is bound to: it retires the lease the pane is actually attached to, detaching a live terminal instead of healing it. Two changes. Arbitration now prefers the lease matching the pane's durable binding, across both the SSH-target and local partitions, falling back to recency only when no binding names either candidate. And supersession at upsert time now defers rather than expiring a bound predecessor. When a lease arrives for a pane that is still bound to a different PTY, the binding has not caught up yet, so both stay live and reattach arbitrates once the binding is available. Co-authored-by: Orca <help@stably.ai> * fix(ssh): roll back a lease retirement whose durable write fails `flush()` logs and swallows write errors, so a failed write left these leases retired in memory while disk still called them attached — and the pane bindings scrubbed alongside them stayed scrubbed. Use `flushOrThrow` and restore both the lease states and the affected session partitions when it throws, reporting nothing retired. Co-authored-by: Orca <help@stably.ai> * test(ssh): prove pane and remote PTY cardinality across reconnects Counts the shells the relay actually hosts, on the container, rather than inferring them from app state — that is the census the report was based on. Asserts the PIDs are unchanged, not merely the count, so a kill-and-respawn cannot pass. Every pane streams before the transport is severed: an idle pane sends no recovery checkpoint, so only a live source comes back needing re-establishment, which is the outcome that used to read as expiry. Co-authored-by: Orca <help@stably.ai> * fix(ssh): actually pass mayCreate:false from the reattach binding write The `mayCreate` guard was correct and had no production caller, so the reattach path still went through the creating branches and grafted panes back. `restoreReattachedPtyRuntime` is that call site — RC3 in the original diagnosis — and it now refuses to create. Binding moves ahead of runtime registration, because registering first would surface a pane the user never opened before the refusal landed. A refusal leaves the remote shell running and reattachable; a *thrown* write stays unknown and still registers, so a failed disk write cannot detach a live pane. Adds an oracle over the call site itself. The store-level tests all passed while the fix was inert, because they called the store directly — only pinning the wiring catches that. Co-authored-by: Orca <help@stably.ai> * fix(terminal): apply the respawn-requires-proof rule to both reattach paths connectPanePty has two near-verbatim reattach blocks — one keyed on the deferred SSH session, one on the restored session — and only the second was fixed. The first still checked for expiry and then respawned unconditionally anyway, so a transport fault there resumed the same agent session a second time. Also keep the wire token out of the pane. The main-process bridge only special-cases expiry, so a source-restore failure crossed IPC as raw `SSH_SOURCE_RESTORE_REQUIRED: <id>` text and surfaced to the user. It correctly does not respawn; it just should not read like that. Co-authored-by: Orca <help@stably.ai> * test(ssh): state plainly that the reconnect spec is a forward guard It was run against an unfixed tree and passed, so it does not prove the STA-3077 fixes and should not be read as if it does. A clean severed transport does not reproduce the field conditions — accumulated duplicate leases, or a source returning needing re-establishment. It keeps its place as a forward guard: it counts the shells the relay actually hosts and pins their PIDs, so a later change that grafts a pane or respawns a shell fails here. Co-authored-by: Orca <help@stably.ai> * docs(terminal): record that a guard must be pinned at its call site A refusal that exists and is never passed is indistinguishable from no refusal, and store-level tests cannot tell the difference — they call the store directly. Learned from `mayCreate`, which was correct and had no production caller for several commits. Co-authored-by: Orca <help@stably.ai> * fix(ssh): park one PTY's exhausted delivery recovery instead of dropping the channel A per-PTY recovery budget running out disposed the whole relay channel, so one PTY that could not re-prove its delivery aborted every in-flight filesystem and git request on that host and stalled every sibling pane. A retry count is not proof of anything, and it certainly is not proof about the other sessions sharing the channel. Exhaustion now parks that PTY's delivery. The remote shell keeps running, its lease stands, and the next relay open reattaches it with a fresh delivery generation — the parked state is cleared on teardown and the generation changes on reconnect, so a reconnect recovers it. The consecutive-attempt ceiling goes away entirely; the per-generation one is what bounds the retry cost, and the second ceiling only existed to reach the channel drop sooner. Tradeoff worth stating: the failing pane used to self-heal within seconds because the forced reconnect wiped all rejection state, and it now stays frozen until the next relay open. That is a worse outcome for that one pane and a much better one for every other session on the host, and reconnecting is user-reachable. Co-authored-by: Orca <help@stably.ai> * fix(pty): let liveness say unknown instead of forcing it to say dead `IPtyProvider.hasPty` returned a boolean, so a provider whose inventory was empty for reasons that have nothing to do with the session — socket down, cache never hydrated, provider generation just constructed — had no way to say so and answered "absent". Its own siblings already knew better: `probePtyLiveness` and the runtime's `PtyController.hasPty` were both already `boolean | null`, with consumers branching on null correctly. The lie was injected at exactly one interface. Now three-valued, and each provider answers unknown where it cannot prove absence: the daemon adapter off-socket, the SSH provider before a completed listing, the router when any adapter cannot answer, and the degraded provider rather than fabricating a verdict. `terminal_gone` requires unanimous proven absence. Also fixes a real cold-start bug this surfaced: `pty:hasPty` never awaited the daemon-swap startup promise, though the sibling `probePtyLiveness` bridge already did, so before the swap the local provider answered an authoritative false for every daemon-owned id. Net +27 production lines. The plan behind this predicted -92 on the strength of deleting the renderer's dead-session reconcile path; that code is live (`pty-connection.ts` imports it), so nothing was deleted. Expressing a third value where there were two costs lines, and a deletion that is not real is not worth manufacturing. Co-authored-by: Orca <help@stably.ai> * docs(terminal): track the terminal-session correctness handoff package The package was untracked under a gitignored `docs/**`, with the un-ignore rules living only in an uncommitted .gitignore edit — a single `git clean -xdf` would have destroyed the authoritative plan. The 814-path construction snapshot is now pushed as `nwparker/react185-authority-snapshot` too; it had no remote ref. Co-authored-by: Orca <help@stably.ai> * test(ssh): make the reconnect settle window actually wait The settle poll reused a matcher the assertion 15 lines above had already satisfied, and Playwright's poll engine probes immediately and returns as soon as the matcher passes — so it observed the same state twice and elapsed 0ms. A shell grafted a second or two after reattach reported ready slipped through into the next cycle. Reviewer was right on #13111. Test-only; no production change. Co-authored-by: Orca <help@stably.ai> * test(ssh): census both durable session partitions on reconnect Adds a second reconnect scenario and a helper that reads pane records from the local partition as well as the ssh host partition. That split matters: the reattach binding call passes no hostId, so a grafted pane lands in the LOCAL partition and an oracle reading only the host partition passes whether or not the guard is present. Both tests remain forward guards. The second one was reported as discriminating and did not reproduce: with `mayCreate: false` removed from the call site and the app rebuilt, both still passed. Its induction races `pty:kill` against a severed transport, so when the kill lands the lease is cleaned up and there is nothing left to graft. The handoff README is corrected to say so rather than claim a journey. Co-authored-by: Orca <help@stably.ai> * docs(terminal): record the user decision relaxing G6 G6 becomes minimise-and-justify rather than strictly net-negative. The deletion budget the plan assumed does not exist: an entrypoint-rooted import graph found 51 of 53 candidate files reachable and instantiated on live paths, leaving 263 deletable LOC against roughly +1,021 to offset. Correctness may still not be traded for line count. Co-authored-by: Orca <help@stably.ai> * test(terminal): add discriminating oracles for restart, daemon, skew and namespaces Six parallel streams, each required to fail with its guard removed rather than merely pass. Local restart proves the OS process itself survives, by reading `ps -o lstart=` for the shell's own pid. That matters: with the quit path made destructive, the tab, leaf and pty ids all came back byte-identical while the shell underneath was a new process — every existing restart spec would have stayed green. Two separate guards were removed to redden it, and the second reddens only the stale-operation case. Daemon restart discriminates by reverting three-valued `hasPty`; version skew now covers publication semantics and confirms the new `SSH_SOURCE_RESTORE_REQUIRED` token mutates nothing on an old client; two-host isolation censuses both containers. Deletes `src/relay/pty-source-replay-index.ts` — 201 production lines with no importer outside its own test, verified against an entrypoint-rooted import graph rather than a name grep. Five namespace tests are skipped, not passing: they reproduce a defect still live on main where folder-workspace ids compare equal with the instance suffix stripped. PR #12474 fixes it; they are its oracle. Co-authored-by: Orca <help@stably.ai> * test(ssh): induce the reattach graft deterministically instead of racing a kill The previous induction closed a pane while the transport was severed and relied on `pty:kill` FAILING so the lease outlived the pane record. It does not fail: with the provider already torn down, `pty:kill` takes its tombstone branch and marks the lease terminated, and `reattachKnownPtys` filters terminated leases out of the fan-out — so the reconnect never visited the PTY the test was about. It passed on both trees. Seed the precondition instead. Spawn a real remote PTY on a leaf that never becomes a pane, then roll the host partition back to its pre-spawn snapshot, leaving a live lease and a live remote shell that no durable pane owns. No failure races a success. Adds a vacuity guard that is independent of the tree under test: the lease's own `lastAttachedAt` must advance, proving the fan-out actually visited this lease before the pane census is trusted. Verified on this machine under an isolated TMPDIR, since the e2e harness keys its seeded-repo pointer on a machine-global tmpdir path: guard present passes, guard removed fails with the phantom leaf grafted into the local partition, guard restored passes. Co-authored-by: Orca <help@stably.ai> * docs(terminal): propose one authoritative binding identity Every defect this program has touched is the same defect: identity compared with the wrong key, or not compared at all. Lease keyed without the pane, reattach using a creating write, folder-workspace ids compared with the instance suffix stripped, local mutating IPC carrying only an id, a live shell classified as expired, liveness unable to say unknown. Proposal: one branded binding type built from fields that already exist and are already persisted, constructible only from an authoritative source, carried by mutating operations, compared by one shared function. Makes a wrong-key comparison a type error rather than the next incident. Under adversarial review, including against the open issue corpus. Not accepted. Co-authored-by: Orca <help@stably.ai> * fix(pty): refuse mutating operations aimed at a superseded PTY `pty:write`, `pty:writeAccepted` and `pty:resize` accepted any id. The renderer queues input, so a keystroke buffered before a reattach landed on whatever PTY had since taken the pane — and a resize reshaped the successor's shell. Main already tracks `ptyPaneKey` and `paneKeyPtyId` in lock-step, so their disagreement is proof the caller's id was superseded. No wire change, no renderer change, nothing added to the input payload. An id with no recorded pane stays permitted: unowned and orphaned PTYs are unknown, not stale, and unknown never authorizes refusing an explicit operation. That is also what keeps orphan cleanup working — those ids have no pane by construction. The tests pin the CALL SITES, not the predicate. A capability that exists and is never called is indistinguishable from no capability, which is exactly how `mayCreate` sat inert here for several commits with every test green. Co-authored-by: Orca <help@stably.ai> * fix(pty): fence signals at a superseded PTY, and pin why kill is exempt A signal means "interrupt my pane", so delivering one to a PTY the pane has already replaced is a misdirected interrupt. Fence it with the same lock-step proof used for write and resize. `pty:kill` stays deliberately unfenced and a test now pins that: a superseded PTY is orphaned, and reclaiming it is exactly what the orphan-cleanup callers ask for. Refusing there would break the operation that reclaims leaked shells — the opposite of the intent. The fence sits at the IPC boundary, above `tryGetProviderForPty`, so it covers local, daemon and SSH rather than the local path alone. Co-authored-by: Orca <help@stably.ai> * test(terminal): poll the pane binding read so a slower host cannot flake it `readPaneBinding` took a single unpolled read of a DOM dataset attribute immediately after a renderer reload, while its sibling helper polls the same data for 15s. On a native Linux host both tests failed every run with 'No bound terminal pane is mounted' while the app was demonstrably healthy — the screenshot showed the terminal restored with a live prompt and the boot PID echoed. The assertion is unchanged; it is only awaited. Nothing is weakened. Found by running this spec on native Linux rather than assuming macOS behaviour generalises. Co-authored-by: Orca <help@stably.ai> * test(terminal): make the restart identity spec run on Windows too Both probes were POSIX-only and unconditional: `echo ...=\$\$` for the shell's own pid, and `ps -o lstart=` for its start time. Running the spec on a real Windows host proved it dies before reaching either guard, so Journey 1's Windows half was unprovable rather than merely unproven. PowerShell exposes the same two facts as `$PID` and `Get-Process` StartTime. The start time still matters on both platforms for the same reason: a PID alone cannot separate a survivor from a reused number. Still green on macOS. The Windows path is written from the host probe and has not itself been executed end to end — that is the next thing to run there, not a claim being made here. Co-authored-by: Orca <help@stably.ai> * docs(terminal): record the fence's real gap and what peer designs taught Marks the client-constructed binding proposal as rejected with the three false claims that sank it, and records what shipped instead. States the shipped fence's actual limitation rather than leaving it implied: it compares a binding, not an incarnation, so a respawn under a reused ptyId passes. The obvious remedy is wrong here — the agent-create id is deterministic by design so a replayed create stays idempotent, and randomising it would trade this narrow gap for a duplicate-spawn bug. Also records the ranked lessons from four comparable agent IDEs, chiefly that a typed end-reason at end time is what stops a user quit from looking like a resume candidate. Co-authored-by: Orca <help@stably.ai> * docs(terminal): promote Journey 1 to proven on all three platforms The oracle now runs natively on macOS, Linux and Windows, and its discrimination was watched on each: a mutation reddens it, a restore greens it. On Linux and Windows both mutations were run, and the second reddens only the stale-operation test — so the journey's two clauses are proved independently rather than jointly. Windows is the new evidence. The PowerShell branches added blind at ebffb85a848 executed correctly on their first run: `$PID` expanded to real integers, which also proves the pane shell there is PowerShell-family rather than Git Bash, and `Get-Process StartTime` returned kernel start times 5.4s apart — so a recycled pid could not have passed as a survivor. First journey promoted in this program. The other twelve are unchanged, and the residual limit on "every stale exact operation" is recorded rather than glossed. Co-authored-by: Orca <help@stably.ai> * test(terminal): add discriminating oracles for the daemon, skew and multi-host journeys Daemon: replaces a spec that modelled only a client restart and never crossed the daemon boundary, whose successor generation owned nothing so "the live successor is neither killed nor replaced" was vacuous. The PTY leader is now a real login shell reporting `$$` back through the production write path, resolved to a kernel start time. Two mutations each redden exactly one of the three clauses, on macOS and Linux: reverting three-valued `hasPty` reddens only the unknown-not-dead clause; widening the sole-provider fallback reddens only the stale generation clause. Skew: reverting the restore-required publication to expiry reddens 4 of 5 new tests while the legacy control stays green — the regression this branch fixed is now caught if reintroduced. Multi-host: restoring `mux.dispose('connection_lost')` reddens sibling isolation on one host. It does NOT redden across hosts, and that is recorded rather than glossed: a mux belongs to one relay session per target, so its dispose cannot cross a host boundary. Journey 4's cross-host clause rests on isolation-by-construction, not on a mutation. No production code changes. Co-authored-by: Orca <help@stably.ai> * docs(terminal): record journey evidence that falls short of promotion Four journeys now have discriminating oracles but none meets its full stated scope, and each shortfall is named rather than rounded up. Journey 2 is one WSL run from promotion. Journey 12's tests are in-process, so they do not close the live-skew gap the original ledger named. Journey 4's cross-host clause cannot be proven by mutation at all — a mux is per target, so its dispose cannot cross hosts, and the cross-host test stayed green under the mutation that reddens siblings. Journey 13 measured one dimension of ten, on lifted predicates rather than through real IPC. Co-authored-by: Orca <help@stably.ai> * docs(terminal): promote Journey 2 to proven on macOS, Linux and physical WSL The oracle runs on every environment the journey names, and is clause-selective on all three: reverting three-valued `hasPty` reddens only the unknown-not-dead clause, and widening the sole-provider fallback reddens only the stale-generation clause. Selectivity in WSL was established rather than assumed. The spec runs serially, so a red first test reports the others as "did not run" — they were re-run alone under the same mutation and stayed green. Also records that an Orca WSL-mode terminal now starts on that host at all, which it could not before: the distro had no provisioned default Unix user, so every interactive launch blocked on first-run setup. One diagnosis from the WSL run is corrected here rather than repeated: the unrelated `local-pty-shell-ready` failure was attributed to bash 5.3.9, but macOS runs the same bash version and passes 67/67. The trigger is environmental to that distro, and the underlying defect is that the spec pins an absolute count of OSC markers it does not own. Co-authored-by: Orca <help@stably.ai> * docs(terminal): correct the WSL provider-suite diagnosis The WSL run blamed bash 5.3.9 for the unrelated `local-pty-shell-ready` failure. macOS runs the same bash version and passes 67/67, so the version is not the cause — the trigger is environmental to that distro, and the underlying defect is that the spec asserts an absolute count of OSC markers it does not own. Co-authored-by: Orca <help@stably.ai> * test(runtime): unskip the workspace-namespace oracles now their fix has merged These five reproduced a defect that was live on main: folder-workspace ids were compared with the instance suffix stripped, so two workspaces sharing a directory read as the same namespace. They were committed skipped, pointing at the PR that fixes it. That PR is merged, and they pass. Verified they still bite: restoring the suffix-stripping comparison reddens exactly these five and leaves the other four green. An oracle written before its fix, held skipped, and confirmed against the fix after the merge — rather than deleted and rewritten from the answer. Co-authored-by: Orca <help@stably.ai> * test(ssh): add MaxSessions, lazy-discovery and paired-skew oracles Three journeys attempted; none promoted, and the reasons are recorded in the ledger rather than rounded up. MaxSessions=1 against real OpenSSH, with the cap read back from `sshd -T` rather than assumed, and remote pids read on the container two independent ways that must agree, each carrying its kernel start time. Two disjoint mutations discriminate — one reddens only the reconnect clause, the other only the two restart clauses. But the disconnect clause is a forward guard: four separate guard removals left it green, so nothing shipped is load-bearing for it. Lazy discovery samples sshd's own accept log and live session census across a 22s window with the in-use host as a positive control. No mutation reddens its third clause alone — the real cross-host lease scoping is load-bearing, but removing it breaks the sibling host during setup, so the failure carries no clause information. The paired-runtime skew spec pairs two real processes at different versions and refuses to run rather than degrade into a same-version pairing that would look green and prove nothing. No production code changes. Co-authored-by: Orca <help@stably.ai> * docs(terminal): record why the duplicate-resume fix was not built I recommended adding a typed end-reason so a user quit stops looking like a resume candidate, then went to implement it and stopped. `SleepingAgentSessionRecord` already carries three fields that each exist to stop something resuming that should not have — `origin`, `restoreOnTabOpenOnly`, and `automaticResumeBlockedBy` — each traceable to its own incident, consulted at 22 non-test sites. A fourth predicate, however well typed, is the fifth containment cycle. The designs without this bug do not have a better flag; they resume only on an explicit action, into a new terminal id, and make two agents in one terminal unrepresentable in the schema. The first of those is a product decision about whether automatic resume stays a feature, so it is the user's call rather than mine. Co-authored-by: Orca <help@stably.ai> * docs(terminal): reconcile G6 with the recorded decision and assess its clauses G6's body still demanded strictly-negative production LOC after the user relaxed it to minimise-and-justify, so the gate had two conflicting pass conditions and no single truth value. Its body now points at that decision. Assessed the remaining clauses against the branch rather than assuming. Two fail structurally: more than one identity comparison and mutation admission path still exist, and `terminal-input-quarantine.ts` is still reachable from two production files. Records why the quarantine is not subsumed by the superseded-PTY fence, which I had assumed and checked. The fence refuses writes aimed at a stale ptyId; the quarantine guards the user's next keystrokes landing on the successor under its current, correct id — a case the fence never sees. Removing it needs the recovery path to surface a different shell as unresolved, not a deletion. Co-authored-by: Orca <help@stably.ai> * docs(terminal): the input quarantine is load-bearing, not superseded G6 lists "no superseded quarantine remains reachable" and this module was assumed to be one. Disabling its single call site reproduces the hazard it exists for — `cho hi; rm -rf x` reaching the shell — so deleting it without a replacement re-opens command execution. The replacement was costed by building it rather than estimated: +26 production LOC to thread the incarnation, ~+33 complete, and the cross-remount state it needs outlives the destroyed pane so it becomes a module about the size of the one deleted. Floor is roughly +140 to delete 88, and it would add a second identity comparison to a gate already failing for having more than one. The decisive part is that the route is not uniformly available: remote runtime results carry no incarnation, old hosts cannot be made to publish one, and mixed versions are the normal state. A paired client reads unknown, which this program's own rule says is not proof — so either every remote reattach surfaces unresolved, or a fallback is needed and the only correct fallback is this module. Whether to amend the clause or accept something weaker on remote hosts is a user decision, so the clause verdict is left as failing rather than quietly reclassified. Co-authored-by: Orca <help@stably.ai> * refactor(runtime): collapse duplicate identity comparisons G6 requires one identity comparison; five implementations existed across two concepts. Worktree-namespace identity had two: `runtimeWorktreeIdsEqual` and `runtimeWorktreeIdentityKey` independently re-derived repoId plus normalized path. Equality now derives from the key, so the comparison and the sleep / mutation-queue keying cannot drift into two different rules — which is exactly how the suffix-stripping bug reached production once. Pane identity had three byte-identical leaf-UUID comparisons, in orchestration `db.ts`, `lifecycle-reconciliation.ts`, and `orchestration-legacy-process-identity.ts`. One copy moved to `stable-pane-id.ts`, which already owns `PaneKey`, `parsePaneKey` and `makePaneKey` and which all three already imported. No new module, no branded type, no parallel comparison. Net -14 production lines. The namespace oracle still bites: restoring the filesystem parser inside the identity key reddens exactly its five cases. The raw counts are not the actionable set, and the classification is worth recording: of 409 non-test `worktreeId` comparisons, 71 are typeof guards and 81 are sentinel tag checks. Most of the remainder are renderer predicates over store rows where both operands are the same main-minted id, so normalizing there would widen equality rather than correct it. Co-authored-by: Orca <help@stably.ai> * refactor(terminal): finish a half-done fixture move and audit the rest `xterm-bypass-event-fixture.ts` and `__fixtures__/xterm-bypass-event.ts` were byte-identical apart from an import path. The `__fixtures__` copy had zero importers and the live copy compiled as production — someone started the move and left both. Dead copy deleted, live one moved, its three test importers updated. Audited the wider G6 clause by importer rather than filename: 32 test-only files, roughly 3,300 LOC, currently compile as production; 4 of the 36 candidates have real production importers and are correctly placed. The list is recorded in the goalposts. Those 32 are almost all older than this program and outside the terminal surface, so sweeping them belongs in its own change rather than inside a terminal PR. The clause stays failing, with the remaining files named. Co-authored-by: Orca <help@stably.ai> * docs(terminal): the fixture clause already holds where it matters Checked what the build emits rather than reasoning from file paths. None of the 32 test-only fixtures appears in `out/` — Rollup drops them because no production entrypoint reaches them. On "compiles into the shipped product", this clause holds today. On the other reading it cannot be closed by moving files at all: both production tsconfigs use bare `include` globs with no `exclude`, so a `__tests__/` directory matches exactly like any other path, as does every `*.test.ts` in the repo. Relocating 32 fixtures would remove nothing from typecheck scope. A sweep was started and stopped once this was verified, rather than landing 32 moves across areas this program does not own for no gain. If the intent is that typecheck scope should exclude test code, that is a repo-wide tsconfig change with a different owner. Co-authored-by: Orca <help@stably.ai> * docs(terminal): add plain-language design and test overviews Two reviewable documents with diagrams, written so someone with no prior context can follow what breaks, why, and what changed. The design overview explains the five things stacked behind one terminal rectangle, the 2 -> 19 -> 20 report, the three root causes, and the rule underneath all of them: unknown is not dead. The test overview explains why a green test proves nothing on its own, the four-step mutation proof we adopted, and — the part worth reviewing hardest — an honest account of what could not be proven and why, including the properties that are true by construction and therefore have no guard to remove. Co-authored-by: Orca <help@stably.ai> * docs(terminal): add a self-contained visual report of the design and its evidence Pre-renders every diagram to inline SVG in both themes so the report opens offline and stays sharp when zoomed. States the gate/journey score and the retractions alongside the fixes, so the unproven half is as visible as the proven half. Co-authored-by: Orca <help@stably.ai> * docs(terminal): record the finalized two-plane architecture decision Adopts the data-plane proposal and adds the control-plane track it does not cover: re-key ownership by pane, split orphan inventory out, then delete the compensating code. Records that the host-authority alternative was refuted and that the shipped keystroke fence is inert on the reattach path. Co-authored-by: Orca <help@stably.ai> * docs(terminal): add the design brief the review counsel works from Separates verified code facts from unverified leads so reviewers attack the design rather than a reconstruction of it, and records which simpler alternatives were already refuted and why. Co-authored-by: Orca <help@stably.ai> * docs(terminal): report the design counsel's outcome and the live respawn bug it found Three review rounds across two models replaced the two-record split with one leaf-keyed record, deleted attach-time pane identity, and made orphans a connect-time projection. Records that a shipped gesture still turns a healthy remote shell into a duplicate agent resume, and that the renderer classifier in that chain treats an error-message shape as proof of death. Co-authored-by: Orca <help@stably.ai> * docs(terminal): correct the report — the respawn proof gate guards a minority path A final review traced every auto-respawn route. The primary one converts the reattach failure into a boolean before any classifier sees it, so the shipped proof gate never runs there. Records that two of the six shipped changes are narrower than claimed, and why their tests could not have caught it. Co-authored-by: Orca <help@stably.ai> * docs(terminal): explain the landed design on its own terms One leaf-keyed ownership record, orphans computed at connect, and replacement shells only on positive proof — with the shipping order and the one product trade the design asks the owner to accept. Co-authored-by: Orca <help@stably.ai> * docs(terminal): rewrite the design explainer in plain English The first version assumed the reader knew the codebase. Reframed around two bugs, two fixes and one decision, with the jargon replaced by pane / program / note / helper and a five-word glossary for what could not be avoided. Co-authored-by: Orca <help@stably.ai> * fix(ssh): stop reading an identity mismatch as a dead shell The relay reports a pane-identity mismatch by saying the pty was not found, but it found it — comparing identity is how it noticed. Publishing that as expiry made the renderer clear the binding and cold-restore with agent resume, so a live shell gained a second agent on one transcript. Reachable today by detaching a pane into a new tab, which changes the tab the relay froze at spawn. Mismatch now carries its own token and the classifier refuses it as proof. Genuine absence still expires, so a shell that really went away is not stranded. The three failure tokens move to src/shared: main published them and the renderer decided respawn on them, from two copies that had drifted apart. Co-authored-by: Orca <help@stably.ai> * fix(ssh): stop sending pane identity on reattach The relay froze pane identity at spawn, so moving a pane to another tab made it refuse a live shell — and refuse by saying 'not found'. The comparison is presence-guarded, so not sending the fields disarms it on every relay version including ones already installed on hosts: no wire change, no redeploy. Nothing is lost. It existed to catch a relay restart recycling pty-N for a new shell, and in exactly that case pane and tab both still match, so it accepted the wrong shell anyway. The incarnation the attach returns is what distinguishes those, and it already crosses the wire. Removes the whole client-side apparatus: the expected-identity type, its per-lease derivation, its map, and the parameter threaded through four layers. Co-authored-by: Orca <help@stably.ai> * docs(terminal): add tracked goalposts for the new design Each goalpost is a behaviour with an oracle and the mutation that must redden it, so 'proven' cannot be claimed from a green test. Records the anti-inert rule as a first-class goalpost, since three guards in this program passed their tests while sitting off the route production takes. Co-authored-by: Orca <help@stably.ai> * docs(terminal): record that the recovery grant is dead code, deleting a design step The lease stores a relay-native pty id and the caller passes the app form, with a raw equality comparison between them, so the 30s grant cannot fire for a real SSH pane. The death rule that existed to referee it is deleted rather than built, and the dead path itself becomes a removal. Co-authored-by: Orca <help@stably.ai> * docs(terminal): keep the full design detail in the repo It only existed in an ephemeral job directory, so the plain-English explainer had no durable source for its specifics — record shape, death rule, reattach algorithm, migration order and the 25 oracles. Co-authored-by: Orca <help@stably.ai> * docs(terminal): add a resume prompt for a clean session Points at the goalposts as the contract, names the three goalposts whose oracles are already written and red, and carries the process rules that were learned the expensive way — prove guards reachable, verify mutations land, commit per step, and never let a subagent write production files in a shared worktree. Co-authored-by: Orca <help@stably.ai> * test(ssh): add the failing oracles for goalposts S3, S4 and S5 Intentionally RED: 14 clauses that fail against current behaviour and go green under the changes named in new-design-goalposts.md. The branch is held unmerged, so red here means unimplemented, not broken. Each was verified to fail for the right reason and to flip green under the identified fix, which was then reverted. Each pins the producer as well as the consumer, so no clause can pass vacuously if its route is ever severed — the failure mode that let three earlier guards ship inert. Co-authored-by: Orca <help@stably.ai> * fix(ssh): stop fabricating an exit when a reattach fails A failed attach never proves the shell exited. The relay answers not-found for a pane-identity mismatch and for any id it merely cannot hand back, so treating it as death sent the pane a synthetic `pty:exit { code: -1 }`, cleared provider state, deleted ownership and expired the lease — four claims about a process we know nothing about, on a shell that is usually still running. Collapse every failure into the non-destructive branch that already existed a few lines above (`restoreRequired = 'reattachAttemptsExhausted'` + wakeRecovery). A branch collapse, not a new mechanism: goalpost S3. Two tests pinned the deleted premise and are INVERTED rather than patched, so the new intent stays covered: - ssh-relay-orphan-abandon-paths: "retires the lease without a kill when the relay proves the PTY is gone" -> "leaves the shell running when the relay only reports the PTY as not found". Its comment claimed attach verifies liveness before answering not-found; it does not. - ssh-relay-session: "invalidates and broadcasts remote PTYs that cannot reattach" -> "leaves an unreattachable remote PTY alone while its sibling reattaches". Also repairs two clauses left red by |
||
|
|
585dd6d3a9 |
fix terminal attribution shim removal edge cases (#14187)
* fix(terminal): fully retire attribution shim * fix(terminal): harden shim tombstone path lookup |
||
|
|
d3a2bf352f | fix(terminal): preserve Fish readiness escape bytes (#14142) | ||
|
|
90b8554fc9 | fix(terminal): recover readiness after startup exec (#14027) | ||
|
|
971d9548b5 |
docs(agent-status): make design references self-contained (#13902)
docs/design/agent-status-over-ssh.md was cited from ~10 source files but does not exist in the repo. Replace each pointer with the invariant the code actually relies on so the knowledge survives without the doc. Renderer-side citations (useIpcEvents.ts, agent-status-types.ts) are left for the concurrent batching change that owns those files. Co-authored-by: Orca <help@stably.ai> |
||
|
|
991a3fe963 |
chore(lint): update oxlint to 1.77 and enable no-op cleanup rules (#13901)
Enable eleven oxlint rules that simplify code without changing behavior, and fix
every existing violation. Each candidate was gated on measured cost rather than
assumption, so rules that regressed runtime performance or type checking were
dropped instead of suppressed.
typescript/no-redundant-type-constituents is the largest addition: 113 sites, no
autofix. Dead constituents are deleted. Where the redundant literal existed to
document intent (`string | 'all'`), it is preserved as `(string & {})`, which
keeps the autocomplete hint the original code was reaching for instead of
flattening it away. The rule also caught a broken import —
remote-shared-control-retirement-probe.ts pulled RuntimeStatus from
src/shared/types, which does not export it, so the type silently degraded to
`any`; no tsconfig covers that file, so tsc never saw it.
oxlint stays at 1.77.0 rather than 1.78.0 because .npmrc sets
minimum-release-age=4320 and 1.78.0 is younger than that window.
Rules evaluated and rejected, with what disqualified each:
- prefer-string-raw: String.raw is a runtime call, not a literal (184x slower)
- prefer-string-replace-all: 26% slower
- text-encoding-identifier-case: ~5% slower, reproducible
- prefer-spread: [...str] is 110% slower than split('') and differs on surrogates
- no-implicit-coercion: `!!x` narrows types and `Boolean(x)` does not (22 tsc errors)
- prefer-arrow-callback: arrows are not constructible, breaking `new` on mocks
- object-shorthand: rewrites source text asserted by a tracked reliability gate
- switch-case-braces: pushes ten files past max-lines, which cannot be suppressed
- no-useless-switch-case: drops `case undefined:` that switch-exhaustiveness-check needs
- arrow-body-style: 115 violations have no fix, and it breaks max-lines
- newline-after-import: false-positives on the leading-semicolon ASI idiom
electron-vite-output-contract asserted on the literal
Object.prototype.hasOwnProperty.call text; retarget it to Object.hasOwn, which
rejects inherited keys identically.
|
||
|
|
43bad61a93 |
fix(terminal): deliver SIGWINCH to the PTY foreground process group (#13164)
* fix(terminal): deliver SIGWINCH to the PTY foreground process group A real window-size change is delivered by the kernel to the terminal's foreground process group, but Orca signalled the pty's root pid. The root is never in that group: the shell setpgid's away for job control, a foreground TUI forms a third group again, and on macOS the root is login(1), which neither handles nor forwards SIGWINCH. So the explicit reattach repaint signal reached a running TUI on no platform, and on macOS reached nothing at all. Resolve the tty's tpgid and signal the negated group instead, pinned to the slave device captured at spawn so a recycled pid cannot aim at a real terminal. Scoped to SIGWINCH: destructive signals keep the narrower root-pid target and the descendant-sweep identity machinery. POSIX-only, so Windows keeps its current behavior where a negative pid is invalid and SIGTERM means terminate. Applied on the relay too, so SSH hosts get it; that is host-local with no wire change. * fix(terminal): bound foreground group lookup latency * fix(terminal): preserve SIGWINCH fallback on signal errors |
||
|
|
34f2a62cda |
fix(pty): stop color-scheme 997 replies from painting cooked prompts (#13309)
Route cooked-echo-risk terminal replies through bounded echo-safe delivery across local, daemon, and SSH relay PTYs. Preserve repeated valid replies, bypass the daemon startup input gate, and keep ordinary input plus latency-critical replies on their existing paths. Closes #13137 Co-authored-by: bbingz <zzb@gxsmjx.com> |
||
|
|
a6b14eb04c |
fix(terminal): reset stale mouse tracking on cold restore (#12101); stop OSC color-reply echo leak in POSIX agent panes (#12112) (#12202)
* fix(terminal): reset stale mouse tracking on cold restore (#12101); stop OSC color-reply echo leak in POSIX agent panes (#12112) #12101: a force-killed TUI never emits its DECRST reset, so its armed mouse mode is latched into the on-disk checkpoint and re-derived into the replacement process's emulator via the cold-restore history seed -- through both rehydrateSequences and SerializeAddon's own mode trailer. The revived bare shell then echoed SGR motion reports at the prompt. Seed a RESET_MOUSE_REPORTING segment after the snapshot (before the torn escape tail), only when there is real recovered content so the empty-array "nothing to recover" sentinel survives. #12112: agent panes arm a main-side PtyStartupIngress that answered opencode's startup OSC 10/11 queries synchronously inside node-pty's onData, while the POSIX tty still had ECHO on. The line discipline echoed Orca's own reply back out as visible text. Echo suppression existed but was gated on windows-conpty. Add PtyStartupReplyDelivery: POSIX defers the write off the query's turn and recognizes its own echo anywhere in a span (bounded, non-destructive); ConPTY keeps its synchronous write; windows-wsl is byte-identical to before. Fixes #12101 Co-authored-by: Orca <help@stably.ai> * fix(terminal): read the slave's ECHO bit before answering a color query The startup color reply was written into a PTY still in cooked mode, so the line discipline echoed it back as visible junk (#12112). Whether that will happen is readable state on the slave rather than something to infer from returning bytes, so the reply now waits until the ECHO bit is observably clear instead of guessing at echo shapes. Two echo sources exist and only one is readable. A `quiet` verdict proves the kernel will not echo, so it retires the caret projection; readline echoes a master write in software with the tty already raw, so that projection stays armed on every path. Scoping `quiet` narrowly is the whole correctness argument here: reading it as "no suppression needed" reintroduces the bug at a plain shell prompt. Polling is bounded by a wall-clock budget rather than an attempt count, because each probe is a subprocess and a multi-pane restore serializes them on fork. Withholding measures flat at ~210ms from 1 to 100 panes. Also resets a cold-restored pane's mouse reporting (#12101). The armed mode is re-derived from the dead process's own persisted bytes through two channels, so the daemon seeds a reset into recovered history and the renderer stops trusting a persisted "live agent" signal after a cold restore. The reset literals move to one shared profile module. Fixes #12101 Fixes #12112 Co-authored-by: Orca <help@stably.ai> * test(terminal): pin the cold-restore reset on the spawn-adopted reattach path A spawn can be answered with an adopted session, which reaches the reattach handler by a door that skips the restored-session path. Pin that the cold-restore signal survives it, so #12101's junk cannot come back through it. Co-authored-by: Orca <help@stably.ai> * test(terminal): note why the adopted-reattach snapshot leaves the cursor visible Co-authored-by: Orca <help@stably.ai> * fix(terminal): harden startup reply delivery --------- Co-authored-by: Orca <help@stably.ai> |
||
|
|
f3c824bc28 |
fix(terminal): expand environment variables in Windows PATH (#11987)
* fix(terminal): expand variables in Windows PATH * fix(terminal): preserve expanded Windows PATH at spawn --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> |
||
|
|
8c5371ebad |
fix(worktrees): respect Windows shell for setup runners (#6967)
* Honor configured shells during worktree setup
* Align setup launch paths with selected Windows shells
* Carry setup shell selection through deferred launches
* Prove Windows setup shell routing at its real adapters
* Ground remote PowerShell proof in the real writer
* Preserve Git Bash across deferred setup launches
* Harden Windows setup runner shell selection
- Resolve remote PowerShell binary without local pwsh probe: for SSH/remote
Windows worktrees, isPwshAvailable() reflects only the LOCAL host, so an
'auto' implementation could route the remote runner to a pwsh.exe the remote
lacks. Add resolveSetupRunnerShell(..., { probeLocalPwsh: false }) so remote
auto keeps the always-present powershell.exe; explicit pwsh.exe still honored.
- Preserve native exit codes in the PowerShell runner by checking
$LASTEXITCODE before $?, so a failing native command surfaces its real code
instead of a generic exit 1; $? still catches cmdlet soft-failures.
- Write the PowerShell runner with a UTF-8 BOM so Windows PowerShell 5.1 (the
new default powershell.exe) reads it as UTF-8 instead of ANSI, preventing
non-ASCII setup-script corruption.
- Add unit tests for the remote-probe behavior.
* Restore setup-shell scope narrowing over the rebase
The force-pushed rebase dropped five review-fix commits that were already
on this branch; this reapplies their combined effect on top of the new
base and the hardening commit:
- Keep SSH setup shell selection remote-owned (no local terminalWindowsShell
or pwsh routing for remote hosts; supersedes the probeLocalPwsh guard)
- Preserve cmd setup compatibility outside POSIX shells (no .ps1 runner
family, so the BOM/exit-code hardening is no longer applicable)
- Route WSL setup runners from the project runtime
- Avoid blocking PowerShell probes during setup creation
- Correct SSH and WSL background setup fixtures
* Satisfy the changed-code gates for the setup-shell runner
- createWorktreeRunnerScript took 7 positional parameters, tripping the
changed-code max-params gate; move it to a single options object.
- hooks-runner.test.ts deep-equals the createSetupRunnerScript result, so
assert the cmd shell now returned for native Windows worktrees.
* Carry the setup launch shell through observed and issue runners
- buildObservedSetupCommand takes the runner's launch shell so WSL-routed
Windows-drive setup replays use /mnt/c instead of Git Bash /c
- resolveSetupRunnerShell gates the posix runner on the same Git Bash
resolution the PTY uses, so a missing or non-MSYS bash keeps the cmd runner
- issue-command runners carry their launch shell, and the renderer passes it
when building the queued command
- treat a bare `bash` shell setting as POSIX like `bash.exe`
Co-authored-by: Orca <help@stably.ai>
* fix(worktrees): close counsel P1 gaps for Windows setup shells
Route windowless/headless creates through the shell-aware setup runner when a
PTY controller is available, existence-check explicit Git Bash paths before
committing to .sh runners, thread the resolved shell into issue-command
runners, and document the intentional Git Bash interpreter flip with a narrow
scope table.
* Convert setup env to MSYS form and harden the bare cmd runner launch
C3: a Git Bash setup runner now receives ORCA_*/CONDUCTOR_*/GHOSTX_* path
values in /c/... form, matching the runner path and the shell's own HOME/PWD.
C5: extension-less `bash` resolves to Git Bash everywhere, matching how
resolveWindowsShellStartupFamily already classifies it.
C7: runner paths carrying characters that cannot be quoted on a cmd command
line launch through a delayed-expansion PowerShell shim instead, and the batch
runner disables inherited delayed expansion so `!` in setup lines survives.
Co-authored-by: Orca <help@stably.ai>
* docs: note MSYS ORCA_* paths and bare bash Git Bash resolution
Keep the setup-shell release note aligned with C3 env conversion and C5 bare
bash resolution so the published claim matches runtime behavior.
* revert: drop windows-setup-shell doc allowlist and AGENTS link
Keep the counsel P1/P2 product fixes without expanding the docs allowlist
or AGENTS.md guidance surface.
* fix(plugins): contain Parcel unsubscribe rejections under Vitest
Dev plugin watchers fire-and-forget unsubscribe, and in-process Parcel
can reject when temp watch roots are already deleted. Catch those
rejections so they cannot fail the suite as unhandled errors.
* fix(plugins): keep in-process unsubscribe rejection surface
Swallowing Parcel unsubscribe errors broke mocked unsubscribe tests
that return non-Promises and expect rejections. Contain failures only
in PluginDevWatcher fire-and-forget paths.
---------
Co-authored-by: OrcaWin <alpha-eng@stably.ai>
Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Co-authored-by: Orca <help@stably.ai>
|
||
|
|
05206046f6 |
chore: condense code comments (#12008)
* chore: condense code comments * chore: shorten more code comments * clarify PTY agent session descendant cleanup behavior Refine the comment on ptyAgentSessionIds to more accurately describe when agent sessions sweep their descendant process trees and note the exception on immediate Windows shutdown. |
||
|
|
5c0195af64 |
Bound remote watcher fan-out and defer File Explorer refreshes (#11908)
* batch remote watcher events and defer File Explorer refreshes Remote filesystem watcher events now batch with the shared 150ms trailing and 500ms max-wait window, coalescing per-path like local events. File Explorer tree and directory refreshes are scheduled with debounce and transport-aware concurrency caps (16 local, 8 runtime, 4 SSH). Stale directory cache tracking prevents trusting collapsed listings skipped by full refresh; they are re-read on re-expansion. Relay implements a 15-minute idle-only grace cap for zero-PTY relays via PTY pool lifecycle tracking, independent of explicitly configured grace time. * fix(watch/relay): bound remote watcher fan-out and read the live relay grace Three P1 fixes from the SSH/remote freeze audit: - Remote watchers now debounce on the same 150/500 window as local ones (finding D), and every teardown path drops the trailing flush timer instead of letting it fire into a dead watch. The deferred send is wrapped so a frame disposed mid-window can't escape as a fatal main-process exception. - File Explorer refreshes are scheduled and concurrency-capped rather than fanned out unbounded over expanded dirs (finding C). Local transports use a zero window, since main already coalesced the burst. - relay.startGrace reads ptyHandler.configuredGraceTimeMs instead of the launch-time argv closure, so a grace raised after launch is honored. The branch selection moves to relay-grace-branch.ts because relay.ts has no exports and calls main() at import, making it untestable. Consequence: a host-sleep relay holding zero PTYs now exits after the idle cap. Pinned by test and documented in docs/reference/relay-grace-time-reconfiguration.md. Also drops the duplicated 150/500/5000 constants in the runtime-RPC batcher in favor of the shared window module. * docs(relay): correct grace-reconfiguration line numbers after the relay.ts edit Co-authored-by: Orca <help@stably.ai> * refactor(file-explorer): use useMemo for paths; remove relay reference Replace manual ref-based caching with proper React hooks for content-stable path memoization. Remove outdated relay grace-time reference documentation from code review cycle. * rm design doc * fix(remote-watcher): prevent stranded timer after close An in-flight provider receive can land after the batch is torn down. Without a guard, pushing events to a closed batch would re-arm a timer that would never be cleared, stranding the task indefinitely. Track the closed state and skip pushes after close(). Relay.ts comment clarifies why pool watches remain registered during grace-period shutdown deferral — the socket server stays listening so a reconnecting client can cancel the grace and resume. --------- Co-authored-by: Orca <help@stably.ai> |
||
|
|
886fa7b438 |
[P0] fix(relay): stop the retired exit record from double-broadcasting pty.exit (#11660)
* fix(relay): stop the retired exit record from double-broadcasting pty.exit Retiring the publication record at cancel or grace-expiry time routed the pending exit through pty-handler's unconditional broadcast, so subscribers that already received the exit from the record's legacy projection got a second copy (which fans out to a whole-provider teardown on the consumer side). - Track the legacy exit projection in a RelayPtySourceLegacyExitIndex keyed by pty incarnation so it outlives the delivery record; the handler's fallback now re-targets only source-owner clients when subscribers already have the exit. - Cover the previously untested defense layers: B2 same-client re-attach healing, B3 deferred retire-on-append-failure (retire plus the microtask deferral), and publishPendingExit's `accepts(id) &&` sub-guard. - Harden layer D's capacity fan-out (it sits outside the catch it depends on), route cancelExact's bare-timer probe through snapshotIfKnown, and keep appendDenied counting cancel-induced append rejections. * test(relay): pin the retired-record exit dedup and stop its index leaking The legacy-exit dedup layer shipped with zero coverage: deleting pty-handler's `publishExitAfterRetire` wiring left the whole relay suite green, because the send scheduler's partial-progress branch keeps a projected record alive so B1 handles the common case and the fallback is never reached. - Cover `RelayPtySourceLegacyExitIndex` directly, pin pty-handler's fallback (a retired record re-targets its own exit instead of broadcasting a duplicate to subscribers that already have it, and still broadcasts when nothing was projected), and drive the reachable retire path: B2's re-attach retires a record whose projection only the index remembers. - Forget the index entry when `exitPublicationSettled` prunes a healthy exit. `remember()` only re-ran from the exit path, which B0 short-circuits after a settled exit, so every source-mode PTY exit leaked one Map row for the daemon's lifetime and would re-publish on any later fallback for that id. * fix(relay): contain retired exit publication faults * fix(relay): retain projected exits across owner faults * fix(relay): retire faulted exit deliveries |
||
|
|
8eee7f039a |
[P0] fix(relay): survive PTY exit after a canceled source delivery (#11634)
* fix(relay): survive PTY exit after a canceled source delivery A client cancel (or reconnect-grace expiry) closed the source-credit ledger delivery without retiring the publication record, so the next pty.exit sealed a dead ledger entry and threw out of onExit into uncaughtException, killing the relay daemon. Five coordinated layers: - A/A': adapter-side cancellations now fire onCreditAvailable so pruneClosed retires the record at cancel time, suppressed while an exit frame is in flight. - B0-B3: settled-first short-circuit in publishPendingExit, a tolerant entry guard in sealAndPublishPtySourceExit that separates healthy completion from cancellation, same-client re-attach healing, and deferred retire-on-append. - C: publishPendingExit's source branch cannot propagate a fault out of onExit. - D: the exit settlement closure probes before settling and never throws out of a bare socket write/drain callback. Adds the non-throwing snapshotIfKnown ledger probe and routes every caller that can race a close through it. * fix(relay): preserve partial PTY exit progress |
||
|
|
854f76cb51 |
fix(relay): slice PTY flush remainder from the memoized source chunk (#11621)
A retry that carries a memoized sourceChunk must republish that exact span and slice the remainder at the memo boundary. Recomputing chunkChars on retry dropped coalesced tails when capacity grew, duplicated already- published chars when it shrank, and discarded coalesced transformed raw advances behind a source-only memo. The remainder entry now also keeps transformed/rawLength/seq bookkeeping so a transformed tail stays a well-formed emission. Co-authored-by: Orca <help@stably.ai> |
||
|
|
9e0a9ebc7d |
fix(pty): do not create unused Pi/OMP home dirs on bare shells (#10198)
* fix(pty): do not create unused Pi/OMP home dirs on bare shells Bare terminals used to materialize ~/.pi/agent and ~/.omp/agent (and install managed extensions) for possible later shell-launched agents. Users who never use those agents still saw the directories recreated after deletion. Only create the default agent home when launching that agent explicitly, or when the home already exists. Bare-shell OMP status still uses the userData fallback so typed `omp` keeps the shell wrapper extension. Closes #10196 * fix(relay): OMP bare-shell status fallback without ~/.omp CodeRabbit: relay materializePi returned null on bare shells with a missing OMP home, so SSH PTYs never set ORCA_OMP_STATUS_EXTENSION. Local already wrote a userData-managed status extension in that case. Write the status file under ~/.orca-relay/omp-managed-status-extension and return MaterializePiResult so relay.ts can export ORCA_OMP_STATUS_EXTENSION without ORCA_OMP_SOURCE_AGENT_DIR or creating ~/.omp. Also fix the local withOrcaManagedExtensionMarker typo on the bare-shell path. * fix(pty): only materialize Pi home for Pi launches --------- Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> |
||
|
|
5f7807497e |
feat(ssh): bound relay PTY output end to end (#11005)
* docs: design SSH relay PTY backpressure * fix(ssh): bound relay frame decoding * fix(relay): bound PTY output publication * fix(ssh): bound PTY model admission * fix(ssh): settle closed model admissions * feat(ssh): negotiate bounded PTY consumer sessions * fix(ssh): fence exit on renderer settlement * feat(ssh): track PTY source credit end to end * fix(ssh): recover bounded PTY output across reconnect * feat(ssh): complete relay PTY output backpressure * fix(ssh): close final PTY source credit races * docs(ssh): record final backpressure validation * feat(ssh): complete relay PTY source-credit lifecycle * test(ssh): complete provider notification fixture * fix(ssh): preserve terminal source credit across rotation * fix(ssh): fail closed on recovery cancellation * fix(ssh): prioritize mux control writes after drain * fix(ssh): retire canceled relay restore deliveries * fix(ssh): order exit cancellation cleanup * fix(ssh): gate provisional source activation * test(ssh): register mux drain-priority coverage * fix(ssh): type stale owner recovery mismatches * fix(ssh): close projection replacement races * fix(relay): contain streaming edge failures * fix(ssh): secure relay endpoint credentials * docs(ssh): reconcile final backpressure lifecycle * fix(ssh): bound main IPC output lifecycle * fix(ssh): close recovery ownership gaps * docs(ssh): record exact artifact validation * fix(ssh): reject reclaimed snapshot replacements * fix(ssh): fence model admission across reconnect * fix(ssh): contain migration failure per PTY * docs(ssh): record final exact-head validation * test(ssh): align deploy fixtures with credential publication * feat(ssh): add per-target bounded output setting * fix(ssh): close source recovery review gaps * fix(ssh): latch source credit environment override * feat(ssh): make PTY source credit the default * docs(ssh): record always-on relay validation * docs(ssh): bind validation to current main * test(ssh): grant source credit in IPC fixture * test(ssh): grant source credit in fake relay --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> |
||
|
|
79ec57d045 |
perf(relay): keep the PTY replay window as chunks, not a re-sliced string (#10900)
* perf(relay): keep the PTY replay window as chunks, not a re-sliced string appendReplayBuffer did `buffered += data` then `buffered.slice(-REPLAY_BUFFER_MAX)` once over the 100KB cap. It runs per raw node-pty emission -- before batching -- so once a PTY saturates the window (which a long-lived shell does almost immediately) every subsequent chunk copied the whole 100KB. Reuse RecentPtyOutputBuffer, which already solved this shape in the main process: keep chunks, drop from the head, defer the join to read(). The relay's three readers are attach, adopt, and revive only. 66-205x on the append path, per PTY, on the user's SSH host. RecentPtyOutputBuffer's limit is now configurable, because the relay retains 100KB where the main process retains 64KB. One arithmetic branch still used the hardcoded constant after that change and silently under-retained (100,800 of 102,400 code units); the equivalence tests caught it before it shipped, and the suite now pins the configured limit directly. Co-authored-by: Orca <help@stably.ai> * test(relay): exercise a real surrogate split; drop eval from the benchmark Review feedback, both valid: - The surrogate test never split a pair. The cap is even and a pair is two code units, so an emoji run alone always cuts on a pair boundary. A trailing single unit shifts the cut mid-pair, leaving a dangling low surrogate (0xDE00) -- asserted directly now, with the boundary-aligned case kept as its own test. - Parse REPLAY_BUFFER_MAX as a product instead of eval(). The regex already admits only digits, spaces and `*`, and eval tripped Biome's noGlobalEval regardless of the eslint suppression. Co-authored-by: Orca <help@stably.ai> --------- Co-authored-by: Orca <help@stably.ai> |
||
|
|
86a993aec4 |
fix(ssh): connect to Linux hosts that cannot compile node-pty (#10776)
* fix(ssh): connect to Linux hosts that cannot compile node-pty node-pty ships no Linux prebuilt at any architecture, so it is compiled on the remote. On a host without a C/C++ toolchain that build fails, and because both native deps install in one npm command it also took down @parcel/watcher — which does have a working Linux prebuilt — and failed the whole connection. Every Linux image without build tools was unusable. node-pty only backs remote terminals; files, git, and the editor do not need it, and a missing native dep is already non-fatal further down the deploy. So when the existing toolchain probe confirms the compiler is missing, reinstall without node-pty instead of aborting. The manifest has to drop it too — npm reconciles every dependency in package.json, not just the ones named on the command line, so naming only @parcel/watcher still rebuilds node-pty. If that reinstall also fails the actionable build-tools error is rethrown, so a host broken for some other reason still reports the toolchain gap. The relay's PTY error now names the fix rather than saying only that node-pty is unavailable. Verified on a stock Rocky Linux 10.2 aarch64 container (openssh-server, git, nodejs, npm, no compiler): connect succeeds, /etc lists over SSH, node-pty is absent while @parcel/watcher installs its linux-arm64-glibc prebuilt, and spawning a terminal reports the install hint. * fix(ssh): keep the node-pty skip path honest about platform and watcher The PTY unavailable message named build tools unconditionally, but only Linux compiles node-pty — the deploy-side skip is gated on linux and the toolchain probe returns null on Windows. A Windows or macOS remote, where node-pty ships prebuilds, was told to install make/g++/python3. Pick the remedy by the relay's own platform. The skip path returned before the install probe, so a @parcel/watcher that installs but cannot require() (glibc below the floor) connected with dead file watching and nothing logged. Probe before returning and warn; no rebuild, since node-pty provably cannot compile on that host, and never fatal. Also log the pty-less reinstall's own failure and attach it as cause — the rethrown toolchain message is built from the original npm error, so an unrelated retry failure (registry, ENOSPC, EACCES) was lost. The reinstall now keeps the caller's resetDeps as well, so a repair reconnect still clears every dep the probe found broken. Tests: the skip-success fixture queued a chmod/probe/rebuild sequence production never runs, and the surplus slots were absorbed by launchRelay's readiness poll (1817ms vs 3-9ms for its peers). It now emits exactly the 12 execs production performs, and pins that no rebuild is issued. Adds the missing negative case: a gyp-shaped failure on a host whose probe reports a complete toolchain must still hard-fail rather than silently degrade. * fix(ssh): hedge the node-pty remedy and keep repair resets on the skip path |
||
|
|
54e22402bc |
docs(relay): trim the pending-output drain comment (#10714)
Co-authored-by: Orca <help@stably.ai> |
||
|
|
a84dd33afb | perf(relay): stop snapshotting the whole pending-PTY map every drain tick (#10670) | ||
|
|
aab112933e |
Revert "fix(memory): bound OOM-prone accumulators (#10179)" (#10255)
Co-authored-by: Orca <help@stably.ai> |
||
|
|
8f40ddf328 | fix(memory): bound OOM-prone accumulators (#10179) | ||
|
|
7ab601487c |
fix(remote): don't classify a stale/gone remote handle as agent completion (#9263)
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> |
||
|
|
b232df732b | fix(terminal): make remote agent sessions host-authoritative (#9687) | ||
|
|
008c71df40 |
fix(terminal): stop leaking Orca's own NODE_ENV into spawned terminals (#9649)
Orca spread its whole process.env into every spawned terminal, so a dev build leaked NODE_ENV=development into integrated terminals (breaking next build, Vitest, etc.). Strips the inherited value at all three PTY-spawn sites (daemon, local provider, relay); a value the user sets themselves still wins. Supersedes #9058, which patched only the relay path and so missed the reported (local daemon) repro. Closes #9057 Co-authored-by: John Debay <john@debay.net> |
||
|
|
cc44acaaa3 | Fix Windows ConPTY OSC color reply leaks at the PTY owner (#9651) | ||
|
|
9680b22e60 |
fix(terminal): heal stale PTY grids on window wake (#9626)
* fix(terminal): heal stale PTY grids on window wake * fix(terminal): bound window-wake size recovery |
||
|
|
0989128287 |
refactor(comments): slim verbose comments in shared/cli/relay/preload (#9544)
Collapse multi-line explanatory comment blocks into single-line "why" statements
per AGENTS.md ("Document the Why, Briefly"): drop restatements of the code and
mechanism narration; keep the non-obvious reason, external refs, and directives.
Comments-only — verified no code changed via a Babel/esbuild comment-strip
token-equality gate against origin/main; typecheck and oxlint clean.
Area: shared, cli, relay, preload. 26 files changed, 1039 insertions(+), 3300 deletions(-).
Co-authored-by: Orca <help@stably.ai>
|
||
|
|
c0f0810dd9 |
Fix native Windows PTY startup query handling (#9500)
* Fix native Windows PTY startup query handling * Fix daemon boot smoke protocol lookup * Fix Windows daemon repro protocol lookup |
||
|
|
fa85536f3a |
fix(ssh): repair unbuilt relay native deps (#8686)
Co-authored-by: Orca <help@stably.ai> Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com> Co-authored-by: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> |
||
|
|
64be819790 |
fix(runtime): harden watcher and PTY teardown ownership (#8661)
* fix(runtime): retain watcher and PTY teardown ownership * fix(runtime): restore watchers after interrupted cleanup * fix(runtime): prevent stale watcher revival * test(runtime): cover watcher shutdown ownership * test(daemon): model physical PTY exit * fix(daemon): keep shutdown terminating when disposal cannot prove exit A rejecting host.dispose() (unreapable child past its exit deadline) left the shutdown RPC without its process.nextTick(shutdown) and skipped socket cleanup in shutdown(), stranding the daemon as an unreachable orphan after the stale-daemon replacement flow unlinks its socket. Log and continue: daemon exit reparents the child to init instead of blocking on it. * fix(runtime): keep local watching alive after an idle-kill deadline miss An idle child that outlived the exit deadline set shutdownRequested on the shared desktop supervisor, which has no retire-and-replace path — every later subscribe rejected supervisor_disposed and the roots were cached unwatchable, silently ending local file watching for the session. The idle path owns zero records, so there is no double-watch hazard; the zombie keeps its capacity reservation until physical exit and the next subscribe gets a fresh child. * fix(renderer): resync replayed paired-web file watches Transparent replay removed the implicit resync the old close-and-rebuild path provided: a replayed files.watch only reports changes from its own native setup, so changes during the reconnect gap were silently lost. Deliver a conservative overflow to consumers once the replayed watch is ready, matching the overflow-after-interruption contract everywhere else. * fix(runtime): address teardown review findings * fix(runtime): retry watches after teardown deadlines * Fix PTY descendant leaks on forced teardown * Fix jitter-sensitive terminal lifecycle test |