Commit Graph
8145 Commits
Author SHA1 Message Date
Brennan BensonandMerge Sim 39cbc68f16 fix(native-chat): honor structured routing with saved options (#19040)
* fix(native-chat): keep saved options on structured route

* fix(native-chat): seed structured session options

* fix(native-chat): preserve create wire compatibility

* fix(native-chat): keep create replayable across an option change

Seeding host-resolved options into the attach fingerprint put a mutable
value into the durable operation identity. A create whose outcome was
unknown, retried under the same operation id after the user reselected a
model, re-resolved different options and hashed to a different
fingerprint — so the ledger refused it as a conflict instead of
replaying. That refusal is not definitive, so no legacy fallback fires
and the launch has no recovery.

Options are the session's initial state, not its identity, and the
reservation still carries them to the record. Excluding them also makes
the digest byte-identical to the pre-change one in every case, not just
when no options resolve.

* refactor(native-chat): name the structured launch option seed

The create-intent resolver narrowed saved options to model/effort with an
inline key literal, inside a file carrying @ts-nocheck — so neither the
key list nor the string narrowing had a typechecked or testable home, and
the repo already expresses this concept as a named shared shape.

Move it to resolveStructuredLaunchSeedOptions beside the persisted
settings it reads, where it is typechecked and unit-tested, and document
why the seed is exactly model and effort: they are the only ids the
picker persists that both providers also accept as strings.

No behavior change. Adds coverage for a non-string persisted effort,
which settings.json can hold and the durable record must not carry.

* test(native-chat): name the structured routing pin for what it asserts

The case drives a saved Codex model and effort through the launch path,
but the structured create intent it asserts on carries no options, so it
pins the route and not the preservation its name claimed. Preservation is
pinned host-side, where the seeding actually happens.

* test(native-chat): pin the empty seed the record cannot carry

valuesByModel is merged over the resolved model, so a stored `model` key
can blank it — the seed then empties out and must resolve to undefined.
Nothing covered that branch, so returning the empty map unguarded stayed
green.

It matters because emitting `{ model: '' }` fails the record's
bounded-string guard, and agent_session_options_invalid is not a wire
refusal code: classifyStoreFailure rethrows it, the client reads the raw
error as an unknown outcome, and the launch strands with no fallback.

Corrects eb33ee6bb3, whose message claimed no behavior change. That
extraction also stopped emitting empty and whitespace-only values, which
is a fix on six input shapes, not a pure move.

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-06 02:27:44 -07:00
Neil 3d48d3a481 fix(source-control): stack the Create PR notice's settings link below its message (#19046) 2026-09-06 02:03:40 -07:00
Neil ffbf35e0d2 fix(source-control): stack Retry below the too-many-changes message (#19037) 2026-09-06 01:09:18 -07:00
6494f2a4f0 fix(native-chat): resume a structured chat from Agent Session History (#18933)
* fix(native-chat): resume a structured chat from Agent Session History

Clicking Resume on a chat-UI row could only reveal an already-open tab. If the
chat had been closed, or this process had never published it, the click re-read
an inventory that did not contain it and toasted "Retry in a moment" — advice
that could never come true, because nothing republishes an unpublished tab. The
legacy `claude --resume` fallback is deliberately refused for structured-owned
rows, so the row had no way back at all.

`close` already keeps the record and the journal on disk so a session can be
attached again, and the hold path already resurrects one in full. What was
missing was the tab: `restoreReadableSessions` is latched to run once, at
startup, so nothing could ask for a single session later.

Adds `agentSession.reveal`. The host looks up its own record, restores the
session readable, and republishes the tab through the same call
`agentSession.create` uses. Deliberately narrow:

- It takes no hold. A provider child exists because a surface asked, and the
  chat pane asks when it binds.
- A journal it cannot read is not a refusal. A chat whose journal predates the
  SQLite store restores to nothing here, but attach still recovers it, so the
  tab is published and the pane's hold finishes the job.
- Workspace and provider come from the record, never the client, so a session
  id alone cannot aim the publication at another workspace.

Claude and Codex both, by construction: eligibility is `adapterSupportsRecord`,
which the router answers from the record's own provider.

Gated on a new advertised capability rather than probing for method_not_found,
matching agent-session.structured.hold.v1 — absence is visible during
negotiation instead of by calling.

* fix(native-chat): negotiate reveal against the host that owns the workspace

The capability gate read the LOCAL runtime's advertised capabilities while the
call went to the host that owns the workspace, which for a paired workspace is
a different build. On desktop the renderer and its local host are always the
same build, so the gate passed unconditionally and proved nothing about the
host being called: an older paired host still received the unknown method and
its method_not_found was reported to the user as 'this chat is no longer on
this host'. The cache it read also starts empty and resets to empty when
status.get fails, so 'not fetched yet' and 'unsupported' were the same value.

Gate on the environment that will answer, the way agentSession.close already
does, and skip the round trip entirely for a local host. Reveal now reports
four outcomes instead of a boolean, so a host that is merely too old is not
reported as a chat that is gone, and a host we could not reach keeps the
retryable message.

Also syncs the localization catalog: the 'gone' key shipped without an en.json
entry, which reddens static analysis and verify while typecheck stays green.

* fix(native-chat): tell a refused reveal apart from a missing chat

The host raises two refusals here and they mean opposite things to a user: it
holds no such record, or it holds one no adapter of its own can open. The
client collapsed both into 'this chat is no longer on this host', which is a
eulogy for a chat still sitting on disk. Read the refusal code, and fold the
host-side case in with the too-old host under one honest message, since the
remedy for both is the same.

Adds the coverage the readiness pass found missing: the host's reveal answer
itself (workspace and provider from the record, both refusals, an unreadable
journal, a live session), and the activation branches for a host that cannot
open the chat and for one that never answered.

* fix(native-chat): read a host version block as the host's age, not a lost link

The capability probe reaches assertRuntimeStatusCompatible, which throws a
runtime_compat_block error. Treating that as unreachable told a user with an
out-of-date host to retry, which is the one thing that cannot help. Branch on
isRuntimeCompatBlockError the way remote-agent-session-launch already does for
the same probe.

Also adds the refusal-code case a previous commit claimed and did not deliver:
nothing drove a structured_agent_session_unsupported reply through the reveal
client, which is the branch that commit existed to add. Corrects a doc comment
that reveal made wrong: attach is no longer the only call that builds the host.

* fix(native-chat): let a dragged history row reach the same reveal as a click

Dropping an Agent Session History row onto a pane activated the tab by id and,
on a miss, raised the very toast this PR exists to remove — so the same row
answered a click and a drop differently, and the drop kept the advice that can
never come true. The structured branch never used the drop pane, so routing it
through the shared activation loses nothing and gains the reveal.

The helper only ever read one field, so its parameter narrows to that field and
the drag payload satisfies it directly. A source ratchet holds both entry points
to the reveal-capable path, since a mounted drag harness does not exist for this
layer and what regresses is a call site, not a rendering.

* fix(native-chat): stop an advisory refresh ending the click, and one click per row

Manual QA found the reveal never ran: the inventory refresh that precedes it
is an optimization, but its failure returned early with 'not available yet,
retry in a moment' — reinstating the dead end this PR removes, one step
earlier. A failed refresh now falls through to the reveal, which is the repair
and does not need the refresh to have worked.

The click can chain a refresh, a capability probe, a reveal and a second
refresh, each with its own timeout, while nothing on the row says it is
working. A per-session in-flight guard keeps an impatient second click from
running the whole sequence again and landing its own toast.

Also drops an unreachable owner scope: the snapshot apply discards any
worktree whose execution host is not local before it reads one, so naming a
remote scope there described a synchronisation that cannot happen.

* fix(native-chat): bound the capability probe and stop naming the wrong machine

The in-flight guard releases when the activation settles, so an await that
never settles holds the row for the life of the process. The capability probe
was the one call in the chain not raced against a deadline: on a cache hit it
awaits a promise an earlier probe created, which may carry no deadline of its
own. Race it like the two calls around it.

A version block can name either side — evaluateRuntimeCompat reports
client-too-old as well as host-too-old — so a message that blamed the host
pointed half of those at the wrong machine. Name the remedy instead of the
machine, which is true for every case that reaches it.

* chore: remove a scratch repro file committed by mistake

It was swept into the previous commit by a broad `git add` while a diagnostic
ran in this worktree. It asserts the current renderer-sync defect as expected
behaviour, so it would fail the moment that defect is fixed.

* fix(native-chat): stop a reveal's own inventory refresh discarding its republished tab

Manual QA: the host answered reveal with ok:true and republished the tab, and
the chat still did not reopen — only a renderer reload brought it back.

The renderer publishes under one epoch string for its whole lifetime, and a
frame recorded under a different lineage retires that epoch permanently with
nothing to un-retire it. The Resume click asks for an inventory first, and a
worktree the host holds no entry for answers with the none/v0 sentinel; the
structured path recorded it, retiring the renderer's own epoch, so the tab the
reveal published a moment later was dropped. A reload minted a new epoch,
which is why reloading appeared to fix it.

A frame that carries no publication is not a later publication to fence
against. Treat the sentinel and a removal frame as a cursor reset, the way the
mainstream session-tabs path already clears its tracking — its comment names
this exact hazard: recording that sentinel would retire the host epoch and
reject the next live frame.

Pre-existing, and it swallows an ordinary new-tab launch on an empty worktree
too; the reveal is what turned a silent invisibility into a visible failure.

* fix(native-chat): let a retraction prune its rows without retiring the epoch

Correcting the previous commit. Skipping a retraction frame outright stopped it
pruning the mirrored rows, so a worktree the host no longer publishes would
have kept a chat on screen with nothing behind it. Apply the frame as before
and clear its cursors instead of recording them, which is what the mainstream
session-tabs path does.

The unpublished sentinel keeps its cursor now too: it is skipped rather than
cleared, so a stale frame arriving late is still fenced. Adds the case the
earlier version would have broken.

* fix(native-chat): keep the retraction's fences, and fence the reveal's refresh

Correcting the retraction handling again. Clearing its cursors was more than the
bug needed and cost a guard: the host mints a fresh epoch when it rebuilds a
pruned entry, so a republication is never gated by the retained cursor, while
dropping it left an inventory response issued before the close free to land
afterwards and strand a chat row for a worktree the host no longer publishes.
Skip only the recording. The mainstream path keeps its epoch history for the
same reason, as a tombstone fence.

The test that justified the stronger clearing asserted a host behaviour that
does not exist — a rebuilt entry republishing under the renderer's epoch with a
restarted counter. It now uses what publishStructuredAgentSessionTab actually
mints for a pruned entry, and a new case covers the frame that would strand.

Also fences the reveal's inventory refresh on the sync generation, which every
other caller that applies an inventory already does: structured chat can be
switched off mid-flight, and the answer would otherwise re-seed a row into a
renderer that just discarded them.

* fix(native-chat): drop the retraction's epoch history, keep its version cursor

Third and final shape for this branch, and the only one of the three that holds.

Keeping both maps re-poisons the epoch one cycle later: the consumer here is
also the publisher, so the history's current is the renderer's own lifetime
epoch, and recording the reveal's fresh epoch retires it. The next chat the
renderer publishes is then dropped — this bug again, one close later. Deleting
both loses the guard that stops a frame issued before the close landing after
it and stranding a row nothing republishes.

So: clear the history, keep the cursor. The mainstream path keeps its history
as a tombstone because there the epochs belong to a remote publisher; that
reasoning does not carry to a path that publishes under its own.

Each of the three variants now fails a different test.

* fix(native-chat): a retraction forgets what is current, not the tombstones

The delete lost a fence the cursor cannot replace: the version cursor only
compares within a lineage, so a delayed frame from an already-superseded epoch
had nothing left to stop it putting a chat row back for a worktree the host no
longer publishes. Keeping the record intact had the opposite fault — the
renderer's own epoch is the history's current, so the next frame under any
other epoch retired it.

Clearing only current does neither: noteRetiredValue retires nothing when there
is nothing current, and the tombstones stay. Each of the four shapes now fails
a different test.

* fix(native-chat): narrow the retraction frame through its own type

Typecheck caught what the tests could not: `removed` is not on
RuntimeMobileSessionTabsResult. The repo already names the shape —
RuntimeMobileSessionTabsRemovedResult — so this reads it through a guard rather
than the inline cast the mainstream path uses.

---------

Co-authored-by: Orca Worker <orca-worker@localhost>
Co-authored-by: Merge Sim <sim@local>
2026-09-06 00:50:20 -07:00
NaoyaTatetsuandNeil a567e33bf7 feat(github-projects): render Roadmap project views as a timeline (#17795)
* Add roadmap timeline view for GitHub Projects

- Renders roadmap-layout project views as a scrollable timeline with
  date/iteration-based placement, zoom levels, and grouped lanes,
  instead of surfacing them as unsupported
- Derives placement fields from view config or row-carried field
  values since GitHub's API never exposes a roadmap's date source
  directly
- Falls back to the existing table list when no field can place items

* Fix roadmap timeline edge cases: reject invalid calendar dates and refre

- parseRoadmapDate previously let Date.UTC silently normalize overflowing
  dates (e.g. 2026-02-30 → Mar 2); now round-trips components to reject them
- ProjectRoadmap's "today" marker was frozen at mount, so panes left open
  across midnight showed the wrong day; now re-derives and re-arms a timer

* fix(github-projects): center roadmaps when dated rows arrive

* fix(github-projects): keep pinned roadmap header opaque

* fix: remove stale pnpm executable lockfile entries

* fix(i18n): retain replaced project labels in runtime catalog

---------

Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
2026-09-06 00:03:41 -07:00
Neil ced8a93bfd fix(sidebar): stop a missed pointerup from hiding a remote host section (#19032)
Clicking a host header arms a drag session on pointerdown, but the window
pointermove/pointerup listeners attach from an effect gated on that state --
a render and a paint later. On a heavy sidebar a quick click's pointerup can
land inside that window and never be seen, so the session survives the click
and the next bare mouse move clears the 4px threshold and promotes a drag the
user is not doing.

The host tier is the only header that hides itself while dragging (opacity-0,
plus forceCollapseHosts on every section), so the host the user just collapsed
vanishes outright. It only returns on a stray later pointerup or when the
viewport remounts -- which is why toggling the host filter fixes it: the
viewport's React key includes visibleWorkspaceHostIds.

Treat a pointermove with no button held as a released pointer and end the
session instead of promoting. The repo and project-group header drags share
the race, where it commits an unintended reorder on the next click, so they
get the same guard. Extracting their duplicated click-swallow block keeps
project-header-drag.ts under the max-lines ceiling.
2026-09-05 23:47:47 -07:00
Neil 15dabf8d0b perf(worktree): overlap base refresh with prepared checkout (#18998)
* Stop obsolete worktree preparations when evicted or expired

* Let worktree preparation proceed during stale reclamation

* Verify creation during stalled stale worktree reclamation

* Preserve preparation ownership until Git removal starts

* test: keep artifact share fixtures unexpired across calendar dates (#18955)

* perf(worktree): overlap base refresh with prepared checkout
2026-09-05 22:42:06 -07:00
Neil a63a4579cf Let worktree creation proceed during stale preparation reclamation (#18967)
* Stop obsolete worktree preparations when evicted or expired

* Let worktree preparation proceed during stale reclamation

* Verify creation during stalled stale worktree reclamation

* Preserve preparation ownership until Git removal starts

* test: keep artifact share fixtures unexpired across calendar dates (#18955)
2026-09-05 22:30:01 -07:00
Neil e2270fe94d Stop evicted and expired worktree preparations (#18951)
* Stop obsolete worktree preparations when evicted or expired

* fix(worktree): skip discard retries for registrations an aborted checkout already removed

An evicted or expired preparation now aborts its checkout, which self-discards
the registration before the pool's own discard runs. That second discard failed
with "is not a working tree" and was enrolled for up to three retries on later
preparations for the same host, spawning Git only to fail again and warning that
the path stays registered when it was already gone.

Also accept fs.watch events without a filename in the abort real-Git test, and
add an opt-in bench (ORCA_WORKTREE_PREPARATION_CANCEL_BENCH=1) that measures a
fresh checkout's wall time with obsolete checkouts left running versus aborted.
2026-09-05 22:16:57 -07:00
Neil 64a449df4e perf(search): assemble fragmented subprocess lines incrementally (#18973) 2026-09-05 21:56:15 -07:00
OrcaWinandOrca Worker 8b88b3b60a fix(windows): drop no-op -ExecutionPolicy Bypass from -Command spawns (#17873)
* fix(windows): drop no-op -ExecutionPolicy Bypass from -Command spawns

Execution policy gates script *files* only; it has no effect on -Command.
Measured on Windows 11:

  powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Restricted \
    -Command "Write-Output 'COMMAND-RAN'"   -> COMMAND-RAN, exit 0

So the switch bought nothing on these two call sites while contributing the
highest-weighted token on the command lines Defender for Endpoint flags.

Font enumeration returns a byte-identical family list with and without the
switch (182 families, matching SHA-256), and the ACL script's argv behaves
identically either way.

Tests now assert the argv carries no -ExecutionPolicy/Bypass, and the
secure-file assertions derive the script position from -Command instead of a
fixed index so they cannot rot the next time the switch list moves.

* refactor(windows): tighten -Command argv assertions and comments

Review follow-ups on the -ExecutionPolicy Bypass removal:

- powershellScriptArgs asserts the -Command anchor before slicing, so a
  -Command -> -File swap names the switch shape that moved instead of
  surfacing as a path mismatch several asserts later.
- Collapse both no-op rationale comments to one line per AGENTS.md.

---------

Co-authored-by: Orca Worker <orca-worker@localhost>
2026-09-05 21:54:37 -07:00
OrcaWinandOrca Worker fc5fa16870 perf(windows): split the process table into two flag sets (#17866)
* perf(windows): split the process table into two flag sets

MDE flags "suspicious memory activity" on the process-table reader: it
opened a handle into every process on the box and read each one's PEB on
a repeating cadence. Two changes narrow that.

Drop `Memory` outright. It cost a second
OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ) plus
GetProcessMemoryInfo per process, and nothing reads a working set off
this table -- the Resource Manager runs its own sweep, and the addon
stores WorkingSetSize into a DWORD so anything above 4 GB wraps.

Split the rest in two. `readWindowsProcessIdentityTable[Fresh]` is a
bare Toolhelp32 walk with zero per-process handles, and returns
`WindowsProcessIdentityRow`, which has no `command` to read.
`readWindowsProcessTable[Fresh]` keeps the command line for the callers
that match on it. PTY root identity and the owner start-time probe move
to the cheap reader; agent recognition, port attribution, codex turn
processes and structured-TUI matching all genuinely need the command
line and stay.

Two independently single-flighted caches, never one per caller: the
fan-out this module prevents is one scan per caller, and each reader
still serves every caller wanting its flag set. The wedge gate and the
3s deadline stay shared, because both readers call the same addon and
one wedged read latches its one `requestInProgress`. With no binding
there is only the 1.4s PowerShell scan to run, so the identity view
rides the detailed snapshot rather than forking a second one.

Measured on Windows 11, 492 processes (p50/p95): identity 6.3/7.0 ms,
detailed 12.3/13.4 ms, previous memory+commandLine 13.1/14.1 ms.

* fix(windows): serialize native process-table reads across flag sets

The two flag-set readers could both be in flight at once, and the
vendored wrapper does not tolerate that. `getRawProcessList` pushes the
callback onto one list and calls the addon only when no request is in
progress, so a second concurrent caller's `flags` are DISCARDED and it
is handed the first caller's rows. Measured against the real addon:
identity issued first, both callers got the same array, 0 of 541 rows
with a command line. A detailed read overlapping an identity read
therefore returned a table with every command line empty, which agent
recognition reads as "no agent" -- silently, and only under concurrency.

Nothing already here excluded that. Each snapshot cache single-flights
only within itself, and the wedge set latches only after a read misses
its 3s deadline, so through the healthy ~12ms of a scan neither reader
excluded the other. Overlap is the normal state: panes poll detailed at
750ms while a teardown takes identity snapshots.

`nativeReadGate` admits one native read at a time across both flag sets.
It also fixes the relay path, where `adaptAddon` has no queue at all and
two simultaneous CreateToolhelp32Snapshot calls are the crash the
vendor's queue exists to prevent. Every link settles, so a wedged read
never strands a waiter; the waiter re-checks the wedge and rejects. With
one call outstanding, retention stays bounded at one callback rather
than one per reader.

Also from review:

- The CIM fallback now belongs to the detailed flag set alone, and the
  identity view projects that snapshot through `toIdentityRow`, so an
  identity row carries no command line on a no-binding host either.
- The concurrency test modelled the wrapper's coalescing queue, which
  the previous synchronous mock could not express; verified failing
  without the gate and passing with it.
- `agent-session-process-identity-probe` early-returns when the
  creation-time flag is unavailable, which no shipped addon build
  provides, instead of scanning the table to produce null.
- Corrected the cost framing: Memory took an OpenProcess(...|VM_READ)
  it never read through, so dropping it halves per-process handle opens
  and leaves the PEB/ReadProcessMemory telemetry unchanged.

* test(windows): keep read exclusion across resets and flag each field

Two review follow-ups, both about tests passing for the wrong reason.

`resetNativeReaderState` replaced the read gate with a resolved promise,
so waiters still holding the old chain ran beside reads queued on the
new one. Reachable only from the `__set*ForTests` hooks, which is what
makes it worth fixing: it hands a suite two concurrent calls into its
own mock addon -- the exact condition the concurrency tests exist to
detect. Chain onto the gate instead; every link settles within the
deadline, so the bounded wait that costs is the right trade.

The coalescing mock shaped every field off the CommandLine bit, so an
identity read that did request CreationTime got `creationTimeMs`
stripped. The identity-side assertion was then only `!('command' in
row)`, which a correctly flagged read and a coalesced one satisfy
equally: a future regression losing identity flags under concurrency
would have kept the case green. Gate each field on its own bit and
assert `creationTimeMs` positively, inside the helper both orderings
share.

Concurrency assertions move to a new bare-addon mock. The coalescing
mock's own latch means it can never report more than one call in
flight, so measuring exclusion there proved nothing; the bare addon has
no queue -- like `adaptAddon` on a relay, where re-entering
CreateToolhelp32Snapshot is a real crash -- and makes re-entry visible.

Verified by deletion: restoring `nativeReadGate = Promise.resolve()`
fails the reset case with `expected 2 to be 1`, and restoring the
single-bit mock fails both overlap orderings on `creationTimeMs`.

* docs(windows): count the third test defect in the list that names them

The section opened "Two defects have now shipped", numbered two, then
described the third in its closing paragraph -- a list that reads as a
complete account while quietly omitting one, which is the exact failure
the section exists to warn about. Say three and number it, and note that
the third arrived inside the fix for the first two.

Also record why the creationTimeMs and flags-array assertions are not
redundant, in the doc and beside the assertions: the flags array catches
a read served another flag set's rows, the positional creationTimeMs
check catches field shaping (identity dropping CreationTime, or
toIdentityRow not forwarding it). Neither sees the other's failure.

* docs(windows): stop describing a PEB read this release removed

Every comment here that justified the flag split in terms of PEB reads became
false when the command-line reader moved to the kernel. Left alone, the
enumeration doc contradicted itself inside one file: the flag-set section
described three chained `ReadProcessMemory` calls per process while the
sections below it explained that the addon contains no such primitive and has
no PEB fallback.

The measurement is now attributed rather than merged. Dropping `Memory` halved
the per-process handle opens and nothing else -- both handles carried
`PROCESS_VM_READ` at the time -- and it was replacing the PEB walk that took
`PROCESS_VM_READ` and `ReadProcessMemory` out of the addon. Neither change
substitutes for the other, which is worth keeping straight: the split's
remaining value is the handle itself, not the memory access.

Also adds `relay/windows-port-scan.ts` to the caller table, the one caller this
effort introduced, and records that it reads only pid/name through the detailed
reader -- free while a pane is polling, not free on a headless relay.

* test(windows): pin the fresh links path against the identity TTL cache

The identity and detailed tables are separate snapshot readers with
independent TTLs, so the detailed path's existing freshness guard says
nothing about the ancestry walk's. Cover the identity reader on its own.

---------

Co-authored-by: Orca Worker <orca-worker@localhost>
2026-09-05 21:42:34 -07:00
Jinjing f811ee0740 Open open new link should not navigate away from current link (#18873)
* fix(browser): open modifier-click and middle-click links in background t

Links opened with modifier keys (Cmd/Ctrl+click) and middle-click now open
in background tabs, matching Chrome's behavior. Shift+middle-click continues
to open in the foreground tab. The routing system now tracks separate
foreground and background frame names, with an `activate` flag controlling
whether the new tab is brought to focus.

* fix(browser): don't navigate away when opening links in background tabs

When opening links via context menu or other mechanisms that create background tabs, keep focus on the source tab rather than automatically switching to the newly opened tab. Set `activate: false` on tab creation to prevent unwanted navigation away from the current page.

* fix(browser): silence popup notices for links opened in Orca tabs

Links that open in new Orca tabs are immediately visible to the user and
don't warrant a toast notification. Only external popup opens now show
notifications, reducing unnecessary clutter while still alerting the user
to unexpected external window opens.

* Replace loading dots with animated spinner icons

Replaces the small dot indicators with animated Loader2 icons that
appear in place of the favicon while tabs are loading. Provides
clearer, more prominent visual feedback during navigation.

* test(browser-tab): verify target=_blank links don't navigate source tab

Add a test case checking that plain main-frame target=_blank clicks open
in a new tab without navigating the source tab away. Extract
startBrowserLinkServer to a helper module and add the /blank-destination
endpoint to support the new test case.

* refactor(browser): localize clicked-link routing frame names

Remove the global clickedLinkFrameNamesByGuestId state map and generate
frame names locally within installGuestPopupPolicy, improving state
encapsulation and simplifying cleanup logic. Functionality unchanged.

* test(browser-tab): hold shift for middle-click gestures

* test(browser-tab): drop duplicate shift-middle gesture

* test(browser-favicon): verify spinner shown while favicon reloads

Updated test expectations to reflect that the favicon component shows a
loading spinner during reload instead of keeping the previous image
mounted.

* fix ci
2026-09-05 21:38:35 -07:00
OrcaWinandOrca Worker c252d855ac fix(windows): resolve npm/pnpm .cmd shims past cmd.exe (#17869)
* fix(windows): resolve npm/pnpm .cmd shims past cmd.exe

A `.cmd` target forces every spawn through `cmd.exe /c` with each argument
caret-escaped, and Microsoft Defender for Endpoint scores a long `cmd.exe /c`
line carrying caret-escaped natural language as obfuscation. `codex.cmd` is
named in the spawn cluster of the MDE incident this addresses.

npm's `cmd-shim` and pnpm's `@zkochan/cmd-shim` generate files whose whole body
is "find node, run this script". Read one, and the spawn can go straight to
`node.exe <script> <args>` — no cmd.exe, no caret escaping. Anything the parser
does not recognise exactly, or whose target cannot be confirmed on disk, keeps
the existing cmd.exe path.

Incidentally fixes a real bug: cmd ends its command at a raw CR/LF whatever the
quote state, so a multi-line agent prompt through a `.cmd` shim had to be
rejected. Resolved shims have no such limit.

* fix(windows): refuse drive-relative shim paths and run the win32 tests in CI

Two blocking findings from review.

A drive-relative path defeated the absolute-path guard:
`win32.isAbsolute('D:evil.js')` is false, but `win32.resolve` reads the drive
letter and lands on `D:\evil.js`, outside the shim directory. cmd would have
built `C:\shim\D:evil.js` and failed; we would have executed the wrong file.
Adding `:` to the unsafe-character set closes it, and the alternate-data-stream
spelling `a.js:zone` with it. It costs no coverage: 84 of the 91 real shims on
this box still resolve, the same seven fall back.

Neither `windows-cmd-shim-resolution.test.ts` nor its `.win32` sibling was in
the Windows package job's file list, so the whole filesystem/resolution half and
the real-spawn equivalence suite ran nowhere. Both are now in
`WINDOWS_PACKAGE_TESTS` and in the pr.yml step.

Also from review: clear `windowsVerbatimArguments` explicitly on the resolved
branch rather than inheriting it, since there is no caller-built command line
there; document the kill switch and the PTY/hook-wrapper scope limits in
docs/reference; and cover drive-relative, BOM, line-ending, casing and `%*`
tampering in the platform-independent half of the tests.

* docs(windows): justify the shim-path colon guard from the filesystem rule

The guard was argued empirically ("none of the 91 shims on this box has one"),
which invites a future reader to relax it for a shim we have not seen. Windows
reserves `:` within a path segment, so a relative path cannot carry one at all:
the only spellings that can are drive-qualified, an alternate data stream, or a
`\?\` device path, and the last is already refused as absolute. That makes a
false refusal impossible rather than unobserved.

* refactor(child-process): move resolveSpawn into its own module

The merge with main pushed run-process.ts one line past the 300-line cap:
both sides grew it. The spawn-argv decision is already a pure, separately
tested unit, so it moves out rather than the cap moving up. run-process.ts
re-exports it, so no caller changes.

* perf(child-process): cache the shim interpreter lookup

The parse cache spared the shim read but not the PATH walk, so a second
resolution of the same .cmd did 0 reads and one statSync per PATH entry --
30 on a 30-entry PATH, synchronous on resolveSpawn, where one dead network
mount blocks the calling thread on every spawn.

Keyed by shim directory AND PATH, since the shim's own rule is
%~dp0\node.exe first then PATH, and a PATH edit between spawns must miss.
Corrects the stat comment, which accounted only for the shim itself.

* fix(child-process): revalidate a cached shim interpreter before using it

The node cache was held for process life and never rechecked, so a cached
node.exe that was later uninstalled -- or dropped from PATH by a version
manager -- was still handed to resolveSpawn, failing the spawn with ENOENT.
An uncached process in the same state returns null and falls back to
cmd.exe successfully, so the cache was strictly worse than no cache.

One statSync on a non-null hit, not one per PATH entry, so the walk this
cache exists to skip is still skipped. The stale-null direction stays
uncorrected on purpose: it only keeps the working cmd.exe fallback. Both
directions are now stated in the comment, along with the known miss for
callers that vary PATH per spawn.

* fix(child-process): honour PATHEXT when resolving the shim interpreter

The doc claimed a node.com/.bat/.cmd on PATH returned null and fell back to
cmd.exe. The scan actually skipped those entries and kept looking for a
node.exe, so PATH=C:\A;C:\B with C:\A\node.com and C:\B\node.exe resolved to
B's node.exe while the shim runs A's node.com -- a different binary, chosen
silently, on the one axis this module must not get wrong.

The scan now follows cmd's rule: first PATH directory holding any PATHEXT
spelling wins, PATHEXT order decides within it, and only an .exe winner is
returned. Anything else gives up and keeps the cmd.exe path, which restores
the strict-subset-of-cmd property everywhere except the documented cwd case.

PATHEXT is read from the child's env and joined into the cache key, since it
now changes the answer. Costs one stat per PATHEXT entry per node-less
directory, paid once per process behind the cache.

---------

Co-authored-by: Orca Worker <orca-worker@localhost>
2026-09-05 21:33:16 -07:00
OrcaWinandOrca Worker 0b6308f4d5 test(child-process): lower DIRECT_IMPORTER_PIN to the ground two PRs took (#19009)
#17861 and #17884 each migrated one file off node:child_process and each
lowered the pin 158 -> 157 independently. Together they took two, so the
true count is 156 and the two-sided assertion fails on main.

Co-authored-by: Orca Worker <orca-worker@localhost>
2026-09-05 21:33:08 -07:00
Jinjing d80c736f57 Improve notification view (#18699)
* feat(activity): add per-notification clear button

Add a dismiss/clear button to individual notification rows in the Activity
view that allows users to quickly remove completed or interrupted agents
from the list. The button appears on hover and clears the selected thread
using the existing clearCompletedActivity infrastructure.

Changes:
- Add clearSingleActivityThread() to clear individual notifications
- Add close button (X icon) to ActivityThreadRow, visible on hover
- Wire handleClearThread action through ActivityPrototypePage → ActivityThreadListPane
- Update ActivityThreadVirtualRow to check if thread is clearable

* fix(activity): wire onClearThread action to sidebar agents list

The sidebar notification list was not receiving the onClearThread callback,
so the clear button was never enabled. Wire the action from useActivityThreadActionBindings
down to the ActivityThreadListPane in the sidebar.

* refactor(activity): silent clear for individual notifications

Clear single notifications without showing a toast. The notification
disappears immediately without user confirmation feedback.

* refactor(activity): remove unread indicator dot

The check mark already indicates when the filter is active.

* refactor(activity): indicate active scope filters

Display visual indicator and updated aria-label in the options menu when
scope filters are applied. Improves visibility of the current filter state
and enhances pointer event handling for action buttons.

* refactor(activity): hide hover-only actions from keyboard navigation

Prevents hover-only actions from adding unnecessary tab stops to long lists while allowing specific actions like the clear button to remain keyboard-accessible via the new keyboardReachable prop.

* refactor(activity): prevent sidebar effects on notification threads

Add revealInSidebar and clearSidebarFilters options to worktree activation
functions, allowing notification threads to activate workspaces silently
without revealing or filtering the sidebar.

* Add option to reset activity scope filters

Users can now clear active host visibility and project filters via a "Show all hosts and projects" menu item that appears in the thread options when filters are active. Includes test coverage and localized strings for all supported languages.
2026-09-05 21:25:37 -07:00
Neil ba86e1c83c perf: avoid repeated whitespace scans in diagnostic redaction (#18908)
* perf: scan diagnostic environment lines without repeated whitespace searches

* perf(observability): skip redundant terminator scans in environment-line redaction

After a successful match ENV_LINE.lastIndex already sits at end of input
or a line terminator, and a failed start's whitespace skip plus terminator
scan collapse into one LINE_CONTENT exec. Byte-identical output; verified
against the pre-change regex over 280k generated inputs.

* perf(observability): find environment-line ends without entering the regex engine
2026-09-05 21:17:46 -07:00
0cbb01ef4b fix(security): apply the Windows path-hardening ACL that never ran (#17884)
* fix(security): apply the Windows path-hardening ACL that never ran

`buildWindowsRestrictAclArgs` invoked the hardening script as
`powershell.exe -Command <script> <path> <sid> <isDir>`. `-Command` does
not populate `$args`; it appends the trailing tokens to the command text.
The script therefore read `$args[1]` as `$null`, threw `NullArrayIndex` at
`$allowedSids[$sidText] = $true` under `$ErrorActionPreference = 'Stop'`,
and exited 1. Both callers swallowed that: the async callback was empty and
`applySecurePathRestriction` returned `true` regardless, while the sync
`catch` returned `false` and nobody logged. Every Windows secure path has
been left on its inherited ACL since the ACL was introduced (#5006), and
nothing said so.

Replace PowerShell with `icacls.exe`, which takes plain argv. That removes
the quoting surface entirely rather than escaping it: interpolating a path
into the command text would have turned a dead no-op into arbitrary
PowerShell on a filesystem path, since `-Command` executes what it appends.
It also drops the execution-policy dependency and the `powershell.exe`
spawn an EDR flags, and runs ~25x faster than the PowerShell cold start.

Hardening is now three passes: `/reset` to purge explicit ACEs that
`/inheritance:r` leaves behind, `/inheritance:r` plus a `/grant:r` per
allowed SID, then a read-back that checks the DACL is protected and grants
only the intended rights. The predecessor's verification block was equally
dead, and an apply that is never read back is only half a control.

Failures stay non-fatal — non-NTFS volumes, network paths and restricted
tokens fail legitimately and must not break startup — but they are no
longer invisible: every failure is logged, and a failed async apply now
evicts its cache entry so the next call retries instead of trusting a
success that never happened.

Routing through `runProcess`/`runProcessSync` also retires this file's
`node:child_process` allowlist entry.

* fix(security): verify the hardened ACL by identity, not by shape

Review found the bug class this PR fixes surviving inside the fix. The
verify pass checked rule count, absence of the inherited marker, and exact
rights — never *who* the rules named. Granting Everyone full control
satisfies all three, so hardening reported success on a DACL that handed
the credential to every local account, and most of the real-filesystem
tests still passed.

Verification now reads the descriptor back with `icacls /save`, which emits
SDDL with raw SIDs, and compares the principal set exactly. That is also
locale-independent by construction: the previous parse read localized
account names out of icacls' OEM-codepage stdout, where a non-ASCII path
survived by accident rather than by the documented mechanism. SDDL parsing
moves to `windows-security-descriptor.ts`.

Two further self-inflicted problems, both measured:

The post-rename re-harden led with `/reset`, which re-widened a DACL that
was already correct — the staged file's protected DACL survives the rename,
so the pass had nothing to do but open a window. Polling an external
process during a write into a relocated root caught it: the e2ee keypair
dropped to `BUILTIN\Users:(RX)` plus `Authenticated Users:(M)` — read *and*
write — before tightening again. Hardening now verifies first and returns
early when the DACL already reads back correct, which closes the window and
cuts the steady state from three spawns to one. Re-measured: 158 samples,
one DACL state, zero broad.

Evicting the cache on every failed async apply reintroduced #4901. The env
store re-hardens on the read path at ~2/s, so on a host where hardening
cannot work (FAT32, network path, restricted token) that was two icacls
spawns and two warnings a second, forever. Async retries now take a retry
floor and a hard per-path attempt cap. The write path keeps retrying
unthrottled — it is user-driven, and a failed credential ACL must still be
retried on the next write.

Also: failures route through a reporter hook that the main process points
at the diagnostic tracer, because `console.warn` reaches nothing in a
packaged GUI-subsystem build; `writeSecureFile` returns whether hardening
took, and the async branch reports `pending` rather than claiming `applied`;
a transient `whoami` failure no longer disables hardening for the process
lifetime, and the SID is shape-validated; the `/c` guard now covers the
synchronous runner too.

* fix(security): re-probe hardening instead of latching a transient failure

The per-process attempt cap added for the read-path storm was a permanent
latch: one AV scan, momentary lock or %TEMP% blip and every later credential
write in that session went unhardened, silently, on a host where hardening
would now succeed. Same defect class as #17858's computer-use host, and
worse here because what stops happening is security hardening on credential
files and nothing said so.

The retry budget now bounds the *rate*, not the lifetime: at most three
attempts per path per minute, re-probing in every later window, forever. The
transition is announced in both directions — `throttled` once per window on
entry, `recovered` when a rate-limited path hardens again — so a host stuck
in the degraded state is diagnosable rather than merely quiet. The reporter
type covers both, and the main process ends the `recovered` span
successfully rather than failing it.

Extracted to secure-path-hardening-retry-budget.ts, which keeps
secure-file.ts under its line cap without a max-lines disable.

Also confirms the second flagged risk rather than assuming it: a real
unwritable %TEMP% is now covered by a test proving verification fails
closed, reports at the `verify` stage, and still leaves the ACL applied —
so that path loses proof, not protection, and with the lifetime cap gone it
can no longer combine into a permanent-off state.

* fix(security): verify a directory's whole inheritance flag set

The flag check tested only that `OI` was present — never that `CI` was, nor
that nothing else was. That was harmless while `/reset` + `/grant` ran on
every pass and repaired whatever was there. The verify-first short-circuit
made it load-bearing: what verification accepts is now left alone, so a
latent under-check went live because a different fix started depending on
it.

Two directory DACLs passed while being wrong — both protected, three
non-inherited full-control rules, correct SIDs, differing from correct only
in their flags:

  (OI)(F)        - no CI, so subdirectories are left unprotected
  (OI)(CI)(IO)   - inherit-only, so the directory object itself grants
                   nobody anything; the next writeFileSync into it fails
                   with EPERM, on a directory just cached as hardened

Verification now compares the whole flag set, which also rejects IO and NP,
and names the offending flags in the failure. Both shapes are planted in
real-filesystem regression tests, including an assertion that a write into
the repaired directory succeeds and its child inherits. Confirmed both tests
fail against the old check and pass against this one.

* fix(security): back the hardening retry off exponentially

The fixed one-minute window bounded the retry rate but left a standing floor
of three attempts per path per minute on a host where hardening can never
succeed — FAT32/exFAT, a network path, a redirected profile. That budget is
per path and there are several secure files, so the floor multiplied into
tens of thousands of icacls spawns a day for work guaranteed to fail.

The delay now doubles after each consecutive failure, from a one-minute
floor to a thirty-minute ceiling, and the attempt cap is gone entirely: once
the backoff elapses the path is re-probed however long it has been failing.
A permanently incapable host settles at ~2 attempts/hour.

Slowing the backstop costs almost nothing, because it is not the recovery
mechanism: the synchronous write path is deliberately unthrottled, so a host
that recovers hardens on its very next credential write regardless of what
the read-path budget says.

The `throttled`/`recovered` reports are unchanged and matter more here,
since the quiet periods between probes are now much longer.

The curve is pinned in a new unit test against the exported delay function
rather than a copy of its constants, covering the doubling, the ceiling
holding at 5000 consecutive failures, a 30-day failing path still
re-probing, one announcement per degraded episode, and per-path isolation.
The integration tests keep only what they uniquely prove: that the read path
is wired to the budget, and that a day of failures still re-probes.
Confirmed four of these fail against a reinstated lifetime cap.

* ci(windows): run the real-icacls DACL suite in CI

The win32 suite only self-skips off Windows, so it passed vacuously in
every lane. Register it the way the cmd-shim suite is registered.

* fix(security): describe the cache's real cost, which is icacls now

Both cache comments still justified themselves with PowerShell -- "~1-1.5s" and
"a PowerShell spawn every read" -- in the same file whose PR removed PowerShell
from this path. The caches are still right, but for different numbers, and the
old ones are the kind an engineer would reasonably delete a cache over.

The real shape: hardening verifies first and returns early, so an already-correct
DACL costs one synchronous icacls spawn and a rewrite costs four (verify, reset,
grant, verify). Still worth caching on the read path, which polls at ~2/s.

* test(security): make the DACL suite safe to schedule

Registering this spec in the Windows lane put it under two rules it had
never been measured against.

Teardown now goes through `removeTreeSync`, which the lane's boundary test
requires, and repairs the DACLs the suite plants on purpose first: those
retries only cover transient locks, so a regressed `(OI)(CI)(IO)` repair
leaves the root un-removable and `afterAll` throws EPERM.

And the no-permission case decides by elevation before it writes anything.
`windows-2022` runs elevated, where hardening succeeds: the old branch
asserted nothing about denial and instead replaced the `hosts` DACL, then
`icacls /reset` -- which is not a restore, it drops the explicit
`SYSTEM:(F)` that file ships with. Ephemeral in CI; permanent for a
developer running the lane from an elevated shell. Now it asserts or it
skips. The probe reads the token integrity SID rather than `icacls /save`,
which succeeds unelevated (`BUILTIN\Users:(RX)` carries READ_CONTROL) and
would have skipped the case on every machine.

* fix(security): measure the hardening latches on a clock that cannot go backwards

`mayAttemptHardening` compared wall-clock times, so any backwards step --
an NTP correction, a VM snapshot restore, a user changing the clock --
made the elapsed time negative and held every failing path below its delay
until the clock caught up. Measured at the 30-minute ceiling with the clock
stepped back a year, the path was refused at +0d, +1d, +30d, +180d and
+364d, and re-probed only at +366d. That is the permanent latch the
exponential backoff was added to remove, and it contradicts the module's
own "bounds the rate without ever bounding the lifetime".

The SID lookup's own one-minute window had the identical shape and is
worse: a failed lookup makes `planFor` return null, which disables the
synchronous *write* path too, so the write-path exemption that recovers
the read-path budget cannot recover it. Both now measure elapsed monotonic
time, following the repo's existing `monotonicNowMs` spelling.

Two things the write path was not doing, both found in the same pass:

- A successful synchronous apply now records the outcome. It is exempt
  from the budget, but it was also invisible to it, so a host that had
  demonstrably recovered kept the read path backing off for up to 30
  minutes and no `recovered` transition ever came from that lane. Only
  success is recorded; recording failure would put the exempt lane back
  under the budget.
- `writeSecureFile`'s JSDoc now says its boolean covers the file only. The
  directory harden is fire-and-forget and answers `pending` on Windows
  regardless, so a `true` says nothing about the directory's ACL.

* fix(security): stop the hardening test doubles from faking a no-op

Three CI failures on this branch, one failure shape: hardening silently
does nothing and the check that should have caught it agrees.

The auth critical-path test hand-rolled a `node:child_process` factory with
`execFileSync`/`execFile`. The rewritten ACL path goes through
`runProcessSync`, i.e. `spawnSync`, which the factory never returned — so
every spawn threw into the SID lookup's bare catch, `planFor` returned null,
and hardening no-opped. It mocks `child-process/run-process` now, the
boundary production code actually calls and the one sibling ACL tests
already mock: an export missing there fails loudly by name instead of
returning undefined. Its fake icacls writes a real UTF-16LE SDDL file, so
the pinned spawn count per write is a property of the ACL path rather than
of the double. The test forces `platform='win32'`, so this failed on every
platform, Linux CI included.

`windowsSystem32Binary` is a production bug, not a test bug: it builds a
Windows path with the host `join`, which off-platform yields the mixed
`C:\Windows/System32/whoami.exe`. On Windows the two joins agree, which is
why it survived; on Linux the SID lookup's whoami match missed and 27 of
secure-file's 32 tests exercised a lane that never ran. These are always
Windows paths, so `path.win32.join` is what it should have been.

The import-boundary pin still read 160 after this branch migrated
secure-path-windows-acl.ts off `node:child_process`; the ratchet correctly
refuses a pin left above reality.

* fix(security): resolve the machine-relative SDDL alias, and stop a denied read destroying the file

Path hardening verified the DACL it wrote by comparing the SIDs `icacls /save`
reports. SDDL substitutes two-letter aliases for well-known SIDs, and the
resolution table could only hold constants -- but `LA` and `LG` name an account
by RID inside the *machine's own* SID, so on a box whose user is the built-in
Administrator (a CI runner, an Administrator-only install) the current user read
back as `LA`, matched nothing, and hardening reported failure for every path.
Resolve those two against the machine authority derived from the user SID;
without one they stay unresolved and the comparison still fails closed.

Three secret stores treated any read failure as "malformed -- regenerate" and
overwrote. A hardened file granting a SID this process does not hold reads as
EPERM while its directory stays writable, so the overwrite succeeds: renaming
over an unreadable file needs FILE_DELETE_CHILD on the parent, not DELETE on the
file. That destroyed the E2EE secret key, every paired device's bearer token,
and the plugin vault. Distinguish EPERM/EACCES from a parse failure and refuse.

Also close the async lane's unhandled rejection: `void p.then(onSettled)` turned
a throw from `onSettled` into a dead main process, and the retry budget it calls
threw whenever nothing had configured it -- a contract held only by import
order. The budget now defaults its own bounds.

* test(windows): say which ACEs icacls listed when a planted DACL fails

`toHaveLength` reports only a count and vitest elides the array, so three
preconditions failing on the CI runner said "expected 3, got 6" and nothing
about what the sixth entry was. Name the entries in the failure.

* fix(security): stop three more stores overwriting what they were denied

Same swallow-default-overwrite shape as the readers already fixed, found by
sweeping every store that reads under a hardened root.

- plugin-storage-store.ts returned `{}` on any read failure and set()/delete()
  wrote it back, losing the plugin KV store. It is the secrets store's shape
  line for line, so the two now behave identically.
- relay-revoke-outbox.ts returned [] and save() wrote it, dropping revocations
  that never reached the relay -- a revoked device stays live.
- profile-cloud-session-store.ts mapped an EPERM read onto `decrypt-failed`,
  which fails the `status === 'found'` guard in clearCloudSessionIfUnchanged and
  falls through to an rmSync of the account session. A denied read now reports
  `unreadable`, which licenses nothing; the refresh path bails on it and the
  auth status surfaces it rather than reporting a bare reconnect.

All reuse isPermissionDeniedError. The predicate stays an EPERM/EACCES allow
list rather than "ENOENT defaults, everything else throws": these stores are
meant to self-heal a truncated or malformed file, and inverting it would turn a
corrupt keypair into an app that cannot start. The distinction that matters is
"could not read it" versus "read it and it was garbage".

* test(windows): plant fixture DACLs that cannot inherit what they did not plant

%TEMP% grants [SYSTEM, Administrators, <user>] (OI)(CI)(F) by default, and those
propagate into every fixture. Three preconditions read back 4 and 6 ACEs where 3
were planted, and the extras looked like Orca's own hardening because the shape
is identical -- on a runner whose user is the built-in Administrator, the
inherited trio IS the trio production grants.

Combining /inheritance:r with /grant:r leaves the argument order to icacls, and
that combined form drops the inherited ACEs on Windows 11 but keeps them as
explicit ones on the Windows Server runner. Removing inheritance in its own
invocation makes the grant the whole DACL on either host, and the fixture root
is de-inherited once up front so nothing propagates in.

Rooting the fixtures outside %TEMP% would not have fixed this: any directory
inherits from wherever it lives. The fix is to stop inheriting, not to move.

No assertion is relaxed -- the counts stay exact.

* test(windows): pick a foreign SID that stays foreign on an elevated runner

`S-1-5-32-544` is only foreign to a token that is not an administrator. The CI
runner is elevated AND logged in as the built-in Administrator, so granting
Administrators granted the reader full control: the file stayed readable, and
all six preservation assertions went vacuous rather than proving anything.

BUILTIN\Guests is resolvable everywhere and no interactive token is a member,
so the read is denied on an unelevated developer box and on the runner alike.
An unresolvable SID would have been the stronger choice but icacls rejects one
with ERROR_NONE_MAPPED (1332).

The premise guard is what caught this -- it asserted the file was actually
unreadable instead of trusting the grant, and named elevation as the suspect.

* fix(security): refuse on any read that never reached the contents, not just a denied one

isPermissionDeniedError becomes isUnreadableError, because "permission denied"
was never the concept -- "could not read it", as opposed to "read it and it was
garbage", is. EBUSY, EMFILE, ENFILE and EIO say exactly as little about a file's
contents as EACCES does, and they fell into the branch that regenerates and
overwrites. On Windows EBUSY is the likelier of the two: antivirus holding a
credential open at the moment of a startup read produces it, which makes it a
commoner path to the same permanent loss than the ACL case that motivated the
original fix.

Still an allow list, deliberately: ENOENT keeps licensing a create, and a parse
failure keeps self-healing. The stores are built to recover from a truncated
write, and turning that into a refusal would trade a recoverable state for an
unrecoverable one on the startup path.

Also fixes the regression suite's own premise on an elevated runner:
makeUnreadable combined /inheritance:r with /grant:r, and that form keeps
%TEMP%'s inherited [SYSTEM, Administrators, user] as explicit ACEs on Windows
Server -- so the file stayed readable and all six assertions were vacuous. Same
split-the-invocation fix as the ACL suite's planter.

* test(windows): skip the preservation suite where a read cannot be denied

An elevated token logged in as the built-in Administrator reads straight through
a DACL that grants it nothing -- confirmed on the CI runner against both
BUILTIN\Administrators and BUILTIN\Guests, and with the grant split into its own
icacls invocation so the DACL really was the planted one. On such a host the
premise these tests rest on does not hold, and every assertion would pass while
proving nothing.

So probe once at module scope and skip rather than assert vacuously -- the same
trade the ACL suite already makes for its unelevated-only case. The gate stays
in the compound `<win32 check> && <flag>` form the win32 lane ratchet detects, so
the file stays registered in both lane lists.

Coverage is not lost where it counts: isUnreadableError has unit tests that run
on every platform and every host, and the stores' refusal is exercised in full on
any machine where a denial is reproducible -- which is every developer box.

---------

Co-authored-by: Orca Worker <orca-worker@localhost>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
2026-09-05 21:13:06 -07:00
OrcaWinandOrca Worker 975bbdedcc fix(windows): scan ports natively instead of encoded PowerShell (#17861)
* fix(windows): scan ports natively instead of encoded PowerShell

Microsoft Defender for Endpoint scored the relay's Windows port scan as
suspicious PowerShell plus network discovery (T1049). The command line was
`-ExecutionPolicy Bypass -EncodedCommand <base64>` around a
Get-NetTCPConnection/Get-Process join -- base64 next to a policy override is
the highest-weighted token pair on a PowerShell command line, and netstat only
ever ran as its fallback.

Invert the chain. `netstat.exe -ano` is now the primary reader and the owning
process name comes from the shared native process table, which exists to keep
PID lookups off PowerShell. The payload survives only as a last resort, and
without the override: execution policy gates script files, never `-Command`,
so nothing needed it (verified: `-ExecutionPolicy Restricted -Command` runs).

Drop `-p tcp` while inverting: on Windows that protocol name means IPv4 only,
so as a primary reader it would have hidden every `[::]` listener the payload
used to report. Names arrive as `sshd.exe` from the table and are published as
`sshd`, keeping the sshd filter and old clients' rendering intact.

Routes both spawns through runProcess, removing the file from the
child_process and windowsHide ratchets.

* fix(windows): read netstat state by shape and refuse a truncated table

Review of the port-scan inversion found two ways the new primary path could
be silently wrong, both of which would have kept the flagged PowerShell
payload running on exactly the hosts this change targets.

`LISTENING` is not in netstat.exe. It lives in System32\<locale>\netstat.exe.mui
and MUI selection follows the UI language, so the pinned-locale env in
relay-command-env.ts cannot reach it -- a German host prints `ABHOEREN` and the
word test parsed zero rows. The zero-listeners guard then read that as a
blocked reader and ran `Get-NetTCPConnection` every 12-30s forever, or returned
nothing at all where PowerShell is also restricted. Keep the word as the fast
path and, when it finds nothing over output that did contain TCP rows, re-read
by shape: only a listening socket has no peer. Measured on this host across all
four states present (LISTENING 47, ESTABLISHED 49, CLOSE_WAIT 29, TIME_WAIT
213): zero non-listening rows with a zero peer, zero listening rows without
one, and the same 47 rows parse after substituting the German state words.
Shape stays the fallback because `BOUND` also prints a zero peer.

Truncation was invisible: createOutputSink discards overflow, ProcessResult
carries no flag, so a capped read still exits 0 and its head still parses.
netstat orders IPv4 TCP, then IPv6 TCP, then UDP, so a host with tens of
thousands of TIME_WAIT rows would have lost every `[::]` listener -- the exact
loss dropping `-p tcp` exists to prevent, and one the zero-listeners guard
cannot see. Refuse the read instead. A `truncated` flag on the shared sink
would be cleaner and is left as a follow-up rather than widened into this PR.

Also: decline to wait on the shared process table once the request is aborted
(it takes no signal and must not be cancelled for other callers); note the
name lookup as best-effort, since a TTL-cached snapshot can hand a recycled
PID its previous owner name; log once on either fall-through, because both are
permanent and invisible when wrong; and drop a stderr assertion that any
PowerShell autoload banner would redden.

Correcting the cost claim in the previous commit: the aggregate win holds with
the native addon (netstat 21ms vs the retired payload 860ms at 532 processes),
not without it. The addon is optional, the snapshot TTL is 500ms and the scan
cadence is 12-30s, so a relay with no active agent pane never warms its own
cache and pays ~1.4s cold on the CIM path -- slower than what it replaced.

* fix(windows): log the port-scan fall-through on the relay diagnostic stream

Checked where this code actually runs before trusting the log. `console.warn`
did reach a file, but relayLogLine is the right call and the reasoning is worth
recording.

`scanWindowsListeningPorts` runs only in the detached relay daemon: relay.ts
returns early for --connect and --orca-cli, so PortScanHandler is reached only
through runRelayDaemon, and both launchers start it detached with a log file
(POSIX `> relay.log 2>&1`, Windows `1>relay.log 2>relay.err.log` via
Win32_Process.Create). installRelayLogRotation then wraps both streams into
relay.log, which is the file the documented diagnostics tail reads. Verified by
installing the real rotation over a temp path and reading the file back.

So the line surfaced -- but untimestamped, in a log whose format exists so
reconnect flaps can be correlated with the events around them (#7773).
relayLogLine is that format and the relay idiom in 41 other places, and
"since when has this host been stuck on PowerShell" is most of what this line
is for. The test spies on process.stderr to pin the stream and the ISO stamp
rather than just asserting something was called, since a fall-through logged
somewhere unread is the failure being guarded against.

Also fixes a comment that ended its own block early: `relay-*/relay.log` in a
doc comment contains `*/`.

* fix(windows): keep the dominant zero-peer state when reading a localized netstat

Shape alone promoted any zero-peer TCP row, not just listeners. `BOUND` and
`CLOSED` print a zero peer too, and on a localized host their state words are
exactly as unreadable as the listening one -- so a German host with listeners
plus one BOUND socket published a phantom listener. Reachable on an English
host too: with zero listeners a lone BOUND row is promoted AND, because the
result is then non-empty, it suppresses the blocked-reader fall-through.

Group the zero-peer rows by state word and keep only the largest group. A
transient BOUND or CLOSED socket cannot outnumber the listeners (51 against 0
on this host), so this removes the class rather than special-casing the words,
which would just be the localization bug again. An exact tie keeps every tied
group rather than guessing -- no worse than reading shape alone.

Verified against real netstat output: injecting a BOUND row into the localized
capture leaves the result identical to the English answer (47 rows, no phantom
65001). The new test has teeth -- reverting the grouping fails it and nothing
else.

Corrects two claims that were slightly wrong: the docblock said shape was the
fallback because BOUND prints a zero peer, which described the hazard without
saying it was unhandled; and a test comment said an English host "never sees a
bound socket", true only when it has at least one readable LISTENING row.

Also gates the fall-through log per reason instead of per module, so a host
that parses nothing today and truncates tomorrow reports both faults. Same
one-shot cost, and the vocabulary is two fixed strings so the set cannot grow.
That guard matters more than it looks: --log-file rotates stdout only, so the
file stderr can land in is unrotated.

* docs(windows): note the direction the zero-peer majority rule can fail in

The docblock described the tie case and stopped there, which reads as a
complete account of the limits when it is not: a majority rule inverts if the
majority is wrong, and enough transient zero-peer sockets would publish the
phantoms and drop the real listeners. Someone would reasonably have concluded
the rule was safe in both directions.

Trigger numbers and the repro stay in the PR discussion; the code only needs
the reader to know the rule has a direction, and the hatch (defer to the
PowerShell reader, which reads the state word instead of inferring it) since
that is the part a future editor would otherwise re-derive.

* ci(windows): run the real-netstat port scan suite in CI

The win32 suite only self-skips off Windows, so it passed vacuously in
every lane. Register it the way the cmd-shim suite is registered.

* test(windows): lower both child-process ratchets to the ground this PR took

Migrating the port scan off `node:child_process` onto `runProcess` drops
`src/relay/windows-port-scan.ts` from both allowlists, so both offender
counts fall by one. Each ratchet pins the count from below as well as
above, so a pin left above reality fails and re-opens room for the next
direct import to land for free.

* docs(windows): qualify the no-PowerShell claim on the netstat scan

The scan starts no PowerShell of its own, but no released relay carries the
optional `windows-process-tree.node` addon (only dev-channel-win-build.yml
builds it), so the shared process-table read falls back to a CIM scan that
forks one `powershell.exe`. The EDR win is the removal of the
`-EncodedCommand` / `-ExecutionPolicy Bypass` shape, not the elimination of
PowerShell. Comment-only.

* docs(windows): record the identity-reader follow-up and the perf table's addon

attachWindowsProcessNames reads only `name`, so it should move to
`readWindowsProcessIdentityTable` once #17866 lands -- on that PR's detailed
reader it would open per-process handles for a field it discards. The reader
does not exist on this branch, so the call stays as-is with the follow-up
recorded rather than pulling #17866 in.

The process-table perf table's two Toolhelp32 rows assume the optional
`windows-process-tree.node` addon. The desktop bundles it; no released relay
does, so on an SSH host the CIM row is the operative number. Comment-only.

* docs(windows): state the CIM scan as the relay's normal path, not a fallback

No released relay carries the optional `windows-process-tree.node` addon --
release-cut.yml has zero references to it and only dev-channel-win-build.yml
builds it -- so the PowerShell CIM scan is what every SSH host runs. The
call-site docstring read as a conditional fallback standalone. Comment-only.

---------

Co-authored-by: Orca Worker <orca-worker@localhost>
2026-09-05 21:12:59 -07:00
bfc6a262a7 fix(windows): read command lines from the kernel, not each process's PEB (#17886)
* fix(windows): read command lines from the kernel, not each process's PEB

MDE incident D scored Orca for suspicious memory activity: the vendored
`@vscode/windows-process-tree` recovered every process's command line by
opening it with `PROCESS_QUERY_INFORMATION | PROCESS_VM_READ` and chaining
three `ReadProcessMemory` calls through the PEB and
`RTL_USER_PROCESS_PARAMETERS`. On a 750ms/2s cadence over the whole table that
is the credential-dumping primitive, whatever the intent.

Windows 8.1 added `NtQueryInformationProcess`'s `ProcessCommandLineInformation`
class (60), which returns the same string as a kernel-built `UNICODE_STRING`
under `PROCESS_QUERY_LIMITED_INFORMATION` alone. Electron's floor is Windows
10, so every supported OS has it. The PEB reader stays behind a process-wide
latch that only `STATUS_INVALID_INFO_CLASS`/`NOT_SUPPORTED`/`NOT_IMPLEMENTED`
can set; a pid that merely denied a handle does not re-arm it, because
`PROCESS_QUERY_INFORMATION` implicitly grants the limited right and so cannot
be obtained where the weaker open already failed.

The same hunk drops `PROCESS_VM_READ` from `GetProcessMemoryUsage` and
`GetCpuUsage`, which acquired it and never read an address space.

Measured on Windows 11 (514 processes), counted in-process by swapping the
addon's import table entries for counting stubs, per CommandLine scan:
`ReadProcessMemory` 1128 -> 0, desired access 0x0410 -> 0x1000, p50 12.7ms ->
9.3ms. Command lines were byte-identical on every process both readers
recovered (376/376, 379/379 across runs), including a 24,068-character argv
with quotes, non-ASCII and trailing whitespace, and a WOW64 target. Three
processes that refused the old rights granted the new one; none went the other
way.

* chore(deps): refresh the windows-process-tree patch hash in the lockfile

* fix(windows): drop the PEB fallback and detect the unpatched prebuilt

Review of #17886 found three ways the reader could still perform, or silently
resume, the primitive it exists to remove.

The class-missing latch was a permanent, process-wide, one-way downgrade back
to the PEB read, and any single target returning STATUS_INVALID_INFO_CLASS /
NOT_SUPPORTED / NOT_IMPLEMENTED could trip it. On an EDR-hooked ntdll -- the
entire premise of this change -- a hook that does not recognise class 60 would
have restored PROCESS_VM_READ plus three ReadProcessMemory per pid per scan for
the life of the process, unobservably, on precisely the machines this was
written for. The fallback is deleted rather than guarded: GetProcessCommandLine
now returns false and leaves the command line empty, which callers already
handle, so the addon imports no ReadProcessMemory at all.

That absence is what makes the property checkable on the artifact. The
published 0.8.0 tarball ships a loadable prebuilt built from unpatched source;
it is node-addon-api, so a bare require() accepts it, allowBuilds is false and
CI installs with --ignore-scripts, and a rebuild that soft-exits on a Windows
file lock leaves it in place. Source-text guards could never see it.
windowsProcessTreeAddonReadsProcessMemory() checks the compiled binary instead,
and is wired into the install check, the rebuild, and the relay build.

The repair itself never worked: `git apply` run inside a work tree prefixes
patch paths with the cwd-relative prefix, skips what does not match, and exits
0, so the branch always fell through to its own post-check throw. The package
dir is always under the project root, while the fixture that covered it was in
%TEMP%, outside any repo. Blinding git with GIT_DIR fixes it, and the test now
runs inside a real work tree.

Also from review: bounds-check the returned UNICODE_STRING against the
allocation (not the size the second query clobbers) and cap the probe so a
bogus length cannot bad_alloc a whole scan; test NT_SUCCESS explicitly; value-
initialize ProcessInfo, which left `memory` as stack garbage -- measured, 82
processes reported the same bogus working set; and correct a comment in
windows-process-table.ts that still described the command line as a PEB read.

Re-measured on Windows 11 (543 processes): ReadProcessMemory 1128 -> 0, with
the symbol absent from the import table so the IAT hook finds no slot to
count; desired access 0x0410 -> 0x1000 on all 543 opens; p50 13.5 -> 12.3ms;
405/405 command lines byte-identical including a 24,087-character quoted
non-ASCII argv and a WOW64 target; 3 processes recovered only by the new path,
0 only by the old.

* chore(deps): refresh the windows-process-tree patch hash in the lockfile

* test(scripts): stage a script's local imports into the native-runtime fixture

ensure-native-runtime.mjs gained an import of windows-process-tree-gyp-rebuild.mjs,
but the fixture copied only the script itself, so every case in the suite died
with ERR_MODULE_NOT_FOUND before reaching its own assertions. copyScriptWithLocalModules
already walks a script's co-located imports for exactly this reason -- its own doc
comment names this failure -- so use it rather than listing files by hand.

The two Windows cases still fail here, on a missing node-pty ConPTY runtime that
also fails on main; this only stops a resolution error from standing in front of
whatever they were meant to catch.

* fix(windows): route a locked stale addon to the Windows file-lock message

`pnpm install` with Orca running aborted with a raw EPERM stack. The stale-binary
guard -- which deletes an addon that still imports ReadProcessMemory so a skipped
rebuild cannot use it -- ran outside the try whose catch classifies Windows file
locks, and whose message is literally "Close running Orca/Electron/dev processes
for this worktree": exactly this situation.

Measured rather than assumed: rmSync against a loaded (memory-mapped) addon throws
EPERM, and `force: true` does not help, since it only swallows ENOENT. Cold copies
of the same file delete fine. So the delete threw a page before the handler that
knows what it means.

Moving the guard inside the try is the whole fix; the classifier already matches
the EPERM text. The new case runs the real script against a temp project whose
stale addon is held open by a live child process, and fails against the old
placement with the raw `syscall: 'rm'` stack the report described.

* feat(windows): warn once when command-line recovery is refused host-wide

Removing the PEB fallback removed a total-defeat vector, but it left a cliff: if
NtQueryInformationProcess(ProcessCommandLineInformation) is refused -- a hooked
ntdll that does not know class 60 -- every command line comes back empty and
agent identity matching silently degrades to image names. The addon still loads
and still enumerates, so every health check the app has stays green. A cliff
nobody can see is the failure mode this area keeps producing.

The querying process is the unambiguous probe. A process can always open itself
with PROCESS_QUERY_LIMITED_INFORMATION, so its own command line coming back empty
means the query is refused for every process -- not that some target denied a
handle, which is normal for roughly a quarter of the table. Keying on our own row
rather than a fraction means no threshold to tune and no false positive on a
hardened box where most processes deny.

One warning per session, gated on the CommandLine flag actually being requested so
a future identity-only reader cannot trip it. The suite's own SELF fixture gains a
command line for the same reason: a self row without one is the alarm, not a
detail.

* fix(windows): check the relay's staged addon at load, and answer tri-state

Two gaps in the ReadProcessMemory check, both about what it does not see.

It only ever looked at node_modules/@vscode/windows-process-tree. A relay host
has no node_modules of ours: it loads ./windows-process-tree.node staged beside
the bundle. The relay build asserts the symbol on the artifact it produces, but a
bundle and the addon beside it redeploy independently, so a host that has not
taken a new bundle keeps whatever binary is already there -- and the published
prebuilt is node-addon-api, so it binds cleanly and then walks every process's
address space. loadWindowsProcessTree now checks that file too and refuses it,
falling back to the CIM scan: slower, but not the thing an EDR quarantines a host
for. The predicate is duplicated rather than imported, because the config-script
copy is install-time tooling that drags in node-gyp and child_process, and this
module is bundled into the app and the relay.

And it returned false for a binary that is not there. All three callers happened
to be safe, but the name read as a safety predicate, so a future caller would take
a missing binary as verified. inspectWindowsProcessTreeAddon() now answers
clean/unpatched/missing over an explicit binary path -- which is also what lets
the relay's staged addon be checked at all -- and each caller states which state
it acts on.

Both are covered by cases that fail against the old code: without the load-time
check the unpatched staged addon is bound and the CIM fallback never runs, and
with 'missing' folded back into 'clean' the absence case fails outright.

* test(windows): load the addon in beforeAll, not at collection time

loadAddon() ran while the file was being collected, so on a Windows checkout with
no built addon the require threw before any case existed and took the seven
patch-text cases down with it -- cases that read only the patch file and need no
binary at all. Verified both ways against a deliberately unresolvable addon path:
at collection time vitest reports "no tests" for the file; from beforeAll the
seven text cases pass and only the three addon cases go.

* fix(deps): normalize the windows-process-tree patch to LF and let pnpm own its hash

`pnpm install --frozen-lockfile` failed on this branch on every platform with
ERR_PNPM_LOCKFILE_CONFIG_MISMATCH, which breaks CI and the release build.

Two coupled defects. The patch file was committed with CRLF -- 174 CR bytes,
against zero on main -- and `.gitattributes` pins `/config/patches/*.patch -text`
precisely so checkout cannot convert it, so those bytes reached every runner. And
pnpm hashes a patch **LF-normalized**, so the raw sha256 of a CRLF file is a value
pnpm never computes:

  raw sha256      322965470c05f63d8527f7d8e892ee26ee444136b66b57fd64c362a9f2ff05d1
  LF-normalized   f8ea245391c94da5770045aeea01fa6de466c2199c6ef46b5b769b398aa9823e

The lockfile carried the raw one, at all three sites. It is the only one of the
seven patches where the two digests differ, which is why the other six passed.

Normalized the patch to LF and took pnpm's own value from
`pnpm install --no-frozen-lockfile`; nothing here is hand-computed. With the file
LF-only the two interpretations coincide, so the lockfile, the contract test's
no-CR assertion and its hash assertion all agree at one number -- and
`config/scripts/windows-process-tree-patch-contract.test.mjs`, which was red on
this branch for the same reason, is green again. The lockfile diff is exactly the
three hash lines.

The regression check is the installer, not a digest. Two separate reviews
"verified" the shipped hash by recomputing sha256(patchBytes) and matching the
lockfile; both were wrong, because both repeated the same wrong assumption about
which bytes pnpm hashes. A check that reproduces the original mistake is not
independent. So the new case runs `pnpm install --frozen-lockfile --lockfile-only
--ignore-scripts` against a copy of the manifest, lockfile and patches, and
asserts exit 0 -- verified by deletion: restoring the shipped hash fails it with
the exact ERR_PNPM_LOCKFILE_CONFIG_MISMATCH from the branch's package (windows)
job.

Also corrected the `.gitattributes` comment claiming pnpm hashes patches
byte-for-byte. The `-text` setting is right -- `git apply` needs the exact bytes --
but that sentence is the claim that produced the wrong hash twice.

* ci(windows): run the process-tree patch suites in CI

Both suites only self-skip off Windows, so the binary-level check that the
addon carries no ReadProcessMemory passed vacuously in every lane.

* fix(windows): force core.autocrlf=input for the patch repair

My LF normalization of the windows-process-tree patch broke the `git apply`
repair path introduced in this PR. The two are coupled and I checked only one.

Those 174 CR bytes were not editor noise. They sat on exactly the pre-image
lines and nowhere else -- 107/107 in src/process.cc, 67/67 in
src/process_commandline.cc, 0 on every added or context line -- because
@vscode/windows-process-tree@0.8.0 ships those two sources as CRLF. Normalizing
the patch made its pre-image stop matching the file it is applied against.

Measured, reconstructing the true CRLF pre-image from the pre-normalization
blob and applying the current LF patch:

  core.autocrlf   plain   -c core.autocrlf=input
  true            exit 0  exit 0
  input           exit 0  exit 0
  false           exit 1  exit 0

`false` is Git's own built-in default and what "checkout as-is" selects in the
Git for Windows installer -- on this box the `true` that hides it comes from the
installer's system gitconfig, not from anything in the repo. There the repair
throws, ensureWindowsProcessTreeCommandLinePatch reports "still reads the PEB,
and repairing it ... failed", isWindowsNativeLockError does not match that text,
and `pnpm install` dies with no path forward.

Forcing the mode rather than `--ignore-whitespace`: both fix every cell and both
leave the applied file fully LF, but `input` relaxes line endings only, so a hunk
whose real content drifted is still rejected. The repair rewrites a
security-relevant source file; it should stay strict about everything except the
thing that is legitimately ambiguous.

Not reverting the patch to CRLF: windows-process-tree-patch-contract.test.mjs
(pre-existing on main) forbids CR bytes in it, and pnpm computes the same hash
either way. LF plus the forced mode is the end state.

The suite could not have caught this. The fixture built its pre-image from the
patch itself and joined with '\n', so fixture and patch agreed by construction on
any encoding -- once again a test that passes without its fix. It now emits the
CRLF the real package ships, and the case runs under both autocrlf modes pinned
through a temp HOME gitconfig, because the repair blinds git to the repo and so
reads global config. Verified by deletion in both directions: with the flag
removed the autocrlf=false case fails with the exact "still reads the PEB" dead
end while autocrlf=true still passes, and with the fixture back on LF all eight
cases pass with no fix present at all.

Also corrected the .gitattributes comment I added last commit. It said `git
apply` needs the bytes the patch was written against, which is now false -- the
pinned bytes are LF and the bytes it was written against are CRLF. That is the
same class of confident-and-wrong claim that produced the bad hash twice.

* fix(windows): assert the rebuilt addon, and install the patch for real in tests

Three follow-ups from review.

**The packaged binary had no check.** The relay build asserts its own artifact
and ensure-native-runtime asserts what it loads, but nothing looked at the addon
copied into the packaged app -- so a rebuild that silently produced the upstream
reader shipped. `rebuild-native-deps.mjs` now asserts `clean` on it after
`rebuild()`. This is also the caller D4's tri-state was missing: every existing
site branches on `=== 'unpatched'`, so `missing` still behaved exactly like
`clean` everywhere, which was the thing making it a state rather than a boolean.
Here both non-clean states fail, and they fail differently: after a rebuild that
reported success, an absent binary is a broken build, not an absence to shrug at.

The fake `rebuild()` had to start producing a binary for that to mean anything,
so it now emits stand-in bytes and takes `addon: 'clean' | 'unpatched' | 'none'`.
Verified by deletion: with the assertion removed both new cases pass.

**The frozen-install case could not see a patch at all.** `--lockfile-only`
resolves and never applies one, so its coverage stops at hash consistency. Added
a case that installs `@vscode/windows-process-tree@0.8.0` for real with the patch
and asserts the materialized `src/process_commandline.cc` carries the marker and
no longer carries `ReadProcessMemory` -- about 1.5s for the pair.

Correcting the brief on that one: it does **not** catch the `git apply` breakage
from the previous commit. Measured -- with `-c core.autocrlf=input` removed it
passes cleanly, because `pnpm install` uses pnpm's own patch applier and never
runs our repair script. What it does catch is a patch pnpm can no longer apply:
corrupting one pre-image line fails both cases. The repair path stays covered by
the CRLF fixture in rebuild-native-deps-node-pty.test.mjs.

Worth recording, since it decides whether the LF normalization was safe at all:
pnpm applies the LF patch to the CRLF tarball sources without complaint, and
materializes them as LF with the marker present and `ReadProcessMemory` absent.
The primary install path was never affected -- only the `git apply` fallback was.

**Dead timeout.** The frozen-install case passed `timeoutMs: 300_000` to the
spawn while vitest capped the case itself at 30s, so on a cold runner vitest
would have killed it first. Both cases now declare the budget they use.

* test(windows): route the frozen-install check through the pnpm invocation owner

The new patched-dependencies check hand-rolled a PATH walk naming 'pnpm.cmd',
which the windows batch shim spawn boundary ratchet rejects: pnpm-cli-invocation
already owns that decision for every other script, and its allowlist only
shrinks.

Reuse resolvePnpmCliInvocation for the command and prefixArgs, and the shared
resolveCliCommand for the presence check, so no shim name is spelled here. Its
`shell` flag is dropped because runProcessSync refuses it and already drives a
shim through the interpreter itself.

---------

Co-authored-by: Orca Worker <orca-worker@localhost>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
2026-09-05 21:12:47 -07:00
OrcaWinandOrca Worker cff202c16a fix(windows): drop EDR-flagged -ExecutionPolicy Bypass from encoded PowerShell (#17880)
* fix(windows): drop EDR-flagged -ExecutionPolicy Bypass from encoded PowerShell

MDE flags `-ExecutionPolicy Bypass` paired with base64 `-EncodedCommand` as a
behavioural signal. Measured on Windows 11: neither `-Command` nor
`-EncodedCommand` is execution-policy gated (both run under an explicit
`-ExecutionPolicy Restricted` and `AllSigned`; only `-File` fails), so the
switch was a pure no-op on every one of these command lines.

Removes the switch from all four sites that spelled it, and de-encodes the one
site whose payload never passes through a re-parsing shell:

- ssh-remote-powershell: one chokepoint for ~40 remote-Windows call sites.
  Base64 kept — the remote sshd DefaultShell re-parses this string.
- setup-agent-sequencing / windows-cmd-runner-delayed-launch: base64 kept —
  these strings are typed into a terminal pane.
- windows-interactive-login-spawn: base64 kept — `cmd.exe /c start` re-parses,
  and the cmd-safe-token guard rejects the `&` and `"` in the raw relay script.
- windows-mobile-firewall local runner: `-EncodedCommand` -> `-Command`, since
  execFile reaches CreateProcess with no shell in between.

The setup startup gate keeps execution-policy relief in-payload (process scope),
because it evals a user-authored startup command that may invoke a `.ps1`, and a
`.ps1` IS gated. Caught by the real-process suite; mirrors the agent-hooks
launcher's trade.

The elevated firewall child deliberately stays encoded: `Start-Process
-ArgumentList` joins its array into one ShellExecuteEx string without quoting
and PowerShell re-splits on whitespace, measured to collapse `C:\My  App\...`
to `C:\My App\...` — a firewall rule for the wrong program.

* test(ssh): enforce the no-script-file invariant remote payloads rely on

Dropping `-ExecutionPolicy Bypass` from `powerShellCommand` is a no-op only
while no remote payload loads a PowerShell script file — execution policy has
never gated anything else. That invariant held by inspection and was guarded by
nothing, so a future payload that dot-sourced, used `-File`, or imported a
`.psm1` would break only on a remote host with a Restricted/AllSigned
LocalMachine policy and no GPO: a failure on someone else's machine.

States the invariant at the wrapper, and adds a ratchet that scans every module
importing it for `.ps1`/`.psm1`, `Import-Module`, `-File`, and dot-sourcing.
The scan discovers importers itself (13 today) so new ones are covered, and
asserts it found some, so an emptied list cannot pass vacuously.

Mutation-checked: injecting each construct into a real importer fails the
matching case and names the file. The first dot-source pattern passed a
`;`-prefixed sample but missed `powerShellCommand(". '$x'")` — the likelier
shape — so the pattern now accepts a string-literal start and the self-test
samples carry their surrounding quotes.

* test(ssh): close two blind spots in the remote-payload ratchet

Both found by independent mutation testing of the ratchet itself, and both let
a real violation pass while the guard reported green.

`-File` was matched case-sensitively, so `-file $scriptVar` slipped through —
PowerShell switches are case-insensitive, and with a variable path the `.ps1`
pattern does not cover for it, so that shape escaped both nets. The naive fix
is wrong: bare /-File\b/i matches `--credential-file`, `--log-file` and
`--body-file`, which occur in three of these importers. Anchoring to a token
boundary catches the lowercase, odd-spacing and argv-element forms with zero
offenders across all 14.

Comment stripping paired a `/*` appearing inside a string (a glob such as
'src/*.ts') with any later comment close and deleted everything between, hiding
violations in the gap. Anchoring the block strip to line start, as the `//`
strip already was, fixes it — verified by injecting an `Import-Module` after a
glob string: the unanchored form misses it, the anchored form catches it.

Extends the same case-insensitivity to `.ps1`/`.psm1` and `Import-Module`,
which had the identical flaw (`import-module`, `DEPLOY.PS1` are legitimate
spellings); measured to add no false positive.

Each construct now carries the fixtures it must catch AND the near-misses it
must not, so a future tightening cannot quietly trade one for the other — the
negative fixtures are what would have caught the naive `-File` fix. Non-vacuity
bound tightened to >10 against 14 importers.

* docs(ssh): state what the remote-payload ratchet cannot see

The scan matches source text, so a script file reached only through a variable
(`& $scriptPath`) never appears in source and no pattern can catch it. The
ratchet narrows the hole; the invariant note on `powerShellCommand` covers the
remainder.

Recorded because a guard that reads as complete coverage when it is not is
worse than one that states its edge: the next author trusts it further than it
deserves, and should learn this limit from the test rather than an incident.

* test(ssh): scan remote payloads with the shared source walk

The ratchet had its own tree walk and comment stripper. The walk skipped
neither node_modules/dist/.git nor dot-directories and excluded tests by
`.test.ts` alone, so its importer count -- the guard's own goalpost -- could
be wrong about what it scanned. The stripper was anchored to line start to
dodge a `/*` inside a glob string, which silently skipped trailing comments;
`stripComments` tracks quote state and handles both.

Importer set re-derived against the shared walk: 15, floor unchanged at 10.

* fix(setup): report a failed execution-policy relief instead of swallowing it

The in-payload Set-ExecutionPolicy carried -ErrorAction SilentlyContinue and
an empty catch, so any failure vanished. A Windows PowerShell 5.1 install with
duplicate extended type data fails every cmdlet in Microsoft.PowerShell.Security
-- autoload, not policy -- and the user then saw only their own .ps1 being
refused, with no trace that the relief had been attempted or why.

-ErrorAction Stop is what routes a non-terminating failure into the catch at
all; the catch reports the FullyQualifiedErrorId to stderr and deliberately
does not rethrow, so a broken policy cmdlet cannot take down the startup this
gate exists to run. Success path is unchanged and stays stderr-clean.

Verified by execution on a clean child environment: success -> policy=Bypass,
stderr empty; shadowed failing cmdlet -> diagnostic on stderr and the gate
still continues; the old empty catch -> silent.

---------

Co-authored-by: Orca Worker <orca-worker@localhost>
2026-09-05 21:12:40 -07:00
OrcaWinandOrca Worker 687a22e1ee fix(computer-use): run the Windows runtime as one persistent helper (#17858)
* fix(computer-use): run the Windows runtime as one persistent helper

Microsoft Defender for Endpoint raised multi-stage Execution + Collection
incidents against Orca on Windows ("Screenshots were taken unexpectedly on
this device... Screen capture code was found in a script launched by
powershell.exe", factor "Executes suspicious MSIL code"). The desktop script
provider spawned a fresh powershell.exe per operation, so a single computer-use
session produced a burst of short-lived PIDs and re-emitted runtime.ps1's
inline Add-Type P/Invoke assembly on every click.

runtime.ps1 gains a -Serve mode that loads its assemblies once and then reads
NDJSON requests from stdin, and a new DesktopScriptRuntimeHost owns one
long-lived child: lazy spawn, strict serialization, a 30s per-request timeout,
restart on crash, a 120s idle shutdown, and dispose() on provider teardown. The
one-shot -OperationPath path stays as the fallback, and Linux keeps its python3
bridge unchanged.

Both Windows spawn sites now use -ExecutionPolicy RemoteSigned instead of
Bypass, falling back once to Bypass (and logging) when a Restricted host
refuses the unsigned script.

* fix(computer-use): recover the runtime host instead of latching it off

Review follow-up on the persistent Windows computer-use helper.

A helper that died before producing a line set an unavailable flag nothing ever
cleared, and the client then dropped the host for the life of the session. One
transient bad spawn — a Defender scan, a locked CSC temp directory — silently
restored the per-click powershell.exe burst and per-operation MSIL emission this
work exists to remove, with computer use still working so nothing looked wrong.
Start failures are now retried, then cool down for 60s, then re-probed; the
client keeps the host so it can come back. Repeated post-answer crashes cool
down too, and a single reply no longer clears the failure count.

The one-shot bridge decided its execution-policy retry from a message that fell
back to stdout, so a window title containing "SecurityError" could replay a
non-idempotent operation — a double click, keystroke or paste — and stick the
session on Bypass. The retry now requires empty stdout and a matching stderr.

Serve-mode replies carry an echoed request id. Without one a single stray stdout
line would make every later response answer the previous request, acting on
stale element indexes with no error raised; a mismatch now kills the child.
Non-JSON noise is ignored rather than counted as the helper having answered.

Also: warnings reach the main process over the sidecar's IPC channel rather than
its piped, unread stdio; the child is watched on close rather than exit; dispose
latches so a queued request cannot respawn during teardown; and the host is
split into a serve channel and an availability policy to stay under max-lines.

* fix(computer-use): prove a helper never started before replaying its request

The retry that replaced the permanent-latch bug could deliver unrequested
input. send() re-sent the same request whenever the helper died without
replying, but "no reply came back" is not "the operation did not run":
runtime.ps1 synthesizes the click and only then builds the snapshot, which
allocates a full-window bitmap and walks the UIA tree — a native GDI+/UIA fault
there is uncatchable, and leaves the click already delivered. A deterministic
fault meant three clicks from the host plus a fourth from the one-shot bridge,
surfaced as a single failed operation.

-Serve now writes one {"ready":true} line after its Add-Type work and before
its first read, so "never started" is a fact rather than an inference. A request
is replayed only when the helper died before announcing. A runtime.ps1 that
predates the announcement — reachable through the provider path override — is
covered by an observation-tool allowlist until a ready line proves otherwise.

Host-detected aborts (timeout, desynchronised reply, oversized line) suppress
the exit handler, so they were bypassing failure accounting entirely and a
helper failing that way was respawned once per operation forever. They now
count and are logged.

Also stop charging twice for one outage: entering the cooldown resets the
failure count, so the first death after recovery no longer re-enters a full
cooldown and an interleaved workload cannot be stranded on the one-shot bridge.

* fix(computer-use): ignore a stdin write callback from a torn-down helper

stop() destroys stdin, so a write still queued at teardown calls back with
ERR_STREAM_DESTROYED. The callback carried no channel or request identity and
write() had no closed guard, so it ran abortChannel a second time: stopChannel
no-opped but recordFailure and the warning did not, charging two failures for
one operation and reaching the 3-strike cooldown at half the intended rate.
That feeds the same accounting that keeps a persistently broken helper from
respawning once per operation.

The same root also allowed a late callback landing after a replacement channel
existed to stop that channel and reject a different request with the previous
one's error. Node fires the destroyed-stream callback on the next tick, well
before a new request arrives, so the double-count is the reachable effect;
binding the callback closes both.

write() now drops payloads and error reports once closed, and the host ignores
any report whose channel or request id is no longer current.

* test(computer-use): pin each stale-write guard independently

The channel's closed guard and the host's request-identity check are redundant
by design, and the existing tests only failed when both were absent. Someone
deleting one, believing the other was the covered one, would have got a green
suite and a live regression — the same shape as a test that passes without the
fix it was written for.

Each is now pinned on its own. The channel's half is tested against the channel
directly: after stop() it takes no writes and reports no error from one already
queued, which the host cannot observe because it drops the channel at the same
moment. The host's half is pinned by the case the channel cannot see — a live
channel whose request was already answered, where backpressure delivers a write
callback for a request that is no longer pending.

Removing either guard alone now fails a test. Both carry a comment saying they
are deliberately redundant and separately pinned, so the next reader does not
have to rediscover this from the diff.

* ci(windows): run the computer-use runtime host suite in CI

The win32 suite only self-skips off Windows, so it passed vacuously in
every lane. Register it the way the cmd-shim suite is registered.

* fix(computer-use): time the runtime host cooldown on a monotonic clock

The start-failure cooldown was a wall-clock deadline, so a backwards step —
an NTP correction, a VM snapshot restore, a user changing the clock — left
`remainingCooldown()` returning the cooldown plus the whole step. A one-hour
step measured 3,660,000ms, and ten real minutes later still 3,060,000ms.

Nothing shortens it from there. Only `recordSuccess()` clears the cooldown on
a non-dispose path, and no request can reach a helper to succeed while it
holds, so every `send()` throws `runtime_host_unavailable` first. The host is
built with no `now` override and its lifecycle is a module-level singleton
that shuts down at process exit, so the latch held for the sidecar's life —
computer use kept working via the one-shot bridge while the per-click
powershell.exe burst this host exists to remove came back silently.

Store the instant the cooldown began and compare elapsed monotonic time,
following the two fixes in #17884. The field is `number | null` rather than
sentinel 0 because `performance.now()` legitimately returns 0.

Both new tests leave `now` unset, because the bug was in the default the host
picks and a test that injects a clock cannot see it.

* fix(computer-use): give a queued request its own deadline

The 30s request timeout was armed only in `sendOnce`, once a request reached
a helper. A request behind N timing-out ones therefore waited roughly N times
that with no deadline of its own: bounded, but the caller sees an `await` that
looks hung for minutes and gets no error to act on.

Move the serialization tail into its own class and arm a deadline at enqueue
time. Only the wait is bounded — a request that reaches a helper still gets
its full execution budget, so nothing that used to succeed now fails. An
expired request is dropped rather than sent late: the caller has already been
told it failed, and a click delivered after that is worse than no click.

The tail keeps its never-rejecting shape and chains on the turn rather than on
the raced promise, so a caller giving up early cannot release the next request
while its predecessor is still in flight.

* fix(computer-use): stop reading a locked file as an execution policy block

`UnauthorizedAccess` is the FullyQualifiedErrorId PowerShell reports for a
policy block, and it is also a strict prefix of `UnauthorizedAccessException`,
which .NET raises for any ordinary locked or ACL-denied file. The predicate
matched the token unanchored, so an AV scan holding runtime.ps1 or a locked
CSC temp directory was read as a policy block.

Two consequences, both bad. `escalateExecutionPolicy()` has no path back, so
one false match spent the rest of the session on `-ExecutionPolicy Bypass` —
the exact command line token this stack exists to stop emitting. And on the
one-shot path `isPolicyBlockedStart` re-runs the operation: one-shot mode
writes stdout only after the operation returns, so a crash partway through an
action is indistinguishable from a helper that never started, and the click
lands twice.

Measured on Windows against all three records, which the test carries verbatim
as fixtures:

  policy/Restricted      FullyQualifiedErrorId: UnauthorizedAccess
  policy/RemoteSigned    FullyQualifiedErrorId: UnauthorizedAccess
  genuine access denied  FullyQualifiedErrorId: UnauthorizedAccessException

`\b` is the whole discriminator: between `s` and `E` both sides are word
characters, so no boundary exists there and the exception cannot match.

Dropped two alternatives that measurement showed were wrong. `PSSecurityException`
never appears — the record surfaces through a native-command wrapper and reports
`ParentContainsErrorRecordException`. The prose is wrong three times over: it
differs by policy, it is localized, and PowerShell hard-wraps it mid-sentence.

Anchoring on the `FullyQualifiedErrorId:`/`CategoryInfo:` labels would be more
precise again, but those labels are localized where the values are not, so it
would lose a real block on a non-English host and strand it with no fallback.
Matching the values with word boundaries keeps both directions; a fixture with
translated labels pins it.

The escalation stays sticky. With the predicate correct, it only fires on a
machine that really does block, where re-probing the preferred policy would buy
a guaranteed failed spawn per operation.

* fix(computer-use): route a malformed request back to the request that caused it

`ConvertFrom-Json` throws before `$requestId` is read, so the serve loop
answered an unparseable request with an untagged error. On the client that is
not an error at all: `deliver()` sees no matching id, calls `abortChannel`,
kills the helper and charges a failure — and the helper's own message is
discarded. A parse failure was reported as a stream desync with no trace of
the real cause, and three of them walked into the 60s cooldown behind three
misleading "did not match" messages.

Recover the id from the raw line when the parse fails. No wire change: the
response shape is untouched and `BridgeResponse.requestId` already documents
this echo. It is the same shape the helper already returns for `not_a_tool`,
where the id survives because it is read before the operation runs. Both
mixed pairings degrade safely — a new script with an old client resolves the
error normally, and an old script with a new client still aborts, but now
reports what the helper said.

When the line is mangled past recovering an id, the desync abort is the honest
outcome, so keep it and carry the helper's text into it rather than replacing
it. A line the helper could not tag is usually the only account of the cause.

Proven against the real `runtime.ps1 -Serve`: the host can only write
well-formed JSON, so the parse-failure branch is unreachable through it and
the test drives the channel directly.

* fix(computer-use): keep the Bypass escalation only when Bypass actually works

AppLocker and WDAC constrained language mode raise PSSecurityException under
the same SecurityError category a real execution-policy block uses, so the
predicate matches them - correctly, on the evidence available. But those block
the script at parse time, which `-ExecutionPolicy Bypass` cannot lift. The
escalation was sticky unconditionally, so on a WDAC host we misdiagnosed,
retried, failed again, and then latched: every later command line carried the
most heavily weighted MDE token there is, on exactly the hardened, monitored
enterprise machine that is watching for it.

Treat the escalation as the diagnosis it is. A fallback that cannot start a
helper either disproves it - the policy was not what stopped the first attempt
- so revert to RemoteSigned instead of latching. When Bypass does start a
helper the diagnosis is confirmed and it stays sticky exactly as before, so a
genuinely Restricted machine still never pays a re-probe per operation.

The revert lands inside the outage rather than only at its end, so a
misdiagnosis costs one Bypass command line instead of one per attempt, and an
escalation that never proved itself does not outlive the cooldown that ends
the outage. Deliberately not a permanent "fallback is useless" flag: a Bypass
attempt that failed for a transient reason would then disable the fallback for
the session, which is the same latch in the other direction.

Only `runtime_host_unavailable` proves no helper started, so only that reverts;
a helper that started and then died proves Bypass works. That also makes the
policy branch reachable on a final attempt for the first time, so it now
rejects as unavailable rather than a generic error - that code is what routes
the operation to the one-shot bridge, which carries its own policy fallback,
and without it an all-blocked host would fail operations outright instead of
degrading. The pre-existing "reports itself unavailable when Bypass is also
refused" test pins that.

---------

Co-authored-by: Orca Worker <orca-worker@localhost>
2026-09-05 21:12:33 -07:00
OrcaWinandOrca Worker fba90e017c fix(windows): copy the daemon host exe verbatim instead of renaming it (MDE T1036) (#17865)
* docs(windows): document the EDR signal surface

Six Microsoft Defender for Endpoint incidents fired against Orca 1.4.192 in
eight days on one enterprise Windows 11 / Intune tenant. All six were
behavioural process-tree scoring, not signature hits; two escalated to
multi-stage incidents mapped to ATT&CK Execution and Collection.

Add a reference doc mapping each attack-technique-shaped behaviour to the code
that produces it and to why it exists: the renamed daemon image (T1036), the
per-process PEB read, encoded policy-bypassed PowerShell (T1049), caret-escaped
cmd.exe lines, and computer-use screen capture plus runtime-compiled MSIL
(T1113). Records that signing is not the gate -- reputation is signer plus
hash-keyed prevalence -- and carries the two evidence gaps the report noted.

Adds an engineer checklist, deployment guidance for admins (AV path exclusions
do not suppress EDR behavioural alerts; an MDE alert suppression rule does), and
an explicit pre-deployment warning about computer use.

* docs(windows): correct the PowerShell flag inventory and admin paths

Review corrections to the EDR posture doc.

The "encoded, policy-bypassing PowerShell" list conflated three different
shapes and was incomplete. Split it into the three tiers an EDR actually scores
differently -- bypass plus encoding, encoding alone, and bypass alone -- and add
the sites it missed, including windows-mobile-firewall.ts, which encodes a
script and launches it elevated through Start-Process -Verb RunAs. system-fonts.ts
(-Command) and desktop-script-provider-bridge.ts (-File) were listed as encoded
and are not. Notes that a raw grep under-reports, because the hook sites reach
-EncodedCommand through wrapWindowsPowerShellEncodedCommand.

Attribute the in-payload Set-ExecutionPolicy move to #16576 rather than to
#16003's measurement, which keyed on -WindowStyle Hidden + -EncodedCommand, and
record that the launcher's own tradeoff is unverified on a real box.

Admin guidance was missing two ways a suppression rule pinned to one full path
misses real activity: the .staging-<hex> sibling that exists mid-update, which
is when the update-cluster incidents fire, and the userData fallback when
LOCALAPPDATA is unset.

Also: state the measurement conditions on the process-table timings, note that
Hermes has surface even though we have no telemetry for it, note that the
uninstaller names are electron-builder-generated and in no repo file, drop a
volatile line count, and mark the per-operation computer-use shape as being
addressed by an unmerged change. Drops the duplicated AGENTS.md section, keeping
the indexed bullet.

* docs(windows): reconcile the EDR posture doc with the shipped remediation

Three claims in this doc became false once the rest of the Windows EDR set
landed, and two told engineers the opposite of what the release does.

The process-table section still described one shared snapshot taken with
`Memory | CommandLine | CreationTime`, argued that splitting the cache per
field set "would restore exactly the fan-out it exists to prevent", and
concluded the shape was unfixable because "the information is only in the
PEB". The split shipped (identity opens no handle at all), `Memory` is
retired, and the command line now comes from the kernel through
`ProcessCommandLineInformation` -- `ReadProcessMemory` is absent from the
compiled addon and a ratchet asserts it against the import table. An engineer
reading the old text would have concluded both fixes were dead ends.

The PowerShell site inventories were stale in three of four lists: the port
scan went native, every `-ExecutionPolicy Bypass` + `-EncodedCommand` pair
was dropped as a measured no-op, and of the unencoded-bypass list only
`wsl-cli-scripts.ts` survives. Regenerated against the merged tree, including
the sites that reach the flag through `wrapWindowsPowerShellEncodedCommand`
and never spell it, which a raw `rg` misses.

Incident-evidence sections are left alone: they record what the tenant observed
on 1.4.192, not what the code does now.

* fix(windows): copy the daemon host exe verbatim instead of renaming it

Microsoft Defender for Endpoint flagged `orca-terminal-daemon.exe` as MITRE
T1036 (Masquerading): Orca copied its own `Orca.exe` into %LOCALAPPDATA% under a
different name, specifically so the NSIS updater's `taskkill /IM Orca.exe` could
not match, then ran it detached. Because that process is what every other flagged
action was attributed to, the name mismatch acted as a reputation multiplier on
unrelated findings.

The rename was never what made the daemon survive. In app-builder-lib 26.15.3 the
installer's FIND_PROCESS/KILL_PROCESS select processes whose image path is under
$INSTDIR; `taskkill /IM` is only the fallback for hosts where PowerShell is
missing or blocked. Survival is a property of the path, and
%LOCALAPPDATA%\Orca\daemon-host is outside $INSTDIR whatever the file is called.

Derive the host exe name from process.execPath so the copy is byte-for-byte,
name included — it keeps its Authenticode signature and carries no renamed-image
signal. On the no-PowerShell fallback the daemon is now killed with the app and
terminals cold-restore, which is the documented pre-relocation outcome the update
harness already asserts, not a regression.

The uninstall macro no longer needs a distinct name to find the daemon; it kills
the app's own image name (plus the legacy name, for hosts left by older builds).

Adds docs/reference/windows-daemon-host-relocation.md with the survival contract,
the rejected alternatives and their measured costs, and the invariants to keep.

* fix(windows): apply daemon-host relocation review corrections

Scope the uninstall taskkill to the current user with `/FI "USERNAME eq
%USERNAME%"` via cmd.exe, matching upstream's per-user KILL_PROCESS — without it
an elevated machine-wide uninstall reaches another logged-on user's session, so
the "no collateral" claim in the comment was overstated.

Comment the rmSync-before-publish: Windows refuses to delete a running image, so
a live daemon already hosted in this version's dir (same-version reinstall, or a
dev channel reusing a version) throws and materialization fails open.

Doc corrections:
- The fallback selector is the full per-user `taskkill /F /IM "<app>.exe" /FI
  "PID ne $pid" /FI "USERNAME eq %USERNAME%"`, not a bare `taskkill /IM`.
- The probe reads `Get-ExecutionPolicy -Scope Process`, not the effective policy,
  and GPO writes MachinePolicy/UserPolicy — so GPO-managed hosts take the primary
  path-scoped branch. Narrow the fallback triggers accordingly.
- Drop the Authenticode sentence: the old name was equally byte-identical and
  equally signed, so a filename has no bearing on signature validity.
- Name the new update-abort path: the daemon now matches FIND_PROCESS, so on the
  fallback branch an unkillable host reaches the retry loop's MessageBox /SD
  IDCANCEL and Quits, aborting a silent update.
- Correct the customCheckAppRunning rejection. It is ~6 lines, not a rewrite; it
  is wrong because forcing the PowerShell branch where PowerShell is absent makes
  FIND/KILL silently no-op and leaves the real app running with files in use.
- Bound the win honestly: OriginalFilename is empty on the shipped binary, so the
  strongest T1036 indicator never fired, and the residual copy-and-run-detached
  shape still maps to T1036.005.

Reconcile docs/reference/windows-edr-posture.md, which documents the rename as a
live finding and would otherwise contradict this change. Content-only edit:
markdown under docs/reference/ is not oxfmt-formatted as a matter of practice and
nothing in CI gates it, so the file is left consistent with its neighbours.

* fix(windows): expand USERNAME in NSIS instead of spawning cmd.exe

The uninstall macro routed both taskkills through `"$SYSDIR\cmd.exe" /C` purely
so `%USERNAME%` would expand — two extra interpreter spawns on the uninstall
path, in a change whose whole point is not adding scored behaviour, and the
exact `cmd.exe /c` shape the new AGENTS.md EDR bullet warns about. NSIS reads
the variable itself with ReadEnvStr, so the spawns buy nothing.

Verified on Windows 11 that the generated command line does what the filter is
there for: a copy of cmd.exe running as orca-nonexistent-probe.exe (pid 34244)
was terminated by `taskkill /F /IM "orca-nonexistent-probe.exe" /FI "USERNAME eq
<user>"` — SUCCESS, exit 0, process gone.

Guarded on an empty USERNAME because the degenerate case is silent: taskkill
rejects an empty filter value outright ("The search filter cannot be
recognized") and kills nothing, which would leave exactly the orphaned daemon
this macro exists to reap. `*` is rejected as a filter value too, so there is no
branchless spelling. With no USERNAME to scope by it kills unfiltered, as the
macro did before the filter was added. Stack stays balanced: three pushes, two
nsExec pops, three restores.

Also strike the last stale row in windows-edr-posture.md's remediation table.
"Copying our own image under a different name" read as outstanding work; it is
done by this change, so the row now points at the relocation doc. Same class of
staleness as the section reconciled in the previous commit, and git would not
have flagged it either.

* fix(windows): port the daemon-host uninstall sweep into the live NSIS include

The uninstall macro this branch rewrote lived in config/nsis/daemon-host-uninstall.nsh,
which main no longer includes: #17906 consolidated every Windows installer hook into
config/nsis/orca-installer-hooks.nsh because electron-builder accepts exactly one
`nsis.include`. Merged as-is, the rewritten macro would have been dead code while the
shipped uninstaller kept running main's stale sweep — `taskkill /F /IM
orca-terminal-daemon.exe`, which matches nothing now that the relocated host is a
verbatim Orca.exe copy. The RMDir that follows then cannot delete the running image, so
a live orphaned daemon and its ~224 MB tree would survive every uninstall.

Ported into the live include: the ${APP_EXECUTABLE_FILENAME} kill, the USERNAME filter
that keeps an elevated machine-wide uninstall out of another logged-on user's session,
and the register save/restore around both. The legacy orca-terminal-daemon.exe kill
stays so hosts left by older builds are still reaped.

The ratchet that was meant to catch exactly this pinned only the legacy image name,
which main's stale macro already satisfied, so it passed both ways. It now asserts the
app-exe kill and the USERNAME filter, against comment-stripped script — the prose above
the macro names both image names, so a toContain over the raw file proves nothing.

---------

Co-authored-by: Orca Worker <orca-worker@localhost>
2026-09-05 21:11:28 -07:00
Neil 64374d5dff perf(cli): skip impossible typo distance comparisons (#18977) 2026-09-05 20:04:26 -07:00
Neil 37427bfd1a perf: remember equivalent session tab source identities (#18976) 2026-09-05 20:04:21 -07:00
Neil 78e3721c23 perf(palette): reuse allowed quality arrays during matching (#18966) 2026-09-05 20:04:16 -07:00
Neil 8ed81ceb8d perf(tabs): index saved tab order during hydration repair (#18964) 2026-09-05 20:04:11 -07:00
Neil 0a573ceac8 perf(browser): reuse decoded single-chunk upload buffers (#18960) 2026-09-05 20:04:07 -07:00
Neil 5cc432eead perf(ssh): reuse streamed response idle timers (#18956) 2026-09-05 20:04:03 -07:00
Neil 56626e7daa perf(ssh): reuse and release relay startup buffers (#18953)
* perf(ssh): reuse the searched relay startup prefix

* perf(ssh): release startup banners after relay readiness
2026-09-05 20:03:58 -07:00
Neil 445c1aeaaf perf(speech): reuse the model download idle timer (#18945) 2026-09-05 20:03:53 -07:00
Neil d969af9ecc perf(jira): preserve replacement attachment download singleflight (#18944) 2026-09-05 20:03:48 -07:00
Neil bf073b833e perf(skills): skip symlink probes beyond discovery depth (#18937) 2026-09-05 20:03:37 -07:00
Neil 6f28e019b5 perf(hooks): use native reverse search for transcript lines (#18936) 2026-09-05 20:03:33 -07:00
Neil d1e62419b6 perf(watcher): stop admitting stats after batch cancellation (#18931) 2026-09-05 20:03:28 -07:00
Neil 9b76ff9217 perf(explorer): avoid redundant dotfile path filtering (#18929) 2026-09-05 20:03:24 -07:00
Neil fb7b75d55d perf(cli): skip feature formatters during help and error startup (#18923)
* perf(cli): load error reporting without feature formatters

* test(cli): follow extracted error reporter in import guard

* chore(cli): track cli-error.ts in deferral equivalence baseline

The equivalence script restores TOUCHED files from the baseline rev to
rebuild the pre-deferral CLI. reportCliError/formatCliError moved from
format.ts into cli-error.ts, so the baseline arm must also drop
cli-error.ts (absent at older revs) or the old tree would still compile
against the new module.
2026-09-05 20:03:19 -07:00
Neil 388e9fb776 perf: avoid rescanning emitted source in analysis guards (#18920) 2026-09-05 20:03:14 -07:00
Neil 4e8e14424d perf: avoid splitting every path during file autocomplete (#18919) 2026-09-05 20:03:08 -07:00
Neil 295684dc6d perf: skip unrelated shared symlink probes during Git status (#18918) 2026-09-05 20:03:02 -07:00
Neil 4204bdf717 perf: avoid repeated Quick Open exclusion string allocations (#18916) 2026-09-05 20:02:57 -07:00
Neil a07d8fe19c perf: avoid file-to-file scans when selecting deletion roots (#18913) 2026-09-05 20:02:48 -07:00
Neil 7b44f3c0e3 perf: decode fragmented CLI replies without repeated scans (#18909)
* perf: decode fragmented CLI replies without rescanning accumulated text

* bench: require an explicit CLI framing baseline
2026-09-05 20:02:44 -07:00
Neil b55e0cffca perf(editor): reuse live Markdown search matches across unrelated renders (#18903) 2026-09-05 20:02:39 -07:00
Neil 5a33e2acb0 perf(editor): reuse Markdown source blocks while positioning review notes (#18895) 2026-09-05 20:02:34 -07:00
Neil 926f3ff585 perf(browser): assemble fragmented tunnel frames once (#18893) 2026-09-05 20:02:30 -07:00
Neil bf87b1290f perf(repos): avoid quadratic icon source scans (#18892)
* perf(repos): avoid quadratic icon source scans

* perf: avoid repeated malformed HTML icon scans

* bench: balance icon parser timing samples
2026-09-05 20:02:25 -07:00
Neil 1c41d59203 perf(relay): drain fragmented frame buffers in linear time (#18891)
* perf(relay): drain fragmented frame buffers in linear time

* style: follow block-body lint in relay buffer checks
2026-09-05 20:02:20 -07:00
Neil 681119dc05 test: isolate window mocks from inherited launch flags (#18989)
* test: isolate mocked window activation from inherited launch flags

* Preserve background window regressions added on main
2026-09-05 19:33:47 -07:00
Brennan BensonandMerge Sim 84432d3aa1 fix(native-chat): repair a structured chat tab permanently fenced by an inherited publication epoch (#18906)
* fix(native-chat): repair a structured tab fenced out by a returning publisher

A publication epoch is retired whenever another publisher takes over a worktree, and a
retired epoch is then rejected forever. But a live publisher can return after transient
interlopers - a `removed:` retraction, then a headless rebuild whose version restarts at
1 - and the structured tab publish inherits the worktree's existing epoch rather than
minting one, so it arrives under the blacklisted epoch and is dropped. The chat tab never
reaches the tab bar.

The fence is right to reject the frame: it cannot tell a returning publisher apart from a
delayed frame queued by a dead generation, whose version can outrank the live cursor. So
the drop is no longer final - it schedules one bounded, debounced authoritative
`session.tabs.listAll`, and only that census may revive an epoch, and only the one it
names current. Subscription frames stay fenced exactly as before.

* fix(native-chat): decay the structured tab repair cap and prune its state

The attempt cap latched: three transient RPC failures left `exhausted` set for the
renderer's lifetime, permanently hiding a chat tab behind a single console warning. It
now decays, so a worktree that has been quiet for a minute gets its full budget back.

The repair map was also missing from the sweep that drops publisher cursors for vanished
worktrees, leaking an entry per deleted worktree. Pruning it there required inverting the
repair lane's dependency on the inventory refresh, which is now injected.

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-05 18:45:34 -07:00