Commit Graph
13638 Commits
Author SHA1 Message Date
Jinwoo HongandNeil 3f8d296b8d fix(macos): preserve terminal permissions across app updates with a small signed helper (#27021)
* fix(macos): preserve terminal privacy identity across app updates

* test(macos): make launchd fixtures portable across host platforms

* fix(macos): probe protected contents and close handles on denial

* fix(i18n): register terminal permission status labels

* fix(macos): fork the daemon when no stable-bundle job can claim the endpoint

A copy, signature, plist or deadline failure before bootstrap, a bootstrap
launchd refused with the job absent, a job whose daemon exited before
answering, and a foreign endpoint all threw out of the launcher. The app then
lost its persistent daemon and fell back to local PTYs, where main forks one.
These now fall through to the existing fork path, which also adopts a foreign
occupant normally instead of as a degraded endpoint. A timed-out bootstrap or
a job still running/unreadable stays fatal, so no second daemon races it.

* feat(telemetry): split daemon origin events by stable-copy launch method

daemon_adopted, daemon_pty_cwd_denied and daemon_pty_cwd_readable gain a
closed-enum launch_method (stable-copy / app-fork / unknown), read from the
spawner path the daemon recorded, so the update fix is measurable per daemon
even when an old forked daemon is adopted across updates. The reliability
gate is a test manifest with no runtime switch, so there is no gate state to
report; spawner_path_class reports the private copy as 'other' alongside
other non-/Applications installs.

* refactor(telemetry): report the stable copy as a spawner path class

The separate launch_method property duplicated what the recorded spawner
path already says. spawner_path_class gains `stable-copy`, classified from
the same path under the private runtime root, and launch_method, its enum
and classifier are removed from all three .strict() daemon origin events.
A deleted copy still reports `missing`.

* refactor(settings): gate terminal-host Full Disk Access on macOS once

The permissions handler already asks for the terminal-host status only on
macOS, so the status function's own platform check and the pane's
'unsupported' branch could never fire. The off-macOS guarantee is now
tested at the handler.

* fix(macos): keep the fork fallback inside the startup PTY gate

The stable launch could run until 55 s after the launch started, so a late
"unavailable" verdict left the fork fallback's own 10 s readiness wait to
finish after the 60 s gate had already moved the app to local PTYs.

The launcher now gives the stable attempt one handoff deadline: the gate
minus the fork's readiness timeout and the 5 s lease/adapter allowance
(45 s). Copy, signature check and plist conversion share one abort signal
that ends early enough for bootstrap and the job's own readiness wait to
meet that deadline; every command otherwise keeps a fixed timeout. An
unavailable verdict past the deadline is reported instead of forking a
daemon that cannot arrive in time. The per-step budget module is gone (its
deletion landed in the previous commit by a staging slip).

The launch body is now two blocks: preparation (plist, plutil, deadline
check) fails as unavailable after removing the copy and job file, and
bootstrap runs in its own try/finally that always deletes the job file.

Retirement is simpler and claims more orphans: one in-flight collection per
process instead of a per-root map and rotating 20-record scan, and the job
record (written before copying) no longer carries a bundle name because
lsof +D scans the whole runtime directory (measured equal cost, same
result on a live copy). A never-submitted copy is retired once its producer
has exited and its job is missing, so a bootstrap timeout or a crash
mid-copy no longer leaves a copy forever; the open-file check still guards
deletion. Collection now starts from the launcher after the attempt.

* docs(macos): ship the daemon launch rules, not the investigation

The signed investigation harness left the tree in the previous telemetry
commit by a staging slip; its results and pinned link stay in the PR. This
trims the reference doc to why the copy and launchd exist, the fallback
states and the cleanup rule, trims the reliability gate to what its
validator needs, and points AGENTS.md at the reference doc next to the
Windows relocation entry.

* fix(macos): hand off to the fork as soon as the stable job exits

A copied daemon refused at exec (for example by Gatekeeper) never answers,
so every fresh start paid the full 10 s readiness wait before the fork
fallback. The readiness loop now reads the job state every 500 ms and
abandons as soon as the job is stopped or missing; a running or
unreadable job is still waited out and never forked beside.

Also drops the dead forkEntryPath the stable launch never read (its
options type now omits it) and un-exports the handoff constant.

* fix(macos): keep daemon copies out of Spotlight and unregister retired ones

Each runtime copy is a full Orca.app, so Spotlight indexed it as an app
and LaunchServices kept a record for it that outlived the copy. A
registered copy claims orca: links and Markdown/CSV files, and becomes
their default handler whenever /Applications/Orca.app is not registered.

New copies go under runtime-*/app.noindex/, which Spotlight skips and which
stops the record LaunchServices made while the bundle was being copied.
Retirement now runs lsregister -u on every bundle in the copy (both the
app.noindex layout and the earlier runtime-*/Orca.app layout) once lsof
shows it unused, then deletes it. A running copy stays registered,
because Local Network access may be resolved through that record.

Collection and stable-copy telemetry match the runtime-* directory, so
copies made before this change are still adopted, classified and retired.

* docs(macos): record the LaunchServices trade-off and the rollback plan

The reference doc now explains why copies sit in a .noindex folder, why
only retired copies are unregistered, and how to turn the copy launch off:
make launchMacDaemonFromStableBundle return null, and keep the retirement
code until no copy-launched daemon can remain, because the old code never
deletes copies.

A test pins that rollback: with the stable launcher returning null, the
launcher forks and still runs the copy collection.

* fix(macos): keep daemon copies registered so Local Network can resolve them

The `.noindex` folder kept copies out of Spotlight and LaunchServices, but
Local Network access is resolved from the signing identifier through
LaunchServices to the executable's Mach-O UUID. After an update that changes
Electron only the copy carries the running daemon's UUID, so an unregistered
copy risks the same Local Network loss main has today. Copies go back to
`runtime-*/Orca.app`; retirement still unregisters each copy before deleting
it, so deleted copies leave no LaunchServices entry. The remaining trade-off:
while `/Applications/Orca.app` is missing, a running registered copy can be
picked for `orca:` links and documents.

* fix(macos): hide daemon copies from Spotlight and tie the job to Orca

Spotlight listed each runtime copy as a second Orca app. New copies go
back under runtime-*/app.noindex/Orca.app, which Spotlight skips.
Retirement and stable-copy telemetry match the runtime-* directory, so
copies in the earlier runtime-*/Orca.app layout still retire and classify
the same way.

Local Network access identifies the daemon by its code signature and
main-executable UUID, not by a LaunchServices record, so an unregistered
copy keeps it. The launchd job now sets AssociatedBundleIdentifiers to
com.stablyai.orca, which is how a job not installed through SMAppService
names the app it belongs to. Copies are never registered explicitly;
retirement still runs lsregister -u before deleting each one.

* feat(macos): package a signed terminal host helper that runs the daemon on the pinned Node

Contents/Helpers/Orca Terminal Host.app carries the pinned Node, the daemon's
out/main JS and this slice's node-pty, signed inside-out as com.stablyai.orca.
afterPack boots it through one PTY round trip, then fails the build on layout,
Info.plist, macOS floor (NODE_RUNTIME_DARWIN_MINIMUM_OS vs Node's minos),
architecture or signature drift.

* ci(macos): cache and prefetch the pinned Node before packaging

Packaging now fetches the darwin Node from nodejs.org, whose download has no
retry. Each mac workflow caches it with the electron-builder downloads, keyed on
the pin, and warms both darwin targets inside the existing retry wrapper.

* feat(macos): launch the daemon from a copy of the terminal host helper, not the whole app

The stable launch now clones Contents/Helpers/Orca Terminal Host.app (~124 MiB
instead of ~628 MiB) and runs its Node on its daemon entry, without
ELECTRON_RUN_AS_NODE. The copy must still match the running app's designated
requirement. A missing helper falls back to the fork, and macOS below the
helper's Node floor forks without copying.

* docs(macos): describe the terminal host helper, its floor and its identity

* fix(macos): bound the terminal host boot check by its deadline and always clean up

A daemon that stayed silent hung packaging past the 20 s deadline; one that closed the
connection left the hello unsettled so Node exited 0 without cleanup; a malformed line threw
uncaught. Every connect, hello and PTY wait now races the deadline, close/error/parse failures
reject, and the daemon and scratch dir are removed on every path.

* ci(macos): cache the pinned Node on its own key with no restore-keys fallback

Sharing the electron-builder cache let old Node archives accumulate through restore-keys.
Also moves release-mac-build's Electron cache comment back onto its step.

* fix(macos): reject a universal slice for the terminal host and pin its name in one test

packageMacTerminalHost turned an unknown arch into darwin-undefined; it now throws through the
shared mac arch mapping. A test asserts the packager and the desktop agree on the helper bundle
and executable names, replacing a Linux/Windows test that could not fail.

* fix(macos): pass the launchd adoption lease its options object after the main merge

* test(macos): accept a reset as the daemon closing the boot-check connection; keep only passing gate commands

* fix(daemon): bump the terminal daemon protocol to v45 so new macOS tabs start in the terminal host helper

* fix(macos): ship the daemon's required packages in the terminal host and boot it outside the build tree

* style(macos): use a template literal in the terminal host resolution check

* fix(macos): derive the terminal host's packages from the daemon's own module graph

* fix(macos): treat any non-literal daemon load as computed, follow directory loads, and keep nested dependencies

* refactor(macos): move terminal-host telemetry, Settings status and Helper folder strings to a follow-up

Keeps this change to the update-safe terminal host itself. The follow-up restores:
- the `stable-copy` spawner path class in daemon adoption telemetry;
- the per-terminal-host Full Disk Access row in Settings and its daemon RPC;
- writing folder usage descriptions into the app's Electron Helpers for the fork fallback.

The helper still takes its Desktop, Documents and Downloads descriptions from
`macos-folder-usage-descriptions.cjs`, so that constant stays.

* fix(i18n): restore en.json to this branch's base, not newer main

The previous commit took the file from a newer main, which pulled unrelated strings into this PR.

* refactor(macos): publish one terminal-host copy per helper build under a fixed launchd label

Name the copy after the helper's CDHash, reuse it when present, and
publish a new one by cloning into a staging dir, verifying it once
against the app's designated requirement and renaming it into place.
One launchd label per profile and protocol replaces per-launch labels,
so job.json, producer pids and per-label collection go away. Retirement
deletes any other copy lsof shows unused. The stable launcher returns
null for every fork fallback and logs its reason.

* fix(packaging): copy terminal-host dependencies from where Node resolves them

A nested package's own dependencies were never walked, so a hoisted
dependency of a nested package was left out of the helper. Resolve each
dependency through the node_modules lookup Node performs from its parent
and copy it to the same relative place, unless a copied package already
carries it.

* fix(packaging): fail release and CI mac builds that cannot boot a terminal-host slice

An arm64 runner without Rosetta skipped the x64 helper's PTY boot check
and shipped the slice untested. Release channels and CI now fail there;
local cross-arch builds still skip with a log line.

* docs(macos): describe the per-build terminal-host copy and its fallback reasons

* docs(macos): keep the codesign command on one line

* test(macos): type the launchd test state without an assertion

* docs(macos): name what the login-session flag gates in the stable launcher

* fix(macos): wait for launchd to drop a booted-out terminal job before reusing its label

---------

Co-authored-by: Neil <neil@stably.ai>
2026-10-11 14:54:36 -04:00
Neil e7da462dbf [CI] Run focused checks for Mobile CI tool changes (#27532) 2026-10-11 11:49:05 -07:00
OrcaWinandm4air 3359fd2bfc refactor(ipc): worktree handlers are local or runtime only (#27528)
* refactor(ipc): worktree handlers are local or runtime only

* refactor(runtime): explain the route witness on removal listing

* test(runtime): pin the SSH create refusal and drop the deleted branch's reliability gap

* test(runtime): drop a needless cast in the SSH create refusal test

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
2026-10-11 11:48:57 -07:00
Jinwoo Hong 448b9f6fe9 refactor(layout): add the pane start, exit, first-surface and rename rules (unused) (#27432)
* refactor(layout): add the pane start, exit, first-surface and rename rules

Pure rules in the shared layout model, with no caller yet:
- startPane leaves a bound pane, or one whose start is in flight, alone;
- a process exit retires its pane on code 0, keeps today's keep cases as
  exited, and only unbinds on an unproven exit or attach absence;
- a per-pane last outcome, cleared by startPane and restartPane;
- createTerminalTab/openAgentSessionTab `initial: restore | activation`;
- editor tabs and their file records follow a renamed file.

* fix(layout): keep a newborn pane's exit whatever its code, as today

* fix(layout): keep a split by today's facts and report renamed tab ids
2026-10-11 14:38:56 -04:00
Jinwoo Hong 73c53311dc refactor(terminal): move the mounted-terminal registry out of the graph file (#27427)
registerRuntimeTerminalTab, hasRegisteredRuntimeTerminalTab,
focusRuntimeTerminalSurface and the mounted-tab maps now live in
runtime/mounted-terminal-tab-registry.ts. The runtime graph subscribes to
registry changes and still schedules a graph sync on mount, unmount and an
explicit leaf focus, exactly as before. No behaviour change.
2026-10-11 14:26:22 -04:00
Jinwoo Hong 7ce15106f3 fix(relay): keep verifying host tokens from the last served keys while auth is down (#27490)
* fix(relay): keep verifying host tokens from the last served keys while auth is down

jose refetches the relay JWKS once its cache is 10 min old and rejects the token when that
fetch fails, so an auth outage turned every still-valid host token into a 4401. The cell now
falls back to the last set auth served for up to an hour, skips refetching for 30 s after a
failure, and still rejects any key that set never had. A fresh set replaces it as soon as auth
answers, so a rotation takes effect at the first successful fetch.

* fix(relay): bound stale relay keys at 30 min and document the emergency purge

Thirty minutes still covers a full auth outage (no token the stale keys could accept outlives
~16 min of one) and halves how long a rotated-out key stays trusted on a cell kept from auth.
A restarted process holds no set and refuses host tokens until a fetch succeeds; the ops doc
names that as the purge after a signing-key leak, and a test pins the fail-closed start.

* test(relay): pin the 30 min stale relay key bound (29 min accepts, 31 min rejects)
2026-10-11 14:25:29 -04:00
Jinwoo Hong 4af35473eb fix(relay): a resumed host control adopts the token its hello proved (#27487)
* fix(relay): a resumed host control adopts the token its hello proved

A rebind replaced the socket, lease and epoch but kept the predecessor's token claims, so a
host that resumed after its token lapsed (auth down past expiry plus grace) was closed 4401
"relay authorization expired" at the next heartbeat, 15 s later, and every resume repeated it.
The rebind now takes the hello's verified claims when they carry a later expiry, and a hello
that speaks for another profile or organization starts a new generation instead of resuming.

* fix(relay): a refresh never lowers the held expiry and closes only its own socket

An auth refresh now keeps the later of the held and refreshed expiry, the rule a resume uses.
A refused refresh, or one that meets an idle cutover, closes the socket it arrived on: after a
rebind, session.socket is the successor, which proved itself and must not be closed for its
predecessor's late frame. Adds an organization-mismatch resume test beside the profile one.

* test(relay): a superseded socket's refresh during an idle cutover closes only that socket
2026-10-11 14:25:11 -04:00
Jinwoo Hong 735e6d6c5d fix(relay): let a host leave a dead reserve cell without an operator fence (#27458)
* fix(relay): let a host leave a dead reserve cell without an operator fence

A host whose reserve home cell died got 503 relay_home_cell_unavailable until the
cell came back: the database path demands a committed fence for every capped cell.
On its own /v1/assign, a host now moves off a reserve cell (Postgres records it as
reserve) when the cell's heartbeat is stale and this director's seat-feed polls have
gone unanswered for 45 s, unless more than a third of the polled cells look the same.
Sweeps, migration pins and capped db cells keep the fence.

* fix(relay): guard the dead-cell verdict by region and sample size, and floor the move on the map

- No verdict when half or more of the dead cell's own region fails with it (a cross-region
  partition), more than a third of the fleet does, or fewer than 6 cells are polled. A withheld
  region or fleet verdict logs orca_relay_dead_cell_verdict_withheld, which alerts.
- The move needs the step-5 plan's epoch floor, so it mints above every seat the map knows;
  without the plan the fence is kept. A test revives the old cell's seat above the row and checks
  that the ledger never writes the row back.
- The move's log line carries the fleet and region counts.

* fix(relay): count only feed cells and young streaks in the dead-cell guards; gate the alert

- Denominators hold only cells that served this director a feed page and have capacity: legacy
  rows answering 404 no longer fill the 6-cell sample or dilute the fleet and region ratios.
- A peer counts as failing for the guards on any current streak (a failure in the last 5 s), so a
  staggered partition cannot let its first cell cross 45 s while its neighbours read healthy.
- Every withheld verdict is logged, sample included (a cell missing from the feed cells is one).
- Tests: ready=0 and enabled=0 with failing polls keep the fence (drain then death is fenced).
- The withheld metric and alert are gated by relay_dead_cell_verdict_alert (default false, on in
  staging); the alert docs now describe poll failures, not heartbeats.

* fix(relay): always sample the dead-cell candidate, so a restarted director can still judge it

A director that restarted after a reserve cell died never received a feed page from it, so the
cell was never in the sample and its verdict stayed withheld as 'sample'. The candidate now always
counts when it has capacity (the store asks only about a cell Postgres records as reserve, and only
feed images run reserve); peers still count only once they have served a feed. Tests: a restart
during a lone outage decides, a partition begun before the restart withholds by region, and the
capacity filter. The Terraform comments now say to apply the metric first and the policy minutes later.
2026-10-11 14:24:52 -04:00
Jinwoo Hong 1bf36692e9 fix(relay): let the staging admission workflow register and promote c5-c7 (#27491)
The staging admission config hard-wired staging-gce-c4, so new staging cells
had no reviewed path into the admission selector. Add c5-c7 as a second
staging wave at their US shape; production cell shapes are unchanged.
2026-10-11 14:24:34 -04:00
Jinwoo Hong ea61d1c03e docs(relay): director Cloud SQL sidecar enablement and rollback (#27461)
* docs(relay): director Cloud SQL sidecar enablement and rollback, as run on staging

The 0% apply revision is not started by Cloud Run, so the first sidecar boot is the deploy's
rollback candidate. A new log-based metric can take minutes to appear, so its alert policy
can fail the first apply. Both observed on staging 2026-10-11.

* docs(relay): production sidecar enablement needs the tfvars PR, a passed drill and the rollout lease
2026-10-11 14:24:15 -04:00
Jinwoo Hong e9dc9534d7 fix(relay): cell-flags tool refuses reserve without readinessLocal=true (#27460)
A reserve cell's dead-man is fed only by director polls through the load balancer. With SQL
readiness the balancer drops the cell during a database outage, the polls get 503
failed_to_pick_backend, and the cell trips (staging drill 2026-10-11, c2 at 11:47:59Z).

admitMode=reserve now needs readinessLocal=true in the object being written, already there
or set in the same write, and a reserve cell refuses readinessLocal=false unless the same
write flips it back to db.
2026-10-11 14:23:56 -04:00
Neil 48d76638a3 [CI] Speed up Mobile test execution (#27521)
* [CI] Speed up Mobile test execution

* [CI] Check rebuild watch registrations directly
2026-10-11 11:22:31 -07:00
Jinjing 45de8d0791 Theme Mermaid dark diagrams with the app's chart colors (#27398)
The legacy Mermaid dark theme drops color from flowcharts and uses light Gantt surfaces. Dark previews now use the `base` theme with palette values read from the app's CSS tokens. Chart tokens can resolve to oklch(), which Mermaid does not accept, so colors are converted to hex through a canvas. Tests cover Gantt label contrast and distinct pie colors.
2026-10-11 11:16:41 -07:00
Brennan Benson ac529511b9 A closed chat tab stays closed instead of coming back on the right (#27205)
* fix(native-chat): a closed chat tab stays closed while the host is still closing it

Closing a chat tab removes it from the window at once. A tab-list frame the
host publishes before it has handled the close (a working chat's status
update, for one) still listed the chat, and the window appended it back at
the end of the strip until the host's own removal arrived.

The window now leaves out a chat whose close it has not heard back on, using
the in-flight close record it already keeps, and re-reads the host's tab list
once the close settles so a chat the host kept comes back.

* fix(native-chat): hide a closing chat tab through the existing close intent

Record a close intent for agent-session:<id> under the chat's host when a chat
tab close begins, so a host frame sent before the host handles the close cannot
re-add the chat at the end of the strip. The intent lifts when a host frame
stops listing the chat, or by its TTL. Only a failed host close clears it and
re-reads that worktree at the version already applied, so the chat the host
kept comes back.

Replaces the close-owner gate and the re-read after every close.

* fix(native-chat): end a chat close intent even when no host frame follows

Re-read the closed chat's worktree after every settled close, not only after a
failure, so a close the host had nothing new to publish for still ends its
intent; otherwise the same chat reopened within the intent's 20 s limit stayed
hidden. Skip the intent for floating-panel chats, whose frames are never
applied. Share one one-worktree local refresh with the Session History reopen
path. Reset close intents between tests in the reopened-tab e2e file, whose
harness tears the host down before a close's follow-up re-read lands.

* fix(native-chat): re-read after a successful chat close only for paired servers

This machine's host emits its removal frame before the close answers, and the
window applies it, so a local chat re-reads only when the close fails. A paired
server's frames travel apart from its answer, so, as for its terminal closes,
every close is followed by a read, and that read no longer joins a tab-list
request that began before the close. Floating-panel chats record no intent and
get no read.

* fix(native-chat): pin a chat close intent to the pairing the close began under

Record and clear the intent with one owner captured at close start, as paired
terminal closes do, so re-pairing a server mid-close cannot miss the clear, and
pass that pairing to the paired re-read.
2026-10-11 11:13:51 -07:00
Brennan Benson 7dfa4d6e54 fix(native-chat): a new chat's model and effort pickers match their first frame before the agent starts (#27031)
* fix(native-chat): a new chat's effort pill keeps its first-frame value before the agent starts

A chat with no model pick runs the catalog's default model at that model's
default effort. The at-rest options answer now says so for every agent, not
only those whose live child reports the model's default, so the effort pill
no longer blanks and then jumps when the agent starts.

* fix(native-chat): read a chat's at-rest options for the folder it runs in

The at-rest options read asked the model catalog with no workspace, so it
always named the account's default model and effort, even in a repo whose
own agent config could pick another. The new chat's first frame, read for
that repo, names nothing there. The at-rest read now asks for the session's
own folder, so both frames agree on the model and the effort.

* fix(native-chat): scope the at-rest options read only for clients that take no model

A client that needs a model in every options answer (mobile today, older
desktops) refuses an answer naming none, and loses that read's commands with
it. Only a client advertising the optional-model capability gets the answer
for the chat's own folder; any other keeps today's account-default answer.

* test: run the at-rest workspace-scope RPC test in the Node SQLite project

* test: build catalog listings so the fixtures compile before and after main drops the tier map

* fix(native-chat): one at-rest rule — the chat's folder decides its default for every client

The folder-scoped catalog read now decides the default for every client. Where
it names one, the answer names that model and its effort. Where it names none,
a client that takes a model-less answer gets none, and one that needs a model
gets the account default model with no effort: exactly what it got before.

The reader is now required, and the first frame and the at-rest read share one
rule for which locations name a local folder.

* fix(native-chat): keep test callers of the host's options read untouched

Only in-process callers omit the reader at the host's entry, and they read
as the RPC gate treats an in-process caller. The shared read itself still
requires one. This leaves main's newer test call sites unconflicted.

* refactor: one native-local location check for Claude, Codex and the at-rest folder read
2026-10-11 10:46:48 -07:00
OrcaWinandm4air 0df25b7ac9 refactor(pty): delete relay-fed SSH output intake and projection threading (#27515)
Nothing in production feeds the SSH PTY output intake: its only data and exit
entry points were called from tests, and the SSH PTY provider that would have
fed it is gone. Delete the intake, its source-obligation ledger, model
admission, projection publication and source-ack plumbing, and strip the
projection ids and source-credit bookkeeping they threaded through the local
renderer delivery path (accept, pending, flush, payload, accounting, exit,
session). Local terminal delivery is unchanged.

Because the intake also installed the runtime's remote source-range hooks,
this desktop host now answers a terminal.subscribe that asks for
ackOutputSourceRanges the way every orcad host already does: without the
capability, so the client uses the byte-window ACK. No source ranges were
ever attached to local output.

Co-authored-by: m4air <m4air@Mac.localdomain>
2026-10-11 10:25:42 -07:00
20f8cb02c1 feat(mobile): verified https pairing links; custom scheme carries no secret (#27475)
* feat(mobile): verified https pairing links; custom scheme carries no secret

The desktop's copy-link now emits https://www.onorca.dev/pair#<code>, with the
code only in the fragment. Orca Mobile claims that link as a verified app link
and refuses any pairing code delivered through orca://, which any app can claim;
orca://pair alone opens the scanner with an explanation. In-app QR scan and
paste still accept the custom scheme, so older desktops and phones keep pairing.

The confirm screen shows the desktop address and host key fingerprint before
pairing. config/verified-links/ holds the apple-app-site-association,
assetlinks.json and fallback page to deploy on www.onorca.dev, with a test that
keeps them, mobile/app.config.js and the link builder in agreement.

Part of #17397.

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

* fix(mobile): refuse pairing codes nested in or normalized out of unverified links

The router unwraps expo-development-client ?url= links and normalizes dot
segments after +native-intent runs, so an orca:// wrapper could still deliver a
code to the confirm screen. Refuse any unverified link carrying code= at any
encoding depth, and match pairing routes on decoded, dot-free segments.

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

* fix(mobile): escape the apostrophe in the refused-link notice

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

* fix(mobile): decode escapes byte by byte before refusing nested pairing codes

An invalid UTF-8 escape made the whole escape run fail to decode, hiding an
adjacent encoded &code= that the router still decodes leniently.

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

* fix(mobile): decode nested pairing links to any depth before refusing codes

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

* fix(mobile): ignore URL-stripped whitespace when refusing nested pairing codes

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

* refactor(mobile): decide pairing-link refusal on the router's own parse

Stop re-implementing URL decoding. The guard now runs expo-router's own
extractExpoPathFromURL and parseUrlUsingCustomBase, the functions its linking
applies after +native-intent, and refuses any link from which the router would
read a `code` param or reach a pairing route with a payload. A link the router
cannot parse is refused. Release builds refuse expo-development-client
wrappers outright. A property-style test walks wrapping depths, encoding
layers, stripped control characters, case and unicode, and asserts that the
guard never passes on a link from which the router would read a code.

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

* fix(mobile): match pairing routes on the top-level segment only

A host link with a segment named pair was sent to the scanner.

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

* test(mobile): pin line-terminator fragment links as refused

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

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Claude <noreply@anthropic.com>
2026-10-11 10:06:05 -07:00
Neil 82ae617609 [CI] Wait for delayed Codex answers before test cleanup (#27516) 2026-10-11 10:05:56 -07:00
OrcaWinandm4air 6a303be248 refactor(main): agent detection, automations, skills, AI vault and ephemeral VM drop SSH provider arms (#27511)
* refactor(main): agent detection, automations, AI vault and ephemeral VM drop SSH provider arms

* refactor(skills): refuse SSH skill destinations at the branch and delete the relay skill services

* test(preflight): clear the local-detection mock before the SSH refusal cases

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
2026-10-11 09:54:46 -07:00
Neil 8622b0ccea [CI] Remove a simulated terminal credit correctness check (#27507) 2026-10-11 09:33:03 -07:00
OrcaWinandm4air a4a96fee70 refactor(pty): delete the SSH PTY provider and its registry slot (#27499)
* refactor(pty): delete the SSH PTY provider and its registry slot

Nothing in production registers an SSH PTY provider, so the registry's SSH
map was always empty: every SSH lookup answered undefined or threw the
'No PTY provider for connection' error. Delete the provider stack, its
consumer-session and source-recovery contracts, the registry maps and the
getSshProvider dependency, keeping each caller's existing result for an
absent provider. The registry now dispatches only 'local'; any other host
throws PtyHostNotDispatchableError, which keeps the renderer-matched
message for SSH ids and is never read as a gone or exited PTY.

* test(pty): keep the no-provider SSH routing cases in local-only form

* test: reword a comment naming the deleted SSH PTY provider

* test: drop an import the rebase left unused

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
2026-10-11 09:23:28 -07:00
OrcaWinandm4air 9c80a61199 feat(daemon): the host keeps its own record of old-client restores it refuses (#27496)
* feat(daemon): keep the host's own record of refused legacy restores

* feat(daemon): the host keeps its own record of old-client restores it refuses

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
2026-10-11 09:20:57 -07:00
OrcaWinandm4air 47b94ef64a fix(daemon): keep tweetnacl out of the relocated Windows daemon-host graph; guard daemon externals at build (#27504)
Co-authored-by: m4air <m4air@Mac.localdomain>
2026-10-11 09:04:55 -07:00
Neil 11cf21e492 [CI] Remove duplicate terminal backlog test replays (#27503) 2026-10-11 08:46:46 -07:00
OrcaWinandm4air 215ee3e625 refactor(runtime): runtime git and file callers drop SSH arms (#27501)
Co-authored-by: m4air <m4air@Mac.localdomain>
2026-10-11 08:23:12 -07:00
OrcaWinandm4air 6ffbdcd875 feat(runtime): device-key authentication and per-installation key migration (host) (#27493)
* feat(runtime): device-key authentication and per-installation key migration (host)

* fix(runtime): corrupt registry is never a revocation verdict; keyed sockets dispatch without a token; forks keep their Relay binding

* fix(runtime): keep an unparseable device registry aside; an unlisted key is never 'revoked' while it exists

* fix(runtime): a set-aside corrupt registry keeps verdicts doubtful even when the registry file is missing

* test(runtime): registerDeviceKey reaches the bound socket through the dispatcher for runtime and mobile rows

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
2026-10-11 07:51:33 -07:00
OrcaWinandm4air 6e9dc090a5 feat(daemon): one automatic-restore budget per execution host (#27492)
* refactor(daemon): keep the gate's host share in its own module

* test(daemon): pin that an observing desktop's restores count against serve's budget

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
2026-10-11 07:45:06 -07:00
OrcaWinandm4air 9c59878fbe refactor(terminal): panes have two transports, local or remote runtime (#27489)
* refactor(terminal): panes have two transports, local or remote runtime

* fix(terminal): keep local-only startup gates off unconverted SSH panes

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
2026-10-11 07:27:21 -07:00
OrcaWinandm4air 3835a42480 refactor(rpc): one dispatcher (#27486)
Fold the streaming dispatcher, the unary invocation helper and two
single-importer helpers into dispatcher.ts so request/response and
streaming-unary share one unary path. No behaviour change.

Co-authored-by: m4air <m4air@Mac.localdomain>
2026-10-11 07:16:30 -07:00
OrcaWinandm4air c414640aef refactor(runtime): paired sockets authenticate to a device principal (#27480)
* refactor(runtime): paired sockets authenticate to a device principal

* chore: format and comment touch-ups

* test(mobile): give the launch-authority socket fixture its principal

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
2026-10-11 07:16:14 -07:00
OrcaWinandm4air ce028b15e5 feat(daemon): cap terminals per execution host (#27488)
* feat(daemon): cap terminals per execution host

* fix(daemon): never let a ledger cleanup throw into a spawn

* fix(daemon): recreate the host terminal ledger dir after a /tmp cleaner

* test(daemon): drop a default-parameter case from the ledger dir test

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
2026-10-11 07:12:14 -07:00
Neil bbac3af5fc [CI] Clean up runtime test callbacks (#27483) 2026-10-11 06:51:00 -07:00
Jinwoo Hong 741f95356f infra(relay): declare staging cells c5-c7 at c2's shape for the dead-cell guard e2e (#27476)
Six feed cells, five in us-central1, so one dead US reserve cell is 1/6 of the fleet and 1/5 of its
region and the dead-cell guards in #27458 can decide. Staging only; nothing is applied by merging.
2026-10-11 09:43:58 -04:00
OrcaWinandm4air 7f30a7aa35 fix(mobile): direct connections use v2 framing and never fall back once v2 is seen (#27473)
* fix(mobile): direct connections use v2 framing and never fall back once v2 is seen

* test: drop casts in direct v2 tests

* fix(mobile): count v2 handshake failures against the auth retry budget; latch only after host auth

* fix(mobile): pick direct framing at open, recheck the latch before legacy auth, and keep old hosts off the auth budget

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
2026-10-11 06:41:39 -07:00
Jinwoo Hong 265e4d495c chore(relay-staging): declare staging C4 on image E so the director can deploy it (#27462)
The staging director deploy binds its digest to staging-gce-c4's checked-in image. E is main
2bdb55350d (#27405 event-loop max, #27406 sidecar), already mirrored to staging. C4 itself
changes only through its own reviewed refresh workflow.
2026-10-11 09:35:55 -04:00
OrcaWinandm4air 08b65edc5c feat(serve): let the owner resume paused automatic restores (#27459)
* feat(serve): let the owner resume paused automatic restores

The terminal daemon now answers automaticWorkStatus and
resumeAutomaticWork. Resume clears a recorded same-boot crash pause or
a pressure pause, writes the cleared state durably, and wakes restores
that were waiting. The runtime exposes runtime.automaticWork.status and
runtime.automaticWork.resume (host-admin; advertised as
runtime.automatic-work.v1), the CLI adds 'orca serve restore resume',
and a sleeping pane paused by its host offers 'Resume automatic
restores'. A daemon that predates the requests reports no gate.

* fix(serve): a stale daemon cannot resume, and an old one says so

Resume now re-reads endpoint ownership right before it runs, so a
daemon replaced by a successor cannot rewrite the successor's run
marker. A gate-bearing daemon from before resume existed is reported as
'terminal host predates resume' instead of 'no gate'; the CLI says to
run the command again after the terminal host restarts, and a sleeping
pane still resumes when the user asks for it.

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
2026-10-11 06:31:43 -07:00
OrcaWinandm4air ec14775f93 refactor(providers): an ssh: route carries no provider and throws one typed error (#27477)
Co-authored-by: m4air <m4air@Mac.localdomain>
2026-10-11 06:25:41 -07:00
e9279b613e refactor(ssh): drop the legacy-relay bridge and the relay branch of the terminal gate (#27471)
The terminal gate's relay lister always resolved to null in production (no SSH PTY
provider is ever registered), so its relay and previous-relay branches, the legacy
relay route/router/routing, the previous-relay census and the once-connected
re-check could never run. The gate now takes only the host census; census-only
verdicts are pinned by a characterization test written before the change.

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Claude <noreply@anthropic.com>
2026-10-11 06:12:22 -07:00
8bc4cef855 fix(serve): decide a duplicate or doomed Electron serve before exec (#27472)
On macOS a second Electron main aborts inside _RegisterApplication
before any Orca JavaScript can apply the one-Orca-per-profile rule, so
supervisors and agents retrying 'orca serve' loop on SIGABRT (#14541).

The CLI now checks the profile before it execs Electron serve. A runtime
that answers, or whose socket accepts, is live; one whose socket neither
accepts nor refuses, or that refuses this CLI's credentials, is
unverifiable; both refuse with exit code 3, the single-instance code
systemd units already key off. Only a definitive refusal (ENOENT or
ECONNREFUSED) lets it start. A fresh profile keeps the old synchronous
launch. A macOS startup abort is remembered per environment, so a later
separate 'orca serve' from the same launchd session refuses for 30
minutes with exit 78 instead of aborting again; --force overrides, and
a serve that reaches readiness clears it.

Adapted from #14768; the Launch Services diagnostic wording and the
exit code 3 contract come from #14542.

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com>
Co-authored-by: thiagomsoares <5190162+thiagomsoares@users.noreply.github.com>
2026-10-11 06:10:50 -07:00
OrcaWinandm4air d3417b257f feat(terminal): declare restore intent and keep deferred panes sleeping (#27455)
* feat(terminal): declare restore intent and keep deferred panes sleeping

The visible pane's agent restore is sent as user-resume and hidden ones
as automatic-restore, to hosts that advertise terminal.spawn-intent.v1
(and always on the local spawn path). When the host keeps a restore
asleep, the pane shows 'Sleeping — click to resume' with the reason,
keeps a sleeping record so the session stays listed and resumable, and
resumes as the user's own when it is opened or clicked. A deferred pane
is not remounted, which would only be deferred again.

* refactor(shared): one restore spawn intent type

* fix(terminal): a deferred pane owns its session against wake sweeps

A deferred pane has no PTY, so the activation and mobile wake sweeps
read its unfinished record as unowned and could launch the session in
a new tab while the pane still held a resume. The sweep now treats a
deferred pane as the owner, the remembered record is a quit-style
record a sweep never discards, and a retry declines to resume a session
that is already live in another pane.

* fix(terminal): keep a deferred pane's claim through its sweep and retry

The wake sweep now leaves a deferred pane's record alone, since that
pane's own queued resume matches it and is not a duplicate. A retry
keeps the pane registered as the session's owner until its PTY binds,
and declines to resume when another tab already has the same session
queued.

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
2026-10-11 05:59:46 -07:00
68c46fa07f fix(updater): install macOS orca serve updates instead of waiting on Squirrel forever (#27466)
* fix(updater): let supervised macOS serve installs stage Squirrel during install

Supervised `orca serve` keeps autoInstallOnAppQuit off, so electron-updater's
MacUpdater hands the download to Squirrel.Mac only inside quitAndInstall. The
updater, however, held the status at "downloading 100%" and gated quitAndInstall
on Squirrel having already staged the bundle. Neither could happen first, so
every macOS serve update stalled at macos_waiting_for_squirrel, remote installs
failed with remote_update_not_downloaded, and a local install request timed out
and quit without installing.

In supervised serve mode, report the download as installable and let the install
proceed to the handoff and native quitAndInstall. The install commits and arms
the exit watchdog when Squirrel signals it has staged the bundle; a staging
failure still recovers through the existing pre-commit error path.

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

* fix(updater): hold destructive install prep until Squirrel stages the serve bundle

The supervised macOS install now calls electron-updater's quitAndInstall before
Squirrel has staged anything, and that staging completes asynchronously. Three
consequences needed handling:

- PTY cleanup and close-listener removal ran before staging, so a Squirrel
  failure recovered the server with its in-process sessions already killed.
  They now run when Squirrel reports the bundle staged, together with the
  commit and exit watchdog.
- Any updater 'error' during staging (for example a background check) failed
  the handoff while MacUpdater's pending listener would still quit and install.
  While staging is awaited, only Squirrel's native error ends it.
- Nothing bounded a Squirrel that never answers. After two minutes the install
  recovers through the existing pre-commit failure path and the server keeps
  serving.

isMacStagingDeferredToInstall is now darwin-only, so Linux supervised installs
keep their immediate commit.

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

* fix(updater): report a slow Squirrel staging instead of abandoning the install

Recovering the install on the staging timeout reset Orca's state and failed the
supervisor handoff, but electron-updater's MacUpdater keeps its own completion
listener. A Squirrel that finished after the timeout still quit the app and
installed, now with a failed handoff, so the supervisor skipped the replacement
spawn.

The timeout now only reports a non-retryable error status and records the slow
staging; the handoff and in-flight install stay live so a late staging commits
and relaunches normally.

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

* fix(updater): commit a staged serve install before its session cleanup

Squirrel's update-downloaded listeners run in registration order, and Orca's
runs before the one MacUpdater adds to quit the app. The staged-install commit
ran PTY cleanup first, so a throwing cleanup callback skipped the commit, the
exit watchdog, and MacUpdater's quit, leaving the install stuck in progress.

Commit and arm the watchdog first, then run cleanup under the same
post_commit_cleanup_failed handling performQuitAndInstall already uses.

The tests now register MacUpdater's persistent quit listener per unstaged
install, so a retried install is followed through completion.

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

* test(updater): pin the MacUpdater staging behavior supervised serve installs rely on

The supervised macOS serve install assumes electron-updater's MacUpdater starts
Squirrel staging only inside quitAndInstall when autoInstallOnAppQuit is off,
and quits the app once staged when autoRunAppAfterInstall is off. Every updater
suite mocks electron-updater, so run the installed MacUpdater methods directly;
an upgrade that changed either behavior now fails here instead of silently
restoring the deadlock.

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

* test(updater): adapt the serve staging tests to current main

Main's macOS quit-guard test predates the staging callbacks this change
adds, so it now passes them, and the new install test builds its window
without a type assertion.

Co-authored-by: jbamlee <dev.jbam@gmail.com>

---------

Co-authored-by: jbamlee <dev.jbam@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: m4air <m4air@Mac.localdomain>
2026-10-11 05:57:43 -07:00
OrcaWinandOliver Mee eade6da6ff fix(updater): remove false manual-update enablement promise (#27464)
The legacy remote-server help text promises that one manual update enables remote updates, even when the server reports its updater is unavailable. Replace the promise with accurate host-side update guidance in every supported locale and cover the fallback and specific help branches.

Refs #22261

Co-authored-by: Oliver Mee <102673257+oliver-mee@users.noreply.github.com>
2026-10-11 05:57:24 -07:00
5d7f48e00b fix(serve): keep the isolated profile off the login keychain (#27463)
* fix(serve): keep the isolated profile off the login keychain

The E2E/pairing runtime hands the app a fresh HOME, so there is no login
keychain for OSCrypt to read. On the serve path the secret-protection report
runs inline before readiness and probes safeStorage on the main thread, where
Chromium's keychain lookup creates the item and waits on an authorization
reply that never arrives, so `orca serve` never advertises (#25453).

The sidecar already launches with --use-mock-keychain for exactly this
profile; share that decision and give the foreground serve process the same
switches.

* test(serve): read spawn args without type assertions

Co-authored-by: Paolo <paovalletta@hotmail.it>

* fix(serve): only an isolated userData profile drops the keychain

The sidecar always runs on a fresh profile, so either E2E variable is
enough there. The foreground serve process opens the selected profile's
sealed secrets, and a HOME override alone still selects the user's real
profile, so it now needs ORCA_E2E_USER_DATA_DIR.

Co-authored-by: Paolo <paovalletta@hotmail.it>

---------

Co-authored-by: Paolo <paovalletta@hotmail.it>
Co-authored-by: m4air <m4air@Mac.localdomain>
2026-10-11 05:57:19 -07:00
BingZandm4air b4aec8a896 fix(sidebar): stop a removed folder workspace from leaving a blank row (#26376)
* fix(sidebar): stop a removed folder workspace from leaving a blank row

A folder-project workspace exists only as its worktreeMeta row. When it is
deleted while a terminal is still bound, the renderer keeps the row until its
teardown runs, and a PTY reattach in that window stamps lastActivityAt through
worktrees:updateMeta. setWorktreeMeta then minted a default row with an empty
name, which the sidebar listed under the project name and which came back after
every delete. The metadata IPC now refuses to create a row for a folder
workspace instance that no longer exists.

Fixes #22712

Claude-Session: https://claude.ai/code/session_01XZXEdbC4RKp288k2gBzLUW

* fix(sidebar): also refuse runtime writes to a removed folder workspace

Move the existence check into a shared predicate that matches any same-id
folder owner, so an owner at a different path on another host cannot bypass
it, and apply it to the runtime's worktree.set path, where a resolution
snapshot taken before the removal still returns the workspace.

Claude-Session: https://claude.ai/code/session_01XZXEdbC4RKp288k2gBzLUW

* test: type the removed-folder-workspace fixture meta for the anti-slop lint

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
2026-10-11 05:39:31 -07:00
github-actions[bot] 3b49ee40db Update README downloads badge 2026-10-11 12:38:59 +00:00
OrcaWinandm4air c9a9ad33b8 test(e2e): one managed-host setup path and skip gate for the ssh-orcad specs (#27465)
Co-authored-by: m4air <m4air@Mac.localdomain>
2026-10-11 05:35:50 -07:00
OrcaWinandm4air 10d5108671 feat(runtime): accept a declared spawn intent from paired clients (#27452)
* feat(runtime): accept a declared spawn intent from paired clients

Paired clients can now say whether a terminal restore is the pane the
user is looking at (user-resume) or a hidden one (automatic-restore).
The host forwards the intent to its fork gate, advertises
terminal.spawn-intent.v1 so clients only send it to hosts that accept
it (terminal.ensureAgentSession params are strict), and reports a
deferred fork as terminal_spawn_deferred with its reason.

No client sends the field yet; that lands next with the sleeping-pane
handling.

* fix(daemon): a claimed agent resume cannot declare itself interactive

Interactive forks skip the gate, and paired clients can now declare
intent, so the host keeps inferring from the claim when a request
says interactive.

* fix(runtime): clients may only declare restore intents

A daemon kept running across an update still lets a declared
interactive request skip its gate, even for a claimed resume. The RPC
and local IPC now accept only user-resume and automatic-restore, and
the daemon request boundary turns a claimed interactive into an
automatic restore.

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
2026-10-11 05:34:23 -07:00
OrcaWinandm4air bdc0921718 test(e2e): one posixShellQuote; docker-ssh-host fixture names (Phase 3.99 0.11b) (#27449)
* test(e2e): one posixShellQuote; docker-ssh-host fixture names

* test(e2e): leave the main-failing stall-recovery spec untouched

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
2026-10-11 05:15:31 -07:00
OrcaWinandm4air 4468a5375f fix(terminal): phone wake reaches mounted hibernated agent panes (#27450)
The pane listener subscribed to 'orca:wake-hibernated-agents-worktree' while the
dispatcher fires WAKE_HIBERNATED_AGENTS_WORKTREE_EVENT ('orca-wake-...'), so the
in-place wake never ran. Extract the listener and bind it to the shared constant;
the dispatcher test now drives the real listener instead of a stand-in.

Co-authored-by: m4air <m4air@Mac.localdomain>
2026-10-11 04:17:28 -07:00
Neil 6d51651711 [CI] Remove copied probes and advance queued-card test waits (#27447) 2026-10-11 03:59:07 -07:00