mirror of
https://github.com/stablyai/orca.git
synced 2026-09-25 08:02:31 +00:00
e2b70a5eba68416972f94de737f39fcd60fdeec7
10162
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
516d91e6bc | Update pull_request_template.md | ||
|
|
fab6e0d6e7 |
fix(mobile): scope optimistic workspace removal to the deleted host (#15424)
* fix(mobile): scope optimistic workspace removal to the deleted host A worktreeId repeats across hosts, so filtering the list on the bare id also removed the identically-named workspace belonging to the other host. Match on (worktreeId, hostId) through a named helper so the rule is testable. * fix(mobile): key host worktree rows consistently |
||
|
|
a61b39a9a6 |
fix(runtime): stamp a runtime's own project setups as local, and report remote status about the remote (STA-4792) (#15376)
* fix(runtime): stamp a runtime's own project setups as local, and report remote status about the remote (STA-4792) Two independent frame-of-reference bugs, both from code describing one machine while labelled as another. #15366 — projectHostSetup.* persisted the caller's host id verbatim. Those `runtime:<environment-id>` ids are minted by the calling client's own pairing store, so they name a machine only relative to that client. A client sending one is addressing this runtime, and runtimes do not proxy these calls onward, so the host it names is us. Storing the client's spelling made one machine look like a different host to every other client, hid its rows from them, and defeated the (projectId, hostId) duplicate check — two laptops paired to one server each created their own setup for the same checkout. Re-spell it as `local` at the RPC boundary. Rows written earlier keep their old stamp; readers already project `local` back to `runtime:<their-id>`, so the client-visible model is unchanged and no ids are rewritten. STA-4792 defect 4 — `status --environment <name>` hardcoded app.running:false to mean "no desktop on THIS machine" while every other field in the same object described the target, including a desktopWindowStatus echoed straight from it. The result contradicted itself and read as "that run was headless" when the remote GUI was up. `app` now describes the target, keyed off the one window status that requires a live renderer, and the result names its own subject so the frame can't be misread again. The remote pid is not knowable, so it stays null. STA-4792 defect 2 gets a regression test rather than a fix: routing already made the client remote, which is what stops a Windows destination being joined to the local cwd. The test pins the exact reported invocation. * fix(status): share the remote app projection with the SSH host passthrough, and name the version gap on project host setup Two review follow-ups. The SSH host passthrough answered `app.running: true` unconditionally for the Orca host a caller reached over SSH, claiming a desktop app even for a headless `serve`. That is the same defect as the paired-server path, one transport over, so the projection moved to shared and both now answer the question the same way. `--host runtime:<id>` routes project commands to a paired server, which means a client can reach a server that predates project host setup without meaning to. That answered a raw `method_not_found`, which reads as an Orca bug rather than a version gap; the CLI now names it the way the desktop already does. Reverted a third change: making the persistence duplicate check treat `local` and `runtime:*` as one machine. That assumption holds at the RPC boundary, where a `runtime:` host means the runtime being addressed, but not in the store, which also records independent provisioning metadata for machines that are not itself. An existing test covers exactly that, and it was right. The duplicate convergence therefore stays bounded to rows written after the normalization. |
||
|
|
7fa23438f4 |
fix(mobile): keep a desktop take-back from being undone by a passive viewport report (#15539)
A phone that stays subscribed after 'Take back this/all terminals' re-phone-fits the PTY and re-takes the presence lock on its next terminal.updateViewport, which iOS forces on app resume and on every reconnect. updateMobileViewport consulted only mobileDisplayModes, which reclaimTerminalForDesktop resets to 'auto' before returning, so the take-back had no suppression left. Treat an in-force desktop take-back the same as the existing desktop display mode: record the viewport, apply nothing. |
||
|
|
6415511a82 |
feat(remote): add a positional file read to the filesystem provider (#15517)
* feat(remote): add a positional file read to the filesystem provider Following a growing remote file means re-reading it from the top on every poll: the relay exposes only whole-file reads, so tailing an append-only log over SSH costs O(size) per tick. Adds fs.readFileRange plus a rangedReadVersion capability, and an optional readFileRange on IFilesystemProvider -- matching how lstat/ supportsQuickOpenSearch already declare degradable capabilities. Three deliberate choices: - The relay loops until the requested length is satisfied or the file truly ends, and REJECTS an over-cap request rather than clamping it. A clamped read is indistinguishable from EOF, so a caller advancing a cursor by bytesRead would silently skip data. - Bytes cross the wire base64-encoded. A range boundary can split a UTF-8 sequence at either edge, and a utf-8 round trip would substitute U+FFFD and shift every subsequent offset. - The provider throws a typed FileRangeReadUnsupportedError against an older relay instead of quietly falling back to a whole-file read. A tailing caller issues several reads per snapshot, so a per-call fallback is quadratic; callers probe supportsFileRangeRead once and snapshot instead. The response is validated before use -- a byte count disagreeing with the payload would shift every downstream offset while looking like success. Terminal-artifact reads/writes move to their own module, mirroring the relay's existing fs-handler-terminal-artifact split; the provider was at the max-lines ceiling and this was the cohesive piece to extract. * fix(remote): size the ranged read to what the relay writer can deliver The 4 MiB cap was justified against MAX_MESSAGE_SIZE (16 MiB), but that is the frame DECODER bound. Responses are gated by the writer's admission budget: a frame over DISPATCHER_CONTROL_QUEUE_MAX_BYTES (1 MiB) is demoted to the legacy-response lane, which is refused once the producer queue passes 2 MiB. A 4 MiB window is ~5.46 MiB of base64, so it was never admissible -- it came back as an opaque ResponseOverCapacity (-33008), which is neither of the PR's typed errors, and above ~1.4 MiB the outcome depended on unrelated queued traffic. Cap at STREAM_CHUNK_SIZE (256 KiB), the house per-frame budget for file bytes, which stays in the control lane unconditionally. Also: - Hoist the cap and offset validation into src/shared/file-range-read.ts so the client rejects an out-of-contract request locally instead of paying a round trip for an error that does not survive the wire as a type. - Validate filePath in the relay handler; a missing one threw a TypeError out of expandTilde despite the comment claiming hand-validated params. - Collapse the two fs.getCapabilities probes onto one cached fetch per multiplexer. They read one document, so probing per feature spent an extra round trip per connection and duplicated the eviction logic. - Reuse readFullStreamChunk instead of a second copy of the short-read fill. - allocUnsafe the window; only subarray(0, bytesRead) escapes, so a tailing poll no longer memsets the whole window per call. - Plain methods for readFileRange/supportsFileRangeRead rather than constructor-assigned arrows; both are unconditional, unlike downloadFolder. Tests: cover the dispatch path and fs.getCapabilities (neither was exercised), param validation at both boundaries, EOF at and past the end, and a full-cap read over a real RelayDispatcher. The transport guard fails at 4 MiB with the real -33008. * test(remote): pin the ranged-read cap to real control-queue headroom The cap comment claimed a full-cap window stays in the control lane "unconditionally" and the guard test only asserted one frame fits the lane, so a raise to 384-768 KiB stayed green while two concurrent full-cap responses would already overflow the shared control queue -- which for a response closes the client. Pin the two-deep headroom and state the real bound, including that widening the cap is a wire change against a host still advertising rangedReadVersion 1. Also cover the two behaviours the suite claimed but did not exercise: a regular file answers a full-cap read in one syscall, so the fill loop was untested (both mutations of readFullStreamChunk stayed green), and the merged capability document made the abort-does-not-evict guard load-bearing without any test reaching it. * fix(remote): harden ranged-read validation and retry |
||
|
|
d4a8da9fdc |
fix(browser): scope a native cookie import's clear to the domains it imports (STA-4797) (#15375)
* fix(browser): scope a native cookie import's clear to the domains it imports (STA-4797) A native import (Settings -> Browser -> Import Cookies -> From Google Chrome) cleared the entire target partition's cookie jar, keeping only the non-transplantable google.com family. Every unrelated site the user was signed into in that partition was silently signed out, with no warning before and no disclosure after. Importing three sites signed you out of every other one. The stated rationale -- mixing stale and imported cookies makes sites reject the session -- reaches only as far as the domains being imported. Beyond them a clear has nothing to reconcile. The file/paste path already did the narrow thing via replaceCookiesForImportedDomains; the two import paths simply disagreed about scope, and the narrow one is the defensible shape. The clear now covers only the domains the import writes, through one shared predicate: - browser-cookie-import-policy.ts: importedDomainScope() / domainIsInImportedScope() are exported as the single scope definition used by all three clears, so they cannot drift apart. - browser-cookie-import-clear.ts: removeTransplantableCookies takes a required importScope. It is not defaulted -- a default would be the whole jar again. The scope test runs before the removal-URL derivation, so an unaddressable cookie parked in an unrelated corner of the jar no longer fails an import that was never going to touch it. - The bulk clearData shortcut is gone, and clearData leaves CookieClearSession for the same structural reason 'set' already had. clearData clears by exclusion, so the only scope it can express is "everything except google.com" -- the defect. An include list is no better: it matches at the registrable-domain boundary, so it would still take host-only siblings the import does not replace, and a partial delete followed by a rejection would destroy them with no identity to restore from. The frozen per-coordinate plan is now the only removal path, and it covers the imported domains rather than the jar. - browser-cookie-staged-image-clear.ts (new): the staged image is a copy of the live jar that replaces it wholesale on the next cold start, so its DELETE FROM cookies WHERE NOT (<google>) was a second whole-partition wipe. Narrowing the live clear alone would have re-erased the partition one restart later. It now clears to the identical scope, through the identical predicate. The scope is named from the emitted plan, so the removal set is the write set. Google stays exempt by policy (STA-3811), unchanged. No wire or summary change: the summary's existing `domains` field already names the imported domains, which is now exactly the scope that was cleared. Tests: fixtures in this module start with an empty cookie jar, which is why a full gate stack passed a session-erasing defect before. browser-cookie-import- scope.test.ts uses a populated jar with a session for a site outside the import set, and reads the staged file itself so the restart path is observed rather than assumed. All three cases fail against pre-fix source. The real-Electron partition test now seeds both an in-scope stale cookie (still removed) and an out-of-scope live one (now survives). * refactor(browser): drop the unreachable removal-URL failure branch (STA-4797) Scoping the clear made the `Could not clear existing cookies` throw in removableCookieEntries dead: a cookie whose domain does not normalize is now skipped by the scope test above it, so nothing reaches the URL derivation with anything but an already-parsed hostname. Rather than leave a fail-closed branch that cannot fire — this module has been misread before when a dead safety leg looked like a live one — the impossibility is now structural. cookieRemovalUrl takes a normalizeCookieDomain output and returns a string: `new URL` cannot throw on a host that already parsed as one, and assigning pathname never throws. Both callers lose their null branch, including the silent `if (url)` skip in replaceCookiesForImportedDomains, which would have narrowed a removal plan without saying so. The identically-worded throw in assertClearIdentitiesCoverRemovable is untouched — that one is live and is what keeps the mutated set inside the restorable set. * test(browser): re-anchor the native concurrency detector on the scoped clear (STA-4797) #15095's detector read "has the second import started clearing yet?" off clearData call counts. Scoping the clear removed the bulk clearData path, so that signal is gone and the assertion measured nothing. It now reads the same question off the removals themselves, which is strictly more specific: the seeded jar holds a stale cookie for each import's own domain, so `remove:old-a` present with `remove:old-b` absent proves the first import cleared and the second has not — where a call count could not tell the two apart. The completed run then pins the exact removal sequence, which also records that each import clears only its own domain. The seed had to move onto the imported domains for the same reason its own comment already gave for not leaving the jar empty: under a scoped clear, a jar holding only an unrelated site is the empty-jar case wearing a disguise -- the clear returns having removed nothing and every assertion passes vacuously. Mutation-checked: with the per-partition lock removed this test still fails, so #15095's protection is intact and the re-anchoring did not hollow it out. * fix(browser): merge staged cookie imports by domain scope (STA-4797) |
||
|
|
fcdbcf85d0 | fix(terminal): keep a cold-parked pane's runtime-graph leaf while its PTY lives (STA-2854) (#15514) | ||
|
|
7675da363e | fix(remote): stop paired-tab resurrection, ghost agent rows, and frozen visible panes (STA-4593) (#15459) | ||
|
|
0a853a5c0d |
fix(agent-hooks): stop backgrounded Claude sessions posting a stale pane key (STA-4769) (#15304)
* fix(agent-hooks): stop backgrounded Claude sessions posting a stale pane key (#9236) A session started with `claude --bg` or `/background` runs in a worker under the shared daemon, and that worker inherits the environment of whichever pane first started the daemon — not the pane that dispatched it. ORCA_PANE_KEY there names an unrelated pane, so the session's hooks overwrite that pane's sidebar row, from any worktree. It fails silently and successfully: the script re-sources the endpoint file, so port and token self-heal and the POST lands, while the pane key has no refresh path and stays wrong. Measured on the wire against a throwaway listener: three sessions on one daemon, and the one dispatched from pane B posted pane A's key and pane A's worktree on SessionStart, UserPromptSubmit and Stop. CLAUDE_JOB_DIR is set only in those workers — absent from all 68 live foreground sessions on this machine — so it is the signal to decline. Declining is the only option that exists: normalizeHookPayload rejects an absent paneKey outright and AgentHookEventPayload.paneKey is a required string, so there is no "session with no pane" representation to post instead. A backgrounded session genuinely has no pane; attributing it to nothing is correct. The Windows guard exits rather than jumping to the stdin drain: the drain parks in more.com and a daemon worker is outside an Orca pane, which is exactly the abandoned-stdin hang #11549 guards against. * fix(agent-hooks): guard the Claude statusline against the same stale pane key The statusline command IS invoked inside a backgrounded worker — measured, with no client ever attached, and its ancestry terminates at the daemon rather than at any pane: statusline pid=41746 <- claude bg-spare <- claude bg-pty-host <- claude daemon CLAUDE_JOB_DIR=/tmp/.../jobs/f1f9edd2 A second session dispatched from a different pane saw the first pane's ORCA_PANE_KEY with its own correct session id, so this script is a live second producer of the same misattribution the hook guard closes. Windows uses exit /b 0 before stdin is owned, per the #11549 contract; POSIX places the guard after capture, since exiting mid-write there surfaces as EPIPE the agent can see (#8110). |
||
|
|
15d2e31777 |
ci: bound the shell-contracts apt install so a stalled mirror cannot wedge the run (#15532)
* ci: bound the shell-contracts apt install so a stalled mirror cannot wedge the run shell contracts wedges intermittently. It is not a lock, not a prompt, and not the PR under test - it is download throughput with no wall-clock bound. Measured on a passing run: apt-get update fetched 11.4 MB of index in 40s, then apt-get install fetched 8.9 MB of packages at 65 kB/s taking 2m17s, while the shell-contract tests the job exists to run took 14s. apt applies no wall-clock bound to a stalled mirror, so when throughput drops below that already-poor baseline the step runs indefinitely - observed at 12+ minutes and climbing while every other job had passed. The job also had no timeout-minutes, so it inherited GitHub's 6h default, and an in-progress required check holds the whole run open and blocks rerun --failed. Bounds both layers: timeout-minutes on the job (a passing run is ~4m30s), and Acquire timeouts plus retries in apt.conf.d so a dead mirror fails fast while a transient blip still passes. Written to apt.conf.d rather than onto the command lines because pr-workflow-parallelism.test.mjs parses those invocations. Does not make the job faster; the 3m38s of download is untouched. Scoping the index refresh to just the PPA risks installing against a stale base index and wants its own evidence. * ci: bound the apt commands by wall clock, not per-connection timeouts The first attempt at this set Acquire timeouts of 30s with 3 retries. That made the wedge worse and the job's own timeout-minutes proved it: on this PR the install step ran 14m26s and was killed by the 15 minute bound. The log shows why. Acquire timeouts are per-connection, so a dead mirror costs timeout x retries x every index file: 30s x 3 across roughly ten index files is ~15 minutes, which is what was observed. The azure archive mirror returned Ign for every suite, apt fell back to archive.ubuntu.com, and that connection then produced zero bytes for 14m26s. So per-connection bounds cannot bound this step; only a wall-clock bound can. Wraps both apt invocations in `timeout`, and drops Acquire::Retries to 1 so a dead mirror fails once instead of multiplying. The update is already tolerant by design, so bounding it just caps what a dead mirror costs before the install runs against whatever index exists. Also drops DPkg::Lock::Timeout: no lock contention was ever observed in these logs, and an option added on speculation is not worth carrying. |
||
|
|
174039a14e | fix(workspaces): re-seed a terminal when an emptied workspace is opened (#15513) | ||
|
|
9b8dbd8930 |
fix(mobile): keep a terminal lease alive when the handle lookup fails (#15463)
* fix(mobile): keep a terminal lease alive when the handle lookup fails `waitForTerminal(..., 'exit')` rejects with `terminal_handle_stale` whenever the handle cannot be resolved right now. The loudest source is `record.rendererGraphEpoch !== this.rendererGraphEpoch` — every renderer graph reload invalidates handles issued before it, on panes whose PTY is untouched. All three terminal.subscribe branches wired that rejection straight to teardown (`.catch(() => registration.releaseIfCurrent())`), so the host retired a live lease and emitted `end`. Mobile reads `end` as "PTY gone" and rearms; after MAX_REARM_ATTEMPTS = 3 it stops and leaves the composer on "Waiting for terminal…", recovering only when a replacement handle arrives. A live PTY keeps its handle, so no replacement ever comes: the pane is dead until the app is backgrounded and reopened. Demand proof instead. Retire on abort (this socket is going away) or when isLeafPtyProvenAbsent says the process is gone; a real exit still resolves and retires through the .then leg. Absence must be proven, never inferred from a lookup that failed. Reproduced live: a paired 0.0.44 client over LAN, fault injected on the handle lookup, composer locked on "Waiting for terminal…" and STILL locked 97s after the fault was lifted with the PTY alive and the handle unchanged. Narrows one behavior #14992 pinned: a stale handle alone no longer retires the owner. The ownership property is unchanged — once absence is proven, the owning registration is still what retires. That test is updated, not deleted. * fix(mobile): observe PTY exit independent of stale handles * fix(mobile): stop subscribe setup when an exited pty releases synchronously The runtime doubles for terminal.subscribe never stubbed subscribeToPtyExit, so the new lifetime watcher threw and killed every subscription under test. Also return early when the watcher releases synchronously: cleanup had already run, so the stream handlers, view subscribers, and mobile presence registered afterwards were never torn down. |
||
|
|
a3b3254885 |
fix(browser): grant storage-access so requestStorageAccess() stops rejecting (#15481)
* fix(browser): grant storage-access so cross-site frames can use their cookies AUTO_GRANTED_BROWSER_PERMISSIONS omitted storage-access, and the installed permission handlers deny anything absent from that set, so every document.requestStorageAccess() in the embedded browser was refused silently. Granting it unconditionally looks like a privacy hole, but that objection assumes Orca blocks third-party cookies. It does not: nothing blocks or partitions them and no Chromium switch touches cookie policy, so Electron's default applies. A cross-site frame therefore already reads and writes its unpartitioned cookies at the network layer, and denying the permission grants no protection - it only breaks sites that take the API's failure path. Chrome resolves requestStorageAccess() without a prompt under the same cookie policy; Electron has no such fast path and forwards to the embedder, so the embedder supplies it. The comment records the condition that would invalidate this. top-level-storage-access stays denied. requestStorageAccessFor() is a separate platform decision: Chromium consults Related Website Sets and has no third-party-cookie auto-grant, and Orca has no such data source, so granting it would invent a permissive answer to a restrictive question. A test pins it. Does not fix Google sign-in - that was #15216. Sign-in completes with this denial in place; this is an independent defect found while investigating it. * test(browser): pin the denial notice storage-access used to raise The suite proved the handler answers true but never pinned the symptom users actually reported — the "asked for storage-access, and Orca denied it" notice. The existing notified-list assertion runs before the storage-access request, so a regression that re-denied it would have left that list untouched. Assert the list after the new requests instead; it goes red pre-fix with 'storage-access' present. Drop the two vi.waitFor wrappers around the same requests. The request handler is synchronous for every non-media permission, so the wait bought nothing and imposed vi.waitFor's 1000ms default on a suite that allows 30s. The waitFor guarding the media path is genuinely async and stays. * refactor(browser): tighten the storage-access rationale and cover isolated partitions Trim the grant's comment to the facts that change a reader's decision. The mechanics of how the request reaches the embedder already live in the commit message; what belongs at the call site is why the answer is grant, and why the check handler must agree with the request handler — Electron builds neither Chrome's activation gate nor its auto-grant, so a disagreeing check pushes compliant sites onto the gesture path, where a rejection consumes the gesture. Move the top-level-storage-access note below the set. It sat after the last element with no trailing comma, so it read as a commented-out entry, and any permission appended at the natural insertion point landed above it. Cover the isolated-partition install path. createProfile and hydrateFromPersisted call installBrowserSessionPartitionPolicies separately from the default-partition path the persistence suite drives, and the two previous changes to this set each added a matching isolated test. Verified red without the grant. Rename the anti-detection case that claimed storage-access has a native denied state; it is granted in production now, and the case really pins pass-through. * docs(browser): correct the storage-access rationale The revisit trigger was backwards. If Orca ever blocked third-party cookies the grant would not become dangerous, it would become useless for cookies: Electron builds no HostContentSettingsMap, so no STORAGE_ACCESS content setting is ever written, and IsAllowedByStorageAccessGrant needs one. Point the tripwire at a cookie or storage-partitioning control instead, which is the change that would actually invalidate the reasoning. The premise was also narrower than the grant. Third-party storage partitioning is enabled by default and independent of cookie policy, and the same permission lifts it for localStorage, IndexedDB, CacheStorage and friends via StorageAccessHandle, which gates only on IsFullCookieAccessAllowed. So the frame does not "already have" everything this grants. It stays the right answer because Chrome grants the same permission under the same cookie policy, but the comment should not claim a narrower blast radius than the change has. * docs(browser): give the storage-access tripwire its consequence Say what goes wrong, not just when to look. If Orca ever blocks third-party cookies or gains a partitioning control, three separate gates stay shut in Electron - the network-service grant check, the frame's trusted status, and the STORAGE_ACCESS content setting that is never written - so the promise would resolve while access stayed blocked. Sites follow the documented pattern of reloading after a successful request, and on reload the check handler still reports granted, so no gesture is needed to ask again. That loops. Qualify the non-cookie clause: the no-arg call resolves undefined and touches only cookies. It is the dictionary form that returns a handle, and since the handler sees the permission name and never the call shape, one grant covers both. Also give "check must agree with request" its reason. Justify the isolated-partition test by the precedent it follows rather than by a call-site divergence the shared mock cannot actually distinguish. * docs(browser): scope the non-cookie clause to the handle A live probe pinned down what the grant actually widens. The frame's ambient window.localStorage and window.indexedDB stay partitioned before and after a successful request; the unpartitioned view is reachable only through the handle the dictionary form returns. Existing code in the frame is unaffected unless the site explicitly calls through that handle, so say handle-scoped rather than leaving a reader to assume the globals change. * docs(browser): correct the isolated-test justification and the tripwire scope "the isolated twin every other entry in this set already has" is false. Counting occurrences in browser-session-registry.test.ts: fullscreen, clipboard-read and clipboard-sanitized-write have none. Cite the pointerLock precedent the test actually mirrors, which is the case directly above it. Separate the two gates in the tripwire. The STORAGE_ACCESS content setting gates cookies; the handle path is gated on IsFullCookieAccessAllowed instead, and a live probe confirmed it works today. Saying "access stayed blocked" read as a claim that the handle is backed by nothing, which contradicted the sentence above it. Say cookie access, and say the handle survives. |
||
|
|
0b8107a41f |
fix(browser): stop the auth-host UA write from cancelling redirects (#15221)
* fix(browser): stop the auth-host UA write from cancelling redirects WebContents.setUserAgent() from will-redirect makes Chromium abort the in-flight navigation (ERR_ABORTED) and replay the original request, so any redirect crossing the Google auth-host boundary dies on a blank tab. Route that write through the CDP override, which retargets navigator.userAgent without touching the navigation. Fixes #15216 * fix(browser): recover auth UA override state * fix(browser): keep the viewport UA override on the session identity The auth-host switch writes the Firefox UA through WebContents.setUserAgent on a direct navigation and then moves to the CDP override, and nothing restores that WebContents UA. sendViewportUserAgentOverride read it back as its base identity, so a tab with a viewport preset republished the Firefox UA on every ordinary host afterwards, with sec-ch-ua still saying Chrome. Read the profile's session UA instead, which is the stable base identity at both call sites. --------- Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> |
||
|
|
73f7767edd |
feat(sidebar): name the host when a delete batch collides on one id (#15423)
* feat(sidebar): name the host when a delete batch collides on one id Two hosts can publish the same worktreeId, so a batch confirmation showed two rows with identical names and paths and nothing to tell them apart. Label each row with its execution host, but only when the batch actually contains a same-id collision — an unconditional chip is noise. * fix(sidebar): qualify colliding delete targets by saved host * fix(sidebar): preserve delete host collision scope |
||
|
|
4fc8b65792 |
docs: add WeChat group 8 as fallback when group 7 is full (#15466)
Provides alternative community group with QR code when primary group reaches capacity. Updates documentation in both English and Chinese. Co-authored-by: m4air <m4air@m4airs-Air.localdomain> |
||
|
|
391385fd5c |
fix(workspace-cleanup): require a host-qualified key for every candidate row (#15422)
The virtualizer keyed rows by bare worktreeId, so two hosts sharing an id collapsed onto one key in the confirm-remove dialog. getRowKey is now a required prop supplied host-qualified by each caller, making omission a compile error rather than a silent fall back to the colliding id. |
||
|
|
41c4d8e8a8 | Update README downloads badge | ||
|
|
90a292eec2 |
Fix WSL stall tests to reset gate state and fake performance timer (#15414)
Reset persistent WSL transcript filesystem gate state in beforeEach to prevent prior test stalls from quarantining subsequent tests. Fake the performance timer used by the route quarantine clock so tests don't block on real time. Update affected tests to wait out the back-off window rather than advancing by zero time. Co-authored-by: m4air <m4air@m4airs-Air.localdomain> |
||
|
|
1ca752a7a4 |
test(e2e): keep the native Hangul reproduction harness (#15438)
* test(e2e): keep the native Hangul reproduction harness This is the spec that reproduced #15299: it drives a real ibus-hangul engine through a real compositor and asserts the bytes reaching the pty. It is the first setup here that can exercise an input method end to end, and three IME issues this week were unreproducible without one. It does not run in CI, and the header says so rather than implying coverage. It needs a compositor session CI does not have, and this repo already carries native IME specs that are skipped everywhere and were mistaken for protection they never gave. The run recipe is in the header so the next person does not rebuild it. Recorded there too are the five things that decide whether a run is real or a silent false negative - nested rather than headless, an unused display, a session script that does not exit, forcing the window visible, and sending Escape before the byte reader starts. Each cost a failed attempt, and four of them are what defeated an earlier try. Keys and expected text are environment-tunable so other IME issues can reuse it unchanged. Refs #15299 * test(e2e): record three more silent-false-negative traps in the native IME harness A Hanja candidate-selection run on the same rig hit all three. Each produced an empty or misleading event log that reads as "the IME ignored the key" rather than as a broken harness, which is the failure mode this header exists to prevent. The panel one is the least obvious: a session whose ibus-daemon runs with --panel=disable never draws a lookup table, so any run that depends on seeing candidates measures nothing while appearing to work. Refs #15299 |
||
|
|
d4460d34f3 |
fix(terminal): stop a Hangul-terminating digit being eaten as a candidate pick (#15429)
* fix(terminal): stop a Hangul-terminating digit being eaten as a candidate pick A digit typed immediately after a Hangul syllable is dropped in the terminal on Wayland. Typing 아1 produces 아. The syllable composes and commits correctly; only the keystroke that ends it is lost. Reproduced on Ubuntu 24.04, GNOME Shell 46, ibus-hangul 1.5.5, in a nested Wayland session with real key injection. The pty receives 아 on Wayland and 아1 under X11 on the same machine with the same engine, and a GTK client in the same Wayland session receives 아1 - so the compositor, the input method and the digit are all behaving. The difference is where the digit is delivered. Under X11 ibus-hangul swallows it into the preedit and commits 아1 as composition text. Under Wayland it commits 아 and lets the digit through as an ordinary key - and the jamo before it arrive with no keydown at all, only keyups. That orphaned keyup is what breaks it. A plain letter keyup with no matching keydown arms a 1500ms window in which a bare digit is treated as a candidate selection, because a Pinyin engine indexes its candidate list by digit. The digit lands microseconds later, inside the window, and is suppressed. The window disarms after exactly one digit, which is why the syllable survives and only the terminating key vanishes. A Hangul engine has no numbered candidates over its preedit - a digit ends the syllable and is literal text - so the guard was spending its one suppression on a keystroke meant for the shell. The tracker now records whether the current preedit is Hangul and the guard declines. Read from compositionupdate rather than compositionend: a Pinyin preedit is the Latin spelling being narrowed while its commit is the Han text, so reading the commit would misclassify Pinyin and reopen the bugs the guard was added for. Space is untouched; only digits are reclassified. Verified A/B/A on that machine: unfixed drops the digit, this change preserves it, reverting drops it again, three runs each. The X11 case and both existing ibus-hangul specs still pass. Closes #15299 * fix(terminal): expire the Hangul preedit flag and narrow its digit exemption Review of the #15299 fix found the Hangul classification could not be retired and that it exempted more than the reported bug. The flag was written only on a non-empty compositionupdate and cleared only on blur, so it latched for the whole focus session. Switching input engine (Hangul -> Pinyin) under fcitx/ibus moves no DOM focus, and the #8241 orphan-digit path emits no composition and no input events, so nothing could refresh or clear it. The stale flag then turned the #8241/#7543 Pinyin candidate-digit guard off silently and permanently - the regression those guards exist to prevent. It now expires against lastCompositionEventAt on the same staleness window as the neighbouring guards, and compositionstart clears it because the following compositionupdate re-reads the preedit script. It is deliberately not cleared on compositionend or on input: the bug is a digit arriving after the commit, and both recordings deliver the commit's own insertText before it. Clearing on either reverts the fix - the orphan-window test fails when input clears it. The exemption also gated suppressCandidateKey as a whole, so it fired during a live composition as well. That is broader than the bug, which is exclusively a digit after compositionend, and it is unsafe: the earlier claim that no Hangul engine indexes candidates by digit over a preedit is wrong. ibus-hangul's Hanja conversion (Hanja key / F9) puts a numbered lookup table over a live Hangul preedit, and its symbol table behaves the same. Only the orphan-keyup guard - which arms off a bare keyup and cannot see which engine produced it - now declines. The tests drove a shape neither recording produces: an empty compositionupdate immediately before compositionend, which armed the 250ms post-composition window. The Wayland reproduction has no empty update, and the X11 fixture follows its empty update with deleteContentBackward + insertText, which disarms that window. The suite now follows the recorded trace, asserts the 250ms window never arms, and covers both the expiry and the live-preedit Hanja case. Refs #15299 |
||
|
|
3ffab9a6b3 |
feat(terminal): read the rendered screen with terminal read --screen (STA-4792) (#15380)
* feat(terminal): read the rendered screen with `terminal read --screen` (STA-4792) `terminal read` returns accumulated pty output with escape sequences stripped. That is the right answer for "what happened over time" and the wrong one for "what is on screen": any program that repaints a line comes back as stacked fragments, so one `clear` typed key by key reads as `cclclecleaclear`, and a prompt that draws a space by moving the cursor loses it. Nothing in the output said which question had been answered, so it was used as rendering evidence and produced false conclusions. The runtime already knew how to render — it replays the byte stream through a headless emulator — but only as a fallback for blank reads, alternate screen, and never-attached ptys. A normal attached terminal never reached it. `--screen` asks for it directly. Every read now reports its source, which also surfaces the pre-existing snapshot fallback that until now swapped rendered lines into an ordinary read with no indication. `screen-unavailable` distinguishes "asked for a screen, none could be rendered, here is the stream" from a stream the caller asked for, and an absent source means the host predates the field. Because an older host strips the unknown param and answers with its ordinary read, `--screen` against one fails with that explanation rather than passing the stream off as a screen. `--screen` and `--cursor` are mutually exclusive: a screen is the current frame and has nothing behind it to page. * refactor(terminal): stamp the screen source where rendered lines enter the read Inferring it from tail array identity worked but made a load-bearing contract out of reference equality; any later path spreading the read would silently mislabel. Rendered lines only enter through one builder, so it stamps there and anything still unlabelled is the stream. |
||
|
|
9b5538d786 | fix(runtime): scope create-with-activate navigation to the requesting client (STA-2802) (#15407) | ||
|
|
12724068b7 |
fix(sidebar): render folder workspaces under every Group by mode (#15362) (#15404)
* fix(sidebar): render folder workspaces under every Group by mode (#15362) Folder-workspace rows were emitted in exactly one place, inside the repo-grouping branch of buildRows, so switching the sidebar to Status, PR status or None dropped them from every lane. Membership is now decided once, above the groupBy switch; a mode only chooses which lane a workspace lands in. Lane assignment is an exhaustive WorktreeGroupBy switch with no default clause, so a future grouping mode is a compile error here rather than a silent regression. Not a regression: the groupBy !== 'repo' gate predates folder workspaces by two weeks (#2866), and #5172 added folder-workspace emission below it, unreachable for non-repo grouping from its first commit. Also fixes four further paths where the same row kind went missing: - Flat mode gated its whole section on worktree count, so an account with only folder workspaces rendered nothing at all. - A collapsed folder-only lane header rendered globally instead of under its host section, because empty worktree lists yield undefined host counts. Host id maps now carry explicit empty arrays so the host fallback cannot leak global worktree ids. - Reveal resolved folder workspaces to project-group header keys only, so revealing one inside a collapsed lane never expanded it. Reveal now reuses the same lane function as grouping, expands the host header, and covers the agent-send path and the host-qualified row lookup. - The Clear Filters empty state ignored folder workspaces, so a folder-only account lost them whenever any filter was active. This one reproduced under Project grouping too. Behaviour deliberately unchanged: archived folder workspaces are still not filtered, matching current behaviour rather than making membership mode-dependent. Pinned folder workspaces still render in their natural lane rather than the Pinned section, now uniformly across modes. * fix(sidebar): count folder workspaces in host badges |
||
|
|
200d8a5738 |
test(relay): stop port-scan fixtures colliding with the worker pid (#15413)
Both port scanners drop any row whose pid is the relay process or its
parent. The tests hardcoded fixture pids, so a vitest worker that happened
to be assigned one of them had its row filtered out and the assertion saw
an empty result.
Reproduced exactly: forcing the worker pid to 2468 fails
windows-port-scan with "expected [] to deeply equal [ { host: '0.0.0.0',
...(2) } ]", the same message seen in CI, where node 26 shard 1 failed
while node 24 passed on the identical SHA. Forcing pid 1000 fails
port-scan-handler the same way.
Pick fixture pids that cannot match the running process instead: the
literals stay 1234/2468/1000 unless the worker owns one, and only then
shift. No production change.
|
||
|
|
6386b540ad |
fix(quick-open): stop node:path from white-screening the renderer (#15405)
* fix(quick-open): stop node:path from white-screening the renderer #15158 pulled `quick-open-filter` into the renderer graph, but the module still used `import { posix, win32 } from 'node:path'`. The bundler stubs `node:path` in the renderer with an object that throws on any member read, and named bindings resolve at module evaluation — so the renderer threw before React mounted. `pnpm dev` white-screened on main. Switch to a namespace import and have `pathFlavor` return the flavor NAME instead of the module, so `node:path` stays untouched until the out-of-root `relative` fallback actually needs it. A namespace import alone is not enough: `pathFlavor` is called unconditionally by `buildExcludePathPrefixes`, so it still threw on any Quick Open with nested-worktree excludes. No behavior change for the main process or the relay. * fix(quick-open): keep exclude containment browser-safe |
||
|
|
fae9282f54 |
test(wsl): account for the route quarantine in stalled-mount tests (#15408)
* test(wsl): account for the route quarantine in stalled-mount tests #15381 added a route-level quarantine: a timed-out task blocks new admissions on that route for a back-off window, so the next real task probes recovery instead of hammering a hung mount. It merged with four tests already failing on main, and they still fail. Two causes, both test-side: - The recovery half of three tests read again immediately after releasing the stall, which the quarantine now refuses. They wait out the window, which is what a real caller does — resetting the gate would skip the admission the recovery assertions exist to prove. - `blockedRoutes` is module state that no test cleared, so a stall in one test refused an unrelated read in the next. Both files now reset the gate in beforeEach. Production behavior is unchanged and is working as designed: eviction still happens, only the immediate re-read is deferred by the back-off. * test(wsl): match the sibling suites' quarantine idiom #15381 applied this same fix to session-scanner-core-parser-wsl-stall, session-scanner-discovery-wsl-gate, and opencode-usage/scanner-wsl-gate, and missed these two files. Adopt that established shape rather than a parallel one: fold the back-off wait into releaseAndSettle instead of a separate helper at each call site, and fake performance explicitly, since performance.now drives the quarantine clock. The explicit toFake is not strictly required today — vitest's default already fakes performance — but it documents the dependency and stops a default change from silently unfaking the quarantine clock. |
||
|
|
c228030516 |
fix(tabs): keep an open diff focused while agents stream (STA-4697) (#15390)
* refactor(tabs): put the visible-tab-type projection in one place Three copies of toVisibleTabType had drifted: the runtime one omits 'simulator'. Move the canonical projection next to the two unions it maps between and replace the two copies that are already identical to it. The runtime copy is left alone on purpose - unifying it would change behavior, so it goes with the follow-up. * fix(tabs): keep an open diff focused while agents stream (STA-4697) resolveWebSessionVisibleTabId answered 'which tab is the user looking at' by inverting a many-to-one projection: it compared tab.contentType against the coarse activeTabType. Diff tabs open with activeTabType 'editor' but carry contentType 'diff', so the match never succeeded and the guard returned null - which is the reconciler's signal to fall through and activate a terminal. Every agent status echo republished the snapshot, so the diff lost focus ~300ms after opening, once per click. Same for conflict-review and check-details. Resolve the visible tab from group state instead, which is what is actually on screen and is the rule deriveActiveSurfaceForWorktree already uses. The coarse address survives only when there are no group records, now projected rather than compared exactly. Also follow the entity within the group when reconcile rematerializes the visible tab under a new id, and teach the browser-create focus guard to observe the group records the resolver now reads. |
||
|
|
8612a2dabb | Fix skill install dialog overflow and simplify file summaries (#15406) | ||
|
|
a3f3a74dcf | test(agent-hooks): prove the Windows hook actually receives its payload (#15403) | ||
|
|
4ec6bbf588 |
Kill hung WSL transcript filesystem operations via child process with route quarantine (#15381)
* fix(native-chat): kill hung WSL operations via child process
Stalled UNC file operations hold libuv permits even after the gate
timeout expires, blocking Chat tab recovery. Two stalled operations
fill both permits and freeze all WSL access until restart.
Fork file I/O for UNC paths into a separate child process. On deadline
expiry, kill the process to force the hung syscall to exit. This frees
the permit for the affected tab's next read. Temporarily quarantine the
stalled route to avoid retry storms.
* chore: drop internal review artifact from the repo root
* fix(native-chat): harden the WSL transcript fs sidecar
Review follow-ups on the sidecar isolation change:
- Only the deadline may abort running gate work. The sole waiter's
same-duration timeout fired first, killed healthy children on caller
abandonment, and settled the task before the deadline could quarantine
a stalled route - leaving the back-off dead for every dedupe:false op.
- Resolve the fork entry from out/main/chunks too: the resolver compiles
into a shared chunk, and the scanner service child has no
process.resourcesPath, so packaged WSL vault scans threw entry-not-found
(masked as an empty tree).
- Allowlist the fork env instead of spreading process.env; ambient
NODE_OPTIONS would halt or --require code into every child.
- Wrap transport faults (spawn failure, child death) in
WslTranscriptFsError('unavailable') so discovery reports them as scan
issues instead of misreading them as missing paths or empty trees.
- Gate the vitest in-process fallback on the vitest worker global so a
leaked VITEST=true cannot revert production to in-process UNC syscalls.
- Reap idle sidecar processes after 60s instead of holding them for the
app session.
- Split 'open' into its own protocol union member so the reusable-call
Exclude actually strips it from the pooled-process API.
- Guard kill('SIGKILL') against the teardown race where an exiting child
emits an unlistened 'error', and dispatch reads by handle kind before
path spelling.
* fix(native-chat): probe stalled WSL routes instead of a fixed quarantine
Remaining review follow-ups:
- Escalating route quarantine: first strike lifts after 5s so a distro
that was cold-booting when its op hit the deadline recovers on the
next poll (~35s total instead of ~90s); repeat stalls double the
back-off toward the prior 2x-timeout cap, and any settle the deadline
did not force clears the strikes. Queued same-route tasks fail fast
at quarantine instead of stranding one waiter deadline per file in
sequential scans.
- Single request implementation: the vitest in-process fallback now runs
the child's own dispatcher (WslTranscriptFsProcessOperations + decode),
so unit suites exercise exactly what the forked process executes and
the per-call-site fallback closures are gone. Dirent fixtures gained
the full kind-flag set the serializer reads.
- Dropped the production-dead per-route close queue; UNC FileHandles
(test fallback only) mirror the process-handle close contract.
- Error class, messages, and factories move to wsl-transcript-fs-error
(re-exported from the gate) to keep the gate under the lines budget.
* fix(native-chat): harden WSL transcript fs with route quarantine strike
Extract quarantine logic into a dedicated module with strike decay: stalls older
than 5 minutes restart from base back-off, and concurrent-lane timeouts count as
one incident. Allow joining live in-flight tasks on quarantined routes (they cost
no new I/O). Preserve quarantine across transport faults (child death). Handle
file shrinking during tail reads by detecting short reads and returning empty.
Defer file closes that arrive mid-read instead of refusing, preventing slot
leaks. Separate process slot and boundary-finding concerns into focused modules.
* fix(native-chat): enforce route quarantine windows and isolate lanes per
A late result arriving after the deadline was incorrectly lifting the route
quarantine, allowing subsequent work to start before the back-off period
expired. Now late results are correctly recognized as stale and never cut
the quarantine short.
Process work is now isolated per (route, priority) lane so a scan stall
cannot block exact reads on the same distro. Each lane gets its own client
and process pool; late results and handle faults stay scoped to their lane.
Tests now fake performance.now() alongside timers (the quarantine clock
depends on it) and wait for the full back-off window to expire rather than
advancing by 0. Gate state is reset between test cases since late releases
never lift the quarantine.
---------
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
|
||
|
|
1b5cc00870 |
fix(shell): content-address shell wrapper trees so builds stop clobbering each other (#15285)
Every writer sharing a userData dir -- main's local PTY path, the daemon fork, and the daemons of other builds that outlive the app that spawned them -- wrote one fixed `shell-ready/` tree. Last writer won, and the guard only re-checked that the files were present, never that they were this build's. A daemon whose spawn env no longer agreed with the wrapper on disk kept launching shells it could not read: the ready marker never fired and every startup command waited out the full 15s timeout, silently, with restarting the app powerless to fix it because the daemon outlives the app. Measured 15.17s vs 1.10s once the tree matched. The five live daemons on the machine this was found on spanned app versions 1.4.181-1.4.185, all from the same installed app across auto-updates, so this reaches ordinary installs. Name each tree after a hash of its contents, at `<userData>/shell-wrappers/<hash>/shell-ready/`. Different bytes are a different directory, so "present" means "written by this build" again. The hash sits above the `shell-ready` leaf because ZDOTDIR self-reference guards match that exact suffix. Nothing collects old trees: ~48KB each, a couple of MB a year against a userData dir in the tens of GB, not worth an `rm -rf` on the spawn path. Also publishes the resolved root to WSL over WSLENV `/p`, since the in-guest script cannot derive a hash, and reports readiness failures to the daemon's NDJSON log rather than a console the detached daemon discards. Generated wrapper content is byte-identical; snapshots unchanged. |
||
|
|
3ac4171d14 |
fix(cmd-j): host-qualify worktree resolution and disambiguate list render keys (STA-4343) (#15371)
* fix(cmd-j): host-qualify worktree identity to fix STA-4343 - Same repo::path across hosts now render as two distinct rows, each resolving to its own worktree on activation — closes wrong-host activation bug. - Disambiguate repeated persisted entry ids with render-key suffixes so React never leaves ghost rows mounted; prevents frozen display glitches when a session holds duplicate tab records. - Collapse query whitespace runs, treat emoji/symbols as content, deduplicate query tokens, and improve CJK compound matching in palette search. - Track filter-field cursor by option id rather than position so re-ranking after a toggle doesn't snap to the wrong row. - Dedup tabs by id at hydration so corrupt sessions don't render duplicates. * test(palette-search): add rankMode and current-state fields to palette o Adapt test fixtures and function calls to include new rankMode parameter and isCurrentPage/isCurrentWorktree tracking fields required by the palette search algorithm fix. * fix(palette-search): add host-qualified repo and worktree resolution Handle repo and worktree collisions in multi-host environments by querying host-qualified maps and comparing execution host IDs when determining current worktree status. Ensures the palette correctly displays repo identity and marks active status for worktrees across multiple hosts. * fix(cmd-j): namespace-safe duplicates and script-aware token matching - Carve out `palette-dup:` namespace for duplicate keys to prevent collision with real persisted ids, which previously could match generated keys like `id#dup1` - Gate reverse-containment token scoring to unsegmented scripts (Han, Hiragana, Katakana, Hangul), preventing false matches like "database" matching keyword "base" in Latin text - Host-qualify worktree identity in empty-query ordering to allow switching between same-id worktrees on different hosts, which were previously treated as duplicates (STA-4343) * Shorten comments in palette-list-entry-render-keys Replace verbose comments with concise one-liners per style guide. |
||
|
|
9312c5cd9b |
fix(browser): serialize cookie imports per partition (#15095)
* fix(browser): serialize cookie imports per partition (STA-4601) Two concurrent cookie imports on one partition can erase a session. Nothing serialises imports per partition — neither the renderer IPC handler (browser-session-profile-ipc.ts) nor the runtime RPC method (orca-runtime-browser.ts) — and the clear lock covered only the clear itself, so it was released before the writes and before any rollback. Reachable interleaving: import A replaces cookies for its imported domains, releases the lock, and later fails a write; import B clears and writes; A's rollback then removes cookies B already wrote and reported as imported. Path B has the same shape: A clears, B clears, then A writes its cookies on top of B's jar. Fix: one lock spans the whole live-jar transaction on both paths — the clear/replace, the writes, and the rollback. withCookieClearLock becomes acquireCookieMutationLock/withCookieMutationLock so path A can hold it across a try/finally rather than a single callback. Rebased onto #15030 (STA-4300), which rewrote this file: imports now write through CDP identities, so path A's writes moved from an inline cookies.set() loop into writeImportedCookies(), path B's into the same helper, and — the part that matters for a lock — importValidatedCookies no longer holds the Electron Session at all. It receives a CookieImportTarget that hides the Session behind openWriteStore(), and openWriteStore() builds a FRESH adapter per call, so keying the lock on anything reachable from it would serialise nothing. The target therefore carries mutationLockOwner, set to session.fromPartition's instance, which is the same object the native path locks on. That is what keeps both paths on one key per partition. Deliberately bounded: this covers the LIVE JAR only. Staging and cold-start replay keep their existing semantics, so two other pre-existing hazards in that subsystem are untouched and still open — the crash window between the clear and the result, and a permanently-armed replay when replay keeps failing. Both need the pending-image operations to become provable, which is a different change. The lock is keyed per owner, so imports into different partitions still run concurrently. Mutation-proved against the rebased tree, every mutation structure-preserving (brace/paren balance pinned) and every one re-run rather than carried over: dropping path A's acquire reddens the file-import detector; keying path A's acquire on a fresh object instead of mutationLockOwner reddens it too — the lock-present-but-miskeyed shape this rebase risks; replacing path B's lock with a passthrough reddens the native detector; doing both reddens both, which rules out incidental serialisation; and neutering the cold-init probe's lock reddens the new probe detector. That last detector is new: neutering the probe lock previously left all 779 browser tests green, so the probe's protection was unproven. The concurrency suite's store stub also gained writeCookieIdentity and getStoragePath. Without them the STA-4300 code throws a TypeError that writeImportedCookies catches as a write rejection, so the imports would have taken the failure path while the ordering assertions still passed. Each real import test now asserts writeCookieIdentity was CALLED, and that cookies.set never was. src/main/browser: 72 files, 780 tests, green; typecheck, oxlint, type-aware code quality, oxfmt and the max-lines ratchet all clean. * fix(browser): serialize native cookie staging * test(browser): pin native flush serialization * test(browser): pin staged cookie replay ordering * test(browser): remove vacuous staged replay detector * test(browser): detect stale native staging images * test(browser): guard cookie import ordering events * test(browser): cover staging lock boundary |
||
|
|
ef788c80c7 |
test(terminal): assert wide-char buffer content across repaints (#15280)
* test(terminal): assert wide-char buffer content across repaints The first reproduction for the Korean duplication asserted on the byte stream a pty emits, and it passes on real Windows on both ConPTY backends. The reporter's evidence says that is the wrong surface: their copied text pastes doubled, so the corruption is in the buffer, and in one run the command echo is doubled while that same command's output is clean - text that was correct when it left the shell and went wrong while being placed on screen. A redraw landing on the wrong cells emits perfectly legitimate bytes, so no stream assertion can see it. These assert buffer content instead, against a cell-level model of a grid where one glyph spans two cells. The model shares no code with the emulator, but its rules were chosen to match observed behaviour, so it is a regression detector rather than a first-principles oracle - the header says so rather than overclaiming. The largest case is a cursor positioned onto the second cell of a two-cell glyph, then erase-to-end-of-line, then rewrite, swept across every row and column at widths 8 to 44. That is the only place a wide character can be half addressed. Also covered: a whole row re-emitted over wide characters, which is the shape a console redraw takes; snapshot round-trip of a half-addressed buffer; and reflow across 148 width pairs, which is the resize the reporter uses as a workaround. Nothing reproduces. The emulator blanks the orphaned half correctly at every width and column, and reflow is lossless. The two pty specs run in the existing Windows packaging job rather than a new lane. A dedicated runner cost roughly four minutes, almost all of it checkout and a native rebuild, to run thirteen seconds of tests, and Windows minutes bill at double - the packaging job already installs the same dependencies and already runs a Windows test step. No production code changes. An earlier revision added an environment variable to select the system ConPTY, and it is dropped: its only effect would have been to let a user disable the fix for an earlier duplication bug, it logged nothing so a support bundle could not confirm it took effect, and the tests drive the backend directly without it. Refs #15192 * test(terminal): share the wide-glyph predicate instead of copying its regex The duplication detector carried its own copy of the range the grid model already exports, so the two could drift and only one would be updated. |
||
|
|
3c676ed13d | fix(ssh): validate hashed known_hosts fields through one strict base64 decoder (STA-4717) (#15345) | ||
|
|
ccb2305c8d | refactor(runtime): name every mobile-session close outcome so the tombstone decision is explicit (STA-4718) (#15346) | ||
|
|
e5a1744e27 |
fix(agent-status): stop an idle title retiring a pane pending a human answer (#15351)
A Claude pane parked on a permission prompt published `done` to mobile and paired clients — the state that retires the card — while the user was still being asked. The title layer cannot express permission for Claude or OpenCode: a title reads `permission` only from a vendor glyph or a synthesized `<Agent> - action required` label, and SYNTHETIC_AGENT_TITLE_PROFILES has no entry for Claude (OpenCode sets synthesizeTerminalTitle: false). So `titleConfirmsState` is unreachable for them and the hook's only protection was a timestamp comparison. That comparison loses because the two title clocks advance on different events: lastAgentStatusRichInvalidatedAtEpochMs moves only when the title's status class changes, while lastOscTitleEpochMs moves on every write. Measured against a real claude 2.1.234 (two runs, identical structure): the title settles working->idle ~12ms after PreToolUse, PermissionRequest fires ~39ms later, then one more same-class repaint lands ~123ms after the hook. That last repaint pushes title evidence past a hook that is still current. The title then goes silent for the whole prompt (31s and 85s in the two runs), so the wrong verdict is frozen for as long as the user takes to answer. Treat a fresh `waiting`/`blocked` under an idle title as non-renewable. Scoped to idle on purpose: idle is the absence of activity evidence, whereas a `working` title or a null/shell/identity-only one contradicts the hook and must still retire the row and its stale question (#11761). Bounded by the stale window so an agent killed while parked still decays. Both state names matter: Claude's PermissionRequest normalizes to `waiting`, not `blocked`, so a guard written against `blocked` alone passes its own tests and misses every Claude permission prompt. |
||
|
|
1be6a035b9 |
fix(shell): carry a WSL pane's shell across relay revive and stop forking to probe zsh emulation (STA-4682) (#15361)
* fix(shell): carry a WSL pane's shell across relay revive and stop forking to probe zsh emulation (STA-4682) Two residuals left behind by #15236 and #15258. Relay revive called resolveDefaultShell() and ignored the entry's shell override, so a restarted relay handed the user a PowerShell pane where a WSL one had been -- and with it the host default shell's history rather than the worktree-scoped one #15236 injects on spawn. The override and the requested distro are now serialized (optional fields; state from an older relay still revives the default shell) and re-resolved through the same allowlist a fresh spawn uses, so revive re-launches the same shell with the same guest-visible HISTFILE and the same WSLENV carrier. A bad override degrades that one pane instead of failing the whole batch. The wrapper's three `$(emulate)` probes each fork a zsh carrying everything the user's config has loaded, and since wrapping widened to every zsh pane every pane pays for them. Each probe now sits behind a fork-free option test that is true whenever `emulate sh`/`emulate ksh` has run; the exact probe still runs when that test passes, so no answer changes. Measured on zsh 5.9 / macOS: 9.97 ms/run unwrapped, 14.20 ms/run wrapped, 12.27 ms/run wrapped after -- about half the wrapper's cost. * fix(shell): harden the revive override path and silence the option probe on an exotic zsh Readiness-review follow-ups on this branch. - `[[ -o <name> ]]` prints "no such option" to stderr and returns false rather than aborting, so a zsh lacking one of the three Bourne option names would put that text in the user's pane. All three predate every supported zsh, so `2>/dev/null` is belt and braces -- but the belt costs nothing. - Bound the WSL distro name revive replays into `wsl.exe -d <name>`. It is the one field this branch newly routes from untrusted serialized state into argv, and reviveEntry's stated job is to re-apply fresh-spawn bounds. - Skip a pane whose overridden shell can no longer spawn instead of letting the throw escape the revive loop and cost every later entry its state. Skipping, not falling back to the host default shell: substituting a different shell is the defect the override exists to fix, and the args and history env are built for the shell that is gone. The worktree-removal fence throws before this, outside reviveEntry, so it stays a hard failure. |
||
|
|
79be5b7fde |
feat(orchestration): report a worker blocked on a human prompt (STA-4513, STA-3714) (#15261)
* feat(orchestration): report a worker blocked on a human prompt (STA-4513, STA-3714) A lane parked on an approval, trust, or permission prompt looked exactly like a lane that was thinking or inside a long tool call. On origin/main, driving a real cursor-agent through Orca: surface running `sleep 60` awaiting approval worktree ps agents[].state working working terminal show / list no such field no such field terminal wait --for tui-idle satisfied: true satisfied: true worker-show no agent state no agent state The runtime already fuses hook state, OSC title, and matched prompt text into a `permission` verdict inside getTerminalAgentStatus — it was reachable only from the renderer, and it was blind to cursor-agent approvals. Two gaps, one boundary. Exposure: getTerminalInteractiveWait publishes that same fusion, minus the async foreground probe, as `agentWait` on `terminal show` and on `worker-show`'s observation. It carries the evidence that proved the wait (hook, prompt-text, or title) so a coordinator can weigh it. Null means no proof; a missing field means the host predates it — absence is never read as "not waiting". Detection: cursor-agent's hook set has no approval event and beforeShellExecution fires identically for auto-allowed commands, so its rendered menu is the only authority. Matched on the key-bound choices rather than the prose, requiring two, and self-clearing when the follow-up input line returns. Its live spinner title is exempted from the staleness rule that clears startup modals, because cursor keeps spinning while it waits. Falls out of routing it through the shared verdict: `dispatch --inject` into a cursor pane on an approval now refuses with agent_prompt_blocked instead of typing the preamble into the dialog. Fixtures are captured verbatim from cursor-agent 2026.08.11-e8db854 driven through Orca; the same case matrix was replayed live against a built runtime. terminal list stays untouched: its rows would each need a full tail scan, and STA-4694 owns the one-call-per-run aggregate. * fix(orchestration): only call a Cursor approval live while it owns the screen Independent review found the approval detector trusted one dismissal string, so any later output that did not contain cursor's follow-up line left the menu reading as a live wait. Reproduced: a tail of the real menu followed by two lines of ordinary output returned agent-approval-prompt, which fails tui-idle and refuses prompt injection on a healthy lane. Replaced with the structural property the string was standing in for: a live dialog owns the bottom of the screen, so the last choice may sit at most one line above the end of the retained tail. That tolerates a status footer or a partial line mid-redraw without admitting scrollback, and it drops the vendor prose. Being bottom-of-screen is also the dating this reason needed, so it no longer requires waitBlockedAt. A tail restored from terminal history carries none, and a lane parked on a prompt emits no bytes — so before this, an Orca restart made exactly the lane both issues are about go quiet for good. The startup modals keep the timestamp rule: their text lingers in scrollback with nothing to say whether it was answered. Also from review: - worker-show and federationShow reuse the verdict showTerminal already computed rather than rescanning the tail, so the two can no longer disagree. - The worker-show test now drives a real runtime, real PTY tail, and the real detector; it previously mocked getTerminalInteractiveWait, so it would have passed with detection permanently returning null. - The guard claim is now asserted against the guard: a blocked pane rejects both assertTerminalAgentSendable and sendTerminalAgentPrompt, and a working pane still passes. - Added a non-local (connectionId) pane case, since the verdict is derived from retained tail and title state on every host. * fix(agent-status): stop a hook wait from outliving its agent A third reviewer caught that the hook branch proved agent ownership from the pane title alone, while the shared verdict it claimed to reuse also probes the foreground process. A shell that takes a pane back usually sets something like `user@host: ~/repo`, which no title rule recognizes, and a hook row stays fresh for AGENT_STATUS_STALE_AFTER_MS — so a dead agent could be reported as waiting on a human for half an hour. Hook evidence now goes through getTerminalAgentStatus, which is the only thing that can answer whether an agent still owns this PTY. The two prompt branches skip it: a matched prompt is on the pane's screen now, so it proves itself. That makes the probe cost fall exactly where correctness needs it, and getTerminalInteractiveWait async, which only showTerminal had to absorb. Also trims the comments the same reviewer flagged as longer than the repo's rule. * test(agent-status): pin that a dead pane stops reporting a human wait A fourth reviewer noted the approval menu sits at the bottom of a dead pane's tail forever, and that no test covered process exit with no trailing output. The snapshot already refuses an exited pane, and worker-show gates agentWait on proven identity — this pins both so neither can drift into reporting a worker that needs intervention as one that needs an answer. * fix(orchestration): never report an unchecked worker as not waiting Automated review caught that the three worker paths which return before the wait is ever evaluated — unattached, missing, and identity_changed — then had their undefined coerced to null by the emitters. A worker whose process was replaced was reported as `agentWait: null`, which reads as "Orca looked and nobody is waiting" when Orca never looked. That is the false negative this field exists to remove. The field is now emitted only when it was evaluated, so a present null is a claim about the pane and an absent one means nobody looked — because the host predates the field, or the worker's identity could not be verified. The CLI and the worker-show note say that rather than blaming an old host. Covered on the context-only path, where the regression test fails against the previous behavior; the supervised and federated emitters take the identical one-line change. Also trims the two test-file headers to one statement of purpose. * fix(agent-status): tighten the Cursor menu match and stop guessing on unknowns Fourth review round, three findings, each reproduced before acting. Matching each choice marker with an independent lastIndexOf let text outside the menu carry the anchor. An agent narrating "next time I'll suggest Run Everything" after the menu was answered pulled the match down to the bottom of the screen and revived it. The match is now confined to the last lines of the tail, and a choice is a line that ends in the key that picks it — prose writes the same words but not the same shape. The one line of slack under the dialog went with it. It was a guess; every capture of a live dialog ends on its last choice, and one line is exactly enough room for that narration. A redraw caught mid-flight now reads as no wait until the next poll, which is the safe way to be wrong. The hook branch awaited a foreground probe that reaches a PTY controller which may be a remote host, so a wedged probe stalled every caller of showTerminal — a path that never probed before. It is bounded now, and a timeout leaves the wait unevaluated rather than claiming there is none. Which is the same distinction the previous commit only fixed one level up: getTerminalInteractiveWait itself turned an unreadable pane into `null`, so showTerminal published "looked, nobody waiting" for a pane it could not read. It returns undefined there, showTerminal omits the key, and worker-show's text output prints unknown rather than rendering it the same as none. * fix(agent-status): bound the wedged probe's cost and stop matching prose keys Fifth review round. No correctness defects in the shipped behaviour this time; two robustness holes and the documentation of the contract. The bounded probe abandoned the wait but not the request, so a coordinator watching a wedged remote host added one live probe on every poll. It is single-flighted per PTY now, the way the leaf-absence probe already is. The trailing-key rule that separates a menu row from the agent narrating a choice was written as a character class, and any lowercase run up to twelve characters satisfied it — "…suggest Run Everything (as before)" passed. Spelled out as key names instead, which also lets the glyph forms of those keys through. The contract wording said an absent agentWait meant an old host or an unverifiable identity. It also covers an unreadable pane and a probe that did not answer, and a reader diagnosing an old peer from that would be wrong. Corrected on the type, the worker-show note, and in docs/reference/remote-wire-compatibility.md, which had no entry for a field whose absent and null states mean different things. Also strengthens the worker-show agreement test, which compared the terminal and observation payloads without asserting either held the expected wait, so it passed when both were absent. |
||
|
|
2b2529c32a |
fix(codex): stop a locked auth.json from deleting a just-authenticated account (STA-4734) (#15279)
* fix(codex): stop a locked auth.json from deleting a just-authenticated account (STA-4734) A successful Codex login could be reported as a failure, and the rollback then deleted the managed home holding the credentials it had just written. Three reads on the add path answered "no credentials" from a read that had merely failed: - `service.ts` decided the login verdict with `existsSync(authJsonPath)`, which returns false for EPERM/EBUSY as readily as for ENOENT. The post-auth tree kill only arms after the watcher has already observed new credential bytes, so an unreadable file there is a lock, not a failed login. - `loadOAuthCredentials` read auth.json bare, so a denial surfaced as a generic error indistinguishable from a real one. - `importCodexAuthFromHome` turned an unreadable source file into "No Codex credentials found ... run `codex login` first" — advice to redo a login that had already succeeded. Each now classifies: only a definitive ENOENT/ENOTDIR means absent, everything else is `ManagedCodexHomeTemporarilyUnavailableError`. Classifying alone is inert, because the add path's rollback deletes the home for any error. `removeManagedHomeUnlessUnproven` holds the rollback back on an unproven failure — a kept home is a recoverable leak, a deleted one is permanent data loss. The mutation check for this is in the test: with the typed error but no rollback guard, the home is still destroyed. `isDefinitiveAbsence` is exported so the second credential lane reuses the host predicate instead of keeping its own copy of the errno allowlist. * refactor(codex): move the definitive-absence predicate to src/shared Why: STA-4737 needs the same predicate from src/main/codex, which the CLI tsconfig project does not admit imports from codex-accounts into. Both branches now carry byte-identical copies so they merge cleanly in either order. |
||
|
|
ddaaa4628c |
fix(codex): keep two host-lane records that a failed read used to destroy (STA-4735) (#15289)
`snapshotCodexRuntimeHookTrustProvenance` rebuilt `.orca-hook-trust-provenance.json` from the current `config.toml` on every install and refresh, including when the existing record could not be read. That record is the only thing separating a trust entry Orca wrote from one the user approved inside Codex, so rewriting it after a failed read stamps the approval as Orca-written — and `promoteCodexRuntimeHookApprovalsToSystem`, which runs earlier in the same pass and had already bailed on the same unreadable file, then skips it on every later pass too. One denied read, permanent loss. Only the unreadable case is preserved. A malformed or absent record is still rebuilt, because resetting those IS the intent; conflating the two would wedge a user on a corrupt file forever. `fileContentsEqual` returned `false` from a bare `catch`, so "I could not read this" reached `writeRuntimeAuth` as "these differ" and sent it to the unconditional write below — replacing a refresh token Codex may have rotated a moment earlier with Orca's stale copy. It now reports the difference only when the bytes were actually compared, and the caller refuses instead. `fileContentsMatchExpected`'s `!existsSync` has the same collapse but is left alone deliberately: the write it guards is `writeFileAtomicallyIfUnchanged`, whose rename-and-compare re-checks the real file and refuses on its own, so classifying there would add a guard no test can drive. Noted in a comment. The three `writeRuntimeAuthAtPath` call sites have the same overwrite shape but are all on the WSL lane, which STA-4606 restructures; refusing there without its lane bookkeeping would set a baseline for a write that never happened. |
||
|
|
4cc7e7859a |
fix(cli): route --host runtime:<id> to that server instead of answering locally (#15364)
* fix(cli): route --host runtime:<id> to that server instead of answering locally `--host` was only ever a local filter over whatever runtime the CLI happened to connect to, so `--host runtime:<id>` silently answered for (and mutated) the local machine. A real environment id and a made-up one were indistinguishable: both returned ok:true with an empty list and the local runtimeId in _meta, and `project setup-clone --host runtime:<id>` cloned into the caller's own machine. Resolve the flag before the client is built: unparseable host ids and runtime ids that no paired environment owns are rejected, and a known runtime id selects that environment as the connection (conflicting with --pairing-code or a different --environment is an error). Once routed, a host filter also accepts the runtime's own `local`-stamped rows, since both spellings name the machine we are now talking to. * fix(cli): close --host routing gaps found in review - Conflict-check an ambient ORCA_ENVIRONMENT, not just the --environment flag. `ORCA_ENVIRONMENT=staging orca ... --host runtime:<prod-id>` silently routed to prod while the flag spelling errored. An ambient pairing code still loses to the explicit flag, because it cannot be resolved to an id to compare. - Attach the known environment ids to the unknown-id error as `error.data`, so a --json consumer can retry without parsing prose, and say outright that runtime:<id> matches ids only and never environment names. - Fix four command examples that documented `--host runtime:gpu`. `gpu` is an environment name, so every one of them would now be rejected; use an id. - Cover the routed connection on `worktree create` and `automations create` (the mutating paths), the `--environment X --host local` filter-only case, and assert error.code/error.data rather than only substrings. * test(cli): pin execution-host-flag to the deferred error-class import index.ts now loads execution-host-flag.ts on every invocation, making it the sixth module on the --help path. It imports RuntimeClientError from ./runtime/types today, but nothing enforced that; switching it to the barrel would silently drag zod/ws/tweetnacl back onto --help, which is exactly what this guard exists to prevent. Verified the assertion fails when the import is flipped to the barrel. |
||
|
|
0b80a773a4 |
fix(codex): stop overwriting and deleting Codex files that were merely unreadable (STA-4737) (#15287)
* fix(codex): stop overwriting and deleting Codex files that were merely unreadable (STA-4737)
Three modules shared by the host and WSL Codex lanes decided a file was absent
from a read that had only failed, and then wrote over it or removed it.
- `codex-config-mirror`: `existsSync` on the RUNTIME config.toml returned false
for a locked file exactly as for an absent one, so the mirror took the
"seed a fresh runtime config" branch and replaced the user's config wholesale.
- `config-settings-promotion`: an unreadable ~/.codex/config.toml counted as
having no promoted settings, and the write path then rebuilt the user's
canonical Codex config from Orca's runtime copy.
- `codex-home-paths`: both delete branches in `linkSystemCodexResource` remove
Orca's mirrored copy because the system resource "is not there". `existsSync`
and `systemResourceIsRegularFile`'s `catch { return false }` both reported
that for a source nobody could read, so one denied read on ~/.codex/AGENTS.md
removed the managed copy on the next launch.
`src/shared/definitive-filesystem-absence.ts` now owns the one errno allowlist —
ENOENT and ENOTDIR, with every other code including unrecognised ones treated as
indeterminate — and `host-codex-managed-home-ownership.ts` drops its private
copy rather than letting the two drift. `codex-path-observation.ts` builds the
three-valued observation on top of it.
The resource sync's two `existsSync`/`statSync` probes collapse into one
resolved stat, which answers reachability and regular-file-ness together and
closes the window between them.
`config-settings-promotion.ts` crossed its max-lines budget, so the write-target
resolution moves to its own module rather than taking a lint exemption.
Deliberately not here: the hook-service trust writes that run after a refused
mirror, and the promotion write target's own classification, which is
unreachable because it always resolves to the same file the read above already
refused. Both are noted in comments rather than half-built.
* fix(codex): preserve resource copies on indeterminate reads
|
||
|
|
487e43d619 | refactor(test): use __fixtures__ for wrapper snapshots and teach the LoC bot (#15365) | ||
|
|
f71ef7ee03 |
fix(sidebar): clear the delete state when a removal is refused (#15187)
* fix(sidebar): clear the delete state when a removal is refused Callers mark rows deleting up front for immediate sidebar feedback, but a refusal in beginHostQualifiedRemoval returns before removeWorktree's try/catch — the only other place that clears the flag. The failure toast auto-dismisses after 10s, so the workspace was left on a 'Deleting…' spinner indefinitely with no explanation still on screen. Also removes workspace-cleanup-removal-host-guard.ts: #14731 refused a colliding cleanup removal, #15013 replaced refusing with routing to the confirmed host, and nothing imports the module any more — not even its own tests. * test(sidebar): pin the two states that actually reach the removal refusal The existing case used an empty store, which proved the clear but not that a user can land there. Cover a stale folder-workspace id and a hostless row under an ambiguous legacy runtime — both resolve to no route from populated state. --------- Co-authored-by: QA <qa@local> |
||
|
|
12550fcc28 | refactor(test): move shell wrapper fixtures where they read as test data (#15363) | ||
|
|
a77a2f93f7 | fix(remote): search Quick Open paths on the host (#15158) | ||
|
|
a3edabcd7b |
fix(package): keep cached dev Electron bundles out of app.asar (#15359)
`files` is an all-negation list, so electron-builder's default `**/*` packs anything without an explicit `!` entry. out/electron-dev holds `pnpm dev`'s per-branch Electron.app copies (~270MB each), so packaging on a machine that has run dev bundled them all. CI never creates the directory, so releases were never affected. |
||
|
|
4b0e01f613 |
fix(github): scope GHES host-auth cache to the executing connection (#14948)
* fix(github): scope GHES host-auth cache to the executing connection The gh auth answer cached for a connection-backed repo is probed without the repository cwd, so it describes that connection's runtime — not the local host. Keying only on repoPath+wslDistro let a local repo and an SSH-hosted repo at the same path share one entry, and whichever resolved first decided "is this GHES host authenticated" for both. Include the connection identity in the runtime cache key. Local and WSL keys are unchanged. * fix(github): fence GHES auth cache across SSH reconnects * fix(github): fence origin cache across SSH reconnects * fix(github): fence repository identity cache on reconnect |