The relay CLI shim on SSH remotes rejected every orchestration/mutation
command with 'Unsupported SSH Orca CLI command' because the host handled
relay CLI requests with a hand-rolled allowlist of five read-only-ish
commands. The host now runs the real bundled orca CLI entry (same entry
as the local shell command, via ELECTRON_RUN_AS_NODE) as a captured
subprocess, so remote invocations get the full command surface by
construction. Remote cwd is carried via ORCA_CLI_CWD so cwd-based
selectors (--worktree active) resolve against the caller's remote
directory; only Orca terminal-context env vars cross the bridge.
Host-interactive commands (serve, claude-teams, agent-teams-tmux) get a
targeted error, and the legacy in-process switch remains as a fallback
when the host CLI entry cannot be launched. Relay-side request timeouts
are raised to fit mutation and long-poll (--wait/--timeout-ms) commands,
and stdin forwarding now covers *-stdin payload flags.
Fixes#7716
Co-authored-by: Orca <help@stably.ai>
* fix(ssh): guard mux dead-link detector against sleep/App Nap timer pauses
After system sleep or App Nap timer throttling, the first post-wake
timeout-check tick saw pre-pause keepalives as >20s stale and killed a
healthy link (false 'Connection timed out (no ack received)' ->
dispose('connection_lost') -> reconnect overlay churn). Track the last
tick time; when a tick gap far exceeds the interval, reset staleness
tracking, probe with a fresh keepalive, and let the next full window
make an honest liveness determination. A genuinely dead link is still
detected within ~25s after wake.
Also adds probeLiveness(timeoutMs): a keepalive round-trip primitive
that resolves true on the first frame of any kind, used by the resume
path to distinguish surviving links from dead ones.
Refs #7773
Co-authored-by: Orca <help@stably.ai>
* fix(ssh): probe relay liveness on system resume instead of unconditional reconnect
powerMonitor 'resume' previously called connectionManager.reconnect()
for every active target, guaranteeing a teardown + reconnect overlay on
every wake even when the connection survived sleep. Now each session's
relay link is probed (keepalive round-trip, 5s timeout, one retry for
slow post-wake network); only targets whose probe fails are reconnected.
Dead-after-sleep connections still reconnect promptly. The 'suspend'
grace-time handling is unchanged.
Refs #7773
Co-authored-by: Orca <help@stably.ai>
* feat(relay): prefix relay.log diagnostic lines with ISO timestamps
The remote relay.log had no timestamps, which blocked correlating
reconnect flaps with user activity and sleep/wake windows while
diagnosing #7773. Daemon-mode diagnostic lines now carry an ISO
timestamp prefix ('<ISO> [relay] ...', grep-stable). Connect-mode and
orca-cli passthrough stderr is untouched since it goes back to the
app/user terminal and is parsed (handshake-mismatch detection).
The relay bundle is content-hashed at build time, so the versioned
install picks up the new relay automatically on next deploy.
Refs #7773
Co-authored-by: Orca <help@stably.ai>
* fix(ssh): re-check session identity before post-probe resume reconnect
The resume probe can take ~10s; if the user disconnected the target or the
session/connection was replaced during that window, reconnecting would
resurrect an intentionally torn-down connection (CodeRabbit).
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
* Sweep all persisted carriers of a removed SSH target id on re-adoption
reassignSshTargetId re-pointed repos and worktree metas but left the old
target id embedded in persisted session pty ids (ssh:<id>@@pty-N in tabs,
layouts, remoteSessionIdsByTabId), the startup reconnect list
(activeConnectionIdsAtShutdown, replayed via ssh.connect at boot — the
exact 'SSH target not found' in STA-1468), sleeping-agent resume records,
provisioned project host setups, sidebar host-scope arrays, and relay pty
leases. Any survivor resurfaces later as a failing connect or reattach.
New ssh-target-id-migration module re-points every carrier in one pass,
wired into reassignSshTargetId with per-carrier unit and store-level
round-trip tests.
Co-authored-by: Orca <help@stably.ai>
* Bridge SSH connection state to paired remote clients
The SSH surface was desktop-only: ssh:state-changed went to the host's
own BrowserWindow and the web client's ssh API was a no-op stub, so a
paired client's reconnect overlay never learned the host connected and
its target labels stayed empty (STA-1468 — overlay stuck on 'please
connect' over a live terminal).
- New sshStateChanged runtime client event, emitted from broadcastSshState
through OrcaRuntimeService onto the existing clientEvents stream.
- New ssh.listTargets / ssh.listRemovedTargetLabels RPC methods next to
the previously unused ssh.getState / ssh.connect.
- Web preload now routes listTargets / listRemovedTargetLabels / getState
/ connect to the paired host's runtime RPC instead of stubbing them.
- useIpcEvents applies sshStateChanged on paired web clients through the
same guarded path as desktop ssh.onStateChanged; desktop clients ignore
the event since a foreign runtime's targets would pollute their local
SSH store.
Co-authored-by: Orca <help@stably.ai>
* Harden the SSH reconnect overlay against stale or unknown target state
- Only present the destructive 'SSH host removed' state on positive
evidence (a removal tombstone label, or a hydrated non-empty target
list lacking the id). A client whose SSH state never hydrated has an
empty labels map for every id and must not offer workspace removal.
- After a failed Connect, resync target metadata so a stale overlay
converges to the ghost/re-adopted state instead of offering the same
failing Connect forever (the repeated 'SSH target not found' toast
loop in STA-1468).
Co-authored-by: Orca <help@stably.ai>
* Address CodeRabbit review on #7767
- Re-key workspaceSessionsByHostId partitions stored under a removed SSH
host id during re-adoption (no writer keys partitions by ssh host today,
but the schema tolerates it — re-key instead of stranding; live partition
wins when both keys exist).
- Track SSH target-list hydration explicitly (sshTargetsHydrated) instead
of inferring it from a non-empty label map, so a legitimately empty
target list still counts as removal evidence and a never-hydrated client
still never offers destructive removal.
- Apply the refreshed target list before the best-effort removed-labels
fetch in the overlay resync, so a labels failure can't discard it.
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
* fix(main): detect CJK input source via cfprefsd on macOS 15
macOS 15's `plutil -extract <key> json` aborts with "invalid object in
plist for destination format" on the AppleSelectedInputSources array even
though it is all strings, so the selected-input-source probe threw and fell
back to the keyboard layout id (com.apple.keylayout.US). That disabled
forwardAsciiPunctuation, so third-party IMEs (Sogou, Doubao) sent half-width
,.? to the PTY instead of full-width ,。? in terminal panes and agent chat.
Apple's built-in IME happened not to trip the plutil bug.
Read the live prefs via `defaults export` (cfprefsd) and extract as xml1
before converting the clean subtree to JSON, dodging both the plutil json
bug and the stale on-disk plist. The parser and CJK term list are unchanged.
* fix(main): reap CJK input-source probe process group on timeout
Run the macOS input-source probe via detached spawn and SIGKILL the whole
process group on timeout so a wedged cfprefsd can't orphan the defaults/plutil
pipeline stages (the probe re-runs on every window focus-in). Pin absolute
/usr/bin paths, guard the stdout stream, and cover the non-zero-exit, spawn-
failure, and timeout fallbacks in tests.
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: su'qiang <nslogname@MacBook-Pro.local>
Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Co-authored-by: Orca <help@stably.ai>
* fix(runtime): keep same-path imports host-qualified (#7018)
* review: harden runtime repo host match against SSH-repo hijack
An unstamped repo with a connectionId is an SSH repo (resolves to
ssh:<id>), so a same-path runtime import must not adopt it into a
runtime/local host. Match/adopt an unstamped repo only when it has no
connectionId, mirroring the existing local-IPC dedup guard
(src/main/ipc/repos.ts). Adds a regression test that fails without the
guard (SSH repo hijacked into runtime host).
Co-authored-by: Orca <help@stably.ai>
* review: only runtime hosts backfill an unstamped repo
A legacy unstamped repo is indistinguishable from a genuine local repo
(both have null executionHostId and connectionId). Restrict the adoption
branch to runtime incoming hosts so a local/ssh import at a colliding
path can never re-attribute a real local project to the wrong host.
Runtime is the only host that lost its identity to the pre-#7018
path-only import and needs the backfill. Adds a regression test.
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com>
Co-authored-by: Orca <help@stably.ai>
* fix: address review findings
* Improve split divider contrast and refactor tab split context menus
- Increase contrast of `--tab-group-split-divider` colors in light and
dark modes to achieve at least 3:1 contrast against `--card`.
- Refactor `TerminalTabSplitMenuSection` to use the shared
`TabWorkspaceLayoutMenuSection` for moving tabs between splits.
- Clarify terminal-specific split action labels in the context menu.
- Remove redundant icon margins in `EditorFileTabContextMenu`.
* fix terminal IME candidate selection and text commit on Linux
Sogou Pinyin and fcitx on Linux failed in Orca's terminal because bare
229 keydowns were swallowed, and empty composition updates prematurely
deactivated tracking. This led to dropped Chinese text or leaked Space/digit
candidate-selection keys reaching the PTY.
- Allow bare 229 keydowns to bypass suppression on Linux so xterm can diff
and commit text.
- Prevent empty compositionupdate events from prematurely deactivating
the composition tracker.
- Suppress and preventDefault candidate-selection keys (Space and digits)
during active composition and a brief post-composition window.
- Add comprehensive unit tests and an Electron CDP-driven E2E repro.
* fix: register IME gate command as direct spec-file invocation
The reliability-gate checker rejects --grep title selectors and requires
every evidenceRun command to match a gate command. Drop the --grep from
the e2e gate command and its evidence run, and remove the stale 3-file
evidence run superseded by the full 7-file run.
Co-authored-by: Orca <help@stably.ai>
* Guard overlapping and post-composition Linux IME candidate keys
- Track pending candidate key releases in a Map instead of a single
slot to support overlapping selector key events without stranding.
- Apply the candidate selection guard to post-composition key releases
that arrive after compositionend, preventing digits/Space from
leaking into the PTY.
- Restrict the Linux/Sogou candidate selection guard to Linux to
prevent interference on macOS and Windows.
- Exclude Shift+Space from candidate selection key checks.
* Guard held-key IME candidate repeats and scope policy to desktop Linux
- Keep auto-repeat keydowns for a candidate key suppressed past the
250ms guard window until its corresponding keyup event is received.
- Clear stale pending releases on fresh non-repeat keydowns to avoid
guarding the wrong key events.
- Exclude Android and ChromeOS user agents from desktop Linux-specific
IME candidate key suppression behaviors.
- Ensure the composition tracker is activated unconditionally on
compositionupdate events.
* Clean up IME reference and extract shared test event fixture
- Remove the obsolete Linux Sogou Pinyin IME reference document.
- Extract the fully-defaulted XtermBypassEvent helper into a shared
fixture file to keep the policy test suites in sync.
- Add a test verifying that Shift+Space (fcitx full-/half-width toggle)
is not suppressed as an IME candidate key.
---------
Co-authored-by: Orca <help@stably.ai>
* Robustify SSH terminal reconnect and session restore recovery
- Release the replay guard after a fallback timeout to prevent permanent
keyboard input lockouts when an unmounted terminal never parses.
- Verify backing process liveness on PTY attach and reap stale entries
so dead shells cleanly trigger a fresh pane spawn.
- Normalize connection IDs during restore to prevent spurious mismatch
errors, and treat true mismatches as expired sessions instead of crashing.
* Route disconnected SSH terminal panes through deferred connection gate
Avoid spawning SSH terminal processes against disconnected targets,
which otherwise throws "No PTY provider" and leaves panes stranded.
- Intercept spawning via a new connect gate that triggers the deferred
connection flow when the SSH target is disconnected.
- Fall back to composite worktree IDs during cold-start hydration to
ensure deferred SSH session IDs are properly stashed.
- Retry spawning and remounting terminal panes upon SSH reconnect if
they are stranded or failed to spawn.
When returning to a worktree in the sidebar, the active tab reset to the
first tab instead of the one the user left on. Three fallback sites derived
the active tab from the first tab rather than the per-worktree remembered
selection (activeTabIdByWorktree):
- reconcileWorktreeTabModel: promoting legacy runtime terminals into a
freshly-ensured group seeded activeTabId from restoredLegacyTabs[0].
- Terminal.tsx active-terminal repair: reset to tabs[0]; a repair firing on
a transient worktree-switch render permanently clobbered the selection to
Terminal 1.
- hydrateLegacyFormat: used the global session.activeTabId, so every
worktree except the last-focused one lost its terminal on restart.
All three now honor activeTabIdByWorktree before falling back to the first
tab. Adds fails-old/passes-new regression tests.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The stderr handler in system-ssh-port-forward-provider.ts stays attached
for the forward process's entire lifetime and appends every chunk to an
unbounded string, only ever read to build the exit-error detail. A
chatty/warning-spamming remote sshd over a long-lived `ssh -N -L`
forward could grow it without bound.
Fix: keep only the most-recent 64 KB tail, mirroring
MAX_RELAY_STARTUP_BUFFER_BYTES in ssh-relay-deploy-helpers.ts. The tail
is what the error message surfaces anyway.
Test (red->green): emitting >64 KB of stderr then exiting keeps the
recent tail marker and drops the oldest, detail bounded below the
produced size; without the cap the whole string is retained.
recentlyClosedAgentStatusTabIds (agent-status store) suppresses late
hook/status events for a just-closed terminal tab. It was only ever
added to — one `true` entry per agent-tab close, keyed by the ephemeral
tabId, never deleted or capped — so it grew for the renderer's whole
session. It's the renderer twin of the main-process
closedAgentStatusTabIds set that #7561 already FIFO-capped.
Fix: bound it to the 1024 most-recent closed tabs via delete-then-set
LRU with oldest-key eviction (Record key order is insertion order),
mirroring #7561. A status event for a tab closed >1024 tabs ago cannot
still arrive, so suppression behavior is unchanged.
Test (red->green): closing 1029 tabs leaves exactly 1024 markers with
the oldest evicted and the most-recent retained; without the cap all
1029 persist.
Two module-level caches in native-chat-pending.ts capped their per-key
arrays (8 entries) but never bounded the KEY count:
- commandMarkerCache — keyed by paneKey\0agent\0sessionId; sessionId
changes on every /clear and paneKey embeds an ephemeral leafId, so a
distinct key was stranded per (pane, session) for the renderer's whole
life. Only test-only clear() ever removed keys.
- pendingSendCache — self-cleans on the normal empty-drain path, but a
pane closed with an unconfirmed send (agent crash / early close) left
its non-empty entry keyed forever.
Route both writes through the existing setBoundedScopeCacheEntry LRU
helper (cap 128) that #7566 already applied to the draft/attachment
caches in the same folder — same pattern, same file family, was just
missed here. Values are tiny so this is a slow leak, but real and
unbounded.
Tests (red->green): appending 133 distinct scopes evicts the oldest and
keeps the most-recent 128; without the cap all 133 survive.
* perf(pty): cap the unsent main→renderer PTY backlog (Win/Linux GB leak)
The per-pty `pendingData` string in ipc/pty.ts (main's unsent
main→renderer output queue) had no size cap. The 512 KB/pty + 8 MB
in-flight caps bound only SENT-but-unacked bytes; `onData` appends to
`pendingData` unconditionally and `flushPendingData` merely `continue`s
(no drain) while ack-gated. On Windows/Linux the main renderer is
background-throttled — `setBackgroundThrottling(false)` is set only for
`process.platform === 'darwin'` — and Chromium freezes hidden pages
after ~5 min, so a backgrounded Orca with an active/verbose agent stops
ACKing: in-flight pins at 8 MB, the flush gates, and `pendingData` grows
at raw PTY throughput → MB→GB in the main process. macOS is unaffected.
Fix: cap the backlog to the most-recent PENDING_DATA_MAX_CHARS (2 MB,
matching the daemon pendingOutput and renderer scheduler caps),
advancing startSeq by the dropped-char count (the same arithmetic
flushPendingData uses when slicing), and set a `droppedBacklog` flag on
the next payload. The renderer's dataCallback sees the flag and calls
the existing markHiddenOutputRestoreNeeded(), so on hidden→visible it
rebuilds the dropped span from the main headless snapshot — no output is
lost within scrollback depth. Background agents keep writing full-speed
into the runtime buffer; only the redundant delivery copy is trimmed.
The flag threads through the same layers as the existing `background`
field: main payload → preload types → dispatcher PtyDataMeta → renderer.
Tests (red→green): a daemon pty emits 5 MB while the renderer never
ACKs. Without the cap the flag is never set (fail); with it the first
emitted chunk carries droppedBacklog exactly once and delivery stays
bounded. Ordinary small output never sets the flag.
Note: leak + fix are Win/Linux-only (macOS is throttling-exempt), so not
reproducible at runtime on macOS; evidence is the code trace + tests.
* test(pty): clarify that the sent-total assertion is a sanity bound, not the cap proof
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
* Allow Windows SSH directory browsing to recover
The remote project picker runs before the relay filesystem ACLs exist, so it uses a raw SSH exec channel with POSIX shell commands. Windows OpenSSH targets whose default shell is cmd.exe reject Orca's POSIX exec wrapper, which prevented browsing any remote directories. Keep the existing POSIX path as the first attempt, then fall back to a narrowly scoped PowerShell listing that emits the same line-based format.
Constraint: Add Remote Project needs raw SSH browsing before relay roots are registered
Constraint: Windows OpenSSH may use cmd.exe as the remote command shell
Rejected: Replace the POSIX command for all hosts | riskier for existing Linux/macOS SSH targets
Confidence: medium
Scope-risk: narrow
Directive: Keep POSIX browsing as the primary path; Windows fallback exists only for shell-wrapper rejection errors
Tested: npx --yes pnpm@10.24.0 exec vitest run --config config/vitest.config.ts src/main/ipc/ssh-browse.test.ts
Tested: npx --yes pnpm@10.24.0 exec oxlint src/main/ipc/ssh-browse.ts src/main/ipc/ssh-browse.test.ts
Tested: npx --yes pnpm@10.24.0 exec oxfmt --check src/main/ipc/ssh-browse.ts src/main/ipc/ssh-browse.test.ts
Tested: npx --yes pnpm@10.24.0 run typecheck:node
Not-tested: Live Windows OpenSSH host with cmd.exe default shell
* fix(ssh-browse): strip CRLF in Windows PowerShell browse output
Windows OpenSSH exec emits CRLF, but the browse parser split on \n only
and never stripped the trailing \r. Every directory line then failed the
endsWith('/') check and was misclassified as a file with a stray CR in
its name (and resolvedPath kept a trailing \r) — breaking the exact
Windows path this PR adds. Split on /\r?\n/ to match the existing
ssh-relay-versioned-install convention; POSIX (LF-only) output and
filenames with legitimate leading/trailing spaces are unaffected.
The added fallback test used \n-only fixtures that no real Windows host
produces, masking the bug; switch it to realistic CRLF output so it now
guards the regression.
Co-authored-by: Orca <help@stably.ai>
* fix(ssh-browse): locale-independent Windows fallback + UTF-8 output
- Trigger the PowerShell fallback on cmd.exe's locale-independent 9009 exit
code, not just English/Spanish stderr text, so non-English Windows hosts
actually recover.
- Pin [Console]::OutputEncoding to UTF-8 in the PowerShell script so non-ASCII
names (e.g. C:\Users\José, CJK, Cyrillic) aren't mojibake'd when decoded.
- Rethrow the original POSIX error when the PowerShell retry also fails, so a
false-positive predicate match doesn't mask the real failure.
- Tests: decode the -EncodedCommand payload to guard injection-safe escaping
and the UTF-8 pin; add a 9009-locale fallback case and a negative-predicate
(permission denied must not retry) case.
- Drop an incorrect sentence from the CRLF-split comment.
Co-authored-by: Orca <help@stably.ai>
* fix(ssh-browse): surface PowerShell error on proven-Windows fallback
When the fallback was triggered by cmd.exe's 9009 exit, the host is provably
Windows and PowerShell genuinely ran, so its error ('Cannot find path' /
'Access is denied') is the real cause — surface it instead of the misleading
cmd.exe "exec is not recognized" prose. Only the string-heuristic path (a
possible POSIX false positive) still rethrows the original error.
Also: note the system-ssh transport's 8-bit exit-code truncation caveat in the
9009 comment, and add tests for both double-failure paths (9009 -> surface
PowerShell error; heuristic -> surface original).
Co-authored-by: Orca <help@stably.ai>
* test(ssh-browse): guard the ~ -> $HOME PowerShell fallback branch
The tilde expansion in powerShellPathExpression (~ is the default browse path)
was unguarded — collapsing it to a literal would pass the whole suite. Add a
fallback test asserting the decoded script contains $dir = $HOME.
Also tighten the 9009 comment: the locale-independent trigger only holds for a
cmd.exe DefaultShell on the ssh2 transport; a powershell.exe DefaultShell exits
1 (not 9009) and, like the system-ssh transport, relies on the stderr heuristics.
Co-authored-by: Orca <help@stably.ai>
* fix(ssh-browse): emit forward-slash resolvedPath from Windows fallback
Get-Location.ProviderPath returns a native backslash path (C:\Users\alice),
but the renderer's parentPath/joinPath only split on '/': backslash paths make
the browser's 'Up' button a no-op and produce mixed separators when descending.
Normalize the emitted resolvedPath to forward slashes (matching the POSIX
branch) while keeping the native $resolved for Get-ChildItem -LiteralPath.
Update the fallback-test fixtures to the real forward-slash output and assert
the -replace normalization is present in the generated script.
Co-authored-by: Orca <help@stably.ai>
* fix(ssh-browse): root Windows drive paths in the PowerShell fallback
The forward-slash resolvedPath means the renderer rebuilds Windows paths with
POSIX helpers, so it hands back drive paths Set-Location mishandles: the
breadcrumb prepends a spurious leading '/' (/C:/Users -> current drive's root),
and 'Up' from a first-level dir yields a bare drive letter ('C:' is
drive-relative, not C:\). Normalize both to a rooted drive path in
powerShellPathExpression so navigation lands where the user clicked. POSIX,
UNC, and relative paths are left untouched.
Add parametrized tests for /C:/Users and C: -> rooted $dir literals.
Co-authored-by: Orca <help@stably.ai>
* test(ssh-browse): cover combined /C: drive-path normalization
Guards the strip-then-root ordering in normalizeWindowsDrivePath so a future
refactor can't regress the combined leading-slash + bare-drive case.
Co-authored-by: Orca <help@stably.ai>
* fix(ssh-browse): trigger Windows fallback on non-zero exit, not exit 9009
Verified on real Windows OpenSSH + cmd.exe that a rejected POSIX exec
wrapper arrives over SSH as process exit code 1, not 9009 (cmd.exe's 9009
ERRORLEVEL never crosses its process boundary; sshd forwards the process
exit code). Confirmed on both the ssh2 and system-ssh transports.
The old trigger keyed off exit 9009 (dead code) with an English/Spanish
stderr string fallback, so non-English cmd.exe hosts (German/French/
Japanese/etc.) never fell back and directory browsing failed.
Fix: retry via PowerShell whenever the POSIX attempt fails with a
RemoteBrowseError (command ran, exited non-zero) - locale-independent and
covers every cmd.exe locale. Transport errors/timeouts aren't
RemoteBrowseErrors, so dropped connections aren't mis-retried. Pick the
error to surface via the POSIX "command not found" exit 127 (no
powershell.exe means the host isn't Windows, so surface the original POSIX
error). Removes the fragile 9009/stderr-string heuristics.
Tests: correct injected exit codes to the real value (1), add a
Japanese-locale regression test, and lock the retry/no-mask contract.
---------
Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com>
Co-authored-by: Orca <help@stably.ai>
Co-authored-by: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com>