Commit Graph
19 Commits
Author SHA1 Message Date
OrcaWinandm4air 799ec595ba refactor(ssh): remove the ssh-relay-deploy-helpers re-export module (#27961)
Co-authored-by: m4air <m4air@Mac.localdomain>
2026-10-12 03:59:05 -07:00
1c773a3e97 refactor(ssh-relay-runtime): clean up Phase 3-3.8 code (#26889)
- Pinned-relay planning throws its PinnedRelayFallbackError instead of returning a host-node plan
- One shared execHostCommand replaces eight private copies
- Install lock shares its post-acquire GC recheck and unconfirmed-termination catch
- Relay session tracks unsupported optional methods in one set
- Previous-relay census carries one status instead of two booleans
- Remote-node resolution drops the signal-only option shims

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Claude <noreply@anthropic.com>
2026-10-09 09:24:07 -07:00
6aef5c569c Phase 3.7: fix SSH/remote failures found by adversarial real-host testing (#26847)
* fix(remote-terminal): keep a reconnected pane's size after an SSH outage (P1-4) (#26625)

* fix(remote-terminal): keep a reconnected pane's grid when its stale stream is reaped (P1-4)

The host's width floor only handed ownership to viewers that had claimed
since subscribing. A reconnecting desktop pane re-subscribes without
claiming, so when the heartbeat later reaped the dead socket that owned the
floor, the host reclaimed the PTY to its spawn size (120x40) and held the
pane there. The owner's same-client replacement stream now inherits the
floor, and a pane whose stream was reaped before it reconnected resumes
ownership on re-register unless the host or another viewer claimed since.

* fix(remote-terminal): host typing during an outage revokes the reaped owner's resume

Host input reaches the PTY without a renderer viewport claim once the host has
reclaimed its grid, so the orphaned-owner entitlement survived and a passive
reconnect resized the host pane back to the remote grid. The host write path
now clears that entitlement on driving input (query replies, focus reports and
launch writes excluded).

* test(pty): stub the runtime's host-input note in write-path handler tests

---------

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(remote): an oversized RPC reply fails that request instead of closing the host connection (#26624)

Expanding a folder with ~80k entries on a managed SSH host produced a
files.readDir reply over the 4 MiB encrypted-reply cap. The channel treated
that as a transport fault and closed the whole control socket (1013), and the
explorer rendered the failed folder as empty.

- The WebSocket RPC transport now answers an over-cap reply with a
  reply_too_large error for that request id, so the channel never sees it.
- files.readDir checks remote listings against the request's content budget
  and names the entry count.
- The explorer shows a subfolder's read error on its expanded row.

Finding: HH-2 (Phase 3.7 hostile-host).

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(ssh): let a slow host finish installing the pinned Node runtime (HH-3) (#26631)

* fix(ssh): let a slow host finish installing the pinned Node runtime

The POSIX promote (extract, hash, self-test and publish the ~110 MiB node
binary) ran under the 30 s default exec timeout. On slow home storage it
timed out, the remote work kept running, and the runtime store lock it held
stayed until the 20-minute stale rule, so relay rung A queued behind it past
its own deploy bound.

Give both platforms the 300 s promote bound and have the host release the
store lock itself when the promote exits, with owner-checked releases so a
late release never removes a lock someone else took. (HH-3)

* fix(ssh): let the host release the store lock after a slow under-lock re-hash

The under-lock re-check of a sibling-published runtime hashed the ~110 MiB
executable as its own command under the 30 s exec default and outside the
host-side lock release. On slow storage it timed out, the client correctly
kept the lock, and nothing on the host freed it until the 20-minute stale
rule, so later installers ran out their deploy bound.

Run the re-hash and the conditional promote as one host command wrapped in
the host release, and give every full-runtime hash, including the initial
probe, the slow-storage bound. (HH-3)

* test(ssh): count the combined under-lock re-probe and promote as one host command

---------

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(ssh): shell startup output on stdout no longer breaks SSH connect (HH-1) (#26617)

* fix(ssh): fence captured exec stdout so shell rc output cannot break connect

sshd runs the user's login shell before Orca's /bin/sh wrapper, so any
startup-file stdout (fish/tcsh/zsh/bash echo, or a bare terminal-title escape)
was glued onto every captured answer. The home probe read "banner\n/home/u"
and connect failed with "Remote home is not a valid path".

execCommand now wraps wrapped POSIX commands in a per-call nonce fence and
returns only the payload between the markers; the SSH folder browser gets the
same fence. The fence is the existing WSL captured-login-shell fence, moved to
src/shared/posix-stdout-fence.ts so both transports share it.

Finding: HH-1 (phase 3.7 hostile-host). Refs #8324.

* fix(ssh): fence the login-shell Node probe and fall back from a noisy SFTP handshake

The login-shell 'command -v node' probe ran unwrapped, so output from sshd's
shell and the nested login profile became the Node path. It now prints a
one-line fence every login shell parses, inside that shell.

An external sftp-server is started through the user's shell, so startup output
corrupts the SFTP handshake. ssh2 reports that parse failure only before the
session is ready, so SshConnection.sftp tags it, and relay install transfers
treat it as SFTP unavailable and stream over exec stdin.

Finding: HH-1 follow-up (phase 3.7 hostile-host). Refs #8324.

* fix(ssh): keep the stdout fence marker out of the command text

The runtime-store inventory lists processes with ps, which prints its own
script, so a literal marker in the script was read as the fence and the
store GC skipped every pass. printf now joins the marker from two halves.

---------

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(orcad): finish an abandoned update on connect, and refresh renderers after a main-side re-pair (P1-A, P1-C) (#26635)

* fix(orcad): finish an update or rollback its client abandoned, and tell renderers about main-side re-pairs

P1-A: a wake that finds the activation fence now runs crash recovery, which takes
only a stale, previous-boot or exited-own fence. Recovery of an activation
interrupted after the candidate launched resumes it: the candidate commits if it
passes the readiness gate, else the activation is undone as before.

P1-C: invalidating a still-linked environment's transport restarts its status
owner, and a renderer that sees status under a newer pairing revision re-reads
the catalog before applying it.

* fix(orcad): a resumed update keeps the app version that started it

Recovery that commits an abandoned candidate stamped no activeAppVersion, so an
older desktop read the host as pre-tracking and downgraded it over state the
newer build had migrated. The activate journal now records the initiating app
version; recovery stamps it, and a journal too old to carry it is undone instead
of committed.

* test(runtime-env): answer nothing for the status restart a re-pair now triggers

* fix(orcad): recovery leaves running an update server a too-old journal cannot commit

A journal without candidateAppVersion was sent to undo, which stopped a
healthy candidate that had already migrated state and then refused to
restart the incumbent, leaving the host serving nothing. Refuse instead,
keeping the fence and the candidate, until the operator accepts Restore.

---------

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(terminal): a remote recovery never splices stale history above the new screen (P2-5, P2-7) (#26647)

* fix(terminal): a remote recovery never splices stale history above the new screen

A pushed remote snapshot (overflow recovery, seq-gap resync, first attach) is
sent because the client's stream broke, so the pane's own history ends before
that image. Since #26319 a screen-only image kept that history, splicing a
stale block directly above the final screen with a silent gap of up to ~270k
lines (P2-5), and a first attach to an older host left silent holes (P2-7).

- Host: the ACK-overflow recovery serializes up to 1000 history rows.
- Client: the seq-gap resync asks for the same 1000 rows.
- Client: a pushed snapshot always replaces the pane's scrollback; the
  keep-local-scrollback path is removed. A screen-only image from an older host
  now shows only its screen, which is lossy but never misleading.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(terminal): a pushed image replaces the normal buffer over a live alt screen too

Over a pane already on the alternate screen, an image that also ended on alt
repainted only its alt frame and kept the pane's normal buffer, inferring the two
had frozen together. Inside the gap the TUI can exit, the shell print and a second
TUI start, so once that TUI exited the pane showed its stale history where the host
had the missed output. A pushed image now always rebuilds the normal buffer and
history from the host before entering alt itself.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(terminal): bound recovery history by link slack and keep pre-TUI scrollback under screen-only images

A recovery that carries history now goes out only when the stream's ACK
window has drained; under a sustained flood recoveries stay screen-only and
the owed history follows once the client catches up, so snapshot bytes no
longer outgrow the client's intake.

A pushed image without history (scrollbackRows absent or 0) over a live alt
screen repaints only the alt frame again, so a reconnect to an older host
no longer wipes the pane's pre-TUI scrollback.

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(source-control): Stage All stages every change when the listing hit the 1,000 cap (HH-4) (#26618)

* fix(source-control): Stage All stages every change when the listing hit the 1,000 cap

The status listing stops at 1,000 rows, but Stage All built its path list
from those rows, so it staged 1,000 of 20,000 changes and the next commit
was silently partial. When the listing is capped, Stage All (and the
Changes header Stage all) now asks the host to stage the whole worktree
via an optional scope on git.bulkStage; unresolved conflicts stay
unstaged. Older hosts ignore the field and stage the listed paths.

Fixes HH-4.

* fix(source-control): whole-worktree Stage All needs a host receipt, skips shared links, and reaches mobile

- Scoped stage sends no paths and requires a stagedScope receipt, so an older
  runtime or relay stages nothing and the client reports it instead of a
  silent partial stage.
- Scoped 'all' skips Orca's shared symlinks (configured and really a link),
  matching the status listing.
- The capped-listing banner offers Stage All, reachable when the visible
  prefix is entirely staged.
- Mobile reads didHitLimit and uses the same scoped stage and receipt.

* test(source-control): type the Stage All test stubs so lint and the mobile test typecheck pass

* fix(source-control): match Shared Paths saved as ./name or name/ against git's spelling

The Shared Paths setting keeps entries as typed, but git prints worktree paths
without a leading ./ or trailing /. Stage All's conflict path compared the raw
spelling to git's untracked list and staged the shared node_modules link, and
an exclude pathspec ending in / made git refuse the add after tracked changes
were already staged. Status listing had the same mismatch. getSafeRelativePath
now returns git's spelling, so every caller gets it, including saved settings.

---------

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(ssh): redeploy a managed server another desktop stopped; forget one whose host is gone (P1-D, P1-E) (#26628)

* fix(ssh): redeploy a server another desktop stopped; let a dead managed host be forgotten

P1-D: a connect whose linked server the host reports as not activated (another desktop
stopped it) now goes on to the update check, which redeploys it in place.
P1-E: add Forget (managedServer.forget, orca environment forget, Settings row) that runs
Stop's local unlink without contacting the host; the verdict stays unverifiable.

* test(ssh): type the forget test's hoisted store without an assertion

* fix(ssh): never downgrade a stopped host; redeploy it mid-session and retry after a failure

A stop now keeps the stopped build's Orca version, so an older desktop's
reconnect skips as host-newer instead of starting an older server on a
store a newer build migrated. A stop recorded without that version is not
guessed at. A stopped host retries the redeploy on each connect instead of
the once-per-version failure suppression, and a runtime call that finds
the server stopped mid-session redeploys it before the call.

* fix(ssh): rebuild the managed forward after redeploying a stopped host

A redeploy can bind a different port than the reused forward targets.

* fix(ssh): keep stop journals readable by older desktops and recheck version policy under the fence

- The stop journal's recordAfter is the core deactivated record older peers compute; the
  committed record keeps activeAppVersion (which released planners already honour) and
  rolledBackFrom, rebuilt from recordBefore on recovery.
- Auto-update re-applies its version policy to the record read under the host fence, and a
  census counted against another incumbent is treated as unverifiable.

* fix(ssh): file a dispatched stop where older desktops cannot commit it, so their recovery never drops the version guard

* fix(ssh): refuse a fresh deploy of an older orcad onto a host a newer desktop stopped

The unlinked deploy path never consulted the stopped record's activeAppVersion, so a desktop
without a link to the host could activate its older build over state a newer build migrated.
Both the auto-update planner and the fresh deploy now apply one downgrade rule under the fence.

---------

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(ssh): redeploy a managed server a pre-version Stop left; let a forced deploy run over a newer one (#26766)

A stopped activation record without activeAppVersion is what every Stop on
main writes, so refusing it stranded those hosts behind an unforceable
policy refusal. Only a provably newer host is now refused; a forced fresh
deploy runs this version anyway, and the refusal names both versions.

Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local>

* fix(remote-terminal): no keystroke lost, duplicated or split across a silent outage (P1-1, P1-2, P1-3) (#26630)

* fix(remote-terminal): no keystroke lost, duplicated or split across a silent outage

Remote pane input was fire-and-forget: keys handed to a socket that had
silently died were gone, keys still in the 8 ms debounce at detection were
cleared, and held keys were discarded when the auto-recovery window ran out.

- New negotiated `inputAck` capability: the client names an input session
  and sequences Input frames (existing frame header `seq`); the host records
  the last applied sequence per (PTY, session) across connections, writes
  each sequence once and acks it with new opcode InputAck (18), sent only
  to clients that advertised the capability.
- The pane keeps sent-but-unacked input and replays it to the same
  terminal on reattach; the host dedupes. Old hosts keep current behavior.
- Debounced and claim-queued input at detection is held, not cleared.
- Held input survives the 'disconnected' phase; the hold coalesces keys so
  minutes of typing do not hit the per-entry cap.
- Banner copy no longer claims a one-minute retry window.

Fixes P1-1, P1-2, P1-3 (phase 3.7 network findings).

* fix(remote-terminal): never replay into a restarted runtime; keep Ctrl+C and validating paste in order

- Host publishes inputLedgerId with inputAck; the client drops unacknowledged input sent to a
  different ledger (runtime restart with the same PTY) instead of running it twice.
- A replayed duplicate is acked only after the original write settles.
- Accepted input (Ctrl+C, Escape, paste) rides the sequenced stream and resolves on the host ack.
- Keys typed while held queue behind a paste still in size validation.

* fix(remote-terminal): parse subscribed capabilities into a typed shape instead of Reflect.get

* fix(remote-terminal): ack only applied input; withdraw writes their caller gave up on

- Host ledger records each write's outcome: a refused write is rolled back and
  never acked, so the client's replay writes it; nothing queued behind it runs or
  is acked. A write that threw is acked as delivery-unknown, so the client
  reports it failed and never replays it.
- Paste and file-drop timeouts abort their accepted write: a copy still held for
  an outage is withdrawn, and a journaled copy is left out of the replay.
- A batch of sequenced input keeps one endpoint and ledger, so a send that
  retires the stream mid-batch no longer discards earlier unacked input.

* fix(remote-terminal): host writes sequenced input strictly in order and asks the live stream to resend after a refusal

- The ledger admits only the next sequence; a refusal or gap answers with an InputAck resend request instead of WriteUnavailable, so the pane keeps its journal and resends on the same stream (no remount, no carrier bookkeeping).
- Sequenced input uses the provider's settled write; an unverifiable handoff is delivery-unknown, not refused.
- Replays fill given-up slots with empty input so the host can continue; the client remounts only after every resend backoff is refused.
- Typing batched ahead of a paste goes out as its own sequences, so cancelling the paste keeps it.

* refactor(remote-terminal): deliver sequenced input with standard go-back-N

Replace the hand-built "block on refusal" bookkeeping with go-back-N:

- Host: per (PTY, input session) the ledger keeps only appliedSeq, the
  highest contiguous confirmed write. Frames run one at a time in arrival
  order: a duplicate is re-acked, the next sequence is written and acked
  on confirmed success, and a refusal or gap is answered with a resend
  request carrying appliedSeq. No blocked state; every stream is equal.
  A settlement that proves no byte left is a refusal; one that may have
  written advances as delivery-unknown and is never retried.
- Client: one journal keeps unacked input in order and resends from
  appliedSeq + 1 on a resend request (with backoff), on a new stream, or
  when acks stall. Refusals never remount the pane. Withdrawn pastes,
  stale query replies and over-budget bytes resend as empty slots.
- The recovery hold pauses (keeps the rest) when a release is cut short
  by another outage, and keeps delivering typing after an accepted write
  a sequenced stream withdrew or could not confirm.
- Add a seeded model-based test that drives the real pane transport,
  multiplexer and journal against the real host dispatcher and ledger
  through 2,000 random fault schedules.

* test(remote-terminal): cover the refusal remount on a stream without sequenced input

A sequenced stream now resends refused input on the same stream instead of remounting, so the end-to-end remount check negotiates no inputAck, as an older peer does.

* fix(remote-terminal): escalate persistent input refusals, drop input once recovery gives up, and never report a lost unknown ack as applied

- The pane falls back to its remount path when the host keeps refusing input past the resend backoff ladder.
- Held and journaled input is discarded when auto-recovery gives up, and input typed under the disconnected banner is not held.
- The host ledger answers a duplicate for its own sequence and remembers delivery-unknown outcomes; the client settles a write as applied only from its own ack.
- The input model now requires a write reported applied to have reached the PTY whole, and covers a refusal that never clears.

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local>
Co-authored-by: Claude <noreply@anthropic.com>

* fix(remote-terminal): keep input typed past the auto-recovery window for the same terminal, within a bounded grace (P1-3) (#26832)

The last round of #26630 discarded held and journaled input as soon as auto-recovery gave up,
and refused input under the disconnected banner. On a real host a 200 s outage lost 102 of
120 keystrokes, although the pane reattached the same terminal on its own seconds later.

Input now stays held for the same handle and incarnation while the pane is disconnected, for
up to 5 minutes after it gave up. Past that grace it is discarded, and typing into the dead
pane is refused, so stale keys never run at an arbitrary later reconnect. The grace is a
timestamp checked when input moves, so a latched pane stays free of timers.

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Claude <noreply@anthropic.com>

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local>
2026-10-09 04:58:48 -07:00
Neil 1578716eb0 revert(runtime): temporarily restore the previous host Node minimum (#26823)
This reverts commit 46460d49c7 (#26171).

Pause the Node 24 requirement so existing SSH and WSL users are not
surprised by a host-runtime upgrade requirement in the next release.
This is a precautionary rollout hold requested to avoid near-term user
disruption, not a cancellation of the Node 24 transition.

Restore the prior Node 18 host minimum and companion build targets.
The previously shipped bundled Node 24 runtime and compatibility launcher
remain in place. Preserve the original change in a separate draft PR for
reintroduction after compatibility and rollout validation.
2026-10-09 02:00:57 -07:00
Neil 46460d49c7 feat(runtime): require Node 24 for servers and remote hosts (#26171)
* feat(runtime): default to bundled Node 24 and require modern hosts

* Update SSH deployment expectations for Node 24 guidance

* Expect Node 24 in Windows runtime installation guidance
2026-10-08 23:35:11 -07:00
e3639ef8d6 Phase 3.5: fix the top SSH/remote issues at the root cause (#26483)
* fix(remote-runtime): stop arm64 Linux servers dropping clients every heartbeat (empty WebSocket write EFAULT) (#26200)

* fix(remote-runtime): never write an empty WebSocket payload on its own (arm64 Electron EFAULT)

Patch ws@8.22.0 Sender.sendFrame so a frame with an empty payload is written
as its header alone. Under Electron on arm64 kernels with 39-bit virtual
addresses, socket.write() of a zero-length buffer fails with EFAULT and kills
the connection, so every empty heartbeat ping, auto-pong and close dropped
the remote runtime with 1006. Wire bytes are unchanged.

* test(remote-runtime): run the ws empty-payload guard on real Node

Bun resolves 'ws' to its built-in client, so under the Bun test project the
guard exercised Bun's implementation instead of the patched package.

---------

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(orcad): ship the session scanner child so agent session history lists on orcad hosts (#26309)

orcad built every forked child except session-scanner-service-entry.js, so
aiVault.listSessions always failed on an orcad host. The resolver also skipped
the adjacent entry whenever isPackaged was true, so shipping the file alone
would not have been enough.

- build every ORCAD_CHILD_ENTRY_POINTS entry from one loop and add the scanner
- add it to ORCAD_ARTIFACTS so a deploy missing it fails integrity checks
- share one forked-child path resolver (asar layout vs flat app root) between
  the watcher and the scanner
- smoke-fork the built scanner during build-orcad under both runtimes

Fixes #24105

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(remote-runtime): one owner for a remote pane's binding and recovery (#26308)

A remote pane no longer latches in connecting on terminal_not_found, holds
keystrokes typed during same-terminal recovery instead of dropping them,
reports transport-owned retries so the pane is not remounted mid-retry, and
no longer infers absence from an inventory that predates the leaf.

Co-authored-by: m4air <m4air@Mac.localdomain>

* ci(e2e): move apt off the Azure mirror on runner images that use a mirror list (#26335)

Ubuntu 24.04 runner images resolve apt sources through /etc/apt/apt-mirrors.txt, so rewriting only the source lists left package downloads on azure.archive.ubuntu.com, which intermittently fails ('Ign:' on every package). Rewrite the mirror list too, and retry the install once with --fix-missing.

Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local>

* fix(terminal): remote snapshot replay keeps scrollback it cannot replace (#26319)

Desktop subscribers of a remote terminal received screen-only snapshots,
and every pushed snapshot replay cleared the pane's scrollback with ESC[3J.
A reconnect, a remount, or a recovery after a dropped frame therefore wiped
history the host never resent.

- Host: desktop subscribe snapshots use the same scrollback-row ladder as
  mobile, and SnapshotStart now carries an optional scrollbackRows field.
- Client: a pushed snapshot clears scrollback only when the host says it
  carries history; screen-only images (scrollbackRows 0, or older hosts
  that omit the field) repaint the screen and keep the pane's history.

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(daemon): clean launch env, validate client frames, fit the socket path (#26306)

* fix(daemon): clean launch env, validate client frames, fit the socket path

- Drop Chromium's DBUS_SESSION_BUS_ADDRESS=disabled: marker from the daemon,
  its systemd-run scope launch and every PTY env (#25580, #21119).
- Use the durable user-manager scope only when that manager outlives the
  caller: lingering on, or the caller already runs inside it (#24201).
- Shape-check hello and request frames, and catch request rejections, so a
  malformed frame closes one connection instead of the daemon (#17841).
- Move the daemon socket to a short per-uid private dir when the data root
  would overflow sun_path (#17840).

* fix(daemon): validate a relocated socket dir before any connect, not just before bind

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local>

* fix(ssh): keep crediting a PTY after its recovery delivery is canceled (#26341)

A canceled recovery delivery released its source spans while the renderer
and model could still settle them; the stale projection then threw on every
later ACK, so the next delivery was never credited and the relay paused the
PTY for good. Settling or transferring a span of a closed delivery is now a
no-op.

Co-authored-by: m4air <m4air@Mac.localdomain>

* fix(rpc): deny-by-default caller permissions and a scoped SSH bridge credential (#26337)

* fix(rpc): deny-by-default caller permissions and a scoped SSH bridge credential

Every RPC method now declares a required permission, and RpcDispatcher checks it
against the caller's scope on every transport (owner, mobile, paired runtime,
SSH bridge). The SSH relay bridge no longer spawns the client CLI with owner
authority: each invocation gets a short-lived credential bound to its SSH target,
revoked when the child exits. Without the new per-host opt-in, that credential
reaches only its own host's terminals; with it, workspace methods but never
desktop control, accounts, settings writes, skill install/share, pairing or host
admin. Paired runtime clients need a pairing-time grant (orca serve
--grant-desktop-control) for computer.* actions.

Fixes #23835
Fixes #25259
Refs #18269

* fix(rpc): let a bridged SSH worker report to its own coordinator; allowlist paired-runtime permissions

Without the per-host opt-in, orchestration send/ask/reply/check/inbox now pass when the caller is a
terminal on the bridged host, binding pane key, payload Dispatch, run and recipient to that caller's
own orchestration (recipient off-host only as the creator of its active Dispatch).

Paired runtime clients get an explicit standard permission set; pairing-admin and desktop-control
are grant-only.

* fix(rpc): resolve a bridged SSH caller's own Run and Dispatch mailboxes before admission

Mail routing stores a worker's follow-ups under dispatch:<id> and worker
questions under run:<id>, so comparing the original recipient to the caller's
terminal handle refused an SSH worker replying to its coordinator, an SSH
coordinator answering its own Run's question, and explicit sends to the
caller's own run:/dispatch: address. The bridge now resolves the exact
mailboxes the caller's live pane and process read (as check does) and admits
replies and recipients against those.

* refactor(rpc): parse SSH bridge selectors with a typed schema instead of Reflect.get

---------

Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local>

* fix(projects): remove a project only on the host the user picked (#26340)

* fix(projects): remove a project only on the host the user picked

removeProject now requires a hostId and matches that host's row exactly.
The bare-id fallbacks (unique candidate, focused host) could resolve to
another host's live project and delete it. Orphaned SSH purge and group
deletion now pass each row's own host.

Fixes #13071

* fix(projects): group delete removes only the group's member rows

Same-id rows on other hosts outside the group were removed because membership
was rebuilt from bare ids after the cascade cleared it. Capture (id, host)
member pairs before deletion and remove only those.

* fix(projects): never let a stale catalog widen a project removal to every host

The renderer chose id-only `repos:remove` when its own catalog showed no
same-id row on another host. A stale catalog (another window, a paired
client, or a pending refresh) hid that row, and main then deleted every
host's registration and saved workspace metadata.

- The local catalog route now always calls `repos:removeForHost` with the
  owner row's host, for local, direct SSH, git and folder projects.
- `Store.removeProject` refuses an id that rows on more than one host share,
  so no id-only caller can delete another host's row.
- Setup rollbacks remove the just-created row by its host.

---------

Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local>

* fix(serve): load SSH targets on a headless serve; a user Quit only closes serve-hosted windows (#26348)

* fix(serve): load SSH targets on a headless serve; a user Quit only closes serve-hosted windows

- launchServeMode registers the SSH handler layer before RPC binds, so a windowless
  'orca serve' lists and connects the profile's SSH targets (#25886, #8489).
- Under serve, the app-menu Quit/Exit, tray Quit and renderer-recovery Quit close the desktop
  windows instead of quitting the process (#15537). Signals, supervisor stops, relaunches and
  update installs still quit normally; on macOS a signal reaches before-quit the same way
  Cmd-Q does, so the guard lives on the user commands, not in before-quit.

* fix(serve): SSH profiles serve on Electron; native macOS quits and frozen windows no longer trap or stop serve

- orca serve: a profile with saved SSH targets stays on the Electron host by default, since
  orcad has no SSH stack yet (#25886, #8489). ORCA_SERVE_RUNTIME=orcad still forces orcad.
- macOS serve: Dock > Quit, app-switcher Cmd-Q and other native terminates only close the
  desktop windows (#15537). Signals, supervisor stops, relaunches, update installs and
  logout/shutdown still quit.
- The serve signal listeners now reclaim SIGINT/SIGTERM/SIGHUP from Electron's native handler.
  Any signal that had a Node listener before app ready was left with Electron's handler, so
  these listeners never ran.
- A serve user Quit closing a frozen renderer's window arms the same 10s ack deadline as a
  desktop quit, and skips minimize-to-tray.

* fix(serve): stay on Electron when the profile's SSH targets cannot be read yet

A valid older-schema profile is rejected by the read-only probe, and the
selector took that as "no SSH", so a default serve before the first
migration started orcad with an empty SSH registry. Fail closed instead.

* refactor(window): type user-Quit close marks as a named window slice, not object

* test(serve): pass the desktop-control grant default in the headless SSH launch fixture

* test(serve): run the serve-selection SQLite fixtures in the Node runtime project

---------

Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local>

* fix(terminal): keep the main-process terminal tail linear under TUI redraw floods (#26316)

* fix(terminal): keep the main-process terminal tail linear under TUI redraw floods

Full-width in-place row rewrites rebuilt the row string per character
(O(width^2)), a long chunk past the line cap spliced the row array once
per newline (O(lines x cap)), and multi-frame chunks summed every
cursor-up so they fell back to the whole-tail path. Rows now hold
mutable cells joined once, capped rows are dropped in batches, and the
redraw window uses the net cursor reach.

Fixes #11315

* fix(terminal): do not rebuild a redraw row the cursor only revisits

Rows stayed cell arrays once touched and re-joined on every newline, so a
cursor revisiting one wide row thousands of times cost O(width) per
newline. Runs now splice into the row string, a row switches to cells
only after 32 edits between snapshots, and cells are joined once and
dropped.

* fix(terminal): keep one cached row per chunk so unchanged redraw revisits cost O(1)

A row now keeps its cells, right-trimmed snapshot and content end across
newlines and only invalidates them on a real content change; identical
writes leave it clean. Adds a work-accounting matrix over restore, the
append path and live onPtyData, plus a per-character row oracle fuzz.

* fix(terminal): return redraw rows to flat strings with cached snapshots

Cells made every changed snapshot a full-width join, which was far slower
than the old flat-string splice. Rows are flat strings again, written one
run at a time; each keeps a cached right-trimmed snapshot and content end
that only a real change invalidates, so identical rewrites and cursor-only
revisits stay O(1). A wall-time test now times every review probe against
a vendored copy of the old row model on restore, append and onPtyData.

* test(terminal): read runtime leaf and PTY tail through typed members

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local>

* fix(ssh): relay fallback runs Orca's pinned Node by default, never a host npm install (#26303)

* fix(ssh): relay fallback runs the pinned Node ladder by default, never a host npm install

A host whose connect falls back to the relay route now gets the A/B/C ladder
(Orca's uploaded Node and prebuilt addons) unless it opts into Host Node. A
refused rung C steps to rung D with an actionable reason instead of compiling
native deps on the host.

* fix(ssh): mark the opt-in Host Node relay runtime as unsupported on the SSH host card

The relay fallback now defaults to Orca's pinned Node ladder; the host-npm path is
reachable only by an explicit per-host Runtime or ORCA_SSH_REMOTE_RUNTIME override.
When a relay actually launches on it, main publishes hostNodeRuntime on the
connection state (optional, admitted field-by-field for paired clients) and the
SSH host card shows an unsupported-configuration warning.

* fix(ssh): keep the relay runtime ladder honest about what it cannot verify

- NixOS's stub loader refusing Orca's generic Linux Node (exit 127, 'Could not
  start dynamically linked executable') is classified wrong_libc, so the ladder
  steps on to the host Node instead of failing the connect at rung A.
- In plain SSH mode the shell provider answers absent only for shells it saw
  exit; a PTY id it never minted (one an earlier relay may still run) is
  unverifiable, never exited.
- The rung D message offers Host Node only when the host's own Node refused the
  prebuilt addons, and calls it an unsupported configuration; a download,
  unresolved-host or Windows refusal gets no Host Node advice.

* fix(ssh): keep the host-Node relay where the ladder can't judge the host

A client without Orca's runtime artifacts (a dev, e2e or source build with no
orcad template, or an offline client) refused rungs A and C with
artifacts_unavailable, and the connect settled on rung D. That reason describes
the client, so it now lands on the host-Node relay it had before the ladder,
marked unsupported, and persists no host decision. A proved noexec still lands
on D.

Windows hosts have no rung B or C yet, so any rung A refusal there keeps the
host-Node relay too. The rung D message is worded from the host OS: Windows
gets no noexec-mount wording and is offered Host Node.

* fix(ssh): a host-Node fallback with no Node settles rung D, and mutable refusals expire

The host-Node relay the ladder chooses in place of D (a client artifact gap,
or a Windows rung A refusal) hard-failed the connect when the host had no
Node: the resolver threw a plain Error, which plain SSH mode does not admit.
The fallback now proves host Node with a strict probe first; an answered
"no Node" settles D with a classified, host-OS worded reason, and an
unanswered probe stays a retryable failure. A gap no longer rewrites D to
host Node once rung C proved host_node_missing.

Mutable pinned-runtime refusals (missing_lib, security_software, and noexec
on Windows) are no longer persisted and replay from memory for ten minutes
only, so a repaired host gets rung A back. The in-memory refusal cache moves
to ssh-relay-pinned-refusal-cache.ts.

* fix(ssh): no host does worse than the pre-ladder host-Node default

Restructure the relay runtime ladder around one invariant: A, B and C are tried
first, and any refusal past them falls back to exactly the pre-ladder default,
the host-Node relay marked unsupported. That covers an unidentified libc, a
client artifact gap, Windows, mutable refusals and a C N-API floor the npm build
would not have hit. Rung D is reached only when that fallback proves the host
has no usable Node, or a proved noexec home would defeat it too.

Strict host-Node resolution now treats only a command that ran and exited as an
answer, so a refused channel (MaxSessions), session limit or timeout stays a
retryable failure instead of a 'no Node' verdict. The launch reuses the Node the
fallback probe found.

Mutable refusals (missing_lib, security_software, Windows noexec) persist again
with their proof time and replay for 24 hours, so a host that still blocks
Orca's runtime is not re-uploaded to on every connect, while a repaired one gets
rung A back. A table-driven test asserts the invariant per host class.

* fix(ssh): every answered rung failure reaches the host-Node default, and D only past it

Two shortcuts skipped the pre-ladder default. An answered but unclassified
install failure (tar ENOSPC, an extract or hash marker, an unverified Windows
promote, a self-test that failed outside every refusal class) aborted the
connect on every attempt; it now steps down as a non-persisted install_failed.
Only unanswered failures (abort, timeout, channel-open, session limit, an
unconfirmed termination, SFTP connection loss, ssh exit 255) stay retryable,
and nothing steps down once a relay launch was issued.

A proved noexec no longer jumps to D: exec denial can be per binary (AppArmor
allowing /usr/bin/node), so it skips B and C, which load from the same tree,
and reaches the host-Node fallback. D now follows only that fallback answering
with a failure, so its outcome is a superset of the default's by construction.

The never-worse test is now the cross-product of every A, B, C and host-Node
answer on Linux and Windows, plus named review cases.

* fix(ssh): system-ssh upload failures and a broken cached runtime step down before launch

System-ssh directory upload and system sftp threw plain Errors that dropped the
host's exit status, so a remote tar ENOSPC or a Windows sftp permission refusal
aborted the connect instead of stepping down. The remote side now throws
SystemSshCommandExitError (ssh exit 255, signals and lost-connection
diagnostics stay retryable), and a local tar failure is still blamed on the
client rather than the remote tar that saw its truncated stream.

The warm runtime check only tested that the verified executable existed, so a
cached runtime that the host's exec policy or a removed library had since broken
was launched and hung on every reconnect. The same command now also runs it and
reports a refusal the way promotion does, so it steps down before any launch,
with no extra round trip.

* fix(ssh): keep the remote answer when local tar only failed writing to it

When the host's tar exits early (ENOSPC, a refusal), BSD tar on macOS exits 1
with 'Write error' and no signal, so the upload rethrew the local error and
dropped the typed remote exit, aborting the connect. The remote answer now wins
whenever local tar finished, was signalled, failed after ssh closed, or reported
a write or pipe error; a local tar that failed on its own input first is still
the client's fault.

* test(ssh): hostile-host cells allow read-only version probes, never installs; probe each Node once

The noexec-home cell failed on 'npm --version': under the never-worse ladder a
proved noexec reaches the strict host-Node fallback, whose probe asks npm its
version before settling D. The cells now judge install and compile calls (npm
install/ci, node-gyp, cc, make) and allow '<tool> --version' / '-v'.

The probe ran twice because the login shell named the Node the path probe had
already rejected. Candidate checks are now memoized per resolution, for the
host-Node fallback and rung C alike.

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local>

* chore(i18n): regenerate the runtime-required English catalog after the main merge

* fix(serve): serve on the profile the CLI selected, and refuse any other before the instance lock (#26424)

Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local>

* fix(remote-runtime): open terminal tabs reattach after a managed server update re-pairs the host (#26433)

* fix(remote-runtime): a remote pane rebinds after a server update re-pairs its host

A pane captured the host's pairing revision once, when it mounted. A forced
managed-server update re-pairs the host under the same environment id, so every
later request from that pane was refused as stale, and the refusal counted as
fatal: the pane stopped retrying and Reconnect replayed the same stale revision.
Each request now carries the current revision, a pairing refusal re-reads the
catalog before the retry, and the pane treats it like lost contact.

* fix(remote-runtime): close a stream left on the old pairing and ignore a 0x0 grid hold

A stream that reopened before main re-paired stayed open while sendFrame refused
its stale revision, so typed keys vanished. A revision change now closes it as
recoverable so the pane rebinds on the new pairing. A just-restarted server also
published a 0x0 hold that shrank the visible pane to one cell; holds with no grid
are ignored.

* fix(remote-runtime): a pane follows only its own machine's re-pair

A re-pair under the same environment id to a different host key published its
revision before the catalog retired the old tabs, and the pane recovered at
the new revision, so its next call was authenticated as the replacement peer.
The catalog now classifies each re-pair before publishing revisions: a
replaced or removed machine's terminal streams end for good first, and a pane
only advances its pairing along a proven same-host rotation (same registration
and host key). An unresolved identity holds the pane until it is proven.

---------

Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local>

* refactor(rpc-permissions): Phase 3.5 elegance pass (#26471)

* refactor(rpc-permissions): drop never-read incarnation and invocation id from bridge credentials

* refactor(rpc-permissions): define runtime device grants once and reuse bridge scope fixtures

* refactor(rpc-permissions): decide terminal host membership in one predicate and share the opt-in hint

* refactor(rpc-permissions): parse bridge selectors once per call

* refactor(rpc-permissions): admit and bind unopted bridge methods from one table

---------

Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local>

* refactor(remote-pane-binding): Phase 3.5 elegance pass (#26470)

* refactor(remote-pane-binding): keep the pane's pairing revision in one slot

* refactor(remote-pane-binding): share one hold decision across the input entry points

* refactor(remote-pane-binding): define the persisted-handle adopt once in attachPane

* refactor(remote-pane-binding): build the pushed-snapshot meta once

* refactor(remote-pane-binding): drop the test-only peerReplacedEnvironmentIds wrapper

* test(remote-pane-binding): share the input and subscribe frame readers via stream fixtures

---------

Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local>

* refactor(relay-ladder): Phase 3.5 elegance pass (#26474)

* refactor(relay-ladder): put the host-Node fallback in the ladder before D

The Auto ladder now lists legacy as the rung before D, so the step function
is one rule plus the next rung. Deletes nextRelayRuntimeStep, the hostOs
step context and the mutable hostNodeFallback flag (now a constructor-time
laddered field). Windows still ends at legacy: B and C refuse there before
any host I/O.

* refactor(relay-ladder): one rung-D reason and message over reachable states

D is reached only from the host-Node fallback, so its refusal is
host_node_missing or install_failed. Merges the reason and message
functions, drops three message branches that could not run, and folds
noexecRemembered/noexecProved into one noexec field.

* test(relay-ladder): one never-worse cross-product with a remembered axis

Drops the named cases (each matches a cross-product outcome), the transport
case that only checked the model against itself, and the third case the rank
check already implies. Adds rung B's noexec answer and per-rung remembered
flags for A and B, the only rungs that replay a cached refusal.

* refactor(ssh): one answered-miss rethrow for the node resolution probes

Four catch blocks repeated the same session-limit, abort and strict checks.
They now call rethrowUnlessAnsweredMiss and keep their own fallthrough.
Also drops a stale 'Caches nothing' comment.

* refactor(ssh): share the host-answered system-ssh exit rule; flatten the upload throw cascade

isHostAnsweredSystemSshExit owns the 255/null transport rule for both the
upload pipeline and isAnsweredHostFailure. settleUploadPipeline drops a
branch that could not run: a failed tar is judged once, then ssh, then pipe.

---------

Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local>

* refactor(serve-daemon-misc): Phase 3.5 elegance pass (#26478)

* refactor(ssh-pty): build obligation transitions behind one closed-delivery guard

* refactor(build-orcad): table-drive the shipped worker smokes

* refactor(daemon): pass scope probe seams as one deps object and share cgroup line parsing

* refactor(repos): route every project removal through removeProjectForHost

* refactor(build-orcad): type the worker smoke table for the type-aware gate

---------

Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local>
Co-authored-by: m4air <m4air@Mac.localdomain>

* refactor(terminal-tail): Phase 3.5 elegance pass (#26465)

* refactor(terminal-tail): share mulberry32 and compare whole results in redraw equivalence tests

* refactor(terminal-tail): keep one splice ceiling at the onPtyData entry point

---------

Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local>

* fix(terminal): bound the backing a redraw row can pin across chained erases (#26547)

Each erase that keeps at least half the row skipped the copy, so a chain
of them left nested slices pinning the original wide row. Rows now track
an upper bound on the string they slice and copy once the cumulative
shrink passes half.

Co-authored-by: m4air <m4air@Mac.localdomain>

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 05:36:25 -07:00
OrcaWinandm4air 53fd2dea0b feat(ssh): relay runtime fallback ladder, telemetry and host runtime setting (#24133)
* feat(ssh): complete the relay runtime fallback ladder (D6 rungs B slot, C, D)

Rung C runs the relay on the host's Node >= 18 with Orca's prebuilt N-API
addons and no npm (addon-only probe mode). Rung B is a data-driven slot chosen
only when a compat runtime is listed. Rung D fails the connect with a
classified reason carried as a TerminalUnavailableCause. The ladder steps
down only on classified refusals; unanswered probes throw. The rung decision
is persisted per host keyed by (glibc, runtime hash, Orca major), and
ssh_remote_runtime_resolved reports it once per host per session.

* feat(settings): SSH host runtime choice (Auto | Orca-managed Node | Host Node)

* docs(telemetry): describe ssh_remote_runtime_resolved

* fix(ssh): let a passing rung C disprove a remembered noexec; allow glibc-less compat runtimes

A remembered rung A noexec was re-persisted even after rung C self-tested addons from the same
~/.orca-remote tree, so rung A stayed skipped until the key changed. Rung B's evaluator also could
never match a musl compat runtime.

* test(ssh): import node:fs once in the host-node addon test

---------

Co-authored-by: m4air <m4air@m4airs-Air.localdomain>
2026-10-01 02:08:26 -07:00
Neil 57681ecd09 fix(remote): resolve the spawn cwd, the node manager dir, the vault host and the scrollback seed (#17952)
* fix(remote): resolve workspace cwd, mise Node, host scope, and TUI scrollback honestly

#15296 relay: a folder workspace id (`folder:<uuid>`) carries no path, so the
worktree-id split yielded nothing and $HOME silently won. Resolve the spawn cwd
through worktreeId -> ORCA_WORKSPACE_ROOT -> host default, and refuse an agent
spawn outright when a folder workspace names a root this host cannot resolve.

#11733 ssh: generalize the NVM dotfile scrape into `orca_dotfile_dirs` and drive
mise off `MISE_DATA_DIR` / `XDG_DATA_HOME` instead of a hardcoded
`$HOME/.local/share/mise`.

#13713 ai-vault: an unresolvable workspace host is `unverifiable`, not local.
Widen the default scope to every host rather than scanning the client's own
history and reporting "No agent sessions found".

#6106 terminal: hydration asked the renderer for `scrollback: 0` while an
alt-screen TUI was up, which drops the normal buffer's shell history rather than
the TUI bytes. Drop the flag; readers already split the two buffers apart.

* fix(remote): stop the relay answering host questions for a guest execution host

Three findings from review of the spawn-cwd resolver, all the same shape: a path
question answered against the wrong host, or with the wrong key.

- resolveRelaySpawnCwd refused an agent launch whenever a folder workspace named
  a root that did not stat on the relay. But relayHostDirectoryExists stats the
  relay's *own* filesystem, and the relay supports WSL shells, so a folder
  workspace on a Windows relay launching into WSL now threw where it previously
  spawned -- contradicting the function's own doc comment, which says an absent
  path for that exact host pair is a miss, not a refusal. Thread the shell's
  execution host in and demote the refusal to a miss when the spawn does not run
  on the relay's filesystem.

- requireRelaySpawnCwd's doc claims both call sites route through one resolver
  so the fence can never be keyed on a directory the spawn won't use, but the
  fence key was still computed with the non-stripping splitWorktreeId while the
  cwd used splitWorktreeIdForFilesystem. For a `::workspace:<uuid>` id those
  disagree by construction, in adjacent lines: the removal fence guarded a path
  no spawn ever enters. Same defect in shutdownForWorktreePath and the revive
  path; all three now use the filesystem split.

- The remote Node probe expanded `$HOME` and `~/` prefixes out of a dotfile
  assignment but not `$XDG_DATA_HOME`, so `MISE_DATA_DIR=$XDG_DATA_HOME/...`
  was used as a literal directory name. Add the case arm, defaulting to the
  POSIX `$HOME/.local/share` the seed value already uses -- sshd's exec channel
  usually has no XDG_DATA_HOME at all.
2026-09-02 21:33:41 -07:00
NeilandOrca aab112933e Revert "fix(memory): bound OOM-prone accumulators (#10179)" (#10255)
Co-authored-by: Orca <help@stably.ai>
2026-07-23 18:35:31 -07:00
Neil 8f40ddf328 fix(memory): bound OOM-prone accumulators (#10179) 2026-07-23 06:22:56 -07:00
Brennan Benson 3e276d78ba fix(ssh): probe npm via prepended PATH, not colocated with node (#9165) (#9255)
* fix(ssh): probe npm via prepended PATH, not colocated with node (#9165)

The remote Node/npm toolchain gate invoked npm by its absolute path
<nodeBinDir>/npm (POSIX) / npm.cmd (Windows, behind a Test-Path
colocation check). But deploy (commandWithNodePath) runs bare `npm`
with nodeBinDir merely prepended to PATH, so npm can resolve from
anywhere on PATH.

A host whose only resolvable node has npm elsewhere on PATH (e.g. node
symlinked into a dir without npm) deployed fine on v1.4.144, but after
upgrade the candidate is rejected with no fallback → SSH/relay
connection fails to establish.

Make the probe resolve npm exactly the way deploy does — bare
`npm --version` under the same prepended PATH — so it still confirms npm
is runnable (the #8450 concern) without requiring colocation. Windows
now prepends the backslash-form dir (matching deploy) so bare-command
PATH lookup resolves reliably.

* test(ssh): cover split Node npm PATH resolution
2026-07-17 18:51:10 -07:00
Brennan Benson f544820552 fix(ssh): require a coherent colocated Node/npm toolchain for the relay (#9165) 2026-07-17 14:27:31 -07:00
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>
2026-07-16 21:32:13 -07:00
Haohan Lin 190ed7d4ed fix(ssh): make POSIX relay wrapper survive csh/tcsh login shells (#8709)
Reconstruct remote POSIX commands with bounded printf arguments so non-POSIX SSH login shells can forward them without requiring remote base64. Preserve relay and system-SSH stdin, and centralize login-shell flag selection for csh/tcsh compatibility.\n\nValidated against real csh and tcsh OpenSSH targets with built-in and system SSH, including cold relay deployment, stdin upload, PTY I/O, file mutation, and reconnect.
2026-07-15 02:38:40 -07:00
leter 6677de1807 Improve SSH Node.js prerequisite guidance
Improve SSH remote Node.js prerequisite failures by adding package-manager-specific POSIX install guidance, Windows Node 18+ candidate validation, fnm XDG probing, and regression coverage while preserving SSH abort and session-limit behavior.
2026-07-04 23:54:26 -07:00
f215a48064 fix: pr-bug-scan validated finding from #6952 (#7180)
* fix: address pr-bug-scan validated finding from #6952

throwNodeNotFound() now re-raises AbortError when the shared signal is aborted, so a signal-cancelled node probe no longer launders into 'Node.js not found'; sequential fallback runs.

* fix(ssh): make session-limited (MaxSessions=1) relay deploys actually succeed

Review of #7180 verified the parent fallback end-to-end against a real
MaxSessions=1 sshd and found the connect still failed. Four gaps, in order
of discovery:

- isSshSessionLimitError missed stock OpenSSH, which refuses session
  channels over MaxSessions with SSH2_OPEN_CONNECT_FAILED (2) and 'open
  failed' — reason 4 never matched, so the fallback never triggered.
- execCommand settled aborted commands before the channel finished
  closing, so the sequential fallback reissued execs while sshd still
  counted the old session.
- SshConnection.waitForSshCallback rejected aborts mid-channel-open
  immediately, leaking a confirmed-late channel that held the only
  session slot; it now settles after the late channel closes (bounded)
  and drains its streams so ssh2 emits 'close'.
- Session channel opens now retry transient session-limit refusals
  (sshd frees the slot only after processing our close-ack, which the
  next open can beat by microseconds), and the remote orca CLI shim
  install is non-fatal like the managed-hook install — after the relay
  bridge occupies the sole session slot, raw-connection extras must
  degrade instead of failing the connection.

Verified live against Docker sshd (OpenSSH 9.2, MaxSessions=1): fresh
deploy (upload + native deps + launch), reconnect cycles, and a PTY
round-trip all succeed; unrestricted-sshd regression run also passes.

Co-authored-by: Orca <help@stably.ai>

* Handle ssh execution aborts immediately during retry backoff or hangs

- Cancel the session-limit retry delay immediately if the operation is
  aborted during backoff.
- Limit the wait time to a 5-second grace period when aborted during a
  channel open that is hung and never invokes its callback, rather than
  waiting for the full connection timeout.

---------

Co-authored-by: orca-bug-scan-bot <orca-bug-scan-bot@stably.ai>
Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Co-authored-by: Orca <help@stably.ai>
2026-07-04 01:26:55 -07:00
Brandon Barker e6ca3087c5 perf(ssh): resolve node path concurrently with remote install state (#6952)
Run independent SSH relay bootstrap probes concurrently when the connection can safely support overlapping execs. Preserve the old sequential path for system SSH without reusable ControlMaster and for remotes that reject concurrent session channels.
2026-07-02 13:55:36 -07:00
44935d4ccb Fix remote Node.js detection for nvm, mise, asdf, and volta (#6037)
* Fix remote Node.js detection for nvm, mise, asdf, and volta

Remote Node resolution failed when node was installed via a version
manager (nvm with custom NVM_DIR, mise, asdf, volta) or when the user's
login shell was zsh/fish rather than bash.

Root cause: the SSH exec transport runs every command under /bin/sh,
which never sources shell init files. The only init-aware path was a
hardcoded `bash -lc` fallback that missed zsh/fish users and was never
reached for the newer version managers. nvm was handled by guessing
~/.nvm (breaking custom NVM_DIR), and mise/asdf/volta had no probes at
all. There was also no version gate, so nvm's highest-version glob
could return Node 8/10/12 and crash the relay on launch.

Fix: resolve via the user's own $SHELL as a login shell first (the only
path that runs nvm.sh / mise activate / asdf.sh init hooks), then fall
back to direct path probes for all major managers (nvm respecting
$NVM_DIR, fnm, mise, asdf, volta, n) plus system locations. Every
candidate is version-checked against the relay's Node 18+ requirement
before being accepted.

* Address CodeRabbit review: probes-first, no || short-circuit

- Reorder to path-probes first (deterministic, doesn't depend on shell
  rc-file semantics where bash -lc skips .bashrc and zsh -lc skips
  .zshrc — exactly where nvm/mise/asdf hooks live).
- Join probes with newlines instead of || so an empty
  `ls | sort -V | tail -1` (exit 0) doesn't mask later probes.
- Deduplicate candidate paths before version-checking.
- Drop unreachable mock and fix misleading $SHELL-unset test name.
- Login shell is now a fallback for custom ~/.profile PATH setups.

* Fix remote Node path probing portability

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com>
Co-authored-by: Orca <help@stably.ai>
2026-06-22 16:46:47 -07:00
98d02bca47 fix: support windows ssh hosts (#5004)
* feat: add windows ssh relay base support

* feat: support windows ssh relay runtime services

* fix: default windows ssh pty cwd to user profile

* fix: support windows hosts over system ssh

* fix: preserve degraded windows relay native deps

* fix: gate windows shell args by relay platform

* fix: preserve windows relay fallback pipes

* test: align windows native deps relay fixture

* fix: build valid windows install lock command

* fix: address windows SSH relay review findings

Resolve correctness, efficiency, and reuse issues found reviewing the
Windows SSH native-host support:

- GC liveness on Windows now probes the actual named pipe (via node
  net.connect against markers + deterministic candidates) instead of
  substring-matching Win32_Process command lines, which could remove a
  live relay dir. Reports ALIVE conservatively only when there is no
  liveness signal at all (no markers and no seed pipes).
- Resolve the remote node path once per deploy and thread it through
  install/repair/launch instead of re-resolving 3-7x.
- Replace the 200ms node -e poll loop with a single long-lived remote
  wait process during Windows relay startup.
- Skip the no-op executable command on Windows in uploadRelay.
- Make the Windows fallback pipe name deterministic and recoverable
  (drop the global counter), with an extra reconnect attempt.
- Normalize the prepended node bin dir to backslashes on Windows PATH.
- Batch the system-SSH Windows directory upload into a single streamed
  JSON package instead of one ssh process per file.
- Extract relay endpoint/marker helpers into ssh-relay-endpoints.ts and
  consolidate the PowerShell EncodedCommand encoding into the shared
  powershell-command-encoding module.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Support cancellation and timeouts in Windows port scanning

- Propagate the request AbortSignal and a 5-second timeout to both
  PowerShell and netstat child processes during Windows port scanning.
- Avoid spawning the netstat fallback process if the port scan has
  already been aborted.
- Wrap the .NET OSArchitecture check in a try/catch block during SSH
  Windows platform detection to robustly fall back to environment
  variables if needed.

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
2026-06-09 01:17:34 -07:00