diff --git a/AGENTS.md b/AGENTS.md index ee6ea02c..c78cab33 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,6 +26,11 @@ job projection protocol). `scripts/` holds build/release/verify tooling. - Bun **exactly 1.4.0** for `*.bun.test.ts` and `*.dom.bun.test.tsx` files (pinned in `scripts/fetch-bun.mjs`): `dist-native/bun` or PATH; fetch with `node scripts/fetch-bun.mjs`. +- `npm ci` applies the app-owned SDK lifecycle patch from + `patches/gjc-sdk-lifecycle/manifest.json` through postinstall. Exact SDK/core/AI + versions and complete before/after hashes are mandatory. Use + `npm run apply:sdk-patch` / `npm run check:sdk-patch`; never hand-edit installed + dependency files. Unknown local modifications must fail rather than be replaced. - Server binds loopback by default (fail-closed; it can run shell commands). `SERVER_PORT` defaults to 3001, Vite dev on 5173. Do not export `SERVER_PORT=0`. - Tauri builds choke on `CI=1`: use `env -u CI npm run tauri -- build`. @@ -151,11 +156,32 @@ is `.ts`/`.tsx`. Routing is react-router-dom 7. unknown dependencies. Do not add cross-module imports that violate them. - **Product identity is checked**: `npm run check:identity` verifies names/URLs/scheme against `shared/productIdentity.js`. Change identity constants there, nowhere else. +- **Desktop updates are click-driven**: `automatic` means discovery checks only. + Download/restart require the native `targetId`; cached bytes alone cannot + authorize startup installation. Preserve one-shot manual intent consumption + and the draft/backend/process gates. Current contract: `docs/DESKTOP-CLICK-UPDATE.md`. - **Design system**: all product colors route through semantic CSS variables in `src/index.css` + the `@theme` color aliases in the same file. See `DESIGN.md` before touching UI styling; do not hardcode palette values. - **Bundled runtime manifest**: `server/gjc-runtime-manifest.json` is filled by `npm run fill:runtime-manifest` (runs automatically before dev/build:server). + Schema 2 includes the native closure and the canonical SDK patch's post-hashes. + Worker startup checks both and refuses mismatched/nested dependency instances. + A verified SDK patch is source-integrity evidence, not proof of complete SDK + quiescence; unrepresented streaming/extension work must still block restart. + `shared/sdkLifecyclePolicy.json` owns the file-count bound used by the applier, + worker and native payload/archive guard. After changing the canonical patch, + reapply it through a clean install and explicitly regenerate tracked runtime + hashes with `npm run fill:runtime-manifest -- --update` before verification; + normal dev/build gates only check the manifest and do not bless changed hashes. +- **Browser archive security backport**: `patches/extract-zip-symlink-leaf/manifest.json` + owns the exact extract-zip 2.0.1 upstream PR160 transform. Postinstall applies + it; `npm run check:extract-zip-patch` verifies canonical source/package hashes + and rejects nested, aliased or modified installations. Server/desktop staging + must carry and verify this independent patch. Do not put it in the SDK32 + lifecycle manifest or hand-edit node_modules. Audit recognition is conditional + on the actual patch and a current review, not an unconditional advisory skip. + Its archive-only protection is not a sandbox against concurrent local writers. - **Chat tool cards follow the runtime, not Claude**: `src/components/chat/tools/configs/toolConfigs.ts` is keyed by the tool's own lowercase name (`bash`, `read`, `edit`, `todo_write`), and its accessors read the runtime's parameter schema. `server/gjc-tool-configs.bun.test.ts` diff --git a/DESIGN.md b/DESIGN.md index f60beaa9..23c3edd8 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -203,6 +203,14 @@ The system uses Tailwind's 4px spacing scale. Existing values like `p-2`, `gap-2 - **Accessibility**: disclosure buttons expose `aria-expanded` and `aria-controls`; all icon-only actions have names and titles, and every control preserves a visible focus ring. - **Responsive behavior**: desktop and mobile preserve the same information order and one scroll owner, with touch-sized primary rows on mobile. +### Desktop Update Notice + +- **Placement**: a compact `bg-card`, `border-border`, `rounded-lg` card sits immediately above Settings in the fixed sidebar footer. The collapsed rail keeps an accessible update-details icon immediately above its bottom Settings control; opening details only expands the sidebar, without a modal or automatic focus change. +- **Visibility**: only confirmed native snapshots with a target introduce a notice. Web, disabled, idle, and no-target states stay hidden. Dismissal is page-memory-only and scoped to the native target, so a different target can appear. About remains available for a dismissed target. +- **Actions**: Update is an explicit click, for available or prepared targets with native installation support. The shared hook owns download and one safe, target-bound restart. Automatic means discovery checks only; rendering, checking, or opening the sidebar never installs anything. Busy, changed-target, and failed operations explain the next user action and never auto-retry. +- **Status**: translated polite live text accompanies download/verification progress; unknown totals remain indeterminate. Pending or unresolved operations lock mutations. Disconnected snapshots are labelled as last-confirmed and cannot enable mutations. Read-only status refresh remains available when no request is pending, including connection failures and recovery; recovery never offers installation controls. +- **Accessibility**: owned Button controls retain visible focus rings and translated names; release metadata remains plain text. The About panel keeps installed/target versions, bounded keyboard-readable notes, manual-update guidance, and the OS-approval caveat. + ### Chat Pane - **Structure**: `ChatMessagesPane` owns scroll; `ChatComposer` is fixed at the bottom of the chat column. diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md index 20ad2d1b..6dc723c7 100644 --- a/THIRD-PARTY-NOTICES.md +++ b/THIRD-PARTY-NOTICES.md @@ -9522,7 +9522,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -### js-yaml 3.15.1 +### js-yaml 3.15.2 License: MIT Copyright holder: Vladimir Zapparov @@ -17235,7 +17235,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -### multer 2.2.0 +### multer 2.3.0 License: MIT Copyright holder: Hage Yaapa (http://www.hacksparrow.com) diff --git a/docs/DESKTOP-CLICK-UPDATE.md b/docs/DESKTOP-CLICK-UPDATE.md new file mode 100644 index 00000000..274b41d8 --- /dev/null +++ b/docs/DESKTOP-CLICK-UPDATE.md @@ -0,0 +1,96 @@ +# Desktop updates: check automatically, install only after a click + +The user's September 9, 2026 request supersedes the previous automatic-download +and automatic-next-launch-install policy. The new interaction is a compact +notice immediately above Settings in the bottom-left sidebar. + +![Update notice above Settings — UI fixture, not a live installation](images/updater/click-update-notice.png) + +## Behavior + +1. The existing schedule discovers release metadata only. An eligible release + becomes `available`; merely checking or opening the app does not download it. +2. The user presses **Update**. Native download admission binds the exact + offered target; it does not rediscover and silently substitute a newer one. +3. After signature/archive verification, the same document requests one + target-bound safe restart. Draft sealing, backend admission, owned-process + shutdown, installation journal and successor health gates remain mandatory. +4. Busy work, target changes, connection loss, errors and unknown state stop + that UI attempt. There is no automatic restart retry when work later ends. + Rate-limited clicks are rejected without queueing a hidden download; retry + requires another user click after the limit expires. + +`automatic` retains its wire/preference field name but means **automatic checks** +only. Neither `automatic: true` nor an existing verified cache permits next-launch +installation. Only a matching explicit manual restart intent can proceed into +the existing native install gate. Legacy schema-1 unbound intents are not accepted; +new schema-2 intents bind both the target ID and the actual archive SHA-256. +The first admitted startup durably consumes that selection before location, +network or installation preflight. If preflight fails, a later ordinary launch +does not replay the click; a fresh explicit request is needed. An existing +installation journal retains its separate verified-successor recovery path. + +## State and authority + +- `shared/desktopUpdateProtocol.ts` owns the UI contract. Snapshot `targetId` is + required (nullable without a target); `download` and `restart` require it. + The native ID hashes a fixed domain, release/asset IDs and raw manifest bytes. + Restart rechecks both the snapshot and prepared record, including same-version + substitutions. The browser supplies no URL, path, signing key or installer. +- This native updater interface has not been publicly enabled. Its strict + protocol-1 schema evolves as one bundled native/server/client unit: old partial + snapshots and unbound commands fail closed, with no compatibility fallback. + Private backend framing and draft challenge protocols are unchanged. +- One document-scoped client shares polling, pending mutations and the user's + click between Sidebar and About. Sidebar mode swaps preserve its stable owner; + full unmount or bridge replacement retires the click. A UI timeout does not + cancel or duplicate native execution. +- Ordinary web and updater-disabled builds show no native sidebar update action. + A retained disconnected snapshot is read-only; Refresh performs only a status + read. Dismissal is in-memory and target-specific, surviving sidebar mode swaps + while allowing a different target to surface. +- Unknown download progress is indeterminate, never a fabricated percentage. + The collapsed rail exposes a labelled details icon directly above bottom + Settings. English, Korean and all other existing settings locales retain key + parity. About uses the same state and explains checks-only behavior. + +## Verification scope + +Focused checks cover the shared client/real injected bridge, HTTP/protocol +validation, native discovery/explicit download, cached startup consent, +same-target restart, cancellation, duplicate clicks, stale responses, rate limits, +Sidebar/Settings behavior and locale parity. Full source and native gate results +are recorded in the current handoff. + +- Full `npm run verify` passed (`/private/tmp/gajae-click-update-full-verify-final.log`). +- Native clippy with `-D warnings` and all native tests passed: 340 unit + 10 + binding tests; 6 dedicated/optional helpers ignored + (`gajae-click-update-consume-clippy.log`, `gajae-click-update-native-accepted.log`). +- Shared client, actual injected bridge, Sidebar and About DOM union: 59 passed; + HTTP/relay/protocol union: 76 passed. Native tests include one-shot intent + consumption across reopen and concurrent consumers, and rate-limited clicks + without hidden delayed retries. + +Browser visual QA renders the actual SidebarFooter/SidebarCollapsed/update +components with the app CSS and React Compiler in a clearly labelled local +state fixture. At the native minimum 960×640, the expanded card fits above +Settings (card bottom 556px; Settings top 562px). Light/dark, indeterminate +download, collapse/expand, dismissal and new-target redisplay were checked. +The fixture recorded one download and one restart request after the explicit +click. This is **UI/protocol evidence, not actual app replacement**. + +This change has not yet been rebuilt into a new signed two-version QA pair or +publicly enabled installer. Earlier signed automatic-update QA proves the old +policy only; do not reuse it as acceptance of this click-based flow. + +## Current installed beta.11 and bootstrap + +The previously delivered beta.11 / desktop 0.2.5 DMG is updater-disabled and +unchanged. To update it now, the user installs a newer DMG manually after quitting +the app. It cannot receive this implementation through an update button it lacks. + +A first click-update-enabled installer therefore still requires one manual +installation. Subsequent releases can use the sidebar button after production +binding, signed artifact acceptance and the remaining release gates are complete. +The UI fixture does not enable production updates, create a release or modify +the user's installed application. diff --git a/docs/DESKTOP-QA-PROFILE.md b/docs/DESKTOP-QA-PROFILE.md index 43668883..1e23bf0d 100644 --- a/docs/DESKTOP-QA-PROFILE.md +++ b/docs/DESKTOP-QA-PROFILE.md @@ -37,6 +37,13 @@ the filesystem profile; keep its UUID with the QA evidence. Deleting the QA directory alone does not erase that WebKit store. QA profiles are not portable. No production browser profile is inspected or copied by this mechanism. +QA pins the automation bridge to the short `a.sock` path directly under its +private profile root. This avoids silently truncated Unix socket names when +the child inherits a long profile-specific `TMPDIR`. Roots that cannot fit the +platform socket address capacity are rejected before the profile is initialized. +Updater preparation's real-app QA is recorded separately in +[DESKTOP-UPDATER-QA-PREPARATION.md](DESKTOP-UPDATER-QA-PREPARATION.md). + The bundled runtime requires macOS 13 or later. On macOS 13, QA mode refuses startup rather than silently falling back to WebKit's default store. Other platforms reject this option. A disposable OS account remains useful for diff --git a/docs/DESKTOP-UPDATE-ADMISSION-IMPLEMENTATION.md b/docs/DESKTOP-UPDATE-ADMISSION-IMPLEMENTATION.md new file mode 100644 index 00000000..db165cca --- /dev/null +++ b/docs/DESKTOP-UPDATE-ADMISSION-IMPLEMENTATION.md @@ -0,0 +1,218 @@ +# Desktop updater: partial runtime admission and draft durability + +## 2026-09-08 current integration delta + +The earlier three-reader checkpoint below is historical. Production composition +now connects fourteen of the fifteen fixed readers. Native-bound `ui-drafts` +remains missing; page receipts do not grant installer authority. The current +handoff is `docs/MACOS-UPDATER-HANDOFF.md`. Public installation remains disabled. + +- Worktrees/orchestrator/native jobs, automation/browser/computer, all native + clients, watchers/notifications, HTTP callbacks and audited auxiliary + Git/clone/startup producers join the existing chat/worker/shell owners. +- Native `job.activity` is a complete read-only aggregate over jobs AND runs, + including archived jobs. Exact schema version 1 has `reserved`, `queued`, + `running`, `aborting`, `unknown`; counters overlap, and unknown durable states + block admission. It never performs reconciliation or starts a process. +- The common fence closes before the worker's reversible fence. The exact + fence ID survives through cancellation/timeout/expiry cleanup. Worker close + and owner snapshots share the original five-second budget; cleanup remains + counted through the actual late acknowledgement/release. Failed releases stay + unknown and a committed fence is never reopened. Remote activity binds the + actual SDK revision and cannot silently adopt another idle baseline. +- Browser child request queues, popup/callback tails, retained pages, evaluations + and graceful final closure report epoch/revision/request-sequence evidence. + Successful use then explicit close can become idle; failed launch/download + ownership is still unknown. No snapshot forcibly closes a browser. +- Project file delivery/upload waits for source/writer close and request-private + cleanup. Git/clone errors wait for real process close before publishing or + cleaning staging; shutdown marking cannot become an idle proof. Native client + failures retain unresolved work instead of treating leader exit as sufficient. +- Notification transport callbacks and dispatch ledger settlement are retained; + enqueue success is not a claim that a notification was shown in the UI. +- Global page freeze covers durable drafts, exact File bytes, queued intents, + offscreen/unmounted continuations, upload/allocation, voice recording and + transcription. Duplicate/stale operation IDs cannot retire another lifetime. + +Pinned SDK 0.16.4 still has a reproduced detached prewarm task outside every +public disposal join and forced-recovery physical ownership gaps. Published +0.16.6 retains the relevant implementation. A narrowly version/hash-bound +app-owned dependency patch is being prepared; it is not yet installed or part +of release packaging, and SDK uncertainty remains a blocker. The native/UI +transaction and final signed qualification remain required, not optional scope. + +## Earlier checkpoint and evidence + +Date: 2026-09-08. Branch: `codex/macos-updater-completion`, based on `49c1010`. +This is implementation progress, **not G0/G3 acceptance or an updater release**. +Native `installation_available` remains false; native `restart` still rejects. +No production installation, user data, signing key or public release was changed. + +## Connected paths + +`server/index.js` now owns one `DesktopRestartAuthority`, constructed with an +explicit immutable required-owner inventory in `desktop-restart-runtime.ts`. +The app factory injects its admission interface into existing HTTP and chat/PTY +dispatch. There is no new browser prepare/commit endpoint or native authority. + +- HTTP handlers use the existing `asyncHandler`. It acquires before invoking the + handler and releases after its actual returned promise settles, not on response + `finish`, request abort or socket `close`. Both synchronous and asynchronous + errors release once and retain Express error handling. A fenced request gets + HTTP 503, `DESKTOP_RESTART_FENCED` and `Retry-After: 1`. +- Image/audio upload handlers now await their actual storage pipelines and + cleanup callbacks. Image creation is exclusive and owner-private; an existing + file or symlink is not overwritten or removed on a collision. Image/TTS streams + remain accounted through source closure/cancellation and descriptor cleanup. + The source coverage inventory finds 113 wrapped registration arguments, two + explicit bootstrap/static exceptions and 14 imported authentication arguments; + this is a registration check, not proof of detached subprocess settlement. +- API middleware also acquires before downstream implicit-owner authentication; + this outer lease does not replace the handler's asynchronous lifetime. WebSocket + upgrade authentication has the same pre-owner guard. `/health` and the already + authenticated native preparation relay remain independent. +- Existing chat sockets acquire before projection, model, goal and OAuth awaits. + A disconnected viewer cannot release a still-running dispatch. The goal's + detached `sendChat` callback retains its own lease. Cached chat replay stays + available; a validated existing approval or abort completion may finish + under a reversible fence. Such activity invalidates a prepared token, even if + it becomes idle again before commit. Nothing is admitted after commit. + OAuth submit/cancel still uses normal admission: a remembered UI attempt does + not prove a live worker, and the current API can lazily spawn one. Its future + completion exception needs generation-bound, non-spawning ownership proof. +- Terminal `init`, input and resize are checked per message, not per connection. + Old PTYs remain represented while their replacements occupy the same session + key. Leader exit does not prove detached-descendant termination, so the + `pty_descendants_unverified` reason stays latched for this server lifetime. + +## Read-only ownership + +Three real readers are composed: `chat`, `gjc-worker`, and `shell`. Every required +reader not implemented is still an explicit `owner_missing` blocker. Reading +activity never starts a process, interrupts a job or clears a health failure. + +The chat registry counts live runs, approvals and asynchronous session/title +publication through settlement. A visible `complete` or registry clear cannot +erase the outstanding publication. + +The worker supervisor tracks startup, request/run continuations, approval replies +in flight and reap uncertainty. Eviction of timed-out request details or a late +response does not prove termination. A live worker still reports +`worker_runtime_unaccounted`; an absent process-tree proof cannot become idle. + +The SDK adapter and OAuth controller now separately expose bounded, credential-free +activity snapshots. OAuth cancellation/timeout/close retains the actual login and +refresh task. Title generation remains counted after its UI grace period until +the title task and persistence really settle. SDK background containment remains +`sdk_background_ownership_unproven`; these inner readers are not yet an integrated +worker-host quiescence protocol. + +## Unsent draft persistence + +The actual composer uses `useDurableComposerDraft` and the browser IndexedDB +repository. Only unsent input, image `File`s and queued intents are stored, not +provider messages/transcripts. Project and conversation form the routing key. +Transaction completion is the persistence acknowledgment; per-record revisions +reject stale-window writes. Failed quota/storage operations retain the live input +and prior committed data rather than evicting unrelated unsent drafts. +Empty visits do not consume draft capacity. Explicitly cleared payloads release +space while bounded revision tombstones prevent stale revision reuse. Incomplete +legacy migration preserves the entire original rather than truncating queued +instructions. A storage error has an explicit retry/rebase action in the composer; +retrying recovery does not itself send a message. + +Loading cannot overwrite newer keystrokes or files, and a late operation for +project A cannot clear or send project B's draft. Restored queued intents retain +their identifiers and files but require review instead of automatically replaying +an instruction whose previous send outcome might be unknown. The existing edit +action restores the intent and its images to the input. + +The legacy localStorage queue remains a compatibility projection. It cannot be +used by the text-only offscreen sender to send a partial File-bearing intent. +Ordinary text-only background auto-send remains supported, including steering +rejection/reconnect. Same-document notifications and consumption reconciliation +prevent old cached queues from resurrecting already-sent instructions. Steering +acknowledgments retain their original project and conversation route. +This is **not** a global freeze/save acknowledgment for native restart: other +windows, offscreen ownership, uploads and in-flight sends still need an integrated +transaction. Browser persistence/eviction is not a native update durability proof. + +## Verification and evidence + +Evidence directory: `/private/tmp/gajae-updater-admission.4woZhX/`. +Final promotion verification passed on the frozen source set. The before/after +SHA256 inventory of changed source, tests and locales is identical +(`inputs-before.sha256`, `inputs-after.sha256`). + +- **Full `npm run verify` passed**, including audit/license/notices, both TypeScript + projects, Rust core, all test lanes, lint, identity and client/server/core build + (`verify-promotion.log`). Earlier `verify-union.log` also passed; the promotion + run includes the final review fixes and locale/UI changes. +- Focused final composer/storage/background-queue DOM tests: 62 pass + (`frontend-final.log`). Recovery/retry UI is tested in English and Korean, with + locale key parity maintained across ten chat locales. + +- Reviewed HTTP/chat/authority/authentication/runtime tests: 123 pass + (`admission-reviewed.log`). Expanded backend union including image/voice/PTY, + chat registry and route coverage: 237 pass (`backend-union.log`). +- Unchanged desktop shell: cargo fmt check; locked Rust tests 182 pass with one + opt-in archive test ignored, and 10 build-binding tests pass (`native-tests.log`). +- GJC wire/browser e2e: 8 pass; browser sidecar e2e: 3 pass (`gjc-e2e.log`, + `browser-e2e.log`). These are separate from native installation acceptance. +- Worker tests: 63 pass, including cancelled enrichment resolving successfully, + reused run IDs, worker replacement and shutdown (`worker-final.log`). OAuth/SDK + tests: 102 pass plus one optional live test skipped (`sdk-final.log`). +- The first aggregate verification caught a legacy background-steering queue + regression (`verify-final.log`). It is retained as failed evidence, not described + as a passing full gate. Subsequent fixes passed the promotion gate above. +- Independent frontend review reproduced and then closed duplicate legacy sends, + cross-project steering acknowledgments, un-retryable recovery/conflict, + incomplete migration loss and missing queue notifications. A late OAuth UI + owner also no longer qualifies as a non-spawning completion exception. + +Browser skill QA used a separate loopback origin, `http://127.0.0.1:5197`, with +synthetic project/session IDs and no real backend requests. The local fixtures are +`.gjc/updater-draft-qa-20260908/`; the checked-in production-composer harness is +`src/components/chat/tests/fixtures/ComposerDraftPersistenceHarness.tsx`. + +Observed through rendered UI: + +1. Input plus both draft/queued image Files survived a full page reload. The queue + ID was unchanged and the restored intent required review. +2. The 95-byte synthetic `qa-image.svg` had SHA256 + `1303e66ce1e0c759517db95d30f91413f342abd8c740e72b9813c863a90c9e87` + before and after restoration, including the queued copy. +3. Project B using the same conversation identifier did not receive A's input or + attachment. Returning to A restored A's data. +4. A second stale window received `error conflict`; reloading it restored the + newer committed text, not the stale attempted overwrite. +5. The actual production composer hook queued a pasted File, restored the same + identifier/File after reload, sent nothing when busy became idle, and returned + the File to the input via its existing edit action. + +These are browser/HTTP/runtime tests, not installed WKWebView, application +replacement, minimum-OS, authorization-dialog or signed A-to-B acceptance. +The private QA browser tabs and Vite listener were closed after testing. During +development, hot replacement of changed hook signatures invalidated the fixture; +full reload restored the final component normally. That transient development +state was not used as passing UI evidence. + +## Still required before installation/release + +- G0's actual macOS 13 qualification and official installer authorization/cancel, + interrupted-write/error classification and writer-termination proof. +- Remaining producer admission and owner readers: worktree/orchestrator/native + job state, internal continuations, automation/browser/CUA, native clients, + watchers/notifications and detached callbacks. A source-wrapper coverage test + does not establish all of these lifetimes. +- Global draft/attachment/queued-intent freeze and native-bound acknowledgment; + safe shutdown with proven owned-server exit and single-instance handoff. +- Product attempt writer/resolver, next-launch pre-server official installation, + embedded applying/recovery and interrupted-install behavior. The earlier + example journal is still QA-only. +- Integrated signed/notarized QA A→B with origin/auth/settings/transcripts/projects + and draft survival; production updater-key custody/backup and release gates. + +The first updater-enabled DMG still requires one manual installation, followed +by a distinct later release to prove public-channel auto-updating. Neither +missing validation nor a passing build authorizes weakening these gates. diff --git a/docs/DESKTOP-UPDATE-ADMISSION.md b/docs/DESKTOP-UPDATE-ADMISSION.md new file mode 100644 index 00000000..213a49e3 --- /dev/null +++ b/docs/DESKTOP-UPDATE-ADMISSION.md @@ -0,0 +1,211 @@ +# Desktop update admission: safe manual restart + +2026-09-08 implementation delta: common HTTP/WS admission and three real owner +readers are partially connected. See +[implementation and remaining gates](DESKTOP-UPDATE-ADMISSION-IMPLEMENTATION.md). +The map below retains its original source-inspection baseline; it is not G3 +acceptance and unimplemented readers still block restart. + +Status: implementation map, not an implemented contract or a G3 pass. +Source inspection: `7b138efc607b1c0bb8de3b06e72fa5ddc34cae02`, 2026-09-07. +Scope: one reversible backend admission fence and existing execution owners. +No installer, native shutdown, runtime, or UI changes accompany this document. + +The approved planning reference is G3 / MU-07 / AC-07 in +`.gjc/_session-01a076d3-db4a-760a-ae8e-1bad69cdb5b8/plans/ralplan/01a076d3-db4a-760a-ae8e-1bad69cdb5b8/stage-03-revision.md`. +See also [the updater handoff](MACOS-UPDATER-HANDOFF.md). +The parent reports an isolated official-updater 2.6 historical signed A-to-B +primitive passed on macOS 26; authorization cancellation is still being probed. +That is not a G0 completion claim or permission to wire product installation. + +## Contract to freeze before assigning code + +Create one `DesktopRestartAuthority` in `server/index.js` and inject its interface. +Do not create independent update reservation registries in every service. Existing +maps remain the owners of accepted work; add read-only readers and preserve their +entries through settlement. All names below are proposed APIs. + +```ts +type OwnerActivity = { + owner: string; + generation: string; + complete: boolean; + starting: number; + queued: number; + running: number; + settling: number; + approvals: number; + retained: number; + unknown: readonly string[]; // bounded reason codes, never payloads/secrets +}; + +interface DesktopRestartAuthority { + enter(source: ProducerKind): ActivityLease; // synchronous check + increment + snapshot(): Promise; + prepare(attempt: BoundNativeAttempt): Promise; + commit(token: string): Promise; + cancel(token: string): void; // idempotent, precommit only +} +``` + +`ProducerKind`, lease/handoff semantics, owner IDs, and result envelopes must be +fixed together; these are not existing exports. A lease is released exactly once +when the actual operation settles, or after synchronous transfer to a registered +live owner. Double-counting during transfer is safe; a zero-count gap is not. +Never release just because a socket closed, a response was sent, or a waiter timed +out. A child-generation change, missing reader, incomplete snapshot, failed +cleanup, or unproven transfer is `unknown`, which blocks commit. +Prepared requires every required reader to be complete, all activity counts to +be zero, and no unknown reasons. Counts may overlap; they are not unique job +totals. Register the required owner set explicitly so a missing reader cannot +silently disappear from the aggregate. + +State transition: `open -> preparing -> prepared -> committed`. Busy/error, +cancel, token expiry, or controller loss may return to `open` only before commit. +This state is separate from job-authority health and irreversible Rust shutdown. + +1. After native preflight and draft/attachment/queued-intent save acknowledgment, + `prepare` closes admission synchronously before its first await. Existing busy + owners return blockers immediately; do not wait for long-running work to end. +2. Collect read-only owner snapshots under that fence. Existing accepted runs may + finish or continue within their owned lifetime; do not abort/pause/dispose them + to obtain idle. A rejected prepare releases only its update fence. +3. Bind the prepared token to attempt, current native/child epoch and expiry. + Prepare/save/snapshot budget is at most five seconds; an uncommitted token + expires after ten seconds. Expiry is not proof that any operation ended. +4. After the native applying page is visible, `commit` revalidates the token and + all ownership proofs. Its final zero-ingress/current-proof check and transition + to `committed` are synchronous, with no intervening await. Async snapshots must + carry current child generation/epoch, not a cached idle boolean. Any producer + capable of invalidating an idle proof must remain fenced or invalidate it. +5. Only the parent native lifecycle proceeds to irreversible shutdown and owned + server-exit proof, then install/restart. Backend commit itself neither installs + nor sends SIGTERM. Postcommit controller loss never automatically reopens. + +Keep reads/status/cancel/approval completion available, but classify operations by +behavior, not HTTP verb. Completion handlers remain accounted until settled. +Reject new sends/steers/root work while fenced. A read that lazily spawns a worker +is not an inert status read: serve a cached result or explicitly defer that spawn. +The bound prepare/commit/cancel calls and inert snapshot reads must not count +themselves as new work. Define new-root, owned-completion and inert-read producer +classes in the shared contract; callers cannot select a permissive class through +request payloads. Internal continuations need a still-live owner or new admission. + +## Composition and ingress sites + +| Site | Required ownership/wiring | +| --- | --- | +| `server/index.js`: production authority/orchestrator, `gjcSpawn()`, `createGjcAppFactory()`, `startServer()` | Own the single authority; supply owner readers and inject admission before listeners/startup callbacks. Current `spawnFns` contains only `gjc`. | +| `server/app-factory.js`: `createGjcAppFactory()` | Inject into HTTP/WS composition before `terminalNotificationAdapter.startupCatchUp()` and `/api/gjc` mounting. Later routes mounted by `index.js` must inherit the same instance. | +| `server/routes/gjc-jobs.js`: `createGjcJobsRouter()` and default export | Default router construction accesses production singletons at module import. Do not leave this alternate construction path unfenced. | +| `server/shared/utils.ts`: `asyncHandler()`; plain async route handlers | Common HTTP lease wrapper must observe handler settlement, not just `finish`/`close`. Adapt plain async handlers to that same wrapper. A request middleware alone cannot observe an abandoned handler promise. | +| `server/modules/websocket/services/websocket-server.service.ts`: `createWebSocketServer()` | Connection routing is insufficient: existing `/ws`, `/shell`, and browser sockets remain usable. Gate dispatch/producer calls on each message or subscription action. | +| `server/modules/automation/automation.service.ts`: `handleBridgeLine()` | Unix-socket automation bypasses HTTP and chat WS. Use the same admission authority after authentication and before the first dispatch await. | + +At this commit `server/shared/types.ts::LLMProvider` and +`server/modules/providers/provider.registry.ts::knownProviders` are GJC-only, +despite AGENTS' legacy-provider wording. Keep dispatch generic and require an +explicit reader for every configured provider. Unmapped providers fail closed. +Cross-module imports use module barrels; engine-facing types/protocol go through +the existing engine boundary, not imports from engine code into app modules. + +## Existing owners and exact producer map + +All paths in this table are repository-relative. Snapshot names are proposed. + +| Owner / reader | Producer entrypoints | Existing accounting and required proof | +| --- | --- | --- | +| `server/modules/websocket/services/chat-run-registry.service.ts`: `chatRunRegistry.snapshotActivity()` | `chat-websocket.service.ts`: `handleChatConnection()` dispatch, `sendChat()`, `steerChat()` | `runsByAppSession`, `pendingApprovals`. Acquire before `await gjcProjection.handle()`. `startRun()` already registers synchronously before model lookup. UI `complete` may precede lower-owner cleanup; retain dispatch/worker ownership through it. | +| Same chat owner, plus worker goal owner | `chat-goal.service.ts::handleChatGoal()`; `chat.goal` callback's `void sendChat(...)` | Scope/goal inspection awaits before starting. Callback calls `sendChat()` immediately, which reserves synchronously: preserve this handoff. Idle-session create/resume and other mutations can open a run; they are not all read/control-only operations. | +| `server/services/session-worktree-runtime.ts`: `snapshotSessionWorktreeActivity()` | `prepareSessionWorktreeRun()`, returned `run()`, `abortSessionWorktreeRun()` | `tickets` is populated before model lookup and spans validation/binding/admission. Preserve a failed/unconfirmed worker's ownership even when the chat ticket is disposed. | +| `server/services/gjc-job-orchestrator.ts`: `JobOrchestrator.snapshotActivity()` | HTTP `POST /api/gjc/jobs`, `/jobs/:jobId/turns`, `/resume`; internal `start()`, `turnStart()`, `resume()`, `serial()`, `dispatch()` | `queues`, `activeRuns`, health transitions, persistence/finalization. `start()` installs a queue synchronously; `turnStart()` first awaits binding resolution, so queue size alone misses preparation. `dispatch()` registers a worker before returning the REST 202 handle. | +| Same orchestrator; durable native authority reader | `enqueueEvent()`, `trackPersistence()`, `completion()`, `appendAdminEvent()`, `authorityHealth()` | Worker terminal is not durable finalization. Count queue tails and pending writes. Keep `admissionBlocked`/health recovery independent of update cancel. Native `job.list/get` must prove no reserved/queued/running/aborting ownership, including archived records; incomplete paging or reconciliation is unknown. | +| `server/gjc-worker-client.ts`: `GjcWorkerSupervisor.snapshotActivity()` | `spawnRun() -> startRun() -> request()`; `ensureWorker()`; `steer()`, `resolveApproval()`, goal/model/OAuth requests | `runs.set()` precedes `void startRun()`. Read all run phases, `starting`, tracker requests, approvals including `inFlight`, expired-request uncertainty, terminating generation and `terminationFailure`. `isActive()` and filtered `pendingApprovals()` are not aggregate proofs. | +| `server/gjc-worker.ts`: host activity contract; `server/gjc-bun-sdk-adapter.ts`: runtime activity reader | `GjcWorkerHost.handle()/#start()`; `GjcBunSdkAdapter.spawnGjc()/#run()/#runInner()` | Host `#runs` and adapter `#starting/#runs` register before awaits. Keep the parent root until goal/ask/delegation/session cleanup and owned background work settle. Cleanup poison stays unknown until verified reap. | +| `server/gjc-goal-session.ts`: goal state within the worker root | `control()`, `invokeTool()`, `onEvent()`; SDK continuation scheduling | Own pending mutation/persistence, timer and stop lifetime. Installed SDK `session/agent-session.ts` has scheduled continuation/post-prompt tasks. Keep these within the root; no updater-driven goal pause/abort to manufacture idle. | +| `server/gjc-delegation-executor.ts`: `snapshotActivity()` | `tools()` task and subagent-resume executors; `#launch()`, nested `#run()`, `serializeGjcDelegationAutomationTools()` | `#jobs.set()` precedes child setup. Count unsettled `job.done`, cleanup failure and the automation promise tail. Receipt status alone is insufficient: child disposal and transcript flush follow completion. Root disposal already joins children; prove this remains gap-free. | +| `server/gjc-bun-oauth-controller.ts`: task-lifetime reader | `start() -> void #run()`; `submit()`, `cancel()`, `#terminate()` | `#active`/last visible phase is not enough: cancellation clears active before login unwinds. Track actual login/refresh settlement without exposing credentials or authorization URLs. | +| `server/modules/automation/automation.service.ts`: `snapshotActivity()` | `openBrowser()`, `commandBrowser()`, `inputBrowser()`, `callComputer()`, authorization methods, `handleBridgeLine()` | Include executing bridge handlers and pre-child setup, not socket count. Public `/api/browser` and legacy `/api/automation/browser` share producers; computer calls are separate. `stopSession()` returning `{closed:false}` is not idle proof. | +| `server/modules/automation/browser-sidecar-client.ts`: `snapshotActivity()` | `request()/ensureStarted()`, `startRecovery()/recoverSessions()/restoreSession()`; `browser-websocket.ts::subscribeFrames()` | Read startup/recovery, requests, retained tabs and uncertainty. Preview connect and state callbacks can start subscriptions. `mode=state`/`cachedState()` is inert; normal preview and `status()` are not. Never use cached empty tabs to certify crashed-child cleanup. | +| `server/modules/automation/browser-sidecar.ts`: child activity contract | `enqueue()/handle()`, `BrowserRuntime.ensureBrowser()/command()/input()/close()`, `onTargetCreated()` | Account global/session queues, realtime bypass handlers, launch/download, popup callbacks and evaluations. Conservative minimal policy: retained live pages are busy until user-requested close is confirmed. No automatic close to make prepare succeed. | +| `server/modules/automation/cua-client.ts`: `snapshotActivity()` plus service session ownership | `call()/ensureStarted()/request()`; service `ensureComputerSession()/endComputerSession()` | Read pending/start/uncertain operations and retained session labels. Ending labels must remain owned through driver acknowledgment. Driver cancellation notification is not cancellation confirmation. | +| `server/modules/websocket/services/shell-websocket.service.ts`: `snapshotShellActivity()` | `handleShellConnection()` local `start()` for `init`/`forceRestart`, input, `detach()`, `clearSavedSession()` | `pty.spawn()` and `sessions.set()` are synchronous. All retained PTYs are busy, including disconnected 30-minute retention. Keep retiring generations until exit/reap proof, not only the currently keyed PTY. | + +### Auxiliary producers: do not omit from an all-idle claim + +| Site | Minimum accounting hook | +| --- | --- | +| `server/modules/projects/projects.routes.ts`: `GET /clone-progress`; `project-clone.service.ts::startCloneProject()` | Lease through `waitForCompletion`, including checkout publication, project registration and cleanup. GET and response disconnect do not imply read-only/finished. | +| `server/services/gjc-job-git.service.ts`: `publish()`, `commit()`, `createPullRequest()`, `execute()` | Hold HTTP/internal ownership through subprocess completion and admin-event persistence. These are not active chat runs. | +| `server/services/gjc-git-client.ts`: `GjcNativeClient.request()/start()/failed()` | Native request/start/restart owner and generation. Automatic restart timer is an internal producer. Orchestrator and job-Git service factories have separate native-client maps; include both. | +| `server/modules/providers/services/sessions-watcher.service.ts`: `startGjcSessionWatcher()`, `openGjcWatcher()`, `synchronizeFile()`, `deliverQueuedUpdates()`, `scheduleRestart()` | Read startup tasks, pending synchronization/flush/restart; underlying `GjcSessionWatcher` owns pending events and `draining`. Defer new producer dispatch while fenced without discarding accepted writes. | +| `server/modules/notifications/services/gjc-terminal-notification-adapter.service.ts`: `startupCatchUp()` | Keep catch-up reads and dispatch-ledger writes accounted. Heartbeats and pure replayable fan-out are not permanent busy owners; do not extend this exemption to pending writes. | +| `server/index.js` file/upload handlers; `server/routes/{git,user,system}.js`; assets/project/provider/voice routers | Common actual-handler lease covers operations outside chat. Audit callbacks that outlive the handler. GET model/status probes can spawn processes; no blanket GET exemption. | + +## Missing proofs that block G3 + +| ID | Concrete gap | Smallest closure/proof obligation | +| --- | --- | --- | +| A1 | SDK adapter `titleTask` races a ten-second grace timer and may write a title after run terminal. | Keep independent background ownership until the task really settles; report it before releasing the worker root. Do not turn the UI grace timeout into cancellation proof. | +| A2 | Browser/CUA client timeout/abort deletes pending entries. Browser `command('run')` times out its waiter without stopping evaluation. Supervisor expired-request entries can be evicted. | Retain unresolved-generation uncertainty through late terminal acknowledgment or verified reap. Never infer zero from these pending maps alone. | +| A3 | PTY grace expiry/restart removes the owner around `kill()`, before confirmed exit; old generation can be replaced at the same key. | Preserve retiring generations in the PTY owner. Leader exit does not establish arbitrary detached-descendant termination: unresolved process ownership blocks, coordinated with the parent's native proof. | +| A4 | Withholding SDK `job`/`cron` tools does not disable background bash. Installed SDK `tools/bash.ts` supports `async`; `async/job-manager.ts` owns registrations, admissions, resumes and deliveries. | Prove root containment through cleanup, including pending callbacks/continuations. Public `getAsyncJobSnapshot()`/`pendingMessageCounts` aid diagnosis but do not establish complete quiescence. Missing SDK ownership surface is unknown. | +| A5 | OAuth `#terminate()` clears active state before asynchronous login/refresh settlement. | Preserve actual task ownership through cancellation unwind. A terminal UI phase alone is insufficient. | +| A6 | Browser session/cache and CUA label removal can precede physical closure. Browser/native clients may respawn from recovery callbacks. | Count closing/recovering work; close admission before callback dispatch. Failed/ambiguous cleanup remains unknown; update prepare must not trigger forced shutdown. | +| A7 | `listRunningSessions()` is only the chat registry; authority health, REST jobs, approvals in flight and auxiliary work are missing. | Aggregate all registered owners. Native job reads require complete pagination within budget or a compact read-only aggregate. No `reconcile()`/`interruptForShutdown()` as an idle query. | +| A8 | `src/components/chat/hooks/useChatComposerState.ts` owns queued sends/steering/dispatch timers; queue persistence omits `File[]` attachments. | Parent/UI lane must freeze new sends and acknowledge durable draft, attachment and queued intent before prepare. Backend idle cannot supply this acknowledgment. | +| A9 | Watcher initialization is in async `server.listen()` callback; `closeSessionsWatcher()` currently runs outside the later shutdown function. | Account readiness/startup/restart independently; do not infer watcher shutdown or idle from this ordering. | + +Do not invoke `server/index.js::shutdownRuntimeServices()`, +`JobOrchestrator.interruptForShutdown()`, `shutdownGjcWorker()`, automation shutdown, +or PTY kill during prepare: these interrupt work or destroy ownership evidence. +Normal shutdown has forceful/error fallback behavior and is not a safe-idle probe. + +## Native/UI transport boundary (parent-owned candidate) + +The proposed transport is a supervisor-owned stdin initialization secret and +authenticated, bounded stdout control frames. No new remote Tauri grants. +Treat this as a candidate pending G0 framing/ownership proof, not an implemented +security guarantee. Admission receives already-bound native requests; it must +not accept arbitrary browser-supplied URLs, paths, executable names or commands. + +- Bind control frames to current child/spawn epoch, request ID and direction; + enforce byte/queue/deadline limits, authentication and replay rejection. + Retire keys/epochs on child replacement. Ordinary logs/descendant stdout must + not impersonate control frames; the secret must not enter logs or inherited + child environment. Parent owns this proof and the final envelope schema. +- Require desktop cookie, exact mutation Origin, and a separate memory-only + current-main-view/navigation capability held in the authorized page's closure. + Do not expose it through cookies, URLs, generic API responses or other windows. + Cookie possession/custom headers alone do not establish native authority. +- Copied cookie without the capability, external browser, stale page/child epoch, + other window and forged log frame must fail. An XSS already running inside the + authorized page is not claimed solved by this binding. +- Precommit controller loss cancels only the update fence. After commit, retain + the committed fence and let native recovery own the outcome. Never restart on + a stale/unauthenticated stdout message or on a failed health probe. + +## Assignment and acceptance checklist + +Freeze the lease/owner/result schema first, then assign disjoint slices: + +1. Composition authority + common HTTP wrapper + native-bound control adapter. +2. Chat/goal dispatch + worktree/orchestrator readers + PTY ownership. +3. Worker protocol/supervisor + SDK/title/OAuth/approval/background lifetime. +4. Automation bridge/client/sidecar/preview + timeout/recovery uncertainty. +5. Auxiliary native clients, watcher/catch-up, clone/Git/file route accounting. + +Required isolated race fixtures (not executed for this document): pause handlers +before first await/owner handoff; race prepare/commit with REST/WS send, goal +continuation, delegated task, background bash, bridge command and PTY init/input; +disconnect an accepted request; deliver a late timeout response; race recovery +and forceRestart with owner snapshots; retain approval and OAuth cancellation +settlement; lose the controller before/after commit; expire/replay a token; fail +health and cancel update. Assert no accepted work/restart double success, no +lost ownership, and no force-drain. Verify inert status/state observers still work. + +Document validation is static source/path/diff inspection only. This document +does not certify G0/G3, installed-app updating, process exit, authentication +cancellation, OS compatibility, or user-data survival. Every uncovered producer +or unknown owner continues to block manual restart; preparation/download work +can remain independently available. diff --git a/docs/DESKTOP-UPDATER-INSTALL-PROGRESS.md b/docs/DESKTOP-UPDATER-INSTALL-PROGRESS.md new file mode 100644 index 00000000..98b03ec8 --- /dev/null +++ b/docs/DESKTOP-UPDATER-INSTALL-PROGRESS.md @@ -0,0 +1,110 @@ +# Native updater installation progress + +2026-09-08. The active objective is still **complete automatic updating and app +distribution**, not preparation-only acceptance. This is an implementation +checkpoint; the objective is not achieved and installation is not activated. + +## CI correction + +`7ef7cda` records the new OAuth test in the engine SSOT and includes untracked, +nonignored `server/gjc-*` files in the inventory check. The earlier local gate +missed that file before staging; the committed head failed remotely. The new +head's Node 22/24 CI and Linux server build/smokes passed. Desktop Linux remained +in progress when checked. Do not transfer those results to later native changes. + +## Implemented native installation components + +- `updater_install.rs`: `VerifiedArchive::load` verifies cache digest, Minisign, + full archive identity/inventory and retains **one immutable boxed buffer**. + Preparation cache validation now uses this same implementation. +- `VerifiedArchive::reconstruct` accepts native `Reconstruction` inputs and uses + supported `UpdaterExt` APIs. The plugin must first be registered by an admitted + native caller. Native release/asset-ID and manifest rereads share one absolute + five-second deadline with plugin check. No plugin download API is called. +- Discovery returns the exact final validated HTTPS manifest endpoint, starting + from the public download URL so the plugin's forced JSON Accept header cannot + select GitHub asset-API metadata instead. Redirect/token policy is retained. + Raw/encoded template braces are rejected before the plugin can rewrite the URL. + Plugin metadata allocation is still timeout-bounded, not byte-bounded; native + reserialization has its own 64-KiB cap rather than making another unbounded copy. +- `InstallLocation` checks the native executable/product/version, canonical + allowed app location, supported ownership, read-only volume and same temporary + volume. Both literal and canonical temporary paths are retained/rechecked; + quote/backslash/control characters are rejected before the pinned plugin's + AppleScript authorization branch. The source Info.plist uses the archive's + bounded semantic parser, including duplicate/depth/reference-expansion limits. +- `updater_attempt.rs`: `Journal::begin(Target)` publishes and fsyncs the canonical + blocker before returning a non-Clone PID-bound `LiveAttempt`. Descriptor/root/ + inode/source-app rechecks guard the permit. Failure/Drop/crash preserves the + blocker. `record_installed` requires the opaque bundle verifier via a sealed + adapter and durably changes the record to `AwaitingHealth`. +- `PreparedInstall::apply` consumes the verified buffer, revalidates location, + begins the journal, calls the official synchronous `Update::install` off the + future event-thread caller, verifies the complete installed B tree, then records + awaiting health. It never reloads different bytes or times out an active + installer. Error/panic/uncertain replacement requires recovery, not retry or a + claim that cancellation left A safe. +- `updater_bundle.rs`: bounded descriptor-relative, no-follow complete-tree + comparison against `ArchiveInventory`, including extras, omissions, modes, + hashes and link targets, followed by a second walk/metadata checks. Its private + `VerifiedBundle` is inventory evidence, not Apple signing/notarization or a + permanent filesystem snapshot against a hostile same-UID process. + +These APIs are **not yet called by the app launch/restart flow**. The existing +presence guard still rejects every present attempt. `LoadedAttempt` is read-only +inspection, not a startup/cleanup permit. Native installation availability stays +false, and the About restart command still rejects. No production app, public +release or signing key was changed. + +## Verified here + +- Whole `npm run verify`: passed (`/private/tmp/gajae-updater-install-verify.log`). +- Locked desktop Rust tests: 236 passed, two opt-in/helper tests ignored; ten + build-binding tests passed (`/private/tmp/gajae-updater-native-install-tests.log`). + Parent tests exercise the journal's ignored subprocess helper explicitly. +- Native formatting passed. Clippy excluding **only unintegrated dead-code + warnings** passed (`/private/tmp/gajae-updater-native-install-clippy.log`). This + is not the final standard `-D warnings` gate: real launch integration must remove + the unused API warnings; no source-level dead-code suppression was added. +- The explicit existing-archive read-only test also compared the historical + isolated installed B using the new native verifier: all **20,673** entries + matched, inventory SHA256 + `384eaccc5d8214a617d8ef45530be997bda73f3480ce06572a3980e1d0763b23`. + Artifact SHA256 was independently rechecked as + `dda009d8e3d51a89fce3c61968fe386f1fcaad9954628aab4ea56ae6b8ffbe63`. + Evidence: `/private/tmp/gajae-updater-installed-bundle-proof.log`. + The fixture is beta.9/desktop 0.2.3 with the known declared-11/loader-13 mismatch. + It is **not** new product A→B, macOS 13, or live installer execution evidence. +- Review corrections: literal temporary path, plugin URL substitution and bounded + installed plist parsing. Focused review found those deltas resolved. +- Read-only operational check: repository Actions secret/variable name lists are + empty. This Mac has a valid Developer ID Application identity; no credential was + exported, no signing/notary submission or secret provisioning occurred. The + existing runbook names the local `gajae-notary` profile; its current authentication + was not revalidated in this checkpoint. + +## Next critical-path work — do not replace this with more preparation-only UI + +1. Register the official plugin only after compiled macOS mode/profile admission. + Gate setup and every supervisor/Retry entry while checking/applying/recovering. +2. Implement verified successor resolution: an `AwaitingHealth` record alone or + matching version strings must not start a server. Require signature-verified + cached bytes, complete installed-B inventory, compiled payload identity and + proved previous-owner exit. Then acknowledge the actual owned server's checked + health and durably archive the attempt; never erase an interrupted `Installing` + record on a guess. Preserve the cache until this durable success transition. +3. Connect next-launch apply before server/worker startup, embedded applying and + recovery screens, preventable Quit deferral, explicit restart intent, held-lock + successor handoff and deep-link replay. Do not abandon a live OS installer. +4. Complete required runtime ownership/internal admission and global draft/File/ + queued-send freeze acknowledgment; connect prepare/commit/cancel and safe + manual restart without force-draining active work. +5. Exercise this exact integrated code using isolated signed/notarized QA A→B, + startup-before-server ordering, same origin/data, prompt/error/crash recovery, + actual macOS 13 and Linux regressions. The approved G0/G3/G5 gates still apply; + production activation is not authorized by the primitive tests above. +6. Establish production updater-key custody/backup and the valid local signing/ + notary route, bump product and desktop versions, produce/verify/publish the + updater-enabled initial DMG and a distinct later update. First-install success + alone does not prove automatic updating. Keep the full goal active until the + requested distribution and update path are actually verified. diff --git a/docs/DESKTOP-UPDATER-LAUNCH-QA.md b/docs/DESKTOP-UPDATER-LAUNCH-QA.md new file mode 100644 index 00000000..891621e6 --- /dev/null +++ b/docs/DESKTOP-UPDATER-LAUNCH-QA.md @@ -0,0 +1,136 @@ +# Native next-launch updater: integrated private QA + +Date: 2026-09-08. This advances the active automatic-update **and distribution** +goal. It is not public-release, macOS 13, or final notarized acceptance. + +## Connected implementation + +- macOS setup now enters `LaunchGate` before any sidecar. Both the early + supervisor check and its PID-locked spawn check consult this same gate. + Checking, installing, restarting and recovery reject Retry/new server starts. +- Official plugin configuration is supplied in the admitted native Tauri + context. `Builder::pubkey` alone was insufficient: plugin Config deserialization + happens first. Disabled/unbound modes gain no plugin configuration. +- A compiled updater-QA executable now rejects a missing/foreign `--qa-profile` + before profile creation, WebKit or a sidecar. It cannot silently fall back to + production HOME/storage when opened without its arguments. +- Only explicit `--qa-update-install` in the matching compile-bound QA app + actuates the installer in this checkpoint. Its private fixture must have its + previous process tree independently verified stopped. The loopback-port veto + is additional evidence, **not a general production owner-absence proof**. + Public-mode installation and safe manual restart remain gated. +- Preflight reuses native cached-byte/signature/archive/location checks and the + exact-endpoint official plugin reconstruction. Nonmutating initialization or + cache failures can defer to A. Present/uncertain install state requires recovery. +- `ScreenState` publishes `(epoch, screen)` atomically. Only the embedded main + document can acknowledge the current display epoch. The double-RAF callback + checks the same connected DOM root; old scripts/overwritten screens cannot + acknowledge Applying. Generic Retry errors cannot overwrite a live updater. + Applying is acknowledged before the PID-locked installation claim. +- After the official installer returns and complete B verification succeeds, + `AwaitingHealth` is durable. Restarting presentation is best effort, not a second + blocking paint gate: a verified replacement must not be stranded by an occluded + WebKit page. Explicit restart intent bypasses ordinary Quit; preventable Quit/ + Close during mutation is held. Unpreventable termination remains a journalled + recovery case, not an installer-cancellation claim. +- Successor startup requires the signed cached archive, complete installed-B + inventory, compiled B/payload identity and the old owner being gone. An opaque + live successor permit supplies the narrow exception to the presence guard. +- Before exposing the SPA, B is rechecked and the actually owned server passes + independent health again. Its native sealed health proof includes a live direct + child with the same UID and pinned process birth identity. +- Completion persists `PreparedSuccess` before retiring the canonical attempt; + only afterward is schema-2 `Committed` atomically published. Pending/invalid/ + legacy/staging completion state blocks even when the canonical name is absent. + Proof-backed recovery can restore AwaitingHealth from pending completion data; + no loaded JSON or version string grants a fresh installation permit. + +## Real private A → B result + +Evidence root: `/private/tmp/gajae-updater-launch.aWWHUs/`. +QA profile: `/private/var/folders/y6/0bfmyp091_577tb_vpgs9yrw0000gn/T/gajae-update-qa-UAlKNY`. +Private HTTPS feed: `https://127.0.0.1:55750`. + +A is product beta.10 / desktop 0.2.4; B is beta.11 / 0.2.5. Both are debug, +ad-hoc-signed builds, with a dedicated disposable QA updater key. B's verified +archive is 164,588,634 bytes, expands to 586,370,560 bytes and has 20,643 entries. +The B artifact/manifest/inventory are recorded in `B-artifact.json` and `assets-B`. +No production key was generated, exported or provisioned. + +The accepted autonomous run is **cycle4**, launched through macOS LaunchServices +with output redirected by `open`, not tied to a terminating terminal process. +There was no manual B launch or process interruption between A launch and commit. + +| Observation | Recorded result | +| --- | --- | +| Applying gate | A PID 79621 logged `applying-visible` before `install-begin` | +| During mutation | At +44.142s: only A; no server/worker processes | +| Replacement/restart | At +72.201s: AwaitingHealth, A gone and B PID 80818 present | +| B server starts | At +162.384s: B-owned server 81804 and two core processes | +| Health/commit | At +280.595s: schema-2 Committed, canonical attempt absent | +| Receipt | Attempt `8243e9adee41f6df0f8a12ae3e685c88`, completed by 80818, server 81804 | +| Origin | `http://127.0.0.1:56452/` before and after | +| Actual UI | beta.11, same Scratch project, same unsent text and `fixture.svg` | + +Native phases are in `cycle4-native.stderr.log`; externally observed process/ +journal transitions are in `cycle4.jsonl`. `B-completed.png` records the final +Computer Use UI. Deep strict codesign verification passed after replacement. +The plain-text draft was `QA draft survives the app update 2026-09-08`; no prompt +was submitted to an external provider. Korean static recovery/checking UI was +observed; a failed automation attempt to type Korean is not Korean-input proof. + +A further normal B quit/reopen retained the same committed attempt ID, did not +create another canonical attempt, and restored the same project/input/image and +origin. This separately checks that a completed update does not reinstall itself. + +After QA, every app/server/worker executable under this fixture and the private +HTTPS feed was confirmed stopped. The five generated app copies were reversibly +renamed from `.app` to `.app.fixture` under the locked QA root; contents and data +backups remain intact. This avoids accidental Finder launches of old intermediate +QA binaries that predate the missing-profile guard. They are evidence fixtures, +not user-installable deliverables. + +Debug verification is slow. These elapsed times are not release performance or +the production acceptance ceiling. Release-mode timing and the full signed/ +notarized data-survival matrix remain required. + +## Earlier runs retained as failures or narrower evidence + +- Initial native plugin registration failed before any attempt/app mutation due + to the missing public Config key. The context fix and a regression test cover it. +- A terminal-hosted run replaced A and spawned B, but B ended before health + completion. Its termination cause was not proved; manual B recovery afterward + succeeded, but that run is **not** autonomous-update acceptance. +- One test reset copied the QA app before its failed UI Quit request was noticed. + That reset is excluded from acceptance. Production installation/data were not + targeted. Subsequent fixture resets require no QA-owned executable and hold + the real profile instance lock; full prior synthetic app/data are retained in + explicitly named backups, not erased. +- Reinstalling older A over a completed B fixture without resetting its update + history was refused as a different completion chain. That invalid test setup + is not a normal-update failure or permission to weaken journal checks. +- A verified replacement could remain in A while waiting for Restarting display + acknowledgment. The final code preserves the strict pre-install Applying + barrier but cannot let post-install presentation failure prevent restart. + +## Verification and remaining scope + +Whole `npm run verify` passed during this iteration. The final native suite has +267 passing tests and ten build-binding tests; four opt-in/helpers are excluded +from the default count. The standard `cargo clippy --all-targets -- -D warnings` +gate and formatting pass. The standalone old journal example allows unused +product-journal APIs only at its import boundary; production APIs are wired and +not hidden by a blanket dead-code suppression. Atomic-display/actual paint-script +DOM regressions passed separately. + +This is a private debug qualification, not a final same-source release pair: +the recorded A/B working-tree builds differ, and B predates some later hardening. +Product/desktop source versions were restored to beta.10/0.2.4 after constructing +the private B artifact. Public release assets were not changed. + +Still required: production previous-owner proof/activation, full runtime/internal +admission and global draft/attachment/send freeze, safe manual restart, final +signed/notarized same-cut A→B including wider auth/transcript/queued-state cases, +real macOS 13 and Linux acceptance, production key custody and initial/subsequent +public releases. The active goal remains open; do not count this checkpoint as +completion of those requirements. diff --git a/docs/DESKTOP-UPDATER-QA-PREPARATION.md b/docs/DESKTOP-UPDATER-QA-PREPARATION.md new file mode 100644 index 00000000..326b21ee --- /dev/null +++ b/docs/DESKTOP-UPDATER-QA-PREPARATION.md @@ -0,0 +1,109 @@ +# Actual macOS updater preparation QA + +This is **preparation/control-path evidence**, not completed automatic installation, +restart, public release or minimum-OS acceptance. The user could not remember the +button selected in the earlier authorization test and reported no known macOS 13 +test machine/VM. Cancellation remains unconfirmed; no release gate was waived. + +## Host and isolation + +- Host: macOS 26.6.2 arm64; app built in debug mode with `GJC_UPDATE_MODE=qa`. +- Product/desktop versions remain beta.10 / 0.2.4. App signing is disposable + ad-hoc signing, not Developer ID/notarization acceptance. +- `qa_profile_init` reuses `QaProfile::open` without constructing a window. It + accepts only a fresh, canonical, current-owner/private `gajae-update-qa-*` + directory directly under the system temporary directory. It does not fabricate + a profile manifest or adopt an existing user/project directory. +- A private HTTPS feed and compiled QA CA were used; TLS validation stayed on. + Only an existing QA public updater key was used. Test TLS private keys stayed + outside the checkout and app; no production updater key was created. +- The app was copied outside the checkout into its compiled QA root. A complete + 20,636-entry inventory comparison and strict deep code-signature verification + passed before the accepted run. An earlier merge-copy left stale resources; + that copy was rejected and retained separately, then replaced with a clean copy. +- UI actions used the real `Gajae Code App — QA` window through Computer Use, + not a mock React bridge. No normal app, credential store, conversation or + worktree was used as the test target. + +## Problems found and fixed + +1. Remote Node 22/24 CI rejected the last snapshot-validator edit because + `Object.hasOwn` is outside the frontend's ES2020 lib contract. The equivalent + `Object.prototype.hasOwnProperty.call` check preserves the contract. Fix + `6f99642`; CI run `34140074184` passed after failed run `34138220283`. +2. QA's long `TMPDIR` made the automation socket path 111 bytes. The OS bound a + truncated path, so chmod on the intended name failed and the server exited. + QA now explicitly uses its private root's short `a.sock`; roots too long for + the target platform's `sockaddr_un.sun_path` are refused. A real bind test + verifies that the expected pathname exists, not just its computed length. +3. The real About screen initially received 503 `updater_unavailable`. Accepted + socket nonblocking mode caused the second read after HMAC authentication to + fail before Node could send its command. A real NodeRelay-to-Rust protocol test + reproduces the failure with an explicitly nonblocking accepted socket. Clearing + that flag on the accepted stream fixes the test and actual app. Listener + behavior, authentication, caps, peer-PID checks and total read deadline remain. +4. With automatic checks disabled, startup could keep the old `server_not_ready` + reason indefinitely because no later network phase cleared it. Healthy + initialization now starts with a fresh idle snapshot; it does not invent a + completed discovery or installed update. + +## Accepted interactive sequence + +- App and supervised server started successfully; About displayed authoritative + product/desktop versions, idle status and the explicit installation-unavailable + boundary. +- Turning automatic checks off changed the native preference to + `{"schema":1,"automatic":false}` only after the response. +- A manual check while automatic checks were off reached the HTTPS fixture + (request count 6 to 8) without enabling automatic checking. +- A malformed release-list response produced the deferred discovery-failure UI + (count 9). Restoring the valid empty list and checking again recovered to idle + (count 11). +- After normal Quit, the tracked app, server and two core processes exited. + Relaunch reused `http://127.0.0.1:57560`, kept automatic checking off, and made + no additional feed request. About showed the persisted value and correctly + kept discovery incomplete because no new scan had been performed. +- The final app signature remained valid after the run. The QA app and feed were + normally stopped. Copies and private evidence were retained. + +![Real native QA preparation state after restart](images/updater/about-native-qa.png) + +Evidence directory on the operator Mac: +`/private/tmp/gajae-updater-desktop.OnXczN/`. It contains build context/diff hashes, +copy verification, red/green protocol logs, feed request counts and GUI capture. +The app was a working-tree QA build based on `6f99642`, not a frozen release cut. + +## QA-only installation journal proof + +`examples/updater_journal_probe.rs` and `examples/support/updater_journal.rs` do not +start an app/server or invoke an installer. They exercise exclusive creation, +file/directory fsync before a live handle, blocking records after error/Drop/crash, +separate target-byte verification and exclusive archival in a cooperative private +fixture namespace. They reuse the product's actual presence-only admission guard. +Saved JSON never recreates ownership or clears a startup blocker. + +Twenty tests passed; the one ignored subprocess helper is explicitly invoked by +the fault tests. The probe's success/cancel/failure values are **simulations**, not +macOS authorization or privileged-writer termination evidence. In particular, an +adversarial same-UID conditional-rename proof and real power-loss testing remain +outside this helper. Do not promote it into product installation authority. + +Useful checks on a configured development checkout: + +```sh +cargo test --locked --manifest-path src-tauri/Cargo.toml --example updater_journal_probe +cargo test --locked --manifest-path src-tauri/Cargo.toml --example qa_profile_init +cargo test --locked --manifest-path src-tauri/Cargo.toml real_node_relay_completes +``` + +The Node/Rust interoperability test requires the repository's supported Node and +installed `tsx` dependency. It launches a real child and verifies the same native +framing/handshake path; it is not a test of the installed application's lifecycle. + +## Remaining boundary + +The production installer, attempt resolver, full work/draft-safe manual restart, +embedded applying/recovery, signed product A→B/data acceptance, macOS 13 execution +and production signing-key custody are still pending. `restart` continues to +reject and `installationAvailable` remains false. Nothing here publishes or +installs a new production version. diff --git a/docs/DESKTOP-UPDATER-SIGNED-QA.md b/docs/DESKTOP-UPDATER-SIGNED-QA.md new file mode 100644 index 00000000..57359462 --- /dev/null +++ b/docs/DESKTOP-UPDATER-SIGNED-QA.md @@ -0,0 +1,184 @@ +# Same-source signed updater qualification — automatic path passed + +On September 9, 2026, the same-source signed/notarized A → B next-launch +automatic update passed on macOS 26.6.2 arm64. Off/on behavior, successor health, +data preservation and an additional ordinary B quit/reopen were verified. +The full public-distribution goal remains open; this is private QA, not a public +release-completion record. + +## Frozen source and signed artifacts + +- Source cut: `f69ec4fa910af64fefe63024162dde37a08256e6`. +- A: product `2.0.0-beta.10`, desktop `0.2.4`. +- B: product `2.0.0-beta.11`, desktop `0.2.5`. +- Both release-mode builds normalize only the four product/desktop version + fields before hashing **all tracked source files**. Both returned + `a959d64fe104a43abac0d6a5126c40e327f72a0289e4606cc1acc3f686da2629`. +- Both are Developer ID signed, hardened, timestamped and notarized. The native + shell was rebuilt against the finalized payload runtime-manifest hash before + the outer signature. Stapler and strict/deep codesign pass; Gatekeeper reports + `accepted`, `Notarized Developer ID`. +- Apple submissions: A `6ea59667-a710-45e3-b0a1-91206ab404f5`, B + `745c9080-5946-4471-9595-3f48c77f8364`, both Accepted. +- B updater archive: 157,856,407 bytes, SHA-256 + `0ce670552bef334113c4f64492fdcae1faff46761f3b367109f919bae27c0202`. + Dedicated QA updater key only; no production key or public release was changed. +- B packaged-server smoke and separate data-survival smoke pass. + +Evidence root: `/private/tmp/gajae-signed-update.CRJaMk/`. +QA profile: `/private/var/folders/y6/0bfmyp091_577tb_vpgs9yrw0000gn/T/gajae-update-qa-nRUrGS`. +App copies: `builds/A/Gajae Code App.app`, `builds/B/Gajae Code App.app` beneath +that profile. The exact compiled root `Gajae Code App.app` now contains installed +B. Temporary source versions were restored; the source cut was clean before +this documentation update. After acceptance the QA app was quit normally and +the private feed, model and passive observer were stopped. All fixture files, +cached bytes and journals are retained; the production app was not changed. + +## Actual data and busy-restart checks + +The private update feed is `https://127.0.0.1:49741`, serving signed B. The app's +stable origin is `http://127.0.0.1:51693`. A local synthetic model at +`http://127.0.0.1:50896` uses the real built-in pi-native transport and default +SDK hosting; it never forwards requests to an external model/account. + +The first fixture held SSE too long and the SDK timed out. Its raw request log +also exposed concurrent fixture log writes; those failures are retained, not +passing evidence. The fixture was corrected to serialize atomic log writes and +emit valid empty text deltas while held. `model-requests-2.json` records two +subsequent successful responses, neither aborted. + +Actual UI session: `4a804db1-9b21-4cc9-851b-8f1f274834c1`, project Scratch. +It contains the failed first run plus two successful assistant responses. The +review-paused queue contains `QA signed update keeps this draft and attachment.` +and one SVG. Current unsent input is +`QA final draft remains after the signed update.` + +While the second successful run was held, clicking Update and restart did not +stop A (PID 97073) or its server (97142), did not abort the model request, and +created no installation attempt. The run completed after the fixture was released. +This proves that particular busy deferral, not every approval/cancellation case. + +`before-update-data.json` captures the actual private WebKit IndexedDB record +through read-only SQLite, not a DOM mock. Its 745-byte serialized draft/queue +record includes the exact 164-byte SVG payload. SVG SHA-256: +`b74f50a8e7962eb50745265ca66d1b6361a698520cbb516a7212045153bfd71b`. +Draft record hash: `a54c78399f59f22b866c84af21662b158390157e2dd815d27f4cb80ee16a6050`. +The provider transcript is 8,280 bytes with hash +`6a224156ca0f4f20ee52d42f4cde3588600ec761559fdccc7f58a258419f75d4`. +Model configuration and automatic preference are captured too. The comparisons +below establish that these exact recorded bytes survived every accepted stage. + +The WebKit data store is UUID `3E12807D-3CDB-4540-ADCF-B8434D0B61DD`, separate +from the QA home/browser directories. `snapshot-data.mjs` targets only that +store and is read-only. Do not inspect a default/production WebKit store. + +## Accepted Off/on and normal-reopen sequence + +1. After manual Mac unlock, Computer Use confirmed the exact `— QA` app and + existing session. With B already cached, automatic updates were turned off + through About. Normal Cmd-Q stopped A/native 97073 and server 97142. +2. Launching the same app with only `--qa-profile` started A/native 21875 and + server 22012. Product beta.10 / desktop 0.2.4 remained installed; automatic + stayed false and no canonical attempt existed. The same session, paused + queue, one image and unsent draft were visible after opening the conversation. +3. Automatic updates were enabled through About. After normal Cmd-Q and observed + native/server exit, the same app was launched with only `--qa-profile`: + **no manual restart button and no `--qa-update-install` flag** were used. +4. `automatic-cycle.jsonl` captured attempt + `d5b3a37a3f728dad88ff942ca69400f8`: installer owner 23709, `installing` before + any server marker, then `awaiting_health`. The old owner exited; B/native + 23998 and server 24006 completed the schema-2 `committed` health record. + No manual intent appeared anywhere in the observed cycle. +5. Installed B reported product beta.11 / desktop 0.2.5. Strict/deep codesign, + stapler validation and Gatekeeper all passed again on the **installed** app. + Origin remained `http://127.0.0.1:51693`; the session, two successful responses, + review-paused queue/image and unsent draft were visible. Model request count + stayed two, both complete and not aborted: the queue was not auto-sent. +6. A further ordinary Cmd-Q/reopen started B/native 25074 and server 25198, + retained the same version/origin and all data, and did not start another + installation. The final normal Quit stopped both processes. + +`accept-results.mjs` compared `off-before-quit`, `off-reopen`, `before-auto`, +`after-auto` and `b-normal-reopen` snapshots with `before-update-data.json`. +All five retain the identical 745-byte draft/queue record, exact SVG bytes, +8,280-byte provider transcript and model configuration hash. It also checks +consent values, installed version, the recorded install/server ordering, +absence of manual intent, successor completion and installed signing acceptance. +Results are saved in `accepted-results.json`. UI evidence includes +`off-reopen-session-ax.txt`, `after-auto-session-ax.txt` / `.png`, and +`B-normal-reopen-session-ax.txt` / `.png`. + +The UI capture during process replacement briefly returned Computer Use +`timeoutReached`; no extra app launch or installer retry was issued. The journal +then committed normally and the successor UI was inspected. The transcript's +failed initial fixture run remains failure evidence, not a successful model run. + +## Remaining public-release boundary + +The post-acceptance source gate was rerun and stopped at the dependency audit: +new blocking advisories affect `extract-zip`, both `js-yaml` majors and `multer`. +That failed run is retained as `verify-after-acceptance.log`; the earlier frozen +source gate must not be represented as today's clean security audit. Security +dependency changes need their own verification and final release artifacts. + +The compatible dependency fixes now require Multer 2.3.0 and constrain installed +YAML 3/4 to 3.15.2/4.3.2. Regression tests cover locked version floors, empty +merge-source limits and frontmatter compatibility; shipped third-party notices +were regenerated. SDK/Puppeteer versions and the SDK runtime manifest did not +change. The remaining ZIP advisory was then addressed with a separate canonical +backport of upstream PR160 (commit `148750acb10c574818906de2a99aa13d457d5329`), +which is open/unmerged, not a published dependency release. The manifest/helper +pin exact version, package metadata and full before/after source hashes; unknown, +linked, aliased and nested installations fail closed. Postinstall applies it; +dev/build/test/audit and both server/desktop staging plus out-of-tree smokes +verify rather than silently apply it. The independent SDK32 manifest is unchanged. + +The archive-only regression proves an unpatched ZIP can overwrite an outside +canary and the patched extractor refuses it. Normal/duplicate files, directories +and safe symlinks remain supported. The upstream change does not protect against +a concurrent local writer swapping the path after lstat, nor later consumers +following extracted symlinks. Neither property is claimed. Audit recognition of +the new advisory requires the installed canonical patch and a current review; +the older advisory additionally retains its existing pinned-vendor-download +restriction. This is not an unconditional vulnerability waiver. + +The focused union passed 46 tests (`security-focused-tests.log`), covering real +archive bytes, integrity and audit-negative cases, staging and the actual copied +checker. A clean `npm ci` applied all 32 SDK files and the one ZIP patch +(`security-clean-install.log`). Final source verification is recorded separately +from the frozen signed artifacts above; any public candidate must be rebuilt +with this security delta. + +The final clean-install **full `npm run verify` passed**, including audit, +licenses/notices, typecheck, core, all tests, lint, identity and build +(`security-final-verify.log`). Audit explicitly reports two patch-verified, +time-bounded extract-zip records and four moderate entries below its gate; this +is not a claim that raw `npm audit` reports zero advisories. No production key, +public release or installed production app was changed by the security work. +The security delta is committed as `5aecb49`; it must not be confused with the +earlier signed updater qualification cut `f69ec4f`. + +With the compatible dependency updates, the parent ran the complete non-audit +verification chain: SDK integrity, licenses/notices, typecheck, Rust core, +all Node/Bun tests, lint, identity and build all passed. The separately rerun +audit at that intermediate point failed on the ZIP advisory, so that intermediate +run is not a full `npm run verify` pass. Before this dependency delta, HEAD +`6280f49`'s Linux server CI retry +`34250378830` (attempt 2) passed archive build and Ubuntu 22.04/24.04 acceptance; +attempt 1 failed only at GitHub artifact finalization with HTTP 403. Its other +Node 22/24 and Linux desktop/GUI checks also passed. Those remote results are +not transferred to a later source cut. + +The native owner suite passed 31 tests (one opt-in test ignored). An explicit +read-only live shared-domain census then failed closed because process/foreign +evidence changed after census (`owner-census-after-qa.log`). This is not a +production owner-absence acceptance; no process was terminated to force a pass. +The QA app had already been closed normally; only the task-owned fixture +services were stopped. + +Remaining release requirements include wider failure/authorization qualification, +production owner/OS13 acceptance, updater-key custody/backup, and initial plus +subsequent public distributions. No real external-provider credential/login +migration was exercised by this synthetic model fixture. Source +supports explicit production bindings, but default builds stay disabled and +this QA does not by itself authorize publishing an updater-enabled installer. diff --git a/docs/GJC-WORKER-PROTOCOL.md b/docs/GJC-WORKER-PROTOCOL.md index 8cab2ca2..2fb1b949 100644 --- a/docs/GJC-WORKER-PROTOCOL.md +++ b/docs/GJC-WORKER-PROTOCOL.md @@ -104,6 +104,8 @@ scope is fixed by this specification, and a frame that gets it wrong is rejected | --- | --- | --- | | `worker.initialize` | global | Negotiate startup. Must be the first request. | | `worker.shutdown` | global | Ask the worker to stop accepting work and exit. | +| `worker.activity` | global | Observe bounded ownership counters on the existing worker without starting a runtime or changing its activity revision. | +| `worker.admission` | global | Reversibly close or reopen admission using an exact fence identifier; does not cancel accepted work. | | `models.catalog` | global | List models the worker can run. | | `oauth.providers` | global | List providers that support interactive sign-in. | | `oauth.status` | global | Report sign-in state. | @@ -141,10 +143,11 @@ scope is fixed by this specification, and a frame that gets it wrong is rejected the same event reports both "this worker is alive" and "this conversation is still working". -## 5. Payload schemas are not part of this layer +## 5. Payload schemas -The envelope above is enforced strictly. **Payload contents are not.** A payload -is checked for being a valid JSON object and nothing more; what belongs inside +The envelope above is enforced strictly. **Most payload contents are not.** Except +for the two ownership methods specified below, a payload is checked for being a +valid JSON object and nothing more; what belongs inside `session.start` or `tool.completed` is defined by the typed contract the two reference implementations share, not by the protocol codec. @@ -157,6 +160,41 @@ Publishing them — as JSON Schema, or as a generated document — is the work t would make this a complete two-sided specification. Until then, treat §1–§4 as normative and payload shapes as observed behaviour. +### Ownership observations and admission + +`worker.activity` accepts exactly `{}`. A successful result contains exactly +`generation`, `complete`, `starting`, `queued`, `running`, `settling`, `approvals`, +`retained`, `unknown`, and `fenceId`. The six counters are nonnegative safe +integers and may overlap; `complete` is boolean. `generation` and each entry in +`unknown` match `^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$`. `unknown` has at most 32 +entries. `fenceId` is null or an identifier matching the same pattern. +An empty counter set is not idle evidence when completeness is false, an unknown +reason is present, or the expected admission fence is absent. + +`worker.admission` accepts exactly `{ "fenceId": "identifier", "closed": true }` +or the same shape with `closed: false`. A successful result contains only +`fenceId`: the accepted identifier when closing and null when reopening. Another +fence cannot replace or release the current fence. Accepted work and its necessary +completion callbacks remain owned; closing admission is not an abort request. +The reference host closes locally before awaiting the worker acknowledgement and +must release the exact fence after a failed/cancelled/expired restart preparation. +A late or failed release acknowledgement is not proof that admission reopened. +The host's common admission fence closes before this worker handshake; setup and +all activity reads share the original five-second preparation budget. A cancelled +or timed-out handshake retains cleanup ownership through its actual settlement +and the ordered release. Successful commit keeps worker admission closed. + +The worker's generation binds its incarnation, host revision and SDK generation. +The first correlated observation under an acknowledged fence establishes the +host's remote baseline. A later changed generation permanently invalidates that +fence, even if counts return to zero. This is sampled evidence, not a substitute +for complete ownership and prevention of new internal work. + +These payloads, including successful response results, are strictly validated by +the codec. Unknown fields, invalid counters, or a missing result are protocol +errors. Both methods operate on the existing worker; observation must not lazily +start one. Neither method grants permission to install or shut down the app. + ## 6. Versioning `protocolVersion` is `1`. A frame carrying any other value is rejected with diff --git a/docs/MACOS-UPDATER-HANDOFF.md b/docs/MACOS-UPDATER-HANDOFF.md index b73c1233..4718d540 100644 --- a/docs/MACOS-UPDATER-HANDOFF.md +++ b/docs/MACOS-UPDATER-HANDOFF.md @@ -1,5 +1,500 @@ # macOS 자동 업데이트 — 남은 작업 인계 +## 최신 정책: 사용자가 누르는 업데이트 + +사용자 요청으로 자동 설치가 아닌 **Settings 위 업데이트 안내 → 클릭 시 +다운로드·안전한 재시작** 방식으로 바꿨다. 자동 확인은 메타데이터 조회만 한다. +캐시나 automatic=true만으로 다음 실행에 설치하지 않으며, 정확한 targetId와 +archive hash에 결합된 수동 intent만 기존 설치 검증에 진입한다. 클라이언트는 +사이드바/About가 상태와 클릭을 공유하고 바쁨·실패·대상 변경을 자동 재시도하지 +않는다. 이 변경은 기존 beta.11 설치본에 아직 반영되지 않았다. 현재 계약과 +검증 경계: [DESKTOP-CLICK-UPDATE.md](DESKTOP-CLICK-UPDATE.md). + +## 최신 사용자 테스트 설치본 + +`e28fa6d`의 beta.11 / desktop 0.2.5 수동 설치용 DMG를 Downloads에 전달했다. +앱·DMG 서명/공증/staple, 복사본 검사, 서버/데이터 보존 및 실제 격리 GUI의 +정상 종료·재실행·초안/첨부 원본 바이트/설정 보존을 확인했다. 프로덕션 설치는 +건드리지 않았다. **이 테스트 DMG의 updater는 disabled이며 공개 배포가 아니다.** +파일·해시·검증 범위: [RELEASE-BETA11-TEST.md](RELEASE-BETA11-TEST.md). + +## 배포 키 로컬 준비 — 2026-09-09 + +사용자가 공식 보안 입력창으로 암호화 키를 생성하고 동일 비밀번호를 login +Keychain에 저장했다. 해당 Keychain 값으로 실제 Tauri 서명 및 native verifier +검증을 통과했고, 변조된 파일/다른 공개키는 거부됐다. 키는 프로젝트 밖에 +보관하며 재생성하지 않는다. **독립 외장 백업은 아직 미완료**이고 공개 앱에 +이 키를 연결하거나 배포하지 않았다. 위치·공개 fingerprint·검증 범위는 +[UPDATER-KEY-CUSTODY.md](../scripts/release/UPDATER-KEY-CUSTODY.md)를 따른다. + +## 최신 서명 QA 상태 + +`f69ec4f`의 동일 소스 release-mode A/B가 Developer ID 서명·공증·Gatekeeper +검증을 통과했다. **2026-09-09 실제 서명 A→B 자동 교체·후속 서버 health·일반 +재실행까지 통과했다.** Off에서는 A를 유지하고 On에서는 정상 종료 후 다음 +실행에 beta.11/0.2.5로 자동 교체한다. 수동 업데이트 버튼이나 QA 설치 플래그는 +사용하지 않았다. 같은 origin, 대화/모델 설정, 초안/대기열/첨부 SVG의 전체 +기록 바이트가 그대로이며 대기열 자동 전송은 없었다. 설치된 B의 서명·staple· +Gatekeeper도 다시 통과했다. QA 앱은 정상 종료하고 피드/모델/관측기만 중지했다. +fixture와 완료 journal은 보존하며 프로덕션 앱·공개 배포는 변경하지 않았다. +증거와 남은 승인·복구/OS13/키 보관·백업/공개 릴리즈 조건은 +[DESKTOP-UPDATER-SIGNED-QA.md](DESKTOP-UPDATER-SIGNED-QA.md)에 있다. + +후속 보안 검증에서 발견한 Multer/YAML 항목은 2.3.0 및 3.15.2/4.3.2로 +갱신했다. ZIP final-leaf 취약점은 exact upstream PR160의 별도 해시 고정 +backport로 처리하고 설치·audit·서버/desktop 패키징에서 확인한다. 적용 전· +변조·중첩 설치는 거부하며 기존 vendor-download 제한과 리뷰 기한도 유지한다. +실제 ZIP/패키징/audit 회귀 46개 및 clean npm ci 이후 전체 verify가 통과했다. +이 보안 변경은 위 signed f69ec4f 쌍 이후이므로 공개 후보는 새로 빌드해야 한다. + +## 후속: 배포 binding과 자동 다음 실행 적용 연결 + +명시적 release-arm64 production binding과 exact QA binding이 같은 설치·재시작 +경로를 사용하도록 연결했다. 기본 빌드의 disabled 상태, 실제 draft/runtime +admission, 소유 프로세스 종료, 캐시/서명/전체 앱 검증은 그대로다. 다음 실행의 +적용 여부는 durable automatic 설정 또는 대상 hash가 일치하는 manual intent로 +결정한다. QA 전용 추가 CLI 스위치는 더 이상 자동 설정의 실행 조건이 아니다. +Off + manual intent 없음은 설치하지 않는다. 이미 충족된 installation gate를 +About에 pending으로 남기던 표시도 수정했다. + +이는 production 앱을 활성화하거나 배포했다는 뜻이 아니다. 동일 소스의 +Developer ID 서명·공증 QA A/B를 만든 뒤 실제 자동 경로를 검증한다. 현재 +서명 identity 및 기존 `gajae-notary` 인증은 read-only 확인을 통과했다. +새 공증과 배포 조건의 완료 여부는 별도 실제 결과로 확인한다. + +## 2026-09-08 후속: SDK 실제 종료 추적·알림 링크 보존·실패 정리 + +이전 `4fb43c2`의 Node 22/24, Linux 서버 아카이브와 desktop 빌드 및 Ubuntu +22.04/24.04 패키지/GUI 검사는 모두 통과했다. 아래는 그 다음 변경이며 공개 +업데이트 활성화나 최종 signed A→B/배포 완료는 아니다. + +- SDK 패치를 pristine 0.16.4의 **32개 파일**로 확장했다. 기본 제공 provider의 + 실제 producer finally, iterator timeout loser와 활성화된 기본 WebSocket host의 + drain/retired owner/delivery 완료를 추적한다. 지원되는 일반 SDK 세션은 실제 + dispose join 뒤 complete/zero가 된다. generic notification host, opaque + extension/provider, Grok 별도 구현, Cursor 내부 subtask, buffered credential + callback, 미증명 custom transport/실패 factory는 여전히 unknown이다. + 자세한 범위: [SDK patch](../patches/gjc-sdk-lifecycle/README.md). +- `npm ci`가 32개 파일을 새 설치에 적용했고, 공식 `--update` 생성기로 tracked + runtime manifest를 갱신했다. shared `sdkLifecyclePolicy.json`이 applier/worker/ + native parser의 공통 상한(32)을 소유한다. Manifest SHA-256: + `7a1b8c20246ffbbe11e69656a0e0327f8d6c2ac8dff2333f5d56d6ec02f75e44`. +- macOS 알림 링크를 updater cache와 별도인 `desktop-deep-links/pending.json`에 + descriptor-relative atomic/fsync 방식으로 보존한다. 부팅/적용/복구 중에는 + 이동하지 않으며 정상 main origin의 root 이동을 관측한 뒤 정확한 delivery + epoch/prefix를 ACK한다. ACK 이전 종료는 다음 프로세스가 재전달한다. 전달과 + durable ACK 사이의 crash는 중복 root focus가 가능한 at-least-once 계약이다. + URL shape/credential/query/fragment 검증과 16개 상한, 실패 시 보존은 유지한다. +- 실제 포트 충돌 QA에서 `ws`가 HTTP bind error를 전달하면서 초기화를 빠져나가 + 자동화 소켓을 남기던 문제를 발견했다. 두 error emitter를 기다리는 listener와 + initializer join/조기 signal handler/실패 cleanup을 연결했다. 부팅 전 실패는 + 새 job mutation을 시작하지 않는다. 자동화 정리 미확인도 정상 exit로 감추지 않는다. + +검증: + +- `/private/tmp/gajae-sdk32-verify-promotion.log`: 전체 verify exit 0. + 실제 설치 SDK 계약 104 pass/선택적 live 1 skip, lifecycle wrapper도 통과. + 이전 manifest hash 미갱신 및 import-order 실패 로그는 별도로 보존했다. +- `/private/tmp/gajae-server-startup-failure-tests.log`: 10 pass. 실제 HTTP/ws와 + 실제 index initializer를 함께 실행해 bind 실패 시 소유 Unix socket 정리를 확인. +- `/private/tmp/gajae-deep-link-qa.cgeQ8X/native-sdk32.log`: native 318 pass, + build-binding 10 pass, 6 ignored(전용 child helper는 부모 테스트가 별도 실행). + 같은 폴더의 `clippy-sdk32.log`도 통과했다. +- 같은 폴더의 `payload-sdk32.log`: fresh staging에서 32개 patch 적용과 out-of-tree + packaged smoke 통과. **이 앱은 debug/ad-hoc QA이며 signed release 증거가 아니다.** + +실제 GUI: 격리 root `gajae-update-qa-c29Vlg`, origin `127.0.0.1:61106`. +초기 OS URL activation은 queued 1 → delivered 1로 처리됐다. 포트 충돌 상태에서 +남긴 링크는 recovery 중 전달되지 않았다. 수정 전 실패가 남긴 `a.sock`은 앱 +종료/실제 profile lock 아래 `a-before-startup-cleanup.sock`으로 보존한 뒤 새 +패키지로 같은 실패를 다시 만들었다. 새 실패에서는 server가 code 1로 정리되고 +`a.sock`이 사라졌으며 링크 두 개는 남았다. 포트를 해제하고 URL 없이 새로 +실행하자 `fixed-resumed-stderr.log`의 delivered 2 및 빈 pending record, +`fixed-resumed-ax.txt`/`.png`의 정상 root 화면을 확인했다. fixture socket/로그는 +보존한다. 생산 앱/데이터/키/공개 release는 변경하지 않았다. + +남은 목표는 동일 소스 signed/notarized A→B와 폭넓은 데이터/취소·복구 검증, +production owner/OS13/키 custody·backup 조건, updater 활성화 및 최초/후속 공개 +배포다. 이 결과로 모든 SDK 확장이나 OS 이탈 descendant를 증명하지 않는다. + +## 2026-09-08 현재: 실제 버튼 A10 → B3 교체·재시작·첨부 보존 통과 + +격리된 QA 앱에서 About의 `Update and restart`로 A10(product beta.10 / desktop +0.2.4) → B3(beta.11 / 0.2.5)가 실제 교체되고 자동 실행됐다. 자동 확인 설정은 +false인 상태로 유지됐다. `A10-stderr.log`는 Applying → backend prepare/commit → +서버/포착한 하위 process 종료 → manual intent → 다음 시작의 installer 호출/전체 +트리 검증 → 신버전 자동 재실행 → `successor-health-committed`를 기록한다. +`A10-manual-cycle.jsonl`과 schema-2 완료 marker는 이전 native/server/core 종료 및 +후속 native 7560/server 7766을 확인한다. 실제 설치된 Info.plist는 0.2.5이며 +`codesign --verify --deep --strict`도 통과했다. + +`B3-after-update-ax.txt`/`.png`: beta.11 UI, Scratch 프로젝트, 동일 origin +`http://127.0.0.1:50761/`, 미전송 초안과 SVG 미리보기 유지. +`B3-after-update-bytes-ax.txt`: 실제 IndexedDB schema 2 / revision 51 / 초안 일치 / +첨부 1개 / queue 0. 172-byte SVG SHA-256은 원본과 동일하다: +`f744bd2e0ac70466bcd76177a22fb54cdbe87bf55b4540d5f7837d64d65c994e`. +증거 root는 아래와 같은 `/private/tmp/gajae-native-restart.wupMI0/`다. + +이는 **debug/ad-hoc 앱 + 전용 QA updater 서명**의 incremental 실증이다. +B3는 A10의 마지막 native 순서 변경 이전 빌드이므로 최종 same-source signed/ +notarized acceptance가 아니며, 공개 배포나 production 활성화도 하지 않았다. +정상 Quit 후 `--qa-profile`만으로 재실행한 B3도 beta.11, 같은 origin/프로젝트, +초안/미리보기를 유지했다(`B3-normal-reopen-ax.txt`, `.png`). 검증 후 QA 앱과 +임시 관측/피드는 정리하며 fixture/로그는 보존한다. 로그인/실제 transcript/대기열의 +폭넓은 데이터 보존, 전체 production 게이트와 공개 릴리즈는 남는다. +자동업데이트와 배포 목표는 유지한다. + +## 이전 실패·수정 과정: native restart 거래 연결과 GUI QA + +부모 인계 기준 pushed HEAD는 `721806d`이며, 그 위 native restart 거래는 +**약 35개 파일의 미커밋 WIP**다(동시 작업으로 파일 수는 변할 수 있다). +아래는 source/저장된 로그 checkpoint이지 배포 또는 수동 재시작 성공 기록이 아니다. +자동 업데이트 완성과 공개 배포라는 전체 목표를 유지한다. + +- [native restart](../src-tauri/src/updater_restart.rs)는 native challenge → + 현재 페이지의 draft 저장·seal ACK → Applying 표시/페이지 종료 → backend prepare → + owner 재검증/commit → owned server/tree 종료 확인 → 대상 archive에 결합된 durable manual + intent → 재시작을 연결한다. 실행은 **compile-bound QA 앱 + 정확한 QA profile만** + 허용한다. 불명확한 commit/종료는 recovery이며 자동 재시도하지 않는다. +- [native backend](../src-tauri/src/updater_backend.rs), + [Node channel](../server/services/desktop-restart-channel.ts), + [backend owner](../server/services/desktop-restart-backend.ts)는 인증된 별도 + native 채널의 epoch/순번/attempt/token을 결합한다. browser prepare/commit + endpoint는 없다. `ui-drafts` reader도 연결됐지만 실제 seal이 없으면 blocker다. +- [owner 관측](../src-tauri/src/updater_owners.rs)은 정확한 QA root의 packaged + owner만 격리하고 production은 기존 cross-installation 범위를 유지한다. + 전체 PID 열거와 포착한 descendant tree의 종료 검사는 축소하지 않는다. + 두 번의 bounded census는 atomic process history나 이미 이탈한 daemon의 + 종료 증명이 아니다. 이 source를 production owner qualification으로 세지 않는다. +- [native payload](../src-tauri/src/expected_payload.rs)와 + [archive 검사](../src-tauri/src/updater_archive.rs)가 runtime manifest schema 2의 + 같은 strict parser를 사용하도록 SSOT 불일치를 수정했다. archive는 native + closure와 SDK lifecycle post-hash의 실제 regular-file 멤버도 검사한다. + `VerifiedSdkPatch`는 source 무결성만 증명하며 full SDK quiescence가 아니다. + +증거 root: `/private/tmp/gajae-native-restart.wupMI0/`. 부모 실행 결과: + +- `verify-final.log`: 전체 `npm run verify` exit 0. +- `native-tests-final-rerun2.log`: desktop Rust **307 pass / 5 ignored**, 별도 + build-binding **10 pass**. ignored는 통과로 세지 않는다. +- `native-clippy-final.log`: 통과. 이후의 소규모 QA 진단/테스트 fixture 변경 + 이전 결과이며 최종 작업 트리 전체의 재검증으로 확장하지 않는다. + +첨부가 있는 실제 GUI A4–A7은 File/Blob `NotFoundError`로 **backend prepare 이전**에 실패했다. +각 `A4-stderr.log`–`A7-stderr.log`는 `draft-challenge`까지만 기록한다. +**이 수동 재시작 거래의 실제 성공은 아직 없다.** 이전 +[next-launch A→B](DESKTOP-UPDATER-LAUNCH-QA.md)는 다른 경로의 격리 QA 증거다. + +원인 분리 증거는 `webkit-picker-reopen-ax.txt`와 `webkit-picker-reopen.png`다. +격리 DB에 picker File·메모리 생성 File·ArrayBuffer를 저장하고 같은 QA 앱을 +정상 종료/재실행했다. 첫 조회는 셋 모두 172 bytes였다. 조회한 record를 +`durability: 'strict'` transaction으로 다시 쓴 뒤, 보유 중인 객체와 새 조회 +객체 **양쪽의 두 File 모두 `NotFoundError`**, ArrayBuffer는 계속 172 bytes였다. +따라서 이 재현은 앱 교체 없이도 발생하는 record 재저장/Blob 수명 문제이며, +단순 재실행만으로 File이 사라진다거나 backend prepare가 실패했다는 증거가 아니다. + +후속 text-only 시험: 부모는 실제 profile lease를 잡고 격리 home/browser를 +같은 증거 root의 `A7-profile-before-codec/`에 백업한 뒤 **현재 QA 초안의 fixture +첨부만** 제거했다. 원본 fixture와 백업은 보존했다. `A7-reopen-stderr.log`는 +아래 재시작 경로를 기록한다. 주의: `home/browser` 백업에는 UUID로 격리된 +WKWebsiteDataStore가 포함되지 않는다. 그 디렉터리 복원만으로 IndexedDB 초안이나 +첨부가 복원됐다고 해석하지 않는다. 이전 형식 실증은 구버전 앱으로 다시 만든다. + +`A7-reopen-stderr.log`: +`backend-prepare → backend-prepared → applying-visible → updater_restart_cancelled` +를 기록한다. 첨부 없는 경로도 commit/재시작 성공은 아니다. +예정된 Applying navigation의 fetch 거부 뒤 JS가 자동 cancel을 보내는 self-cancel +race가 유력 원인이다. 부모의 [bridge 수정](../src-tauri/src/updater_bridge.js)과 +[회귀 테스트](../src/shared/nativeUpdateBridge.dom.bun.test.tsx)는 prepared ACK를 +보낸 뒤 응답이 유실돼도 자동 cancel을 보내지 않고 native abort 확인까지 seal을 +유지한다(native가 deadline 소유). 빌드/검증 진행 중이며 실제 성공은 미확인이다. + +[versioned byte-backed codec](../src/components/chat/utils/composerDraftStorage.ts)은 +File 대신 실제 bytes와 metadata를 저장하고 조회 시 독립 File을 만든다. 읽을 수 +있는 legacy File은 이후 CAS 저장 전에 복사한다. 읽기 실패는 원본을 덮어쓰거나 +saved로 표시하지 않는다. 부모의 `codec-parent-tests.log`는 실제 repository를 +구동하는 합성 IDB driver와 인접 DOM/bridge 5개 suite에서 **123 pass**다. +WebKit 실증과는 구분한다. **위 verify 통과 로그는 codec/후속 bridge 변경 이전이다.** +`native-clippy-qa-fixes.log`와 `native-tests-qa-fixes.log`는 후속 native 진단과 +fixture 수정까지 통과했다(307 + 10 pass, 5 ignored). + +A8 text-only 실제 버튼 시험은 `backend-prepared` 뒤 owner capture 또는 Applying +뒤 owner revalidation에서 보류됐다(`A8-stderr.log`, `A8-manual-cycle.jsonl`). +JS self-cancel 수정 후에도 commit/종료/교체 성공은 아직 입증되지 않았다. +다음 빌드에는 native owner scanner의 정적 사유를 QA-only 로그에 남긴다. +부모가 동일 소스 A/B payload 빌드, 합집합 검증과 실제 재시작·첨부 byte 보존을 +이어서 확인한다. 임시 QA 버전 변경은 배포 버전 변경이 아니며 빌드 후 복구한다. + +추가 실증: 구버전 A8 GUI에서 fixture를 다시 첨부·저장하고 정상 종료한 뒤 A9를 +실행했다. A9의 restart 준비는 첨부 byte 검증과 owner revalidation을 통과했지만 +Applying 후 backend commit의 `updater_runtime_changed`로 취소됐다. +`A9-migrated-draft-ax.txt`는 그 뒤의 실제 IndexedDB read-only 조회다: +schema 2, revision 50, 초안 일치, 첨부 1개(172 bytes), queue 0; +SHA-256 `f744bd2e0ac70466bcd76177a22fb54cdbe87bf55b4540d5f7837d64d65c994e`가 +원본 fixture와 일치한다. 미리보기도 유지됐다. 이것은 실제 legacy 마이그레이션 +증거지만 자동 앱 교체 성공 증거는 아니다. + +`verify-codec-union.log`는 codec/bridge 합집합의 전체 verify exit 0이다. +후속 A10은 sealed 페이지를 Applying으로 전환한 뒤 backend prepare를 요청한다. +예정된 WebSocket/HTTP 종료를 generation 검사의 예외로 인정하지 않고, 페이지 +종료 뒤 새 runtime 증명을 얻는다. 바쁘거나 불명확하면 여전히 취소하며 설치와 +server 종료는 commit 이후다. 원래 5초 준비 예산도 유지한다. +`native-clippy-handoff-order.log`와 `native-tests-handoff-order.log`는 통과했다 +(307 + 10 pass, 5 ignored). A10 → B3는 이 순서 수정 전후 native가 다른 +incremental QA 쌍이므로 최종 same-source signed qualification으로 세지 않는다. +production 활성화/owner qualification, key custody와 backup, signed/notarized +same-source A→B, 실제 macOS 13, G0 승인·취소 및 privileged writer 종료 증명, +deep-link buffering, 미표현 SDK streaming/extension tails와 전체 G3/G5, +최초/후속 공개 배포는 남아 있다. `/Applications`의 production 앱은 사용 중이며 +이 문서 sidecar는 앱·사용자 데이터·키를 조회하거나 변경하지 않았다. + +## 이전 checkpoint: 2026-09-08 SDK 패치 적용·source 검증·페이지 owner 등록 + +최신 커밋 `06bbc51`은 Linux 패키지 데이터 보존 검증에서 확인된 종료 순서 +문제를 수정한다. watcher 정리를 기다리기 전에 native job interruption을 +기록한다. 일반 CI, Linux 서버 아카이브와 Linux desktop CI가 모두 통과했다. + +이후 작업 트리에는 **아직 커밋하지 않은** 다음 통합이 있다. + +- `patches/gjc-sdk-lifecycle/manifest.json`의 8개 파일을 모두 pristine hash와 + 비교한 뒤 적용했다. SDK/core/AI 버전은 0.16.4 그대로다. prewarm, physical + prompt/loop/post-prompt, registry 유지보수, auth timeout loser와 설정 저장을 + 실제 Promise 정리까지 추적한다. 생성자/global owner도 adapter에서 관측한다. +- root와 desktop/server staging의 postinstall이 같은 패치를 적용한다. + `check:sdk-patch`, runtime manifest schema 2의 전체 post-hash와 의존성 + 해석 경로 검사가 불완전/다른 SDK를 거부한다. applier의 symlink CLI 경로가 + 검사를 건너뛰던 문제도 회귀 테스트와 함께 수정했다. +- `VerifiedSdkPatch`는 source 무결성만 증명한다. SDK streaming producer나 + 확장 callback의 아직 표현되지 않은 tail은 `sdk_background_ownership_unproven` + 으로 남는다. 이를 없애거나 full G3를 통과했다고 선언하지 않는다. +- App 최상위의 draft owner가 native가 주입한 브리지에 실제 등록된다. + 토큰·준비 객체·현재 창 수명이 결합되며 오래된 등록/응답은 거부한다. + 등록은 저장 ACK나 재시작이 아니다. native challenge → draft ACK → backend + prepare/commit → 안전 종료 거래와 production previous-owner 증명은 남아 있다. + +증거: `/private/tmp/gajae-sdk-integration.v1k0FL/`. 설치된 패치의 lifecycle +24개와 replay/applier 4개, SDK 계약 101개(선택적 live 1개 제외), manifest/ +SSOT 테스트, 브리지 DOM 합집합 62개와 native bridge Rust 10개가 통과했다. +`verify-sdk-union.log`도 통과했으며 이후 추가한 nested SDK 해석 검사는 별도 +테스트로 확인했다. 실제 macOS payload도 새로 빌드하여 pristine npm 설치에서 +8개 패치 적용, out-of-tree 재검증, 실제 Bun worker initialize/shutdown 및 +서버 health/종료 smoke를 통과했다(`macos-payload.log`). 이는 ad-hoc native +payload 검사이며 signed/notarized 앱 교체나 GUI acceptance가 아니다. +최종 `verify-promotion.log`도 통과했으며 코드/테스트/문서 및 적용 SDK 파일을 +포함한 38개 입력 해시가 검증 전후 동일했다. 이후 이 결과 문단만 갱신했다. +GJC E2E 8개, browser E2E 3개와 desktop Rust 267개 + binding 10개도 통과했다 +(`gjc-e2e.log`, `browser-e2e.log`, `native-full.log`). 배포 전 native 거래/ +owner 증명과 최종 서명 앱 수용 검증은 여전히 남는다. 현재 설치 앱, +production key, 공개 release는 바꾸지 않았다. + +## 이전 checkpoint + +## 2026-09-08: 작업 소유권·페이지 초안 동결·worker fence 통합 + +이 절은 `c64d8aa` 이후 소유권 통합 작업의 진행 기록이다. +자동업데이트는 배포 목표에 포함된다. 수동 설치 검증과 구버전 → 신버전 자동 +교체 검증의 차이는 목표를 분리하거나 완료 범위를 줄인다는 뜻이 아니다. + +- `server/index.js`는 필수 15개 owner 중 14개 reader를 연결하고, startup + catch-up 이전에 같은 admission을 worktree/orchestrator/native clients, + worker/automation/browser/computer/watcher/notification에 주입한다. + Git/clone 및 server startup/listen callback을 `internal-producers`에 + 연결했다. native-bound `ui-drafts`는 아직 누락 blocker다. +- native `job.activity`는 archived job과 미완료 run을 포함하는 읽기 전용 + aggregate다. 관측은 프로세스를 새로 시작하거나 reconcile하지 않는다. +- project 파일 스트림과 업로드는 실제 descriptor/pipeline/cleanup 완료까지 + HTTP 소유권을 유지한다. watcher의 잘못된 조기 close를 실제 종료 경로로 + 옮겼으며 kill/abort 요청만으로 종료를 확인했다고 처리하지 않는다. +- worker activity/admission 프로토콜, 브라우저 child queue/popup/close 증명, + 알림 전송 callback과 페이지 초안·첨부·녹음 freeze를 구현했다. + 페이지 receipt는 `scope: page`, `installerAuthority: false`이며 아직 + native restart 허가가 아니다. +- 공통 ingress fence가 먼저 닫힌 뒤 worker fence를 닫고 관측한다. setup과 + read는 원래의 5초 예산을 공유하며, 취소/timeout/expiry는 늦은 close와 + 정확한 ID의 release 완료까지 소유권을 유지한다. commit 뒤에는 열지 않는다. + 원격 SDK generation 변화도 기존 준비를 무효화한다. +- SDK 0.16.4에서 공개 dispose join 뒤에도 prewarm credential 작업이 남는 + 반례를 재현했다. `sdk_background_ownership_unproven`을 유지한다. + 공식 배포 0.16.6의 해당 코드도 동일함을 별도 임시 경로에서 확인했다. + 앱 전용 SDK 패치를 준비 중이며 dependency pin이나 node_modules는 아직 + 변경하지 않았다. `scripts/apply-sdk-lifecycle-patch.mjs`는 정확한 버전과 + 수정 전후 해시 검증·일치하는 변경의 1회 적용·멱등 재검증 도구이며 아직 + postinstall/배포 경로에 연결하지 않았다. 실제 SDK patch가 검증되기 전 + `sdk_background_ownership_unproven`을 없애지 않는다. + +증거 경로: `/private/tmp/gajae-updater-ownership.6r2PfB/`. +부모의 HTTP/file-transfer 8개, native client/watcher/runtime/route-coverage +31개, authority 경합 106개, SDK patch 도구 7개 테스트와 `check:core`가 +통과했다. 전체 `verify-union.log`도 통과했으나 최종 추가 통합 뒤의 promotion +검증과는 구분한다. `verify-working.log`의 TS2322는 수정된 이전 실패 기록이다. +최종 `verify-promotion.log`도 통과했고 검증 전후 78개 소스/테스트/문서 입력의 +SHA256이 동일했다. 이후 이 검증 결과 문단만 갱신했다. worker의 실제 +supervisor/host/protocol을 사용한 통합 경합을 포함해 worker-client 77개가 +통과했다(`worker-integrated.log`). desktop shell fmt/locked test도 통과했다 +(`native-shell.log`). Apple 서명 identity와 기존 notary profile의 read-only +인증 확인도 통과했지만 서명키 생성·반출·공증 제출·공개 배포는 하지 않았다. +GJC wire/browser E2E 8개와 browser E2E 3개가 통과했다. wire fixture는 종료된 +orchestrator를 재사용하지 않고 새 HTTP/projection/orchestrator로 재개하도록 +수정했다. 첫 실패 로그도 보존하며, 이 결과는 설치 앱 handoff 검증이 아니다. +제품 버전과 production updater 활성화/공개 배포는 변경하지 않았다. + +## 2026-09-08: 다음 시작 설치·후속 앱 건강 확인 연결 + +`DESKTOP-UPDATER-LAUNCH-QA.md`가 최신 네이티브 진행 기록이다. 격리된 debug QA에서 +A(0.2.4) → B(0.2.5)의 실제 교체·자동 재실행·서버 건강 확인·schema-2 완료 기록과 +프로젝트/초안/첨부/동일 origin 보존을 확인했다. **임시 서명 QA이며 공개 배포가 +아니다.** 실행은 정확한 compile-bound QA profile과 명시적 qualification 인자로만 +허용하며 production owner 증명/활성화, 전체 G3와 최종 G0/G5/배포는 남아 있다. + +## 활성 목표: 자동 업데이트 완성과 앱 배포 + +목표는 준비 경로 구현으로 축소하지 않는다. 현재 이어지는 네이티브 설치 작업과 +검증 근거, 다음 시작/건강 상태/복구 연결의 순서는 +`DESKTOP-UPDATER-INSTALL-PROGRESS.md`에 있다. 공식 installer 호출, durable +attempt writer, 전체 설치 트리 검증과 **격리된 QA 시작·재시작 경로를 연결했다.** +일반 배포용 활성화와 native-bound 안전 재시작은 아직 남아 있다. +원격 CI에서 드러난 엔진 테스트 SSOT 누락은 +`7ef7cda`로 수정했다. 자동 설치/배포 완료를 선언하거나 목표를 닫지 않았다. + +## 2026-09-08 재개: 실제 admission 연결과 초안 보존 + +`server/index.js`에 하나의 restart authority를 만들고 HTTP handler, 채팅 메시지, +PTY 메시지와 인증 전 경로에 연결했다. 응답/연결 종료와 실제 작업 종료를 구분하고, +준비 중 완료된 작업도 이전 prepare token을 무효화한다. chat/worker/shell의 실제 +reader를 합치되 나머지 필수 owner는 누락 상태로 남겨 재시작을 차단한다. + +OAuth 취소 후 정리, UI 완료 이후 제목 저장, 교체 중인 PTY를 실제 수명까지 +추적한다. SDK 내부 백그라운드와 detached descendant 종료는 미확인이므로 +유휴 상태라고 주장하지 않는다. 실제 composer에는 IndexedDB 기반의 초안·File· +대기 메시지 보존과 오래된 창의 덮어쓰기 방지를 연결했다. + +구현/증거/남은 작업: `DESKTOP-UPDATE-ADMISSION-IMPLEMENTATION.md`. +**자동 설치·안전 재시작·공개 배포는 여전히 미완료다.** G0를 완화하거나 제품 +installer를 켜지 않았으며 package/desktop 버전은 beta.10/0.2.4 그대로다. + +## 사용자 환경 확인 후 실제 QA 앱 검증 + +사용자는 이전 인증창에서 무엇을 눌렀는지 기억하지 못하며 macOS 13 테스트 +환경도 없는 것 같다고 답했다. 취소 성공과 OS13 검증은 **미확인 그대로**다. +추가 환경 준비를 사용자에게 요구하지 않고 현재 Mac의 격리 QA 증거를 보강했다. + +- ES2020 `Object.hasOwn` 타입 오류를 수정해 원격 Node 22/24 CI를 통과시켰다 + (`6f99642`, run `34140074184`). 이전 로컬 검사 후의 마지막 편집이 CI에서 실패한 + 것이며, 이전 head의 원격 CI까지 통과했다고 해석하지 않는다. +- 실제 debug/QA 앱의 기동을 막던 긴 automation socket 경로를 짧은 private + `a.sock`으로 수정했다. 길이 한도는 플랫폼 구조체에서 얻고 실제 bind로 검증한다. +- 실제 About의 503 오류를 재현했다. accepted socket의 nonblocking 모드 때문에 + HMAC 응답 다음 read가 너무 일찍 실패했다. 인증/peer PID/시간·크기 한도는 + 유지하면서 연결 읽기 모드를 수정했고 실제 Node↔Rust 회귀 테스트를 추가했다. +- 실제 QA 앱에서 native 상태, 설정의 durable 저장, 자동 확인 off 상태의 수동 + 확인, 잘못된 피드 오류 및 복구, 정상 종료와 같은 origin 재실행/설정 보존을 + 확인했다. auto-off 재기동의 잔존 `server_not_ready` 문구도 수정했다. +- 설치 시도 journal은 **examples 아래 QA-only 증명 도구**다. fsync 전 live + handle 부재, 실패/Drop/crash 뒤 차단 기록 보존 등을 20개 테스트로 검사했다. + 실제 installer/writer 종료, 적대적 same-UID namespace, 전원 차단 또는 제품 + 설치 resolver 검증이 아니다. 기존 생산 startup guard는 그대로 보수적으로 차단한다. + +전체 근거와 재개 방법: `DESKTOP-UPDATER-QA-PREPARATION.md`. +생산 `/Applications` 앱, 실제 사용자 데이터, public release와 updater key는 +변경하지 않았다. **자동 설치·재시작·공개 배포는 아직 완료되지 않았다.** + +## 추가 구현: 메인 화면 준비 제어와 restart admission 기초 + +브랜치 `codex/macos-updater-completion`의 미배포 변경이다. **전체 자동 +업데이트 완료가 아니며 설치·재시작은 계속 거부한다.** 아래 내용은 이어지는 +이전 진행 기록의 ‘준비 경로 UI/bridge 없음’ 부분을 갱신한다. + +- `shared/desktopUpdateProtocol.ts` + 공용 상태 fixture를 기준으로 native + snapshot, 준비 상태/설정/수동 확인 명령과 About UI를 연결했다. 웹 알림은 + 별도 SemVer/channel 기준이며 desktop에서는 native snapshot만 사용한다. +- native가 만든 일회성 stdin 초기화로만 Node relay에 연결 정보가 전달된다. + 비밀값은 환경변수/로그/브라우저 응답에 넣지 않는다. 혼합 stdout은 제어 + 입력으로 사용하지 않고 소유자 전용 Unix socket을 사용한다. +- 연결마다 새로운 challenge/HMAC-SHA256 증명으로 native endpoint를 먼저 + 인증한 뒤에만 view capability를 전송한다. macOS `LOCAL_PEERPID`가 실제 + 소유 Node PID와 일치해야 하므로 socket을 바꿔 끼운 다른 프로세스가 진짜 + native에 challenge를 대신 전달할 수 없다. HMAC은 macOS 대상의 정확히 + 고정한 `hmac=0.12.1`이며 기존 tempfile/getrandom 선택은 유지했다. +- HTTP는 desktop cookie + 정확한 Origin + 현재 main-view capability를 요구한다. + 페이지/서버 교체 시 권한을 폐기하고, preference 직렬화 잠금 안에서 다시 + 권한과 mutation sequence를 확인한다. 오래된 요청이 새 opt-out을 덮지 않는다. + 4개 요청, 2초 relay deadline, 제한된 frame 크기이며 timeout은 취소/저장 성공이 아니다. +- QA 환경의 `env_clear()` 뒤에 relay flag를 설정한다. disabled/dev/Linux에서는 + 제어 채널을 시작하지 않으며, native bridge 초기화 실패 시 자동 준비도 시작하지 않는다. +- About에 EN/KO 및 10-locale parity, null progress, 상태/오류/릴리즈 노트, + 실제 저장 응답 뒤에 반영하는 자동 설정과 수동 확인을 추가했다. 준비 완료를 + 설치 완료로 표시하지 않는다. ordinary web에는 설치 제어가 없다. +- `DesktopRestartAuthority`는 하나의 가역 fence와 기존 owner snapshot을 합칠 + 안전 기초다. 95개 테스트를 통과했지만 **실제 HTTP/WS/internal producer와 + 아직 연결하지 않았으므로 G3 통과가 아니다.** 필요한 연결 지점은 + `DESKTOP-UPDATE-ADMISSION.md`에 있다. native `restart`도 명시적으로 거부한다. +- 검증: 통합 `npm run verify`, native locked tests 179 pass + 1 opt-in ignore, + build-binding 10 pass, native clippy `-D warnings`, relay/HTTP/admission 141 tests, + frontend DOM 33 tests를 부모가 실행해 통과했다. Browser 스킬의 격리 UI fixture로 + 1024×768/390×844, 한국어/영어, 설정 반영과 미정 progress를 확인했다. + 이는 native packaged-app/설치 GUI 증거가 아니다. + +격리된 About 상태 fixture의 화면(설치 실행 없음): + +![자동 설치는 차단된 준비 상태 UI](images/updater/about-preparation-qa.png) + +### 실제 installer probe 결과 정정 + +- 첫 authorization probe는 취소가 아니라 `install()` 성공으로 반환했다. + 별도 `authorization-approved-verification.json`에서 전체 B inventory, + 코드 서명·staple·Gatekeeper를 검증했다. 취소 성공으로 기록하지 않는다. +- 두 번째 probe(시작 `2026-09-07T14:52:06Z`, root suffix `F5tbr5`)는 + `install_failed`, exit 1/signal 없음으로 반환했다. 전체 A inventory 및 + 서명·staple·Gatekeeper는 그대로였다. 사용자에게 실제 ‘취소’ 클릭 여부를 + 확인 요청했으며 `humanActionConfirmed`는 아직 false다. 원인 구분 및 OS + privileged writer 종료 증거를 단순한 PID 종료/오류 문자열로 대체하지 않는다. +- 로그/receipt/runner는 `/private/tmp/gajae-updater-resume.Ym5u1L/`에 유지했다. + 기존 승인 결과는 `authorization-approval-result.json`에 따로 보존했다. + 최신 `authorization-result.json`을 이전 승인 결과로 혼동하지 않는다. + +### 그대로 남은 차단 조건 + +G0 취소·writer 종료/설치 오류 분류, 실제 macOS 13 검증, 전체 producer와 +draft/첨부 보존의 G3 연결, install-attempt writer/resolver/다음 시작 적용, +safe restart와 embedded applying/recovery, 최종 서명된 제품 QA A→B 및 데이터 +보존, production updater key custody/backup와 배포가 남아 있다. 이 Mac은 +26.6.2이고 등록된 repository self-hosted runner는 0개이며 로컬 macOS 13 VM은 +확인하지 못했다. 지원 하한이나 권한 검사를 낮추지 않았다. Package/desktop +버전은 beta.10/0.2.4 그대로이고 새 릴리즈·설치·production key 생성은 하지 않았다. + +## 2026-09-07 추가 재개: 설치 기능 완성 요청 + +사용자가 재배포를 통한 업데이트 시험을 요청했고, 기존 beta.10의 updater가 +disabled이고 웹 알림의 `/releases/latest`도 베타 전용 저장소에서 404인 사실을 +설명한 뒤 **자동 업데이트 완성부터 진행**하도록 승인했다. 현재 브랜치는 +`codex/macos-updater-completion`이며 아래 결과는 전체 기능 완료/배포가 아니다. + +- 웹 알림 fallback을 releases 목록 + 표준 SemVer/channel 비교로 수정했다. + 14개 단위 테스트와 8개 DOM 테스트를 부모가 재실행해 통과했다. native 설치 + 권한이나 UI는 추가하지 않았다. 전체 verify는 별도로 실행 중이다. +- `docs/DESKTOP-UPDATE-ADMISSION.md`에 실제 producer/owner와 zero-gap accounting + 미검증 지점을 정리했다. 이는 구현지도이며 G3 통과가 아니다. +- 현재 locked 공식 updater 2.6.0 probe를 다시 빌드했다. 새 private-CA HTTPS + 격리 fixture에서 역사적 signed beta.8→beta.9의 실제 `install()`이 반환했고, + 전체 B inventory, codesign, staple, Gatekeeper를 다시 확인했다. 기존 앱은 + 실행하지 않았으며 `/Applications` 또는 실제 사용자 데이터는 수정하지 않았다. + 역사적 11.0 선언/13.0 loader 불일치는 그대로이므로 이 결과는 설치 primitive + 증거일 뿐 새 제품 릴리즈, macOS 13 또는 최종 signed QA A→B acceptance가 아니다. +- 취소 probe는 사용자 응답 대기 중이다. 임시 증거/runner: + `/private/tmp/gajae-updater-resume.Ym5u1L/`. `authorization-running.json`이 + 정확한 현재 root, driver, PID를 기록한다. 시작 시 PID는 11927이었다. + 스택 표본은 공식 `install_inner` → OSAKit `Script::execute`에서 대기함을 보였고, + Computer Use의 테스트 앱 AX 읽기는 두 차례 timeout이었다. 창이나 실제 취소를 + 관찰한 것으로 취급하지 않는다. 사용자에게 표시된 시스템 인증창을 취소하고 + 알려 달라고 요청했다. 강제 종료/timeout 후 성공 처리하지 않는다. +- `replace-result.json`은 정상 교체 증거, `authorization-result.json`은 probe가 + 반환한 뒤 생성된다. 재개 시 먼저 결과/프로세스를 확인하고 사용자의 실제 + 동작과 전체 A 무결성·writer 종료를 별도로 입증한다. PID 숫자만 재사용하여 + 신호를 보내거나, 결과 파일만으로 사용자 취소를 확인했다고 기록하지 않는다. +- 설치 수명주기/attempt writer·resolver, 좁은 native bridge, 전체 admission, + About UI, OS13 실행, production key custody, 최종 QA/배포는 아직 남아 있다. + G0/G3 조건을 완화하거나 production updater를 켜지 않았다. + ## 2026-09-07 재개: 준비 경로 구현 사용자가 이 작업에서 구현 재개와 Astra xhigh 병렬 작업을 승인했다. 아래 diff --git a/docs/RELEASE-BETA11-TEST.md b/docs/RELEASE-BETA11-TEST.md new file mode 100644 index 00000000..937936e5 --- /dev/null +++ b/docs/RELEASE-BETA11-TEST.md @@ -0,0 +1,93 @@ +# beta.11 macOS local test installer — accepted + +This is a private, manual-install test candidate, not a public release or an +updater-enabled distribution. The installed production app is not overwritten +by the build or acceptance process. + +## Frozen source and scope + +- Source: `e28fa6d68e5985e0a7fda5320e23c0bff66fd364`. +- Product: `2.0.0-beta.11`; desktop: `0.2.5`; SDK: `0.16.4`. +- Apple Silicon macOS; declared loader minimum remains `13.0`. +- Native build information confirms `debug: false`, `updateMode: disabled`. +- Includes tasks above chat, routine auto-approved notice filtering, browser + top-level await and bypass handoff fixes, browser reveal/viewport fixes, + SDK lifecycle/data preservation work and the latest YAML/Multer/ZIP security + changes. It is not the earlier pre-security beta.11 updater-QA fixture. +- No production updater private key is provided to the build or embedded in + the app. Public updater activation remains a separate acceptance step. + +## Verification + +- Whole `npm run verify`: passed at this version. +- Native shell: 320 tests passed, 6 dedicated/optional helpers ignored; + build-binding suite: 10 passed. +- Browser sidecar E2E: 3 passed. GJC driver/wire E2E: 8 passed; these use + controlled fixtures, not an external paid model/account. +- Fresh source snapshot and dependencies; all 1,049 tracked source blobs + match the pinned commit after building. +- Developer ID application signing, strict/deep verification, hardened runtime + and runtime-manifest rebinding completed. +- App notarization: Accepted, submission + `b0850e34-c358-47e8-92ae-3458c1c30007`; app stapled and Gatekeeper accepted. +- DMG notarization: Accepted, submission + `d377189e-2355-415c-87a3-26d47d19b26f`; final DMG stapled and rehashed. +- Mounted/copy inventories, both Apple signature/ticket checks, expected versions, + native build mode and loader stamps passed the manual-lane verifier. +- Copied-app packaged server integration: 7 passed; separate data-survival smoke + passed (persisted job/event and idempotent schemas). +- Actual macOS 26.6.2 GUI: Scratch creation, draft plus SVG attachment, theme + change, normal Cmd-Q/reopen, same origin and final native/server exit passed. + The 510-byte IndexedDB draft record remained byte-identical, including the + exact 173-byte SVG. SVG SHA-256: + `40b67b4752793406a80fa6b2bc12a1103421d9cbbad1fbaa5986967192da76f1`. + The draft was not sent and no external account/model was used. +- The quarantined copy ran under macOS App Translocation. Computer Use followed + the actual translocated QA path; quarantine was not removed. The production + native/server processes remained running and were not changed. + +The original CI app verification reached the independent website gate, where +Node 22/24 failed because its test equated the public release with every local +candidate version. Website-only follow-up `a649273` pins the reviewed public +beta.10 fixture instead; website tests/build pass and download links stay on the +existing public release. No shipped app input changed; this DMG remains built +from `e28fa6d`. Subsequent CI runs must be reported separately. + +Evidence root: `/private/tmp/gajae-beta11-build.WfsStD`. +Source/native/E2E logs are `/private/tmp/gajae-beta11-{source-verify,native-tests,browser-e2e,gjc-e2e}.log`. +The private GUI profile is `/private/tmp/gajae-beta11-gui.trY6Xu`; it does not +reuse the user's production WebKit store, credentials or conversations. +Its WebKit UUID is `9E84C9D0-22B4-40B7-A2C9-8F33409486DB`. Evidence includes +`context.json`, `source-integrity.json`, `build-info.json`, both notary receipts, +`dmg-verification.json`, `accepted.json`, `gui-accepted.json`, draft snapshots +and the before/reopen screenshots. The QA app is normally stopped; evidence is +retained. The separate WebKit store is not removed by deleting the profile. + +## Delivered file + +`/Users/devswha/Downloads/gajae-app-desktop-2.0.0-beta.11-macos-arm64.dmg` + +- Size: 220,781,809 bytes. +- SHA-256: `6e6910bd72f35eb09b96149b5162321df14bb8718bd6a04383209483b17cc035`. +- Adjacent `.dmg.sha256` sidecar copied with exclusive creation; delivered bytes + match the independently accepted final DMG hash. No existing download was + replaced. No GitHub release, tag or public artifact was created. + +## User test procedure + +Fully quit the existing app with Cmd-Q, open +the delivered DMG, and copy its app into Applications. Launch the Applications +copy, not the mounted DMG app or an older temporary QA copy. About must show +beta.11. The independently checked native desktop version is 0.2.5; the disabled +updater panel does not display that field. Use a disposable project for initial +agent actions. + +Check the task list above chat, browser panel sizing and top-level await, and +the project permission-mode behavior. OS permissions, first browser-download +consent and genuine questions are not suppressed by bypass mode. + +This test installer cannot demonstrate public automatic updates: its updater +is intentionally disabled. Minimum-OS execution, broader authority/cancellation +qualification, production updater activation and initial/subsequent public +release acceptance remain separate. Local package/GUI QA must not be described +as real external-account login validation. diff --git a/docs/V2-SESSION-HANDOFF.md b/docs/V2-SESSION-HANDOFF.md index 8c3e9640..eb50fb9c 100644 --- a/docs/V2-SESSION-HANDOFF.md +++ b/docs/V2-SESSION-HANDOFF.md @@ -1,6 +1,128 @@ # gajae-app v2 — Session Handoff (resume state) -Last updated: 2026-09-07 (published beta.10 with the pending Chat UI changes). Supersedes the 2026-07-18 handoff. +Latest updater policy: the user requested a bottom-left notice above Settings +and explicit Update clicks instead of automatic installation. Discovery is +checks-only; download and safe restart bind the clicked native target. Cached +bytes/automatic=true no longer authorize a next-launch install. See +[DESKTOP-CLICK-UPDATE.md](DESKTOP-CLICK-UPDATE.md). The delivered beta.11 DMG is +unchanged and does not yet contain this follow-up. + +Latest user-test installer: **beta.11 / desktop 0.2.5**, built from `e28fa6d`, +signed/notarized/stapled and delivered to Downloads. Final copied-app server, +data-survival and actual isolated GUI quit/reopen/draft/image/settings checks +passed. This is a **manual-install, updater-disabled private test DMG**, not a +public updater release; the existing production installation was not changed. +Artifact hash, exact source and limits: [RELEASE-BETA11-TEST.md](RELEASE-BETA11-TEST.md). + +Production updater key: local encrypted-file + login-Keychain provisioning and +sign/verify/negative checks passed on September 9. Do not regenerate it. +Independent external backup and release gates remain open. Exact local metadata +and limits: [UPDATER-KEY-CUSTODY.md](../scripts/release/UPDATER-KEY-CUSTODY.md). + +Latest signed qualification: [DESKTOP-UPDATER-SIGNED-QA.md](DESKTOP-UPDATER-SIGNED-QA.md). +Same-source f69ec4f release-mode A/B are notarized and Gatekeeper-accepted. The +actual signed next-launch automatic A → B transition, Off/on behavior, byte-exact +transcript/draft/queue/image/config survival and normal B reopen passed on +September 9. QA processes are stopped and fixture/journal evidence is retained. +Public deployment and its remaining authorization/OS13/key-custody gates are +not complete; the production installation was not changed. + +Post-QA security updates raise Multer/YAML floors and apply a separate +integrity-checked upstream ZIP symlink-leaf backport through installation, +audit and both packaging lanes. Clean npm ci and the full verify gate pass. +This delta is not in the frozen f69ec4f signed pair; final public artifacts +must be rebuilt. See the signed QA record for the bounded patch/audit scope. + +Last updated: 2026-09-09 (same-source signed automatic A → B and data preservation passed). Supersedes the 2026-07-18 handoff; historical sections remain below. + +## Follow-up checkpoint — SDK32 and durable notification handoff + +`4fb43c2` remote Node 22/24 and Linux server/desktop/package/GUI checks all passed. +The next work expands the exact 0.16.4 SDK patch to 32 files, physically joins +built-in provider tails and the actively enabled default WebSocket host, and +lets supported normal sessions become idle after actual cleanup. Unsupported +opaque features remain unknown; source hashes alone are never quiescence. +Clean `npm ci`, the regenerated runtime manifest, and a shared file-count policy +connect all 32 files to worker/native validation. + +macOS notification links now survive startup/recovery/process replacement in a +separate bounded durable queue. Actual QA uncovered and fixed an HTTP/ws bind +failure that skipped automation-socket cleanup. The new packaged app removes +its socket on failure, preserves pending links in recovery, then delivers both +after a normal reopen without re-supplying URLs. Native tests: 318 + 10 pass; +whole verify passed. Evidence and exact remaining limits are in the top section +of [the updater handoff](MACOS-UPDATER-HANDOFF.md). This is debug/ad-hoc QA, not a +signed same-source release or production activation; the full deployment goal remains open. + +## Current checkpoint — private actual restart passed, not release acceptance + +About's `Update and restart` now actually upgrades the isolated A10 (beta.10 / +0.2.4) to B3 (beta.11 / 0.2.5), restarts it and commits successor health. The +automatic preference remains false. Old native/server/core identities exited; +the successor is native PID 7560/server 7766 with a schema-2 completion marker. +Installed Info.plist and strict/deep code-signature verification pass. The same +origin, Scratch project, unsent text and SVG preview remain. A read-only actual +IndexedDB check confirms schema 2, revision 51, one 172-byte attachment and its +original SHA-256 (`B3-after-update-bytes-ax.txt`). + +Evidence: `/private/tmp/gajae-native-restart.wupMI0/`, especially `A10-stderr.log`, +`A10-manual-cycle.jsonl`, `B3-after-update-ax.txt` and `.png`. These are ad-hoc/debug +apps with a dedicated QA updater key. B3 predates A10's final native sequencing +change, so this is not final same-source signed/notarized qualification or a +public release. Normal Quit/reopen also preserves beta.11, origin/project/draft/ +attachment preview (`B3-normal-reopen-ax.txt` and `.png`). Broader authenticated/ +transcript/queue data acceptance, production activation and remaining release +gates still need work. QA fixtures/logs are retained, not installed over production. + +### Earlier failure and implementation checkpoints + +Parent-reported pushed HEAD is `721806d`; the native-restart transaction above it +is uncommitted WIP (about 35 files at handoff; concurrent work can change this). +The transaction now connects native challenge/draft seal, Applying/page teardown, +authenticated backend prepare/commit, owned-tree shutdown and durable manual restart intent. +Execution remains exact compile-bound **QA-only**, not production activation. +Native payload and archive checks now share the strict schema-2 runtime parser. +Source, evidence and remaining gates: [macOS updater handoff](MACOS-UPDATER-HANDOFF.md). + +Evidence root: `/private/tmp/gajae-native-restart.wupMI0/`. `verify-final.log` +records full `npm run verify` (parent exit 0); `native-tests-final-rerun2.log` +has 307 desktop Rust passes / 5 ignored plus 10 binding passes. +`native-clippy-final.log` passed before later minor QA diagnostics/test-fixture +changes. These are bounded checkpoints, not validation of the evolving worktree. + +Attachment-bearing GUI A4–A7 fail on File/Blob `NotFoundError` **before backend prepare**; +no successful actual manual restart is recorded. An isolated same-app quit/reopen +probe first reads both Files and ArrayBuffer at 172 bytes; rewriting the retrieved +record breaks retained and freshly loaded Files while ArrayBuffer stays readable. + +For a text-only follow-up, the parent backed up isolated home/browser under the +real profile lease to `A7-profile-before-codec/` in the evidence root, then removed +only the current QA fixture attachment (source fixture/backup retained). +That home/browser backup does not include the separately UUID-isolated WebKit +store; restoring it does not restore IndexedDB attachments. Legacy migration QA +must create the fixture again through the older app. +`A7-reopen-stderr.log` reaches backend prepare → prepared → Applying → cancelled, +not commit/restart. A JS auto-cancel after fetch rejection during expected Applying +navigation is the likely self-cancel race. The new bridge/test sends no automatic +cancel after prepared ACK dispatch; the seal waits for native abort confirmation +and native owns the deadline. Subsequent A8 text-only attempts defer on owner +capture/revalidation, not a successful commit or app replacement. The byte-backed +codec is implemented; the parent's five codec/freeze/bridge suites pass 123 tests. +Native follow-up clippy/tests also pass (307 + 10, 5 ignored). These do not replace +fresh combined verification and actual A→B with attachment byte checks. Matching +private QA payloads were rebuilt; temporary version overrides are restored, not a release. +Actual A8 → A9 legacy attachment migration is verified in `A9-migrated-draft-ax.txt`: +schema 2, expected draft, one 172-byte attachment with the original SHA-256. +`verify-codec-union.log` is full verify exit 0. A9 still defers at backend commit; +A10 moves page teardown before prepare without weakening generation checks. +Its native clippy/tests pass, but A10 → B3 is incremental QA, not final same-source acceptance. + +The goal remains automatic update **and public deployment**. Production +activation/owner qualification, key custody/backup, signed/notarized same-source +A→B, actual macOS 13, G0 approval/cancel/writer-exit proof, deep-link buffering, +SDK streaming/extension tails and full G3/G5 remain open. A verified SDK source +patch is not full SDK quiescence. The user-active `/Applications` app and its +data are outside this docs-only pass; no Git, runtime, build or GUI actions. ## Current task scope @@ -26,6 +148,13 @@ not changed. The older session records below are historical. ## TL;DR +- **Unreleased updater work** includes preparation controls, the earlier private + next-launch A→B, and a now-passing private About-button A10 → B3 native restart + with exact attachment-byte preservation. This is incremental debug QA, not final + signed same-source acceptance. Production install/restart + remains gated and no updater-enabled release is published. See the current + [updater checkpoint](MACOS-UPDATER-HANDOFF.md), not the older progress records. + - **Unreleased follow-up: tasks above the conversation.** `ChatTasksPanel` now shows the session's live todo list above the transcript with collapse, progress and bounded scrolling. The right-hand Tasks tab is retired; its diff --git a/docs/images/updater/about-native-qa.png b/docs/images/updater/about-native-qa.png new file mode 100644 index 00000000..fc33ac10 Binary files /dev/null and b/docs/images/updater/about-native-qa.png differ diff --git a/docs/images/updater/about-preparation-qa.png b/docs/images/updater/about-preparation-qa.png new file mode 100644 index 00000000..3c7dd934 Binary files /dev/null and b/docs/images/updater/about-preparation-qa.png differ diff --git a/docs/images/updater/click-update-notice.png b/docs/images/updater/click-update-notice.png new file mode 100644 index 00000000..3434f783 Binary files /dev/null and b/docs/images/updater/click-update-notice.png differ diff --git a/docs/plans/macos-auto-update.md b/docs/plans/macos-auto-update.md index 8f4ae741..67dfd78e 100644 --- a/docs/plans/macos-auto-update.md +++ b/docs/plans/macos-auto-update.md @@ -1,5 +1,11 @@ # macOS 자동 업데이트 구현 계획 +> 2026-09-09 정책 변경: 이 문서의 자동 다운로드·다음 실행 자동 설치 정책은 +> 사용자 요청에 따라 폐기됐다. 현재 구현은 자동으로 새 버전만 확인하고, +> 왼쪽 아래 Settings 위의 **업데이트** 버튼을 눌렀을 때만 다운로드·안전한 +> 재시작을 진행한다. 아래는 역사적 계획이며 현재 계약은 +> [DESKTOP-CLICK-UPDATE.md](../DESKTOP-CLICK-UPDATE.md)를 따른다. + 상태: 구현 승인 전 계획 초안. 제품 코드·키·릴리스·배포 환경 변경 없음. 범위: macOS 데스크톱부터. 현재 배포 대상에 맞춰 Apple Silicon(arm64)을 1차 대상으로 한다. Intel/universal, Linux, Windows, 웹 셀프호스트 서버 업데이트는 제외한다. diff --git a/docs/releases/v2.0.0-beta.12.md b/docs/releases/v2.0.0-beta.12.md new file mode 100644 index 00000000..26798104 --- /dev/null +++ b/docs/releases/v2.0.0-beta.12.md @@ -0,0 +1,47 @@ +# Gajae Code App v2.0.0-beta.12 + +Desktop build: 0.2.6. This is a beta release for iterative testing. + +## Updates happen when you choose + +- The app checks for new versions automatically, but does not automatically + download or install them. +- A notice above Settings in the lower-left sidebar offers **Update**. Clicking + it downloads the selected signed update and requests a safe restart. +- Active work, changed targets and failures defer the operation instead of + forcing a restart. A consumed click is not silently retried on a later launch. +- The collapsed sidebar has an update-details icon above Settings. + +## Also included + +- Session tasks above the conversation; routine auto-approved notices hidden. +- Browser top-level await, project bypass handoff and preview sizing/reveal fixes. +- Draft/attachment and restart lifecycle safeguards, SDK lifecycle tracking, + and YAML/Multer/ZIP security fixes. + +## Installing this first button-update-enabled version + +Earlier beta.10/beta.11 installers have their updater disabled. Quit the old app, +install this DMG once, and launch the copy in Applications. Subsequent eligible +releases can be selected through the sidebar Update button. There will be no +update notice while this installed version is already current. + +macOS desktop targets Apple Silicon. Linux packages/server archives remain +manual-update distributions. No Windows or Intel Mac installer is supplied. + +## Validation and beta limitations + +This release retains the source-test, signature, notarization, archive integrity, +version and data-safety gates. It is not a declaration that every update/failure +scenario has been exercised. The operator explicitly requested publication and +installation with additional end-to-end testing deferred to a later release. + +- The local Mac validation environment is macOS 26.6.2. The bundle's loader + minimum is 13.0; real macOS 13 execution is not yet verified. +- The new click-driven workflow has automated protocol/native/UI coverage; + a subsequent public version is still needed to prove public A-to-B updating. +- Broader administrator approval/cancel, interrupted-install/recovery, process + ownership and real external-account migration scenarios need further testing. +- Keep important project work committed/backed up before testing. Updates do + not promise automatic rollback of user-data changes. +- OS permission prompts and first browser-download consent remain intentional. diff --git a/eslint.config.js b/eslint.config.js index 92ee16be..6af9b741 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -104,6 +104,7 @@ const backendElements = [ 'server/shared/image-attachments.ts', 'server/shared/tool-output-transport.ts', 'server/shared/request-origin.ts', + 'server/shared/desktop-internal-activity.ts', 'server/middleware/desktop-auth.js', 'server/middleware/auth.js', ], diff --git a/native/gajae-core/src/jobs.rs b/native/gajae-core/src/jobs.rs index 02f5fa93..61c3218e 100644 --- a/native/gajae-core/src/jobs.rs +++ b/native/gajae-core/src/jobs.rs @@ -13,6 +13,17 @@ const DEFAULT_LIST_BUDGET: u64 = 48 * 1024; const DEFAULT_CAPACITY: u64 = 4; const MAX_RECONCILE_JOB_IDS: usize = 100; + +#[derive(Debug, Default, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +struct JobActivity { + schema_version: u8, + reserved: u64, + queued: u64, + running: u64, + aborting: u64, + unknown: u64, +} #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "snake_case")] pub enum JobState { @@ -221,6 +232,32 @@ fn map_authority_lock_error(error: rusqlite::Error) -> AuthorityError { } impl PersistentAuthority { + /// One read-only aggregate over ALL durable ownership, including archived + /// jobs and orphan nonterminal runs. No pagination, reconciliation or writes. + fn activity(&self) -> Result { + let mut statement = self.connection.prepare( + "SELECT state,COUNT(*) FROM (SELECT state FROM jobs UNION ALL SELECT state FROM runs) GROUP BY state", + ).map_err(|_| AuthorityError::Storage)?; + let mut rows = statement.query([]).map_err(|_| AuthorityError::Storage)?; + let mut result = JobActivity { + schema_version: 1, + ..JobActivity::default() + }; + while let Some(row) = rows.next().map_err(|_| AuthorityError::Storage)? { + let state: String = row.get(0).map_err(|_| AuthorityError::Storage)?; + let count: u64 = row.get(1).map_err(|_| AuthorityError::Storage)?; + match state.as_str() { + "reserved" => result.reserved += count, + "queued" => result.queued += count, + "running" => result.running += count, + "aborting" => result.aborting += count, + "ready" | "succeeded" | "failed" | "aborted" | "interrupted" => {} + _ => result.unknown += count, + } + } + Ok(result) + } + fn open(path: &Path) -> Result { let path = validate_database_path(path)?; let lock = AuthorityLock::acquire(&path)?; @@ -1644,6 +1681,7 @@ fn dispatch( .ok_or(AuthorityError::InvalidIdentifier) }; let value = match request.method.as_str() { + "job.activity" => serde_json::to_value(authority.activity()?), "job.get" => serde_json::to_value(authority.snapshot(id()?)?), "lease.acquire" => serde_json::to_value( authority.acquire( @@ -1934,6 +1972,60 @@ fn error_code(error: AuthorityError) -> &'static str { #[cfg(test)] mod tests { use super::*; + + #[test] + fn activity_is_complete_read_only_and_includes_archived_jobs_and_nonterminal_runs() { + let (directory, database) = db(); + let authority = PersistentAuthority::open(&database).unwrap(); + for index in 0..151 { + authority.connection.execute( + "INSERT INTO jobs(id,provider,state,archived_at) VALUES(?1,'gjc','queued','2026-01-01')", + [format!("archived-{index}")], + ).unwrap(); + } + authority + .connection + .execute( + "INSERT INTO jobs(id,provider,state) VALUES('ready','gjc','ready')", + [], + ) + .unwrap(); + authority + .connection + .execute( + "INSERT INTO runs(run_id,job_id,state) VALUES('unfinished','ready','running')", + [], + ) + .unwrap(); + authority + .connection + .execute( + "INSERT INTO jobs(id,provider,state) VALUES('unknown','gjc','not-a-state')", + [], + ) + .unwrap(); + let before = authority.connection.total_changes(); + let observed = authority.activity().unwrap(); + assert_eq!( + observed, + JobActivity { + schema_version: 1, + reserved: 0, + queued: 151, + running: 1, + aborting: 0, + unknown: 1 + } + ); + assert_eq!(authority.activity().unwrap(), observed); + assert_eq!( + authority.connection.total_changes(), + before, + "observation must not reconcile or mutate" + ); + drop(authority); + std::fs::remove_dir_all(directory).unwrap(); + } use serde_json::json; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Barrier}; diff --git a/package-lock.json b/package-lock.json index d5a341b1..2afd1376 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "gajae-app", - "version": "2.0.0-beta.10", + "version": "2.0.0-beta.12", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gajae-app", - "version": "2.0.0-beta.10", + "version": "2.0.0-beta.12", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -29,7 +29,7 @@ "katex": "^0.16.25", "lucide-react": "^0.515.0", "mime-types": "^3.0.1", - "multer": "^2.0.1", + "multer": "^2.3.0", "node-pty": "^1.2.0-beta.12", "pretendard": "^1.3.9", "puppeteer-core": "^24.43.1", @@ -1497,9 +1497,9 @@ } }, "node_modules/@eslint/eslintrc/node_modules/js-yaml": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", - "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "dev": true, "funding": [ { @@ -8593,9 +8593,9 @@ "license": "Python-2.0" }, "node_modules/cosmiconfig/node_modules/js-yaml": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", - "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "dev": true, "funding": [ { @@ -11114,6 +11114,19 @@ "node": ">=6.0" } }, + "node_modules/gray-matter/node_modules/js-yaml": { + "version": "3.15.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.2.tgz", + "integrity": "sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==", + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/handlebars": { "version": "4.7.9", "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", @@ -12547,19 +12560,6 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, - "node_modules/js-yaml": { - "version": "3.15.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.1.tgz", - "integrity": "sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==", - "license": "MIT", - "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -14793,9 +14793,9 @@ "license": "MIT" }, "node_modules/multer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz", - "integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.3.0.tgz", + "integrity": "sha512-cjNbm3sttszgZeGfJR124D+jFEfkXCVAsoPBmFn9X7UxmDSFHWqE2CoEj0vrmSpuAFnqWR1Szcm9QTsiHr60Xw==", "license": "MIT", "dependencies": { "append-field": "^1.0.0", diff --git a/package.json b/package.json index 16a85aa8..c6438fef 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { "name": "gajae-app", "private": true, - "version": "2.0.0-beta.10", - "desktopVersion": "0.2.4", + "version": "2.0.0-beta.12", + "desktopVersion": "0.2.6", "productName": "Gajae Code App", "description": "A self-hosted web and desktop interface for GJC", "type": "module", @@ -17,7 +17,11 @@ "dist/", "dist-server/", "dist-native/", - "scripts/" + "scripts/", + "patches/gjc-sdk-lifecycle/manifest.json", + "patches/gjc-sdk-lifecycle/README.md", + "patches/extract-zip-symlink-leaf/manifest.json", + "patches/extract-zip-symlink-leaf/README.md" ], "homepage": "https://github.com/devswha/gajae-code-app", "repository": { @@ -32,9 +36,9 @@ "dev:isolated": "node scripts/start-isolated-dev.mjs", "server": "node dist-server/server/index.js", "server:dev": "tsx --tsconfig server/tsconfig.json server/index.js", - "preserver:dev": "npm run fill:runtime-manifest && npm run build:core:dev", + "preserver:dev": "npm run check:extract-zip-patch && npm run fill:runtime-manifest && npm run build:core:dev", "server:dev-watch": "tsx watch --tsconfig server/tsconfig.json server/index.js", - "preserver:dev-watch": "npm run fill:runtime-manifest && npm run build:core:dev", + "preserver:dev-watch": "npm run check:extract-zip-patch && npm run fill:runtime-manifest && npm run build:core:dev", "client": "vite", "desktop": "node src-tauri/scripts/tauri.mjs dev", "desktop:dev": "node src-tauri/scripts/tauri.mjs dev", @@ -50,11 +54,15 @@ "build:client": "vite build", "build:core": "node scripts/build-rust-core.mjs --release", "build:core:dev": "node scripts/build-rust-core.mjs", - "prebuild:server": "node scripts/fill-runtime-manifest.mjs && node scripts/generate-command-surface.mjs && node -e \"require('node:fs').rmSync('dist-server', { recursive: true, force: true })\"", + "prebuild:server": "npm run check:sdk-patch && npm run check:extract-zip-patch && node scripts/fill-runtime-manifest.mjs && node scripts/generate-command-surface.mjs && node -e \"require('node:fs').rmSync('dist-server', { recursive: true, force: true })\"", "build:server": "tsc -p server/tsconfig.json && tsc-alias -p server/tsconfig.json", "preview": "vite preview", "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p server/tsconfig.json", "check:identity": "node scripts/check-identity.mjs", + "apply:sdk-patch": "node scripts/apply-sdk-lifecycle-patch.mjs", + "check:sdk-patch": "node scripts/apply-sdk-lifecycle-patch.mjs --check", + "apply:extract-zip-patch": "node scripts/apply-extract-zip-patch.mjs", + "check:extract-zip-patch": "node scripts/apply-extract-zip-patch.mjs --check", "audit": "node scripts/check-audit.mjs", "audit:react": "npx react-doctor@latest", "check:core": "cargo fmt --manifest-path native/gajae-core/Cargo.toml -- --check && cargo clippy --locked --manifest-path native/gajae-core/Cargo.toml --all-targets -- -D warnings && cargo test --locked --manifest-path native/gajae-core/Cargo.toml", @@ -64,16 +72,16 @@ "icon:preview": "node scripts/generate-app-icon.mjs --preview", "test": "node scripts/run-tests.mjs", "smoke:packaged-server": "node scripts/release/smoke-packaged-server.mjs", - "pretest": "npm run build:core:dev", - "test:e2e:gjc": "TSX_TSCONFIG_PATH=server/tsconfig.json node --import tsx --test --test-concurrency=1 server/e2e/gjc-slice4.browser.e2e.ts server/e2e/gjc-slice4.wire.e2e.ts", - "test:e2e:browser": "TSX_TSCONFIG_PATH=server/tsconfig.json node --import tsx --test --test-concurrency=1 server/e2e/browser-sidecar.e2e.ts", + "pretest": "npm run check:sdk-patch && npm run check:extract-zip-patch && npm run build:core:dev", + "test:e2e:gjc": "npm run check:sdk-patch && npm run check:extract-zip-patch && TSX_TSCONFIG_PATH=server/tsconfig.json node --import tsx --test --test-concurrency=1 server/e2e/gjc-slice4.browser.e2e.ts server/e2e/gjc-slice4.wire.e2e.ts", + "test:e2e:browser": "npm run check:sdk-patch && npm run check:extract-zip-patch && TSX_TSCONFIG_PATH=server/tsconfig.json node --import tsx --test --test-concurrency=1 server/e2e/browser-sidecar.e2e.ts", "browser:debug": "node scripts/browser-debug-client.mjs", "poc:opencodex:cua": "node scripts/check-opencodex-cua-poc.mjs", "lint": "eslint src/ server/ shared/ scripts/ vite.config.js", "lint:fix": "eslint src/ server/ shared/ scripts/ vite.config.js --fix", - "verify": "npm run audit && npm run check:licenses && npm run check:notices && npm run typecheck && npm run check:core && npm test && npm run lint && npm run check:identity && npm run build", + "verify": "npm run check:sdk-patch && npm run check:extract-zip-patch && npm run audit && npm run check:licenses && npm run check:notices && npm run typecheck && npm run check:core && npm test && npm run lint && npm run check:identity && npm run build", "start": "npm run build && npm run server", - "postinstall": "node scripts/fix-node-pty.js", + "postinstall": "node scripts/fix-node-pty.js && node scripts/apply-sdk-lifecycle-patch.mjs && node scripts/apply-extract-zip-patch.mjs", "prepare": "husky", "check:licenses": "node scripts/check-dependency-licenses.mjs", "generate:notices": "node scripts/generate-third-party-notices.mjs", @@ -124,6 +132,8 @@ }, "overrides": { "ip-address": "^10.5.0", + "js-yaml@^3.0.0": "3.15.2", + "js-yaml@^4.0.0": "4.3.2", "release-it": { "undici": "7.29.0" } @@ -148,7 +158,7 @@ "katex": "^0.16.25", "lucide-react": "^0.515.0", "mime-types": "^3.0.1", - "multer": "^2.0.1", + "multer": "^2.3.0", "node-pty": "^1.2.0-beta.12", "pretendard": "^1.3.9", "puppeteer-core": "^24.43.1", diff --git a/patches/extract-zip-symlink-leaf/README.md b/patches/extract-zip-symlink-leaf/README.md new file mode 100644 index 00000000..0ab77726 --- /dev/null +++ b/patches/extract-zip-symlink-leaf/README.md @@ -0,0 +1,54 @@ +# extract-zip 2.0.1: bounded symlink-leaf backport + +Reviewed 2026-09-09: upstream PR160 was open/unmerged at commit +`148750acb10c574818906de2a99aa13d457d5329`. The canonical manifest records the +upstream URL and exact index.js before/after SHA-256 values. The only source +change is the upstream `lstat(dest)` / `isSymbolicLink()` rejection immediately +before `createWriteStream`. No SDK/Puppeteer upgrade or new dependency. + +## Scope and remaining limits + +An archive can plant a symlink then reuse its leaf name for a regular file. +The backport rejects that write, including dangling or pre-existing symlinks. +Normal files, directories and safe symlinks still extract. Existing parent-path +realpath checks are unchanged. Symlinks are not globally prohibited. + +This is **archive-only** protection, not a sandbox. A concurrent same-UID local +writer can race the lstat/open interval; later consumers can follow extracted +symlinks. Neither property is fixed or promised here. Extraction can leave +partial contents and rejected symlinks behind, as upstream does. + +`scripts/check-audit.mjs` recognizes GHSA-7pqw-9j4j-h8q3 only with this exact +installed patch. The older GHSA-jmr9-qjv8-65gv retains its constrained-use +exception (Puppeteer's pinned vendor browser download, not app-supplied hostile +archives), now also patch-required; PR160 is not claimed to resolve that +advisory's full scope. Both records have a review date and expire 2026-11-30. +Missing/changed advisories require review; unknown high/critical advisories, +expired records, missing patches and unrecognized installations still fail. + +## Integrity and lifecycle + +`npm ci` postinstall invokes `scripts/apply-extract-zip-patch.mjs`. For an already +installed tree use `npm run apply:extract-zip-patch`; for verification use +`npm run check:extract-zip-patch`. Never hand-edit installed sources. + +The dependency-free helper pins the whole canonical manifest, exact version, +unchanged package.json digest (including resolution metadata), and full +index.js pre/post digests. It validates before an atomic same-directory rename, +preserves permissions, rechecks the result, and is idempotent. Unknown bytes +are refused, not replaced. It rejects nested/aliased extract-zip copies and +linked npm installation slots; it does not fall back to ancestor dependencies. +The repository/helper are the trust root, not protection against local code +tampering by an actor who controls that trust root. + +Dev, server build, tests, verify and audit check without silently applying. +Server and desktop stages carry only the helper, manifest and optional README, +retain the install hook during dependency installation, then keep a check-only +runtime script. Both staging and out-of-tree packaged smokes run the shipped +checker. SDK lifecycle policy, its 32-file inventory and runtime hashes are +independent and unchanged. + +Targeted coverage: `scripts/apply-extract-zip-patch.test.mjs`, +`scripts/check-audit.test.mjs`, and `scripts/release/*extract-zip*.test.mjs`, +plus the existing server/desktop staging suites. Archive tests run the real +extractor with real ZIP bytes, including an unpatched negative control. diff --git a/patches/extract-zip-symlink-leaf/manifest.json b/patches/extract-zip-symlink-leaf/manifest.json new file mode 100644 index 00000000..8bb50bef --- /dev/null +++ b/patches/extract-zip-symlink-leaf/manifest.json @@ -0,0 +1,18 @@ +{ + "schemaVersion": 1, + "id": "extract-zip-symlink-leaf-v1", + "package": "extract-zip", + "version": "2.0.1", + "upstream": { + "pr": "https://github.com/max-mapper/extract-zip/pull/160", + "commit": "148750acb10c574818906de2a99aa13d457d5329" + }, + "packageJsonSha256": "3e8737deb0089259f282f6a0d625175ac8eb2b06cc8ce67a2956e3f6ec5cca07", + "path": "index.js", + "beforeSha256": "1073ca8196d3c9ae51b0de41df9d7c347957c8501d0749d852b4994789cddd78", + "afterSha256": "b15baee7015fe18cb290bd94c3512278c4eb6bd2dac585cfdafaa5b7cedcf4eb", + "replacement": { + "before": " await pipeline(readStream, createWriteStream(dest, { mode: procMode }))", + "after": " const existing = await fs.lstat(dest).catch(() => null)\n if (existing && existing.isSymbolicLink()) {\n throw new Error(\u0060Out of bound path \"\u0024{dest}\" found while processing file \u0024{entry.fileName}\u0060)\n }\n\n await pipeline(readStream, createWriteStream(dest, { mode: procMode }))" + } +} diff --git a/patches/gjc-sdk-lifecycle/README.md b/patches/gjc-sdk-lifecycle/README.md new file mode 100644 index 00000000..2099f454 --- /dev/null +++ b/patches/gjc-sdk-lifecycle/README.md @@ -0,0 +1,376 @@ +# GJC SDK lifecycle remediation — app-owned patch + +Status: producer-completion and enabled-default-host remediation verified in isolation +and applied through a clean checkout `npm ci` on September 8, 2026 for exactly +**0.16.4**. All 32 files passed pristine before-hash validation and installation; +check-only subsequently verified all 32. The app, worker and native validators +share `shared/sdkLifecyclePolicy.json` (maximum 32 files). This is not +an updater/plugin fork, an upstream publication, or permission to clear +`sdk_background_ownership_unproven`. Root and packaging postinstall apply this +same manifest; runtime manifest v2 checks all post-hashes before SDK startup. +The source-integrity receipt does not authorize installation or certify idle. + +## Artifact contract and provenance + +`manifest.json` uses the agreed schema exactly: schema version 1, id +`gjc-sdk-lifecycle-v1`, exact package versions, and 32 source-file entries with +full before/after SHA-256 digests and ordered replace-once edits. Each `before` +snippet occurs exactly once in the preceding source. The complete transformed +file must match `afterSha256`; snippet matches alone are not sufficient. + +Pristine packages were fetched on September 8, 2026 into a new temporary +directory with `npm pack --ignore-scripts --registry=https://registry.npmjs.org`. +The artifact review did not mutate installed dependencies. Parent integration +subsequently applied the original eight verified edits using the guarded applier. +The producer candidate started from independently fetched pristine packages and +that same guarded eight-file application. New edits were made only to the isolated +candidate and encoded as additional replace-once operations. All old replacements +remain in order, including the four otherwise unchanged leaf/registry files; +versions remain pinned to the published 0.16.4 packages. + +| Published package | npm tarball SHA-1 | npm integrity | +| --- | --- | --- | +| `@gajae-code/coding-agent@0.16.4` | `4613aba27825509b0be68de5b9eea05fd8c6e841` | `sha512-cnqyYOEGygPp87gCEkqahNiRYoBhL4gxvQnWY16lDADThfNjhrl7VP+5f9cLakevI+pjRbTSdDBw/iZ3Nc6PCw==` | +| `@gajae-code/agent-core@0.16.4` | `64f8c249bad9c0f1ff8574ec476417294ef3c6c9` | `sha512-Ck0TIybhjf7qfWNQMbFEHDXCUiT0xDOq27DCeO2UbRglorI49iBmbw7Lq2J8yWVf8FgtEVD2RXEENX13GwH1Tg==` | +| `@gajae-code/ai@0.16.4` | `e69483ff743d6e9b23e63e1781d3ed099e749f3a` | `sha512-q89+ggA3vWaWxx3u9cEelpdiLtHX2uHFsqHkJtYZDVj40RNgU8PPpYwPvHi3jwJ/THQE49GOwSddAPvT57N5KA==` | + +The separately inspected official `coding-agent@0.16.6` tarball has SHA-1 +`b72393e4107c69d6cfaf5549e64971c5b8017232`. Its relevant prewarm, disposal and +forced-recovery blocks, plus its async-job manager, are unchanged from 0.16.4. +That comparison is not runtime compatibility qualification for a pin upgrade. + +## What changes + +1. **`coding-agent/src/sdk/session.ts`: startup ownership.** Model-host preconnect + and Codex credential/WebSocket prewarm each reserve a promise before their + operation begins. Session transition cleanup joins these promises before + credential/provider resources are released. The failed-creation path also + joins startup work and retained async-job disposal. Transport preferences, + selected models and enabled tools are unchanged; WebSockets are not disabled. +2. **`coding-agent/src/session/agent-session.ts`: actual retained completion.** + Calling `awaitDisposeCompletion()` first now starts disposal but returns its + retained promise, not the bounded caller promise. Separate physical owners + retain post-prompt work when forced recovery clears the logical queue, and + retain each prompt through its actual finalizer. Normal disposal joins these + owners and the core physical ledger before closing session resources. The + SDK factory also enables a final `flushOrThrow()` for caller-borrowed Settings + after physical/tool cleanup; it does not close that object. Persistence errors + reject retained completion. +3. **`agent-core/src/run-resource-ledger.ts`: physical accounting survives + quarantine.** The existing promise-registration paths also retain physical + promises independently of visible resources and bounded tombstones. This + includes promises submitted through already-closed/quarantined leases. + Logical `waitForSettlement()` results are unchanged: quarantine remains + `unfenced`, even after a physical join. Terminal physical joining denies + fresh `open()` calls but never deletes pending promises to become idle. +4. **`agent-core/src/agent-loop.ts`: retain the producer, not only its consumer.** + Both loop entrypoints reserve their producer-body completion before invoking + the body. It resolves in the producer's `finally`, not when a consumer sees + `agent_end`, breaks iteration, or `forceAbort()` clears the busy flag. This + also covers pre-provider metadata/auth/hook awaits inside that body. This is + physical-only retention, not a logical ledger entry: it does not add a + `waitForSettlement()` self-dependency or change standalone sealing. +5. **`coding-agent/src/config/model-preset-registry.ts`: recurring maintenance.** + This is a recurring six-hour refresh (default startup delay: 30 seconds), not + a one-shot task. Its callable cancellation handle also exposes read-only + activity, a reversible admission fence, and a pending-work join. A fence + pauses future timer dispatch/publication and retains the due time/deferred + notification. Accepted flights and publication promises keep running through + callback completion. Reopening resumes scheduling; automatic refresh is not + disabled. +6. **`coding-agent/src/config/model-registry.ts`: maintenance ownership.** + Constructor publication returns its catalog-mutation promise instead of + discarding it. Activity covers helper roots, catalog queue/tail, explicit + background refresh, async catalog callbacks and their settings persistence. + Disposal joins these owners, not only the helper. A persistence failure stays + unknown. Borrowed AuthStorage is accounted separately by its leaf seam. +7. **`ai/src/auth-storage.ts`: actual auth leaf ownership.** Raw OAuth provider/ + broker refresh promises, scoped/shared usage overrides, per-credential usage + work and cache publication, config-value resolution, and credential-disabled + callbacks reserve ownership before invocation. Caller timeout/cancellation + races never release their underlying loser. Existing public timeout and + synchronous `close()` behavior remain unchanged. A close does not erase + pending ownership. Data generations and activity revisions remain separate. +8. **`coding-agent/src/config/settings.ts`: reserved background saves.** Every + real save promise is retained independently of the latest `#savePromise`. + The getter exposes the reserved debounce and in-flight writes; the join waits + for the existing timer/save naturally and never invokes flush/close. A failed + save leaves sticky `settings_persistence_unconfirmed`, even once its promise + has settled. Normal `flush()`/`flushOrThrow()` semantics are unchanged. + +9. **AI stream producer contract (19 additional files).** + `src/utils/event-stream.ts` holds per-stream physical owners in a module-private + WeakMap. `runAppStreamProducer` reserves the owner before invoking the actual + async producer and resolves only after that body, including its real `finally`, + and adopted child producers settle. `push`, `end`, `fail`, `result` and iterator + completion do not release it. A lookalike property or a bare + `AssistantMessageEventStream` is not a registered producer. + `src/providers/register-builtins.ts` owns module loading and forwarding, and + adopts the inner producer before consuming it. `src/stream.ts` does likewise + for lazy imports, custom-provider forwarding and **every** auth-retry attempt. + `complete`/`completeSimple` also join known producers, so non-streaming callers + such as title generation retain their tail. `result()` itself stays unchanged. + `src/utils/idle-iterator.ts` retains raced `next()` and `return()` promises in + the initiating producer's async-local owner; its timeout/abort still returns + promptly, without discharging the losing physical operation. + + The 15 actual transport bodies are `amazon-bedrock`, `anthropic`, + `azure-openai-responses`, `cursor`, `google-gemini-cli`, `google-shared`, + `kiro-api-key`, `kiro-codewhisperer`, `ollama`, `openai-completions`, + `openai-responses`, `openai-codex-responses`, `openai-anthropic-shim`, + `gitlab-duo`, and `pi-native-client` (all `ai/src/providers/*.ts`). Google + and Vertex share the patched Google body; Kimi/Synthetic delegate through the + patched shim. Shim/GitLab forwarding adopts their inner transport as well. + Cursor's body is retained, but its separately dispatched HTTP/2 task/debug + work is not fully audited and explicitly remains unknown. + +10. **Core/session composition and factory retention.** The core retains each + authenticated producer promise in the physical ledger, separately from + logical provider settlement. Missing producers and child coverage failures + leave sticky `sdk_provider_producer_unrepresented`; quarantine cannot erase + either retained promises or that reason. The ledger exposes a pure activity + getter. The session composes it with physical prompt/post-prompt owners and + the SDK factory's pending work, with disposal/failure generations. + Parallel workspace/context/prompt-template discovery is now reserved before + invocation, including workspace deadline losers and early factory failure. + Direct credential-disabled callbacks and reactive MCP publication reserve + returned promises, and cleanup joins accepted work before closing shared + resources. The adapter reads these actual owners from creation through + retained disposal; it does not turn a source-integrity receipt into coverage. + +11. **Enabled default SDK host (five additional files).** + `coding-agent/src/sdk/host/host.ts` provides a session-local physical owner + and reserves accepted dispatch, activation and send promises before invoking + them. Reverse RPC deliveries use the same owned send path. Admission closes + on ordinary host stop; already-accepted handlers and writes remain owned. + `src/sdk/host/session-runtime.ts` retains directed deliveries, preflight and + submission continuations, terminalization/skill recovery, gate resolution, + lifecycle persistence and the actual startup/shutdown handlers. Retired-owner + cleanup timers have physical reservations through dispatch; cancelling a + future timer does not discharge an already-running callback. Existing bounded + drains still return on their original budgets. SDK session resource cleanup + then joins the separate physical owners before releasing shared resources. + `src/sdk/prompt-deadline-manager.ts` similarly retains deadline/retry timers, + in-flight expiry writes and uncertainty recovery after logical lease clears. + `src/sdk/host/websocket-transport.ts` preserves the 250ms public stop race but + retains the actual server-stop loser; its physical join propagates late + failure. `src/sdk/host/query/revision-store.ts` joins detached snapshot-unlink + promises before final directory cleanup. + + A real SDK session is tested with its default WebSocket host actively started + (endpoint creation verified), an offline provider turn, normal disposal and + endpoint removal. Its receipt is complete with zero activity. The adapter + contract also proves that the enabled default path becomes eligible after + cleanup. The fixture pre-starts an isolated **in-process** broker, so no + detached process is spawned or signalled. These are ownership tests, not a + transport-disable workaround or a certificate for opaque user extensions. + +These APIs are additions to the Bun source runtime, not changes to npm declaration +files. The async-local iterator retention is qualified for the pinned Bun runtime; +it does not constitute browser or other JavaScript-runtime qualification. + +## Public registry seam + +```ts +getAppLifecycleActivity(): { + generation: string; + complete: boolean; + starting: number; + queued: number; + running: number; + settling: number; + unknown: string[]; +} +setAppLifecycleAdmission(closed: boolean): void; +awaitAppLifecycleSettlement(): Promise; +``` + +The getter is pure and detached; counts may overlap. It is incomplete while +maintenance admission is open, if a required settings flush capability is +missing, or after unconfirmed persistence. Deferred future maintenance is not an +accepted root and is not busy while fenced. The explicit join requires a held +fence and rejects if reopened during its wait. This is **registry maintenance**, +not an AuthStorage, settings-global, or whole-worker receipt. + +Foreground `refresh`/`refreshStatic`/`refreshProvider` retain their behavior: they +may be required continuations of accepted OAuth/session work. Top-level app +admission must gate genuinely new callers; every accepted catalog mutation is +counted before its first await. `refreshInBackground` and helper timer/notification +roots defer while fenced. Parent must include registry generation/counts from +adapter construction, not only after first session creation. A missing seam is +unknown. The new runtime methods need a narrow public capability type at the app +boundary; the npm declaration files are deliberately not rewritten. + +## AuthStorage and Settings leaf seams + +Both classes expose the same seven-field `getAppLifecycleActivity()` shape above +and `awaitAppLifecycleSettlement(): Promise`. **Neither gets an admission +fence.** New roots are already gated by the worker, registry and OAuth owners; +an accepted registry flight must be allowed to reach a later auth dependency or +settings write. Leaf starts/completions revise their own generation. + +Source audit found no autonomous recurring dispatcher in AuthStorage: its timers +bound requests. Settings' autonomous save debounce belongs to a reservation +created synchronously by a writer. Its complete save promise remains owned from +reservation through persistence/failure cleanup. Thus these constructor/global +leaf owners need no perpetual blanket unknown once their real pending sets are +empty (unless Settings persistence failed). These are component receipts, not a +full SDK certificate. Missing APIs remain unknown; the adapter must aggregate +leaf generations/counts from construction and retain references to every owner +that can still have pending work. + +The joins are physical-promise joins with no artificial timeout, no mutation of +admission, and no forced flush. They must be called by an outer owner, not awaited +from a callback that they themselves own. The read-only getters are bounded and +do not inspect credentials or the filesystem. + +The new runtime helper `awaitPhysicalRunResources(ledger)` is a **terminal join**, +not a read-only observer. It is called only by normal SDK disposal. Its optional +exported marker `GJC_APP_LIFECYCLE_PATCH = 'gjc-sdk-lifecycle-v1'` is useful for +debugging, but is neither a quiescence certificate nor proof that all patch files +are present. A bundler may elide the marker if unused. Use the manifest inventory +to verify source bytes before compilation. The SDK accesses the additive helper +through a narrow public namespace type; existing app-facing declaration files and +the public `awaitDisposeCompletion(): Promise` signature are unchanged. + +## Preserved behavior + +- `abort()` and `forceAbort()` keep their existing bounded/logical behavior. + They may free the interactive busy state while physical work remains retained. +- `dispose()` keeps its existing caller deadline and + `SessionDisposalIncompleteError`. The retained join has no invented timeout: + a task that never settles must keep it pending. +- Existing disposal failures still reject. Physical completion is not success + of the user's tool/provider operation; an ordinary failed operation can settle. +- Ownership is per ledger/session. One session's completed teardown does not + discharge another session's pending work. +- No process-tree kill, updater admission change, SDK-command replacement, timer + monkeypatch, or installed private-field inspection is used to manufacture idle. + +## Physical evidence and reproduction + +`lifecycle.bun.test.ts` imports an explicitly selected isolated candidate SDK and +core/AI. Only their 32 manifest-listed source files differ from pristine published packages. +Other dependencies are read-only links to the existing 0.16.4 dependency closure; +this is not a clean-install, cross-platform or packaged-binary qualification. +All session data goes into independent temporary fixtures. No live credentials, +provider requests or shell commands are needed. + +The tests hold actual promise gates, verify the retained join remains unresolved, +then release the gates and await completion. They do not infer physical completion +from generation changes or empty diagnostic counters. Coverage includes: + +- quarantined and late failed-lease resources; +- a provider factory still pending after `forceAbort()`/`waitForIdle()`; +- an abort-ignoring tool; +- abandoned post-prompt work with a bounded public disposal timeout; +- Codex prewarm before resource cleanup, with WebSockets still enabled; +- independent sessions and preserved cleanup failure; +- a real SDK prompt reaching normal disposal; +- standalone logical settlement while its loop body is retained physically; +- recurring refresh and publication deferral without aborting accepted work; +- independent borrowed-registry activity and catalog callback completion; +- borrowed Settings flush ordering, held public persistence promises, actual + durable reload, error propagation and preservation of borrowed storage; +- auth scoped/shared usage timeout losers, provider deadlines, OAuth cancellation + losers, config resolver reservation-before-invocation, and callback completion; +- real Settings debounce and file-lock-blocked persistence, overlapping saves, + durable reload, and actual background-save failure; +- physical producer `finally` on success/failure after an early terminal event; +- an **actual pi-native transport** publishing terminal SSE before EOF, through + both a core join and real session disposal with its caller deadline; +- lazy built-in dispatch, nested/custom forwarding and auth-retry loser tails; +- physical idle-iterator timeout/abort losers, and rejection of forged receipts; +- accepted host control/response/delivery callbacks after logical stop, independent + host owners, actual WebSocket shutdown timeout losers and late failure; +- a real hosted lifecycle drain exceeding its public budget while persistence + and retired-owner timers remain retained, plus physical deadline writes after + logical clears and future-vs-running timer cancellation; +- enabled default-host SDK and adapter cleanup reaching complete, zero activity; +- the unregistered-provider-tail limitation below. + +From the repository, run the read-only/replay tests with the supported Node: + +```sh +node --test patches/gjc-sdk-lifecycle/manifest.test.mjs +``` + +During parent integration, set `GJC_SDK_LIFECYCLE_CANDIDATE` to the isolated +pristine or known-after install root for replay tests; this avoids treating the +checkout's previous eight-file state as the new manifest's known-after state. +The full parent-applier tests use the shared policy and require capacity for 32 files. + +For physical tests, extract the exact published packages into a fresh temporary +install layout (`node_modules/@gajae-code/{coding-agent,agent-core,ai}`), supply their +unchanged transitive dependencies, and use the parent's exported +`applySdkLifecyclePatch(tempRoot, manifest)` function on **that temporary root**. +Do not run the applier CLI against the checkout as part of artifact review. +For normal development, `npm ci` applies it through postinstall; `npm run +apply:sdk-patch` applies the checked manifest explicitly, and `npm run +check:sdk-patch` is read-only. An unknown/local modification is refused, not +overwritten. `npm test` runs these same lifecycle tests against the installed +patched code through `scripts/sdk-lifecycle-contract.test.mjs`, using separate +temporary data directories and offline providers. + +```sh +GJC_SDK_LIFECYCLE_CANDIDATE=/absolute/temporary/install-root \ + dist-native/bun test patches/gjc-sdk-lifecycle/lifecycle.bun.test.ts +``` + +`manifest.test.mjs` verifies exact replay/full hashes and invokes the parent's +applier only on temporary fixtures. It checks unapplied `--check` semantics, +known-after idempotence, version rejection, and all-file prevalidation. Installed +source is read only; known-after fixtures can be reconstructed in memory. + +## Concrete remaining proof limits + +This patch is **not yet an all-feature SDK quiescence certificate**. + +- **Leaf receipts require upstream root admission.** Auth and Settings do not + reject an accepted continuation. A future external caller can create new work; + the app must gate those callers and compose each leaf generation. Per-instance + leaf activity is not evidence for a different AuthStorage/Settings instance. + Synchronous request APIs or external stores remain owned by their calling root; + the leaf seam specifically retains asynchronous work which may escape a raced + waiter. It does not certify unrepresented work hidden behind a third-party + provider/store callback's returned promise. +- The built-in **producer-body tail gap is closed**, including forwarding and + iterator timeout losers. An unregistered custom/extension provider, including + the bundled Grok provider's independent implementation, remains unknown. A + returned promise is a contract for represented work, not evidence for detached + effects hidden by a custom fetch/callback implementation. +- **Default SDK hosting now has physical ownership, not a blanket exception.** + Its bounded return is still not physical completion: the session joins its + retained owners and the real WebSocket stop promise. Missing host registration + remains `sdk_host_effects_unrepresented`; a custom transport without a physical + join is `sdk_host_transport_unrepresented`. Joining a host from the callback it + would itself wait for fails explicitly with `sdk_host_reentrant_settlement` + instead of manufacturing idle or deadlocking. This does not claim coverage of + separate generic notification hosting, which still reports + `sdk_notification_effects_unrepresented` when enabled. +- Explicit/preloaded extensions and discovered hook/plugin factories may detach + arbitrary effects; those sessions report `sdk_extension_effects_unrepresented`. + `ExtensionRunner.initialize()` can also flush buffered credential-disabled + events through unretained microtasks. Although direct returned callback + promises are now retained, a session receiving these events reports + `sdk_extension_credential_dispatch_unrepresented`. Runner-level queue/finally + retention is still required for that buffered path. Borrowed arbitrary runtime + services/event buses/MCP managers report `sdk_injected_services_unrepresented`. +- Cursor's producer is retained, but its HTTP/2 coordinator subtasks and async + debug writer still require independent validation and joins; its receipt + includes `sdk_cursor_subtasks_unrepresented`. +- A shell/tool can intentionally detach a descendant or create an external + effect not represented by its returned promise. Physical promise settlement + alone is not OS process-tree proof. +- Failed factories have no returned whole-session receipt. The enumerated + parallel discoveries, prewarm and async-job cleanup now join on failure, but + all fallible extension/host/discovery implementations have not been certified. + The adapter therefore retains `sdk_background_ownership_unproven` for a failed + factory rather than inferring coverage from rejection. + +Accordingly, do **not** blanket-remove `sdk_background_ownership_unproven` based +on the marker or this patch. Parent integration must preserve unknown for these +unproven paths. Guarded application, postinstall/build wiring and runtime manifest +v2 are connected. Final frozen-source promotion, real packaged/platform checks, +native restart/previous-owner proof and public release still require their own +evidence; this artifact is not a waiver of any of them. diff --git a/patches/gjc-sdk-lifecycle/lifecycle.bun.test.ts b/patches/gjc-sdk-lifecycle/lifecycle.bun.test.ts new file mode 100644 index 00000000..d05e092c --- /dev/null +++ b/patches/gjc-sdk-lifecycle/lifecycle.bun.test.ts @@ -0,0 +1,913 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, rm, rename, readFile, stat } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { isAbsolute, join } from 'node:path'; +import { test } from 'node:test'; +import { pathToFileURL } from 'node:url'; + +// Run only against an explicitly selected isolated candidate, never installed deps. +const candidate = process.env.GJC_SDK_LIFECYCLE_CANDIDATE; +if (!candidate || !isAbsolute(candidate)) throw new Error('Set GJC_SDK_LIFECYCLE_CANDIDATE to the isolated candidate root.'); +const load = (pkg: string, file: string) => import(pathToFileURL(join(candidate, 'node_modules', pkg, file)).href); +const { Agent } = await load('@gajae-code/agent-core', 'src/agent.ts'); +const { agentLoop } = await load('@gajae-code/agent-core', 'src/agent-loop.ts'); +const lifecycle = await load('@gajae-code/agent-core', 'src/run-resource-ledger.ts'); +const { createAgentSession, discoverAuthStorage } = await load('@gajae-code/coding-agent', 'src/sdk/session.ts'); +const { ModelRegistry } = await load('@gajae-code/coding-agent', 'src/config/model-registry.ts'); +const { refreshModelPresetRegistryInBackground, setModelPresetRegistryDisabled } = await load('@gajae-code/coding-agent', 'src/config/model-preset-registry.ts'); +const { Settings } = await load('@gajae-code/coding-agent', 'src/config/settings.ts'); +const { withFileLock } = await load('@gajae-code/coding-agent', 'src/config/file-lock.ts'); +const { AuthStorage } = await load('@gajae-code/ai', 'src/auth-storage.ts'); +const { SessionManager } = await load('@gajae-code/coding-agent', 'src/session/session-manager.ts'); +const { SessionDisposalIncompleteError } = await load('@gajae-code/coding-agent', 'src/session/agent-session.ts'); +const { AssistantMessageEventStream, runAppStreamProducer, getAppStreamProducer, adoptAppStreamProducer } = await load('@gajae-code/ai', 'src/utils/event-stream.ts'); +const { streamPiNative } = await load('@gajae-code/ai', 'src/providers/pi-native-client.ts'); +const { streamFromLazyImport, streamSimple } = await load('@gajae-code/ai', 'src/stream.ts'); +const builtins = await load('@gajae-code/ai', 'src/providers/register-builtins.ts'); +const { iterateWithIdleTimeout } = await load('@gajae-code/ai', 'src/utils/idle-iterator.ts'); +const { AppSdkHostOwner } = await load('@gajae-code/coding-agent', 'src/sdk/host/host.ts'); +const { SessionSdkSessionRuntime, createSdkSessionRuntimeExtension } = await load('@gajae-code/coding-agent', 'src/sdk/host/session-runtime.ts'); +const { createSdkWebSocketTransport } = await load('@gajae-code/coding-agent', 'src/sdk/host/websocket-transport.ts'); +const { PromptDeadlineManager } = await load('@gajae-code/coding-agent', 'src/sdk/prompt-deadline-manager.ts'); +const { Broker } = await load('@gajae-code/coding-agent', 'src/sdk/broker/broker.ts'); +const { createReconciliationStore } = await load('@gajae-code/coding-agent', 'src/sdk/reconciliation-extensions.ts'); +const { registerCustomApi, unregisterCustomApis } = await load('@gajae-code/ai', 'src/api-registry.ts'); +const { z } = await load('zod', 'index.js'); + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((yes) => { resolve = yes; }); + return { promise, resolve }; +} +const model = { id: 'offline', provider: 'lifecycle-offline', api: 'lifecycle-offline', + name: 'Offline lifecycle fixture', baseUrl: 'http://127.0.0.1:1', reasoning: false, + input: ['text'], contextWindow: 100000, maxTokens: 1000, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } }; +function answer(content: unknown[] = [{ type: 'text', text: 'settled' }], stopReason = 'stop') { + return { ...model, role: 'assistant', model: model.id, content, stopReason, timestamp: Date.now(), + usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } } }; +} +function stream(message = answer()) { + const result = new AssistantMessageEventStream(); + result.push({ type: 'done', reason: message.stopReason, message }); result.end(message); return result; +} +async function stillPending(promise: Promise) { + let settled = false; + void promise.then(() => { settled = true; }, () => { settled = true; }); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(settled, false); +} + +async function sessionFixture(prewarm?: { entered: ReturnType; release: ReturnType }, + persistence?: { entered: ReturnType; release: ReturnType; error?: Error }) { + const root = await mkdtemp(join(tmpdir(), 'gjc-lifecycle-contract-')); + const cwd = join(root, 'project'); const agentDir = join(root, 'agent'); await mkdir(cwd); + const authStorage = await discoverAuthStorage(agentDir); + const rawSettings = await Settings.loadForScope({ cwd, agentDir }); + // Public injected Settings facade: no private fields, timer replacement, or + // SDK command patch. Its held promise delegates to actual flushOrThrow. + const settings = persistence ? new Proxy(rawSettings, { get(target, property) { + if (property === 'flushOrThrow') return async () => { + persistence.entered.resolve(); await persistence.release.promise; + if (persistence.error) throw persistence.error; + return target.flushOrThrow(); + }; + const value = Reflect.get(target, property, target); + return typeof value === 'function' ? value.bind(target) : value; + } }) : rawSettings; + settings.override('memory.enabled', false); settings.override('skills.enabled', false); + settings.override('startup.networkPrewarm', false); + settings.override('providers.openaiWebsockets', prewarm ? 'on' : 'off'); + class HeldRegistry extends ModelRegistry { + async getApiKey(...args: unknown[]) { + if (!prewarm || args.length > 2) return super.getApiKey(...args); + prewarm.entered.resolve(); await prewarm.release.promise; return undefined; + } + } + const registry = new HeldRegistry(authStorage, join(agentDir, 'models.yml'), settings, { agentDir }); + registry.registerProvider(model.provider, { api: prewarm ? 'openai-codex-responses' : model.api, + apiKey: 'offline-unused-key', baseUrl: model.baseUrl, + models: [{ id: model.id, name: model.name, reasoning: false, input: model.input, + contextWindow: model.contextWindow, maxTokens: model.maxTokens, cost: model.cost }] }); + const { session } = await createAgentSession({ + cwd, agentDir, settings, authStorage, modelRegistry: registry, + agentId: `fixture-${root.split('-').at(-1)}`, + model: registry.find(model.provider, model.id), sessionManager: SessionManager.create(cwd, join(root, 'sessions')), + toolNames: ['bash'], spawns: 'deny', enableMcpAutoload: false, enableLsp: false, + skipPythonPreflight: true, disableExtensionDiscovery: true, + skills: [], rules: [], contextFiles: [], promptTemplates: [], slashCommands: [], + }); + session.setDisposeTimeoutForTests(25); + return { session, registry, settings, async close(expectedRegistryFailure = false) { + await session.awaitDisposeCompletion().catch(() => {}); + if (expectedRegistryFailure) await registry.dispose().catch(() => {}); + else await registry.dispose(); + authStorage.close(); await settings.close(); + await rm(root, { recursive: true, force: true }); + } }; +} + +test('physical ledger retains quarantined and late failed-lease promises without changing logical abort proof', async () => { + assert.equal(lifecycle.GJC_APP_LIFECYCLE_PATCH, 'gjc-sdk-lifecycle-v1'); + const ledger = lifecycle.createRunResourceLedger(); const owner = ledger.open('r'); + const lease = ledger.reserveProducer('r', owner, 'tool', 'fixture'); assert.ok(lease.ok); + const first = deferred(); const late = deferred(); + lease.lease.track('tool', 'first', first.promise); + ledger.quarantine('r'); + assert.equal(lease.lease.track('tool', 'late', late.promise), false); + lease.lease.closeDiscovery(); + const joined = lifecycle.awaitPhysicalRunResources(ledger); + await stillPending(joined); first.resolve(); await stillPending(joined); + late.resolve(); await joined; + assert.equal((await ledger.waitForSettlement('r', { graceMs: 0 })).status, 'unfenced'); + assert.equal(ledger.open('new-root'), undefined); +}); + +for (const fails of [false, true]) { + test(`borrowed Settings flush is retained after tool cleanup and ${fails ? 'propagates failure' : 'persists actual writes'}`, async () => { + const entered = deferred(); const release = deferred(); + const f = await sessionFixture(undefined, { entered, release, ...(fails ? { error: new Error('held persistence failure') } : {}) }); + try { + let toolClosed = false; + f.session.registerToolSessionCleanup(() => { toolClosed = true; f.settings.set('defaultThinkingLevel', 'high'); }); + const joined = f.session.awaitDisposeCompletion(); + void joined.catch(() => {}); + await entered.promise; + assert.equal(toolClosed, true); + await assert.rejects(f.session.dispose(), (error: unknown) => error instanceof SessionDisposalIncompleteError); + await stillPending(joined); + release.resolve(); + if (fails) await assert.rejects(joined, /borrowed settings persistence/); + else { + await joined; + assert.ok(f.settings.getStorage(), 'borrowed storage was flushed, not closed'); + const reloaded = await Settings.loadForScope({ cwd: f.settings.getCwd(), agentDir: f.settings.getAgentDir() }); + try { assert.equal(reloaded.get('defaultThinkingLevel'), 'high'); } + finally { await reloaded.close(); } + } + } finally { release.resolve(); await f.close(); } + }); +} + +test('registry catalog listener ownership includes its borrowed settings persistence', async () => { + const entered = deferred(); const release = deferred(); + const f = await sessionFixture(undefined, { entered, release }); + try { + f.registry.onCatalogChanged(() => { f.settings.set('defaultThinkingLevel', 'medium'); }); + f.registry.registerProvider('settings-catalog-contract', { api: model.api, apiKey: 'offline', baseUrl: model.baseUrl, + models: [{ id: 'other', name: 'Other', input: ['text'], reasoning: false, + contextWindow: 10000, maxTokens: 1000, cost: model.cost }] }); + await entered.promise; + f.registry.setAppLifecycleAdmission(true); + const joined = f.registry.awaitAppLifecycleSettlement(); + assert.ok(f.registry.getAppLifecycleActivity().settling > 0); + await stillPending(joined); release.resolve(); await joined; + assert.equal(f.registry.getAppLifecycleActivity().settling, 0); + } finally { release.resolve(); await f.close(); } +}); + +test('failed registry-owned settings persistence remains incomplete after its promise settles', async () => { + const entered = deferred(); const release = deferred(); + const f = await sessionFixture(undefined, { entered, release, error: new Error('registry persistence failure') }); + try { + f.registry.onCatalogChanged(() => { f.settings.set('defaultThinkingLevel', 'medium'); }); + f.registry.registerProvider('failed-settings-contract', { api: model.api, apiKey: 'offline', baseUrl: model.baseUrl, + models: [{ id: 'other', name: 'Other', input: ['text'], reasoning: false, + contextWindow: 10000, maxTokens: 1000, cost: model.cost }] }); + await entered.promise; + f.registry.setAppLifecycleAdmission(true); + const joined = f.registry.awaitAppLifecycleSettlement(); release.resolve(); + await assert.rejects(joined, /persistence is unconfirmed/); + const result = f.registry.getAppLifecycleActivity(); + assert.equal(result.complete, false); + assert.ok(result.unknown.includes('model_registry_settings_unconfirmed')); + assert.deepEqual(f.registry.getAppLifecycleActivity(), result, 'observation cannot clear persistence failure'); + } finally { release.resolve(); await f.close(true); } +}); + +test('physical join retains a provider factory after forceAbort logically releases waitForIdle', async () => { + const entered = deferred(); const released = deferred(); + const agent = new Agent({ initialState: { model }, getApiKey: async () => 'offline', + streamFn: async () => { entered.resolve(); return released.promise; } }); + const prompt = agent.prompt('offline'); + try { + await entered.promise; assert.equal(agent.forceAbort(), true); await agent.waitForIdle(); + const joined = lifecycle.awaitPhysicalRunResources(agent.resourceLedger); + await stillPending(joined); + released.resolve(stream()); await prompt; await joined; + await assert.rejects(agent.prompt('after terminal close'), /ownership|domain/i); + } finally { released.resolve(stream()); await prompt; } +}); + +test('physical join retains an abort-ignoring tool after logical terminal publication', async () => { + const entered = deferred(); const released = deferred(); + const agent = new Agent({ initialState: { model, tools: [{ name: 'lifecycle_hold', label: 'Hold', description: 'Test only', + parameters: z.object({}), execute: async () => { entered.resolve(); await released.promise; return { content: [{ type: 'text', text: 'done' }] }; } }] }, + getApiKey: async () => 'offline', streamFn: () => stream(answer([{ type: 'toolCall', id: 'held', name: 'lifecycle_hold', arguments: {} }], 'toolUse')) }); + const prompt = agent.prompt('offline'); + try { + await entered.promise; agent.forceAbort(); await agent.waitForIdle(); + const joined = lifecycle.awaitPhysicalRunResources(agent.resourceLedger); + await stillPending(joined); released.resolve(); await prompt; await joined; + } finally { released.resolve(); await prompt; } +}); + +test('first-call awaitDisposeCompletion joins abandoned post-prompt tasks while dispose remains bounded', async () => { + const f = await sessionFixture(); const released = deferred(); + try { + f.session.trackPostPromptTaskForTests(released.promise); + await f.session.abort({ timeoutMs: 5 }); + assert.equal(f.session.hasPostPromptWork, false, 'logical recovery behavior is preserved'); + const joined = f.session.awaitDisposeCompletion(); + assert.equal(f.session.awaitDisposeCompletion(), joined, 'same retained owner, no new deadline'); + await assert.rejects(f.session.dispose(), (error: unknown) => error instanceof SessionDisposalIncompleteError); + await stillPending(joined); released.resolve(); await joined; + } finally { released.resolve(); await f.close(); } +}); + +test('normal disposal owns Codex credential/prewarm before resource cleanup without disabling WebSockets', async () => { + const entered = deferred(); const release = deferred(); + const f = await sessionFixture({ entered, release }); + let resourcesClosed = false; + try { + await entered.promise; + assert.equal(f.session.agent.preferWebsockets, true); + f.session.registerToolSessionCleanup(() => { resourcesClosed = true; }); + const joined = f.session.awaitDisposeCompletion(); + await assert.rejects(f.session.dispose(), (error: unknown) => error instanceof SessionDisposalIncompleteError); + assert.equal(resourcesClosed, false); await stillPending(joined); + release.resolve(); await joined; assert.equal(resourcesClosed, true); + } finally { release.resolve(); await f.close(); } +}); + +test('physical owners are session-scoped and real cleanup failures still reject the public retained join', async () => { + const a = await sessionFixture(); const b = await sessionFixture(); const released = deferred(); + try { + a.session.trackPostPromptTaskForTests(released.promise); + await a.session.abort({ timeoutMs: 5 }); + const pending = a.session.awaitDisposeCompletion(); + b.session.registerToolSessionCleanup(() => { throw new Error('cleanup failure fixture'); }); + await assert.rejects(b.session.awaitDisposeCompletion(), /disposal|cleanup/i); + await stillPending(pending); released.resolve(); await pending; + } finally { released.resolve(); await a.close(); await b.close(); } +}); + +test('a real SDK prompt and its normal event/continuation cleanup reach physical settlement', async () => { + const f = await sessionFixture(); const source = 'app-lifecycle-real-prompt'; + registerCustomApi(model.api, () => stream(), source); + try { + await f.session.prompt('offline normal turn'); + await f.session.awaitDisposeCompletion(); + assert.equal(f.session.isDisposed, true); + } finally { unregisterCustomApis(source); await f.close(); } +}); + +test('standalone loop physical retention does not add a logical settlement self-dependency', async () => { + const ledger = lifecycle.createRunResourceLedger(); + const events = agentLoop([{ role: 'user', content: 'offline', timestamp: Date.now() }], + { systemPrompt: [], messages: [], tools: [] }, + { model, convertToLlm: (messages: unknown[]) => messages, getApiKey: async () => 'offline', + resourceLedger: ledger, resourceRunId: 'standalone' }, undefined, () => stream()); + for await (const event of events) { + if (event.type === 'agent_end') { + assert.equal(ledger.pending('standalone').some((entry: { label: string }) => entry.label === 'agent-loop-body'), false); + assert.equal((await ledger.waitForSettlement('standalone', { graceMs: 100 })).status, 'settled'); + } + } + await lifecycle.awaitPhysicalRunResources(ledger); +}); + +test('registry maintenance fence defers recurring refresh without aborting its accepted flight', async () => { + const root = await mkdtemp(join(tmpdir(), 'gjc-registry-maintenance-')); + const entered = deferred(); const release = deferred(); + let calls = 0; let signal: AbortSignal | undefined; + const control = refreshModelPresetRegistryInBackground({ agentDir: root, startupDelayMs: 0, + refreshIntervalMs: 20, timeoutMs: 1000, manifestUrl: 'https://registry-lifecycle.invalid/manifest.json', + fetch: async (_url: unknown, options: { signal?: AbortSignal }) => { + calls++; signal = options.signal; entered.resolve(); await release.promise; + return new Response('unavailable', { status: 503 }); + } }); + try { + await entered.promise; + control.setAdmissionFence(true); + const pending = control.getActivity(); + assert.equal(pending.fenced, true); assert.ok(pending.pending > 0); + assert.equal(signal?.aborted, false, 'a fence is not cancellation'); + const joined = control.awaitSettlement(); await stillPending(joined); + release.resolve(); await joined; + const idle = control.getActivity(); + assert.equal(idle.pending, 0); assert.equal(idle.scheduled, false); assert.equal(idle.deferred, true); + await new Promise((resolve) => setTimeout(resolve, 25)); + assert.equal(calls, 1, 'periodic work stays deferred while fenced'); + assert.deepEqual(control.getActivity(), idle, 'reads do not invalidate their own revision'); + control.setAdmissionFence(false); + assert.equal(control.getActivity().scheduled, true, 'normal scheduling resumes without disabling automaticRefresh'); + } finally { release.resolve(); await control(); await rm(root, { recursive: true, force: true }); } +}); + +test('registry publication queued before a fence remains owned, while new publication waits for reopen', async () => { + const root = await mkdtemp(join(tmpdir(), 'gjc-registry-publication-')); + const entered = deferred(); const release = deferred(); let calls = 0; + const control = refreshModelPresetRegistryInBackground({ agentDir: root, startupDelayMs: 60_000 }, + async () => { calls++; entered.resolve(); await release.promise; }); + try { + control.setAdmissionFence(true); + // A public control mutation in an isolated fixture creates a real registry + // notification. No installed config or production feature is disabled. + await setModelPresetRegistryDisabled({ agentDir: root, disabled: true }); + assert.equal(calls, 0); assert.equal(control.getActivity().deferred, true); + control.setAdmissionFence(false); // admits a publication microtask + control.setAdmissionFence(true); // cannot discard that accepted task + await entered.promise; + const joined = control.awaitSettlement(); await stillPending(joined); + assert.ok(control.getActivity().pending > 0); + release.resolve(); await joined; + assert.equal(calls, 1); assert.equal(control.getActivity().pending, 0); + } finally { release.resolve(); await control(); await rm(root, { recursive: true, force: true }); } +}); + +test('borrowed registry activity is independent from session disposal and joins accepted catalog callbacks', async () => { + const f = await sessionFixture(); const release = deferred(); const entered = deferred(); + try { + const initial = f.registry.getAppLifecycleActivity(); + assert.equal(initial.complete, false); assert.ok(initial.unknown.includes('model_registry_admission_open')); + f.registry.onCatalogChanged(async () => { entered.resolve(); await release.promise; }); + f.registry.registerProvider('catalog-change-contract', { api: model.api, apiKey: 'offline', baseUrl: model.baseUrl, + models: [{ id: 'other', name: 'Other', input: ['text'], reasoning: false, + contextWindow: 10000, maxTokens: 1000, cost: model.cost }] }); + await entered.promise; + f.registry.setAppLifecycleAdmission(true); + const pending = f.registry.getAppLifecycleActivity(); + assert.ok(pending.settling > 0); + const joined = f.registry.awaitAppLifecycleSettlement(); await stillPending(joined); + // Foreground refresh can be an already accepted OAuth/run continuation. + // Top-level app admission owns new callers; the maintenance fence must + // not abort an accepted continuation just because it reaches refresh later. + await f.registry.refreshStatic(); + release.resolve(); await joined; + const idle = f.registry.getAppLifecycleActivity(); + assert.equal(idle.complete, true); + assert.equal(idle.starting + idle.queued + idle.running + idle.settling, 0); + assert.deepEqual(f.registry.getAppLifecycleActivity(), idle); + f.registry.refreshInBackground('online-if-uncached'); + assert.notEqual(f.registry.getAppLifecycleActivity().generation, idle.generation); + assert.equal(f.registry.getAppLifecycleActivity().running, 0, 'new maintenance is deferred'); + await f.session.awaitDisposeCompletion(); + assert.equal(f.registry.getAppLifecycleActivity().complete, true, 'borrowed registry is still an independent live owner'); + } finally { release.resolve(); await f.close(); } +}); + +test('opaque provider tail stays explicitly unknown after its iterator and result have settled', async () => { + const released = deferred(); let tailSettled = false; + let tail: Promise | undefined; + const agent = new Agent({ initialState: { model }, getApiKey: async () => 'offline', streamFn: () => { + const output = new AssistantMessageEventStream(); + tail = (async () => { + const message = answer(); + output.push({ type: 'done', reason: 'stop', message }); output.end(message); + await released.promise; tailSettled = true; + })(); + return output; + } }); + try { + await agent.prompt('offline'); + await lifecycle.awaitPhysicalRunResources(agent.resourceLedger); + assert.ok(tail, 'the held task was started by the actual provider factory'); + assert.equal(tailSettled, false, 'stream result/iterator completion is not an all-provider producer-lifetime API'); + const activity = lifecycle.getPhysicalRunResourceActivity(agent.resourceLedger); + assert.equal(activity.complete, false); + assert.deepEqual(activity.unknown, ['sdk_provider_producer_unrepresented']); + released.resolve(); await tail; + assert.equal(lifecycle.getPhysicalRunResourceActivity(agent.resourceLedger).complete, false, 'unrepresented work cannot clear itself'); + } finally { released.resolve(); await tail; } +}); + +for (const fails of [false, true]) { + test(`registered producer retains an early terminal through actual ${fails ? 'rejecting' : 'successful'} finally`, async () => { + const entered = deferred(); const release = deferred(); let finalized = false; + const events = new AssistantMessageEventStream(); + runAppStreamProducer(events, async () => { + assert.ok(getAppStreamProducer(events), 'reserved before invoking producer'); + try { + const message = answer(); events.push({ type: 'done', reason: 'stop', message }); + if (fails) throw new Error('ordinary producer failure'); + } finally { entered.resolve(); await release.promise; finalized = true; } + }); + await entered.promise; + assert.equal((await events.result()).stopReason, 'stop'); + const agent = new Agent({ initialState: { model }, getApiKey: async () => 'offline', streamFn: () => events }); + try { + await agent.prompt('offline'); + const joined = lifecycle.awaitPhysicalRunResources(agent.resourceLedger); + await stillPending(joined); assert.equal(finalized, false); + assert.ok(lifecycle.getPhysicalRunResourceActivity(agent.resourceLedger).running > 0); + release.resolve(); await joined; + assert.equal(finalized, true); + assert.equal(lifecycle.getPhysicalRunResourceActivity(agent.resourceLedger).complete, true); + } finally { release.resolve(); await getAppStreamProducer(events).completion; } + }); +} + +test('structurally forged completion and a terminal-only class instance confer no producer authority', () => { + const events = stream(); + events.getAppStreamProducer = () => ({ completion: Promise.resolve(), getUnknown: () => [] }); + events.producerCompletion = Promise.resolve(); + assert.equal(getAppStreamProducer(events), undefined); + assert.equal(getAppStreamProducer({ completion: Promise.resolve() }), undefined); +}); + +test('wrapper completion joins nested producers and preserves missing child coverage', async () => { + const release = deferred(); const inner = new AssistantMessageEventStream(); const outer = new AssistantMessageEventStream(); + runAppStreamProducer(inner, async () => { try { inner.end(answer()); } finally { await release.promise; } }); + runAppStreamProducer(outer, async () => { + adoptAppStreamProducer(outer, inner); + adoptAppStreamProducer(outer, stream()); + outer.end(answer()); + }); + try { + await outer.result(); await stillPending(getAppStreamProducer(outer).completion); + release.resolve(); await getAppStreamProducer(outer).completion; + assert.deepEqual(getAppStreamProducer(outer).getUnknown(), ['sdk_provider_producer_unrepresented']); + } finally { release.resolve(); } +}); + +// An actual built-in transport publishes the terminal event before draining the +// response body. The injected fetch never makes a network request. +function heldNativeTransport() { + let body!: ReadableStreamDefaultController; + const transportModel = { ...model, transport: 'pi-native' }; + const response = new Response(new ReadableStream({ start(controller) { body = controller; } }), + { headers: { 'content-type': 'text/event-stream' } }); + const make = () => streamPiNative(transportModel, { messages: [] }, { fetch: async () => response }); + body.enqueue(new TextEncoder().encode(`data: ${JSON.stringify({ type: 'done', reason: 'stop', message: answer() })}\n\n`)); + return { make, release: () => body.close() }; +} + +test('real pi-native built-in retains terminal-before-EOF through core physical join', async () => { + const transport = heldNativeTransport(); const events = transport.make(); + const agent = new Agent({ initialState: { model }, getApiKey: async () => 'offline', streamFn: () => events }); + try { + await events.result(); await agent.prompt('offline'); + const joined = lifecycle.awaitPhysicalRunResources(agent.resourceLedger); + await stillPending(joined); transport.release(); await joined; + assert.equal(lifecycle.getPhysicalRunResourceActivity(agent.resourceLedger).complete, true); + } finally { if (lifecycle.getPhysicalRunResourceActivity(agent.resourceLedger).running) transport.release(); } +}); + +test('real built-in producer survives lazy import and a real SDK session disposal deadline', async () => { + const f = await sessionFixture(); const transport = heldNativeTransport(); const source = 'app-physical-native'; + registerCustomApi(model.api, () => streamFromLazyImport(async () => transport.make()), source); + let released = false; + try { + await f.session.prompt('offline terminal-before-tail'); + const joined = f.session.awaitDisposeCompletion(); + await assert.rejects(f.session.dispose(), (error: unknown) => error instanceof SessionDisposalIncompleteError); + await stillPending(joined); + assert.ok(f.session.getAppLifecycleActivity().running > 0); + transport.release(); released = true; await joined; + const activity = f.session.getAppLifecycleActivity(); + assert.equal(activity.running + activity.starting + activity.settling, 0); + assert.equal(activity.unknown.includes('sdk_provider_producer_unrepresented'), false); + assert.deepEqual(activity.unknown, []); + assert.equal(activity.complete, true, 'the enabled default host and built-in producer now have physical owners'); + } finally { if (!released) transport.release(); unregisterCustomApis(source); await f.close(); } +}); + +test('normal built-in lazy dispatch exposes a retained completion without provider requests', async () => { + const events = builtins.streamOllama({ ...model, provider: 'ollama', api: 'ollama-chat' }, { messages: [] }, { + fetch: async () => new Response('{"message":{"role":"assistant","content":"offline"},"done":true}\n'), + }); + for await (const _event of events) { /* exercise the wrapper consumer */ } + const owner = getAppStreamProducer(events); assert.ok(owner); + await owner.completion; assert.deepEqual(owner.getUnknown(), []); +}); + +test('forceAbort cannot discharge a late factory response with a real built-in producer tail', async () => { + const transport = heldNativeTransport(); const events = transport.make(); + const entered = deferred(); const factory = deferred(); let released = false; + const agent = new Agent({ initialState: { model }, getApiKey: async () => 'offline', streamFn: async () => { + entered.resolve(); await factory.promise; return events; + } }); + const prompt = agent.prompt('offline late factory'); + try { + await entered.promise; agent.forceAbort(); await agent.waitForIdle(); + const joined = lifecycle.awaitPhysicalRunResources(agent.resourceLedger); + await stillPending(joined); factory.resolve(); await prompt; + await stillPending(joined); transport.release(); released = true; await joined; + assert.equal(lifecycle.getPhysicalRunResourceActivity(agent.resourceLedger).complete, true); + } finally { factory.resolve(); if (!released) transport.release(); await prompt; } +}); + +test('failed SDK factory joins its already-started workspace discovery before rejecting', async () => { + const root = await mkdtemp(join(tmpdir(), 'gjc-factory-discovery-')); + const cwd = join(root, 'project'); const agentDir = join(root, 'agent'); await mkdir(cwd); + const authStorage = await discoverAuthStorage(agentDir); + const settings = await Settings.loadForScope({ cwd, agentDir }); + settings.override('workspaceTree.mode', 'eager'); settings.override('startup.networkPrewarm', false); + const registry = new ModelRegistry(authStorage, join(agentDir, 'models.yml'), settings, { agentDir }); + const entered = deferred(); const failReached = deferred(); const release = deferred(); let started = false; + const facade = new Proxy(settings, { get(target, property) { + if (property === 'get') return (key: string) => { + if (started && key === 'providers.webSearch') { failReached.resolve(); throw new Error('offline creation failure after discovery'); } + return target.get(key); + }; + const value = Reflect.get(target, property, target); + return typeof value === 'function' ? value.bind(target) : value; + } }); + const pending = createAgentSession({ cwd, agentDir, settings: facade, authStorage, modelRegistry: registry, + contextFiles: [], promptTemplates: [], slashCommands: [], + runtimeServices: { workspaceTree: { get: async () => { + started = true; entered.resolve(); await release.promise; + return { snapshot: { rootPath: cwd, rendered: '', truncated: false, totalLines: 0, agentsMdFiles: [] } }; + } } }, + }); + void pending.catch(() => {}); + try { + await entered.promise; await failReached.promise; await stillPending(pending); + release.resolve(); await assert.rejects(pending, /offline creation failure after discovery/); + } finally { + release.resolve(); await pending.catch(() => {}); await registry.dispose(); authStorage.close(); await settings.close(); + await rm(root, { recursive: true, force: true }); + } +}); + +function memorySdkTransport(send: () => Promise = async () => {}) { + let handler: ((id: string, frame: Record) => void) | undefined; + return { + sessionId: 'physical-host', stateRoot: '/tmp/unused-physical-host', token: 'offline-only', + onFrame(next: typeof handler) { handler = next; return () => { handler = undefined; }; }, + sendFrame: send, start: async () => ({ url: 'ws://127.0.0.1:1' }), stop: async () => {}, + awaitAppLifecycleSettlement: async () => {}, + feed(frame: Record) { handler?.('local-fixture', frame); }, + }; +} + +test('host physical owner keeps dispatched control, response delivery and its callback after logical stop', async () => { + const work = deferred(); const workEntered = deferred(); const write = deferred(); const writeEntered = deferred(); + const publication = deferred(); const publicationEntered = deferred(); + const transport = memorySdkTransport(async () => { writeEntered.resolve(); await write.promise; }); + const runtime = new SessionSdkSessionRuntime({ transport, + control: async () => { workEntered.resolve(); await work.promise; return { ok: true }; }, + onControlResponseDelivery: async () => { publicationEntered.resolve(); await publication.promise; }, + }); + try { + await runtime.start(); transport.feed({ type: 'control_request', id: 'held-control', operation: 'fixture' }); + await workEntered.promise; await runtime.stop(); + const joined = runtime.awaitAppLifecycleSettlement(); await stillPending(joined); + work.resolve(); await writeEntered.promise; await stillPending(joined); + write.resolve(); await publicationEntered.promise; await stillPending(joined); + publication.resolve(); await joined; + assert.equal(runtime.appLifecycleOwner.getAppLifecycleActivity().running, 0); + } finally { work.resolve(); write.resolve(); publication.resolve(); await runtime.stop(); await runtime.awaitAppLifecycleSettlement(); } +}); + +test('host directed deliveries are session-local and cannot escape through sendFrameTo', async () => { + const release = deferred(); const entered = deferred(); + const a = new SessionSdkSessionRuntime({ transport: memorySdkTransport(async () => { entered.resolve(); await release.promise; }) }); + const b = new SessionSdkSessionRuntime({ transport: memorySdkTransport() }); + try { + await a.start(); await b.start(); a.sendFrameTo(['fixture'], { type: 'message_update' }); await entered.promise; + await a.stop(); await b.stop(); await b.awaitAppLifecycleSettlement(); + const joined = a.awaitAppLifecycleSettlement(); await stillPending(joined); + release.resolve(); await joined; + } finally { release.resolve(); await a.stop(); await b.stop(); await a.awaitAppLifecycleSettlement(); } +}); + +for (const fails of [false, true]) { + test(`real WebSocket transport retains its 250ms shutdown loser through ${fails ? 'late rejection' : 'completion'}`, async () => { + const root = await mkdtemp(join(tmpdir(), 'gjc-owned-websocket-')); + const release = deferred(); const entered = deferred(); let actualServer: ReturnType | undefined; + const transport = await createSdkWebSocketTransport({ sessionId: 'held-stop', stateRoot: root, token: 'offline-only', + serve(options: Parameters[0]) { + const server = Bun.serve(options); actualServer = server; + return new Proxy(server, { get(target, property) { + if (property === 'stop') return async () => { + entered.resolve(); await release.promise; await target.stop(true); + if (fails) throw new Error('late physical server stop failure'); + }; + const value = Reflect.get(target, property, target); + return typeof value === 'function' ? value.bind(target) : value; + } }); + }, + }); + try { + const endpoint = await transport.start(); assert.match(endpoint.url, /^ws:\/\/127\.0\.0\.1:/); + const stopped = transport.stop(); await entered.promise; await stopped; + const joined = transport.awaitAppLifecycleSettlement(); void joined.catch(() => {}); + await stillPending(joined); release.resolve(); + if (fails) await assert.rejects(joined, /late physical server stop failure/); + else await joined; + await assert.rejects(stat(join(root, 'sdk/held-stop.json')), { code: 'ENOENT' }); + } finally { release.resolve(); await transport.stop().catch(() => {}); await actualServer?.stop(true); await rm(root, { recursive: true, force: true }); } + }); +} + +test('deadline logical clear does not discharge an in-flight reconciliation write', async () => { + const entered = deferred(); const release = deferred(); const owner = new AppSdkHostOwner(); + const manager = new PromptDeadlineManager({ appLifecycleOwner: owner, getLeaseMs: () => 1, getMaxMs: () => 1, + reconciliation: { lookup: () => ({ status: 'accepted' }), + claimPendingOutcome: async () => { entered.resolve(); await release.promise; }, finalizeOutcome: async () => {}, + }, + }); + try { + manager.onAccepted({ commandId: 'physical-deadline', turnId: 'one' }); await entered.promise; + manager.clearAll(); const joined = owner.awaitAppLifecycleSettlement(); await stillPending(joined); + release.resolve(); await joined; + const activity = owner.getAppLifecycleActivity(); assert.equal(activity.queued + activity.running, 0); + } finally { manager.clearAll(); release.resolve(); await owner.awaitAppLifecycleSettlement(); } +}); + +test('retired-owner timer cancellation releases only the future callback, not accepted async work', async () => { + const owner = new AppSdkHostOwner(); const entered = deferred(); const release = deferred(); + let dispatched = false; + const future = owner.schedule(() => { dispatched = true; }, 60_000); + const active = owner.schedule(async () => { entered.resolve(); await release.promise; }, 0); + try { + await entered.promise; owner.cancelTimer(future); owner.cancelTimer(active); + const joined = owner.awaitAppLifecycleSettlement(); await stillPending(joined); + assert.equal(dispatched, false); release.resolve(); await joined; + assert.equal(owner.getAppLifecycleActivity().running, 0); + } finally { owner.cancelTimer(future); release.resolve(); } +}); + +test('actual hosted lifecycle drain retains retired-owner timers and persistence after bounded shutdown', async () => { + const root = await mkdtemp(join(tmpdir(), 'gjc-host-retired-')); + const broker = new Broker({ agentDir: root }); + const handlers = new Map Promise>(); + const submitted = deferred(); const accepted = deferred(); const writeEntered = deferred(); const writeRelease = deferred(); + const drainTimeout = deferred(); let holdWrites = false; let owner!: InstanceType; + const transport = memorySdkTransport(); + const store = createReconciliationStore({ sessionFile: join(root, 'session.jsonl'), sessionId: transport.sessionId }); + const heldStore = new Proxy(store, { get(target, property) { + if (property === 'transact') return async (...args: unknown[]) => { + if (holdWrites) { writeEntered.resolve(); await writeRelease.promise; } + return target.transact(...args); + }; + const value = Reflect.get(target, property, target); return typeof value === 'function' ? value.bind(target) : value; + } }); + createSdkSessionRuntimeExtension({ + on(name: string, callback: (event: unknown, ctx: unknown) => Promise) { handlers.set(name, callback); }, + sendUserMessage: async (_text: unknown, options: { onPreflightAcceptCommit?: () => Promise }) => { + await options.onPreflightAcceptCommit?.(); accepted.resolve(); await submitted.promise; return 'finished'; + }, + }, { agentDir: root, createTransport: async () => transport, registerAppLifecycleOwner: (value: typeof owner) => { owner = value; }, + onLifecycleDrainTimeoutForTests: () => drainTimeout.resolve(), + terminalAbortSeams: { getReconciliationStore: () => heldStore }, + }); + const ctx = { cwd: root, sdkBindings: () => [], isIdle: () => true, abort: () => {}, + sessionManager: { getSessionId: () => transport.sessionId, getSessionFile: () => join(root, 'session.jsonl'), + getSessionName: () => undefined, getBranch: () => [] }, + }; + let end: Promise | undefined; + try { + await broker.start(); await handlers.get('session_start')!({}, ctx); + transport.feed({ type: 'control_request', id: 'accepted-physical', operation: 'turn.prompt', input: { text: 'offline' } }); + await accepted.promise; await handlers.get('agent_start')!({ type: 'agent_start' }, ctx); + holdWrites = true; + end = handlers.get('agent_end')!({ type: 'agent_end', messages: [], stopReason: 'stop' }, ctx); + await writeEntered.promise; + const shutdown = handlers.get('session_shutdown')!({}, ctx); + await drainTimeout.promise; await shutdown; + assert.ok(owner.getAppLifecycleActivity().queued > 0, 'the actual retired owner cleanup timer is reserved'); + const joined = owner.awaitAppLifecycleSettlement(); await stillPending(joined); + holdWrites = false; writeRelease.resolve(); await end; await stillPending(joined); + submitted.resolve(); await joined; + const activity = owner.getAppLifecycleActivity(); assert.equal(activity.running + activity.queued, 0); + assert.deepEqual(activity.unknown, []); + } finally { + holdWrites = false; writeRelease.resolve(); submitted.resolve(); await end; + await handlers.get('session_shutdown')?.({}, ctx); await owner?.awaitAppLifecycleSettlement(); + await broker.stop(); await rm(root, { recursive: true, force: true }); + } +}); + +test('a real SDK session with its default WebSocket host actively enabled reaches physical completion', async () => { + assert.notEqual(process.env.GJC_SDK_DISABLE, '1', 'this test must exercise enabled hosting'); + const f = await sessionFixture(); const source = 'app-host-enabled'; + // Pre-start an in-process broker in this isolated root. ensureBroker can reuse + // it without spawning or signalling a detached process. + const broker = new Broker({ agentDir: f.settings.getAgentDir() }); + const endpointFile = join(f.settings.getCwd(), '.gjc/state/sdk', `${f.session.sessionManager.getSessionId()}.json`); + registerCustomApi(model.api, () => { + const events = new AssistantMessageEventStream(); + runAppStreamProducer(events, async () => { events.push({ type: 'done', reason: 'stop', message: answer() }); }); + return events; + }, source); + try { + await broker.start(); await f.session.extensionRunner.emit({ type: 'session_start' }); + const endpoint = JSON.parse(await readFile(endpointFile, 'utf8')); + assert.match(endpoint.url, /^ws:\/\/127\.0\.0\.1:/); + await f.session.prompt('offline with live default host'); + await f.session.awaitDisposeCompletion(); + await assert.rejects(stat(endpointFile), { code: 'ENOENT' }); + const activity = f.session.getAppLifecycleActivity(); + assert.equal(activity.starting + activity.running + activity.settling, 0); + assert.equal(activity.complete, true); assert.deepEqual(activity.unknown, []); + } finally { unregisterCustomApis(source); await f.session.awaitDisposeCompletion().catch(() => {}); await broker.stop(); await f.close(); } +}); + +for (const cancel of [false, true]) { + test(`producer owns idle-iterator ${cancel ? 'abort' : 'timeout'} losers through their physical settlement`, async () => { + const next = deferred>(); const returned = deferred>(); + const started = deferred(); const controller = new AbortController(); const events = new AssistantMessageEventStream(); + runAppStreamProducer(events, async () => { + try { + const input = { [Symbol.asyncIterator]() { return { + next: () => { started.resolve(); return next.promise; }, return: () => returned.promise, + }; } }; + for await (const _value of iterateWithIdleTimeout(input, { + idleTimeoutMs: cancel ? 60_000 : 5, abortSignal: controller.signal, errorMessage: 'held iterator', + })) { /* no value until the held read settles */ } + } catch { events.push({ type: 'done', reason: 'stop', message: answer() }); } + }); + try { + await started.promise; if (cancel) controller.abort(); + await events.result(); const owner = getAppStreamProducer(events); + await stillPending(owner.completion); + next.resolve({ done: true, value: undefined }); await stillPending(owner.completion); + returned.resolve({ done: true, value: undefined }); await owner.completion; + assert.deepEqual(owner.getUnknown(), []); + } finally { next.resolve({ done: true, value: undefined }); returned.resolve({ done: true, value: undefined }); } + }); +} + +test('auth retry wrapper retains the first attempt tail even when a later attempt is terminal', async () => { + const release = deferred(); const source = 'app-auth-retry-physical'; let attempts = 0; + registerCustomApi(model.api, () => { + const events = new AssistantMessageEventStream(); const first = attempts++ === 0; + runAppStreamProducer(events, async () => { + try { + if (first) { + const error = { ...answer([], 'error'), errorStatus: 401, errorMessage: 'Unauthorized' }; + events.push({ type: 'error', reason: 'error', error }); + } else events.push({ type: 'done', reason: 'stop', message: answer() }); + } finally { if (first) await release.promise; } + }); + return events; + }, source); + try { + const events = streamSimple(model, { messages: [] }, { apiKey: 'offline-first', onAuthError: async () => 'offline-second' }); + await events.result(); assert.equal(attempts, 2); + const owner = getAppStreamProducer(events); assert.ok(owner); + await stillPending(owner.completion); release.resolve(); await owner.completion; + assert.deepEqual(owner.getUnknown(), []); + } finally { release.resolve(); unregisterCustomApis(source); } +}); + +async function authFixture(options: Record = {}) { + const root = await mkdtemp(join(tmpdir(), 'gjc-auth-leaf-')); + const storage = await AuthStorage.create(join(root, 'credentials.db'), { usageProviderResolver: () => undefined, ...options }); + return { storage, async close() { + await storage.awaitAppLifecycleSettlement(); storage.close(); await rm(root, { recursive: true, force: true }); + } }; +} + +for (const scoped of [false, true]) { + test(`AuthStorage ${scoped ? 'scoped' : 'shared'} usage timeout retains its actual loser and admits continuations`, async () => { + const entered = deferred(); const release = deferred(); let calls = 0; + const fetchUsage = async () => { calls++; entered.resolve(); await release.promise; return []; }; + const f = await authFixture(scoped ? { fetchUsageReportsForProvider: fetchUsage } : { fetchUsageReports: fetchUsage }); + try { + const before = f.storage.getAppLifecycleActivity(); + assert.equal(before.complete, true); + const input = scoped ? { provider: 'openai-codex' } : {}; + const request = f.storage.fetchUsageReports({ ...input, signal: AbortSignal.timeout(10) }); + const timedOut = assert.rejects(request, /usage fetch aborted/); + await entered.promise; await timedOut; + const losing = f.storage.getAppLifecycleActivity(); + assert.ok(losing.running > 0); assert.notEqual(losing.generation, before.generation); + const joined = f.storage.awaitAppLifecycleSettlement(); await stillPending(joined); + // No leaf fence may reject a dependency of an accepted registry/OAuth root. + const continuation = f.storage.fetchUsageReports(input); + if (!scoped) assert.equal(calls, 1, 'the original shared single-flight is retained'); + release.resolve(); await continuation; await joined; + const idle = f.storage.getAppLifecycleActivity(); + assert.equal(idle.running + idle.settling, 0); assert.equal(idle.complete, true); + assert.notEqual(idle.generation, losing.generation); + assert.deepEqual(f.storage.getAppLifecycleActivity(), idle); + } finally { release.resolve(); await f.close(); } + }); +} + +test('AuthStorage OAuth caller cancellation retains the underlying refresh and close does not erase it', async () => { + const entered = deferred(); const release = deferred(); + const f = await authFixture({ refreshOAuthCredential: async () => { + entered.resolve(); await release.promise; + return { access: 'new-offline-access', refresh: 'new-offline-refresh', expires: Date.now() + 3600000 }; + } }); + try { + await f.storage.set('openai-codex', { type: 'oauth', access: 'offline-access', refresh: 'offline-refresh', expires: 0 }); + const id = f.storage.exportSnapshot().credentials[0].id; + const controller = new AbortController(); + const request = f.storage.refreshCredentialById(id, controller.signal); + const cancelled = assert.rejects(request, /abort/); + await entered.promise; controller.abort(); await cancelled; + const pending = f.storage.getAppLifecycleActivity(); assert.ok(pending.running > 0); + const joined = f.storage.awaitAppLifecycleSettlement(); await stillPending(joined); + f.storage.close(); + assert.ok(f.storage.getAppLifecycleActivity().running > 0, 'close remains synchronous but cannot manufacture idle'); + await stillPending(joined); release.resolve(); await joined; + assert.equal(f.storage.getAppLifecycleActivity().running, 0); + } finally { release.resolve(); await f.close(); } +}); + +test('AuthStorage keeps a per-credential provider task after real caller and provider deadlines fire', async () => { + const entered = deferred(); const release = deferred(); let providerSignal: AbortSignal | undefined; + const f = await authFixture({ usageRequestTimeoutMs: 15, + usageProviderResolver: () => ({ supports: () => true, fetchUsage: async (params: { signal?: AbortSignal }) => { + providerSignal = params.signal; entered.resolve(); await release.promise; return null; + } }) }); + try { + await f.storage.set('leaf-usage-provider', { type: 'api_key', key: 'offline-usage-key' }); + const request = f.storage.fetchUsageReports({ signal: AbortSignal.timeout(10), logDetails: false }); + const timedOut = assert.rejects(request, /usage fetch aborted/); + await entered.promise; await timedOut; + await new Promise((resolve) => setTimeout(resolve, 20)); + assert.equal(providerSignal?.aborted, true); + assert.ok(f.storage.getAppLifecycleActivity().running > 0); + const joined = f.storage.awaitAppLifecycleSettlement(); await stillPending(joined); + release.resolve(); await joined; + assert.equal(f.storage.getAppLifecycleActivity().running, 0); + } finally { release.resolve(); await f.close(); } +}); + +test('AuthStorage reserves ownership before calling a held config-value resolver', async () => { + const entered = deferred(); const release = deferred(); + let inside = 0; + const f = await authFixture({ configValueResolver: async () => { + inside = f.storage.getAppLifecycleActivity().running; + entered.resolve(); await release.promise; return 'offline-resolved-key'; + } }); + try { + await f.storage.set('leaf-config-provider', { type: 'api_key', key: '!offline-fixture' }); + const request = f.storage.getApiKey('leaf-config-provider'); + await entered.promise; assert.ok(inside > 0); + const joined = f.storage.awaitAppLifecycleSettlement(); await stillPending(joined); + release.resolve(); assert.equal(await request, 'offline-resolved-key'); await joined; + assert.equal(f.storage.getAppLifecycleActivity().running, 0); + } finally { release.resolve(); await f.close(); } +}); + +test('AuthStorage retains credential-disabled callback completion independently of its caller', async () => { + const entered = deferred(); const release = deferred(); + const f = await authFixture({ + refreshOAuthCredential: async () => { throw new Error('invalid_grant'); }, + onCredentialDisabled: async () => { entered.resolve(); await release.promise; }, + }); + try { + await f.storage.set('openai-codex', { type: 'oauth', access: 'offline-access', refresh: 'offline-refresh', expires: 0 }); + await f.storage.getApiKey('openai-codex').catch(() => undefined); + await entered.promise; + assert.ok(f.storage.getAppLifecycleActivity().settling > 0); + const joined = f.storage.awaitAppLifecycleSettlement(); await stillPending(joined); + release.resolve(); await joined; + assert.equal(f.storage.getAppLifecycleActivity().settling, 0); + } finally { release.resolve(); await f.close(); } +}); + +test('Settings leaf counts reserve the real debounce and remain owned behind a real file lock', async () => { + const root = await mkdtemp(join(tmpdir(), 'gjc-settings-leaf-')); + const settings = await Settings.loadForScope({ cwd: root, agentDir: root }); + const locked = deferred(); const release = deferred(); + const lock = withFileLock(join(root, 'config.yml'), async () => { locked.resolve(); await release.promise; }); + try { + await locked.promise; + const before = settings.getAppLifecycleActivity(); + settings.set('defaultThinkingLevel', 'high'); + const queued = settings.getAppLifecycleActivity(); assert.ok(queued.queued > 0); assert.ok(queued.running > 0); + assert.notEqual(queued.generation, before.generation); + const joined = settings.awaitAppLifecycleSettlement(); + assert.deepEqual(settings.getAppLifecycleActivity(), queued, 'joining does not flush or release the debounce'); + await new Promise((resolve) => setTimeout(resolve, 130)); + assert.ok(settings.getAppLifecycleActivity().running > 0); await stillPending(joined); + settings.set('defaultThinkingLevel', 'medium'); + assert.ok(settings.getAppLifecycleActivity().running >= 2, 'an older save is not forgotten when savePromise is replaced'); + release.resolve(); await lock; await joined; + const idle = settings.getAppLifecycleActivity(); + assert.equal(idle.complete, true); assert.equal(idle.queued + idle.running, 0); + assert.notEqual(idle.generation, queued.generation); assert.deepEqual(settings.getAppLifecycleActivity(), idle); + const reloaded = await Settings.loadForScope({ cwd: root, agentDir: root }); + try { assert.equal(reloaded.get('defaultThinkingLevel'), 'medium'); } + finally { await reloaded.close(); } + } finally { release.resolve(); await lock; await settings.close(); await rm(root, { recursive: true, force: true }); } +}); + +test('Settings failed background persistence remains unknown after its actual save promise settles', async () => { + const root = await mkdtemp(join(tmpdir(), 'gjc-settings-failed-leaf-')); + const settings = await Settings.loadForScope({ cwd: root, agentDir: root }); + const file = join(root, 'config.yml'); const backup = join(root, 'saved-config.yml'); + let replaced = false; + try { + settings.set('defaultThinkingLevel', 'low'); await settings.flushOrThrow(); await settings.awaitAppLifecycleSettlement(); + await rename(file, backup); await mkdir(file); replaced = true; + settings.set('defaultThinkingLevel', 'high'); + await assert.rejects(settings.awaitAppLifecycleSettlement(), /persistence is unconfirmed/); + const failed = settings.getAppLifecycleActivity(); + assert.equal(failed.complete, false); assert.ok(failed.unknown.includes('settings_persistence_unconfirmed')); + assert.deepEqual(settings.getAppLifecycleActivity(), failed, 'reads cannot clear failed persistence'); + } finally { + if (replaced) { await rm(file, { recursive: true, force: true }); await rename(backup, file); } + await settings.close().catch(() => {}); await rm(root, { recursive: true, force: true }); + } +}); diff --git a/patches/gjc-sdk-lifecycle/manifest.json b/patches/gjc-sdk-lifecycle/manifest.json new file mode 100644 index 00000000..95fa5a9e --- /dev/null +++ b/patches/gjc-sdk-lifecycle/manifest.json @@ -0,0 +1,1031 @@ +{ + "schemaVersion": 1, + "id": "gjc-sdk-lifecycle-v1", + "packages": { + "@gajae-code/coding-agent": "0.16.4", + "@gajae-code/agent-core": "0.16.4", + "@gajae-code/ai": "0.16.4" + }, + "files": [ + { + "package": "@gajae-code/coding-agent", + "path": "src/sdk/session.ts", + "beforeSha256": "cd7856280e70f466a4d88113d5b96c5175f0b37c55a3c157bb12d40c2ef09b5e", + "afterSha256": "6bc35899fa1386ab4887537eb0bdd068289291d588b0ce4cd1f1660d4075841c", + "replacements": [ + { + "before": "\tlet session!: AgentSession;\n\tlet sessionManager!: SessionManager;\n\tlet hasSession = false;\n\tlet processCwdClaimed = false;\n\tlet hasRegistered = false;\n\tlet asyncJobManager: AsyncJobManager | undefined;\n", + "after": "\tlet session!: AgentSession;\n\tlet sessionManager!: SessionManager;\n\tlet hasSession = false;\n\tconst startupTasks = new Set>();\n\tconst startOwnedStartupTask = (operation: () => Promise): void => {\n\t\tconst owner = Promise.withResolvers();\n\t\tstartupTasks.add(owner.promise);\n\t\tvoid Promise.resolve().then(operation).finally(() => {\n\t\t\tstartupTasks.delete(owner.promise);\n\t\t\towner.resolve();\n\t\t}).catch(() => {}); // Existing startup probes remain best-effort.\n\t};\n\tconst joinStartupTasks = async (): Promise => {\n\t\twhile (startupTasks.size > 0) await Promise.allSettled([...startupTasks]);\n\t};\n\tlet processCwdClaimed = false;\n\tlet hasRegistered = false;\n\tlet asyncJobManager: AsyncJobManager | undefined;\n" + }, + { + "before": "\t\t\t// Keep the legacy startup trigger for the model-host preconnect. The\n\t\t\t// runtime service preserves the best-effort fetch.preconnect contract while\n\t\t\t// allowing startup.networkPrewarm=false to skip the call entirely.\n\t\t\tvoid runtimeServices.networkPrewarm\n\t\t\t\t.get(\"legacy-startup\")\n\t\t\t\t.then(prewarm => prewarm.preconnect(resolvedModel.baseUrl))\n\t\t\t\t.catch(error => {\n\t\t\t\t\tlogger.warn(\"Model-host prewarm service failed\", {\n\t\t\t\t\t\terror: error instanceof Error ? error.message : String(error),\n\t\t\t\t\t});\n\t\t\t\t});\n\t\t}\n\n\t\tlet skills: Skill[];\n", + "after": "\t\t\t// Keep the legacy startup trigger for the model-host preconnect. The\n\t\t\t// runtime service preserves the best-effort fetch.preconnect contract while\n\t\t\t// allowing startup.networkPrewarm=false to skip the call entirely.\n\t\t\tstartOwnedStartupTask(async () => { await runtimeServices.networkPrewarm\n\t\t\t\t.get(\"legacy-startup\")\n\t\t\t\t.then(prewarm => prewarm.preconnect(resolvedModel.baseUrl))\n\t\t\t\t.catch(error => {\n\t\t\t\t\tlogger.warn(\"Model-host prewarm service failed\", {\n\t\t\t\t\t\terror: error instanceof Error ? error.message : String(error),\n\t\t\t\t\t});\n\t\t\t\t}); });\n\t\t}\n\n\t\tlet skills: Skill[];\n" + }, + { + "before": "\t\t\tthinkingLevel,\n\t\t\tsessionManager,\n\t\t\tsettings,\n\t\t\t// The session's REQUESTED agent directory when the caller explicitly\n\t\t\t// supplied it, independent of the reused global Settings singleton\n\t\t\t// (which may belong to an earlier session). When the option is\n", + "after": "\t\t\tthinkingLevel,\n\t\t\tsessionManager,\n\t\t\tsettings,\n\t\t\tflushBorrowedSettingsOnDispose: !ownsScopedSettings,\n\t\t\t// The session's REQUESTED agent directory when the caller explicitly\n\t\t\t// supplied it, independent of the reused global Settings singleton\n\t\t\t// (which may belong to an earlier session). When the option is\n" + }, + { + "before": "\t\t// carried by the host replay ring through the internal runtime seam above.\n\t\tif (autoroutingInactive) session.configWarnings.push(AUTOROUTING_INACTIVE_WARNING);\n\t\thasSession = true;\n\t\tif (masterModeContext) {\n\t\t\t// One scoped, no-probe peer snapshot immediately before the FIRST accepted\n\t\t\t// provider request; see createMasterPeerSnapshotContributor for the\n", + "after": "\t\t// carried by the host replay ring through the internal runtime seam above.\n\t\tif (autoroutingInactive) session.configWarnings.push(AUTOROUTING_INACTIVE_WARNING);\n\t\thasSession = true;\n\t\t// Drain startup before credential/provider resources are released. Do not\n\t\t// race this with finishCleanup's independent resource-close callbacks.\n\t\tsession.registerToolSessionTransitionCleanup(joinStartupTasks);\n\t\tif (masterModeContext) {\n\t\t\t// One scoped, no-probe peer snapshot immediately before the FIRST accepted\n\t\t\t// provider request; see createMasterPeerSnapshotContributor for the\n" + }, + { + "before": "\t\t\t\tproviderSessionState: session.providerSessionState,\n\t\t\t});\n\t\t\tif (codexTransport.websocketPreferred) {\n\t\t\t\tvoid (async () => {\n\t\t\t\t\ttry {\n\t\t\t\t\t\tconst codexPrewarmApiKey = await modelRegistry.getApiKey(codexModel, credentialSessionId);\n\t\t\t\t\t\tif (!codexPrewarmApiKey) return;\n", + "after": "\t\t\t\tproviderSessionState: session.providerSessionState,\n\t\t\t});\n\t\t\tif (codexTransport.websocketPreferred) {\n\t\t\t\tstartOwnedStartupTask(async () => {\n\t\t\t\t\ttry {\n\t\t\t\t\t\tconst codexPrewarmApiKey = await modelRegistry.getApiKey(codexModel, credentialSessionId);\n\t\t\t\t\t\tif (!codexPrewarmApiKey) return;\n" + }, + { + "before": "\t\t\t\t\t\t\tmodel: codexModel.id,\n\t\t\t\t\t\t});\n\t\t\t\t\t}\n\t\t\t\t})();\n\t\t\t}\n\t\t}\n\n", + "after": "\t\t\t\t\t\t\tmodel: codexModel.id,\n\t\t\t\t\t\t});\n\t\t\t\t\t}\n\t\t\t\t});\n\t\t\t}\n\t\t}\n\n" + }, + { + "before": "\t\treleaseCredentialDisabledSubscription();\n\t\tlet cleanupDiagnostic: unknown;\n\t\ttry {\n\t\t\tif (hasSession) {\n\t\t\t\ttry {\n\t\t\t\t\tawait session.dispose();\n", + "after": "\t\treleaseCredentialDisabledSubscription();\n\t\tlet cleanupDiagnostic: unknown;\n\t\ttry {\n\t\t\tawait joinStartupTasks();\n\t\t\tif (hasSession) {\n\t\t\t\ttry {\n\t\t\t\t\tawait session.dispose();\n" + }, + { + "before": "\t\t\t\t\tif (AsyncJobManager.instance() === asyncJobManager) {\n\t\t\t\t\t\tAsyncJobManager.setInstance(priorAsyncJobManager);\n\t\t\t\t\t}\n\t\t\t\t\tawait asyncJobManager.dispose({ timeoutMs: 100 });\n\t\t\t\t}\n\t\t\t\tawait cleanupOwnedMcpManager?.();\n\t\t\t\tconst [{ disposeKernelSessionsByOwner }, { disposeVmContextsByOwner }] = await Promise.all([\n", + "after": "\t\t\t\t\tif (AsyncJobManager.instance() === asyncJobManager) {\n\t\t\t\t\t\tAsyncJobManager.setInstance(priorAsyncJobManager);\n\t\t\t\t\t}\n\t\t\t\t\tif (!(await asyncJobManager.dispose({ timeoutMs: 100 })))\n\t\t\t\t\t\tawait asyncJobManager.awaitRetainedDisposalCompletion();\n\t\t\t\t}\n\t\t\t\tawait cleanupOwnedMcpManager?.();\n\t\t\t\tconst [{ disposeKernelSessionsByOwner }, { disposeVmContextsByOwner }] = await Promise.all([\n" + }, + { + "before": "\tlet agent: Agent;\n\tlet sessionAgent: Agent | undefined;\n\tlet session!: AgentSession;\n\tlet sessionManager!: SessionManager;\n\tlet hasSession = false;\n\tconst startupTasks = new Set>();\n\tconst startOwnedStartupTask = (operation: () => Promise): void => {\n\t\tconst owner = Promise.withResolvers();\n\t\tstartupTasks.add(owner.promise);\n\t\tvoid Promise.resolve().then(operation).finally(() => {\n\t\t\tstartupTasks.delete(owner.promise);\n\t\t\towner.resolve();\n\t\t}).catch(() => {}); // Existing startup probes remain best-effort.\n\t};\n\tconst joinStartupTasks = async (): Promise => {\n\t\twhile (startupTasks.size > 0) await Promise.allSettled([...startupTasks]);\n\t};\n\tlet processCwdClaimed = false;\n\tlet hasRegistered = false;\n\tlet asyncJobManager: AsyncJobManager | undefined;\n", + "after": "\tlet agent: Agent;\n\tlet sessionAgent: Agent | undefined;\n\tlet session!: AgentSession;\n\tlet sessionManager!: SessionManager;\n\tlet hasSession = false;\n\tconst startupTasks = new Set>();\n\tlet appFactoryRevision = 0;\n\tconst appFactoryUnknown = new Set();\n\tconst appHostOwners = new Set<{\n\t\tgetAppLifecycleActivity(): { generation: string; complete: boolean; starting: number; queued: number; running: number; settling: number; unknown: string[] };\n\t\tawaitAppLifecycleSettlement(): Promise;\n\t}>();\n\tconst joinAppHosts = async () => { for (const owner of appHostOwners) await owner.awaitAppLifecycleSettlement(); };\n\tconst ownFactoryTask = (operation: () => Promise): Promise => {\n\t\tconst owner = Promise.withResolvers();\n\t\tstartupTasks.add(owner.promise); appFactoryRevision++;\n\t\tconst finish = () => { startupTasks.delete(owner.promise); appFactoryRevision++; owner.resolve(); };\n\t\ttry { return Promise.resolve(operation()).finally(finish); }\n\t\tcatch (error) { finish(); return Promise.reject(error); }\n\t};\n\tconst startOwnedStartupTask = (operation: () => Promise): void => {\n\t\tvoid ownFactoryTask(operation).catch(() => {}); // Existing startup probes remain best-effort.\n\t};\n\tconst joinStartupTasks = async (): Promise => {\n\t\twhile (startupTasks.size > 0) await Promise.allSettled([...startupTasks]);\n\t};\n\tlet processCwdClaimed = false;\n\tlet hasRegistered = false;\n\tlet asyncJobManager: AsyncJobManager | undefined;\n" + }, + { + "before": "\t\t// credential_disabled event past us. An embedder's constructor handler makes the\n\t\t// listener set non-empty from construction, which defeats AuthStorage's no-listener\n\t\t// buffer — so we can't rely on it to catch startup events for the extension runner.\n\t\tconst startupCredentialDisabledEvents: CredentialDisabledEvent[] = [];\n\t\tlet credentialDisabledTarget: ExtensionRunner | undefined;\n\t\tunsubscribeCredentialDisabled = authStorage.onCredentialDisabled(event => {\n\t\t\tif (credentialDisabledTarget) {\n\t\t\t\t// Discard return: any handler error is routed through runner.onError listeners.\n\t\t\t\tvoid credentialDisabledTarget.emitCredentialDisabled(event);\n\t\t\t} else {\n\t\t\t\tstartupCredentialDisabledEvents.push(event);\n\t\t\t}\n\t\t});\n\t\tconst applyCredentialSelector = (scopeId: string, provider: string, selector: AuthCredentialSelector): void => {\n\t\t\tauthStorage.setSessionCredentialSelector(scopeId, provider, selector, authStorageOwner);\n", + "after": "\t\t// credential_disabled event past us. An embedder's constructor handler makes the\n\t\t// listener set non-empty from construction, which defeats AuthStorage's no-listener\n\t\t// buffer — so we can't rely on it to catch startup events for the extension runner.\n\t\tconst startupCredentialDisabledEvents: CredentialDisabledEvent[] = [];\n\t\tlet credentialDisabledTarget: ExtensionRunner | undefined;\n\t\tunsubscribeCredentialDisabled = authStorage.onCredentialDisabled(event => {\n\t\t\t// Runner.initialize() may defer buffered delivery to its own microtask.\n\t\t\t// Its returned promise does not yet represent that buffered dispatch.\n\t\t\tappFactoryUnknown.add(\"sdk_extension_credential_dispatch_unrepresented\"); appFactoryRevision++;\n\t\t\tif (credentialDisabledTarget) {\n\t\t\t\treturn ownFactoryTask(() => credentialDisabledTarget!.emitCredentialDisabled(event));\n\t\t\t} else {\n\t\t\t\tstartupCredentialDisabledEvents.push(event);\n\t\t\t}\n\t\t});\n\t\tconst applyCredentialSelector = (scopeId: string, provider: string, selector: AuthCredentialSelector): void => {\n\t\t\tauthStorage.setSessionCredentialSelector(scopeId, provider, selector, authStorageOwner);\n" + }, + { + "before": "\t\t\tagentsMdFiles: [],\n\t\t};\n\t\tlet workspaceTreePromise: Promise = options.workspaceTree\n\t\t\t? Promise.resolve(options.workspaceTree)\n\t\t\t: workspaceTreeMode === \"lazy\"\n\t\t\t\t? Promise.resolve(emptyWorkspaceTree)\n\t\t\t\t: logger.time(\"buildWorkspaceTree\", () =>\n\t\t\t\t\t\truntimeServices.workspaceTree.get(\"legacy-startup\").then(runtime => runtime.snapshot),\n\t\t\t\t\t);\n\t\tworkspaceTreePromise.catch(() => {});\n\n\t\t// Independent discoveries that depend only on cwd/agentDir — kicked off in parallel and awaited\n\t\t// at their respective consumer sites. Their work can overlap with model resolution, secret loading,\n\t\t// session-context build, tool creation, MCP discovery, and extension discovery.\n\t\tconst contextFilesResultPromise = options.contextFiles\n\t\t\t? Promise.resolve({ contextFiles: options.contextFiles, warnings: [] })\n\t\t\t: logger.time(\"discoverContextFiles\", loadContextFilesResultInternal, { cwd });\n\t\tcontextFilesResultPromise.catch(() => {});\n\t\tconst promptTemplatesPromise = options.promptTemplates\n\t\t\t? Promise.resolve(options.promptTemplates)\n\t\t\t: logger.time(\"discoverPromptTemplates\", discoverPromptTemplates, cwd, agentDir);\n\t\tpromptTemplatesPromise.catch(() => {});\n\t\tconst slashCommandsPromise = options.slashCommands ? Promise.resolve(options.slashCommands) : Promise.resolve([]);\n\t\tslashCommandsPromise.catch(() => {});\n\n\t\t// Initialize provider preferences from settings\n\t\tconst { getConfiguredSearchProviderPreference, setPreferredSearchProvider, setSearchFallbackProviders } =\n", + "after": "\t\t\tagentsMdFiles: [],\n\t\t};\n\t\tlet workspaceTreePromise: Promise = options.workspaceTree\n\t\t\t? Promise.resolve(options.workspaceTree)\n\t\t\t: workspaceTreeMode === \"lazy\"\n\t\t\t\t? Promise.resolve(emptyWorkspaceTree)\n\t\t\t\t: ownFactoryTask(() => logger.time(\"buildWorkspaceTree\", () =>\n\t\t\t\t\t\truntimeServices.workspaceTree.get(\"legacy-startup\").then(runtime => runtime.snapshot),\n\t\t\t\t\t));\n\t\tworkspaceTreePromise.catch(() => {});\n\n\t\t// Independent discoveries that depend only on cwd/agentDir — kicked off in parallel and awaited\n\t\t// at their respective consumer sites. Their work can overlap with model resolution, secret loading,\n\t\t// session-context build, tool creation, MCP discovery, and extension discovery.\n\t\tconst contextFilesResultPromise = options.contextFiles\n\t\t\t? Promise.resolve({ contextFiles: options.contextFiles, warnings: [] })\n\t\t\t: ownFactoryTask(() => logger.time(\"discoverContextFiles\", loadContextFilesResultInternal, { cwd }));\n\t\tcontextFilesResultPromise.catch(() => {});\n\t\tconst promptTemplatesPromise = options.promptTemplates\n\t\t\t? Promise.resolve(options.promptTemplates)\n\t\t\t: ownFactoryTask(() => logger.time(\"discoverPromptTemplates\", discoverPromptTemplates, cwd, agentDir));\n\t\tpromptTemplatesPromise.catch(() => {});\n\t\tconst slashCommandsPromise = options.slashCommands ? Promise.resolve(options.slashCommands) : Promise.resolve([]);\n\t\tslashCommandsPromise.catch(() => {});\n\n\t\t// Initialize provider preferences from settings\n\t\tconst { getConfiguredSearchProviderPreference, setPreferredSearchProvider, setSearchFallbackProviders } =\n" + }, + { + "before": "\t\t// MCP routing is scope-held; no process-global manager registration.\n\n\t\t// General extension discovery is quarantined from the public SDK surface.\n\t\t// Recognized hook conventions are the bounded exception: their descriptors\n\t\t// normalize before import and then adapt into the authoritative ExtensionRunner.\n\t\tconst inlineExtensions: ExtensionFactory[] = [...(options.extensions ?? [])];\n\t\tconst discoveredHookExtensions: Array<{ factory: ExtensionFactory; name: string }> = [];\n\t\tif (customTools.length > 0) {\n\t\t\tinlineExtensions.push(createCustomToolsExtension(customTools));\n\t\t}\n\t\tif (!options.disableExtensionDiscovery) {\n\t\t\ttry {\n\t\t\t\tconst hookExtensions = await discoverAndLoadHookExtensions(options.hookPaths ?? [], cwd);\n\t\t\t\tdiscoveredHookExtensions.push(...hookExtensions.factories);\n\t\t\t\tfor (const error of hookExtensions.errors) {\n\t\t\t\t\tlogger.warn(\"Rejected discovered hook\", { path: error.path, error: error.error });\n\t\t\t\t}\n\t\t\t} catch (error) {\n\t\t\t\tlogger.warn(\"Failed to discover hook extensions\", { error: safeErrorForLog(error) });\n\t\t\t}\n", + "after": "\t\t// MCP routing is scope-held; no process-global manager registration.\n\n\t\t// General extension discovery is quarantined from the public SDK surface.\n\t\t// Recognized hook conventions are the bounded exception: their descriptors\n\t\t// normalize before import and then adapt into the authoritative ExtensionRunner.\n\t\tconst inlineExtensions: ExtensionFactory[] = [...(options.extensions ?? [])];\n\t\tif (options.extensions?.length || options.preloadedExtensions?.extensions.length)\n\t\t\tappFactoryUnknown.add(\"sdk_extension_effects_unrepresented\");\n\t\tif (options.runtimeServices || options.eventBus || options.mcpManager || options.inheritedMcpManager)\n\t\t\tappFactoryUnknown.add(\"sdk_injected_services_unrepresented\");\n\t\tconst discoveredHookExtensions: Array<{ factory: ExtensionFactory; name: string }> = [];\n\t\tif (customTools.length > 0) {\n\t\t\tinlineExtensions.push(createCustomToolsExtension(customTools));\n\t\t}\n\t\tif (!options.disableExtensionDiscovery) {\n\t\t\ttry {\n\t\t\t\tconst hookExtensions = await discoverAndLoadHookExtensions(options.hookPaths ?? [], cwd);\n\t\t\t\tdiscoveredHookExtensions.push(...hookExtensions.factories);\n\t\t\t\tif (hookExtensions.factories.length) appFactoryUnknown.add(\"sdk_extension_effects_unrepresented\");\n\t\t\t\tfor (const error of hookExtensions.errors) {\n\t\t\t\t\tlogger.warn(\"Rejected discovered hook\", { path: error.path, error: error.error });\n\t\t\t\t}\n\t\t\t} catch (error) {\n\t\t\t\tlogger.warn(\"Failed to discover hook extensions\", { error: safeErrorForLog(error) });\n\t\t\t}\n" + }, + { + "before": "\t\t// Always-on constrained plugin hooks (validated registry surfaces). Additive\n\t\t// and a no-op without installed plugins; the loader denies all dangerous APIs.\n\t\ttry {\n\t\t\tconst pluginHookResult = await loadConstrainedPluginHooks({ cwd });\n\t\t\tif (pluginHookResult.hooks.length > 0) {\n\t\t\t\tinlineExtensions.push(createPluginHooksExtension(pluginHookResult.hooks));\n\t\t\t}\n\t\t\tfor (const q of pluginHookResult.quarantine) {\n\t\t\t\tgjcFindings.add({ identity: q.identity, surfaceId: q.surfaceId, code: q.code, message: q.message });\n\t\t\t\tlogger.warn(\"Quarantined GJC plugin hook\", { plugin: q.plugin, surface: q.surfaceId, code: q.code });\n\t\t\t}\n\t\t} catch (error) {\n", + "after": "\t\t// Always-on constrained plugin hooks (validated registry surfaces). Additive\n\t\t// and a no-op without installed plugins; the loader denies all dangerous APIs.\n\t\ttry {\n\t\t\tconst pluginHookResult = await loadConstrainedPluginHooks({ cwd });\n\t\t\tif (pluginHookResult.hooks.length > 0) {\n\t\t\t\tinlineExtensions.push(createPluginHooksExtension(pluginHookResult.hooks));\n\t\t\t\tappFactoryUnknown.add(\"sdk_extension_effects_unrepresented\");\n\t\t\t}\n\t\t\tfor (const q of pluginHookResult.quarantine) {\n\t\t\t\tgjcFindings.add({ identity: q.identity, surfaceId: q.surfaceId, code: q.code, message: q.message });\n\t\t\t\tlogger.warn(\"Quarantined GJC plugin hook\", { plugin: q.plugin, surface: q.surfaceId, code: q.code });\n\t\t\t}\n\t\t} catch (error) {\n" + }, + { + "before": "\t\t\t\t\tcurrentAgentType: options.currentAgentType,\n\t\t\t\t\tspawnedByGjc,\n\t\t\t\t}),\n\t\t);\n\t\tconst sdkHostEligible =\n\t\t\tshouldHostSdk(notificationCfg, isTopLevelSdkSession) && (options.sdkHostModeSupported ?? true);\n\t\tconst notificationAdapterService = createLazyService({\n\t\t\tid: \"sdk.notifications.adapters\",\n\t\t\tenabled: () => notificationsExtensionEligible || sdkHostEligible,\n\t\t\tinitialize: async () => ({\n\t\t\t\tvalue: (await import(\"../sdk/bus\")).createNotificationsExtension,\n\t\t\t}),\n", + "after": "\t\t\t\t\tcurrentAgentType: options.currentAgentType,\n\t\t\t\t\tspawnedByGjc,\n\t\t\t\t}),\n\t\t);\n\t\tconst sdkHostEligible =\n\t\t\tshouldHostSdk(notificationCfg, isTopLevelSdkSession) && (options.sdkHostModeSupported ?? true);\n\t\t// These hosts have independent retry/delivery roots beyond their shutdown handler.\n\t\t// Do not certify them from the extension runner's returned callback alone.\n\t\tif (sdkHostEligible) appFactoryUnknown.add(\"sdk_host_effects_unrepresented\");\n\t\tif (notificationsExtensionEligible) appFactoryUnknown.add(\"sdk_notification_effects_unrepresented\");\n\t\tconst notificationAdapterService = createLazyService({\n\t\t\tid: \"sdk.notifications.adapters\",\n\t\t\tenabled: () => notificationsExtensionEligible || sdkHostEligible,\n\t\t\tinitialize: async () => ({\n\t\t\t\tvalue: (await import(\"../sdk/bus\")).createNotificationsExtension,\n\t\t\t}),\n" + }, + { + "before": "\t\t\t\t\t} else if (sdkHostEligible) {\n\t\t\t\t\t\tregisterSdkOnlyNotificationCommand(api);\n\t\t\t\t\t\tlet sdkOnlyReconciliationStore:\n\t\t\t\t\t\t\t| { sessionId: string; sessionFile: string | undefined; store: ReconciliationStore }\n\t\t\t\t\t\t\t| undefined;\n\t\t\t\t\t\tcreateSdkSessionRuntimeExtension(api, {\n\t\t\t\t\t\t\tagentDir,\n\t\t\t\t\t\t\tbrokerRegistrationRequired: lifecycleStartupCapability !== undefined,\n\t\t\t\t\t\t\t...(lifecycleStartupCapability?.lifecycleRequestId\n\t\t\t\t\t\t\t\t? { lifecycleRequestId: lifecycleStartupCapability.lifecycleRequestId }\n\t\t\t\t\t\t\t\t: {}),\n\t\t\t\t\t\t\tcreateTransport: input => createSdkWebSocketTransport(input),\n", + "after": "\t\t\t\t\t} else if (sdkHostEligible) {\n\t\t\t\t\t\tregisterSdkOnlyNotificationCommand(api);\n\t\t\t\t\t\tlet sdkOnlyReconciliationStore:\n\t\t\t\t\t\t\t| { sessionId: string; sessionFile: string | undefined; store: ReconciliationStore }\n\t\t\t\t\t\t\t| undefined;\n\t\t\t\t\t\tcreateSdkSessionRuntimeExtension(api, {\n\t\t\t\t\t\t\tregisterAppLifecycleOwner: owner => {\n\t\t\t\t\t\t\t\tappHostOwners.add(owner); appFactoryUnknown.delete(\"sdk_host_effects_unrepresented\"); appFactoryRevision++;\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\tagentDir,\n\t\t\t\t\t\t\tbrokerRegistrationRequired: lifecycleStartupCapability !== undefined,\n\t\t\t\t\t\t\t...(lifecycleStartupCapability?.lifecycleRequestId\n\t\t\t\t\t\t\t\t? { lifecycleRequestId: lifecycleStartupCapability.lifecycleRequestId }\n\t\t\t\t\t\t\t\t: {}),\n\t\t\t\t\t\t\tcreateTransport: input => createSdkWebSocketTransport(input),\n" + }, + { + "before": "\t\t}\n\n\t\tif (extensionRunner) {\n\t\t\tcredentialDisabledTarget = extensionRunner;\n\t\t\tfor (const event of startupCredentialDisabledEvents.splice(0)) {\n\t\t\t\t// Discard return: any handler error is routed through runner.onError listeners.\n\t\t\t\tvoid extensionRunner.emitCredentialDisabled(event);\n\t\t\t}\n\t\t} else {\n\t\t\t// No runner to forward to; release our subscription. The embedder's own\n\t\t\t// onCredentialDisabled (if any) keeps firing through its own subscription.\n\t\t\tstartupCredentialDisabledEvents.length = 0;\n\t\t\treleaseCredentialDisabledSubscription();\n", + "after": "\t\t}\n\n\t\tif (extensionRunner) {\n\t\t\tcredentialDisabledTarget = extensionRunner;\n\t\t\tfor (const event of startupCredentialDisabledEvents.splice(0)) {\n\t\t\t\t// Discard return: any handler error is routed through runner.onError listeners.\n\t\t\t\tstartOwnedStartupTask(() => extensionRunner!.emitCredentialDisabled(event));\n\t\t\t}\n\t\t} else {\n\t\t\t// No runner to forward to; release our subscription. The embedder's own\n\t\t\t// onCredentialDisabled (if any) keeps firing through its own subscription.\n\t\t\tstartupCredentialDisabledEvents.length = 0;\n\t\t\treleaseCredentialDisabledSubscription();\n" + }, + { + "before": "\t\t\t\tif (cleanupPromise) return cleanupPromise;\n\t\t\t\tconst cleanup = (async () => {\n\t\t\t\t\tconst failures: unknown[] = [];\n\t\t\t\t\ttry {\n\t\t\t\t\t\tagentRegistry.unregister(resolvedAgentId);\n\t\t\t\t\t\treleaseCredentialDisabledSubscription();\n\t\t\t\t\t\treleaseLocalProtocolOverride();\n\t\t\t\t\t} catch (error) {\n\t\t\t\t\t\tfailures.push(error);\n\t\t\t\t\t} finally {\n\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\tif (!options.parentTaskPrefix) {\n", + "after": "\t\t\t\tif (cleanupPromise) return cleanupPromise;\n\t\t\t\tconst cleanup = (async () => {\n\t\t\t\t\tconst failures: unknown[] = [];\n\t\t\t\t\ttry {\n\t\t\t\t\t\tagentRegistry.unregister(resolvedAgentId);\n\t\t\t\t\t\treleaseCredentialDisabledSubscription();\n\t\t\t\t\t\tawait joinStartupTasks();\n\t\t\t\t\t\tawait joinAppHosts();\n\t\t\t\t\t\treleaseLocalProtocolOverride();\n\t\t\t\t\t} catch (error) {\n\t\t\t\t\t\tfailures.push(error);\n\t\t\t\t\t} finally {\n\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\tif (!options.parentTaskPrefix) {\n" + }, + { + "before": "\t\t\t\t\t\t.catch(error => {\n\t\t\t\t\t\t\tlogger.warn(\"Failed to publish conventional MCP tools\", { error: safeErrorForLog(error) });\n\t\t\t\t\t\t});\n\t\t\t\t\treturn conventionalToolsSync;\n\t\t\t\t};\n\t\t\t\tmcpManager.setOnToolsChanged(tools => {\n\t\t\t\t\tvoid syncConventionalTools(tools as CustomTool[]);\n\t\t\t\t});\n\t\t\t\tvoid syncConventionalTools(mcpManager.getTools() as CustomTool[]);\n\t\t\t} else if (!ownsMcpManager) {\n\t\t\t\tmcpManager.setOnToolsChanged(tools => {\n\t\t\t\t\tvoid session.refreshMCPTools(tools);\n\t\t\t\t});\n\t\t\t}\n\t\t\tconst clearDebounceTimers = () => {\n\t\t\t\tfor (const timer of notificationDebounceTimers.values()) clearTimeout(timer);\n\t\t\t\tnotificationDebounceTimers.clear();\n\t\t\t};\n", + "after": "\t\t\t\t\t\t.catch(error => {\n\t\t\t\t\t\t\tlogger.warn(\"Failed to publish conventional MCP tools\", { error: safeErrorForLog(error) });\n\t\t\t\t\t\t});\n\t\t\t\t\treturn conventionalToolsSync;\n\t\t\t\t};\n\t\t\t\tmcpManager.setOnToolsChanged(tools => {\n\t\t\t\t\tif (!session.isDisposed) startOwnedStartupTask(() => syncConventionalTools(tools as CustomTool[]));\n\t\t\t\t});\n\t\t\t\tstartOwnedStartupTask(() => syncConventionalTools(mcpManager.getTools() as CustomTool[]));\n\t\t\t} else if (!ownsMcpManager) {\n\t\t\t\tmcpManager.setOnToolsChanged(tools => {\n\t\t\t\t\tif (!session.isDisposed) startOwnedStartupTask(() => session.refreshMCPTools(tools));\n\t\t\t\t});\n\t\t\t}\n\t\t\tconst clearDebounceTimers = () => {\n\t\t\t\tfor (const timer of notificationDebounceTimers.values()) clearTimeout(timer);\n\t\t\t\tnotificationDebounceTimers.clear();\n\t\t\t};\n" + }, + { + "before": "\t\t}\n\t\t// Expose the published evidence on the session itself so UI surfaces that\n\t\t// only hold a session (Settings) can consume it without threading the\n\t\t// creation result through every controller.\n\t\tsession.gjcRuntimeSnapshot = gjcRuntimeStore;\n\t\tsession.gjcActivationGeneration = gjcActivationGeneration;\n\t\treturn {\n\t\t\tsession,\n\t\t\textensionsResult,\n\t\t\tsetToolUIContext,\n\t\t\tmcpManager: ownsMcpManager && mcpManager?.isToolsOnly() ? undefined : mcpManager,\n\t\t\tstartDeferredMcpConfig,\n", + "after": "\t\t}\n\t\t// Expose the published evidence on the session itself so UI surfaces that\n\t\t// only hold a session (Settings) can consume it without threading the\n\t\t// creation result through every controller.\n\t\tsession.gjcRuntimeSnapshot = gjcRuntimeStore;\n\t\tsession.gjcActivationGeneration = gjcActivationGeneration;\n\t\tsession.registerAppLifecycleFactoryOwner(() => {\n\t\t\tconst hosts = [...appHostOwners].map(owner => owner.getAppLifecycleActivity());\n\t\t\treturn { generation: `${appFactoryRevision}:${hosts.map(host => host.generation).join(\":\")}`,\n\t\t\t\tpending: startupTasks.size + hosts.reduce((n, host) => n + host.starting + host.queued + host.running + host.settling, 0),\n\t\t\t\tunknown: [...new Set([...appFactoryUnknown, ...hosts.flatMap(host => host.unknown)])] };\n\t\t});\n\t\treturn {\n\t\t\tsession,\n\t\t\textensionsResult,\n\t\t\tsetToolUIContext,\n\t\t\tmcpManager: ownsMcpManager && mcpManager?.isToolsOnly() ? undefined : mcpManager,\n\t\t\tstartDeferredMcpConfig,\n" + }, + { + "before": "\t\t\t\t\t}\n\t\t\t\t\t// The first call is intentionally bounded for startup callers. Join the\n\t\t\t\t\t// retained teardown before releasing the resources it still owns.\n\t\t\t\t\tawait session.awaitDisposeCompletion();\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tif (hasRegistered) agentRegistry.unregister(resolvedAgentId);\n\t\t\t\t// Admission happens before session construction. Any later startup\n\t\t\t\t// failure must remove THIS manager's endpoint mapping and restore\n\t\t\t\t// the prior global only when this manager is still global: otherwise\n\t\t\t\t// a retry under the same endpoint is falsely rejected and an orphan\n\t\t\t\t// redirects global-manager consumers away from the live session\n", + "after": "\t\t\t\t\t}\n\t\t\t\t\t// The first call is intentionally bounded for startup callers. Join the\n\t\t\t\t\t// retained teardown before releasing the resources it still owns.\n\t\t\t\t\tawait session.awaitDisposeCompletion();\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tawait joinAppHosts();\n\t\t\t\tif (hasRegistered) agentRegistry.unregister(resolvedAgentId);\n\t\t\t\t// Admission happens before session construction. Any later startup\n\t\t\t\t// failure must remove THIS manager's endpoint mapping and restore\n\t\t\t\t// the prior global only when this manager is still global: otherwise\n\t\t\t\t// a retry under the same endpoint is falsely rejected and an orphan\n\t\t\t\t// redirects global-manager consumers away from the live session\n" + } + ] + }, + { + "package": "@gajae-code/coding-agent", + "path": "src/session/agent-session.ts", + "beforeSha256": "f5ad1c0f021ff54183f78c01640d9214eae29444f48c9388b51b01e6bb2193ed", + "afterSha256": "838658da725233a0628bf813fc4ca9ff7f060e056636cebcfe6eb03b9a1ecc29", + "replacements": [ + { + "before": "\tThinkingLevel,\n} from \"@gajae-code/agent-core\";\nimport { ESCAPED_NONASCII_RECOVERY_PROMPT, normalizeMessagesForProvider } from \"@gajae-code/agent-core/agent-loop\";\nimport type { AttemptRunHandle, AttemptScope, AttemptScopeAuthority } from \"@gajae-code/agent-core/attempt-scope\";\nimport {\n\tAUTO_HANDOFF_THRESHOLD_FOCUS,\n", + "after": "\tThinkingLevel,\n} from \"@gajae-code/agent-core\";\nimport { ESCAPED_NONASCII_RECOVERY_PROMPT, normalizeMessagesForProvider } from \"@gajae-code/agent-core/agent-loop\";\nimport * as appLifecycle from \"@gajae-code/agent-core/run-resource-ledger\";\nimport type { AttemptRunHandle, AttemptScope, AttemptScopeAuthority } from \"@gajae-code/agent-core/attempt-scope\";\nimport {\n\tAUTO_HANDOFF_THRESHOLD_FOCUS,\n" + }, + { + "before": "\tagent: Agent;\n\tsessionManager: SessionManager;\n\tsettings: Settings;\n\t/** The session's REQUESTED effective agent directory, independent of the\n\t * global Settings singleton (which may be reused across sessions). */\n\tagentDir?: string;\n", + "after": "\tagent: Agent;\n\tsessionManager: SessionManager;\n\tsettings: Settings;\n\t/** Caller-owned settings are flushed at terminal cleanup, never closed here. */\n\tflushBorrowedSettingsOnDispose?: boolean;\n\t/** The session's REQUESTED effective agent directory, independent of the\n\t * global Settings singleton (which may be reused across sessions). */\n\tagentDir?: string;\n" + }, + { + "before": "\treadonly agent: Agent;\n\tsessionManager: SessionManager;\n\treadonly settings: Settings;\n\treadonly #requestedAgentDir: string | undefined;\n\n\t/**\n", + "after": "\treadonly agent: Agent;\n\tsessionManager: SessionManager;\n\treadonly settings: Settings;\n\treadonly #flushBorrowedSettingsOnDispose: boolean;\n\treadonly #requestedAgentDir: string | undefined;\n\n\t/**\n" + }, + { + "before": "\t/** Test-only abort outcome override for cancel-and-submit rollback coverage. */\n\t#cancelAndSubmitAbortOutcomeProviderForTests: (() => Promise) | undefined = undefined;\n\t#postPromptTasks = new Set>();\n\t#postPromptTaskSelectionFenceGenerations = new Map, number>();\n\t#postPromptTaskRecoveryExcluded = new Set>();\n\t#postPromptTasksPromise: Promise | undefined = undefined;\n", + "after": "\t/** Test-only abort outcome override for cancel-and-submit rollback coverage. */\n\t#cancelAndSubmitAbortOutcomeProviderForTests: (() => Promise) | undefined = undefined;\n\t#postPromptTasks = new Set>();\n\t// Independent lifetime owners: force recovery may clear logical queues, not these.\n\treadonly #physicalPostPromptTasks = new Set>();\n\treadonly #physicalPromptCompletions = new Map; resolve(): void }>();\n\t#postPromptTaskSelectionFenceGenerations = new Map, number>();\n\t#postPromptTaskRecoveryExcluded = new Set>();\n\t#postPromptTasksPromise: Promise | undefined = undefined;\n" + }, + { + "before": "\n\t#beginInFlight(): symbol {\n\t\tconst token = Symbol(\"in-flight-prompt\");\n\t\tconst hadLivePrompt = this.#livePromptsInFlight() > 0;\n\t\tthis.#inFlightPromptTokens.set(token, this.#abortEpoch);\n\t\tthis.#promptInFlightCount++;\n", + "after": "\n\t#beginInFlight(): symbol {\n\t\tconst token = Symbol(\"in-flight-prompt\");\n\t\tthis.#physicalPromptCompletions.set(token, Promise.withResolvers());\n\t\tconst hadLivePrompt = this.#livePromptsInFlight() > 0;\n\t\tthis.#inFlightPromptTokens.set(token, this.#abortEpoch);\n\t\tthis.#promptInFlightCount++;\n" + }, + { + "before": "\t\treturn flushError;\n\t}\n\tasync #settleEndedInFlight(token: symbol, promptWait?: \"publication\" | \"full\"): Promise {\n\t\tconst flushError = this.#endInFlight(token);\n\t\tconst predecessorPromptStillInFlight = this.#livePromptsInFlight() > 0;\n\t\tif (promptWait === \"publication\") {\n", + "after": "\t\treturn flushError;\n\t}\n\tasync #settleEndedInFlight(token: symbol, promptWait?: \"publication\" | \"full\"): Promise {\n\t\ttry { await this.#settleEndedInFlightLogical(token, promptWait); }\n\t\tfinally {\n\t\t\tthis.#physicalPromptCompletions.get(token)?.resolve();\n\t\t\tthis.#physicalPromptCompletions.delete(token);\n\t\t}\n\t}\n\n\tasync #settleEndedInFlightLogical(token: symbol, promptWait?: \"publication\" | \"full\"): Promise {\n\t\tconst flushError = this.#endInFlight(token);\n\t\tconst predecessorPromptStillInFlight = this.#livePromptsInFlight() > 0;\n\t\tif (promptWait === \"publication\") {\n" + }, + { + "before": "\t\tthis.agent.bindRunCancellationDomainBridge(this.#runCancellationDomains, this.#agentSessionClaimKey);\n\t\tthis.sessionManager = config.sessionManager;\n\t\tthis.settings = config.settings;\n\t\tthis.#requestedAgentDir = config.agentDir ? path.resolve(config.agentDir) : undefined;\n\t\tthis.sessionManager.setSessionMemoryMode(this.settings.get(\"sessionMemory.mode\"));\n\t\tthis.#unregisterSessionMemorySettings = this.settings.onChanged(settingPath => {\n", + "after": "\t\tthis.agent.bindRunCancellationDomainBridge(this.#runCancellationDomains, this.#agentSessionClaimKey);\n\t\tthis.sessionManager = config.sessionManager;\n\t\tthis.settings = config.settings;\n\t\tthis.#flushBorrowedSettingsOnDispose = config.flushBorrowedSettingsOnDispose === true;\n\t\tthis.#requestedAgentDir = config.agentDir ? path.resolve(config.agentDir) : undefined;\n\t\tthis.sessionManager.setSessionMemoryMode(this.settings.get(\"sessionMemory.mode\"));\n\t\tthis.#unregisterSessionMemorySettings = this.settings.onChanged(settingPath => {\n" + }, + { + "before": "\t\texcludeFromRecovery = false,\n\t): void {\n\t\tthis.#postPromptTasks.add(task);\n\t\tthis.#postPromptTaskSelectionFenceGenerations.set(task, selectionFenceGeneration);\n\t\tif (excludeFromRecovery) this.#postPromptTaskRecoveryExcluded.add(task);\n\t\tthis.#ensurePostPromptTasksPromise();\n", + "after": "\t\texcludeFromRecovery = false,\n\t): void {\n\t\tthis.#postPromptTasks.add(task);\n\t\tthis.#physicalPostPromptTasks.add(task);\n\t\tthis.#postPromptTaskSelectionFenceGenerations.set(task, selectionFenceGeneration);\n\t\tif (excludeFromRecovery) this.#postPromptTaskRecoveryExcluded.add(task);\n\t\tthis.#ensurePostPromptTasksPromise();\n" + }, + { + "before": "\t\t\t.catch(() => {})\n\t\t\t.finally(() => {\n\t\t\t\tthis.#postPromptTasks.delete(task);\n\t\t\t\tthis.#postPromptTaskSelectionFenceGenerations.delete(task);\n\t\t\t\tthis.#postPromptTaskRecoveryExcluded.delete(task);\n\t\t\t\tif (this.#postPromptTasks.size === 0) this.#resolvePostPromptTasks();\n", + "after": "\t\t\t.catch(() => {})\n\t\t\t.finally(() => {\n\t\t\t\tthis.#postPromptTasks.delete(task);\n\t\t\t\tthis.#physicalPostPromptTasks.delete(task);\n\t\t\t\tthis.#postPromptTaskSelectionFenceGenerations.delete(task);\n\t\t\t\tthis.#postPromptTaskRecoveryExcluded.delete(task);\n\t\t\t\tif (this.#postPromptTasks.size === 0) this.#resolvePostPromptTasks();\n" + }, + { + "before": "\n\t/** Join the retained teardown owner without allocating another public deadline. */\n\tawaitDisposeCompletion(): Promise {\n\t\treturn this.#disposeRunPromise ?? this.dispose();\n\t}\n\n\tsetDisposeTimeoutForTests(timeoutMs: number): void {\n", + "after": "\n\t/** Join the retained teardown owner without allocating another public deadline. */\n\tawaitDisposeCompletion(): Promise {\n\t\tif (!this.#disposeRunPromise) void this.dispose().catch(() => {});\n\t\treturn this.#disposeRunPromise!;\n\t}\n\n\tasync #joinPhysicalExecutions(): Promise {\n\t\t// Runtime helper is additive; the app-facing public signature stays unchanged.\n\t\tconst lifecycle = appLifecycle as typeof appLifecycle & {\n\t\t\tawaitPhysicalRunResources?: (ledger: ReturnType) => Promise;\n\t\t};\n\t\tif (typeof lifecycle.awaitPhysicalRunResources !== \"function\")\n\t\t\tthrow new Error(\"GJC app lifecycle patch is incomplete\");\n\t\tdo {\n\t\t\tawait Promise.allSettled([\n\t\t\t\t...[...this.#physicalPromptCompletions.values()].map(owner => owner.promise),\n\t\t\t\t...this.#physicalPostPromptTasks,\n\t\t\t]);\n\t\t\tawait lifecycle.awaitPhysicalRunResources(this.agent.resourceLedger);\n\t\t} while (this.#physicalPromptCompletions.size > 0 || this.#physicalPostPromptTasks.size > 0);\n\t}\n\n\tsetDisposeTimeoutForTests(timeoutMs: number): void {\n" + }, + { + "before": "\t\t\t}\n\t\t\tif (AsyncJobManager.instance() === ownedAsyncManager) AsyncJobManager.setInstance(undefined);\n\t\t}\n\t\tawait awaitDisposeStep(\"tool session transition cleanups\", this.#runToolSessionTransitionCleanups(), true);\n\t\tawait awaitDisposeStep(\"tool session cleanups\", this.#runToolSessionCleanups(), true);\n\t\t// Only disconnect the MCP manager THIS session owns (top-level sessions that\n", + "after": "\t\t\t}\n\t\t\tif (AsyncJobManager.instance() === ownedAsyncManager) AsyncJobManager.setInstance(undefined);\n\t\t}\n\t\tawait awaitDisposeStep(\"physical session executions\", this.#joinPhysicalExecutions(), true);\n\t\tawait awaitDisposeStep(\"tool session transition cleanups\", this.#runToolSessionTransitionCleanups(), true);\n\t\tawait awaitDisposeStep(\"tool session cleanups\", this.#runToolSessionCleanups(), true);\n\t\t// Only disconnect the MCP manager THIS session owns (top-level sessions that\n" + }, + { + "before": "\t\t}\n\t\tthis.#eventListeners = [];\n\t\tthis.#rebuildEventListenerSnapshot();\n\t\tif (criticalDisposeError !== undefined) {\n\t\t\tdisposeFailures.push({ label: \"session manager close\", error: criticalDisposeError, critical: true });\n\t\t}\n", + "after": "\t\t}\n\t\tthis.#eventListeners = [];\n\t\tthis.#rebuildEventListenerSnapshot();\n\t\tif (this.#flushBorrowedSettingsOnDispose)\n\t\t\tawait awaitDisposeStep(\"borrowed settings persistence\", this.settings.flushOrThrow(), true);\n\t\tif (criticalDisposeError !== undefined) {\n\t\t\tdisposeFailures.push({ label: \"session manager close\", error: criticalDisposeError, critical: true });\n\t\t}\n" + }, + { + "before": "\treadonly #asyncJobProviderSessionId: string | undefined;\n\t#isDisposed = false;\n\t#disposeRunPromise: Promise | undefined;\n\t#disposeCallerPromise: Promise | undefined;\n\t#disposeCompleted = false;\n\t#disposeTerminalError: unknown;\n\treadonly #disposeAbortController = new AbortController();\n\t#disposeAdmissionClosed: Promise | undefined;\n\t#disposePostPromptDrain: Promise | undefined;\n\t#disposeDeadline = 0;\n\t#disposeDeadlineTimer: NodeJS.Timeout | undefined;\n\t#disposeDeadlineExpired: PromiseWithResolvers | undefined;\n", + "after": "\treadonly #asyncJobProviderSessionId: string | undefined;\n\t#isDisposed = false;\n\t#disposeRunPromise: Promise | undefined;\n\t#disposeCallerPromise: Promise | undefined;\n\t#disposeCompleted = false;\n\t#disposeTerminalError: unknown;\n\t#appFactoryOwner?: () => { generation: string; pending: number; unknown: string[] };\n\t/** The SDK factory supplies its real asynchronous owner, once, before returning. */\n\tregisterAppLifecycleFactoryOwner(owner: () => { generation: string; pending: number; unknown: string[] }): void {\n\t\tif (this.#appFactoryOwner) throw new Error(\"Factory lifecycle owner already registered\");\n\t\tthis.#appFactoryOwner = owner;\n\t}\n\tgetAppLifecycleActivity(): {\n\t\tgeneration: string; complete: boolean; starting: number; queued: number;\n\t\trunning: number; settling: number; unknown: string[];\n\t} {\n\t\tconst read = (appLifecycle as typeof appLifecycle & {\n\t\t\t\tgetPhysicalRunResourceActivity?: (ledger: ReturnType) => {\n\t\t\t\tgeneration: string; complete: boolean; running: number; unknown: string[];\n\t\t\t};\n\t\t}).getPhysicalRunResourceActivity;\n\t\tconst physical = read?.(this.agent.resourceLedger);\n\t\tconst factory = this.#appFactoryOwner?.();\n\t\tconst unknown = [...(physical?.unknown ?? [\"sdk_physical_ledger_unrepresented\"]),\n\t\t\t...(factory?.unknown ?? [\"sdk_factory_ownership_unrepresented\"]),\n\t\t\t...(this.#disposeTerminalError === undefined ? [] : [\"sdk_session_cleanup_unconfirmed\"])];\n\t\treturn { generation: crypto.createHash(\"sha256\").update(`${physical?.generation ?? \"missing\"}:${factory?.generation ?? 0}:${Number(this.#isDisposed)}:${Number(this.#disposeCompleted)}`).digest(\"hex\"),\n\t\t\tcomplete: unknown.length === 0, starting: factory?.pending ?? 0, queued: 0,\n\t\t\trunning: (physical?.running ?? 0) + this.#physicalPromptCompletions.size + this.#physicalPostPromptTasks.size + Number(!this.#isDisposed),\n\t\t\tsettling: this.#isDisposed && !this.#disposeCompleted ? 1 : 0, unknown: [...new Set(unknown)] };\n\t}\n\treadonly #disposeAbortController = new AbortController();\n\t#disposeAdmissionClosed: Promise | undefined;\n\t#disposePostPromptDrain: Promise | undefined;\n\t#disposeDeadline = 0;\n\t#disposeDeadlineTimer: NodeJS.Timeout | undefined;\n\t#disposeDeadlineExpired: PromiseWithResolvers | undefined;\n" + } + ] + }, + { + "package": "@gajae-code/agent-core", + "path": "src/run-resource-ledger.ts", + "beforeSha256": "a254f4810c34f61fb8eb1f9ef1245d7dfa72223a5424b9117b2eb60f79610938", + "afterSha256": "ef54970d42a667e786a92c622d5e89d2d624e3e30ee37842aedddd546efcf390", + "replacements": [ + { + "before": "} from \"./types\";\n\nconst MAX_TOMBSTONE_ENTRIES = 256;\ntype RunLifecycle = \"open\" | \"sealed\" | \"quarantined\";\n\ninterface TrackedResource {\n", + "after": "} from \"./types\";\n\nconst MAX_TOMBSTONE_ENTRIES = 256;\n// App-owned patch: physical promises survive logical quarantine/tombstone eviction.\nexport const GJC_APP_LIFECYCLE_PATCH = \"gjc-sdk-lifecycle-v1\";\nconst physicalOwners = new WeakMap): void; join(): Promise }>();\n/** Physical-only ownership must not add a logical waitForSettlement dependency. */\nexport function retainPhysicalRunResource(ledger: RunResourceLedger, promise: PromiseLike): void {\n\tconst owner = physicalOwners.get(ledger);\n\tif (!owner) throw new Error(\"Physical resource ownership is unavailable\");\n\towner.retain(promise);\n}\n/** Terminal join: deny fresh runs, but retain already-owned physical promises. */\nexport function awaitPhysicalRunResources(ledger: RunResourceLedger): Promise {\n\tconst owner = physicalOwners.get(ledger);\n\tif (!owner) return Promise.reject(new Error(\"Physical resource ownership is unavailable\"));\n\treturn owner.join();\n}\ntype RunLifecycle = \"open\" | \"sealed\" | \"quarantined\";\n\ninterface TrackedResource {\n" + }, + { + "before": "}\n\nexport function createRunResourceLedger(): RunResourceLedger {\n\tconst runs = new Map();\n\tconst standaloneDomains = new Map();\n\tconst standaloneReleased = new Set();\n", + "after": "}\n\nexport function createRunResourceLedger(): RunResourceLedger {\n\tconst physical = new Set>();\n\tlet physicalAdmissionClosed = false;\n\tconst runs = new Map();\n\tconst standaloneDomains = new Map();\n\tconst standaloneReleased = new Set();\n" + }, + { + "before": "\n\tconst observeSettlement = (settled: PromiseLike, onSettled: () => void): void => {\n\t\ttry {\n\t\t\tvoid Promise.resolve(settled).then(onSettled, onSettled);\n\t\t} catch {\n\t\t\tonSettled();\n\t\t}\n", + "after": "\n\tconst observeSettlement = (settled: PromiseLike, onSettled: () => void): void => {\n\t\ttry {\n\t\t\tconst promise = Promise.resolve(settled);\n\t\t\tphysical.add(promise);\n\t\t\tconst finish = () => {\n\t\t\t\ttry { onSettled(); }\n\t\t\t\tfinally { physical.delete(promise); }\n\t\t\t};\n\t\t\tvoid promise.then(finish, finish);\n\t\t} catch {\n\t\t\tonSettled();\n\t\t}\n" + }, + { + "before": "\t\treturn lease;\n\t};\n\n\treturn {\n\t\tbindCancellationDomainBridge(nextBridge) {\n\t\t\tif (bridge && bridge !== nextBridge) throw new Error(\"Run cancellation domain bridge is already bound\");\n\t\t\tbridge = nextBridge;\n", + "after": "\t\treturn lease;\n\t};\n\n\tconst ledger: RunResourceLedger = {\n\t\tbindCancellationDomainBridge(nextBridge) {\n\t\t\tif (bridge && bridge !== nextBridge) throw new Error(\"Run cancellation domain bridge is already bound\");\n\t\t\tbridge = nextBridge;\n" + }, + { + "before": "\t\t},\n\t\topen(resourceRunId) {\n\t\t\tconst existing = runs.get(resourceRunId);\n\t\t\tif (existing) return existing.lifecycle === \"open\" ? existing.domain : undefined;\n\t\t\tconst domainBridge = bridge ?? standaloneBridge;\n\t\t\tconst opened = domainBridge.open(resourceRunId);\n", + "after": "\t\t},\n\t\topen(resourceRunId) {\n\t\t\tconst existing = runs.get(resourceRunId);\n\t\t\tif (physicalAdmissionClosed) return undefined;\n\t\t\tif (existing) return existing.lifecycle === \"open\" ? existing.domain : undefined;\n\t\t\tconst domainBridge = bridge ?? standaloneBridge;\n\t\t\tconst opened = domainBridge.open(resourceRunId);\n" + }, + { + "before": "\t\t\treturn state ? quarantineState(state) : [];\n\t\t},\n\t};\n}\n", + "after": "\t\t\treturn state ? quarantineState(state) : [];\n\t\t},\n\t};\n\tphysicalOwners.set(ledger, {\n\t\tretain: promise => observeSettlement(promise, () => {}),\n\t\tjoin: async () => {\n\t\t\tphysicalAdmissionClosed = true;\n\t\t\twhile (physical.size > 0) await Promise.allSettled([...physical]);\n\t\t},\n\t});\n\treturn ledger;\n}\n" + }, + { + "before": "\tRunSettlementProof,\n} from \"./types\";\n\nconst MAX_TOMBSTONE_ENTRIES = 256;\n// App-owned patch: physical promises survive logical quarantine/tombstone eviction.\nexport const GJC_APP_LIFECYCLE_PATCH = \"gjc-sdk-lifecycle-v1\";\nconst physicalOwners = new WeakMap): void; join(): Promise }>();\n/** Physical-only ownership must not add a logical waitForSettlement dependency. */\nexport function retainPhysicalRunResource(ledger: RunResourceLedger, promise: PromiseLike): void {\n\tconst owner = physicalOwners.get(ledger);\n\tif (!owner) throw new Error(\"Physical resource ownership is unavailable\");\n\towner.retain(promise);\n}\n", + "after": "\tRunSettlementProof,\n} from \"./types\";\n\nconst MAX_TOMBSTONE_ENTRIES = 256;\n// App-owned patch: physical promises survive logical quarantine/tombstone eviction.\nexport const GJC_APP_LIFECYCLE_PATCH = \"gjc-sdk-lifecycle-v1\";\nexport interface AppPhysicalActivity {\n\tgeneration: string; complete: boolean; starting: number; queued: number;\n\trunning: number; settling: number; unknown: string[];\n}\nconst physicalOwners = new WeakMap): void; join(): Promise;\n\tunknown(reason: string): void; activity(): AppPhysicalActivity;\n}>();\nexport function markPhysicalRunResourceUnknown(ledger: RunResourceLedger, reason: string): void {\n\tphysicalOwners.get(ledger)?.unknown(reason);\n}\nexport function getPhysicalRunResourceActivity(ledger: RunResourceLedger): AppPhysicalActivity {\n\treturn physicalOwners.get(ledger)?.activity() ?? {\n\t\tgeneration: \"unrepresented\", complete: false, starting: 0, queued: 0, running: 0, settling: 0,\n\t\tunknown: [\"sdk_physical_ledger_unrepresented\"],\n\t};\n}\n/** Physical-only ownership must not add a logical waitForSettlement dependency. */\nexport function retainPhysicalRunResource(ledger: RunResourceLedger, promise: PromiseLike): void {\n\tconst owner = physicalOwners.get(ledger);\n\tif (!owner) throw new Error(\"Physical resource ownership is unavailable\");\n\towner.retain(promise);\n}\n" + }, + { + "before": "function copyEntries(entries: readonly RunResourceEntry[]): RunResourceEntry[] {\n\treturn entries.map(entry => ({ ...entry }));\n}\n\nexport function createRunResourceLedger(): RunResourceLedger {\n\tconst physical = new Set>();\n\tlet physicalAdmissionClosed = false;\n\tconst runs = new Map();\n\tconst standaloneDomains = new Map();\n\tconst standaloneReleased = new Set();\n\tconst standaloneQuarantined = new Set();\n\tlet bridge: RunCancellationDomainBridge | undefined;\n", + "after": "function copyEntries(entries: readonly RunResourceEntry[]): RunResourceEntry[] {\n\treturn entries.map(entry => ({ ...entry }));\n}\n\nexport function createRunResourceLedger(): RunResourceLedger {\n\tconst physical = new Set>();\n\tconst physicalEpoch = crypto.randomUUID();\n\tlet physicalRevision = 0;\n\tconst physicalUnknown = new Set();\n\tlet physicalAdmissionClosed = false;\n\tconst runs = new Map();\n\tconst standaloneDomains = new Map();\n\tconst standaloneReleased = new Set();\n\tconst standaloneQuarantined = new Set();\n\tlet bridge: RunCancellationDomainBridge | undefined;\n" + }, + { + "before": "\t};\n\n\tconst observeSettlement = (settled: PromiseLike, onSettled: () => void): void => {\n\t\ttry {\n\t\t\tconst promise = Promise.resolve(settled);\n\t\t\tphysical.add(promise);\n\t\t\tconst finish = () => {\n\t\t\t\ttry { onSettled(); }\n\t\t\t\tfinally { physical.delete(promise); }\n\t\t\t};\n\t\t\tvoid promise.then(finish, finish);\n\t\t} catch {\n\t\t\tonSettled();\n\t\t}\n\t};\n", + "after": "\t};\n\n\tconst observeSettlement = (settled: PromiseLike, onSettled: () => void): void => {\n\t\ttry {\n\t\t\tconst promise = Promise.resolve(settled);\n\t\t\tphysical.add(promise);\n\t\t\tphysicalRevision++;\n\t\t\tconst finish = () => {\n\t\t\t\ttry { onSettled(); }\n\t\t\t\tfinally { physical.delete(promise); physicalRevision++; }\n\t\t\t};\n\t\t\tvoid promise.then(finish, finish);\n\t\t} catch {\n\t\t\tonSettled();\n\t\t}\n\t};\n" + }, + { + "before": "\t\tquarantine(resourceRunId) {\n\t\t\tconst state = runs.get(resourceRunId);\n\t\t\treturn state ? quarantineState(state) : [];\n\t\t},\n\t};\n\tphysicalOwners.set(ledger, {\n\t\tretain: promise => observeSettlement(promise, () => {}),\n\t\tjoin: async () => {\n\t\t\tphysicalAdmissionClosed = true;\n\t\t\twhile (physical.size > 0) await Promise.allSettled([...physical]);\n\t\t},\n\t});\n", + "after": "\t\tquarantine(resourceRunId) {\n\t\t\tconst state = runs.get(resourceRunId);\n\t\t\treturn state ? quarantineState(state) : [];\n\t\t},\n\t};\n\tphysicalOwners.set(ledger, {\n\t\tunknown: reason => { if (!physicalUnknown.has(reason)) { physicalUnknown.add(reason); physicalRevision++; } },\n\t\tactivity: () => ({ generation: `${physicalEpoch}:${physicalRevision}`, complete: physicalUnknown.size === 0,\n\t\t\tstarting: 0, queued: 0, running: physical.size, settling: 0, unknown: [...physicalUnknown] }),\n\t\tretain: promise => observeSettlement(promise, () => {}),\n\t\tjoin: async () => {\n\t\t\tphysicalAdmissionClosed = true;\n\t\t\twhile (physical.size > 0) await Promise.allSettled([...physical]);\n\t\t},\n\t});\n" + } + ] + }, + { + "package": "@gajae-code/agent-core", + "path": "src/agent-loop.ts", + "beforeSha256": "3bf15d130e1613898fae3f77417673f54c4cef00f71e2602319fe9afb5290cee", + "afterSha256": "efccd78060495147b0475aabb69fe10c5cefdabe7bf88b618b16c0ad49e6eab6", + "replacements": [ + { + "before": "\tverifyUnicodeEscapeEvidence,\n} from \"@gajae-code/ai/utils/json-parse\";\nimport { $credentialEnv, sanitizeText } from \"@gajae-code/utils\";\nimport * as logger from \"@gajae-code/utils/logger\";\nimport { revokeProviderSafetyStop } from \"../../ai/src/adapter-internals/provider-safety-stop\";\nimport type { AttemptScope } from \"./attempt-scope\";\nimport {\n\tcreateHarmonyAuditEvent,\n\tdetectHarmonyLeakInAssistantMessage,\n\textractHarmonyRemoved,\n\ttype HarmonyDetection,\n\ttype HarmonyRecoveredToolCall,\n", + "after": "\tverifyUnicodeEscapeEvidence,\n} from \"@gajae-code/ai/utils/json-parse\";\nimport { $credentialEnv, sanitizeText } from \"@gajae-code/utils\";\nimport * as logger from \"@gajae-code/utils/logger\";\nimport { revokeProviderSafetyStop } from \"../../ai/src/adapter-internals/provider-safety-stop\";\nimport type { AttemptScope } from \"./attempt-scope\";\nimport { retainPhysicalRunResource } from \"./run-resource-ledger\";\nimport {\n\tcreateHarmonyAuditEvent,\n\tdetectHarmonyLeakInAssistantMessage,\n\textractHarmonyRemoved,\n\ttype HarmonyDetection,\n\ttype HarmonyRecoveredToolCall,\n" + }, + { + "before": "\tsignal?: AbortSignal,\n\tstreamFn?: StreamFn,\n\temitAgentStart = true,\n\tinitialScope?: AttemptScope,\n): EventStream {\n\tconst stream = createAgentStream();\n\n\t(async () => {\n\t\tconst newMessages: AgentMessage[] = [...prompts];\n\t\tconst currentContext: AgentContext = {\n\t\t\t...context,\n\t\t\tmessages: [...context.messages, ...prompts],\n", + "after": "\tsignal?: AbortSignal,\n\tstreamFn?: StreamFn,\n\temitAgentStart = true,\n\tinitialScope?: AttemptScope,\n): EventStream {\n\tconst stream = createAgentStream();\n\tconst physical = Promise.withResolvers();\n\tif (config.resourceLedger) retainPhysicalRunResource(config.resourceLedger, physical.promise);\n\n\t(async () => {\n\t\tconst newMessages: AgentMessage[] = [...prompts];\n\t\tconst currentContext: AgentContext = {\n\t\t\t...context,\n\t\t\tmessages: [...context.messages, ...prompts],\n" + }, + { + "before": "\t\t\t\tstream.push({ type: \"message_end\", message: prompt, scope });\n\t\t\t}\n\t\t\tawait runLoop(currentContext, newMessages, config, signal, stream, streamFn, transaction, scope);\n\t\t} catch (err) {\n\t\t\tsealStandaloneOnError(config);\n\t\t\tstream.fail(err);\n\t\t}\n\t})();\n\n\treturn stream;\n}\n\n", + "after": "\t\t\t\tstream.push({ type: \"message_end\", message: prompt, scope });\n\t\t\t}\n\t\t\tawait runLoop(currentContext, newMessages, config, signal, stream, streamFn, transaction, scope);\n\t\t} catch (err) {\n\t\t\tsealStandaloneOnError(config);\n\t\t\tstream.fail(err);\n\t\t} finally {\n\t\t\tphysical.resolve();\n\t\t}\n\t})();\n\n\treturn stream;\n}\n\n" + }, + { + "before": "\n\tif (context.messages[context.messages.length - 1].role === \"assistant\") {\n\t\tthrow new Error(\"Cannot continue from message role: assistant\");\n\t}\n\n\tconst stream = createAgentStream();\n\n\t(async () => {\n\t\tconst newMessages: AgentMessage[] = [];\n\t\tconst currentContext: AgentContext = { ...context };\n\t\t// Allocate before constructing the provisional transaction so every first turn\n\t\t// has one stable scope for lifecycle events, transform hooks, and transport.\n", + "after": "\n\tif (context.messages[context.messages.length - 1].role === \"assistant\") {\n\t\tthrow new Error(\"Cannot continue from message role: assistant\");\n\t}\n\n\tconst stream = createAgentStream();\n\tconst physical = Promise.withResolvers();\n\tif (config.resourceLedger) retainPhysicalRunResource(config.resourceLedger, physical.promise);\n\n\t(async () => {\n\t\tconst newMessages: AgentMessage[] = [];\n\t\tconst currentContext: AgentContext = { ...context };\n\t\t// Allocate before constructing the provisional transaction so every first turn\n\t\t// has one stable scope for lifecycle events, transform hooks, and transport.\n" + }, + { + "before": "\t\t\tif (emitAgentStart) stream.push({ type: \"agent_start\", ...(scope ? { scope } : {}) });\n\t\t\tattemptStream.push({ type: \"turn_start\", ...(scope ? { scope } : {}) });\n\t\t\tawait runLoop(currentContext, newMessages, config, signal, stream, streamFn, transaction, scope);\n\t\t} catch (err) {\n\t\t\tsealStandaloneOnError(config);\n\t\t\tstream.fail(err);\n\t\t}\n\t})();\n\n\treturn stream;\n}\n\n", + "after": "\t\t\tif (emitAgentStart) stream.push({ type: \"agent_start\", ...(scope ? { scope } : {}) });\n\t\t\tattemptStream.push({ type: \"turn_start\", ...(scope ? { scope } : {}) });\n\t\t\tawait runLoop(currentContext, newMessages, config, signal, stream, streamFn, transaction, scope);\n\t\t} catch (err) {\n\t\t\tsealStandaloneOnError(config);\n\t\t\tstream.fail(err);\n\t\t} finally {\n\t\t\tphysical.resolve();\n\t\t}\n\t})();\n\n\treturn stream;\n}\n\n" + }, + { + "before": "\tunicodeEscapeScalarTag,\n\tverifyUnicodeEscapeEvidence,\n} from \"@gajae-code/ai/utils/json-parse\";\nimport { $credentialEnv, sanitizeText } from \"@gajae-code/utils\";\nimport * as logger from \"@gajae-code/utils/logger\";\nimport { revokeProviderSafetyStop } from \"../../ai/src/adapter-internals/provider-safety-stop\";\nimport type { AttemptScope } from \"./attempt-scope\";\nimport { retainPhysicalRunResource } from \"./run-resource-ledger\";\nimport {\n\tcreateHarmonyAuditEvent,\n\tdetectHarmonyLeakInAssistantMessage,\n\textractHarmonyRemoved,\n\ttype HarmonyDetection,\n\ttype HarmonyRecoveredToolCall,\n", + "after": "\tunicodeEscapeScalarTag,\n\tverifyUnicodeEscapeEvidence,\n} from \"@gajae-code/ai/utils/json-parse\";\nimport { $credentialEnv, sanitizeText } from \"@gajae-code/utils\";\nimport * as logger from \"@gajae-code/utils/logger\";\nimport { revokeProviderSafetyStop } from \"../../ai/src/adapter-internals/provider-safety-stop\";\nimport { getAppStreamProducer } from \"../../ai/src/utils/event-stream\";\nimport type { AttemptScope } from \"./attempt-scope\";\nimport { retainPhysicalRunResource, markPhysicalRunResourceUnknown } from \"./run-resource-ledger\";\nimport {\n\tcreateHarmonyAuditEvent,\n\tdetectHarmonyLeakInAssistantMessage,\n\textractHarmonyRemoved,\n\ttype HarmonyDetection,\n\ttype HarmonyRecoveredToolCall,\n" + }, + { + "before": "\t\t\tconst { promise: iteratorSettled, resolve: settleIterator } = Promise.withResolvers();\n\t\t\tlet responseResultPromise: Promise | undefined;\n\t\t\tlet responseForResult: { result(): Promise } | undefined;\n\t\t\tconst getResponseResult = (): Promise =>\n\t\t\t\t(responseResultPromise ??= Promise.resolve().then(() => responseForResult!.result()));\n\t\t\tconst providerLifecycle = responsePromise.then(async response => {\n\t\t\t\tresponseForResult = response;\n\t\t\t\tawait iteratorSettled;\n\t\t\t\tawait Promise.allSettled([getResponseResult()]);\n\t\t\t});\n\t\t\tconst closeLateFactoryResponse = (): void => {\n\t\t\t\tvoid responsePromise.then(\n", + "after": "\t\t\tconst { promise: iteratorSettled, resolve: settleIterator } = Promise.withResolvers();\n\t\t\tlet responseResultPromise: Promise | undefined;\n\t\t\tlet responseForResult: { result(): Promise } | undefined;\n\t\t\tconst getResponseResult = (): Promise =>\n\t\t\t\t(responseResultPromise ??= Promise.resolve().then(() => responseForResult!.result()));\n\t\t\tconst providerLifecycle = responsePromise.then(async response => {\n\t\t\t\tif (config.resourceLedger) {\n\t\t\t\t\tconst ledger = config.resourceLedger;\n\t\t\t\t\tconst producer = getAppStreamProducer(response);\n\t\t\t\t\tif (producer) {\n\t\t\t\t\t\t// Independent physical retention: terminal publication must not wait on\n\t\t\t\t\t\t// a consumer-dependent finally, nor discharge that producer's tail.\n\t\t\t\t\t\tretainPhysicalRunResource(ledger, producer.completion.then(() => {\n\t\t\t\t\t\t\tfor (const reason of producer.getUnknown()) markPhysicalRunResourceUnknown(ledger, reason);\n\t\t\t\t\t\t}));\n\t\t\t\t\t} else markPhysicalRunResourceUnknown(ledger, \"sdk_provider_producer_unrepresented\");\n\t\t\t\t}\n\t\t\t\tresponseForResult = response;\n\t\t\t\tawait iteratorSettled;\n\t\t\t\tawait Promise.allSettled([getResponseResult()]);\n\t\t\t});\n\t\t\tconst closeLateFactoryResponse = (): void => {\n\t\t\t\tvoid responsePromise.then(\n" + } + ] + }, + { + "package": "@gajae-code/coding-agent", + "path": "src/config/model-registry.ts", + "beforeSha256": "e5afb12f7447014ec6772dabb33b68731aac601d13d85e6585342771673f5301", + "afterSha256": "60529fec5306da95d08127b014ee7b13e83adf6a5e6ebdfff3df53010fff4f67", + "replacements": [ + { + "before": "\ttype ProviderSelectionPolicy,\n\tprojectCatalogProviderOrder,\n} from \"./provider-selection-policy\";\nimport { type Settings, settings } from \"./settings\";\n\nexport type { BillingPath, BillingPathKind } from \"./billing-path\";\nexport type { EffectiveProviderAuth, ProviderSelectionPolicy } from \"./provider-selection-policy\";\n", + "after": "\ttype ProviderSelectionPolicy,\n\tprojectCatalogProviderOrder,\n} from \"./provider-selection-policy\";\nimport { type Settings, isSettingsInitialized, settings } from \"./settings\";\n\nexport type { BillingPath, BillingPathKind } from \"./billing-path\";\nexport type { EffectiveProviderAuth, ProviderSelectionPolicy } from \"./provider-selection-policy\";\n" + }, + { + "before": " */\nexport class ModelRegistry {\n\t#models: Model[] = [];\n\t#catalogChangeListeners: Set<() => void> = new Set();\n\t#canonicalIndex: CanonicalModelIndex = {\n\t\trecords: [],\n\t\tbyId: new Map(),\n", + "after": " */\nexport class ModelRegistry {\n\t#models: Model[] = [];\n\t#catalogChangeListeners: Set<() => void | Promise> = new Set();\n\treadonly #appCatalogCallbacks = new Set>();\n\treadonly #appLifecycleEpoch = crypto.randomUUID();\n\t#appLifecycleRevision = 0;\n\t#appLifecycleFenced = false;\n\t#appDisposing = false;\n\t#appSettingsUnaccounted = false;\n\t#appSettingsFailure = false;\n\t#appDeferredRefresh: ModelRefreshStrategy | undefined;\n\t#canonicalIndex: CanonicalModelIndex = {\n\t\trecords: [],\n\t\tbyId: new Map(),\n" + }, + { + "before": "\t#loadedModelPresetRegistryManifestSha256: string | undefined;\n\t#modelPresetRegistryAgentDir: string;\n\t#modelPresetRegistryDependencies: Omit;\n\t#cancelModelPresetRegistryRefresh: (() => Promise) | undefined;\n\t#disposePromise: Promise | undefined;\n\t#unsubscribeAuthGeneration: (() => void) | undefined;\n\t#staticModelsLoaded = false;\n", + "after": "\t#loadedModelPresetRegistryManifestSha256: string | undefined;\n\t#modelPresetRegistryAgentDir: string;\n\t#modelPresetRegistryDependencies: Omit;\n\t#cancelModelPresetRegistryRefresh: ReturnType | undefined;\n\t#disposePromise: Promise | undefined;\n\t#unsubscribeAuthGeneration: (() => void) | undefined;\n\t#staticModelsLoaded = false;\n" + }, + { + "before": "\t\tmodelPresetRegistryDependencies: ModelPresetRegistryDependencies = {},\n\t) {\n\t\tthis.#settings = registrySettings ?? settings;\n\t\tconst configuredAgentDir = path.resolve(modelPresetRegistryDependencies.agentDir ?? getAgentDir());\n\t\tthis.#modelsConfigFile = ModelsConfigFile.relocate(modelsPath);\n\t\tthis.#modelPresetRegistryAgentDir = modelPresetRegistryDependencies.agentDir\n", + "after": "\t\tmodelPresetRegistryDependencies: ModelPresetRegistryDependencies = {},\n\t) {\n\t\tthis.#settings = registrySettings ?? settings;\n\t\tthis.#appSettingsUnaccounted = registrySettings !== undefined\n\t\t\t&& typeof (registrySettings as Partial>).flushOrThrow !== \"function\";\n\t\tconst configuredAgentDir = path.resolve(modelPresetRegistryDependencies.agentDir ?? getAgentDir());\n\t\tthis.#modelsConfigFile = ModelsConfigFile.relocate(modelsPath);\n\t\tthis.#modelPresetRegistryAgentDir = modelPresetRegistryDependencies.agentDir\n" + }, + { + "before": "\t\t\t\tagentDir: this.#modelPresetRegistryAgentDir,\n\t\t\t\tknownManifestSha256: this.#loadedModelPresetRegistryManifestSha256,\n\t\t\t},\n\t\t\t() => {\n\t\t\t\tvoid this.#enqueueCatalogMutation(() => {\n\t\t\t\t\tif (this.#disposed) return;\n\t\t\t\t\tthis.#reloadStaticModels();\n\t\t\t\t\tthis.#modelBindingsApplier.apply();\n\t\t\t\t\tthis.#notifyCatalogChanged();\n\t\t\t\t}).catch(() => undefined);\n\t\t\t},\n\t\t);\n\t}\n\n\tdispose(): Promise {\n\t\tif (this.#disposePromise) return this.#disposePromise;\n\t\tthis.#disposed = true;\n\t\tthis.#catalogRefreshGeneration++;\n\t\tconst awaitRefreshDisposal = this.#cancelModelPresetRegistryRefresh?.() ?? Promise.resolve();\n\t\tthis.#cancelModelPresetRegistryRefresh = undefined;\n", + "after": "\t\t\t\tagentDir: this.#modelPresetRegistryAgentDir,\n\t\t\t\tknownManifestSha256: this.#loadedModelPresetRegistryManifestSha256,\n\t\t\t},\n\t\t\tasync () => {\n\t\t\t\tawait this.#enqueueCatalogMutation(() => {\n\t\t\t\t\tif (this.#disposed) return;\n\t\t\t\t\tthis.#reloadStaticModels();\n\t\t\t\t\tthis.#modelBindingsApplier.apply();\n\t\t\t\t\tthis.#notifyCatalogChanged();\n\t\t\t\t});\n\t\t\t\tawait Promise.allSettled([...this.#appCatalogCallbacks]);\n\t\t\t\tawait this.#flushAppSettings();\n\t\t\t},\n\t\t);\n\t}\n\n\t/** Registry maintenance only; borrowed AuthStorage/Settings require separate owners. */\n\tgetAppLifecycleActivity(): {\n\t\tgeneration: string; complete: boolean; starting: number; queued: number;\n\t\trunning: number; settling: number; unknown: string[];\n\t} {\n\t\tconst helper = this.#cancelModelPresetRegistryRefresh?.getActivity();\n\t\tconst unknown = this.#appLifecycleFenced ? [] : [\"model_registry_admission_open\"];\n\t\tif (!helper && !this.#disposed) unknown.push(\"model_registry_maintenance_unaccounted\");\n\t\tif (this.#appSettingsUnaccounted) unknown.push(\"model_registry_settings_unaccounted\");\n\t\tif (this.#appSettingsFailure) unknown.push(\"model_registry_settings_unconfirmed\");\n\t\treturn {\n\t\t\tgeneration: `${this.#appLifecycleEpoch}:${this.#appLifecycleRevision}:${helper?.revision ?? 0}`,\n\t\t\tcomplete: unknown.length === 0,\n\t\t\tstarting: Number(helper?.scheduled === true), queued: this.#pendingCatalogMutations,\n\t\t\trunning: (helper?.pending ?? 0) + Number(this.#backgroundRefresh !== undefined),\n\t\t\tsettling: this.#appCatalogCallbacks.size + Number(this.#appDisposing), unknown,\n\t\t};\n\t}\n\n\tsetAppLifecycleAdmission(closed: boolean): void {\n\t\tif (this.#disposed || this.#appLifecycleFenced === closed) return;\n\t\tthis.#appLifecycleFenced = closed; this.#appLifecycleRevision++;\n\t\tthis.#cancelModelPresetRegistryRefresh?.setAdmissionFence(closed);\n\t\tif (!closed && this.#appDeferredRefresh !== undefined) {\n\t\t\tconst strategy = this.#appDeferredRefresh; this.#appDeferredRefresh = undefined;\n\t\t\tthis.refreshInBackground(strategy);\n\t\t}\n\t}\n\n\tasync awaitAppLifecycleSettlement(): Promise {\n\t\tif (!this.#appLifecycleFenced && !this.#disposed) throw new Error(\"Model registry admission is not fenced\");\n\t\tdo {\n\t\t\tawait Promise.allSettled([\n\t\t\t\tthis.#cancelModelPresetRegistryRefresh?.awaitSettlement(), this.#backgroundRefresh,\n\t\t\t\tthis.#catalogMutationTail, ...this.#appCatalogCallbacks,\n\t\t\t]);\n\t\t\tif (!this.#appLifecycleFenced && !this.#disposed) throw new Error(\"Model registry admission fence changed\");\n\t\t} while ((this.#cancelModelPresetRegistryRefresh?.getActivity().pending ?? 0) > 0\n\t\t\t|| this.#backgroundRefresh || this.#pendingCatalogMutations > 0 || this.#appCatalogCallbacks.size > 0);\n\t\tif (this.#appSettingsFailure) throw new Error(\"Model registry settings persistence is unconfirmed\");\n\t}\n\n\tasync #flushAppSettings(): Promise {\n\t\t// The default proxy has no object or pending writes before initialization.\n\t\tif (this.#settings === settings && !isSettingsInitialized()) return;\n\t\tconst owner = this.#settings as Partial>;\n\t\tif (typeof owner.flushOrThrow !== \"function\") return;\n\t\ttry { await owner.flushOrThrow(); }\n\t\tcatch (error) { this.#appSettingsFailure = true; this.#appLifecycleRevision++; throw error; }\n\t}\n\n\tdispose(): Promise {\n\t\tif (this.#disposePromise) return this.#disposePromise;\n\t\tthis.#disposed = true;\n\t\tthis.#appDisposing = true;\n\t\tthis.#appLifecycleFenced = true; this.#appLifecycleRevision++;\n\t\tthis.#catalogRefreshGeneration++;\n\t\tconst awaitRefreshDisposal = this.#cancelModelPresetRegistryRefresh?.() ?? Promise.resolve();\n\t\tthis.#cancelModelPresetRegistryRefresh = undefined;\n" + }, + { + "before": "\t\tthis.#disposeAuthStorageFallbackResolver?.();\n\t\tthis.#disposeAuthStorageFallbackResolver = undefined;\n\t\tthis.#catalogChangeListeners.clear();\n\t\tthis.#disposePromise = awaitRefreshDisposal;\n\t\treturn this.#disposePromise;\n\t}\n\n\t#enqueueCatalogMutation(operation: () => void | Promise): Promise {\n\t\tthis.#pendingCatalogMutations++;\n\t\tlet run: Promise;\n\t\tif (this.#pendingCatalogMutations === 1) {\n\t\t\ttry {\n", + "after": "\t\tthis.#disposeAuthStorageFallbackResolver?.();\n\t\tthis.#disposeAuthStorageFallbackResolver = undefined;\n\t\tthis.#catalogChangeListeners.clear();\n\t\tthis.#disposePromise = awaitRefreshDisposal.then(() => this.awaitAppLifecycleSettlement()).finally(() => {\n\t\t\tthis.#appDisposing = false; this.#appLifecycleRevision++;\n\t\t});\n\t\treturn this.#disposePromise;\n\t}\n\n\t#enqueueCatalogMutation(operation: () => void | Promise): Promise {\n\t\tthis.#pendingCatalogMutations++;\n\t\tthis.#appLifecycleRevision++;\n\t\tlet run: Promise;\n\t\tif (this.#pendingCatalogMutations === 1) {\n\t\t\ttry {\n" + }, + { + "before": "\t\t}\n\t\tconst completion = run.finally(() => {\n\t\t\tthis.#pendingCatalogMutations--;\n\t\t});\n\t\tthis.#catalogMutationTail = completion.catch(() => undefined);\n\t\treturn completion;\n\t}\n\n\tonCatalogChanged(listener: () => void): () => void {\n\t\tthis.#catalogChangeListeners.add(listener);\n\t\treturn () => {\n\t\t\tthis.#catalogChangeListeners.delete(listener);\n", + "after": "\t\t}\n\t\tconst completion = run.finally(() => {\n\t\t\tthis.#pendingCatalogMutations--;\n\t\t\tthis.#appLifecycleRevision++;\n\t\t});\n\t\tthis.#catalogMutationTail = completion.catch(() => undefined);\n\t\treturn completion;\n\t}\n\n\tonCatalogChanged(listener: () => void | Promise): () => void {\n\t\tthis.#catalogChangeListeners.add(listener);\n\t\treturn () => {\n\t\t\tthis.#catalogChangeListeners.delete(listener);\n" + }, + { + "before": "\tsetScopedSettings(settingsReader: Pick): void {\n\t\tthis.#catalogRefreshGeneration++;\n\t\tthis.#settings = settingsReader;\n\t\tthis.#staticModelsLoaded = false;\n\t\tthis.#reloadStaticModels();\n\t\tthis.#rebuildCanonicalIndex();\n", + "after": "\tsetScopedSettings(settingsReader: Pick): void {\n\t\tthis.#catalogRefreshGeneration++;\n\t\tthis.#settings = settingsReader;\n\t\tthis.#appSettingsUnaccounted = typeof (settingsReader as Partial>).flushOrThrow !== \"function\";\n\t\tthis.#appLifecycleRevision++;\n\t\tthis.#staticModelsLoaded = false;\n\t\tthis.#reloadStaticModels();\n\t\tthis.#rebuildCanonicalIndex();\n" + }, + { + "before": "\t}\n\n\trefreshInBackground(strategy: ModelRefreshStrategy = \"online-if-uncached\"): void {\n\t\tif (this.#backgroundRefresh) {\n\t\t\treturn;\n\t\t}\n", + "after": "\t}\n\n\trefreshInBackground(strategy: ModelRefreshStrategy = \"online-if-uncached\"): void {\n\t\tif (this.#disposed) return;\n\t\tif (this.#appLifecycleFenced) {\n\t\t\tif (this.#appDeferredRefresh === undefined) { this.#appDeferredRefresh = strategy; this.#appLifecycleRevision++; }\n\t\t\treturn;\n\t\t}\n\t\tif (this.#backgroundRefresh) {\n\t\t\treturn;\n\t\t}\n" + }, + { + "before": "\t\t\t.finally(() => {\n\t\t\t\tif (this.#backgroundRefresh === refreshPromise) {\n\t\t\t\t\tthis.#backgroundRefresh = undefined;\n\t\t\t\t}\n\t\t\t});\n\t\tthis.#backgroundRefresh = refreshPromise;\n\t}\n\n\tasync refreshProvider(providerId: string, strategy: ModelRefreshStrategy = \"online\"): Promise {\n", + "after": "\t\t\t.finally(() => {\n\t\t\t\tif (this.#backgroundRefresh === refreshPromise) {\n\t\t\t\t\tthis.#backgroundRefresh = undefined;\n\t\t\t\t\tthis.#appLifecycleRevision++;\n\t\t\t\t}\n\t\t\t});\n\t\tthis.#backgroundRefresh = refreshPromise;\n\t\tthis.#appLifecycleRevision++;\n\t}\n\n\tasync refreshProvider(providerId: string, strategy: ModelRefreshStrategy = \"online\"): Promise {\n" + }, + { + "before": "\t}\n\n\t#notifyCatalogChanged(): void {\n\t\tfor (const listener of [...this.#catalogChangeListeners]) {\n\t\t\ttry {\n\t\t\t\tlistener();\n\t\t\t} catch (error) {\n\t\t\t\tlogger.debug(\"ModelRegistry catalog listener failed\", { error: String(error) });\n\t\t\t}\n\t\t}\n\t}\n\n\t#suspendRebuild(): void {\n", + "after": "\t}\n\n\t#notifyCatalogChanged(): void {\n\t\tthis.#appLifecycleRevision++;\n\t\tfor (const listener of [...this.#catalogChangeListeners]) {\n\t\t\ttry {\n\t\t\t\tconst result = listener();\n\t\t\t\tif (result && typeof result.then === \"function\") {\n\t\t\t\t\tconst task = Promise.resolve(result);\n\t\t\t\t\tthis.#appCatalogCallbacks.add(task); this.#appLifecycleRevision++;\n\t\t\t\t\tvoid task.finally(() => {\n\t\t\t\t\t\tthis.#appCatalogCallbacks.delete(task); this.#appLifecycleRevision++;\n\t\t\t\t\t}).catch(error => logger.debug(\"ModelRegistry catalog listener failed\", { error: String(error) }));\n\t\t\t\t}\n\t\t\t} catch (error) {\n\t\t\t\tlogger.debug(\"ModelRegistry catalog listener failed\", { error: String(error) });\n\t\t\t}\n\t\t}\n\t\tif (this.#catalogChangeListeners.size > 0) {\n\t\t\tconst persistence = Promise.allSettled([...this.#appCatalogCallbacks]).then(() => this.#flushAppSettings());\n\t\t\tthis.#appCatalogCallbacks.add(persistence); this.#appLifecycleRevision++;\n\t\t\tvoid persistence.finally(() => {\n\t\t\t\tthis.#appCatalogCallbacks.delete(persistence); this.#appLifecycleRevision++;\n\t\t\t}).catch(() => {});\n\t\t}\n\t}\n\n\t#suspendRebuild(): void {\n" + } + ] + }, + { + "package": "@gajae-code/coding-agent", + "path": "src/config/model-preset-registry.ts", + "beforeSha256": "2bfda0d780c68ca8cc8110940cc28486495bc8016e69d3ebd2326ae561d727df", + "afterSha256": "0a7f77c3d3a1cdf09db189d22bfefd4129de6adf3dc5cf72a18b6ef0a7b77835", + "replacements": [ + { + "before": "\t\tpresetCount: valid ? valid.presets.presets.length + valid.retainedPresets.length : 0,\n\t};\n}\n\nexport function refreshModelPresetRegistryInBackground(\n\tdependencies: ModelPresetRegistryDependencies = {},\n\tonAccepted?: () => void,\n): () => Promise {\n\tif (dependencies.automaticRefresh === false) return async () => {};\n\tconst agentDir = effectiveAgentDir(dependencies);\n\tlet status: ModelPresetRegistryStatus;\n\ttry {\n", + "after": "\t\tpresetCount: valid ? valid.presets.presets.length + valid.retainedPresets.length : 0,\n\t};\n}\n\nexport type ModelPresetRegistryBackgroundController = (() => Promise) & {\n\tgetActivity(): { revision: number; pending: number; scheduled: boolean; deferred: boolean; fenced: boolean };\n\tsetAdmissionFence(closed: boolean): void;\n\tawaitSettlement(): Promise;\n};\n\nexport function refreshModelPresetRegistryInBackground(\n\tdependencies: ModelPresetRegistryDependencies = {},\n\tonAccepted?: () => void | Promise,\n): ModelPresetRegistryBackgroundController {\n\tif (dependencies.automaticRefresh === false) {\n\t\tlet fenced = false; let revision = 0;\n\t\treturn Object.assign(async () => {}, {\n\t\t\tgetActivity: () => ({ revision, pending: 0, scheduled: false, deferred: false, fenced }),\n\t\t\tsetAdmissionFence: (closed: boolean) => { if (fenced !== closed) { fenced = closed; revision++; } },\n\t\t\tawaitSettlement: async () => {},\n\t\t});\n\t}\n\tconst agentDir = effectiveAgentDir(dependencies);\n\tlet status: ModelPresetRegistryStatus;\n\ttry {\n" + }, + { + "before": "\tlet disposal: Promise | undefined;\n\tconst consumer: RefreshFlightConsumer = { cancelled: false };\n\tconst pendingFlights = new Set>();\n\tlet knownManifestSha256 = dependencies.knownManifestSha256 ?? status.manifestSha256;\n\tconst publicationFingerprint = (current: ModelPresetRegistryStatus): string =>\n\t\tJSON.stringify({\n", + "after": "\tlet disposal: Promise | undefined;\n\tconst consumer: RefreshFlightConsumer = { cancelled: false };\n\tconst pendingFlights = new Set>();\n\tconst pendingActivities = new Set>();\n\tlet fenced = false;\n\tlet revision = 0;\n\tlet deferredPublication = false;\n\tlet nextRefreshAt: number | undefined;\n\tconst own = (operation: () => Promise): Promise => {\n\t\tconst task = Promise.resolve().then(operation).finally(() => {\n\t\t\tpendingActivities.delete(task); revision++;\n\t\t});\n\t\tpendingActivities.add(task); revision++;\n\t\tvoid task.catch(() => {});\n\t\treturn task;\n\t};\n\tlet knownManifestSha256 = dependencies.knownManifestSha256 ?? status.manifestSha256;\n\tconst publicationFingerprint = (current: ModelPresetRegistryStatus): string =>\n\t\tJSON.stringify({\n" + }, + { + "before": "\t\t\tcacheHealth: current.cacheHealth,\n\t\t});\n\tlet publishedFingerprint = publicationFingerprint(status);\n\tconst publishCurrentStatus = (): void => {\n\t\tif (cancelled) return;\n\t\ttry {\n\t\t\tconst currentStatus = getModelPresetRegistryStatus({ ...dependencies, agentDir });\n", + "after": "\t\t\tcacheHealth: current.cacheHealth,\n\t\t});\n\tlet publishedFingerprint = publicationFingerprint(status);\n\tconst publishCurrentStatus = async (): Promise => {\n\t\tif (cancelled) return;\n\t\ttry {\n\t\t\tconst currentStatus = getModelPresetRegistryStatus({ ...dependencies, agentDir });\n" + }, + { + "before": "\t\t\tif (currentFingerprint !== publishedFingerprint) {\n\t\t\t\tpublishedFingerprint = currentFingerprint;\n\t\t\t\ttry {\n\t\t\t\t\tonAccepted?.();\n\t\t\t\t} catch {\n\t\t\t\t\t// Consumer publication must not make a durable local control mutation fail.\n\t\t\t\t}\n", + "after": "\t\t\tif (currentFingerprint !== publishedFingerprint) {\n\t\t\t\tpublishedFingerprint = currentFingerprint;\n\t\t\t\ttry {\n\t\t\t\t\tawait onAccepted?.();\n\t\t\t\t} catch {\n\t\t\t\t\t// Consumer publication must not make a durable local control mutation fail.\n\t\t\t\t}\n" + }, + { + "before": "\t\t\t// The origin refresh below records bounded diagnostics; local publication remains best-effort.\n\t\t}\n\t};\n\tconst unsubscribe = subscribeRegistryChanges(agentDir, () => queueMicrotask(publishCurrentStatus));\n\tlet timer: Timer | undefined;\n\tconst schedule = (delayMs: number): void => {\n\t\tif (cancelled) return;\n\t\ttimer = setTimeout(() => {\n\t\t\tif (cancelled) return;\n\t\t\tpublishCurrentStatus();\n\t\t\tconst flight = getRefreshFlight({ ...dependencies, agentDir, knownManifestSha256 }, consumer);\n\t\t\tpendingFlights.add(flight.promise);\n\t\t\tvoid flight.promise\n\t\t\t\t.then(result => {\n\t\t\t\t\tif (result.status === \"updated\") knownManifestSha256 = result.manifestSha256;\n\t\t\t\t\tif (!cancelled) {\n\t\t\t\t\t\tlet shouldPublish = false;\n", + "after": "\t\t\t// The origin refresh below records bounded diagnostics; local publication remains best-effort.\n\t\t}\n\t};\n\tconst queuePublication = (): void => {\n\t\tif (cancelled) return;\n\t\tif (fenced) {\n\t\t\tif (!deferredPublication) { deferredPublication = true; revision++; }\n\t\t\treturn;\n\t\t}\n\t\t// Reserve before the microtask: a later fence cannot disown this callback.\n\t\tvoid own(publishCurrentStatus);\n\t};\n\tconst unsubscribe = subscribeRegistryChanges(agentDir, queuePublication);\n\tlet timer: Timer | undefined;\n\tconst schedule = (delayMs: number): void => {\n\t\tif (cancelled) return;\n\t\tnextRefreshAt = Date.now() + delayMs;\n\t\trevision++;\n\t\tif (fenced) return;\n\t\ttimer = setTimeout(() => {\n\t\t\ttimer = undefined;\n\t\t\tif (cancelled || fenced) return;\n\t\t\tnextRefreshAt = undefined;\n\t\t\tvoid own(async () => {\n\t\t\t\tif (cancelled) return;\n\t\t\t\tconst publication = own(publishCurrentStatus);\n\t\t\t\tconst flight = getRefreshFlight({ ...dependencies, agentDir, knownManifestSha256 }, consumer);\n\t\t\t\tpendingFlights.add(flight.promise);\n\t\t\t\ttry {\n\t\t\t\t\tconst [flightResult] = await Promise.allSettled([flight.promise, publication]);\n\t\t\t\t\tif (flightResult.status === \"rejected\") return;\n\t\t\t\t\tconst result = flightResult.value;\n\t\t\t\t\tif (result.status === \"updated\") knownManifestSha256 = result.manifestSha256;\n\t\t\t\t\tif (!cancelled) {\n\t\t\t\t\t\tlet shouldPublish = false;\n" + }, + { + "before": "\t\t\t\t\t\t} catch {\n\t\t\t\t\t\t\tshouldPublish = result.status === \"updated\";\n\t\t\t\t\t\t}\n\t\t\t\t\t\tif (shouldPublish) onAccepted?.();\n\t\t\t\t\t}\n\t\t\t\t})\n\t\t\t\t.catch(() => undefined)\n\t\t\t\t.finally(() => {\n\t\t\t\t\tpendingFlights.delete(flight.promise);\n\t\t\t\t\tschedule(refreshIntervalMs);\n\t\t\t\t});\n\t\t}, delayMs);\n\t\ttimer.unref?.();\n\t};\n\tschedule(initialDelay);\n\treturn () => {\n\t\tif (disposal) return disposal;\n\t\tcancelled = true;\n\t\tconsumer.cancelled = true;\n\t\tunsubscribe();\n\t\tif (timer) clearTimeout(timer);\n\t\tdisposal = Promise.allSettled([...pendingFlights]).then(() => undefined);\n\t\treturn disposal;\n\t};\n}\n", + "after": "\t\t\t\t\t\t} catch {\n\t\t\t\t\t\t\tshouldPublish = result.status === \"updated\";\n\t\t\t\t\t\t}\n\t\t\t\t\t\tif (shouldPublish) await onAccepted?.();\n\t\t\t\t\t}\n\t\t\t\t} finally {\n\t\t\t\t\tpendingFlights.delete(flight.promise);\n\t\t\t\t\tschedule(refreshIntervalMs);\n\t\t\t\t}\n\t\t\t});\n\t\t}, delayMs);\n\t\ttimer.unref?.();\n\t};\n\tschedule(initialDelay);\n\tconst awaitSettlement = async (): Promise => {\n\t\twhile (pendingActivities.size > 0) await Promise.allSettled([...pendingActivities]);\n\t};\n\tconst cancel = () => {\n\t\tif (disposal) return disposal;\n\t\tcancelled = true;\n\t\tfenced = true; revision++;\n\t\tconsumer.cancelled = true;\n\t\tunsubscribe();\n\t\tif (timer) clearTimeout(timer);\n\t\ttimer = undefined; nextRefreshAt = undefined; deferredPublication = false;\n\t\tdisposal = awaitSettlement();\n\t\treturn disposal;\n\t};\n\treturn Object.assign(cancel, {\n\t\tgetActivity: () => ({ revision, pending: pendingActivities.size,\n\t\t\tscheduled: timer !== undefined, deferred: deferredPublication || (fenced && nextRefreshAt !== undefined), fenced }),\n\t\tsetAdmissionFence: (closed: boolean) => {\n\t\t\tif (cancelled || fenced === closed) return;\n\t\t\tfenced = closed; revision++;\n\t\t\tif (closed) {\n\t\t\t\tif (timer) clearTimeout(timer);\n\t\t\t\ttimer = undefined;\n\t\t\t} else {\n\t\t\t\tif (deferredPublication) { deferredPublication = false; queuePublication(); }\n\t\t\t\tif (nextRefreshAt !== undefined) schedule(Math.max(0, nextRefreshAt - Date.now()));\n\t\t\t}\n\t\t},\n\t\tawaitSettlement,\n\t});\n}\n" + } + ] + }, + { + "package": "@gajae-code/ai", + "path": "src/auth-storage.ts", + "beforeSha256": "0d4cbf3422a1312bb2a3e8eca8522024b7b18980da19f30a565855a3297b5ac8", + "afterSha256": "a005e9f1d9d3dc515ae04668ab364aef497f7fbce093d12fac97182981240581", + "replacements": [ + { + "before": "\t#oauthRefreshLeaseOwner = crypto.randomUUID();\n\n\t#closed = false;\n\n\tconstructor(store: AuthCredentialStore, options: AuthStorageOptions = {}) {\n\t\tthis.#store = store;\n\t\tstore.onSnapshotChanged?.(() => {\n\t\t\tthis.#reloadCredentialRowsFromStore();\n\t\t\tvoid this.reload();\n\t\t});\n\t\tthis.#configValueResolver = options.configValueResolver ?? defaultConfigValueResolver;\n\t\tthis.#usageProviderResolver = options.usageProviderResolver ?? resolveDefaultUsageProvider;\n\t\tthis.#rankingStrategyResolver = options.rankingStrategyResolver ?? resolveDefaultRankingStrategy;\n\t\tthis.#usageCache = new AuthStorageUsageCache(this.#store);\n", + "after": "\t#oauthRefreshLeaseOwner = crypto.randomUUID();\n\n\t#closed = false;\n\treadonly #appLifecycleEpoch = crypto.randomUUID();\n\t#appLifecycleRevision = 0;\n\treadonly #appTasks = new Set>();\n\treadonly #appCallbacks = new Set>();\n\n\t/** Leaf ownership only: callers own ingress; cancellation never denies a continuation. */\n\tgetAppLifecycleActivity(): {\n\t\tgeneration: string; complete: boolean; starting: number; queued: number;\n\t\trunning: number; settling: number; unknown: string[];\n\t} {\n\t\treturn { generation: `${this.#appLifecycleEpoch}:${this.#appLifecycleRevision}:${this.#generation}`,\n\t\t\tcomplete: true, starting: 0, queued: 0, running: this.#appTasks.size,\n\t\t\tsettling: this.#appCallbacks.size, unknown: [] };\n\t}\n\n\tasync awaitAppLifecycleSettlement(): Promise {\n\t\twhile (this.#appTasks.size || this.#appCallbacks.size)\n\t\t\tawait Promise.allSettled([...this.#appTasks, ...this.#appCallbacks]);\n\t}\n\n\t#ownAppTask(operation: () => T | PromiseLike, callback = false): Promise {\n\t\tconst owner = Promise.withResolvers();\n\t\tconst tasks = callback ? this.#appCallbacks : this.#appTasks;\n\t\ttasks.add(owner.promise); this.#appLifecycleRevision++;\n\t\tconst release = () => { tasks.delete(owner.promise); this.#appLifecycleRevision++; owner.resolve(); };\n\t\ttry { return Promise.resolve(operation()).finally(release); }\n\t\tcatch (error) { release(); return Promise.reject(error); }\n\t}\n\n\tconstructor(store: AuthCredentialStore, options: AuthStorageOptions = {}) {\n\t\tthis.#store = store;\n\t\tstore.onSnapshotChanged?.(() => {\n\t\t\tthis.#reloadCredentialRowsFromStore();\n\t\t\tvoid this.reload();\n\t\t});\n\t\tconst configValueResolver = options.configValueResolver ?? defaultConfigValueResolver;\n\t\tthis.#configValueResolver = (config, cacheScope) => this.#ownAppTask(() => configValueResolver.call(this, config, cacheScope));\n\t\tthis.#usageProviderResolver = options.usageProviderResolver ?? resolveDefaultUsageProvider;\n\t\tthis.#rankingStrategyResolver = options.rankingStrategyResolver ?? resolveDefaultRankingStrategy;\n\t\tthis.#usageCache = new AuthStorageUsageCache(this.#store);\n" + }, + { + "before": "\tclose(): void {\n\t\tif (this.#closed) return;\n\t\tthis.#closed = true;\n\t\tthis.#credentialScopeLeases.clear();\n\t\tthis.#sessionCredentialSelectors.clear();\n\t\tthis.#sessionCredentialAutoMasks.clear();\n", + "after": "\tclose(): void {\n\t\tif (this.#closed) return;\n\t\tthis.#closed = true;\n\t\tthis.#appLifecycleRevision++;\n\t\tthis.#credentialScopeLeases.clear();\n\t\tthis.#sessionCredentialSelectors.clear();\n\t\tthis.#sessionCredentialAutoMasks.clear();\n" + }, + { + "before": "\t\t\tlogger.warn(\"onCredentialDisabled listener threw\", { provider: event.provider, error: String(error) });\n\t\t};\n\t\ttry {\n\t\t\tconst result = listener(event);\n\t\t\tif (result && typeof (result as PromiseLike).then === \"function\") {\n\t\t\t\t(result as Promise).catch(logListenerError);\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tlogListenerError(error);\n\t\t}\n", + "after": "\t\t\tlogger.warn(\"onCredentialDisabled listener threw\", { provider: event.provider, error: String(error) });\n\t\t};\n\t\ttry {\n\t\t\tvoid this.#ownAppTask(() => listener(event), true).catch(logListenerError);\n\t\t} catch (error) {\n\t\t\tlogListenerError(error);\n\t\t}\n" + }, + { + "before": "\t\tif (providerImpl.supports && !providerImpl.supports(params)) return null;\n\n\t\ttry {\n\t\t\treturn await providerImpl.fetchUsage(params, {\n\t\t\t\tfetch: this.#usageFetch,\n\t\t\t\tlogger: logDetails ? this.#usageLogger : undefined,\n\t\t\t});\n\t\t} catch (error) {\n\t\t\tif (logDetails) {\n\t\t\t\tlogger.debug(\"AuthStorage usage fetch failed\", {\n", + "after": "\t\tif (providerImpl.supports && !providerImpl.supports(params)) return null;\n\n\t\ttry {\n\t\t\treturn await this.#ownAppTask(() => providerImpl.fetchUsage(params, {\n\t\t\t\tfetch: this.#usageFetch,\n\t\t\t\tlogger: logDetails ? this.#usageLogger : undefined,\n\t\t\t}));\n\t\t} catch (error) {\n\t\t\tif (logDetails) {\n\t\t\t\tlogger.debug(\"AuthStorage usage fetch failed\", {\n" + }, + { + "before": "\t\tconst inFlight = this.#usageRequestInFlight.get(cacheKey);\n\t\tif (inFlight) return inFlight;\n\n\t\tconst promise = (async () => {\n\t\t\tconst report = await this.#fetchUsageUncached(request, timeoutMs, logDetails);\n\t\t\tconst ttlJitter = USAGE_REPORT_TTL_MS * (Math.random() * 0.5 - 0.25);\n\t\t\tif (report !== null) {\n", + "after": "\t\tconst inFlight = this.#usageRequestInFlight.get(cacheKey);\n\t\tif (inFlight) return inFlight;\n\n\t\tconst promise = this.#ownAppTask(async () => {\n\t\t\tconst report = await this.#fetchUsageUncached(request, timeoutMs, logDetails);\n\t\t\tconst ttlJitter = USAGE_REPORT_TTL_MS * (Math.random() * 0.5 - 0.25);\n\t\t\tif (report !== null) {\n" + }, + { + "before": "\t\t\t\tthis.#usageCache.set(cacheKey, { value: lastGood, expiresAt: coolDown });\n\t\t\t}\n\t\t\treturn lastGood;\n\t\t})().finally(() => {\n\t\t\tif (this.#usageRequestInFlight.get(cacheKey) === promise) {\n\t\t\t\tthis.#usageRequestInFlight.delete(cacheKey);\n\t\t\t}\n", + "after": "\t\t\t\tthis.#usageCache.set(cacheKey, { value: lastGood, expiresAt: coolDown });\n\t\t\t}\n\t\t\treturn lastGood;\n\t\t}).finally(() => {\n\t\t\tif (this.#usageRequestInFlight.get(cacheKey) === promise) {\n\t\t\t\tthis.#usageRequestInFlight.delete(cacheKey);\n\t\t\t}\n" + }, + { + "before": "\t\t\t? (this.#fetchUsageReportsForProviderOverride ?? this.#store.fetchUsageReportsForProvider?.bind(this.#store))\n\t\t\t: undefined;\n\t\tif (scopedStoreFetch && options?.provider) {\n\t\t\treturn raceUsageWithSignal(scopedStoreFetch(options.provider), options.signal);\n\t\t}\n\t\tif (options?.provider && (this.#fetchUsageReportsOverride || this.#store.fetchUsageReports)) {\n\t\t\tthrow new Error(\"Provider-scoped usage fetch is unavailable\");\n", + "after": "\t\t\t? (this.#fetchUsageReportsForProviderOverride ?? this.#store.fetchUsageReportsForProvider?.bind(this.#store))\n\t\t\t: undefined;\n\t\tif (scopedStoreFetch && options?.provider) {\n\t\t\treturn raceUsageWithSignal(this.#ownAppTask(() => scopedStoreFetch(options.provider!)), options.signal);\n\t\t}\n\t\tif (options?.provider && (this.#fetchUsageReportsOverride || this.#store.fetchUsageReports)) {\n\t\t\tthrow new Error(\"Provider-scoped usage fetch is unavailable\");\n" + }, + { + "before": "\t\t\tif (!shared) {\n\t\t\t\t// Don't forward the caller signal into the shared fetch — first caller's\n\t\t\t\t// abort would otherwise cancel the upstream for every peer.\n\t\t\t\tshared = override().finally(() => {\n\t\t\t\t\tif (this.#usageReportsInFlight.get(OVERRIDE_KEY) === shared) {\n\t\t\t\t\t\tthis.#usageReportsInFlight.delete(OVERRIDE_KEY);\n\t\t\t\t\t}\n", + "after": "\t\t\tif (!shared) {\n\t\t\t\t// Don't forward the caller signal into the shared fetch — first caller's\n\t\t\t\t// abort would otherwise cancel the upstream for every peer.\n\t\t\t\tshared = this.#ownAppTask(() => override()).finally(() => {\n\t\t\t\t\tif (this.#usageReportsInFlight.get(OVERRIDE_KEY) === shared) {\n\t\t\t\t\t\tthis.#usageReportsInFlight.delete(OVERRIDE_KEY);\n\t\t\t\t\t}\n" + }, + { + "before": "\t\tconst inFlight = this.#usageReportsInFlight.get(cacheKey);\n\t\tif (inFlight) return raceUsageWithSignal(inFlight, options?.signal);\n\n\t\tconst promise = (async () => {\n\t\t\tif (options?.logDetails !== false) {\n\t\t\t\tfor (const request of requests) {\n\t\t\t\t\tthis.#usageLogger?.debug(\"Usage fetch queued\", {\n", + "after": "\t\tconst inFlight = this.#usageReportsInFlight.get(cacheKey);\n\t\tif (inFlight) return raceUsageWithSignal(inFlight, options?.signal);\n\n\t\tconst promise = this.#ownAppTask(async () => {\n\t\t\tif (options?.logDetails !== false) {\n\t\t\t\tfor (const request of requests) {\n\t\t\t\t\tthis.#usageLogger?.debug(\"Usage fetch queued\", {\n" + }, + { + "before": "\t\t\t\t});\n\t\t\t}\n\t\t\treturn resolved;\n\t\t})().finally(() => {\n\t\t\tif (this.#usageReportsInFlight.get(cacheKey) === promise) {\n\t\t\t\tthis.#usageReportsInFlight.delete(cacheKey);\n\t\t\t}\n", + "after": "\t\t\t\t});\n\t\t\t}\n\t\t\treturn resolved;\n\t\t}).finally(() => {\n\t\t\tif (this.#usageReportsInFlight.get(cacheKey) === promise) {\n\t\t\t\tthis.#usageReportsInFlight.delete(cacheKey);\n\t\t\t}\n" + }, + { + "before": "\t\tif (credentialId === undefined) {\n\t\t\treturn this.#refreshOAuthCredentialUnshared(provider, credential, undefined, signal, force, mcpClient);\n\t\t}\n\t\tconst promise = this.#refreshOAuthCredentialUnshared(\n\t\t\tprovider,\n\t\t\tcredential,\n\t\t\tcredentialId,\n\t\t\tundefined,\n\t\t\tforce,\n\t\t\tmcpClient,\n\t\t).finally(() => {\n\t\t\tthis.#oauthCredentialRefreshInFlight.delete(credentialId);\n\t\t});\n\t\tthis.#oauthCredentialRefreshInFlight.set(credentialId, promise);\n", + "after": "\t\tif (credentialId === undefined) {\n\t\t\treturn this.#refreshOAuthCredentialUnshared(provider, credential, undefined, signal, force, mcpClient);\n\t\t}\n\t\tconst promise = this.#ownAppTask(() => this.#refreshOAuthCredentialUnshared(\n\t\t\tprovider,\n\t\t\tcredential,\n\t\t\tcredentialId,\n\t\t\tundefined,\n\t\t\tforce,\n\t\t\tmcpClient,\n\t\t)).finally(() => {\n\t\t\tthis.#oauthCredentialRefreshInFlight.delete(credentialId);\n\t\t});\n\t\tthis.#oauthCredentialRefreshInFlight.set(credentialId, promise);\n" + }, + { + "before": "\t\tconst storeRefresh = this.#store.refreshOAuthCredential?.bind(this.#store);\n\t\tconst overrideRefresh = this.#refreshOAuthCredentialOverride ?? storeRefresh;\n\t\tif (overrideRefresh && credentialId !== undefined) {\n\t\t\trefreshPromise = overrideRefresh(provider, credentialId, credential, signal);\n\t\t} else {\n\t\t\t// Stale-snapshot guard: before replaying our in-memory refresh token\n\t\t\t// upstream, re-read the persisted row. With several gjc processes\n", + "after": "\t\tconst storeRefresh = this.#store.refreshOAuthCredential?.bind(this.#store);\n\t\tconst overrideRefresh = this.#refreshOAuthCredentialOverride ?? storeRefresh;\n\t\tif (overrideRefresh && credentialId !== undefined) {\n\t\t\trefreshPromise = this.#ownAppTask(() => overrideRefresh(provider, credentialId!, credential, signal));\n\t\t} else {\n\t\t\t// Stale-snapshot guard: before replaying our in-memory refresh token\n\t\t\t// upstream, re-read the persisted row. With several gjc processes\n" + }, + { + "before": "\t\t\t// and its refresh token must only ever be sent to the bound token\n\t\t\t// endpoint.\n\t\t\tif (credential.mcpBinding) {\n\t\t\t\trefreshPromise = refreshBoundMCPOAuthCredential(credential, mcpClient, signal);\n\t\t\t} else {\n\t\t\t\tconst customProvider = getOAuthProvider(provider);\n\t\t\t\tif (customProvider) {\n\t\t\t\t\tif (!customProvider.refreshToken) {\n\t\t\t\t\t\tthrow new Error(`OAuth provider \"${provider}\" does not support token refresh`);\n\t\t\t\t\t}\n\t\t\t\t\trefreshPromise = customProvider.refreshToken(credential);\n\t\t\t\t} else {\n\t\t\t\t\trefreshPromise = refreshOAuthToken(provider as OAuthProvider, credential);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n", + "after": "\t\t\t// and its refresh token must only ever be sent to the bound token\n\t\t\t// endpoint.\n\t\t\tif (credential.mcpBinding) {\n\t\t\t\trefreshPromise = this.#ownAppTask(() => refreshBoundMCPOAuthCredential(credential, mcpClient, signal));\n\t\t\t} else {\n\t\t\t\tconst customProvider = getOAuthProvider(provider);\n\t\t\t\tif (customProvider) {\n\t\t\t\t\tif (!customProvider.refreshToken) {\n\t\t\t\t\t\tthrow new Error(`OAuth provider \"${provider}\" does not support token refresh`);\n\t\t\t\t\t}\n\t\t\t\t\trefreshPromise = this.#ownAppTask(() => customProvider.refreshToken!(credential));\n\t\t\t\t} else {\n\t\t\t\t\trefreshPromise = this.#ownAppTask(() => refreshOAuthToken(provider as OAuthProvider, credential));\n\t\t\t\t}\n\t\t\t}\n\t\t}\n" + }, + { + "before": "\t\tconst existing = this.#oauthRefreshInFlight.get(id);\n\t\tif (existing) return raceCredentialRefreshWithSignal(existing, signal);\n\n\t\tconst promise = (async () => {\n\t\t\tthis.#bumpGeneration(\"credential-refresh-start\");\n\t\t\ttry {\n\t\t\t\treturn await this.#forceRefreshCredentialByIdUnshared(id, signal, mcpClient);\n", + "after": "\t\tconst existing = this.#oauthRefreshInFlight.get(id);\n\t\tif (existing) return raceCredentialRefreshWithSignal(existing, signal);\n\n\t\tconst promise = this.#ownAppTask(async () => {\n\t\t\tthis.#bumpGeneration(\"credential-refresh-start\");\n\t\t\ttry {\n\t\t\t\treturn await this.#forceRefreshCredentialByIdUnshared(id, signal, mcpClient);\n" + }, + { + "before": "\t\t\t} finally {\n\t\t\t\tthis.#oauthRefreshInFlight.delete(id);\n\t\t\t}\n\t\t})();\n\t\tthis.#oauthRefreshInFlight.set(id, promise);\n\t\treturn raceCredentialRefreshWithSignal(promise, signal);\n\t}\n", + "after": "\t\t\t} finally {\n\t\t\t\tthis.#oauthRefreshInFlight.delete(id);\n\t\t\t}\n\t\t});\n\t\tthis.#oauthRefreshInFlight.set(id, promise);\n\t\treturn raceCredentialRefreshWithSignal(promise, signal);\n\t}\n" + } + ] + }, + { + "package": "@gajae-code/coding-agent", + "path": "src/config/settings.ts", + "beforeSha256": "0409039ac65202148d52953718bfb01b21797195e5e3f3bd01ba45aaa10754ca", + "afterSha256": "9e31c89d986f2f200ec6fb35dd4d68be9f3f5987d80eead57bf1a9d1d8303f38", + "replacements": [ + { + "before": "\t#savePromise?: Promise;\n\t#changeListeners = new Set<(path: SettingPath) => void>();\n\t#pendingSaveSlot?: PendingSaveSlot;\n\n\t/** Legacy fallback migration warnings emitted once per settings instance. */\n\t#legacyFallbackMigrationWarnings = 0;\n\t#legacyFallbackMigrationGlobalFingerprint: string | undefined;\n", + "after": "\t#savePromise?: Promise;\n\t#changeListeners = new Set<(path: SettingPath) => void>();\n\t#pendingSaveSlot?: PendingSaveSlot;\n\treadonly #appLifecycleEpoch = nodeCrypto.randomUUID();\n\t#appLifecycleRevision = 0;\n\treadonly #appSaves = new Set>();\n\t#appSaveFailed = false;\n\n\t/** Background-save leaf accounting. Never flushes, closes, or fences writers. */\n\tgetAppLifecycleActivity(): {\n\t\tgeneration: string; complete: boolean; starting: number; queued: number;\n\t\trunning: number; settling: number; unknown: string[];\n\t} {\n\t\tconst unknown = this.#appSaveFailed ? [\"settings_persistence_unconfirmed\"] : [];\n\t\treturn { generation: `${this.#appLifecycleEpoch}:${this.#appLifecycleRevision}:${this.#nextRevision}`,\n\t\t\tcomplete: unknown.length === 0, starting: 0,\n\t\t\tqueued: Number(this.#pendingSaveSlot !== undefined && !this.#pendingSaveSlot.released),\n\t\t\trunning: this.#appSaves.size, settling: 0, unknown };\n\t}\n\n\tasync awaitAppLifecycleSettlement(): Promise {\n\t\t// Wait for the existing debounce and writes; do not release a save slot here.\n\t\twhile (this.#appSaves.size > 0) await Promise.allSettled([...this.#appSaves]);\n\t\tif (this.#appSaveFailed) throw new Error(\"Settings persistence is unconfirmed\");\n\t}\n\n\t/** Legacy fallback migration warnings emitted once per settings instance. */\n\t#legacyFallbackMigrationWarnings = 0;\n\t#legacyFallbackMigrationGlobalFingerprint: string | undefined;\n" + }, + { + "before": "\t\t\t}),\n\t\t};\n\t\tthis.#pendingSaveSlot = slot;\n\n\t\tlet captured: SettingsPatch[] = [];\n\t\tlet durableBeforeWrite: RawSettings | undefined;\n\t\tconst save = reserveAtomicYamlUpdateSlot(this.#configPath, async () => {\n\t\t\tawait slot.wait;\n\t\t\tslot.captured = true;\n\t\t\tif (this.#pendingSaveSlot === slot) this.#pendingSaveSlot = undefined;\n\t\t\tcaptured = this.#pendingPatchesInGenerationOrder();\n\t\t\treturn {\n", + "after": "\t\t\t}),\n\t\t};\n\t\tthis.#pendingSaveSlot = slot;\n\t\tthis.#appLifecycleRevision++;\n\n\t\tlet captured: SettingsPatch[] = [];\n\t\tlet durableBeforeWrite: RawSettings | undefined;\n\t\tconst save = reserveAtomicYamlUpdateSlot(this.#configPath, async () => {\n\t\t\tawait slot.wait;\n\t\t\tslot.captured = true;\n\t\t\tthis.#appLifecycleRevision++;\n\t\t\tif (this.#pendingSaveSlot === slot) this.#pendingSaveSlot = undefined;\n\t\t\tcaptured = this.#pendingPatchesInGenerationOrder();\n\t\t\treturn {\n" + }, + { + "before": "\t\t\t.then(() => undefined)\n\t\t\t.catch(async error => {\n\t\t\t\tlogger.warn(\"Settings: background save failed\", { error: String(error) });\n\t\t\t\tfor (const patch of captured) {\n\t\t\t\t\tconst key = settingsPatchKey(patch);\n\t\t\t\t\tif (this.#modified.get(key)?.generation === patch.generation) this.#modified.set(key, patch);\n", + "after": "\t\t\t.then(() => undefined)\n\t\t\t.catch(async error => {\n\t\t\t\tlogger.warn(\"Settings: background save failed\", { error: String(error) });\n\t\t\t\tthis.#appSaveFailed = true; this.#appLifecycleRevision++;\n\t\t\t\tfor (const patch of captured) {\n\t\t\t\t\tconst key = settingsPatchKey(patch);\n\t\t\t\t\tif (this.#modified.get(key)?.generation === patch.generation) this.#modified.set(key, patch);\n" + }, + { + "before": "\t\t\t\tthrow error;\n\t\t\t});\n\t\tthis.#savePromise = save;\n\t\tvoid save.catch(() => {});\n\t\tthis.#armSaveTimer(slot);\n\t}\n\n\t#armSaveTimer(slot: PendingSaveSlot): void {\n\t\tif (this.#saveTimer) clearTimeout(this.#saveTimer);\n\t\tthis.#saveTimer = setTimeout(() => {\n\t\t\tthis.#saveTimer = undefined;\n\t\t\tif (slot.released) return;\n\t\t\tslot.released = true;\n\t\t\tslot.release();\n", + "after": "\t\t\t\tthrow error;\n\t\t\t});\n\t\tthis.#savePromise = save;\n\t\tthis.#appSaves.add(save);\n\t\tvoid save.finally(() => { this.#appSaves.delete(save); this.#appLifecycleRevision++; }).catch(() => {});\n\t\tvoid save.catch(() => {});\n\t\tthis.#armSaveTimer(slot);\n\t}\n\n\t#armSaveTimer(slot: PendingSaveSlot): void {\n\t\tif (this.#saveTimer) clearTimeout(this.#saveTimer);\n\t\tthis.#appLifecycleRevision++;\n\t\tthis.#saveTimer = setTimeout(() => {\n\t\t\tthis.#saveTimer = undefined;\n\t\t\tthis.#appLifecycleRevision++;\n\t\t\tif (slot.released) return;\n\t\t\tslot.released = true;\n\t\t\tslot.release();\n" + }, + { + "before": "\t\treturn [...this.#modified.values()].sort((left, right) => left.generation - right.generation);\n\t}\n\t#releasePendingSaveSlot(): void {\n\t\tif (this.#saveTimer) {\n\t\t\tclearTimeout(this.#saveTimer);\n\t\t\tthis.#saveTimer = undefined;\n", + "after": "\t\treturn [...this.#modified.values()].sort((left, right) => left.generation - right.generation);\n\t}\n\t#releasePendingSaveSlot(): void {\n\t\tthis.#appLifecycleRevision++;\n\t\tif (this.#saveTimer) {\n\t\t\tclearTimeout(this.#saveTimer);\n\t\t\tthis.#saveTimer = undefined;\n" + } + ] + }, + { + "package": "@gajae-code/ai", + "path": "src/utils/event-stream.ts", + "replacements": [ + { + "before": "import type { AssistantMessage, AssistantMessageEvent } from \"../types\";\n\ninterface EventQueueNode {\n\ttype: \"event\";\n\tevent: T;\n}\n\ntype QueueNode = EventQueueNode | { type: \"consumer-drain\"; drain: ConsumerDrain };\n", + "after": "import { AsyncLocalStorage } from \"node:async_hooks\";\nimport type { AssistantMessage, AssistantMessageEvent } from \"../types\";\n\n// Physical producer identity is held here, not inferred from a terminal event,\n// a source marker, or a structurally similar property on an arbitrary stream.\ninterface AppStreamProducer {\n\tcompletion: Promise;\n\tchildren: Set>;\n\tunknown: Set;\n\tclosed: boolean;\n}\nconst appStreamProducers = new WeakMap();\nconst appStreamProducerContext = new AsyncLocalStorage();\n\n/** Retain raced iterator work in the actual initiating producer, not its caller. */\nexport function retainAppStreamTask(task: PromiseLike): Promise {\n\tconst promise = Promise.resolve(task);\n\tconst owner = appStreamProducerContext.getStore();\n\tif (owner) {\n\t\tif (owner.closed) throw new Error(\"Stream producer started work after completion\");\n\t\tconst joined = promise.then(() => {}, () => {}).finally(() => owner.children.delete(joined));\n\t\towner.children.add(joined);\n\t}\n\treturn promise;\n}\n\nexport function getAppStreamProducer(source: object): {\n\tcompletion: Promise; getUnknown(): string[];\n} | undefined {\n\tconst owner = appStreamProducers.get(source);\n\treturn owner && Object.freeze({ completion: owner.completion, getUnknown: () => [...owner.unknown] });\n}\n\n/** Reserve BEFORE invoking the body; settle only after its actual finally and children. */\nexport function runAppStreamProducer(stream: AssistantMessageEventStream, operation: () => Promise): void {\n\tif (appStreamProducers.has(stream)) throw new Error(\"Stream producer already registered\");\n\tconst completion = Promise.withResolvers();\n\tconst owner: AppStreamProducer = { completion: completion.promise, children: new Set(), unknown: new Set(), closed: false };\n\tappStreamProducers.set(stream, owner);\n\tvoid (async () => {\n\t\ttry { await appStreamProducerContext.run(owner, operation); }\n\t\tcatch (error) { stream.fail(error); }\n\t\tfinally {\n\t\t\twhile (owner.children.size) await Promise.allSettled([...owner.children]);\n\t\t\towner.closed = true;\n\t\t\tcompletion.resolve();\n\t\t}\n\t})();\n}\n\n/** Wrapper consumption is not the child transport's producer completion. */\nexport function adoptAppStreamProducer(target: object, source: object): void {\n\tconst owner = appStreamProducers.get(target);\n\tif (!owner || owner.closed) throw new Error(\"Stream producer ownership is unavailable\");\n\tconst child = getAppStreamProducer(source);\n\tif (!child) { owner.unknown.add(\"sdk_provider_producer_unrepresented\"); return; }\n\tconst joined = child.completion.then(() => {\n\t\tfor (const reason of child.getUnknown()) owner.unknown.add(reason);\n\t}).finally(() => owner.children.delete(joined));\n\towner.children.add(joined);\n}\n\n/** Used only for concrete unaudited subfeatures, never as a substitute for retention. */\nexport function markAppStreamProducerUnknown(stream: object, reason: string): void {\n\tconst owner = appStreamProducers.get(stream);\n\tif (!owner || owner.closed) throw new Error(\"Stream producer ownership is unavailable\");\n\towner.unknown.add(reason);\n}\n\ninterface EventQueueNode {\n\ttype: \"event\";\n\tevent: T;\n}\n\ntype QueueNode = EventQueueNode | { type: \"consumer-drain\"; drain: ConsumerDrain };\n" + } + ], + "beforeSha256": "0bed38466df6d56c6b63a0295bfa7aa9f4f189937299246f2f8705a5936952ae", + "afterSha256": "eb81b0961f3ef800905efb80245049c88b5a0c498e0b79a23caafedc8e519559" + }, + { + "package": "@gajae-code/ai", + "path": "src/utils/idle-iterator.ts", + "replacements": [ + { + "before": "import { $env } from \"@gajae-code/utils\";\nimport { STREAM_FIRST_EVENT_TIMEOUT_PROVIDER_CODE } from \"./fallback-transport\";\n\nconst DEFAULT_STREAM_IDLE_TIMEOUT_MS = 120_000;\nconst DEFAULT_STREAM_FIRST_EVENT_TIMEOUT_MS = 100_000;\nconst ALIBABA_TOKEN_PLAN_FIRST_EVENT_TIMEOUT_MS = 600_000;\nconst KIMI_CODE_FIRST_EVENT_TIMEOUT_MS = 300_000;\n", + "after": "import { $env } from \"@gajae-code/utils\";\nimport { retainAppStreamTask } from \"./event-stream\";\nimport { STREAM_FIRST_EVENT_TIMEOUT_PROVIDER_CODE } from \"./fallback-transport\";\n\nconst DEFAULT_STREAM_IDLE_TIMEOUT_MS = 120_000;\nconst DEFAULT_STREAM_FIRST_EVENT_TIMEOUT_MS = 100_000;\nconst ALIBABA_TOKEN_PLAN_FIRST_EVENT_TIMEOUT_MS = 600_000;\nconst KIMI_CODE_FIRST_EVENT_TIMEOUT_MS = 300_000;\n" + }, + { + "before": "\tconst abortSignal = options.abortSignal;\n\tconst iterator = iterable[Symbol.asyncIterator]();\n\n\tconst closeIterator = (): void => {\n\t\tconst returnPromise = iterator.return?.();\n\t\tif (returnPromise) {\n\t\t\tvoid returnPromise.catch(() => {});\n\t\t}\n\t};\n\n\tif (abortSignal?.aborted) {\n\t\tcloseIterator();\n\t\tthrow abortReason(abortSignal);\n", + "after": "\tconst abortSignal = options.abortSignal;\n\tconst iterator = iterable[Symbol.asyncIterator]();\n\n\tconst closeIterator = (): void => {\n\t\tconst returnPromise = iterator.return?.();\n\t\tif (returnPromise) {\n\t\t\tvoid retainAppStreamTask(returnPromise).catch(() => {});\n\t\t}\n\t};\n\n\tif (abortSignal?.aborted) {\n\t\tcloseIterator();\n\t\tthrow abortReason(abortSignal);\n" + }, + { + "before": "\n\t\t// Arm timeout/abort races before asking the source for its next item. A\n\t\t// periodic keepalive iterator commonly registers its own timer inside\n\t\t// `next()`; registering that first lets equal-deadline keepalives win every\n\t\t// race and extend the idle window forever. Already-buffered items still\n\t\t// settle as microtasks before a 0ms watchdog.\n\t\tracers.unshift(withRacy(iterator.next()));\n\n\t\ttry {\n\t\t\tconst outcome = await Promise.race(racers);\n\t\t\tif (outcome.kind === \"abort\") {\n\t\t\t\tcloseIterator();\n\t\t\t\tthrow abortReason(abortSignal!);\n", + "after": "\n\t\t// Arm timeout/abort races before asking the source for its next item. A\n\t\t// periodic keepalive iterator commonly registers its own timer inside\n\t\t// `next()`; registering that first lets equal-deadline keepalives win every\n\t\t// race and extend the idle window forever. Already-buffered items still\n\t\t// settle as microtasks before a 0ms watchdog.\n\t\tracers.unshift(withRacy(retainAppStreamTask(iterator.next())));\n\n\t\ttry {\n\t\t\tconst outcome = await Promise.race(racers);\n\t\t\tif (outcome.kind === \"abort\") {\n\t\t\t\tcloseIterator();\n\t\t\t\tthrow abortReason(abortSignal!);\n" + } + ], + "beforeSha256": "2bd8de687b56695bc9faa6ac4772c2693ce573a614144eba2b47a1cbb5e2d875", + "afterSha256": "fbc6742abee2bcf20d22fa174ba7d21e688fde5cec13b32401fe36225ea90fd3" + }, + { + "package": "@gajae-code/ai", + "path": "src/stream.ts", + "replacements": [ + { + "before": "\tOptionsForApi,\n\tSimpleStreamOptions,\n\tStreamOptions,\n\tThinkingBudgets,\n\tToolChoice,\n} from \"./types\";\nimport { AssistantMessageEventStream } from \"./utils/event-stream\";\nimport { isFoundryEnabled } from \"./utils/foundry\";\n\nlet cachedVertexAdcCredentialsExists: boolean | null = null;\n\nfunction hasVertexAdcCredentials(): boolean {\n\tif (cachedVertexAdcCredentialsExists === null) {\n", + "after": "\tOptionsForApi,\n\tSimpleStreamOptions,\n\tStreamOptions,\n\tThinkingBudgets,\n\tToolChoice,\n} from \"./types\";\nimport { AssistantMessageEventStream, runAppStreamProducer, adoptAppStreamProducer, getAppStreamProducer } from \"./utils/event-stream\";\nimport { isFoundryEnabled } from \"./utils/foundry\";\n\nlet cachedVertexAdcCredentialsExists: boolean | null = null;\n\nfunction hasVertexAdcCredentials(): boolean {\n\tif (cachedVertexAdcCredentialsExists === null) {\n" + }, + { + "before": "}\nfunction pipeAssistantStream(\n\touter: AssistantMessageEventStream,\n\tinner: AssistantMessageEventStream,\n\tsignal?: AbortSignal,\n\tonStreamCreated?: () => void,\n): void {\n\tvoid (async () => {\n\t\ttry {\n\t\t\tlet admitted = false;\n\t\t\tconst markAdmission = (): void => {\n\t\t\t\tif (admitted) return;\n\t\t\t\tadmitted = true;\n\t\t\t\tonStreamCreated?.();\n", + "after": "}\nfunction pipeAssistantStream(\n\touter: AssistantMessageEventStream,\n\tinner: AssistantMessageEventStream,\n\tsignal?: AbortSignal,\n\tonStreamCreated?: () => void,\n): Promise {\n\tadoptAppStreamProducer(outer, inner);\n\treturn (async () => {\n\t\ttry {\n\t\t\tlet admitted = false;\n\t\t\tconst markAdmission = (): void => {\n\t\t\t\tif (admitted) return;\n\t\t\t\tadmitted = true;\n\t\t\t\tonStreamCreated?.();\n" + }, + { + "before": "export function streamFromLazyImport(\n\tcreateInner: () => Promise,\n\tsignal?: AbortSignal,\n\tonStreamCreated?: () => void,\n): AssistantMessageEventStream {\n\tconst outer = new AssistantMessageEventStream();\n\tvoid (async () => {\n\t\ttry {\n\t\t\tconst inner = await createInner();\n\t\t\tpipeAssistantStream(outer, inner, signal, onStreamCreated);\n\t\t} catch (error) {\n\t\t\touter.fail(error);\n\t\t}\n\t})();\n\treturn outer;\n}\n\n/**\n * Build an actionable \"missing API key\" error for a provider, used by the\n * low-level `stream`/`complete` entry points (#755).\n", + "after": "export function streamFromLazyImport(\n\tcreateInner: () => Promise,\n\tsignal?: AbortSignal,\n\tonStreamCreated?: () => void,\n): AssistantMessageEventStream {\n\tconst outer = new AssistantMessageEventStream();\n\trunAppStreamProducer(outer, async () => {\n\t\ttry {\n\t\t\tconst inner = await createInner();\n\t\t\tawait pipeAssistantStream(outer, inner, signal, onStreamCreated);\n\t\t} catch (error) {\n\t\t\touter.fail(error);\n\t\t}\n\t});\n\treturn outer;\n}\n\n/**\n * Build an actionable \"missing API key\" error for a provider, used by the\n * low-level `stream`/`complete` entry points (#755).\n" + }, + { + "before": "export async function complete(\n\tmodel: Model,\n\tcontext: Context,\n\toptions?: OptionsForApi,\n): Promise {\n\tconst s = stream(model, context, options);\n\treturn s.result();\n}\n\ntype AuthRetryFailure = {\n\terror: unknown;\n\tbufferedEvents: AssistantMessageEvent[];\n\tterminalEvent?: Extract;\n", + "after": "export async function complete(\n\tmodel: Model,\n\tcontext: Context,\n\toptions?: OptionsForApi,\n): Promise {\n\tconst s = stream(model, context, options);\n\ttry { return await s.result(); }\n\tfinally { await getAppStreamProducer(s)?.completion; }\n}\n\ntype AuthRetryFailure = {\n\terror: unknown;\n\tbufferedEvents: AssistantMessageEvent[];\n\tterminalEvent?: Extract;\n" + }, + { + "before": "\t\t\t\t\t...options,\n\t\t\t\t\tapiKey,\n\t\t\t\t\tonAuthError: undefined,\n\t\t\t\t\tonStreamCreated: markAdmission,\n\t\t\t\t\tsignal: requestSignal,\n\t\t\t\t});\n\t\t\t\tfor await (const event of inner) {\n\t\t\t\t\tif (!emittedReplayUnsafeEvent && event.type === \"start\") {\n\t\t\t\t\t\tbufferedEvents.push(event);\n\t\t\t\t\t\tcontinue;\n\t\t\t\t\t}\n\t\t\t\t\tif (\n", + "after": "\t\t\t\t\t...options,\n\t\t\t\t\tapiKey,\n\t\t\t\t\tonAuthError: undefined,\n\t\t\t\t\tonStreamCreated: markAdmission,\n\t\t\t\t\tsignal: requestSignal,\n\t\t\t\t});\n\t\t\t\tadoptAppStreamProducer(outer, inner);\n\t\t\t\tfor await (const event of inner) {\n\t\t\t\t\tif (!emittedReplayUnsafeEvent && event.type === \"start\") {\n\t\t\t\t\t\tbufferedEvents.push(event);\n\t\t\t\t\t\tcontinue;\n\t\t\t\t\t}\n\t\t\t\t\tif (\n" + }, + { + "before": "\t\t\t\touter.push(failure.terminalEvent);\n\t\t\t} else {\n\t\t\t\touter.fail(failure.error);\n\t\t\t}\n\t\t};\n\n\t\tvoid (async () => {\n\t\t\tconst failure = await runAttempt(retryApiKey, true, options?.onStreamCreated);\n\t\t\tif (!failure) return;\n\t\t\tlet nextCredential: string | AuthRetryCredential | undefined;\n\t\t\ttry {\n\t\t\t\tnextCredential = await onAuthError(model.provider, retryApiKey, failure.error);\n\t\t\t} catch {\n", + "after": "\t\t\t\touter.push(failure.terminalEvent);\n\t\t\t} else {\n\t\t\t\touter.fail(failure.error);\n\t\t\t}\n\t\t};\n\n\t\trunAppStreamProducer(outer, async () => {\n\t\t\tconst failure = await runAttempt(retryApiKey, true, options?.onStreamCreated);\n\t\t\tif (!failure) return;\n\t\t\tlet nextCredential: string | AuthRetryCredential | undefined;\n\t\t\ttry {\n\t\t\t\tnextCredential = await onAuthError(model.provider, retryApiKey, failure.error);\n\t\t\t} catch {\n" + }, + { + "before": "\t\t\tif (!retryCredential?.apiKey || retryCredential.apiKey === retryApiKey) {\n\t\t\t\tif (retryCredential) retryCredential.onStreamCreated?.();\n\t\t\t\temitFailure(failure);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tawait runAttempt(retryCredential.apiKey, false, retryCredential.onStreamCreated);\n\t\t})();\n\t\treturn outer;\n\t}\n\n\t// Pi-native transport short-circuits the per-provider dispatch entirely:\n\t// the gateway resolves provider + credential server-side, so we don't\n\t// need an `apiKey` from `getEnvApiKey` here — `options.apiKey` carries\n", + "after": "\t\t\tif (!retryCredential?.apiKey || retryCredential.apiKey === retryApiKey) {\n\t\t\t\tif (retryCredential) retryCredential.onStreamCreated?.();\n\t\t\t\temitFailure(failure);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tawait runAttempt(retryCredential.apiKey, false, retryCredential.onStreamCreated);\n\t\t});\n\t\treturn outer;\n\t}\n\n\t// Pi-native transport short-circuits the per-provider dispatch entirely:\n\t// the gateway resolves provider + credential server-side, so we don't\n\t// need an `apiKey` from `getEnvApiKey` here — `options.apiKey` carries\n" + }, + { + "before": "\t\tconst events = customApiProvider.streamSimple(model, context, {\n\t\t\t...options,\n\t\t\tmaxTokens: resolvedRequestMaxTokens,\n\t\t});\n\t\tif (!options?.onStreamCreated) return events;\n\t\tconst forwarded = new AssistantMessageEventStream();\n\t\tpipeAssistantStream(forwarded, events, options.signal, options.onStreamCreated);\n\t\treturn forwarded;\n\t}\n\n\t// Vertex AI uses Application Default Credentials, not API keys\n\tif (model.api === \"google-vertex\") {\n\t\tconst providerOptions = mapOptionsForApi(model, options, undefined);\n", + "after": "\t\tconst events = customApiProvider.streamSimple(model, context, {\n\t\t\t...options,\n\t\t\tmaxTokens: resolvedRequestMaxTokens,\n\t\t});\n\t\tif (!options?.onStreamCreated) return events;\n\t\tconst forwarded = new AssistantMessageEventStream();\n\t\trunAppStreamProducer(forwarded, () => pipeAssistantStream(forwarded, events, options.signal, options.onStreamCreated));\n\t\treturn forwarded;\n\t}\n\n\t// Vertex AI uses Application Default Credentials, not API keys\n\tif (model.api === \"google-vertex\") {\n\t\tconst providerOptions = mapOptionsForApi(model, options, undefined);\n" + }, + { + "before": "export async function completeSimple(\n\tmodel: Model,\n\tcontext: Context,\n\toptions?: SimpleStreamOptions,\n): Promise {\n\tconst s = streamSimple(model, context, options);\n\treturn s.result();\n}\n\nconst MIN_OUTPUT_TOKENS = 1024;\nconst DEFAULT_REQUEST_MAX_TOKENS = 32000;\nexport const OUTPUT_FALLBACK_BUFFER = 4000;\nconst ANTHROPIC_USE_INTERLEAVED_THINKING = Bun.env.PI_NO_INTERLEAVED_THINKING !== \"1\";\n", + "after": "export async function completeSimple(\n\tmodel: Model,\n\tcontext: Context,\n\toptions?: SimpleStreamOptions,\n): Promise {\n\tconst s = streamSimple(model, context, options);\n\ttry { return await s.result(); }\n\tfinally { await getAppStreamProducer(s)?.completion; }\n}\n\nconst MIN_OUTPUT_TOKENS = 1024;\nconst DEFAULT_REQUEST_MAX_TOKENS = 32000;\nexport const OUTPUT_FALLBACK_BUFFER = 4000;\nconst ANTHROPIC_USE_INTERLEAVED_THINKING = Bun.env.PI_NO_INTERLEAVED_THINKING !== \"1\";\n" + } + ], + "beforeSha256": "47180a9a63d6abe2faaa23da6a68b592f369eae46c657a677f502266dd134cda", + "afterSha256": "404db53024a885ef16e6749911964b228d6670d3796e00acfe245956dad28a22" + }, + { + "package": "@gajae-code/ai", + "path": "src/providers/register-builtins.ts", + "replacements": [ + { + "before": "\tAssistantMessageEventStream,\n\tContext,\n\tModel,\n\tOptionsForApi,\n} from \"../types\";\nimport { type AbortSourceTracker, createAbortSourceTracker } from \"../utils/abort\";\nimport { AssistantMessageEventStream as EventStreamImpl } from \"../utils/event-stream\";\nimport { transportFailureFacts } from \"../utils/fallback-transport\";\nimport {\n\tFirstEventTimeoutError,\n\tgetProviderFirstEventTimeoutFallbackMs,\n\tgetStreamFirstEventTimeoutMs,\n\tgetStreamIdleTimeoutMs,\n", + "after": "\tAssistantMessageEventStream,\n\tContext,\n\tModel,\n\tOptionsForApi,\n} from \"../types\";\nimport { type AbortSourceTracker, createAbortSourceTracker } from \"../utils/abort\";\nimport { AssistantMessageEventStream as EventStreamImpl, runAppStreamProducer, adoptAppStreamProducer } from \"../utils/event-stream\";\nimport { transportFailureFacts } from \"../utils/fallback-transport\";\nimport {\n\tFirstEventTimeoutError,\n\tgetProviderFirstEventTimeoutFallbackMs,\n\tgetStreamFirstEventTimeoutMs,\n\tgetStreamIdleTimeoutMs,\n" + }, + { + "before": "\tsource: AsyncIterable,\n\tmodel: Model,\n\toptions: OptionsForApi,\n\tabortTracker: AbortSourceTracker,\n\tonStreamCreated?: () => void,\n\tlimits?: LazyStreamLimits,\n): void {\n\t(async () => {\n\t\ttry {\n\t\t\tlet admitted = false;\n\t\t\tconst markAdmission = (): void => {\n\t\t\t\tif (admitted) return;\n\t\t\t\tadmitted = true;\n\t\t\t\tonStreamCreated?.();\n", + "after": "\tsource: AsyncIterable,\n\tmodel: Model,\n\toptions: OptionsForApi,\n\tabortTracker: AbortSourceTracker,\n\tonStreamCreated?: () => void,\n\tlimits?: LazyStreamLimits,\n): Promise {\n\tadoptAppStreamProducer(target, source);\n\treturn (async () => {\n\t\ttry {\n\t\t\tlet admitted = false;\n\t\t\tconst markAdmission = (): void => {\n\t\t\t\tif (admitted) return;\n\t\t\t\tadmitted = true;\n\t\t\t\tonStreamCreated?.();\n" + }, + { + "before": "\t\tlet abortTracker: AbortSourceTracker | undefined;\n\t\tconst outer = new EventStreamImpl(() =>\n\t\t\tabortTracker?.abortLocally(new Error(\"Provider stream consumer stopped before completion\")),\n\t\t);\n\t\tconst streamOptions = (options ?? {}) as OptionsForApi;\n\n\t\tloadModule()\n\t\t\t.then(module => {\n\t\t\t\tabortTracker = createAbortSourceTracker(streamOptions.signal);\n\t\t\t\tconst providerOptions = { ...streamOptions, signal: abortTracker.requestSignal } as OptionsForApi;\n\t\t\t\tconst inner = module.stream(model, context, providerOptions);\n\t\t\t\tforwardStream(outer, inner, model, streamOptions, abortTracker, onStreamCreated, limits);\n\t\t\t})\n\t\t\t.catch(error => {\n\t\t\t\tconst message = createLazyLoadErrorMessage(model, error);\n\t\t\t\touter.push({ type: \"error\", reason: \"error\", error: message });\n\t\t\t\touter.end(message);\n\t\t\t});\n\n\t\treturn outer;\n\t};\n}\n\n// ---------------------------------------------------------------------------\n", + "after": "\t\tlet abortTracker: AbortSourceTracker | undefined;\n\t\tconst outer = new EventStreamImpl(() =>\n\t\t\tabortTracker?.abortLocally(new Error(\"Provider stream consumer stopped before completion\")),\n\t\t);\n\t\tconst streamOptions = (options ?? {}) as OptionsForApi;\n\n\t\trunAppStreamProducer(outer, () => loadModule()\n\t\t\t.then(async module => {\n\t\t\t\tabortTracker = createAbortSourceTracker(streamOptions.signal);\n\t\t\t\tconst providerOptions = { ...streamOptions, signal: abortTracker.requestSignal } as OptionsForApi;\n\t\t\t\tconst inner = module.stream(model, context, providerOptions);\n\t\t\t\tawait forwardStream(outer, inner, model, streamOptions, abortTracker, onStreamCreated, limits);\n\t\t\t})\n\t\t\t.catch(error => {\n\t\t\t\tconst message = createLazyLoadErrorMessage(model, error);\n\t\t\t\touter.push({ type: \"error\", reason: \"error\", error: message });\n\t\t\t\touter.end(message);\n\t\t\t}));\n\n\t\treturn outer;\n\t};\n}\n\n// ---------------------------------------------------------------------------\n" + } + ], + "beforeSha256": "6ef5d14c49188b889dcf8fce48a7281366bbd719091d679a748fa329d53d4ba5", + "afterSha256": "abceac1f4e6e107da1f0bef71000ba4085508c1f56812127ecf6d556ee751a9d" + }, + { + "package": "@gajae-code/ai", + "path": "src/providers/amazon-bedrock.ts", + "replacements": [ + { + "before": "/**\n * Amazon Bedrock Converse Stream provider.\n *\n * Talks directly to `bedrock-runtime.{region}.amazonaws.com` over HTTPS with\n * SigV4 signing and decodes the `application/vnd.amazon.eventstream` response.\n * No `@aws-sdk/*`, no `@smithy/*`, no `proxy-agent`. Proxies are honored via\n", + "after": "import { runAppStreamProducer } from \"../utils/event-stream\";\n/**\n * Amazon Bedrock Converse Stream provider.\n *\n * Talks directly to `bedrock-runtime.{region}.amazonaws.com` over HTTPS with\n * SigV4 signing and decodes the `application/vnd.amazon.eventstream` response.\n * No `@aws-sdk/*`, no `@smithy/*`, no `proxy-agent`. Proxies are honored via\n" + }, + { + "before": "\tmodel: Model<\"bedrock-converse-stream\">,\n\tcontext: Context,\n\toptions: BedrockOptions,\n): AssistantMessageEventStream => {\n\tconst stream = new AssistantMessageEventStream();\n\n\t(async () => {\n\t\tconst startTime = Date.now();\n\t\tlet firstTokenTime: number | undefined;\n\n\t\tconst output: AssistantMessage = {\n\t\t\trole: \"assistant\",\n\t\t\tcontent: [],\n", + "after": "\tmodel: Model<\"bedrock-converse-stream\">,\n\tcontext: Context,\n\toptions: BedrockOptions,\n): AssistantMessageEventStream => {\n\tconst stream = new AssistantMessageEventStream();\n\n\trunAppStreamProducer(stream, async () => {\n\t\tconst startTime = Date.now();\n\t\tlet firstTokenTime: number | undefined;\n\n\t\tconst output: AssistantMessage = {\n\t\t\trole: \"assistant\",\n\t\t\tcontent: [],\n" + }, + { + "before": "\t\t\toutput.errorMessage = await appendRawHttpRequestDumpFor400(baseMessage + diagnostics, error, rawRequestDump);\n\t\t\toutput.duration = Date.now() - startTime;\n\t\t\tif (firstTokenTime) output.ttft = firstTokenTime - startTime;\n\t\t\tstream.push({ type: \"error\", reason: output.stopReason, error: output });\n\t\t\tstream.end();\n\t\t}\n\t})();\n\n\treturn stream;\n};\n\nfunction safeParsePayload(payload: Uint8Array): unknown {\n\tif (payload.length === 0) return {};\n", + "after": "\t\t\toutput.errorMessage = await appendRawHttpRequestDumpFor400(baseMessage + diagnostics, error, rawRequestDump);\n\t\t\toutput.duration = Date.now() - startTime;\n\t\t\tif (firstTokenTime) output.ttft = firstTokenTime - startTime;\n\t\t\tstream.push({ type: \"error\", reason: output.stopReason, error: output });\n\t\t\tstream.end();\n\t\t}\n\t});\n\n\treturn stream;\n};\n\nfunction safeParsePayload(payload: Uint8Array): unknown {\n\tif (payload.length === 0) return {};\n" + } + ], + "beforeSha256": "9b78c2af7e97d018f100fb6e6e8dc88694821ec6bcd802d6165b50ab11f43c37", + "afterSha256": "972549a554509bc2c2e1f0723c197aadd339c10c8345c0b78420b8499821fd65" + }, + { + "package": "@gajae-code/ai", + "path": "src/providers/anthropic.ts", + "replacements": [ + { + "before": "import * as nodeCrypto from \"node:crypto\";\nimport * as fs from \"node:fs\";\nimport * as os from \"node:os\";\nimport { scheduler } from \"node:timers/promises\";\nimport * as tls from \"node:tls\";\nimport Anthropic, { type ClientOptions as AnthropicSdkClientOptions } from \"@anthropic-ai/sdk\";\n", + "after": "import { runAppStreamProducer } from \"../utils/event-stream\";\nimport * as nodeCrypto from \"node:crypto\";\nimport * as fs from \"node:fs\";\nimport * as os from \"node:os\";\nimport { scheduler } from \"node:timers/promises\";\nimport * as tls from \"node:tls\";\nimport Anthropic, { type ClientOptions as AnthropicSdkClientOptions } from \"@anthropic-ai/sdk\";\n" + }, + { + "before": "\tmodel: Model<\"anthropic-messages\">,\n\tcontext: Context,\n\toptions?: AnthropicOptions,\n): AssistantMessageEventStream => {\n\tconst stream = new AssistantMessageEventStream();\n\n\t(async () => {\n\t\tconst startTime = Date.now();\n\t\tlet firstTokenTime: number | undefined;\n\n\t\tconst copilotDynamicHeaders =\n\t\t\tmodel.provider === \"github-copilot\"\n\t\t\t\t? buildCopilotDynamicHeaders({\n", + "after": "\tmodel: Model<\"anthropic-messages\">,\n\tcontext: Context,\n\toptions?: AnthropicOptions,\n): AssistantMessageEventStream => {\n\tconst stream = new AssistantMessageEventStream();\n\n\trunAppStreamProducer(stream, async () => {\n\t\tconst startTime = Date.now();\n\t\tlet firstTokenTime: number | undefined;\n\n\t\tconst copilotDynamicHeaders =\n\t\t\tmodel.provider === \"github-copilot\"\n\t\t\t\t? buildCopilotDynamicHeaders({\n" + }, + { + "before": "\t\t\toutput.errorMessage = rewriteCopilotError(output.errorMessage, error, model.provider);\n\t\t\toutput.duration = Date.now() - startTime;\n\t\t\tif (firstTokenTime) output.ttft = firstTokenTime - startTime;\n\t\t\tstream.push({ type: \"error\", reason: output.stopReason, error: output });\n\t\t\tstream.end();\n\t\t}\n\t})();\n\n\treturn stream;\n};\n\nexport type AnthropicSystemBlock = {\n\ttype: \"text\";\n", + "after": "\t\t\toutput.errorMessage = rewriteCopilotError(output.errorMessage, error, model.provider);\n\t\t\toutput.duration = Date.now() - startTime;\n\t\t\tif (firstTokenTime) output.ttft = firstTokenTime - startTime;\n\t\t\tstream.push({ type: \"error\", reason: output.stopReason, error: output });\n\t\t\tstream.end();\n\t\t}\n\t});\n\n\treturn stream;\n};\n\nexport type AnthropicSystemBlock = {\n\ttype: \"text\";\n" + } + ], + "beforeSha256": "33e7c0cfef5c849bbfe958c4a62bcf217e19366d2ca3133e71b4399ad1fb10b5", + "afterSha256": "eee1c52fdd77f53dad676297560ca5722553088cf9888e7300e3d77507e4eefc" + }, + { + "package": "@gajae-code/ai", + "path": "src/providers/azure-openai-responses.ts", + "replacements": [ + { + "before": "import { $credentialEnv, $env, extractHttpStatusFromError, logger } from \"@gajae-code/utils\";\nimport { APIConnectionTimeoutError, AzureOpenAI } from \"openai\";\nimport type {\n\tTool as OpenAITool,\n\tResponseCreateParamsStreaming,\n\tResponseInput,\n", + "after": "import { runAppStreamProducer } from \"../utils/event-stream\";\nimport { $credentialEnv, $env, extractHttpStatusFromError, logger } from \"@gajae-code/utils\";\nimport { APIConnectionTimeoutError, AzureOpenAI } from \"openai\";\nimport type {\n\tTool as OpenAITool,\n\tResponseCreateParamsStreaming,\n\tResponseInput,\n" + }, + { + "before": "\tcontext: Context,\n\toptions?: AzureOpenAIResponsesOptions,\n): AssistantMessageEventStream => {\n\tconst stream = new AssistantMessageEventStream();\n\n\t// Start async processing\n\t(async () => {\n\t\tconst startTime = Date.now();\n\t\tlet firstTokenTime: number | undefined;\n\t\tlet streamConnected = false;\n\t\tconst deploymentName = resolveDeploymentName(model, options);\n\n\t\tconst output: AssistantMessage = createInitialResponsesAssistantMessage(\n", + "after": "\tcontext: Context,\n\toptions?: AzureOpenAIResponsesOptions,\n): AssistantMessageEventStream => {\n\tconst stream = new AssistantMessageEventStream();\n\n\t// Start async processing\n\trunAppStreamProducer(stream, async () => {\n\t\tconst startTime = Date.now();\n\t\tlet firstTokenTime: number | undefined;\n\t\tlet streamConnected = false;\n\t\tconst deploymentName = resolveDeploymentName(model, options);\n\n\t\tconst output: AssistantMessage = createInitialResponsesAssistantMessage(\n" + }, + { + "before": "\t\t\t\tfirstEventTimeoutError?.message ?? (await finalizeErrorMessage(normalizedError, rawRequestDump));\n\t\t\toutput.duration = Date.now() - startTime;\n\t\t\tif (firstTokenTime) output.ttft = firstTokenTime - startTime;\n\t\t\tstream.push({ type: \"error\", reason: output.stopReason, error: output });\n\t\t\tstream.end();\n\t\t}\n\t})();\n\n\treturn stream;\n};\n\nfunction normalizeAzureBaseUrl(baseUrl: string): string {\n\treturn baseUrl.replace(/\\/+$/, \"\");\n", + "after": "\t\t\t\tfirstEventTimeoutError?.message ?? (await finalizeErrorMessage(normalizedError, rawRequestDump));\n\t\t\toutput.duration = Date.now() - startTime;\n\t\t\tif (firstTokenTime) output.ttft = firstTokenTime - startTime;\n\t\t\tstream.push({ type: \"error\", reason: output.stopReason, error: output });\n\t\t\tstream.end();\n\t\t}\n\t});\n\n\treturn stream;\n};\n\nfunction normalizeAzureBaseUrl(baseUrl: string): string {\n\treturn baseUrl.replace(/\\/+$/, \"\");\n" + } + ], + "beforeSha256": "757e869c4cf59a0b7cd214e943f6a46b9457f93266a0c9f98ab1c91aa17c9014", + "afterSha256": "403196951817447052fbe2f0b059679923f64ce2f1322bcdc44cc1b0a3ff0075" + }, + { + "package": "@gajae-code/ai", + "path": "src/providers/cursor.ts", + "replacements": [ + { + "before": "import { createHash } from \"node:crypto\";\nimport * as fs from \"node:fs/promises\";\nimport http2 from \"node:http2\";\nimport { create, fromBinary, fromJson, type JsonValue, toBinary, toJson } from \"@bufbuild/protobuf\";\nimport { ValueSchema } from \"@bufbuild/protobuf/wkt\";\nimport { $env, extractHttpStatusFromError, sanitizeText } from \"@gajae-code/utils\";\n", + "after": "import { runAppStreamProducer, markAppStreamProducerUnknown } from \"../utils/event-stream\";\nimport { createHash } from \"node:crypto\";\nimport * as fs from \"node:fs/promises\";\nimport http2 from \"node:http2\";\nimport { create, fromBinary, fromJson, type JsonValue, toBinary, toJson } from \"@bufbuild/protobuf\";\nimport { ValueSchema } from \"@bufbuild/protobuf/wkt\";\nimport { $env, extractHttpStatusFromError, sanitizeText } from \"@gajae-code/utils\";\n" + }, + { + "before": "\tcontext: Context,\n\toptions?: CursorOptions,\n): AssistantMessageEventStream => {\n\tconst stream = new AssistantMessageEventStream();\n\tconst requestContextRules = buildCursorRequestContextRules(context.systemPrompt);\n\n\t(async () => {\n\t\tconst startTime = Date.now();\n\t\tlet firstTokenTime: number | undefined;\n\n\t\tconst output: AssistantMessage = {\n\t\t\trole: \"assistant\",\n\t\t\tcontent: [],\n", + "after": "\tcontext: Context,\n\toptions?: CursorOptions,\n): AssistantMessageEventStream => {\n\tconst stream = new AssistantMessageEventStream();\n\tconst requestContextRules = buildCursorRequestContextRules(context.systemPrompt);\n\n\trunAppStreamProducer(stream, async () => {\n\t\tmarkAppStreamProducerUnknown(stream, \"sdk_cursor_subtasks_unrepresented\");\n\t\tconst startTime = Date.now();\n\t\tlet firstTokenTime: number | undefined;\n\n\t\tconst output: AssistantMessage = {\n\t\t\trole: \"assistant\",\n\t\t\tcontent: [],\n" + }, + { + "before": "\t\t\tif (h2Request && !h2Request.closed && !h2Request.destroyed) {\n\t\t\t\th2Request.end();\n\t\t\t}\n\t\t\th2Client?.close();\n\t\t\tproxiedSocket?.destroy();\n\t\t}\n\t})();\n\n\treturn stream;\n};\n\ntype ToolCallState = ToolCall & {\n\tindex: number;\n", + "after": "\t\t\tif (h2Request && !h2Request.closed && !h2Request.destroyed) {\n\t\t\t\th2Request.end();\n\t\t\t}\n\t\t\th2Client?.close();\n\t\t\tproxiedSocket?.destroy();\n\t\t}\n\t});\n\n\treturn stream;\n};\n\ntype ToolCallState = ToolCall & {\n\tindex: number;\n" + } + ], + "beforeSha256": "600ec9d4ae4cbd13e02e394938fb4c234cb5fbab38d0526c77df1fd86665de74", + "afterSha256": "3c8cbb82d7e210b126a627292e492ba010ffabe37f408b4dcfe017f2db0593ba" + }, + { + "package": "@gajae-code/ai", + "path": "src/providers/google-gemini-cli.ts", + "replacements": [ + { + "before": "/**\n * Google Gemini CLI / Antigravity provider.\n * Shared implementation for both google-gemini-cli and google-antigravity providers.\n * Uses the Cloud Code Assist API endpoint to access Gemini and Anthropic model models.\n */\nimport { createHash, randomBytes, randomUUID } from \"node:crypto\";\n", + "after": "import { runAppStreamProducer } from \"../utils/event-stream\";\n/**\n * Google Gemini CLI / Antigravity provider.\n * Shared implementation for both google-gemini-cli and google-antigravity providers.\n * Uses the Cloud Code Assist API endpoint to access Gemini and Anthropic model models.\n */\nimport { createHash, randomBytes, randomUUID } from \"node:crypto\";\n" + }, + { + "before": "\tmodel: Model<\"google-gemini-cli\">,\n\tcontext: Context,\n\toptions?: GoogleGeminiCliOptions,\n): AssistantMessageEventStream => {\n\tconst stream = new AssistantMessageEventStream();\n\n\t(async () => {\n\t\tconst startTime = Date.now();\n\t\tlet firstTokenTime: number | undefined;\n\n\t\tconst output: AssistantMessage = {\n\t\t\trole: \"assistant\",\n\t\t\tcontent: [],\n", + "after": "\tmodel: Model<\"google-gemini-cli\">,\n\tcontext: Context,\n\toptions?: GoogleGeminiCliOptions,\n): AssistantMessageEventStream => {\n\tconst stream = new AssistantMessageEventStream();\n\n\trunAppStreamProducer(stream, async () => {\n\t\tconst startTime = Date.now();\n\t\tlet firstTokenTime: number | undefined;\n\n\t\tconst output: AssistantMessage = {\n\t\t\trole: \"assistant\",\n\t\t\tcontent: [],\n" + }, + { + "before": "\t\t\t);\n\t\t\toutput.duration = Date.now() - startTime;\n\t\t\tif (firstTokenTime) output.ttft = firstTokenTime - startTime;\n\t\t\tstream.push({ type: \"error\", reason: output.stopReason, error: output });\n\t\t\tstream.end();\n\t\t}\n\t})();\n\n\treturn stream;\n};\n\nconst INT63_MASK = (1n << 63n) - 1n;\nconst ANTIGRAVITY_RANDOM_BOUND = 9_000_000_000_000_000_000n;\n", + "after": "\t\t\t);\n\t\t\toutput.duration = Date.now() - startTime;\n\t\t\tif (firstTokenTime) output.ttft = firstTokenTime - startTime;\n\t\t\tstream.push({ type: \"error\", reason: output.stopReason, error: output });\n\t\t\tstream.end();\n\t\t}\n\t});\n\n\treturn stream;\n};\n\nconst INT63_MASK = (1n << 63n) - 1n;\nconst ANTIGRAVITY_RANDOM_BOUND = 9_000_000_000_000_000_000n;\n" + } + ], + "beforeSha256": "a9341059e06c18341b448325452c9e7b7d0b1a5b837b73bcbb2f41d72a4bfd88", + "afterSha256": "0a6d88064e796dfb9890af1e88a5ea49b5844128c8a4900b4bc43f765693677b" + }, + { + "package": "@gajae-code/ai", + "path": "src/providers/google-shared.ts", + "replacements": [ + { + "before": "/**\n * Shared utilities for Google Generative AI and Google Cloud Code Assist providers.\n */\n\nimport { extractHttpStatusFromError, readJsonl, readSseJson } from \"@gajae-code/utils\";\nimport type { ProviderSafetyStopAdapterInvocation } from \"../adapter-internals/provider-safety-stop\";\n", + "after": "import { runAppStreamProducer } from \"../utils/event-stream\";\n/**\n * Shared utilities for Google Generative AI and Google Cloud Code Assist providers.\n */\n\nimport { extractHttpStatusFromError, readJsonl, readSseJson } from \"@gajae-code/utils\";\nimport type { ProviderSafetyStopAdapterInvocation } from \"../adapter-internals/provider-safety-stop\";\n" + }, + { + "before": "\tretainTextSignature?: boolean;\n\tprepare: () => GoogleGenAIRequestPlan | Promise;\n}): AssistantMessageEventStream {\n\tconst { model, options, api, retainTextSignature, prepare } = args;\n\tconst stream = new AssistantMessageEventStream();\n\n\t(async () => {\n\t\tconst startTime = Date.now();\n\t\tlet firstTokenTime: number | undefined;\n\n\t\tconst output: AssistantMessage = {\n\t\t\trole: \"assistant\",\n\t\t\tcontent: [],\n", + "after": "\tretainTextSignature?: boolean;\n\tprepare: () => GoogleGenAIRequestPlan | Promise;\n}): AssistantMessageEventStream {\n\tconst { model, options, api, retainTextSignature, prepare } = args;\n\tconst stream = new AssistantMessageEventStream();\n\n\trunAppStreamProducer(stream, async () => {\n\t\tconst startTime = Date.now();\n\t\tlet firstTokenTime: number | undefined;\n\n\t\tconst output: AssistantMessage = {\n\t\t\trole: \"assistant\",\n\t\t\tcontent: [],\n" + }, + { + "before": "\t\t\toutput.errorMessage = await finalizeErrorMessage(error, rawRequestDump);\n\t\t\toutput.duration = Date.now() - startTime;\n\t\t\tif (firstTokenTime) output.ttft = firstTokenTime - startTime;\n\t\t\tstream.push({ type: \"error\", reason: output.stopReason, error: output });\n\t\t\tstream.end();\n\t\t}\n\t})();\n\n\treturn stream;\n}\n\n/**\n * Lift the SDK's `params.config` fields out of `config` and place them where the\n", + "after": "\t\t\toutput.errorMessage = await finalizeErrorMessage(error, rawRequestDump);\n\t\t\toutput.duration = Date.now() - startTime;\n\t\t\tif (firstTokenTime) output.ttft = firstTokenTime - startTime;\n\t\t\tstream.push({ type: \"error\", reason: output.stopReason, error: output });\n\t\t\tstream.end();\n\t\t}\n\t});\n\n\treturn stream;\n}\n\n/**\n * Lift the SDK's `params.config` fields out of `config` and place them where the\n" + } + ], + "beforeSha256": "047262d15dc484dcc29cb97e4572a070e74f65ec42bd21ca81522d328081f164", + "afterSha256": "8348e5dfdc0771292695295738024f339219ffa12d121644ce5b756b49a7871d" + }, + { + "package": "@gajae-code/ai", + "path": "src/providers/kiro-api-key.ts", + "replacements": [ + { + "before": "/**\n * Kiro API-key (ksk_) transport.\n *\n * Headless Kiro Pro keys authenticate against the Kiro service root with\n * `tokentype: API_KEY` and `origin: AI_EDITOR`. This is distinct from the\n * AWS SSO OIDC / CodeWhisperer streaming path used by `gjc auth-broker login kiro`.\n", + "after": "import { runAppStreamProducer } from \"../utils/event-stream\";\n/**\n * Kiro API-key (ksk_) transport.\n *\n * Headless Kiro Pro keys authenticate against the Kiro service root with\n * `tokentype: API_KEY` and `origin: AI_EDITOR`. This is distinct from the\n * AWS SSO OIDC / CodeWhisperer streaming path used by `gjc auth-broker login kiro`.\n" + }, + { + "before": "export const streamKiroApiKey: StreamFunction<\"kiro-codewhisperer-stream\"> = (\n\tmodel: Model<\"kiro-codewhisperer-stream\">,\n\tcontext: Context,\n\toptions: KiroCodeWhispererOptions,\n): AssistantMessageEventStream => {\n\tconst stream = new AssistantMessageEventStream();\n\t(async () => {\n\t\tconst apiKey = options.apiKey?.trim() ?? \"\";\n\t\tconst output: AssistantMessage = {\n\t\t\trole: \"assistant\",\n\t\t\tcontent: [],\n\t\t\tapi: \"kiro-codewhisperer-stream\" as Api,\n\t\t\tprovider: model.provider,\n", + "after": "export const streamKiroApiKey: StreamFunction<\"kiro-codewhisperer-stream\"> = (\n\tmodel: Model<\"kiro-codewhisperer-stream\">,\n\tcontext: Context,\n\toptions: KiroCodeWhispererOptions,\n): AssistantMessageEventStream => {\n\tconst stream = new AssistantMessageEventStream();\n\trunAppStreamProducer(stream, () => (async () => {\n\t\tconst apiKey = options.apiKey?.trim() ?? \"\";\n\t\tconst output: AssistantMessage = {\n\t\t\trole: \"assistant\",\n\t\t\tcontent: [],\n\t\t\tapi: \"kiro-codewhisperer-stream\" as Api,\n\t\t\tprovider: model.provider,\n" + }, + { + "before": "\t})().catch(() => {\n\t\ttry {\n\t\t\tstream.end();\n\t\t} catch {\n\t\t\t// ignore\n\t\t}\n\t});\n\treturn stream;\n};\n", + "after": "\t})().catch(() => {\n\t\ttry {\n\t\t\tstream.end();\n\t\t} catch {\n\t\t\t// ignore\n\t\t}\n\t}));\n\treturn stream;\n};\n" + } + ], + "beforeSha256": "991efd42b6b445a95786cc1632efe0d868489103685b31f8e7a27f58d225ac5d", + "afterSha256": "b687ff7b23bcd08640652dc910303d1534069569a162082be5e6604baba17d2e" + }, + { + "package": "@gajae-code/ai", + "path": "src/providers/kiro-codewhisperer.ts", + "replacements": [ + { + "before": "/**\n * Kiro / Amazon Q Developer / CodeWhisperer streaming transport.\n *\n * Talks directly to the CodeWhisperer streaming service over HTTPS using\n * a bearer token from AWS SSO OIDC. The response is an\n * `application/vnd.amazon.eventstream`, decoded by the shared\n", + "after": "import { runAppStreamProducer } from \"../utils/event-stream\";\n/**\n * Kiro / Amazon Q Developer / CodeWhisperer streaming transport.\n *\n * Talks directly to the CodeWhisperer streaming service over HTTPS using\n * a bearer token from AWS SSO OIDC. The response is an\n * `application/vnd.amazon.eventstream`, decoded by the shared\n" + }, + { + "before": "\tif (isKiroApiKey(token)) {\n\t\treturn streamKiroApiKey(model, context, { ...options, apiKey: token });\n\t}\n\n\tconst stream = new AssistantMessageEventStream();\n\n\t(async () => {\n\t\tconst startTime = Date.now();\n\t\tlet firstTokenTime: number | undefined;\n\n\t\tconst output: AssistantMessage = {\n\t\t\trole: \"assistant\",\n\t\t\tcontent: [],\n", + "after": "\tif (isKiroApiKey(token)) {\n\t\treturn streamKiroApiKey(model, context, { ...options, apiKey: token });\n\t}\n\n\tconst stream = new AssistantMessageEventStream();\n\n\trunAppStreamProducer(stream, async () => {\n\t\tconst startTime = Date.now();\n\t\tlet firstTokenTime: number | undefined;\n\n\t\tconst output: AssistantMessage = {\n\t\t\trole: \"assistant\",\n\t\t\tcontent: [],\n" + }, + { + "before": "\t\t\toutput.errorMessage = baseMessage;\n\t\t\toutput.duration = Date.now() - startTime;\n\t\t\tif (firstTokenTime) output.ttft = firstTokenTime - startTime;\n\t\t\tstream.push({ type: \"error\", reason: output.stopReason, error: output });\n\t\t\tstream.end();\n\t\t}\n\t})();\n\n\treturn stream;\n};\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Request building\n", + "after": "\t\t\toutput.errorMessage = baseMessage;\n\t\t\toutput.duration = Date.now() - startTime;\n\t\t\tif (firstTokenTime) output.ttft = firstTokenTime - startTime;\n\t\t\tstream.push({ type: \"error\", reason: output.stopReason, error: output });\n\t\t\tstream.end();\n\t\t}\n\t});\n\n\treturn stream;\n};\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Request building\n" + } + ], + "beforeSha256": "c07fd910ef704d1542f561e1453fd99c0047ad3eac573022595f1ac3e97b1e25", + "afterSha256": "1769145787803a0da584b18ada0393364a576d764f7853e3cd05cf3f63be02fb" + }, + { + "package": "@gajae-code/ai", + "path": "src/providers/ollama.ts", + "replacements": [ + { + "before": "import { extractHttpStatusFromError, fetchWithRetry } from \"@gajae-code/utils\";\nimport { getEnvApiKey } from \"../stream\";\nimport type {\n\tApi,\n\tAssistantMessage,\n\tContext,\n", + "after": "import { runAppStreamProducer } from \"../utils/event-stream\";\nimport { extractHttpStatusFromError, fetchWithRetry } from \"@gajae-code/utils\";\nimport { getEnvApiKey } from \"../stream\";\nimport type {\n\tApi,\n\tAssistantMessage,\n\tContext,\n" + }, + { + "before": "export const streamOllama: StreamFunction<\"ollama-chat\"> = (\n\tmodel: Model<\"ollama-chat\">,\n\tcontext: Context,\n\toptions: OllamaChatOptions,\n): AssistantMessageEventStream => {\n\tconst stream = new AssistantMessageEventStream();\n\tvoid (async () => {\n\t\tconst startTime = Date.now();\n\t\tlet firstTokenTime: number | undefined;\n\t\tconst output = createEmptyOutput(model);\n\t\tlet rawRequestDump: RawHttpRequestDump | undefined;\n\t\tlet activeThinkingIndex: number | undefined;\n\t\tlet activeTextIndex: number | undefined;\n", + "after": "export const streamOllama: StreamFunction<\"ollama-chat\"> = (\n\tmodel: Model<\"ollama-chat\">,\n\tcontext: Context,\n\toptions: OllamaChatOptions,\n): AssistantMessageEventStream => {\n\tconst stream = new AssistantMessageEventStream();\n\trunAppStreamProducer(stream, async () => {\n\t\tconst startTime = Date.now();\n\t\tlet firstTokenTime: number | undefined;\n\t\tconst output = createEmptyOutput(model);\n\t\tlet rawRequestDump: RawHttpRequestDump | undefined;\n\t\tlet activeThinkingIndex: number | undefined;\n\t\tlet activeTextIndex: number | undefined;\n" + }, + { + "before": "\t\t\tif (firstTokenTime) {\n\t\t\t\toutput.ttft = firstTokenTime - startTime;\n\t\t\t}\n\t\t\tstream.push({ type: \"error\", reason: output.stopReason, error: output });\n\t\t\tstream.end();\n\t\t}\n\t})();\n\treturn stream;\n};\n", + "after": "\t\t\tif (firstTokenTime) {\n\t\t\t\toutput.ttft = firstTokenTime - startTime;\n\t\t\t}\n\t\t\tstream.push({ type: \"error\", reason: output.stopReason, error: output });\n\t\t\tstream.end();\n\t\t}\n\t});\n\treturn stream;\n};\n" + } + ], + "beforeSha256": "1c159fa9917eb12af4c23b9398ed09412271a96d0f4e3243479f278bd00e8d86", + "afterSha256": "d2b1272d91d46b92fd494605b78d0619c8f072b45e68ba8f2ef25cfc5aab7df0" + }, + { + "package": "@gajae-code/ai", + "path": "src/providers/openai-completions.ts", + "replacements": [ + { + "before": "import { scheduler } from \"node:timers/promises\";\nimport { $credentialEnv, $env, extractHttpStatusFromError, logger } from \"@gajae-code/utils\";\nimport OpenAI, { APIConnectionTimeoutError } from \"openai\";\nimport type {\n\tChatCompletionAssistantMessageParam,\n\tChatCompletionChunk,\n", + "after": "import { runAppStreamProducer } from \"../utils/event-stream\";\nimport { scheduler } from \"node:timers/promises\";\nimport { $credentialEnv, $env, extractHttpStatusFromError, logger } from \"@gajae-code/utils\";\nimport OpenAI, { APIConnectionTimeoutError } from \"openai\";\nimport type {\n\tChatCompletionAssistantMessageParam,\n\tChatCompletionChunk,\n" + }, + { + "before": "\tmodel: Model<\"openai-completions\">,\n\tcontext: Context,\n\toptions?: OpenAICompletionsOptions,\n): AssistantMessageEventStream => {\n\tconst stream = new AssistantMessageEventStream();\n\n\t(async () => {\n\t\tconst startTime = Date.now();\n\t\tlet firstTokenTime: number | undefined;\n\t\tlet streamConnected = false;\n\t\tlet getCapturedErrorResponse: (() => CapturedHttpErrorResponse | undefined) | undefined;\n\n\t\tconst output: AssistantMessage = createInitialResponsesAssistantMessage(model.api, model.provider, model.id);\n", + "after": "\tmodel: Model<\"openai-completions\">,\n\tcontext: Context,\n\toptions?: OpenAICompletionsOptions,\n): AssistantMessageEventStream => {\n\tconst stream = new AssistantMessageEventStream();\n\n\trunAppStreamProducer(stream, async () => {\n\t\tconst startTime = Date.now();\n\t\tlet firstTokenTime: number | undefined;\n\t\tlet streamConnected = false;\n\t\tlet getCapturedErrorResponse: (() => CapturedHttpErrorResponse | undefined) | undefined;\n\n\t\tconst output: AssistantMessage = createInitialResponsesAssistantMessage(model.api, model.provider, model.id);\n" + }, + { + "before": "\t\t\t}\n\t\t\toutput.duration = Date.now() - startTime;\n\t\t\tif (firstTokenTime) output.ttft = firstTokenTime - startTime;\n\t\t\tstream.push({ type: \"error\", reason: output.stopReason, error: output });\n\t\t\tstream.end();\n\t\t}\n\t})();\n\n\treturn stream;\n};\n\nasync function createClient(\n\tmodel: Model<\"openai-completions\">,\n", + "after": "\t\t\t}\n\t\t\toutput.duration = Date.now() - startTime;\n\t\t\tif (firstTokenTime) output.ttft = firstTokenTime - startTime;\n\t\t\tstream.push({ type: \"error\", reason: output.stopReason, error: output });\n\t\t\tstream.end();\n\t\t}\n\t});\n\n\treturn stream;\n};\n\nasync function createClient(\n\tmodel: Model<\"openai-completions\">,\n" + } + ], + "beforeSha256": "f33c6065494a3143f70fee94cd91427bb8475f0f4fe55128b6e6d5b05d9f9604", + "afterSha256": "b0ba241c2645acf199dfcab65091d755286b06e5aea949d3eb9512fe25a74323" + }, + { + "package": "@gajae-code/ai", + "path": "src/providers/openai-responses.ts", + "replacements": [ + { + "before": "import { $credentialEnv, extractHttpStatusFromError, logger, structuredCloneJSON } from \"@gajae-code/utils\";\nimport OpenAI, { APIConnectionTimeoutError } from \"openai\";\nimport type {\n\tTool as OpenAITool,\n\tResponseCreateParamsStreaming,\n\tResponseInput,\n", + "after": "import { runAppStreamProducer } from \"../utils/event-stream\";\nimport { $credentialEnv, extractHttpStatusFromError, logger, structuredCloneJSON } from \"@gajae-code/utils\";\nimport OpenAI, { APIConnectionTimeoutError } from \"openai\";\nimport type {\n\tTool as OpenAITool,\n\tResponseCreateParamsStreaming,\n\tResponseInput,\n" + }, + { + "before": "\tcontext: Context,\n\toptions?: OpenAIResponsesOptions,\n): AssistantMessageEventStream => {\n\tconst stream = new AssistantMessageEventStream();\n\n\t// Start async processing\n\t(async () => {\n\t\tconst startTime = Date.now();\n\t\tlet firstTokenTime: number | undefined;\n\t\tlet streamConnected = false;\n\n\t\tconst output: AssistantMessage = createInitialResponsesAssistantMessage(\n\t\t\t\"openai-responses\",\n", + "after": "\tcontext: Context,\n\toptions?: OpenAIResponsesOptions,\n): AssistantMessageEventStream => {\n\tconst stream = new AssistantMessageEventStream();\n\n\t// Start async processing\n\trunAppStreamProducer(stream, async () => {\n\t\tconst startTime = Date.now();\n\t\tlet firstTokenTime: number | undefined;\n\t\tlet streamConnected = false;\n\n\t\tconst output: AssistantMessage = createInitialResponsesAssistantMessage(\n\t\t\t\"openai-responses\",\n" + }, + { + "before": "\t\t\t}\n\t\t\toutput.duration = Date.now() - startTime;\n\t\t\tif (firstTokenTime) output.ttft = firstTokenTime - startTime;\n\t\t\tstream.push({ type: \"error\", reason: output.stopReason, error: output });\n\t\t\tstream.end();\n\t\t}\n\t})();\n\n\treturn stream;\n};\n\nfunction createClient(\n\tmodel: Model<\"openai-responses\">,\n", + "after": "\t\t\t}\n\t\t\toutput.duration = Date.now() - startTime;\n\t\t\tif (firstTokenTime) output.ttft = firstTokenTime - startTime;\n\t\t\tstream.push({ type: \"error\", reason: output.stopReason, error: output });\n\t\t\tstream.end();\n\t\t}\n\t});\n\n\treturn stream;\n};\n\nfunction createClient(\n\tmodel: Model<\"openai-responses\">,\n" + } + ], + "beforeSha256": "fc45debb2e3f89fe505407d0d90b5d33a9dc5ad9370c18be03275522a1a1a9c4", + "afterSha256": "bb6991c117a20f2e38ca7d018db3c781c74bbe2c0843b698ef38929929099d26" + }, + { + "package": "@gajae-code/ai", + "path": "src/providers/openai-codex-responses.ts", + "replacements": [ + { + "before": "import * as os from \"node:os\";\nimport { scheduler } from \"node:timers/promises\";\nimport {\n\t$env,\n\t$pickflag,\n\tasRecord,\n", + "after": "import { runAppStreamProducer } from \"../utils/event-stream\";\nimport * as os from \"node:os\";\nimport { scheduler } from \"node:timers/promises\";\nimport {\n\t$env,\n\t$pickflag,\n\tasRecord,\n" + }, + { + "before": "\tconst stream = new AssistantMessageEventStream(() => consumerAbortController.abort());\n\tconst signal = options?.signal\n\t\t? AbortSignal.any([options.signal, consumerAbortController.signal])\n\t\t: consumerAbortController.signal;\n\tconst streamOptions = { ...options, signal };\n\n\t(async () => {\n\t\tconst startTime = Date.now();\n\t\tconst output = createAssistantOutput(model);\n\t\tconst requestSetup = createRequestSetup(streamOptions);\n\t\tlet processingContext: CodexStreamProcessingContext | undefined;\n\n\t\ttry {\n", + "after": "\tconst stream = new AssistantMessageEventStream(() => consumerAbortController.abort());\n\tconst signal = options?.signal\n\t\t? AbortSignal.any([options.signal, consumerAbortController.signal])\n\t\t: consumerAbortController.signal;\n\tconst streamOptions = { ...options, signal };\n\n\trunAppStreamProducer(stream, async () => {\n\t\tconst startTime = Date.now();\n\t\tconst output = createAssistantOutput(model);\n\t\tconst requestSetup = createRequestSetup(streamOptions);\n\t\tlet processingContext: CodexStreamProcessingContext | undefined;\n\n\t\ttry {\n" + }, + { + "before": "\t\t\t\t\tstartTime,\n\t\t\t\t} satisfies CodexStreamProcessingContext);\n\t\t\tconst failure = await handleCodexStreamFailure(failureContext, error);\n\t\t\tstream.push({ type: \"error\", reason: failure.stopReason as \"error\" | \"aborted\", error: failure });\n\t\t\tstream.end();\n\t\t}\n\t})();\n\n\treturn stream;\n};\n\nexport async function prewarmOpenAICodexResponses(\n\tmodel: Model<\"openai-codex-responses\">,\n", + "after": "\t\t\t\t\tstartTime,\n\t\t\t\t} satisfies CodexStreamProcessingContext);\n\t\t\tconst failure = await handleCodexStreamFailure(failureContext, error);\n\t\t\tstream.push({ type: \"error\", reason: failure.stopReason as \"error\" | \"aborted\", error: failure });\n\t\t\tstream.end();\n\t\t}\n\t});\n\n\treturn stream;\n};\n\nexport async function prewarmOpenAICodexResponses(\n\tmodel: Model<\"openai-codex-responses\">,\n" + } + ], + "beforeSha256": "565d287edcd17dbbe067ffd8c094afb12c2794258960d29fb2e941866500b73a", + "afterSha256": "961ea9317a7852759c43555ef8a72901025cb17f6de2bfb86e72446197cc87eb" + }, + { + "package": "@gajae-code/ai", + "path": "src/providers/openai-anthropic-shim.ts", + "replacements": [ + { + "before": "/**\n * Shared implementation for providers that expose BOTH an OpenAI-compatible\n * and an Anthropic-compatible API surface against the same model catalog\n * (currently Kimi Code and Synthetic).\n *\n * Each call site supplies the provider-specific bits (base URLs, default\n", + "after": "import { runAppStreamProducer, adoptAppStreamProducer } from \"../utils/event-stream\";\n/**\n * Shared implementation for providers that expose BOTH an OpenAI-compatible\n * and an Anthropic-compatible API surface against the same model catalog\n * (currently Kimi Code and Synthetic).\n *\n * Each call site supplies the provider-specific bits (base URLs, default\n" + }, + { + "before": "\toptions: OpenAIAnthropicShimOptions | undefined,\n\tconfig: OpenAIAnthropicShimConfig,\n): AssistantMessageEventStream {\n\tconst stream = new AssistantMessageEventStream();\n\tconst format = options?.format ?? config.defaultFormat;\n\n\t(async () => {\n\t\ttry {\n\t\t\tconst mergedHeaders = {\n\t\t\t\t...(config.extraHeaders?.() ?? {}),\n\t\t\t\t...options?.headers,\n\t\t\t};\n\n", + "after": "\toptions: OpenAIAnthropicShimOptions | undefined,\n\tconfig: OpenAIAnthropicShimConfig,\n): AssistantMessageEventStream {\n\tconst stream = new AssistantMessageEventStream();\n\tconst format = options?.format ?? config.defaultFormat;\n\n\trunAppStreamProducer(stream, async () => {\n\t\ttry {\n\t\t\tconst mergedHeaders = {\n\t\t\t\t...(config.extraHeaders?.() ?? {}),\n\t\t\t\t...options?.headers,\n\t\t\t};\n\n" + }, + { + "before": "\t\t\t\t\t\tstreamFirstEventTimeoutMs: options?.streamFirstEventTimeoutMs,\n\t\t\t\t\t\tthinkingEnabled,\n\t\t\t\t\t\tthinkingBudgetTokens: thinkingBudget,\n\t\t\t\t\t}),\n\t\t\t\t);\n\n\t\t\t\tfor await (const event of innerStream) {\n\t\t\t\t\tstream.push(event);\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tconst openaiModel: Model<\"openai-completions\"> = config.openaiBaseUrl\n\t\t\t\t\t? { ...model, baseUrl: config.openaiBaseUrl, headers: mergedHeaders }\n", + "after": "\t\t\t\t\t\tstreamFirstEventTimeoutMs: options?.streamFirstEventTimeoutMs,\n\t\t\t\t\t\tthinkingEnabled,\n\t\t\t\t\t\tthinkingBudgetTokens: thinkingBudget,\n\t\t\t\t\t}),\n\t\t\t\t);\n\n\t\t\t\tadoptAppStreamProducer(stream, innerStream);\n\t\t\t\tfor await (const event of innerStream) {\n\t\t\t\t\tstream.push(event);\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tconst openaiModel: Model<\"openai-completions\"> = config.openaiBaseUrl\n\t\t\t\t\t? { ...model, baseUrl: config.openaiBaseUrl, headers: mergedHeaders }\n" + }, + { + "before": "\t\t\t\t\t\tstreamIdleTimeoutMs: options?.streamIdleTimeoutMs,\n\t\t\t\t\t\tstreamFirstEventTimeoutMs: options?.streamFirstEventTimeoutMs,\n\t\t\t\t\t\treasoning: reasoningEffort,\n\t\t\t\t\t}),\n\t\t\t\t);\n\n\t\t\t\tfor await (const event of innerStream) {\n\t\t\t\t\tstream.push(event);\n\t\t\t\t}\n\t\t\t}\n\t\t} catch (err) {\n\t\t\tstream.push({\n\t\t\t\ttype: \"error\",\n\t\t\t\treason: \"error\",\n\t\t\t\terror: createProviderErrorMessage(model, err),\n\t\t\t});\n\t\t}\n\t})();\n\n\treturn stream;\n}\n", + "after": "\t\t\t\t\t\tstreamIdleTimeoutMs: options?.streamIdleTimeoutMs,\n\t\t\t\t\t\tstreamFirstEventTimeoutMs: options?.streamFirstEventTimeoutMs,\n\t\t\t\t\t\treasoning: reasoningEffort,\n\t\t\t\t\t}),\n\t\t\t\t);\n\n\t\t\t\tadoptAppStreamProducer(stream, innerStream);\n\t\t\t\tfor await (const event of innerStream) {\n\t\t\t\t\tstream.push(event);\n\t\t\t\t}\n\t\t\t}\n\t\t} catch (err) {\n\t\t\tstream.push({\n\t\t\t\ttype: \"error\",\n\t\t\t\treason: \"error\",\n\t\t\t\terror: createProviderErrorMessage(model, err),\n\t\t\t});\n\t\t}\n\t});\n\n\treturn stream;\n}\n" + } + ], + "beforeSha256": "185f653297144b10de41a27c96ae85b2fd73f62eb680cc12373c3bf60ebee4a8", + "afterSha256": "ac5bef1d48b06dbf714fb0afa908d3c57e437050e628d6d37ebf33ee06ea6adb" + }, + { + "package": "@gajae-code/ai", + "path": "src/providers/gitlab-duo.ts", + "replacements": [ + { + "before": "import { copyProviderSafetyStopAdapterInvocation } from \"../adapter-internals/provider-safety-stop\";\nimport { ANTHROPIC_THINKING, mapAnthropicToolChoice } from \"../stream\";\nimport type { Api, Context, FetchImpl, Model, SimpleStreamOptions } from \"../types\";\nimport { AssistantMessageEventStream } from \"../utils/event-stream\";\nimport { createProviderErrorMessage } from \"./error-message\";\nimport type { OpenAICompletionsOptions } from \"./openai-completions\";\n", + "after": "import { runAppStreamProducer, adoptAppStreamProducer } from \"../utils/event-stream\";\nimport { copyProviderSafetyStopAdapterInvocation } from \"../adapter-internals/provider-safety-stop\";\nimport { ANTHROPIC_THINKING, mapAnthropicToolChoice } from \"../stream\";\nimport type { Api, Context, FetchImpl, Model, SimpleStreamOptions } from \"../types\";\nimport { AssistantMessageEventStream } from \"../utils/event-stream\";\nimport { createProviderErrorMessage } from \"./error-message\";\nimport type { OpenAICompletionsOptions } from \"./openai-completions\";\n" + }, + { + "before": "\tmodel: Model,\n\tcontext: Context,\n\toptions?: SimpleStreamOptions,\n): AssistantMessageEventStream {\n\tconst stream = new AssistantMessageEventStream();\n\n\t(async () => {\n\t\ttry {\n\t\t\tif (!options?.apiKey) {\n\t\t\t\tthrow new Error(\"Missing GitLab access token. Run /login gitlab-duo or set GITLAB_TOKEN.\");\n\t\t\t}\n\n\t\t\tconst mapping = getModelMapping(model.id);\n", + "after": "\tmodel: Model,\n\tcontext: Context,\n\toptions?: SimpleStreamOptions,\n): AssistantMessageEventStream {\n\tconst stream = new AssistantMessageEventStream();\n\n\trunAppStreamProducer(stream, async () => {\n\t\ttry {\n\t\t\tif (!options?.apiKey) {\n\t\t\t\tthrow new Error(\"Missing GitLab access token. Run /login gitlab-duo or set GITLAB_TOKEN.\");\n\t\t\t}\n\n\t\t\tconst mapping = getModelMapping(model.id);\n" + }, + { + "before": "\t\t\t\t\t\t\t\t\tfetch: options.fetch,\n\t\t\t\t\t\t\t\t\treasoning: reasoningEffort,\n\t\t\t\t\t\t\t\t\ttoolChoice: options.toolChoice,\n\t\t\t\t\t\t\t\t}) satisfies OpenAICompletionsOptions,\n\t\t\t\t\t\t\t);\n\n\t\t\tfor await (const event of inner) {\n\t\t\t\tstream.push(event);\n\t\t\t}\n\t\t} catch (err) {\n\t\t\tstream.push({\n\t\t\t\ttype: \"error\",\n\t\t\t\treason: \"error\",\n\t\t\t\terror: createProviderErrorMessage(model, err),\n\t\t\t});\n\t\t}\n\t})();\n\n\treturn stream;\n}\n", + "after": "\t\t\t\t\t\t\t\t\tfetch: options.fetch,\n\t\t\t\t\t\t\t\t\treasoning: reasoningEffort,\n\t\t\t\t\t\t\t\t\ttoolChoice: options.toolChoice,\n\t\t\t\t\t\t\t\t}) satisfies OpenAICompletionsOptions,\n\t\t\t\t\t\t\t);\n\n\t\t\tadoptAppStreamProducer(stream, inner);\n\t\t\tfor await (const event of inner) {\n\t\t\t\tstream.push(event);\n\t\t\t}\n\t\t} catch (err) {\n\t\t\tstream.push({\n\t\t\t\ttype: \"error\",\n\t\t\t\treason: \"error\",\n\t\t\t\terror: createProviderErrorMessage(model, err),\n\t\t\t});\n\t\t}\n\t});\n\n\treturn stream;\n}\n" + } + ], + "beforeSha256": "a8982837c0f8f4b6c6e433db4b103b02215d9bc9423bf240094081b78a943a45", + "afterSha256": "9a742a461ebe03e0a13f55a756a32ac146518312ce21f5e64f022919b5d15f71" + }, + { + "package": "@gajae-code/ai", + "path": "src/providers/pi-native-client.ts", + "replacements": [ + { + "before": "/**\n * Client half of the pi-native auth-gateway protocol.\n *\n * Dispatches a {@link streamSimple}-shaped request to an `gjc auth-gateway`\n * via `POST /v1/pi/stream`, reads the SSE event stream back, and pushes the\n * parsed events into a local {@link AssistantMessageEventStream} — the same\n", + "after": "import { runAppStreamProducer } from \"../utils/event-stream\";\n/**\n * Client half of the pi-native auth-gateway protocol.\n *\n * Dispatches a {@link streamSimple}-shaped request to an `gjc auth-gateway`\n * via `POST /v1/pi/stream`, reads the SSE event stream back, and pushes the\n * parsed events into a local {@link AssistantMessageEventStream} — the same\n" + }, + { + "before": "\tmodel: Model,\n\tcontext: Context,\n\toptions?: SimpleStreamOptions,\n): AssistantMessageEventStreamType {\n\tconst stream = new AssistantMessageEventStream();\n\n\tvoid (async () => {\n\t\tconst signal = options?.signal;\n\t\t// Abort propagation: cancel the response body when the caller's signal\n\t\t// fires. Mirror `streamProxy`'s shape — explicit listener + finally\n\t\t// cleanup — so we don't leak listeners on the long-running case.\n\t\tlet response: Response | null = null;\n\t\tconst onAbort = (): void => {\n", + "after": "\tmodel: Model,\n\tcontext: Context,\n\toptions?: SimpleStreamOptions,\n): AssistantMessageEventStreamType {\n\tconst stream = new AssistantMessageEventStream();\n\n\trunAppStreamProducer(stream, async () => {\n\t\tconst signal = options?.signal;\n\t\t// Abort propagation: cancel the response body when the caller's signal\n\t\t// fires. Mirror `streamProxy`'s shape — explicit listener + finally\n\t\t// cleanup — so we don't leak listeners on the long-running case.\n\t\tlet response: Response | null = null;\n\t\tconst onAbort = (): void => {\n" + }, + { + "before": "\t\t\tstream.end();\n\t\t} catch (err) {\n\t\t\tstream.fail(err);\n\t\t} finally {\n\t\t\tif (signal) signal.removeEventListener(\"abort\", onAbort);\n\t\t}\n\t})();\n\n\treturn stream;\n}\n\nfunction makeSyntheticAssistant(model: Model): AssistantMessage {\n\treturn {\n", + "after": "\t\t\tstream.end();\n\t\t} catch (err) {\n\t\t\tstream.fail(err);\n\t\t} finally {\n\t\t\tif (signal) signal.removeEventListener(\"abort\", onAbort);\n\t\t}\n\t});\n\n\treturn stream;\n}\n\nfunction makeSyntheticAssistant(model: Model): AssistantMessage {\n\treturn {\n" + } + ], + "beforeSha256": "9ca2db0f86aa261a819d4c7cb49cb1aa5f21d5087e6e31eed5c5bf0f9499746d", + "afterSha256": "64be63baba7dfeac15bfa1a4779d1cf8d3be6c8f9ef14f92ca8ea0b28c829d49" + }, + { + "package": "@gajae-code/coding-agent", + "path": "src/sdk/host/host.ts", + "replacements": [ + { + "before": "import { logger } from \"@gajae-code/utils\";\nimport { AUTOROUTING_INACTIVE_WARNING } from \"../../config/autorouting-contract\";\nimport { redactBrokerRuntimeCapabilities, redactObservedRequestContent } from \"./control/runtime-gate\";\nimport { type EventFrame, SessionEventStream } from \"./events\";\nimport { isAutoroutingInactive } from \"./internal-autorouting-state\";\nimport { type ProviderLease, ReverseLeaseError, ReverseLeaseRuntime } from \"./reverse-leases\";\nimport type { BrokerIndexWriter, HostEndpointAdapters, SdkFrame } from \"./types\";\n\nexport type SdkRequestObserver = (kind: \"control\" | \"query\", connectionId: string, frame: SdkFrame) => void;\n\n/**\n * When a session publishes its replayable readiness signal.\n *\n * `immediate` is the stock contract: `start()` publishes `session_ready` at\n * once, so a chat daemon that attaches (or replays late) surfaces the session\n * and creates its stock root. `deferred` prepares the session instead: the\n", + "after": "import { AsyncLocalStorage } from \"node:async_hooks\";\nimport { logger } from \"@gajae-code/utils\";\nimport { AUTOROUTING_INACTIVE_WARNING } from \"../../config/autorouting-contract\";\nimport { redactBrokerRuntimeCapabilities, redactObservedRequestContent } from \"./control/runtime-gate\";\nimport { type EventFrame, SessionEventStream } from \"./events\";\nimport { isAutoroutingInactive } from \"./internal-autorouting-state\";\nimport { type ProviderLease, ReverseLeaseError, ReverseLeaseRuntime } from \"./reverse-leases\";\nimport type { BrokerIndexWriter, HostEndpointAdapters, SdkFrame } from \"./types\";\n\nexport type SdkRequestObserver = (kind: \"control\" | \"query\", connectionId: string, frame: SdkFrame) => void;\n\nconst appHostContext = new AsyncLocalStorage<{ owner: AppSdkHostOwner; token: object }>();\n/** Session-local physical ownership, independent of bounded/logical SDK shutdown. */\nexport class AppSdkHostOwner {\n\treadonly #epoch = crypto.randomUUID();\n\t#revision = 0;\n\treadonly #tasks = new Set>();\n\treadonly #active = new Set();\n\treadonly #timers = new Map, () => void>();\n\treadonly #unknown = new Set();\n\tmarkUnknown(reason: string): void { if (!this.#unknown.has(reason)) { this.#unknown.add(reason); this.#revision++; } }\n\tgetAppLifecycleActivity() {\n\t\treturn { generation: `${this.#epoch}:${this.#revision}`, complete: this.#unknown.size === 0,\n\t\t\tstarting: 0, queued: this.#timers.size, running: this.#tasks.size, settling: 0, unknown: [...this.#unknown] };\n\t}\n\town(operation: () => T | PromiseLike): Promise {\n\t\tconst reservation = Promise.withResolvers(); const token = {};\n\t\tthis.#tasks.add(reservation.promise); this.#active.add(token); this.#revision++;\n\t\tconst finish = () => { this.#tasks.delete(reservation.promise); this.#active.delete(token); this.#revision++; reservation.resolve(); };\n\t\ttry { return Promise.resolve(appHostContext.run({ owner: this, token }, operation)).finally(finish); }\n\t\tcatch (error) { finish(); return Promise.reject(error); }\n\t}\n\tretain(task: PromiseLike): Promise { return this.own(() => task); }\n\tschedule(operation: () => void | Promise, delay: number): ReturnType {\n\t\tconst due = Promise.withResolvers();\n\t\tvoid this.own(() => due.promise);\n\t\tconst timer = setTimeout(() => {\n\t\t\tthis.#timers.delete(timer); this.#revision++;\n\t\t\tvoid this.own(async () => {\n\t\t\t\ttry { await operation(); }\n\t\t\t\tcatch { this.markUnknown(\"sdk_host_callback_failed\"); }\n\t\t\t\tfinally { due.resolve(); }\n\t\t\t});\n\t\t}, delay);\n\t\tthis.#timers.set(timer, due.resolve); this.#revision++;\n\t\treturn timer;\n\t}\n\tcancelTimer(timer: ReturnType): void {\n\t\tclearTimeout(timer); const release = this.#timers.get(timer);\n\t\tif (release) { this.#timers.delete(timer); this.#revision++; release(); }\n\t}\n\tasync awaitAppLifecycleSettlement(): Promise {\n\t\tconst context = appHostContext.getStore();\n\t\tif (context?.owner === this && this.#active.has(context.token)) {\n\t\t\tthis.markUnknown(\"sdk_host_reentrant_settlement\");\n\t\t\tthrow new Error(\"Cannot join SDK host ownership from its own callback\");\n\t\t}\n\t\twhile (this.#tasks.size) await Promise.allSettled([...this.#tasks]);\n\t}\n}\n\n/** Capture the initiating host across detached continuations, without process-global ownership. */\nexport function ownAppSdkHostTask(operation: () => T | PromiseLike): Promise {\n\tconst owner = appHostContext.getStore()?.owner;\n\tif (owner) return owner.own(operation);\n\ttry { return Promise.resolve(operation()); } catch (error) { return Promise.reject(error); }\n}\nexport function currentAppSdkHostOwner(): AppSdkHostOwner | undefined { return appHostContext.getStore()?.owner; }\n\n/**\n * When a session publishes its replayable readiness signal.\n *\n * `immediate` is the stock contract: `start()` publishes `session_ready` at\n * once, so a chat daemon that attaches (or replays late) surfaces the session\n * and creates its stock root. `deferred` prepares the session instead: the\n" + }, + { + "before": "\t| \"authority_unavailable\";\n\n/** Proves that a prepared session may publish readiness at this exact generation. */\nexport type SessionActivationGate = (input: { sessionId: string; generation: number }) => boolean | Promise;\n\nexport interface SessionSdkHostOptions extends HostEndpointAdapters {\n\tcontrol?: (connectionId: string, frame: SdkFrame) => unknown | Promise;\n\tquery?: (connectionId: string, frame: SdkFrame) => unknown | Promise;\n\t/** Supplies the durable transcript revision stamped on ordinary emitted events. */\n\teventRevision?: () => number | undefined;\n\t/** Test/lifecycle seam invoked synchronously before fire-and-forget dispatch. */\n\tonFrameAdmitted?: (connectionId: string, frame: SdkFrame) => void;\n", + "after": "\t| \"authority_unavailable\";\n\n/** Proves that a prepared session may publish readiness at this exact generation. */\nexport type SessionActivationGate = (input: { sessionId: string; generation: number }) => boolean | Promise;\n\nexport interface SessionSdkHostOptions extends HostEndpointAdapters {\n\tappLifecycleOwner?: AppSdkHostOwner;\n\tcontrol?: (connectionId: string, frame: SdkFrame) => unknown | Promise;\n\tquery?: (connectionId: string, frame: SdkFrame) => unknown | Promise;\n\t/** Supplies the durable transcript revision stamped on ordinary emitted events. */\n\teventRevision?: () => number | undefined;\n\t/** Test/lifecycle seam invoked synchronously before fire-and-forget dispatch. */\n\tonFrameAdmitted?: (connectionId: string, frame: SdkFrame) => void;\n" + }, + { + "before": "function has(frame: SdkFrame, field: string): boolean {\n\treturn Object.hasOwn(frame, field);\n}\n\n/** Adapter-based session host; bus wiring owns NotificationServer creation and transport framing. */\nexport class SessionSdkHost {\n\treadonly events: SessionEventStream;\n\treadonly reverse: ReverseLeaseRuntime;\n\treadonly #options: SessionSdkHostOptions;\n\t#started = false;\n\t#stopping = false;\n\t#stopPromise?: Promise<\"stopped\">;\n", + "after": "function has(frame: SdkFrame, field: string): boolean {\n\treturn Object.hasOwn(frame, field);\n}\n\n/** Adapter-based session host; bus wiring owns NotificationServer creation and transport framing. */\nexport class SessionSdkHost {\n\treadonly appLifecycleOwner: AppSdkHostOwner;\n\treadonly events: SessionEventStream;\n\treadonly reverse: ReverseLeaseRuntime;\n\treadonly #options: SessionSdkHostOptions;\n\t#started = false;\n\t#stopping = false;\n\t#stopPromise?: Promise<\"stopped\">;\n" + }, + { + "before": "\t/** The generation whose readiness signal has already been published. */\n\t#readyGeneration?: number;\n\t/** Serializes activation attempts so a concurrent pair cannot both publish. */\n\t#activation: Promise = Promise.resolve(\"not_prepared\");\n\n\tconstructor(options: SessionSdkHostOptions) {\n\t\tthis.#options = options;\n\t\tthis.events = new SessionEventStream({ revisionProvider: options.eventRevision });\n\t\tthis.reverse = new ReverseLeaseRuntime({\n\t\t\tsendFrame: options.sendFrame,\n\t\t\tinstallDefinitions: options.installProviderDefinitions,\n\t\t\tonDefinitionsRemoved: options.onProviderDefinitionsRemoved,\n\t\t\tonCancel: options.onReverseCancel,\n\t\t});\n\t}\n\n", + "after": "\t/** The generation whose readiness signal has already been published. */\n\t#readyGeneration?: number;\n\t/** Serializes activation attempts so a concurrent pair cannot both publish. */\n\t#activation: Promise = Promise.resolve(\"not_prepared\");\n\n\tconstructor(options: SessionSdkHostOptions) {\n\t\tthis.appLifecycleOwner = options.appLifecycleOwner ?? new AppSdkHostOwner();\n\t\tthis.#options = { ...options, sendFrame: (connectionId, frame) =>\n\t\t\tthis.appLifecycleOwner.own(() => options.sendFrame(connectionId, frame)) };\n\t\tthis.events = new SessionEventStream({ revisionProvider: options.eventRevision });\n\t\tthis.reverse = new ReverseLeaseRuntime({\n\t\t\tsendFrame: this.#options.sendFrame,\n\t\t\tinstallDefinitions: options.installProviderDefinitions,\n\t\t\tonDefinitionsRemoved: options.onProviderDefinitionsRemoved,\n\t\t\tonCancel: options.onReverseCancel,\n\t\t});\n\t}\n\n" + }, + { + "before": "\t/** Release reverse leases after the transport reports a WebSocket disconnect. */\n\thandleDisconnect(connectionId: string): void {\n\t\tthis.reverse.disconnect(connectionId);\n\t}\n\t/** Route malformed transport bytes through the host's structured protocol-error seam. */\n\thandleMalformedFrame(connectionId: string, message: string): void {\n\t\tvoid this.#sendBestEffort(connectionId, {\n\t\t\ttype: \"protocol_error\",\n\t\t\tok: false,\n\t\t\terror: { code: \"invalid_frame\", message },\n\t\t});\n\t}\n\n\t/** Adds an event to the resumable event ring. Transport delivery is owned by bus wiring. */\n\temitEvent(frame: SdkFrame): EventFrame {\n\t\treturn this.events.emit(frame);\n\t}\n", + "after": "\t/** Release reverse leases after the transport reports a WebSocket disconnect. */\n\thandleDisconnect(connectionId: string): void {\n\t\tthis.reverse.disconnect(connectionId);\n\t}\n\t/** Route malformed transport bytes through the host's structured protocol-error seam. */\n\thandleMalformedFrame(connectionId: string, message: string): void {\n\t\tif (this.#stopping || !this.#started) return;\n\t\tvoid this.appLifecycleOwner.own(() => this.#sendBestEffort(connectionId, {\n\t\t\ttype: \"protocol_error\",\n\t\t\tok: false,\n\t\t\terror: { code: \"invalid_frame\", message },\n\t\t}));\n\t}\n\n\t/** Adds an event to the resumable event ring. Transport delivery is owned by bus wiring. */\n\temitEvent(frame: SdkFrame): EventFrame {\n\t\treturn this.events.emit(frame);\n\t}\n" + }, + { + "before": "\t\t\tthis.emitEvent({\n\t\t\t\tname: SESSION_PREPARED_EVENT,\n\t\t\t\tsessionId: this.#options.sessionId,\n\t\t\t\tgeneration: this.events.generation,\n\t\t\t});\n\t\tconst disposer = this.#options.onFrame((connectionId, frame) => {\n\t\t\tthis.#options.onFrameAdmitted?.(connectionId, frame);\n\t\t\tvoid this.#onFrame(connectionId, frame);\n\t\t});\n\t\tthis.#unsubscribe = typeof disposer === \"function\" ? disposer : undefined;\n\t\tthis.#started = true;\n\t\ttry {\n\t\t\tif (this.#registration)\n\t\t\t\tawait this.#registration.writer.register({\n", + "after": "\t\t\tthis.emitEvent({\n\t\t\t\tname: SESSION_PREPARED_EVENT,\n\t\t\t\tsessionId: this.#options.sessionId,\n\t\t\t\tgeneration: this.events.generation,\n\t\t\t});\n\t\tconst disposer = this.#options.onFrame((connectionId, frame) => {\n\t\t\tif (this.#stopping || !this.#started) return;\n\t\t\tvoid this.appLifecycleOwner.own(() => {\n\t\t\t\tthis.#options.onFrameAdmitted?.(connectionId, frame);\n\t\t\t\treturn this.#onFrame(connectionId, frame);\n\t\t\t});\n\t\t});\n\t\tthis.#unsubscribe = typeof disposer === \"function\" ? disposer : undefined;\n\t\tthis.#started = true;\n\t\ttry {\n\t\t\tif (this.#registration)\n\t\t\t\tawait this.#registration.writer.register({\n" + }, + { + "before": "\t * at that same generation. Authority is re-proved after the gate resolves,\n\t * because a stop or an endpoint roll can land while it is in flight, and an\n\t * exact retry after a successful activation is answered `already` instead of\n\t * publishing a second readiness signal.\n\t */\n\tactivate(expectedGeneration?: number): Promise {\n\t\tconst attempt = this.#activation.then(\n\t\t\t() => this.#activateOnce(expectedGeneration),\n\t\t\t() => this.#activateOnce(expectedGeneration),\n\t\t);\n\t\tthis.#activation = attempt.then(\n\t\t\toutcome => outcome,\n\t\t\t() => \"authority_unavailable\" as const,\n\t\t);\n\t\treturn attempt;\n\t}\n", + "after": "\t * at that same generation. Authority is re-proved after the gate resolves,\n\t * because a stop or an endpoint roll can land while it is in flight, and an\n\t * exact retry after a successful activation is answered `already` instead of\n\t * publishing a second readiness signal.\n\t */\n\tactivate(expectedGeneration?: number): Promise {\n\t\tconst attempt = this.appLifecycleOwner.own(() => this.#activation.then(\n\t\t\t() => this.#activateOnce(expectedGeneration),\n\t\t\t() => this.#activateOnce(expectedGeneration),\n\t\t));\n\t\tthis.#activation = attempt.then(\n\t\t\toutcome => outcome,\n\t\t\t() => \"authority_unavailable\" as const,\n\t\t);\n\t\treturn attempt;\n\t}\n" + } + ], + "beforeSha256": "038feef9348b0f56a2b92f2947e4d6e2b8cc80a918092e601b5103ed56be24fe", + "afterSha256": "12e497ea081ca73337288cd5ea84f85d8be141372547665bd355beb6628e19ae" + }, + { + "package": "@gajae-code/coding-agent", + "path": "src/sdk/host/session-runtime.ts", + "replacements": [ + { + "before": "import { type ControlSurface, controlRequestFromFrame, dispatchControl, terminalAbortIdentity } from \"./control\";\nimport {\n\tBROKER_RUNTIME_ABORT_CAPABILITY_FIELD,\n\tBROKER_RUNTIME_CLOSE_CAPABILITY_FIELD,\n\thasBrokerRuntimeAbortCapability,\n} from \"./control/runtime-gate\";\nimport { SessionSdkHost, type SessionSdkHostOptions } from \"./host\";\nimport { clearAutoroutingInactive, isAutoroutingInactive, markAutoroutingInactive } from \"./internal-autorouting-state\";\nimport { CursorRegistry, QueryHandlers, RevisionStore, type SessionSurface } from \"./query\";\nimport { createSdkRunCapability } from \"./sdk-run-capability\";\nimport {\n\tcreateSdkCapabilities,\n\tcreateSdkSurfacePolicyForContext,\n", + "after": "import { type ControlSurface, controlRequestFromFrame, dispatchControl, terminalAbortIdentity } from \"./control\";\nimport {\n\tBROKER_RUNTIME_ABORT_CAPABILITY_FIELD,\n\tBROKER_RUNTIME_CLOSE_CAPABILITY_FIELD,\n\thasBrokerRuntimeAbortCapability,\n} from \"./control/runtime-gate\";\nimport { AppSdkHostOwner, ownAppSdkHostTask, SessionSdkHost, type SessionSdkHostOptions } from \"./host\";\nimport { clearAutoroutingInactive, isAutoroutingInactive, markAutoroutingInactive } from \"./internal-autorouting-state\";\nimport { CursorRegistry, QueryHandlers, RevisionStore, type SessionSurface } from \"./query\";\nimport { createSdkRunCapability } from \"./sdk-run-capability\";\nimport {\n\tcreateSdkCapabilities,\n\tcreateSdkSurfacePolicyForContext,\n" + }, + { + "before": " * Concrete transports (including the optional notification/native transport) are\n * injected by the caller. This module owns host construction, control/query\n * dispatch, replay/event publication, and reverse-provider lifecycle without\n * importing any notification adapter or native notification class.\n */\nexport class SessionSdkSessionRuntime {\n\treadonly host: SessionSdkHost;\n\treadonly transport: SessionSdkTransport;\n\treadonly #connectionDisposer?: () => void;\n\treadonly #malformedDisposer?: () => void;\n\treadonly #capabilitiesDisposer?: () => void;\n\t#transportStarted = false;\n\t#transportStartPromise?: Promise<{ url: string }>;\n\n\tconstructor(options: SessionSdkRuntimeOptions) {\n\t\tthis.transport = options.transport;\n\t\tconst capabilities = new Map>();\n\t\tthis.host = new SessionSdkHost({\n\t\t\t...options,\n\t\t\tconnectionCapabilities: options.connectionCapabilities ?? (connectionId => capabilities.get(connectionId)),\n\t\t\tsessionId: options.transport.sessionId,\n\t\t\tstateRoot: options.transport.stateRoot,\n\t\t\ttoken: options.transport.token,\n\t\t\tsendFrame: (connectionId, frame) => {\n\t\t\t\tconst result = options.transport.sendFrame(connectionId, frame);\n", + "after": " * Concrete transports (including the optional notification/native transport) are\n * injected by the caller. This module owns host construction, control/query\n * dispatch, replay/event publication, and reverse-provider lifecycle without\n * importing any notification adapter or native notification class.\n */\nexport class SessionSdkSessionRuntime {\n\treadonly appLifecycleOwner: AppSdkHostOwner;\n\treadonly host: SessionSdkHost;\n\treadonly transport: SessionSdkTransport;\n\treadonly #connectionDisposer?: () => void;\n\treadonly #malformedDisposer?: () => void;\n\treadonly #capabilitiesDisposer?: () => void;\n\t#transportStarted = false;\n\t#transportStartPromise?: Promise<{ url: string }>;\n\n\tconstructor(options: SessionSdkRuntimeOptions) {\n\t\tthis.appLifecycleOwner = options.appLifecycleOwner ?? new AppSdkHostOwner();\n\t\tthis.transport = options.transport;\n\t\tconst capabilities = new Map>();\n\t\tthis.host = new SessionSdkHost({\n\t\t\t...options,\n\t\t\tappLifecycleOwner: this.appLifecycleOwner,\n\t\t\tconnectionCapabilities: options.connectionCapabilities ?? (connectionId => capabilities.get(connectionId)),\n\t\t\tsessionId: options.transport.sessionId,\n\t\t\tstateRoot: options.transport.stateRoot,\n\t\t\ttoken: options.transport.token,\n\t\t\tsendFrame: (connectionId, frame) => {\n\t\t\t\tconst result = options.transport.sendFrame(connectionId, frame);\n" + }, + { + "before": "\t * asked for. Delivery failure is ignored: a disconnected consumer must never\n\t * disturb the turn producing the content.\n\t */\n\tsendFrameTo(connectionIds: Iterable, frame: SdkFrame): void {\n\t\tfor (const connectionId of connectionIds) {\n\t\t\ttry {\n\t\t\t\tconst result = this.transport.sendFrame(connectionId, frame);\n\t\t\t\tif (result instanceof Promise) result.catch(() => undefined);\n\t\t\t} catch {\n\t\t\t\t// A dead connection is reaped by the transport's own close handling.\n\t\t\t}\n\t\t}\n\t}\n\n", + "after": "\t * asked for. Delivery failure is ignored: a disconnected consumer must never\n\t * disturb the turn producing the content.\n\t */\n\tsendFrameTo(connectionIds: Iterable, frame: SdkFrame): void {\n\t\tfor (const connectionId of connectionIds) {\n\t\t\ttry {\n\t\t\t\tvoid this.appLifecycleOwner.own(() => this.transport.sendFrame(connectionId, frame)).catch(() => undefined);\n\t\t\t} catch {\n\t\t\t\t// A dead connection is reaped by the transport's own close handling.\n\t\t\t}\n\t\t}\n\t}\n\n" + }, + { + "before": "\t\t}\n\t\tif (hostError !== undefined) throw hostError;\n\t}\n\n\tasync registerWithBroker(writer: BrokerIndexWriter): Promise {\n\t\tawait this.host.registerWithBroker(writer);\n\t}\n}\n\n/** Narrow extension-facing factory for the SDK-only session path. */\nexport interface CreateSdkSessionRuntimeOptions {\n\t/** Authoritative broker state root for this session's endpoint lifecycle. */\n\tagentDir: string;\n\t/** Lifecycle-owned sessions require broker publication before they become usable. */\n\tbrokerRegistrationRequired?: boolean;\n\t/** Trusted broker-issued lifecycle marker bound to lifecycle host index events. */\n\tlifecycleRequestId?: string;\n", + "after": "\t\t}\n\t\tif (hostError !== undefined) throw hostError;\n\t}\n\n\tasync registerWithBroker(writer: BrokerIndexWriter): Promise {\n\t\tawait this.host.registerWithBroker(writer);\n\t}\n\n\tasync awaitAppLifecycleSettlement(): Promise {\n\t\tawait this.appLifecycleOwner.awaitAppLifecycleSettlement();\n\t\tconst transport = this.transport as SessionSdkTransport & { awaitAppLifecycleSettlement?: () => Promise };\n\t\tif (transport.awaitAppLifecycleSettlement) await transport.awaitAppLifecycleSettlement();\n\t\telse this.appLifecycleOwner.markUnknown(\"sdk_host_transport_unrepresented\");\n\t}\n}\n\n/** Narrow extension-facing factory for the SDK-only session path. */\nexport interface CreateSdkSessionRuntimeOptions {\n\tregisterAppLifecycleOwner?: (owner: AppSdkHostOwner) => void;\n\t/** Authoritative broker state root for this session's endpoint lifecycle. */\n\tagentDir: string;\n\t/** Lifecycle-owned sessions require broker publication before they become usable. */\n\tbrokerRegistrationRequired?: boolean;\n\t/** Trusted broker-issued lifecycle marker bound to lifecycle host index events. */\n\tlifecycleRequestId?: string;\n" + }, + { + "before": "\t\t// queued follow-up is actually promoted to a run (review thread P1).\n\t\t// Skills always start their own invocation; a plain prompt starts one\n\t\t// only when idle at dispatch time.\n\t\tconst startsOwnTurn = kind === \"skill\" || (kind === \"prompt\" && !alwaysQueued && !queuedAtDispatch);\n\t\tlet promotionStartsOwnRun: boolean | undefined;\n\t\ttry {\n\t\t\tconst submission = Promise.resolve(\n\t\t\t\trun({\n\t\t\t\t\tonPreflightAccepted: () => void accept().catch(() => undefined),\n\t\t\t\t\tsdkRunCapability,\n\t\t\t\t\tonPreflightAcceptCommit: accept,\n\t\t\t\t\tonDispatchDisposition: promotion => {\n\t\t\t\t\t\tpromotionStartsOwnRun = promotion.startsOwnRun;\n\t\t\t\t\t\tif (promotion.startsOwnRun === false) {\n\t\t\t\t\t\t\t// Dispatch-race diversion (#4668 review P1): the idle snapshot leased\n", + "after": "\t\t// queued follow-up is actually promoted to a run (review thread P1).\n\t\t// Skills always start their own invocation; a plain prompt starts one\n\t\t// only when idle at dispatch time.\n\t\tconst startsOwnTurn = kind === \"skill\" || (kind === \"prompt\" && !alwaysQueued && !queuedAtDispatch);\n\t\tlet promotionStartsOwnRun: boolean | undefined;\n\t\ttry {\n\t\t\tconst submission = ownAppSdkHostTask(() =>\n\t\t\t\trun({\n\t\t\t\t\tonPreflightAccepted: () => void ownAppSdkHostTask(accept).catch(() => undefined),\n\t\t\t\t\tsdkRunCapability,\n\t\t\t\t\tonPreflightAcceptCommit: accept,\n\t\t\t\t\tonDispatchDisposition: promotion => {\n\t\t\t\t\t\tpromotionStartsOwnRun = promotion.startsOwnRun;\n\t\t\t\t\t\tif (promotion.startsOwnRun === false) {\n\t\t\t\t\t\t\t// Dispatch-race diversion (#4668 review P1): the idle snapshot leased\n" + }, + { + "before": "\t\t\t\t\t\tpromotionStartsOwnRun = promotion?.startsOwnRun;\n\t\t\t\t\t\tonPromotedTurn?.(kind, correlation, requesterConnectionId, sdkRunToken, promotion);\n\t\t\t\t\t},\n\t\t\t\t\tqueuedAtDispatch,\n\t\t\t\t}),\n\t\t\t);\n\t\t\tvoid submission.then(\n\t\t\t\tresult => {\n\t\t\t\t\tif (settled) {\n\t\t\t\t\t\t// A resolved submission after preflight acceptance means the work is over\n\t\t\t\t\t\t// for every kind. `noteTransition` ignores an already-terminal record, so\n\t\t\t\t\t\t// terminalizing here is safe — unless the submission resolved at queue time\n\t\t\t\t\t\t// (followUp, or a prompt diverted to steer while streaming), in which case\n", + "after": "\t\t\t\t\t\tpromotionStartsOwnRun = promotion?.startsOwnRun;\n\t\t\t\t\t\tonPromotedTurn?.(kind, correlation, requesterConnectionId, sdkRunToken, promotion);\n\t\t\t\t\t},\n\t\t\t\t\tqueuedAtDispatch,\n\t\t\t\t}),\n\t\t\t);\n\t\t\tvoid ownAppSdkHostTask(() => submission.then(\n\t\t\t\tresult => {\n\t\t\t\t\tif (settled) {\n\t\t\t\t\t\t// A resolved submission after preflight acceptance means the work is over\n\t\t\t\t\t\t// for every kind. `noteTransition` ignores an already-terminal record, so\n\t\t\t\t\t\t// terminalizing here is safe — unless the submission resolved at queue time\n\t\t\t\t\t\t// (followUp, or a prompt diverted to steer while streaming), in which case\n" + }, + { + "before": "\t\t\t\t\t\t\t\t\t\treturn attemptTerminalization(remaining);\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\tawait Bun.sleep(1_000);\n\t\t\t\t\t\t\t\t\treturn attemptTerminalization(remaining - 1);\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t};\n\t\t\t\t\t\t\tvoid attemptTerminalization(3);\n\t\t\t\t\t\t}\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\t\t\t\t\tif (allowCompletionFallback) {\n\t\t\t\t\t\tvoid accept().catch(() => undefined);\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\t\t\t\t\tsettled = true;\n\t\t\t\t\tpreflight.reject(\n\t\t\t\t\t\tObject.assign(new Error(\"Prompt submission completed without preflight acceptance.\"), {\n\t\t\t\t\t\t\tcode: \"busy\",\n", + "after": "\t\t\t\t\t\t\t\t\t\treturn attemptTerminalization(remaining);\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\tawait Bun.sleep(1_000);\n\t\t\t\t\t\t\t\t\treturn attemptTerminalization(remaining - 1);\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t};\n\t\t\t\t\t\t\tvoid ownAppSdkHostTask(() => attemptTerminalization(3));\n\t\t\t\t\t\t}\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\t\t\t\t\tif (allowCompletionFallback) {\n\t\t\t\t\t\tvoid ownAppSdkHostTask(accept).catch(() => undefined);\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\t\t\t\t\tsettled = true;\n\t\t\t\t\tpreflight.reject(\n\t\t\t\t\t\tObject.assign(new Error(\"Prompt submission completed without preflight acceptance.\"), {\n\t\t\t\t\t\t\tcode: \"busy\",\n" + }, + { + "before": "\t\t\t\t\t\tretirePendingOwner?.(kind, correlation);\n\t\t\t\t\t\t// agent_failed alone is diagnostic-only (agent_end is the\n\t\t\t\t\t\t// terminal boundary), so the failure reason is recorded first\n\t\t\t\t\t\t// and the same submission is then terminalized as failed.\n\t\t\t\t\t\t// Terminalizing only with agent_failed would leave the record\n\t\t\t\t\t\t// accepted forever — exactly the #4668 zero-activity strand.\n\t\t\t\t\t\tvoid (async () => {\n\t\t\t\t\t\t\t// Compound failure+terminal recovery, kind-aware (exact-head review P1):\n\t\t\t\t\t\t\t// the reason must be durable BEFORE the boundary or the rejection reads\n\t\t\t\t\t\t\t// terminal_ok. Prompts hand the compound intent to the deadline manager\n\t\t\t\t\t\t\t// (which also retires pending ownership at expiry). Skills have NO lease,\n\t\t\t\t\t\t\t// so their recovery owner is this bounded in-place retry loop: the same\n\t\t\t\t\t\t\t// order, retried, until both writes land or the budget is spent.\n", + "after": "\t\t\t\t\t\tretirePendingOwner?.(kind, correlation);\n\t\t\t\t\t\t// agent_failed alone is diagnostic-only (agent_end is the\n\t\t\t\t\t\t// terminal boundary), so the failure reason is recorded first\n\t\t\t\t\t\t// and the same submission is then terminalized as failed.\n\t\t\t\t\t\t// Terminalizing only with agent_failed would leave the record\n\t\t\t\t\t\t// accepted forever — exactly the #4668 zero-activity strand.\n\t\t\t\t\t\tvoid ownAppSdkHostTask(async () => {\n\t\t\t\t\t\t\t// Compound failure+terminal recovery, kind-aware (exact-head review P1):\n\t\t\t\t\t\t\t// the reason must be durable BEFORE the boundary or the rejection reads\n\t\t\t\t\t\t\t// terminal_ok. Prompts hand the compound intent to the deadline manager\n\t\t\t\t\t\t\t// (which also retires pending ownership at expiry). Skills have NO lease,\n\t\t\t\t\t\t\t// so their recovery owner is this bounded in-place retry loop: the same\n\t\t\t\t\t\t\t// order, retried, until both writes land or the budget is spent.\n" + }, + { + "before": "\t\t\t\t\t\t\t\t\t\t// Sanitized representation only: the raw transition error may\n\t\t\t\t\t\t\t\t\t\t// carry transport or filesystem detail (exact-head review P2).\n\t\t\t\t\t\t\t\t\t\terror: sanitizePromptFailure(transitionError),\n\t\t\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t};\n\t\t\t\t\t\t\tvoid attemptRejectionTerminalization(3);\n\t\t\t\t\t\t})();\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\t\t\t\t\tsettled = true;\n\t\t\t\t\tpreflight.reject(error);\n\t\t\t\t},\n\t\t\t);\n\t\t\tawait preflight.promise;\n\t\t\treturn {\n\t\t\t\taccepted: true,\n\t\t\t\t...correlation,\n\t\t\t\t...(retainedClientRef === undefined ? {} : { clientRef: retainedClientRef }),\n\t\t\t\t...(acceptedFields?.() ?? {}),\n", + "after": "\t\t\t\t\t\t\t\t\t\t// Sanitized representation only: the raw transition error may\n\t\t\t\t\t\t\t\t\t\t// carry transport or filesystem detail (exact-head review P2).\n\t\t\t\t\t\t\t\t\t\terror: sanitizePromptFailure(transitionError),\n\t\t\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t};\n\t\t\t\t\t\t\tawait attemptRejectionTerminalization(3);\n\t\t\t\t\t\t});\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\t\t\t\t\tsettled = true;\n\t\t\t\t\tpreflight.reject(error);\n\t\t\t\t},\n\t\t\t));\n\t\t\tawait preflight.promise;\n\t\t\treturn {\n\t\t\t\taccepted: true,\n\t\t\t\t...correlation,\n\t\t\t\t...(retainedClientRef === undefined ? {} : { clientRef: retainedClientRef }),\n\t\t\t\t...(acceptedFields?.() ?? {}),\n" + }, + { + "before": "\t\treturn { type: \"transport_error\", code: \"session_quiescing\", message: error.message };\n\treturn undefined;\n}\n\n/** Install a complete SDK host for a session when notifications are inactive. */\nexport function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: CreateSdkSessionRuntimeOptions): void {\n\tlet active:\n\t\t| {\n\t\t\t\tsessionId: string;\n\t\t\t\tsessionIdentity: string;\n\t\t\t\truntime: SessionSdkSessionRuntime;\n\t\t\t\trevisions: RevisionStore;\n", + "after": "\t\treturn { type: \"transport_error\", code: \"session_quiescing\", message: error.message };\n\treturn undefined;\n}\n\n/** Install a complete SDK host for a session when notifications are inactive. */\nexport function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: CreateSdkSessionRuntimeOptions): void {\n\tconst appHost = new AppSdkHostOwner();\n\toptions.registerAppLifecycleOwner?.(appHost);\n\tconst appRuntimes = new Set();\n\tconst appJoin = appHost.awaitAppLifecycleSettlement.bind(appHost);\n\tappHost.awaitAppLifecycleSettlement = async () => {\n\t\tawait appJoin();\n\t\tfor (const runtime of appRuntimes) {\n\t\t\tconst transport = runtime.transport as SessionSdkTransport & { awaitAppLifecycleSettlement?: () => Promise };\n\t\t\tif (transport.awaitAppLifecycleSettlement) await transport.awaitAppLifecycleSettlement();\n\t\t\telse appHost.markUnknown(\"sdk_host_transport_unrepresented\");\n\t\t}\n\t\tawait appJoin();\n\t};\n\tlet active:\n\t\t| {\n\t\t\t\tsessionId: string;\n\t\t\t\tsessionIdentity: string;\n\t\t\t\truntime: SessionSdkSessionRuntime;\n\t\t\t\trevisions: RevisionStore;\n" + }, + { + "before": "\t\ttypeof sdkRunToken === \"string\" && sdkRunToken.length > 0\n\t\t\t? lifecycleRunOwners.get(sdkRunToken)?.state\n\t\t\t: lifecycleStateForContext(ctx, type);\n\tconst removeRetiredLifecycleOwner = (owner: RuntimeState): void => {\n\t\tconst timer = retiredLifecycleOwnerTimers.get(owner);\n\t\tif (timer !== undefined) {\n\t\t\tclearTimeout(timer);\n\t\t\tretiredLifecycleOwnerTimers.delete(owner);\n\t\t}\n\t\tconst owners = retiredLifecycleOwners.get(owner.sessionId);\n\t\tif (!owners) {\n\t\t\tambiguousLifecycleIdentities.delete(owner.sessionIdentity);\n\t\t\treturn;\n", + "after": "\t\ttypeof sdkRunToken === \"string\" && sdkRunToken.length > 0\n\t\t\t? lifecycleRunOwners.get(sdkRunToken)?.state\n\t\t\t: lifecycleStateForContext(ctx, type);\n\tconst removeRetiredLifecycleOwner = (owner: RuntimeState): void => {\n\t\tconst timer = retiredLifecycleOwnerTimers.get(owner);\n\t\tif (timer !== undefined) {\n\t\t\tappHost.cancelTimer(timer);\n\t\t\tretiredLifecycleOwnerTimers.delete(owner);\n\t\t}\n\t\tconst owners = retiredLifecycleOwners.get(owner.sessionId);\n\t\tif (!owners) {\n\t\t\tambiguousLifecycleIdentities.delete(owner.sessionIdentity);\n\t\t\treturn;\n" + }, + { + "before": "\tconst activePromptOwnerHolder: { connectionIds?: Set; lifecycleEpoch?: number } = {};\n\tlet nextLifecycleEpoch = 0;\n\tconst skillTerminalRecoveryKeys = new Set();\n\tconst trackLifecycle = (handler: () => Promise, owner: RuntimeState | undefined): Promise => {\n\t\tif (!owner) return Promise.resolve();\n\t\tlet task: Promise;\n\t\ttask = handler().finally(() => {\n\t\t\towner.lifecycleTasks.delete(task);\n\t\t\tmaybeRetireLifecycleOwner(owner);\n\t\t});\n\t\towner.lifecycleTasks.add(task);\n\t\treturn task;\n\t};\n\tconst emitLifecycle = async (\n\t\ttype: \"agent_start\" | \"agent_end\" | \"agent_failed\",\n", + "after": "\tconst activePromptOwnerHolder: { connectionIds?: Set; lifecycleEpoch?: number } = {};\n\tlet nextLifecycleEpoch = 0;\n\tconst skillTerminalRecoveryKeys = new Set();\n\tconst trackLifecycle = (handler: () => Promise, owner: RuntimeState | undefined): Promise => {\n\t\tif (!owner) return Promise.resolve();\n\t\tlet task: Promise;\n\t\ttask = appHost.own(async () => {\n\t\t\ttry { await handler(); }\n\t\t\tfinally { owner.lifecycleTasks.delete(task); maybeRetireLifecycleOwner(owner); }\n\t\t});\n\t\towner.lifecycleTasks.add(task);\n\t\treturn task;\n\t};\n\tconst emitLifecycle = async (\n\t\ttype: \"agent_start\" | \"agent_end\" | \"agent_failed\",\n" + }, + { + "before": "\t\t\t\t\t\tskillTerminalRecoveryKeys.delete(recoveryKey);\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\t\t\t\t\treturn attempt();\n\t\t\t\t}\n\t\t\t};\n\t\t\tvoid attempt().finally(() => skillTerminalRecoveryControllers.delete(recoveryKey));\n\t\t};\n\t\tif (type === \"agent_end\" && isContinuingMidRunMaintenanceOutcome(maintenanceOutcome)) {\n\t\t\ttry {\n\t\t\t\tcurrent.runtime.emitEvent({ type, sessionId: ctx.sessionManager.getSessionId() });\n\t\t\t} catch {\n\t\t\t\t// Maintenance checkpoints are non-terminal lifecycle observations.\n", + "after": "\t\t\t\t\t\tskillTerminalRecoveryKeys.delete(recoveryKey);\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\t\t\t\t\treturn attempt();\n\t\t\t\t}\n\t\t\t};\n\t\t\tvoid appHost.own(async () => {\n\t\t\t\ttry { await attempt(); } finally { skillTerminalRecoveryControllers.delete(recoveryKey); }\n\t\t\t});\n\t\t};\n\t\tif (type === \"agent_end\" && isContinuingMidRunMaintenanceOutcome(maintenanceOutcome)) {\n\t\t\ttry {\n\t\t\t\tcurrent.runtime.emitEvent({ type, sessionId: ctx.sessionManager.getSessionId() });\n\t\t\t} catch {\n\t\t\t\t// Maintenance checkpoints are non-terminal lifecycle observations.\n" + }, + { + "before": "\t\tconst steerReconciliation = createKindAwareReconciliation({\n\t\t\tstore: reconciliationStore as never,\n\t\t\townedKinds: [\"steer\"],\n\t\t});\n\t\tawait steerReconciliation.hydrateFromStore();\n\t\tconst deadlineManager = new PromptDeadlineManager({\n\t\t\treconciliation,\n\t\t\tgetLeaseMs: () => {\n\t\t\t\tconst v = options.settings?.get(\"sdk.promptDeadlineMs\" as never) as number | undefined;\n\t\t\t\treturn typeof v === \"number\" && Number.isFinite(v) ? v : 1_800_000;\n\t\t\t},\n\t\t\tgetMaxMs: () => {\n", + "after": "\t\tconst steerReconciliation = createKindAwareReconciliation({\n\t\t\tstore: reconciliationStore as never,\n\t\t\townedKinds: [\"steer\"],\n\t\t});\n\t\tawait steerReconciliation.hydrateFromStore();\n\t\tconst deadlineManager = new PromptDeadlineManager({\n\t\t\tappLifecycleOwner: appHost,\n\t\t\treconciliation,\n\t\t\tgetLeaseMs: () => {\n\t\t\t\tconst v = options.settings?.get(\"sdk.promptDeadlineMs\" as never) as number | undefined;\n\t\t\t\treturn typeof v === \"number\" && Number.isFinite(v) ? v : 1_800_000;\n\t\t\t},\n\t\t\tgetMaxMs: () => {\n" + }, + { + "before": "\t\t\t}>;\n\t\t}> = [];\n\t\tconst configRevision = { current: 0 };\n\t\tlet acceptingGateResolutions = true;\n\t\tconst inFlightGateResolutions = new Set>();\n\t\tconst trackGateResolution = (resolution: Promise): Promise => {\n\t\t\tconst tracked = resolution.finally(() => inFlightGateResolutions.delete(tracked));\n\t\t\tinFlightGateResolutions.add(tracked);\n\t\t\treturn tracked;\n\t\t};\n\t\tconst waitForGateResolutionQuiescence = async (): Promise => {\n\t\t\tconst settled = Promise.allSettled(inFlightGateResolutions);\n\t\t\tconst timeout = Bun.sleep(GATE_RESOLUTION_QUIESCENCE_MS).then(() => {\n\t\t\t\tlogger.warn(\"SDK workflow gate resolution drain timed out; proceeding with uncertain outcomes.\");\n\t\t\t});\n\t\t\tawait Promise.race([settled, timeout]);\n\t\t};\n\t\tlet runtime: SessionSdkSessionRuntime;\n\t\tconst surfaceFactory = createSdkSurfaceFactory({\n\t\t\tctx,\n\t\t\tid: sessionId,\n\t\t\tapi,\n", + "after": "\t\t\t}>;\n\t\t}> = [];\n\t\tconst configRevision = { current: 0 };\n\t\tlet acceptingGateResolutions = true;\n\t\tconst inFlightGateResolutions = new Set>();\n\t\tconst trackGateResolution = (resolution: Promise): Promise => {\n\t\t\tconst tracked = appHost.own(async () => {\n\t\t\t\ttry { return await resolution; } finally { inFlightGateResolutions.delete(tracked); }\n\t\t\t});\n\t\t\tinFlightGateResolutions.add(tracked);\n\t\t\treturn tracked;\n\t\t};\n\t\tconst waitForGateResolutionQuiescence = async (): Promise => {\n\t\t\tconst settled = Promise.allSettled(inFlightGateResolutions);\n\t\t\tlet timer: ReturnType | undefined;\n\t\t\tconst timeout = new Promise(resolve => { timer = setTimeout(() => {\n\t\t\t\tlogger.warn(\"SDK workflow gate resolution drain timed out; proceeding with uncertain outcomes.\"); resolve();\n\t\t\t}, GATE_RESOLUTION_QUIESCENCE_MS); });\n\t\t\ttry { await Promise.race([settled, timeout]); } finally { clearTimeout(timer); }\n\t\t};\n\t\tlet runtime: SessionSdkSessionRuntime;\n\t\tconst surfaceFactory = createSdkSurfaceFactory({\n\t\t\tctx,\n\t\t\tid: sessionId,\n\t\t\tapi,\n" + }, + { + "before": "\t\t): void => {\n\t\t\tconst key = lifecycleCorrelationKey(correlation);\n\t\t\tif (skillRecoveryTasks.has(key)) return;\n\t\t\tconst controller = new AbortController();\n\t\t\tskillRecoveryControllers.set(key, controller);\n\t\t\tconst intent = failureIntent;\n\t\t\tconst task = (async (): Promise => {\n\t\t\t\tlet failureRecorded = intent === undefined;\n\t\t\t\tfor (;;) {\n\t\t\t\t\tif (controller.signal.aborted) return;\n\t\t\t\t\ttry {\n\t\t\t\t\t\tif (!failureRecorded) {\n\t\t\t\t\t\t\tawait reconciliation.noteTransition(\"skill\", correlation, {\n", + "after": "\t\t): void => {\n\t\t\tconst key = lifecycleCorrelationKey(correlation);\n\t\t\tif (skillRecoveryTasks.has(key)) return;\n\t\t\tconst controller = new AbortController();\n\t\t\tskillRecoveryControllers.set(key, controller);\n\t\t\tconst intent = failureIntent;\n\t\t\tconst task = appHost.own(async (): Promise => {\n\t\t\t\tlet failureRecorded = intent === undefined;\n\t\t\t\tfor (;;) {\n\t\t\t\t\tif (controller.signal.aborted) return;\n\t\t\t\t\ttry {\n\t\t\t\t\t\tif (!failureRecorded) {\n\t\t\t\t\t\t\tawait reconciliation.noteTransition(\"skill\", correlation, {\n" + }, + { + "before": "\t\t\t\t\t\t};\n\t\t\t\t\t\tcontroller.signal.addEventListener(\"abort\", onAbort, { once: true });\n\t\t\t\t\t\tawait wait.promise;\n\t\t\t\t\t\tcontroller.signal.removeEventListener(\"abort\", onAbort);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t})();\n\t\t\tskillRecoveryTasks.set(key, task);\n\t\t\tvoid task.finally(() => {\n\t\t\t\tskillRecoveryTasks.delete(key);\n\t\t\t\tskillRecoveryControllers.delete(key);\n\t\t\t});\n\t\t};\n\t\t// Durable-first bounded terminalization for accepted submissions that leave\n\t\t// their queue or race a run WITHOUT consumption (exact-head review: clearing\n\t\t// the lease before the durable writes strands the row accepted with no\n\t\t// recovery owner when persistence fails, and an agent_failed write that\n\t\t// silently fails lets the following agent_end terminalize the cancellation\n", + "after": "\t\t\t\t\t\t};\n\t\t\t\t\t\tcontroller.signal.addEventListener(\"abort\", onAbort, { once: true });\n\t\t\t\t\t\tawait wait.promise;\n\t\t\t\t\t\tcontroller.signal.removeEventListener(\"abort\", onAbort);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t});\n\t\t\tskillRecoveryTasks.set(key, task);\n\t\t\tvoid appHost.retain(task.finally(() => {\n\t\t\t\tskillRecoveryTasks.delete(key);\n\t\t\t\tskillRecoveryControllers.delete(key);\n\t\t\t}));\n\t\t};\n\t\t// Durable-first bounded terminalization for accepted submissions that leave\n\t\t// their queue or race a run WITHOUT consumption (exact-head review: clearing\n\t\t// the lease before the durable writes strands the row accepted with no\n\t\t// recovery owner when persistence fails, and an agent_failed write that\n\t\t// silently fails lets the following agent_end terminalize the cancellation\n" + }, + { + "before": "\t\t\t\t\t\tcommandId: correlation.commandId,\n\t\t\t\t\t\tturnId: correlation.turnId,\n\t\t\t\t\t\terror: sanitizePromptFailure(transitionError),\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t};\n\t\t\tvoid attempt(3);\n\t\t};\n\n\t\tconst controlSurface = createControlSurface(\n\t\t\tctx,\n\t\t\tapi,\n\t\t\treconciliation,\n", + "after": "\t\t\t\t\t\tcommandId: correlation.commandId,\n\t\t\t\t\t\tturnId: correlation.turnId,\n\t\t\t\t\t\terror: sanitizePromptFailure(transitionError),\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t};\n\t\t\tvoid appHost.own(() => attempt(3));\n\t\t};\n\n\t\tconst controlSurface = createControlSurface(\n\t\t\tctx,\n\t\t\tapi,\n\t\t\treconciliation,\n" + }, + { + "before": "\t\t};\n\t\tconst removeProviderDefinitions = (capability: string): void => {\n\t\t\tif (capability === \"permission\") ctx.setSdkPermissionProvider?.(undefined);\n\t\t\tif (capability === \"fs\") ctx.setSdkClientBridge?.(undefined);\n\t\t};\n\t\truntime = new SessionSdkSessionRuntime({\n\t\t\ttransport,\n\t\t\teventRevision: () =>\n\t\t\t\ttypeof (ctx as Partial).getTranscript === \"function\"\n\t\t\t\t\t? ctx.getTranscript().length\n\t\t\t\t\t: undefined,\n\t\t\tmasterCapabilityVerify: frame =>\n", + "after": "\t\t};\n\t\tconst removeProviderDefinitions = (capability: string): void => {\n\t\t\tif (capability === \"permission\") ctx.setSdkPermissionProvider?.(undefined);\n\t\t\tif (capability === \"fs\") ctx.setSdkClientBridge?.(undefined);\n\t\t};\n\t\truntime = new SessionSdkSessionRuntime({\n\t\t\tappLifecycleOwner: appHost,\n\t\t\ttransport,\n\t\t\teventRevision: () =>\n\t\t\t\ttypeof (ctx as Partial).getTranscript === \"function\"\n\t\t\t\t\t? ctx.getTranscript().length\n\t\t\t\t\t: undefined,\n\t\t\tmasterCapabilityVerify: frame =>\n" + }, + { + "before": "\t\t\tlifecycleActive: false,\n\t\t\tlifecycleEpoch: 0,\n\t\t\tfailureDiagnosticKeys: new Set(),\n\t\t\tfailureDiagnosticCodes: new Map(),\n\t\t\tlifecycleTasks: new Set(),\n\t\t};\n\t\tlifecycleOwnerHolder.state = runtimeOwner;\n\t\tactive = runtimeOwner;\n\t\ttry {\n\t\t\tpublishedEndpointUrl = (await runtime.start()).url;\n\t\t\tawait registerBroker();\n\t\t} catch (error) {\n", + "after": "\t\t\tlifecycleActive: false,\n\t\t\tlifecycleEpoch: 0,\n\t\t\tfailureDiagnosticKeys: new Set(),\n\t\t\tfailureDiagnosticCodes: new Map(),\n\t\t\tlifecycleTasks: new Set(),\n\t\t};\n\t\tappRuntimes.add(runtime);\n\t\tlifecycleOwnerHolder.state = runtimeOwner;\n\t\tactive = runtimeOwner;\n\t\ttry {\n\t\t\tpublishedEndpointUrl = (await runtime.start()).url;\n\t\t\tawait registerBroker();\n\t\t} catch (error) {\n" + }, + { + "before": "\t\t\t\t\t\tcurrent.pending.length > 0 ||\n\t\t\t\t\t\tcurrent.openLifecycleBatches.length > 0 ||\n\t\t\t\t\t\t(current.attachedInvocations?.length ?? 0) > 0 ||\n\t\t\t\t\t\t(current.drainedInvocations?.length ?? 0) > 0 ||\n\t\t\t\t\t\tcurrent.lifecycleTasks.size > 0\n\t\t\t\t\t) {\n\t\t\t\t\t\tconst retry = setTimeout(retryCleanup, LIFECYCLE_QUIESCENCE_MS);\n\t\t\t\t\t\tretry.unref();\n\t\t\t\t\t\tretiredLifecycleOwnerTimers.set(current, retry);\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\t\t\t\t\tremoveRetiredLifecycleOwner(current);\n\t\t\t\t};\n\t\t\t\tconst timer = setTimeout(retryCleanup, LIFECYCLE_QUIESCENCE_MS);\n\t\t\t\ttimer.unref();\n\t\t\t\tretiredLifecycleOwnerTimers.set(current, timer);\n\t\t\t} else current.deadlineManager.clearAll();\n\t\t\tcurrent.disposeGate?.();\n\t\t\tawait current.runtime.stop();\n\t\t} catch (error) {\n\t\t\t// Keep the immutable owner available for a retry when transport teardown\n\t\t\t// fails after quiescing. Clearing `active` before stop prevents a second\n\t\t\t// shutdown from retrying the failed endpoint removal.\n\t\t\tif (active === undefined) active = current;\n\t\t\tlogger.error(\"sdk runtime stop failed\", { code: errorCode(error), error: String(error) });\n\t\t\tthrow error;\n\t\t}\n\t\tcurrent.cursors.close();\n\t\tawait current.revisions.close();\n\t};\n\tapi.on(\"session_start\", async (_event, ctx) => {\n\t\tawait startRuntime(ctx);\n\t});\n\tapi.on(\"session_switch\", async (_event, ctx) => {\n\t\tawait stopActive();\n\t\tawait startRuntime(ctx);\n\t});\n\tapi.on(\"session_branch\", async (_event, ctx) => {\n\t\tawait stopActive();\n\t\tawait startRuntime(ctx);\n\t});\n\tapi.on(\"session_shutdown\", async () => {\n\t\tawait stopActive(true);\n\t});\n}\n", + "after": "\t\t\t\t\t\tcurrent.pending.length > 0 ||\n\t\t\t\t\t\tcurrent.openLifecycleBatches.length > 0 ||\n\t\t\t\t\t\t(current.attachedInvocations?.length ?? 0) > 0 ||\n\t\t\t\t\t\t(current.drainedInvocations?.length ?? 0) > 0 ||\n\t\t\t\t\t\tcurrent.lifecycleTasks.size > 0\n\t\t\t\t\t) {\n\t\t\t\t\t\tconst retry = appHost.schedule(retryCleanup, LIFECYCLE_QUIESCENCE_MS);\n\t\t\t\t\t\tretry.unref();\n\t\t\t\t\t\tretiredLifecycleOwnerTimers.set(current, retry);\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\t\t\t\t\tremoveRetiredLifecycleOwner(current);\n\t\t\t\t};\n\t\t\t\tconst timer = appHost.schedule(retryCleanup, LIFECYCLE_QUIESCENCE_MS);\n\t\t\t\ttimer.unref();\n\t\t\t\tretiredLifecycleOwnerTimers.set(current, timer);\n\t\t\t} else current.deadlineManager.clearAll();\n\t\t\tcurrent.disposeGate?.();\n\t\t\tawait current.runtime.stop();\n\t\t} catch (error) {\n\t\t\t// Keep the immutable owner available for a retry when transport teardown\n\t\t\t// fails after quiescing. Clearing `active` before stop prevents a second\n\t\t\t// shutdown from retrying the failed endpoint removal.\n\t\t\tif (active === undefined) active = current;\n\t\t\tappHost.markUnknown(\"sdk_host_cleanup_unconfirmed\");\n\t\t\tlogger.error(\"sdk runtime stop failed\", { code: errorCode(error), error: String(error) });\n\t\t\tthrow error;\n\t\t}\n\t\tcurrent.cursors.close();\n\t\tawait current.revisions.close();\n\t};\n\tapi.on(\"session_start\", (_event, ctx) => appHost.own(async () => {\n\t\tawait startRuntime(ctx);\n\t}));\n\tapi.on(\"session_switch\", (_event, ctx) => appHost.own(async () => {\n\t\tawait stopActive();\n\t\tawait startRuntime(ctx);\n\t}));\n\tapi.on(\"session_branch\", (_event, ctx) => appHost.own(async () => {\n\t\tawait stopActive();\n\t\tawait startRuntime(ctx);\n\t}));\n\tapi.on(\"session_shutdown\", () => appHost.own(async () => {\n\t\tawait stopActive(true);\n\t}));\n}\n" + } + ], + "beforeSha256": "e396424d612ccf65e7057742e69b412b3ab974df9148e03d9892d54e6afe4859", + "afterSha256": "62ba771a376315285efb28ab50676a0ed2d0e1c64e637738bd04eed4e6430cff" + }, + { + "package": "@gajae-code/coding-agent", + "path": "src/sdk/host/websocket-transport.ts", + "replacements": [ + { + "before": "import { createHash, randomUUID } from \"node:crypto\";\nimport * as fs from \"node:fs/promises\";\nimport * as path from \"node:path\";\nimport { exactUnlink, type NativeExactFileIdentity } from \"@gajae-code/natives\";\nimport type { SessionSdkTransport } from \"./session-runtime\";\nimport type { SdkFrame } from \"./types\";\n\ntype SocketData = { connectionId: string };\ntype Socket = { readonly data: SocketData; send(message: string): void; close(): void; terminate?(): void };\n\nexport interface SdkWebSocketTransportDependencies {\n", + "after": "import { createHash, randomUUID } from \"node:crypto\";\nimport * as fs from \"node:fs/promises\";\nimport * as path from \"node:path\";\nimport { exactUnlink, type NativeExactFileIdentity } from \"@gajae-code/natives\";\nimport type { SessionSdkTransport } from \"./session-runtime\";\nimport { AppSdkHostOwner } from \"./host\";\nimport type { SdkFrame } from \"./types\";\n\ntype SocketData = { connectionId: string };\ntype Socket = { readonly data: SocketData; send(message: string): void; close(): void; terminate?(): void };\n\nexport interface SdkWebSocketTransportDependencies {\n" + }, + { + "before": " * remains an optional notification adapter; this transport keeps SDK hosting\n * available without loading that adapter or its native dependency.\n */\nexport async function createSdkWebSocketTransport(\n\tinput: { sessionId: string; stateRoot: string; token: string } & SdkWebSocketTransportDependencies,\n): Promise {\n\tconst filesystem = input.filesystem ?? fs;\n\tconst serve = input.serve ?? Bun.serve;\n\tlet frameHandler: ((connectionId: string, frame: SdkFrame) => void) | undefined;\n\tlet malformedHandler: ((connectionId: string, message: string) => void) | undefined;\n\tlet connectionCloseHandler: ((connectionId: string) => void) | undefined;\n\tlet capabilitiesHandler: ((connectionId: string, capabilities: readonly string[]) => void) | undefined;\n", + "after": " * remains an optional notification adapter; this transport keeps SDK hosting\n * available without loading that adapter or its native dependency.\n */\nexport async function createSdkWebSocketTransport(\n\tinput: { sessionId: string; stateRoot: string; token: string } & SdkWebSocketTransportDependencies,\n): Promise {\n\tconst appOwner = new AppSdkHostOwner();\n\tlet appStopFailure: unknown;\n\tconst filesystem = input.filesystem ?? fs;\n\tconst serve = input.serve ?? Bun.serve;\n\tlet frameHandler: ((connectionId: string, frame: SdkFrame) => void) | undefined;\n\tlet malformedHandler: ((connectionId: string, message: string) => void) | undefined;\n\tlet connectionCloseHandler: ((connectionId: string) => void) | undefined;\n\tlet capabilitiesHandler: ((connectionId: string, capabilities: readonly string[]) => void) | undefined;\n" + }, + { + "before": "\tlet started = false;\n\tlet startPromise: Promise<{ url: string }> | undefined;\n\tlet stopPromise: Promise | undefined;\n\n\tconst stopServer = async (current: SdkServer): Promise => {\n\t\ttry {\n\t\t\tconst stopResult = current.stop(true);\n\t\t\tawait Promise.race([stopResult, new Promise(resolve => setTimeout(resolve, 250))]);\n\t\t} catch (error) {\n\t\t\tthrow asLifecycleError(\"server_stop_failed\", \"SDK WebSocket server shutdown failed.\", error);\n\t\t}\n\t};\n\n\tconst closeServer = async (current: SdkServer | undefined = server): Promise => {\n", + "after": "\tlet started = false;\n\tlet startPromise: Promise<{ url: string }> | undefined;\n\tlet stopPromise: Promise | undefined;\n\n\tconst stopServer = async (current: SdkServer): Promise => {\n\t\ttry {\n\t\t\tconst stopResult = appOwner.own(async () => {\n\t\t\t\ttry { await current.stop(true); }\n\t\t\t\tcatch (error) { appStopFailure = error; throw error; }\n\t\t\t});\n\t\t\tlet timer: ReturnType | undefined;\n\t\t\ttry { await Promise.race([stopResult, new Promise(resolve => { timer = setTimeout(resolve, 250); })]); }\n\t\t\tfinally { clearTimeout(timer); }\n\t\t} catch (error) {\n\t\t\tthrow asLifecycleError(\"server_stop_failed\", \"SDK WebSocket server shutdown failed.\", error);\n\t\t}\n\t};\n\n\tconst closeServer = async (current: SdkServer | undefined = server): Promise => {\n" + }, + { + "before": "\tconst endpointUrl = (current: SdkServer): string => {\n\t\tconst url = new URL(current.url);\n\t\turl.protocol = url.protocol === \"https:\" ? \"wss:\" : \"ws:\";\n\t\treturn url.toString();\n\t};\n\n\tconst transport: SessionSdkTransport = {\n\t\tsessionId: input.sessionId,\n\t\tstateRoot: input.stateRoot,\n\t\ttoken: input.token,\n\t\tonFrame(handler) {\n\t\t\tframeHandler = handler;\n\t\t\treturn () => {\n", + "after": "\tconst endpointUrl = (current: SdkServer): string => {\n\t\tconst url = new URL(current.url);\n\t\turl.protocol = url.protocol === \"https:\" ? \"wss:\" : \"ws:\";\n\t\treturn url.toString();\n\t};\n\n\tconst transport: SessionSdkTransport & { awaitAppLifecycleSettlement(): Promise } = {\n\t\tawaitAppLifecycleSettlement: async () => {\n\t\t\tawait appOwner.awaitAppLifecycleSettlement();\n\t\t\tif (appStopFailure !== undefined) throw appStopFailure;\n\t\t},\n\t\tsessionId: input.sessionId,\n\t\tstateRoot: input.stateRoot,\n\t\ttoken: input.token,\n\t\tonFrame(handler) {\n\t\t\tframeHandler = handler;\n\t\t\treturn () => {\n" + } + ], + "beforeSha256": "de22d60c2697e74eeabbe1ce9a9900358f70c75a2c7ec7994a11e1e6662caeec", + "afterSha256": "33ec5a5eb9b7bcee8828205990be0843095f490a8ccecc3166ad94ef844d65a2" + }, + { + "package": "@gajae-code/coding-agent", + "path": "src/sdk/prompt-deadline-manager.ts", + "replacements": [ + { + "before": "import type { KindAwareReconciliation } from \"./bus/kind-aware-reconciliation\";\nimport type { InvocationCorrelation, InvocationReconciliation } from \"./host/session-runtime\";\nimport {\n\tcreatePromptDeadlineLease,\n\tisAttributableProgressEventType,\n\ttype PromptDeadlineLease,\n\tpromptDeadlineAt,\n", + "after": "import type { KindAwareReconciliation } from \"./bus/kind-aware-reconciliation\";\nimport { AppSdkHostOwner, currentAppSdkHostOwner } from \"./host/host\";\nimport type { InvocationCorrelation, InvocationReconciliation } from \"./host/session-runtime\";\nimport {\n\tcreatePromptDeadlineLease,\n\tisAttributableProgressEventType,\n\ttype PromptDeadlineLease,\n\tpromptDeadlineAt,\n" + }, + { + "before": "\nfunction leaseKey(correlation: InvocationCorrelation): string {\n\treturn `${correlation.commandId}:${correlation.turnId}`;\n}\n\nexport class PromptDeadlineManager {\n\treadonly #leases = new Map();\n\treadonly #correlations = new Map();\n\treadonly #timers = new Map>();\n\treadonly #reconciliation: DeadlineReconciliation;\n\treadonly #expiryRetries = new Map();\n\treadonly #uncertaintyRetries = new Map();\n", + "after": "\nfunction leaseKey(correlation: InvocationCorrelation): string {\n\treturn `${correlation.commandId}:${correlation.turnId}`;\n}\n\nexport class PromptDeadlineManager {\n\treadonly #appOwner: AppSdkHostOwner;\n\treadonly #leases = new Map();\n\treadonly #correlations = new Map();\n\treadonly #timers = new Map>();\n\treadonly #reconciliation: DeadlineReconciliation;\n\treadonly #expiryRetries = new Map();\n\treadonly #uncertaintyRetries = new Map();\n" + }, + { + "before": "\treadonly #getLeaseMs: () => number;\n\treadonly #getMaxMs: () => number;\n\treadonly #now: () => number;\n\treadonly #onExpired?: (correlation: InvocationCorrelation) => void;\n\n\tconstructor(options: {\n\t\treconciliation: DeadlineReconciliation;\n\t\tgetLeaseMs: () => number;\n\t\tgetMaxMs: () => number;\n\t\tnow?: () => number;\n\t\tonExpired?: (correlation: InvocationCorrelation) => void;\n\t}) {\n\t\tthis.#reconciliation = options.reconciliation;\n\t\tthis.#getLeaseMs = options.getLeaseMs;\n\t\tthis.#getMaxMs = options.getMaxMs;\n\t\tthis.#now = options.now ?? Date.now;\n\t\tthis.#onExpired = options.onExpired;\n\t}\n\n\t#clearTimer(key: string): void {\n\t\tconst timer = this.#timers.get(key);\n\t\tif (timer !== undefined) {\n\t\t\tclearTimeout(timer);\n\t\t\tthis.#timers.delete(key);\n\t\t}\n\t}\n\n\t#schedule(key: string): void {\n\t\tconst lease = this.#leases.get(key);\n\t\tif (!lease) return;\n\t\tthis.#clearTimer(key);\n\t\tconst deadlineAt = promptDeadlineAt(lease);\n\t\tconst delayMs = Math.max(0, deadlineAt - this.#now());\n\t\tconst timer = setTimeout(() => {\n\t\t\tvoid this.#onDeadline(key);\n\t\t}, delayMs);\n\t\t// Allow process to exit without waiting for deadline timer.\n\t\t(timer as unknown as { unref?: () => void }).unref?.();\n\t\tthis.#timers.set(key, timer);\n\t}\n\n\tasync #onDeadline(key: string): Promise {\n", + "after": "\treadonly #getLeaseMs: () => number;\n\treadonly #getMaxMs: () => number;\n\treadonly #now: () => number;\n\treadonly #onExpired?: (correlation: InvocationCorrelation) => void;\n\n\tconstructor(options: {\n\t\tappLifecycleOwner?: AppSdkHostOwner;\n\t\treconciliation: DeadlineReconciliation;\n\t\tgetLeaseMs: () => number;\n\t\tgetMaxMs: () => number;\n\t\tnow?: () => number;\n\t\tonExpired?: (correlation: InvocationCorrelation) => void;\n\t}) {\n\t\tthis.#appOwner = options.appLifecycleOwner ?? currentAppSdkHostOwner() ?? new AppSdkHostOwner();\n\t\tthis.#reconciliation = options.reconciliation;\n\t\tthis.#getLeaseMs = options.getLeaseMs;\n\t\tthis.#getMaxMs = options.getMaxMs;\n\t\tthis.#now = options.now ?? Date.now;\n\t\tthis.#onExpired = options.onExpired;\n\t}\n\n\t#clearTimer(key: string): void {\n\t\tconst timer = this.#timers.get(key);\n\t\tif (timer !== undefined) {\n\t\t\tthis.#appOwner.cancelTimer(timer);\n\t\t\tthis.#timers.delete(key);\n\t\t}\n\t}\n\n\t#schedule(key: string): void {\n\t\tconst lease = this.#leases.get(key);\n\t\tif (!lease) return;\n\t\tthis.#clearTimer(key);\n\t\tconst deadlineAt = promptDeadlineAt(lease);\n\t\tconst delayMs = Math.max(0, deadlineAt - this.#now());\n\t\tconst timer = this.#appOwner.schedule(() => this.#onDeadline(key), delayMs);\n\t\t// Allow process to exit without waiting for deadline timer.\n\t\t(timer as unknown as { unref?: () => void }).unref?.();\n\t\tthis.#timers.set(key, timer);\n\t}\n\n\tasync #onDeadline(key: string): Promise {\n" + }, + { + "before": "\t\tif (attempts > MAX_EXPIRY_RETRIES) {\n\t\t\tconst correlation = this.#correlations.get(key);\n\t\t\tconst lease = this.#leases.get(key);\n\t\t\tif (correlation && lease) this.#recoverUncertainty(key, correlation, lease, lease.generation);\n\t\t\treturn;\n\t\t}\n\t\tconst timer = setTimeout(() => void this.#onDeadline(key), EXPIRY_RETRY_DELAY_MS);\n\t\t(timer as unknown as { unref?: () => void }).unref?.();\n\t\tthis.#timers.set(key, timer);\n\t}\n\n\t#recoverUncertainty(\n\t\tkey: string,\n", + "after": "\t\tif (attempts > MAX_EXPIRY_RETRIES) {\n\t\t\tconst correlation = this.#correlations.get(key);\n\t\t\tconst lease = this.#leases.get(key);\n\t\t\tif (correlation && lease) this.#recoverUncertainty(key, correlation, lease, lease.generation);\n\t\t\treturn;\n\t\t}\n\t\tconst timer = this.#appOwner.schedule(() => this.#onDeadline(key), EXPIRY_RETRY_DELAY_MS);\n\t\t(timer as unknown as { unref?: () => void }).unref?.();\n\t\tthis.#timers.set(key, timer);\n\t}\n\n\t#recoverUncertainty(\n\t\tkey: string,\n" + }, + { + "before": "\t\t\tcurrent.generation !== generation ||\n\t\t\ttypeof this.#reconciliation.markUncertain !== \"function\"\n\t\t)\n\t\t\treturn;\n\t\tconst attempts = (this.#uncertaintyRetries.get(key) ?? 0) + 1;\n\t\tthis.#uncertaintyRetries.set(key, attempts);\n\t\tvoid this.#reconciliation\n\t\t\t.markUncertain(\n\t\t\t\t\"prompt\",\n\t\t\t\tcorrelation,\n\t\t\t\t() => {\n\t\t\t\t\tconst current = this.#leases.get(key);\n\t\t\t\t\treturn current === lease && current.generation === generation;\n", + "after": "\t\t\tcurrent.generation !== generation ||\n\t\t\ttypeof this.#reconciliation.markUncertain !== \"function\"\n\t\t)\n\t\t\treturn;\n\t\tconst attempts = (this.#uncertaintyRetries.get(key) ?? 0) + 1;\n\t\tthis.#uncertaintyRetries.set(key, attempts);\n\t\tvoid this.#appOwner.own(() => this.#reconciliation\n\t\t\t.markUncertain(\n\t\t\t\t\"prompt\",\n\t\t\t\tcorrelation,\n\t\t\t\t() => {\n\t\t\t\t\tconst current = this.#leases.get(key);\n\t\t\t\t\treturn current === lease && current.generation === generation;\n" + }, + { + "before": "\t\t\t\t\t// write so accepted work is never left indefinitely unbounded (exact-\n\t\t\t\t\t// head review P1: parking without a timer strands the accepted row).\n\t\t\t\t\tthis.#uncertaintyRecoveryPending.add(key);\n\t\t\t\t\tconst live = this.#leases.get(key);\n\t\t\t\t\tif (live === lease) {\n\t\t\t\t\t\tthis.#clearTimer(key);\n\t\t\t\t\t\tconst recoveryTimer = setTimeout(\n\t\t\t\t\t\t\t() => this.#recoverUncertainty(key, correlation, live, live.generation),\n\t\t\t\t\t\t\tUNCERTAINTY_RETRY_DELAY_MS,\n\t\t\t\t\t\t);\n\t\t\t\t\t\t(recoveryTimer as unknown as { unref?: () => void }).unref?.();\n\t\t\t\t\t\tthis.#timers.set(key, recoveryTimer);\n\t\t\t\t\t}\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tthis.#clearTimer(key);\n\t\t\t\tconst timer = setTimeout(\n\t\t\t\t\t() => this.#recoverUncertainty(key, correlation, lease, generation),\n\t\t\t\t\tUNCERTAINTY_RETRY_DELAY_MS,\n\t\t\t\t);\n\t\t\t\t(timer as unknown as { unref?: () => void }).unref?.();\n\t\t\t\tthis.#timers.set(key, timer);\n\t\t\t});\n\t}\n\n\tonAccepted(correlation: InvocationCorrelation): void {\n\t\tconst key = leaseKey(correlation);\n\t\tif (this.#leases.has(key)) return;\n\t\tconst now = this.#now();\n", + "after": "\t\t\t\t\t// write so accepted work is never left indefinitely unbounded (exact-\n\t\t\t\t\t// head review P1: parking without a timer strands the accepted row).\n\t\t\t\t\tthis.#uncertaintyRecoveryPending.add(key);\n\t\t\t\t\tconst live = this.#leases.get(key);\n\t\t\t\t\tif (live === lease) {\n\t\t\t\t\t\tthis.#clearTimer(key);\n\t\t\t\t\t\tconst recoveryTimer = this.#appOwner.schedule(\n\t\t\t\t\t\t\t() => this.#recoverUncertainty(key, correlation, live, live.generation),\n\t\t\t\t\t\t\tUNCERTAINTY_RETRY_DELAY_MS,\n\t\t\t\t\t\t);\n\t\t\t\t\t\t(recoveryTimer as unknown as { unref?: () => void }).unref?.();\n\t\t\t\t\t\tthis.#timers.set(key, recoveryTimer);\n\t\t\t\t\t}\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tthis.#clearTimer(key);\n\t\t\t\tconst timer = this.#appOwner.schedule(\n\t\t\t\t\t() => this.#recoverUncertainty(key, correlation, lease, generation),\n\t\t\t\t\tUNCERTAINTY_RETRY_DELAY_MS,\n\t\t\t\t);\n\t\t\t\t(timer as unknown as { unref?: () => void }).unref?.();\n\t\t\t\tthis.#timers.set(key, timer);\n\t\t\t}));\n\t}\n\n\tonAccepted(correlation: InvocationCorrelation): void {\n\t\tconst key = leaseKey(correlation);\n\t\tif (this.#leases.has(key)) return;\n\t\tconst now = this.#now();\n" + } + ], + "beforeSha256": "3acde77a41a96531cc73b92ece9a08419059012072a25ee92b505869900c8449", + "afterSha256": "6c5867ff16c5dfc7a550a0c3dfa67309a9ae234cf23253ab7739c55dd790e295" + }, + { + "package": "@gajae-code/coding-agent", + "path": "src/sdk/host/query/revision-store.ts", + "replacements": [ + { + "before": "import { createHash } from \"node:crypto\";\nimport { chmod, mkdir, mkdtemp, open, readFile, rename, rm, unlink } from \"node:fs/promises\";\nimport { tmpdir } from \"node:os\";\nimport { join } from \"node:path\";\nimport { postmortem } from \"@gajae-code/utils\";\n\n/**\n", + "after": "import { createHash } from \"node:crypto\";\nimport { AppSdkHostOwner } from \"../host\";\nimport { chmod, mkdir, mkdtemp, open, readFile, rename, rm, unlink } from \"node:fs/promises\";\nimport { tmpdir } from \"node:os\";\nimport { join } from \"node:path\";\nimport { postmortem } from \"@gajae-code/utils\";\n\n/**\n" + }, + { + "before": "\t#peakBufferedBytes = 0;\n\t#peakReadBufferedBytes = 0;\n\treadonly #onReadRange?: (start: number, end: number) => void;\n\t#closing = false;\n\t#closePromise: Promise | undefined;\n\treadonly #pendingWrites = new Set>();\n\n\tconstructor(\n\t\treadonly sessionId: string,\n\t\tprivate readonly now: () => number = Date.now,\n\t\toptions?: { storageDir?: string; onReadRange?: (start: number, end: number) => void },\n\t) {\n", + "after": "\t#peakBufferedBytes = 0;\n\t#peakReadBufferedBytes = 0;\n\treadonly #onReadRange?: (start: number, end: number) => void;\n\t#closing = false;\n\t#closePromise: Promise | undefined;\n\treadonly #pendingWrites = new Set>();\n\treadonly #appUnlinks = new AppSdkHostOwner();\n\n\tconstructor(\n\t\treadonly sessionId: string,\n\t\tprivate readonly now: () => number = Date.now,\n\t\toptions?: { storageDir?: string; onReadRange?: (start: number, end: number) => void },\n\t) {\n" + }, + { + "before": "\n\tasync close(): Promise {\n\t\tif (this.#closePromise) return this.#closePromise;\n\t\tthis.#closing = true;\n\t\tthis.#closePromise = (async () => {\n\t\t\tawait Promise.all(this.#pendingWrites);\n\t\t\tthis.#resources.clear();\n\t\t\tthis.#pinIndex.clear();\n\t\t\tthis.#memoryBytes = 0;\n\t\t\tthis.#chunkRefs.clear();\n\t\t\tthis.#manifestRefs.clear();\n\t\t\tif (this.#directory) {\n", + "after": "\n\tasync close(): Promise {\n\t\tif (this.#closePromise) return this.#closePromise;\n\t\tthis.#closing = true;\n\t\tthis.#closePromise = (async () => {\n\t\t\tawait Promise.all(this.#pendingWrites);\n\t\t\tawait this.#appUnlinks.awaitAppLifecycleSettlement();\n\t\t\tthis.#resources.clear();\n\t\t\tthis.#pinIndex.clear();\n\t\t\tthis.#memoryBytes = 0;\n\t\t\tthis.#chunkRefs.clear();\n\t\t\tthis.#manifestRefs.clear();\n\t\t\tif (this.#directory) {\n" + }, + { + "before": "\n\t#releaseChunk(chunk: string): void {\n\t\tconst refs = (this.#chunkRefs.get(chunk) ?? 1) - 1;\n\t\tif (refs > 0) this.#chunkRefs.set(chunk, refs);\n\t\telse {\n\t\t\tthis.#chunkRefs.delete(chunk);\n\t\t\tif (this.#directory) void unlink(join(this.#directory, \"objects\", chunk)).catch(() => undefined);\n\t\t}\n\t}\n\n\t#releaseManifest(manifest: string): void {\n\t\tconst refs = (this.#manifestRefs.get(manifest) ?? 1) - 1;\n\t\tif (refs > 0) this.#manifestRefs.set(manifest, refs);\n\t\telse {\n\t\t\tthis.#manifestRefs.delete(manifest);\n\t\t\tvoid unlink(manifest).catch(() => undefined);\n\t\t}\n\t}\n\n\tasync #discardUnreferenced(chunks: string[], manifest: string): Promise {\n\t\tawait Promise.all(\n\t\t\tchunks\n", + "after": "\n\t#releaseChunk(chunk: string): void {\n\t\tconst refs = (this.#chunkRefs.get(chunk) ?? 1) - 1;\n\t\tif (refs > 0) this.#chunkRefs.set(chunk, refs);\n\t\telse {\n\t\t\tthis.#chunkRefs.delete(chunk);\n\t\t\tif (this.#directory) void this.#appUnlinks.own(() => unlink(join(this.#directory!, \"objects\", chunk))).catch(() => undefined);\n\t\t}\n\t}\n\n\t#releaseManifest(manifest: string): void {\n\t\tconst refs = (this.#manifestRefs.get(manifest) ?? 1) - 1;\n\t\tif (refs > 0) this.#manifestRefs.set(manifest, refs);\n\t\telse {\n\t\t\tthis.#manifestRefs.delete(manifest);\n\t\t\tvoid this.#appUnlinks.own(() => unlink(manifest)).catch(() => undefined);\n\t\t}\n\t}\n\n\tasync #discardUnreferenced(chunks: string[], manifest: string): Promise {\n\t\tawait Promise.all(\n\t\t\tchunks\n" + } + ], + "beforeSha256": "00a5fe011ce57db76fda461274fcd1a6ee544251d1192b0835373cd4b328dbd9", + "afterSha256": "db47586d7c821a7c7d5aaf9b2ea6c1dacf2a5c83919babda8a22b4017d6c6e21" + } + ] +} diff --git a/patches/gjc-sdk-lifecycle/manifest.test.mjs b/patches/gjc-sdk-lifecycle/manifest.test.mjs new file mode 100644 index 00000000..cbbd14eb --- /dev/null +++ b/patches/gjc-sdk-lifecycle/manifest.test.mjs @@ -0,0 +1,81 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import fs from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +import { applySdkLifecyclePatch } from '../../scripts/apply-sdk-lifecycle-patch.mjs'; + +const repository = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const fixtureSources = process.env.GJC_SDK_LIFECYCLE_CANDIDATE + ? path.resolve(process.env.GJC_SDK_LIFECYCLE_CANDIDATE) : repository; +const manifest = JSON.parse(await fs.readFile(new URL('./manifest.json', import.meta.url), 'utf8')); +const sha = (value) => createHash('sha256').update(value).digest('hex'); +function replaceOnce(source, before, after) { + const at = source.indexOf(before); + assert.ok(before.length && at >= 0 && source.indexOf(before, at + 1) === -1, 'replacement must occur exactly once'); + return source.slice(0, at) + after + source.slice(at + before.length); +} +async function original(file) { + let source = await fs.readFile(path.join(fixtureSources, 'node_modules', file.package, file.path), 'utf8'); + // Read-only support for the parent's eventual installed known-after state. + if (sha(source) === file.afterSha256) { + for (const edit of [...file.replacements].reverse()) source = replaceOnce(source, edit.after, edit.before); + } + assert.equal(sha(source), file.beforeSha256, 'fixture source must match the published version'); + return source; +} +async function fixture(t) { + const root = await fs.mkdtemp(path.join(tmpdir(), 'gjc-sdk-patch-replay-')); + t.after(() => fs.rm(root, { recursive: true, force: true })); + for (const [name, version] of Object.entries(manifest.packages)) { + const dir = path.join(root, 'node_modules', name); await fs.mkdir(dir, { recursive: true }); + await fs.writeFile(path.join(dir, 'package.json'), JSON.stringify({ name, version })); + } + for (const file of manifest.files) { + const filename = path.join(root, 'node_modules', file.package, file.path); + await fs.mkdir(path.dirname(filename), { recursive: true }); await fs.writeFile(filename, await original(file)); + } + return root; +} + +test('exact replace-once edits reproduce every full post-hash from published source', async () => { + assert.deepEqual(Object.keys(manifest).sort(), ['files', 'id', 'packages', 'schemaVersion']); + assert.equal(manifest.schemaVersion, 1); assert.equal(manifest.id, 'gjc-sdk-lifecycle-v1'); + assert.deepEqual(manifest.packages, { '@gajae-code/coding-agent': '0.16.4', '@gajae-code/agent-core': '0.16.4', '@gajae-code/ai': '0.16.4' }); + assert.equal(manifest.files.length, 32, 'exact reviewed producer/leaf/host source closure'); + for (const file of manifest.files) { + let source = await original(file); + for (const edit of file.replacements) source = replaceOnce(source, edit.before, edit.after); + assert.equal(sha(source), file.afterSha256); + for (const edit of [...file.replacements].reverse()) source = replaceOnce(source, edit.after, edit.before); + assert.equal(sha(source), file.beforeSha256, 'known-after fixtures can be reconstructed in memory without installed writes'); + } +}); + +test('parent applier checks unapplied source, applies all files, and accepts only known-after idempotence', async (t) => { + const root = await fixture(t); + await assert.rejects(applySdkLifecyclePatch(root, manifest, { checkOnly: true }), /not been applied/); + assert.deepEqual(await applySdkLifecyclePatch(root, manifest), { id: manifest.id, applied: manifest.files.length, verified: manifest.files.length }); + assert.deepEqual(await applySdkLifecyclePatch(root, manifest), { id: manifest.id, applied: 0, verified: manifest.files.length }); + assert.deepEqual(await applySdkLifecyclePatch(root, manifest, { checkOnly: true }), { id: manifest.id, applied: 0, verified: manifest.files.length }); +}); + +test('last-file tampering fails all-file prevalidation without partially patching earlier files', async (t) => { + const root = await fixture(t); + const last = manifest.files.at(-1); const first = manifest.files[0]; + await fs.appendFile(path.join(root, 'node_modules', last.package, last.path), '\n// unrelated source edit\n'); + await assert.rejects(applySdkLifecyclePatch(root, manifest), /digest mismatch/); + assert.equal(sha(await fs.readFile(path.join(root, 'node_modules', first.package, first.path))), first.beforeSha256); +}); + +test('wrong package versions are rejected before any replacement', async (t) => { + const root = await fixture(t); + const name = '@gajae-code/agent-core'; + await fs.writeFile(path.join(root, 'node_modules', name, 'package.json'), JSON.stringify({ name, version: '0.16.6' })); + await assert.rejects(applySdkLifecyclePatch(root, manifest), /version mismatch/); + const first = manifest.files[0]; + assert.equal(sha(await fs.readFile(path.join(root, 'node_modules', first.package, first.path))), first.beforeSha256); +}); diff --git a/scripts/apply-extract-zip-patch.mjs b/scripts/apply-extract-zip-patch.mjs new file mode 100644 index 00000000..3f23fc3e --- /dev/null +++ b/scripts/apply-extract-zip-patch.mjs @@ -0,0 +1,132 @@ +#!/usr/bin/env node +// One reviewed upstream backport, not a general-purpose patch mechanism. +import { createHash, randomUUID } from 'node:crypto'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const MANIFEST_SHA256 = '8d9d8520a197257b51d640b00c3e172a7bea16dde1813bc07c05501460dabbdc'; +const MAX_FILE_BYTES = 1024 * 1024; +const hash = bytes => createHash('sha256').update(bytes).digest('hex'); + +async function readRegular(filename) { + const before = await fs.lstat(filename); + if (!before.isFile() || before.size > MAX_FILE_BYTES || await fs.realpath(filename) !== filename) { + throw new Error('extract-zip patch requires a bounded regular file: ' + filename); + } + const bytes = await fs.readFile(filename); + const after = await fs.lstat(filename); + if (bytes.length > MAX_FILE_BYTES || before.dev !== after.dev || before.ino !== after.ino + || before.mtimeMs !== after.mtimeMs || before.size !== after.size) throw new Error('extract-zip patch target changed during read.'); + return { bytes, metadata: before }; +} + +// No ancestor resolution or npm ls fallback. Inventory actual npm installation +// slots, including scopes, aliases and nested node_modules; links fail closed. +async function assertSingleInstallation(root) { + const expected = path.join(root, 'node_modules', 'extract-zip'); + let count = 0; + async function visit(modules, optional = false, depth = 0) { + if (depth > 64) throw new Error('extract-zip dependency inventory exceeds depth bound.'); + let metadata; + try { metadata = await fs.lstat(modules); } catch (error) { + if (optional && error.code === 'ENOENT') return; + throw error; + } + if (!metadata.isDirectory() || await fs.realpath(modules) !== modules) throw new Error('Linked dependency directory is not a canonical extract-zip installation: ' + modules); + for (const entry of await fs.readdir(modules, { withFileTypes: true })) { + if (entry.name === '.bin' || entry.name === '.package-lock.json') continue; + const directory = path.join(modules, entry.name); + if (entry.isSymbolicLink()) throw new Error('Linked dependency is not a canonical extract-zip installation: ' + directory); + if (!entry.isDirectory()) continue; + if (entry.name.startsWith('@')) { + await visit(directory, false, depth + 1); + continue; + } + // Some distribution stubs have no package.json. Still inspect their + // nested dependency slots; do not skip an unrecorded extract-zip. + let installed; + try { installed = JSON.parse((await readRegular(path.join(directory, 'package.json'))).bytes); } catch (error) { + if (error.code !== 'ENOENT') throw error; + } + if (entry.name === 'extract-zip' || installed?.name === 'extract-zip') { + if (directory !== expected) throw new Error('Nested or aliased extract-zip installation is not covered: ' + directory); + count += 1; + } + await visit(path.join(directory, 'node_modules'), true, depth + 1); + } + } + await visit(path.join(root, 'node_modules')); + if (count !== 1) throw new Error('Missing canonical extract-zip installation.'); +} + +async function readManifest(root) { + const { bytes } = await readRegular(path.join(root, 'patches', 'extract-zip-symlink-leaf', 'manifest.json')); + // Pin the *entire* canonical manifest, not caller-supplied digests or a + // patch-ID marker. Changing the reviewed transform needs a source review. + if (hash(bytes) !== MANIFEST_SHA256) throw new Error('Noncanonical extract-zip patch manifest.'); + return JSON.parse(bytes); +} + +async function verifyPackage(root, manifest) { + await assertSingleInstallation(root); + const directory = path.join(root, 'node_modules', manifest.package); + const { bytes } = await readRegular(path.join(directory, 'package.json')); + const installed = JSON.parse(bytes); + if (installed.name !== manifest.package || installed.version !== manifest.version) throw new Error('extract-zip patch version mismatch.'); + // Also pins resolution metadata (main/exports), not only a version string. + if (hash(bytes) !== manifest.packageJsonSha256) throw new Error('extract-zip package metadata digest mismatch.'); + return path.join(directory, manifest.path); +} + +export async function applyExtractZipPatch(installRoot, { checkOnly = false } = {}) { + const root = await fs.realpath(installRoot); + const manifest = await readManifest(root); + const filename = await verifyPackage(root, manifest); + const { bytes, metadata } = await readRegular(filename); + const observed = hash(bytes); + if (observed === manifest.afterSha256) return { id: manifest.id, applied: 0, verified: 1 }; + if (observed !== manifest.beforeSha256) throw new Error('extract-zip source digest mismatch; refusing unknown local modifications.'); + if (checkOnly) throw new Error('extract-zip patch has not been applied.'); + const source = new TextDecoder('utf-8', { fatal: true }).decode(bytes); + const { before, after } = manifest.replacement; + const at = source.indexOf(before); + if (at < 0 || source.indexOf(before, at + 1) !== -1) throw new Error('extract-zip replacement must match exactly once.'); + const result = Buffer.from(source.slice(0, at) + after + source.slice(at + before.length)); + if (result.length > MAX_FILE_BYTES || hash(result) !== manifest.afterSha256) throw new Error('extract-zip patched digest mismatch.'); + const temporary = path.join(path.dirname(filename), '.gajae-extract-zip-patch-' + randomUUID()); + try { + const handle = await fs.open(temporary, 'wx', 0o600); + try { + await handle.writeFile(result); + await handle.chmod(metadata.mode & 0o777); + await handle.sync(); + } finally { await handle.close(); } + const current = await readRegular(filename); + if (hash(current.bytes) !== manifest.beforeSha256 || current.metadata.dev !== metadata.dev || current.metadata.ino !== metadata.ino) { + throw new Error('extract-zip source changed before replacement.'); + } + await fs.rename(temporary, filename); + if (process.platform !== 'win32') { + const directory = await fs.open(path.dirname(filename), 'r'); + try { await directory.sync(); } finally { await directory.close(); } + } + await applyExtractZipPatch(root, { checkOnly: true }); + return { id: manifest.id, applied: 1, verified: 1 }; + } finally { + await fs.unlink(temporary).catch(error => { if (error.code !== 'ENOENT') throw error; }); + } +} + +async function main() { + const args = process.argv.slice(2); + if (args.length > 1 || args.some(argument => argument !== '--check')) throw new Error('Usage: node scripts/apply-extract-zip-patch.mjs [--check]'); + const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + const result = await applyExtractZipPatch(root, { checkOnly: args.includes('--check') }); + console.log('Verified extract-zip archive-only symlink-leaf patch (' + result.applied + ' applied).'); +} + +const entry = process.argv[1] ? await fs.realpath(path.resolve(process.argv[1])).catch(() => null) : null; +if (entry !== null && entry === await fs.realpath(fileURLToPath(import.meta.url))) { + main().catch(error => { console.error(error.message); process.exitCode = 1; }); +} diff --git a/scripts/apply-extract-zip-patch.test.mjs b/scripts/apply-extract-zip-patch.test.mjs new file mode 100644 index 00000000..dff0b73a --- /dev/null +++ b/scripts/apply-extract-zip-patch.test.mjs @@ -0,0 +1,120 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import test from 'node:test'; + +import { applyExtractZipPatch } from './apply-extract-zip-patch.mjs'; +import { extractZipFixture, hash, helper, manifest, patchDirectory, zip } from './test-fixtures/extract-zip.mjs'; + +test('exact pre/post integrity, idempotence, permissions and non-mutating check', async t => { + const f = await extractZipFixture(t); + await assert.rejects(applyExtractZipPatch(f.root, { checkOnly: true }), /not been applied/); + assert.equal(hash(await fs.readFile(f.filename)), manifest.beforeSha256); + assert.deepEqual(await applyExtractZipPatch(f.root), { id: manifest.id, applied: 1, verified: 1 }); + assert.equal(hash(await fs.readFile(f.filename)), manifest.afterSha256); + const before = await fs.stat(f.filename); + assert.equal(before.mode & 0o777, 0o644); + for (const checkOnly of [true, false]) assert.equal((await applyExtractZipPatch(f.root, { checkOnly })).applied, 0); + assert.equal((await fs.stat(f.filename)).ino, before.ino); + assert.deepEqual((await fs.readdir(f.packageRoot)).sort(), ['index.js', 'package.json']); +}); + +test('unknown source/version/metadata/manifest and nested/aliased installs refuse before writing', async t => { + const changes = [ + async f => fs.writeFile(f.filename, f.original + '\n// local modification'), + async f => { + const filename = path.join(f.packageRoot, 'package.json'); + const pkg = JSON.parse(await fs.readFile(filename)); + pkg.version = '2.0.2'; await fs.writeFile(filename, JSON.stringify(pkg)); + }, + async f => { + const filename = path.join(f.packageRoot, 'package.json'); + const pkg = JSON.parse(await fs.readFile(filename)); + pkg.main = 'unknown.js'; await fs.writeFile(filename, JSON.stringify(pkg)); + }, + async f => fs.writeFile(path.join(f.root, patchDirectory, 'manifest.json'), JSON.stringify({ ...manifest, afterSha256: hash(f.original) })), + async f => fs.cp(f.packageRoot, path.join(f.root, 'node_modules/parent/node_modules/extract-zip'), { recursive: true }), + async f => fs.cp(f.packageRoot, path.join(f.root, 'node_modules/alias'), { recursive: true }), + async f => fs.cp(f.packageRoot, path.join(f.root, 'node_modules/@scope/parent/node_modules/extract-zip'), { recursive: true }), + ]; + for (const change of changes) { + const f = await extractZipFixture(t); await change(f); + const before = await fs.readFile(f.filename); + for (const checkOnly of [false, true]) await assert.rejects(applyExtractZipPatch(f.root, { checkOnly })); + assert.deepEqual(await fs.readFile(f.filename), before); + } +}); + +test('symlinked source/package and missing metadata fail closed', { skip: process.platform === 'win32' }, async t => { + for (const relative of ['index.js', 'package.json']) { + const f = await extractZipFixture(t); + const filename = path.join(f.packageRoot, relative); const outside = path.join(f.root, 'outside'); + await fs.rename(filename, outside); await fs.symlink(outside, filename); + await assert.rejects(applyExtractZipPatch(f.root), /regular file/); + } + const f = await extractZipFixture(t); + await fs.rename(f.packageRoot, path.join(f.root, 'outside')); + await fs.symlink(path.join(f.root, 'outside'), f.packageRoot); + await assert.rejects(applyExtractZipPatch(f.root), /Linked dependency/); +}); + +test('copied CLI checks its own root, executes through aliases, and rejects unknown arguments', async t => { + const f = await extractZipFixture(t); + const alias = path.join(f.root, 'alias'); await fs.symlink(f.root, alias, process.platform === 'win32' ? 'junction' : 'dir'); + const run = args => spawnSync(process.execPath, [path.join(alias, helper), ...args], { encoding: 'utf8' }); + assert.notEqual(run(['--check']).status, 0); + assert.equal(run([]).status, 0); + assert.equal(run(['--check']).status, 0); + assert.notEqual(run(['--unknown']).status, 0); + await fs.writeFile(f.filename, 'tampered'); + assert.notEqual(run(['--check']).status, 0); +}); + +test('real archive symlink-leaf attack writes outside before patch and is rejected after patch', { skip: process.platform === 'win32' }, async t => { + for (const patched of [false, true]) { + const f = await extractZipFixture(t, { patched, runtime: true }); + const extract = createRequire(f.filename)(f.filename); + const canary = path.join(f.root, 'canary'); await fs.writeFile(canary, 'ORIGINAL'); + const archive = path.join(f.root, 'attack.zip'); + await fs.writeFile(archive, zip([{ name: 'pwn', data: '../canary', mode: 0o120777 }, { name: 'pwn', data: 'OVERWRITTEN' }])); + const task = extract(archive, { dir: path.join(f.root, 'dest') }); + if (patched) await assert.rejects(task, /Out of bound path .*processing file pwn/); + else await task; + assert.equal(await fs.readFile(canary, 'utf8'), patched ? 'ORIGINAL' : 'OVERWRITTEN'); + assert.equal((await fs.lstat(path.join(f.root, 'dest/pwn'))).isSymbolicLink(), true); + } +}); + +test('real normal ZIP, duplicate regular file, directories and safe symlink remain compatible', { skip: process.platform === 'win32' }, async t => { + const f = await extractZipFixture(t, { patched: true, runtime: true }); + const extract = createRequire(f.filename)(f.filename); + const archive = path.join(f.root, 'safe.zip'); + await fs.writeFile(archive, zip([ + { name: 'bin/', mode: 0o40755 }, { name: 'bin/tool', data: 'first', mode: 0o100755 }, + { name: 'bin/tool', data: 'second', mode: 0o100755 }, { name: 'empty' }, + { name: 'shortcut', data: 'bin/tool', mode: 0o120777 }, + ])); + const dest = path.join(f.root, 'dest'); await extract(archive, { dir: dest }); + assert.equal(await fs.readFile(path.join(dest, 'bin/tool'), 'utf8'), 'second'); + assert.equal(await fs.readFile(path.join(dest, 'shortcut'), 'utf8'), 'second'); + assert.equal(await fs.readlink(path.join(dest, 'shortcut')), 'bin/tool'); + assert.equal((await fs.stat(path.join(dest, 'empty'))).size, 0); + assert.equal((await fs.stat(path.join(dest, 'bin/tool'))).mode & 0o111, 0o111); +}); + +test('real archives reject dangling/pre-existing leaves and preserve existing parent traversal protection', { skip: process.platform === 'win32' }, async t => { + for (const scenario of ['dangling', 'pre-existing', 'parent']) { + const f = await extractZipFixture(t, { patched: true, runtime: true }); + const extract = createRequire(f.filename)(f.filename); + const dest = path.join(f.root, 'dest'); await fs.mkdir(dest); + const entries = []; + if (scenario === 'pre-existing') await fs.symlink('../outside', path.join(dest, 'pwn')); + else entries.push({ name: 'pwn', data: scenario === 'parent' ? '..' : '../outside', mode: 0o120777 }); + entries.push({ name: scenario === 'parent' ? 'pwn/outside' : 'pwn', data: 'bad' }); + const archive = path.join(f.root, 'attack.zip'); await fs.writeFile(archive, zip(entries)); + await assert.rejects(extract(archive, { dir: dest }), /Out of bound path/); + await assert.rejects(fs.stat(path.join(f.root, 'outside')), { code: 'ENOENT' }); + } +}); diff --git a/scripts/apply-sdk-lifecycle-patch.mjs b/scripts/apply-sdk-lifecycle-patch.mjs new file mode 100644 index 00000000..3711470b --- /dev/null +++ b/scripts/apply-sdk-lifecycle-patch.mjs @@ -0,0 +1,142 @@ +#!/usr/bin/env node +// Reproducible build-time dependency remediation, never an app installer. +import { createHash, randomUUID } from 'node:crypto'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import policy from '../shared/sdkLifecyclePolicy.json' with { type: 'json' }; + +const PATCH_ID = 'gjc-sdk-lifecycle-v1'; +const PACKAGES = new Set(['@gajae-code/coding-agent', '@gajae-code/agent-core', '@gajae-code/ai']); +const MAX_FILE_BYTES = 4 * 1024 * 1024; +if (policy.schemaVersion !== 1 || !Number.isSafeInteger(policy.maxFiles) || policy.maxFiles < 1 || policy.maxFiles > 128) { + throw new Error('Invalid SDK lifecycle file-count policy.'); +} +const hash = (bytes) => createHash('sha256').update(bytes).digest('hex'); +const plain = (value) => value !== null && typeof value === 'object' && !Array.isArray(value) + && [null, Object.prototype].includes(Object.getPrototypeOf(value)); +const exact = (value, keys) => plain(value) && Object.keys(value).length === keys.length + && keys.every((key) => Object.hasOwn(value, key)); +const digest = (value) => typeof value === 'string' && /^[a-f0-9]{64}$/u.test(value); + +function validateManifest(manifest) { + if (!exact(manifest, ['schemaVersion', 'id', 'packages', 'files']) || manifest.schemaVersion !== 1 || manifest.id !== PATCH_ID + || !plain(manifest.packages) || !Object.keys(manifest.packages).length + || Object.entries(manifest.packages).some(([name, version]) => !PACKAGES.has(name) || !/^\d+\.\d+\.\d+$/u.test(version)) + || !Array.isArray(manifest.files) || !manifest.files.length || manifest.files.length > policy.maxFiles) throw new Error('Invalid SDK lifecycle patch manifest.'); + const paths = new Set(); + for (const file of manifest.files) { + if (!exact(file, ['package', 'path', 'beforeSha256', 'afterSha256', 'replacements']) + || !Object.hasOwn(manifest.packages, file.package) + || typeof file.path !== 'string' || !/^src\/[A-Za-z0-9._/-]+\.ts$/u.test(file.path) + || file.path.split('/').some((part) => !part || part === '.' || part === '..') + || !digest(file.beforeSha256) || !digest(file.afterSha256) || file.beforeSha256 === file.afterSha256 + || !Array.isArray(file.replacements) || !file.replacements.length || file.replacements.length > 64) throw new Error('Invalid SDK lifecycle patch file.'); + const key = `${file.package}/${file.path}`; + if (paths.has(key)) throw new Error('Duplicate SDK lifecycle patch file.'); + paths.add(key); + for (const replacement of file.replacements) { + if (!exact(replacement, ['before', 'after']) || typeof replacement.before !== 'string' || !replacement.before + || typeof replacement.after !== 'string' || replacement.before === replacement.after + || Buffer.byteLength(replacement.before) > MAX_FILE_BYTES || Buffer.byteLength(replacement.after) > MAX_FILE_BYTES) throw new Error('Invalid SDK lifecycle replacement.'); + } + } +} + +async function readRegular(filename) { + const metadata = await fs.lstat(filename); + if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.size > MAX_FILE_BYTES + || await fs.realpath(filename) !== filename) throw new Error('SDK patch target is not a bounded regular file.'); + const bytes = await fs.readFile(filename); + const after = await fs.lstat(filename); + if (bytes.length > MAX_FILE_BYTES || after.dev !== metadata.dev || after.ino !== metadata.ino + || after.mtimeMs !== metadata.mtimeMs || after.size !== metadata.size) throw new Error('SDK patch target changed during read.'); + return { bytes, metadata }; +} + +/** Validate ALL inputs before writing. Known post-hashes make retries idempotent; + * an unknown version or local source change is an error, never patched over. */ +export async function applySdkLifecyclePatch(installRoot, manifest, { checkOnly = false } = {}) { + validateManifest(manifest); + const root = await fs.realpath(installRoot); + const planned = []; + for (const [name, version] of Object.entries(manifest.packages)) { + const filename = path.join(root, 'node_modules', name, 'package.json'); + const { bytes } = await readRegular(filename); + const installed = JSON.parse(bytes.toString('utf8')); + if (installed.name !== name || installed.version !== version) throw new Error(`SDK lifecycle patch version mismatch: ${name}.`); + } + for (const file of manifest.files) { + const filename = path.join(root, 'node_modules', file.package, file.path); + const { bytes, metadata } = await readRegular(filename); + const observed = hash(bytes); + if (observed === file.afterSha256) continue; + if (observed !== file.beforeSha256) throw new Error(`SDK lifecycle source digest mismatch: ${file.package}/${file.path}.`); + if (checkOnly) throw new Error('SDK lifecycle patch has not been applied.'); + let text = new TextDecoder('utf-8', { fatal: true }).decode(bytes); + for (const replacement of file.replacements) { + const at = text.indexOf(replacement.before); + if (at < 0 || text.indexOf(replacement.before, at + 1) !== -1) throw new Error('SDK lifecycle replacement must match exactly once.'); + text = text.slice(0, at) + replacement.after + text.slice(at + replacement.before.length); + } + const result = Buffer.from(text); + if (result.length > MAX_FILE_BYTES || hash(result) !== file.afterSha256) throw new Error('SDK lifecycle patched digest mismatch.'); + planned.push({ filename, metadata, result, before: observed, after: file.afterSha256 }); + } + + const temporary = new Set(); + try { + // Stage every result before replacing any original. A crash between renames + // is repaired by the same exact before/after inventory on the next run. + for (const file of planned) { + file.temporary = path.join(path.dirname(file.filename), `.gajae-sdk-patch-${randomUUID()}`); + const handle = await fs.open(file.temporary, 'wx', 0o600); + temporary.add(file.temporary); + try { + await handle.writeFile(file.result); + await handle.chmod(file.metadata.mode & 0o777); + await handle.sync(); + } finally { await handle.close(); } + } + for (const file of planned) { + const { bytes, metadata } = await readRegular(file.filename); + if (hash(bytes) === file.after) continue; // Another identical applier won. + if (hash(bytes) !== file.before || metadata.dev !== file.metadata.dev || metadata.ino !== file.metadata.ino) throw new Error('SDK lifecycle source changed before replacement.'); + await fs.rename(file.temporary, file.filename); + temporary.delete(file.temporary); + if (process.platform !== 'win32') { + const directory = await fs.open(path.dirname(file.filename), 'r'); + try { await directory.sync(); } finally { await directory.close(); } + } + } + // A successful return attests to every final file, not just the files this + // invocation happened to replace. + for (const file of manifest.files) { + const { bytes } = await readRegular(path.join(root, 'node_modules', file.package, file.path)); + if (hash(bytes) !== file.afterSha256) throw new Error('SDK lifecycle final verification failed.'); + } + return { id: manifest.id, applied: planned.length, verified: manifest.files.length }; + } finally { + for (const filename of temporary) await fs.unlink(filename).catch((error) => { + if (error.code !== 'ENOENT') throw error; + }); + } +} + +async function main() { + const args = process.argv.slice(2); + if (args.length > 1 || args.some((argument) => argument !== '--check')) throw new Error('Usage: node scripts/apply-sdk-lifecycle-patch.mjs [--check]'); + const root = await fs.realpath(path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')); + const { bytes } = await readRegular(path.join(root, 'patches', 'gjc-sdk-lifecycle', 'manifest.json')); + const result = await applySdkLifecyclePatch(root, JSON.parse(bytes.toString('utf8')), { checkOnly: args.includes('--check') }); + console.log(`Verified ${result.verified} SDK lifecycle patch files (${result.applied} applied).`); +} + +// Node canonicalizes import.meta.url while argv can still use /tmp or another +// symlink alias. A direct --check must never silently become an inert import. +const invokedFile = process.argv[1] + ? await fs.realpath(path.resolve(process.argv[1])).catch(() => null) : null; +if (invokedFile !== null && invokedFile === await fs.realpath(fileURLToPath(import.meta.url))) { + main().catch((error) => { console.error(error.message); process.exitCode = 1; }); +} diff --git a/scripts/apply-sdk-lifecycle-patch.test.mjs b/scripts/apply-sdk-lifecycle-patch.test.mjs new file mode 100644 index 00000000..8257b171 --- /dev/null +++ b/scripts/apply-sdk-lifecycle-patch.test.mjs @@ -0,0 +1,133 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import policy from '../shared/sdkLifecyclePolicy.json' with { type: 'json' }; + +import { applySdkLifecyclePatch } from './apply-sdk-lifecycle-patch.mjs'; + +const hash = (text) => createHash('sha256').update(text).digest('hex'); +async function fixture(t, files = ['one.ts']) { + const root = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'gajae-sdk-patch-test-'))); + t.after(() => fs.rm(root, { recursive: true, force: true })); + const pkg = '@gajae-code/coding-agent'; + const packageRoot = path.join(root, 'node_modules', pkg); + await fs.mkdir(path.join(packageRoot, 'src'), { recursive: true }); + await fs.writeFile(path.join(packageRoot, 'package.json'), JSON.stringify({ name: pkg, version: '0.16.4' })); + const manifest = { schemaVersion: 1, id: 'gjc-sdk-lifecycle-v1', packages: { [pkg]: '0.16.4' }, files: [] }; + for (const name of files) { + const before = `export const ${name.split('.')[0]} = 'unjoined';\n`; + const after = before.replace('unjoined', 'joined'); + await fs.writeFile(path.join(packageRoot, 'src', name), before, { mode: 0o644 }); + manifest.files.push({ package: pkg, path: `src/${name}`, beforeSha256: hash(before), afterSha256: hash(after), replacements: [{ before: "'unjoined'", after: "'joined'" }] }); + } + return { root, packageRoot, manifest, source: (name = files[0]) => path.join(packageRoot, 'src', name) }; +} + +test('applier verifies every file, is idempotent, and preserves source permissions', async (t) => { + const f = await fixture(t, ['one.ts', 'two.ts']); + assert.deepEqual(await applySdkLifecyclePatch(f.root, f.manifest), { id: f.manifest.id, applied: 2, verified: 2 }); + assert.match(await fs.readFile(f.source(), 'utf8'), /'joined'/u); + assert.equal((await fs.stat(f.source())).mode & 0o777, 0o644); + assert.deepEqual(await applySdkLifecyclePatch(f.root, f.manifest), { id: f.manifest.id, applied: 0, verified: 2 }); + assert.deepEqual(await applySdkLifecyclePatch(f.root, f.manifest, { checkOnly: true }), { id: f.manifest.id, applied: 0, verified: 2 }); + assert.deepEqual((await fs.readdir(path.dirname(f.source()))).sort(), ['one.ts', 'two.ts']); +}); + +test('check-only refuses unapplied sources without writing', async (t) => { + const f = await fixture(t); + await assert.rejects(applySdkLifecyclePatch(f.root, f.manifest, { checkOnly: true }), /not been applied/u); + assert.equal(hash(await fs.readFile(f.source())), f.manifest.files[0].beforeSha256); +}); + +test('shared file-count policy supports the complete transport inventory and rejects overflow before writes', async (t) => { + const f = await fixture(t, Array.from({ length: policy.maxFiles }, (_, index) => `file${index}.ts`)); + const overflow = structuredClone(f.manifest); + overflow.files.push({ ...overflow.files[0], path: 'src/overflow.ts' }); + await assert.rejects(applySdkLifecyclePatch(f.root, overflow), /Invalid SDK lifecycle patch manifest/u); + assert.equal(hash(await fs.readFile(f.source())), f.manifest.files[0].beforeSha256); + assert.equal((await applySdkLifecyclePatch(f.root, f.manifest)).verified, policy.maxFiles); +}); + +test('all sources are validated before replacing any one file', async (t) => { + const f = await fixture(t, ['one.ts', 'two.ts']); + await fs.writeFile(f.source('two.ts'), 'user-owned local modification'); + await assert.rejects(applySdkLifecyclePatch(f.root, f.manifest), /source digest mismatch/u); + assert.equal(hash(await fs.readFile(f.source('one.ts'))), f.manifest.files[0].beforeSha256); + assert.equal(await fs.readFile(f.source('two.ts'), 'utf8'), 'user-owned local modification'); +}); + +test('version drift never patches a newly installed SDK even if source bytes happen to match', async (t) => { + const f = await fixture(t); + await fs.writeFile(path.join(f.packageRoot, 'package.json'), JSON.stringify({ name: '@gajae-code/coding-agent', version: '0.16.6' })); + await assert.rejects(applySdkLifecyclePatch(f.root, f.manifest), /version mismatch/u); + assert.equal(hash(await fs.readFile(f.source())), f.manifest.files[0].beforeSha256); +}); + +test('malformed, traversing, duplicate and incorrect-result manifests are rejected without mutation', async (t) => { + const f = await fixture(t); + for (const mutate of [ + (m) => { m.files[0].path = 'src/../../private.ts'; }, + (m) => { m.files[0].path = '/private.ts'; }, + (m) => { m.files[0].path = 'src/../private.ts'; }, + (m) => { m.files.push(structuredClone(m.files[0])); }, + (m) => { m.files[0].afterSha256 = '0'.repeat(64); }, + (m) => { m.files[0].replacements[0].before = 'not present'; }, + (m) => { m.files[0].replacements[0].before = ''; }, + (m) => { m.files[0].replacements[0].shell = 'no'; }, + (m) => { m.packages = { 'foreign-package': '0.16.4' }; }, + ]) { + const manifest = structuredClone(f.manifest); mutate(manifest); + await assert.rejects(applySdkLifecyclePatch(f.root, manifest)); + assert.equal(hash(await fs.readFile(f.source())), f.manifest.files[0].beforeSha256); + } +}); + +test('symlinked targets and parent directories cannot redirect writes', { skip: process.platform === 'win32' }, async (t) => { + const f = await fixture(t); + const outside = path.join(f.root, 'outside'); await fs.mkdir(outside); + const externalFile = path.join(outside, 'one.ts'); + const original = await fs.readFile(f.source()); await fs.writeFile(externalFile, original); + await fs.unlink(f.source()); await fs.symlink(externalFile, f.source()); + await assert.rejects(applySdkLifecyclePatch(f.root, f.manifest), /regular file/u); + assert.equal(hash(await fs.readFile(externalFile)), f.manifest.files[0].beforeSha256); + await fs.unlink(f.source()); await fs.rmdir(path.dirname(f.source())); + await fs.symlink(outside, path.join(f.packageRoot, 'src')); + await assert.rejects(applySdkLifecyclePatch(f.root, f.manifest), /regular file/u); + assert.equal(hash(await fs.readFile(externalFile)), f.manifest.files[0].beforeSha256); +}); + +test('a partially applied known inventory can finish without rewriting verified files', async (t) => { + const f = await fixture(t, ['one.ts', 'two.ts']); + const text = await fs.readFile(f.source('one.ts'), 'utf8'); + await fs.writeFile(f.source('one.ts'), text.replace('unjoined', 'joined')); + const first = await fs.stat(f.source('one.ts')); + assert.equal((await applySdkLifecyclePatch(f.root, f.manifest)).applied, 1); + const after = await fs.stat(f.source('one.ts')); + assert.equal(after.ino, first.ino); + assert.equal(after.mtimeMs, first.mtimeMs); + await applySdkLifecyclePatch(f.root, f.manifest, { checkOnly: true }); +}); + +test('absolute symlink-alias CLI paths execute apply/check instead of silently succeeding', { skip: process.platform === 'win32' }, async (t) => { + const f = await fixture(t); + await fs.mkdir(path.join(f.root, 'scripts')); + await fs.mkdir(path.join(f.root, 'patches/gjc-sdk-lifecycle'), { recursive: true }); + await fs.copyFile(new URL('./apply-sdk-lifecycle-patch.mjs', import.meta.url), path.join(f.root, 'scripts/apply-sdk-lifecycle-patch.mjs')); + await fs.mkdir(path.join(f.root, 'shared'), { recursive: true }); + await fs.copyFile(new URL('../shared/sdkLifecyclePolicy.json', import.meta.url), path.join(f.root, 'shared/sdkLifecyclePolicy.json')); + await fs.writeFile(path.join(f.root, 'patches/gjc-sdk-lifecycle/manifest.json'), JSON.stringify(f.manifest)); + const alias = path.join(f.root, 'alias'); await fs.symlink(f.root, alias); + const entry = path.join(alias, 'scripts/apply-sdk-lifecycle-patch.mjs'); + const checked = spawnSync(process.execPath, [entry, '--check'], { encoding: 'utf8' }); + assert.equal(checked.status, 1, checked.stdout + checked.stderr); + assert.match(checked.stderr, /not been applied/u); + const applied = spawnSync(process.execPath, [entry], { encoding: 'utf8' }); + assert.equal(applied.status, 0, applied.stderr); + assert.match(applied.stdout, /1 applied/u); + assert.equal(hash(await fs.readFile(f.source())), f.manifest.files[0].afterSha256); +}); diff --git a/scripts/check-audit.mjs b/scripts/check-audit.mjs index fd99b774..3849091d 100644 --- a/scripts/check-audit.mjs +++ b/scripts/check-audit.mjs @@ -1,122 +1,148 @@ #!/usr/bin/env node - -/** - * Fails the build on any high or critical npm advisory that is not a recorded, - * still-valid exception. - * - * `npm audit --audit-level=high` cannot express "this one has no patched - * release anywhere". Without a place to record that, the gate is either red - * forever or turned off entirely, and the second one hides the next real - * advisory. Every exception below therefore carries the reason no fix exists - * and a review date: an expired exception fails, and so does one that no longer - * matches a live advisory, so the list cannot quietly rot. - */ - +// Keep npm's high/critical gate. A reviewed backport is recognized only after +// verifying its exact installed integrity; it is never a blanket advisory skip. import { execFile as execFileCallback } from 'node:child_process'; +import { realpath } from 'node:fs/promises'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { promisify } from 'node:util'; +import { applyExtractZipPatch } from './apply-extract-zip-patch.mjs'; + const execFile = promisify(execFileCallback); const REPOSITORY_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const BLOCKING_SEVERITIES = new Set(['high', 'critical']); const ADVISORY_PATTERN = /GHSA-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}/iu; -const EXCEPTIONS = [ +export const EXCEPTIONS = [ { advisory: 'GHSA-jmr9-qjv8-65gv', package: 'extract-zip', + reviewedOn: '2026-09-09', + reviewBy: '2026-11-30', + reason: 'extract-zip has no patched release at review. It reaches us through ' + + '@gajae-code/coding-agent -> puppeteer-core 24.x -> @puppeteer/browsers 2.x; ' + + 'removing it needs a Puppeteer major upgrade. Retain the constrained-use exception: ' + + 'the app does not unpack attacker-supplied archives with extract-zip; its caller is ' + + 'Puppeteer browser acquisition from the pinned vendor download URL. Additionally require ' + + 'the canonical PR160 backport. PR160 does not establish remediation of this advisory in ' + + 'its full scope: symlinks themselves remain supported, and subsequent consumers or a ' + + 'concurrent local writer are outside the archive-only guarantee.', + }, + { + advisory: 'GHSA-7pqw-9j4j-h8q3', + package: 'extract-zip', + reviewedOn: '2026-09-09', reviewBy: '2026-11-30', - reason: - 'extract-zip has no patched release. It reaches us through ' - + '@gajae-code/coding-agent -> puppeteer-core 24.x -> @puppeteer/browsers 2.x, ' - + 'and @puppeteer/browsers only dropped extract-zip in 3.x, which needs ' - + 'puppeteer-core 25. Nothing downstream of the SDK can resolve it. The app ' - + 'never unpacks an attacker-supplied archive with it: the only caller is the ' - + 'browser download puppeteer performs against its pinned Chromium URL.', + reason: 'Backported the exact PR160 archive-only symlink-leaf fix to extract-zip 2.0.1; ' + + 'the PR was open/unmerged at review. Require the canonical manifest, package metadata, ' + + 'post-hash and single installed copy. This does not close the lstat/open race against ' + + 'a concurrent same-UID writer and is not sandbox protection.', }, ]; -function advisoryIdOf(via) { - if (typeof via === 'string') return null; - const source = via.url ?? via.title ?? ''; - return ADVISORY_PATTERN.exec(source)?.[0]?.toUpperCase() ?? null; -} +const validDate = value => typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/u.test(value) + && Number.isFinite(Date.parse(value)) && new Date(value).toISOString().slice(0, 10) === value; async function auditReport() { try { - const { stdout } = await execFile('npm', ['audit', '--json'], { - cwd: REPOSITORY_ROOT, - maxBuffer: 32 * 1024 * 1024, - }); + const { stdout } = await execFile('npm', ['audit', '--json'], { cwd: REPOSITORY_ROOT, maxBuffer: 32 * 1024 * 1024 }); return JSON.parse(stdout); } catch (error) { - // npm exits non-zero whenever it reports a vulnerability, and still writes - // the report to stdout. Only a missing report is a real failure. - if (typeof error.stdout === 'string' && error.stdout.trim().length > 0) { - return JSON.parse(error.stdout); - } - throw new Error(`npm audit did not produce a report: ${error.message}`); + // npm reports advisories with a nonzero exit. Network/error-only JSON is + // rejected by evaluateAuditReport, not misinterpreted as a clean report. + if (typeof error.stdout === 'string' && error.stdout.trim()) return JSON.parse(error.stdout); + throw new Error('npm audit did not produce a report: ' + error.message); } } -const report = await auditReport(); -const vulnerabilities = Object.values(report.vulnerabilities ?? {}); -const blocking = new Map(); +export async function evaluateAuditReport(report, { + installRoot = REPOSITORY_ROOT, today = new Date().toISOString().slice(0, 10), exceptions = EXCEPTIONS, +} = {}) { + const errors = []; + const honored = []; + if (!validDate(today) || report?.error || report?.auditReportVersion !== 2 || !report.vulnerabilities + || typeof report.vulnerabilities !== 'object' || Array.isArray(report.vulnerabilities) + || !report.metadata?.vulnerabilities) { + return { errors: ['npm audit returned an invalid or incomplete report.'], honored }; + } + let patchVerified = false; + try { + await applyExtractZipPatch(installRoot, { checkOnly: true }); + patchVerified = true; + } catch (error) { errors.push('Canonical extract-zip patch verification failed: ' + error.message); } -for (const vulnerability of vulnerabilities) { - if (!BLOCKING_SEVERITIES.has(vulnerability.severity)) continue; - for (const via of vulnerability.via ?? []) { - const advisory = advisoryIdOf(via); - // A string `via` is a dependency path onto another package's advisory; the - // advisory itself is reported on the package that actually carries it. - if (!advisory) continue; - if (!blocking.has(advisory)) { - blocking.set(advisory, { advisory, package: via.name ?? vulnerability.name, severity: via.severity ?? vulnerability.severity, title: via.title ?? '' }); + const blocking = []; + for (const vulnerability of Object.values(report.vulnerabilities)) { + if (!BLOCKING_SEVERITIES.has(vulnerability.severity)) continue; + if (!Array.isArray(vulnerability.via) || !vulnerability.via.length) { + errors.push('Blocking advisory has no evidence: ' + vulnerability.name); + continue; + } + for (const via of vulnerability.via) { + // String edges refer to the advisory-bearing dependency, never an + // exception of their own. A dangling edge is not a clean audit. + if (typeof via === 'string') { + if (!report.vulnerabilities[via]) errors.push('Missing audit dependency evidence: ' + via); + continue; + } + if (!via || typeof via !== 'object') { + errors.push('Malformed audit advisory: ' + vulnerability.name); + continue; + } + if (!BLOCKING_SEVERITIES.has(via.severity ?? vulnerability.severity)) continue; + const advisory = ADVISORY_PATTERN.exec(via.url ?? via.title ?? '')?.[0]?.toUpperCase(); + if (!advisory) { errors.push('Unidentified blocking advisory: ' + vulnerability.name); continue; } + blocking.push({ advisory, package: via.name ?? vulnerability.name, owner: vulnerability.name, + nodes: vulnerability.nodes, severity: via.severity ?? vulnerability.severity, title: via.title ?? '' }); } } -} -const today = new Date().toISOString().slice(0, 10); -const errors = []; -const honored = []; - -for (const exception of EXCEPTIONS) { - const advisory = exception.advisory.toUpperCase(); - const live = blocking.get(advisory); - if (!live) { - errors.push( - `Exception for ${advisory} (${exception.package}) no longer matches any high or critical advisory. ` - + 'Remove it from scripts/check-audit.mjs.', - ); - continue; + const recognized = new Set(); + for (const exception of exceptions) { + const advisory = exception.advisory.toUpperCase(); + const matches = blocking.filter(live => live.advisory === advisory && live.package === exception.package && live.owner === exception.package); + if (!matches.length) { + errors.push('Exception for ' + advisory + ' no longer matches a high or critical advisory; remove or re-review it.'); + continue; + } + if (!validDate(exception.reviewedOn) || !validDate(exception.reviewBy) || exception.reviewedOn > today + || exception.reviewBy < today || exception.reviewBy < exception.reviewedOn || !exception.reason?.trim()) { + errors.push('Missing, future or expired review for ' + advisory + ' (review by ' + exception.reviewBy + ').'); + continue; + } + if (matches.some(live => !Array.isArray(live.nodes) || live.nodes.length !== 1 || live.nodes[0] !== 'node_modules/extract-zip')) { + errors.push('Unreviewed installed paths for ' + advisory + '; nested/unknown extract-zip copies are not covered.'); + continue; + } + if (!patchVerified) continue; + for (const live of matches) recognized.add(live); + honored.push(advisory + ' (' + exception.package + ', canonical archive-only backport; reviewed ' + + exception.reviewedOn + ', review by ' + exception.reviewBy + ')'); } - blocking.delete(advisory); - if (exception.reviewBy < today) { - errors.push( - `Exception for ${advisory} (${exception.package}) expired on ${exception.reviewBy}. ` - + 'Re-check for an upstream fix, then either resolve it or extend the review date with a fresh reason.', - ); - continue; + for (const live of blocking) { + if (!recognized.has(live)) errors.push(live.severity + ' advisory ' + live.advisory + ' in ' + live.package + ': ' + live.title); } - honored.push(`${advisory} (${exception.package}, review by ${exception.reviewBy})`); + return { errors, honored }; } -for (const live of blocking.values()) { - errors.push(`${live.severity} advisory ${live.advisory} in ${live.package}: ${live.title}`); +async function main() { + const report = await auditReport(); + const { errors, honored } = await evaluateAuditReport(report); + if (errors.length) { + console.error('Dependency audit failed:\n' + errors.map(error => ' - ' + error).join('\n')); + console.error('\nRun npm audit for the full report. Do not bypass the integrity or review gates.'); + process.exitCode = 1; + return; + } + const belowGate = Object.entries(report.metadata.vulnerabilities) + .filter(([severity, count]) => !BLOCKING_SEVERITIES.has(severity) && severity !== 'total' && count > 0) + .map(([severity, count]) => count + ' ' + severity).join(', '); + console.log('Dependency audit passed (no unexpected high or critical advisories' + (belowGate ? '; below the gate: ' + belowGate : '') + ').'); + for (const entry of honored) console.log(' verified, time-bounded exception: ' + entry); } -if (errors.length > 0) { - console.error('Dependency audit failed:'); - for (const error of errors) console.error(` - ${error}`); - console.error('\nRun `npm audit` for the full report, or `npm audit fix` for the advisories that have a patched release.'); - process.exit(1); +const entry = process.argv[1] ? await realpath(resolve(process.argv[1])).catch(() => null) : null; +if (entry !== null && entry === await realpath(fileURLToPath(import.meta.url))) { + main().catch(error => { console.error('Dependency audit failed: ' + error.message); process.exitCode = 1; }); } - -const counts = report.metadata?.vulnerabilities ?? {}; -const belowGate = Object.entries(counts) - .filter(([severity, count]) => !BLOCKING_SEVERITIES.has(severity) && severity !== 'total' && count > 0) - .map(([severity, count]) => `${count} ${severity}`) - .join(', '); -console.log(`Dependency audit passed (no unexpected high or critical advisories${belowGate ? `; below the gate: ${belowGate}` : ''}).`); -for (const entry of honored) console.log(` recorded exception: ${entry}`); diff --git a/scripts/check-audit.test.mjs b/scripts/check-audit.test.mjs new file mode 100644 index 00000000..ae829073 --- /dev/null +++ b/scripts/check-audit.test.mjs @@ -0,0 +1,129 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { copyFile, mkdir, mkdtemp, readFile, realpath, rm, symlink, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +import { applyExtractZipPatch } from './apply-extract-zip-patch.mjs'; +import { evaluateAuditReport, EXCEPTIONS } from './check-audit.mjs'; + +const repository = fileURLToPath(new URL('..', import.meta.url)); +const today = '2026-09-09'; +const digest = bytes => createHash('sha256').update(bytes).digest('hex'); + +async function fixture(t, { patched = true } = {}) { + const root = await realpath(await mkdtemp(path.join(os.tmpdir(), 'gajae-audit-guard-'))); + t.after(() => rm(root, { recursive: true, force: true })); + const directory = path.join(root, 'node_modules/extract-zip'); + const manifestDirectory = path.join(root, 'patches/extract-zip-symlink-leaf'); + await mkdir(directory, { recursive: true }); + await mkdir(manifestDirectory, { recursive: true }); + await copyFile(path.join(repository, 'patches/extract-zip-symlink-leaf/manifest.json'), path.join(manifestDirectory, 'manifest.json')); + await copyFile(path.join(repository, 'node_modules/extract-zip/package.json'), path.join(directory, 'package.json')); + const manifest = JSON.parse(await readFile(path.join(manifestDirectory, 'manifest.json'), 'utf8')); + let source = await readFile(path.join(repository, 'node_modules/extract-zip/index.js'), 'utf8'); + if (digest(source) === manifest.afterSha256) source = source.replace(manifest.replacement.after, manifest.replacement.before); + assert.equal(digest(source), manifest.beforeSha256); + await writeFile(path.join(directory, 'index.js'), source); + if (patched) await applyExtractZipPatch(root); + return { root, directory, manifestDirectory }; +} + +function report() { + return { + auditReportVersion: 2, + vulnerabilities: { + 'extract-zip': { + name: 'extract-zip', severity: 'high', nodes: ['node_modules/extract-zip'], + via: EXCEPTIONS.map(exception => ({ name: 'extract-zip', severity: 'high', title: 'fixture', url: `https://github.com/advisories/${exception.advisory}` })), + }, + '@puppeteer/browsers': { name: '@puppeteer/browsers', severity: 'high', via: ['extract-zip'], nodes: ['node_modules/@puppeteer/browsers'] }, + }, + metadata: { vulnerabilities: { high: 2, total: 2 } }, + }; +} + +test('audit recognizes only exact installed backports with current reviews', async t => { + const { root } = await fixture(t); + const result = await evaluateAuditReport(report(), { installRoot: root, today }); + assert.deepEqual(result.errors, []); + assert.equal(result.honored.length, 2); +}); + +test('unapplied and altered sources cannot turn an advisory into an exception', async t => { + for (const scenario of ['unapplied', 'altered']) await t.test(scenario, async t => { + const { root, directory } = await fixture(t, { patched: scenario !== 'unapplied' }); + if (scenario === 'altered') await writeFile(path.join(directory, 'index.js'), 'module.exports = () => {};\n'); + const result = await evaluateAuditReport(report(), { installRoot: root, today }); + assert.match(result.errors.join('\n'), /patch verification failed/); + assert.equal(result.honored.length, 0); + assert.match(result.errors.join('\n'), /GHSA-7PQW-9J4J-H8Q3/); + }); +}); + +test('changed canonical manifests and resolution metadata fail closed', async t => { + for (const target of ['manifest', 'metadata']) await t.test(target, async t => { + const { root, directory, manifestDirectory } = await fixture(t); + const filename = target === 'manifest' ? path.join(manifestDirectory, 'manifest.json') : path.join(directory, 'package.json'); + await writeFile(filename, (await readFile(filename, 'utf8')) + '\n'); + const result = await evaluateAuditReport(report(), { installRoot: root, today }); + assert.match(result.errors.join('\n'), /patch verification failed/); + assert.equal(result.honored.length, 0); + }); +}); + +test('unrecorded nested or aliased extract-zip copies are rejected', async t => { + for (const slot of ['node_modules/other/node_modules/extract-zip', 'node_modules/zip-alias']) await t.test(slot, async t => { + const { root, directory } = await fixture(t); + const extra = path.join(root, slot); + await mkdir(extra, { recursive: true }); + await copyFile(path.join(directory, 'package.json'), path.join(extra, 'package.json')); + const result = await evaluateAuditReport(report(), { installRoot: root, today }); + assert.match(result.errors.join('\n'), /Nested or aliased/); + assert.equal(result.honored.length, 0); + }); +}); + +test('linked dependency slots do not grant a canonical patch exception', { skip: process.platform === 'win32' }, async t => { + const { root, directory } = await fixture(t); + await symlink(directory, path.join(root, 'node_modules/zip-link')); + const result = await evaluateAuditReport(report(), { installRoot: root, today }); + assert.match(result.errors.join('\n'), /Linked dependency/); + assert.equal(result.honored.length, 0); +}); + +test('expired, future and stale advisory reviews remain blocking', async t => { + const { root } = await fixture(t); + for (const overrides of [{ reviewBy: '2026-09-08' }, { reviewedOn: '2026-09-10' }]) { + const exceptions = EXCEPTIONS.map(exception => ({ ...exception, ...overrides })); + const result = await evaluateAuditReport(report(), { installRoot: root, today, exceptions }); + assert.match(result.errors.join('\n'), /Missing, future or expired review/); + assert.equal(result.honored.length, 0); + } + const stale = report(); + stale.vulnerabilities['extract-zip'].via.pop(); + const result = await evaluateAuditReport(stale, { installRoot: root, today }); + assert.match(result.errors.join('\n'), /no longer matches/); +}); + +test('unknown high severity and mismatched npm paths are not covered', async t => { + const { root } = await fixture(t); + const unknown = report(); + unknown.vulnerabilities.other = { name: 'other', severity: 'critical', nodes: ['node_modules/other'], via: [{ name: 'other', severity: 'critical', url: 'https://github.com/advisories/GHSA-aaaa-bbbb-cccc' }] }; + assert.match((await evaluateAuditReport(unknown, { installRoot: root, today })).errors.join('\n'), /GHSA-AAAA-BBBB-CCCC/); + const nested = report(); + nested.vulnerabilities['extract-zip'].nodes.push('node_modules/other/node_modules/extract-zip'); + assert.match((await evaluateAuditReport(nested, { installRoot: root, today })).errors.join('\n'), /Unreviewed installed paths/); +}); + +test('network errors, incomplete reports and dangling dependency edges fail closed', async t => { + const { root } = await fixture(t); + for (const invalid of [{}, { error: { code: 'ECONNRESET' } }, { ...report(), vulnerabilities: [] }]) { + assert.match((await evaluateAuditReport(invalid, { installRoot: root, today })).errors.join('\n'), /invalid or incomplete/); + } + const dangling = report(); + dangling.vulnerabilities['@puppeteer/browsers'].via = ['missing']; + assert.match((await evaluateAuditReport(dangling, { installRoot: root, today })).errors.join('\n'), /Missing audit dependency evidence/); +}); diff --git a/scripts/dependency-security-updates.test.mjs b/scripts/dependency-security-updates.test.mjs new file mode 100644 index 00000000..48404800 --- /dev/null +++ b/scripts/dependency-security-updates.test.mjs @@ -0,0 +1,36 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +import matter from 'gray-matter'; +import semver from 'semver'; + +const lock = JSON.parse(await readFile(new URL('../package-lock.json', import.meta.url), 'utf8')); +const require = createRequire(import.meta.url); + +test('locked multer and both js-yaml majors meet the security floors', () => { + assert.ok(semver.satisfies(lock.packages['node_modules/multer'].version, '>=2.3.0 <3')); + const yaml = Object.entries(lock.packages).filter(([name]) => name.endsWith('/js-yaml')); + assert.deepEqual([...new Set(yaml.map(([, entry]) => semver.major(entry.version)))].sort(), [3, 4]); + for (const [name, entry] of yaml) { + assert.ok(semver.satisfies(entry.version, '>=3.15.2 <4 || >=4.3.2 <5'), name); + } +}); + +test('every installed locked YAML parser charges empty merge sources against its work limit', () => { + for (const name of Object.keys(lock.packages).filter(name => name.endsWith('/js-yaml'))) { + const yaml = require(fileURLToPath(new URL(`../${name}`, import.meta.url))); + assert.deepEqual(yaml.load('defaults: &defaults {enabled: true}\njob: {<<: *defaults}\n'), { + defaults: { enabled: true }, job: { enabled: true }, + }); + assert.throws(() => yaml.load('job: {<<: [{}, {}, {}, {}]}\n', { maxTotalMergeKeys: 3 }), /maxTotalMergeKeys/, name); + } +}); + +test('gray-matter retains YAML 3 frontmatter and merge compatibility', () => { + const parsed = matter('---\ndefaults: &defaults\n enabled: true\njob:\n <<: *defaults\n name: build\n---\nBody\n'); + assert.deepEqual(parsed.data.job, { enabled: true, name: 'build' }); + assert.equal(parsed.content, 'Body\n'); +}); diff --git a/scripts/fill-runtime-manifest.mjs b/scripts/fill-runtime-manifest.mjs index 7cc39601..74a69cbd 100644 --- a/scripts/fill-runtime-manifest.mjs +++ b/scripts/fill-runtime-manifest.mjs @@ -7,6 +7,8 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { promisify } from 'node:util'; +import { applySdkLifecyclePatch } from './apply-sdk-lifecycle-patch.mjs'; + const execFile = promisify(execFileCallback); const __dirname = path.dirname(fileURLToPath(import.meta.url)); const rootDir = path.resolve(__dirname, '..'); @@ -117,6 +119,13 @@ async function platformClosure(nativesRoot, platform, foreignRoot) { const manifestText = await fs.readFile(manifestPath, 'utf8'); const manifest = JSON.parse(manifestText); +const sdkPatch = JSON.parse(await fs.readFile(path.join(rootDir, 'patches/gjc-sdk-lifecycle/manifest.json'), 'utf8')); +await applySdkLifecyclePatch(rootDir, sdkPatch, { checkOnly: true }); +const sdkLifecycle = { + id: sdkPatch.id, + packages: sdkPatch.packages, + files: sdkPatch.files.map(({ package: name, path: filename, afterSha256 }) => ({ package: name, path: filename, sha256: afterSha256 })), +}; const nativesRoot = await packageRoot(await nativesEntrypoint(), '@gajae-code/natives'); const currentPlatform = `${process.platform}-${process.arch}`; if (!SUPPORTED_PLATFORMS.has(currentPlatform)) { @@ -125,13 +134,16 @@ if (!SUPPORTED_PLATFORMS.has(currentPlatform)) { const actualCurrentClosure = await platformClosure(nativesRoot, currentPlatform); if (!update) { - if (JSON.stringify(manifest.platforms?.[currentPlatform]) !== JSON.stringify(actualCurrentClosure)) { + if (manifest.schemaVersion !== 2 || JSON.stringify(manifest.sdkLifecycle) !== JSON.stringify(sdkLifecycle) + || JSON.stringify(manifest.platforms?.[currentPlatform]) !== JSON.stringify(actualCurrentClosure)) { console.error(`GJC runtime manifest closure does not match ${currentPlatform}; run npm run fill:runtime-manifest -- --update.`); process.exitCode = 1; } else { console.log(`Verified GJC runtime manifest closure: ${currentPlatform}`); } } else { + manifest.schemaVersion = 2; + manifest.sdkLifecycle = sdkLifecycle; manifest.platforms ??= {}; manifest.platforms[currentPlatform] = actualCurrentClosure; diff --git a/scripts/release/SIGNING-READINESS.md b/scripts/release/SIGNING-READINESS.md index 192873e4..fc40d0ce 100644 --- a/scripts/release/SIGNING-READINESS.md +++ b/scripts/release/SIGNING-READINESS.md @@ -1,5 +1,10 @@ # Release signing readiness +Production updater-key local provisioning was verified on September 9, 2026; +independent backup remains pending. See [UPDATER-KEY-CUSTODY.md](UPDATER-KEY-CUSTODY.md) +before generating or replacing any key. This does not configure hosted secrets +or complete public-release acceptance. + Run from the reviewed release checkout with Node 22. These checks do not sign, submit, publish, dispatch workflows, export credentials or modify a keychain. The checker uses only Node built-ins, so it can run before `npm ci`. diff --git a/scripts/release/UPDATER-KEY-CUSTODY.md b/scripts/release/UPDATER-KEY-CUSTODY.md new file mode 100644 index 00000000..df36f40e --- /dev/null +++ b/scripts/release/UPDATER-KEY-CUSTODY.md @@ -0,0 +1,49 @@ +# Local production updater-key custody + +## Verified local setup — September 9, 2026 + +The operator generated a password-protected Tauri key through the official +interactive prompt and stored the same password through macOS Security's +interactive Keychain prompt. Neither password was supplied in chat or on argv. +No existing key or Keychain item was overwritten. + +- Private key: `/Users/devswha/.config/gajae-release/updater.key`. +- Public key: the adjacent `updater.key.pub`. +- The private directory is mode 700; both key files are mode 600. +- Exact login Keychain item: service `app.gajae.release.updater`, account + `production-v1`. Other credential items were not read. +- Public-key packet SHA-256: + `6f0054b3ce55917aeb1bc07e18b6c7d266298fe356a361ab94972fc821f1a4c5`. + +The stored, nonempty password successfully decrypted the key for an official +Tauri CLI 2.11.4 signature over a random non-release challenge. Password input +was scoped to that signer child's documented environment, not command arguments, +build/install processes or logs. The native updater dependency minisign-verify +0.2.5 accepted the prehashed signature, rejected changed challenge bytes and +rejected an unrelated public key. Both key files were unchanged. + +Local receipt: +`/Users/devswha/.config/gajae-release/verification-FoLuRJ/result.json`. +The private directory also contains the operator guide and bounded smoke helper. +The repository contains no private key, password or signing credential. + +This is **local key-provisioning proof**, not a published update, a production +app change, or final release acceptance. The release verifier must still use +official Minisign 0.12 as required by `LOCAL-RELEASE.md`. This key has not yet +been bound into a publicly distributed updater-enabled application. + +## Independent recovery backup remains pending + +The key and Keychain password currently reside on one Mac. A second folder on +that Mac is not an independent backup. No external destination was selected, +no external key copy was created, and no restore test was claimed. + +The operator must select encrypted external storage and retain the encrypted +private key, matching public key and recovery instructions there. The password +must also remain recoverable independently of this Mac, without storing it in +plaintext beside the key. Verify copied bytes and a restore/sign/verify test +before marking backup complete; never overwrite the original to test recovery. + +Local key provisioning is now ready. Independent backup and remaining +authorization/recovery/minimum-OS/public-release gates are still open. Do not +regenerate the production key merely because a new app version is being built. diff --git a/scripts/release/build-desktop-server-payload.mjs b/scripts/release/build-desktop-server-payload.mjs index 5e17c3eb..84bc796d 100644 --- a/scripts/release/build-desktop-server-payload.mjs +++ b/scripts/release/build-desktop-server-payload.mjs @@ -12,9 +12,8 @@ import { DESKTOP_NODE_VERSION, desktopPlatform } from './desktop-platforms.mjs'; import { pruneForeignPrebuilds } from './desktop-prebuilds.mjs'; import { smokeEnvironment } from './packaged-server-paths.mjs'; -const desktop = desktopPlatform(); +let desktop; const NODE_VERSION = DESKTOP_NODE_VERSION; -const NODE_ARCHIVE_SHA256 = desktop.nodeSha256; const BUN_VERSION = '1.4.0'; const NATIVE_MODULES = ['better-sqlite3', 'node-pty']; const RUNTIME_DEPENDENCIES = [ @@ -39,7 +38,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); const rootDir = path.resolve(__dirname, '..', '..'); const payloadDir = path.join(rootDir, 'src-tauri', 'resources', 'server-payload'); const sidecarDir = path.join(rootDir, 'src-tauri', 'binaries'); -const sidecarPath = path.join(sidecarDir, `gajae-app-server-${desktop.target}`); +let sidecarPath; function run(command, args, options = {}) { return new Promise((resolve, reject) => { @@ -81,13 +80,13 @@ async function sha256(filePath) { return crypto.createHash('sha256').update(await fs.readFile(filePath)).digest('hex'); } -async function copy(relativePath) { - await fs.cp(path.join(rootDir, relativePath), path.join(payloadDir, relativePath), { recursive: true }); +async function copy(relativePath, sourceRoot = rootDir, destination = payloadDir) { + await fs.cp(path.join(sourceRoot, relativePath), path.join(destination, relativePath), { recursive: true }); } -async function restrictRuntimeDependencies() { - const packagePath = path.join(payloadDir, 'package.json'); - const lockPath = path.join(payloadDir, 'package-lock.json'); +export async function restrictRuntimeDependencies(directory = payloadDir) { + const packagePath = path.join(directory, 'package.json'); + const lockPath = path.join(directory, 'package-lock.json'); const packageJson = JSON.parse(await fs.readFile(packagePath, 'utf8')); const packageLock = JSON.parse(await fs.readFile(lockPath, 'utf8')); const dependencies = {}; @@ -102,7 +101,39 @@ async function restrictRuntimeDependencies() { packageJson.dependencies = dependencies; delete packageJson.devDependencies; delete packageJson.optionalDependencies; - packageJson.scripts = {}; + // Keep exactly the reviewed install hooks; never stage dev/prepare hooks. + packageJson.scripts = Object.fromEntries(['postinstall', 'apply:sdk-patch', 'check:sdk-patch', 'apply:extract-zip-patch', 'check:extract-zip-patch'].map(name => { + const command = packageJson.scripts?.[name]; + if (typeof command !== 'string' || !command) throw new Error(`Missing required SDK installation script: ${name}`); + return [name, command]; + })); + await fs.writeFile(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`); +} + +export async function installDesktopPayloadDependencies(payloadNode, npmCli, npmEnvironment, directory = payloadDir, execute = run) { + await restrictRuntimeDependencies(directory); + await execute(payloadNode, [npmCli, 'install', '--package-lock-only', '--ignore-scripts', '--omit=dev'], { cwd: directory, env: npmEnvironment }); + await execute(payloadNode, [npmCli, 'ci', '--omit=dev'], { cwd: directory, env: npmEnvironment }); + // An ignored/failed postinstall must not silently produce an unpatched SDK. + // Resolve the CLI from its stage cwd (as npm does), including /var -> + // /private/var aliases on macOS; never invoke a checkout-relative fallback. + await execute(payloadNode, ['scripts/apply-sdk-lifecycle-patch.mjs', '--check'], { cwd: directory, env: npmEnvironment }); + await execute(payloadNode, ['scripts/apply-extract-zip-patch.mjs', '--check'], { cwd: directory, env: npmEnvironment }); + await execute(payloadNode, [npmCli, 'rebuild', '--omit=dev', '--build-from-source', ...NATIVE_MODULES], { cwd: directory, env: { ...npmEnvironment, npm_config_build_from_source: 'true' } }); + await execute(payloadNode, [path.join(directory, 'scripts', 'fix-node-pty.js')], { cwd: directory, env: npmEnvironment }); +} + +export async function finalizeDesktopPayloadMetadata(directory = payloadDir) { + await fs.rm(path.join(directory, 'package-lock.json'), { force: true }); + await fs.rm(path.join(directory, 'scripts', 'fix-node-pty.js'), { force: true }); + const packagePath = path.join(directory, 'package.json'); + const packageJson = JSON.parse(await fs.readFile(packagePath, 'utf8')); + // The immutable payload has no lockfile/reinstall entrypoint. Retain the + // verifier and evidence, not postinstall hooks pointing at removed tools. + packageJson.scripts = { + 'check:sdk-patch': packageJson.scripts['check:sdk-patch'], + 'check:extract-zip-patch': packageJson.scripts['check:extract-zip-patch'], + }; await fs.writeFile(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`); } @@ -131,7 +162,7 @@ async function downloadPinnedNode() { const chunks = []; for await (const chunk of response.body) chunks.push(chunk); await fs.writeFile(archive, Buffer.concat(chunks), { mode: 0o600 }); - if (await sha256(archive) !== NODE_ARCHIVE_SHA256) throw new Error('Pinned Node archive failed SHA-256 verification.'); + if (await sha256(archive) !== desktop.nodeSha256) throw new Error('Pinned Node archive failed SHA-256 verification.'); await run('tar', ['-xzf', archive, '-C', payloadDir]); } finally { await fs.rm(temporary, { recursive: true, force: true }); @@ -241,6 +272,8 @@ async function smoke(payloadNode) { await fs.mkdir(directory, { recursive: true }); } await fs.symlink(payloadNode, path.join(smokeHome, 'bin', 'node')); + await run(payloadNode, ['scripts/apply-sdk-lifecycle-patch.mjs', '--check'], { cwd: copyDir, env }); + await run(payloadNode, ['scripts/apply-extract-zip-patch.mjs', '--check'], { cwd: copyDir, env }); await run(payloadNode, ['--input-type=module', '--eval', smoke], { cwd: copyDir, env, @@ -251,52 +284,70 @@ async function smoke(payloadNode) { }, { filter: (source) => source !== buildOnlyNode && !source.startsWith(`${buildOnlyNode}${path.sep}`) }); } -await required(['dist', 'dist-server', 'shared', 'public', 'package.json', 'package-lock.json', 'server/gjc-runtime-manifest.json', 'scripts/fix-node-pty.js', 'dist-native/gajae-core', 'dist-native/bun', 'LICENSE', 'NOTICE', 'THIRD-PARTY-NOTICES.md']); -await fs.rm(payloadDir, { recursive: true, force: true }); -await fs.mkdir(payloadDir, { recursive: true }); -try { - // LICENSE and NOTICE ship with the payload for the same reason the server - // tarball carries them: MIT requires the licence text and copyright notice to - // travel with every copy, and NOTICE carries the origin attribution this - // project keeps voluntarily. The desktop bundle used to omit both while the - // tarball included them, so compliance depended on which artifact a user - // happened to install. - for (const input of ['dist', 'dist-server', 'shared', 'public', 'server/gjc-runtime-manifest.json', 'scripts/fix-node-pty.js', 'scripts/gajae-app-runtime.mjs', 'package.json', 'package-lock.json', 'dist-native', 'LICENSE', 'NOTICE', 'THIRD-PARTY-NOTICES.md']) await copy(input); - await downloadPinnedNode(); - const payloadNode = path.join(payloadDir, 'node', 'bin', 'node'); - if ((await capture(payloadNode, ['--version'])).trim() !== `v${NODE_VERSION}`) throw new Error('Pinned Node runtime version verification failed.'); - const payloadNodeBin = path.dirname(payloadNode); - const npmEnvironment = { ...process.env, PATH: `${payloadNodeBin}:/usr/bin:/bin`, npm_config_audit: 'false', npm_config_fund: 'false', npm_config_update_notifier: 'false' }; - const npmCli = path.join(payloadDir, 'node', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js'); - await restrictRuntimeDependencies(); - await run(payloadNode, [npmCli, 'install', '--package-lock-only', '--ignore-scripts', '--omit=dev'], { cwd: payloadDir, env: npmEnvironment }); - await run(payloadNode, [npmCli, 'ci', '--omit=dev'], { cwd: payloadDir, env: npmEnvironment }); - await run(payloadNode, [npmCli, 'rebuild', '--omit=dev', '--build-from-source', ...NATIVE_MODULES], { cwd: payloadDir, env: { ...npmEnvironment, npm_config_build_from_source: 'true' } }); - await run(payloadNode, [path.join(payloadDir, 'scripts', 'fix-node-pty.js')], { cwd: payloadDir, env: npmEnvironment }); - await verifyManifest(); - // Nothing upstream is patched: the packages install normally and are deleted - // from this payload (a first-party stub takes the place of any that is - // imported at module scope), so a runtime bump re-applies the decision - // without anyone remembering to. `npm run check:licenses` is what notices - // when the tree grows a new one. - console.log(describeDistributionExclusions(await removeExcludedDistributionPackages(fs, path, path.join(payloadDir, 'node_modules')))); - if (process.platform === 'linux') { - const foreignPrebuilds = await pruneForeignPrebuilds(path.join(payloadDir, 'node_modules')); - console.log(`Removed ${foreignPrebuilds.length} incompatible native directories from the Linux payload.`); +export const DESKTOP_PAYLOAD_INPUTS = [ + 'dist', 'dist-server', 'shared', 'public', 'server/gjc-runtime-manifest.json', + 'scripts/fix-node-pty.js', 'scripts/gajae-app-runtime.mjs', 'scripts/apply-sdk-lifecycle-patch.mjs', + 'patches/gjc-sdk-lifecycle/manifest.json', + 'scripts/apply-extract-zip-patch.mjs', 'patches/extract-zip-symlink-leaf/manifest.json', + 'package.json', 'package-lock.json', 'dist-native', 'LICENSE', 'NOTICE', 'THIRD-PARTY-NOTICES.md', +]; + +export async function stageDesktopPayloadFiles(sourceRoot = rootDir, directory = payloadDir) { + for (const input of DESKTOP_PAYLOAD_INPUTS) await copy(input, sourceRoot, directory); + for (const readme of ['patches/gjc-sdk-lifecycle/README.md', 'patches/extract-zip-symlink-leaf/README.md']) { + if (await exists(path.join(sourceRoot, readme))) await copy(readme, sourceRoot, directory); } - const prunedMetadataFiles = await pruneNonRuntimeMetadata(path.join(payloadDir, 'node_modules')) - + await pruneNonRuntimeMetadata(path.join(payloadDir, 'dist-server')); - const nonAsciiRemoved = process.platform === 'darwin' ? await removeNonAsciiPaths(fs, path, payloadDir) : []; - if (nonAsciiRemoved.length) console.log(`Removed non-ASCII bin links that would break the code signature on copy: ${nonAsciiRemoved.join(', ')}`); - await codesignNativeClosure(payloadDir); - await stageSidecar(payloadNode); - await fs.rm(path.join(payloadDir, 'package-lock.json'), { force: true }); - await fs.rm(path.join(payloadDir, 'scripts', 'fix-node-pty.js'), { force: true }); - await smoke(sidecarPath); - await fs.rm(path.join(payloadDir, 'node'), { recursive: true, force: true }); - console.log(`Built and verified ${desktop.label} server payload at ${path.relative(rootDir, payloadDir)}; pruned ${prunedMetadataFiles} non-runtime metadata files.`); -} catch (error) { +} + +export async function buildDesktopServerPayload() { + desktop = desktopPlatform(); + sidecarPath = path.join(sidecarDir, `gajae-app-server-${desktop.target}`); + await required([...DESKTOP_PAYLOAD_INPUTS, 'dist-native/gajae-core', 'dist-native/bun']); await fs.rm(payloadDir, { recursive: true, force: true }); - await fs.rm(sidecarPath, { force: true }); - throw error; + await fs.mkdir(payloadDir, { recursive: true }); + try { + // LICENSE and NOTICE ship with the payload for the same reason the server + // tarball carries them: MIT requires the licence text and copyright notice to + // travel with every copy, and NOTICE carries the origin attribution this + // project keeps voluntarily. The desktop bundle used to omit both while the + // tarball included them, so compliance depended on which artifact a user + // happened to install. + await stageDesktopPayloadFiles(); + await downloadPinnedNode(); + const payloadNode = path.join(payloadDir, 'node', 'bin', 'node'); + if ((await capture(payloadNode, ['--version'])).trim() !== `v${NODE_VERSION}`) throw new Error('Pinned Node runtime version verification failed.'); + const payloadNodeBin = path.dirname(payloadNode); + const npmEnvironment = { ...process.env, PATH: `${payloadNodeBin}:/usr/bin:/bin`, npm_config_audit: 'false', npm_config_fund: 'false', npm_config_update_notifier: 'false' }; + const npmCli = path.join(payloadDir, 'node', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js'); + await installDesktopPayloadDependencies(payloadNode, npmCli, npmEnvironment); + await verifyManifest(); + // Distribution exclusions remain a separate policy from the hash-checked + // SDK lifecycle patch. Excluded code is deleted (or replaced by a first-party + // stub); the SDK patch does not change this policy or its license gate. + console.log(describeDistributionExclusions(await removeExcludedDistributionPackages(fs, path, path.join(payloadDir, 'node_modules')))); + if (process.platform === 'linux') { + const foreignPrebuilds = await pruneForeignPrebuilds(path.join(payloadDir, 'node_modules')); + console.log(`Removed ${foreignPrebuilds.length} incompatible native directories from the Linux payload.`); + } + const prunedMetadataFiles = await pruneNonRuntimeMetadata(path.join(payloadDir, 'node_modules')) + + await pruneNonRuntimeMetadata(path.join(payloadDir, 'dist-server')); + const nonAsciiRemoved = process.platform === 'darwin' ? await removeNonAsciiPaths(fs, path, payloadDir) : []; + if (nonAsciiRemoved.length) console.log(`Removed non-ASCII bin links that would break the code signature on copy: ${nonAsciiRemoved.join(', ')}`); + await codesignNativeClosure(payloadDir); + await stageSidecar(payloadNode); + await finalizeDesktopPayloadMetadata(); + await smoke(sidecarPath); + await fs.rm(path.join(payloadDir, 'node'), { recursive: true, force: true }); + console.log(`Built and verified ${desktop.label} server payload at ${path.relative(rootDir, payloadDir)}; pruned ${prunedMetadataFiles} non-runtime metadata files.`); + } catch (error) { + await fs.rm(payloadDir, { recursive: true, force: true }); + await fs.rm(sidecarPath, { force: true }); + throw error; + } } + +// Platform wrappers import this module as their executable implementation. +// Imports from tests expose staging helpers without downloading/building. +const entry = process.argv[1] ? await fs.realpath(process.argv[1]).catch(() => null) : null; +if (['build-desktop-server-payload.mjs', 'build-linux-server-payload.mjs', 'build-macos-server-payload.mjs'] + .some(name => entry === path.join(__dirname, name))) await buildDesktopServerPayload(); diff --git a/scripts/release/build-desktop-server-payload.test.mjs b/scripts/release/build-desktop-server-payload.test.mjs index 76cfb663..0c3a57ed 100644 --- a/scripts/release/build-desktop-server-payload.test.mjs +++ b/scripts/release/build-desktop-server-payload.test.mjs @@ -1,21 +1,24 @@ import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; -import { copyFile, mkdir, mkdtemp, readdir, rm, stat, writeFile } from 'node:fs/promises'; +import { copyFile, mkdir, mkdtemp, readFile, readdir, rm, stat, symlink, writeFile } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { test } from 'node:test'; +import { DESKTOP_PAYLOAD_INPUTS, finalizeDesktopPayloadMetadata, installDesktopPayloadDependencies, restrictRuntimeDependencies, stageDesktopPayloadFiles } from './build-desktop-server-payload.mjs'; +import { assertOutOfTree } from './out-of-tree.mjs'; + test('a rejected Node archive cleans the download, incomplete payload and sidecar on Linux and Mac', async t => { for (const [platform, arch, target] of [['linux', 'x64', 'x86_64-unknown-linux-gnu'], ['darwin', 'arm64', 'aarch64-apple-darwin']]) { await t.test(platform, async t => { const root = await mkdtemp(path.join(os.tmpdir(), 'gajae-payload-cleanup-')); t.after(() => rm(root, { recursive: true, force: true })); await mkdir(path.join(root, 'scripts/release'), { recursive: true }); - for (const file of ['build-desktop-server-payload.mjs', 'desktop-platforms.mjs', 'desktop-prebuilds.mjs', 'distribution-exclusions.mjs', 'out-of-tree.mjs', 'packaged-server-paths.mjs']) { + for (const file of ['build-desktop-server-payload.mjs', 'build-linux-server-payload.mjs', 'build-macos-server-payload.mjs', 'desktop-platforms.mjs', 'desktop-prebuilds.mjs', 'distribution-exclusions.mjs', 'out-of-tree.mjs', 'packaged-server-paths.mjs']) { await copyFile(new URL(file, import.meta.url), path.join(root, 'scripts/release', file)); } for (const directory of ['dist', 'dist-server', 'shared', 'public', 'tmp', 'src-tauri/binaries']) await mkdir(path.join(root, directory), { recursive: true }); - for (const file of ['package.json', 'package-lock.json', 'server/gjc-runtime-manifest.json', 'scripts/fix-node-pty.js', 'scripts/gajae-app-runtime.mjs', 'dist-native/bun', 'dist-native/gajae-core', 'LICENSE', 'NOTICE', 'THIRD-PARTY-NOTICES.md']) { + for (const file of ['package.json', 'package-lock.json', 'server/gjc-runtime-manifest.json', 'scripts/fix-node-pty.js', 'scripts/gajae-app-runtime.mjs', 'scripts/apply-sdk-lifecycle-patch.mjs', 'patches/gjc-sdk-lifecycle/manifest.json', 'scripts/apply-extract-zip-patch.mjs', 'patches/extract-zip-symlink-leaf/manifest.json', 'dist-native/bun', 'dist-native/gajae-core', 'LICENSE', 'NOTICE', 'THIRD-PARTY-NOTICES.md']) { await mkdir(path.dirname(path.join(root, file)), { recursive: true }); await writeFile(path.join(root, file), '{}'); } @@ -25,6 +28,7 @@ test('a rejected Node archive cleans the download, incomplete payload and sideca await writeFile(preload, ` Object.defineProperty(process, 'platform', { value: process.env.TEST_PLATFORM }); Object.defineProperty(process, 'arch', { value: process.env.TEST_ARCH }); + process.report.getReport = () => ({ header: { glibcVersionRuntime: '2.35' } }); global.fetch = async () => ({ ok: true, body: [Buffer.from('corrupted archive')] }); `); const result = spawnSync(process.execPath, ['--require', preload, path.join(root, 'scripts/release/build-desktop-server-payload.mjs')], { @@ -35,6 +39,120 @@ test('a rejected Node archive cleans the download, incomplete payload and sideca await assert.rejects(stat(path.join(root, 'src-tauri/resources/server-payload')), { code: 'ENOENT' }); await assert.rejects(stat(sidecar), { code: 'ENOENT' }); assert.deepEqual(await readdir(path.join(root, 'tmp')), []); + const wrapper = path.join(root, 'scripts/release', platform === 'linux' ? 'build-linux-server-payload.mjs' : 'build-macos-server-payload.mjs'); + const linked = path.join(root, 'payload-entry.mjs'); + await symlink(wrapper, linked); + for (const entry of [wrapper, linked]) { + const invoked = spawnSync(process.execPath, ['--require', preload, entry], { + env: { ...process.env, TMPDIR: path.join(root, 'tmp'), TEST_PLATFORM: platform, TEST_ARCH: arch }, encoding: 'utf8', + }); + assert.notEqual(invoked.status, 0, 'wrapper must execute its payload builder'); + assert.match(invoked.stderr, /Pinned Node archive failed SHA-256 verification/); + await assert.rejects(stat(path.join(root, 'src-tauri/resources/server-payload')), { code: 'ENOENT' }); + } }); } }); + +async function fixture(t, { readme = true } = {}) { + const root = await mkdtemp(path.join(os.tmpdir(), 'gajae-payload-install-contract-')); + t.after(() => rm(root, { recursive: true, force: true })); + const source = path.join(root, 'source'); const stage = path.join(root, 'stage'); + await mkdir(stage, { recursive: true }); + for (const input of DESKTOP_PAYLOAD_INPUTS) { + const filename = path.join(source, input); + if (['dist', 'dist-server', 'shared', 'public', 'dist-native'].includes(input)) await mkdir(filename, { recursive: true }); + else { await mkdir(path.dirname(filename), { recursive: true }); await writeFile(filename, '{}'); } + } + const packageJson = JSON.parse(await readFile(new URL('../../package.json', import.meta.url), 'utf8')); + await writeFile(path.join(source, 'package.json'), JSON.stringify(packageJson)); + await copyFile(new URL('../../package-lock.json', import.meta.url), path.join(source, 'package-lock.json')); + const patch = 'patches/gjc-sdk-lifecycle'; + const manifest = await readFile(new URL(`../../${patch}/manifest.json`, import.meta.url)); + const applier = await readFile(new URL('../apply-sdk-lifecycle-patch.mjs', import.meta.url)); + await writeFile(path.join(source, patch, 'manifest.json'), manifest); + await writeFile(path.join(source, 'scripts/apply-sdk-lifecycle-patch.mjs'), applier); + for (const input of ['scripts/apply-extract-zip-patch.mjs', 'patches/extract-zip-symlink-leaf/manifest.json']) { + await copyFile(new URL(`../../${input}`, import.meta.url), path.join(source, input)); + } + await writeFile(path.join(source, patch, 'lifecycle.bun.test.ts'), 'must not ship'); + await writeFile(path.join(source, patch, 'manifest.test.mjs'), 'must not ship'); + if (readme) await writeFile(path.join(source, patch, 'README.md'), 'app-owned patch evidence'); + await assertOutOfTree(stage, 'fixture stage'); + return { source, stage, patch, manifest, applier, packageJson }; +} + +test('desktop stage copies only canonical patch inputs, retaining evidence without patch tests', async t => { + for (const readme of [true, false]) { + const f = await fixture(t, { readme }); + await stageDesktopPayloadFiles(f.source, f.stage); + assert.deepEqual(await readFile(path.join(f.stage, f.patch, 'manifest.json')), f.manifest); + assert.deepEqual(await readFile(path.join(f.stage, 'scripts/apply-sdk-lifecycle-patch.mjs')), f.applier); + assert.deepEqual((await readdir(path.join(f.stage, f.patch))).sort(), readme ? ['README.md', 'manifest.json'] : ['manifest.json']); + await restrictRuntimeDependencies(f.stage); + const install = JSON.parse(await readFile(path.join(f.stage, 'package.json'), 'utf8')); + assert.deepEqual(install.scripts, Object.fromEntries(['postinstall', 'apply:sdk-patch', 'check:sdk-patch', 'apply:extract-zip-patch', 'check:extract-zip-patch'].map(name => [name, f.packageJson.scripts[name]]))); + assert.equal(install.dependencies['@gajae-code/coding-agent'], f.packageJson.dependencies['@gajae-code/coding-agent']); + assert.equal(install.devDependencies, undefined); + assert.equal(install.optionalDependencies, undefined); + assert.equal(install.scripts.prepare, undefined); + await finalizeDesktopPayloadMetadata(f.stage); + const runtime = JSON.parse(await readFile(path.join(f.stage, 'package.json'), 'utf8')); + assert.deepEqual(runtime.scripts, { 'check:sdk-patch': f.packageJson.scripts['check:sdk-patch'], 'check:extract-zip-patch': f.packageJson.scripts['check:extract-zip-patch'] }); + for (const input of ['scripts/apply-extract-zip-patch.mjs', 'patches/extract-zip-symlink-leaf/manifest.json']) { + assert.deepEqual(await readFile(path.join(f.stage, input)), await readFile(new URL(`../../${input}`, import.meta.url))); + } + await assert.rejects(stat(path.join(f.stage, 'scripts/fix-node-pty.js')), { code: 'ENOENT' }); + assert.deepEqual(await readFile(path.join(f.stage, f.patch, 'manifest.json')), f.manifest); + } +}); + +test('desktop npm ci uses the source postinstall then verifies before rebuilding the pinned native modules', async t => { + const f = await fixture(t); await stageDesktopPayloadFiles(f.source, f.stage); + const calls = []; const env = { PATH: '/fixture/pinned-node/bin:/usr/bin:/bin' }; + const node = '/fixture/pinned-node/bin/node'; const npm = '/fixture/pinned-node/lib/node_modules/npm/bin/npm-cli.js'; + await installDesktopPayloadDependencies(node, npm, env, f.stage, async (command, args, options) => { + calls.push({ command, args, options }); + if (args.includes('ci')) { + const staged = JSON.parse(await readFile(path.join(options.cwd, 'package.json'), 'utf8')); + assert.equal(staged.scripts.postinstall, f.packageJson.scripts.postinstall); + assert.equal(args.includes('--ignore-scripts'), false); + } + }); + assert.ok(calls.every(call => call.command === node && call.options.cwd === f.stage)); + assert.deepEqual(calls.map(call => call.args), [ + [npm, 'install', '--package-lock-only', '--ignore-scripts', '--omit=dev'], + [npm, 'ci', '--omit=dev'], + ['scripts/apply-sdk-lifecycle-patch.mjs', '--check'], + ['scripts/apply-extract-zip-patch.mjs', '--check'], + [npm, 'rebuild', '--omit=dev', '--build-from-source', 'better-sqlite3', 'node-pty'], + [path.join(f.stage, 'scripts/fix-node-pty.js')], + ]); + assert.equal(calls[4].options.env.npm_config_build_from_source, 'true'); + await assert.rejects(stat(path.join(f.stage, 'node_modules')), { code: 'ENOENT' }); +}); + +test('unverified ZIP patch stops desktop staging before native rebuilding', async t => { + const f = await fixture(t); await stageDesktopPayloadFiles(f.source, f.stage); + const calls = []; + await assert.rejects(installDesktopPayloadDependencies('/fixture/node', '/fixture/npm', {}, f.stage, async (_command, args) => { + calls.push(args); + if (args[0] === 'scripts/apply-extract-zip-patch.mjs') throw new Error('ZIP patch missing'); + }), /ZIP patch missing/); + assert.equal(calls.length, 4); + assert.equal(calls.some(args => args.includes('rebuild')), false); + await rm(path.join(f.source, 'patches/extract-zip-symlink-leaf/manifest.json')); + await assert.rejects(stageDesktopPayloadFiles(f.source, f.stage), { code: 'ENOENT' }); +}); + +test('an unapplied desktop SDK stops before native rebuilding instead of applying outside postinstall', async t => { + const f = await fixture(t); await stageDesktopPayloadFiles(f.source, f.stage); + const calls = []; + await assert.rejects(installDesktopPayloadDependencies('/fixture/node', '/fixture/npm', {}, f.stage, async (_command, args) => { + calls.push(args); + if (args.includes('--check')) throw new Error('SDK lifecycle patch has not been applied.'); + }), /has not been applied/); + assert.equal(calls.length, 3); + assert.equal(calls.some(args => args.includes('rebuild')), false); + await assert.rejects(stat(path.join(f.stage, 'node_modules')), { code: 'ENOENT' }); +}); diff --git a/scripts/release/build-server-bundle.js b/scripts/release/build-server-bundle.js index 1363837c..a2f86aa7 100644 --- a/scripts/release/build-server-bundle.js +++ b/scripts/release/build-server-bundle.js @@ -241,9 +241,9 @@ async function validateRequiredInputs(relativePaths) { } } -async function stageRequiredInput(stageDir, relativePath) { +async function stageRequiredInput(stageDir, relativePath, sourceRoot = rootDir) { await fs.cp( - path.join(rootDir, relativePath), + path.join(sourceRoot, relativePath), path.join(stageDir, relativePath), { recursive: true }, ); @@ -268,10 +268,14 @@ async function pruneSourceMaps(directory) { return removed; } -async function writeInstallPackageJson(stageDir, packageJson) { +export async function writeInstallPackageJson(stageDir, packageJson) { const installManifest = { ...packageJson, - scripts: {}, + scripts: Object.fromEntries(['postinstall', 'apply:sdk-patch', 'check:sdk-patch', 'apply:extract-zip-patch', 'check:extract-zip-patch'].map(name => { + const command = packageJson.scripts?.[name]; + if (typeof command !== 'string' || !command) throw new Error(`Missing required SDK installation script: ${name}`); + return [name, command]; + })), }; const manifestPath = path.join(stageDir, 'package.json'); await fs.writeFile( @@ -281,7 +285,7 @@ async function writeInstallPackageJson(stageDir, packageJson) { ); } -async function writeRuntimePackageJson(stageDir, packageJson) { +export async function writeRuntimePackageJson(stageDir, packageJson) { const runtimePackageJson = { name: SERVER_PACKAGE_NAME, version: packageJson.version, @@ -297,6 +301,8 @@ async function writeRuntimePackageJson(stageDir, packageJson) { }, scripts: { start: 'node scripts/gajae-app-runtime.mjs start', + 'check:sdk-patch': packageJson.scripts['check:sdk-patch'], + 'check:extract-zip-patch': packageJson.scripts['check:extract-zip-patch'], }, dependencies: packageJson.dependencies, license: packageJson.license, @@ -453,6 +459,8 @@ async function smokeNativeRuntime(stageDir) { const qaHome = path.join(copyDir, '.smoke-home'); await fs.mkdir(qaHome, { recursive: true }); const env = isolatedQaEnvironment({ parentEnv: process.env, qaHome, host: '127.0.0.1', serverPort: 3001, vitePort: 5173, remote: false }); + await execute(process.execPath, ['scripts/apply-sdk-lifecycle-patch.mjs', '--check'], { cwd: copyDir, env }); + await execute(process.execPath, ['scripts/apply-extract-zip-patch.mjs', '--check'], { cwd: copyDir, env }); await execute(process.execPath, ['--input-type=module', '--eval', smokeSource], { cwd: copyDir, env }); }); } @@ -479,7 +487,7 @@ async function createDeterministicArchive(stageDir, archivePath, epoch) { await execute('gzip', ['--no-name', '--force', tarPath]); } -const SERVER_BUNDLE_INPUTS = [ +export const SERVER_BUNDLE_INPUTS = [ 'dist', 'dist-server', 'dist-native', @@ -488,6 +496,10 @@ const SERVER_BUNDLE_INPUTS = [ 'package-lock.json', 'scripts/fix-node-pty.js', 'scripts/gajae-app-runtime.mjs', + 'scripts/apply-sdk-lifecycle-patch.mjs', + 'patches/gjc-sdk-lifecycle/manifest.json', + 'scripts/apply-extract-zip-patch.mjs', + 'patches/extract-zip-symlink-leaf/manifest.json', 'packaging/systemd/gajae-app.service', 'docs/SELF-HOST.md', 'docs/INSTALL.md', @@ -534,22 +546,27 @@ async function prepareBundleStage(locations) { await fs.mkdir(locations.stageDir, { recursive: true }); } -async function stageBundleFiles(stageDir, packageJson) { +export async function stageBundleFiles(stageDir, packageJson, sourceRoot = rootDir) { for (const relativePath of SERVER_BUNDLE_INPUTS) { - await stageRequiredInput(stageDir, relativePath); + await stageRequiredInput(stageDir, relativePath, sourceRoot); + } + for (const readme of ['patches/gjc-sdk-lifecycle/README.md', 'patches/extract-zip-symlink-leaf/README.md']) { + if (await canAccess(path.join(sourceRoot, readme))) await stageRequiredInput(stageDir, readme, sourceRoot); } const prunedSourceMaps = await pruneSourceMaps(path.join(stageDir, 'dist-server')); console.log(`Pruned ${prunedSourceMaps} source map files from dist-server.`); await writeInstallPackageJson(stageDir, packageJson); } -async function installStageDependencies(stageDir) { +export async function installStageDependencies(stageDir, { run = execute, verifyVersions = assertInstalledGjcSdkDependencies } = {}) { console.log('Installing production server dependencies into bundle stage...'); - await execute('npm', ['ci', '--omit=dev'], { + await run('npm', ['ci', '--omit=dev'], { cwd: stageDir, env: npmEnvironment(), }); - await assertInstalledGjcSdkDependencies(stageDir); + await run(process.execPath, ['scripts/apply-sdk-lifecycle-patch.mjs', '--check'], { cwd: stageDir, env: npmEnvironment() }); + await run(process.execPath, ['scripts/apply-extract-zip-patch.mjs', '--check'], { cwd: stageDir, env: npmEnvironment() }); + await verifyVersions(stageDir); } async function excludeDistributionPackages(stageDir) { @@ -638,4 +655,4 @@ async function buildServerBundle() { await reportBundleOutput(locations); } -await buildServerBundle(); +if (process.argv[1] && await fs.realpath(process.argv[1]).catch(() => null) === fileURLToPath(import.meta.url)) await buildServerBundle(); diff --git a/scripts/release/build-server-bundle.test.mjs b/scripts/release/build-server-bundle.test.mjs new file mode 100644 index 00000000..1c334af9 --- /dev/null +++ b/scripts/release/build-server-bundle.test.mjs @@ -0,0 +1,198 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { copyFile, mkdir, mkdtemp, readFile, readdir, realpath, rm, stat, symlink, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +import { installStageDependencies, SERVER_BUNDLE_INPUTS, stageBundleFiles, writeInstallPackageJson, writeRuntimePackageJson } from './build-server-bundle.js'; +import { assertOutOfTree } from './out-of-tree.mjs'; + +const repository = fileURLToPath(new URL('../../', import.meta.url)); +const sourcePackage = JSON.parse(await readFile(path.join(repository, 'package.json'), 'utf8')); +const patchDirectory = 'patches/gjc-sdk-lifecycle'; + +async function fixture(t, { readme = true } = {}) { + const root = await realpath(await mkdtemp(path.join(os.tmpdir(), 'gajae-server-patch-stage-'))); + t.after(() => rm(root, { recursive: true, force: true })); + const source = path.join(root, 'source'); const stage = path.join(root, 'stage'); + await mkdir(stage, { recursive: true }); + for (const relative of SERVER_BUNDLE_INPUTS) { + const filename = path.join(source, relative); + if (['dist', 'dist-server', 'dist-native', 'public', 'shared'].includes(relative)) await mkdir(filename, { recursive: true }); + else { await mkdir(path.dirname(filename), { recursive: true }); await writeFile(filename, '{}'); } + } + const manifest = await readFile(path.join(repository, patchDirectory, 'manifest.json')); + const applier = await readFile(path.join(repository, 'scripts/apply-sdk-lifecycle-patch.mjs')); + await writeFile(path.join(source, patchDirectory, 'manifest.json'), manifest); + await writeFile(path.join(source, 'scripts/apply-sdk-lifecycle-patch.mjs'), applier); + for (const input of ['scripts/apply-extract-zip-patch.mjs', 'patches/extract-zip-symlink-leaf/manifest.json']) { + await copyFile(path.join(repository, input), path.join(source, input)); + } + await copyFile(path.join(repository, 'shared/sdkLifecyclePolicy.json'), path.join(source, 'shared/sdkLifecyclePolicy.json')); + await copyFile(path.join(repository, 'scripts/fix-node-pty.js'), path.join(source, 'scripts/fix-node-pty.js')); + await writeFile(path.join(source, patchDirectory, 'manifest.test.mjs'), 'must not ship'); + await writeFile(path.join(source, patchDirectory, 'lifecycle.bun.test.ts'), 'must not ship'); + await writeFile(path.join(source, 'dist-server', 'index.js.map'), '{}'); + if (readme) await writeFile(path.join(source, patchDirectory, 'README.md'), 'app-owned patch evidence'); + await assertOutOfTree(stage, 'fixture stage'); + return { source, stage, manifest, applier }; +} + +test('root npm ci preserves node-pty repair and runs the canonical SDK applier without ancestry lookup', () => { + assert.equal(sourcePackage.scripts.postinstall, 'node scripts/fix-node-pty.js && node scripts/apply-sdk-lifecycle-patch.mjs && node scripts/apply-extract-zip-patch.mjs'); + assert.equal(sourcePackage.scripts['apply:sdk-patch'], 'node scripts/apply-sdk-lifecycle-patch.mjs'); + assert.equal(sourcePackage.scripts['check:sdk-patch'], 'node scripts/apply-sdk-lifecycle-patch.mjs --check'); + assert.ok(sourcePackage.files.includes(`${patchDirectory}/manifest.json`)); + assert.ok(sourcePackage.files.includes(`${patchDirectory}/README.md`)); + assert.deepEqual(sourcePackage.files.filter(input => input.startsWith('patches/')).sort(), [ + 'patches/extract-zip-symlink-leaf/README.md', 'patches/extract-zip-symlink-leaf/manifest.json', + `${patchDirectory}/README.md`, `${patchDirectory}/manifest.json`, + ]); +}); + +test('verify, npm test, direct e2e scripts and server builds reject an unapplied SDK before work', () => { + for (const name of ['verify', 'pretest', 'test:e2e:gjc', 'test:e2e:browser', 'prebuild:server']) { + assert.ok(sourcePackage.scripts[name].startsWith('npm run check:sdk-patch && '), name); + assert.ok(sourcePackage.scripts[name].includes('npm run check:extract-zip-patch && '), name); + assert.equal(sourcePackage.scripts[name].includes('npm run apply:extract-zip-patch'), false); + assert.equal(sourcePackage.scripts[name].includes('npm run apply:sdk-patch'), false, `${name} must verify, not modify installed dependencies`); + } + for (const gate of ['audit', 'check:licenses', 'check:notices', 'typecheck', 'check:core', 'lint', 'check:identity', 'build']) { + assert.ok(sourcePackage.scripts.verify.includes(`npm run ${gate}`), gate); + } + assert.ok(sourcePackage.scripts.verify.includes('npm test')); +}); + +test('server stage ships the exact patch manifest/applier and optional README, never patch tests', async t => { + for (const readme of [true, false]) { + const f = await fixture(t, { readme }); + await stageBundleFiles(f.stage, sourcePackage, f.source); + assert.deepEqual(await readFile(path.join(f.stage, patchDirectory, 'manifest.json')), f.manifest); + assert.deepEqual(await readFile(path.join(f.stage, 'scripts/apply-sdk-lifecycle-patch.mjs')), f.applier); + assert.deepEqual(await readFile(path.join(f.stage, 'shared/sdkLifecyclePolicy.json')), await readFile(path.join(repository, 'shared/sdkLifecyclePolicy.json'))); + assert.deepEqual((await readdir(path.join(f.stage, patchDirectory))).sort(), readme ? ['README.md', 'manifest.json'] : ['manifest.json']); + await assert.rejects(stat(path.join(f.stage, 'dist-server/index.js.map')), { code: 'ENOENT' }); + const install = JSON.parse(await readFile(path.join(f.stage, 'package.json'), 'utf8')); + assert.deepEqual(install.scripts, Object.fromEntries(['postinstall', 'apply:sdk-patch', 'check:sdk-patch', 'apply:extract-zip-patch', 'check:extract-zip-patch'].map(name => [name, sourcePackage.scripts[name]]))); + assert.equal(install.scripts.prepare, undefined); + assert.deepEqual(install.dependencies, sourcePackage.dependencies); + await writeRuntimePackageJson(f.stage, sourcePackage); + const runtime = JSON.parse(await readFile(path.join(f.stage, 'package.json'), 'utf8')); + assert.deepEqual(runtime.scripts, { start: 'node scripts/gajae-app-runtime.mjs start', 'check:sdk-patch': sourcePackage.scripts['check:sdk-patch'], 'check:extract-zip-patch': sourcePackage.scripts['check:extract-zip-patch'] }); + for (const input of ['scripts/apply-extract-zip-patch.mjs', 'patches/extract-zip-symlink-leaf/manifest.json']) { + assert.deepEqual(await readFile(path.join(f.stage, input)), await readFile(path.join(repository, input))); + } + assert.equal(runtime.engines.node, '>=22.22.2 <23'); + assert.deepEqual(await readFile(path.join(f.stage, patchDirectory, 'manifest.json')), f.manifest); + } +}); + +test('server npm ci retains the root hook and runs check-only before accepting runtime versions', async t => { + const f = await fixture(t); await stageBundleFiles(f.stage, sourcePackage, f.source); + const calls = []; + await installStageDependencies(f.stage, { + run: async (command, args, options) => { + calls.push({ command, args, cwd: options.cwd }); + if (command === 'npm') { + const staged = JSON.parse(await readFile(path.join(options.cwd, 'package.json'), 'utf8')); + assert.equal(staged.scripts.postinstall, sourcePackage.scripts.postinstall); + } + }, + verifyVersions: async directory => { calls.push({ versions: directory }); }, + }); + assert.deepEqual(calls, [ + { command: 'npm', args: ['ci', '--omit=dev'], cwd: f.stage }, + { command: process.execPath, args: ['scripts/apply-sdk-lifecycle-patch.mjs', '--check'], cwd: f.stage }, + { command: process.execPath, args: ['scripts/apply-extract-zip-patch.mjs', '--check'], cwd: f.stage }, + { versions: f.stage }, + ]); + await assert.rejects(stat(path.join(f.stage, 'node_modules')), { code: 'ENOENT' }); +}); + +test('an unapplied or hash-invalid server SDK aborts staging instead of being accepted or repatched', async t => { + const f = await fixture(t); await stageBundleFiles(f.stage, sourcePackage, f.source); + for (const reason of ['SDK lifecycle patch has not been applied.', 'SDK lifecycle source digest mismatch.']) { + const calls = []; + await assert.rejects(installStageDependencies(f.stage, { + run: async (_command, args) => { calls.push(args); if (args.includes('--check')) throw new Error(reason); }, + verifyVersions: async () => assert.fail('patch verification must succeed first'), + }), error => error.message === reason); + assert.deepEqual(calls, [['ci', '--omit=dev'], ['scripts/apply-sdk-lifecycle-patch.mjs', '--check']]); + } + await assert.rejects(stat(path.join(f.stage, 'node_modules')), { code: 'ENOENT' }); +}); + +test('missing install hooks or patch evidence fail closed in a fresh stage', async t => { + const f = await fixture(t); + await assert.rejects(writeInstallPackageJson(f.stage, { ...sourcePackage, scripts: {} }), /Missing required SDK installation script/); + await rm(path.join(f.source, patchDirectory, 'manifest.json')); + await assert.rejects(stageBundleFiles(f.stage, sourcePackage, f.source), { code: 'ENOENT' }); +}); + +test('a staged checker cannot use the checkout SDK, even when invoked from the checkout', async t => { + const f = await fixture(t); await stageBundleFiles(f.stage, sourcePackage, f.source); + // No dependency tree is created or modified. The copied checker must fail + // against the stage's missing SDK, not resolve the checkout through cwd. + const result = spawnSync(process.execPath, [path.join(f.stage, 'scripts/apply-sdk-lifecycle-patch.mjs'), '--check'], { + cwd: repository, encoding: 'utf8', env: { ...process.env, NODE_PATH: path.join(repository, 'node_modules') }, + }); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /ENOENT/); + assert.ok(result.stderr.includes(path.join(f.stage, 'node_modules'))); + await assert.rejects(stat(path.join(f.stage, 'node_modules')), { code: 'ENOENT' }); +}); + +test('the npm-style stage-relative checker executes from a fresh symlinked install cwd', async t => { + const f = await fixture(t); await stageBundleFiles(f.stage, sourcePackage, f.source); + const alias = path.join(f.source, 'install-alias'); + await symlink(f.stage, alias, process.platform === 'win32' ? 'junction' : 'dir'); + const result = spawnSync(process.execPath, ['scripts/apply-sdk-lifecycle-patch.mjs', '--check'], { + cwd: alias, encoding: 'utf8', env: { ...process.env, NODE_PATH: path.join(repository, 'node_modules') }, + }); + assert.notEqual(result.status, 0, 'the stage-relative main guard must run, not silently succeed'); + assert.match(result.stderr, /ENOENT/); + assert.ok(result.stderr.includes(path.join(f.stage, 'node_modules'))); + await assert.rejects(stat(path.join(f.stage, 'node_modules')), { code: 'ENOENT' }); +}); + +test('both packaging smokes verify the staged patch out of tree without weakening existing native and exclusion checks', async () => { + const desktop = await readFile(new URL('./build-desktop-server-payload.mjs', import.meta.url), 'utf8'); + const server = await readFile(new URL('./build-server-bundle.js', import.meta.url), 'utf8'); + assert.match(desktop, /withOutOfTreeCopy\(payloadDir[\s\S]*?\['scripts\/apply-sdk-lifecycle-patch\.mjs', '--check'\], \{ cwd: copyDir, env \}/); + assert.match(server, /withOutOfTreeCopy\(stageDir[\s\S]*?\['scripts\/apply-sdk-lifecycle-patch\.mjs', '--check'\], \{ cwd: copyDir, env \}/); + for (const source of [desktop, server]) { + assert.match(source, /\['scripts\/apply-extract-zip-patch\.mjs', '--check'\], \{ cwd: copyDir, env \}/); + assert.match(source, /const BUN_VERSION = '1\.4\.0'/); + assert.match(source, /'better-sqlite3', 'node-pty'/); + assert.match(source, /removeExcludedDistributionPackages/); + assert.match(source, /pty\.spawn/); + assert.match(source, /worker\.initialize/); + assert.match(source, /worker\.shutdown/); + } + assert.match(desktop, /NODE_VERSION = DESKTOP_NODE_VERSION/); + assert.match(desktop, /desktop\.nodeSha256/); + assert.match(desktop, /pruneForeignPrebuilds/); + assert.match(server, /TARGET_NODE_VERSION = \[22, 22, 2\]/); + assert.match(server, /TARGET_GLIBC_VERSION = \[2, 35, 0\]/); + assert.match(server, /await auditGlibcRequirements\(stageDir\)/); +}); + +test('missing or unpatched ZIP evidence stops server stage acceptance', async t => { + const f = await fixture(t); await stageBundleFiles(f.stage, sourcePackage, f.source); + const calls = []; + await assert.rejects(installStageDependencies(f.stage, { + run: async (_command, args) => { + calls.push(args); + if (args[0] === 'scripts/apply-extract-zip-patch.mjs') throw new Error('ZIP patch missing'); + }, + verifyVersions: async () => assert.fail('ZIP patch verification must finish first'), + }), /ZIP patch missing/); + assert.equal(calls.length, 3); + const result = spawnSync(process.execPath, [path.join(f.stage, 'scripts/apply-extract-zip-patch.mjs'), '--check'], { cwd: repository, encoding: 'utf8' }); + assert.notEqual(result.status, 0); + assert.ok(result.stderr.includes(path.join(f.stage, 'node_modules'))); + await rm(path.join(f.source, 'patches/extract-zip-symlink-leaf/manifest.json')); + await assert.rejects(stageBundleFiles(f.stage, sourcePackage, f.source), { code: 'ENOENT' }); +}); diff --git a/scripts/release/packaged-extract-zip.test.mjs b/scripts/release/packaged-extract-zip.test.mjs new file mode 100644 index 00000000..91cb550d --- /dev/null +++ b/scripts/release/packaged-extract-zip.test.mjs @@ -0,0 +1,19 @@ +import assert from 'node:assert/strict'; +import { rm, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import test from 'node:test'; + +import { extractZipFixture } from '../test-fixtures/extract-zip.mjs'; + +import { extractZipPatchSmoke } from './smoke-packaged-server.mjs'; + +test('out-of-tree packaged checker accepts only actual patched payload bytes', async t => { + for (const state of ['patched', 'unpatched', 'tampered', 'missing']) await t.test(state, async t => { + const f = await extractZipFixture(t, { patched: state !== 'unpatched' }); + if (state === 'tampered') await writeFile(f.filename, 'modified'); + if (state === 'missing') await rm(path.join(f.root, 'scripts/apply-extract-zip-patch.mjs')); + const check = extractZipPatchSmoke({ command: process.execPath, cwd: f.root, env: process.env }); + if (state === 'patched') await check; + else await assert.rejects(check, /Packaged extract-zip patch check failed/); + }); +}); diff --git a/scripts/release/smoke-packaged-server.mjs b/scripts/release/smoke-packaged-server.mjs index 16c25d06..d935dcc8 100644 --- a/scripts/release/smoke-packaged-server.mjs +++ b/scripts/release/smoke-packaged-server.mjs @@ -158,6 +158,7 @@ export function launch(target, dataDirectory, projectDir) { } async function nativeClosureSmoke(target) { + await extractZipPatchSmoke(target); const source = ` import { createRequire } from 'node:module'; import { createHash } from 'node:crypto'; @@ -224,6 +225,27 @@ async function nativeClosureSmoke(target) { if (target.serverArchive) await workerInitializationSmoke(target); } +// Execute the verifier shipped in the extracted payload, not a checkout +// import. This check precedes native/module loading in every packaged smoke. +export async function extractZipPatchSmoke(target) { + await assertOutOfTree(target.cwd, 'extract-zip packaged patch smoke'); + await new Promise((resolve, reject) => { + const child = spawn(target.command, ['scripts/apply-extract-zip-patch.mjs', '--check'], { + cwd: target.cwd, env: target.env, stdio: ['ignore', 'pipe', 'pipe'], + }); + let output = ''; + const timer = setTimeout(() => { child.kill(); reject(new Error('Packaged extract-zip patch check timed out.')); }, 30_000); + child.stdout.on('data', chunk => { output += chunk; }); + child.stderr.on('data', chunk => { output += chunk; }); + child.once('error', error => { clearTimeout(timer); reject(error); }); + child.once('close', code => { + clearTimeout(timer); + if (code === 0) resolve(); + else reject(new Error('Packaged extract-zip patch check failed (' + code + '): ' + output)); + }); + }); +} + export async function workerInitializationSmoke(target, { timeoutMs = 45_000 } = {}) { await new Promise((resolve, reject) => { const child = spawn(target.bun, [path.join(target.cwd, 'dist-server/server/gjc-bun-worker.js')], { diff --git a/scripts/sdk-lifecycle-contract.test.mjs b/scripts/sdk-lifecycle-contract.test.mjs new file mode 100644 index 00000000..69bd92a5 --- /dev/null +++ b/scripts/sdk-lifecycle-contract.test.mjs @@ -0,0 +1,29 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +import '../patches/gjc-sdk-lifecycle/manifest.test.mjs'; +import { applySdkLifecyclePatch } from './apply-sdk-lifecycle-patch.mjs'; + +test('installed patched SDK passes the real retained-lifetime contract with isolated fixture data', async () => { + const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + const manifest = JSON.parse(await fs.readFile(path.join(root, 'patches/gjc-sdk-lifecycle/manifest.json'), 'utf8')); + await applySdkLifecyclePatch(root, manifest, { checkOnly: true }); + const bun = path.join(root, 'dist-native', process.platform === 'win32' ? 'bun.exe' : 'bun'); + const version = spawnSync(bun, ['--version'], { encoding: 'utf8', timeout: 5_000 }); + assert.equal(version.status, 0, version.error?.message ?? version.stderr); + assert.equal(version.stdout.trim(), '1.4.0'); + const env = { ...process.env, GJC_SDK_LIFECYCLE_CANDIDATE: root }; + for (const key of ['TMUX', 'TMUX_PANE', 'KITTY_WINDOW_ID', 'TERM_SESSION_ID', 'WT_SESSION']) delete env[key]; + // The candidate dependencies are read-only; every test creates its own data + // directory and offline provider. Nothing is copied into a shipped payload. + const result = spawnSync(bun, ['test', 'patches/gjc-sdk-lifecycle/lifecycle.bun.test.ts'], { + cwd: root, env, encoding: 'utf8', timeout: 60_000, maxBuffer: 2 * 1024 * 1024, + }); + assert.equal(result.status, 0, result.error?.message ?? `${result.stdout}\n${result.stderr}`); + assert.match(result.stderr, /\n [1-9][0-9]* pass\n/u); + assert.match(result.stderr, /\n 0 fail\n/u); +}); diff --git a/scripts/test-fixtures/extract-zip.mjs b/scripts/test-fixtures/extract-zip.mjs new file mode 100644 index 00000000..a5f14dbb --- /dev/null +++ b/scripts/test-fixtures/extract-zip.mjs @@ -0,0 +1,77 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import fs from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import os from 'node:os'; +import path from 'node:path'; + +import { applyExtractZipPatch } from '../apply-extract-zip-patch.mjs'; + +export const repository = path.resolve(import.meta.dirname, '../..'); +export const patchDirectory = 'patches/extract-zip-symlink-leaf'; +export const helper = 'scripts/apply-extract-zip-patch.mjs'; +export const manifest = JSON.parse(await fs.readFile(path.join(repository, patchDirectory, 'manifest.json'), 'utf8')); +export const hash = bytes => createHash('sha256').update(bytes).digest('hex'); + +export async function extractZipFixture(t, { patched = false, runtime = false } = {}) { + const root = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'gajae-extract-zip-test-'))); + t.after(() => fs.rm(root, { recursive: true, force: true })); + const packageRoot = path.join(root, 'node_modules/extract-zip'); + await fs.mkdir(packageRoot, { recursive: true }); + if (runtime) { + const copied = new Set(); + async function copyPackage(name, from) { + if (copied.has(name)) return; + copied.add(name); + const source = path.dirname(from.resolve(name + '/package.json')); + await fs.cp(source, path.join(root, 'node_modules', name), { recursive: true }); + const metadata = JSON.parse(await fs.readFile(path.join(source, 'package.json'), 'utf8')); + for (const dependency of Object.keys(metadata.dependencies ?? {})) { + await copyPackage(dependency, createRequire(path.join(source, 'package.json'))); + } + } + await copyPackage('extract-zip', createRequire(path.join(repository, 'package.json'))); + } + await fs.copyFile(path.join(repository, 'node_modules/extract-zip/package.json'), path.join(packageRoot, 'package.json')); + let original = await fs.readFile(path.join(repository, 'node_modules/extract-zip/index.js'), 'utf8'); + if (hash(original) === manifest.afterSha256) original = original.replace(manifest.replacement.after, manifest.replacement.before); + assert.equal(hash(original), manifest.beforeSha256, 'fixture must derive from exact upstream bytes'); + const filename = path.join(packageRoot, 'index.js'); + await fs.writeFile(filename, original, { mode: 0o644 }); + for (const relative of [helper, patchDirectory + '/manifest.json']) { + await fs.mkdir(path.dirname(path.join(root, relative)), { recursive: true }); + await fs.copyFile(path.join(repository, relative), path.join(root, relative)); + } + if (patched) await applyExtractZipPatch(root); + return { root, packageRoot, filename, original }; +} + +// Stored ZIP entries with Unix modes: real duplicate names/symlinks, no ZIP +// dependency, shell utility or mocked extractor/filesystem. +export function zip(entries) { + const local = []; const central = []; let offset = 0; + for (const { name, data = '', mode = 0o100644 } of entries) { + const filename = Buffer.from(name); const bytes = Buffer.from(data); + let crc = 0xffffffff; + for (const byte of bytes) { + crc ^= byte; + for (let bit = 0; bit < 8; bit++) crc = (crc >>> 1) ^ ((crc & 1) ? 0xedb88320 : 0); + } + crc = (crc ^ 0xffffffff) >>> 0; + const header = Buffer.alloc(30); + header.writeUInt32LE(0x04034b50); header.writeUInt16LE(20, 4); + header.writeUInt32LE(crc, 14); header.writeUInt32LE(bytes.length, 18); header.writeUInt32LE(bytes.length, 22); + header.writeUInt16LE(filename.length, 26); + local.push(header, filename, bytes); + const record = Buffer.alloc(46); + record.writeUInt32LE(0x02014b50); record.writeUInt16LE(0x0314, 4); record.writeUInt16LE(20, 6); + record.writeUInt32LE(crc, 16); record.writeUInt32LE(bytes.length, 20); record.writeUInt32LE(bytes.length, 24); + record.writeUInt16LE(filename.length, 28); record.writeUInt32LE((mode << 16) >>> 0, 38); record.writeUInt32LE(offset, 42); + central.push(record, filename); + offset += header.length + filename.length + bytes.length; + } + const directory = Buffer.concat(central); const end = Buffer.alloc(22); + end.writeUInt32LE(0x06054b50); end.writeUInt16LE(entries.length, 8); end.writeUInt16LE(entries.length, 10); + end.writeUInt32LE(directory.length, 12); end.writeUInt32LE(offset, 16); + return Buffer.concat([...local, directory, end]); +} diff --git a/server/app-factory.js b/server/app-factory.js index 2677a6e9..27b80fba 100644 --- a/server/app-factory.js +++ b/server/app-factory.js @@ -4,11 +4,13 @@ import cors from 'cors'; import express from 'express'; import { parseAllowedHosts } from '../shared/networkHosts.js'; +import { isDesktopNativeCommand } from '../shared/desktopRestartProtocol.js'; import { createDesktopAuth, DESKTOP_BOOTSTRAP_PATH } from './middleware/desktop-auth.js'; import { createWebSocketServer } from './modules/websocket/index.js'; import { createGjcJobsRouter } from './routes/gjc-jobs.js'; import { isAllowedRequestOrigin } from './shared/request-origin.js'; +import { asyncHandler } from './shared/utils.js'; /** * Builds the production GJC HTTP and WebSocket composition with explicit @@ -26,6 +28,8 @@ export function createGjcAppFactory({ chat, shell, browser = undefined, + desktopUpdateRelay = undefined, + desktopRestartAdmission = /** @type {import('./shared/interfaces.js').DesktopWorkAdmission | undefined} */ (undefined), }) { orchestrator.deps.broadcast = (jobId, event) => { try { projection.publish(jobId, event); } catch { /* Durable replay recovers isolated websocket fan-out failures. */ } @@ -34,13 +38,16 @@ export function createGjcAppFactory({ void terminalNotificationAdapter?.startupCatchUp().catch(() => {}); const app = express(); + // One server-owned object is shared by routes mounted here and later by + // index.js, and by every message on already-connected chat/terminal sockets. + app.locals.desktopRestartAdmission = desktopRestartAdmission; app.set('trust proxy', 1); const server = http.createServer(app); const desktopAuth = createDesktopAuth({ server }); const wss = createWebSocketServer(server, { - verifyClient: { authenticateWebSocket, desktopAuth }, - chat, - shell, + verifyClient: { authenticateWebSocket, desktopAuth, desktopRestartAdmission }, + chat: { ...chat, desktopRestartAdmission }, + shell: { ...shell, desktopRestartAdmission }, browser, }); app.locals.wss = wss; @@ -89,7 +96,21 @@ export function createGjcAppFactory({ }, })); app.use(express.urlencoded({ limit: '50mb', extended: true })); + app.post('/api/desktop/update', (request, response) => { + response.set('Cache-Control', 'no-store'); + if (!desktopAuth.enabled || !desktopUpdateRelay?.isAvailable()) return response.status(404).json({ error: 'updater_unavailable' }); + if (request.headers.origin !== desktopAuth.expectedOrigin() + || typeof request.headers['x-gajae-update-view'] !== 'string') return response.status(403).json({ error: 'updater_unauthorized' }); + if (!isDesktopNativeCommand(request.body)) return response.status(400).json({ error: 'updater_invalid_command' }); + void desktopUpdateRelay.request(request.body, request.headers['x-gajae-update-view'], request.headers.origin) + .then((snapshot) => { if (!response.destroyed) response.json(snapshot); }) + .catch((error) => { if (!response.destroyed) response.status(error.message === 'updater_unauthorized' ? 403 : 503).json({ error: /^[a-z_]{1,64}$/.test(error.message) ? error.message : 'updater_unavailable' }); }); + }); app.use('/api', validateApiKey); + // Authentication may create the implicit owner. Acquire before downstream + // middleware as well as within each handler. The nested handler lease owns + // its async lifetime; this outer lease alone is never completion evidence. + app.use('/api', asyncHandler((_request, _response, next) => next())); app.use('/api/gjc', authenticateGjcRoute, createGjcJobsRouter({ authority, orchestrator, gitService })); return { app, server, wss }; diff --git a/server/e2e/gjc-slice4.wire.e2e.ts b/server/e2e/gjc-slice4.wire.e2e.ts index da1a6954..b192cdb7 100644 --- a/server/e2e/gjc-slice4.wire.e2e.ts +++ b/server/e2e/gjc-slice4.wire.e2e.ts @@ -96,16 +96,16 @@ test('wire e2e: HTTP jobs endpoints and full websocket projection matrix', { tim return jobs.replayEvents(params); }, }; - const projection = new GjcJobProjectionService(authority as any); + let projection = new GjcJobProjectionService(authority as any); let id = 0; - const orchestrator = new JobOrchestrator({ + let orchestrator = new JobOrchestrator({ jobs, supervisor, owner: 'wire-e2e', createId: () => `wire-${++id}`, gitForProject: () => client, broadcast: () => {}, }); const gitService = new GjcJobGitService(jobs, () => client, async (jobId, eventId, payload) => orchestrator.appendAdminEvent(jobId, eventId, payload)); const originalApiKey = process.env.API_KEY; process.env.API_KEY = 'wire-e2e-api-key'; - const { server, wss } = createGjcAppFactory({ + const createRuntime = () => createGjcAppFactory({ authority, orchestrator, gitService, @@ -123,8 +123,10 @@ test('wire e2e: HTTP jobs endpoints and full websocket projection matrix', { tim }, shell: {}, }); - server.listen(0, '127.0.0.1'); await once(server, 'listening'); const port = (server.address() as any).port; - t.after(async () => { for (const ws of wss.clients) ws.terminate(); await new Promise(resolve => wss.close(() => resolve())); await new Promise(resolve => server.close(() => resolve())); jobs.close(); client.close(); await rm(database, { force: true }); await rm(root, { recursive: true, force: true }); if (originalApiKey === undefined) delete process.env.API_KEY; else process.env.API_KEY = originalApiKey; }); + let { server, wss } = createRuntime(); + const listen = async () => { server.listen(0, '127.0.0.1'); await once(server, 'listening'); return (server.address() as any).port as number; }; + let port = await listen(); + t.after(async () => { for (const ws of wss.clients) ws.terminate(); await new Promise(resolve => wss.close(() => resolve())); await new Promise(resolve => server.close(() => resolve())); jobs.close(); client.close(); await waitFor(async () => jobs.activity().settling === 0 && client.activity().settling === 0, 'native client close'); await rm(database, { force: true }); await rm(root, { recursive: true, force: true }); if (originalApiKey === undefined) delete process.env.API_KEY; else process.env.API_KEY = originalApiKey; }); const request = (path: string, method = 'GET', body?: unknown, apiKey: string | null = 'wire-e2e-api-key') => fetch(`http://127.0.0.1:${port}${path}`, { method, headers: { 'content-type': 'application/json', ...(apiKey === null ? {} : { 'x-api-key': apiKey }) }, body: body === undefined ? undefined : JSON.stringify(body) }); const connect = (apiKey: string | null = 'wire-e2e-api-key', origin?: string) => new WebSocket(`ws://127.0.0.1:${port}/ws`, { headers: { ...(apiKey === null ? {} : { 'x-api-key': apiKey }), ...(origin ? { origin } : {}) }, @@ -157,6 +159,19 @@ test('wire e2e: HTTP jobs endpoints and full websocket projection matrix', { tim await inbox.wait(frame => frame.kind === 'gjc_job_unsubscribed', 'unsubscribe'); ws.terminate(); await once(ws, 'close'); await orchestrator.interruptForShutdown(); + // An irreversibly retired owner cannot be reused to fake a restart. Rebuild + // the HTTP/projection/orchestrator composition over the same durable jobs. + // This is a wire-resume fixture, not proof of installed-app process handoff. + await supervisor.abort(supervisor.runs[0]!.input.runId); + await new Promise(resolve => wss.close(() => resolve())); + await new Promise(resolve => server.close(() => resolve())); + projection = new GjcJobProjectionService(authority as any); + orchestrator = new JobOrchestrator({ + jobs, supervisor, owner: 'wire-e2e-resumed', createId: () => `wire-${++id}`, gitForProject: () => client, + broadcast: () => {}, + }); + ({ server, wss } = createRuntime()); + port = await listen(); const resumed = await request(`/api/gjc/jobs/${jobId}/resume`, 'POST', { appSessionId: 'app-wire', message: 'resume' }); assert.equal(resumed.status, 202); const resumedDto = await resumed.json() as any; diff --git a/server/gjc-bun-oauth-controller.bun.test.ts b/server/gjc-bun-oauth-controller.bun.test.ts new file mode 100644 index 00000000..c7576e61 --- /dev/null +++ b/server/gjc-bun-oauth-controller.bun.test.ts @@ -0,0 +1,271 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import type { ModelRegistry } from '@gajae-code/coding-agent/config/model-registry'; +import type { AuthStorage } from '@gajae-code/coding-agent/session/auth-storage'; + +import { GjcBunOAuthController, type GjcOAuthActivitySnapshot, type GjcOAuthEvent } from './gjc-bun-oauth-controller.js'; + +// The installed AuthStorage declaration accepts unknown callbacks. Keep the +// test seam at the same narrow callback contract the controller supplies. +type Callbacks = { + onAuth(info: { url: string; instructions?: string }): void; + onPrompt(prompt: { message: string; placeholder?: string }): Promise; + signal?: AbortSignal; +}; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason: Error) => void; + const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); + return { promise, resolve, reject }; +} + +async function until(predicate: () => boolean): Promise { + for (let i = 0; i < 200; i += 1) { + if (predicate()) return; + await new Promise((resolve) => setTimeout(resolve, 1)); + } + assert.fail('Expected OAuth lifetime transition did not occur.'); +} + +function total(snapshot: GjcOAuthActivitySnapshot): number { + return snapshot.starting + snapshot.running + snapshot.settling; +} + +function fixture(login: (callbacks: Callbacks) => Promise, refresh = async () => {}, timeoutMs?: number) { + let credentialReads = 0; + let loginCalls = 0; + let refreshCalls = 0; + const events: GjcOAuthEvent[] = []; + const storage = { + exportSnapshot() { + credentialReads += 1; + return { credentials: [{ provider: 'openai-codex', accessToken: 'stored-credential-canary' }] }; + }, + login: async (_provider: string, callbacks: Callbacks) => { loginCalls += 1; await login(callbacks); }, + }; + const controller = new GjcBunOAuthController(storage as unknown as AuthStorage, { + refresh: async () => { refreshCalls += 1; await refresh(); }, + } as unknown as ModelRegistry, { timeoutMs }); + controller.subscribe((event) => events.push(event)); + return { controller, events, reads: () => credentialReads, logins: () => loginCalls, refreshes: () => refreshCalls }; +} + +test('OAuth activity is synchronously reserved, read-only, detached and credential-free', async () => { + const finish = deferred(); + const entered = deferred(); + const f = fixture(async (callbacks) => { + callbacks.onAuth({ url: 'https://example.invalid/authorization-url-canary', instructions: 'instruction-canary' }); + entered.resolve(); + await finish.promise; + }); + const initial = f.controller.snapshotActivity(); + assert.equal(total(initial), 0); + assert.equal(f.reads(), 0); + assert.equal(f.controller.getGeneration(), initial.generation); + assert.notEqual(f.controller.getGeneration(), fixture(async () => {}).controller.getGeneration()); + const attempt = f.controller.start('openai-codex'); + try { + const starting = f.controller.snapshotActivity(); + assert.equal(starting.starting, 1); + assert.ok(starting.revision > initial.revision); + await entered.promise; + const running = f.controller.snapshotActivity(); + assert.equal(running.running, 1); + assert.ok(running.revision > starting.revision); + const reads = f.reads(); + assert.deepEqual(f.controller.snapshotActivity(), running); + assert.equal(f.controller.getGeneration(), running.generation); + assert.equal(f.reads(), reads, 'activity reads must not inspect auth storage'); + const serialized = JSON.stringify(running); + for (const secret of ['stored-credential-canary', 'authorization-url-canary', 'instruction-canary', attempt.attemptId, 'openai-codex']) { + assert.equal(serialized.includes(secret), false); + } + (running as { running: number }).running = 987; + assert.equal(f.controller.snapshotActivity().running, 1); + assert.equal(total(initial), 0, 'past snapshots must not mutate'); + } finally { + f.controller.close(); + finish.resolve(); + await until(() => total(f.controller.snapshotActivity()) === 0); + } +}); + +test('OAuth cancellation retains the old login while a replacement owns the dialog', async () => { + const firstDone = deferred(); + const secondDone = deferred(); + const callbacks: Callbacks[] = []; + const f = fixture(async (current) => { + callbacks.push(current); + await (callbacks.length === 1 ? firstDone.promise : secondDone.promise); + }); + try { + const first = f.controller.start('openai-codex'); + await until(() => callbacks.length === 1); + const before = f.controller.snapshotActivity(); + assert.equal(f.controller.cancel(first.attemptId).phase, 'cancelled'); + const cancelled = f.controller.snapshotActivity(); + assert.equal(cancelled.settling, 1); + assert.equal(cancelled.running, 0); + assert.ok(cancelled.revision > before.revision); + assert.equal(callbacks[0]!.signal?.aborted, true); + const replacement = f.controller.start('openai-codex'); + assert.equal(f.controller.snapshotActivity().starting, 1); + assert.equal(f.controller.snapshotActivity().settling, 1); + await until(() => callbacks.length === 2); + const overlapping = f.controller.snapshotActivity(); + assert.equal(overlapping.running, 1); + assert.equal(overlapping.settling, 1); + firstDone.resolve(); + await until(() => f.controller.snapshotActivity().settling === 0); + assert.equal(f.controller.snapshotActivity().running, 1); + assert.ok(f.controller.snapshotActivity().revision > overlapping.revision); + assert.equal(f.controller.status().attempt?.attemptId, replacement.attemptId); + assert.equal(f.refreshes(), 0); + f.controller.cancel(replacement.attemptId); + secondDone.reject(new Error('late-login-secret-canary')); + await until(() => total(f.controller.snapshotActivity()) === 0); + assert.equal(f.controller.status().attempt?.phase, 'cancelled'); + assert.equal(JSON.stringify(f.events).includes('late-login-secret-canary'), false); + } finally { + f.controller.close(); firstDone.resolve(); secondDone.resolve(); + await until(() => total(f.controller.snapshotActivity()) === 0); + } +}); + +test('OAuth timeout keeps ownership until an abort-ignoring login actually rejects', async () => { + const finish = deferred(); + const f = fixture(async () => finish.promise, undefined, 10); + try { + f.controller.start('openai-codex'); + await until(() => f.controller.status().attempt?.phase === 'timed_out'); + const timedOut = f.controller.snapshotActivity(); + assert.equal(timedOut.settling, 1); + f.controller.close(); + assert.equal(f.controller.snapshotActivity().settling, 1); + finish.reject(new Error('late timeout failure')); + await until(() => total(f.controller.snapshotActivity()) === 0); + assert.ok(f.controller.snapshotActivity().revision > timedOut.revision); + assert.equal(f.refreshes(), 0); + } finally { + f.controller.close(); finish.resolve(); + await until(() => total(f.controller.snapshotActivity()) === 0); + } +}); + +for (const outcome of ['resolve', 'reject'] as const) { + test(`OAuth close retains an in-flight refresh until its actual ${outcome}`, async () => { + const refreshDone = deferred(); + const f = fixture(async () => {}, () => refreshDone.promise); + try { + f.controller.start('openai-codex'); + await until(() => f.refreshes() === 1); + assert.equal(f.controller.status().attempt?.phase, 'refreshing'); + const before = f.controller.snapshotActivity(); + f.controller.close(); + const closing = f.controller.snapshotActivity(); + assert.ok(closing.revision > before.revision); + assert.equal(closing.settling, 1); + assert.equal(f.controller.status().attempt?.phase, 'cancelled'); + const eventCount = f.events.length; + if (outcome === 'resolve') refreshDone.resolve(); + else refreshDone.reject(new Error('refresh-credential-canary')); + await until(() => total(f.controller.snapshotActivity()) === 0); + assert.ok(f.controller.snapshotActivity().revision > closing.revision); + assert.equal(f.events.length, eventCount, 'close must not resurrect UI listeners'); + assert.equal(f.controller.status().attempt?.phase, 'cancelled'); + } finally { + f.controller.close(); refreshDone.resolve(); + await until(() => total(f.controller.snapshotActivity()) === 0); + } + }); +} + +test('OAuth submission invalidates activity even when its visible phase does not change', async () => { + const finish = deferred(); + let submitted: string | undefined; + const f = fixture(async (callbacks) => { + submitted = await callbacks.onPrompt({ message: 'Enter password' }); + await finish.promise; + }); + try { + const attempt = f.controller.start('openai-codex'); + await until(() => f.controller.snapshotActivity().approvals === 1); + const waiting = f.controller.snapshotActivity(); + assert.equal(f.controller.submit(attempt.attemptId, 'submitted-password-canary').phase, 'awaiting_input'); + const accepted = f.controller.snapshotActivity(); + assert.equal(accepted.approvals, 0); + assert.equal(accepted.running, 1); + assert.ok(accepted.revision > waiting.revision); + assert.notEqual(accepted.generation, waiting.generation); + await until(() => submitted !== undefined); + assert.equal(submitted, 'submitted-password-canary'); + assert.equal(JSON.stringify(accepted).includes(submitted), false); + const beforeInvalid = f.controller.getGeneration(); + assert.throws(() => f.controller.submit(attempt.attemptId, 'duplicate'), /OAuth request failed/); + assert.equal(f.controller.getGeneration(), beforeInvalid); + } finally { + f.controller.close(); finish.resolve(); + await until(() => total(f.controller.snapshotActivity()) === 0); + } +}); + +test('OAuth completion observers still see the task until the outer login chain settles', async () => { + const f = fixture(async () => {}); + let terminal: GjcOAuthActivitySnapshot | undefined; + f.controller.subscribe((event) => { + if (event.method === 'oauth.phase' && event.payload.phase === 'completed') terminal = f.controller.snapshotActivity(); + }); + f.controller.start('openai-codex'); + await until(() => terminal !== undefined && total(f.controller.snapshotActivity()) === 0); + assert.equal(terminal?.settling, 1); + assert.ok(f.controller.snapshotActivity().revision > terminal!.revision); + f.controller.close(); +}); + +test('OAuth same-stack cancellation does not start the deferred login', async () => { + const f = fixture(async () => {}); + const attempt = f.controller.start('openai-codex'); + f.controller.cancel(attempt.attemptId); + assert.equal(f.controller.snapshotActivity().settling, 1); + await until(() => total(f.controller.snapshotActivity()) === 0); + assert.equal(f.logins(), 0); + assert.equal(f.refreshes(), 0); + f.controller.close(); +}); + +test('OAuth reentrant input cancellation owns the registered input and its delayed unwind', async () => { + const unwound = deferred(); + const finish = deferred(); + const f = fixture(async (callbacks) => { + try { await callbacks.onPrompt({ message: 'Enter code' }); } + finally { unwound.resolve(); await finish.promise; } + }); + const approvalCounts: number[] = []; + f.controller.subscribe((event) => { + if (event.method === 'oauth.phase' && event.payload.phase === 'awaiting_input') { + approvalCounts.push(f.controller.snapshotActivity().approvals); + f.controller.cancel(event.payload.attemptId); + } + }); + try { + f.controller.start('openai-codex'); + await unwound.promise; + assert.deepEqual(approvalCounts, [1]); + assert.equal(f.controller.snapshotActivity().approvals, 0); + assert.equal(f.controller.snapshotActivity().settling, 1); + } finally { + f.controller.close(); finish.resolve(); + await until(() => total(f.controller.snapshotActivity()) === 0); + } +}); + +test('OAuth observer failures cannot strand a reserved login task', async () => { + const f = fixture(async () => {}); + f.controller.subscribe(() => { throw new Error('observer failure'); }); + f.controller.start('openai-codex'); + await until(() => f.logins() === 1 && total(f.controller.snapshotActivity()) === 0); + assert.equal(f.controller.status().attempt?.phase, 'completed'); + f.controller.close(); +}); diff --git a/server/gjc-bun-oauth-controller.ts b/server/gjc-bun-oauth-controller.ts index b51b9601..a5505d04 100644 --- a/server/gjc-bun-oauth-controller.ts +++ b/server/gjc-bun-oauth-controller.ts @@ -51,6 +51,16 @@ export type GjcBunOAuthControllerOptions = { timeoutMs?: number; }; +/** Actual task ownership, not the last phase shown by the login dialog. */ +export type GjcOAuthActivitySnapshot = Readonly<{ + generation: string; + revision: number; + starting: number; + running: number; + settling: number; + approvals: number; +}>; + type PendingInput = { resolve(value: string): void; reject(reason: Error): void; @@ -60,6 +70,7 @@ type AttemptState = GjcOAuthAttempt & { abortController: AbortController; input?: PendingInput; timeout: ReturnType; + taskStarted: boolean; }; const terminalPhases = new Set(['completed', 'cancelled', 'timed_out', 'failed']); @@ -91,6 +102,12 @@ function isPasswordPrompt(prompt: { message: string; placeholder?: string }): bo export class GjcBunOAuthController { readonly #listeners = new Set<(event: GjcOAuthEvent) => void>(); readonly #timeoutMs: number; + readonly #generation = randomUUID(); + #revision = 0; + // A cancelled attempt may still be persisting credentials or refreshing + // models while a new attempt owns the dialog. Never transfer these tasks + // to #lastAttempt or release them on abort/timeout/close notification. + readonly #tasks = new Set(); #active: AttemptState | undefined; #lastAttempt: AttemptState | undefined; @@ -109,6 +126,25 @@ export class GjcBunOAuthController { return { providers: this.#providerDescriptors() }; } + getGeneration(): string { + return `${this.#generation}:${this.#revision}`; + } + + /** Pure in-memory read: no auth snapshot, tokens, URLs, inputs or task IDs. */ + snapshotActivity(): GjcOAuthActivitySnapshot { + let starting = 0; + let running = 0; + let settling = 0; + let approvals = 0; + for (const attempt of this.#tasks) { + if (!this.#isActive(attempt)) settling += 1; + else if (!attempt.taskStarted) starting += 1; + else running += 1; + if (attempt.input) approvals += 1; + } + return { generation: this.getGeneration(), revision: this.#revision, starting, running, settling, approvals }; + } + status(): { providers: GjcOAuthProviderDescriptor[]; attempt?: GjcOAuthAttempt } { return { providers: this.#providerDescriptors(), @@ -130,10 +166,13 @@ export class GjcBunOAuthController { expiresAt: Date.now() + this.#timeoutMs, abortController: new AbortController(), timeout: undefined as unknown as ReturnType, + taskStarted: false, }; attempt.timeout = setTimeout(() => this.#timeout(attempt), this.#timeoutMs); this.#active = attempt; this.#lastAttempt = attempt; + this.#tasks.add(attempt); + this.#revision += 1; this.#emitPhase(attempt); void Promise.resolve().then(() => this.#run(attempt)); return this.#snapshot(attempt); @@ -147,6 +186,7 @@ export class GjcBunOAuthController { if (!input) error('oauth_input_not_requested'); attempt.input = undefined; + this.#revision += 1; try { input.resolve(value); } finally { @@ -210,7 +250,10 @@ export class GjcBunOAuthController { } #emit(event: GjcOAuthEvent): void { - for (const listener of this.#listeners) listener(event); + for (const listener of this.#listeners) { + try { listener(event); } + catch { /* A UI observer cannot abandon the underlying login owner. */ } + } } #emitPhase(attempt: AttemptState): void { @@ -223,24 +266,28 @@ export class GjcBunOAuthController { delete attempt.valueKind; delete attempt.password; Object.assign(attempt, fields); + this.#revision += 1; this.#emitPhase(attempt); } #requestInput(attempt: AttemptState, valueKind: GjcOAuthInputValueKind, password?: true): Promise { if (!this.#isActive(attempt)) return Promise.reject(new GjcOAuthControllerError('oauth_attempt_not_active')); - this.#transition(attempt, 'awaiting_input', { valueKind, ...(password ? { password } : {}) }); return new Promise((resolve, reject) => { attempt.input = { resolve, reject }; + // Register before notifying: a synchronous subscriber may submit/cancel. + this.#transition(attempt, 'awaiting_input', { valueKind, ...(password ? { password } : {}) }); }); } #terminate(attempt: AttemptState, phase: Extract): void { if (!this.#isActive(attempt)) return; this.#active = undefined; + this.#revision += 1; clearTimeout(attempt.timeout); attempt.abortController.abort(); const input = attempt.input; attempt.input = undefined; + if (input) this.#revision += 1; input?.reject(new GjcOAuthControllerError(phase === 'timed_out' ? 'oauth_timed_out' : 'oauth_cancelled')); this.#transition(attempt, phase, { errorCode: phase === 'timed_out' ? 'oauth_timed_out' : 'oauth_cancelled' }); } @@ -250,6 +297,28 @@ export class GjcBunOAuthController { } async #run(attempt: AttemptState): Promise { + try { + // A same-stack cancellation must not start a new login in a microtask. + if (!this.#isActive(attempt)) return; + attempt.taskStarted = true; + this.#revision += 1; + await this.#runAttempt(attempt); + } catch { + if (this.#isActive(attempt)) { + this.#active = undefined; + this.#revision += 1; + clearTimeout(attempt.timeout); + this.#transition(attempt, 'failed', { errorCode: 'oauth_login_failed' }); + } + } finally { + // Only the underlying login/refresh chain reaching settlement releases + // this task. Neither #terminate nor close is a completion proof. + this.#tasks.delete(attempt); + this.#revision += 1; + } + } + + async #runAttempt(attempt: AttemptState): Promise { try { await this.authStorage.login(attempt.providerId, { onAuth: (info: OAuthAuthInfo) => { @@ -275,6 +344,7 @@ export class GjcBunOAuthController { if (!this.#isActive(attempt)) return; clearTimeout(attempt.timeout); this.#active = undefined; + this.#revision += 1; // The runtime's callback listener rejects a callback whose `state` is // not this attempt's: the browser finished a link from an earlier // attempt (a retry issues a new one). Named so the dialog can say @@ -288,6 +358,7 @@ export class GjcBunOAuthController { if (!this.#isActive(attempt)) return; this.#transition(attempt, 'refreshing'); + if (!this.#isActive(attempt)) return; let refreshFailed = false; try { @@ -300,9 +371,12 @@ export class GjcBunOAuthController { const providers = this.#providerDescriptors(); const provider = providers.find((candidate) => candidate.id === attempt.providerId); if (provider) this.#emit({ method: 'provider.auth.updated', payload: provider }); + if (!this.#isActive(attempt)) return; this.#emit({ method: 'oauth.providers.updated', payload: { providers } }); + if (!this.#isActive(attempt)) return; clearTimeout(attempt.timeout); this.#active = undefined; + this.#revision += 1; this.#transition( attempt, refreshFailed ? 'failed' : 'completed', diff --git a/server/gjc-bun-sdk-adapter.ts b/server/gjc-bun-sdk-adapter.ts index 0738d7a9..cddac43e 100644 --- a/server/gjc-bun-sdk-adapter.ts +++ b/server/gjc-bun-sdk-adapter.ts @@ -1,3 +1,4 @@ +import { createHash, randomUUID } from 'node:crypto'; import { realpath } from 'node:fs/promises'; import { createAgentSession, discoverAuthStorage } from '@gajae-code/coding-agent/sdk/session'; @@ -7,6 +8,7 @@ import { activateModelProfile } from '@gajae-code/coding-agent/config/model-prof import { resolveModelRoleValue } from '@gajae-code/coding-agent/config/model-resolver'; import { Settings } from '@gajae-code/coding-agent/config/settings'; import { AuthStorage } from '@gajae-code/coding-agent/session/auth-storage'; +import { SessionDisposalIncompleteError } from '@gajae-code/coding-agent/session/agent-session'; import { SessionManager } from '@gajae-code/coding-agent/session/session-manager'; import { executeAcpBuiltinSlashCommand } from '@gajae-code/coding-agent/slash-commands/acp-builtins'; import { initTheme, theme } from '@gajae-code/coding-agent/modes/theme/theme'; @@ -16,11 +18,13 @@ import { getSupportedEfforts } from '@gajae-code/ai/model-thinking'; import { parseGjcGoalCommand, type GjcGoalCommand, type GjcGoalSnapshot } from '../shared/gjc-goal.js'; import { appendImagesInputTag } from './shared/image-attachments.js'; -import { GjcBunOAuthController, type GjcBunOAuthControllerOptions } from './gjc-bun-oauth-controller.js'; +import { GjcBunOAuthController, type GjcBunOAuthControllerOptions, type GjcOAuthActivitySnapshot } from './gjc-bun-oauth-controller.js'; import { GJC_APP_BUILTIN_COMMAND_NAMES } from './gjc-command-surface.generated.js'; import type { GjcWorkerOAuthRuntime, GjcWorkerRuntime, GjcWorkerWriter } from './gjc-worker.js'; +import type { GjcWorkerActivity } from './gjc-worker-protocol.js'; import { GjcBunAskController } from './gjc-bun-ask-controller.js'; import { GjcCleanupUnconfirmedError, isGjcCleanupUnconfirmedError } from './gjc-cleanup-error.js'; +import { isVerifiedSdkPatch, type VerifiedSdkPatch } from './gjc-runtime-manifest.js'; import { GjcDelegationExecutor, GJC_APP_DELEGATION_TOOL_NAMES, serializeGjcDelegationAutomationTools } from './gjc-delegation-executor.js'; import { createGjcPermissionProvider, type GjcPermissionProvider } from './gjc-bun-permission-gate.js'; import { forwardPromptTerminal, forwardSdkEvent, normalizeBuiltinCommandStdout, type SdkRunState } from './gjc-bun-sdk-events.js'; @@ -82,8 +86,12 @@ export type GjcAgentSessionFactory = typeof createAgentSession; /** The runtime's title generator, narrowed to what the adapter supplies. */ export type GjcSessionTitleGenerator = (firstMessage: string, registry: ModelRegistry, settings: Settings, model: Model) => Promise; export type GjcBunSdkAdapterOptions = { + /** Source-integrity receipt from bootstrap, never a complete ownership proof. */ + sdkPatch?: VerifiedSdkPatch; createSessionFactory?: GjcAgentSessionFactory; generateSessionTitle?: GjcSessionTitleGenerator; + /** Shorter UI grace for embedders/tests; never extends the ten-second cap. */ + sessionTitleGraceMs?: number; settings?: Settings; loadSettings?: () => Promise; executeBuiltinCommand?: typeof executeAcpBuiltinSlashCommand; @@ -92,6 +100,50 @@ export type GjcBunSdkAdapterOptions = { closeAutomationSession?: (appSessionId: string) => Promise; }; +export type GjcSdkActivitySnapshot = Readonly<{ + generation: string; + revision: number; + starting: number; + running: number; + settling: number; + background: number; + operations: number; + oauth: GjcOAuthActivitySnapshot; + /** Coverage only, NOT idle: all counts, including nested OAuth, must be zero. */ + complete: boolean; + unknown: readonly string[]; + registry: GjcRegistryActivity; + credentials: GjcRegistryActivity; + settings: GjcRegistryActivity; +}>; + +type GjcRegistryActivity = { + generation: string; complete: boolean; starting: number; queued: number; + running: number; settling: number; unknown: readonly string[]; +}; +type GjcRegistryLifecycle = { + getAppLifecycleActivity(): GjcRegistryActivity; + setAppLifecycleAdmission(closed: boolean): void; +}; + +/** Public patched ownership seam only; never inspect SDK private state. */ +function readSdkLifecycleOwner(owner: unknown, unavailableReason: string): GjcRegistryActivity { + const unknown = { generation: unavailableReason, complete: false, + starting: 0, queued: 0, running: 0, settling: 0, unknown: [unavailableReason] }; + try { + const source = owner as Partial> | undefined; + if (typeof source?.getAppLifecycleActivity !== 'function') return unknown; + const value = source.getAppLifecycleActivity(); + if (!value || Object.keys(value).length !== 7 || typeof value.generation !== 'string' + || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u.test(value.generation) + || typeof value.complete !== 'boolean' + || [value.starting, value.queued, value.running, value.settling].some((count) => !Number.isSafeInteger(count) || count < 0) + || !Array.isArray(value.unknown) || value.unknown.length > 32 + || value.unknown.some((reason) => typeof reason !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u.test(reason))) return unknown; + return { ...value, unknown: [...value.unknown] }; + } catch { return unknown; } +} + type ActiveRun = { goals?: GjcGoalSession; goalScope?: GjcGoalScope; @@ -100,6 +152,7 @@ type ActiveRun = { prompt(message: string, options?: { streamingBehavior?: 'steer' | 'followUp' }): Promise; abort(): Promise; dispose(): Promise; + awaitDisposeCompletion?(): Promise; subscribe(listener: (event: unknown) => void): () => void; /** True while a turn is in flight. Absent on runtimes that never stream. */ readonly isStreaming?: boolean; @@ -111,14 +164,28 @@ type ActiveRun = { askController: GjcBunAskController; state: SdkRunState; abortState: 'idle' | 'aborting' | 'aborted'; + settling: boolean; appSessionId?: string; delegation?: GjcDelegationExecutor; }; const FAILURE = 'GJC SDK configuration is invalid.'; + +/** Normal end-of-run cleanup, never invoked by restart observation/admission. */ +async function disposeSdkSession(session: ActiveRun['session']): Promise { + try { await session.dispose(); } + catch (error) { + // SDK 0.16.4's public caller deadline does not end its teardown owner. + // Join that exact session's retained promise while the adapter root stays + // in settling. Real cleanup failures still poison the worker; an arbitrary + // provider error with the same name is not permission to ignore failure. + if (!(error instanceof SessionDisposalIncompleteError) || !session.awaitDisposeCompletion) throw error; + } + await session.awaitDisposeCompletion?.(); +} const MODEL_ID_EFFORT = /-(off|minimal|low|medium|high|xhigh|max)(?:-fast)?$/; /** - * How long a finished turn waits for its title before giving up on it. The + * How long a finished turn waits for its title before releasing the UI. The * title is a 30-token completion started with the turn, so it is normally * long done; a hung title request must not hold the turn's terminal frame. */ @@ -384,8 +451,133 @@ async function resumeManager(providerSessionId: string, sessionRoot: string): Pr /** In-process, serial-only SDK runtime. AuthStorage and ModelRegistry are app-owned singleton inputs. */ export class GjcBunSdkAdapter implements GjcWorkerRuntime { + readonly #generation = randomUUID(); + #revision = 0; + #operations = 0; + #backgroundTitles = 0; + #admissionClosed = false; + #sdkBackgroundOwnershipUnproven = false; + readonly #sdkSessionOwners = new Set(); + readonly #sdkCoverageFailures = new Set(); + #baseSettings?: Settings; #cleanupFailure?: GjcCleanupUnconfirmedError; + getGeneration(): string { + // Hash the component generations so adding another real owner cannot exceed + // the protocol's bounded identifier size during a long-running worker. + return createHash('sha256').update(JSON.stringify([ + this.#generation, this.#revision, this.oauth.getGeneration(), this.#registryActivity().generation, + this.#credentialActivity().generation, this.#settingsActivity().generation, + [...this.#sdkSessionOwners].map((session) => readSdkLifecycleOwner(session, 'sdk_background_ownership_unproven').generation), + ])).digest('hex'); + } + + setAdmissionFence(closed: boolean): void { + if (this.#admissionClosed === closed) return; + this.#admissionClosed = closed; + this.#revision += 1; + const registry = this.modelRegistry as ModelRegistry & Partial; + registry.setAppLifecycleAdmission?.(closed); + } + + #registryActivity(): GjcRegistryActivity { + const registry = this.modelRegistry as ModelRegistry & Partial; + return readSdkLifecycleOwner(typeof registry.setAppLifecycleAdmission === 'function' ? registry : undefined, + 'sdk_registry_ownership_unproven'); + } + #credentialActivity(): GjcRegistryActivity { return readSdkLifecycleOwner(this.authStorage, 'sdk_auth_ownership_unproven'); } + #settingsActivity(): GjcRegistryActivity { return readSdkLifecycleOwner(this.#baseSettings, 'sdk_settings_ownership_unproven'); } + + #assertAdmission(): void { + this.#assertHealthy(); + if (this.#admissionClosed) throw Object.assign(new Error('Worker admission is fenced.'), { code: 'worker_admission_fenced' }); + } + + /** No credential access, teardown, SDK diagnostic polling or new work. */ + observeActivity(): GjcWorkerActivity { + const value = this.snapshotActivity(); + return { + generation: value.generation, + complete: value.complete, + starting: value.starting + value.oauth.starting + value.registry.starting + value.credentials.starting + value.settings.starting, + queued: value.registry.queued + value.credentials.queued + value.settings.queued, + running: value.running + value.oauth.running + value.registry.running + value.credentials.running + value.settings.running, + settling: value.settling + value.operations + value.oauth.settling + value.background + value.registry.settling + value.credentials.settling + value.settings.settling, + approvals: value.oauth.approvals, + retained: 0, + unknown: [...value.unknown], + }; + } + + /** Fixed-size, credential-free observation. Never polls or disposes the SDK. */ + snapshotActivity(): GjcSdkActivitySnapshot { + const oauth = this.oauth.snapshotActivity(); + const registry = this.#registryActivity(); + const credentials = this.#credentialActivity(); + const settings = this.#settingsActivity(); + let starting = 0; + let running = 0; + let settling = 0; + for (const runId of this.#starting.keys()) if (!this.#runs.has(runId)) starting += 1; + for (const run of this.#runs.values()) { + if (run.settling) settling += 1; + else running += 1; + } + const unknown: GjcSdkActivitySnapshot['unknown'][number][] = []; + if (this.#sdkBackgroundOwnershipUnproven) unknown.push('sdk_background_ownership_unproven'); + if (this.#cleanupFailure) unknown.push('sdk_cleanup_unconfirmed'); + unknown.push(...this.#sdkCoverageFailures); + const activeSessions = new Set([...this.#runs.values()].map((run) => run.session)); + for (const session of this.#sdkSessionOwners) { + const activity = readSdkLifecycleOwner(session, 'sdk_background_ownership_unproven'); + // An active run already owns its entire subtree until retained disposal. + // A returned session not (or no longer) covered by a run still counts. + if (!activeSessions.has(session)) { + starting += activity.starting; + running += activity.running; + settling += activity.queued + activity.settling; + } + unknown.push(...activity.unknown); + if (!activity.complete && !activity.unknown.length) unknown.push('sdk_background_ownership_unproven'); + } + // Ordinary diagnostics describe coverage. A worker admission proof, unlike + // those diagnostics, requires the registry's own producer fence to be shut. + unknown.push(...registry.unknown.filter((reason) => this.#admissionClosed || reason !== 'model_registry_admission_open')); + if (!registry.complete && registry.unknown.length === 0) unknown.push('sdk_registry_ownership_unproven'); + for (const [value, reason] of [[credentials, 'sdk_auth_ownership_unproven'], [settings, 'sdk_settings_ownership_unproven']] as const) { + unknown.push(...value.unknown); + if (!value.complete && !value.unknown.length) unknown.push(reason); + } + return { + generation: this.getGeneration(), revision: this.#revision + oauth.revision, + starting, running, settling, background: this.#backgroundTitles, operations: this.#operations, + oauth, registry, credentials, settings, complete: unknown.length === 0, unknown: [...new Set(unknown)], + }; + } + + async #withOperation(operation: () => Promise): Promise { + this.#operations += 1; + this.#revision += 1; + try { return await operation(); } + finally { + this.#operations -= 1; + this.#revision += 1; + } + } + + async #withTitleTask(operation: () => Promise): Promise { + // Reserve synchronously, including before an injected generator can throw. + // The lifetime includes setSessionName persistence and the title callback. + this.#backgroundTitles += 1; + this.#revision += 1; + try { await operation(); } + catch { /* A title failure does not fail the user's turn. */ } + finally { + this.#backgroundTitles -= 1; + this.#revision += 1; + } + } + #assertHealthy(): void { if (this.#cleanupFailure) throw this.#cleanupFailure; } @@ -393,6 +585,7 @@ export class GjcBunSdkAdapter implements GjcWorkerRuntime { #poison(): GjcCleanupUnconfirmedError { if (this.#cleanupFailure) return this.#cleanupFailure; const failure = this.#cleanupFailure = new GjcCleanupUnconfirmedError(); + this.#revision += 1; // Fence every session in this shared runtime immediately. These are only // best-effort aborts; the Node supervisor must prove whole-worker reaping. for (const starting of this.#starting.values()) starting.abortRequested = true; @@ -412,9 +605,9 @@ export class GjcBunSdkAdapter implements GjcWorkerRuntime { return failure; } readonly #runs = new Map(); - /** Runs accepted but not yet holding a session; an abort can still reach them. */ + /** Accepted roots through settlement; pre-session aborts still reach them here. */ readonly #starting = new Map(); - readonly oauth: GjcWorkerOAuthRuntime; + readonly oauth: GjcWorkerOAuthRuntime & Pick; constructor( private readonly authStorage: AuthStorage, @@ -422,20 +615,28 @@ export class GjcBunSdkAdapter implements GjcWorkerRuntime { private readonly options: GjcBunSdkAdapterOptions = {}, ) { if (modelRegistry.authStorage !== authStorage) throw new Error(FAILURE); + if (options.sdkPatch !== undefined && !isVerifiedSdkPatch(options.sdkPatch)) throw new Error(FAILURE); + this.#baseSettings = options.settings; const oauth = new GjcBunOAuthController(authStorage, modelRegistry, options.oauth); this.oauth = { providers: () => oauth.providers(), status: () => oauth.status(), - start: (providerId) => oauth.start(providerId), + start: (providerId) => { this.#assertAdmission(); return oauth.start(providerId); }, submit: (attemptId, value) => oauth.submit(attemptId, value), cancel: (attemptId) => oauth.cancel(attemptId), subscribe: (listener) => oauth.subscribe(listener), close: () => oauth.close(), + snapshotActivity: () => oauth.snapshotActivity(), + getGeneration: () => oauth.getGeneration(), }; } async modelCatalog() { - this.#assertHealthy(); + this.#assertAdmission(); + return this.#withOperation(() => this.#modelCatalog()); + } + + async #modelCatalog() { const seen = new Set(); const models = []; const candidates = await modelsForCredential(this.authStorage, this.modelRegistry, { kind: 'stored' }); @@ -465,12 +666,13 @@ export class GjcBunSdkAdapter implements GjcWorkerRuntime { } spawnGjc(message: string, options: Record, writer: GjcWorkerWriter): Promise & { abortHandle?: string; processId?: number } { - this.#assertHealthy(); + this.#assertAdmission(); if (isAppOAuthCommand(message)) throw new Error(FAILURE); const runId = typeof options.runHandle === 'string' && options.runHandle ? options.runHandle : ''; const config = configFromOptions(options); if (!runId || this.#runs.has(runId) || this.#starting.has(runId)) throw new Error(FAILURE); this.#starting.set(runId, { abortRequested: false }); + this.#revision += 1; const guardedWriter: GjcWorkerWriter = { send: (value) => { if (!this.#cleanupFailure) writer.send(value); }, ...(writer.setSessionId ? { setSessionId: (id: string) => { if (!this.#cleanupFailure) writer.setSessionId!(id); } } : {}), @@ -480,7 +682,10 @@ export class GjcBunSdkAdapter implements GjcWorkerRuntime { ...(writer.setModel ? { setModel: (model: string) => { if (!this.#cleanupFailure) writer.setModel!(model); } } : {}), ...(writer.setAborted ? { setAborted: () => { if (!this.#cleanupFailure) writer.setAborted!(); } } : {}), }; - const task = this.#run(runId, message, options, config, guardedWriter).finally(() => this.#starting.delete(runId)); + const task = this.#run(runId, message, options, config, guardedWriter).finally(() => { + this.#starting.delete(runId); + this.#revision += 1; + }); return Object.assign(task, { abortHandle: runId }); } @@ -499,6 +704,10 @@ export class GjcBunSdkAdapter implements GjcWorkerRuntime { */ async steerGjcSession(runHandle: string, message: string): Promise { this.#assertHealthy(); + return this.#withOperation(() => this.#steerGjcSession(runHandle, message)); + } + + async #steerGjcSession(runHandle: string, message: string): Promise { const run = this.#runs.get(runHandle); if (!run || run.abortState !== 'idle') return false; if (run.session.isStreaming === false) return false; @@ -515,6 +724,10 @@ export class GjcBunSdkAdapter implements GjcWorkerRuntime { async abortGjcSession(sessionId: string): Promise { this.#assertHealthy(); + return this.#withOperation(() => this.#abortGjcSession(sessionId)); + } + + async #abortGjcSession(sessionId: string): Promise { const run = this.#runs.get(sessionId); if (!run) { // Stop pressed while the session is still being built (model and @@ -524,6 +737,7 @@ export class GjcBunSdkAdapter implements GjcWorkerRuntime { const starting = this.#starting.get(sessionId); if (!starting || starting.abortRequested) return false; starting.abortRequested = true; + this.#revision += 1; return true; } if (run.abortState !== 'idle') return false; @@ -532,6 +746,7 @@ export class GjcBunSdkAdapter implements GjcWorkerRuntime { // `session.abort()` is still in flight, and that turn must not be reported // back to the user as an unexpected interruption. run.state.abortPending = true; + this.#revision += 1; const closeAutomation = this.options.closeAutomationSession ?? (this.options.automationBridge ? (appSessionId: string) => closeGjcAutomationSession(appSessionId, this.options.automationBridge) @@ -547,6 +762,7 @@ export class GjcBunSdkAdapter implements GjcWorkerRuntime { run.askController.dispose(); run.abortState = 'aborted'; run.state.abortRequested = true; + this.#revision += 1; run.markAborted?.(); await automationCleanup; return true; @@ -554,12 +770,15 @@ export class GjcBunSdkAdapter implements GjcWorkerRuntime { await automationCleanup; run.abortState = 'idle'; run.state.abortPending = false; + this.#revision += 1; return false; } } resolveGjcToolApproval(requestId: string, decision: unknown): boolean { this.#assertHealthy(); + // Resolution may synchronously enqueue an owned SDK continuation. + this.#revision += 1; for (const run of this.#runs.values()) { if (run.askController.resolve(requestId, decision)) return true; } @@ -567,7 +786,11 @@ export class GjcBunSdkAdapter implements GjcWorkerRuntime { } async inspectGjcGoal(scope: GjcGoalScope, providerSessionId: string, sessionRoot: string): Promise { - this.#assertHealthy(); + this.#assertAdmission(); + return this.#withOperation(() => this.#inspectGjcGoal(scope, providerSessionId, sessionRoot)); + } + + async #inspectGjcGoal(scope: GjcGoalScope, providerSessionId: string, sessionRoot: string): Promise { const manager = await resumeManager(providerSessionId, sessionRoot); try { const { state, scope: owner } = readPersistedGjcGoal(manager); @@ -583,6 +806,10 @@ export class GjcBunSdkAdapter implements GjcWorkerRuntime { async controlGjcGoal(runId: string, scope: GjcGoalScope, command?: GjcGoalCommand, stopAfterMutation = true): Promise { this.#assertHealthy(); + return this.#withOperation(() => this.#controlGjcGoal(runId, scope, command, stopAfterMutation)); + } + + async #controlGjcGoal(runId: string, scope: GjcGoalScope, command?: GjcGoalCommand, stopAfterMutation: boolean = true): Promise { const run = this.#runs.get(runId); if (!run || run.abortState !== 'idle' || !matchesGjcGoalOwner(run.goalScope, scope)) throw new Error('No controllable goal exists for this run.'); if (!run.goals) { @@ -622,12 +849,14 @@ export class GjcBunSdkAdapter implements GjcWorkerRuntime { } if (active) { const run = active; + run.settling = true; + this.#revision += 1; for (const cleanup of [ () => run.goals?.dispose(), () => run.unsubscribe(), () => run.askController.dispose(), () => run.delegation?.dispose(), - () => run.session.dispose(), + () => disposeSdkSession(run.session), ]) { try { await cleanup(); } catch { disposalError ??= new Error(FAILURE); } @@ -636,8 +865,19 @@ export class GjcBunSdkAdapter implements GjcWorkerRuntime { console.error('GJC SDK session disposal failed.'); throw this.#poison(); } + // Only a completed physical teardown plus its live ownership receipt may + // retire this session. Source hashes and logical terminal events are not idle proof. + if (this.#sdkSessionOwners.has(run.session)) { + const activity = readSdkLifecycleOwner(run.session, 'sdk_background_ownership_unproven'); + if (activity.starting + activity.queued + activity.running + activity.settling === 0) { + for (const reason of activity.unknown) this.#sdkCoverageFailures.add(reason); + if (!activity.complete && !activity.unknown.length) this.#sdkBackgroundOwnershipUnproven = true; + this.#sdkSessionOwners.delete(run.session); + } + } this.#assertHealthy(); this.#runs.delete(runId); + this.#revision += 1; forwardPromptTerminal(writer, run.state, didRunFail ? runError ?? new Error(FAILURE) : undefined); } this.#assertHealthy(); @@ -655,6 +895,7 @@ export class GjcBunSdkAdapter implements GjcWorkerRuntime { ?? await Settings.init( process.env.GJC_WORKER_AGENT_DIR ? { agentDir: process.env.GJC_WORKER_AGENT_DIR } : {}, ); + this.#baseSettings = globalSettings; const configuredModelId = config.modelId === 'default' ? await configuredDefaultModelIdWithRefresh( globalSettings, @@ -735,7 +976,7 @@ export class GjcBunSdkAdapter implements GjcWorkerRuntime { delegation.setToolUIContext(askController.uiContext); } this.#assertHealthy(); - const result = await (this.options.createSessionFactory ?? createAgentSession)({ + const result = await Promise.resolve().then(() => (this.options.createSessionFactory ?? createAgentSession)({ ...sessionOptions, // CustomTool is the public SDK replacement API. Never construct the // built-in executor: it does not inherit the app permission boundary. @@ -744,7 +985,17 @@ export class GjcBunSdkAdapter implements GjcWorkerRuntime { // The app executor above receives the configured role policy. Only // native SDK spawning is denied for goal/delegation-capable sessions. spawns: delegation || goalEnabled ? 'deny' : sessionOptions.spawns, + })).catch((error: unknown) => { + // No returned owner: even a patched factory's rejection is not a + // receipt for every fallible discovery/extension implementation. + this.#sdkBackgroundOwnershipUnproven = true; + this.#revision += 1; + throw error; }); + const ownership = readSdkLifecycleOwner(result.session, 'sdk_background_ownership_unproven'); + if (ownership.unknown.includes('sdk_background_ownership_unproven')) this.#sdkBackgroundOwnershipUnproven = true; + else this.#sdkSessionOwners.add(result.session); + this.#revision += 1; this.#assertHealthy(); if (config.modelProfile) { await activateModelProfile({ @@ -797,6 +1048,7 @@ export class GjcBunSdkAdapter implements GjcWorkerRuntime { // footer snapshot is read here and handed to the event mapper. let goals: GjcGoalSession | undefined; const unsubscribe = result.session.subscribe((event: unknown) => { + this.#revision += 1; goals?.onEvent(event); forwardSdkEvent( event, @@ -814,11 +1066,13 @@ export class GjcBunSdkAdapter implements GjcWorkerRuntime { askController, state, abortState: 'idle', + settling: false, ...(delegation ? { delegation } : {}), ...(config.appSessionId ? { appSessionId: config.appSessionId } : {}), }; setActive(activeRun); this.#runs.set(runId, activeRun); + this.#revision += 1; if (goalEnabled && goalScope) { goals = new GjcGoalSession(result.session, sessionManager, goalScope, runId, (goal) => writer.send({ kind: 'status', text: 'session_state', sessionState: { goal } }), @@ -836,6 +1090,7 @@ export class GjcBunSdkAdapter implements GjcWorkerRuntime { activeRun.abortState = 'aborted'; state.abortPending = true; state.abortRequested = true; + this.#revision += 1; return; } if (!resumedId) writer.setSessionId?.(sessionManager.getSessionId()); @@ -907,12 +1162,11 @@ export class GjcBunSdkAdapter implements GjcWorkerRuntime { // it or opted out. The title reaches the app as a `session_title` // message that the server stores and never shows as chat. const titleTask = !resumedId && promptMessage !== null && !sessionManager.getSessionName() && !sessionTitlesDisabled() - ? (this.options.generateSessionTitle ?? runtimeSessionTitle)(message, this.modelRegistry, settings, model) - .then(async (title) => { + ? this.#withTitleTask(async () => { + const title = await (this.options.generateSessionTitle ?? runtimeSessionTitle)(message, this.modelRegistry, settings, model); if (!title || !(await sessionManager.setSessionName(title, 'auto'))) return; writer.send({ kind: 'session_title', title: sessionManager.getSessionName(), source: 'auto', sessionId: sessionManager.getSessionId() }); }) - .catch(() => {}) : null; let promptError: unknown; try { @@ -927,7 +1181,10 @@ export class GjcBunSdkAdapter implements GjcWorkerRuntime { } if (titleTask) { let grace: ReturnType | undefined; - await Promise.race([titleTask, new Promise((resolve) => { grace = setTimeout(resolve, SESSION_TITLE_GRACE_MS); })]); + const requestedGrace = this.options.sessionTitleGraceMs; + const graceMs = requestedGrace !== undefined && Number.isSafeInteger(requestedGrace) && requestedGrace >= 0 + ? Math.min(requestedGrace, SESSION_TITLE_GRACE_MS) : SESSION_TITLE_GRACE_MS; + await Promise.race([titleTask, new Promise((resolve) => { grace = setTimeout(resolve, graceMs); })]); clearTimeout(grace); } await delegation?.dispose(); @@ -953,7 +1210,7 @@ export async function ensureSdkThemeInitialized(): Promise { await initTheme(false); } -export async function createGjcBunSdkAdapter(agentDir: string = process.env.GJC_WORKER_AGENT_DIR ?? ''): Promise { +export async function createGjcBunSdkAdapter(agentDir: string = process.env.GJC_WORKER_AGENT_DIR ?? '', sdkPatch?: VerifiedSdkPatch): Promise { if (!agentDir) throw new Error(FAILURE); if (!installGjcCliShim() && !warnedAboutGjcCliShim) { warnedAboutGjcCliShim = true; @@ -964,14 +1221,16 @@ export async function createGjcBunSdkAdapter(agentDir: string = process.env.GJC_ // the worker environment. The model can use the injected tools but cannot // print or reuse the bridge token through shell commands. const automationBridge = takeGjcAutomationBridgeTransport(); - const [authStorage] = await Promise.all([ + const [authStorage, , settings] = await Promise.all([ discoverAuthStorage(agentDir), ensureSdkThemeInitialized(), + Settings.init({ agentDir }), ]); - const modelRegistry = new ModelRegistry(authStorage); + const modelRegistry = new ModelRegistry(authStorage, undefined, settings, { agentDir }); await modelRegistry.refresh(); return new GjcBunSdkAdapter(authStorage, modelRegistry, { - loadSettings: () => Settings.init({ agentDir }), + sdkPatch, + settings, ...(automationBridge ? { automationBridge } : {}), }); } diff --git a/server/gjc-bun-worker.ts b/server/gjc-bun-worker.ts index 5defa737..5d514fcc 100644 --- a/server/gjc-bun-worker.ts +++ b/server/gjc-bun-worker.ts @@ -2,9 +2,9 @@ import { verifyRuntimeManifest } from './gjc-runtime-manifest.js'; import { runGjcWorkerEntrypoint, type GjcWorkerRuntime } from './gjc-worker.js'; async function loadBunSdkRuntime(): Promise { - await verifyRuntimeManifest(); + const sdkPatch = await verifyRuntimeManifest(); const { createGjcBunSdkAdapter } = await import('./gjc-bun-sdk-adapter.js'); - return createGjcBunSdkAdapter(); + return createGjcBunSdkAdapter(undefined, sdkPatch); } // stdout is owned exclusively by the Protocol v1 entrypoint. diff --git a/server/gjc-engine-manifest.json b/server/gjc-engine-manifest.json index 111f028a..2f96fc91 100644 --- a/server/gjc-engine-manifest.json +++ b/server/gjc-engine-manifest.json @@ -43,6 +43,7 @@ "server/gjc-ask-decision.bun.test.ts", "server/gjc-automation-tools.test.ts", "server/gjc-bun-ask-controller.test.ts", + "server/gjc-bun-oauth-controller.bun.test.ts", "server/gjc-bun-permission-gate.test.ts", "server/gjc-bun-sdk-events.contract.test.ts", "server/gjc-bun-sdk-events.test.ts", @@ -55,6 +56,7 @@ "server/gjc-goal-session.test.ts", "server/gjc-goal-protocol.test.ts", "server/gjc-permission-policy.test.ts", + "server/gjc-runtime-manifest.test.ts", "server/gjc-sdk-bridge.test.ts", "server/gjc-sdk-client.test.ts", "server/gjc-sdk-contract.bun.test.ts", diff --git a/server/gjc-engine-manifest.test.ts b/server/gjc-engine-manifest.test.ts index af959d73..e11320ba 100644 --- a/server/gjc-engine-manifest.test.ts +++ b/server/gjc-engine-manifest.test.ts @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; -import { readFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; import { dirname, join, relative, resolve } from 'node:path'; import test from 'node:test'; import { fileURLToPath } from 'node:url'; @@ -31,13 +32,19 @@ for (const [group, entries] of Object.entries(manifest)) { } } -const tracked = execFileSync('git', ['ls-files', 'server/gjc-*'], { cwd: REPOSITORY_ROOT, encoding: 'utf8' }) - .trim() - .split('\n') - .filter(Boolean); +function inventoryEngineFiles(repositoryRoot: string): string[] { + // Verify authored files before git add, but keep ignored build/runtime output + // out and retain the same server/gjc-* namespace as the extraction manifest. + const files = execFileSync('git', [ + 'ls-files', '--cached', '--others', '--exclude-standard', '-z', '--', 'server/gjc-*', + ], { cwd: repositoryRoot, encoding: 'utf8' }); + return [...new Set(files.split('\0').filter(Boolean))].sort(); +} + +const inventory = inventoryEngineFiles(REPOSITORY_ROOT); test('every file in the engine namespace has a recorded side', () => { - const undeclared = tracked.filter((file) => !declared.has(file)); + const undeclared = inventory.filter((file) => !declared.has(file)); assert.deepEqual( undeclared, [], @@ -48,8 +55,8 @@ test('every file in the engine namespace has a recorded side', () => { }); test('the manifest describes no file that does not exist', () => { - const trackedSet = new Set(tracked); - const missing = [...declared.keys()].filter((file) => !trackedSet.has(file)); + const inventorySet = new Set(inventory); + const missing = [...declared.keys()].filter((file) => !inventorySet.has(file)); assert.deepEqual( missing, [], @@ -93,3 +100,60 @@ test('nothing declared as engine imports the application', () => { ); } }); + +test('engine inventory includes untracked nonignored files without widening its namespace', (t) => { + const repositoryRoot = mkdtempSync(join(tmpdir(), 'gjc-engine-inventory-')); + t.after(() => rmSync(repositoryRoot, { recursive: true, force: true })); + const git = (args: string[]) => execFileSync('git', args, { cwd: repositoryRoot, encoding: 'utf8' }); + git(['init', '-q']); + // Keep the fixture independent of the developer's global ignore patterns. + git(['config', 'core.excludesFile', '']); + + const trackedFiles = [ + 'server/gjc-tracked.ts', + 'server/gjc-tracked.log', + 'server/gjc-existing/tracked.ts', + ]; + const untrackedFiles = [ + 'server/gjc-bun-oauth-controller.bun.test.ts', + 'server/gjc-new.ts', + 'server/gjc-existing/new.ts', + 'server/gjc-new-directory/nested.ts', + 'server/gjc-space name.ts', + 'server/gjc-한글.ts', + ]; + const excludedFiles = [ + 'server/gjc-ignored.log', + 'server/gjc-existing/ignored.log', + 'server/gjc-ignored-directory/generated.ts', + 'server/gjc-local-only.ts', + 'server/other.ts', + 'server/gjclient.ts', + 'server/modules/providers/gjc-app.ts', + 'src/gjc-ui.ts', + 'scripts/gjc-helper.ts', + 'other/server/gjc-lookalike.ts', + ]; + for (const file of [...trackedFiles, ...untrackedFiles, ...excludedFiles]) { + const path = join(repositoryRoot, file); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, '// inventory fixture\n'); + } + git(['add', '--', ...trackedFiles]); + // Existing tracked files must remain visible even when a later ignore rule + // matches them; only untracked files are subject to standard Git exclusions. + writeFileSync(join(repositoryRoot, '.gitignore'), '*.log\n/server/gjc-ignored-directory/\n'); + writeFileSync(join(repositoryRoot, '.git/info/exclude'), '/server/gjc-local-only.ts\n'); + + const beforeStaging = inventoryEngineFiles(repositoryRoot); + assert.deepEqual(beforeStaging, [...trackedFiles, ...untrackedFiles].sort()); + const recordedFiles = new Set(trackedFiles); + assert.deepEqual( + beforeStaging.filter((file) => !recordedFiles.has(file)), + [...untrackedFiles].sort(), + 'new engine files must require a classification before git add', + ); + + git(['add', '--', ...untrackedFiles]); + assert.deepEqual(inventoryEngineFiles(repositoryRoot), beforeStaging, 'staging must not change the inventory'); +}); diff --git a/server/gjc-runtime-manifest.json b/server/gjc-runtime-manifest.json index 169de93d..a7eca7ea 100644 --- a/server/gjc-runtime-manifest.json +++ b/server/gjc-runtime-manifest.json @@ -1,5 +1,5 @@ { - "schemaVersion": 1, + "schemaVersion": 2, "gjcSdk": "0.16.4", "bun": "1.4.0", "natives": "0.16.4", @@ -57,5 +57,175 @@ } ] } + }, + "sdkLifecycle": { + "id": "gjc-sdk-lifecycle-v1", + "packages": { + "@gajae-code/coding-agent": "0.16.4", + "@gajae-code/agent-core": "0.16.4", + "@gajae-code/ai": "0.16.4" + }, + "files": [ + { + "package": "@gajae-code/coding-agent", + "path": "src/sdk/session.ts", + "sha256": "6bc35899fa1386ab4887537eb0bdd068289291d588b0ce4cd1f1660d4075841c" + }, + { + "package": "@gajae-code/coding-agent", + "path": "src/session/agent-session.ts", + "sha256": "838658da725233a0628bf813fc4ca9ff7f060e056636cebcfe6eb03b9a1ecc29" + }, + { + "package": "@gajae-code/agent-core", + "path": "src/run-resource-ledger.ts", + "sha256": "ef54970d42a667e786a92c622d5e89d2d624e3e30ee37842aedddd546efcf390" + }, + { + "package": "@gajae-code/agent-core", + "path": "src/agent-loop.ts", + "sha256": "efccd78060495147b0475aabb69fe10c5cefdabe7bf88b618b16c0ad49e6eab6" + }, + { + "package": "@gajae-code/coding-agent", + "path": "src/config/model-registry.ts", + "sha256": "60529fec5306da95d08127b014ee7b13e83adf6a5e6ebdfff3df53010fff4f67" + }, + { + "package": "@gajae-code/coding-agent", + "path": "src/config/model-preset-registry.ts", + "sha256": "0a7f77c3d3a1cdf09db189d22bfefd4129de6adf3dc5cf72a18b6ef0a7b77835" + }, + { + "package": "@gajae-code/ai", + "path": "src/auth-storage.ts", + "sha256": "a005e9f1d9d3dc515ae04668ab364aef497f7fbce093d12fac97182981240581" + }, + { + "package": "@gajae-code/coding-agent", + "path": "src/config/settings.ts", + "sha256": "9e31c89d986f2f200ec6fb35dd4d68be9f3f5987d80eead57bf1a9d1d8303f38" + }, + { + "package": "@gajae-code/ai", + "path": "src/utils/event-stream.ts", + "sha256": "eb81b0961f3ef800905efb80245049c88b5a0c498e0b79a23caafedc8e519559" + }, + { + "package": "@gajae-code/ai", + "path": "src/utils/idle-iterator.ts", + "sha256": "fbc6742abee2bcf20d22fa174ba7d21e688fde5cec13b32401fe36225ea90fd3" + }, + { + "package": "@gajae-code/ai", + "path": "src/stream.ts", + "sha256": "404db53024a885ef16e6749911964b228d6670d3796e00acfe245956dad28a22" + }, + { + "package": "@gajae-code/ai", + "path": "src/providers/register-builtins.ts", + "sha256": "abceac1f4e6e107da1f0bef71000ba4085508c1f56812127ecf6d556ee751a9d" + }, + { + "package": "@gajae-code/ai", + "path": "src/providers/amazon-bedrock.ts", + "sha256": "972549a554509bc2c2e1f0723c197aadd339c10c8345c0b78420b8499821fd65" + }, + { + "package": "@gajae-code/ai", + "path": "src/providers/anthropic.ts", + "sha256": "eee1c52fdd77f53dad676297560ca5722553088cf9888e7300e3d77507e4eefc" + }, + { + "package": "@gajae-code/ai", + "path": "src/providers/azure-openai-responses.ts", + "sha256": "403196951817447052fbe2f0b059679923f64ce2f1322bcdc44cc1b0a3ff0075" + }, + { + "package": "@gajae-code/ai", + "path": "src/providers/cursor.ts", + "sha256": "3c8cbb82d7e210b126a627292e492ba010ffabe37f408b4dcfe017f2db0593ba" + }, + { + "package": "@gajae-code/ai", + "path": "src/providers/google-gemini-cli.ts", + "sha256": "0a6d88064e796dfb9890af1e88a5ea49b5844128c8a4900b4bc43f765693677b" + }, + { + "package": "@gajae-code/ai", + "path": "src/providers/google-shared.ts", + "sha256": "8348e5dfdc0771292695295738024f339219ffa12d121644ce5b756b49a7871d" + }, + { + "package": "@gajae-code/ai", + "path": "src/providers/kiro-api-key.ts", + "sha256": "b687ff7b23bcd08640652dc910303d1534069569a162082be5e6604baba17d2e" + }, + { + "package": "@gajae-code/ai", + "path": "src/providers/kiro-codewhisperer.ts", + "sha256": "1769145787803a0da584b18ada0393364a576d764f7853e3cd05cf3f63be02fb" + }, + { + "package": "@gajae-code/ai", + "path": "src/providers/ollama.ts", + "sha256": "d2b1272d91d46b92fd494605b78d0619c8f072b45e68ba8f2ef25cfc5aab7df0" + }, + { + "package": "@gajae-code/ai", + "path": "src/providers/openai-completions.ts", + "sha256": "b0ba241c2645acf199dfcab65091d755286b06e5aea949d3eb9512fe25a74323" + }, + { + "package": "@gajae-code/ai", + "path": "src/providers/openai-responses.ts", + "sha256": "bb6991c117a20f2e38ca7d018db3c781c74bbe2c0843b698ef38929929099d26" + }, + { + "package": "@gajae-code/ai", + "path": "src/providers/openai-codex-responses.ts", + "sha256": "961ea9317a7852759c43555ef8a72901025cb17f6de2bfb86e72446197cc87eb" + }, + { + "package": "@gajae-code/ai", + "path": "src/providers/openai-anthropic-shim.ts", + "sha256": "ac5bef1d48b06dbf714fb0afa908d3c57e437050e628d6d37ebf33ee06ea6adb" + }, + { + "package": "@gajae-code/ai", + "path": "src/providers/gitlab-duo.ts", + "sha256": "9a742a461ebe03e0a13f55a756a32ac146518312ce21f5e64f022919b5d15f71" + }, + { + "package": "@gajae-code/ai", + "path": "src/providers/pi-native-client.ts", + "sha256": "64be63baba7dfeac15bfa1a4779d1cf8d3be6c8f9ef14f92ca8ea0b28c829d49" + }, + { + "package": "@gajae-code/coding-agent", + "path": "src/sdk/host/host.ts", + "sha256": "12e497ea081ca73337288cd5ea84f85d8be141372547665bd355beb6628e19ae" + }, + { + "package": "@gajae-code/coding-agent", + "path": "src/sdk/host/session-runtime.ts", + "sha256": "62ba771a376315285efb28ab50676a0ed2d0e1c64e637738bd04eed4e6430cff" + }, + { + "package": "@gajae-code/coding-agent", + "path": "src/sdk/host/websocket-transport.ts", + "sha256": "33ec5a5eb9b7bcee8828205990be0843095f490a8ccecc3166ad94ef844d65a2" + }, + { + "package": "@gajae-code/coding-agent", + "path": "src/sdk/prompt-deadline-manager.ts", + "sha256": "6c5867ff16c5dfc7a550a0c3dfa67309a9ae234cf23253ab7739c55dd790e295" + }, + { + "package": "@gajae-code/coding-agent", + "path": "src/sdk/host/query/revision-store.ts", + "sha256": "db47586d7c821a7c7d5aaf9b2ea6c1dacf2a5c83919babda8a22b4017d6c6e21" + } + ] } } diff --git a/server/gjc-runtime-manifest.test.ts b/server/gjc-runtime-manifest.test.ts new file mode 100644 index 00000000..a127023d --- /dev/null +++ b/server/gjc-runtime-manifest.test.ts @@ -0,0 +1,113 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import path from 'node:path'; +import test from 'node:test'; + +import sdkPolicy from '../shared/sdkLifecyclePolicy.json' with { type: 'json' }; + +import { isVerifiedSdkPatch, verifyRuntimeManifest } from './gjc-runtime-manifest.js'; + +const sha = (value: string) => createHash('sha256').update(value).digest('hex'); +function fixture(t: test.TestContext) { + const platform = `${process.platform}-${process.arch}`; + const root = path.resolve('/runtime-fixture/node_modules/@gajae-code'); + const files = new Map(); + const metadata = (name: string, version = '0.16.4') => { + files.set(path.join(root, name, 'package.json'), JSON.stringify({ name: `@gajae-code/${name}`, version })); + }; + for (const name of ['coding-agent', 'agent-core', 'natives', `natives-${platform}`]) metadata(name); + files.set(path.join(root, `natives-${platform}`, 'native/addon.node'), 'native fixture'); + files.set(path.join(root, 'coding-agent', 'src/session.ts'), 'patched session fixture'); + files.set(path.join(root, 'agent-core', 'src/ledger.ts'), 'patched ledger fixture'); + const manifest: Record = { + schemaVersion: 2, gjcSdk: '0.16.4', natives: '0.16.4', bun: '1.4.0', + platforms: { [platform]: { files: [{ package: `@gajae-code/natives-${platform}`, path: 'native/addon.node', sha256: sha('native fixture') }] } }, + sdkLifecycle: { id: 'gjc-sdk-lifecycle-v1', packages: { '@gajae-code/coding-agent': '0.16.4', '@gajae-code/agent-core': '0.16.4' }, + files: [{ package: '@gajae-code/coding-agent', path: 'src/session.ts', sha256: sha('patched session fixture') }, + { package: '@gajae-code/agent-core', path: 'src/ledger.ts', sha256: sha('patched ledger fixture') }] }, + }; + const override = path.resolve('/runtime-fixture/manifest.json'); + const requested: string[] = []; + const bun = { + version: '1.4.0', + resolveSync(name: string) { return path.join(root, name.slice('@gajae-code/'.length), 'index.ts'); }, + file(filename: string) { + requested.push(filename); + const content = () => { + const value = filename === override ? JSON.stringify(manifest) : files.get(filename); + if (value === undefined) throw new Error('missing fixture file'); + return value; + }; + return { text: async () => content(), arrayBuffer: async () => Uint8Array.from(Buffer.from(content())).buffer }; + }, + }; + const descriptor = Object.getOwnPropertyDescriptor(globalThis, 'Bun'); + const previousAllow = process.env.GJC_ALLOW_RUNTIME_MANIFEST_OVERRIDE; + const previousPath = process.env.GJC_RUNTIME_MANIFEST_PATH; + t.after(() => { + if (descriptor) Object.defineProperty(globalThis, 'Bun', descriptor); + else delete (globalThis as unknown as Record).Bun; + if (previousAllow === undefined) delete process.env.GJC_ALLOW_RUNTIME_MANIFEST_OVERRIDE; + else process.env.GJC_ALLOW_RUNTIME_MANIFEST_OVERRIDE = previousAllow; + if (previousPath === undefined) delete process.env.GJC_RUNTIME_MANIFEST_PATH; + else process.env.GJC_RUNTIME_MANIFEST_PATH = previousPath; + }); + Object.defineProperty(globalThis, 'Bun', { configurable: true, value: bun }); + process.env.GJC_ALLOW_RUNTIME_MANIFEST_OVERRIDE = '1'; + process.env.GJC_RUNTIME_MANIFEST_PATH = override; + return { files, manifest, root, requested, metadata, bun }; +} + +test('manifest v2 checks native and every SDK post-hash; test overrides cannot mint source-integrity receipts', async (t) => { + const f = fixture(t); + assert.equal(await verifyRuntimeManifest(), undefined); + assert.ok(f.requested.includes(path.join(f.root, 'coding-agent', 'src/session.ts'))); + assert.ok(f.requested.includes(path.join(f.root, 'agent-core', 'src/ledger.ts'))); +}); + +test('shared SDK file-count limit admits the full boundary and rejects an extra member', async (t) => { + const f = fixture(t); + while (f.manifest.sdkLifecycle.files.length < sdkPolicy.maxFiles) { + const relative = `src/provider-${f.manifest.sdkLifecycle.files.length}.ts`; + f.files.set(path.join(f.root, 'coding-agent', relative), 'patched provider'); + f.manifest.sdkLifecycle.files.push({ package: '@gajae-code/coding-agent', path: relative, sha256: sha('patched provider') }); + } + await verifyRuntimeManifest(); + f.manifest.sdkLifecycle.files.push({ package: '@gajae-code/coding-agent', path: 'src/overflow.ts', sha256: sha('patched provider') }); + await assert.rejects(verifyRuntimeManifest(), { message: 'GJC runtime manifest validation failed.' }); +}); + +for (const [name, mutate] of Object.entries({ + legacy: (f: ReturnType) => { f.manifest.schemaVersion = 1; }, + omitted: (f: ReturnType) => { delete f.manifest.sdkLifecycle; }, + empty: (f: ReturnType) => { f.manifest.sdkLifecycle.files = []; }, + partial: (f: ReturnType) => { f.manifest.sdkLifecycle.files.pop(); }, + duplicate: (f: ReturnType) => { f.manifest.sdkLifecycle.files.push(f.manifest.sdkLifecycle.files[0]); }, + traversal: (f: ReturnType) => { f.manifest.sdkLifecycle.files[0].path = 'src/../../secret.ts'; }, + mixed: (f: ReturnType) => { f.files.set(path.join(f.root, 'agent-core', 'src/ledger.ts'), 'pristine source'); }, + wrongVersion: (f: ReturnType) => { f.metadata('agent-core', '0.16.6'); }, + wrongDigest: (f: ReturnType) => { f.manifest.sdkLifecycle.files[0].sha256 = '0'.repeat(64); }, + nativeTamper: (f: ReturnType) => { f.manifest.platforms[`${process.platform}-${process.arch}`].files[0].sha256 = '0'.repeat(64); }, +})) { + test(`manifest rejects ${name} lifecycle/native evidence before SDK creation`, async (t) => { + const f = fixture(t); mutate(f); + await assert.rejects(verifyRuntimeManifest(), { message: 'GJC runtime manifest validation failed.' }); + assert.ok(!f.requested.some((filename) => filename.includes('secret'))); + }); +} + +test('a marker or serialized receipt cannot impersonate the trusted bootstrap result', () => { + assert.equal(isVerifiedSdkPatch({ id: 'gjc-sdk-lifecycle-v1' }), false); + assert.equal(isVerifiedSdkPatch(JSON.parse('{"id":"gjc-sdk-lifecycle-v1"}')), false); + assert.equal(isVerifiedSdkPatch(null), false); +}); + +test('matching top-level hashes do not certify a different SDK-nested core instance', async (t) => { + const f = fixture(t); + const nested = path.join(f.root, 'coding-agent/node_modules/@gajae-code/agent-core'); + f.files.set(path.join(nested, 'package.json'), JSON.stringify({ name: '@gajae-code/agent-core', version: '0.16.4' })); + const original = f.bun.resolveSync; + f.bun.resolveSync = (name: string, from?: string) => name === '@gajae-code/agent-core' && from?.includes('coding-agent') + ? path.join(nested, 'index.ts') : original(name); + await assert.rejects(verifyRuntimeManifest(), { message: 'GJC runtime manifest validation failed.' }); +}); diff --git a/server/gjc-runtime-manifest.ts b/server/gjc-runtime-manifest.ts index 987eb771..5be4c475 100644 --- a/server/gjc-runtime-manifest.ts +++ b/server/gjc-runtime-manifest.ts @@ -1,6 +1,8 @@ import { isAbsolute, dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; +import sdkPolicy from '../shared/sdkLifecyclePolicy.json' with { type: 'json' }; + import manifest from './gjc-runtime-manifest.json' with { type: 'json' }; type RuntimeManifestFile = { @@ -19,8 +21,21 @@ type RuntimeManifest = { bun: string; natives: string; platforms: Record; + sdkLifecycle: { + id: string; + packages: Record; + files: RuntimeManifestFile[]; + }; }; +const sdkLifecycleProofs = new WeakSet(); +declare const verifiedSdkPatchBrand: unique symbol; +/** Source-integrity evidence only, NOT complete SDK quiescence or installation authority. */ +export type VerifiedSdkPatch = Readonly<{ id: string; [verifiedSdkPatchBrand]: true }>; +export function isVerifiedSdkPatch(value: unknown): value is VerifiedSdkPatch { + return typeof value === 'object' && value !== null && sdkLifecycleProofs.has(value); +} + type BunRuntime = { version: string; resolveSync(specifier: string, from: string): string; @@ -32,6 +47,31 @@ type PackageMetadata = { name?: unknown; version?: unknown }; const RUNTIME_MANIFEST_FAILURE = 'GJC runtime manifest validation failed.'; const SHA256 = /^[a-f0-9]{64}$/; const resolverFrom = fileURLToPath(new URL('.', import.meta.url)); +const SDK_PACKAGES = new Set(['@gajae-code/coding-agent', '@gajae-code/agent-core', '@gajae-code/ai']); +const REQUIRED_SDK_PACKAGES = ['@gajae-code/coding-agent', '@gajae-code/agent-core']; + +function validSdkLifecycle(value: unknown): value is RuntimeManifest['sdkLifecycle'] { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const patch = value as RuntimeManifest['sdkLifecycle']; + if (Object.keys(patch).length !== 3 || typeof patch.id !== 'string' || !/^[a-z][a-z0-9-]{0,127}$/u.test(patch.id) + || !patch.packages || typeof patch.packages !== 'object' || Array.isArray(patch.packages) + || Object.keys(patch.packages).length < REQUIRED_SDK_PACKAGES.length || Object.keys(patch.packages).length > SDK_PACKAGES.size + || REQUIRED_SDK_PACKAGES.some((name) => !Object.hasOwn(patch.packages, name)) + || Object.entries(patch.packages).some(([name, version]) => !SDK_PACKAGES.has(name) || typeof version !== 'string' || !/^\d+\.\d+\.\d+$/u.test(version)) + || sdkPolicy.schemaVersion !== 1 || !Number.isSafeInteger(sdkPolicy.maxFiles) || sdkPolicy.maxFiles < 1 || sdkPolicy.maxFiles > 128 + || !Array.isArray(patch.files) || !patch.files.length || patch.files.length > sdkPolicy.maxFiles) return false; + const seen = new Set(); + for (const file of patch.files) { + if (!file || typeof file !== 'object' || Object.keys(file).length !== 3 + || !Object.hasOwn(patch.packages, file.package) || typeof file.path !== 'string' + || !/^src\/[A-Za-z0-9._/-]+\.ts$/u.test(file.path) + || file.path.split('/').some((part) => !part || part === '.' || part === '..') || !SHA256.test(file.sha256)) return false; + const key = `${file.package}/${file.path}`; + if (seen.has(key)) return false; + seen.add(key); + } + return Object.keys(patch.packages).every((name) => patch.files.some((file) => file.package === name)); +} function validFile(file: unknown): file is RuntimeManifestFile { return typeof file === 'object' @@ -58,13 +98,14 @@ async function runtimeManifest(bun: BunRuntime): Promise } return typeof value === 'object' && value !== null - && (value as RuntimeManifest).schemaVersion === 1 + && (value as RuntimeManifest).schemaVersion === 2 && typeof (value as RuntimeManifest).gjcSdk === 'string' && typeof (value as RuntimeManifest).bun === 'string' && typeof (value as RuntimeManifest).natives === 'string' && typeof (value as RuntimeManifest).platforms === 'object' && (value as RuntimeManifest).platforms !== null && Object.values((value as RuntimeManifest).platforms).every((platform) => Array.isArray(platform.files) && platform.files.every(validFile)) + && validSdkLifecycle((value as RuntimeManifest).sdkLifecycle) ? value as RuntimeManifest : null; } @@ -88,8 +129,8 @@ async function packageMetadata(bun: BunRuntime, packageRoot: string): Promise { - const resolved = bun.resolveSync(specifier, resolverFrom); +async function packageRoot(bun: BunRuntime, specifier: string, from = resolverFrom): Promise { + const resolved = bun.resolveSync(specifier, from); let directory = dirname(resolved); while (directory !== dirname(directory)) { const metadata = await packageMetadata(bun, directory); @@ -108,7 +149,7 @@ async function sha256Hex(bun: BunRuntime, path: string): Promise { * Verifies the pinned Bun runtime and installed GJC packages before starting a Bun worker. * This module remains importable in Node; only this function requires Bun globals. */ -export async function verifyRuntimeManifest(): Promise { +export async function verifyRuntimeManifest(): Promise { try { const bun = bunRuntime(); const expected = bun ? await runtimeManifest(bun) : null; @@ -144,6 +185,36 @@ export async function verifyRuntimeManifest(): Promise { : null; if (!root || await sha256Hex(bun, join(root, file.path)) !== file.sha256) throw new Error(); } + const sdkRoots = new Map([['@gajae-code/coding-agent', sdkRoot]]); + for (const [name, version] of Object.entries(expected.sdkLifecycle.packages)) { + const root = sdkRoots.get(name) ?? await packageRoot(bun, name); + if (!root || (await packageMetadata(bun, root))?.version !== version) throw new Error(); + sdkRoots.set(name, root); + } + // The application and the SDK/core must load the same patched instances. + // A matching top-level copy cannot certify an unpatched nested dependency. + for (const [consumer, dependencies] of [ + ['@gajae-code/coding-agent', ['@gajae-code/agent-core', '@gajae-code/ai']], + ['@gajae-code/agent-core', ['@gajae-code/ai']], + ] as const) { + const consumerRoot = sdkRoots.get(consumer); + if (!consumerRoot) throw new Error(); + for (const dependency of dependencies) { + if (!sdkRoots.has(dependency)) continue; + if (await packageRoot(bun, dependency, join(consumerRoot, 'src')) !== sdkRoots.get(dependency)) throw new Error(); + } + } + if (expected.sdkLifecycle.packages['@gajae-code/coding-agent'] !== expected.gjcSdk) throw new Error(); + for (const file of expected.sdkLifecycle.files) { + const root = sdkRoots.get(file.package); + if (!root || await sha256Hex(bun, join(root, file.path)) !== file.sha256) throw new Error(); + } + // Test overrides may exercise native closure rejection, but cannot grant + // production lifetime evidence for arbitrary alternate SDK implementations. + if (process.env.GJC_ALLOW_RUNTIME_MANIFEST_OVERRIDE === '1') return undefined; + const proof = Object.freeze({ id: expected.sdkLifecycle.id }) as VerifiedSdkPatch; + sdkLifecycleProofs.add(proof); + return proof; } catch { throw new Error(RUNTIME_MANIFEST_FAILURE); } diff --git a/server/gjc-sdk-contract.bun.test.ts b/server/gjc-sdk-contract.bun.test.ts index 681ec5e7..2e4400b7 100644 --- a/server/gjc-sdk-contract.bun.test.ts +++ b/server/gjc-sdk-contract.bun.test.ts @@ -11,6 +11,7 @@ import { createAgentSession, discoverAuthStorage, type AutomationTools } from '@ import { ModelRegistry } from '@gajae-code/coding-agent/config/model-registry'; import { Settings } from '@gajae-code/coding-agent/config/settings'; import { SessionManager } from '@gajae-code/coding-agent/session/session-manager'; +import { SessionDisposalIncompleteError } from '@gajae-code/coding-agent/session/agent-session'; import { AsyncJobManager } from '@gajae-code/coding-agent/async/job-manager'; import { registerCustomApi, unregisterCustomApis } from '@gajae-code/ai/api-registry'; import { AssistantMessageEventStream } from '@gajae-code/ai/utils/event-stream'; @@ -38,6 +39,11 @@ import { import { GjcWorkerHost } from './gjc-worker.js'; import { GJC_MODEL_UNRESOLVED_CODE, GJC_MODEL_UNRESOLVED_MESSAGE } from './gjc-model-resolution.js'; import { GJC_CLEANUP_UNCONFIRMED_CODE } from './gjc-cleanup-error.js'; +import { isVerifiedSdkPatch, verifyRuntimeManifest } from './gjc-runtime-manifest.js'; + +// The test starts an isolated in-process broker so enabled hosting never needs +// to spawn a detached broker. Resolve within this exact SDK source instance. +const { Broker } = await import(new URL('./broker/broker.ts', import.meta.resolve('@gajae-code/coding-agent/sdk/session')).href); type Listener = (event: unknown) => void; type Deferred = { promise: Promise; resolve(value: T): void; reject(error: Error): void }; @@ -59,6 +65,8 @@ type OAuthLogin = (provider: string, callbacks: OAuthCallbacks) => Promise const globalMethods = new Set([ 'worker.initialize', + 'worker.activity', + 'worker.admission', 'worker.shutdown', 'models.catalog', 'oauth.providers', @@ -147,6 +155,7 @@ class FakeAgentSession { isStreaming = true; abortDeferred: Deferred | undefined; disposeError: Error | undefined; + disposeDeferred: Deferred | undefined; promptCalls = 0; /** Messages that arrived while a turn was already running. */ readonly steeredMessages: string[] = []; @@ -204,6 +213,7 @@ class FakeAgentSession { } async dispose(): Promise { this.disposed = true; + await this.disposeDeferred?.promise; if (this.disposeError) throw this.disposeError; } async setModelTemporary(model: unknown, thinkingLevel: unknown, options: unknown): Promise { @@ -251,7 +261,12 @@ async function fixture( const sessions: FakeAgentSession[] = []; const factoryOptions: Array> = []; const trace: string[] = []; + // These objects have no autonomous SDK work. Explicit fixture owners keep + // adapter tests honest without treating absent production readers as idle. + const idleLeaf = (name: string) => ({ generation: `fixture:${name}`, complete: true, + starting: 0, queued: 0, running: 0, settling: 0, unknown: [] as string[] }); const authStorage = { + getAppLifecycleActivity: () => idleLeaf('auth'), credentials: [] as Array<{ id: number; provider: string }>, /** Providers `peekApiKey` reports a key for (models.yml apiKey/apiKeyEnv, env fallback). */ resolvableProviders: new Set(), @@ -267,6 +282,8 @@ async function fixture( }; const models = Array.isArray(modelOrModels) ? modelOrModels : [modelOrModels]; const modelRegistry = { + getAppLifecycleActivity: () => idleLeaf('registry'), + setAppLifecycleAdmission: (_closed: boolean) => {}, authStorage, getAll: () => models, getAvailable: () => models, @@ -314,6 +331,7 @@ async function fixture( get: (key: string) => overrides.get(key), }); const settings = { + getAppLifecycleActivity: () => idleLeaf('settings'), getModelRole: () => defaultModel || undefined, get: (key: string) => key === 'modelProfile.default' ? modelProfile : undefined, cloneForCwd: async () => settingsClone(), @@ -341,7 +359,7 @@ async function fixture( spawns: 'deny', bashPolicy: { allowedPrefixes: [] }, }; - return { root, adapter, authStorage, modelRegistry, trace, factoryOptions, sessions, frames, host, options, toolPolicyOverrides: overrides, close: () => rm(root, { recursive: true, force: true }) }; + return { root, adapter, authStorage, modelRegistry, settings, trace, factoryOptions, sessions, frames, host, options, toolPolicyOverrides: overrides, close: () => rm(root, { recursive: true, force: true }) }; } function methods(frames: Array>): string[] { return frames.filter((frame) => frame.kind === 'event').map((frame) => frame.method as string); } @@ -1299,7 +1317,10 @@ test('resume opens the sole exact session file and never re-emits session.create }); /** Real SDK construction; prompts are intercepted before any model transport can run. */ -async function identityFixture() { +async function identityFixture(behavior: { + realPrompts?: boolean; + onCreated?: (session: Awaited>['session']) => void; +} = {}) { const root = await mkdtemp(join(tmpdir(), 'gjc-sdk-identity-')); const cwd = join(root, 'project'); const agentDir = join(root, 'agent'); @@ -1344,12 +1365,15 @@ async function identityFixture() { try { const result = await createAgentSession(sdkOptions); sessions.push(result.session); - const inspect = inspections.shift(); - assert.ok(inspect, 'each SDK session needs an explicit offline prompt handler'); - result.session.prompt = async () => { - try { await inspect(result.session); } - catch (error) { failures.push(error); throw error; } - }; + behavior.onCreated?.(result.session); + if (!behavior.realPrompts) { + const inspect = inspections.shift(); + assert.ok(inspect, 'each SDK session needs an explicit offline prompt handler'); + result.session.prompt = async () => { + try { await inspect(result.session); } + catch (error) { failures.push(error); throw error; } + }; + } return result; } catch (error) { failures.push(error); throw error; } }, @@ -1364,7 +1388,7 @@ async function identityFixture() { toolNames: ['bash', 'skill'], spawns: 'deny', bashPolicy: { allowedPrefixes: [] }, }; return { - root, options, factoryOptions, host, frames, + root, options, factoryOptions, host, frames, adapter, sessions, enqueueInspection(inspect: (session: Session) => Promise) { inspections.push(inspect); }, async run(id: string, inspect: (session: Session) => Promise, providerSessionId?: string) { inspections.push(inspect); @@ -2709,6 +2733,686 @@ test('the first turn of a new session titles it from the first message and tells } finally { await f.close(); } }); +test('SDK activity retains late title generation and persistence after the UI grace expires', async () => { + const generated = deferred(); + const persisted = deferred(); + const writing = deferred(); + const f = await fixture(undefined, undefined, undefined, undefined, undefined, undefined, { + sessionTitleGraceMs: 0, + generateSessionTitle: () => generated.promise, + }); + try { + const run = f.host.handle(request('session.start', 'late-title', { message: 'title-canary', options: f.options })); + const session = await firstSession(f.sessions); + await session.promptStarted.promise; + const manager = f.factoryOptions[0]!.sessionManager as SessionManager; + const setName = manager.setSessionName.bind(manager); + manager.setSessionName = async (name, source) => { + writing.resolve(); + await persisted.promise; + return setName(name, source); + }; + assert.equal(f.adapter.snapshotActivity().background, 1); + session.complete(); + await run; + assert.ok(f.frames.some((frame) => (frame.payload as { message?: { kind?: string } })?.message?.kind === 'complete'), + 'the title request must not hold UI completion past its grace period'); + const terminal = f.adapter.snapshotActivity(); + assert.equal(terminal.running + terminal.starting + terminal.settling, 0); + assert.equal(terminal.background, 1, 'UI complete is not background task completion'); + assert.equal(JSON.stringify(terminal).includes('title-canary'), false); + generated.resolve('Delayed title'); + await writing.promise; + assert.equal(f.adapter.snapshotActivity().background, 1, 'title persistence is part of the owned task'); + persisted.resolve(); + await waitFor(() => f.adapter.snapshotActivity().background === 0 ? true : undefined); + assert.ok(f.adapter.snapshotActivity().revision > terminal.revision); + assert.notEqual(f.adapter.getGeneration(), terminal.generation); + assert.equal(terminal.background, 1, 'earlier snapshots must remain detached'); + } finally { + generated.resolve(null); persisted.resolve(); + for (const session of f.sessions) session.complete(); + await waitFor(() => f.adapter.snapshotActivity().background === 0 ? true : undefined); + await f.close(); + } +}); + +for (const outcome of ['resolve', 'reject'] as const) { + test(`SDK activity retains a title after user cancellation until its actual ${outcome}`, async () => { + const generated = deferred(); + const f = await fixture(undefined, undefined, undefined, undefined, undefined, undefined, { + sessionTitleGraceMs: 0, + generateSessionTitle: () => generated.promise, + }); + try { + const run = f.adapter.spawnGjc('cancel title', { ...f.options, runHandle: 'cancel-title' }, { send() {} }); + const session = await firstSession(f.sessions); + await session.promptStarted.promise; + const before = f.adapter.getGeneration(); + assert.equal(await f.adapter.abortGjcSession('cancel-title'), true); + await run; + const cancelled = f.adapter.snapshotActivity(); + assert.equal(cancelled.background, 1); + assert.equal(cancelled.running + cancelled.starting + cancelled.settling + cancelled.operations, 0); + assert.notEqual(cancelled.generation, before); + if (outcome === 'resolve') generated.resolve(null); + else generated.reject(new Error('late-title-credential-canary')); + await waitFor(() => f.adapter.snapshotActivity().background === 0 ? true : undefined); + assert.ok(f.adapter.snapshotActivity().revision > cancelled.revision); + assert.equal(JSON.stringify(f.adapter.snapshotActivity()).includes('late-title-credential-canary'), false); + } finally { + generated.resolve(null); + for (const session of f.sessions) session.complete(); + await waitFor(() => f.adapter.snapshotActivity().background === 0 ? true : undefined); + await f.close(); + } + }); +} + +test('SDK activity releases overlapping title tasks independently and absorbs synchronous generator failure', async () => { + const first = deferred(); + const second = deferred(); + let titles = 0; + const f = await fixture(undefined, undefined, undefined, undefined, undefined, undefined, { + sessionTitleGraceMs: 0, + generateSessionTitle: () => { + titles += 1; + if (titles === 1) return first.promise; + if (titles === 2) return second.promise; + throw new Error('synchronous title failure'); + }, + }); + try { + for (let index = 0; index < 3; index += 1) { + const run = f.adapter.spawnGjc('title', { ...f.options, runHandle: `overlap-${index}` }, { send() {} }); + const session = await waitFor(() => f.sessions[index]); + await session.promptStarted.promise; + session.complete(); + await run; + } + assert.equal(f.adapter.snapshotActivity().background, 2); + const before = f.adapter.getGeneration(); + second.reject(new Error('second title failure')); + await waitFor(() => f.adapter.snapshotActivity().background === 1 ? true : undefined); + assert.notEqual(f.adapter.getGeneration(), before); + first.resolve(null); + await waitFor(() => f.adapter.snapshotActivity().background === 0 ? true : undefined); + } finally { + first.resolve(null); second.resolve(null); + for (const session of f.sessions) session.complete(); + await waitFor(() => f.adapter.snapshotActivity().background === 0 ? true : undefined); + await f.close(); + } +}); + +test('SDK activity revisions cover reservation, SDK events and actual cleanup settlement', async () => { + const disposed = deferred(); + const f = await fixture(); + try { + const initial = f.adapter.snapshotActivity(); + assert.equal(initial.complete, true); + assert.equal(initial.revision, 0); + const run = f.adapter.spawnGjc('hello', { ...f.options, runHandle: 'activity-root' }, { send() {} }); + const starting = f.adapter.snapshotActivity(); + assert.equal(starting.starting, 1, 'reserve before the first await'); + assert.ok(starting.revision > initial.revision); + const session = await firstSession(f.sessions); + await session.promptStarted.promise; + session.disposeDeferred = disposed; + const active = f.adapter.snapshotActivity(); + assert.equal(active.starting, 0); + assert.equal(active.running, 1); + assert.deepEqual(f.adapter.snapshotActivity(), active); + assert.equal(f.adapter.getGeneration(), active.generation); + session.emit({ type: 'tool_execution_start', toolCallId: 'canary', toolName: 'bash', args: { command: 'secret-command-canary' } }); + assert.notEqual(f.adapter.getGeneration(), active.generation); + session.complete(); + await waitFor(() => session.disposed ? true : undefined); + const settling = f.adapter.snapshotActivity(); + assert.equal(settling.settling, 1); + assert.equal(settling.running + settling.starting, 0); + assert.equal(JSON.stringify(settling).includes('secret-command-canary'), false); + disposed.resolve(); + await run; + const completed = f.adapter.snapshotActivity(); + assert.equal(completed.running + completed.starting + completed.settling + completed.background, 0); + assert.ok(completed.revision > settling.revision); + assert.equal(completed.complete, false, 'adapter counts alone do not prove SDK background containment'); + assert.deepEqual(completed.unknown, ['sdk_background_ownership_unproven']); + } finally { + disposed.resolve(); + for (const session of f.sessions) session.complete(); + await f.close(); + } +}); + +test('SDK activity keeps a user-abort operation owned after the run has completed', async () => { + const automationClosed = deferred(); + const f = await fixture(undefined, undefined, undefined, undefined, undefined, undefined, { + closeAutomationSession: async () => automationClosed.promise, + }); + try { + const run = f.adapter.spawnGjc('hello', { ...f.options, appSessionId: 'owned-app', runHandle: 'owned-abort' }, { send() {} }); + const session = await firstSession(f.sessions); + await session.promptStarted.promise; + const abort = f.adapter.abortGjcSession('owned-abort'); + assert.equal(f.adapter.snapshotActivity().operations, 1); + await run; + const waiting = f.adapter.snapshotActivity(); + assert.equal(waiting.running + waiting.starting + waiting.settling, 0); + assert.equal(waiting.operations, 1); + assert.deepEqual(f.adapter.snapshotActivity(), waiting, 'snapshot must never dispose work to become idle'); + automationClosed.resolve(); + assert.equal(await abort, true); + assert.equal(f.adapter.snapshotActivity().operations, 0); + assert.ok(f.adapter.snapshotActivity().revision > waiting.revision); + } finally { + automationClosed.resolve(); + for (const session of f.sessions) session.complete(); + await f.close(); + } +}); + +test('SDK activity composes OAuth cancellation settlement and revisions without inspecting credentials', async () => { + const loginDone = deferred(); + const loginStarted = deferred(); + const f = await fixture(undefined, undefined, undefined, undefined, async () => { + loginStarted.resolve(); + await loginDone.promise; + }); + try { + const initial = f.adapter.snapshotActivity(); + const attempt = f.adapter.oauth.start('openai-codex'); + assert.equal(typeof attempt.attemptId, 'string'); + assert.equal(f.adapter.snapshotActivity().oauth.starting, 1); + assert.notEqual(f.adapter.getGeneration(), initial.generation); + await loginStarted.promise; + f.adapter.oauth.cancel(attempt.attemptId as string); + const cancelled = f.adapter.snapshotActivity(); + assert.equal(cancelled.oauth.settling, 1); + assert.ok(cancelled.revision > initial.revision); + assert.deepEqual(f.adapter.snapshotActivity(), cancelled); + const exportSnapshot = f.authStorage.exportSnapshot; + f.authStorage.exportSnapshot = () => { throw new Error('activity must not access credentials'); }; + try { assert.deepEqual(f.adapter.snapshotActivity(), cancelled); } + finally { f.authStorage.exportSnapshot = exportSnapshot; } + loginDone.resolve(); + await waitFor(() => f.adapter.snapshotActivity().oauth.settling === 0 ? true : undefined); + assert.ok(f.adapter.snapshotActivity().revision > cancelled.revision); + assert.equal(f.adapter.snapshotActivity().complete, true, 'no SDK session was created'); + } finally { + f.adapter.oauth.close(); loginDone.resolve(); + await waitFor(() => f.adapter.snapshotActivity().oauth.settling === 0 ? true : undefined); + await f.close(); + } +}); + +test('adapter admission fences new roots but preserves accepted session startup and late title ownership', async () => { + const settingsReady = deferred(); + const titleDone = deferred(); + const f = await fixture(undefined, undefined, undefined, undefined, undefined, undefined, { + loadSettings: () => settingsReady.promise, + sessionTitleGraceMs: 0, + generateSessionTitle: () => titleDone.promise, + }); + try { + const run = f.adapter.spawnGjc('accepted', { ...f.options, runHandle: 'accepted-before-fence' }, { send() {} }); + assert.equal(f.adapter.snapshotActivity().starting, 1); + f.adapter.setAdmissionFence(true); + assert.throws(() => f.adapter.spawnGjc('blocked', { ...f.options, runHandle: 'blocked' }, { send() {} }), { code: 'worker_admission_fenced' }); + assert.throws(() => f.adapter.oauth.start('openai-codex'), { code: 'worker_admission_fenced' }); + await assert.rejects(f.adapter.modelCatalog(), { code: 'worker_admission_fenced' }); + settingsReady.resolve(f.settings as unknown as Settings); + const session = await firstSession(f.sessions); + await session.promptStarted.promise; + assert.equal(session.aborted, false, 'fencing does not abort an accepted root during startup'); + assert.equal(await f.adapter.steerGjcSession('accepted-before-fence', 'continue owned work'), true); + session.complete(); + await run; + assert.equal(f.adapter.observeActivity().settling, 1, 'the accepted title remains owned after terminal UI completion'); + titleDone.resolve(null); + await waitFor(() => f.adapter.observeActivity().settling === 0 ? true : undefined); + assert.deepEqual(f.adapter.observeActivity().unknown, ['sdk_background_ownership_unproven']); + f.adapter.setAdmissionFence(false); + assert.ok(Array.isArray((await f.adapter.modelCatalog()).models)); + } finally { + titleDone.resolve(null); + for (const session of f.sessions) session.complete(); + await f.close(); + } +}); + +test('worker observation certifies an unused SDK adapter and preserves OAuth unwind behind the fence', async () => { + const loginDone = deferred(); + const loginStarted = deferred(); + const f = await fixture(undefined, undefined, undefined, undefined, async () => { + loginStarted.resolve(); await loginDone.promise; + }); + const observe = async (id: string) => { + await f.host.handle(request('worker.activity', id)); + return (response(f.frames, id).payload as { result: { complete: boolean; settling: number; generation: string; unknown: string[] } }).result; + }; + try { + await f.host.handle(request('worker.admission', 'first-fence', { fenceId: 'f1', closed: true })); + const first = await observe('first-idle'); + assert.equal(first.complete, true); + assert.equal(first.settling, 0); + assert.deepEqual(await observe('second-idle'), first); + await f.host.handle(request('worker.admission', 'release', { fenceId: 'f1', closed: false })); + const attempt = f.adapter.oauth.start('openai-codex'); + await loginStarted.promise; + await f.host.handle(request('worker.admission', 'second-fence', { fenceId: 'f2', closed: true })); + f.adapter.oauth.cancel(attempt.attemptId as string); + assert.equal((await observe('unwind')).settling, 1); + assert.equal(f.sessions.length, 0); + loginDone.resolve(); + await waitFor(() => f.adapter.snapshotActivity().oauth.settling === 0 ? true : undefined); + const settled = await observe('oauth-settled'); + assert.equal(settled.complete, true); + assert.equal(settled.settling, 0); + assert.deepEqual(settled.unknown, []); + } finally { + loginDone.resolve(); + await waitFor(() => f.adapter.snapshotActivity().oauth.settling === 0 ? true : undefined); + await f.close(); + } +}); + +test('real SDK adapter becomes eligible after its actively enabled default host completes cleanup', async () => { + const f = await identityFixture(); + const broker = new Broker({ agentDir: join(f.root, 'agent') }); + try { + assert.notEqual(process.env.GJC_SDK_DISABLE, '1'); + await broker.start(); + await f.run('actual-sdk-disposal', async (session) => { + assert.equal(typeof session.awaitDisposeCompletion, 'function'); + await session.extensionRunner!.emit({ type: 'session_start' }); + const endpoint = join(f.options.cwd, '.gjc/state/sdk', `${session.sessionManager.getSessionId()}.json`); + assert.match(JSON.parse(await readFile(endpoint, 'utf8')).url, /^ws:\/\/127\.0\.0\.1:/); + assert.equal(f.adapter.observeActivity().complete, true, 'coverage is not idle while a run is active'); + assert.ok(f.adapter.observeActivity().running > 0); + }); + await f.sessions[0]!.awaitDisposeCompletion(); + // Stop future maintenance admission, not accepted work; an open registry + // deliberately represents its next scheduled refresh as pending startup. + f.adapter.setAdmissionFence(true); + const actual = f.adapter.observeActivity(); + assert.equal(actual.starting + actual.running + actual.settling, 0); + assert.deepEqual(actual.unknown, []); + assert.equal(actual.complete, true); + } finally { + for (const session of f.sessions) await session.awaitDisposeCompletion().catch(() => {}); + await broker.stop(); await f.close(); + } +}); + +test('SDK physical session receipt clears represented work only after retained disposal', async () => { + const physical = deferred(); const entered = deferred(); + const session = new FakeAgentSession(); let finished = false; let revision = 0; + const factory = (async () => { + Object.assign(session, { + getAppLifecycleActivity: () => ({ generation: `physical-session:${revision}`, complete: true, + starting: 0, queued: 0, running: finished ? 0 : 1, settling: 0, unknown: [] }), + awaitDisposeCompletion: async () => { entered.resolve(); await physical.promise; finished = true; revision++; }, + }); + return { session, setToolUIContext: session.setToolUIContext.bind(session) }; + }) as unknown as GjcAgentSessionFactory; + const f = await fixture(undefined, undefined, undefined, undefined, undefined, undefined, { createSessionFactory: factory }); + const run = f.adapter.spawnGjc('offline owned', { ...f.options, runHandle: 'physical-session' }, { send() {} }); + try { + await session.promptStarted.promise; + session.complete(); await entered.promise; + f.adapter.setAdmissionFence(true); + const held = f.adapter.observeActivity(); + assert.ok(held.settling > 0); + assert.deepEqual(held.unknown, []); + assert.equal(finished, false); + physical.resolve(); await run; + const done = f.adapter.observeActivity(); + assert.equal(done.starting + done.queued + done.running + done.settling, 0); + assert.equal(done.complete, true); + assert.deepEqual(done.unknown, []); + assert.notEqual(done.generation, held.generation); + } finally { physical.resolve(); session.complete(); await run; await f.close(); } +}); + +test('SDK physical disposal retains feature-specific unknown instead of blanket-clearing it', async () => { + const session = new FakeAgentSession(); + const factory = (async () => { + Object.assign(session, { + getAppLifecycleActivity: () => ({ generation: `opaque:${Number(session.disposed)}`, complete: false, + starting: 0, queued: 0, running: session.disposed ? 0 : 1, settling: 0, + unknown: ['sdk_provider_producer_unrepresented'] }), + }); + return { session, setToolUIContext: session.setToolUIContext.bind(session) }; + }) as unknown as GjcAgentSessionFactory; + const f = await fixture(undefined, undefined, undefined, undefined, undefined, undefined, { createSessionFactory: factory }); + const run = f.adapter.spawnGjc('offline opaque', { ...f.options, runHandle: 'opaque-session' }, { send() {} }); + try { + await session.promptStarted.promise; session.complete(); await run; + f.adapter.setAdmissionFence(true); + const done = f.adapter.observeActivity(); + assert.equal(done.complete, false); + assert.deepEqual(done.unknown, ['sdk_provider_producer_unrepresented']); + } finally { session.complete(); await run; await f.close(); } +}); + +test('SDK rejected factory cannot discharge potentially escaped creation work without an owner', async () => { + const f = await fixture(undefined, undefined, undefined, undefined, undefined, undefined, { + createSessionFactory: async () => { throw new Error('offline startup failure'); }, + }); + try { + await assert.rejects(f.adapter.spawnGjc('offline creation', { ...f.options, runHandle: 'failed-factory' }, { send() {} })); + f.adapter.setAdmissionFence(true); + assert.deepEqual(f.adapter.observeActivity().unknown, ['sdk_background_ownership_unproven']); + } finally { await f.close(); } +}); + +test('missing constructor leaf owners remain unknown and real leaf revisions affect the worker proof', async () => { + const f = await fixture(); + try { + for (const [owner, reason] of [[f.authStorage, 'sdk_auth_ownership_unproven'], + [f.modelRegistry, 'sdk_registry_ownership_unproven'], [f.settings, 'sdk_settings_ownership_unproven']] as const) { + const read = owner.getAppLifecycleActivity; + const state = { ...read(), generation: 'leaf:idle' }; + owner.getAppLifecycleActivity = () => ({ ...state, unknown: [...state.unknown] }); + const before = f.adapter.getGeneration(); + state.running = 1; state.generation = 'leaf:running'; + assert.ok(f.adapter.observeActivity().running > 0); + assert.notEqual(f.adapter.getGeneration(), before); + state.running = 0; state.generation = 'leaf:finished'; + assert.notEqual(f.adapter.getGeneration(), before, 'an idle/busy/idle cycle cannot reuse the proof'); + Object.defineProperty(owner, 'getAppLifecycleActivity', { configurable: true, writable: true, value: undefined }); + assert.ok(f.adapter.observeActivity().unknown.includes(reason)); + assert.equal(f.adapter.observeActivity().complete, false); + owner.getAppLifecycleActivity = read; + } + assert.equal(f.adapter.observeActivity().complete, true); + } finally { await f.close(); } +}); + +test('normal adapter cleanup joins real SDK retained cleanup past its public deadline', async () => { + const held = deferred(); + const cleaning = deferred(); + const f = await identityFixture({ realPrompts: true, onCreated(session) { + session.setDisposeTimeoutForTests(30); + session.registerToolSessionCleanup(async () => { cleaning.resolve(); await held.promise; }); + } }); + registerCustomApi('identity-contract', () => { + const stream = new AssistantMessageEventStream(); + const message = identityAnswer('Normal cleanup contract.'); + stream.push({ type: 'done', reason: 'stop', message }); stream.end(message); return stream; + }, f.root); + let completed = false; + const run = f.host.handle(request('session.start', 'actual-sdk-timeout', { message: 'offline held cleanup', options: f.options })) + .then(() => { completed = true; }); + try { + await cleaning.promise; + await assert.rejects(f.sessions[0]!.dispose(), (error) => error instanceof SessionDisposalIncompleteError); + assert.equal(completed, false); + assert.equal(f.adapter.observeActivity().settling, 1); + assert.equal(f.adapter.observeActivity().unknown.includes('sdk_cleanup_unconfirmed'), false, + 'a public deadline is not a teardown failure while the exact retained owner remains joinable'); + held.resolve(); + await run; + assert.equal((response(f.frames, 'actual-sdk-timeout').payload as { ok: boolean }).ok, true); + assert.equal(f.adapter.observeActivity().settling, 0); + assert.equal(f.adapter.observeActivity().unknown.includes('sdk_cleanup_unconfirmed'), false); + } finally { + held.resolve(); + await run; + unregisterCustomApis(f.root); + await f.close(); + } +}); + +test('real SDK post-prompt continuation remains owned across the adapter admission fence', async () => { + const held = deferred(); + const entered = deferred(); + const f = await identityFixture({ realPrompts: true, onCreated(session) { + let registered = false; + session.subscribe((event: { type: string }) => { + if (event.type !== 'agent_end' || registered) return; + registered = true; + // Public SDK test seam reserves the same owner used by its retry, + // compaction and delivery continuations. No timers or commands replaced. + session.trackPostPromptTaskForTests(held.promise); + entered.resolve(); + }); + } }); + registerCustomApi('identity-contract', () => { + const stream = new AssistantMessageEventStream(); + const message = identityAnswer('Post-prompt lifecycle.'); + stream.push({ type: 'done', reason: 'stop', message }); stream.end(message); return stream; + }, f.root); + let completed = false; + const run = f.host.handle(request('session.start', 'owned-post-prompt', { message: 'offline continuation', options: f.options })) + .then(() => { completed = true; }); + try { + await entered.promise; + f.adapter.setAdmissionFence(true); + const pending = f.adapter.observeActivity(); + assert.equal(completed, false); + assert.ok(pending.running + pending.starting + pending.settling > 0); + held.resolve(); + await run; + assert.equal((response(f.frames, 'owned-post-prompt').payload as { ok: boolean }).ok, true); + const settled = f.adapter.observeActivity(); + assert.equal(settled.running + settled.starting + settled.settling, 0); + assert.equal(settled.unknown.includes('sdk_cleanup_unconfirmed'), false); + } finally { + held.resolve(); await run; + unregisterCustomApis(f.root); + await f.close(); + } +}); + +test('SDK activity never treats empty diagnostics after background cleanup as proof of idle', async () => { + const runnerDone = deferred(); + const runnerStarted = deferred(); + let runnerSettled = false; + let retainedSettled = false; + const manager = new AsyncJobManager({ onJobComplete: async () => {} }); + const f = await fixture(); + try { + const run = f.adapter.spawnGjc('background work', { ...f.options, toolNames: ['bash'], runHandle: 'background-bash' }, { send() {} }); + const session = await firstSession(f.sessions); + await session.promptStarted.promise; + manager.register('bash', 'background-command-canary', async () => { + runnerStarted.resolve(); + try { return await runnerDone.promise; } + finally { runnerSettled = true; } + }); + await runnerStarted.promise; + let diagnosticsRead = false; + Object.assign(session, { + getAsyncJobSnapshot: () => { diagnosticsRead = true; return { running: [], recent: [] }; }, + pendingMessageCounts: { steering: 0, followUp: 0, nextTurn: 0 }, + hasPostPromptWork: false, + }); + // Reproduce the SDK's lossy public diagnostic surface with its REAL job + // manager: cancellation clears diagnostic rows while an ignoring runner + // is still retained. This is a cleanup fixture, not updater-driven drain. + session.dispose = async () => { + assert.equal(await manager.dispose({ timeoutMs: 0 }), false); + session.disposed = true; + }; + session.complete(); + await run; + const retained = manager.awaitRetainedDisposalCompletion().then(() => { retainedSettled = true; }); + assert.equal(manager.getRunningJobs().length, 0); + assert.equal(runnerSettled, false); + assert.equal(retainedSettled, false); + const snapshot = f.adapter.snapshotActivity(); + assert.equal(snapshot.running + snapshot.starting + snapshot.settling + snapshot.background, 0); + assert.equal(snapshot.complete, false); + assert.deepEqual(snapshot.unknown, ['sdk_background_ownership_unproven']); + assert.equal(diagnosticsRead, false, 'a read must not replace ownership proof with diagnostics'); + assert.equal(JSON.stringify(snapshot).includes('background-command-canary'), false); + runnerDone.resolve('finished'); + await retained; + assert.equal(runnerSettled, true); + assert.equal(f.adapter.snapshotActivity().complete, false, 'the adapter has no complete SDK proof to clear the unknown'); + } finally { + runnerDone.resolve('finished'); + for (const session of f.sessions) session.complete(); + await manager.dispose(); + await manager.awaitRetainedDisposalCompletion(); + await f.close(); + } +}); + +test('normal adapter cleanup retains the real SDK owner while an async-job runner ignores cancellation', { timeout: 10_000 }, async () => { + const runnerDone = deferred(); + const cancelled = deferred(); + const managerDisposed = deferred(); + let manager!: AsyncJobManager; + let runnerSettled = false; + const f = await identityFixture({ realPrompts: true, onCreated(session) { + manager = AsyncJobManager.forEndpoint(session.sessionManager.getSessionId())!; + assert.ok(manager, 'use this actual SDK session owner, never the process-global fallback'); + manager.register('bash', 'owned-cleanup-contract', async ({ signal }) => { + signal.addEventListener('abort', () => cancelled.resolve(), { once: true }); + try { return await runnerDone.promise; } + finally { runnerSettled = true; } + }, { ownerId: session.getAgentId() }); + manager.onChange(() => { if (manager.getAllJobs().length === 0) managerDisposed.resolve(); }); + session.setDisposeTimeoutForTests(30); + } }); + registerCustomApi('identity-contract', () => { + const stream = new AssistantMessageEventStream(); + const message = identityAnswer('Normal async-job cleanup.'); + stream.push({ type: 'done', reason: 'stop', message }); stream.end(message); return stream; + }, f.root); + let completed = false; + const run = f.host.handle(request('session.start', 'actual-owned-job', { message: 'offline owned job', options: f.options })) + .then(() => { completed = true; }); + try { + await cancelled.promise; + await assert.rejects(f.sessions[0]!.dispose(), (error) => error instanceof SessionDisposalIncompleteError); + // Wait for the SDK's REAL 3-second manager deadline. Its visible rows are + // now gone, but awaitRetainedDisposalCompletion still owns the runner. + await managerDisposed.promise; + assert.deepEqual(manager.getAllJobs(), []); + let retainedJoined = false; + const retained = manager.awaitRetainedDisposalCompletion().then(() => { retainedJoined = true; }); + await Promise.resolve(); + assert.equal(retainedJoined, false); + assert.equal(completed, false); + assert.equal(runnerSettled, false); + assert.equal(f.adapter.observeActivity().settling, 1); + // No manager.dispose() call from the app/updater: the SDK's existing + // normal session cleanup owns cancellation, disposal and retained joins. + runnerDone.resolve('normal runner settled'); + await retained; + await run; + assert.equal(runnerSettled, true); + assert.equal((response(f.frames, 'actual-owned-job').payload as { ok: boolean }).ok, true); + assert.equal(f.adapter.observeActivity().settling, 0); + assert.throws(() => manager.register('bash', 'after-close', async () => 'not admitted'), /disposed|shutting down/); + } finally { + runnerDone.resolve('finished'); + await run; + unregisterCustomApis(f.root); + await f.close(); + } +}); + +test('patched SDK retains Codex prewarm until physical completion while public disposal stays bounded', { timeout: 10_000 }, async () => { + const root = await mkdtemp(join(tmpdir(), 'gjc-sdk-prewarm-lifetime-')); + const cwd = join(root, 'project'); + const agentDir = join(root, 'agent'); + await mkdir(cwd); + const authStorage = await discoverAuthStorage(agentDir); + const settings = await Settings.loadForScope({ cwd, agentDir }); + settings.override('memory.enabled', false); + settings.override('skills.enabled', false); + settings.override('startup.networkPrewarm', false); + settings.override('providers.openaiWebsockets', 'on'); + const entered = deferred(); + const release = deferred(); + const settled = deferred(); + let credentialPending = false; + // Public injected dependency, not patched SDK commands/lifecycle or timers. + // No token is returned, so this fixture cannot start a real WebSocket. + class HeldCredentialRegistry extends ModelRegistry { + override async getApiKey(..._args: Parameters): Promise { + credentialPending = true; entered.resolve(); + try { await release.promise; return undefined; } + finally { credentialPending = false; settled.resolve(); } + } + } + const registry = new HeldCredentialRegistry(authStorage, join(agentDir, 'models.yml'), settings, { agentDir }); + registry.registerProvider('prewarm-contract', { + api: 'openai-codex-responses', apiKey: 'offline-unusable-key', baseUrl: 'http://127.0.0.1:1', + models: [{ id: 'astra', name: 'Offline lifecycle contract', reasoning: false, + input: ['text'], contextWindow: 100000, maxTokens: 1000, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } }], + }); + let session: Awaited>['session'] | undefined; + try { + ({ session } = await createAgentSession({ + cwd, agentDir, settings, authStorage, modelRegistry: registry, + model: registry.find('prewarm-contract', 'astra'), + sessionManager: SessionManager.create(cwd, join(root, 'sessions')), + toolNames: [], spawns: 'deny', enableMcpAutoload: false, enableLsp: false, + skipPythonPreflight: true, disableExtensionDiscovery: true, + skills: [], rules: [], contextFiles: [], promptTemplates: [], slashCommands: [], + })); + await entered.promise; + await session.waitForIdle(); + await session.awaitSessionSettlement(); + session.setDisposeTimeoutForTests(25); + await assert.rejects(session.dispose(), (error) => error instanceof SessionDisposalIncompleteError); + let joined = false; + const completion = session.awaitDisposeCompletion().then(() => { joined = true; }); + await Promise.resolve(); + assert.equal(credentialPending, true); + assert.equal(joined, false, 'caller timeout cannot release the retained prewarm owner'); + release.resolve(); + await settled.promise; + await completion; + assert.equal(session.isDisposed, true); + assert.equal(joined, true); + assert.equal(credentialPending, false); + } finally { + release.resolve(); + if (credentialPending) await settled.promise; + await session?.dispose(); + await registry.dispose(); authStorage.close(); await settings.close(); + await rm(root, { recursive: true, force: true }); + } +}); + +test('the real patched runtime bootstrap mints only a nonserializable source-integrity receipt', async () => { + const proof = await verifyRuntimeManifest(); + assert.equal(isVerifiedSdkPatch(proof), true); + assert.equal(isVerifiedSdkPatch({ ...proof }), false); + assert.equal(isVerifiedSdkPatch(JSON.parse(JSON.stringify(proof))), false); + // This receipt is deliberately not used to clear unknown streaming/extension + // ownership. The actual component observations still decide runtime safety. +}); + +test('SDK activity retains cleanup failure as unknown and never clears it on a read', async () => { + const f = await fixture(); + try { + const run = f.adapter.spawnGjc('hello', { ...f.options, runHandle: 'failed-cleanup' }, { send() {} }); + const failed = assert.rejects(run, { name: 'GjcCleanupUnconfirmedError' }); + const session = await firstSession(f.sessions); + await session.promptStarted.promise; + session.disposeError = new Error('cleanup-secret-canary'); + session.complete(); + await failed; + const snapshot = f.adapter.snapshotActivity(); + assert.equal(snapshot.complete, false); + assert.equal(snapshot.settling, 1); + assert.deepEqual(snapshot.unknown, ['sdk_background_ownership_unproven', 'sdk_cleanup_unconfirmed']); + assert.deepEqual(f.adapter.snapshotActivity(), snapshot); + assert.equal(f.adapter.getGeneration(), snapshot.generation); + assert.equal(JSON.stringify(snapshot).includes('cleanup-secret-canary'), false); + } finally { await f.close(); } +}); + test('a generator that declines leaves the session untitled, and a resumed session is never retitled', async () => { let calls = 0; const f = await fixture(undefined, undefined, undefined, undefined, undefined, undefined, { diff --git a/server/gjc-worker-client.test.ts b/server/gjc-worker-client.test.ts index 88df0f95..79e58430 100644 --- a/server/gjc-worker-client.test.ts +++ b/server/gjc-worker-client.test.ts @@ -7,10 +7,17 @@ import { join } from 'node:path'; import { PassThrough } from 'node:stream'; import { after, test } from 'node:test'; +import type { DesktopOwnerActivity } from '../shared/desktopUpdateProtocol.js'; + +import { DesktopRestartAuthority, type DesktopRestartAuthorityOptions } from './services/desktop-restart-authority.js'; +import { createDesktopRestartRuntime, DESKTOP_RESTART_REQUIRED_OWNERS } from './services/desktop-restart-runtime.js'; +import { GjcWorkerHost, type GjcWorkerRuntime } from './gjc-worker.js'; import { DEFAULT_INITIALIZE_TIMEOUT_MS, DEFAULT_SHUTDOWN_TIMEOUT_MS, GjcWorkerSupervisor, + createGjcWorkerDesktopRestartReader, + getGjcWorkerSupervisor, killWorkerTree, resolveGjcResumeSessionRoot, } from './gjc-worker-client.js'; @@ -1320,3 +1327,1106 @@ test('a start refused for an unresolvable model tells the client why', async () ]); assert.deepEqual(failures, [GJC_MODEL_UNRESOLVED_MESSAGE]); }); + +function assertDesktopIdle(activity: DesktopOwnerActivity): void { + assert.equal(activity.owner, 'gjc-worker'); + assert.equal(activity.complete, true); + assert.deepEqual(activity.unknown, []); + for (const count of ['starting', 'queued', 'running', 'settling', 'approvals', 'retained'] as const) { + assert.equal(activity[count], 0, count); + } +} + +async function restartObservationFixture() { + const child = new FakeChild(); + const peer = new FakePeer(child); + let spawns = 0; + let reaps = 0; + let fenceId: string | null = null; + const remote = { generation: 'sdk-1', complete: true, starting: 0, queued: 0, running: 0, + settling: 0, approvals: 0, retained: 0, unknown: [] as string[] }; + let delayed = false; + peer.handle((request) => { + if (request.method === 'worker.admission') { + fenceId = request.payload.closed ? request.payload.fenceId : null; + peer.respond(request, { ok: true, result: { fenceId } }); + } else if (request.method === 'worker.activity') { + if (!delayed) peer.respond(request, { ok: true, result: { ...remote, unknown: [...remote.unknown], fenceId } }); + } else if (request.method === 'worker.initialize' || request.method === 'models.catalog') peer.respond(request); + }); + const supervisor = new GjcWorkerSupervisor({ ...runtime(child), + spawn: () => { spawns++; return child; }, killTree: () => { reaps++; } }); + await supervisor.modelCatalog(); + return { child, peer, supervisor, remote, + delay() { delayed = true; }, counts: () => ({ spawns, reaps }), + reply(request: GjcWorkerRequestFrame, observedFence = fenceId) { + peer.respond(request, { ok: true, result: { ...remote, unknown: [...remote.unknown], fenceId: observedFence } }); + }, + }; +} + +/** Real authority + production reader + protocol host; only the SDK/process are fake. */ +async function composedRestartWorkerFixture(options: { + holdClose?: boolean; + holdRelease?: boolean; + clock?: Pick; +} = {}) { + const child = new FakeChild(); const peer = new FakePeer(child); + const trace: string[] = []; + const roots = new Map>>(); + const errors: unknown[] = []; + const replies: GjcWorkerResponseFrame[] = []; + let sdkRevision = 0; + let aborted = 0; + let spawned = 0; + let reaped = 0; + let holdClose = options.holdClose ?? false; + let holdRelease = options.holdRelease ?? false; + const sdk: GjcWorkerRuntime = { + observeActivity: () => ({ generation: `sdk-${sdkRevision}`, complete: true, + starting: 0, queued: 0, running: roots.size, settling: 0, approvals: 0, retained: 0, unknown: [] }), + setAdmissionFence: (closed) => { + trace.push(closed ? 'sdk:close' : 'sdk:release'); + if (closed) assert.equal(authority.state, 'preparing', 'top-level admission closes before the worker'); + sdkRevision++; + }, + modelCatalog: async () => ({}), + spawnGjc: (_message, input) => { + const runId = String(input.runHandle); + const done = deferredEnrichment(); roots.set(runId, done); sdkRevision++; + return Object.assign(done.promise.finally(() => { roots.delete(runId); sdkRevision++; }), { abortHandle: runId }); + }, + abortGjcSession: async () => { aborted++; return false; }, + resolveGjcToolApproval: () => false, + }; + const host = new GjcWorkerHost({ runtime: async () => sdk, emit(frame) { + if (frame.kind === 'response' && frame.method === 'worker.admission' && frame.payload.ok) { + const closing = frame.payload.result.fenceId !== null; + if (closing ? holdClose : holdRelease) { replies.push(frame); return; } + } + child.stdout.write(serializeGjcWorkerFrame(frame)); + } }); + peer.handle((request) => { + trace.push(request.method === 'worker.admission' + ? `wire:${request.payload.closed ? 'close' : 'release'}` : `wire:${request.method}`); + void host.handle(request).catch((error) => { errors.push(error); }); + }); + const supervisor = new GjcWorkerSupervisor({ ...runtime(child), + spawn: () => { spawned++; return child; }, killTree: () => { reaped++; } }); + const reader = createGjcWorkerDesktopRestartReader(supervisor); + const ownerReaders = Object.fromEntries(DESKTOP_RESTART_REQUIRED_OWNERS.map((owner) => [owner, { + getGeneration: () => 'fixture-idle', + read: () => ({ owner, generation: 'fixture-idle', complete: true, + starting: 0, queued: 0, running: 0, settling: 0, approvals: 0, retained: 0, unknown: [] }), + }])); + const readers = { ...ownerReaders, 'gjc-worker': { + getGeneration: reader.getGeneration, + read: () => { trace.push('owner:read'); return reader.read(); }, + } }; + const preparationFence = { owner: 'gjc-worker', + close: (id: string) => supervisor.fenceForDesktopRestart(id), + release: (id: string) => supervisor.releaseDesktopRestartFence(id), + }; + // Exercise the exact production factory by default. Expiry/deadline tests + // inject only authority time; worker transport and its timers remain real. + const authority: DesktopRestartAuthority = options.clock + ? new DesktopRestartAuthority({ requiredOwners: DESKTOP_RESTART_REQUIRED_OWNERS, + ownerReaders: readers, preparationFence, ...options.clock }) + : createDesktopRestartRuntime(readers, preparationFence); + supervisor.configureDesktopRestartAdmission({ acquire: (source) => ({ release: authority.enter(source) }) }); + await supervisor.modelCatalog(); + trace.length = 0; + const acknowledge = (closed: boolean) => { + const index = replies.findIndex((frame) => frame.method === 'worker.admission' && frame.payload.ok + && (frame.payload.result.fenceId !== null) === closed); + assert.notEqual(index, -1, 'the delayed acknowledgement must exist'); + child.stdout.write(serializeGjcWorkerFrame(replies.splice(index, 1)[0]!)); + }; + return { authority, supervisor, peer, reader, trace, errors, roots, + counts: () => ({ spawned, reaped, aborted }), + acknowledgeClose: () => acknowledge(true), acknowledgeRelease: () => acknowledge(false), + releaseAcknowledgements() { holdClose = false; holdRelease = false; }, + async close() { for (const root of roots.values()) root.resolve(); await host.close(); }, + }; +} + +function restartCompositionClock() { + let now = 0; + const timers = new Set<{ at: number; callback: () => void }>(); + return { + options: { now: () => now, tokenTtlMs: 20, + schedule(callback: () => void, delay: number) { + const timer = { at: now + delay, callback }; timers.add(timer); return () => { timers.delete(timer); }; + }, + }, + advance(milliseconds: number) { + now += milliseconds; + for (const timer of [...timers]) if (timer.at <= now && timers.delete(timer)) timer.callback(); + }, + }; +} + +test('production restart composition closes before its first observation and owns cancellation release', async () => { + const f = await composedRestartWorkerFixture({ holdClose: true, holdRelease: true }); + try { + const preparedTask = f.authority.prepare({ attemptId: 'composed-1', epoch: 'desktop-1' }); + assert.equal(f.authority.state, 'preparing'); + await assert.rejects(f.supervisor.modelCatalog(), { code: 'DESKTOP_RESTART_FENCED' }); + const close = await f.peer.waitFor('worker.admission'); + assert.equal(close.payload.closed, true); + assert.equal(f.trace.includes('owner:read'), false, 'owner generation is captured only after close settles'); + f.acknowledgeClose(); + const prepared = await preparedTask; + assert.ok(prepared.ok, JSON.stringify(prepared)); + const owner = prepared.snapshot.owners.find((value) => value.owner === 'gjc-worker')!; + assertDesktopIdle(owner); + assert.equal(owner.generation, f.reader.getGeneration(), 'close/observation did not invalidate the first snapshot'); + assert.ok(f.trace.indexOf('sdk:close') < f.trace.indexOf('owner:read')); + const stable = f.reader.getGeneration(); + assertDesktopIdle(await f.reader.read()); + assert.equal(f.reader.getGeneration(), stable); + f.authority.cancel(prepared.token); + await flushEnrichment(); + const release = f.peer.requests.filter((r) => r.method === 'worker.admission').at(-1)!; + assert.equal(release.payload.closed, false); + assert.equal(release.payload.fenceId, close.payload.fenceId); + const releasing = await f.authority.snapshot(); + assert.equal(releasing.ingress, 1, 'release acknowledgement retains cleanup ownership'); + assert.equal((await f.authority.prepare({ attemptId: 'cannot-overtake', epoch: 'desktop-1' })).ok, false); + await assert.rejects(f.supervisor.modelCatalog(), { code: 'DESKTOP_RESTART_FENCED' }); + f.acknowledgeRelease(); + await flushEnrichment(); + assert.equal((await f.authority.snapshot()).ingress, 0); + await f.supervisor.modelCatalog(); + assert.deepEqual(f.errors, []); + assert.deepEqual(f.counts(), { spawned: 1, reaped: 0, aborted: 0 }); + } finally { await f.close(); } +}); + +test('composed restart expiry releases the same fake-worker fence while cleanup blocks another prepare', async () => { + const clock = restartCompositionClock(); + const f = await composedRestartWorkerFixture({ holdRelease: true, clock: clock.options }); + try { + const prepared = await f.authority.prepare({ attemptId: 'expires', epoch: 'desktop-1' }); + assert.ok(prepared.ok, JSON.stringify(prepared)); + clock.advance(20); + await flushEnrichment(); + assert.equal(f.authority.state, 'open'); + const admissions = f.peer.requests.filter((r) => r.method === 'worker.admission'); + assert.equal(admissions.length, 2); + assert.equal(admissions[0]!.payload.fenceId, admissions[1]!.payload.fenceId); + assert.equal(admissions[1]!.payload.closed, false); + assert.equal((await f.authority.snapshot()).ingress, 1); + const blocked = await f.authority.prepare({ attemptId: 'too-early', epoch: 'desktop-1' }); + assert.equal(blocked.ok, false); + if (!blocked.ok) assert.equal(blocked.code, 'busy'); + f.acknowledgeRelease(); await flushEnrichment(); + assert.equal((await f.authority.snapshot()).ingress, 0); + f.releaseAcknowledgements(); + const next = await f.authority.prepare({ attemptId: 'after-expiry', epoch: 'desktop-1' }); + assert.ok(next.ok, JSON.stringify(next)); + f.authority.cancel(next.token); await flushEnrichment(); + await f.supervisor.modelCatalog(); + assert.deepEqual(f.errors, []); + assert.deepEqual(f.counts(), { spawned: 1, reaped: 0, aborted: 0 }); + } finally { await f.close(); } +}); + +for (const reason of ['controller-loss', 'prepare-deadline'] as const) { + test(`composed restart ${reason} joins a delayed close before exact release without observing`, async () => { + const clock = restartCompositionClock(); + const f = await composedRestartWorkerFixture({ holdClose: true, holdRelease: true, clock: clock.options }); + try { + const preparedTask = f.authority.prepare({ attemptId: 'interrupted', epoch: 'desktop-1' }); + const close = await f.peer.waitFor('worker.admission'); + if (reason === 'controller-loss') f.authority.controllerLost('desktop-1'); + else clock.advance(5_000); + const prepared = await preparedTask; + assert.equal(prepared.ok, false); + assert.equal(f.trace.includes('owner:read'), false); + assert.equal(f.peer.requests.filter((r) => r.method === 'worker.admission').length, 1, + 'release cannot overtake the unsettled close'); + const blocked = await f.authority.prepare({ attemptId: 'cannot-overtake', epoch: 'desktop-2' }); + assert.equal(blocked.ok, false); + if (!blocked.ok) assert.equal(blocked.code, 'busy'); + f.acknowledgeClose(); await flushEnrichment(); + const release = f.peer.requests.filter((r) => r.method === 'worker.admission').at(-1)!; + assert.equal(release.payload.closed, false); + assert.equal(release.payload.fenceId, close.payload.fenceId); + f.acknowledgeRelease(); await flushEnrichment(); + assert.equal((await f.authority.snapshot()).ingress, 0); + await f.supervisor.modelCatalog(); + assert.deepEqual(f.errors, []); + assert.deepEqual(f.counts(), { spawned: 1, reaped: 0, aborted: 0 }); + } finally { await f.close(); } + }); +} + +test('production restart composition refuses an accepted root without sending abort or a remote fence', async () => { + const f = await composedRestartWorkerFixture(); + try { + const root = f.supervisor.spawnRun({ runId: 'accepted-root', appSessionId: 'scope', message: 'owned work', writer: { send() {} } }); + await root.started; + const prepared = await f.authority.prepare({ attemptId: 'while-busy', epoch: 'desktop-1' }); + assert.equal(prepared.ok, false); + if (!prepared.ok) assert.equal(prepared.code, 'busy'); + assert.equal(f.peer.requests.some((r) => r.method === 'worker.admission' || r.method === 'turn.abort'), false); + assert.equal(f.supervisor.isActive('accepted-root'), true); + f.roots.get('accepted-root')!.resolve(); await root.completion; await flushEnrichment(); + const next = await f.authority.prepare({ attemptId: 'after-root', epoch: 'desktop-1' }); + assert.ok(next.ok, JSON.stringify(next)); + f.authority.cancel(next.token); await flushEnrichment(); + assert.deepEqual(f.errors, []); + assert.deepEqual(f.counts(), { spawned: 1, reaped: 0, aborted: 0 }); + } finally { await f.close(); } +}); + +test('fenced healthy worker certifies idle through the production reader without self-invalidating', async () => { + const f = await restartObservationFixture(); + const reader = createGjcWorkerDesktopRestartReader(f.supervisor); + assert.equal((await reader.read()).complete, false, 'an unfenced remote snapshot cannot certify idle'); + assert.equal(f.peer.requests.filter((r) => r.method === 'worker.activity').length, 1); + await f.supervisor.fenceForDesktopRestart('update-1'); + const generation = reader.getGeneration(); + assertDesktopIdle(await reader.read()); + assertDesktopIdle(await reader.read()); + assert.equal(reader.getGeneration(), generation); + assert.equal(f.supervisor.snapshotActivity().retained, 1, 'observation never discards OS ownership'); + const authority = new DesktopRestartAuthority({ requiredOwners: ['gjc-worker'], ownerReaders: { 'gjc-worker': reader } }); + const prepared = await authority.prepare({ attemptId: 'update-1', epoch: 'desktop-1' }); + assert.equal(prepared.ok, true); + if (prepared.ok) { + const committed = await authority.commit(prepared.token, prepared.epoch); + assert.equal(committed.ok, true); + } + await assert.rejects(f.supervisor.modelCatalog(), { code: 'DESKTOP_RESTART_FENCED' }); + await f.supervisor.releaseDesktopRestartFence('update-1'); + await f.supervisor.modelCatalog(); + assert.deepEqual(f.counts(), { spawns: 1, reaps: 0 }); +}); + +test('SDK-only idle-busy-idle invalidates the prepared owner even without host events', async () => { + const f = await restartObservationFixture(); + const reader = createGjcWorkerDesktopRestartReader(f.supervisor); + await f.supervisor.fenceForDesktopRestart('update-1'); + const authority = new DesktopRestartAuthority({ requiredOwners: ['gjc-worker'], ownerReaders: { 'gjc-worker': reader } }); + const prepared = await authority.prepare({ attemptId: 'update-1', epoch: 'desktop-1' }); + assert.equal(prepared.ok, true); + assert.ok(prepared.ok); + const generation = reader.getGeneration(); + // The SDK accepts and settles internal work without emitting a run/OAuth + // frame. Zero endpoint counts do not erase the intervening mutations. + f.remote.generation = 'sdk-3'; + assert.equal(reader.getGeneration(), generation, 'no host event announced this remote change'); + const committed = await authority.commit(prepared.token, prepared.epoch); + assert.equal(committed.ok, false, 'a different remote revision must not reuse the prepared proof'); + assert.notEqual(reader.getGeneration(), generation); + const invalidated = reader.getGeneration(); + assert.ok((await reader.read()).unknown.includes('worker_observation_stale')); + assert.equal(reader.getGeneration(), invalidated, 'unchanged observation does not create another mutation'); + // Do not silently adopt a new baseline (or accept a reverted one) under + // the same lease after it was invalidated. + f.remote.generation = 'sdk-1'; + assert.equal((await reader.read()).complete, false); + await f.supervisor.releaseDesktopRestartFence('update-1'); + await f.supervisor.fenceForDesktopRestart('update-2'); + assertDesktopIdle(await reader.read()); + assert.deepEqual(f.counts(), { spawns: 1, reaps: 0 }); + await f.supervisor.releaseDesktopRestartFence('update-2'); +}); + +test('an SDK revision change while the commit observation is pending cannot certify an idle reply', async () => { + const f = await restartObservationFixture(); + const reader = createGjcWorkerDesktopRestartReader(f.supervisor); + await f.supervisor.fenceForDesktopRestart('update-1'); + const authority = new DesktopRestartAuthority({ requiredOwners: ['gjc-worker'], ownerReaders: { 'gjc-worker': reader } }); + const prepared = await authority.prepare({ attemptId: 'update-1', epoch: 'desktop-1' }); + assert.ok(prepared.ok); + f.delay(); + const committed = authority.commit(prepared.token, prepared.epoch); + const request = f.peer.requests.filter((r) => r.method === 'worker.activity').at(-1)!; + f.remote.generation = 'sdk-3'; + f.reply(request); + assert.equal((await committed).ok, false); + assert.deepEqual(f.counts(), { spawns: 1, reaps: 0 }); + await f.supervisor.releaseDesktopRestartFence('update-1'); +}); + +test('observation timeouts coalesce into one bounded slot and never poison or reap a healthy worker', async () => { + const f = await restartObservationFixture(); + await f.supervisor.fenceForDesktopRestart('update-1'); + f.delay(); + const reader = createGjcWorkerDesktopRestartReader(f.supervisor); + const generation = reader.getGeneration(); + const results = await Promise.all(Array.from({ length: 20 }, () => reader.read())); + assert.equal(f.peer.requests.filter((r) => r.method === 'worker.activity').length, 1); + assert.equal(f.supervisor.snapshotActivity().queued, 0); + assert.equal(reader.getGeneration(), generation); + for (const result of results) { + assert.equal(result.complete, false); + assert.ok(result.unknown.includes('worker_observation_unavailable')); + assert.equal(result.unknown.includes('worker_request_timeout_unconfirmed'), false); + } + await reader.read(); + assert.equal(f.peer.requests.filter((r) => r.method === 'worker.activity').length, 1, 'unanswered reads cannot grow a late-ID cache'); + f.reply(f.peer.requests.find((r) => r.method === 'worker.activity')!); + const fresh = reader.read(); + f.reply(f.peer.requests.filter((r) => r.method === 'worker.activity')[1]!); + assertDesktopIdle(await fresh); + assert.equal(reader.getGeneration(), generation); + assert.deepEqual(f.counts(), { spawns: 1, reaps: 0 }); + await f.supervisor.releaseDesktopRestartFence('update-1'); +}); + +test('timed-out admission stays fenced until ordered release without permanently poisoning SDK activity', async () => { + const f = await restartObservationFixture(); + let closeRequest: GjcWorkerRequestFrame | undefined; + f.peer.handle((request) => { + if (request.method !== 'worker.admission') return; + if (request.payload.closed) closeRequest = request; + else f.peer.respond(request, { ok: true, result: { fenceId: null } }); + }); + await assert.rejects(f.supervisor.fenceForDesktopRestart('update-1')); + assert.ok(closeRequest); + await assert.rejects(f.supervisor.modelCatalog(), { code: 'DESKTOP_RESTART_FENCED' }); + assert.equal((await createGjcWorkerDesktopRestartReader(f.supervisor).read()).complete, false); + assert.equal(f.supervisor.snapshotActivity().unknown.includes('worker_request_timeout_unconfirmed'), false); + await f.supervisor.releaseDesktopRestartFence('update-1'); + f.peer.respond(closeRequest, { ok: true, result: { fenceId: 'update-1' } }); + f.peer.handle((request) => f.peer.respond(request)); + await f.supervisor.modelCatalog(); + assert.equal(f.supervisor.snapshotActivity().unknown.includes('worker_request_timeout_unconfirmed'), false); + assert.deepEqual(f.counts(), { spawns: 1, reaps: 0 }); +}); + +test('worker-side titles, OAuth unwind and SDK unknowns are not replaced by empty parent maps', async () => { + const f = await restartObservationFixture(); + await f.supervisor.fenceForDesktopRestart('update-1'); + f.remote.settling = 2; + f.remote.complete = false; + f.remote.unknown = ['sdk_background_ownership_unproven']; + const result = await createGjcWorkerDesktopRestartReader(f.supervisor).read(); + assert.equal(result.complete, false); + assert.equal(result.settling, 2); + assert.deepEqual(result.unknown, ['sdk_background_ownership_unproven']); + assert.deepEqual(f.counts(), { spawns: 1, reaps: 0 }); + await f.supervisor.releaseDesktopRestartFence('update-1'); +}); + +test('a released or mismatched fence cannot reuse an in-flight worker idle snapshot', async () => { + const f = await restartObservationFixture(); + await f.supervisor.fenceForDesktopRestart('update-1'); + f.delay(); + const reader = createGjcWorkerDesktopRestartReader(f.supervisor); + const pending = reader.read(); + const observation = f.peer.requests.find((r) => r.method === 'worker.activity')!; + await assert.rejects(f.supervisor.releaseDesktopRestartFence('other-update')); + await f.supervisor.releaseDesktopRestartFence('update-1'); + f.reply(observation, 'update-1'); + const result = await pending; + assert.equal(result.complete, false); + assert.ok(result.unknown.includes('worker_observation_stale')); + assert.notEqual(result.generation, reader.getGeneration()); + assert.deepEqual(f.counts(), { spawns: 1, reaps: 0 }); +}); + +test('cold worker fencing and observation never spawn and optional root admission is releasable', async () => { + let spawns = 0; + const child = new FakeChild(); + const peer = new FakePeer(child); + peer.handle((request) => peer.respond(request)); + const supervisor = new GjcWorkerSupervisor({ ...runtime(child), spawn: () => { spawns++; return child; } }); + const reader = createGjcWorkerDesktopRestartReader(supervisor); + await supervisor.fenceForDesktopRestart('cold-fence'); + assertDesktopIdle(await reader.read()); + await assert.rejects(supervisor.oauthStart('openai-codex'), { code: 'DESKTOP_RESTART_FENCED' }); + await assert.rejects(supervisor.oauthSubmit('not-owned', 'input')); + assert.equal(spawns, 0); + assert.equal(peer.requests.length, 0); + await supervisor.releaseDesktopRestartFence('cold-fence'); + let leases = 0; + let open = false; + supervisor.configureDesktopRestartAdmission({ acquire(source) { + assert.equal(source, 'gjc-worker:models.catalog'); + if (!open) throw Object.assign(new Error('fenced'), { code: 'DESKTOP_RESTART_FENCED' }); + leases++; + return { release() { leases--; } }; + } }); + await assert.rejects(supervisor.modelCatalog(), { code: 'DESKTOP_RESTART_FENCED' }); + assert.equal(spawns, 0); + open = true; + await supervisor.modelCatalog(); + assert.equal(spawns, 1); + assert.equal(leases, 0); +}); + +test('fencing during accepted enrichment leaves that root owned and rejects new roots without abort', async () => { + const child = new FakeChild(); const peer = new FakePeer(child); + const enriched = deferredEnrichment>(); + let entered = false; + const supervisor = new GjcWorkerSupervisor({ ...runtime(child), enrichOptions: async () => { entered = true; return enriched.promise; } }); + const accepted = supervisor.spawnRun({ runId: 'accepted', appSessionId: 'scope', message: 'existing', writer: { send() {} } }); + peer.respond(await peer.waitFor('worker.initialize')); + await flushEnrichment(); + assert.equal(entered, true); + await assert.rejects(supervisor.fenceForDesktopRestart('update-1'), /accepted work/); + const blocked = supervisor.spawnRun({ runId: 'blocked', appSessionId: 'scope', message: 'new', writer: { send() {} } }); + await assert.rejects(blocked.started, { code: 'DESKTOP_RESTART_FENCED' }); + assert.equal(await blocked.outcome, 'not_started'); + assert.equal(peer.requests.some((r) => r.method === 'worker.admission' || r.method === 'turn.abort'), false); + enriched.resolve({}); + const start = await peer.waitFor('session.start'); + assert.equal(start.id, 'accepted'); + peer.respond(start); + await accepted.completion; + await supervisor.releaseDesktopRestartFence('update-1'); + assert.equal(child.killed, false); +}); + +test('desktop reader is inert, detached from returned snapshots, and bound to the production singleton by default', async () => { + let spawns = 0; + let reaps = 0; + const child = new FakeChild(); + const supervisor = new GjcWorkerSupervisor({ + ...runtime(child), + spawn: () => { spawns += 1; return child; }, + killTree: () => { reaps += 1; }, + }); + const reader = createGjcWorkerDesktopRestartReader(supervisor); + const generation = reader.getGeneration(); + const first = await reader.read(); + assertDesktopIdle(first); + assert.equal(first.generation, generation); + (first.unknown as string[]).push('caller_mutation'); + first.queued = 100; + assertDesktopIdle(await reader.read()); + assert.equal(reader.getGeneration(), generation); + assert.notEqual(new GjcWorkerSupervisor().getGeneration(), generation); + assert.deepEqual(await createGjcWorkerDesktopRestartReader().read(), getGjcWorkerSupervisor().snapshotActivity()); + assert.equal(spawns, 0); + assert.equal(reaps, 0); + assert.equal(child.killed, false); +}); + +test('desktop startup is owned inside spawn and request settlement retains its awaiting continuation', async () => { + const child = new FakeChild(); const peer = new FakePeer(child); + let insideSpawn!: DesktopOwnerActivity; + const supervisor = new GjcWorkerSupervisor({ + ...runtime(child), + spawn: () => { insideSpawn = supervisor.snapshotActivity(); return child; }, + }); + const reader = createGjcWorkerDesktopRestartReader(supervisor); + const cold = reader.getGeneration(); + const catalog = supervisor.modelCatalog(); + assert.ok(insideSpawn.starting > 0); + assert.ok(insideSpawn.settling > 0); + assert.notEqual(insideSpawn.generation, cold); + const initializing = supervisor.snapshotActivity(); + assert.equal(initializing.complete, false); + assert.deepEqual(initializing.unknown, ['worker_runtime_unaccounted']); + peer.respond(await peer.waitFor('worker.initialize')); + const request = await peer.waitFor('models.catalog'); + const pending = supervisor.snapshotActivity(); + assert.equal(pending.queued, 1); + assert.equal(pending.starting, 0); + assert.notEqual(pending.generation, initializing.generation); + peer.respond(request); + const acknowledged = supervisor.snapshotActivity(); + assert.equal(acknowledged.queued, 0); + assert.ok(acknowledged.settling > 0, 'response acknowledgement cannot drop its continuation'); + assert.notEqual(acknowledged.generation, pending.generation); + await catalog; + const retained = supervisor.snapshotActivity(); + assert.equal(retained.settling, 0); + assert.equal(retained.retained, 1); + assert.equal(retained.complete, false, 'an empty parent request map is not SDK idle proof'); + assert.equal(child.killed, false, 'reading never drains a retained worker'); +}); + +test('desktop generation records failed startup even when both endpoint snapshots are idle', async () => { + let observed!: DesktopOwnerActivity; + const supervisor = new GjcWorkerSupervisor({ + ...runtime(new FakeChild()), + spawn: () => { observed = supervisor.snapshotActivity(); throw new Error('spawn failed'); }, + }); + const before = supervisor.snapshotActivity(); + assertDesktopIdle(before); + await assert.rejects(supervisor.modelCatalog(), /spawn failed/); + assert.ok(observed.starting > 0); + assert.ok(observed.settling > 0); + const after = supervisor.snapshotActivity(); + assertDesktopIdle(after); + assert.notEqual(before.generation, after.generation, 'idle -> failed startup -> idle must invalidate a prepared proof'); +}); + +test('desktop reader tracks registered, issued and terminal run mutations without exposing payloads', async () => { + const child = new FakeChild(); const peer = new FakePeer(child); + const supervisor = new GjcWorkerSupervisor(runtime(child)); + const reader = createGjcWorkerDesktopRestartReader(supervisor); + const cold = reader.getGeneration(); + const run = spawn(supervisor, 'private-prompt-never-in-snapshot', {}, { send() {} }); + const registered = supervisor.snapshotActivity(); + assert.ok(registered.starting >= 2, 'registered run plus worker startup'); + assert.notEqual(registered.generation, cold); + peer.respond(await peer.waitFor('worker.initialize')); + const start = await peer.waitFor('session.start'); + const issued = supervisor.snapshotActivity(); + assert.equal(issued.starting, 0); + assert.equal(issued.running, 1); + assert.equal(issued.queued, 1); + assert.notEqual(issued.generation, registered.generation); + peer.event('app-session-1', start.id, 'turn.completed', { message: { kind: 'complete' } }); + const terminalEvent = supervisor.snapshotActivity(); + assert.equal(terminalEvent.running, 1, 'UI terminal does not retire the request/run owner'); + assert.notEqual(terminalEvent.generation, issued.generation); + peer.respond(start); + assert.equal(supervisor.snapshotActivity().queued, 0); + assert.equal(supervisor.snapshotActivity().running, 1, 'run finalization is still queued after acknowledgement'); + await run; + await new Promise((resolve) => setImmediate(resolve)); + const finished = supervisor.snapshotActivity(); + assert.equal(finished.running, 0); + assert.equal(finished.settling, 0); + assert.deepEqual(finished.unknown, ['worker_runtime_unaccounted']); + assert.notEqual(finished.generation, terminalEvent.generation); + assert.equal(JSON.stringify(finished).includes('private-prompt'), false); + assert.equal(JSON.stringify(finished).includes(start.id), false); +}); + +test('desktop approvals include hidden in-flight replies, restoration and cancellation', async () => { + const child = new FakeChild(); const peer = new FakePeer(child); replyToHandshake(peer); + const supervisor = new GjcWorkerSupervisor(runtime(child)); + const run = spawn(supervisor, 'hello', {}, { send() {} }); + const start = await peer.waitFor('session.start'); + const initial = supervisor.snapshotActivity(); + peer.event('app-session-1', start.id, 'ask.presented', { + message: { kind: 'permission_request', requestId: 'private-approval', content: 'secret-input' }, + }); + const presented = supervisor.snapshotActivity(); + assert.equal(presented.approvals, 1); + assert.notEqual(presented.generation, initial.generation); + assert.equal(supervisor.resolveApproval('private-approval', { allow: true }), true); + const replying = supervisor.snapshotActivity(); + assert.deepEqual(supervisor.pendingApprovals('app-session-1'), []); + assert.equal(replying.approvals, 1); + assert.ok(replying.settling > presented.settling); + assert.notEqual(replying.generation, presented.generation); + assert.equal(JSON.stringify(replying).includes('private-approval'), false); + const reply = await peer.waitFor('ask.reply'); + peer.respond(reply, { ok: true, result: { accepted: false } }); + await new Promise((resolve) => setImmediate(resolve)); + const restored = supervisor.snapshotActivity(); + assert.equal(restored.approvals, 1); + assert.equal(supervisor.pendingApprovals('app-session-1').length, 1); + assert.equal(restored.settling, presented.settling); + assert.notEqual(restored.generation, replying.generation); + supervisor.resolveApproval('private-approval', { allow: false }); + peer.respond(await peer.waitFor('ask.reply', 2), { ok: true, result: { accepted: true } }); + await new Promise((resolve) => setImmediate(resolve)); + const accepted = supervisor.snapshotActivity(); + assert.equal(accepted.approvals, 1, 'accepted reply alone does not erase the mirrored approval'); + peer.event('app-session-1', start.id, 'ask.presented', { + message: { kind: 'permission_cancelled', requestId: 'private-approval' }, + }); + assert.equal(supervisor.snapshotActivity().approvals, 0); + assert.notEqual(supervisor.getGeneration(), accepted.generation); + peer.respond(start); await run; +}); + +test('desktop timeout uncertainty survives 257-request eviction, late replies and failAll until tree proof', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const child = new FakeChild(); const peer = new FakePeer(child); replyToHandshake(peer); + let releaseReap!: () => void; + const verifiedTree = new Promise((resolve) => { releaseReap = resolve; }); + let insideReap!: DesktopOwnerActivity; + const supervisor = new GjcWorkerSupervisor({ + ...runtime(child), requestTimeoutMs: 5, + killTree: () => { insideReap = supervisor.snapshotActivity(); return verifiedTree; }, + }); + const warm = supervisor.modelCatalog(); + peer.respond(await peer.waitFor('models.catalog')); await warm; + const waiters = Array.from({ length: 257 }, () => supervisor.modelCatalog()); + const failures = Promise.all(waiters.map((waiter) => assert.rejects(waiter, /request timed out/))); + await peer.waitFor('models.catalog', 258); + const before = supervisor.snapshotActivity(); + assert.equal(before.queued, 257); + t.mock.timers.tick(5); await failures; + const timedOut = supervisor.snapshotActivity(); + assert.equal(timedOut.queued, 0); + assert.equal(timedOut.settling, 0); + assert.ok(timedOut.unknown.includes('worker_request_timeout_unconfirmed')); + assert.notEqual(timedOut.generation, before.generation); + const expired = (supervisor as unknown as { expiredRequests: ReadonlyMap }).expiredRequests; + assert.equal(expired.size, 256, 'exercise actual bounded-cache eviction, not just one timeout'); + const requests = peer.requests.filter((request) => request.method === 'models.catalog').slice(1); + for (const request of requests.slice(1)) peer.respond(request); + assert.equal(expired.size, 0); + const late = supervisor.snapshotActivity(); + assert.ok(late.unknown.includes('worker_request_timeout_unconfirmed')); + assert.notEqual(late.generation, timedOut.generation); + assert.equal(child.killed, false); + + child.emit('exit', 1); + assert.ok(insideReap.unknown.includes('worker_reap_pending')); + assert.equal(insideReap.retained, 1, 'the child field is cleared before killTree but ownership must survive'); + const pendingReap = supervisor.snapshotActivity(); + assert.ok(pendingReap.unknown.includes('worker_request_timeout_unconfirmed')); + assert.equal(expired.size, 0, 'failAll/cache clearing is not reap proof'); + assert.notEqual(pendingReap.generation, late.generation); + releaseReap(); + await new Promise((resolve) => setImmediate(resolve)); + assertDesktopIdle(supervisor.snapshotActivity()); + assert.notEqual(supervisor.getGeneration(), pendingReap.generation); +}); + +test('desktop failed reap retains runtime and timeout uncertainty without active parent requests', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const child = new FakeChild(); const peer = new FakePeer(child); replyToHandshake(peer); + const supervisor = new GjcWorkerSupervisor({ + ...runtime(child), requestTimeoutMs: 5, + killTree: () => Promise.reject(new Error('tree remains alive')), + }); + const request = assert.rejects(supervisor.oauthStatus(), /request timed out/); + await peer.waitFor('oauth.status'); + t.mock.timers.tick(5); await request; + child.emit('exit', 1); + await new Promise((resolve) => setImmediate(resolve)); + const failed = supervisor.snapshotActivity(); + assert.equal(failed.queued, 0); + assert.equal(failed.running, 0); + assert.equal(failed.settling, 0); + assert.equal(failed.retained, 1); + assert.equal(failed.complete, false); + assert.deepEqual(failed.unknown, [ + 'worker_runtime_unaccounted', 'worker_request_timeout_unconfirmed', 'worker_reap_unconfirmed', + ]); + assert.equal(supervisor.getGeneration(), failed.generation); + assert.deepEqual(supervisor.snapshotActivity(), failed); +}); + +test('desktop reader retains option enrichment after registered-run abort and verified worker reap', async () => { + const child = new FakeChild(); const peer = new FakePeer(child); replyToHandshake(peer); + let rejectEnrichment!: (error: Error) => void; + const enrichment = new Promise((_resolve, reject) => { rejectEnrichment = reject; }); + let enriching = false; + const supervisor = new GjcWorkerSupervisor({ + ...runtime(child), enrichOptions: () => { enriching = true; return enrichment; }, + }); + const run = spawn(supervisor, 'hello', {}, { send() {} }); + await peer.waitFor('worker.initialize'); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(enriching, true); + assert.equal(await supervisor.abort(run.abortHandle), 'not_started'); + await run; + child.emit('exit', 1); + await new Promise((resolve) => setImmediate(resolve)); + const waiting = supervisor.snapshotActivity(); + assert.equal(waiting.running, 0); + assert.equal(waiting.starting, 0); + assert.equal(waiting.retained, 0); + assert.equal(waiting.complete, true); + assert.ok(waiting.settling > 0, 'the removed run still has an accepted enrichment continuation'); + rejectEnrichment(new Error('late enrichment failed')); + await new Promise((resolve) => setImmediate(resolve)); + assertDesktopIdle(supervisor.snapshotActivity()); + assert.notEqual(supervisor.getGeneration(), waiting.generation); + assert.equal(peer.requests.some((request) => request.method === 'session.start'), false); +}); + +function deferredEnrichment() { + let resolve!: (value: T) => void; + const promise = new Promise((yes) => { resolve = yes; }); + return { promise, resolve }; +} + +const flushEnrichment = () => new Promise((resolve) => setImmediate(resolve)); + +for (const method of ['session.start', 'session.resume'] as const) { + test(`successful option enrichment cannot dispatch a cancelled ${method}`, async () => { + const child = new FakeChild(); const peer = new FakePeer(child); replyToHandshake(peer); + const entered = deferredEnrichment(); + const enriched = deferredEnrichment>(); + const messages: unknown[] = []; + let stopped = 0; + const supervisor = new GjcWorkerSupervisor({ + ...runtime(child), + enrichOptions: () => { entered.resolve(); return enriched.promise; }, + notifyRunStopped: () => { stopped += 1; }, + }); + const run = supervisor.spawnRun({ + runId: 'cancel-during-enrichment', appSessionId: 'app-session-1', message: 'never send this', + options: method === 'session.resume' ? { sessionId: 'existing-provider-session' } : {}, + writer: { send: (message) => messages.push(message) }, + }); + try { + await entered.promise; + const alias = method === 'session.resume' ? 'existing-provider-session' : run.abortHandle; + assert.equal(await supervisor.abort(alias), 'not_started'); + await run.completion; + await assert.rejects(run.started, /GJC worker failed/); + assert.equal(await run.outcome, 'not_started'); + assert.equal(run.phase?.(), 'run_terminal'); + const cancelled = supervisor.snapshotActivity(); + assert.ok(cancelled.settling > 0, 'cancellation still owns the unfinished enrichment'); + enriched.resolve({ cwd: '/test/project', modelId: 'resolved-model' }); + await flushEnrichment(); + assert.equal(peer.requests.some((request) => request.method === method), false); + assert.equal(run.phase?.(), 'run_terminal'); + assert.equal(supervisor.isActive(alias), false); + assert.equal(supervisor.snapshotActivity().settling, 0); + assert.notEqual(supervisor.getGeneration(), cancelled.generation); + assert.equal(child.killed, false, 'cancelling an unissued run must not kill the shared worker'); + assert.deepEqual(messages, [], 'no synthetic completion or late stream after the accepted abort'); + assert.equal(stopped, 1); + } finally { + enriched.resolve({}); + await flushEnrichment(); + for (const request of peer.requests.filter((entry) => entry.method === method)) peer.respond(request); + await flushEnrichment(); + } + }); +} + +test('successful option enrichment from a cancelled run cannot seize its reused run ID', async () => { + const child = new FakeChild(); const peer = new FakePeer(child); replyToHandshake(peer); + const oldEntered = deferredEnrichment(); const nextEntered = deferredEnrichment(); + const oldOptions = deferredEnrichment>(); + const nextOptions = deferredEnrichment>(); + let enrichments = 0; + const supervisor = new GjcWorkerSupervisor({ + ...runtime(child), + enrichOptions: () => { + if (++enrichments === 1) { oldEntered.resolve(); return oldOptions.promise; } + nextEntered.resolve(); return nextOptions.promise; + }, + }); + const input = { runId: 'reused-run-id', appSessionId: 'app-session-1', writer: { send() {} } }; + const oldRun = supervisor.spawnRun({ ...input, message: 'cancelled message' }); + try { + await oldEntered.promise; + assert.equal(await supervisor.abort(oldRun.abortHandle), 'not_started'); + await oldRun.completion; + const replacement = supervisor.spawnRun({ ...input, message: 'replacement message' }); + await nextEntered.promise; + oldOptions.resolve({ modelId: 'stale-model' }); + await flushEnrichment(); + assert.equal(peer.requests.some((request) => request.method === 'session.start'), false); + assert.equal(oldRun.phase?.(), 'run_terminal'); + assert.equal(replacement.phase?.(), 'registered'); + assert.equal(supervisor.isActive(replacement.abortHandle), true); + nextOptions.resolve({ modelId: 'current-model' }); + const start = await peer.waitFor('session.start'); + assert.equal((start.payload as JsonObject).message, 'replacement message'); + assert.equal(((start.payload as JsonObject).options as Record).modelId, 'current-model'); + await replacement.started; + peer.respond(start); + await replacement.completion; + assert.equal(await replacement.outcome, 'completed'); + assert.equal(await oldRun.outcome, 'not_started'); + assert.equal(peer.requests.filter((request) => request.method === 'session.start').length, 1); + } finally { + oldOptions.resolve({}); nextOptions.resolve({}); + await flushEnrichment(); + for (const request of peer.requests.filter((entry) => entry.method === 'session.start')) peer.respond(request); + await flushEnrichment(); + } +}); + +test('successful option enrichment during shutdown uses the existing not-started abort outcome', async () => { + const child = new FakeChild(); const peer = new FakePeer(child); replyToHandshake(peer); + const entered = deferredEnrichment(); + const enriched = deferredEnrichment>(); + const supervisor = new GjcWorkerSupervisor({ + ...runtime(child), enrichOptions: () => { entered.resolve(); return enriched.promise; }, + }); + const run = supervisor.spawnRun({ + runId: 'shutdown-enrichment', appSessionId: 'app-session-1', message: 'never dispatch', writer: { send() {} }, + }); + let shutdown: Promise | undefined; + try { + await entered.promise; + shutdown = supervisor.shutdown(); + await peer.waitFor('worker.shutdown'); + enriched.resolve({ modelId: 'late-model' }); + await flushEnrichment(); + assert.equal(peer.requests.some((request) => request.method === 'session.start'), false); + await run.completion; + assert.equal(await run.outcome, 'not_started'); + assert.equal(run.phase?.(), 'run_terminal'); + assert.equal(child.killed, false, 'the existing shutdown response/reap sequence is unchanged'); + } finally { + enriched.resolve({}); + await flushEnrichment(); + for (const request of peer.requests.filter((entry) => entry.method === 'session.start' || entry.method === 'worker.shutdown')) peer.respond(request); + if (shutdown) await shutdown; + await flushEnrichment(); + } +}); + +test('successful option enrichment from a reaped worker cannot dispatch into its replacement', async () => { + const first = new FakeChild(); const second = new FakeChild(); + const peer = new FakePeer(first); const nextPeer = new FakePeer(second); + replyToHandshake(peer); replyToHandshake(nextPeer); + const entered = deferredEnrichment(); + const enriched = deferredEnrichment>(); + let spawns = 0; + const supervisor = new GjcWorkerSupervisor({ + ...runtime(first), spawn: () => ++spawns === 1 ? first : second, + killTree: () => {}, + enrichOptions: () => { entered.resolve(); return enriched.promise; }, + }); + const run = supervisor.spawnRun({ + runId: 'old-worker-enrichment', appSessionId: 'app-session-1', message: 'old worker only', writer: { send() {} }, + }); + try { + await entered.promise; + const failure = assert.rejects(run.completion, /GJC worker failed/); + first.emit('exit', 1); + await failure; + assert.equal(await run.outcome, 'reaped'); + const catalog = supervisor.modelCatalog(); + nextPeer.respond(await nextPeer.waitFor('models.catalog')); + await catalog; + assert.equal(spawns, 2); + enriched.resolve({ modelId: 'old-generation-model' }); + await flushEnrichment(); + assert.equal(nextPeer.requests.some((request) => request.method === 'session.start'), false); + assert.equal(peer.requests.some((request) => request.method === 'session.start'), false); + assert.equal(run.phase?.(), 'run_terminal'); + assert.equal(supervisor.active().length, 0); + assert.equal(supervisor.snapshotActivity().settling, 0); + assert.equal(second.killed, false); + } finally { + enriched.resolve({}); + await flushEnrichment(); + for (const request of nextPeer.requests.filter((entry) => entry.method === 'session.start')) nextPeer.respond(request); + await flushEnrichment(); + } +}); + +test('desktop reader owns terminal callback settlement after run removal and tree reap', async () => { + const child = new FakeChild(); const peer = new FakePeer(child); replyToHandshake(peer); + let finishNotification!: () => void; + const notification = new Promise((resolve) => { finishNotification = resolve; }); + let duringNotification!: DesktopOwnerActivity; + const supervisor = new GjcWorkerSupervisor({ + ...runtime(child), notifyRunStopped: () => { + duringNotification = supervisor.snapshotActivity(); + return notification; + }, + }); + const run = spawn(supervisor, 'hello', {}, { send() {} }); + peer.respond(await peer.waitFor('session.start')); await run; + assert.equal(duringNotification.running, 0); + assert.ok(duringNotification.settling > 0); + child.emit('exit', 1); + await new Promise((resolve) => setImmediate(resolve)); + const waiting = supervisor.snapshotActivity(); + assert.equal(waiting.retained, 0); + assert.equal(waiting.complete, true); + assert.ok(waiting.settling > 0); + finishNotification(); + await new Promise((resolve) => setImmediate(resolve)); + assertDesktopIdle(supervisor.snapshotActivity()); + assert.notEqual(supervisor.getGeneration(), waiting.generation); +}); + +test('desktop reap-to-finalization handoff has no zero-count gap inside a writer callback', async () => { + const child = new FakeChild(); const peer = new FakePeer(child); replyToHandshake(peer); + const observed: DesktopOwnerActivity[] = []; + const supervisor = new GjcWorkerSupervisor(runtime(child)); + const run = spawn(supervisor, 'hello', {}, { send() { observed.push(supervisor.snapshotActivity()); } }); + await peer.waitFor('session.start'); + const failure = assert.rejects(run, /GJC worker failed/); + child.emit('exit', 1); + await failure; + await new Promise((resolve) => setImmediate(resolve)); + assert.ok(observed.length > 0); + for (const during of observed) { + assert.equal(during.running, 0); + assert.equal(during.retained, 0); + assert.ok(during.settling > 0, 'finish owns synchronous callbacks after releasing the run map entry'); + } + assertDesktopIdle(supervisor.snapshotActivity()); +}); + +test('desktop replacement waiters remain owned across reap and stale old-child frames cannot mutate the reader', async () => { + const first = new FakeChild(); const second = new FakeChild(); + const peer = new FakePeer(first); const nextPeer = new FakePeer(second); + replyToHandshake(peer); replyToHandshake(nextPeer); + let releaseReap!: () => void; + const verifiedTree = new Promise((resolve) => { releaseReap = resolve; }); + let spawns = 0; + const supervisor = new GjcWorkerSupervisor({ + ...runtime(first), spawn: () => ++spawns === 1 ? first : second, + killTree: () => verifiedTree, + }); + const warm = supervisor.modelCatalog(); + peer.respond(await peer.waitFor('models.catalog')); await warm; + const oldGeneration = supervisor.getGeneration(); + first.emit('exit', 1); + const run = spawn(supervisor, 'replacement', {}, { send() {} }); + const catalog = supervisor.modelCatalog(); + const waiting = supervisor.snapshotActivity(); + assert.equal(spawns, 1); + assert.ok(waiting.starting > 0); + assert.ok(waiting.settling > 0); + assert.ok(waiting.unknown.includes('worker_reap_pending')); + assert.notEqual(waiting.generation, oldGeneration); + releaseReap(); + const start = await nextPeer.waitFor('session.start'); + const models = await nextPeer.waitFor('models.catalog'); + const replaced = supervisor.snapshotActivity(); + assert.equal(spawns, 2); + assert.equal(replaced.retained, 1); + assert.equal(replaced.running, 1); + assert.deepEqual(replaced.unknown, ['worker_runtime_unaccounted']); + assert.notEqual(replaced.generation, waiting.generation); + first.stdout.write('not-json\n'); + first.emit('close', 1); + assert.equal(supervisor.getGeneration(), replaced.generation); + nextPeer.respond(start); nextPeer.respond(models); + await Promise.all([run, catalog]); +}); + +test('desktop process-tree proof includes a separately reported run process, not just worker leader exit', async () => { + const child = new FakeChild(); const peer = new FakePeer(child); replyToHandshake(peer); + let proveProcessExit!: () => void; + const processExit = new Promise((resolve) => { proveProcessExit = resolve; }); + const killed: number[] = []; + const supervisor = new GjcWorkerSupervisor({ + ...runtime(child), killTree: () => {}, + killProcessTree: (pid) => { killed.push(pid); return processExit; }, + }); + const run = spawn(supervisor, 'hello', {}, { send() {} }); + const start = await peer.waitFor('session.start'); + const beforePid = supervisor.getGeneration(); + peer.status('app-session-1', start.id, 4242); + assert.notEqual(supervisor.getGeneration(), beforePid); + const failure = assert.rejects(run, /GJC worker failed/); + child.emit('exit', 1); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(killed, [4242]); + const pending = supervisor.snapshotActivity(); + assert.equal(pending.retained, 1); + assert.equal(pending.running, 1); + assert.ok(pending.unknown.includes('worker_reap_pending')); + proveProcessExit(); await failure; + await new Promise((resolve) => setImmediate(resolve)); + assertDesktopIdle(supervisor.snapshotActivity()); +}); + +test('desktop missing process reaper or discarded PID never becomes an OS tree-termination proof', async () => { + for (const mode of ['missing-reaper', 'pid-cleared', 'pid-replaced', 'run-finished'] as const) { + const child = new FakeChild(); const peer = new FakePeer(child); replyToHandshake(peer); + const supervisor = new GjcWorkerSupervisor({ + ...runtime(child), + ...(mode !== 'missing-reaper' ? { killProcessTree: () => {} } : {}), + }); + const run = spawn(supervisor, 'hello', {}, { send() {} }); + const start = await peer.waitFor('session.start'); + peer.status('app-session-1', start.id, 4242); + if (mode === 'pid-cleared') peer.status('app-session-1', start.id, null); + if (mode === 'pid-replaced') peer.status('app-session-1', start.id, 4243); + if (mode === 'run-finished') { peer.respond(start); await run; } + const settled = mode === 'run-finished' ? run : assert.rejects(run, /GJC worker failed/); + child.emit('exit', 1); await settled; + await new Promise((resolve) => setImmediate(resolve)); + const unknown = supervisor.snapshotActivity(); + assert.equal(unknown.retained, 1, mode); + assert.equal(unknown.complete, false, mode); + assert.deepEqual(unknown.unknown, ['worker_runtime_unaccounted', 'worker_process_tree_unaccounted'], mode); + } +}); + +test('desktop pending approval and abort completions outlive terminal run and approval map removal', async () => { + const child = new FakeChild(); const peer = new FakePeer(child); replyToHandshake(peer); + const supervisor = new GjcWorkerSupervisor(runtime(child)); + const run = spawn(supervisor, 'hello', {}, { send() {} }); + const start = await peer.waitFor('session.start'); + peer.event('app-session-1', start.id, 'ask.presented', { + message: { kind: 'permission_request', requestId: 'outliving-reply' }, + }); + supervisor.resolveApproval('outliving-reply', { allow: true }); + const beforeAbort = supervisor.getGeneration(); + const abort = supervisor.abort(run.abortHandle); + assert.notEqual(supervisor.getGeneration(), beforeAbort); + const abortRequest = await peer.waitFor('turn.abort'); + const approvalRequest = await peer.waitFor('ask.reply'); + peer.respond(start); await run; + const terminal = supervisor.snapshotActivity(); + assert.equal(terminal.running, 0); + assert.equal(terminal.approvals, 0); + assert.equal(terminal.queued, 2); + assert.ok(terminal.settling >= 2, 'both owned completion handlers are still live'); + peer.respond(abortRequest, { ok: true, result: { aborted: true } }); + peer.respond(approvalRequest, { ok: true, result: { accepted: false } }); + const replies = supervisor.snapshotActivity(); + assert.equal(replies.queued, 0); + assert.ok(replies.settling >= 2, 'acknowledging both requests does not run their continuations inline'); + assert.notEqual(replies.generation, terminal.generation); + assert.equal(await abort, 'unconfirmed', 'do not change existing late-abort behavior'); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(supervisor.snapshotActivity().settling, 0); + assert.notEqual(supervisor.getGeneration(), replies.generation); +}); + +test('desktop old reap cannot erase a reentrant replacement generation or its pending reap', async () => { + const first = new FakeChild(); const second = new FakeChild(); + const peer = new FakePeer(first); const nextPeer = new FakePeer(second); + replyToHandshake(peer); replyToHandshake(nextPeer); + let proveFirstExit!: () => void; let proveSecondExit!: () => void; + const firstExit = new Promise((resolve) => { proveFirstExit = resolve; }); + const secondExit = new Promise((resolve) => { proveSecondExit = resolve; }); + let replacement!: Promise; + let insideReplacement!: DesktopOwnerActivity; + let spawns = 0; + const supervisor = new GjcWorkerSupervisor({ + ...runtime(first), spawn: () => ++spawns === 1 ? first : second, + killTree: (child) => { + if (child === first) { + // Existing lifecycle hooks can reenter before terminating is assigned. + // Observation must remain safe without changing that runtime behavior. + replacement = supervisor.modelCatalog(); + insideReplacement = supervisor.snapshotActivity(); + return firstExit; + } + return secondExit; + }, + }); + const warm = supervisor.modelCatalog(); + peer.respond(await peer.waitFor('models.catalog')); await warm; + first.emit('exit', 1); + assert.equal(insideReplacement.retained, 2); + nextPeer.respond(await nextPeer.waitFor('models.catalog')); await replacement; + second.emit('exit', 1); + const bothRetiring = supervisor.snapshotActivity(); + assert.equal(bothRetiring.retained, 2); + proveFirstExit(); + await new Promise((resolve) => setImmediate(resolve)); + const secondRetiring = supervisor.snapshotActivity(); + assert.equal(secondRetiring.retained, 1); + assert.equal(secondRetiring.complete, false); + assert.ok(secondRetiring.unknown.includes('worker_reap_pending')); + assert.notEqual(secondRetiring.generation, bothRetiring.generation); + proveSecondExit(); + await new Promise((resolve) => setImmediate(resolve)); + assertDesktopIdle(supervisor.snapshotActivity()); +}); + +test('desktop frozen Windows tree remains unaccounted even if an injected reaper fulfills', async () => { + const child = new FakeChild(); const peer = new FakePeer(child, true); replyToHandshake(peer); + const supervisor = new GjcWorkerSupervisor({ + ...runtime(child), platform: 'win32', killTree: () => {}, + environment: { SystemRoot: 'C:\\Windows' }, + }); + const catalog = supervisor.modelCatalog(); + child.stdout.write(`${GJC_WINDOWS_JOB_GUARD_READY}\n`); + peer.respond(await peer.waitFor('models.catalog')); await catalog; + child.emit('exit', 1); + await new Promise((resolve) => setImmediate(resolve)); + const snapshot = supervisor.snapshotActivity(); + assert.equal(snapshot.retained, 1); + assert.equal(snapshot.complete, false); + assert.deepEqual(snapshot.unknown, ['worker_runtime_unaccounted']); +}); diff --git a/server/gjc-worker-client.ts b/server/gjc-worker-client.ts index 4543285d..bbfeaab2 100644 --- a/server/gjc-worker-client.ts +++ b/server/gjc-worker-client.ts @@ -7,6 +7,7 @@ import { dirname, isAbsolute, join, relative } from 'node:path'; import { fileURLToPath } from 'node:url'; import type { Writable } from 'node:stream'; +import type { DesktopOwnerActivity } from '../shared/desktopUpdateProtocol.js'; import type { GjcGoalCommand, GjcGoalSnapshot, GjcGoalScope } from '../shared/gjc-goal.js'; import { @@ -67,6 +68,9 @@ export type GjcWorkerOptions = Record & { notificationOwner?: 'terminal-adapter'; }; type GjcOptionsEnricher = (options: GjcWorkerOptions) => Promise; +/** Optional application-owned ingress accounting; the engine owns no app authority. */ +export type GjcWorkerDesktopAdmission = { acquire(source: string): { release(): void } }; +type WorkerActivityObservation = Extract['payload'], { ok: true }>['result']; export type GjcWorkerWriter = { send(value: unknown): void; setSessionId?(id: string): void; getAppSessionId?(): string | undefined; userId?: string | number | null }; type Child = { pid?: number; @@ -133,7 +137,9 @@ export type GjcWorkerSupervisorRuntime = { notifyRunFailed?: RunFailedNotifier; createScope?: () => string; diagnostic?: (message: string) => void; + /** Fulfillment must prove owned process-tree termination, not merely send a signal. */ killTree?: (child: Child) => void | Promise; + /** Same proof contract for separately reported run processes. */ killProcessTree?: (processId: number) => void | Promise; platform?: NodeJS.Platform; environment?: NodeJS.ProcessEnv; @@ -422,8 +428,28 @@ export class GjcWorkerSupervisor { private readonly approvals = new Map(); private readonly expiredRequests = new Map(); private readonly oauthListeners = new Set(); + private readonly activityEpoch = randomUUID(); + private activityRevision = 0n; + private readonly activityTasks = { starting: 0, settling: 0 }; + private readonly unreapedWorkers = new Set(); + private requestTimeoutUncertainty = false; + private readonly reapingWorkers = new Set(); + private runProcessProofMissing = false; + private readonly hasRunProcessReaper: boolean; + private desktopAdmission?: GjcWorkerDesktopAdmission; + private restartFence?: { + id: string; child?: Child; acknowledged: boolean; pending?: Promise; + /** Immutable first correlated observation for this exact acknowledged fence. */ + remoteGeneration?: string; + invalidated?: boolean; + }; + private observation?: { + id: string; child: Child; promise: Promise; + settle(value?: WorkerActivityObservation): void; + }; constructor(runtime: GjcWorkerSupervisorRuntime = {}) { + this.hasRunProcessReaper = runtime.killProcessTree !== undefined; this.runtime = { spawn: runtime.spawn ?? spawnChild as unknown as Spawn, corePath: runtime.corePath, @@ -445,6 +471,190 @@ export class GjcWorkerSupervisor { environment: runtime.environment ?? process.env, }; } + + /** Pure revision; unique across supervisors and never reused after an idle/busy/idle cycle. */ + getGeneration(): string { + return `${this.activityEpoch}:${this.activityRevision}`; + } + + configureDesktopRestartAdmission(admission?: GjcWorkerDesktopAdmission): void { + this.desktopAdmission = admission; + } + + private acquireRoot(source: string): () => void { + if (this.restartFence) throw Object.assign(new Error('Worker admission is fenced.'), { code: 'DESKTOP_RESTART_FENCED' }); + const lease = this.desktopAdmission?.acquire(`gjc-worker:${source}`); + return () => lease?.release(); + } + + /** Close locally before any await. Busy accepted roots keep running; never abort to fence. */ + async fenceForDesktopRestart(fenceId: string): Promise { + if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u.test(fenceId)) throw new TypeError('Invalid worker fence.'); + if (this.restartFence && this.restartFence.id !== fenceId) throw new Error('Worker fence conflicts.'); + if (!this.restartFence) { + this.restartFence = { id: fenceId, acknowledged: false }; + this.activityChanged(); + } + const fence = this.restartFence; + if (fence.pending) return fence.pending; + if (fence.acknowledged && fence.child === this.child) return; + // These operations were accepted before close. They can still deliver + // their original request, so do not place a remote fence in their path. + if (this.starting || this.runs.size || this.tracker.size || this.activityTasks.starting || this.activityTasks.settling) { + throw new Error('Worker has accepted work.'); + } + const child = this.child; + if (!child) { + if (this.unreapedWorkers.size || this.terminating || this.terminationFailure) throw new Error('Worker ownership is unconfirmed.'); + fence.acknowledged = true; + return; + } + if (!this.ready) throw new Error('Worker is not ready.'); + fence.child = child; + const pending = this.request('worker.admission', undefined, { fenceId, closed: true }, 1_000).then((response) => { + if (!response.ok || object(response.result)?.fenceId !== fenceId || this.child !== child || this.restartFence !== fence) { + throw new Error('Worker fence was not acknowledged.'); + } + fence.acknowledged = true; + this.activityChanged(); + }).finally(() => { if (fence.pending === pending) fence.pending = undefined; }); + fence.pending = pending; + return pending; + } + + /** Exact-ID release; failed/late acknowledgement leaves local admission closed. */ + async releaseDesktopRestartFence(fenceId: string): Promise { + const fence = this.restartFence; + if (!fence) return; + if (fence.id !== fenceId) throw new Error('Worker fence conflicts.'); + try { await fence.pending; } catch { /* Still send ordered release to the same child. */ } + if (fence.child && fence.child === this.child) { + const response = await this.request('worker.admission', undefined, { fenceId, closed: false }, 1_000); + if (!response.ok || object(response.result)?.fenceId !== null) throw new Error('Worker fence release was not acknowledged.'); + } + if (this.restartFence === fence) { + this.restartFence = undefined; + this.activityChanged(); + } + } + + /** Observes the existing child only. Never calls ensureWorker, cancellation or reaping. */ + async readDesktopRestartActivity(): Promise { + const initial = this.snapshotActivity(); + const child = this.child; + if (!child || !this.ready || this.terminating || this.terminationFailure) return initial; + const fence = this.restartFence; + const remote = await this.observeWorker(child); + const changed = this.getGeneration() !== initial.generation || this.child !== child || this.restartFence !== fence; + if (!remote || changed || remote.fenceId !== (fence?.id ?? null)) { + return { ...initial, complete: false, unknown: [...new Set([...initial.unknown, + !remote ? 'worker_observation_unavailable' : 'worker_observation_stale'])] }; + } + if (fence?.acknowledged && fence.child === child) { + if (fence.remoteGeneration === undefined) { + // Establishing evidence is not new activity: the first observation + // must still match the synchronous generation captured by authority. + fence.remoteGeneration = remote.generation; + } else if (fence.remoteGeneration !== remote.generation && !fence.invalidated) { + // An actual remote mutation invalidates every prepared proof under + // this fence. Never silently rebase it to a newer (or reverted) idle + // revision. Release + a new fence is the only way to establish proof. + fence.invalidated = true; + this.activityChanged(); + } + } + // Account for this child through evidence without forgetting its OS owner. + // Other unreaped generations, escaped PIDs and timeout latches stay unknown. + const unknown = [...new Set([ + ...initial.unknown.filter((reason) => reason !== 'worker_runtime_unaccounted' + || this.unreapedWorkers.size !== 1 || this.runProcessProofMissing || this.runtime.platform === 'win32'), + ...remote.unknown, + ...(!fence?.acknowledged || fence.child !== child ? ['worker_admission_open'] : []), + ...(fence?.invalidated ? ['worker_observation_stale'] : []), + ])].slice(0, 32); + return { + ...initial, complete: remote.complete && unknown.length === 0, + starting: initial.starting + remote.starting, + queued: initial.queued + remote.queued, + running: initial.running + remote.running, + settling: initial.settling + remote.settling, + approvals: initial.approvals + remote.approvals, + retained: initial.retained - Number(this.runtime.platform !== 'win32') + remote.retained, + unknown, + }; + } + + private observeWorker(child: Child): Promise { + // One slot per child, including a timed-out request awaiting its late reply. + // Repeated reads cannot accumulate requests, timers or expired-ID entries. + if (this.observation?.child === child) return this.observation.promise; + const id = `observe-${randomUUID()}`; + let settle!: (value?: WorkerActivityObservation) => void; + const promise = new Promise((resolve) => { settle = resolve; }); + const timer = setTimeout(() => settle(), 250); + timer.unref?.(); + const observation = { id, child, promise, settle: (value?: WorkerActivityObservation) => { clearTimeout(timer); settle(value); } }; + this.observation = observation; + try { + child.stdin.write(serializeGjcWorkerFrame({ protocolVersion: GJC_WORKER_PROTOCOL_VERSION, + kind: 'request', id, method: 'worker.activity', payload: {} })); + } catch { observation.settle(); } + return promise; + } + + /** + * Pure parent-side accounting. A retained live child is unaccounted here; + * readDesktopRestartActivity can compose fenced worker evidence without + * forgetting that OS owner. Counts overlap and include app continuations + * after a request/run is removed. + */ + snapshotActivity(): DesktopOwnerActivity { + let registered = 0; + let running = 0; + let aborting = 0; + let approvalsInFlight = 0; + for (const run of this.runs.values()) { + if (run.phase === 'registered') registered += 1; + if (run.phase === 'request_issued') running += 1; + if (run.abortPromise) aborting += 1; + } + for (const approval of this.approvals.values()) { + if (approval.inFlight) approvalsInFlight += 1; + } + const unknown: string[] = []; + if (this.unreapedWorkers.size || this.runProcessProofMissing) unknown.push('worker_runtime_unaccounted'); + if (this.requestTimeoutUncertainty) unknown.push('worker_request_timeout_unconfirmed'); + if (this.reapingWorkers.size) unknown.push('worker_reap_pending'); + if (this.terminationFailure) unknown.push('worker_reap_unconfirmed'); + if (this.runProcessProofMissing) unknown.push('worker_process_tree_unaccounted'); + return { + owner: 'gjc-worker', generation: this.getGeneration(), complete: unknown.length === 0, + starting: this.activityTasks.starting + registered, + queued: this.tracker.size, + running, + settling: this.activityTasks.settling + aborting + approvalsInFlight, + approvals: this.approvals.size, + retained: this.unreapedWorkers.size + Number(this.runProcessProofMissing), + unknown, + }; + } + + private activityChanged(): void { + this.activityRevision += 1n; + } + + /** Covers awaits and synchronous user callbacks that can outlive map entries. */ + private beginActivity(kind: keyof GjcWorkerSupervisor['activityTasks']): () => void { + this.activityTasks[kind] += 1; + this.activityChanged(); + let released = false; + return () => { + if (released) return; + released = true; + this.activityTasks[kind] -= 1; + this.activityChanged(); + }; + } /** * Sends a global OAuth request through the one supervised worker. OAuth * protocol requests deliberately carry no app session id. @@ -453,33 +663,60 @@ export class GjcWorkerSupervisor { method: GjcWorkerOAuthRequestMethod, payload: JsonObject, ): Promise { - await this.ensureWorker(); - return this.request(method, undefined, payload); + const releaseAdmission = method === 'oauth.submit' || method === 'oauth.cancel' ? () => {} : this.acquireRoot(method); + const release = this.beginActivity('settling'); + try { + if (this.restartFence && (!this.child || !this.ready)) throw new Error('No accepted OAuth attempt is available.'); + await this.ensureWorker(); + return await this.request(method, undefined, payload); + } finally { + release(); + releaseAdmission(); + } } async modelCatalog(): Promise { - await this.ensureWorker(); - return this.request('models.catalog', undefined, {}); + const releaseAdmission = this.acquireRoot('models.catalog'); + const release = this.beginActivity('settling'); + try { + await this.ensureWorker(); + return await this.request('models.catalog', undefined, {}); + } finally { + release(); + releaseAdmission(); + } } async inspectGoal(scope: GjcGoalScope, providerSessionId: string): Promise { - const liveRoot = getGjcLiveSessionRoot(); - const sessionRoot = await resolveGjcResumeSessionRoot(providerSessionId, liveRoot) ?? liveRoot; - await this.ensureWorker(); - const response = await this.request('goal.inspect', scope.appSessionId, { owner: scope.owner, cwd: scope.cwd, ...(scope.projectPath ? { projectPath: scope.projectPath } : {}), providerSessionId, sessionRoot }); - if (!response.ok) throw new Error(response.error.message); - return response.result as GjcGoalSnapshot; + const releaseAdmission = this.acquireRoot('goal.inspect'); + const release = this.beginActivity('settling'); + try { + const liveRoot = getGjcLiveSessionRoot(); + const sessionRoot = await resolveGjcResumeSessionRoot(providerSessionId, liveRoot) ?? liveRoot; + await this.ensureWorker(); + const response = await this.request('goal.inspect', scope.appSessionId, { owner: scope.owner, cwd: scope.cwd, ...(scope.projectPath ? { projectPath: scope.projectPath } : {}), providerSessionId, sessionRoot }); + if (!response.ok) throw new Error(response.error.message); + return response.result as GjcGoalSnapshot; + } finally { + release(); + releaseAdmission(); + } } async controlGoal(runId: string, scope: GjcGoalScope, command?: GjcGoalCommand, stopAfterMutation = true): Promise { const run = this.runs.get(runId); if (!run || run.appScope !== scope.appSessionId || run.phase !== 'request_issued' || run.aborted || run.abortPromise) throw new Error('The active run changed. Refresh before controlling its goal.'); - const response = await this.request('goal.control', scope.appSessionId, { - runId, owner: scope.owner, cwd: scope.cwd, ...(scope.projectPath ? { projectPath: scope.projectPath } : {}), ...(command ? { command } : {}), - ...(stopAfterMutation ? {} : { stopAfterMutation: false }), - }); - if (!response.ok) throw new Error(response.error.message); - return response.result as GjcGoalSnapshot; + const release = this.beginActivity('settling'); + try { + const response = await this.request('goal.control', scope.appSessionId, { + runId, owner: scope.owner, cwd: scope.cwd, ...(scope.projectPath ? { projectPath: scope.projectPath } : {}), ...(command ? { command } : {}), + ...(stopAfterMutation ? {} : { stopAfterMutation: false }), + }); + if (!response.ok) throw new Error(response.error.message); + return response.result as GjcGoalSnapshot; + } finally { + release(); + } } oauthProviders(): Promise { @@ -525,6 +762,7 @@ export class GjcWorkerSupervisor { } private emitOAuthEvent(event: GjcWorkerOAuthEvent): void { + this.activityChanged(); for (const listener of this.oauthListeners) { try { listener(event); @@ -544,10 +782,12 @@ export class GjcWorkerSupervisor { } private invokeAppCallback(label: string, callback: () => unknown): void { + const release = this.beginActivity('settling'); try { - void Promise.resolve(callback()).catch(() => this.diagnose(label)); + void Promise.resolve(callback()).catch(() => this.diagnose(label)).finally(release); } catch { this.diagnose(label); + release(); } } @@ -575,24 +815,52 @@ export class GjcWorkerSupervisor { resolveOutcome, resolveStarted, rejectStarted, started: false, }; this.runs.set(runId, run); - void this.startRun(run, message); + this.activityChanged(); + let releaseAdmission: () => void; + try { releaseAdmission = this.acquireRoot('session.start'); } + catch (error) { + this.runs.delete(runId); + this.activityChanged(); + rejectStarted(error as Error); reject(error as Error); resolveOutcome('not_started'); + return { started, completion, outcome, abortHandle: runId }; + } + void this.startRun(run, message).finally(releaseAdmission); return { started, completion, outcome, phase: () => run.phase, abortHandle: runId }; } - + private canStartRun(run: Run, startingChild: Child | undefined): boolean { + // A cancelled/reaped run may have been removed and its ID reused while + // startup or model/session-root enrichment was awaiting external work. + if (this.runs.get(run.runId) !== run || run.phase !== 'registered') return false; + if (run.aborted || this.shuttingDown) { + // Exact identity + registered phase select abort's existing synchronous + // not_started path. Its notification promises retain their own lifetime. + void this.abort(run.runId); + return false; + } + // Bind to the Child, not the activity revision (ordinary events change it). + // If its generation is being reaped, workerFailed still owns settlement. + return Boolean(startingChild && this.child === startingChild && this.ready + && !this.terminating && !this.terminationFailure && !run.cleanupUnconfirmed && !run.abortPromise); + } private async startRun(run: Run, message: string): Promise { + const release = this.beginActivity('settling'); try { await this.ensureWorker(); - if (run.phase === 'run_terminal') return; + const startingChild = this.child; + if (!this.canStartRun(run, startingChild)) return; const providerSessionId = safeId(run.options.sessionId); if (providerSessionId) { run.providerSessionId = providerSessionId; this.aliases.set(providerSessionId, run.runId); + this.activityChanged(); } const options = safeOptions(await this.runtime.enrichOptions(run.options)); + // No await between the final ownership check and writing the request. + if (!this.canStartRun(run, startingChild)) return; if (!options) { this.finish(run, true, SAFE_FAILURE, 'not_started'); return; @@ -613,6 +881,7 @@ export class GjcWorkerSupervisor { () => { run.phase = 'request_issued'; run.started = true; + this.activityChanged(); run.resolveStarted(); }, ); @@ -621,6 +890,7 @@ export class GjcWorkerSupervisor { // Preserve that outcome through native jobs and the chat terminal. run.runtimeAborted = !run.aborted && !run.abortPromise; run.aborted = true; + this.activityChanged(); } this.finish(run, run.terminalFailed || !response.ok, runFailureMessage(response)); } catch (error) { @@ -631,6 +901,8 @@ export class GjcWorkerSupervisor { true, error instanceof GjcConfigurationError ? error.message : SAFE_FAILURE, ); + } finally { + release(); } } @@ -641,6 +913,16 @@ export class GjcWorkerSupervisor { } if (this.ready && this.child) return Promise.resolve(); if (this.starting) return this.starting; + const release = this.beginActivity('starting'); + try { + return this.startWorker(release); + } catch (error) { + release(); + throw error; + } + } + + private startWorker(releaseStartup: () => void): Promise { const compiled = this.runtime.compiled ?? !import.meta.url.endsWith('.ts'); const workerPath = this.runtime.workerPath ?? fileURLToPath(new URL(compiled ? './gjc-bun-worker.js' : './gjc-bun-worker.ts', import.meta.url)); const bundledBunPath = fileURLToPath(new URL( @@ -684,6 +966,8 @@ export class GjcWorkerSupervisor { windowsHide: true, }); this.child = child; this.ready = false; this.decoder = new GjcWorkerNdjsonDecoder(); + this.unreapedWorkers.add(child); + this.activityChanged(); const usesWindowsJobGuard = this.runtime.platform === 'win32'; let guardSettled = !usesWindowsJobGuard; let guardBuffer = Buffer.alloc(0); @@ -699,6 +983,7 @@ export class GjcWorkerSupervisor { const settleGuard = (error?: Error): void => { if (guardSettled) return; guardSettled = true; + this.activityChanged(); if (guardTimer) clearTimeout(guardTimer); if (error) rejectGuard(error); else resolveGuard(); @@ -761,6 +1046,7 @@ export class GjcWorkerSupervisor { if (child !== this.child) throw new Error('worker generation was replaced during initialization'); if (!response.ok) throw new Error(`worker.initialize was rejected (${response.error.code})`); this.ready = true; + this.activityChanged(); }) .catch((error: unknown) => { // Callers only ever see the sanitized failure; this line is the one @@ -772,9 +1058,14 @@ export class GjcWorkerSupervisor { throw new Error(SAFE_FAILURE); }) .finally(() => { - if (this.starting === starting) this.starting = undefined; + if (this.starting === starting) { + this.starting = undefined; + this.activityChanged(); + } + releaseStartup(); }); this.starting = starting; + this.activityChanged(); return starting; } @@ -803,6 +1094,7 @@ export class GjcWorkerSupervisor { return Promise.reject(error); } const tracked = this.tracker.track(request); + this.activityChanged(); try { child.stdin.write(frame); onWritten?.(); @@ -816,6 +1108,12 @@ export class GjcWorkerSupervisor { request.id, new Error(REQUEST_TIMEOUT), )) { + // The bounded late-response correlation cache is not a lifetime proof. + // Even a late OAuth/goal reply cannot account for its SDK continuations. + // Admission cannot launch SDK work. A timed-out close remains fenced + // locally until an ordered exact-ID release is acknowledged; it must + // not manufacture permanent SDK uncertainty after successful release. + if (method !== 'worker.admission') this.requestTimeoutUncertainty = true; this.expiredRequests.set(request.id, { method: request.method, ...('sessionId' in request ? { sessionId: request.sessionId } : {}), @@ -824,6 +1122,7 @@ export class GjcWorkerSupervisor { const oldest = this.expiredRequests.keys().next().value; if (oldest) this.expiredRequests.delete(oldest); } + this.activityChanged(); } }, timeout); timer.unref?.(); @@ -855,10 +1154,20 @@ export class GjcWorkerSupervisor { } private handleResponse(response: GjcWorkerResponseFrame): void { + if (response.method === 'worker.activity') { + const observation = this.observation; + if (!observation || observation.id !== response.id || observation.child !== this.child) { + throw new GjcWorkerProtocolError('unknown_response_id', 'Activity response does not match its request.'); + } + this.observation = undefined; + observation.settle(response.payload.ok ? response.payload.result : undefined); + return; + } if (!response.payload.ok && response.payload.error.code === GJC_CLEANUP_UNCONFIRMED_CODE) { const child = this.child; if (child) { for (const run of this.runs.values()) run.cleanupUnconfirmed = true; + this.activityChanged(); // Fence synchronously, before settling the request and its startRun // continuation. workerFailed owns every terminal after verified reap. void this.workerFailed(child); @@ -868,6 +1177,7 @@ export class GjcWorkerSupervisor { const expired = this.expiredRequests.get(response.id); if (!expired) { this.tracker.settle(response); + this.activityChanged(); return; } @@ -879,6 +1189,7 @@ export class GjcWorkerSupervisor { ); } this.expiredRequests.delete(response.id); + this.activityChanged(); } private handleEvent(event: GjcWorkerEventFrame): void { @@ -895,10 +1206,14 @@ export class GjcWorkerSupervisor { const run = runId ? this.runs.get(runId) : undefined; const scope = 'sessionId' in event ? event.sessionId : undefined; if (!run || scope !== run.appScope) return; + this.activityChanged(); if (event.method === 'worker.status') { const processId = payload?.processId; if (processId === null) { + // A status message dropping a PID is not OS termination proof. Do not + // forget a process the existing reap path can no longer verify. + if (run.processId) this.runProcessProofMissing = true; run.processId = undefined; return; } @@ -908,6 +1223,9 @@ export class GjcWorkerSupervisor { && processId > 0 && processId <= 0x7fffffff ) { + if (!this.hasRunProcessReaper || (run.processId && run.processId !== processId)) { + this.runProcessProofMissing = true; + } run.processId = processId; } return; @@ -958,6 +1276,7 @@ export class GjcWorkerSupervisor { if (event.method === 'turn.failed' || event.method === 'turn.completed') { run.terminalForwarded = true; run.terminalFailed = event.method === 'turn.failed'; + this.activityChanged(); } } @@ -975,6 +1294,7 @@ export class GjcWorkerSupervisor { // 'registered' has not reached the worker yet and 'run_terminal' is over. if (!run || run.phase !== 'request_issued' || run.aborted || run.abortPromise) return false; + const release = this.beginActivity('settling'); try { const response = await this.request('turn.steer', run.appScope, { runId: run.runId, @@ -984,6 +1304,8 @@ export class GjcWorkerSupervisor { return object(response.result)?.steered === true; } catch { return false; + } finally { + release(); } } @@ -994,20 +1316,28 @@ export class GjcWorkerSupervisor { if (run.abortPromise) return run.abortPromise.then((aborted) => aborted ? 'aborted' : 'unconfirmed'); if (run.phase === 'registered') { run.aborted = true; + this.activityChanged(); this.finish(run, false, SAFE_FAILURE, 'not_started'); return Promise.resolve('not_started'); } + const release = this.beginActivity('settling'); const abortPromise = this.request('turn.abort', run.appScope, { runId: run.runId, }).then((response) => { const result = response.ok ? object(response.result) : undefined; if (!response.ok || result?.aborted !== true || run.phase === 'run_terminal') return false; run.aborted = true; + this.activityChanged(); return true; }).catch(() => false).finally(() => { - if (run.abortPromise === abortPromise) run.abortPromise = undefined; + if (run.abortPromise === abortPromise) { + run.abortPromise = undefined; + this.activityChanged(); + } + release(); }); run.abortPromise = abortPromise; + this.activityChanged(); return abortPromise.then((aborted) => aborted ? 'aborted' : 'unconfirmed'); } async terminate(alias: string): Promise { @@ -1035,6 +1365,7 @@ export class GjcWorkerSupervisor { if (!pending || !serializedDecision) return false; if (pending.inFlight) return true; pending.inFlight = true; + const release = this.beginActivity('settling'); void this.request('ask.reply', pending.appScope, { runId: pending.runId, requestId, @@ -1044,7 +1375,7 @@ export class GjcWorkerSupervisor { if (!response.ok || result?.accepted !== true) { this.restoreApproval(requestId, pending); } - }).catch(() => this.restoreApproval(requestId, pending)); + }).catch(() => this.restoreApproval(requestId, pending)).finally(release); return true; } @@ -1054,10 +1385,12 @@ export class GjcWorkerSupervisor { if (run?.cleanupUnconfirmed) return; if (!run || run.phase === 'run_terminal') { this.approvals.delete(requestId); + this.activityChanged(); return; } pending.inFlight = false; + this.activityChanged(); try { run.writer.send(pending.message); } catch { @@ -1073,6 +1406,15 @@ export class GjcWorkerSupervisor { } private finish(run: Run, failed: boolean, failureMessage = SAFE_FAILURE, outcome: GjcWorkerOutcome = run.aborted ? 'aborted' : run.phase === 'registered' ? 'not_started' : 'completed'): void { + const release = this.beginActivity('settling'); + try { + this.finishRun(run, failed, failureMessage, outcome); + } finally { + release(); + } + } + + private finishRun(run: Run, failed: boolean, failureMessage: string, outcome: GjcWorkerOutcome): void { if (run.phase === 'run_terminal') return; if (run.cleanupUnconfirmed) { if (outcome !== 'reaped') return; @@ -1084,7 +1426,9 @@ export class GjcWorkerSupervisor { run.runtimeAborted = false; } } + if (run.processId && outcome !== 'reaped') this.runProcessProofMissing = true; run.phase = 'run_terminal'; + this.activityChanged(); if (!run.started) run.rejectStarted(new Error(failureMessage)); run.resolveOutcome(outcome); @@ -1098,6 +1442,7 @@ export class GjcWorkerSupervisor { for (const [id, pending] of this.approvals) { if (pending.runId === run.runId) this.approvals.delete(id); } + this.activityChanged(); const sessionId = run.providerSessionId ?? run.appScope; if (failed && !run.aborted) { @@ -1160,10 +1505,19 @@ export class GjcWorkerSupervisor { const existingGeneration = this.terminatingGeneration; if (existingGeneration?.child === child) return existingGeneration.outcome; if (child !== this.child) return Promise.resolve('unconfirmed'); + // Retain ownership BEFORE clearing child or calling an injected terminator; + // callbacks may read the owner synchronously inside killTree(). + const release = this.beginActivity('settling'); + this.reapingWorkers.add(child); + if (this.observation?.child === child) { + this.observation.settle(); + this.observation = undefined; + } this.child = undefined; this.ready = false; this.starting = undefined; this.decoder = undefined; + this.activityChanged(); const usesWindowsJobGuard = this.runtime.platform === 'win32'; const affectedRuns = [...this.runs.values()]; @@ -1190,32 +1544,53 @@ export class GjcWorkerSupervisor { } const termination = Promise.all(terminations).then(() => {}).catch((error) => { this.terminationFailure = new Error(SAFE_FAILURE, { cause: error }); + this.activityChanged(); throw this.terminationFailure; }); this.terminating = termination; const outcome = termination.then( () => { + // Only this existing successful OS tree-reap barrier can clear the + // generation's runtime/timeout uncertainty, never failAll/exit/eviction. + // Lost/unverified separately reported PIDs remain a distinct blocker. + this.reapingWorkers.delete(child); + // Windows has no qualified tree-reap contract, including injected hooks. + if (!usesWindowsJobGuard) this.unreapedWorkers.delete(child); + // The default no-op run reaper or a discarded PID cannot prove the whole + // tree gone. Its bounded poison latch survives without retaining every + // otherwise-reaped Child (and its streams) across later generations. + if (this.unreapedWorkers.size === 0 && !this.runProcessProofMissing) { + this.requestTimeoutUncertainty = false; + } + this.activityChanged(); for (const run of affectedRuns) { this.finish(run, run.terminalForwarded ? run.terminalFailed : true, SAFE_FAILURE, 'reaped'); } return 'reaped' as const; }, () => { + this.reapingWorkers.delete(child); + this.activityChanged(); for (const run of affectedRuns) run.resolveOutcome('unconfirmed'); return 'unconfirmed' as const; }, - ); + ).finally(release); this.terminatingGeneration = { child, runIds: new Set(affectedRuns.map((run) => run.runId)), outcome, }; + this.activityChanged(); void termination.finally(() => { - if (this.terminating === termination) this.terminating = undefined; + if (this.terminating === termination) { + this.terminating = undefined; + this.activityChanged(); + } }).catch(() => {}); this.tracker.failAll(new Error(SAFE_FAILURE)); this.expiredRequests.clear(); + this.activityChanged(); return outcome; } @@ -1228,17 +1603,28 @@ export class GjcWorkerSupervisor { shutdown(): Promise { if (this.shutdownPromise) return this.shutdownPromise; this.shuttingDown = true; + this.activityChanged(); this.shutdownPromise = this.stopWorker(); return this.shutdownPromise; } private async stopWorker(): Promise { + const release = this.beginActivity('settling'); + try { + await this.stopWorkerAndReap(); + } finally { + release(); + } + } + + private async stopWorkerAndReap(): Promise { const child = this.child; if (!child) { await this.awaitTermination(); return; } for (const run of this.runs.values()) run.aborted = true; + this.activityChanged(); try { await this.request( 'worker.shutdown', @@ -1289,6 +1675,18 @@ function reportWorkerDiagnostic(message: string): void { const supervisor = new GjcWorkerSupervisor({ enrichOptions: enrichGjcSdkRunOptions, diagnostic: reportWorkerDiagnostic }); registerGjcRuntimeModelCatalogLoader(() => supervisor.modelCatalog()); + +/** No lazy spawn, shutdown or admission mutation. A live idle proof needs an explicit fence. */ +export function createGjcWorkerDesktopRestartReader(worker: GjcWorkerSupervisor = supervisor): { + getGeneration(): string; + read(): Promise; +} { + return Object.freeze({ + getGeneration: () => worker.getGeneration(), + read: () => worker.readDesktopRestartActivity(), + }); +} + export function getGjcWorkerSupervisor(): GjcWorkerSupervisor { return supervisor; } export function isGjcSessionActive(alias: string) { return supervisor.isActive(alias); } export function resolveGjcToolApproval(requestId: string, decision: GjcApprovalDecision) { return supervisor.resolveApproval(requestId, decision); } diff --git a/server/gjc-worker-protocol.test.ts b/server/gjc-worker-protocol.test.ts index 714429f5..9a87ce0e 100644 --- a/server/gjc-worker-protocol.test.ts +++ b/server/gjc-worker-protocol.test.ts @@ -25,14 +25,14 @@ function request(method: typeof GJC_WORKER_REQUEST_METHODS[number], id = 'reques kind: 'request' as const, id, method, - payload: { input: 'hello' }, + payload: method === 'worker.activity' ? {} : method === 'worker.admission' ? { fenceId: 'fence-1', closed: true } : { input: 'hello' }, }; if (scopedMethods.has(method)) { return { ...base, method: method as Exclude, sessionId: 'session-1', - }; + } as GjcWorkerRequestFrame; } return base as GjcWorkerRequestFrame; } @@ -44,7 +44,7 @@ function protocolError(action: () => unknown, code?: string): void { test('declares the independent worker protocol v1 surface', () => { assert.equal(GJC_WORKER_PROTOCOL_VERSION, 1); assert.equal(GJC_WORKER_MAX_FRAME_BYTES, 64 * 1024 * 1024); - assert.deepEqual(GJC_WORKER_REQUEST_METHODS, ['worker.initialize', 'session.start', 'session.resume', 'turn.start', 'turn.abort', 'turn.steer', 'goal.inspect', 'goal.control', 'ask.reply', 'models.catalog', 'oauth.providers', 'oauth.status', 'oauth.start', 'oauth.submit', 'oauth.cancel', 'worker.shutdown']); + assert.deepEqual(GJC_WORKER_REQUEST_METHODS, ['worker.initialize', 'worker.activity', 'worker.admission', 'session.start', 'session.resume', 'turn.start', 'turn.abort', 'turn.steer', 'goal.inspect', 'goal.control', 'ask.reply', 'models.catalog', 'oauth.providers', 'oauth.status', 'oauth.start', 'oauth.submit', 'oauth.cancel', 'worker.shutdown']); assert.deepEqual(GJC_WORKER_EVENT_METHODS, ['session.created', 'message.delta', 'message.completed', 'tool.started', 'tool.completed', 'ask.presented', 'usage.updated', 'turn.completed', 'turn.failed', 'worker.status', 'oauth.phase', 'oauth.providers.updated', 'provider.auth.updated']); }); @@ -76,6 +76,25 @@ test('validates response success and failure payloads with exact scope', () => { protocolError(() => parseGjcWorkerFrame(JSON.stringify({ protocolVersion: 1, kind: 'response', id: 'request-4', method: 'turn.start', sessionId: 'session-1', payload: { ok: false, error: { code: 1, message: 'bad' } } })), 'invalid_response_payload'); }); +test('ownership RPCs reject loose payloads, unsafe counters and unbounded evidence', () => { + for (const payload of [{ fenceId: 'f1' }, { fenceId: '', closed: true }, { fenceId: 'f1', closed: 1 }, + { fenceId: 'f1', closed: true, ignored: true }]) { + protocolError(() => parseGjcWorkerFrame(JSON.stringify({ ...request('worker.admission'), payload })), 'invalid_payload'); + } + protocolError(() => parseGjcWorkerFrame(JSON.stringify({ ...request('worker.activity'), payload: { spawn: true } })), 'invalid_payload'); + const activity = { fenceId: 'f1', generation: 'generation-1', complete: true, + starting: 0, queued: 0, running: 0, settling: 0, approvals: 0, retained: 0, unknown: [] }; + const response = (result: unknown) => JSON.stringify({ protocolVersion: 1, kind: 'response', id: 'observation', + method: 'worker.activity', payload: { ok: true, result } }); + assert.equal(parseGjcWorkerFrame(response(activity)).method, 'worker.activity'); + for (const result of [undefined, {}, { ...activity, ignored: true }, { ...activity, starting: -1 }, + { ...activity, running: 0.1 }, { ...activity, retained: Number.MAX_SAFE_INTEGER + 1 }, + { ...activity, generation: 'x'.repeat(129) }, { ...activity, unknown: Array(33).fill('unknown') }, + { ...activity, unknown: ['secret with spaces'] }]) { + protocolError(() => parseGjcWorkerFrame(response(result)), 'invalid_response_payload'); + } +}); + test('fails closed on malformed JSON, methods, invalid JSON values, and direct byte bounds', () => { protocolError(() => parseGjcWorkerFrame('{'), 'malformed_frame'); protocolError(() => parseGjcWorkerFrame(JSON.stringify({ ...request('turn.start'), method: 'provider.run' })), 'unknown_method'); @@ -98,7 +117,7 @@ test('serializes compact LF NDJSON and recursively redacts supplied secrets', () token: secret, nested: [secret, { [`prefix-${secret}`]: `x${secret}y` }], }, - }, [secret]); + } as GjcWorkerRequestFrame, [secret]); assert.ok(serialized.endsWith('\n')); assert.equal(serialized.slice(0, -1).includes('\n'), false); assert.equal(serialized.includes(secret), false); diff --git a/server/gjc-worker-protocol.ts b/server/gjc-worker-protocol.ts index f359ecc9..b9b1ed64 100644 --- a/server/gjc-worker-protocol.ts +++ b/server/gjc-worker-protocol.ts @@ -7,6 +7,8 @@ export type JsonObject = { [key: string]: JsonValue }; export const GJC_WORKER_REQUEST_METHODS = [ 'worker.initialize', + 'worker.activity', + 'worker.admission', 'session.start', 'session.resume', 'turn.start', @@ -42,7 +44,7 @@ export const GJC_WORKER_EVENT_METHODS = [ export type GjcWorkerRequestMethod = typeof GJC_WORKER_REQUEST_METHODS[number]; export type GjcWorkerEventMethod = typeof GJC_WORKER_EVENT_METHODS[number]; -type GjcWorkerGlobalRequestMethod = Extract; +type GjcWorkerGlobalRequestMethod = Extract; export type GjcWorkerGlobalEventMethod = Extract; type GjcWorkerSuccess = { @@ -61,6 +63,23 @@ type GjcWorkerFailure = { export type GjcWorkerResponsePayload = GjcWorkerSuccess | GjcWorkerFailure; +/** Fixed-size, credential-free ownership evidence. Counts can overlap. */ +export type GjcWorkerActivity = { + /** Opaque revision of ALL accounted activity, not merely current counters. */ + generation: string; + complete: boolean; + starting: number; + queued: number; + running: number; + settling: number; + approvals: number; + retained: number; + unknown: string[]; +}; +/** generation binds the host incarnation/revision AND runtime generation. */ +export type GjcWorkerActivityObservation = GjcWorkerActivity & { fenceId: string | null }; +export type GjcWorkerAdmissionRequest = { fenceId: string; closed: boolean }; + type GlobalRequestMethod = GjcWorkerGlobalRequestMethod; type ScopedRequestMethod = Exclude; @@ -68,7 +87,7 @@ type GjcWorkerGlobalRequestFrame = { protocolVersion: typeof GJC_WORKER_PROTOCOL_VERSION; kind: 'request'; id: string; - method: GlobalRequestMethod; + method: Exclude; payload: JsonObject; }; @@ -81,13 +100,18 @@ type GjcWorkerScopedRequestFrame = { payload: JsonObject; }; -export type GjcWorkerRequestFrame = GjcWorkerGlobalRequestFrame | GjcWorkerScopedRequestFrame; +export type GjcWorkerRequestFrame = GjcWorkerGlobalRequestFrame | GjcWorkerScopedRequestFrame | { + protocolVersion: typeof GJC_WORKER_PROTOCOL_VERSION; + kind: 'request'; + id: string; +} & ({ method: 'worker.activity'; payload: Record } + | { method: 'worker.admission'; payload: GjcWorkerAdmissionRequest }); type GjcWorkerGlobalResponseFrame = { protocolVersion: typeof GJC_WORKER_PROTOCOL_VERSION; kind: 'response'; id: string; - method: GlobalRequestMethod; + method: Exclude; payload: GjcWorkerResponsePayload; }; @@ -100,7 +124,12 @@ type GjcWorkerScopedResponseFrame = { payload: GjcWorkerResponsePayload; }; -export type GjcWorkerResponseFrame = GjcWorkerGlobalResponseFrame | GjcWorkerScopedResponseFrame; +export type GjcWorkerResponseFrame = GjcWorkerGlobalResponseFrame | GjcWorkerScopedResponseFrame | { + protocolVersion: typeof GJC_WORKER_PROTOCOL_VERSION; + kind: 'response'; + id: string; +} & ({ method: 'worker.activity'; payload: GjcWorkerFailure | { ok: true; result: GjcWorkerActivityObservation } } + | { method: 'worker.admission'; payload: GjcWorkerFailure | { ok: true; result: { fenceId: string | null } } }); type GjcWorkerStatusEventFrame = { protocolVersion: typeof GJC_WORKER_PROTOCOL_VERSION; @@ -144,7 +173,7 @@ export class GjcWorkerProtocolError extends Error { const requestMethods = new Set(GJC_WORKER_REQUEST_METHODS); const eventMethods = new Set(GJC_WORKER_EVENT_METHODS); -const globalMethods = new Set(['worker.initialize', 'worker.shutdown', 'models.catalog', 'oauth.providers', 'oauth.status', 'oauth.start', 'oauth.submit', 'oauth.cancel']); +const globalMethods = new Set(['worker.initialize', 'worker.shutdown', 'worker.activity', 'worker.admission', 'models.catalog', 'oauth.providers', 'oauth.status', 'oauth.start', 'oauth.submit', 'oauth.cancel']); const globalEventMethods = new Set(['oauth.phase', 'oauth.providers.updated', 'provider.auth.updated']); const safeIdentifier = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/; const redacted = '[redacted]'; @@ -210,6 +239,45 @@ function assertResponsePayload(value: unknown): asserts value is GjcWorkerRespon if ('details' in value.error) validateJson(value.error.details); } +const activityCounts = ['starting', 'queued', 'running', 'settling', 'approvals', 'retained'] as const; +const activityIdentifier = (value: unknown): value is string => typeof value === 'string' + && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u.test(value); + +export function isGjcWorkerActivity(value: unknown): value is GjcWorkerActivity { + if (!isPlainObject(value)) return false; + const keys = ['generation', 'complete', ...activityCounts, 'unknown']; + const descriptors = Object.getOwnPropertyDescriptors(value); + if (Reflect.ownKeys(value).length !== keys.length + || keys.some((key) => !descriptors[key] || !Object.hasOwn(descriptors[key], 'value'))) return false; + return activityIdentifier(value.generation) && typeof value.complete === 'boolean' + && activityCounts.every((key) => Number.isSafeInteger(value[key]) && (value[key] as number) >= 0) + && Array.isArray(value.unknown) && value.unknown.length <= 32 && value.unknown.every(activityIdentifier); +} + +function assertActivityPayload(method: string, value: JsonObject | GjcWorkerResponsePayload, response: boolean): void { + if (method !== 'worker.activity' && method !== 'worker.admission') return; + if (!response) { + const request = value as JsonObject; + if (method === 'worker.activity') { + if (Object.keys(value).length !== 0) fail('invalid_payload', 'Activity requests must have an empty payload.'); + } else if (Object.keys(request).length !== 2 || !activityIdentifier(request.fenceId) || typeof request.closed !== 'boolean') { + fail('invalid_payload', 'Admission request is invalid.'); + } + return; + } + if (!value.ok) return; + const result = value.result; + if (!isPlainObject(result) || (result.fenceId !== null && !activityIdentifier(result.fenceId))) { + fail('invalid_response_payload', 'Worker ownership response is invalid.'); + } + if (method === 'worker.admission') { + if (Object.keys(result).length !== 1) fail('invalid_response_payload', 'Admission response is invalid.'); + } else { + const { fenceId: _fenceId, ...activity } = result; + if (!isGjcWorkerActivity(activity)) fail('invalid_response_payload', 'Activity response is invalid.'); + } +} + function byteLength(input: string | Uint8Array): number { return typeof input === 'string' ? Buffer.byteLength(input, 'utf8') : input.byteLength; } @@ -237,6 +305,7 @@ export function parseGjcWorkerFrame(input: string | Uint8Array): GjcWorkerFrame assertIdentifier(parsed.id, 'id'); if (typeof parsed.method !== 'string' || !requestMethods.has(parsed.method)) fail('unknown_method', 'Request method is not supported.'); assertPayload(parsed.payload); + assertActivityPayload(parsed.method, parsed.payload, false); if (globalMethods.has(parsed.method)) { if ('sessionId' in parsed) fail('invalid_session_scope', 'Global requests must omit sessionId.'); } else { @@ -250,6 +319,7 @@ export function parseGjcWorkerFrame(input: string | Uint8Array): GjcWorkerFrame assertIdentifier(parsed.id, 'id'); if (typeof parsed.method !== 'string' || !requestMethods.has(parsed.method)) fail('unknown_method', 'Response method must be a request method.'); assertResponsePayload(parsed.payload); + assertActivityPayload(parsed.method, parsed.payload, true); if (globalMethods.has(parsed.method)) { if ('sessionId' in parsed) fail('invalid_session_scope', 'Global responses must omit sessionId.'); } else { diff --git a/server/gjc-worker.test.ts b/server/gjc-worker.test.ts index 6332f212..ebab94e6 100644 --- a/server/gjc-worker.test.ts +++ b/server/gjc-worker.test.ts @@ -18,7 +18,7 @@ import { GJC_CLEANUP_UNCONFIRMED_CODE, GJC_CLEANUP_UNCONFIRMED_MESSAGE, GjcClean import { GJC_MODEL_UNRESOLVED_CODE, GJC_MODEL_UNRESOLVED_MESSAGE, GjcModelResolutionError } from './gjc-model-resolution.js'; import { claimProtocolStdout, GjcWorkerHost, runGjcWorkerEntrypoint, type GjcWorkerRuntime, type GjcWorkerWriter } from './gjc-worker.js'; -const request = (method: string, id: string, payload: Record = {}, sessionId = 'scope-1') => ({ protocolVersion: GJC_WORKER_PROTOCOL_VERSION, kind: 'request' as const, id, method, payload, ...(['worker.initialize', 'worker.shutdown'].includes(method) ? {} : { sessionId }) }) as GjcWorkerRequestFrame; +const request = (method: string, id: string, payload: Record = {}, sessionId = 'scope-1') => ({ protocolVersion: GJC_WORKER_PROTOCOL_VERSION, kind: 'request' as const, id, method, payload, ...(['worker.initialize', 'worker.shutdown', 'worker.activity', 'worker.admission', 'models.catalog', 'oauth.providers', 'oauth.status', 'oauth.start', 'oauth.submit', 'oauth.cancel'].includes(method) ? {} : { sessionId }) }) as GjcWorkerRequestFrame; const deferred = () => { let resolve!: (value: T) => void; let reject!: (error: Error) => void; const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); return { promise, resolve, reject }; }; function fakeRuntime() { const runs: Array<{ run: ReturnType>; writer?: GjcWorkerWriter }> = []; const calls: string[] = []; @@ -45,6 +45,88 @@ async function initialized(fake = fakeRuntime()) { return { fake, frames, host }; } +test('worker observation is noninitializing, self-excluding and requires reversible runtime admission', async () => { + const fake = fakeRuntime(); + let loads = 0; + const changes: boolean[] = []; + const activity = { generation: 'sdk-1', complete: true, starting: 0, queued: 0, + running: 0, settling: 0, approvals: 0, retained: 0, unknown: [] as string[] }; + fake.runtime.observeActivity = () => ({ ...activity, unknown: [...activity.unknown] }); + fake.runtime.setAdmissionFence = (closed) => { changes.push(closed); }; + const frames: any[] = []; + const host = new GjcWorkerHost({ runtime: async () => { loads++; return fake.runtime; }, emit: (frame) => frames.push(frame) }); + const call = async (method: string, id: string, payload = {}) => { + await host.handle(request(method, id, payload)); + return frames.find((frame) => frame.id === id)!.payload; + }; + assert.equal((await call('worker.activity', 'cold')).result.complete, false); + assert.equal(loads, 0); + await call('worker.initialize', 'initialize'); + assert.ok((await call('worker.activity', 'open')).result.unknown.includes('worker_admission_open')); + await call('worker.admission', 'close', { fenceId: 'f1', closed: true }); + const first = (await call('worker.activity', 'first')).result; + const second = (await call('worker.activity', 'second')).result; + assert.deepEqual(second, first, 'observation does not revise or count itself'); + assert.equal(first.complete, true); + assert.equal(first.settling + first.running + first.starting + first.queued, 0); + assert.equal((await call('session.start', 'blocked', { message: 'new', options: {} })).error.code, 'worker_admission_fenced'); + assert.equal(fake.runs.length, 0); + assert.equal((await call('worker.admission', 'wrong', { fenceId: 'f2', closed: false })).error.code, 'worker_admission_conflict'); + await call('worker.admission', 'release', { fenceId: 'f1', closed: false }); + assert.deepEqual(changes, [true, false]); + const accepted = host.handle(request('session.start', 'accepted', { message: 'existing', options: {} })); + assert.equal(fake.runs.length, 1); + await call('worker.admission', 'reclose', { fenceId: 'f2', closed: true }); + assert.equal(fake.calls.length, 0, 'closing admission does not abort accepted roots'); + assert.ok((await call('worker.activity', 'busy')).result.settling > 0); + fake.runs[0]!.run.resolve(); + await accepted; + assert.equal((await call('worker.activity', 'settled')).result.complete, true); + await host.close(); +}); + +test('runtime unknowns and missing observations remain blockers behind a worker fence', async () => { + const fake = fakeRuntime(); + fake.runtime.setAdmissionFence = () => {}; + const { host, frames } = await initialized(fake); + await host.handle(request('worker.admission', 'fence', { fenceId: 'f1', closed: true })); + await host.handle(request('worker.activity', 'missing')); + const missing = (frames.find((frame: any) => frame.id === 'missing') as any).payload.result; + assert.equal(missing.complete, false); + assert.deepEqual(missing.unknown, ['worker_runtime_unaccounted']); + fake.runtime.observeActivity = () => ({ generation: 'sdk-2', complete: false, starting: 0, queued: 0, + running: 0, settling: 1, approvals: 0, retained: 0, unknown: ['sdk_background_ownership_unproven'] }); + await host.handle(request('worker.activity', 'unproven')); + const unproven = (frames.find((frame: any) => frame.id === 'unproven') as any).payload.result; + assert.deepEqual(unproven.unknown, ['sdk_background_ownership_unproven']); + assert.equal(unproven.settling, 1); + assert.equal(unproven.complete, false); + await host.close(); +}); + +test('worker observation generation binds SDK-only revisions without revising on the observation itself', async () => { + const fake = fakeRuntime(); + let generation = 'sdk-1'; + fake.runtime.setAdmissionFence = () => {}; + fake.runtime.observeActivity = () => ({ generation, complete: true, starting: 0, queued: 0, + running: 0, settling: 0, approvals: 0, retained: 0, unknown: [] }); + const { host, frames } = await initialized(fake); + await host.handle(request('worker.admission', 'fence', { fenceId: 'f1', closed: true })); + const observe = async (id: string): Promise => { + await host.handle(request('worker.activity', id)); + const frame = frames.find((frame: any) => frame.id === id) as { payload: { result: { generation: string } } }; + return frame.payload.result.generation; + }; + const before = await observe('before'); + assert.equal(await observe('unchanged'), before); + generation = 'sdk-3'; + const after = await observe('after'); + assert.notEqual(after, before, 'idle endpoint counts cannot conceal SDK mutations'); + assert.equal(await observe('still-unchanged'), after); + assert.match(after, /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u); + await host.close(); +}); + test('cleanup failure requires the app-owned brand, not a provider name, code, message or prototype', () => { const genuine = new GjcCleanupUnconfirmedError(); assert.equal(isGjcCleanupUnconfirmedError(genuine), true); diff --git a/server/gjc-worker.ts b/server/gjc-worker.ts index d03774d2..6f8b812c 100644 --- a/server/gjc-worker.ts +++ b/server/gjc-worker.ts @@ -1,4 +1,4 @@ -import { randomUUID } from 'node:crypto'; +import { createHash, randomUUID } from 'node:crypto'; import type { Readable, Writable } from 'node:stream'; import { pathToFileURL } from 'node:url'; @@ -11,6 +11,8 @@ import { GjcWorkerNdjsonDecoder, GjcWorkerProtocolError, serializeGjcWorkerFrame, + isGjcWorkerActivity, + type GjcWorkerActivity, type GjcWorkerEventFrame, type GjcWorkerGlobalEventMethod, type GjcWorkerRequestFrame, @@ -44,6 +46,10 @@ export type GjcWorkerOAuthRuntime = { close(): void; }; export type GjcWorkerRuntime = { + /** Pure, synchronous and complete only for accounted ownership. */ + observeActivity?(): GjcWorkerActivity; + /** Reject new roots without aborting or suppressing accepted continuations. */ + setAdmissionFence?(closed: boolean): void; inspectGjcGoal?(scope: GjcGoalScope, providerSessionId: string, sessionRoot: string): Promise; controlGjcGoal?(runId: string, scope: GjcGoalScope, command?: GjcGoalCommand, stopAfterMutation?: boolean): Promise; spawnGjc(message: string, options: JsonObject, writer: GjcWorkerWriter): SpawnedRun; @@ -171,6 +177,10 @@ export class GjcWorkerHost { #initializationAttempted = false; #initialized = false; #closed = false; + #fenceId: string | null = null; + #operations = 0; + #revision = 0; + readonly #generation = randomUUID(); #runs = new Map(); #closePromise: Promise | undefined; #oauthUnsubscribe: (() => void) | undefined; @@ -200,6 +210,20 @@ export class GjcWorkerHost { async handle(request: GjcWorkerRequestFrame): Promise { if (this.#cleanupUnconfirmed) return this.#response(request, failure(GJC_CLEANUP_UNCONFIRMED_CODE, GJC_CLEANUP_UNCONFIRMED_MESSAGE)); if (this.#closed) return this.#response(request, failure('worker_closed', 'Worker is no longer accepting requests.')); + // Observation and admission controls are not work and must not invalidate + // their own snapshot or enter the host's operation counter. + if (request.method === 'worker.activity') return this.#observe(request); + if (request.method === 'worker.admission') return this.#admission(request); + if (this.#fenceId && ['worker.initialize', 'session.start', 'session.resume', 'turn.start', 'models.catalog', 'goal.inspect', 'oauth.start', 'oauth.providers', 'oauth.status'].includes(request.method)) { + return this.#response(request, failure('worker_admission_fenced', 'Worker admission is fenced.')); + } + this.#operations += 1; + this.#revision += 1; + try { await this.#dispatch(request); } + finally { this.#operations -= 1; this.#revision += 1; } + } + + async #dispatch(request: GjcWorkerRequestFrame): Promise { if (request.method === 'worker.initialize') return this.#initialize(request); if (!this.#initialized) return this.#response(request, failure('not_initialized', 'Worker must be initialized before use.')); switch (request.method) { @@ -218,6 +242,57 @@ export class GjcWorkerHost { } } + #admission(request: Extract): void { + const input = payload(request, ['fenceId', 'closed']); + if (!input || typeof input.fenceId !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u.test(input.fenceId) + || typeof input.closed !== 'boolean') return this.#response(request, failure('invalid_payload', 'Admission request is invalid.')); + if (!this.#initialized || !this.#runtime?.setAdmissionFence) { + return this.#response(request, failure('worker_admission_unavailable', 'Worker admission is unavailable.')); + } + if (this.#fenceId !== null && this.#fenceId !== input.fenceId) { + return this.#response(request, failure('worker_admission_conflict', 'Worker admission belongs to another fence.')); + } + const next = input.closed ? input.fenceId : null; + if (next !== this.#fenceId) { + try { this.#runtime.setAdmissionFence(input.closed); } + catch { return this.#response(request, failure('worker_admission_unavailable', 'Worker admission is unavailable.')); } + this.#fenceId = next; + this.#revision += 1; + } + this.#response(request, success({ fenceId: this.#fenceId })); + } + + #observe(request: Extract): void { + if (!payload(request, [])) return this.#response(request, failure('invalid_payload', 'Activity request is invalid.')); + let runtime: GjcWorkerActivity | undefined; + try { + const observed = this.#runtime?.observeActivity?.(); + if (isGjcWorkerActivity(observed)) runtime = observed; + } catch { /* Missing or invalid runtime evidence remains unknown. */ } + const unknown = [...new Set([ + ...(!this.#initialized ? ['worker_not_initialized'] : []), + ...(!runtime ? ['worker_runtime_unaccounted'] : runtime.unknown), + ...(!this.#fenceId ? ['worker_admission_open'] : []), + ])].slice(0, 32); + this.#response(request, success({ + // Bind BOTH ownership layers, including SDK-internal idle/busy/idle + // cycles that emit no worker events. Hashing the tuple is pure and + // bounded; taking an observation never increments either revision. + generation: createHash('sha256').update(JSON.stringify([ + this.#generation, this.#revision, runtime?.generation ?? null, + ])).digest('hex'), + fenceId: this.#fenceId, + complete: Boolean(runtime?.complete) && unknown.length === 0, + starting: Number(this.#initializing) + (runtime?.starting ?? 0), + queued: runtime?.queued ?? 0, + running: this.#runs.size + (runtime?.running ?? 0), + settling: this.#operations + (runtime?.settling ?? 0), + approvals: runtime?.approvals ?? 0, + retained: runtime?.retained ?? 0, + unknown, + })); + } + /** Idempotently rejects new work, aborts every run, and allows their children to settle. */ close(): Promise { if (this.#closePromise) return this.#closePromise; diff --git a/server/index.js b/server/index.js index 79d3525e..f2492d26 100755 --- a/server/index.js +++ b/server/index.js @@ -10,18 +10,36 @@ import express from 'express'; import mime from 'mime-types'; import Database from 'better-sqlite3'; -import { AppError, WORKSPACES_ROOT, getOpenCodeDatabasePath, validateWorkspacePath } from '@/shared/utils.js'; +import { + AppError, WORKSPACES_ROOT, asyncHandler, getHttpActivityGeneration, + getOpenCodeDatabasePath, snapshotHttpActivity, validateWorkspacePath, +} from '@/shared/utils.js'; +import { + configureInternalDesktopAdmission, + enterInternalActivity, + getInternalActivityGeneration, + markInternalActivityUncertain, + snapshotInternalActivity, +} from '@/shared/desktop-internal-activity.js'; import { openProjectFileForWrite, resolveProjectEntryForMutation, resolveProjectFileForRead, resolveProjectFileForWrite } from '@/shared/project-file-containment.js'; -import { closeSessionsWatcher, configureSessionWorktrees, initializeSessionsWatcher } from '@/modules/providers/index.js'; +import { + closeSessionsWatcher, + configureSessionWorktrees, + configureSessionsWatcherDesktopAdmission, + getSessionsWatcherActivityGeneration, + initializeSessionsWatcher, + snapshotSessionsWatcherActivity, +} from '@/modules/providers/index.js'; import { getConnectableHost } from '../shared/networkHosts.js'; import { GjcJobProjectionService } from './modules/websocket/services/gjc-job-projection.service.js'; +import { chatRunRegistry } from './modules/websocket/index.js'; import { drainWebSocketClients } from './modules/websocket/services/websocket-drain.service.js'; import { createGjcTerminalNotificationAdapter } from './modules/notifications/services/gjc-terminal-notification-adapter.service.js'; import { findAppRoot, getModuleDir } from './utils/runtime-paths.js'; @@ -31,13 +49,28 @@ import { steerGjcRun, getPendingGjcApprovalsForSession, getGjcWorkerSupervisor, + createGjcWorkerDesktopRestartReader, resolveGjcToolApproval, shutdownGjcWorker, spawnGjcRun, } from './gjc-worker-client.js'; -import { getProductionJobAuthority, getProductionJobOrchestrator } from './services/gjc-job-orchestrator.js'; +import { + configureGjcJobOrchestratorDesktopAdmission, + createGjcJobOrchestratorDesktopRestartReader, + getProductionJobAuthority, + getProductionJobOrchestrator, + getProductionNativeJobsDesktopRestartReader, +} from './services/gjc-job-orchestrator.js'; import { readSessionLocation, resolveSessionWorkspacePath, validateSessionRepository } from './services/session-worktree-paths.js'; -import { abortSessionWorktreeRun, prepareSessionWorktreeRun, sessionWorktreeWorkerHandle } from './services/session-worktree-runtime.js'; +import { + abortSessionWorktreeRun, + configureSessionWorktreeDesktopAdmission, + createSessionWorktreeDesktopRestartReader, + prepareSessionWorktreeRun, + sessionWorktreeWorkerHandle, +} from './services/session-worktree-runtime.js'; +import { configureNativeDesktopRestartAdmission, createNativeDesktopRestartReader } from './services/gjc-git-client.js'; +import { createProjectUploadStorage, streamProjectFile } from './services/project-file-transfer.js'; import { getProductionGjcJobGitService } from './services/gjc-job-git.service.js'; import { stripAnsiSequences, @@ -49,9 +82,19 @@ import gitRoutes from './routes/git.js'; import authRoutes from './routes/auth.js'; import settingsRoutes from './routes/settings.js'; import { createGjcAppFactory } from './app-factory.js'; +import { DesktopUpdateRelay } from './services/desktop-update-relay.js'; +import { createDesktopRestartRuntime } from './services/desktop-restart-runtime.js'; +import { DesktopRestartBackend } from './services/desktop-restart-backend.js'; +import { listenForStartup } from './services/server-listener.js'; +import { getShellActivityGeneration, snapshotShellActivity } from './modules/websocket/services/shell-websocket.service.js'; import { isWorkspaceRoot } from './modules/projects/index.js'; import projectModuleRoutes from './modules/projects/projects.routes.js'; import notificationRoutes from './modules/notifications/notifications.routes.js'; +import { + configureNotificationDesktopAdmission, + getNotificationActivityGeneration, + snapshotNotificationActivity, +} from './modules/notifications/index.js'; import userRoutes from './routes/user.js'; import systemRoutes from './routes/system.js'; import providerRoutes from './modules/providers/provider.routes.js'; @@ -61,7 +104,11 @@ import { initializeDatabase, projectsDb, sessionsDb } from './modules/database/i import { automationRoutes, automationService, + configureDesktopRestartAdmission as configureAutomationDesktopAdmission, + createAutomationDesktopRestartReader, createBrowserAutomationRouter, + createBrowserDesktopRestartReader, + createComputerDesktopRestartReader, handleBrowserConnection, } from './modules/automation/index.js'; import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js'; @@ -109,6 +156,43 @@ const gjcJobProjection = new GjcJobProjectionService({ const gjcTerminalNotificationAdapter = createGjcTerminalNotificationAdapter({ authority: gjcJobAuthority, }); +// This is not exposed as a browser prepare/commit endpoint. Unimplemented +// ownership readers remain explicit blockers; native install stays disabled. +const desktopRestartBackend = new DesktopRestartBackend(); +const desktopRestartAdmission = createDesktopRestartRuntime({ + chat: { getGeneration: chatRunRegistry.getGeneration, read: chatRunRegistry.snapshotActivity }, + worktrees: createSessionWorktreeDesktopRestartReader(), + orchestrator: createGjcJobOrchestratorDesktopRestartReader(gjcJobOrchestrator), + 'native-jobs': getProductionNativeJobsDesktopRestartReader(), + 'gjc-worker': createGjcWorkerDesktopRestartReader(), + automation: createAutomationDesktopRestartReader(), + browser: createBrowserDesktopRestartReader(), + computer: createComputerDesktopRestartReader(), + shell: { getGeneration: getShellActivityGeneration, read: snapshotShellActivity }, + 'native-clients': createNativeDesktopRestartReader(), + watchers: { getGeneration: getSessionsWatcherActivityGeneration, read: snapshotSessionsWatcherActivity }, + notifications: { getGeneration: getNotificationActivityGeneration, read: snapshotNotificationActivity }, + 'http-callbacks': { getGeneration: getHttpActivityGeneration, read: snapshotHttpActivity }, + 'internal-producers': { getGeneration: getInternalActivityGeneration, read: snapshotInternalActivity }, + 'ui-drafts': desktopRestartBackend.draftReader, +}, { + owner: 'gjc-worker', + close: (fenceId) => getGjcWorkerSupervisor().fenceForDesktopRestart(fenceId), + release: (fenceId) => getGjcWorkerSupervisor().releaseDesktopRestartFence(fenceId), +}); +desktopRestartBackend.attachAuthority(desktopRestartAdmission); +// Install the same admission authority before startup catch-up, listeners, or +// watcher initialization can accept work. Readers never perform configuration. +configureGjcJobOrchestratorDesktopAdmission(desktopRestartAdmission); +configureSessionWorktreeDesktopAdmission(desktopRestartAdmission); +configureNativeDesktopRestartAdmission(desktopRestartAdmission); +configureAutomationDesktopAdmission(desktopRestartAdmission); +configureSessionsWatcherDesktopAdmission(desktopRestartAdmission); +configureNotificationDesktopAdmission(desktopRestartAdmission); +configureInternalDesktopAdmission(desktopRestartAdmission); +getGjcWorkerSupervisor().configureDesktopRestartAdmission({ + acquire: (source) => ({ release: desktopRestartAdmission.enter(source) }), +}); function gjcSpawn(message, options, writer) { return spawnGjcRun(message, { ...options, @@ -131,6 +215,8 @@ function steerGjcChatRun(runId, message) { } const { app, server, wss } = createGjcAppFactory({ + desktopRestartAdmission, + desktopUpdateRelay: new DesktopUpdateRelay({ restart: desktopRestartBackend }), authority: gjcJobAuthority, orchestrator: gjcJobOrchestrator, gitService: getProductionGjcJobGitService( @@ -257,7 +343,7 @@ const expandWorkspacePath = (inputPath) => { }; // Browse filesystem endpoint for project suggestions - uses existing getFileTree -app.get('/api/browse-filesystem', authenticateToken, async (req, res) => { +app.get('/api/browse-filesystem', authenticateToken, asyncHandler(async (req, res) => { try { const { path: dirPath } = req.query; @@ -335,9 +421,9 @@ app.get('/api/browse-filesystem', authenticateToken, async (req, res) => { console.error('Error browsing filesystem:', error); res.status(500).json({ error: 'Failed to browse filesystem' }); } -}); +})); -app.post('/api/create-folder', authenticateToken, async (req, res) => { +app.post('/api/create-folder', authenticateToken, asyncHandler(async (req, res) => { try { const { path: folderPath } = req.body; if (!folderPath) { @@ -375,10 +461,10 @@ app.post('/api/create-folder', authenticateToken, async (req, res) => { console.error('Error creating folder:', error); res.status(500).json({ error: 'Failed to create folder' }); } -}); +})); // Read file content endpoint -app.get('/api/projects/:projectId/file', authenticateToken, async (req, res) => { +app.get('/api/projects/:projectId/file', authenticateToken, asyncHandler(async (req, res) => { try { const { projectId } = req.params; const { filePath } = req.query; @@ -424,10 +510,10 @@ app.get('/api/projects/:projectId/file', authenticateToken, async (req, res) => res.status(500).json({ error: error.message }); } } -}); +})); // Serve raw file bytes for previews and downloads. -app.get('/api/projects/:projectId/files/content', authenticateToken, async (req, res) => { +app.get('/api/projects/:projectId/files/content', authenticateToken, asyncHandler(async (req, res) => { try { const { projectId } = req.params; const { path: filePath } = req.query; @@ -470,16 +556,9 @@ app.get('/api/projects/:projectId/files/content', authenticateToken, async (req, const mimeType = mime.lookup(readablePath) || 'application/octet-stream'; res.setHeader('Content-Type', mimeType); - // Stream the file - const fileStream = fs.createReadStream(readablePath); - fileStream.pipe(res); - - fileStream.on('error', (error) => { - console.error('Error streaming file:', error); - if (!res.headersSent) { - res.status(500).json({ error: 'Error reading file' }); - } - }); + // Keep the admission lease until the source descriptor actually closes, + // including a disconnected viewer or a source read error. + await streamProjectFile(fs.createReadStream(readablePath), res); } catch (error) { console.error('Error serving binary file:', error); @@ -487,10 +566,10 @@ app.get('/api/projects/:projectId/files/content', authenticateToken, async (req, res.status(error.statusCode || 500).json({ error: error.message }); } } -}); +})); // Save file content endpoint -app.put('/api/projects/:projectId/file', authenticateToken, async (req, res) => { +app.put('/api/projects/:projectId/file', authenticateToken, asyncHandler(async (req, res) => { try { const { projectId } = req.params; const { filePath, content } = req.body; @@ -564,9 +643,9 @@ app.put('/api/projects/:projectId/file', authenticateToken, async (req, res) => res.status(500).json({ error: error.message }); } } -}); +})); -app.get('/api/projects/:projectId/files', authenticateToken, async (req, res) => { +app.get('/api/projects/:projectId/files', authenticateToken, asyncHandler(async (req, res) => { try { // Using fsPromises from import @@ -597,7 +676,7 @@ app.get('/api/projects/:projectId/files', authenticateToken, async (req, res) => console.error('[ERROR] File tree error:', error.message); res.status(error.statusCode || 500).json({ error: error.message }); } -}); +})); // ============================================================================ // FILE OPERATIONS API ENDPOINTS @@ -647,7 +726,7 @@ function validateFilename(name) { } // POST /api/projects/:projectId/files/create - Create new file or directory -app.post('/api/projects/:projectId/files/create', authenticateToken, async (req, res) => { +app.post('/api/projects/:projectId/files/create', authenticateToken, asyncHandler(async (req, res) => { try { const { projectId } = req.params; const { path: parentPath, type, name } = req.body; @@ -722,10 +801,10 @@ app.post('/api/projects/:projectId/files/create', authenticateToken, async (req, res.status(500).json({ error: error.message }); } } -}); +})); // PUT /api/projects/:projectId/files/rename - Rename file or directory -app.put('/api/projects/:projectId/files/rename', authenticateToken, async (req, res) => { +app.put('/api/projects/:projectId/files/rename', authenticateToken, asyncHandler(async (req, res) => { try { const { projectId } = req.params; const { oldPath, newName } = req.body; @@ -803,10 +882,10 @@ app.put('/api/projects/:projectId/files/rename', authenticateToken, async (req, res.status(500).json({ error: error.message }); } } -}); +})); // DELETE /api/projects/:projectId/files - Delete file or directory -app.delete('/api/projects/:projectId/files', authenticateToken, async (req, res) => { +app.delete('/api/projects/:projectId/files', authenticateToken, asyncHandler(async (req, res) => { try { const { projectId } = req.params; const { path: targetPath } = req.body; @@ -869,7 +948,7 @@ app.delete('/api/projects/:projectId/files', authenticateToken, async (req, res) res.status(500).json({ error: error.message }); } } -}); +})); // POST /api/projects/:projectId/files/upload - Upload files // Dynamic import of multer for file uploads @@ -882,19 +961,9 @@ const uploadFilesHandler = async (req, res) => { const stagingDir = await fsPromises.mkdtemp(path.join(uploadStagingRoot, 'request-')); await fsPromises.chmod(stagingDir, 0o700); + const ownedStorage = createProjectUploadStorage(stagingDir); const uploadMiddleware = multer({ - storage: multer.diskStorage({ - destination: (req, file, cb) => { - cb(null, stagingDir); - }, - filename: (req, file, cb) => { - // Use a unique temp name, but preserve original name in file.originalname - // Note: file.originalname may contain path separators for folder uploads - const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9); - // For temp file, just use a safe unique name without the path - cb(null, `upload-${uniqueSuffix}`); - } - }), + storage: ownedStorage.storage, limits: { fileSize: MAX_FILE_UPLOAD_SIZE_BYTES, files: MAX_FILE_UPLOAD_COUNT @@ -906,6 +975,7 @@ const uploadFilesHandler = async (req, res) => { await new Promise((resolve) => { uploadMiddleware.array('files', MAX_FILE_UPLOAD_COUNT)(req, res, async (err) => { try { + await ownedStorage.settle(); if (err) { console.error('Multer error:', err); if (err.code === 'LIMIT_FILE_SIZE') { @@ -916,6 +986,7 @@ const uploadFilesHandler = async (req, res) => { } return res.status(500).json({ error: err.message }); } + if (req.aborted) return; const { projectId } = req.params; const { targetPath, relativePaths, requestedFileCount: requestedFileCountRaw } = req.body; @@ -1047,6 +1118,7 @@ const uploadFilesHandler = async (req, res) => { } } finally { try { + await ownedStorage.settle(); await fsPromises.rm(stagingDir, { recursive: true, force: true }); } catch (cleanupError) { console.error('Failed to clean upload staging directory:', cleanupError); @@ -1057,14 +1129,14 @@ const uploadFilesHandler = async (req, res) => { }); }; -app.post('/api/projects/:projectId/files/upload', authenticateToken, uploadFilesHandler); +app.post('/api/projects/:projectId/files/upload', authenticateToken, asyncHandler(uploadFilesHandler)); // Chat image uploads moved to POST /api/assets/images (server/modules/assets), // which stores them in the global ~/.gajae-app/assets folder. // Get token usage for a specific session. `projectId` is the DB primary key; // the Claude branch below resolves it to an absolute path via the DB. -app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticateToken, async (req, res) => { +app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticateToken, asyncHandler(async (req, res) => { try { const { projectId, sessionId } = req.params; const homeDir = os.homedir(); @@ -1336,7 +1408,7 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate console.error('Error reading session token usage:', error); res.status(500).json({ error: 'Failed to read session token usage' }); } -}); +})); // Serve React app for all other routes (excluding static files) app.get('*', (req, res) => { @@ -1576,17 +1648,110 @@ async function removeLocalServerMarker() { // Initialize database and start server async function startServer() { + const releaseStartup = enterInternalActivity('server:startup'); + let startupFlight = Promise.resolve(); + let jobsInitialized = false; + let shutdownStarted = false; + let shutdownExitCode = 0; + const shutdownRuntimeServices = async (exitCode = 0) => { + shutdownExitCode = Math.max(shutdownExitCode, exitCode); + if (shutdownStarted) { + return; + } + shutdownStarted = true; + // Normal quit is not a restart-idle observation. Once this path + // starts, no reversible preparation may certify an idle runtime. + markInternalActivityUncertain('runtime_shutdown_started'); + await startupFlight.catch(() => { }); + // Persist interruption before any slower observer cleanup. Otherwise + // a not-yet-started worker can cancel admission back to ready while + // watcher shutdown awaits, erasing the interrupted-resume contract. + // A committed native restart has already proved every durable job + // idle and closed all ingress. Do not start a fresh native mutation + // after that commit; normal Quit still records interruption first. + let gjcShutdownFenced = desktopRestartAdmission.state === 'committed' || !jobsInitialized; + try { + if (!gjcShutdownFenced) + await gjcJobOrchestrator.interruptForShutdown(); + gjcShutdownFenced = true; + } + catch (err) { + console.error('[GJC Jobs] Shutdown fence failed; forcing worker tree reap while preserving authority failure evidence:', err?.message || err); + } + // Join the actual watcher startup/synchronization/exit lifetime. + // The old eager close ran before the asynchronous listen callback, + // and never closed the watcher that callback subsequently started. + try { + await closeSessionsWatcher(); + } + catch (err) { + console.error('[Watcher] Shutdown is unconfirmed; refusing normal process exit:', err?.message || err); + process.exitCode = 1; + return; + } + await drainWebSocketClients(wss.clients); + server.close(); + wss.close(); + server.closeAllConnections?.(); + try { + await shutdownGjcWorker(); + } + catch (err) { + console.error('[GJC Worker] Worker tree reap failed during shutdown; refusing normal process exit:', err?.message || err); + process.exitCode = 1; + setInterval(() => { }, 60 * 60 * 1000); + return; + } + try { + await automationService.shutdown(); + } + catch (err) { + console.error('[Automation] Sidecar shutdown failed:', err?.message || err); + process.exitCode = 1; + setInterval(() => { }, 60 * 60 * 1000); + return; + } + if (gjcShutdownFenced) { + try { + gjcJobOrchestrator.close(); + } + catch (err) { + console.error('[GJC Jobs] Error closing authority clients during shutdown:', err?.message || err); + } + } + try { + await removeLocalServerMarker(); + } + catch (err) { + console.error('[Local Server] Error removing server marker during shutdown:', err?.message || err); + } + process.exit(shutdownExitCode); + }; + process.on('SIGTERM', () => void shutdownRuntimeServices()); + process.on('SIGINT', () => void shutdownRuntimeServices()); + const onServerError = (error) => { + console.error('[ERROR] HTTP/WebSocket server failed:', error); + void shutdownRuntimeServices(1); + }; + server.on('error', onServerError); + wss.on('error', onServerError); try { // Initialize authentication database - await initializeDatabase(); - await automationService.startBridge(); + await (startupFlight = initializeDatabase()); + if (shutdownStarted) + return; + await (startupFlight = automationService.startBridge()); + if (shutdownStarted) + return; + jobsInitialized = true; try { - await gjcJobOrchestrator.reconcile(); - } catch (error) { + await (startupFlight = gjcJobOrchestrator.reconcile()); + } + catch (error) { console.error('[GJC Jobs] Authority reconciliation failed:', error?.message || error); } - - + if (shutdownStarted) + return; // Fail-closed exposure guard: desktop traffic stays on loopback unless // a trusted private-network override is explicitly configured. const exposure = evaluateExposure({ @@ -1595,114 +1760,73 @@ async function startServer() { }); if (exposure.level === 'block') { console.error(`${c.warn('[SECURITY]')} ${exposure.message}`); - process.exit(1); + throw new Error(exposure.message); } if (exposure.level === 'warn') { console.warn(`${c.warn('[SECURITY]')} ${exposure.message}`); } - // Check if running in production mode (dist folder exists) const distIndexPath = path.join(APP_ROOT, 'dist', 'index.html'); const isProduction = fs.existsSync(distIndexPath); - console.log(''); - if (isProduction) { - console.log(`${c.info('[INFO]')} To run in production mode, go to http://${DISPLAY_HOST}:${SERVER_PORT}`); + console.log(`${c.info('[INFO]')} To run in production mode, go to http://${DISPLAY_HOST}:${SERVER_PORT}`); } - console.log(`${c.info('[INFO]')} To run in development mode with hot-module replacement, go to http://${DISPLAY_HOST}:${VITE_PORT}`); - - server.listen(SERVER_PORT, HOST, async () => { - const address = server.address(); - const port = typeof address === 'object' && address ? address.port : Number.parseInt(String(SERVER_PORT), 10); - const appRoot = APP_ROOT; - await writeLocalServerMarker(port).catch((error) => { - console.warn('[WARN] Could not write local server marker:', error.message); - }); - - if (process.env.GJC_DESKTOP === '1') { - console.log(JSON.stringify({ - kind: 'gajae-desktop-ready', - pid: process.pid, - host: HOST, - port, - protocolVersion: 1, - version: RUNNING_VERSION, - })); - } - - console.log(''); - console.log(c.dim('═'.repeat(63))); - console.log(` ${c.bright('Gajae Code App Server - Ready')}`); - console.log(c.dim('═'.repeat(63))); - console.log(''); - console.log(`${c.info('[INFO]')} Server URL: ${c.bright('http://' + DISPLAY_HOST + ':' + port)}`); - console.log(`${c.info('[INFO]')} App root: ${c.dim(appRoot)}`); - console.log(`${c.tip('[TIP]')} Run "gajae-app status" for full configuration details`); - console.log(''); - - // Start watching the projects folder for changes - await initializeSessionsWatcher(); - - - }); - - await closeSessionsWatcher(); - let shutdownStarted = false; - const shutdownRuntimeServices = async () => { - if (shutdownStarted) { - return; - } - shutdownStarted = true; - - let gjcShutdownFenced = false; - try { - await gjcJobOrchestrator.interruptForShutdown(); - gjcShutdownFenced = true; - } catch (err) { - console.error('[GJC Jobs] Shutdown fence failed; forcing worker tree reap while preserving authority failure evidence:', err?.message || err); - } - - await drainWebSocketClients(wss.clients); - server.close(); - wss.close(); - server.closeAllConnections?.(); - - try { - await shutdownGjcWorker(); - } catch (err) { - console.error('[GJC Worker] Worker tree reap failed during shutdown; refusing normal process exit:', err?.message || err); - process.exitCode = 1; - setInterval(() => {}, 60 * 60 * 1000); - return; - } - - try { - await automationService.shutdown(); - } catch (err) { - console.error('[Automation] Sidecar shutdown failed:', err?.message || err); - } - - if (gjcShutdownFenced) { + // Reserve the asynchronous listen callback before releasing startup. + // Native readiness can be observed before marker/watcher setup finishes. + const releaseReady = enterInternalActivity('server:listen-ready', true); + try { + await (startupFlight = listenForStartup(server, wss, Number(SERVER_PORT), HOST, async () => { + if (shutdownStarted) + return; try { - gjcJobOrchestrator.close(); - } catch (err) { - console.error('[GJC Jobs] Error closing authority clients during shutdown:', err?.message || err); + const address = server.address(); + const port = typeof address === 'object' && address ? address.port : Number.parseInt(String(SERVER_PORT), 10); + const appRoot = APP_ROOT; + await writeLocalServerMarker(port).catch((error) => { + console.warn('[WARN] Could not write local server marker:', error.message); + }); + if (shutdownStarted) + return; + if (process.env.GJC_DESKTOP === '1') { + console.log(JSON.stringify({ + kind: 'gajae-desktop-ready', + pid: process.pid, + host: HOST, + port, + protocolVersion: 1, + version: RUNNING_VERSION, + })); + } + console.log(''); + console.log(c.dim('═'.repeat(63))); + console.log(` ${c.bright('Gajae Code App Server - Ready')}`); + console.log(c.dim('═'.repeat(63))); + console.log(''); + console.log(`${c.info('[INFO]')} Server URL: ${c.bright('http://' + DISPLAY_HOST + ':' + port)}`); + console.log(`${c.info('[INFO]')} App root: ${c.dim(appRoot)}`); + console.log(`${c.tip('[TIP]')} Run "gajae-app status" for full configuration details`); + console.log(''); + // Start watching the projects folder for changes + await initializeSessionsWatcher(); } - } - try { - await removeLocalServerMarker(); - } catch (err) { - console.error('[Local Server] Error removing server marker during shutdown:', err?.message || err); - } - process.exit(0); - }; - process.on('SIGTERM', () => void shutdownRuntimeServices()); - process.on('SIGINT', () => void shutdownRuntimeServices()); - } catch (error) { + catch (error) { + markInternalActivityUncertain('server_ready_failed'); + console.error('[ERROR] Server readiness setup failed:', error); + } + })); + } + finally { + releaseReady(); + } + } + catch (error) { console.error('[ERROR] Failed to start server:', error); - process.exit(1); + await shutdownRuntimeServices(1); + } + finally { + releaseStartup(); } } diff --git a/server/modules/assets/assets.routes.ts b/server/modules/assets/assets.routes.ts index dec524f8..f2094f1d 100644 --- a/server/modules/assets/assets.routes.ts +++ b/server/modules/assets/assets.routes.ts @@ -1,5 +1,7 @@ import { randomUUID } from 'node:crypto'; -import { constants, promises as fsPromises } from 'node:fs'; +import fs, { constants, promises as fsPromises } from 'node:fs'; +import path from 'node:path'; +import { finished, pipeline } from 'node:stream/promises'; import express from 'express'; import mime from 'mime-types'; @@ -9,6 +11,7 @@ import { buildStoredImageRecords, ensureImageAssetsDir, isAllowedImageMimeType, resolveImageAssetFile, } from '@/modules/assets/services/image-assets.service.js'; +import { asyncHandler } from '@/shared/utils.js'; const assetsRouter = express.Router(); @@ -18,43 +21,97 @@ function generatedFilename(mimeType: string): string { return `${randomUUID()}.${mime.extension(mimeType)}`; } -const imageUpload = multer({ - storage: multer.diskStorage({ - destination: (_request, _file, done) => { - void ensureImageAssetsDir().then( - (directory) => done(null, directory), - (reason: Error) => done(reason, ''), - ); +async function receiveImages(request: express.Request, response: express.Response): Promise { + const operations: Promise[] = []; + const writes = new Map>>(); + const created = new Set(); + const remove = async (filename: string): Promise => { + // Multer can request removal while an aborted write is still closing. + await writes.get(filename)?.catch(() => {}); + // A failed exclusive open (including an existing symlink) never grants + // ownership of that pathname. Only remove files this request created. + if (!created.has(filename)) return; + await fsPromises.unlink(filename).catch((error: NodeJS.ErrnoException) => { + if (error.code !== 'ENOENT') throw error; + }); + created.delete(filename); + }; + const upload = multer({ + // Use Multer's storage extension point so the handler owns the actual file + // pipeline. diskStorage calls back on finish (before descriptor close), and + // Multer's request-abort path can call next before pending storage callbacks. + storage: { + _handleFile: (_request, file, done) => { + const write: Promise> = Promise.resolve().then(async () => { + const destination = await ensureImageAssetsDir(); + if (request.aborted || file.stream.destroyed) throw new Error('Request aborted'); + const filename = generatedFilename(file.mimetype); + const target = path.join(destination, filename); + file.path = target; + writes.set(target, write); + const output = fs.createWriteStream(target, { flags: 'wx', mode: 0o600 }); + output.once('open', () => created.add(target)); + await pipeline(file.stream, output); + return { destination, filename, path: target, size: output.bytesWritten }; + }); + operations.push(write.then((info) => done(null, info), (error: Error) => done(error))); + }, + _removeFile: (_request, file, done) => { + operations.push(remove(file.path).then(() => { + delete (file as Partial).destination; + delete (file as Partial).filename; + delete (file as Partial).path; + done(null); + }, (error: Error) => done(error))); + }, }, - filename: (_request, file, done) => done(null, generatedFilename(file.mimetype)), - }), - fileFilter: (_request, file, done) => { - if (!isAllowedImageMimeType(file.mimetype)) { - return done(new Error('Invalid file type. Only JPEG, PNG, GIF, WebP, and SVG are allowed.')); + fileFilter: (_request, file, done) => { + if (!isAllowedImageMimeType(file.mimetype)) { + return done(new Error('Invalid file type. Only JPEG, PNG, GIF, WebP, and SVG are allowed.')); + } + done(null, true); + }, + limits: { files: 5, fileSize: 5 * 1024 * 1024 }, + }); + const failure = await new Promise((resolve) => upload.array('images', 5)(request, response, resolve)).catch((error: unknown) => error); + const storageFailures: unknown[] = []; + // Storage completion can schedule removal. Include operations added while a + // previous batch is settling; do not use the response finish/close as a lease. + for (let settled = 0; settled < operations.length;) { + const batch = operations.slice(settled); + settled += batch.length; + const results = await Promise.allSettled(batch); + for (const result of results) if (result.status === 'rejected') storageFailures.push(result.reason); + } + if (failure || request.aborted || storageFailures.length) { + // Abort may bypass Multer's pending-file list. All writers are closed now; + // remove late/partially written files before returning the upload failure. + const cleanup = await Promise.allSettled([...created].map((filename) => remove(filename))); + for (const result of cleanup) { + if (result.status === 'rejected') console.error('Failed to clean up image upload:', result.reason); } - done(null, true); - }, - limits: { files: 5, fileSize: 5 * 1024 * 1024 }, -}); + throw failure || storageFailures[0] || new Error('Request aborted'); + } +} -assetsRouter.post('/images', (request, response) => { - imageUpload.array('images', 5)(request, response, (failure: unknown) => { - if (failure) { - const error = failure instanceof Error ? failure.message : 'Upload failed'; - response.status(400).json({ error }); - return; - } +assetsRouter.post('/images', asyncHandler(async (request, response) => { + try { + await receiveImages(request, response); + } catch (failure) { + const error = failure instanceof Error ? failure.message : 'Upload failed'; + response.status(400).json({ error }); + return; + } - const files = Array.isArray(request.files) ? request.files : []; - if (!files.length) { - response.status(400).json({ error: 'No image files provided' }); - return; - } - response.json({ images: buildStoredImageRecords(files) }); - }); -}); + const files = Array.isArray(request.files) ? request.files : []; + if (!files.length) { + response.status(400).json({ error: 'No image files provided' }); + return; + } + response.json({ images: buildStoredImageRecords(files) }); +})); -assetsRouter.get('/images/:filename', async (request, response) => { +assetsRouter.get('/images/:filename', asyncHandler(async (request, response) => { const filename = resolveImageAssetFile(request.params.filename); if (filename === null) { response.status(400).json({ error: 'Invalid asset filename' }); @@ -81,22 +138,44 @@ assetsRouter.get('/images/:filename', async (request, response) => { return; } - const detectedType = mime.lookup(filename); - const contentType = detectedType && isAllowedImageMimeType(detectedType) ? detectedType : 'application/octet-stream'; - response.setHeader('Content-Type', contentType); - response.setHeader('X-Content-Type-Options', 'nosniff'); - if (contentType === 'image/svg+xml' || contentType === 'application/octet-stream') { - response.setHeader('Content-Disposition', 'attachment'); - } + try { + const detectedType = mime.lookup(filename); + const contentType = detectedType && isAllowedImageMimeType(detectedType) ? detectedType : 'application/octet-stream'; + response.setHeader('Content-Type', contentType); + response.setHeader('X-Content-Type-Options', 'nosniff'); + if (contentType === 'image/svg+xml' || contentType === 'application/octet-stream') { + response.setHeader('Content-Disposition', 'attachment'); + } - const assetStream = asset.createReadStream(); - response.once('close', () => assetStream.destroy()); - assetStream.on('error', (failure) => { - console.error('Error streaming image asset:', failure); - if (!response.headersSent) response.status(500).json({ error: 'Error reading asset' }); - else response.destroy(); - }); - assetStream.pipe(response); -}); + const assetStream = asset.createReadStream(); + const sourceClosed = new Promise((resolve) => assetStream.once('close', resolve)); + const stopSource = () => { assetStream.destroy(); }; + const reportError = (failure: Error) => { + console.error('Error streaming image asset:', failure); + if (response.destroyed) return; + if (!response.headersSent) response.status(500).json({ error: 'Error reading asset' }); + else response.destroy(); + }; + assetStream.on('error', reportError); + response.once('close', stopSource); + const responseDone = finished(response, { cleanup: true }).catch(stopSource); + try { + if (response.destroyed) stopSource(); + else assetStream.pipe(response); + await Promise.all([sourceClosed, responseDone]); + } catch (error) { + response.destroy(error instanceof Error ? error : new Error('Error reading asset')); + throw error; + } finally { + stopSource(); + await Promise.all([sourceClosed, responseDone]); + response.off('close', stopSource); + assetStream.off('error', reportError); + } + } finally { + // Source close, not response finish, establishes descriptor cleanup. + await asset.close(); + } +})); export default assetsRouter; diff --git a/server/modules/assets/tests/assets.routes.test.ts b/server/modules/assets/tests/assets.routes.test.ts index bfa0b75f..9592448e 100644 --- a/server/modules/assets/tests/assets.routes.test.ts +++ b/server/modules/assets/tests/assets.routes.test.ts @@ -1,8 +1,11 @@ import assert from 'node:assert/strict'; import { once } from 'node:events'; -import { mkdtemp, mkdir, rm, symlink, writeFile } from 'node:fs/promises'; +import fs from 'node:fs'; +import fileSystem, { lstat, mkdtemp, mkdir, readFile, readdir, rm, symlink, writeFile } from 'node:fs/promises'; +import http from 'node:http'; import os from 'node:os'; import path from 'node:path'; +import { Readable, type Writable } from 'node:stream'; import test, { type TestContext } from 'node:test'; import express from 'express'; @@ -15,6 +18,17 @@ async function serve(t: TestContext) { const assets = path.join(home, '.gajae-app', 'assets'); await mkdir(assets, { recursive: true }); const app = express(); + const requests: express.Request[] = []; + let active = 0; + const idle: Array<() => void> = []; + app.locals.desktopRestartAdmission = { enter: () => { + active++; + return () => { + active--; + if (!active) idle.splice(0).forEach((resolve) => resolve()); + }; + } }; + app.use((request, _response, next) => { requests.push(request); next(); }); app.use('/assets', assetsRouter); const server = app.listen(0, '127.0.0.1'); await once(server, 'listening'); @@ -27,6 +41,10 @@ async function serve(t: TestContext) { return { home, assets, + origin: `http://127.0.0.1:${address.port}/assets`, + requests, + active: () => active, + idle: () => active ? new Promise((resolve) => idle.push(resolve)) : Promise.resolve(), request: (url: string, options?: RequestInit) => fetch(`http://127.0.0.1:${address.port}/assets${url}`, options), }; } @@ -46,6 +64,223 @@ test('an image MIME cannot turn an HTML filename into an active same-origin docu await downloaded.arrayBuffer(); }); +function deferred(t: TestContext) { + let resolve!: () => void; + const promise = new Promise((done) => { resolve = done; }); + t.after(resolve); + return { promise, resolve }; +} + +function delayDestroy(stream: Readable | Writable, entered: ReturnType, release: ReturnType): void { + const original = stream._destroy.bind(stream); + stream._destroy = (error, callback) => { + entered.resolve(); + void release.promise.then(() => original(error, callback)); + }; +} + +function unfinishedUpload(server: Awaited>) { + const request = http.request(`${server.origin}/images`, { + method: 'POST', headers: { 'content-type': 'multipart/form-data; boundary=owned-upload' }, + }); + request.on('error', () => {}); + request.write('--owned-upload\r\nContent-Disposition: form-data; name="images"; filename="partial.png"\r\nContent-Type: image/png\r\n\r\npartial bytes'); + return request; +} + +test('image upload waits for the writer close callback, not merely writable finish', { timeout: 10_000 }, async (t) => { + const closing = deferred(t); + const release = deferred(t); + const server = await serve(t); + const create = fs.createWriteStream; + t.mock.method(fs, 'createWriteStream', (...args: Parameters) => { + const stream = create(...args); + delayDestroy(stream, closing, release); + return stream; + }); + const form = new FormData(); + form.append('images', new Blob(['image'], { type: 'image/png' }), 'image.png'); + let answered = false; + const response = server.request('/images', { method: 'POST', body: form }).then((value) => { answered = true; return value; }); + await closing.promise; + assert.equal(server.active(), 1); + assert.equal(answered, false); + release.resolve(); + assert.equal((await response).status, 200); + await server.idle(); + assert.equal(server.active(), 0); +}); + +for (const kind of ['file', 'symlink'] as const) { + test(`failed exclusive image open never overwrites or removes a colliding ${kind}`, async (t) => { + const server = await serve(t); + const outside = path.join(server.home, 'existing.txt'); + await writeFile(outside, 'keep outside'); + const create = fs.createWriteStream; + let target = ''; + t.mock.method(fs, 'createWriteStream', (...args: Parameters) => { + target = String(args[0]); + if (kind === 'symlink') fs.symlinkSync(outside, target); + else fs.writeFileSync(target, 'keep existing', { flag: 'wx' }); + return create(...args); + }); + const form = new FormData(); + form.append('images', new Blob(['must not overwrite'], { type: 'image/png' }), 'collision.png'); + const response = await server.request('/images', { method: 'POST', body: form }); + assert.equal(response.status, 400); + assert.match((await response.json() as { error: string }).error, /EEXIST/u); + await server.idle(); + assert.equal((await lstat(target)).isSymbolicLink(), kind === 'symlink'); + assert.equal(await readFile(target, 'utf8'), kind === 'symlink' ? 'keep outside' : 'keep existing'); + assert.equal(await readFile(outside, 'utf8'), 'keep outside'); + }); +} + +test('aborted image uploads retain ownership through delayed writer close and remove partial files', { timeout: 10_000 }, async (t) => { + const opened = deferred(t); + const closing = deferred(t); + const release = deferred(t); + const server = await serve(t); + const create = fs.createWriteStream; + t.mock.method(fs, 'createWriteStream', (...args: Parameters) => { + const stream = create(...args); + stream.once('open', opened.resolve); + delayDestroy(stream, closing, release); + return stream; + }); + const request = unfinishedUpload(server); + t.after(() => request.destroy()); + await opened.promise; + request.destroy(); + await closing.promise; + assert.equal(server.active(), 1); + assert.equal((await readdir(server.assets)).length, 1); + release.resolve(); + await server.idle(); + assert.deepEqual(await readdir(server.assets), []); +}); + +test('aborted uploads wait for pending directory preparation and never start a late file write', { timeout: 10_000 }, async (t) => { + const preparing = deferred(t); + const release = deferred(t); + const server = await serve(t); + const mkdir = fileSystem.mkdir; + t.mock.method(fileSystem, 'mkdir', async (...args: Parameters) => { + if (String(args[0]) === server.assets) { preparing.resolve(); await release.promise; } + return mkdir(...args); + }); + const request = unfinishedUpload(server); + t.after(() => request.destroy()); + await preparing.promise; + const aborted = once(server.requests[0], 'aborted'); + request.destroy(); + await aborted; + assert.equal(server.active(), 1); + release.resolve(); + await server.idle(); + assert.deepEqual(await readdir(server.assets), []); +}); + +test('image size-limit failure waits for removal and preserves the upload error contract', { timeout: 10_000 }, async (t) => { + const removing = deferred(t); + const release = deferred(t); + const server = await serve(t); + const unlink = fileSystem.unlink; + t.mock.method(fileSystem, 'unlink', async (filename: Parameters[0]) => { + removing.resolve(); + await release.promise; + return unlink(filename); + }); + const form = new FormData(); + form.append('images', new Blob([new Uint8Array(5 * 1024 * 1024 + 1)], { type: 'image/png' }), 'large.png'); + const pending = server.request('/images', { method: 'POST', body: form }); + await removing.promise; + assert.equal(server.active(), 1); + release.resolve(); + const response = await pending; + assert.equal(response.status, 400); + assert.equal((await response.json() as { error: string }).error, 'File too large'); + await server.idle(); + assert.deepEqual(await readdir(server.assets), []); +}); + +test('image GET retains ownership after the response body ends until file descriptor cleanup', { timeout: 10_000 }, async (t) => { + const closing = deferred(t); + const release = deferred(t); + const server = await serve(t); + await writeFile(path.join(server.assets, 'stream.png'), 'stream bytes'); + const open = fileSystem.open; + t.mock.method(fileSystem, 'open', async (...args: Parameters) => { + const handle = await open(...args); + const create = handle.createReadStream.bind(handle); + t.mock.method(handle, 'createReadStream', (...options: Parameters) => { + const stream = create(...options); + delayDestroy(stream, closing, release); + return stream; + }); + return handle; + }); + const response = await server.request('/images/stream.png'); + assert.equal(await response.text(), 'stream bytes'); + await closing.promise; + assert.equal(server.active(), 1); + release.resolve(); + await server.idle(); + assert.equal(server.active(), 0); +}); + +test('image GET disconnect waits for source destruction before closing the owned file handle', { timeout: 10_000 }, async (t) => { + const closing = deferred(t); + const release = deferred(t); + const server = await serve(t); + await writeFile(path.join(server.assets, 'disconnect.png'), 'fixture'); + const open = fileSystem.open; + let handleClosed = false; + t.mock.method(fileSystem, 'open', async (...args: Parameters) => { + const handle = await open(...args); + const close = handle.close.bind(handle); + t.mock.method(handle, 'close', async () => { await close(); handleClosed = true; }); + t.mock.method(handle, 'createReadStream', () => { + const source = new Readable({ read() {} }); + source.push('partial'); + delayDestroy(source, closing, release); + return source as ReturnType; + }); + return handle; + }); + const response = await server.request('/images/disconnect.png'); + const reader = response.body!.getReader(); + assert.equal(new TextDecoder().decode((await reader.read()).value), 'partial'); + await reader.cancel(); + await closing.promise; + assert.equal(server.active(), 1); + assert.equal(handleClosed, false); + release.resolve(); + await server.idle(); + assert.equal(handleClosed, true); +}); + +test('image read failure preserves its 500 response and closes the file handle', async (t) => { + const server = await serve(t); + await writeFile(path.join(server.assets, 'failed.png'), 'fixture'); + const open = fileSystem.open; + let handleClosed = false; + t.mock.method(fileSystem, 'open', async (...args: Parameters) => { + const handle = await open(...args); + const close = handle.close.bind(handle); + t.mock.method(handle, 'close', async () => { await close(); handleClosed = true; }); + t.mock.method(handle, 'createReadStream', () => new Readable({ + read() { this.destroy(new Error('fixture read failure')); }, + }) as ReturnType); + return handle; + }); + const response = await server.request('/images/failed.png'); + assert.equal(response.status, 500); + assert.deepEqual(await response.json(), { error: 'Error reading asset' }); + await server.idle(); + assert.equal(handleClosed, true); +}); + test('legacy non-image assets and SVGs are downloaded without an active document type', async (t) => { const server = await serve(t); for (const filename of ['legacy.html', 'legacy.xml', 'legacy.svg']) { diff --git a/server/modules/automation/automation.routes.ts b/server/modules/automation/automation.routes.ts index 9b91abd3..235767bb 100644 --- a/server/modules/automation/automation.routes.ts +++ b/server/modules/automation/automation.routes.ts @@ -1,5 +1,7 @@ import { Router, type Request, type Response } from 'express'; +import { asyncHandler } from '@/shared/utils.js'; + import { safeSessionId, type BrowserCommand, type BrowserInput } from './browser-protocol.js'; import { isCuaSafeTool } from './cua-client.js'; import { automationService, type AutomationService } from './automation.service.js'; @@ -28,7 +30,7 @@ function sessionId(request: Request, response: Response): string | null { } function registerBrowserRoutes(router: Router, prefix: string, service: AutomationService): void { - router.post(`${prefix}/:sessionId/open`, async (request, response) => { + router.post(`${prefix}/:sessionId/open`, asyncHandler(async (request, response) => { const id = sessionId(request, response); if (!id) return; try { @@ -40,9 +42,9 @@ function registerBrowserRoutes(router: Router, prefix: string, service: Automati } catch (error) { errorResponse(response, error); } - }); + })); - router.post(`${prefix}/:sessionId/command`, async (request, response) => { + router.post(`${prefix}/:sessionId/command`, asyncHandler(async (request, response) => { const id = sessionId(request, response); if (!id) return; try { @@ -50,9 +52,9 @@ function registerBrowserRoutes(router: Router, prefix: string, service: Automati } catch (error) { errorResponse(response, error); } - }); + })); - router.post(`${prefix}/:sessionId/input`, async (request, response) => { + router.post(`${prefix}/:sessionId/input`, asyncHandler(async (request, response) => { const id = sessionId(request, response); if (!id) return; try { @@ -60,9 +62,9 @@ function registerBrowserRoutes(router: Router, prefix: string, service: Automati } catch (error) { errorResponse(response, error); } - }); + })); - router.delete(`${prefix}/:sessionId`, async (request, response) => { + router.delete(`${prefix}/:sessionId`, asyncHandler(async (request, response) => { const id = sessionId(request, response); if (!id) return; try { @@ -70,7 +72,7 @@ function registerBrowserRoutes(router: Router, prefix: string, service: Automati } catch (error) { errorResponse(response, error); } - }); + })); } export function createBrowserAutomationRouter(service: AutomationService = automationService): Router { @@ -81,15 +83,15 @@ export function createBrowserAutomationRouter(service: AutomationService = autom export function createAutomationRouter(service: AutomationService = automationService): Router { const router = Router(); - router.get('/status', async (_request, response) => { + router.get('/status', asyncHandler(async (_request, response) => { try { response.json(await service.status()); } catch (error) { errorResponse(response, error); } - }); + })); - router.get('/local-sites', async (request, response) => { + router.get('/local-sites', asyncHandler(async (request, response) => { try { const localPort = request.socket.localPort; response.json({ @@ -98,13 +100,13 @@ export function createAutomationRouter(service: AutomationService = automationSe } catch (error) { errorResponse(response, error); } - }); + })); // Kept for compatibility with the first PoC client. The documented/public // desktop surface is mounted separately at /api/browser/:sessionId. registerBrowserRoutes(router, '/browser', service); - router.post('/computer/:sessionId/call', async (request, response) => { + router.post('/computer/:sessionId/call', asyncHandler(async (request, response) => { const id = sessionId(request, response); if (!id) return; if (!isCuaSafeTool(request.body?.tool)) { @@ -116,16 +118,16 @@ export function createAutomationRouter(service: AutomationService = automationSe } catch (error) { errorResponse(response, error); } - }); + })); - router.get('/grants', (request, response) => { + router.get('/grants', asyncHandler((request, response) => { const id = typeof request.query.sessionId === 'string' && safeSessionId(request.query.sessionId) ? request.query.sessionId : undefined; response.json(service.grants.list(id)); - }); + })); - router.post('/grants', (request, response) => { + router.post('/grants', asyncHandler((request, response) => { const { kind, value, scope, sessionId: requestedSessionId } = request.body ?? {}; if ((kind !== 'origin' && kind !== 'application') || (scope !== 'session' && scope !== 'always') || typeof value !== 'string' || !value || value.length > 512 @@ -139,9 +141,9 @@ export function createAutomationRouter(service: AutomationService = automationSe } catch (error) { errorResponse(response, error); } - }); + })); - router.delete('/grants', (request, response) => { + router.delete('/grants', asyncHandler((request, response) => { try { const filter = parseAutomationGrantFilter(request.body ?? {}); service.grants.revoke(filter); @@ -149,7 +151,7 @@ export function createAutomationRouter(service: AutomationService = automationSe } catch (error) { errorResponse(response, error); } - }); + })); return router; } diff --git a/server/modules/automation/automation.service.ts b/server/modules/automation/automation.service.ts index 450b48bf..8a1125fd 100644 --- a/server/modules/automation/automation.service.ts +++ b/server/modules/automation/automation.service.ts @@ -4,6 +4,10 @@ import net, { type Server as NetServer, type Socket } from 'node:net'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import type { DesktopWorkAdmission } from '@/shared/interfaces.js'; + +import type { DesktopOwnerActivity } from '../../../shared/desktopUpdateProtocol.js'; + import { AutomationGrantStore, type AutomationGrant } from './automation-grants.js'; import { BrowserSidecarClient, type BrowserEventListener } from './browser-sidecar-client.js'; import type { BrowserCommand, BrowserInput, BrowserSessionState } from './browser-protocol.js'; @@ -111,6 +115,13 @@ const COMPUTER_DISCOVERY_TOOLS = new Set([ const WORKSPACE_BROWSER_APPLICATION_ID = 'app.gajae.workspace-browser'; const WORKSPACE_BROWSER_LABEL = 'Workspace Browser'; +type ComputerSession = { + label: string; + starting?: Promise<{ label: string; result: unknown }>; + ending?: Promise; + uncertain?: boolean; +}; + export function automationSupport(platform: NodeJS.Platform, arch: string, environment: NodeJS.ProcessEnv) { const override = environment.GAJAE_AUTOMATION === '1'; const desktop = environment.GJC_DESKTOP === '1'; @@ -123,7 +134,14 @@ export function automationSupport(platform: NodeJS.Platform, arch: string, envir export class AutomationService { readonly browser = new BrowserSidecarClient(); - readonly cua = new CuaDriverClient(); + readonly cua = new CuaDriverClient({ onSessionClosed: (label) => { + for (const [id, session] of this.cuaSessionLabels) { + if (session.label === label) { + this.cuaSessionLabels.delete(id); + this.activityRevision++; + } + } + } }); readonly grants = new AutomationGrantStore(); private readonly capabilities = automationSupport(process.platform, process.arch, process.env); readonly supported = this.capabilities.browser; @@ -132,27 +150,72 @@ export class AutomationService { ?? join(tmpdir(), `gajae-automation-${process.pid}.sock`); private bridge?: NetServer; private readonly bridgeConnections = new Set(); - private readonly cuaSessionLabels = new Map(); + private readonly cuaSessionLabels = new Map(); + private bridgeStarting?: Promise; + private admission?: DesktopWorkAdmission; + private readonly activityEpoch = randomUUID(); + private activityRevision = 0; + private dispatching = 0; + private closing = 0; + + constructor(admission?: DesktopWorkAdmission) { + this.configureDesktopRestartAdmission(admission); + } - async status() { - const [browser, cua] = await Promise.all([ - this.supported - ? this.browser.status().catch((error) => ({ state: 'error', installed: false, buildId: 'unknown', error: error instanceof Error ? error.message : String(error) })) - : Promise.resolve({ state: 'idle', installed: false, buildId: 'unsupported' }), - this.capabilities.computer - ? this.cua.status() - : Promise.resolve({ installed: false, daemon: 'unknown' as const }), - ]); + configureDesktopRestartAdmission(admission?: DesktopWorkAdmission): void { + this.admission = admission; + this.browser.configureDesktopRestartAdmission(admission); + this.cua.configureDesktopRestartAdmission(admission); + this.activityRevision++; + } + + getGeneration(): string { return `${this.activityEpoch}:${this.activityRevision}`; } + + snapshotActivity(): DesktopOwnerActivity { + const sessions = [...this.cuaSessionLabels.values()]; + const unknown = sessions.some((session) => session.uncertain) ? ['computer_session_unconfirmed'] : []; return { - supported: this.supported, - computerSupported: this.capabilities.computer, - platform: process.platform, - architecture: process.arch, - browser, - cua, + owner: 'automation', generation: this.getGeneration(), complete: unknown.length === 0, + starting: Number(Boolean(this.bridgeStarting)) + sessions.filter((session) => session.starting).length, + queued: 0, running: this.dispatching, + settling: this.closing + sessions.filter((session) => session.ending).length, + approvals: 0, retained: sessions.length, unknown, + }; + } + + private enter(source: string): () => void { + const release = this.admission?.enter(`automation.${source}`); + this.dispatching++; + this.activityRevision++; + return () => { + this.dispatching--; + this.activityRevision++; + release?.(); }; } + async status() { + const release = this.enter('status'); + try { + const [browser, cua] = await Promise.all([ + this.supported + ? this.browser.status().catch((error) => ({ state: 'error', installed: false, buildId: 'unknown', error: error instanceof Error ? error.message : String(error) })) + : Promise.resolve({ state: 'idle', installed: false, buildId: 'unsupported' }), + this.capabilities.computer + ? this.cua.status() + : Promise.resolve({ installed: false, daemon: 'unknown' as const }), + ]); + return { + supported: this.supported, + computerSupported: this.capabilities.computer, + platform: process.platform, + architecture: process.arch, + browser, + cua, + }; + } finally { release(); } + } + subscribeBrowser(listener: BrowserEventListener): () => void { return this.browser.subscribe(listener); } @@ -163,33 +226,45 @@ export class AutomationService { signal?: AbortSignal, ): Promise { this.requireSupported(); - return this.browser.open(sessionId, payload, signal); + const release = this.enter('browser.open'); + try { return await this.browser.open(sessionId, payload, signal); } + finally { release(); } } - commandBrowser(sessionId: string, command: BrowserCommand, signal?: AbortSignal): Promise { + async commandBrowser(sessionId: string, command: BrowserCommand, signal?: AbortSignal): Promise { this.requireSupported(); - return this.browser.command(sessionId, command, signal); + const release = this.enter('browser.command'); + try { return await this.browser.command(sessionId, command, signal); } + finally { release(); } } - inputBrowser(sessionId: string, input: BrowserInput): Promise { + async inputBrowser(sessionId: string, input: BrowserInput): Promise { this.requireSupported(); - return this.browser.input(sessionId, input); + const release = this.enter('browser.input'); + try { return await this.browser.input(sessionId, input); } + finally { release(); } } async stopSession(sessionId: string): Promise { - this.grants.clearSession(sessionId); - const signal = AbortSignal.timeout(2_500); - const [browser] = await Promise.allSettled([ - this.browser.close(sessionId, signal), - this.endComputerSession(sessionId, signal), - ]); - return browser.status === 'fulfilled' ? browser.value : { closed: false }; + const release = this.enter('session.stop'); + try { + this.grants.clearSession(sessionId); + const signal = AbortSignal.timeout(2_500); + const [browser] = await Promise.allSettled([ + this.browser.close(sessionId, signal), + this.endComputerSession(sessionId, signal), + ]); + return browser.status === 'fulfilled' ? browser.value : { closed: false }; + } finally { release(); } } grant(grant: AutomationGrant): void { - const value = grant.kind === 'origin' ? automationOrigin(grant.value) : grant.value.trim(); - if (!value) throw new Error('A web origin is required for this grant.'); - this.grants.grant({ ...grant, value }); + const release = this.enter('grant'); + try { + const value = grant.kind === 'origin' ? automationOrigin(grant.value) : grant.value.trim(); + if (!value) throw new Error('A web origin is required for this grant.'); + this.grants.grant({ ...grant, value }); + } finally { release(); } } async authorizeBrowser( @@ -198,23 +273,26 @@ export class AutomationService { signal?: AbortSignal, ): Promise<{ granted: boolean; origin: string | null }> { this.requireSupported(); - let rawUrl = typeof payload.url === 'string' ? payload.url : undefined; - if (!rawUrl) { - const state = await this.browser.state(sessionId, signal) as BrowserSessionState; - rawUrl = state.tabs.find((tab) => tab.id === state.activeTabId)?.url; - } - if (!rawUrl) throw new Error('Open a browser tab before requesting browser access.'); - const origin = automationOrigin(rawUrl); - if (!origin) return { granted: true, origin: null }; - if (payload.scope === 'session' || payload.scope === 'always') { - this.grant({ - kind: 'origin', - value: origin, - scope: payload.scope, - ...(payload.scope === 'session' ? { sessionId } : {}), - }); - } - return { granted: this.grants.has('origin', origin, sessionId), origin }; + const release = this.enter('browser.authorize'); + try { + let rawUrl = typeof payload.url === 'string' ? payload.url : undefined; + if (!rawUrl) { + const state = await this.browser.state(sessionId, signal) as BrowserSessionState; + rawUrl = state.tabs.find((tab) => tab.id === state.activeTabId)?.url; + } + if (!rawUrl) throw new Error('Open a browser tab before requesting browser access.'); + const origin = automationOrigin(rawUrl); + if (!origin) return { granted: true, origin: null }; + if (payload.scope === 'session' || payload.scope === 'always') { + this.grant({ + kind: 'origin', + value: origin, + scope: payload.scope, + ...(payload.scope === 'session' ? { sessionId } : {}), + }); + } + return { granted: this.grants.has('origin', origin, sessionId), origin }; + } finally { release(); } } async authorizeComputer( @@ -223,127 +301,154 @@ export class AutomationService { signal?: AbortSignal, ): Promise { this.requireComputerSupported(); - if (!isCuaSafeTool(payload.tool)) throw new Error('Unsupported CUA Driver tool.'); - const args = object(payload.arguments); - let application = typeof payload.application === 'string' ? payload.application.trim() : ''; - let label: string | null = null; - - if (!application && payload.tool === 'launch_app') { - application = typeof args.bundle_id === 'string' ? args.bundle_id.trim() : ''; - label = typeof args.name === 'string' && args.name.trim() ? args.name.trim() : null; - } + const release = this.enter('computer.authorize'); + try { + if (!isCuaSafeTool(payload.tool)) throw new Error('Unsupported CUA Driver tool.'); + const args = object(payload.arguments); + let application = typeof payload.application === 'string' ? payload.application.trim() : ''; + let label: string | null = null; + + if (!application && payload.tool === 'launch_app') { + application = typeof args.bundle_id === 'string' ? args.bundle_id.trim() : ''; + label = typeof args.name === 'string' && args.name.trim() ? args.name.trim() : null; + } - let pid = requestedPid(args); - const windowId = requestedWindowId(args); - const sidecarPid = this.browser.browserPid; - const needsApplication = payload.tool === 'launch_app' + let pid = requestedPid(args); + const windowId = requestedWindowId(args); + const sidecarPid = this.browser.browserPid; + const needsApplication = payload.tool === 'launch_app' || pid !== undefined || windowId !== undefined || (payload.tool === 'list_windows' && args.pid !== undefined); - if (!application && needsApplication && !(pid !== undefined && pid === sidecarPid)) { - const inventory = await this.cua.call( - pid === undefined && windowId !== undefined ? 'list_windows' : 'list_apps', - {}, - signal, - ); - if (pid === undefined && windowId !== undefined) { - const window = windowRecords(inventory).find((candidate) => candidate.window_id === windowId); - if (window && typeof window.pid === 'number' && Number.isSafeInteger(window.pid) && window.pid > 0) { - pid = window.pid; + if (!application && needsApplication && !(pid !== undefined && pid === sidecarPid)) { + const inventory = await this.cua.call( + pid === undefined && windowId !== undefined ? 'list_windows' : 'list_apps', + {}, + signal, + ); + if (pid === undefined && windowId !== undefined) { + const window = windowRecords(inventory).find((candidate) => candidate.window_id === windowId); + if (window && typeof window.pid === 'number' && Number.isSafeInteger(window.pid) && window.pid > 0) { + pid = window.pid; + } } - } - let apps = applicationRecords(inventory); - if (pid !== undefined && apps.length === 0) { - apps = applicationRecords(await this.cua.call('list_apps', {}, signal)); - } - const requestedName = typeof args.name === 'string' ? args.name.trim().toLocaleLowerCase() : ''; - const match = apps.find((app) => ( - (pid !== undefined && app.pid === pid) + let apps = applicationRecords(inventory); + if (pid !== undefined && apps.length === 0) { + apps = applicationRecords(await this.cua.call('list_apps', {}, signal)); + } + const requestedName = typeof args.name === 'string' ? args.name.trim().toLocaleLowerCase() : ''; + const match = apps.find((app) => ( + (pid !== undefined && app.pid === pid) || (requestedName && typeof app.name === 'string' && app.name.trim().toLocaleLowerCase() === requestedName) - )); - if (match && typeof match.bundle_id === 'string') application = match.bundle_id.trim(); - if (match && typeof match.name === 'string' && match.name.trim()) label = match.name.trim(); - } + )); + if (match && typeof match.bundle_id === 'string') application = match.bundle_id.trim(); + if (match && typeof match.name === 'string' && match.name.trim()) label = match.name.trim(); + } - if (!application && pid !== undefined && pid === sidecarPid) { - application = WORKSPACE_BROWSER_APPLICATION_ID; - label = WORKSPACE_BROWSER_LABEL; - } + if (!application && pid !== undefined && pid === sidecarPid) { + application = WORKSPACE_BROWSER_APPLICATION_ID; + label = WORKSPACE_BROWSER_LABEL; + } - if (!application) { - if (COMPUTER_DISCOVERY_TOOLS.has(payload.tool) || (payload.tool === 'list_windows' && !needsApplication)) { - return { granted: true, application: null, label: null }; + if (!application) { + if (COMPUTER_DISCOVERY_TOOLS.has(payload.tool) || (payload.tool === 'list_windows' && !needsApplication)) { + return { granted: true, application: null, label: null }; + } + throw new Error('Computer action requires a resolvable application identity.'); } - throw new Error('Computer action requires a resolvable application identity.'); - } - if (!label) label = application; - if (payload.scope === 'session' || payload.scope === 'always') { - this.grant({ - kind: 'application', - value: application, - scope: payload.scope, - ...(payload.scope === 'session' ? { sessionId } : {}), - }); - } - return { - granted: this.grants.has('application', application, sessionId), - application, - label, - }; + if (!label) label = application; + if (payload.scope === 'session' || payload.scope === 'always') { + this.grant({ + kind: 'application', + value: application, + scope: payload.scope, + ...(payload.scope === 'session' ? { sessionId } : {}), + }); + } + return { + granted: this.grants.has('application', application, sessionId), + application, + label, + }; + } finally { release(); } } async callComputer(sessionId: string, tool: CuaSafeTool, args: Record, signal?: AbortSignal): Promise { this.requireComputerSupported(); - const { session: _ignoredSession, ...scopedArgs } = args; - if (tool === 'end_session') return this.endComputerSession(sessionId, signal); - const { label, result } = await this.ensureComputerSession( - sessionId, - tool === 'start_session' ? scopedArgs : {}, - signal, - ); - if (tool === 'start_session') return result; - return this.cua.call(tool, { ...scopedArgs, session: label }, signal); + const release = this.enter('computer.call'); + try { + const { session: _ignoredSession, ...scopedArgs } = args; + if (tool === 'end_session') return await this.endComputerSession(sessionId, signal); + const { label, result } = await this.ensureComputerSession( + sessionId, + tool === 'start_session' ? scopedArgs : {}, + signal, + ); + if (tool === 'start_session') return result; + return await this.cua.call(tool, { ...scopedArgs, session: label }, signal); + } finally { release(); } } async startBridge(): Promise { if (!this.supported) return; + if (this.bridgeStarting) return this.bridgeStarting; if (this.bridge) return; - await mkdir(join(tmpdir()), { recursive: true }); - const bridge = net.createServer((socket) => { - this.bridgeConnections.add(socket); - socket.once('close', () => this.bridgeConnections.delete(socket)); - this.handleBridgeSocket(socket); - }); - await new Promise((resolve, reject) => { - bridge.once('error', reject); - bridge.listen(this.bridgePath, () => { - bridge.off('error', reject); - resolve(); + const release = this.enter('bridge.start'); + this.bridgeStarting = (async () => { + await mkdir(join(tmpdir()), { recursive: true }); + const bridge = net.createServer((socket) => { + this.bridgeConnections.add(socket); + this.activityRevision++; + socket.once('close', () => { + this.bridgeConnections.delete(socket); + this.activityRevision++; + }); + this.handleBridgeSocket(socket); + }); + await new Promise((resolve, reject) => { + bridge.once('error', reject); + bridge.listen(this.bridgePath, () => { + bridge.off('error', reject); + resolve(); + }); }); + this.bridge = bridge; + this.activityRevision++; + if (process.platform !== 'win32') await chmod(this.bridgePath, 0o600); + process.env.GJC_AUTOMATION_SOCKET = this.bridgePath; + process.env.GJC_AUTOMATION_TOKEN = this.bridgeToken; + })().finally(() => { + this.bridgeStarting = undefined; + release(); }); - if (process.platform !== 'win32') await chmod(this.bridgePath, 0o600); - this.bridge = bridge; - process.env.GJC_AUTOMATION_SOCKET = this.bridgePath; - process.env.GJC_AUTOMATION_TOKEN = this.bridgeToken; + return this.bridgeStarting; } async shutdown(): Promise { - const computerSessions = [...this.cuaSessionLabels.keys()]; - await Promise.allSettled([ - this.browser.shutdown(), - ...computerSessions.map((sessionId) => this.endComputerSession(sessionId, AbortSignal.timeout(2_000))), - ]); - await this.cua.shutdown(); - const bridge = this.bridge; - this.bridge = undefined; - for (const socket of this.bridgeConnections) socket.destroy(); - this.bridgeConnections.clear(); - // Node removes the Unix socket it bound when close completes. A service - // that never bound must not unlink another server's configured socket. - if (bridge) await new Promise((resolve) => bridge.close(() => resolve())); - if (process.env.GJC_AUTOMATION_SOCKET === this.bridgePath && process.env.GJC_AUTOMATION_TOKEN === this.bridgeToken) { - delete process.env.GJC_AUTOMATION_SOCKET; - delete process.env.GJC_AUTOMATION_TOKEN; + this.closing++; + this.activityRevision++; + try { + if (this.bridgeStarting) await this.bridgeStarting.catch(() => {}); + const computerSessions = [...this.cuaSessionLabels.keys()]; + await Promise.allSettled([ + this.browser.shutdown(), + ...computerSessions.map((sessionId) => this.endComputerSession(sessionId, AbortSignal.timeout(2_000))), + ]); + await this.cua.shutdown(); + const bridge = this.bridge; + for (const socket of this.bridgeConnections) socket.destroy(); + // Node removes the Unix socket it bound when close completes. A service + // that never bound must not unlink another server's configured socket. + if (bridge) await new Promise((resolve) => bridge.close(() => resolve())); + this.bridge = undefined; + this.activityRevision++; + if (process.env.GJC_AUTOMATION_SOCKET === this.bridgePath && process.env.GJC_AUTOMATION_TOKEN === this.bridgeToken) { + delete process.env.GJC_AUTOMATION_SOCKET; + delete process.env.GJC_AUTOMATION_TOKEN; + } + } finally { + this.closing--; + this.activityRevision++; } } @@ -356,35 +461,70 @@ export class AutomationService { args: Record, signal?: AbortSignal, ): Promise<{ label: string; result: unknown }> { - let label = this.cuaSessionLabels.get(sessionId); - const hadLabel = Boolean(label); - if (!label) { - label = this.newComputerSessionLabel(); - this.cuaSessionLabels.set(sessionId, label); - } - let result = await this.cua.call('start_session', { ...args, session: label }, signal); - let error = cuaToolError(result); - if (error && hadLabel) { - // Named sessions belong to one MCP transport lease. If cua-driver or the - // app server restarted, rotate the private label instead of exposing a - // dead public name to the coding agent. - label = this.newComputerSessionLabel(); - this.cuaSessionLabels.set(sessionId, label); - result = await this.cua.call('start_session', { ...args, session: label }, signal); - error = cuaToolError(result); + let session = this.cuaSessionLabels.get(sessionId); + if (session?.ending) { + await session.ending; + return this.ensureComputerSession(sessionId, args, signal); } - if (error) { - this.cuaSessionLabels.delete(sessionId); - throw new Error(error); + if (session?.starting) return session.starting; + const hadLabel = Boolean(session); + if (!session) { + session = { label: this.newComputerSessionLabel() }; + this.cuaSessionLabels.set(sessionId, session); } - return { label, result }; + const owned = session; + this.activityRevision++; + owned.starting = (async () => { + let result = await this.cua.call('start_session', { ...args, session: owned.label }, signal); + let error = cuaToolError(result); + if (error && hadLabel) { + // Named sessions belong to one MCP transport lease. If cua-driver or the + // app server restarted, rotate the private label instead of exposing a + // dead public name to the coding agent. The client retains any uncertain + // old transport-session owner independently in its original request. + owned.label = this.newComputerSessionLabel(); + this.activityRevision++; + result = await this.cua.call('start_session', { ...args, session: owned.label }, signal); + error = cuaToolError(result); + } + if (error) { + throw new Error(error); + } + owned.uncertain = false; + return { label: owned.label, result }; + })().catch((error) => { + owned.uncertain = true; + throw error; + }).finally(() => { + owned.starting = undefined; + this.activityRevision++; + }); + return owned.starting; } private async endComputerSession(sessionId: string, signal?: AbortSignal): Promise { - const label = this.cuaSessionLabels.get(sessionId); - this.cuaSessionLabels.delete(sessionId); - if (!label) return { ended: false }; - return this.cua.call('end_session', { session: label }, signal); + const session = this.cuaSessionLabels.get(sessionId); + if (!session) return { ended: false }; + if (session.ending) return session.ending; + this.activityRevision++; + session.ending = (async () => { + if (session.starting) await session.starting.catch(() => {}); + const result = await this.cua.call('end_session', { session: session.label }, signal); + const error = cuaToolError(result); + if (error) throw new Error(error); + if (!result || typeof result !== 'object' || Array.isArray(result) || object(result).ok === false || object(result).ended === false) { + throw new Error('CUA Driver did not acknowledge session closure.'); + } + if (this.cuaSessionLabels.get(sessionId) === session) this.cuaSessionLabels.delete(sessionId); + return result; + })().catch((error) => { + session.uncertain = true; + throw error; + }).finally(() => { + session.ending = undefined; + this.activityRevision++; + }); + return session.ending; } private requireSupported(): void { @@ -417,6 +557,7 @@ export class AutomationService { private async handleBridgeLine(socket: Socket, line: string): Promise { let request: BridgeRequest | undefined; + let release: (() => void) | undefined; const controller = new AbortController(); const abort = () => controller.abort(); socket.once('close', abort); @@ -425,6 +566,9 @@ export class AutomationService { if (request.token !== this.bridgeToken || !safeBridgeId(request.id) || !safeBridgeId(request.sessionId)) { throw new Error('Unauthorized automation bridge request.'); } + // Unix sockets bypass the HTTP/WS ingress wrappers. This authenticated + // handler owns dispatch until settlement, even after socket disconnect. + release = this.enter('bridge.request'); let result: unknown; if (request.surface === 'browser') { if (request.operation === 'open') result = await this.openBrowser(request.sessionId, object(request.payload), controller.signal); @@ -455,8 +599,42 @@ export class AutomationService { })}\n`); } finally { socket.off('close', abort); + release?.(); } } } export const automationService = new AutomationService(); + +/** + * Parent composition injects the SAME server-owned admission into all owners. + * Producer/owner map: docs/DESKTOP-UPDATE-ADMISSION.md (automation, browser, CUA). + * Readers below never call status(), spawn, drain, cancel, or shutdown. + */ +export function configureDesktopRestartAdmission(admission?: DesktopWorkAdmission, service = automationService): void { + service.configureDesktopRestartAdmission(admission); +} + +export function createAutomationDesktopRestartReader(service = automationService) { + return Object.freeze({ getGeneration: () => service.getGeneration(), read: () => service.snapshotActivity() }); +} + +export function createBrowserDesktopRestartReader(browser = automationService.browser) { + return Object.freeze({ getGeneration: () => browser.getGeneration(), read: () => browser.snapshotActivity() }); +} + +export function createComputerDesktopRestartReader(service = automationService) { + return Object.freeze({ + getGeneration: () => `${service.cua.getGeneration()}:${service.getGeneration()}`, + read: (): DesktopOwnerActivity => { + const activity = service.cua.snapshotActivity(); + const automation = service.snapshotActivity(); + const unknown = [...new Set([...activity.unknown, ...automation.unknown])]; + return { + ...activity, generation: `${activity.generation}:${automation.generation}`, + retained: activity.retained + automation.retained, + complete: activity.complete && automation.complete, unknown, + }; + }, + }); +} diff --git a/server/modules/automation/browser-lifecycle.test.ts b/server/modules/automation/browser-lifecycle.test.ts new file mode 100644 index 00000000..51ba00b3 --- /dev/null +++ b/server/modules/automation/browser-lifecycle.test.ts @@ -0,0 +1,363 @@ +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { existsSync } from 'node:fs'; +import { mkdtemp, rm } from 'node:fs/promises'; +import http from 'node:http'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +import type { Browser, Page, Target } from 'puppeteer-core'; + +import type { DesktopOwnerActivity } from '../../../shared/desktopUpdateProtocol.js'; + +import { BrowserRequestQueue } from './browser-runtime.js'; +import { BrowserRuntime } from './browser-sidecar.js'; +import { BrowserSidecarClient } from './browser-sidecar-client.js'; +import { isBrowserChildActivity, type BrowserChildActivity, type BrowserRequestFrame } from './browser-protocol.js'; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: Error) => void; + const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); + return { promise, resolve, reject }; +} + +async function until(predicate: () => boolean, timeoutMs = 5_000): Promise { + const deadline = Date.now() + timeoutMs; + while (!predicate()) { + if (Date.now() >= deadline) assert.fail('Expected lifecycle transition did not happen'); + await new Promise((resolve) => setTimeout(resolve, 5)); + } +} + +function isIdle(activity: DesktopOwnerActivity) { + return activity.complete && !activity.unknown.length + && ['starting', 'queued', 'running', 'settling', 'retained', 'approvals'] + .every((field) => activity[field as keyof DesktopOwnerActivity] === 0); +} + +test('child activity validation rejects malformed or extra fields', () => { + const activity: BrowserChildActivity = { + version: 1, epoch: 'child-epoch', revision: 1, requestSequence: 1, + starting: 0, queued: 0, running: 0, callbacks: 0, settling: 0, retained: 0, + browserAlive: false, unknown: [], + }; + assert.equal(isBrowserChildActivity(activity), true); + for (const value of [ + { ...activity, callbacks: -1 }, { ...activity, requestSequence: NaN }, + { ...activity, unknown: ['payload with spaces'] }, { ...activity, browserAlive: 0 }, + { ...activity, extra: true }, { ...activity, version: 2 }, + ]) assert.equal(isBrowserChildActivity(value), false); +}); + +test('queue owner covers global/session tails and realtime close before handler settlement', async () => { + const started: string[] = []; + const holds = new Map>>(); + let revisions = 0; + const queue = new BrowserRequestQueue(async (frame) => { + started.push(frame.id); + await holds.get(frame.id)!.promise; + }, () => { revisions++; }, (error) => { throw error; }); + const request = (id: string, method: BrowserRequestFrame['method'], sessionId?: string) => { + holds.set(id, deferred()); + queue.enqueue({ protocolVersion: 1, kind: 'request', id, method, payload: {}, ...(sessionId ? { sessionId } : {}) }); + }; + request('global-a', 'status'); + request('global-b', 'status'); + request('run', 'browser.command', 'session'); + request('next', 'browser.command', 'session'); + request('close', 'session.close', 'session'); + assert.deepEqual(started, ['close']); + assert.deepEqual(queue.snapshot(), { queued: 4, running: 1, requestSequence: 5 }); + await Promise.resolve(); + assert.equal(queue.snapshot().running, 3); + assert.equal(queue.snapshot().queued, 2); + const before = revisions; + queue.snapshot(); + assert.equal(revisions, before, 'snapshot is inert'); + for (const id of ['close', 'global-a', 'run']) holds.get(id)!.resolve(); + await until(() => started.length === 5); + assert.equal(queue.snapshot().running, 2); + holds.get('global-b')!.resolve(); + holds.get('next')!.resolve(); + await until(() => queue.snapshot().running === 0); + assert.equal(queue.snapshot().queued, 0); +}); + +class FakePage extends EventEmitter { + closed = false; + closeFailure = false; + readonly evaluation = deferred>(); + readonly cdp = Object.assign(new EventEmitter(), { + send: async (method: string) => { + if (method === 'Page.getFrameTree') return { frameTree: { frame: { id: 'frame' } } }; + if (method === 'Page.getNavigationHistory') return { currentIndex: 0, entries: [] }; + if (method === 'Runtime.evaluate') return this.evaluation.promise; + return {}; + }, + }); + readonly targetValue = { createCDPSession: async () => this.cdp }; + target() { return this.targetValue; } + isClosed() { return this.closed; } + url() { return 'about:blank'; } + async title() { return 'Fixture'; } + async setViewport() {} + async createCDPSession() { return this.cdp; } + async close() { + if (this.closeFailure) throw new Error('page close failed'); + this.closed = true; + this.evaluation.reject(new Error('Target closed')); + this.emit('close'); + } +} + +async function runtimeFixture() { + const directory = await mkdtemp(join(tmpdir(), 'browser-runtime-owner-')); + const process = Object.assign(new EventEmitter(), { pid: 123456 }); + const pages: FakePage[] = []; + let closeRequests = 0; + let killRequests = 0; + let revisions = 0; + let groupAlive = false; + const browser = Object.assign(new EventEmitter(), { + connected: true, + process: () => process, + target: () => ({ createCDPSession: async () => ({ + on() {}, + async send(method: string) { if (method === 'Browser.close') closeRequests++; return {}; }, + }) }), + async newPage() { + const page = new FakePage(); + void page.evaluation.promise.catch(() => {}); + pages.push(page); + return page; + }, + async close() { killRequests++; throw new Error('force-close fallback is forbidden'); }, + }); + const runtime = new BrowserRuntime({ + profilePath: directory, executablePath: globalThis.process.execPath, + launch: async () => browser as unknown as Browser, + changed: () => { revisions++; }, emit: () => {}, processGroupExists: () => groupAlive, + }); + return { + runtime, browser, pages, process, directory, + setGroupAlive: (value: boolean) => { groupAlive = value; }, + revisions: () => revisions, closeRequests: () => closeRequests, killRequests: () => killRequests, + async cleanup() { process.emit('close', 0); await rm(directory, { recursive: true, force: true }); }, + }; +} + +test('a user close retains child ownership through Chromium close acknowledgment until actual exit', async () => { + const fixture = await runtimeFixture(); + try { + await fixture.runtime.open('session', {}); + const close = fixture.runtime.close('session'); + await until(() => fixture.closeRequests() === 1); + assert.equal(fixture.runtime.snapshotActivity().browserAlive, true); + assert.equal(fixture.runtime.snapshotActivity().retained, 1); + assert.ok(fixture.runtime.snapshotActivity().settling > 0); + const revision = fixture.revisions(); + for (let i = 0; i < 3; i++) fixture.runtime.snapshotActivity(); + assert.equal(fixture.revisions(), revision); + assert.equal(fixture.killRequests(), 0); + fixture.process.emit('close', 0); + await close; + assert.deepEqual(fixture.runtime.snapshotActivity(), { + starting: 0, callbacks: 0, settling: 0, retained: 0, browserAlive: false, unknown: [], + }); + } finally { await fixture.cleanup(); } +}); + +test('close before an accepted open starts cannot leave a late Chromium launch behind', async () => { + const fixture = await runtimeFixture(); + try { + const open = assert.rejects(fixture.runtime.open('session', {}), /session_closing/); + await fixture.runtime.close('session'); + await open; + assert.equal(fixture.pages.length, 0); + assert.equal(fixture.closeRequests(), 0); + assert.deepEqual(fixture.runtime.snapshotActivity(), { + starting: 0, callbacks: 0, settling: 0, retained: 0, browserAlive: false, unknown: [], + }); + } finally { await fixture.cleanup(); } +}); + +test('popup page resolution concurrent with close is retained, closed, and drained before idle', async () => { + const fixture = await runtimeFixture(); + try { + await fixture.runtime.open('session', {}); + const popup = new FakePage(); + void popup.evaluation.promise.catch(() => {}); + const page = deferred(); + const target = { + opener: () => fixture.pages[0]!.targetValue, + type: () => 'page', page: () => page.promise, + } as unknown as Target; + fixture.browser.emit('targetcreated', target); + await Promise.resolve(); + const close = fixture.runtime.close('session'); + await until(() => fixture.closeRequests() === 1); + fixture.process.emit('close', 0); + await Promise.resolve(); + assert.ok(fixture.runtime.snapshotActivity().callbacks > 0); + assert.equal(fixture.runtime.snapshotActivity().retained, 1); + page.resolve(popup as unknown as Page); + await close; + await until(() => fixture.runtime.snapshotActivity().callbacks === 0); + assert.equal(popup.closed, true); + assert.equal(fixture.runtime.snapshotActivity().retained, 0); + assert.deepEqual(fixture.runtime.snapshotActivity().unknown, []); + } finally { await fixture.cleanup(); } +}); + +test('Chromium leader exit cannot prove idle while its process group still exists', async () => { + const fixture = await runtimeFixture(); + try { + await fixture.runtime.open('session', {}); + const close = fixture.runtime.close('session'); + await until(() => fixture.closeRequests() === 1); + fixture.setGroupAlive(true); + fixture.process.emit('close', 0); + await until(() => fixture.runtime.snapshotActivity().unknown.length > 0); + assert.equal(fixture.runtime.snapshotActivity().browserAlive, true); + assert.ok(fixture.runtime.snapshotActivity().callbacks > 0); + assert.equal(fixture.killRequests(), 0); + fixture.setGroupAlive(false); + await close; + await until(() => fixture.runtime.snapshotActivity().callbacks === 0); + assert.equal(fixture.runtime.snapshotActivity().browserAlive, false); + assert.deepEqual(fixture.runtime.snapshotActivity().unknown, []); + } finally { fixture.setGroupAlive(false); await fixture.cleanup(); } +}); + +test('failed launcher with no process handle remains unknown after the empty session is closed', async () => { + const fixture = await runtimeFixture(); + try { + const runtime = new BrowserRuntime({ + executablePath: process.execPath, profilePath: fixture.directory, emit: () => {}, + launch: async () => { throw new Error('launcher failed before publishing its process'); }, + }); + await assert.rejects(runtime.open('session', {}), /launcher failed/); + await runtime.close('session'); + assert.equal(runtime.snapshotActivity().retained, 0); + assert.deepEqual(runtime.snapshotActivity().unknown, ['browser_launch_unconfirmed']); + } finally { await fixture.cleanup(); } +}); + +test('run timeout retains the evaluation until late settlement or verified browser closure', async () => { + const fixture = await runtimeFixture(); + try { + await fixture.runtime.open('session', {}); + await assert.rejects(fixture.runtime.command('session', { action: 'run', code: 'pending', timeoutMs: 5 }), /run_timeout/); + assert.ok(fixture.runtime.snapshotActivity().callbacks > 0); + assert.deepEqual(fixture.runtime.snapshotActivity().unknown, ['browser_callback_unconfirmed']); + fixture.pages[0]!.evaluation.resolve({ result: { type: 'string', value: 'late result' } }); + await until(() => fixture.runtime.snapshotActivity().callbacks === 0); + assert.deepEqual(fixture.runtime.snapshotActivity().unknown, []); + assert.equal(fixture.runtime.snapshotActivity().browserAlive, true); + const close = fixture.runtime.close('session'); + await until(() => fixture.closeRequests() === 1); + fixture.process.emit('close', 0); + await close; + } finally { await fixture.cleanup(); } +}); + +test('failed page close with another live session stays unknown and does not close the other owner', async () => { + const fixture = await runtimeFixture(); + try { + await fixture.runtime.open('first', {}); + await fixture.runtime.open('second', {}); + fixture.pages[0]!.closeFailure = true; + await assert.rejects(fixture.runtime.close('first'), /page close failed/); + assert.equal(fixture.runtime.snapshotActivity().retained, 2); + assert.deepEqual(fixture.runtime.snapshotActivity().unknown, ['browser_operation_unconfirmed']); + assert.equal(fixture.closeRequests(), 0); + assert.equal(fixture.pages[1]!.closed, false); + fixture.pages[0]!.closeFailure = false; + await fixture.runtime.close('first'); + const close = fixture.runtime.close('second'); + await until(() => fixture.closeRequests() === 1); + fixture.process.emit('close', 0); + await close; + assert.deepEqual(fixture.runtime.snapshotActivity().unknown, []); + } finally { await fixture.cleanup(); } +}); + +test('isolated real sidecar: popup, worker, screencast, timed-out evaluation, close, reopen and idle', { + skip: process.env.GAJAE_BROWSER_LIFECYCLE_E2E !== '1', timeout: 60_000, +}, async () => { + const executable = process.env.GAJAE_BROWSER_E2E_EXECUTABLE; + assert.ok(executable && existsSync(executable), 'Set GAJAE_BROWSER_E2E_EXECUTABLE to an installed Chromium executable'); + const runtimePath = fileURLToPath(new URL('../../../dist-native/bun', import.meta.url)); + assert.ok(existsSync(runtimePath)); + const directory = await mkdtemp(join(tmpdir(), 'browser-lifecycle-e2e-')); + const previous = Object.fromEntries(['GAJAE_BROWSER_EXECUTABLE_PATH', 'GAJAE_BROWSER_PROFILE_DIR', 'GAJAE_BROWSER_CACHE_DIR'] + .map((key) => [key, process.env[key]])); + Object.assign(process.env, { + GAJAE_BROWSER_EXECUTABLE_PATH: executable, + GAJAE_BROWSER_PROFILE_DIR: join(directory, 'profile'), + GAJAE_BROWSER_CACHE_DIR: join(directory, 'cache'), + }); + const server = http.createServer((request, response) => { + if (request.url === '/worker.js') { + response.setHeader('Content-Type', 'application/javascript'); + response.end('self.addEventListener("install", () => self.skipWaiting()); self.addEventListener("activate", event => event.waitUntil(self.clients.claim())); self.addEventListener("fetch", () => {});'); + } else { + response.setHeader('Content-Type', 'text/html'); + response.end('Isolated lifecycle fixture

Browser lifecycle

'); + } + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + assert.ok(address && typeof address === 'object'); + const url = `http://127.0.0.1:${address.port}/`; + const client = new BrowserSidecarClient({ runtimePath }); + const observations: DesktopOwnerActivity[] = []; + let frames = 0; + client.subscribe((event) => { if (event.method === 'frame') frames++; }); + try { + await client.status(); + await until(() => isIdle(client.snapshotActivity())); + await client.open('lifecycle', { url }); + const pid = client.browserPid; + assert.ok(pid); + await client.command('lifecycle', { action: 'run', code: 'document.cookie = "lifecycle=preserved;max-age=3600"; window.open("/popup"); await navigator.serviceWorker.register("/worker.js"); await navigator.serviceWorker.ready; "ready"' }); + await until(() => client.cachedState('lifecycle').tabs.length === 2); + await client.subscribeFrames('lifecycle'); + await until(() => frames > 0); + await assert.rejects(client.command('lifecycle', { + action: 'run', code: 'await new Promise(resolve => setTimeout(() => resolve("late"), 200)); "late"', timeoutMs: 10, + }), /run_timeout/); + observations.push(client.snapshotActivity()); + assert.ok(observations.at(-1)!.unknown.includes('browser_callback_unconfirmed')); + await until(() => !client.snapshotActivity().unknown.length); + await assert.rejects(client.command('lifecycle', { + action: 'run', code: 'await new Promise(() => {});', timeoutMs: 10, + }), /run_timeout/); + await client.close('lifecycle'); + await until(() => isIdle(client.snapshotActivity()), 10_000); + assert.throws(() => process.kill(pid, 0), { code: 'ESRCH' }); + if (process.platform !== 'win32') assert.throws(() => process.kill(-pid, 0), { code: 'ESRCH' }); + const generation = client.getGeneration(); + for (let i = 0; i < 10; i++) assert.equal(isIdle(client.snapshotActivity()), true); + assert.equal(client.getGeneration(), generation); + await client.open('lifecycle', { url }); + assert.notEqual(client.browserPid, pid); + const cookie = await client.command('lifecycle', { action: 'run', code: 'document.cookie' }); + assert.match(JSON.stringify(cookie), /lifecycle=preserved/); + await client.close('lifecycle'); + await until(() => isIdle(client.snapshotActivity()), 10_000); + await client.shutdown(); + await until(() => isIdle(client.snapshotActivity())); + } finally { + await client.shutdown(); + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[key]; else process.env[key] = value; + } + await rm(directory, { recursive: true, force: true }); + } +}); diff --git a/server/modules/automation/browser-protocol.ts b/server/modules/automation/browser-protocol.ts index 84d7cfed..fe1ceca3 100644 --- a/server/modules/automation/browser-protocol.ts +++ b/server/modules/automation/browser-protocol.ts @@ -2,6 +2,34 @@ export type { BrowserTabState, BrowserSessionState } from '../../../shared/brows export const BROWSER_PROTOCOL_VERSION = 1 as const; +/** Child-owned observation, bound to one initialize handshake and request tail. */ +export type BrowserChildActivity = { + version: 1; + epoch: string; + revision: number; + requestSequence: number; + starting: number; + queued: number; + running: number; + callbacks: number; + settling: number; + retained: number; + browserAlive: boolean; + unknown: readonly string[]; +}; + +export function isBrowserChildActivity(value: unknown): value is BrowserChildActivity { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const activity = value as Record; + const identifier = (field: unknown): field is string => typeof field === 'string' + && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u.test(field); + return Object.keys(activity).length === 12 && activity.version === 1 && identifier(activity.epoch) + && ['revision', 'requestSequence', 'starting', 'queued', 'running', 'callbacks', 'settling', 'retained'] + .every((key) => Number.isSafeInteger(activity[key]) && (activity[key] as number) >= 0) + && typeof activity.browserAlive === 'boolean' + && Array.isArray(activity.unknown) && activity.unknown.length <= 32 && activity.unknown.every(identifier); +} + export type BrowserCommand = | { action: 'navigate'; url: string; waitUntil?: BrowserWaitUntil } | { action: 'back' } @@ -50,6 +78,8 @@ export type BrowserRequestFrame = { method: BrowserRequestMethod; sessionId?: string; payload: Record; + /** Monotonic within this parent-owned sidecar transport. */ + sequence?: number; }; export type BrowserResponseFrame = { diff --git a/server/modules/automation/browser-runtime.ts b/server/modules/automation/browser-runtime.ts new file mode 100644 index 00000000..e94ebf21 --- /dev/null +++ b/server/modules/automation/browser-runtime.ts @@ -0,0 +1,57 @@ +import type { BrowserRequestFrame } from './browser-protocol.js'; + +/** Actual NDJSON queue owner, including the realtime control-plane bypass. */ +export class BrowserRequestQueue { + private globalRequestQueue = Promise.resolve(); + private readonly sessionRequestQueues = new Map>(); + private queued = 0; + private running = 0; + private requestSequence = 0; + + constructor( + private readonly handle: (frame: BrowserRequestFrame) => Promise, + private readonly changed: () => void, + private readonly reportError: (error: unknown) => void, + ) {} + + snapshot() { + return { queued: this.queued, running: this.running, requestSequence: this.requestSequence }; + } + + enqueue(frame: BrowserRequestFrame): void { + if (frame.sequence !== undefined && (!Number.isSafeInteger(frame.sequence) || frame.sequence <= this.requestSequence)) { + throw new Error('invalid_sequence: Browser request sequence must increase.'); + } + this.requestSequence = frame.sequence ?? this.requestSequence + 1; + this.queued++; + this.changed(); + const dispatch = async () => { + this.queued--; + this.running++; + this.changed(); + try { await this.handle(frame); } + finally { + this.running--; + this.changed(); + } + }; + const realtimeInput = frame.method === 'browser.input' + && (frame.payload.input as { kind?: unknown } | undefined)?.kind !== 'viewport'; + if (frame.method === 'session.close' || frame.method === 'screencast.unsubscribe' + || realtimeInput || frame.method === 'shutdown') { + void dispatch().catch(this.reportError); + return; + } + if (frame.sessionId) { + const previous = this.sessionRequestQueues.get(frame.sessionId) ?? Promise.resolve(); + const next = previous.then(dispatch).catch(this.reportError); + this.sessionRequestQueues.set(frame.sessionId, next); + void next.finally(() => { + if (this.sessionRequestQueues.get(frame.sessionId!) === next) this.sessionRequestQueues.delete(frame.sessionId!); + this.changed(); + }); + } else { + this.globalRequestQueue = this.globalRequestQueue.then(dispatch).catch(this.reportError); + } + } +} diff --git a/server/modules/automation/browser-sidecar-client.ts b/server/modules/automation/browser-sidecar-client.ts index 81be6896..aa2e6ad5 100644 --- a/server/modules/automation/browser-sidecar-client.ts +++ b/server/modules/automation/browser-sidecar-client.ts @@ -5,12 +5,17 @@ import { homedir } from 'node:os'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; +import type { DesktopWorkAdmission } from '@/shared/interfaces.js'; + import { isBrowserSessionState } from '../../../shared/browserSessionState.js'; +import type { DesktopOwnerActivity } from '../../../shared/desktopUpdateProtocol.js'; import { BROWSER_PROTOCOL_VERSION, BrowserNdjsonDecoder, serializeBrowserFrame, + isBrowserChildActivity, + type BrowserChildActivity, type BrowserCommand, type BrowserEventFrame, type BrowserInput, @@ -26,6 +31,7 @@ type Pending = { resolve: (value: unknown) => void; reject: (error: Error) => void; timer: NodeJS.Timeout; + uncertain?: boolean; }; export type BrowserEventListener = (event: BrowserEventFrame) => void; @@ -35,6 +41,7 @@ type BrowserSidecarClientOptions = { sidecarPath?: string; recoveryAttempts?: number; recoveryDelayMs?: number; + desktopRestartAdmission?: DesktopWorkAdmission; }; type RecoverableSession = { @@ -60,13 +67,57 @@ export class BrowserSidecarClient { private decoder = new BrowserNdjsonDecoder(); private starting?: Promise; private recovering?: Promise; + private recoveryDeferred = false; private shuttingDown = false; private readonly pending = new Map(); private readonly listeners = new Set(); private readonly sessions = new Map(); private ownedBrowserPid?: number; + private admission?: DesktopWorkAdmission; + private readonly activityEpoch = randomUUID(); + private activityRevision = 0; + private dispatching = 0; + private closing = 0; + private transportUncertain = false; + private browserClosureUncertain = false; + private browserWorkDispatched = false; + private childRequestSequence = 0; + private childActivityEpoch?: string; + private childActivity?: BrowserChildActivity; + private childActivityInvalid = false; + + constructor(private readonly options: BrowserSidecarClientOptions = {}) { + this.admission = options.desktopRestartAdmission; + } - constructor(private readonly options: BrowserSidecarClientOptions = {}) {} + configureDesktopRestartAdmission(admission?: DesktopWorkAdmission): void { + this.admission = admission; + this.activityRevision++; + } + + getGeneration(): string { return `${this.activityEpoch}:${this.activityRevision}`; } + + /** Pure observation of the existing request/recovery/session owners. */ + snapshotActivity(): DesktopOwnerActivity { + const unknown: string[] = []; + if ([...this.pending.values()].some((request) => request.uncertain)) unknown.push('browser_request_unconfirmed'); + if (this.transportUncertain) unknown.push('browser_transport_unconfirmed'); + if (this.browserClosureUncertain) unknown.push('browser_closure_unconfirmed'); + const child = this.childActivity; + const current = child && child.epoch === this.childActivityEpoch && child.requestSequence === this.childRequestSequence; + if (this.childActivityInvalid || (this.childActivityEpoch && !current)) unknown.push('browser_child_activity_unconfirmed'); + if (this.browserWorkDispatched && this.sessions.size === 0 && !current) unknown.push('browser_child_quiescence_unconfirmed'); + if (current) unknown.push(...child.unknown); + return { + owner: 'browser', generation: this.getGeneration(), complete: unknown.length === 0, + starting: Number(Boolean(this.starting)) + (current ? child.starting : 0), + queued: this.dispatching + Number(this.recoveryDeferred) + (current ? child.queued : 0), + running: this.pending.size + (current ? child.running + child.callbacks : 0), + settling: this.closing + Number(Boolean(this.recovering)) + Number(this.shuttingDown && Boolean(this.child)) + + (current ? child.settling : 0), + approvals: 0, retained: this.sessions.size + (current ? child.retained + Number(child.browserAlive) : 0), unknown, + }; + } /** Pid of the Chromium process the sidecar currently owns, when known. */ get browserPid(): number | undefined { @@ -121,24 +172,30 @@ export class BrowserSidecarClient { async shutdown(): Promise { if (this.shuttingDown) return; this.shuttingDown = true; + this.activityRevision++; const child = this.child; - if (!child) { - this.sessions.clear(); - return; - } + if (!child) return; + this.closing++; + this.activityRevision++; try { - await this.request('shutdown', undefined, {}, 2_000); + // Shutdown is lifecycle-owned, not an idle query or a new producer. + await this.requestStarted('shutdown', undefined, {}, 2_000); } catch { this.killOwnedProcess(child); + } finally { + // A response/kill request is not process-exit evidence. fail(..., true) + // owns transport closure and clears requests only on the close event. + this.closing--; + this.activityRevision++; } - this.child = undefined; - this.sessions.clear(); } private async ensureStarted(): Promise { - if (this.child && this.child.exitCode === null) return; if (this.starting) return this.starting; if (this.shuttingDown) throw new Error('Browser automation is shutting down.'); + if (this.transportUncertain) throw new Error('Browser sidecar closure is unconfirmed.'); + if (this.child && this.child.exitCode === null) return; + this.activityRevision++; this.starting = (async () => { const compiled = !import.meta.url.endsWith('.ts'); const sidecarPath = this.options.sidecarPath @@ -160,15 +217,21 @@ export class BrowserSidecarClient { env, }); this.child = child; + this.childRequestSequence = 0; + this.childActivityEpoch = undefined; + this.childActivity = undefined; + this.childActivityInvalid = false; + this.activityRevision++; this.decoder = new BrowserNdjsonDecoder(); child.stdout.on('data', (chunk: Buffer) => this.handleData(child, chunk)); child.stderr.on('data', (chunk: Buffer) => logDiagnostic(chunk.toString())); child.stdin.on('error', (error) => this.fail(child, error)); child.on('error', (error) => this.fail(child, error)); - child.on('close', () => this.fail(child, new Error('Browser sidecar exited.'))); + child.on('close', () => this.fail(child, new Error('Browser sidecar exited.'), true)); await this.requestStarted('initialize', undefined, {}, 10_000); })().finally(() => { this.starting = undefined; + this.activityRevision++; }); return this.starting; } @@ -180,9 +243,22 @@ export class BrowserSidecarClient { timeoutMs = 30_000, signal?: AbortSignal, ): Promise { - await this.ensureStarted(); - if (this.recovering && method !== 'status' && method !== 'shutdown') await this.recovering; - return this.requestStarted(method, sessionId, payload, timeoutMs, signal); + // Includes lazy status/state, preview subscription, open/command/input and + // cleanup requests: none of these is a cached read. + const release = this.admission?.enter(`automation.browser.${method}`); + this.dispatching++; + this.activityRevision++; + try { + if (signal?.aborted) throw new Error('Browser request was cancelled.'); + if (this.recoveryDeferred) this.startRecovery(this.recoverySnapshots()); + await this.ensureStarted(); + if (this.recovering && method !== 'status' && method !== 'shutdown') await this.recovering; + return await this.requestStarted(method, sessionId, payload, timeoutMs, signal); + } finally { + this.dispatching--; + this.activityRevision++; + release?.(); + } } private requestStarted( @@ -194,25 +270,30 @@ export class BrowserSidecarClient { ): Promise { const child = this.child; if (!child || child.exitCode !== null) return Promise.reject(new Error('Browser sidecar is unavailable.')); + if (signal?.aborted) return Promise.reject(new Error('Browser request was cancelled.')); const id = `browser-${randomUUID()}`; const frame: BrowserRequestFrame = { protocolVersion: BROWSER_PROTOCOL_VERSION, kind: 'request', id, method, + sequence: ++this.childRequestSequence, ...(sessionId ? { sessionId } : {}), payload, }; return new Promise((resolve, reject) => { const timer = setTimeout(() => { - this.pending.delete(id); - reject(new Error('Browser sidecar request timed out.')); + abandon(new Error('Browser sidecar request timed out.')); }, timeoutMs); - const onAbort = () => { + const abandon = (error: Error) => { + const pending = this.pending.get(id); + if (!pending || pending.uncertain) return; clearTimeout(timer); - this.pending.delete(id); - reject(new Error('Browser request was cancelled.')); + pending.uncertain = true; + this.activityRevision++; + pending.reject(error); }; + const onAbort = () => abandon(new Error('Browser request was cancelled.')); signal?.addEventListener('abort', onAbort, { once: true }); this.pending.set(id, { method, @@ -227,7 +308,10 @@ export class BrowserSidecarClient { }, timer, }); - child.stdin.write(serializeBrowserFrame(frame)); + if (method === 'session.open') this.browserWorkDispatched = true; + this.activityRevision++; + try { child.stdin.write(serializeBrowserFrame(frame)); } + catch (error) { abandon(error instanceof Error ? error : new Error('Browser request write failed.')); } }); } @@ -237,7 +321,20 @@ export class BrowserSidecarClient { for (const frame of this.decoder.push(chunk)) { if (frame.kind === 'response') this.settle(frame); else if (frame.kind === 'event') { - if (frame.method === 'async' && frame.payload.type === 'browser.process') { + if (frame.method === 'async' && frame.payload.type === 'browser.runtime.activity') { + const activity = frame.payload.activity; + if (!isBrowserChildActivity(activity) + || (this.childActivityEpoch && activity.epoch !== this.childActivityEpoch) + || (this.childActivity && (activity.epoch !== this.childActivity.epoch || activity.revision <= this.childActivity.revision))) { + this.childActivityInvalid = true; + } else { + this.childActivity = { ...activity, unknown: [...activity.unknown] }; + this.childActivityInvalid = false; + } + this.activityRevision++; + } else if (frame.method === 'async' && frame.payload.type === 'browser.process') { + this.activityRevision++; + if (frame.payload.pid === null && this.ownedBrowserPid && !this.childActivityEpoch) this.browserClosureUncertain = true; this.ownedBrowserPid = typeof frame.payload.pid === 'number' && Number.isSafeInteger(frame.payload.pid) && frame.payload.pid > 0 @@ -256,42 +353,66 @@ export class BrowserSidecarClient { private settle(frame: BrowserResponseFrame): void { const pending = this.pending.get(frame.id); if (!pending) return; - this.pending.delete(frame.id); - clearTimeout(pending.timer); const responseSessionId = 'sessionId' in frame ? frame.sessionId : undefined; if (pending.method !== frame.method || pending.sessionId !== responseSessionId) { + clearTimeout(pending.timer); + pending.uncertain = true; + this.activityRevision++; pending.reject(new Error('Browser sidecar returned a mismatched response.')); return; } + this.pending.delete(frame.id); + clearTimeout(pending.timer); + this.activityRevision++; if (!frame.ok) pending.reject(new Error(`${frame.error?.code ?? 'browser_error'}: ${frame.error?.message ?? 'Browser operation failed.'}`)); else { + if (frame.method === 'initialize' && frame.result && typeof frame.result === 'object') { + const result = frame.result as Record; + if (result.activityProtocol === 1 && typeof result.activityEpoch === 'string' + && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u.test(result.activityEpoch)) { + this.childActivityEpoch = result.activityEpoch; + } + } this.remember(frame.method, responseSessionId, frame.result); pending.resolve(frame.result); } } - private fail(child: ChildProcessWithoutNullStreams, error: Error): void { + private fail(child: ChildProcessWithoutNullStreams, error: Error, closed = false): void { if (child !== this.child) return; - this.child = undefined; + this.transportUncertain = !closed; + this.activityRevision++; logDiagnostic(error.message); for (const pending of this.pending.values()) { clearTimeout(pending.timer); + pending.uncertain = true; pending.reject(new Error('Browser sidecar disconnected.')); } - this.pending.clear(); this.killOwnedProcess(child, this.ownedBrowserPid); + if (!closed) return; + // The sidecar and Chromium have separate process groups. Sidecar exit + // alone must not turn a lost browser (or a half-finished launch) into idle. + const provedBrowserExit = this.childActivity && this.childActivity.epoch === this.childActivityEpoch + && this.childActivity.requestSequence === this.childRequestSequence && !this.childActivity.browserAlive + && !this.childActivity.starting && !this.childActivity.unknown.length && !this.childActivityInvalid; + if (!provedBrowserExit && (this.ownedBrowserPid || this.browserWorkDispatched)) { + this.browserClosureUncertain = true; + } + this.child = undefined; + this.pending.clear(); this.ownedBrowserPid = undefined; + if (provedBrowserExit && !this.browserClosureUncertain) { + // The child transport is now closed too: no queued JS callback can + // launch a new browser after its final, sequence-bound absence proof. + this.childActivity = undefined; + this.childActivityEpoch = undefined; + this.childActivityInvalid = false; + this.browserWorkDispatched = false; + } if (this.shuttingDown || this.sessions.size === 0) return; - const snapshots = [...this.sessions.entries()].map(([sessionId, session]) => ({ - sessionId, - subscribed: session.subscribed, - state: { - sessionId, - activeTabId: session.state.activeTabId, - tabs: session.state.tabs.map((tab) => ({ ...tab })), - }, - })); + this.recoveryDeferred = true; + const snapshots = this.recoverySnapshots(); for (const snapshot of snapshots) { this.emit({ protocolVersion: BROWSER_PROTOCOL_VERSION, @@ -306,14 +427,18 @@ export class BrowserSidecarClient { method: 'state', sessionId: snapshot.sessionId, payload: { sessionId: snapshot.sessionId, activeTabId: null, tabs: [] }, - }); + }, false); } this.startRecovery(snapshots); } private remember(method: BrowserRequestMethod, sessionId: string | undefined, value: unknown): void { if (!sessionId) return; + this.activityRevision++; if (method === 'session.close') { + // Legacy children close best-effort. Upgraded children supply independent + // epoch/sequence-bound activity; their close reply alone is not idle proof. + if (!this.childActivityEpoch && this.browserWorkDispatched && this.sessions.has(sessionId)) this.browserClosureUncertain = true; this.sessions.delete(sessionId); return; } @@ -325,7 +450,11 @@ export class BrowserSidecarClient { if (method === 'screencast.unsubscribe') existing.subscribed = false; const state = this.browserState(value, sessionId); if (state) existing.state = state; - this.sessions.set(sessionId, existing); + // Unsubscribe/state for an unknown session must not manufacture retained + // ownership. Empty *existing* sessions stay retained until explicit close. + if (this.sessions.has(sessionId) || method === 'session.open' || method === 'screencast.subscribe' || state?.tabs.length) { + this.sessions.set(sessionId, existing); + } } private browserState(value: unknown, sessionId: string): BrowserSessionState | null { @@ -333,19 +462,39 @@ export class BrowserSidecarClient { return { sessionId, activeTabId: value.activeTabId, tabs: value.tabs.map((tab) => ({ ...tab })) }; } - private emit(event: BrowserEventFrame): void { - if (event.method === 'state' && event.sessionId) this.remember('session.state', event.sessionId, event.payload); + private emit(event: BrowserEventFrame, remember = true): void { + // Delayed title/history callbacks from a closed session are replayable + // metadata, not evidence that a new session was opened. + if (remember && event.method === 'state' && event.sessionId && (this.sessions.has(event.sessionId) + || [...this.pending.values()].some((pending) => pending.sessionId === event.sessionId + && (pending.method === 'session.open' || pending.method === 'screencast.subscribe')))) { + this.remember('session.state', event.sessionId, event.payload); + } for (const listener of this.listeners) { try { listener(event); } catch { /* One consumer cannot break recovery fan-out. */ } } } + private recoverySnapshots() { + return [...this.sessions.entries()].map(([sessionId, session]) => ({ + sessionId, subscribed: session.subscribed, + state: { sessionId, activeTabId: session.state.activeTabId, tabs: session.state.tabs.map((tab) => ({ ...tab })) }, + })); + } + private startRecovery( snapshots: Array<{ sessionId: string; state: BrowserSessionState; subscribed: boolean }>, ): void { if (this.recovering || this.shuttingDown) return; + let release: (() => void) | undefined; + try { release = this.admission?.enter('automation.browser.recovery'); } + catch { return; } // Retained recovery snapshots remain busy; no hidden spawn. + this.recoveryDeferred = false; + this.activityRevision++; this.recovering = this.recoverSessions(snapshots).finally(() => { this.recovering = undefined; + this.activityRevision++; + release?.(); }); } diff --git a/server/modules/automation/browser-sidecar.ts b/server/modules/automation/browser-sidecar.ts index 20d2de78..fe452441 100644 --- a/server/modules/automation/browser-sidecar.ts +++ b/server/modules/automation/browser-sidecar.ts @@ -1,5 +1,6 @@ #!/usr/bin/env bun /// +import type { ChildProcess } from 'node:child_process'; import { randomUUID } from 'node:crypto'; import { existsSync } from 'node:fs'; import { mkdir } from 'node:fs/promises'; @@ -41,6 +42,7 @@ import { safeSessionId, serializeBrowserFrame, type BrowserCommand, + type BrowserChildActivity, type BrowserEventFrame, type BrowserInput, type BrowserRequestFrame, @@ -50,6 +52,7 @@ import { type BrowserWaitUntil, } from './browser-protocol.js'; import { normalizeAutomationUrl } from './automation-url.js'; +import { BrowserRequestQueue } from './browser-runtime.js'; const PUPPETEER_KEY_NAMES = new Set([ 'Backspace', 'Tab', 'Enter', 'Escape', 'Shift', 'Control', 'Alt', 'Meta', @@ -85,6 +88,20 @@ type Session = { tabs: Map; activeTabId: string | null; subscribed: boolean; + closeRequested: boolean; + closing?: Promise<{ closed: boolean }>; + tasks: Set; +}; + +type BackgroundTask = { promise: Promise; uncertain: boolean }; + +type BrowserRuntimeOptions = { + changed?: () => void; + emit?: typeof emit; + executablePath?: string; + profilePath?: string; + launch?: BrowserLaunchDependencies['launch']; + processGroupExists?: (pid: number) => boolean; }; type AxNode = { @@ -141,6 +158,7 @@ type BrowserLaunchDependencies = { explicitExecutable?: boolean; launch?: (options: LaunchOptions) => Promise; systemExecutable?: () => string; + onLaunchFailure?: () => void; }; export async function launchBrowserWithLinuxFallback( @@ -151,11 +169,13 @@ export async function launchBrowserWithLinuxFallback( explicitExecutable = Boolean(process.env.GAJAE_BROWSER_EXECUTABLE_PATH), launch = launchOptions => puppeteer.launch(launchOptions), systemExecutable = () => computeSystemExecutablePath({ browser: BrowserBinary.CHROME, channel: ChromeReleaseChannel.STABLE }), + onLaunchFailure, }: BrowserLaunchDependencies = {}, ): Promise { try { return await launch({ ...options, executablePath }); } catch (error) { + onLaunchFailure?.(); const message = error instanceof Error ? error.message : String(error); const sandboxFailure = /No usable sandbox!|SUID sandbox helper binary[^\n]*not configured correctly|Failed to move to new namespace[^\n]*Operation not permitted/i.test(message); if (platform !== 'linux' || explicitExecutable || !sandboxFailure) throw error; @@ -169,6 +189,7 @@ export async function launchBrowserWithLinuxFallback( process.stderr.write('[Browser] Managed Chromium sandbox unavailable; trying installed stable Chrome with the app profile.\n'); try { return await launch({ ...options, executablePath: fallback }); } catch (fallbackError) { + onLaunchFailure?.(); throw new Error(`Installed Chrome fallback failed: ${fallbackError instanceof Error ? fallbackError.message : String(fallbackError)}`, { cause: error }); } } @@ -206,7 +227,7 @@ function waitUntil(value: unknown): BrowserWaitUntil { : 'domcontentloaded'; } -class BrowserRuntime { +export class BrowserRuntime { private browser?: Browser; private browserCdp?: CDPSession; private launchState: 'idle' | 'starting' | 'ready' | 'error' = 'idle'; @@ -214,8 +235,74 @@ class BrowserRuntime { private launchPromise?: Promise; private buildId: string = PUPPETEER_REVISIONS.chrome; private readonly sessions = new Map(); - private readonly ownerByTarget = new WeakMap(); + private readonly ownerByTarget = new WeakMap(); private readonly ownerByFrameId = new Map(); + private readonly background = new Set(); + private browserProcess?: ChildProcess; + private browserExit?: Promise; + private browserExited = true; + private closingBrowser?: Promise; + private launchUnconfirmed = false; + private downloadUnconfirmed = false; + private processTreeUnobservable = false; + private browserUnconfirmed = false; + + constructor(private readonly options: BrowserRuntimeOptions = {}) {} + + private changed(): void { this.options.changed?.(); } + private emit: typeof emit = (...args) => (this.options.emit ?? emit)(...args); + + /** No CDP requests, lazy launch, timers, cleanup, or health reset. */ + snapshotActivity() { + const unknown: string[] = []; + if (this.launchUnconfirmed) unknown.push('browser_launch_unconfirmed'); + if (this.downloadUnconfirmed) unknown.push('browser_download_unconfirmed'); + if (this.processTreeUnobservable) unknown.push('browser_process_tree_unobservable'); + if (this.browserUnconfirmed) unknown.push('browser_operation_unconfirmed'); + if ([...this.background].some((task) => task.uncertain)) unknown.push('browser_callback_unconfirmed'); + return { + starting: Number(Boolean(this.launchPromise)), callbacks: this.background.size, + settling: Number(Boolean(this.closingBrowser)) + [...this.sessions.values()].filter((session) => session.closing).length, + retained: this.sessions.size, browserAlive: !this.browserExited, unknown, + }; + } + + private track(session: Session | undefined, action: () => Promise, uncertainOnFailure = false): BackgroundTask { + const task: BackgroundTask = { promise: Promise.resolve().then(action), uncertain: false }; + this.background.add(task); + session?.tasks.add(task); + task.promise = task.promise.catch((error) => { + // A local CDP failure is not proof that remote execution stopped. Only a + // confirmed process exit clears this generation's remaining uncertainty. + if (uncertainOnFailure && !this.browserExited) this.browserUnconfirmed = true; + throw error; + }).finally(() => { + this.background.delete(task); + session?.tasks.delete(task); + this.changed(); + }); + // Detached callbacks still have an owner even when no caller awaits them. + void task.promise.catch(() => {}); + this.changed(); + return task; + } + + private async drain(session: Session): Promise { + while (session.tasks.size) await Promise.allSettled([...session.tasks].map((task) => task.promise)); + } + + private requireOpen(session: Session): void { + if (session.closeRequested || this.sessions.get(session.id) !== session) { + throw new Error('session_closing: Browser session is closing.'); + } + } + + private processGroupExists(pid: number): boolean { + if (this.options.processGroupExists) return this.options.processGroupExists(pid); + if (process.platform === 'win32') return false; // Browser product support is macOS/Linux. + try { process.kill(-pid, 0); return true; } + catch (error) { return (error as NodeJS.ErrnoException).code !== 'ESRCH'; } + } async status(): Promise> { const installed = await this.installedBrowser(); @@ -228,33 +315,67 @@ class BrowserRuntime { } async open(sessionId: string, payload: Record): Promise { - const browser = await this.ensureBrowser(payload.allowDownload === true, sessionId); const session = this.session(sessionId); - let tab = session.activeTabId ? session.tabs.get(session.activeTabId) : undefined; - if (!tab || tab.page.isClosed()) { - const page = await browser.newPage(); - tab = await this.registerPage(session, page); - } - const url = typeof payload.url === 'string' ? normalizeAutomationUrl(payload.url) : undefined; - if (url && tab.page.url() !== url) { - tab.loading = true; - this.emitState(session); - await tab.page.goto(url, { waitUntil: waitUntil(payload.waitUntil), timeout: 30_000 }); - } - session.activeTabId = tab.id; - if (session.subscribed) await this.ensureScreencast(session); - return this.state(session); + return this.track(session, async () => { + this.requireOpen(session); + const browser = await this.ensureBrowser(payload.allowDownload === true, sessionId); + this.requireOpen(session); + let tab = session.activeTabId ? session.tabs.get(session.activeTabId) : undefined; + if (!tab || tab.page.isClosed()) { + const page = await browser.newPage(); + tab = await this.registerPage(session, page); + } + const url = typeof payload.url === 'string' ? normalizeAutomationUrl(payload.url) : undefined; + if (url && tab.page.url() !== url) { + tab.loading = true; + this.emitState(session); + await tab.page.goto(url, { waitUntil: waitUntil(payload.waitUntil), timeout: 30_000 }); + } + session.activeTabId = tab.id; + if (session.subscribed) await this.ensureScreencast(session); + return this.state(session); + }).promise; } async close(sessionId: string): Promise<{ closed: boolean }> { const session = this.sessions.get(sessionId); if (!session) return { closed: false }; - this.sessions.delete(sessionId); - await Promise.all([...session.tabs.values()].map(async (tab) => { - await this.stopScreencast(tab); - if (!tab.page.isClosed()) await tab.page.close().catch(() => {}); - })); - return { closed: true }; + if (session.closing) return session.closing; + session.closeRequested = true; + session.subscribed = false; + session.closing = (async () => { + const closed = await Promise.allSettled([...session.tabs.values()].map(async (tab) => { + tab.screencasting = false; + if (!tab.page.isClosed()) await tab.page.close(); + })); + if ([...this.sessions.values()].every((owner) => owner.closeRequested)) { + if (this.launchPromise) await this.launchPromise.catch(() => {}); + // Recheck after launch: a different session may have arrived meanwhile. + if ([...this.sessions.values()].every((owner) => owner.closeRequested)) await this.closeBrowser(); + } + if (!this.browserExited) { + const failure = closed.find((result) => result.status === 'rejected'); + if (failure?.status === 'rejected') throw failure.reason; + } + // Do not wait for an unresolved evaluation before closing its browser. + // Actual target/process closure settles CDP and callback tails, not the + // caller's deadline. Page creation already in flight remains owned here. + await this.drain(session); + if ([...session.tabs.values()].some((tab) => !tab.page.isClosed())) { + throw new Error('browser_close_unconfirmed: A session page is still live.'); + } + this.sessions.delete(sessionId); + this.emit('state', { sessionId, activeTabId: null, tabs: [] }, sessionId); + return { closed: true }; + })().catch((error) => { + if (!this.browserExited) this.browserUnconfirmed = true; + throw error; + }).finally(() => { + session.closing = undefined; + this.changed(); + }); + this.changed(); + return session.closing; } stateFor(sessionId: string): BrowserSessionState { @@ -266,15 +387,19 @@ class BrowserRuntime { async subscribe(sessionId: string): Promise { const session = this.session(sessionId); session.subscribed = true; - await this.ensureScreencast(session); - return this.state(session); + this.changed(); + return this.track(session, async () => { + await this.ensureScreencast(session); + return this.state(session); + }).promise; } async unsubscribe(sessionId: string): Promise<{ subscribed: false }> { const session = this.sessions.get(sessionId); if (session) { session.subscribed = false; - await Promise.all([...session.tabs.values()].map((tab) => this.stopScreencast(tab))); + this.changed(); + await this.track(session, () => this.ensureScreencast(session)).promise; } return { subscribed: false }; } @@ -282,187 +407,201 @@ class BrowserRuntime { async command(sessionId: string, command: BrowserCommand): Promise { const session = this.sessions.get(sessionId); if (!session) throw new Error('session_not_found: Open the browser session first.'); - if (command.action === 'selectTab') { - if (!session.tabs.has(command.tabId)) throw new Error('tab_not_found: Browser tab was not found.'); - session.activeTabId = command.tabId; - await this.ensureScreencast(session); - this.emitState(session); - return this.state(session); - } - if (command.action === 'newTab') { - const browser = await this.ensureBrowser(false, sessionId); - const page = await browser.newPage(); - const created = await this.registerPage(session, page); - if (command.url) { - await page.goto(normalizeAutomationUrl(command.url), { waitUntil: 'domcontentloaded', timeout: 30_000 }); + this.requireOpen(session); + return this.track(session, async () => { + this.requireOpen(session); + if (command.action === 'selectTab') { + if (!session.tabs.has(command.tabId)) throw new Error('tab_not_found: Browser tab was not found.'); + session.activeTabId = command.tabId; + await this.ensureScreencast(session); + this.emitState(session); + return this.state(session); } - session.activeTabId = created.id; - await this.ensureScreencast(session); - this.emitState(session); - return this.state(session); - } - if (command.action === 'closeTab') { - const closing = command.tabId ? session.tabs.get(command.tabId) : this.activeTab(session); - if (!closing) throw new Error('tab_not_found: Browser tab was not found.'); - await this.stopScreencast(closing); - if (!closing.page.isClosed()) await closing.page.close(); - await this.ensureScreencast(session); - this.emitState(session); - return this.state(session); - } - const tab = this.activeTab(session); - const page = tab.page; - - switch (command.action) { - case 'navigate': - tab.loading = true; + if (command.action === 'newTab') { + const browser = await this.ensureBrowser(false, sessionId); + this.requireOpen(session); + const page = await browser.newPage(); + const created = await this.registerPage(session, page); + if (command.url) { + await page.goto(normalizeAutomationUrl(command.url), { waitUntil: 'domcontentloaded', timeout: 30_000 }); + } + session.activeTabId = created.id; + await this.ensureScreencast(session); this.emitState(session); - await page.goto(normalizeAutomationUrl(command.url), { waitUntil: waitUntil(command.waitUntil), timeout: 30_000 }); - break; - case 'back': - tab.loading = true; - await page.goBack({ waitUntil: 'domcontentloaded', timeout: 30_000 }); - break; - case 'forward': - tab.loading = true; - await page.goForward({ waitUntil: 'domcontentloaded', timeout: 30_000 }); - break; - case 'reload': - tab.loading = true; - await page.reload({ waitUntil: 'domcontentloaded', timeout: 30_000 }); - break; - case 'click': - await this.click(tab, command); - break; - case 'type': - await this.focus(tab, command); - await page.keyboard.type(command.text); - break; - case 'fill': - await this.focus(tab, command); - await page.keyboard.down(process.platform === 'darwin' ? 'Meta' : 'Control'); - await page.keyboard.press('A'); - await page.keyboard.up(process.platform === 'darwin' ? 'Meta' : 'Control'); - await page.keyboard.type(command.text); - break; - case 'select': - if (command.selector) await page.select(command.selector, ...command.values); - else await this.callOnRef(tab, command.ref, `function(...values) { + return this.state(session); + } + if (command.action === 'closeTab') { + const closing = command.tabId ? session.tabs.get(command.tabId) : this.activeTab(session); + if (!closing) throw new Error('tab_not_found: Browser tab was not found.'); + await this.stopScreencast(closing); + if (!closing.page.isClosed()) await closing.page.close(); + await this.ensureScreencast(session); + this.emitState(session); + return this.state(session); + } + const tab = this.activeTab(session); + const page = tab.page; + + switch (command.action) { + case 'navigate': + tab.loading = true; + this.emitState(session); + await page.goto(normalizeAutomationUrl(command.url), { waitUntil: waitUntil(command.waitUntil), timeout: 30_000 }); + break; + case 'back': + tab.loading = true; + await page.goBack({ waitUntil: 'domcontentloaded', timeout: 30_000 }); + break; + case 'forward': + tab.loading = true; + await page.goForward({ waitUntil: 'domcontentloaded', timeout: 30_000 }); + break; + case 'reload': + tab.loading = true; + await page.reload({ waitUntil: 'domcontentloaded', timeout: 30_000 }); + break; + case 'click': + await this.click(tab, command); + break; + case 'type': + await this.focus(tab, command); + await page.keyboard.type(command.text); + break; + case 'fill': + await this.focus(tab, command); + await page.keyboard.down(process.platform === 'darwin' ? 'Meta' : 'Control'); + await page.keyboard.press('A'); + await page.keyboard.up(process.platform === 'darwin' ? 'Meta' : 'Control'); + await page.keyboard.type(command.text); + break; + case 'select': + if (command.selector) await page.select(command.selector, ...command.values); + else await this.callOnRef(tab, command.ref, `function(...values) { const options = Array.from(this.options || []); for (const option of options) option.selected = values.includes(option.value); this.dispatchEvent(new Event('input', { bubbles: true })); this.dispatchEvent(new Event('change', { bubbles: true })); return Array.from(this.selectedOptions || []).map(option => option.value); }`, command.values); - break; - case 'press': - await page.keyboard.press(command.key as KeyInput); - break; - case 'scroll': - await page.mouse.wheel({ deltaX: command.dx ?? 0, deltaY: command.dy ?? 600 }); - break; - case 'wait': - if (command.selector) await page.waitForSelector(command.selector, { timeout: command.ms ?? 10_000 }); - else if (command.text) await page.waitForFunction((text) => document.body?.innerText.includes(text), { timeout: command.ms ?? 10_000 }, command.text); - else await new Promise((resolve) => setTimeout(resolve, Math.min(Math.max(command.ms ?? 500, 0), 30_000))); - break; - case 'observe': - return this.observe(tab, command.includeAll === true); - case 'extract': { - const format = command.format ?? 'text'; - const result = await page.evaluate(({ selector, format }) => { - const element = selector ? document.querySelector(selector) : document.body; - if (!element) return null; - return format === 'html' ? element.outerHTML : (element.textContent ?? ''); - }, { selector: command.selector, format }); - return { value: typeof result === 'string' ? result.slice(0, MAX_RESULT_TEXT) : result }; + break; + case 'press': + await page.keyboard.press(command.key as KeyInput); + break; + case 'scroll': + await page.mouse.wheel({ deltaX: command.dx ?? 0, deltaY: command.dy ?? 600 }); + break; + case 'wait': + if (command.selector) await page.waitForSelector(command.selector, { timeout: command.ms ?? 10_000 }); + else if (command.text) await page.waitForFunction((text) => document.body?.innerText.includes(text), { timeout: command.ms ?? 10_000 }, command.text); + else await new Promise((resolve) => setTimeout(resolve, Math.min(Math.max(command.ms ?? 500, 0), 30_000))); + break; + case 'observe': + return this.observe(tab, command.includeAll === true); + case 'extract': { + const format = command.format ?? 'text'; + const result = await page.evaluate(({ selector, format }) => { + const element = selector ? document.querySelector(selector) : document.body; + if (!element) return null; + return format === 'html' ? element.outerHTML : (element.textContent ?? ''); + }, { selector: command.selector, format }); + return { value: typeof result === 'string' ? result.slice(0, MAX_RESULT_TEXT) : result }; + } + case 'screenshot': { + const data = await page.screenshot({ type: 'jpeg', quality: 75, encoding: 'base64' }); + return { mimeType: 'image/jpeg', data }; + } + case 'run': { + if (Buffer.byteLength(command.code) > MAX_RUN_CODE_BYTES) throw new Error('run_too_large: Browser script is too large.'); + const timeoutMs = Math.min(Math.max(command.timeoutMs ?? 30_000, 1), 300_000); + let timeout: ReturnType | undefined; + const evaluation = this.track(session, () => this.cdp(tab).then((cdp) => evaluateBrowserScript(cdp, command.code)), true); + const value = await Promise.race([ + evaluation.promise, + new Promise((_, reject) => { + timeout = setTimeout(() => { + evaluation.uncertain = true; + this.changed(); + reject(new Error('run_timeout: Browser script timed out.')); + }, timeoutMs); + }), + ]).finally(() => { + if (timeout) clearTimeout(timeout); + }); + const serialized = JSON.stringify(value); + return { value: serialized && serialized.length > MAX_RESULT_TEXT ? `${serialized.slice(0, MAX_RESULT_TEXT)}…` : value }; + } } - case 'screenshot': { - const data = await page.screenshot({ type: 'jpeg', quality: 75, encoding: 'base64' }); - return { mimeType: 'image/jpeg', data }; - } - case 'run': { - if (Buffer.byteLength(command.code) > MAX_RUN_CODE_BYTES) throw new Error('run_too_large: Browser script is too large.'); - const timeoutMs = Math.min(Math.max(command.timeoutMs ?? 30_000, 1), 300_000); - let timeout: ReturnType | undefined; - const value = await Promise.race([ - this.cdp(tab).then((cdp) => evaluateBrowserScript(cdp, command.code)), - new Promise((_, reject) => { - timeout = setTimeout(() => reject(new Error('run_timeout: Browser script timed out.')), timeoutMs); - }), - ]).finally(() => { - if (timeout) clearTimeout(timeout); - }); - const serialized = JSON.stringify(value); - return { value: serialized && serialized.length > MAX_RESULT_TEXT ? `${serialized.slice(0, MAX_RESULT_TEXT)}…` : value }; - } - } - this.emitState(session); - return this.state(session); + this.emitState(session); + return this.state(session); + }).promise; } async input(sessionId: string, input: BrowserInput): Promise<{ accepted: true }> { const session = this.sessions.get(sessionId); if (!session) throw new Error('session_not_found: Open the browser session first.'); - const tab = this.activeTab(session); - const cdp = await this.cdp(tab); - if (input.kind === 'viewport') { - const viewport = normalizeBrowserViewport(input.width, input.height); - if (!viewport) throw new Error('invalid_viewport: Browser viewport dimensions must be positive finite numbers.'); - if (viewport.width !== tab.viewport.width || viewport.height !== tab.viewport.height) { - const resumeScreencast = tab.screencasting && session.subscribed && session.activeTabId === tab.id; - if (resumeScreencast) await this.stopScreencast(tab); - tab.viewport = viewport; - await tab.page.setViewport({ ...viewport, deviceScaleFactor: 1 }); - if (resumeScreencast) await this.startScreencast(session, tab); - } - } else if (input.kind === 'mouse') { - await cdp.send('Input.dispatchMouseEvent', { - type: input.event === 'move' ? 'mouseMoved' : input.event === 'down' ? 'mousePressed' : 'mouseReleased', - x: input.x, - y: input.y, - button: input.button ?? 'left', - clickCount: input.clickCount ?? 1, - }); - } else if (input.kind === 'wheel') { - await cdp.send('Input.dispatchMouseEvent', { - type: 'mouseWheel', x: input.x, y: input.y, deltaX: input.deltaX, deltaY: input.deltaY, - }); - } else if (input.kind === 'text') { - await cdp.send('Input.insertText', { text: input.text }); - } else { - const keyInput = toPuppeteerKeyInput(input.key, input.code); - if (keyInput) { - if (input.event === 'down') await tab.page.keyboard.down(keyInput as KeyInput); - else await tab.page.keyboard.up(keyInput as KeyInput); - } else { - await cdp.send('Input.dispatchKeyEvent', { - type: input.event === 'down' ? 'keyDown' : 'keyUp', - key: input.key, - code: input.code ?? input.key, - modifiers: input.modifiers ?? 0, + this.requireOpen(session); + return this.track(session, async () => { + this.requireOpen(session); + const tab = this.activeTab(session); + const cdp = await this.cdp(tab); + if (input.kind === 'viewport') { + const viewport = normalizeBrowserViewport(input.width, input.height); + if (!viewport) throw new Error('invalid_viewport: Browser viewport dimensions must be positive finite numbers.'); + if (viewport.width !== tab.viewport.width || viewport.height !== tab.viewport.height) { + const resumeScreencast = tab.screencasting && session.subscribed && session.activeTabId === tab.id; + if (resumeScreencast) await this.stopScreencast(tab); + tab.viewport = viewport; + await tab.page.setViewport({ ...viewport, deviceScaleFactor: 1 }); + if (resumeScreencast) await this.startScreencast(session, tab); + } + } else if (input.kind === 'mouse') { + await cdp.send('Input.dispatchMouseEvent', { + type: input.event === 'move' ? 'mouseMoved' : input.event === 'down' ? 'mousePressed' : 'mouseReleased', + x: input.x, + y: input.y, + button: input.button ?? 'left', + clickCount: input.clickCount ?? 1, }); + } else if (input.kind === 'wheel') { + await cdp.send('Input.dispatchMouseEvent', { + type: 'mouseWheel', x: input.x, y: input.y, deltaX: input.deltaX, deltaY: input.deltaY, + }); + } else if (input.kind === 'text') { + await cdp.send('Input.insertText', { text: input.text }); + } else { + const keyInput = toPuppeteerKeyInput(input.key, input.code); + if (keyInput) { + if (input.event === 'down') await tab.page.keyboard.down(keyInput as KeyInput); + else await tab.page.keyboard.up(keyInput as KeyInput); + } else { + await cdp.send('Input.dispatchKeyEvent', { + type: input.event === 'down' ? 'keyDown' : 'keyUp', + key: input.key, + code: input.code ?? input.key, + modifiers: input.modifiers ?? 0, + }); + } } - } - return { accepted: true }; + return { accepted: true as const }; + }).promise; } async shutdown(): Promise { for (const id of [...this.sessions.keys()]) await this.close(id); - await this.browser?.close().catch(() => {}); - this.browser = undefined; - this.browserCdp = undefined; - this.ownerByFrameId.clear(); - this.launchState = 'idle'; + if (this.launchPromise) await this.launchPromise.catch(() => {}); + await this.closeBrowser(); + while (this.background.size) await Promise.allSettled([...this.background].map((task) => task.promise)); } private session(id: string): Session { let session = this.sessions.get(id); if (!session) { - session = { id, tabs: new Map(), activeTabId: null, subscribed: false }; + session = { id, tabs: new Map(), activeTabId: null, subscribed: false, closeRequested: false, tasks: new Set() }; this.sessions.set(id, session); + this.changed(); } + this.requireOpen(session); return session; } @@ -473,7 +612,7 @@ class BrowserRuntime { } private async installedBrowser() { - const override = process.env.GAJAE_BROWSER_EXECUTABLE_PATH; + const override = this.options.executablePath ?? process.env.GAJAE_BROWSER_EXECUTABLE_PATH; if (override && existsSync(override)) { return { browser: BrowserBinary.CHROME, buildId: 'system-override', executablePath: override }; } @@ -483,8 +622,10 @@ class BrowserRuntime { } private async ensureBrowser(allowDownload: boolean, sessionId?: string): Promise { - if (this.browser?.connected) return this.browser; + if (this.closingBrowser) await this.closingBrowser; if (this.launchPromise) return this.launchPromise; + if (!this.browserExited && !this.browser?.connected) throw new Error('browser_close_unconfirmed: Prior Chromium process has not exited.'); + if (this.browser?.connected) return this.browser; this.launchState = 'starting'; this.launchError = undefined; this.launchPromise = (async () => { @@ -493,34 +634,100 @@ class BrowserRuntime { let installed = await this.installedBrowser(); if (!installed) { if (!allowDownload) throw new Error('browser_download_required: Chromium must be downloaded before first use.'); - this.buildId = await resolveBuildId(BrowserBinary.CHROME, platform, BrowserTag.STABLE).catch(() => PUPPETEER_REVISIONS.chrome); - emit('download.progress', { phase: 'starting', buildId: this.buildId }, sessionId); + this.buildId = await resolveBuildId(BrowserBinary.CHROME, platform, BrowserTag.STABLE).catch(() => { + this.downloadUnconfirmed = true; + return PUPPETEER_REVISIONS.chrome; + }); + this.emit('download.progress', { phase: 'starting', buildId: this.buildId }, sessionId); installed = await install({ browser: BrowserBinary.CHROME, buildId: this.buildId, cacheDir: CACHE_ROOT, platform, - downloadProgressCallback(downloadedBytes, totalBytes) { - emit('download.progress', { phase: 'downloading', downloadedBytes, totalBytes, buildId: String(PUPPETEER_REVISIONS.chrome) }, sessionId); + downloadProgressCallback: (downloadedBytes, totalBytes) => { + this.changed(); + this.emit('download.progress', { phase: 'downloading', downloadedBytes, totalBytes, buildId: String(PUPPETEER_REVISIONS.chrome) }, sessionId); }, + }).catch((error) => { + // @puppeteer/browsers rejects downloads on stream error before close; + // it exposes no stream/FD owner with which to certify failed cleanup. + this.downloadUnconfirmed = true; + throw error; }); - emit('download.progress', { phase: 'complete', buildId: this.buildId }, sessionId); + this.emit('download.progress', { phase: 'complete', buildId: this.buildId }, sessionId); } - await mkdir(PROFILE_ROOT, { recursive: true }); + const profilePath = this.options.profilePath ?? PROFILE_ROOT; + await mkdir(profilePath, { recursive: true }); if (!existsSync(installed.executablePath)) throw new Error('browser_missing: Chromium executable was not found.'); - const browser = await launchBrowserWithLinuxFallback(installed.executablePath, { - userDataDir: PROFILE_ROOT, - headless: true, - defaultViewport: { ...DEFAULT_BROWSER_VIEWPORT, deviceScaleFactor: 1 }, - downloadBehavior: { policy: 'deny' }, - args: ['--disable-background-networking', '--disable-component-update', '--no-first-run'], + let browser: Browser; + try { + browser = await launchBrowserWithLinuxFallback(installed.executablePath, { + userDataDir: profilePath, + headless: true, + defaultViewport: { ...DEFAULT_BROWSER_VIEWPORT, deviceScaleFactor: 1 }, + downloadBehavior: { policy: 'deny' }, + args: ['--disable-background-networking', '--disable-component-update', '--no-first-run'], + }, { + launch: this.options.launch, + onLaunchFailure: () => { this.launchUnconfirmed = true; this.changed(); }, + }); + } catch (error) { + // The launcher does not expose a process on failure. A replacement + // launch cannot prove that hidden generation was reaped. + this.launchUnconfirmed = true; + throw error; + } + this.browser = browser; + this.browserProcess = browser.process() ?? undefined; + this.browserExited = false; + if (process.platform === 'win32' && !this.options.processGroupExists) this.processTreeUnobservable = true; + const browserProcess = this.browserProcess; + if (!browserProcess || !browserProcess.pid) { + this.launchUnconfirmed = true; + throw new Error('browser_process_unavailable: Chromium process ownership is unavailable.'); + } + this.browserExit = new Promise((resolve) => { + browserProcess.once('close', () => { + this.track(undefined, async () => { + // Leader exit alone is insufficient if a renderer/helper is still + // in the owned group. Probe only; never kill to make proof succeed. + while (this.processGroupExists(browserProcess.pid!)) { + this.browserUnconfirmed = true; + this.changed(); + await new Promise((done) => setTimeout(done, 25)); + } + if (this.browserProcess === browserProcess) { + this.browserExited = true; + this.browserUnconfirmed = false; + this.browser = undefined; + this.browserCdp = undefined; + this.launchState = 'idle'; + this.ownerByFrameId.clear(); + this.emit('async', { type: 'browser.process', pid: null }); + this.changed(); + } + resolve(); + }).promise.catch(() => {}); + }); + }); + this.emit('async', { type: 'browser.process', pid: browserProcess.pid }); + this.changed(); + browser.on('targetcreated', (target) => { + if (this.browserProcess !== browserProcess || this.browserExited) return; + this.track(undefined, () => this.onTargetCreated(target), true); + }); + browser.on('disconnected', () => { + if (this.browserProcess !== browserProcess) return; + if (!this.browserExited) this.browserUnconfirmed = true; + this.changed(); + for (const session of this.sessions.values()) this.emitState(session); }); const browserCdp = await browser.target().createCDPSession(); await browserCdp.send('Browser.setDownloadBehavior', { behavior: 'deny', eventsEnabled: true }); browserCdp.on('Browser.downloadWillBegin', (event) => { const owner = this.ownerByFrameId.get(event.frameId); if (!owner) return; - emit('async', { + this.emit('async', { type: 'download.attempt', tabId: owner.tabId, url: event.url, @@ -528,17 +735,7 @@ class BrowserRuntime { }, owner.sessionId); }); this.browserCdp = browserCdp; - browser.on('targetcreated', (target) => void this.onTargetCreated(target)); - browser.on('disconnected', () => { - this.browser = undefined; - this.launchState = 'idle'; - emit('async', { type: 'browser.process', pid: null }); - for (const session of this.sessions.values()) this.emitState(session); - }); - this.browser = browser; this.launchState = 'ready'; - const browserPid = browser.process()?.pid; - if (browserPid) emit('async', { type: 'browser.process', pid: browserPid }); return browser; })().catch((error) => { this.launchState = 'error'; @@ -546,24 +743,58 @@ class BrowserRuntime { throw error; }).finally(() => { this.launchPromise = undefined; + this.changed(); }); + this.changed(); return this.launchPromise; } + private async closeBrowser(): Promise { + if (this.closingBrowser) return this.closingBrowser; + if (this.browserExited) return; + const browser = this.browser; + const exited = this.browserExit; + if (!browser || !exited) throw new Error('browser_close_unconfirmed: Chromium closure cannot be observed.'); + this.closingBrowser = (async () => { + // Only an explicit last-session close/shutdown enters here. Do not call + // Puppeteer's browser.close(): its launcher has a force-kill fallback. + // Preserve the persistent profile, close gracefully, and await real exit. + try { + const cdp = this.browserCdp ?? await browser.target().createCDPSession(); + await cdp.send('Browser.close'); + } catch { + if (!this.browserExited) this.browserUnconfirmed = true; + this.changed(); + } + await exited; + })().finally(() => { + this.closingBrowser = undefined; + this.changed(); + }); + this.changed(); + return this.closingBrowser; + } + private async onTargetCreated(target: Target): Promise { const opener = target.opener(); - const sessionId = opener ? this.ownerByTarget.get(opener) : undefined; - if (!sessionId || target.type() !== 'page') return; - const page = await target.page(); - const session = this.sessions.get(sessionId); - if (!page || !session) return; - const tab = await this.registerPage(session, page); - session.activeTabId = tab.id; - emit('async', { type: 'popup', tabId: tab.id, url: page.url() }, sessionId); - if (session.subscribed) await this.ensureScreencast(session); + const session = opener ? this.ownerByTarget.get(opener) : undefined; + if (!session || target.type() !== 'page') return; + await this.track(session, async () => { + const page = await target.page(); + if (!page) return; + const tab = await this.registerPage(session, page); + session.activeTabId = tab.id; + this.emit('async', { type: 'popup', tabId: tab.id, url: page.url() }, session.id); + if (session.subscribed) await this.ensureScreencast(session); + }, true).promise; } private async registerPage(session: Session, page: Page): Promise { + if (session.closeRequested || this.sessions.get(session.id) !== session) { + // This page was created by already-owned work concurrent with user close. + if (!page.isClosed()) await page.close(); + throw new Error('session_closing: Browser session is closing.'); + } const existing = [...session.tabs.values()].find((tab) => tab.page === page); if (existing) return existing; const tab: Tab = { @@ -577,7 +808,8 @@ class BrowserRuntime { }; session.tabs.set(tab.id, tab); session.activeTabId = tab.id; - this.ownerByTarget.set(page.target(), session.id); + this.ownerByTarget.set(page.target(), session); + this.changed(); await page.setViewport({ ...DEFAULT_BROWSER_VIEWPORT, deviceScaleFactor: 1 }); const cdp = await this.cdp(tab); const rememberMainFrame = (frameId: string) => { @@ -600,26 +832,28 @@ class BrowserRuntime { page.on('framenavigated', (frame) => { if (frame === page.mainFrame()) { tab.refs.clear(); - emit('async', { type: 'navigation', tabId: tab.id, url: frame.url() }, session.id); + this.emit('async', { type: 'navigation', tabId: tab.id, url: frame.url() }, session.id); this.emitState(session); } }); page.on('dialog', (dialog) => { - emit('async', { + if (tab.page.isClosed() || this.browserExited) return; + this.emit('async', { type: 'dialog', dialogType: dialog.type(), message: dialog.message(), disposition: 'dismissed', }, session.id); - void dialog.dismiss().catch(() => {}); + this.track(session, () => dialog.dismiss(), true); }); page.on('close', () => { for (const [frameId, owner] of this.ownerByFrameId) { if (owner.sessionId === session.id && owner.tabId === tab.id) this.ownerByFrameId.delete(frameId); } session.tabs.delete(tab.id); + this.changed(); if (session.activeTabId === tab.id) session.activeTabId = session.tabs.keys().next().value ?? null; - emit('async', { type: 'tab.closed', tabId: tab.id }, session.id); + this.emit('async', { type: 'tab.closed', tabId: tab.id }, session.id); this.emitState(session); }); this.emitState(session); @@ -652,10 +886,16 @@ class BrowserRuntime { } private emitState(session: Session): void { - void Promise.all([...session.tabs.values()].map(async (tab): Promise => { + if (this.browserExited || session.closeRequested || this.sessions.get(session.id) !== session) return; + this.track(session, () => Promise.allSettled([...session.tabs.values()].map(async (tab): Promise => { const [title, history] = await Promise.all([tab.page.title().catch(() => ''), this.history(tab)]); return { id: tab.id, title, url: tab.page.url(), loading: tab.loading, ...history }; - })).then((tabs) => emit('state', { sessionId: session.id, activeTabId: session.activeTabId, tabs }, session.id)).catch(() => {}); + })).then((results) => { + if (!session.closeRequested && this.sessions.get(session.id) === session) { + const tabs = results.flatMap((result) => result.status === 'fulfilled' ? [result.value] : []); + this.emit('state', { sessionId: session.id, activeTabId: session.activeTabId, tabs }, session.id); + } + })); } private async cdp(tab: Tab): Promise { @@ -665,10 +905,12 @@ class BrowserRuntime { private async ensureScreencast(session: Session): Promise { const active = session.activeTabId; - await Promise.all([...session.tabs.values()].map(async (tab) => { + const results = await Promise.allSettled([...session.tabs.values()].map(async (tab) => { if (tab.id === active && session.subscribed) await this.startScreencast(session, tab); else await this.stopScreencast(tab); })); + const failed = results.find((result) => result.status === 'rejected'); + if (failed?.status === 'rejected') throw failed.reason; } private async startScreencast(session: Session, tab: Tab): Promise { @@ -678,9 +920,10 @@ class BrowserRuntime { if (!tab.screencastListenerAttached) { tab.screencastListenerAttached = true; cdp.on('Page.screencastFrame', (event) => { - void cdp.send('Page.screencastFrameAck', { sessionId: event.sessionId }).catch(() => {}); + if (tab.page.isClosed() || this.browserExited) return; + this.track(session, () => cdp.send('Page.screencastFrameAck', { sessionId: event.sessionId })); if (!tab.screencasting || session.activeTabId !== tab.id || !session.subscribed) return; - emit('frame', { + this.emit('frame', { tabId: tab.id, mimeType: 'image/jpeg', data: event.data, @@ -696,7 +939,14 @@ class BrowserRuntime { private async stopScreencast(tab: Tab): Promise { if (!tab.screencasting) return; tab.screencasting = false; - await tab.cdp?.send('Page.stopScreencast').catch(() => {}); + try { await tab.cdp?.send('Page.stopScreencast'); } + catch (error) { + if (!tab.page.isClosed() && !this.browserExited) { + this.browserUnconfirmed = true; + this.changed(); + throw error; + } + } } private async observe(tab: Tab, includeAll: boolean): Promise> { @@ -772,10 +1022,19 @@ class BrowserRuntime { } } -const runtime = new BrowserRuntime(); +const childEpoch = randomUUID(); +let activityRevision = 0; +const runtime = new BrowserRuntime({ changed: publishActivity }); const decoder = new BrowserNdjsonDecoder(); -let globalRequestQueue = Promise.resolve(); -const sessionRequestQueues = new Map>(); +const requests = new BrowserRequestQueue(handle, publishActivity, reportQueueError); + +function publishActivity(): void { + const activity: BrowserChildActivity = { + version: 1, epoch: childEpoch, revision: ++activityRevision, + ...requests.snapshot(), ...runtime.snapshotActivity(), + }; + emit('async', { type: 'browser.runtime.activity', activity }); +} async function handle(frame: BrowserRequestFrame): Promise { let result: unknown; @@ -785,7 +1044,7 @@ async function handle(frame: BrowserRequestFrame): Promise { } switch (frame.method) { case 'initialize': - result = { ready: true, protocolVersion: BROWSER_PROTOCOL_VERSION }; + result = { ready: true, protocolVersion: BROWSER_PROTOCOL_VERSION, activityProtocol: 1, activityEpoch: childEpoch }; break; case 'status': result = await runtime.status(); @@ -843,41 +1102,12 @@ function reportQueueError(error: unknown): void { process.stderr.write(`${sanitizeError(error).message}\n`); } -function enqueue(frame: BrowserRequestFrame): void { - // These operations are the cancellation/control plane. They must not sit - // behind a long page wait or run from the same session. Closing a page makes - // Puppeteer's in-flight operation reject, which drains that session queue. - // Viewport changes are the exception among browser inputs: resizing restarts - // the screencast, so preserve their order instead of racing stop/start calls. - const isRealtimeBrowserInput = frame.method === 'browser.input' - && object(frame.payload.input).kind !== 'viewport'; - if (frame.method === 'session.close' - || frame.method === 'screencast.unsubscribe' - || isRealtimeBrowserInput - || frame.method === 'shutdown') { - void handle(frame).catch(reportQueueError); - return; - } - - if (frame.sessionId) { - const previous = sessionRequestQueues.get(frame.sessionId) ?? Promise.resolve(); - const next = previous.then(() => handle(frame)).catch(reportQueueError); - sessionRequestQueues.set(frame.sessionId, next); - void next.finally(() => { - if (sessionRequestQueues.get(frame.sessionId!) === next) sessionRequestQueues.delete(frame.sessionId!); - }); - return; - } - - globalRequestQueue = globalRequestQueue.then(() => handle(frame)).catch(reportQueueError); -} - function runBrowserSidecarEntrypoint(): void { readline.createInterface({ input: process.stdin, crlfDelay: Infinity }).on('line', (line) => { try { for (const frame of decoder.push(`${line}\n`)) { if (frame.kind !== 'request') throw new Error('Only request frames are accepted.'); - enqueue(frame); + requests.enqueue(frame); } } catch (error) { reportQueueError(error); @@ -885,6 +1115,7 @@ function runBrowserSidecarEntrypoint(): void { }); emit('ready', { protocolVersion: BROWSER_PROTOCOL_VERSION }); + publishActivity(); } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) runBrowserSidecarEntrypoint(); diff --git a/server/modules/automation/cua-client.ts b/server/modules/automation/cua-client.ts index f13838ae..51db655e 100644 --- a/server/modules/automation/cua-client.ts +++ b/server/modules/automation/cua-client.ts @@ -6,6 +6,10 @@ import { homedir } from 'node:os'; import { join } from 'node:path'; import readline from 'node:readline'; +import type { DesktopWorkAdmission } from '@/shared/interfaces.js'; + +import type { DesktopOwnerActivity } from '../../../shared/desktopUpdateProtocol.js'; + export const CUA_SAFE_TOOLS = [ 'start_session', 'end_session', @@ -43,9 +47,19 @@ type JsonRpcResponse = { }; type Pending = { + child: ChildProcessWithoutNullStreams; resolve: (value: unknown) => void; reject: (error: Error) => void; timer: NodeJS.Timeout; + tool?: string; + session?: string; + uncertain?: boolean; + retained?: boolean; +}; + +type CuaDriverClientOptions = { + desktopRestartAdmission?: DesktopWorkAdmission; + onSessionClosed?: (label: string) => void; }; function executableCandidates(): string[] { @@ -69,30 +83,41 @@ async function findExecutable(): Promise { return null; } -async function runInspection(executable: string, args: string[], timeoutMs = 3_000): Promise<{ ok: boolean; output: string }> { - return new Promise((resolve) => { +async function runInspection( + executable: string, args: string[], changed: () => void, uncertainty: (delta: number) => void, + timeoutMs = 3_000, +): Promise<{ ok: boolean; output: string }> { + return new Promise<{ ok: boolean; output: string }>((resolve) => { + changed(); const child = spawn(executable, args, { stdio: ['ignore', 'pipe', 'pipe'], env: process.env }); let output = ''; let settled = false; + let uncertain = false; + const markUncertain = () => { + if (uncertain || settled) return; + uncertain = true; + uncertainty(1); + }; const finish = (result: { ok: boolean; output: string }) => { if (settled) return; settled = true; clearTimeout(timer); + if (uncertain) uncertainty(-1); + changed(); resolve(result); }; const timer = setTimeout(() => { + markUncertain(); + changed(); child.kill('SIGKILL'); - finish({ ok: false, output: output.trim() }); }, timeoutMs); child.stdout.on('data', (chunk) => { output += chunk.toString(); }); child.stderr.on('data', (chunk) => { output += chunk.toString(); }); - child.on('error', () => { - finish({ ok: false, output: output.trim() }); - }); + child.on('error', markUncertain); child.on('close', (code) => { finish({ ok: code === 0, output: output.trim() }); }); - }); + }).catch(() => ({ ok: false, output: 'Unable to start CUA Driver inspection.' })); } function permissionValue(output: string, names: string[]): boolean | undefined { @@ -108,41 +133,104 @@ export class CuaDriverClient { private starting?: Promise; private sequence = 0; private readonly pending = new Map(); + private admission?: DesktopWorkAdmission; + private readonly activityEpoch = randomUUID(); + private activityRevision = 0; + private dispatching = 0; + private inspecting = 0; + private uncertainInspections = 0; + private closing = 0; + private shuttingDown = false; + private transportUncertain = false; - async status(): Promise { - const executable = await findExecutable(); - if (!executable) return { installed: false, daemon: 'unknown' }; - const [version, daemon, permissions] = await Promise.all([ - runInspection(executable, ['--version']), - runInspection(executable, ['status']), - process.platform === 'darwin' - ? runInspection(executable, ['permissions', 'status']) - : Promise.resolve({ ok: true, output: '' }), - ]); + constructor(private readonly options: CuaDriverClientOptions = {}) { + this.admission = options.desktopRestartAdmission; + } + + configureDesktopRestartAdmission(admission?: DesktopWorkAdmission): void { + this.admission = admission; + this.activityRevision++; + } + + getGeneration(): string { return `${this.activityEpoch}:${this.activityRevision}`; } + + snapshotActivity(): DesktopOwnerActivity { + const requests = [...this.pending.values()]; + const unknown: string[] = []; + if (requests.some((request) => request.uncertain)) unknown.push('cua_request_unconfirmed'); + if (this.transportUncertain) unknown.push('cua_transport_unconfirmed'); + if (this.uncertainInspections) unknown.push('cua_inspection_unconfirmed'); return { - installed: true, - version: version.output.split(/\r?\n/u)[0]?.slice(0, 120), - daemon: daemon.ok ? 'running' : /not running|stopped|unavailable/iu.test(daemon.output) ? 'stopped' : 'unknown', - accessibility: permissionValue(permissions.output, ['accessibility']), - screenRecording: permissionValue(permissions.output, ['screen recording', 'screen capture']), - ...(!version.ok ? { error: version.output || 'Unable to inspect CUA Driver.' } : {}), + owner: 'computer', generation: this.getGeneration(), complete: unknown.length === 0, + starting: Number(Boolean(this.starting)), queued: this.dispatching, + running: requests.filter((request) => !request.retained).length + this.inspecting, + settling: this.closing, approvals: 0, + retained: requests.filter((request) => request.retained).length, unknown, }; } + async status(): Promise { + const release = this.admission?.enter('automation.computer.status'); + this.inspecting++; + this.activityRevision++; + try { + const executable = await findExecutable(); + if (!executable) return { installed: false, daemon: 'unknown' }; + const inspect = (args: string[]) => runInspection(executable, args, + () => { this.activityRevision++; }, + (delta) => { this.uncertainInspections += delta; this.activityRevision++; }); + const [version, daemon, permissions] = await Promise.all([ + inspect(['--version']), + inspect(['status']), + process.platform === 'darwin' + ? inspect(['permissions', 'status']) + : Promise.resolve({ ok: true, output: '' }), + ]); + return { + installed: true, + version: version.output.split(/\r?\n/u)[0]?.slice(0, 120), + daemon: daemon.ok ? 'running' : /not running|stopped|unavailable/iu.test(daemon.output) ? 'stopped' : 'unknown', + accessibility: permissionValue(permissions.output, ['accessibility']), + screenRecording: permissionValue(permissions.output, ['screen recording', 'screen capture']), + ...(!version.ok ? { error: version.output || 'Unable to inspect CUA Driver.' } : {}), + }; + } finally { + this.inspecting--; + this.activityRevision++; + release?.(); + } + } + async call(tool: CuaSafeTool, args: Record, signal?: AbortSignal): Promise { if (!CUA_SAFE_TOOLS.includes(tool)) throw new Error('CUA Driver tool is not allowed.'); - await this.ensureStarted(); - return this.request('tools/call', { name: tool, arguments: args }, 60_000, signal); + const release = this.admission?.enter(`automation.computer.${tool}`); + this.dispatching++; + this.activityRevision++; + try { + if (signal?.aborted) throw new Error('CUA Driver request was cancelled.'); + await this.ensureStarted(); + return await this.request('tools/call', { name: tool, arguments: args }, 60_000, signal); + } finally { + this.dispatching--; + this.activityRevision++; + release?.(); + } } async shutdown(): Promise { + this.shuttingDown = true; + this.activityRevision++; + if (this.starting) await this.starting.catch(() => {}); const child = this.child; - this.child = undefined; if (!child) return; + this.closing++; + this.activityRevision++; child.stdin.end(); await new Promise((resolve) => { const timer = setTimeout(() => { child.kill('SIGKILL'); + this.transportUncertain = true; + this.activityRevision++; resolve(); }, 2_000); child.once('close', () => { @@ -150,24 +238,32 @@ export class CuaDriverClient { resolve(); }); }); + this.closing--; + this.activityRevision++; } private async ensureStarted(): Promise { - if (this.child && this.child.exitCode === null) return; if (this.starting) return this.starting; + if (this.shuttingDown) throw new Error('CUA Driver is shutting down.'); + if (this.transportUncertain) throw new Error('CUA Driver closure is unconfirmed.'); + if (this.child && this.child.exitCode === null) return; + this.activityRevision++; this.starting = (async () => { const executable = await findExecutable(); if (!executable) throw new Error('CUA Driver is not installed.'); + if (this.shuttingDown) throw new Error('CUA Driver is shutting down.'); const child = spawn(executable, ['mcp'], { stdio: ['pipe', 'pipe', 'pipe'], env: process.env, }); this.child = child; + this.activityRevision++; const lines = readline.createInterface({ input: child.stdout, crlfDelay: Infinity }); - lines.on('line', (line) => this.handleLine(line)); + lines.on('line', (line) => { if (child === this.child) this.handleLine(line); }); child.stderr.on('data', () => {}); - child.on('close', () => this.failAll(new Error('CUA Driver disconnected.'))); - child.on('error', (error) => this.failAll(error)); + child.stdin.on('error', (error) => this.failAll(child, error)); + child.on('close', () => this.failAll(child, new Error('CUA Driver disconnected.'), true)); + child.on('error', (error) => this.failAll(child, error)); await this.request('initialize', { protocolVersion: '2025-03-26', capabilities: {}, @@ -176,6 +272,7 @@ export class CuaDriverClient { this.notify('notifications/initialized', {}); })().finally(() => { this.starting = undefined; + this.activityRevision++; }); return this.starting; } @@ -183,20 +280,27 @@ export class CuaDriverClient { private request(method: string, params: Record, timeoutMs: number, signal?: AbortSignal): Promise { const child = this.child; if (!child || child.exitCode !== null) return Promise.reject(new Error('CUA Driver is unavailable.')); + if (signal?.aborted) return Promise.reject(new Error('CUA Driver request was cancelled.')); const id = `${++this.sequence}-${randomUUID()}`; return new Promise((resolve, reject) => { const timer = setTimeout(() => { - this.pending.delete(id); - reject(new Error('CUA Driver request timed out.')); + abandon(new Error('CUA Driver request timed out.')); }, timeoutMs); - const onAbort = () => { + const abandon = (error: Error) => { + const pending = this.pending.get(id); + if (!pending || pending.uncertain) return; clearTimeout(timer); - this.pending.delete(id); + pending.uncertain = true; + this.activityRevision++; + pending.reject(error); + }; + const onAbort = () => { + abandon(new Error('CUA Driver request was cancelled.')); this.notify('notifications/cancelled', { requestId: id, reason: 'Client request cancelled.' }); - reject(new Error('CUA Driver request was cancelled.')); }; signal?.addEventListener('abort', onAbort, { once: true }); this.pending.set(id, { + child, resolve: (value) => { signal?.removeEventListener('abort', onAbort); resolve(value); @@ -206,13 +310,24 @@ export class CuaDriverClient { reject(error); }, timer, + ...(method === 'tools/call' ? { + tool: String(params.name), + session: typeof (params.arguments as Record)?.session === 'string' + ? String((params.arguments as Record).session) : 'default', + } : {}), }); - child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`); + this.activityRevision++; + try { child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`); } + catch (error) { abandon(error instanceof Error ? error : new Error('CUA Driver write failed.')); } }); } private notify(method: string, params: Record): void { - this.child?.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', method, params })}\n`); + this.activityRevision++; + const child = this.child; + if (!child) return; + try { child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', method, params })}\n`); } + catch (error) { this.failAll(child, error instanceof Error ? error : new Error('CUA Driver write failed.')); } } private handleLine(line: string): void { @@ -224,20 +339,56 @@ export class CuaDriverClient { } if (message.id === undefined) return; const pending = this.pending.get(message.id); - if (!pending) return; + if (!pending || pending.retained) return; + if (message.jsonrpc !== '2.0' || (!Object.hasOwn(message, 'result') && !message.error) + || (pending.tool && !message.error && (!message.result || typeof message.result !== 'object' || Array.isArray(message.result)))) { + clearTimeout(pending.timer); + pending.uncertain = true; + this.activityRevision++; + pending.reject(new Error('CUA Driver returned an invalid response.')); + return; + } this.pending.delete(message.id); clearTimeout(pending.timer); + this.activityRevision++; + const succeeded = !message.error && Boolean(message.result && typeof message.result === 'object' + && (message.result as { isError?: unknown }).isError !== true + && (message.result as { ok?: unknown }).ok !== false + && (message.result as { ended?: unknown }).ended !== false); + if (pending.tool === 'start_session' && succeeded) { + // Keep the original request as the named transport-session owner until + // end_session acknowledgment; no independent reservation registry. + for (const [id, entry] of this.pending) { + if (entry.retained && entry.child === pending.child && entry.session === pending.session) this.pending.delete(id); + } + pending.retained = true; + pending.uncertain = false; + this.pending.set(message.id, pending); + } + if (pending.tool === 'end_session' && succeeded) { + for (const [id, entry] of this.pending) { + if (entry.retained && entry.child === pending.child && entry.session === pending.session) this.pending.delete(id); + } + if (pending.session) this.options.onSessionClosed?.(pending.session); + } if (message.error) pending.reject(new Error(message.error.message || 'CUA Driver request failed.')); else pending.resolve(message.result); } - private failAll(error: Error): void { - this.child = undefined; - for (const pending of this.pending.values()) { + private failAll(child: ChildProcessWithoutNullStreams, error: Error, closed = false): void { + if (child !== this.child) return; + this.transportUncertain = !closed; + this.activityRevision++; + if (closed) this.child = undefined; + for (const [id, pending] of this.pending) { + if (pending.child !== child) continue; clearTimeout(pending.timer); + // MCP transport exit does not prove completion in the external daemon. + // Initialization is local to the dead transport; tools/sessions are not. + if (closed && !pending.tool) this.pending.delete(id); + else pending.uncertain = true; pending.reject(error); } - this.pending.clear(); } } diff --git a/server/modules/automation/desktop-restart.test.ts b/server/modules/automation/desktop-restart.test.ts new file mode 100644 index 00000000..caa27829 --- /dev/null +++ b/server/modules/automation/desktop-restart.test.ts @@ -0,0 +1,613 @@ +import assert from 'node:assert/strict'; +import childProcess from 'node:child_process'; +import { EventEmitter, once } from 'node:events'; +import { mkdtemp, rm } from 'node:fs/promises'; +import type { IncomingMessage } from 'node:http'; +import { syncBuiltinESMExports } from 'node:module'; +import net from 'node:net'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { PassThrough } from 'node:stream'; +import test, { type TestContext } from 'node:test'; + +import type WebSocket from 'ws'; + +import type { DesktopWorkAdmission } from '@/shared/interfaces.js'; + +import type { DesktopOwnerActivity } from '../../../shared/desktopUpdateProtocol.js'; + +import { + AutomationService, configureDesktopRestartAdmission, createAutomationDesktopRestartReader, + createBrowserDesktopRestartReader, createComputerDesktopRestartReader, +} from './automation.service.js'; +import type { BrowserChildActivity, BrowserRequestFrame, BrowserRequestMethod } from './browser-protocol.js'; +import { BrowserSidecarClient } from './browser-sidecar-client.js'; +import { handleBrowserConnection } from './browser-websocket.js'; +import { CuaDriverClient } from './cua-client.js'; + +class Admission implements DesktopWorkAdmission { + fenced = false; + active = 0; + sources: string[] = []; + enter(source: string): () => void { + this.sources.push(source); + if (this.fenced) throw new Error('restart fenced'); + this.active++; + let released = false; + return () => { + assert.equal(released, false, 'admission must release exactly once'); + released = true; + this.active--; + }; + } + enterCompletion(): () => void { throw new Error('new automation must not claim completion admission'); } +} + +type WireRequest = Omit & { + method: string; + params?: { name?: string; arguments?: { session?: string } }; +}; + +class FakeChild extends EventEmitter { + stdin = new PassThrough(); + stdout = new PassThrough(); + stderr = new PassThrough(); + exitCode: number | null = null; + signalCode = null; + kills = 0; + calls: WireRequest[] = []; + constructor() { + super(); + this.stdin.on('data', (bytes: Buffer) => { + for (const line of bytes.toString().trim().split('\n')) this.calls.push(JSON.parse(line) as WireRequest); + }); + } + kill(): boolean { this.kills++; return true; } + close(): void { + this.exitCode = 0; + this.stdout.end(); + this.stderr.end(); + this.emit('close', 0); + } + browserReply(request: WireRequest, result: unknown = {}): void { + this.stdout.write(`${JSON.stringify({ + protocolVersion: 1, kind: 'response', id: request.id, method: request.method, + ...(request.sessionId ? { sessionId: request.sessionId } : {}), ok: true, result, + })}\n`); + } + cuaReply(request: WireRequest, result: unknown = { ok: true }): void { + this.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id: request.id, result })}\n`); + } +} + +function children(t: TestContext) { + const processes: FakeChild[] = []; + const spawn = t.mock.method(childProcess, 'spawn', () => { + const child = new FakeChild(); + processes.push(child); + return child; + }); + syncBuiltinESMExports(); + t.after(() => { + for (const child of processes) if (child.exitCode === null) child.close(); + spawn.mock.restore(); + syncBuiltinESMExports(); + }); + return { processes, spawn }; +} + +function environment(t: TestContext, values: Record) { + for (const [name, value] of Object.entries(values)) { + const previous = process.env[name]; + process.env[name] = value; + t.after(() => { if (previous === undefined) delete process.env[name]; else process.env[name] = previous; }); + } +} + +async function until(predicate: () => boolean): Promise { + for (let i = 0; i < 1_000; i++) { + if (predicate()) return; + await new Promise((resolve) => setTimeout(resolve, 1)); + } + assert.fail('expected asynchronous transition did not happen'); +} + +function idle(activity: DesktopOwnerActivity): void { + assert.equal(activity.complete, true); + assert.deepEqual(activity.unknown, []); + for (const field of ['starting', 'queued', 'running', 'settling', 'retained', 'approvals'] as const) { + assert.equal(activity[field], 0, `${activity.owner}.${field}`); + } +} + +async function startBrowser(client: BrowserSidecarClient, fixture: ReturnType) { + const status = client.status(); + await until(() => Boolean(fixture.processes[0]?.calls.length)); + const child = fixture.processes[0]!; + assert.equal(child.calls[0]!.method, 'initialize'); + child.browserReply(child.calls[0]!); + await until(() => child.calls.some((call) => call.method === 'status')); + child.browserReply(child.calls.find((call) => call.method === 'status')!); + await status; + return child; +} + +async function initializeCua(fixture: ReturnType) { + await until(() => Boolean(fixture.processes.at(-1)?.calls.length)); + const child = fixture.processes.at(-1)!; + assert.equal(child.calls[0]!.method, 'initialize'); + child.cuaReply(child.calls[0]!); + await until(() => child.calls.some((call) => call.method === 'tools/call')); + return child; +} + +test('all three readers are pure, complete for healthy unused owners, and independent snapshots', (t) => { + const fixture = children(t); + const admission = new Admission(); + const service = new AutomationService(); + configureDesktopRestartAdmission(admission, service); + const readers = [createAutomationDesktopRestartReader(service), createBrowserDesktopRestartReader(service.browser), createComputerDesktopRestartReader(service)]; + for (const reader of readers) { + const generation = reader.getGeneration(); + const snapshot = reader.read(); + idle(snapshot); + assert.equal(snapshot.generation, generation); + (snapshot.unknown as string[]).push('caller_mutation'); + idle(reader.read()); + assert.equal(reader.getGeneration(), generation); + } + const generation = service.browser.getGeneration(); + const unsubscribe = service.subscribeBrowser(() => {}); + service.browser.cachedState('read-only'); + unsubscribe(); + assert.equal(service.browser.getGeneration(), generation); + assert.equal(fixture.spawn.mock.callCount(), 0); + assert.deepEqual(admission.sources, []); +}); + +test('fenced direct service and client producers reject before startup, label creation or dispatch', async (t) => { + environment(t, { GAJAE_AUTOMATION: '1', CUA_DRIVER_PATH: process.execPath }); + const fixture = children(t); + const admission = new Admission(); + const service = new AutomationService(admission); + admission.fenced = true; + const generation = service.getGeneration(); + const operations = [ + () => service.openBrowser('session', {}), + () => service.commandBrowser('session', { action: 'reload' }), + () => service.inputBrowser('session', { kind: 'text', text: 'input' }), + () => service.authorizeBrowser('session', {}), + () => service.authorizeComputer('session', { tool: 'list_apps' }), + () => service.callComputer('session', 'start_session', {}), + () => service.stopSession('session'), + () => service.startBridge(), + () => service.status(), + () => service.browser.open('session', {}), + () => service.browser.command('session', { action: 'reload' }), + () => service.browser.input('session', { kind: 'text', text: 'input' }), + () => service.browser.state('session'), + () => service.browser.status(), + () => service.browser.subscribeFrames('session'), + () => service.browser.unsubscribeFrames('session'), + () => service.cua.call('start_session', {}), + () => service.cua.status(), + ]; + for (const operation of operations) await assert.rejects(operation(), /fenced/); + assert.equal(fixture.spawn.mock.callCount(), 0); + assert.equal(service.getGeneration(), generation); + idle(service.snapshotActivity()); + idle(service.browser.snapshotActivity()); + idle(service.cua.snapshotActivity()); + assert.equal(admission.active, 0); +}); + +test('browser startup is owned before first await and concurrent requests wait for initialize', async (t) => { + const fixture = children(t); + const admission = new Admission(); + const client = new BrowserSidecarClient({ runtimePath: process.execPath, desktopRestartAdmission: admission }); + const before = client.getGeneration(); + const first = client.status(); + const second = client.status(); + assert.equal(admission.active, 2); + assert.notEqual(client.getGeneration(), before); + assert.equal(client.snapshotActivity().starting, 1); + await Promise.resolve(); + const child = fixture.processes[0]!; + assert.deepEqual(child.calls.map((call) => call.method), ['initialize']); + child.browserReply(child.calls[0]!); + await until(() => child.calls.length === 3); + for (const request of child.calls.slice(1)) child.browserReply(request); + await Promise.all([first, second]); + idle(client.snapshotActivity()); + assert.equal(admission.active, 0); +}); + +test('browser timeout and cancellation retain uncertainty until matching late completion', async (t) => { + const fixture = children(t); + const admission = new Admission(); + const client = new BrowserSidecarClient({ runtimePath: process.execPath, desktopRestartAdmission: admission }); + const child = await startBrowser(client, fixture); + // Exercise the production request path with a short deadline, not a second + // timeout implementation or a fake pending-map mutation. + const request = client as unknown as { + request(method: BrowserRequestMethod, session: undefined, payload: Record, timeout: number, signal?: AbortSignal): Promise; + }; + for (const cancel of [false, true]) { + const controller = new AbortController(); + const generation = client.getGeneration(); + const pending = request.request('status', undefined, {}, cancel ? 1_000 : 5, controller.signal); + const rejected = assert.rejects(pending, cancel ? /cancelled/ : /timed out/); + await until(() => child.calls.at(-1)?.method === 'status' && client.snapshotActivity().running === 1); + const wire = child.calls.at(-1)!; + if (cancel) controller.abort(); + await rejected; + assert.equal(admission.active, 0, 'waiter released only after transfer to the pending owner'); + assert.equal(client.snapshotActivity().complete, false); + assert.equal(client.snapshotActivity().running, 1); + assert.deepEqual(client.snapshotActivity().unknown, ['browser_request_unconfirmed']); + assert.notEqual(client.getGeneration(), generation); + const uncertain = client.getGeneration(); + child.browserReply(wire); + idle(client.snapshotActivity()); + assert.notEqual(client.getGeneration(), uncertain); + } +}); + +test('mismatched browser response never deletes uncertainty and reads never kill or recover', async (t) => { + const fixture = children(t); + const client = new BrowserSidecarClient({ runtimePath: process.execPath }); + const child = await startBrowser(client, fixture); + const pending = client.status(); + const rejected = assert.rejects(pending, /mismatched/); + await until(() => client.snapshotActivity().running === 1); + const request = child.calls.at(-1)!; + child.browserReply({ ...request, method: 'session.close' }); + await rejected; + const generation = client.getGeneration(); + for (let i = 0; i < 3; i++) { + assert.equal(client.snapshotActivity().complete, false); + client.cachedState('never-opened'); + assert.equal(client.getGeneration(), generation); + } + assert.equal(child.kills, 0); + assert.equal(fixture.spawn.mock.callCount(), 1); + child.browserReply(request); + idle(client.snapshotActivity()); +}); + +test('legacy browser sessions remain busy and best-effort close/cache deletion is not closure proof', async (t) => { + const fixture = children(t); + const client = new BrowserSidecarClient({ runtimePath: process.execPath }); + const child = await startBrowser(client, fixture); + const open = client.open('session', {}); + await until(() => child.calls.at(-1)?.method === 'session.open'); + child.browserReply(child.calls.at(-1)!, { + sessionId: 'session', activeTabId: 'tab', + tabs: [{ id: 'tab', title: '', url: 'about:blank', loading: false, canGoBack: false, canGoForward: false }], + }); + await open; + assert.equal(client.snapshotActivity().retained, 1); + assert.equal(client.snapshotActivity().running, 0); + const close = client.close('session'); + assert.equal(client.snapshotActivity().retained, 1); + await until(() => child.calls.at(-1)?.method === 'session.close'); + child.browserReply(child.calls.at(-1)!, { closed: true }); + await close; + assert.deepEqual(client.cachedState('session').tabs, []); + assert.equal(client.snapshotActivity().retained, 0); + assert.equal(client.snapshotActivity().complete, false); + assert.ok(client.snapshotActivity().unknown.includes('browser_closure_unconfirmed')); + assert.equal(child.kills, 0, 'snapshots never force-close to obtain idle'); +}); + +test('browser shutdown remains settling after acknowledgment until the child close event', async (t) => { + const fixture = children(t); + const client = new BrowserSidecarClient({ runtimePath: process.execPath }); + const child = await startBrowser(client, fixture); + const shutdown = client.shutdown(); + child.browserReply(child.calls.at(-1)!, { shutdown: true }); + await shutdown; + assert.ok(client.snapshotActivity().settling > 0); + child.close(); + idle(client.snapshotActivity()); +}); + +test('upgraded child proof must match epoch, request tail and settled callbacks before used browser idle', async (t) => { + const fixture = children(t); + const client = new BrowserSidecarClient({ runtimePath: process.execPath }); + const status = client.status(); + const child = fixture.processes[0]!; + child.browserReply(child.calls[0]!, { activityProtocol: 1, activityEpoch: 'child-proof' }); + await until(() => child.calls.at(-1)?.method === 'status'); + child.browserReply(child.calls.at(-1)!); + await status; + assert.equal(client.snapshotActivity().complete, false, 'handshake is not an idle report'); + let revision = 0; + const report = (patch: Partial = {}) => { + const activity: BrowserChildActivity = { + version: 1, epoch: 'child-proof', revision: ++revision, + requestSequence: child.calls.at(-1)!.sequence!, starting: 0, queued: 0, running: 0, + callbacks: 0, settling: 0, retained: 0, browserAlive: false, unknown: [], ...patch, + }; + child.stdout.write(`${JSON.stringify({ protocolVersion: 1, kind: 'event', method: 'async', payload: { + type: 'browser.runtime.activity', activity, + } })}\n`); + }; + report({ callbacks: 1 }); + assert.equal(client.snapshotActivity().running, 1); + report({ requestSequence: 1 }); + assert.equal(client.snapshotActivity().complete, false, 'old request tail cannot prove current idle'); + report(); + idle(client.snapshotActivity()); + const open = client.open('session', {}); + await until(() => child.calls.at(-1)?.method === 'session.open'); + child.browserReply(child.calls.at(-1)!, { sessionId: 'session', activeTabId: null, tabs: [] }); + await open; + assert.equal(client.snapshotActivity().retained, 1); + const close = client.close('session'); + await until(() => child.calls.at(-1)?.method === 'session.close'); + child.browserReply(child.calls.at(-1)!, { closed: true }); + await close; + assert.equal(client.snapshotActivity().complete, false, 'close reply without matching child proof stays unknown'); + report({ callbacks: 1, browserAlive: true }); + assert.ok(client.snapshotActivity().running > 0); + assert.ok(client.snapshotActivity().retained > 0); + report({ unknown: ['browser_operation_unconfirmed'] }); + assert.equal(client.snapshotActivity().complete, false); + report({ epoch: 'another-child' }); + assert.equal(client.snapshotActivity().complete, false); + report(); + idle(client.snapshotActivity()); + const snapshot = client.snapshotActivity(); + report({ revision: revision - 1 }); + assert.equal(client.snapshotActivity().complete, false, 'replayed revision is not fresh evidence'); + report(); + idle(client.snapshotActivity()); + assert.equal(snapshot.complete, true, 'published snapshots do not mutate'); +}); + +test('recovery callback admission is fenced while retained recovery ownership is preserved', async (t) => { + const fixture = children(t); + const admission = new Admission(); + const client = new BrowserSidecarClient({ runtimePath: process.execPath, desktopRestartAdmission: admission }); + const child = await startBrowser(client, fixture); + const open = client.open('session', {}); + await until(() => child.calls.at(-1)?.method === 'session.open'); + const state = { sessionId: 'session', activeTabId: 'tab', tabs: [ + { id: 'tab', title: 'Keep me', url: 'https://recovery.test/', loading: false, canGoBack: false, canGoForward: false }, + ] }; + child.browserReply(child.calls.at(-1)!, state); + await open; + admission.fenced = true; + const generation = client.getGeneration(); + child.close(); + await Promise.resolve(); + assert.equal(fixture.spawn.mock.callCount(), 1); + assert.ok(admission.sources.includes('automation.browser.recovery')); + assert.equal(client.snapshotActivity().retained, 1); + assert.equal(client.snapshotActivity().queued, 1); + assert.deepEqual(client.cachedState('session'), state, 'a rejected fence cannot discard recovery URLs'); + assert.notEqual(client.getGeneration(), generation); + + admission.fenced = false; + const status = client.status(); + await until(() => fixture.processes.length === 2); + const replacement = fixture.processes[1]!; + replacement.browserReply(replacement.calls[0]!); + await until(() => replacement.calls.some((call) => call.method === 'session.open')); + const restored = replacement.calls.find((call) => call.method === 'session.open')!; + assert.equal(restored.payload.url, 'https://recovery.test/'); + replacement.browserReply(restored, state); + await until(() => replacement.calls.some((call) => call.method === 'status')); + replacement.browserReply(replacement.calls.find((call) => call.method === 'status')!); + await status; + await until(() => client.snapshotActivity().settling === 0); + assert.equal(client.snapshotActivity().queued, 0); +}); + +test('CUA named session ownership survives cancellation and is released by late end acknowledgment', async (t) => { + environment(t, { GAJAE_AUTOMATION: '1', CUA_DRIVER_PATH: process.execPath }); + const fixture = children(t); + const admission = new Admission(); + const service = new AutomationService(admission); + const start = service.callComputer('session', 'start_session', {}); + assert.equal(service.snapshotActivity().retained, 1, 'session label is owned before executable lookup'); + assert.ok(service.cua.snapshotActivity().starting > 0); + const child = await initializeCua(fixture); + child.cuaReply(child.calls.at(-1)!); + await start; + const reader = createComputerDesktopRestartReader(service); + assert.equal(reader.read().retained, 2, 'service label and original start request both retain the session'); + assert.equal(reader.read().running, 0); + const controller = new AbortController(); + const end = service.callComputer('session', 'end_session', {}, controller.signal); + const rejected = assert.rejects(end, /cancelled/); + await until(() => child.calls.at(-1)?.params?.name === 'end_session'); + const request = child.calls.at(-1)!; + assert.ok(service.snapshotActivity().settling > 0); + controller.abort(); + await rejected; + assert.equal(admission.active, 0); + assert.equal(reader.read().complete, false); + assert.equal(reader.read().retained, 2); + assert.ok(child.calls.some((call) => call.method === 'notifications/cancelled')); + const generation = reader.getGeneration(); + child.cuaReply(request); + idle(reader.read()); + idle(service.snapshotActivity()); + assert.notEqual(reader.getGeneration(), generation); +}); + +test('CUA timed-out requests survive transport exit and replacement at the same session name', async (t) => { + environment(t, { CUA_DRIVER_PATH: process.execPath }); + const fixture = children(t); + const client = new CuaDriverClient(); + const first = client.call('start_session', { session: 'same-label' }); + const oldChild = await initializeCua(fixture); + oldChild.cuaReply(oldChild.calls.at(-1)!); + await first; + const request = client as unknown as { + request(method: string, params: Record, timeout: number): Promise; + }; + await assert.rejects(request.request('tools/call', { name: 'list_apps', arguments: {} }, 5), /timed out/); + assert.equal(client.snapshotActivity().complete, false); + oldChild.close(); + const next = client.call('start_session', { session: 'same-label' }); + await until(() => fixture.processes.length === 2); + const child = await initializeCua(fixture); + child.cuaReply(child.calls.at(-1)!); + await next; + assert.equal(client.snapshotActivity().retained, 2, 'replacement must not erase an older transport owner'); + const end = client.call('end_session', { session: 'same-label' }); + await until(() => child.calls.at(-1)?.params?.name === 'end_session'); + oldChild.emit('close', 0); + child.cuaReply(child.calls.at(-1)!); + await end; + assert.equal(client.snapshotActivity().retained, 1); + assert.equal(client.snapshotActivity().running, 1); + assert.deepEqual(client.snapshotActivity().unknown, ['cua_request_unconfirmed']); +}); + +test('failed computer cleanup retains its label and a retry can prove healthy idle', async (t) => { + environment(t, { GAJAE_AUTOMATION: '1' }); + const service = new AutomationService(); + let failEnd = true; + service.cua.call = async (tool) => tool === 'end_session' && failEnd + ? { isError: true, content: [{ text: 'cleanup failed' }] } : { ok: true }; + await service.callComputer('session', 'start_session', {}); + await assert.rejects(service.callComputer('session', 'end_session', {}), /cleanup failed/); + assert.equal(service.snapshotActivity().retained, 1); + assert.deepEqual(service.snapshotActivity().unknown, ['computer_session_unconfirmed']); + failEnd = false; + await service.callComputer('session', 'end_session', {}); + idle(service.snapshotActivity()); +}); + +test('computer close waits for an already-owned session start instead of forgetting its label', async (t) => { + environment(t, { GAJAE_AUTOMATION: '1', CUA_DRIVER_PATH: process.execPath }); + const fixture = children(t); + const service = new AutomationService(); + const start = service.callComputer('session', 'start_session', {}); + const end = service.callComputer('session', 'end_session', {}); + const child = await initializeCua(fixture); + assert.equal(child.calls.filter((call) => call.params?.name === 'start_session').length, 1); + assert.equal(child.calls.filter((call) => call.params?.name === 'end_session').length, 0); + assert.equal(service.snapshotActivity().retained, 1); + assert.ok(service.snapshotActivity().settling > 0); + const startRequest = child.calls.at(-1)!; + child.cuaReply(startRequest); + await start; + await until(() => child.calls.at(-1)?.params?.name === 'end_session'); + assert.equal(child.calls.at(-1)!.params?.arguments?.session, startRequest.params?.arguments?.session); + child.cuaReply(child.calls.at(-1)!); + await end; + idle(createComputerDesktopRestartReader(service).read()); +}); + +test('CUA inspection deadline keeps admission and unknown ownership until all processes close', async (t) => { + environment(t, { CUA_DRIVER_PATH: process.execPath }); + const fixture = children(t); + const admission = new Admission(); + const client = new CuaDriverClient({ desktopRestartAdmission: admission }); + t.mock.timers.enable({ apis: ['setTimeout'] }); + const status = client.status(); + const expected = process.platform === 'darwin' ? 3 : 2; + for (let i = 0; i < 1_000 && fixture.processes.length < expected; i++) { + await new Promise((resolve) => setImmediate(resolve)); + } + assert.equal(fixture.processes.length, expected); + const generation = client.getGeneration(); + t.mock.timers.tick(3_000); + assert.equal(admission.active, 1); + assert.deepEqual(client.snapshotActivity().unknown, ['cua_inspection_unconfirmed']); + assert.notEqual(client.getGeneration(), generation); + for (const child of fixture.processes.slice(0, -1)) child.close(); + assert.equal(client.snapshotActivity().complete, false); + fixture.processes.at(-1)!.close(); + await status; + assert.equal(admission.active, 0); + idle(client.snapshotActivity()); +}); + +class FakeWebSocket extends EventEmitter { + readonly OPEN = 1; + readyState = 1; + bufferedAmount = 0; + sent: string[] = []; + send(value: string): void { this.sent.push(value); } + close(): void { this.readyState = 3; this.emit('close'); } +} + +test('preview subscription is gated but cached-state websocket observers are inert under the fence', async (t) => { + const fixture = children(t); + const admission = new Admission(); + const service = new AutomationService(admission); + admission.fenced = true; + for (const mode of ['state', 'preview']) { + const socket = new FakeWebSocket(); + const before = service.browser.getGeneration(); + handleBrowserConnection(socket as unknown as WebSocket, { + url: `/ws/browser?sessionId=session&mode=${mode}`, + } as IncomingMessage, service); + await until(() => socket.sent.length > 0); + const frame = JSON.parse(socket.sent[0]!) as { type: string }; + assert.equal(frame.type, mode === 'state' ? 'state' : 'error'); + assert.equal(service.browser.getGeneration(), before); + if (mode === 'state') assert.deepEqual(admission.sources, []); + socket.close(); + } + assert.equal(fixture.spawn.mock.callCount(), 0); + idle(service.browser.snapshotActivity()); +}); + +async function bridge(t: TestContext) { + const directory = await mkdtemp(join(tmpdir(), 'automation-admission-')); + environment(t, { GAJAE_AUTOMATION: '1', GAJAE_AUTOMATION_SOCKET: join(directory, 'bridge.sock') }); + const admission = new Admission(); + const service = new AutomationService(admission); + await service.startBridge(); + const socket = net.createConnection(process.env.GJC_AUTOMATION_SOCKET!); + await once(socket, 'connect'); + t.after(async () => { + socket.destroy(); + await service.shutdown(); + await rm(directory, { recursive: true, force: true }); + }); + const send = (token = process.env.GJC_AUTOMATION_TOKEN) => socket.write(`${JSON.stringify({ + id: 'request', token, sessionId: 'session', surface: 'browser', operation: 'open', payload: {}, + })}\n`); + return { admission, service, socket, send }; +} + +test('direct Unix bridge authenticates before admission and rejects fenced dispatch', { skip: process.platform === 'win32' }, async (t) => { + const { admission, service, socket, send } = await bridge(t); + service.openBrowser = async () => { assert.fail('fenced bridge must not dispatch'); }; + admission.fenced = true; + const count = admission.sources.length; + send('invalid-token'); + let [data] = await once(socket, 'data'); + assert.match(data.toString(), /Unauthorized/); + assert.equal(admission.sources.length, count); + send(); + [data] = await once(socket, 'data'); + assert.match(data.toString(), /fenced/); + assert.equal(admission.sources.at(-1), 'automation.bridge.request'); + idle(service.snapshotActivity()); +}); + +test('bridge socket close cannot release an executing handler before its actual promise settles', { skip: process.platform === 'win32' }, async (t) => { + const { admission, service, socket, send } = await bridge(t); + let finish!: (value: unknown) => void; + service.openBrowser = () => new Promise((resolve) => { finish = resolve; }); + send(); + await until(() => Boolean(finish)); + socket.destroy(); + await once(socket, 'close'); + assert.equal(admission.active, 1); + assert.equal(service.snapshotActivity().running, 1); + const generation = service.getGeneration(); + finish({ opened: false }); + await until(() => admission.active === 0); + idle(service.snapshotActivity()); + assert.notEqual(service.getGeneration(), generation); +}); diff --git a/server/modules/automation/index.ts b/server/modules/automation/index.ts index 2fb1ebe8..602f59eb 100644 --- a/server/modules/automation/index.ts +++ b/server/modules/automation/index.ts @@ -3,7 +3,10 @@ export { createAutomationRouter, createBrowserAutomationRouter, } from './automation.routes.js'; -export { automationService, AutomationService } from './automation.service.js'; +export { + automationService, AutomationService, configureDesktopRestartAdmission, + createAutomationDesktopRestartReader, createBrowserDesktopRestartReader, createComputerDesktopRestartReader, +} from './automation.service.js'; export { handleBrowserConnection } from './browser-websocket.js'; export { CUA_SAFE_TOOLS, type CuaSafeTool } from './cua-client.js'; export type { BrowserCommand, BrowserInput, BrowserSessionState } from './browser-protocol.js'; diff --git a/server/modules/notifications/index.ts b/server/modules/notifications/index.ts index 1ed7d318..256a0210 100644 --- a/server/modules/notifications/index.ts +++ b/server/modules/notifications/index.ts @@ -11,6 +11,11 @@ import { notifyUserIfEnabled as notifyEnabledUser, } from '@/modules/notifications/services/notification-orchestrator.service.js'; import { handleDesktopNotificationsConnection as handleDesktopConnection } from '@/modules/notifications/websocket/desktop-notifications-websocket.service.js'; +export { + configureNotificationDesktopAdmission, + getNotificationActivityGeneration, + snapshotNotificationActivity, +} from './services/desktop-update-activity.service.js'; export { buildPayload as buildNotificationPayload, diff --git a/server/modules/notifications/notifications.routes.ts b/server/modules/notifications/notifications.routes.ts index c86960d8..50d916ca 100644 --- a/server/modules/notifications/notifications.routes.ts +++ b/server/modules/notifications/notifications.routes.ts @@ -1,6 +1,7 @@ import express from 'express'; import { notificationChannelEndpointsDb, notificationPreferencesDb } from '@/modules/database/index.js'; +import { asyncHandler } from '@/shared/utils.js'; const router = express.Router(); @@ -65,7 +66,7 @@ function guardEndpointRoute( } } -router.get('/endpoints', (request, response) => { +router.get('/endpoints', asyncHandler((request, response) => { const channel = requiredText(request.query.channel); if (!channel) return response.status(400).json({ error: 'channel is required' }); @@ -77,9 +78,9 @@ router.get('/endpoints', (request, response) => { return response.json({ success: true, endpoints }); }, ); -}); +})); -router.post('/endpoints/current', (request, response) => { +router.post('/endpoints/current', asyncHandler((request, response) => { const input = request.body || {}; const channel = requiredText(input.channel); const endpointId = requiredText(input.endpointId); @@ -106,9 +107,9 @@ router.post('/endpoints/current', (request, response) => { }); }, ); -}); +})); -router.patch('/endpoints/:channel/:endpointId', (request, response) => { +router.patch('/endpoints/:channel/:endpointId', asyncHandler((request, response) => { if (typeof request.body?.enabled !== 'boolean') { return response.status(400).json({ error: 'enabled must be a boolean' }); } @@ -130,9 +131,9 @@ router.patch('/endpoints/:channel/:endpointId', (request, response) => { }); }, ); -}); +})); -router.delete('/endpoints/:channel/:endpointId', (request, response) => { +router.delete('/endpoints/:channel/:endpointId', asyncHandler((request, response) => { return guardEndpointRoute( response, { log: 'Error removing notification endpoint:', body: 'Failed to remove notification endpoint' }, @@ -145,6 +146,6 @@ router.delete('/endpoints/:channel/:endpointId', (request, response) => { return response.json({ success: true, preferences: syncChannelPreference(userId, channel) }); }, ); -}); +})); export default router; diff --git a/server/modules/notifications/services/desktop-notification-clients.service.ts b/server/modules/notifications/services/desktop-notification-clients.service.ts index e9ddd1d0..d6b3536a 100644 --- a/server/modules/notifications/services/desktop-notification-clients.service.ts +++ b/server/modules/notifications/services/desktop-notification-clients.service.ts @@ -2,6 +2,8 @@ import type { WebSocket } from 'ws'; import { notificationChannelEndpointsDb } from '@/modules/database/index.js'; +import { enterNotificationActivity } from './desktop-update-activity.service.js'; + // The channel value is part of the endpoint rows in the database; only the // constant's name is ours to choose. const CHANNEL_DESKTOP = 'desktop'; @@ -59,47 +61,87 @@ export function registerDesktopNotificationClient(registration: DesktopClientReg const ownerId = userIdOrNull(userId); const endpointId = endpointIdFrom(deviceId); if (ownerId === null || !endpointId) return false; + const release = enterNotificationActivity(false); + try { + const upsertRecord = { + userId: ownerId, channel: CHANNEL_DESKTOP, endpointId, label, + metadata: { platform, appVersion }, enabled: true, + }; + const endpoint = notificationChannelEndpointsDb.upsertEndpoint(upsertRecord); + + const userClients = clientsFor(ownerId); + const replacedSocket = userClients.get(endpointId); + userClients.set(endpointId, ws); + registrationForSocket.set(ws, { userId: ownerId, endpointId }); + if (replacedSocket && replacedSocket !== ws && replacedSocket.readyState === replacedSocket.OPEN) { + try { replacedSocket.close(4000, 'Device reconnected'); } catch { /* The replacement is already registered. */ } + } + return endpoint; + } finally { release(); } +} - const upsertRecord = { - userId: ownerId, channel: CHANNEL_DESKTOP, endpointId, label, - metadata: { platform, appVersion }, enabled: true, - }; - const endpoint = notificationChannelEndpointsDb.upsertEndpoint(upsertRecord); +export function unregisterDesktopNotificationClient(ws: WebSocket): void { + if (!registrationForSocket.has(ws)) return; + const release = enterNotificationActivity(); + try { forgetClient(ws); } finally { release(); } +} - const userClients = clientsFor(ownerId); - const replacedSocket = userClients.get(endpointId); - if (replacedSocket && replacedSocket !== ws && replacedSocket.readyState === replacedSocket.OPEN) { - replacedSocket.close(4000, 'Device reconnected'); +type SendTally = { attempted: number; sent: number }; +function dispatchDesktopNotification(userId: unknown, payload: unknown): { tally: SendTally; settled: Promise } { + const release = enterNotificationActivity(); + let settled: Promise | undefined; + const pending: Promise[] = []; + try { + const ownerId = userIdOrNull(userId); + const userClients = ownerId === null ? undefined : clientsByUser.get(ownerId); + if (ownerId === null || !userClients?.size) return { tally: { attempted: 0, sent: 0 }, settled: Promise.resolve() }; + + const enabledEndpoints = new Set( + notificationChannelEndpointsDb.getEnabledEndpoints(ownerId, CHANNEL_DESKTOP).map(({ endpoint_id }) => endpoint_id), + ); + const message = serializeNotification(payload); + const tally = { attempted: 0, sent: 0 }; + + for (const [endpointId, socket] of userClients) { + if (!enabledEndpoints.has(endpointId)) continue; + tally.attempted += 1; + if (socket.readyState !== socket.OPEN) { forgetClient(socket); continue; } + + let finish!: () => void; + pending.push(new Promise((resolve) => { finish = resolve; })); + let finished = false; + const sent = (error?: Error): void => { + if (finished) return; + finished = true; + // Transport completion is not evidence of presentation in the UI. + try { if (error) forgetClient(socket); } finally { finish(); } + }; + try { socket.send(message, sent); } + catch { forgetClient(socket); sent(); continue; } + try { + notificationChannelEndpointsDb.touchEndpoint(ownerId, CHANNEL_DESKTOP, endpointId); + tally.sent += 1; + } catch { forgetClient(socket); } + } + settled = Promise.all(pending).then(() => undefined).finally(release); + return { tally, settled }; + } finally { + // A socket close or a caller dropping the synchronous tally cannot release + // an issued send. Its callback owns the remaining lifetime. + if (!settled) { + if (pending.length) void Promise.all(pending).finally(release); + else release(); + } } - - userClients.set(endpointId, ws); - registrationForSocket.set(ws, { userId: ownerId, endpointId }); - return endpoint; } -export function unregisterDesktopNotificationClient(ws: WebSocket): void { forgetClient(ws); } +/** Historical synchronous result: `sent` counts enqueue success, not UI delivery. */ +export function sendDesktopNotification(userId: unknown, payload: unknown): SendTally { + return dispatchDesktopNotification(userId, payload).tally; +} -export function sendDesktopNotification(userId: unknown, payload: unknown): { attempted: number; sent: number } { - const ownerId = userIdOrNull(userId); - const userClients = ownerId === null ? undefined : clientsByUser.get(ownerId); - if (ownerId === null || !userClients?.size) return { attempted: 0, sent: 0 }; - - const enabledEndpoints = new Set( - notificationChannelEndpointsDb.getEnabledEndpoints(ownerId, CHANNEL_DESKTOP).map(({ endpoint_id }) => endpoint_id), - ); - const message = serializeNotification(payload); - const tally = { attempted: 0, sent: 0 }; - - for (const [endpointId, socket] of userClients) { - if (!enabledEndpoints.has(endpointId)) continue; - tally.attempted += 1; - if (socket.readyState !== socket.OPEN) { forgetClient(socket); continue; } - - try { - socket.send(message); - notificationChannelEndpointsDb.touchEndpoint(ownerId, CHANNEL_DESKTOP, endpointId); - tally.sent += 1; - } catch { forgetClient(socket); } - } - return tally; +/** Facades can retain the actual transport lifetime without changing the tally API. */ +export function sendDesktopNotificationAndWait(userId: unknown, payload: unknown): Promise { + const { tally, settled } = dispatchDesktopNotification(userId, payload); + return settled.then(() => tally); } diff --git a/server/modules/notifications/services/desktop-update-activity.service.ts b/server/modules/notifications/services/desktop-update-activity.service.ts new file mode 100644 index 00000000..16afdf15 --- /dev/null +++ b/server/modules/notifications/services/desktop-update-activity.service.ts @@ -0,0 +1,28 @@ +import { randomUUID } from 'node:crypto'; + +import type { DesktopWorkAdmission } from '@/shared/interfaces.js'; + +import type { DesktopOwnerActivity } from '../../../../shared/desktopUpdateProtocol.js'; + +const epoch = randomUUID(); +let revision = 0n; +let active = 0; +let admission: DesktopWorkAdmission | undefined; + +export function configureNotificationDesktopAdmission(value: DesktopWorkAdmission): void { + if (admission && admission !== value) throw new Error('Notification admission already configured.'); + if (admission === value) return; + admission = value; + revision++; +} +export function enterNotificationActivity(owned = true): () => void { + const release = owned ? admission?.enterCompletion('notification:completion') : admission?.enter('notification:dispatch'); + active++; revision++; + let done = false; + return () => { if (done) return; done = true; active--; revision++; release?.(); }; +} +export const getNotificationActivityGeneration = (): string => `${epoch}:${revision}`; +export function snapshotNotificationActivity(): DesktopOwnerActivity { + return { owner: 'notifications', generation: getNotificationActivityGeneration(), complete: true, + starting: 0, queued: 0, running: active, settling: 0, approvals: 0, retained: 0, unknown: [] }; +} diff --git a/server/modules/notifications/services/gjc-terminal-notification-adapter.service.ts b/server/modules/notifications/services/gjc-terminal-notification-adapter.service.ts index 2ed82b77..166b3eb6 100644 --- a/server/modules/notifications/services/gjc-terminal-notification-adapter.service.ts +++ b/server/modules/notifications/services/gjc-terminal-notification-adapter.service.ts @@ -8,6 +8,7 @@ import { createNotificationEvent, notifyUserIfEnabled, } from '@/modules/notifications/services/notification-orchestrator.service.js'; +import { enterNotificationActivity } from '@/modules/notifications/services/desktop-update-activity.service.js'; import type { JobProjectionEvent, JobTerminalOutcome } from '../../../../shared/gjc-job-projection-protocol.js'; @@ -30,6 +31,7 @@ type NotificationFacade = { createNotificationEvent(event: Record): unknown; notifyUserIfEnabled(input: { userId: number | null; event: unknown }): unknown; }; +type DispatchResult = { result: 'accepted' | 'deduped' | 'failed'; settled?: Promise }; export type GjcTerminalNotificationAdapter = { onCommittedEvent(jobId: string, event: JobProjectionEvent): 'accepted' | 'deduped' | 'ignored' | 'failed'; @@ -90,30 +92,36 @@ export function createGjcTerminalNotificationAdapter( event: JobProjectionEvent, payload: TerminalPayload, advanceCursor: boolean, - ): 'accepted' | 'deduped' | 'failed' => { - const userId = resolveUserId(); - const dispatch = { - jobId, - eventId: event.eventId, - sequence: event.sequence, - runId: payload.runId, - appSessionId: payload.appSessionId ?? null, - userId, - outcome: payload.outcome, - claimToken: randomUUID(), - } as const; - let claimed: boolean; + ): DispatchResult => { + let release: () => void; + try { release = enterNotificationActivity(); } + catch { return { result: 'failed' }; } + let settled: Promise | undefined; + let claimToken: string | undefined; + const recordFailure = (error: unknown): void => { + if (!claimToken) return; + try { gjcTerminalNotificationDispatchesDb.markFailed(claimToken, boundedFailure(error)); } + catch { /* A notification failure must not change durable job completion. */ } + }; try { - claimed = advanceCursor + const userId = resolveUserId(); + const dispatch = { + jobId, + eventId: event.eventId, + sequence: event.sequence, + runId: payload.runId, + appSessionId: payload.appSessionId ?? null, + userId, + outcome: payload.outcome, + claimToken: randomUUID(), + } as const; + const claimed = advanceCursor ? gjcTerminalNotificationDispatchesDb.claimAndAdvanceCursor(dispatch) : gjcTerminalNotificationDispatchesDb.claim(dispatch); - } catch { - return 'failed'; - } - if (!claimed) return 'deduped'; + if (!claimed) return { result: 'deduped' }; + claimToken = dispatch.claimToken; - try { - notifications.notifyUserIfEnabled({ + const delivery = notifications.notifyUserIfEnabled({ userId, event: notifications.createNotificationEvent({ provider: 'gjc', @@ -127,15 +135,23 @@ export function createGjcTerminalNotificationAdapter( dedupeKey: `gjc:job-terminal:${jobId}:${event.eventId}`, }), }); + if (delivery !== null && (typeof delivery === 'object' || typeof delivery === 'function') + && typeof (delivery as { then?: unknown }).then === 'function') { + settled = Promise.resolve(delivery) + .then(() => { gjcTerminalNotificationDispatchesDb.markAccepted(dispatch.claimToken); }) + .catch(recordFailure) + .finally(release); + // Keep the synchronous API: accepted means handed to the facade. The + // durable claim remains pending until it settles, not until UI display. + return { result: 'accepted', settled }; + } gjcTerminalNotificationDispatchesDb.markAccepted(dispatch.claimToken); - return 'accepted'; + return { result: 'accepted' }; } catch (error) { - try { - gjcTerminalNotificationDispatchesDb.markFailed(dispatch.claimToken, boundedFailure(error)); - } catch { - // A notification failure must not change durable job completion. - } - return 'failed'; + recordFailure(error); + return { result: 'failed' }; + } finally { + if (!settled) release(); } }; @@ -154,7 +170,7 @@ export function createGjcTerminalNotificationAdapter( || sequence < 1) continue; const event = { eventId: candidate.eventId, sequence, payload: candidate.payload } as JobProjectionEvent; const payload = terminalPayload(event.payload); - if (payload) notifyClaimed(jobId, event, payload, true); + if (payload) await notifyClaimed(jobId, event, payload, true).settled; else gjcTerminalNotificationDispatchesDb.advanceCursor(jobId, event.sequence); after = Math.max(after, event.sequence); progressed = true; @@ -167,34 +183,38 @@ export function createGjcTerminalNotificationAdapter( return { onCommittedEvent(jobId, event) { const payload = terminalPayload(event.payload); - return payload ? notifyClaimed(jobId, event, payload, false) : 'ignored'; + if (!payload) return 'ignored'; + return notifyClaimed(jobId, event, payload, false).result; }, async startupCatchUp(): Promise { - if (!options.authority.list) return; - const jobs: JobSnapshot[] = []; - let afterCursor: string | undefined; - for (;;) { - const response = await options.authority.list({ provider: 'gjc', afterCursor, limit: 100 }); - const page = snapshots(response); - jobs.push(...page); - const nextCursor = listNextCursor(response); - if (nextCursor) { - afterCursor = nextCursor; - continue; + const release = enterNotificationActivity(false); + try { + if (!options.authority.list) return; + const jobs: JobSnapshot[] = []; + let afterCursor: string | undefined; + for (;;) { + const response = await options.authority.list({ provider: 'gjc', afterCursor, limit: 100 }); + const page = snapshots(response); + jobs.push(...page); + const nextCursor = listNextCursor(response); + if (nextCursor) { + afterCursor = nextCursor; + continue; + } + // Legacy array responses carry no cursor; fall back to length-based + // termination for that shape only. + if (!Array.isArray(response) || page.length < 100) break; + afterCursor = page[page.length - 1]?.jobId; + if (!afterCursor) break; } - // Legacy array responses carry no cursor; fall back to length-based - // termination for that shape only. - if (!Array.isArray(response) || page.length < 100) break; - afterCursor = page[page.length - 1]?.jobId; - if (!afterCursor) break; - } - const initialized = gjcTerminalNotificationDispatchesDb.initializeBaseline(jobs.map((job) => ({ - jobId: job.jobId, - lastSequence: Number.isSafeInteger(job.lastSequence) && job.lastSequence! >= 0 ? job.lastSequence! : 0, - }))); - if (initialized) return; - for (const job of jobs) await scanJob(job.jobId); + const initialized = gjcTerminalNotificationDispatchesDb.initializeBaseline(jobs.map((job) => ({ + jobId: job.jobId, + lastSequence: Number.isSafeInteger(job.lastSequence) && job.lastSequence! >= 0 ? job.lastSequence! : 0, + }))); + if (initialized) return; + for (const job of jobs) await scanJob(job.jobId); + } finally { release(); } }, }; } diff --git a/server/modules/notifications/services/notification-orchestrator.service.js b/server/modules/notifications/services/notification-orchestrator.service.js index 7c2ccfda..19a95c72 100644 --- a/server/modules/notifications/services/notification-orchestrator.service.js +++ b/server/modules/notifications/services/notification-orchestrator.service.js @@ -1,5 +1,6 @@ import { notificationPreferencesDb, sessionsDb } from '@/modules/database/index.js'; -import { sendDesktopNotification } from '@/modules/notifications/services/desktop-notification-clients.service.js'; +import { sendDesktopNotificationAndWait } from '@/modules/notifications/services/desktop-notification-clients.service.js'; +import { enterNotificationActivity } from '@/modules/notifications/services/desktop-update-activity.service.js'; const eventPreferences = new Map([ ['action_required', 'actionRequired'], @@ -105,14 +106,20 @@ function reportDesktopFailure(error) { function notifyUserIfEnabled({ userId, event }) { if (!userId || !event) return; - - const current = canonicalSession(event); - const preferences = notificationPreferencesDb.getPreferences(userId); - if (!eventIsAllowed(preferences, current) || wasDelivered(current)) return; - if (!preferences?.channels?.desktop) return; - - const payload = buildNotificationPayload(current); - Promise.resolve(sendDesktopNotification(userId, payload)).catch(reportDesktopFailure); + const release = enterNotificationActivity(); + let pending; + try { + const current = canonicalSession(event); + const preferences = notificationPreferencesDb.getPreferences(userId); + if (!eventIsAllowed(preferences, current) || wasDelivered(current)) return; + if (!preferences?.channels?.desktop) return; + + const payload = buildNotificationPayload(current); + pending = Promise.resolve(sendDesktopNotificationAndWait(userId, payload)).catch(reportDesktopFailure).finally(release); + return pending; + } finally { + if (!pending) release(); + } } function errorText(error) { @@ -122,7 +129,7 @@ function errorText(error) { } function notifyRunStopped({ userId, provider, sessionId = null, stopReason = 'completed', sessionName = null }) { - notifyUserIfEnabled({ + return notifyUserIfEnabled({ userId, event: createNotificationEvent({ provider, sessionId, kind: 'stop', code: 'run.stopped', meta: { stopReason, sessionName }, severity: 'info', @@ -133,7 +140,7 @@ function notifyRunStopped({ userId, provider, sessionId = null, stopReason = 'co function notifyRunFailed({ userId, provider, sessionId = null, error, sessionName = null }) { const message = errorText(error); - notifyUserIfEnabled({ + return notifyUserIfEnabled({ userId, event: createNotificationEvent({ provider, sessionId, kind: 'error', code: 'run.failed', meta: { error: message, sessionName }, severity: 'error', diff --git a/server/modules/notifications/tests/desktop-update-activity.test.ts b/server/modules/notifications/tests/desktop-update-activity.test.ts new file mode 100644 index 00000000..b2c9c252 --- /dev/null +++ b/server/modules/notifications/tests/desktop-update-activity.test.ts @@ -0,0 +1,353 @@ +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; + +import type { WebSocket } from 'ws'; + +import { closeConnection, getConnection, gjcTerminalNotificationDispatchesDb, initializeDatabase, notificationChannelEndpointsDb, notificationPreferencesDb, userDb } from '@/modules/database/index.js'; +import { configureNotificationDesktopAdmission, getNotificationActivityGeneration, snapshotNotificationActivity } from '@/modules/notifications/index.js'; +import { enterNotificationActivity } from '@/modules/notifications/services/desktop-update-activity.service.js'; +import { registerDesktopNotificationClient, sendDesktopNotification, unregisterDesktopNotificationClient } from '@/modules/notifications/services/desktop-notification-clients.service.js'; +import { createGjcTerminalNotificationAdapter } from '@/modules/notifications/services/gjc-terminal-notification-adapter.service.js'; +import { createNotificationEvent, notifyRunFailed, notifyRunStopped, notifyUserIfEnabled } from '@/modules/notifications/services/notification-orchestrator.service.js'; +import { handleDesktopNotificationsConnection } from '@/modules/notifications/websocket/desktop-notifications-websocket.service.js'; +import type { DesktopWorkAdmission } from '@/shared/interfaces.js'; +import type { AuthenticatedWebSocketRequest } from '@/shared/types.js'; + +// The module consumes only the injected admission interface. The real restart +// authority's prepare/commit behavior is tested at its composition boundary. +class Admission implements DesktopWorkAdmission { + fenced = false; + committed = false; + active = 0; + enter(): () => void { + if (this.fenced) throw Object.assign(new Error('Restart fenced.'), { code: 'DESKTOP_RESTART_FENCED' }); + return this.acquire(); + } + enterCompletion(): () => void { + if (this.committed) throw Object.assign(new Error('Restart fenced.'), { code: 'DESKTOP_RESTART_FENCED' }); + return this.acquire(); + } + private acquire(): () => void { + this.active++; + let released = false; + return () => { assert.equal(released, false, 'release exactly once'); released = true; this.active--; }; + } + snapshot() { + return { idle: this.active === 0 && snapshotNotificationActivity().running === 0, ingress: this.active }; + } +} + +let authority: Admission | undefined; +const admission = { + enter: () => authority?.enter() ?? (() => {}), + enterCompletion: () => authority?.enterCompletion() ?? (() => {}), +}; +configureNotificationDesktopAdmission(admission); + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); + return { promise, resolve, reject }; +} +const tick = () => new Promise((resolve) => setImmediate(resolve)); + +class Socket extends EventEmitter { + OPEN = 1; + readyState = 1; + delay = false; + throwSend = false; + frames: Array> = []; + pending: Array<(error?: Error) => void> = []; + send(message: string, callback?: (error?: Error) => void) { + if (this.throwSend) throw new Error('socket send failed'); + const frame = JSON.parse(message) as Record; + this.frames.push(frame); + if (callback && this.delay && frame.type === 'notification') this.pending.push(callback); + else callback?.(); + } + close() { this.readyState = 3; this.emit('close'); } + complete(error?: Error) { for (const callback of this.pending.splice(0)) callback(error); } + get ws() { return this as unknown as WebSocket; } +} + +async function fixture(t: test.TestContext) { + const previous = process.env.DATABASE_PATH; + const directory = await mkdtemp(join(tmpdir(), 'notification-activity-')); + closeConnection(); + process.env.DATABASE_PATH = join(directory, 'auth.db'); + await initializeDatabase(); + const userId = Number(userDb.createUser('notification-activity-user', 'hash').id); + notificationPreferencesDb.updatePreferences(userId, { + channels: { desktop: true }, events: { actionRequired: true, stop: true, error: true }, + }); + const current = new Admission(); + authority = current; + const sockets: Socket[] = []; + const socket = () => { const value = new Socket(); sockets.push(value); return value; }; + t.after(async () => { + authority = undefined; + for (const value of sockets) value.complete(); + await tick(); + for (const value of sockets) unregisterDesktopNotificationClient(value.ws); + closeConnection(); + if (previous === undefined) delete process.env.DATABASE_PATH; + else process.env.DATABASE_PATH = previous; + await rm(directory, { recursive: true, force: true }); + }); + const connect = (value: Socket, deviceId = 'device-1') => { + handleDesktopNotificationsConnection(value.ws, { user: { id: userId } } as AuthenticatedWebSocketRequest); + value.emit('message', JSON.stringify({ type: 'register', deviceId })); + }; + return { userId, authority: current, socket, connect }; +} + +const terminal = (id = 'terminal-1') => ({ + eventId: id, sequence: 1, + payload: { schemaVersion: 1, kind: 'job_terminal', runId: 'run-1', appSessionId: 'app-1', outcome: 'succeeded', reason: 'completed' }, +}); +const dispatchRow = () => getConnection().prepare('SELECT status, failure FROM gjc_terminal_notification_dispatches').get() as { status: string; failure: string | null }; + +test('notification revision is pure, monotonic and release is idempotent', async (t) => { + const f = await fixture(t); + const before = snapshotNotificationActivity(); + configureNotificationDesktopAdmission(admission); + assert.deepEqual(snapshotNotificationActivity(), before); + assert.equal(getNotificationActivityGeneration(), before.generation); + const release = enterNotificationActivity(false); + const busy = snapshotNotificationActivity(); + assert.equal(busy.running, 1); + assert.notEqual(busy.generation, before.generation); + assert.equal(f.authority.snapshot().idle, false); + release(); + const after = snapshotNotificationActivity(); + release(); + assert.deepEqual(snapshotNotificationActivity(), after); + assert.deepEqual({ ...after, generation: before.generation }, before); + assert.ok(BigInt(after.generation.split(':').at(-1)!) > BigInt(busy.generation.split(':').at(-1)!)); +}); + +test('stop and failure notification facades return their delayed send lifetimes', async (t) => { + const f = await fixture(t); + const socket = f.socket(); socket.delay = true; f.connect(socket); + const stopped = notifyRunStopped({ userId: f.userId, provider: 'gjc', stopReason: 'delayed stop' }); + const failed = notifyRunFailed({ userId: f.userId, provider: 'gjc', error: 'delayed failure' }); + assert.ok(stopped instanceof Promise); + assert.ok(failed instanceof Promise); + let settled = false; + const both = Promise.all([stopped, failed]).then(() => { settled = true; }); + await tick(); + assert.equal(settled, false); + assert.equal(socket.pending.length, 2); + // Both facades and both underlying sends must still own their leases. A + // premature outer finally would leave only the two transport leases here. + assert.equal(snapshotNotificationActivity().running, 4); + assert.equal(f.authority.snapshot().idle, false); + socket.close(); + assert.equal(settled, false); + assert.equal((await f.authority.snapshot()).idle, false); + socket.complete(); + await both; + assert.equal((await f.authority.snapshot()).idle, true); +}); + +test('the synchronous send tally remains owned after it is returned and a socket closes', async (t) => { + const f = await fixture(t); + const socket = f.socket(); socket.delay = true; f.connect(socket); + const tally = sendDesktopNotification(f.userId, { data: { tag: 'queued-not-presented' } }); + assert.deepEqual(tally, { attempted: 1, sent: 1 }); + assert.equal('then' in tally, false); + socket.close(); + await tick(); + assert.equal((await f.authority.snapshot()).idle, false); + socket.complete(new Error('transport failed later')); + await tick(); + assert.equal((await f.authority.snapshot()).idle, true); + // It remains the original enqueue tally, never a claim of UI presentation. + assert.deepEqual(tally, { attempted: 1, sent: 1 }); +}); + +test('dedupe and disabled-event paths stay synchronous and do not release an earlier send', async (t) => { + const f = await fixture(t); + const socket = f.socket(); socket.delay = true; f.connect(socket); + const event = { ...createNotificationEvent({ provider: 'gjc', kind: 'stop', code: 'run.stopped' }), dedupeKey: 'pending-dedupe' }; + const pending = notifyUserIfEnabled({ userId: f.userId, event }); + const active = snapshotNotificationActivity().running; + assert.equal(active, 2); + assert.equal(notifyUserIfEnabled({ userId: f.userId, event }), undefined); + assert.equal(snapshotNotificationActivity().running, active); + notificationPreferencesDb.updatePreferences(f.userId, { events: { stop: false } }); + assert.equal(notifyRunStopped({ userId: f.userId, provider: 'gjc', stopReason: 'disabled stop' }), undefined); + assert.equal(snapshotNotificationActivity().running, active); + assert.equal(socket.pending.length, 1); + socket.complete(); + await pending; + assert.equal((await f.authority.snapshot()).idle, true); +}); + +test('an existing socket fences each new registration but accepts owned close and inert acknowledgement', async (t) => { + const f = await fixture(t); + const socket = f.socket(); + handleDesktopNotificationsConnection(socket.ws, { user: { id: f.userId } } as AuthenticatedWebSocketRequest); + assert.equal(f.authority.snapshot().idle, true); + f.authority.fenced = true; + assert.doesNotThrow(() => socket.emit('message', JSON.stringify({ type: 'register', deviceId: 'late-device' }))); + assert.equal(socket.frames.at(-1)?.code, 'DESKTOP_RESTART_FENCED'); + assert.deepEqual(notificationChannelEndpointsDb.getEndpoints(f.userId, 'desktop'), []); + assert.throws(() => registerDesktopNotificationClient({ ws: socket.ws, userId: f.userId, deviceId: 'direct' }), { code: 'DESKTOP_RESTART_FENCED' }); + f.authority.fenced = false; + socket.emit('message', JSON.stringify({ type: 'register', deviceId: 'late-device' })); + assert.equal(socket.frames.at(-1)?.type, 'registered'); + assert.equal(f.authority.snapshot().idle, true); + f.authority.fenced = true; + const generation = getNotificationActivityGeneration(); + socket.emit('message', JSON.stringify({ type: 'notification_ack' })); + assert.equal(getNotificationActivityGeneration(), generation); + assert.doesNotThrow(() => socket.close()); + assert.notEqual(getNotificationActivityGeneration(), generation); + assert.deepEqual(sendDesktopNotification(f.userId, {}), { attempted: 0, sent: 0 }); +}); + +test('an owned notification may finish under a closed fence and changes the owner revision', async (t) => { + const f = await fixture(t); + const socket = f.socket(); socket.delay = true; f.connect(socket); + assert.equal(f.authority.snapshot().idle, true); + const generation = getNotificationActivityGeneration(); + f.authority.fenced = true; + const pending = notifyRunStopped({ userId: f.userId, provider: 'gjc', stopReason: 'accepted before restart' }); + assert.ok(pending instanceof Promise); + assert.equal(socket.pending.length, 1); + socket.complete(); + await pending; + assert.notEqual(getNotificationActivityGeneration(), generation); +}); + +test('late errors from a replaced socket do not remove the newly registered client', async (t) => { + const f = await fixture(t); + const first = f.socket(); first.delay = true; f.connect(first); + sendDesktopNotification(f.userId, {}); + const replacement = f.socket(); f.connect(replacement); + assert.equal(first.readyState, 3); + first.complete(new Error('retired send failed')); + await tick(); + assert.deepEqual(sendDesktopNotification(f.userId, {}), { attempted: 1, sent: 1 }); + assert.equal(replacement.frames.filter((frame) => frame.type === 'notification').length, 1); + await tick(); + assert.equal((await f.authority.snapshot()).idle, true); +}); + +test('postcommit socket callbacks and synchronous send failures do not crash or leak activity', async (t) => { + const f = await fixture(t); + const socket = f.socket(); f.connect(socket); socket.throwSend = true; + assert.deepEqual(sendDesktopNotification(f.userId, {}), { attempted: 1, sent: 0 }); + await tick(); + socket.throwSend = false; + registerDesktopNotificationClient({ ws: socket.ws, userId: f.userId, deviceId: 'device-1' }); + assert.equal(f.authority.snapshot().idle, true); + f.authority.fenced = true; + f.authority.committed = true; + assert.doesNotThrow(() => socket.close()); + assert.doesNotThrow(() => socket.emit('error', new Error('late socket error'))); + assert.throws(() => notifyRunFailed({ userId: f.userId, provider: 'gjc', error: 'not silently dropped after commit' }), { code: 'DESKTOP_RESTART_FENCED' }); +}); + +test('terminal facade promises retain the claim until acceptance is durably decided', async (t) => { + const f = await fixture(t); + const delivery = deferred(); + let sends = 0; + const adapter = createGjcTerminalNotificationAdapter({ + authority: { replayEvents: async () => ({ events: [] }) }, resolveUserId: () => f.userId, + notifications: { createNotificationEvent: (event) => event, notifyUserIfEnabled: () => { sends++; return delivery.promise; } }, + }); + const markAccepted = gjcTerminalNotificationDispatchesDb.markAccepted; + t.mock.method(gjcTerminalNotificationDispatchesDb, 'markAccepted', (claim: string) => { + assert.ok(snapshotNotificationActivity().running > 0); + markAccepted(claim); + }); + assert.equal(adapter.onCommittedEvent('job-1', terminal()), 'accepted'); + assert.equal(dispatchRow().status, 'claimed'); + assert.equal(adapter.onCommittedEvent('job-1', terminal()), 'deduped'); + assert.equal(sends, 1); + assert.equal(f.authority.snapshot().idle, false); + delivery.resolve(); + await tick(); + assert.equal(dispatchRow().status, 'accepted'); + assert.equal((await f.authority.snapshot()).idle, true); +}); + +test('a rejected terminal facade promise records failure before releasing and is not retried', async (t) => { + const f = await fixture(t); + const delivery = deferred(); + const unhandled: unknown[] = []; + const capture = (error: unknown) => { unhandled.push(error); }; + process.on('unhandledRejection', capture); + t.after(() => process.off('unhandledRejection', capture)); + let sends = 0; + const markFailed = gjcTerminalNotificationDispatchesDb.markFailed; + t.mock.method(gjcTerminalNotificationDispatchesDb, 'markFailed', (claim: string, failure: string) => { + assert.ok(snapshotNotificationActivity().running > 0); + markFailed(claim, failure); + }); + const adapter = createGjcTerminalNotificationAdapter({ + authority: { replayEvents: async () => ({ events: [] }) }, resolveUserId: () => f.userId, + notifications: { createNotificationEvent: (event) => event, notifyUserIfEnabled: () => { sends++; return delivery.promise; } }, + }); + assert.equal(adapter.onCommittedEvent('job-1', terminal()), 'accepted'); + delivery.reject(new Error('asynchronous facade failed')); + await tick(); + assert.equal(dispatchRow().status, 'failed'); + assert.match(dispatchRow().failure!, /asynchronous facade failed/); + assert.equal(adapter.onCommittedEvent('job-1', terminal()), 'deduped'); + assert.equal(sends, 1); + assert.deepEqual(unhandled, []); + assert.equal((await f.authority.snapshot()).idle, true); +}); + +test('startup catch-up owns list, replay, facade and ledger settlement and rejects new scans while fenced', async (t) => { + const f = await fixture(t); + const listing = deferred(); const delivery = deferred(); const sending = deferred(); + let listCalls = 0; + const adapter = createGjcTerminalNotificationAdapter({ + authority: { + list: async () => { listCalls++; await listing.promise; return [{ jobId: 'job-1', lastSequence: 0 }]; }, + replayEvents: async () => ({ events: [terminal()] }), + }, resolveUserId: () => f.userId, + notifications: { createNotificationEvent: (event) => event, notifyUserIfEnabled: () => { sending.resolve(); return delivery.promise; } }, + }); + const baseline = adapter.startupCatchUp(); + assert.equal(snapshotNotificationActivity().running, 1); + assert.equal(f.authority.snapshot().idle, false); + listing.resolve(); + await baseline; + let caughtUp = false; + const catchUp = adapter.startupCatchUp().then(() => { caughtUp = true; }); + await sending.promise; + assert.equal(caughtUp, false); + assert.equal(dispatchRow().status, 'claimed'); + assert.ok(snapshotNotificationActivity().running >= 2); + delivery.resolve(); + await catchUp; + assert.equal(dispatchRow().status, 'accepted'); + assert.equal((await f.authority.snapshot()).idle, true); + f.authority.fenced = true; + await assert.rejects(adapter.startupCatchUp(), { code: 'DESKTOP_RESTART_FENCED' }); + assert.equal(listCalls, 2); +}); + +test('failed startup list and synchronous facade failures release exactly once', async (t) => { + const f = await fixture(t); + const adapter = createGjcTerminalNotificationAdapter({ + authority: { list: async () => { throw new Error('list failed'); }, replayEvents: async () => ({ events: [] }) }, + resolveUserId: () => f.userId, + notifications: { createNotificationEvent: (event) => event, notifyUserIfEnabled: () => { throw new Error('facade failed'); } }, + }); + await assert.rejects(adapter.startupCatchUp(), /list failed/); + assert.equal(adapter.onCommittedEvent('job-1', terminal()), 'failed'); + assert.equal(dispatchRow().status, 'failed'); + assert.equal(snapshotNotificationActivity().running, 0); + assert.equal((await f.authority.snapshot()).ingress, 0); +}); diff --git a/server/modules/notifications/tests/notification-orchestrator.test.js b/server/modules/notifications/tests/notification-orchestrator.test.js index a469d70b..c54cbe1e 100644 --- a/server/modules/notifications/tests/notification-orchestrator.test.js +++ b/server/modules/notifications/tests/notification-orchestrator.test.js @@ -44,8 +44,9 @@ test('desktop payload uses the app session id when notified with a provider sess const fakeSocket = { OPEN: 1, readyState: 1, - send(message) { + send(message, callback) { sentMessages.push(JSON.parse(message)); + callback?.(); }, close() {}, }; diff --git a/server/modules/notifications/websocket/desktop-notifications-websocket.service.ts b/server/modules/notifications/websocket/desktop-notifications-websocket.service.ts index af105d6d..75a4b7cc 100644 --- a/server/modules/notifications/websocket/desktop-notifications-websocket.service.ts +++ b/server/modules/notifications/websocket/desktop-notifications-websocket.service.ts @@ -22,7 +22,12 @@ function requestUserId(request: AuthenticatedWebSocketRequest): number | null { } function sendWhenOpen(ws: WebSocket, message: unknown): void { - if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(message)); + try { if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(message), () => {}); } + catch { /* A failed control response must not crash an existing connection. */ } +} + +function closeSafely(ws: WebSocket, code: number, reason: string): void { + try { ws.close(code, reason); } catch { /* Socket teardown can race this callback. */ } } function registerCommand(message: DesktopNotificationRegisterMessage): string { @@ -33,7 +38,7 @@ function registerCommand(message: DesktopNotificationRegisterMessage): string { export function handleDesktopNotificationsConnection(ws: WebSocket, request: AuthenticatedWebSocketRequest): void { const userId = requestUserId(request); - if (userId === null) return ws.close(1008, 'Missing authenticated user'); + if (userId === null) return closeSafely(ws, 1008, 'Missing authenticated user'); let boundToClient = false; ws.on('message', (incoming) => { @@ -47,7 +52,7 @@ export function handleDesktopNotificationsConnection(ws: WebSocket, request: Aut if (deviceId === null) { const rejection = { type: 'error', code: 'DEVICE_ID_REQUIRED', message: 'Desktop notification registration requires deviceId.' }; sendWhenOpen(ws, rejection); - return ws.close(1008, 'Missing deviceId'); + return closeSafely(ws, 1008, 'Missing deviceId'); } const registration = { @@ -56,15 +61,33 @@ export function handleDesktopNotificationsConnection(ws: WebSocket, request: Aut platform: nonEmptyText(message.platform), appVersion: nonEmptyText(message.appVersion), }; - const endpoint = registerDesktopNotificationClient(registration); - if (!endpoint) return ws.close(1011, 'Registration failed'); - - boundToClient = true; - const confirmation = { type: 'registered', deviceId: endpoint.endpoint_id, enabled: Boolean(endpoint.enabled) }; - sendWhenOpen(ws, confirmation); + try { + // Registration owns a fresh root on every message, not just at upgrade. + const endpoint = registerDesktopNotificationClient(registration); + if (!endpoint) return closeSafely(ws, 1011, 'Registration failed'); + boundToClient = true; + const confirmation = { type: 'registered', deviceId: endpoint.endpoint_id, enabled: Boolean(endpoint.enabled) }; + sendWhenOpen(ws, confirmation); + } catch (error) { + if (error && typeof error === 'object' && 'code' in error && error.code === 'DESKTOP_RESTART_FENCED') { + sendWhenOpen(ws, { type: 'error', code: 'DESKTOP_RESTART_FENCED', message: 'Desktop restart admission is fenced.' }); + return; // Leave the unbound connection able to retry after cancellation. + } + sendWhenOpen(ws, { type: 'error', code: 'REGISTRATION_FAILED', message: 'Desktop notification registration failed.' }); + closeSafely(ws, 1011, 'Registration failed'); + } }); - const unregister = (): void => unregisterDesktopNotificationClient(ws); + const unregister = (): void => { + try { unregisterDesktopNotificationClient(ws); } + catch (error) { + // Precommit closes are owned completions. After commit, do not let a + // rejected callback crash shutdown or resume notification work. + if (!(error && typeof error === 'object' && 'code' in error && error.code === 'DESKTOP_RESTART_FENCED')) { + console.error('Desktop notification unregister failed:', error); + } + } + }; ws.on('close', unregister); ws.on('error', unregister); } diff --git a/server/modules/projects/projects.routes.ts b/server/modules/projects/projects.routes.ts index 329a1865..63d739d9 100644 --- a/server/modules/projects/projects.routes.ts +++ b/server/modules/projects/projects.routes.ts @@ -156,7 +156,7 @@ router.post('/migrate-legacy-stars', asyncHandler(async (request, response) => { response.json({ success: true, updated: applyLegacyStarredProjectIds(projectIds).updated }); })); -router.get('/clone-progress', async (request, response) => { +router.get('/clone-progress', asyncHandler(async (request, response) => { response.setHeader('Content-Type', 'text/event-stream'); response.setHeader('Cache-Control', 'no-cache'); response.setHeader('Connection', 'keep-alive'); @@ -194,16 +194,16 @@ router.get('/clone-progress', async (request, response) => { request.off('close', cancelClone); if (!response.writableEnded) response.end(); } -}); +})); -router.put('/:projectId/rename', (request, response) => { +router.put('/:projectId/rename', asyncHandler((request, response) => { try { const body: { displayName?: unknown } = request.body; updateProjectDisplayName(routeProjectId(request.params.projectId), body.displayName); } catch (error) { response.status(500).json({ error: error instanceof Error ? error.message : 'Failed to rename project' }); } -}); +})); router.post('/:projectId/toggle-star', asyncHandler(async (request, response) => { response.json({ success: true, isStarred: toggleProjectStar(routeProjectId(request.params.projectId)).isStarred }); diff --git a/server/modules/projects/services/project-clone.service.ts b/server/modules/projects/services/project-clone.service.ts index eae82a78..9abcf38b 100644 --- a/server/modules/projects/services/project-clone.service.ts +++ b/server/modules/projects/services/project-clone.service.ts @@ -7,6 +7,7 @@ import { githubTokensDb } from '@/modules/database/index.js'; import { createProject } from '@/modules/projects/services/project-management.service.js'; import type { WorkspacePathValidationResult } from '@/shared/types.js'; import { AppError, validateWorkspacePath } from '@/shared/utils.js'; +import { enterInternalActivity, markInternalActivityUncertain } from '@/shared/desktop-internal-activity.js'; type CloneProjectInput = { workspacePath: string; githubUrl: string; githubTokenId?: number | null; newGithubToken?: string | null; userId: number | string }; type CloneCompletePayload = { project: Record; message: string }; @@ -144,6 +145,20 @@ const cloneDependencies: CloneProjectDependencies = { }; export async function startCloneProject(input: CloneProjectInput, handlers: CloneProjectEventHandlers, dependencies: CloneProjectDependencies = cloneDependencies): Promise { + const release = enterInternalActivity('clone:start'); + try { + const operation = await startCloneProjectOwned(input, handlers, dependencies); + // Returning the handle or disconnecting its HTTP waiter is not settlement. + const waitForCompletion = operation.waitForCompletion.finally(release); + void waitForCompletion.catch(() => {}); + return { waitForCompletion, cancel: operation.cancel }; + } catch (error) { + release(); + throw error; + } +} + +async function startCloneProjectOwned(input: CloneProjectInput, handlers: CloneProjectEventHandlers, dependencies: CloneProjectDependencies): Promise { const workspacePath = input.workspacePath.trim(); const githubUrl = input.githubUrl.trim(); requireCloneInput(workspacePath, 'WORKSPACE_PATH_REQUIRED'); @@ -164,9 +179,13 @@ export async function startCloneProject(input: CloneProjectInput, handlers: Clon } const workspace = await dependencies.createCloneWorkspace(destination); - const cleanup = () => workspace.cleanup().catch((error: unknown) => { - dependencies.logError('Failed to clean up clone staging directory:', error); - }); + const cleanup = async () => { + try { await workspace.cleanup(); } + catch (error) { + markInternalActivityUncertain('clone:cleanup_failed'); + dependencies.logError('Failed to clean up clone staging directory:', error); + } + }; let child: GitCloneProcess; try { handlers.onProgress(`Cloning into '${name}'...`); @@ -176,19 +195,24 @@ export async function startCloneProject(input: CloneProjectInput, handlers: Clon throw error; } let stderr = ''; + let processFailure: AppError | undefined; + let closed = false; + let cancelled = false; const reportOutput = (chunk: Buffer | string, isErrorOutput: boolean): void => { const message = chunk.toString().replaceAll(workspace.path, destination).trim(); if (isErrorOutput) stderr = message; - if (message) handlers.onProgress(message); + if (message && !closed) { + try { handlers.onProgress(message); } + catch (error) { processFailure ??= new AppError(messageFor(error), { code: 'GIT_EXECUTION_FAILED', statusCode: 500 }); } + } }; child.stdout?.on('data', (chunk: Buffer | string) => reportOutput(chunk, false)); child.stderr?.on('data', (chunk: Buffer | string) => reportOutput(chunk, true)); const completion = new Promise((resolve, reject) => { - let settled = false; const finish = (failure?: AppError) => { - if (settled) return; - settled = true; + if (closed) return; + closed = true; const complete = async () => { try { if (failure) throw failure; @@ -213,10 +237,19 @@ export async function startCloneProject(input: CloneProjectInput, handlers: Clon child.on('error', (error) => { const missingGit = error.code === 'ENOENT'; const failure = { code: missingGit ? 'GIT_NOT_FOUND' : 'GIT_EXECUTION_FAILED', statusCode: 500 }; - finish(new AppError(missingGit ? 'Git is not installed or not in PATH' : error.message, failure)); + processFailure ??= new AppError(missingGit ? 'Git is not installed or not in PATH' : error.message, failure); }); - child.on('close', (exitCode) => finish(exitCode === 0 ? undefined : new AppError(cloneFailureMessage(stderr, token), { code: 'GIT_CLONE_FAILED', statusCode: 500 }))); + // Neither spawn/kill error nor cancellation acknowledgement proves close. + // Never clean staging while Git can still be writing into it. + child.on('close', (exitCode) => finish(processFailure ?? (exitCode === 0 && !cancelled ? undefined : new AppError(cloneFailureMessage(stderr, token), { code: 'GIT_CLONE_FAILED', statusCode: 500 })))); }); - return { waitForCompletion: completion, cancel: () => child.kill() }; + return { + waitForCompletion: completion, + cancel: () => { + if (closed) return; + const release = enterInternalActivity('clone:cancel', true); + try { cancelled = true; child.kill(); } finally { release(); } + }, + }; } diff --git a/server/modules/projects/tests/project-clone.service.test.ts b/server/modules/projects/tests/project-clone.service.test.ts index ec6e0843..09c16b4b 100644 --- a/server/modules/projects/tests/project-clone.service.test.ts +++ b/server/modules/projects/tests/project-clone.service.test.ts @@ -8,8 +8,25 @@ import { test } from 'node:test'; import { createCloneWorkspace, startCloneProject as beginProjectClone } from '@/modules/projects/services/project-clone.service.js'; import { AppError as ProjectCloneError } from '@/shared/utils.js'; +import { configureInternalDesktopAdmission, getInternalActivityGeneration, snapshotInternalActivity } from '@/shared/desktop-internal-activity.js'; type CloneDependencies = NonNullable[2]>; +let fenced = false; +let ingress = 0; +const sources: string[] = []; +const acquire = () => { + ingress++; + let released = false; + return () => { assert.equal(released, false); released = true; ingress--; }; +}; +configureInternalDesktopAdmission({ + enter(source) { + sources.push(source); + if (fenced) throw Object.assign(new Error('Desktop restart fenced.'), { code: 'DESKTOP_RESTART_FENCED' }); + return acquire(); + }, + enterCompletion(source) { sources.push(source); return acquire(); }, +}); function cloneInput(overrides: Partial[0]> = {}) { return { @@ -217,3 +234,153 @@ test('a failed publication removes its empty reservation and private staging onl await workspace.cleanup(); assert.deepEqual(await readdir(root), []); }); + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((yes) => { resolve = yes; }); + return { promise, resolve }; +} +const tick = () => new Promise((resolve) => setImmediate(resolve)); + +test('clone admission is fresh and precedes even its first path-validation await', async (t) => { + fenced = true; + t.after(() => { fenced = false; }); + let validated = false; + await assert.rejects(beginProjectClone(cloneInput(), createCloneEvents().handlers, cloneDependencies({ + validatePath: async () => { validated = true; return { valid: true, resolvedPath: '/fixture' }; }, + })), { code: 'DESKTOP_RESTART_FENCED' }); + assert.equal(validated, false); + assert.equal(ingress, 0); + assert.equal(snapshotInternalActivity().running, 0); +}); + +test('clone preparation transfers its ingress to the returned completion without a zero-count gap', async (t) => { + const validation = deferred<{ valid: boolean; resolvedPath: string }>(); + const child = createCloneProcess(); + const before = getInternalActivityGeneration(); + const starting = beginProjectClone(cloneInput(), createCloneEvents().handlers, cloneDependencies({ + validatePath: () => validation.promise, spawnGitClone: () => child, + })); + assert.equal(ingress, 1); assert.equal(snapshotInternalActivity().running, 1); + assert.notEqual(getInternalActivityGeneration(), before); + fenced = true; // Accepted preparation must not attempt another fresh root. + t.after(() => { fenced = false; }); + validation.resolve({ valid: true, resolvedPath: '/fixture' }); + const clone = await starting; + assert.equal(ingress, 1); assert.equal(snapshotInternalActivity().running, 1); + assert.equal(typeof clone.cancel, 'function'); + child.emit('close', 0); await clone.waitForCompletion; + assert.equal(ingress, 0); assert.equal(snapshotInternalActivity().running, 0); +}); + +test('clone error and exit do not clean staging before close or release before cleanup settles', async () => { + const child = createCloneProcess(); + const cleanup = deferred(); const cleaning = deferred(); + let cleaned = 0; + const clone = await beginProjectClone(cloneInput(), createCloneEvents().handlers, cloneDependencies({ + createCloneWorkspace: async () => ({ path: '/fixture/staging', publish: async () => assert.fail('errored clone cannot publish'), cleanup: async () => { cleaned++; cleaning.resolve(); await cleanup.promise; } }), + spawnGitClone: () => child, + })); + let settled = false; + const result = clone.waitForCompletion.finally(() => { settled = true; }); + const rejected = assert.rejects(result, { code: 'GIT_NOT_FOUND' }); + child.emit('error', Object.assign(new Error('missing fixture git'), { code: 'ENOENT' })); + child.emit('exit', 1); + await tick(); + assert.equal(cleaned, 0); assert.equal(settled, false); + assert.equal(snapshotInternalActivity().running, 1); assert.equal(ingress, 1); + child.emit('close', 0); await cleaning.promise; + child.emit('close', 1); // Duplicate close cannot create a second cleanup. + assert.equal(cleaned, 1); assert.equal(settled, false); + assert.equal(snapshotInternalActivity().running, 1); + cleanup.resolve(); await rejected; + assert.equal(snapshotInternalActivity().running, 0); assert.equal(ingress, 0); +}); + +test('clone completion owns publication, project registration, UI callback and cleanup after real close', async () => { + const child = createCloneProcess(); const events = createCloneEvents(); + const publication = deferred(); const publishing = deferred(); + const registration = deferred(); const registering = deferred(); + const cleanup = deferred(); const cleaning = deferred(); + const clone = await beginProjectClone(cloneInput(), events.handlers, cloneDependencies({ + createCloneWorkspace: async () => ({ + path: '/fixture/staging', + publish: async () => { publishing.resolve(); await publication.promise; }, + cleanup: async () => { cleaning.resolve(); await cleanup.promise; }, + }), + spawnGitClone: () => child, + registerProject: async () => { registering.resolve(); await registration.promise; return { project: { name: 'fixture' } }; }, + })); + let settled = false; void clone.waitForCompletion.then(() => { settled = true; }); + child.emit('close', 0); await publishing.promise; + assert.equal(ingress, 1); assert.equal(settled, false); + publication.resolve(); await registering.promise; + assert.equal(snapshotInternalActivity().running, 1); assert.equal(settled, false); + registration.resolve(); await cleaning.promise; + assert.ok(events.getCompletion()); + assert.equal(snapshotInternalActivity().running, 1); assert.equal(settled, false); + cleanup.resolve(); await clone.waitForCompletion; + assert.equal(ingress, 0); assert.equal(snapshotInternalActivity().running, 0); +}); + +test('cancel stays synchronous under a fence but cannot settle a still-open clone or publish a late successful exit', async (t) => { + const child = createCloneProcess(); let kills = 0; let cleaned = 0; + child.kill = () => { kills++; }; + sources.length = 0; + const clone = await beginProjectClone(cloneInput(), createCloneEvents().handlers, cloneDependencies({ + createCloneWorkspace: async () => ({ path: '/fixture/staging', publish: async () => assert.fail('cancelled clone cannot publish'), cleanup: async () => { cleaned++; } }), + spawnGitClone: () => child, + })); + fenced = true; + t.after(() => { fenced = false; }); + const rejected = assert.rejects(clone.waitForCompletion, { code: 'GIT_CLONE_FAILED' }); + assert.equal(clone.cancel(), undefined); + assert.equal(kills, 1); assert.equal(cleaned, 0); + assert.equal(snapshotInternalActivity().running, 1); assert.equal(ingress, 1); + assert.deepEqual(sources, ['clone:start', 'clone:cancel']); + child.emit('close', 0); await rejected; + assert.equal(cleaned, 1); assert.equal(ingress, 0); + clone.cancel(); assert.equal(kills, 1); +}); + +test('a synchronous clone spawn failure stays owned through delayed staging cleanup', async () => { + const cleanup = deferred(); const cleaning = deferred(); + const pending = beginProjectClone(cloneInput(), createCloneEvents().handlers, cloneDependencies({ + createCloneWorkspace: async () => ({ path: '/fixture/staging', publish: async () => {}, cleanup: async () => { cleaning.resolve(); await cleanup.promise; } }), + spawnGitClone: () => { throw new Error('synchronous spawn failure'); }, + })); + const rejected = assert.rejects(pending, /synchronous spawn failure/); + await cleaning.promise; + assert.equal(snapshotInternalActivity().running, 1); assert.equal(ingress, 1); + cleanup.resolve(); await rejected; + assert.equal(snapshotInternalActivity().running, 0); assert.equal(ingress, 0); +}); + +test('an abandoned clone waiter handles failure without dropping the actual child lifetime', async (t) => { + const child = createCloneProcess(); + const unhandled: unknown[] = []; + const capture = (error: unknown) => { unhandled.push(error); }; + process.on('unhandledRejection', capture); + t.after(() => process.off('unhandledRejection', capture)); + await beginProjectClone(cloneInput(), createCloneEvents().handlers, cloneDependencies({ spawnGitClone: () => child })); + child.emit('error', new Error('abandoned waiter failure')); await tick(); + assert.equal(snapshotInternalActivity().running, 1); + assert.deepEqual(unhandled, []); + child.emit('close', 1); await tick(); + assert.equal(snapshotInternalActivity().running, 0); assert.equal(ingress, 0); + assert.deepEqual(unhandled, []); +}); + +test('clone cleanup failure preserves the logged result contract but leaves bounded restart uncertainty', async () => { + const child = createCloneProcess(); const logged: string[] = []; + const clone = await beginProjectClone(cloneInput(), createCloneEvents().handlers, cloneDependencies({ + spawnGitClone: () => child, + createCloneWorkspace: async () => ({ path: '/fixture/staging', publish: async () => {}, cleanup: () => { throw new Error('fixture cleanup failed'); } }), + logError: (message) => { logged.push(message); }, + })); + child.emit('close', 0); await clone.waitForCompletion; + assert.equal(logged.length, 1); + assert.equal(snapshotInternalActivity().running, 0); assert.equal(ingress, 0); + assert.equal(snapshotInternalActivity().complete, false); + assert.deepEqual(snapshotInternalActivity().unknown, ['clone:cleanup_failed']); +}); diff --git a/server/modules/providers/index.ts b/server/modules/providers/index.ts index 1cdffc43..d2df05be 100644 --- a/server/modules/providers/index.ts +++ b/server/modules/providers/index.ts @@ -3,6 +3,11 @@ import { closeSessionsWatcher as closeWatcher, initializeSessionsWatcher as initializeWatcher, } from './services/sessions-watcher.service.js'; +export { + configureSessionsWatcherDesktopAdmission, + getSessionsWatcherActivityGeneration, + snapshotSessionsWatcherActivity, +} from './services/sessions-watcher.service.js'; import { sessionSynchronizerService as synchronizer } from './services/session-synchronizer.service.js'; import { configureSessionWorktrees as configureWorktrees } from './services/session-worktrees.service.js'; diff --git a/server/modules/providers/services/gjc-session-watcher.service.ts b/server/modules/providers/services/gjc-session-watcher.service.ts index 95f76ebb..3a533d42 100644 --- a/server/modules/providers/services/gjc-session-watcher.service.ts +++ b/server/modules/providers/services/gjc-session-watcher.service.ts @@ -1,6 +1,8 @@ import { spawn as spawnChild } from 'node:child_process'; import { fileURLToPath } from 'node:url'; +import type { DesktopWorkAdmission } from '@/shared/interfaces.js'; + const MAX_FRAME_BYTES = 64 * 1024; const MAX_QUEUED_PATHS = 4096; const FAILURE_MESSAGE = 'GJC session watcher failed.'; @@ -51,6 +53,8 @@ export type GjcSessionWatcherOptions = { closeExitTimeoutMs?: number; diagnostic?: (message: string) => void; compiled?: boolean; + desktopAdmission?: DesktopWorkAdmission; + onActivityChange?: () => void; }; function deferred(): { promise: Promise; resolve: () => void; reject: (error: Error) => void } { @@ -70,14 +74,6 @@ function timeout(ms: number): Promise { }); } -function safeCall(callback: () => unknown): void { - try { - void Promise.resolve(callback()).catch(() => {}); - } catch { - // Callback failures must not escape process event handlers. - } -} - /** Watches GJC transcript files through the mandatory native Protocol v1 host. */ export class GjcSessionWatcher { private readonly options: Required> & Pick; @@ -96,8 +92,14 @@ export class GjcSessionWatcher { private drainDone = deferred(); private drainCancelled = false; private readonly drainAbort = new AbortController(); + private readonly desktopAdmission?: DesktopWorkAdmission; + private readonly onActivityChange: () => void; + private callbacks = 0; + private admissionRetry?: ReturnType; constructor(options: GjcSessionWatcherOptions) { + this.desktopAdmission = options.desktopAdmission; + this.onActivityChange = options.onActivityChange ?? (() => {}); this.options = { roots: options.roots, onEvent: options.onEvent, @@ -114,6 +116,19 @@ export class GjcSessionWatcher { }; } + snapshotActivity() { + return { starting: this.starting && !this.ready && !this.closed ? 1 : 0, + queued: this.pending.size, running: Number(this.draining) + this.callbacks, + settling: this.closed && this.child && !this.exitedOnce ? 1 : 0, + unknown: this.failed && !this.exitedOnce ? ['watcher_exit_unconfirmed'] : [] }; + } + private changed(): void { this.onActivityChange(); } + private callback(work: () => unknown): void { + this.callbacks++; this.changed(); + const done = () => { this.callbacks--; this.changed(); }; + try { void Promise.resolve(work()).then(done, done); } catch { done(); } + } + start(): Promise { if (this.closed) return Promise.reject(new Error(FAILURE_MESSAGE)); if (this.starting) return this.starting; @@ -125,6 +140,7 @@ export class GjcSessionWatcher { )); const args = ['watch', ...this.options.roots.flatMap((root) => ['--root', root])]; this.starting = this.waitForReady(); + this.changed(); try { const child = this.options.spawn(corePath, args, { detached: false, @@ -133,6 +149,7 @@ export class GjcSessionWatcher { windowsHide: true, }); this.child = child; + this.changed(); child.stdout.on('data', (chunk) => this.onStdout(chunk)); child.stderr?.on('data', () => this.diagnose(STDERR_MESSAGE)); child.stdin.on?.('error', () => this.fail()); @@ -194,6 +211,7 @@ export class GjcSessionWatcher { if (record.kind === 'ready') { if (this.ready || keys.length !== 2 || !keys.includes('protocolVersion') || !keys.includes('kind')) return this.fail(); this.ready = true; + this.changed(); this.started.resolve(); return; } @@ -214,6 +232,7 @@ export class GjcSessionWatcher { } if (!this.pending.has(record.path) && this.pending.size >= MAX_QUEUED_PATHS) return this.fail(); this.pending.set(record.path, { kind: record.event, path: record.path }); + this.changed(); void this.drain(); } @@ -221,36 +240,53 @@ export class GjcSessionWatcher { if (this.draining) return; this.drainDone = deferred(); this.draining = true; + this.changed(); try { while (!this.failed && !this.drainCancelled && this.pending.size > 0) { const event = this.pending.values().next().value as GjcSessionWatchEvent; + let release: (() => void) | undefined; + try { release = this.desktopAdmission?.enter('watcher:synchronize'); } + catch (error) { + if (error && typeof error === 'object' && 'code' in error && error.code === 'DESKTOP_RESTART_FENCED') { + // Retain the accepted path. It invalidates an idle proof and can + // resume after cancellation; it is never silently discarded. + if (!this.admissionRetry) this.admissionRetry = setTimeout(() => { this.admissionRetry = undefined; if (!this.closed) void this.drain(); }, 25); + return; + } + throw error; + } this.pending.delete(event.path); + this.changed(); try { await this.options.onEvent(event, this.drainAbort.signal); } catch { if (!this.drainCancelled) this.diagnose(CALLBACK_FAILURE_MESSAGE); + } finally { + release?.(); } } } finally { this.draining = false; + this.changed(); if (this.pending.size === 0 || this.drainCancelled) this.drainDone.resolve(); } } private diagnose(message: string): void { - safeCall(() => this.options.diagnostic(message)); + this.callback(() => this.options.diagnostic(message)); } private fail(): void { if (this.failed || this.closed) return; this.failed = true; + this.changed(); this.drainCancelled = true; this.pending.clear(); this.drainAbort.abort(); this.drainDone.resolve(); this.started.reject(new Error(FAILURE_MESSAGE)); this.diagnose(FAILURE_MESSAGE); - safeCall(() => this.options.onFailure(new Error(FAILURE_MESSAGE))); + this.callback(() => this.options.onFailure(new Error(FAILURE_MESSAGE))); try { this.child?.kill('SIGKILL'); } catch { @@ -261,6 +297,7 @@ export class GjcSessionWatcher { private onExit(): void { if (this.exitedOnce) return; this.exitedOnce = true; + this.changed(); this.exited.resolve(); if (!this.closed) this.fail(); } @@ -269,6 +306,9 @@ export class GjcSessionWatcher { if (this.closing) return this.closing; if (!this.ready && !this.failed) this.started.reject(new Error(FAILURE_MESSAGE)); this.closed = true; + if (this.admissionRetry) clearTimeout(this.admissionRetry); + this.admissionRetry = undefined; + this.changed(); this.closing = (async () => { try { this.child?.stdin.end(); @@ -298,6 +338,7 @@ export class GjcSessionWatcher { await Promise.race([this.exited.promise, timeout(this.options.closeExitTimeoutMs)]); } } + if ((this.child && !this.exitedOnce) || this.draining) throw new Error('GJC watcher shutdown is unconfirmed.'); })(); return this.closing; } diff --git a/server/modules/providers/services/sessions-watcher.service.ts b/server/modules/providers/services/sessions-watcher.service.ts index 92a06c47..0f4cc877 100644 --- a/server/modules/providers/services/sessions-watcher.service.ts +++ b/server/modules/providers/services/sessions-watcher.service.ts @@ -1,6 +1,7 @@ import { promises as fs } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import { randomUUID } from 'node:crypto'; import { projectsDb, sessionsDb } from '@/modules/database/index.js'; import { generateDisplayName } from '@/modules/projects/index.js'; @@ -8,8 +9,11 @@ import { GjcSessionWatcher } from '@/modules/providers/services/gjc-session-watc import { sessionSynchronizerService } from '@/modules/providers/services/session-synchronizer.service.js'; import { WS_OPEN_STATE, connectedClients } from '@/modules/websocket/index.js'; import type { LLMProvider } from '@/shared/types.js'; +import type { DesktopWorkAdmission } from '@/shared/interfaces.js'; import { getGjcLiveSessionRoot } from '@/shared/utils.js'; +import type { DesktopOwnerActivity } from '../../../../shared/desktopUpdateProtocol.js'; + type WatcherEventType = 'add' | 'change'; type PendingWatcherUpdate = { providers: Set; changeTypes: Set; updatedSessionIdsByProvider: Map> }; @@ -31,6 +35,43 @@ let queuedSince: number | null = null; let flushTimer: ReturnType | null = null; let flushRunning = false; let flushAgain = false; +const activityEpoch = randomUUID(); +let activityRevision = 0n; +let desktopAdmission: DesktopWorkAdmission | undefined; +let initializationCount = 0; +const initializations = new Set>(); +const retiringWatchers = new Set(); +const closingTasks = new Set>(); + +function activityChanged(): void { + activityRevision += 1n; + for (const watcher of retiringWatchers) { + const state = watcher.snapshotActivity(); + if (!state.starting && !state.queued && !state.running && !state.settling && !state.unknown.length) retiringWatchers.delete(watcher); + } +} +export function configureSessionsWatcherDesktopAdmission(admission: DesktopWorkAdmission): void { + if (desktopAdmission && desktopAdmission !== admission) throw new Error('Watcher admission already configured.'); + desktopAdmission = admission; +} +export function getSessionsWatcherActivityGeneration(): string { return `${activityEpoch}:${activityRevision}`; } +export function snapshotSessionsWatcherActivity(): DesktopOwnerActivity { + const watchers = [...new Set([activeWatcher, openingWatcher, ...retiringWatchers].filter((watcher): watcher is GjcSessionWatcher => watcher !== null))]; + const parts = watchers.map((watcher) => watcher.snapshotActivity()); + const sum = (key: 'starting' | 'queued' | 'running' | 'settling') => parts.reduce((count, item) => count + item[key], 0); + const unknown = [...new Set(parts.flatMap((item) => item.unknown))]; + if (!activeWatcher && !isClosing) unknown.push('watcher_not_ready'); + return { owner: 'watchers', generation: getSessionsWatcherActivityGeneration(), complete: unknown.length === 0, + starting: initializationCount + startingTasks.size + sum('starting'), queued: Number(Boolean(queued)) + Number(Boolean(restartTimer)) + sum('queued'), + running: Number(flushRunning) + sum('running'), settling: closingTasks.size + sum('settling'), approvals: 0, retained: 0, unknown }; +} + +function closeTracked(watcher: GjcSessionWatcher): Promise { + retiringWatchers.add(watcher); activityChanged(); + const task = watcher.close().finally(() => { closingTasks.delete(task); activityChanged(); }); + closingTasks.add(task); activityChanged(); + return task; +} function clearTimer(timer: 'restart' | 'flush'): void { if (timer === 'restart') { @@ -40,6 +81,7 @@ function clearTimer(timer: 'restart' | 'flush'): void { if (flushTimer) clearTimeout(flushTimer); flushTimer = null; } + activityChanged(); } function enqueue(kind: WatcherEventType, provider: LLMProvider, sessionId: string | null): void { @@ -52,6 +94,7 @@ function enqueue(kind: WatcherEventType, provider: LLMProvider, sessionId: strin queued.updatedSessionIdsByProvider.set(provider, ids); } armFlush(); + activityChanged(); } function armFlush(): void { @@ -61,6 +104,7 @@ function armFlush(): void { const wait = Math.min(debounceMs, Math.max(0, maxDelayMs - (now - queuedSince))); clearTimer('flush'); flushTimer = setTimeout(() => { void deliverQueuedUpdates(); }, wait); + activityChanged(); } async function sessionFrame(provider: LLMProvider, providerSessionId: string): Promise { @@ -92,6 +136,7 @@ async function deliverQueuedUpdates(): Promise { queued = null; queuedSince = null; flushRunning = true; + activityChanged(); try { const frames: string[] = []; for (const [provider, ids] of batch.updatedSessionIdsByProvider) { @@ -114,6 +159,7 @@ async function deliverQueuedUpdates(): Promise { flushAgain = false; armFlush(); } + activityChanged(); } } @@ -137,9 +183,11 @@ function scheduleRestart(): void { restartDelay = Math.min(restartDelay * 2, maxRestartMs); restartTimer = setTimeout(() => { restartTimer = null; + activityChanged(); void startGjcSessionWatcher(true); }, delay); restartTimer.unref?.(); + activityChanged(); } async function openGjcWatcher(reconcile: boolean, controller: AbortController): Promise { @@ -165,9 +213,10 @@ async function openGjcWatcher(reconcile: boolean, controller: AbortController): const watcher = slot.current; if (activeWatcher === watcher) activeWatcher = null; console.error('GJC native session watcher failed.'); - void watcher?.close().catch(() => {}).finally(() => { + void (watcher ? closeTracked(watcher) : Promise.resolve()).catch(() => {}).finally(() => { if (openingWatcher === watcher) openingWatcher = null; scheduleRestart(); + activityChanged(); }); }; slot.current = new GjcSessionWatcher({ @@ -175,61 +224,79 @@ async function openGjcWatcher(reconcile: boolean, controller: AbortController): onEvent: (event, eventSignal) => synchronizeFile(event.kind, event.path, 'gjc', eventSignal), onFailure: failed, diagnostic: (message) => console.error(message), + desktopAdmission, + onActivityChange: activityChanged, }); const watcher = slot.current; openingWatcher = watcher; + activityChanged(); try { await watcher.start(); if (reported || isClosing || epoch !== watcherEpoch) { - await watcher.close(); + await closeTracked(watcher); return; } if (openingWatcher === watcher) openingWatcher = null; activeWatcher = watcher; + activityChanged(); if (reconcile) { const result = await sessionSynchronizerService.reconcileProvider('gjc', signal); if (reported || isClosing || epoch !== watcherEpoch) { if (activeWatcher === watcher) activeWatcher = null; - await watcher.close(); + await closeTracked(watcher); return; } result.sessionIds.forEach((sessionId) => enqueue('change', 'gjc', sessionId)); } if (reported || isClosing || epoch !== watcherEpoch) { if (activeWatcher === watcher) activeWatcher = null; - await watcher.close(); + await closeTracked(watcher); return; } restartDelay = 1_000; } catch { failed(); - await watcher.close(); + await closeTracked(watcher); } } function startGjcSessionWatcher(reconcile = false): Promise { if (isClosing || activeWatcher || openingWatcher) return Promise.resolve(); + let release: (() => void) | undefined; + try { release = desktopAdmission?.enter('watcher:start'); } + catch { scheduleRestart(); return Promise.resolve(); } const controller = new AbortController(); startControllers.add(controller); - const task = openGjcWatcher(reconcile, controller); + const task = openGjcWatcher(reconcile, controller).finally(() => release?.()); startingTasks.add(task); + activityChanged(); void task.then( - () => { startControllers.delete(controller); startingTasks.delete(task); }, - () => { startControllers.delete(controller); startingTasks.delete(task); }, + () => { startControllers.delete(controller); startingTasks.delete(task); activityChanged(); }, + () => { startControllers.delete(controller); startingTasks.delete(task); activityChanged(); }, ); return task; } -export async function initializeSessionsWatcher(): Promise { +async function initializeOwned(): Promise { console.log('Setting up session watchers'); isClosing = false; await startGjcSessionWatcher(); + if (isClosing) return; const initialSync = await sessionSynchronizerService.synchronizeSessions(); console.log('Initial session synchronization complete', { processedByProvider: initialSync.processedByProvider, failures: initialSync.failures }); } +export function initializeSessionsWatcher(): Promise { + const release = desktopAdmission?.enter('watcher:initialize'); + initializationCount++; activityChanged(); + const task = initializeOwned().finally(() => { initializationCount--; initializations.delete(task); release?.(); activityChanged(); }); + initializations.add(task); + return task; +} + export async function closeSessionsWatcher(): Promise { isClosing = true; + activityChanged(); watcherEpoch += 1; clearTimer('restart'); clearTimer('flush'); @@ -239,12 +306,15 @@ export async function closeSessionsWatcher(): Promise { activeWatcher = null; openingWatcher = null; await Promise.all([ - ...watchers.map((watcher) => watcher.close().catch(() => { console.error('Failed to close GJC native session watcher.'); })), + ...watchers.map((watcher) => closeTracked(watcher).catch(() => { console.error('Failed to close GJC native session watcher.'); })), ...tasks.map((task) => task.catch(() => { console.error('Failed to stop GJC native session watcher startup.'); })), + ...[...initializations].map((task) => task.catch(() => {})), ]); restartDelay = 1_000; queued = null; queuedSince = null; - flushRunning = false; + // An already-running publication still owns its actual async callback. flushAgain = false; + activityChanged(); + if (retiringWatchers.size || flushRunning) throw new Error('Session watcher cleanup is unconfirmed.'); } diff --git a/server/modules/providers/tests/gjc-session-watcher.test.ts b/server/modules/providers/tests/gjc-session-watcher.test.ts index 5b32592a..46bad63a 100644 --- a/server/modules/providers/tests/gjc-session-watcher.test.ts +++ b/server/modules/providers/tests/gjc-session-watcher.test.ts @@ -168,7 +168,7 @@ test('fails closed when distinct queued paths exceed the fixed bound', async () release(); }); -test('close cancels queued callbacks at its deadline and reaps a non-exiting child', async () => { +test('close cancellation and kill requests do not claim unsettled callbacks or process exit', async () => { let release!: () => void; const hold = new Promise((resolve) => { release = resolve; }); const callbacks: string[] = []; @@ -186,22 +186,49 @@ test('close cancels queued callbacks at its deadline and reaps a non-exiting chi child.output('{"protocolVersion":1,"kind":"event","event":"add","path":"warmup"}\n'); await new Promise((resolve) => setImmediate(resolve)); child.output('{"protocolVersion":1,"kind":"event","event":"add","path":"accepted"}\n'); - await watcher.close(); + await assert.rejects(watcher.close(), /shutdown is unconfirmed/); assert.deepEqual(child.kills, ['SIGKILL']); assert.deepEqual(callbacks, ['warmup']); assert.equal(callbackSignal?.aborted, true); + assert.equal(watcher.snapshotActivity().running, 1); + assert.equal(watcher.snapshotActivity().settling, 1); release(); + child.emit('close'); await new Promise((resolve) => setImmediate(resolve)); assert.deepEqual(callbacks, ['warmup']); + assert.equal(watcher.snapshotActivity().running, 0); + assert.equal(watcher.snapshotActivity().settling, 0); }); test('close before readiness rejects the pending start without reporting a runtime failure', async () => { - const { watcher, failures } = setup(); + const { watcher, child, failures } = setup(); const started = watcher.start(); const rejected = assert.rejects(started, /GJC session watcher failed\./u); + child.stdin.end = () => child.emit('close'); await watcher.close(); await rejected; assert.equal(failures.length, 0); }); + +test('fenced watcher dispatch retains the path and resumes without duplicating it', async () => { + let fenced = true; let calls = 0; let active = 0; let revision = 0; + const { watcher, child } = setup({ + desktopAdmission: { + enter() { if (fenced) throw Object.assign(new Error('fenced'), { code: 'DESKTOP_RESTART_FENCED' }); active++; return () => { active--; }; }, + enterCompletion() { throw new Error('not a completion'); }, + }, + onActivityChange() { revision++; }, + onEvent() { assert.equal(active, 1); calls++; }, + }); + await ready(watcher, child); + child.output('{"protocolVersion":1,"kind":"event","event":"change","path":"fixture.jsonl"}\n'); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(calls, 0); assert.equal(watcher.snapshotActivity().queued, 1); + const before = revision; fenced = false; + for (let attempt = 0; attempt < 100 && !calls; attempt++) await new Promise((resolve) => setTimeout(resolve, 2)); + assert.equal(calls, 1); assert.equal(active, 0); assert.ok(revision > before); + assert.equal(watcher.snapshotActivity().queued, 0); + child.stdin.end = () => child.emit('close'); await watcher.close(); +}); diff --git a/server/modules/websocket/services/chat-run-registry.service.ts b/server/modules/websocket/services/chat-run-registry.service.ts index ea23344d..e7a7d35b 100644 --- a/server/modules/websocket/services/chat-run-registry.service.ts +++ b/server/modules/websocket/services/chat-run-registry.service.ts @@ -8,6 +8,8 @@ import { connectedClients, WS_OPEN_STATE } from '@/modules/websocket/services/we import { generateMessageId } from '@/shared/utils.js'; import type { LLMProvider, NormalizedMessage, RealtimeClientConnection } from '@/shared/types.js'; +import type { DesktopOwnerActivity } from '../../../../shared/desktopUpdateProtocol.js'; + type ChatRunStatus = 'running' | 'completed'; type ChatRun = { appSessionId: string; provider: LLMProvider; providerSessionId: string | null; @@ -33,10 +35,17 @@ type StartRunInput = { const completedRunLifetime = 5 * 60 * 1000; const eventBufferLimit = 5000; const runsByAppSession = new Map(); +const activityEpoch = randomUUID(); +let activityRevision = 0n; +let pendingPublications = 0; +const getGeneration = (): string => `${activityEpoch}:${activityRevision}`; function scheduleCompletedRunRemoval(run: ChatRun): void { const timer = setTimeout(() => { - if (runsByAppSession.get(run.appSessionId) === run && run.status === 'completed') runsByAppSession.delete(run.appSessionId); + if (runsByAppSession.get(run.appSessionId) === run && run.status === 'completed') { + runsByAppSession.delete(run.appSessionId); + activityRevision += 1n; + } }, completedRunLifetime); void timer.unref?.(); } @@ -46,6 +55,7 @@ function decorateRunEvent(run: ChatRun, event: NormalizedMessage): NormalizedMes if (run.status === 'completed' && event.kind === 'complete') return null; const sequence = ++run.lastSeq; + activityRevision += 1n; const publishedEvent: NormalizedMessage = { ...event, id: event.id || generateMessageId(event.kind), @@ -80,6 +90,13 @@ function decorateRunEvent(run: ChatRun, event: NormalizedMessage): NormalizedMes } async function broadcastSessionUpsert(sessionId: string): Promise { + pendingPublications += 1; + activityRevision += 1n; + try { await publishSessionUpsert(sessionId); } + finally { pendingPublications -= 1; activityRevision += 1n; } +} + +async function publishSessionUpsert(sessionId: string): Promise { const session = sessionsDb.getSessionById(sessionId); if (!session || session.isArchived) return; @@ -119,6 +136,7 @@ async function broadcastSessionUpsert(sessionId: string): Promise { function persistProviderSessionId(run: ChatRun, providerSessionId: string): void { if (!providerSessionId || providerSessionId === run.providerSessionId) return; run.providerSessionId = providerSessionId; + activityRevision += 1n; const context = { appSessionId: run.appSessionId, providerSessionId }; const report = (label: string, error: unknown) => { const message = error instanceof Error ? error.message : String(error); @@ -179,12 +197,27 @@ function isCurrentRunningRun(run: ChatRun): boolean { } export const chatRunRegistry = { + getGeneration, + + /** Registry ownership only; worker/SDK settlement is a separate required reader. */ + snapshotActivity(): DesktopOwnerActivity { + let running = 0; + let approvals = 0; + for (const run of runsByAppSession.values()) { + if (run.status === 'running') running += 1; + approvals += run.pendingApprovals.size; + } + return { owner: 'chat', generation: getGeneration(), complete: true, starting: 0, + queued: 0, running, settling: pendingPublications, approvals, retained: 0, unknown: [] }; + }, + startRun(input: StartRunInput): ChatRun | null { const currentRun = runsByAppSession.get(input.appSessionId); if (currentRun?.status === 'running') return null; const run = createRun(input); runsByAppSession.set(input.appSessionId, run); + activityRevision += 1n; return run; }, @@ -214,6 +247,14 @@ export const chatRunRegistry = { * decision can be persisted against the provider's tool name rather than * whatever the browser claims. */ + getPendingApproval(requestId: string): PendingApproval | null { + for (const run of runsByAppSession.values()) { + const pending = run.pendingApprovals.get(requestId); + if (pending) return pending; + } + return null; + }, + resolvePendingApproval(requestId: string): PendingApproval | null { let resolved: PendingApproval | null = null; for (const run of runsByAppSession.values()) { @@ -221,6 +262,7 @@ export const chatRunRegistry = { if (pending) { resolved ??= pending; run.pendingApprovals.delete(requestId); + activityRevision += 1n; } } return resolved; @@ -231,12 +273,14 @@ export const chatRunRegistry = { const run = runsByAppSession.get(appSessionId); if (!run) return false; run.writer.attachConnection(connection); + activityRevision += 1n; return true; }, /** A socket went away; no run keeps sending to it. */ detachConnection(connection: RealtimeClientConnection): void { for (const run of runsByAppSession.values()) run.writer.detachConnection(connection); + activityRevision += 1n; }, replayEvents(appSessionId: AppSessionId, afterSeq: number, replayGeneration?: unknown): NormalizedMessage[] { @@ -260,5 +304,6 @@ export const chatRunRegistry = { clearAll(): void { runsByAppSession.clear(); + activityRevision += 1n; }, }; diff --git a/server/modules/websocket/services/chat-websocket.service.ts b/server/modules/websocket/services/chat-websocket.service.ts index 80624bad..2c457f30 100644 --- a/server/modules/websocket/services/chat-websocket.service.ts +++ b/server/modules/websocket/services/chat-websocket.service.ts @@ -8,6 +8,7 @@ import { chatRunRegistry } from '@/modules/websocket/services/chat-run-registry. import { connectedClients, WS_OPEN_STATE } from '@/modules/websocket/services/websocket-state.service.js'; import type { GjcJobProjectionService } from '@/modules/websocket/services/gjc-job-projection.service.js'; import { getGlobalImageAssetsDir, normalizeImageDescriptors } from '@/shared/image-attachments.js'; +import type { DesktopWorkAdmission } from '@/shared/interfaces.js'; import type { AnyRecord, AuthenticatedWebSocketRequest, LLMProvider } from '@/shared/types.js'; import { createNormalizedMessage, parseIncomingJsonObject } from '@/shared/utils.js'; @@ -23,6 +24,7 @@ type OAuthSupervisor = { oauthProviders(): Promise; oauthStatus(): Promise; oauthStart(providerId: string): Promise; oauthSubmit(attemptId: string, value: string): Promise; oauthCancel(attemptId: string): Promise; subscribeOAuth(listener: (event: OAuthEvent) => void): () => void; }; type ChatWebSocketDependencies = { + desktopRestartAdmission?: DesktopWorkAdmission; goalSupervisor?: GoalSupervisor; sessionWorktrees?: SessionWorktreeRuntime; spawnFns: Record; @@ -364,11 +366,12 @@ export function handleChatConnection(ws: WebSocket, request: AuthenticatedWebSoc const result = await handleChatGoal(userId, data, dependencies.goalSupervisor, async (sessionId, command) => { // Run ownership remains in the existing chat pipeline; controls do // not introduce a second execution loop or background task system. + const release = dependencies.desktopRestartAdmission?.enter('ws:goal-continuation'); void sendChat(ws, userId, { sessionId, content: command.operation === 'create' ? `Goal: ${command.objective}` : `Goal: ${command.operation}`, options: { model: 'default', goalUiVersion: 1 }, - }, dependencies, command).catch((error) => protocolFailure(ws, 'GOAL_RUN_FAILED', error instanceof Error ? error.message : 'Goal run failed.', sessionId)); + }, dependencies, command).catch((error) => protocolFailure(ws, 'GOAL_RUN_FAILED', error instanceof Error ? error.message : 'Goal run failed.', sessionId)).finally(() => release?.()); subscribeChat(ws, { sessions: [{ sessionId, lastSeq: 0 }] }, dependencies); }, dependencies.sessionWorktrees); sendFrame(ws, { kind: 'chat_goal', sessionId: data.sessionId, requestId: data.requestId, result }); @@ -384,10 +387,24 @@ export function handleChatConnection(ws: WebSocket, request: AuthenticatedWebSoc }; ws.on('message', async (raw) => { + let release: (() => void) | undefined; try { const data = parseIncomingJsonObject(raw); if (!data) throw new Error('Invalid websocket payload'); const type = typeof data.type === 'string' ? data.type : ''; + const admission = dependencies.desktopRestartAdmission; + // In-memory replay/status does not spawn work. Every other dispatch is + // acquired before projection/model/goal/OAuth's first asynchronous step. + if (admission && type !== 'chat.subscribe') { + const ownsRun = typeof data.sessionId === 'string' && chatRunRegistry.isProcessing(data.sessionId); + const ownsApproval = typeof data.requestId === 'string' && chatRunRegistry.getPendingApproval(data.requestId) !== null; + // OAuth's UI owner can outlive the actual attempt/worker. Its current + // submit/cancel API may lazily spawn a worker, so it is not yet a + // proven owned-completion path and must use normal admission. + const completion = (type === 'chat.abort' && ownsRun) + || (type === 'chat.permission-response' && ownsApproval); + release = completion ? admission.enterCompletion('ws:owned-completion') : admission.enter('ws:dispatch'); + } if (await dependencies.gjcProjection?.handle(ws, data)) return; if (type.startsWith('oauth.')) { @@ -402,8 +419,14 @@ export function handleChatConnection(ws: WebSocket, request: AuthenticatedWebSoc else protocolFailure(ws, 'UNKNOWN_MESSAGE_TYPE', `Unknown message type "${type}".`); } catch (error) { const message = error instanceof Error ? error.message : String(error); + if (error && typeof error === 'object' && 'code' in error && error.code === 'DESKTOP_RESTART_FENCED') { + protocolFailure(ws, 'DESKTOP_RESTART_FENCED', 'Desktop restart is being prepared. Retry this request.'); + return; + } console.error('[ERROR] Chat WebSocket error:', message); protocolFailure(ws, 'INTERNAL_ERROR', message); + } finally { + release?.(); } }); ws.on('close', () => { diff --git a/server/modules/websocket/services/shell-websocket.service.test.ts b/server/modules/websocket/services/shell-websocket.service.test.ts index 491c3118..1862bc83 100644 --- a/server/modules/websocket/services/shell-websocket.service.test.ts +++ b/server/modules/websocket/services/shell-websocket.service.test.ts @@ -7,7 +7,9 @@ import test from 'node:test'; import pty, { type IPty } from 'node-pty'; import { WebSocket } from 'ws'; -import { handleShellConnection } from './shell-websocket.service.js'; +import type { DesktopWorkAdmission } from '@/shared/interfaces.js'; + +import { getShellActivityGeneration, handleShellConnection, snapshotShellActivity } from './shell-websocket.service.js'; const GRACE_PERIOD = 30 * 60 * 1000; @@ -28,27 +30,50 @@ class FakePty { class FakeSocket extends EventEmitter { readyState: number = WebSocket.OPEN; - readonly frames: Array<{ type: string; data?: string; message?: string }> = []; + readonly frames: Array<{ type: string; data?: string; message?: string; code?: string }> = []; send(data: string) { this.frames.push(JSON.parse(data)); } receive(frame: Record) { this.emit('message', Buffer.from(JSON.stringify(frame))); } close() { this.readyState = WebSocket.CLOSED; this.emit('close'); } output() { return this.frames.map(frame => frame.data ?? '').join(''); } } +class FakeAdmission implements DesktopWorkAdmission { + fenced = false; + active = 0; + releases = 0; + readonly sources: string[] = []; + onRelease?: () => void; + enter(source: string) { + this.sources.push(source); + if (this.fenced) throw Object.assign(new Error('fixture denial must not leak'), { code: 'DESKTOP_RESTART_FENCED' }); + this.active += 1; + return () => { + assert.equal(this.active, 1, 'the synchronous handler owns exactly one live lease'); + this.onRelease?.(); + this.active -= 1; + this.releases += 1; + }; + } + enterCompletion(_source: string): () => void { + throw new Error('Shell producer messages must use regular admission.'); + } +} + function fixture(t: test.TestContext) { t.mock.timers.enable({ apis: ['setTimeout'] }); const timeout = t.mock.method(globalThis, 'setTimeout'); const terminals: FakePty[] = []; - t.mock.method(pty, 'spawn', () => { + const spawn = t.mock.method(pty, 'spawn', () => { const terminal = new FakePty(); terminals.push(terminal); return terminal as unknown as IPty; }); t.after(() => { for (const terminal of terminals) terminal.exit(); }); const init = { type: 'init', projectPath: os.tmpdir(), sessionId: randomUUID(), isPlainShell: true, initialCommand: 'fixture-shell' }; - const connect = () => { + const connect = (desktopRestartAdmission?: DesktopWorkAdmission) => { const socket = new FakeSocket(); handleShellConnection(socket as unknown as WebSocket, { + desktopRestartAdmission, resolveProviderSessionId: () => undefined, stripAnsiSequences: value => value, normalizeDetectedUrl: () => null, @@ -57,9 +82,30 @@ function fixture(t: test.TestContext) { }); return socket; }; - return { init, connect, terminals, timeout }; + return { init, connect, terminals, timeout, spawn }; } +test('shell activity is initially complete and empty; connection and snapshot reads have no side effects', t => { + const f = fixture(t); + const before = snapshotShellActivity(); + assert.deepEqual(before, { + owner: 'shell', generation: getShellActivityGeneration(), complete: true, + starting: 0, queued: 0, running: 0, settling: 0, approvals: 0, retained: 0, unknown: [], + }); + const admission = new FakeAdmission(); + const socket = f.connect(admission); + socket.receive({ type: 'input', data: 'no-owned-session' }); + socket.receive({ type: 'resize', cols: 100, rows: 40 }); + socket.receive({ type: 'status' }); + socket.receive({ type: 'constructor' }); + socket.close(); + assert.deepEqual(snapshotShellActivity(), before); + assert.equal(getShellActivityGeneration(), before.generation); + assert.equal(f.spawn.mock.callCount(), 0); + assert.equal(f.timeout.mock.callCount(), 0); + assert.deepEqual(admission.sources, []); +}); + test('closing replaced A preserves B output and schedules no cleanup timer', t => { const f = fixture(t); const a = f.connect(); a.receive(f.init); @@ -216,3 +262,288 @@ test('an invalid re-init leaves the current binding and output intact', t => { assert.match(a.output(), /still-valid/); assert.equal(f.timeout.mock.callCount(), 0); }); + +test('fenced init cannot spawn, reclaim, detach or force-restart a retained terminal', t => { + const f = fixture(t); + const admission = new FakeAdmission(); + const owner = f.connect(admission); + admission.fenced = true; + const beforeSpawn = snapshotShellActivity(); + owner.receive(f.init); + assert.equal(f.spawn.mock.callCount(), 0); + assert.deepEqual(snapshotShellActivity(), beforeSpawn); + const denial = { type: 'error', code: 'DESKTOP_RESTART_FENCED', message: 'Desktop restart is being prepared. Retry this request.' }; + assert.deepEqual(owner.frames, [denial]); + assert.equal(admission.releases, 0); + + admission.fenced = false; + owner.receive(f.init); + const terminal = f.terminals[0]!; + admission.fenced = true; + const before = snapshotShellActivity(); + owner.receive({ ...f.init, forceRestart: true }); + owner.receive({ ...f.init, sessionId: randomUUID() }); + owner.receive({ ...f.init, initialCommand: 'gjc auth login' }); + const replacement = f.connect(admission); + replacement.receive(f.init); + assert.equal(f.terminals.length, 1); + assert.equal(terminal.kills, 0); + assert.equal(f.timeout.mock.callCount(), 0); + assert.deepEqual(snapshotShellActivity(), before); + assert.deepEqual(owner.frames.slice(-3), [denial, denial, denial]); + assert.deepEqual(replacement.frames, [denial]); + assert.equal(admission.releases, 1); + terminal.output('the original owner is still attached'); + assert.match(owner.output(), /original owner/); + assert.doesNotMatch(replacement.output(), /original owner/); + + admission.fenced = false; + owner.receive({ type: 'input', data: 'still-owner\n' }); + assert.deepEqual(terminal.writes, ['still-owner\n']); +}); + +test('each input and resize is fenced on an already accepted connection, without stopping its PTY', t => { + const f = fixture(t); + const admission = new FakeAdmission(); + const owner = f.connect(admission); + owner.receive(f.init); + const terminal = f.terminals[0]!; + admission.fenced = true; + const before = snapshotShellActivity(); + owner.receive({ type: 'input', data: 'must-not-run\n' }); + owner.receive({ type: 'resize', cols: 2, rows: 2 }); + assert.deepEqual(terminal.writes, []); + assert.deepEqual(terminal.sizes, []); + assert.equal(terminal.kills, 0); + assert.deepEqual(snapshotShellActivity(), before); + assert.deepEqual(owner.frames.slice(-2).map(frame => frame.code), ['DESKTOP_RESTART_FENCED', 'DESKTOP_RESTART_FENCED']); + assert.doesNotMatch(owner.output(), /fixture denial/); + assert.equal(admission.active, 0); + assert.equal(admission.releases, 1); + + admission.fenced = false; + owner.receive({ type: 'input', data: 'accepted\n' }); + const afterInput = getShellActivityGeneration(); + assert.notEqual(afterInput, before.generation); + owner.receive({ type: 'resize', cols: 97, rows: 31 }); + assert.notEqual(getShellActivityGeneration(), afterInput); + assert.deepEqual(terminal.writes, ['accepted\n']); + assert.deepEqual(terminal.sizes, [[97, 31]]); + assert.deepEqual(admission.sources, ['shell.init', 'shell.input', 'shell.resize', 'shell.input', 'shell.resize']); + assert.equal(admission.releases, 3); +}); + +test('admission spans synchronous spawn, setup, input and resize; release observes the registered owner', t => { + const f = fixture(t); + const admission = new FakeAdmission(); + const owner = f.connect(admission); + const terminal = new FakePty(); + f.terminals.push(terminal); + f.spawn.mock.mockImplementation(() => { + assert.equal(admission.active, 1); + assert.equal(snapshotShellActivity().starting, 1); + assert.equal(snapshotShellActivity().running, 0); + return terminal as unknown as IPty; + }); + admission.onRelease = () => { + assert.equal(snapshotShellActivity().starting, 0); + assert.equal(snapshotShellActivity().running, 1); + }; + const send = owner.send.bind(owner); + const sendMock = t.mock.method(owner, 'send', (payload: string) => { + assert.equal(admission.active, 1, 'welcome/setup has not released admission early'); + send(payload); + }); + owner.receive(f.init); + assert.equal(admission.active, 0); + const write = terminal.write.bind(terminal); + t.mock.method(terminal, 'write', (data: string) => { + assert.equal(admission.active, 1); + write(data); + }); + t.mock.method(terminal, 'resize', () => { assert.equal(admission.active, 1); }); + owner.receive({ type: 'input', data: 'leased\n' }); + owner.receive({ type: 'resize', cols: 120, rows: 40 }); + assert.equal(admission.active, 0); + assert.equal(admission.releases, 3); + // Exit output is an owned completion, not a new ingress operation. + sendMock.mock.restore(); +}); + +test('throwing producers release admission synchronously and cannot erase PTY uncertainty', t => { + const f = fixture(t); + const admission = new FakeAdmission(); + const owner = f.connect(admission); + owner.receive(f.init); + const terminal = f.terminals[0]!; + t.mock.method(terminal, 'write', () => { throw new Error('fixture write failure'); }); + t.mock.method(terminal, 'resize', () => { throw new Error('fixture resize failure'); }); + owner.receive({ type: 'input', data: 'attempted\n' }); + owner.receive({ type: 'resize', cols: 120, rows: 40 }); + assert.equal(admission.active, 0); + assert.equal(admission.releases, 3); + assert.match(owner.output(), /fixture write failure/); + assert.match(owner.output(), /fixture resize failure/); + assert.equal(snapshotShellActivity().running, 1); + + terminal.exit(); + f.spawn.mock.mockImplementation(() => { throw new Error('fixture spawn failure'); }); + owner.receive(f.init); + assert.equal(admission.active, 0); + assert.equal(admission.releases, 4); + assert.match(owner.output(), /fixture spawn failure/); + assert.equal(snapshotShellActivity().starting, 0); + assert.equal(snapshotShellActivity().running, 0); + assert.equal(snapshotShellActivity().complete, false); + assert.deepEqual(snapshotShellActivity().unknown, ['pty_descendants_unverified']); +}); + +test('superseded sockets cannot acquire a lease, even after the current generation exits', t => { + const f = fixture(t); + const admission = new FakeAdmission(); + const a = f.connect(admission); a.receive(f.init); + const b = f.connect(admission); b.receive(f.init); + assert.equal(admission.releases, 2); + admission.fenced = true; + a.receive({ type: 'input', data: 'revoked\n' }); + a.receive({ type: 'resize' }); + a.receive({ ...f.init, forceRestart: true }); + a.close(); + f.terminals[0]!.exit(); + a.receive(f.init); + assert.deepEqual(admission.sources, ['shell.init', 'shell.init']); + assert.equal(f.timeout.mock.callCount(), 0); + assert.equal(f.terminals[0]!.kills, 0); +}); + +test('detached sessions remain busy and grace expiry retains a retiring generation until its own exit', t => { + const f = fixture(t); + const admission = new FakeAdmission(); + const a = f.connect(admission); a.receive(f.init); + const original = f.terminals[0]!; + const connected = snapshotShellActivity(); + assert.equal(connected.running, 1); + assert.equal(connected.retained, 0); + admission.fenced = true; + a.close(); // Normal detach remains available under the fence. + const detached = snapshotShellActivity(); + assert.notEqual(detached.generation, connected.generation); + assert.equal(detached.running, 1); + assert.equal(detached.retained, 1); + assert.equal(original.kills, 0); + assert.equal(admission.releases, 1); + original.output('buffered-output'); + assert.notEqual(getShellActivityGeneration(), detached.generation); + const beforeExpiry = snapshotShellActivity(); + t.mock.timers.tick(GRACE_PERIOD); + const retiring = snapshotShellActivity(); + assert.equal(original.kills, 1); + assert.notEqual(retiring.generation, beforeExpiry.generation); + assert.equal(retiring.running, 0); + assert.equal(retiring.retained, 0); + assert.equal(retiring.settling, 1); + assert.deepEqual(snapshotShellActivity(), retiring, 'snapshot does not force-drain or change the timer'); + assert.equal(original.kills, 1); + + admission.fenced = false; + const b = f.connect(admission); b.receive(f.init); + assert.equal(snapshotShellActivity().running, 1); + assert.equal(snapshotShellActivity().settling, 1); + const beforeExit = getShellActivityGeneration(); + original.exit(); + assert.notEqual(getShellActivityGeneration(), beforeExit); + assert.equal(snapshotShellActivity().running, 1); + assert.equal(snapshotShellActivity().settling, 0); + assert.doesNotMatch(b.output(), /Process exited/); + b.receive({ type: 'input', data: 'replacement\n' }); + assert.deepEqual(f.terminals[1]!.writes, ['replacement\n']); +}); + +test('replacement and out-of-order late exits retain every retiring generation, not only the keyed PTY', t => { + const f = fixture(t); + const owner = f.connect(); owner.receive(f.init); + const original = f.terminals[0]!; + owner.receive({ ...f.init, forceRestart: true }); + const middle = f.terminals[1]!; + owner.receive({ ...f.init, forceRestart: true }); + const latest = f.terminals[2]!; + assert.equal(original.kills, 1); + assert.equal(middle.kills, 1); + assert.equal(snapshotShellActivity().running, 1); + assert.equal(snapshotShellActivity().settling, 2); + middle.exit(); + assert.equal(snapshotShellActivity().settling, 1); + assert.equal(snapshotShellActivity().running, 1); + const afterMiddleExit = getShellActivityGeneration(); + middle.exit(); + middle.output('stale'); + assert.equal(getShellActivityGeneration(), afterMiddleExit, 'duplicate retired callbacks are inert'); + latest.exit(); + assert.equal(snapshotShellActivity().running, 0); + assert.equal(snapshotShellActivity().settling, 1); + original.exit(); + const exited = snapshotShellActivity(); + assert.equal(exited.running, 0); + assert.equal(exited.settling, 0); + assert.equal(exited.complete, false, 'leader exit is not detached-descendant reap proof'); + assert.deepEqual(exited.unknown, ['pty_descendants_unverified']); + // A returned snapshot must never expose mutable owner state. + (exited.unknown as string[]).push('forged'); + exited.running = 999; + assert.deepEqual(snapshotShellActivity().unknown, ['pty_descendants_unverified']); + assert.equal(snapshotShellActivity().running, 0); +}); + +test('reconnect changes activity generation and a cancelled old expiry is read-only', t => { + const f = fixture(t); + const a = f.connect(); a.receive(f.init); + a.close(); + const expiry = f.timeout.mock.calls[0]!.arguments[0]; + const before = snapshotShellActivity(); + const b = f.connect(); b.receive(f.init); + const reconnected = snapshotShellActivity(); + assert.notEqual(reconnected.generation, before.generation); + assert.equal(reconnected.running, 1); + assert.equal(reconnected.retained, 0); + expiry(); + assert.deepEqual(snapshotShellActivity(), reconnected); + assert.equal(f.terminals[0]!.kills, 0); +}); + +test('synchronous exit during a requested restart does not leak or delete the replacement', t => { + const f = fixture(t); + const owner = f.connect(); owner.receive(f.init); + const original = f.terminals[0]!; + t.mock.method(original, 'kill', () => { + assert.equal(snapshotShellActivity().settling, 1); + original.kills += 1; + original.exit(); + }); + owner.receive({ ...f.init, forceRestart: true }); + assert.equal(original.kills, 1); + assert.equal(f.terminals.length, 2); + assert.equal(snapshotShellActivity().running, 1); + assert.equal(snapshotShellActivity().settling, 0); + owner.receive({ type: 'input', data: 'replacement\n' }); + assert.deepEqual(f.terminals[1]!.writes, ['replacement\n']); +}); + +test('a failed user-requested kill retains original ownership and retiring uncertainty until exit', t => { + const f = fixture(t); + const admission = new FakeAdmission(); + const owner = f.connect(admission); owner.receive(f.init); + const original = f.terminals[0]!; + t.mock.method(original, 'kill', () => { throw new Error('fixture kill failure'); }); + owner.receive({ ...f.init, forceRestart: true }); + assert.equal(f.terminals.length, 1); + assert.equal(admission.active, 0); + assert.equal(admission.releases, 2); + assert.equal(snapshotShellActivity().running, 1); + assert.equal(snapshotShellActivity().settling, 1); + owner.receive({ type: 'input', data: 'still-owned\n' }); + assert.deepEqual(original.writes, ['still-owned\n']); + original.exit(); + assert.equal(snapshotShellActivity().running, 0); + assert.equal(snapshotShellActivity().settling, 0); + assert.deepEqual(snapshotShellActivity().unknown, ['pty_descendants_unverified']); +}); diff --git a/server/modules/websocket/services/shell-websocket.service.ts b/server/modules/websocket/services/shell-websocket.service.ts index ff6e6037..8eb033d5 100644 --- a/server/modules/websocket/services/shell-websocket.service.ts +++ b/server/modules/websocket/services/shell-websocket.service.ts @@ -6,11 +6,15 @@ import path from 'node:path'; import pty, { type IPty } from 'node-pty'; import { WebSocket, type RawData } from 'ws'; +import type { DesktopWorkAdmission } from '@/shared/interfaces.js'; import { parseIncomingJsonObject } from '@/shared/utils.js'; +import type { DesktopOwnerActivity } from '../../../../shared/desktopUpdateProtocol.js'; + type ShellIncomingMessage = { type?: string; data?: string; cols?: number; rows?: number; projectPath?: string; sessionId?: string; hasSession?: boolean; provider?: string; initialCommand?: string; isPlainShell?: boolean; forceRestart?: boolean; }; type PtySessionEntry = { pty: IPty; ws: WebSocket | null; buffer: string[]; timeoutId: NodeJS.Timeout | null; projectPath: string; sessionId: string | null; urlText: string; reportedUrls: Set; }; type ShellWebSocketDependencies = { + desktopRestartAdmission?: DesktopWorkAdmission; resolveProviderSessionId: (sessionId: string, provider: string) => string | null | undefined; stripAnsiSequences: (content: string) => string; normalizeDetectedUrl: (url: string) => string | null; @@ -19,6 +23,38 @@ type ShellWebSocketDependencies = { }; const sessions = new Map(); +// A key may already name a replacement while its old PTY is still exiting. +// These are the same owned entries, retained until their own onExit callback. +const retiringSessions = new Set(); +let shellActivityRevision = 0n; +let startingPtys = 0; +// node-pty proves only the leader's exit, not arbitrary detached descendants. +// Keep one bounded, process-lifetime uncertainty latch; neither kill(), onExit, +// timer expiry nor a socket disconnect can independently clear this proof gap. +let unverifiedPtyDescendants = false; + +export function getShellActivityGeneration(): string { + return `shell:${shellActivityRevision}`; +} + +export function snapshotShellActivity(): DesktopOwnerActivity { + let retained = 0; + for (const session of sessions.values()) { + if (session.ws === null) retained += 1; + } + return { + owner: 'shell', generation: getShellActivityGeneration(), complete: !unverifiedPtyDescendants, + starting: startingPtys, queued: 0, running: sessions.size, settling: retiringSessions.size, + approvals: 0, retained, unknown: unverifiedPtyDescendants ? ['pty_descendants_unverified'] : [], + }; +} + +function retireSession(session: PtySessionEntry): void { + if (retiringSessions.has(session)) return; + retiringSessions.add(session); + shellActivityRevision += 1n; +} + // Revocation outlives the PTY entry: its exit must not let a delayed init from // a replaced connection seize the session before the current owner restarts. const supersededSockets = new WeakSet(); @@ -107,18 +143,29 @@ export function handleShellConnection(ws: WebSocket, dependencies: ShellWebSocke const timer = setTimeout(() => { // A cancelled callback may already be queued when a new owner attaches. if (sessions.get(id) !== current || current.ws !== null || current.timeoutId !== timer) return; + retireSession(current); sessions.delete(id); + current.timeoutId = null; + shellActivityRevision += 1n; current.pty.kill(); }, SESSION_GRACE_PERIOD); current.timeoutId = timer; + shellActivityRevision += 1n; }; const clearSavedSession = (id: string) => { const old = sessions.get(id); if (!old) return; if (old.ws && old.ws !== ws) supersededSockets.add(old.ws); if (old.timeoutId) clearTimeout(old.timeoutId); + old.timeoutId = null; + shellActivityRevision += 1n; + retireSession(old); old.pty.kill(); - sessions.delete(id); + // kill() may report exit synchronously. Never remove a newer generation. + if (sessions.get(id) === old) { + sessions.delete(id); + shellActivityRevision += 1n; + } }; const relayOutput = (id: string, child: IPty) => { return (chunk: string) => { @@ -126,10 +173,12 @@ export function handleShellConnection(ws: WebSocket, dependencies: ShellWebSocke if (!current || current.pty !== child) return; if (current.buffer.length === 5000) current.buffer.shift(); current.buffer.push(chunk); + shellActivityRevision += 1n; if (!current.ws || current.ws.readyState !== WebSocket.OPEN) return; const stripped = dependencies.stripAnsiSequences(chunk); current.urlText = `${current.urlText}${stripped}`.slice(-URL_WINDOW_LENGTH); + shellActivityRevision += 1n; const output = chunk.replace(/OPEN_URL:\s*(https?:\/\/[^\s\x1b\x07]+)/g, '[INFO] Opening in browser: $1'); const urls = Array.from(new Set(dependencies.extractUrlsFromText(current.urlText) .map((url) => dependencies.normalizeDetectedUrl(url)) @@ -138,6 +187,7 @@ export function handleShellConnection(ws: WebSocket, dependencies: ShellWebSocke const announce = (url: string, autoOpen: boolean) => { if (current.reportedUrls.has(url)) return; current.reportedUrls.add(url); + shellActivityRevision += 1n; current.ws?.send(JSON.stringify({ type: 'auth_url', url, autoOpen })); }; urls.forEach((url) => announce(url, false)); @@ -180,6 +230,7 @@ export function handleShellConnection(ws: WebSocket, dependencies: ShellWebSocke previous.timeoutId = null; if (previous.ws && previous.ws !== ws) supersededSockets.add(previous.ws); previous.ws = ws; + shellActivityRevision += 1n; write({ type: 'output', data: '\x1b[36m[Reconnected to existing session]\x1b[0m\r\n' }); previous.buffer.forEach((data) => write({ type: 'output', data })); return; @@ -188,21 +239,35 @@ export function handleShellConnection(ws: WebSocket, dependencies: ShellWebSocke const commandLine = shellCommand(data, dependencies); const resumeId = nativeSession(data, dependencies); const npmPath = preferredPath(process.env); - activePty = pty.spawn(executable, os.platform() === 'win32' ? ['-Command', commandLine] : ['-c', commandLine], { - name: 'xterm-256color', cols: dimension(data.cols, 80), rows: dimension(data.rows, 24), cwd, - env: { ...process.env, [npmPath.key]: npmPath.value, TERM: 'xterm-256color', COLORTERM: 'truecolor', FORCE_COLOR: '3' }, - }); - const child = activePty; - sessions.set(key, { pty: child, ws, buffer: [], timeoutId: null, projectPath, sessionId, urlText: '', reportedUrls: new Set() }); - child.onData(relayOutput(nextKey, child)); + startingPtys += 1; + // Even a throwing native spawn may have started a process before failing. + unverifiedPtyDescendants = true; + shellActivityRevision += 1n; + let entry: PtySessionEntry; + try { + activePty = pty.spawn(executable, os.platform() === 'win32' ? ['-Command', commandLine] : ['-c', commandLine], { + name: 'xterm-256color', cols: dimension(data.cols, 80), rows: dimension(data.rows, 24), cwd, + env: { ...process.env, [npmPath.key]: npmPath.value, TERM: 'xterm-256color', COLORTERM: 'truecolor', FORCE_COLOR: '3' }, + }); + entry = { pty: activePty, ws, buffer: [], timeoutId: null, projectPath, sessionId, urlText: '', reportedUrls: new Set() }; + sessions.set(key, entry); + shellActivityRevision += 1n; + } finally { + startingPtys -= 1; + shellActivityRevision += 1n; + } + const child = entry.pty; child.onExit((status) => { + if (retiringSessions.delete(entry)) shellActivityRevision += 1n; const current = sessions.get(nextKey); - if (!current || current.pty !== child) return; - if (current.ws?.readyState === WebSocket.OPEN) current.ws.send(JSON.stringify({ type: 'output', data: `\r\n\x1b[33mProcess exited with code ${status.exitCode}${status.signal != null ? ` (${status.signal})` : ''}\x1b[0m\r\n` })); + if (current !== entry) return; if (current.timeoutId) clearTimeout(current.timeoutId); sessions.delete(nextKey); if (activePty === child) activePty = null; + shellActivityRevision += 1n; + if (current.ws?.readyState === WebSocket.OPEN) current.ws.send(JSON.stringify({ type: 'output', data: `\r\n\x1b[33mProcess exited with code ${status.exitCode}${status.signal != null ? ` (${status.signal})` : ''}\x1b[0m\r\n` })); }); + child.onData(relayOutput(nextKey, child)); const welcome = plain ? `\x1b[36mStarting terminal in: ${projectPath}\x1b[0m\r\n` : hasSession && resumeId @@ -216,7 +281,7 @@ export function handleShellConnection(ws: WebSocket, dependencies: ShellWebSocke resize: (data) => { ownedSession()?.pty.resize(dimension(data.cols, 80), dimension(data.rows, 24)); }, }; - ws.on('message', async (raw) => { + ws.on('message', (raw) => { try { if (ws.readyState !== WebSocket.OPEN || supersededSockets.has(ws)) return; // A replaced connection cannot reclaim, restart or control the new owner. @@ -224,8 +289,22 @@ export function handleShellConnection(ws: WebSocket, dependencies: ShellWebSocke if (current && current.ws !== ws) return; const data = decode(raw); if (!data?.type) throw new Error('Invalid websocket payload'); - handlers[data.type]?.(data); + if (data.type !== 'init' && data.type !== 'input' && data.type !== 'resize') return; + if (data.type !== 'init' && !ownedSession()) return; + // Admission is per producer message, not per connection. Keep its lease + // through the synchronous handler and transfer to the registered PTY owner. + const release = dependencies.desktopRestartAdmission?.enter(`shell.${data.type}`); + shellActivityRevision += 1n; + try { handlers[data.type]!(data); } + finally { + shellActivityRevision += 1n; + release?.(); + } } catch (error) { + if (error && typeof error === 'object' && 'code' in error && error.code === 'DESKTOP_RESTART_FENCED') { + if (ws.readyState === WebSocket.OPEN) write({ type: 'error', code: 'DESKTOP_RESTART_FENCED', message: 'Desktop restart is being prepared. Retry this request.' }); + return; + } const message = error instanceof Error ? error.message : String(error); console.error('[ERROR] Shell WebSocket error:', message); if (ws.readyState === WebSocket.OPEN) write({ type: 'output', data: `\r\n\x1b[31mError: ${message}\x1b[0m\r\n` }); diff --git a/server/modules/websocket/services/websocket-auth.service.ts b/server/modules/websocket/services/websocket-auth.service.ts index 2ddc5405..3cae57e4 100644 --- a/server/modules/websocket/services/websocket-auth.service.ts +++ b/server/modules/websocket/services/websocket-auth.service.ts @@ -2,6 +2,7 @@ import type { VerifyClientCallbackSync } from 'ws'; import { hasValidApiKey } from '@/middleware/auth.js'; import { isAllowedRequestOrigin } from '@/shared/request-origin.js'; +import type { DesktopWorkAdmission } from '@/shared/interfaces.js'; import type { AuthenticatedWebSocketRequest } from '@/shared/types.js'; import { parseAllowedHosts } from '../../../../shared/networkHosts.js'; @@ -17,6 +18,7 @@ type WebSocketAuthDependencies = Readonly<{ desktopAuth?: { authenticateWebSocket: (request: { headers: { origin?: string; cookie?: string } }) => boolean }; /** Raw `ALLOWED_HOSTS`; defaults to the process environment. */ allowedHosts?: string | undefined; + desktopRestartAdmission?: DesktopWorkAdmission; }>; function acceptsOrigin(request: AuthenticatedWebSocketRequest, configuredHosts?: string): boolean { @@ -56,7 +58,17 @@ export function verifyWebSocketClient(info: Parameters void) | undefined; + let owner: AuthenticatedOwner | null; + try { + release = dependencies.desktopRestartAdmission?.enter('ws:authenticate'); + owner = dependencies.authenticateWebSocket(); + } catch (error) { + if (error && typeof error === 'object' && 'code' in error && error.code === 'DESKTOP_RESTART_FENCED') return false; + throw error; + } finally { + release?.(); + } if (!owner) { console.log('[WARN] Rejected WebSocket upgrade: no authenticated user'); return false; diff --git a/server/modules/websocket/tests/chat-run-registry.test.ts b/server/modules/websocket/tests/chat-run-registry.test.ts index 7f9b4749..e26d9159 100644 --- a/server/modules/websocket/tests/chat-run-registry.test.ts +++ b/server/modules/websocket/tests/chat-run-registry.test.ts @@ -55,6 +55,33 @@ function framesOf(socket: SocketCapture, kind: string): Array { + test('restart snapshot retains approvals and publication work beyond visible run completion', async () => { + await openDatabase(async () => { + const initial = chatRunRegistry.getGeneration(); + const { run } = createRun('restart-activity'); + assert.notEqual(chatRunRegistry.getGeneration(), initial); + assert.equal(chatRunRegistry.snapshotActivity().running, 1); + run.writer.send({ kind: 'permission_request', requestId: 'approval-1', toolName: 'bash' }); + const pendingRevision = chatRunRegistry.getGeneration(); + assert.equal(chatRunRegistry.snapshotActivity().approvals, 1); + assert.deepEqual(chatRunRegistry.getPendingApproval('approval-1'), { appSessionId: 'restart-activity', toolName: 'bash' }); + assert.equal(chatRunRegistry.getGeneration(), pendingRevision, 'read-only lookup must not mutate ownership'); + chatRunRegistry.resolvePendingApproval('approval-1'); + assert.equal(chatRunRegistry.snapshotActivity().approvals, 0); + assert.notEqual(chatRunRegistry.getGeneration(), pendingRevision); + run.writer.send({ kind: 'session_created', provider: 'gjc', sessionId: 'native-activity', newSessionId: 'native-activity' }); + run.writer.send({ kind: 'complete', provider: 'gjc', exitCode: 0 }); + assert.equal(chatRunRegistry.snapshotActivity().running, 0); + assert.equal(chatRunRegistry.snapshotActivity().settling, 1, 'async publication remains owned after terminal UI state'); + chatRunRegistry.clearAll(); + assert.equal(chatRunRegistry.snapshotActivity().settling, 1, 'clearing the run registry cannot erase an unsettled publication'); + await new Promise((resolve) => setImmediate(resolve)); + const settled = chatRunRegistry.snapshotActivity(); + assert.equal(settled.settling, 0); + assert.equal(settled.generation, chatRunRegistry.getGeneration()); + }); + }); + test('uses the application session and increasing event positions', async () => { await openDatabase(() => { const { run, socket } = createRun('sequence'); diff --git a/server/modules/websocket/tests/websocket-auth.service.test.ts b/server/modules/websocket/tests/websocket-auth.service.test.ts index bacfef2f..6528d921 100644 --- a/server/modules/websocket/tests/websocket-auth.service.test.ts +++ b/server/modules/websocket/tests/websocket-auth.service.test.ts @@ -20,6 +20,34 @@ import { createWebSocketServer } from '@/modules/websocket/services/websocket-se const owner = () => ({ userId: 'owner', username: 'owner' }); +test('restart fence rejects a new upgrade before implicit-owner creation', () => { + let authenticated = 0; + const result = verifyWebSocketClient(upgrade({ host: '127.0.0.1:3001' }), { + authenticateWebSocket: () => { authenticated++; return owner(); }, + desktopRestartAdmission: { + enter() { throw Object.assign(new Error('fenced'), { code: 'DESKTOP_RESTART_FENCED' }); }, + enterCompletion() { throw new Error('not a completion'); }, + }, + }); + assert.equal(result, false); + assert.equal(authenticated, 0); +}); + +test('upgrade authentication remains accounted through synchronous owner attachment', () => { + let active = 0; + let completed = 0; + const result = verifyWebSocketClient(upgrade({ host: '127.0.0.1:3001' }), { + authenticateWebSocket: () => { assert.equal(active, 1); return owner(); }, + desktopRestartAdmission: { + enter() { active++; return () => { active--; completed++; }; }, + enterCompletion() { throw new Error('not a completion'); }, + }, + }); + assert.equal(result, true); + assert.equal(active, 0); + assert.equal(completed, 1); +}); + const upgrade = (headers: Record) => ({ req: { url: '/ws', headers }, origin: headers.origin ?? '', diff --git a/server/routes/auth.js b/server/routes/auth.js index adfe37b8..0ccf4f12 100644 --- a/server/routes/auth.js +++ b/server/routes/auth.js @@ -2,10 +2,11 @@ import express from 'express'; import { authenticateToken } from '../middleware/auth.js'; import { isDesktopMode } from '../middleware/desktop-auth.js'; +import { asyncHandler } from '../shared/utils.js'; const router = express.Router(); -router.get('/user', authenticateToken, (req, res) => { +router.get('/user', authenticateToken, asyncHandler((req, res) => { res.json({ user: req.user, // The desktop webview is a loopback origin with no Tauri IPC; the client @@ -13,6 +14,6 @@ router.get('/user', authenticateToken, (req, res) => { // the sidecar instead of window.open. shell: { desktop: isDesktopMode() }, }); -}); +})); export default router; diff --git a/server/routes/git.js b/server/routes/git.js index e88be8ee..6ebfa04a 100755 --- a/server/routes/git.js +++ b/server/routes/git.js @@ -6,6 +6,7 @@ import express from 'express'; import spawn from 'cross-spawn'; import { projectsDb } from '../modules/database/index.js'; +import { asyncHandler } from '../shared/utils.js'; const router = express.Router(); @@ -709,7 +710,7 @@ async function attachBoundedFilePatches(files, context, hasCommits) { return output; } -router.get('/status', async (req, res) => { +router.get('/status', asyncHandler(async (req, res) => { const { project } = req.query; if (!project) { @@ -756,7 +757,7 @@ router.get('/status', async (req, res) => { : `Failed to get git status: ${error.message}` }); } -}); +})); export async function readProjectDiff(projectPath) { await validateGitRepository(projectPath); @@ -806,7 +807,7 @@ export async function readProjectDiff(projectPath) { return { branch, hasCommits, files, totalFiles, truncated: totalFiles > files.length }; } -router.get('/diff', async (req, res) => { +router.get('/diff', asyncHandler(async (req, res) => { const { project } = req.query; if (!project) { @@ -828,10 +829,10 @@ router.get('/diff', async (req, res) => { : `Failed to get git diff: ${error.message}`, }); } -}); +})); // Get list of branches -router.get('/branches', async (req, res) => { +router.get('/branches', asyncHandler(async (req, res) => { const { project } = req.query; if (!project) { @@ -872,10 +873,10 @@ router.get('/branches', async (req, res) => { console.error('Git branches error:', error); res.json({ error: error.message }); } -}); +})); // Checkout branch -router.post('/checkout', async (req, res) => { +router.post('/checkout', asyncHandler(async (req, res) => { const { project, branch } = req.body; if (!project || !branch) { @@ -894,7 +895,7 @@ router.post('/checkout', async (req, res) => { console.error('Git checkout error:', error); res.status(500).json({ error: error.message }); } -}); +})); // Fields are joined with the ASCII unit separator so pipes (or anything else // typed into a commit subject) cannot break parsing. @@ -944,7 +945,7 @@ export function parseGitLogWithStats(stdout) { } // Get recent commits (across all branches, in graph order) -router.get('/commits', async (req, res) => { +router.get('/commits', asyncHandler(async (req, res) => { const { project, limit = 10 } = req.query; if (!project) { @@ -984,10 +985,10 @@ router.get('/commits', async (req, res) => { console.error('Git commits error:', error); res.json({ error: error.message }); } -}); +})); // Fetch from remote (using smart remote detection) -router.post('/fetch', async (req, res) => { +router.post('/fetch', asyncHandler(async (req, res) => { const { project } = req.body; if (!project) { @@ -1025,10 +1026,10 @@ router.post('/fetch', async (req, res) => { : error.message }); } -}); +})); // Pull from remote (fetch + merge using smart remote detection) -router.post('/pull', async (req, res) => { +router.post('/pull', asyncHandler(async (req, res) => { const { project } = req.body; if (!project) { @@ -1093,10 +1094,10 @@ router.post('/pull', async (req, res) => { details: details }); } -}); +})); // Push commits to remote repository -router.post('/push', async (req, res) => { +router.post('/push', asyncHandler(async (req, res) => { const { project } = req.body; if (!project) { @@ -1164,6 +1165,6 @@ router.post('/push', async (req, res) => { details: details }); } -}); +})); export default router; diff --git a/server/routes/gjc-jobs.js b/server/routes/gjc-jobs.js index f2de364a..b5dd684e 100644 --- a/server/routes/gjc-jobs.js +++ b/server/routes/gjc-jobs.js @@ -6,6 +6,7 @@ import { Octokit } from '@octokit/rest'; import { githubTokensDb } from '../modules/database/index.js'; import { getProductionJobAuthority, getProductionJobOrchestrator } from '../services/gjc-job-orchestrator.js'; import { getProductionGjcJobGitService } from '../services/gjc-job-git.service.js'; +import { asyncHandler } from '../shared/utils.js'; const MAX_LIST_LIMIT = 100; const MAX_SAFE_U64 = Number.MAX_SAFE_INTEGER; @@ -86,7 +87,7 @@ export function createGjcJobsRouter({ const router = express.Router(); const jobGit = () => gitService; -router.post('/jobs', async (req, res) => { +router.post('/jobs', asyncHandler(async (req, res) => { const message = text(req.body?.message); const projectPath = text(req.body?.projectPath); if (!message || !projectPath) return res.status(400).json({ error: 'message and projectPath are required.' }); // Managed job worktrees live under /.gjc-worktrees/; accepting one @@ -94,8 +95,8 @@ router.post('/jobs', async (req, res) => { // but the HTTP surface must reject them too (defense in depth for direct calls). if (projectPath.split(/[\\/]/u).includes('.gjc-worktrees')) return res.status(400).json({ error: 'projectPath must not target a managed job worktree.', code: 'managed_worktree_project' }); try { const appSessionId = appSession(req.body); const handle = await orchestrator.start('gjc', appSessionId, projectPath, message, { writer, provider: 'gjc', appSessionId, model: text(req.body.model), effort: text(req.body.effort) }); return jobResponse(res, handle, appSessionId); } catch (error) { return fail(res, error); } -}); -router.post('/jobs/:jobId/turns', async (req, res) => { +})); +router.post('/jobs/:jobId/turns', asyncHandler(async (req, res) => { const message = text(req.body?.message); const appSessionId = text(req.body?.appSessionId) ?? text(req.body?.sessionId); if (!message || !appSessionId) return res.status(400).json({ error: 'message and appSessionId are required.' }); try { @@ -105,8 +106,8 @@ router.post('/jobs/:jobId/turns', async (req, res) => { const handle = await currentOrchestrator.turnStart('gjc', appSessionId, message, { writer, provider: 'gjc', appSessionId, model: text(req.body.model), effort: text(req.body.effort) }); return jobResponse(res, handle, appSessionId); } catch (error) { return fail(res, error); } -}); -router.post('/jobs/:jobId/resume', async (req, res) => { +})); +router.post('/jobs/:jobId/resume', asyncHandler(async (req, res) => { const message = text(req.body?.message) ?? ''; const appSessionId = text(req.body?.appSessionId) ?? text(req.body?.sessionId); if (!appSessionId) return res.status(400).json({ error: 'appSessionId is required.' }); try { @@ -118,38 +119,38 @@ router.post('/jobs/:jobId/resume', async (req, res) => { const handle = await orchestrator.resume(req.params.jobId, appSessionId, message, { writer, provider: 'gjc', appSessionId, model: text(req.body.model), effort: text(req.body.effort) }); return jobResponse(res, handle, appSessionId); } catch (error) { return fail(res, error); } -}); -router.post('/jobs/:jobId/abort', async (req, res) => { try { return res.status(202).json({ provider: 'gjc', jobId: req.params.jobId, aborted: await orchestrator.abort(req.params.jobId) }); } catch (error) { return fail(res, error); } }); -router.post('/jobs/:jobId/archive', async (req, res) => { try { return res.json(await authority.archive({ jobId: req.params.jobId })); } catch (error) { return fail(res, error); } }); -router.post('/jobs/:jobId/unarchive', async (req, res) => { try { return res.json(await authority.unarchive({ jobId: req.params.jobId })); } catch (error) { return fail(res, error); } }); -router.get('/jobs', async (req, res) => { +})); +router.post('/jobs/:jobId/abort', asyncHandler(async (req, res) => { try { return res.status(202).json({ provider: 'gjc', jobId: req.params.jobId, aborted: await orchestrator.abort(req.params.jobId) }); } catch (error) { return fail(res, error); } })); +router.post('/jobs/:jobId/archive', asyncHandler(async (req, res) => { try { return res.json(await authority.archive({ jobId: req.params.jobId })); } catch (error) { return fail(res, error); } })); +router.post('/jobs/:jobId/unarchive', asyncHandler(async (req, res) => { try { return res.json(await authority.unarchive({ jobId: req.params.jobId })); } catch (error) { return fail(res, error); } })); +router.get('/jobs', asyncHandler(async (req, res) => { try { return res.json(listResponse(await authority.list(decodeListQuery(req.query)))); } catch (error) { return fail(res, error); } -}); -router.get('/jobs/git-summaries', async (req, res) => { +})); +router.get('/jobs/git-summaries', asyncHandler(async (req, res) => { try { const { jobIds, forceRefresh } = decodeGitSummariesQuery(req.query); return res.json(await jobGit().summaries(jobIds, { forceRefresh })); } catch (error) { return fail(res, error); } -}); -router.get('/jobs/:jobId', async (req, res) => { try { return res.json(await authority.get({ jobId: req.params.jobId })); } catch (error) { return fail(res, error); } }); -router.get('/jobs/:jobId/events', async (req, res) => { +})); +router.get('/jobs/:jobId', asyncHandler(async (req, res) => { try { return res.json(await authority.get({ jobId: req.params.jobId })); } catch (error) { return fail(res, error); } })); +router.get('/jobs/:jobId/events', asyncHandler(async (req, res) => { try { return res.json(await authority.replayEvents({ jobId: req.params.jobId, ...decodeReplayQuery(req.query) })); } catch (error) { return fail(res, error); } -}); -router.get('/jobs/:jobId/git/status', async (req, res) => { try { return res.json(await jobGit().status(req.params.jobId)); } catch (error) { return fail(res, error); } }); -router.get('/jobs/:jobId/git/diff', async (req, res) => { try { return res.json(await jobGit().diff(req.params.jobId)); } catch (error) { return fail(res, error); } }); -router.post('/jobs/:jobId/git/publish', async (req, res) => { try { return res.json(await jobGit().publish(req.params.jobId)); } catch (error) { return fail(res, error); } }); -router.post('/jobs/:jobId/git/commit', async (req, res) => { try { return res.status(201).json(await jobGit().commit(req.params.jobId, req.body?.message, req.body?.paths)); } catch (error) { return fail(res, error); } }); -router.post('/jobs/:jobId/git/pr', async (req, res) => { +})); +router.get('/jobs/:jobId/git/status', asyncHandler(async (req, res) => { try { return res.json(await jobGit().status(req.params.jobId)); } catch (error) { return fail(res, error); } })); +router.get('/jobs/:jobId/git/diff', asyncHandler(async (req, res) => { try { return res.json(await jobGit().diff(req.params.jobId)); } catch (error) { return fail(res, error); } })); +router.post('/jobs/:jobId/git/publish', asyncHandler(async (req, res) => { try { return res.json(await jobGit().publish(req.params.jobId)); } catch (error) { return fail(res, error); } })); +router.post('/jobs/:jobId/git/commit', asyncHandler(async (req, res) => { try { return res.status(201).json(await jobGit().commit(req.params.jobId, req.body?.message, req.body?.paths)); } catch (error) { return fail(res, error); } })); +router.post('/jobs/:jobId/git/pr', asyncHandler(async (req, res) => { try { const result = await jobGit().createPullRequest(req.params.jobId, async context => { const match = context.remoteUrl.match(/github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?$/u); @@ -161,7 +162,7 @@ router.post('/jobs/:jobId/git/pr', async (req, res) => { }); return res.status(201).json(result); } catch (error) { return fail(res, error); } -}); +})); return router; } diff --git a/server/routes/settings.js b/server/routes/settings.js index cc1832ed..ffb8bc02 100644 --- a/server/routes/settings.js +++ b/server/routes/settings.js @@ -5,6 +5,7 @@ import { credentialsDb, notificationPreferencesDb, } from '../modules/database/index.js'; +import { asyncHandler } from '../shared/utils.js'; const router = express.Router(); @@ -13,7 +14,7 @@ const router = express.Router(); // =============================== // Get all API keys for the authenticated user -router.get('/api-keys', async (req, res) => { +router.get('/api-keys', asyncHandler(async (req, res) => { try { const apiKeys = apiKeysDb.getApiKeys(req.user.id); // Don't send the full API key in the list for security @@ -26,10 +27,10 @@ router.get('/api-keys', async (req, res) => { console.error('Error fetching API keys:', error); res.status(500).json({ error: 'Failed to fetch API keys' }); } -}); +})); // Create a new API key -router.post('/api-keys', async (req, res) => { +router.post('/api-keys', asyncHandler(async (req, res) => { try { const { keyName } = req.body; @@ -46,10 +47,10 @@ router.post('/api-keys', async (req, res) => { console.error('Error creating API key:', error); res.status(500).json({ error: 'Failed to create API key' }); } -}); +})); // Delete an API key -router.delete('/api-keys/:keyId', async (req, res) => { +router.delete('/api-keys/:keyId', asyncHandler(async (req, res) => { try { const { keyId } = req.params; const success = apiKeysDb.deleteApiKey(req.user.id, parseInt(keyId)); @@ -63,10 +64,10 @@ router.delete('/api-keys/:keyId', async (req, res) => { console.error('Error deleting API key:', error); res.status(500).json({ error: 'Failed to delete API key' }); } -}); +})); // Toggle API key active status -router.patch('/api-keys/:keyId/toggle', async (req, res) => { +router.patch('/api-keys/:keyId/toggle', asyncHandler(async (req, res) => { try { const { keyId } = req.params; const { isActive } = req.body; @@ -86,14 +87,14 @@ router.patch('/api-keys/:keyId/toggle', async (req, res) => { console.error('Error toggling API key:', error); res.status(500).json({ error: 'Failed to toggle API key' }); } -}); +})); // =============================== // Generic Credentials Management // =============================== // Get all credentials for the authenticated user (optionally filtered by type) -router.get('/credentials', async (req, res) => { +router.get('/credentials', asyncHandler(async (req, res) => { try { const { type } = req.query; const credentials = credentialsDb.getCredentials(req.user.id, type || null); @@ -103,10 +104,10 @@ router.get('/credentials', async (req, res) => { console.error('Error fetching credentials:', error); res.status(500).json({ error: 'Failed to fetch credentials' }); } -}); +})); // Create a new credential -router.post('/credentials', async (req, res) => { +router.post('/credentials', asyncHandler(async (req, res) => { try { const { credentialName, credentialType, credentialValue, description } = req.body; @@ -138,10 +139,10 @@ router.post('/credentials', async (req, res) => { console.error('Error creating credential:', error); res.status(500).json({ error: 'Failed to create credential' }); } -}); +})); // Delete a credential -router.delete('/credentials/:credentialId', async (req, res) => { +router.delete('/credentials/:credentialId', asyncHandler(async (req, res) => { try { const { credentialId } = req.params; const success = credentialsDb.deleteCredential(req.user.id, parseInt(credentialId)); @@ -155,10 +156,10 @@ router.delete('/credentials/:credentialId', async (req, res) => { console.error('Error deleting credential:', error); res.status(500).json({ error: 'Failed to delete credential' }); } -}); +})); // Toggle credential active status -router.patch('/credentials/:credentialId/toggle', async (req, res) => { +router.patch('/credentials/:credentialId/toggle', asyncHandler(async (req, res) => { try { const { credentialId } = req.params; const { isActive } = req.body; @@ -178,13 +179,13 @@ router.patch('/credentials/:credentialId/toggle', async (req, res) => { console.error('Error toggling credential:', error); res.status(500).json({ error: 'Failed to toggle credential' }); } -}); +})); // =============================== // Notification Preferences // =============================== -router.get('/notification-preferences', async (req, res) => { +router.get('/notification-preferences', asyncHandler(async (req, res) => { try { const preferences = notificationPreferencesDb.getPreferences(req.user.id); res.json({ success: true, preferences }); @@ -192,9 +193,9 @@ router.get('/notification-preferences', async (req, res) => { console.error('Error fetching notification preferences:', error); res.status(500).json({ error: 'Failed to fetch notification preferences' }); } -}); +})); -router.put('/notification-preferences', async (req, res) => { +router.put('/notification-preferences', asyncHandler(async (req, res) => { try { const preferences = notificationPreferencesDb.updatePreferences(req.user.id, req.body || {}); res.json({ success: true, preferences }); @@ -202,6 +203,6 @@ router.put('/notification-preferences', async (req, res) => { console.error('Error saving notification preferences:', error); res.status(500).json({ error: 'Failed to save notification preferences' }); } -}); +})); export default router; diff --git a/server/routes/system.js b/server/routes/system.js index e8cef27b..564a80e2 100644 --- a/server/routes/system.js +++ b/server/routes/system.js @@ -6,6 +6,7 @@ import { isAbsolute } from 'node:path'; import express from 'express'; import { sessionsDb } from '../modules/database/repositories/sessions.db.js'; +import { asyncHandler } from '../shared/utils.js'; const PLATFORM_OPENERS = { darwin: { command: 'open', args: (target) => [target] }, @@ -26,7 +27,7 @@ function defaultOpener(target) { export function createSystemRouter({ opener = defaultOpener } = {}) { const router = express.Router(); - router.post('/open-file', async (req, res) => { + router.post('/open-file', asyncHandler(async (req, res) => { const target = req.body?.path; if (typeof target !== 'string' || !isAbsolute(target)) { return res.status(400).json({ error: 'An absolute path is required.' }); @@ -45,7 +46,7 @@ export function createSystemRouter({ opener = defaultOpener } = {}) { console.error('Failed to open file externally:', error); return res.status(500).json({ error: 'Failed to open the file' }); } - }); + })); /** * The desktop shell's webview loads the server's loopback origin, where @@ -54,7 +55,7 @@ export function createSystemRouter({ opener = defaultOpener } = {}) { * machine as the person, so it hands the URL to the OS browser. Only * https: is accepted: this is for web pages, not for schemes. */ - router.post('/open-url', async (req, res) => { + router.post('/open-url', asyncHandler(async (req, res) => { const target = safeExternalUrl(req.body?.url); if (!target) { return res.status(400).json({ error: 'An https URL is required.' }); @@ -67,11 +68,11 @@ export function createSystemRouter({ opener = defaultOpener } = {}) { console.error('Failed to open URL externally:', error); return res.status(500).json({ error: 'Failed to open the link' }); } - }); + })); // Workspace Browser also visits local HTTP development servers. Keep that // explicit action separate from the HTTPS-only sign-in/docs link contract. - router.post('/open-browser-url', async (req, res) => { + router.post('/open-browser-url', asyncHandler(async (req, res) => { const target = safeBrowserUrl(req.body?.url); if (!target) return res.status(400).json({ error: 'An HTTP or HTTPS page URL is required.' }); try { @@ -81,7 +82,7 @@ export function createSystemRouter({ opener = defaultOpener } = {}) { console.error('Failed to open browser page externally:', error); return res.status(500).json({ error: 'Failed to open the page' }); } - }); + })); /** * Everything a bug report about a session needs, in one paste: the DB row, @@ -89,7 +90,7 @@ export function createSystemRouter({ opener = defaultOpener } = {}) { * screenshot and a retelling; this makes "Copy debug info" carry the * evidence instead. Text on purpose: it goes into a chat message. */ - router.post('/debug-bundle', async (req, res) => { + router.post('/debug-bundle', asyncHandler(async (req, res) => { const sessionId = typeof req.body?.sessionId === 'string' ? req.body.sessionId.trim() : ''; try { const bundle = await buildDebugBundle(sessionId || null); @@ -98,7 +99,7 @@ export function createSystemRouter({ opener = defaultOpener } = {}) { console.error('Failed to assemble the debug bundle:', error); res.status(500).json({ error: 'Failed to assemble the debug bundle' }); } - }); + })); return router; } diff --git a/server/routes/user.js b/server/routes/user.js index 86431e8e..e9f94701 100644 --- a/server/routes/user.js +++ b/server/routes/user.js @@ -5,6 +5,7 @@ import spawn from 'cross-spawn'; import { userDb } from '../modules/database/index.js'; import { authenticateToken } from '../middleware/auth.js'; import { getSystemGitConfig } from '../utils/gitConfig.js'; +import { asyncHandler } from '../shared/utils.js'; const router = express.Router(); @@ -27,7 +28,7 @@ function spawnAsync(command, args, options = {}) { }); } -router.get('/git-config', authenticateToken, async (req, res) => { +router.get('/git-config', authenticateToken, asyncHandler(async (req, res) => { try { const userId = req.user.id; let gitConfig = userDb.getGitConfig(userId); @@ -53,10 +54,10 @@ router.get('/git-config', authenticateToken, async (req, res) => { console.error('Error getting git config:', error); res.status(500).json({ error: 'Failed to get git configuration' }); } -}); +})); // Apply git config globally via git config --global -router.post('/git-config', authenticateToken, async (req, res) => { +router.post('/git-config', authenticateToken, asyncHandler(async (req, res) => { try { const userId = req.user.id; const { gitName, gitEmail } = req.body; @@ -90,7 +91,7 @@ router.post('/git-config', authenticateToken, async (req, res) => { console.error('Error updating git config:', error); res.status(500).json({ error: 'Failed to update git configuration' }); } -}); +})); export default router; diff --git a/server/services/desktop-chat-admission.test.ts b/server/services/desktop-chat-admission.test.ts new file mode 100644 index 00000000..40ff6e6e --- /dev/null +++ b/server/services/desktop-chat-admission.test.ts @@ -0,0 +1,119 @@ +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import test from 'node:test'; + +import type { WebSocket } from 'ws'; + +import { handleChatConnection } from '../modules/websocket/services/chat-websocket.service.js'; +import { chatRunRegistry } from '../modules/websocket/services/chat-run-registry.service.js'; +import type { GjcJobProjectionService } from '../modules/websocket/services/gjc-job-projection.service.js'; +import type { AuthenticatedWebSocketRequest } from '../shared/types.js'; + +import { DesktopRestartAuthority } from './desktop-restart-authority.js'; + +class Socket extends EventEmitter { + readyState = 1; + sent: Record[] = []; + send(value: string) { this.sent.push(JSON.parse(value)); } + dispatch(value: unknown) { this.emit('message', JSON.stringify(value)); } +} +const tick = () => new Promise((resolve) => setImmediate(resolve)); +const attempt = { attemptId: 'test', epoch: 'test-native' }; +function createAuthority() { + return new DesktopRestartAuthority({ requiredOwners: ['test'], ownerReaders: { test: { + getGeneration: () => 'g1', + read: () => ({ owner: 'test', generation: 'g1', complete: true, starting: 0, queued: 0, running: 0, settling: 0, approvals: 0, retained: 0, unknown: [] }), + } } }); +} +function connect(admission: DesktopRestartAuthority, overrides: Partial[2]> = {}) { + const socket = new Socket(); + handleChatConnection(socket as unknown as WebSocket, { user: { id: 1 } } as AuthenticatedWebSocketRequest, { + desktopRestartAdmission: admission, spawnFns: { gjc: async () => {} }, abortFns: { gjc: () => false }, + resolveToolApproval() {}, getPendingApprovalsForSession: () => [], ...overrides, + }); + return socket; +} + +test('a previously connected socket cannot dispatch new work while preparation is fenced', async (t) => { + const admission = createAuthority(); + let projections = 0; + const socket = connect(admission, { gjcProjection: { async handle() { projections++; return true; } } as unknown as GjcJobProjectionService }); + t.after(() => socket.emit('close')); + assert.equal((await admission.prepare(attempt)).ok, true); + for (const type of ['chat.send', 'chat.steer', 'chat.goal', 'oauth.start', 'oauth.providers', 'gjc.job.subscribe']) socket.dispatch({ type }); + await tick(); + assert.equal(projections, 0, 'denial happens before the first projection await'); + assert.equal(socket.sent.length, 6); + assert.ok(socket.sent.every((frame) => frame.code === 'DESKTOP_RESTART_FENCED')); +}); + +test('projection dispatch keeps the same lease after a websocket disconnect', async () => { + const admission = createAuthority(); + let finish!: (handled: boolean) => void; + const pending = new Promise((resolve) => { finish = resolve; }); + const socket = connect(admission, { gjcProjection: { handle() { return pending; } } as unknown as GjcJobProjectionService }); + socket.dispatch({ type: 'gjc.job.subscribe' }); + assert.equal((await admission.snapshot()).ingress, 1); + socket.emit('close'); + assert.equal((await admission.prepare(attempt)).ok, false); + finish(true); await tick(); + assert.equal((await admission.snapshot()).ingress, 0); + assert.equal((await admission.prepare(attempt)).ok, true); +}); + +test('cached chat subscription remains available while new work is fenced', async (t) => { + const admission = createAuthority(); const socket = connect(admission); + t.after(() => socket.emit('close')); + assert.equal((await admission.prepare(attempt)).ok, true); + socket.dispatch({ type: 'chat.subscribe', sessions: [{ sessionId: 'no-active-run' }] }); + await tick(); + assert.equal(socket.sent[0]?.kind, 'chat_subscribed'); + assert.equal((await admission.snapshot()).ingress, 0); +}); + +test('an already recorded approval may complete during preparation and invalidates its token', async (t) => { + const admission = createAuthority(); let resolved = 0; + const socket = connect(admission, { resolveToolApproval() { resolved++; } }); + t.after(() => { socket.emit('close'); chatRunRegistry.clearAll(); }); + const run = chatRunRegistry.startRun({ appSessionId: 'approval-session', provider: 'gjc', providerSessionId: null, connection: socket, userId: null }); + assert.ok(run); + run.writer.send({ kind: 'permission_request', requestId: 'owned-approval', toolName: 'bash' }); + const prepared = await admission.prepare(attempt); assert.equal(prepared.ok, true); + socket.dispatch({ type: 'chat.permission-response', requestId: 'owned-approval', allow: false }); + await tick(); + assert.equal(resolved, 1); + assert.equal(chatRunRegistry.getPendingApproval('owned-approval'), null); + if (prepared.ok) assert.equal((await admission.commit(prepared.token, attempt.epoch)).ok, false); +}); + +test('a forged completion is not allowed to use the owned-completion admission path', async (t) => { + const admission = createAuthority(); let resolved = 0; + const socket = connect(admission, { resolveToolApproval() { resolved++; } }); + t.after(() => socket.emit('close')); + assert.equal((await admission.prepare(attempt)).ok, true); + socket.dispatch({ type: 'chat.permission-response', requestId: 'not-owned', allow: true }); + await tick(); + assert.equal(resolved, 0); + assert.equal(socket.sent[0]?.code, 'DESKTOP_RESTART_FENCED'); +}); + +test('a remembered OAuth UI owner cannot bypass the fence through lazy-spawning submit or cancel', async (t) => { + const admission = createAuthority(); let completions = 0; + const socket = connect(admission, { oauthSupervisor: { + oauthProviders: async () => ({}), oauthStatus: async () => ({}), + oauthStart: async () => ({ ok: true, result: { attemptId: 'old-attempt' } }), + oauthSubmit: async () => { completions++; return {}; }, + oauthCancel: async () => { completions++; return {}; }, + subscribeOAuth: () => () => {}, + } }); + t.after(() => socket.emit('close')); + socket.dispatch({ type: 'oauth.start', providerId: 'test' }); await tick(); + assert.equal(socket.sent[0]?.kind, 'oauth.start'); + const prepared = await admission.prepare(attempt); assert.equal(prepared.ok, true); + // This UI ownership cache is deliberately unchanged, as it would be after a + // terminal event or worker replacement. It is not live process ownership. + socket.dispatch({ type: 'oauth.submit', attemptId: 'old-attempt', value: 'fixture' }); + socket.dispatch({ type: 'oauth.cancel', attemptId: 'old-attempt' }); await tick(); + assert.equal(completions, 0); + assert.equal(socket.sent.filter((frame) => frame.code === 'DESKTOP_RESTART_FENCED').length, 2); +}); diff --git a/server/services/desktop-http-admission.test.ts b/server/services/desktop-http-admission.test.ts new file mode 100644 index 00000000..32c7ea7b --- /dev/null +++ b/server/services/desktop-http-admission.test.ts @@ -0,0 +1,123 @@ +import assert from 'node:assert/strict'; +import { once } from 'node:events'; +import http from 'node:http'; +import test from 'node:test'; + +import express from 'express'; + +import { createGjcAppFactory } from '../app-factory.js'; +import { asyncHandler, getHttpActivityGeneration, snapshotHttpActivity } from '../shared/utils.js'; + +import { DesktopRestartAuthority } from './desktop-restart-authority.js'; + +function deferred() { + let resolve!: () => void; + const promise = new Promise((done) => { resolve = done; }); + return { promise, resolve }; +} +function authority() { + return new DesktopRestartAuthority({ requiredOwners: ['test'], ownerReaders: { test: { + getGeneration: () => 'g1', + read: () => ({ owner: 'test', generation: 'g1', complete: true, starting: 0, queued: 0, running: 0, settling: 0, approvals: 0, retained: 0, unknown: [] }), + } } }); +} +const attempt = { attemptId: 'test', epoch: 'test-native' }; +const tick = () => new Promise((resolve) => setImmediate(resolve)); + +test('response finish and client disconnect do not release unfinished handler ownership', async (t) => { + const admission = authority(); + const app = express(); + app.locals.desktopRestartAdmission = admission; + const started = deferred(); + const finish = deferred(); + app.post('/write', asyncHandler(async (_req, res) => { + started.resolve(); + res.json({ accepted: true }); + await finish.promise; + })); + const server = http.createServer(app).listen(0, '127.0.0.1'); + await once(server, 'listening'); + t.after(async () => { finish.resolve(); await new Promise((resolve) => server.close(() => resolve())); }); + const address = server.address(); + assert.ok(address && typeof address !== 'string'); + const response = await fetch(`http://127.0.0.1:${address.port}/write`, { method: 'POST' }); + await response.json(); await started.promise; + assert.equal((await admission.snapshot()).ingress, 1); + assert.equal((await admission.prepare(attempt)).ok, false); + finish.resolve(); await tick(); + assert.equal((await admission.snapshot()).ingress, 0); + assert.equal((await admission.prepare(attempt)).ok, true); +}); + +test('GET producers and later mounted routes share the production composition fence', async (t) => { + const previous = process.env.GJC_DESKTOP; + delete process.env.GJC_DESKTOP; + t.after(() => { if (previous === undefined) delete process.env.GJC_DESKTOP; else process.env.GJC_DESKTOP = previous; }); + const admission = authority(); + let starts = 0; + let ownerAttachments = 0; + const factory = createGjcAppFactory({ + authority: {}, orchestrator: { deps: {} }, gitService: {}, projection: { publish() {} }, terminalNotificationAdapter: undefined, + authenticateWebSocket: () => false, authenticateGjcRoute: (_req: unknown, _res: unknown, next: () => void) => next(), + validateApiKey: (_req: unknown, _res: unknown, next: () => void) => next(), chat: {}, shell: {}, + desktopRestartAdmission: admission, + }); + factory.app.get('/api/probe', asyncHandler(async (_req, res) => { starts++; res.json({ started: true }); })); + factory.app.use('/api/owner-probe', (_req, _res, next) => { ownerAttachments++; next(); }); + factory.app.get('/api/owner-probe', asyncHandler(async (_req, res) => res.json({ ok: true }))); + factory.app.get('/health', (_req, res) => res.json({ status: 'ok' })); + factory.server.listen(0, '127.0.0.1'); await once(factory.server, 'listening'); + t.after(async () => { factory.wss.close(); await new Promise((resolve) => factory.server.close(() => resolve())); }); + const address = factory.server.address(); + assert.ok(address && typeof address !== 'string'); + const origin = `http://127.0.0.1:${address.port}`; + const prepared = await admission.prepare(attempt); + assert.equal(prepared.ok, true); + const denied = await fetch(`${origin}/api/probe`); + assert.equal(denied.status, 503); + assert.equal(denied.headers.get('retry-after'), '1'); + assert.equal((await denied.json()).code, 'DESKTOP_RESTART_FENCED'); + assert.equal(starts, 0); + assert.equal((await fetch(`${origin}/api/owner-probe`)).status, 503); + assert.equal(ownerAttachments, 0, 'new requests must not create an owner before handler admission'); + assert.equal((await fetch(`${origin}/health`)).status, 200); + if (prepared.ok) admission.cancel(prepared.token); + assert.equal((await fetch(`${origin}/api/probe`)).status, 200); + assert.equal(starts, 1); +}); + +test('sync throws and async rejection release once and reach Express error handling', async (t) => { + const admission = authority(); + const initialActivity = snapshotHttpActivity(); + const app = express(); app.locals.desktopRestartAdmission = admission; + app.get('/sync', asyncHandler(() => { throw new Error('sync'); })); + app.get('/async', asyncHandler(async () => { throw new Error('async'); })); + app.use((error: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => { res.status(500).json({ error: error.message }); }); + const server = http.createServer(app).listen(0, '127.0.0.1'); await once(server, 'listening'); + t.after(async () => { await new Promise((resolve) => server.close(() => resolve())); }); + const address = server.address(); assert.ok(address && typeof address !== 'string'); + for (const route of ['sync', 'async']) { + const generation = getHttpActivityGeneration(); + assert.equal((await fetch(`http://127.0.0.1:${address.port}/${route}`)).status, 500); + assert.equal((await admission.snapshot()).ingress, 0); + assert.equal(snapshotHttpActivity().running, initialActivity.running); + assert.notEqual(getHttpActivityGeneration(), generation); + } +}); + +test('aborted transport does not authorize restart before the accepted write settles', async (t) => { + const admission = authority(); + const app = express(); app.locals.desktopRestartAdmission = admission; + const started = deferred(); const finish = deferred(); + app.post('/write', asyncHandler(async (_req, res) => { started.resolve(); await finish.promise; res.end(); })); + const server = http.createServer(app).listen(0, '127.0.0.1'); await once(server, 'listening'); + t.after(async () => { finish.resolve(); await new Promise((resolve) => server.close(() => resolve())); }); + const address = server.address(); assert.ok(address && typeof address !== 'string'); + const controller = new AbortController(); + const request = fetch(`http://127.0.0.1:${address.port}/write`, { method: 'POST', signal: controller.signal }).catch(() => null); + await started.promise; controller.abort(); await request; await tick(); + assert.equal((await admission.snapshot()).ingress, 1); + assert.equal((await admission.prepare(attempt)).ok, false); + finish.resolve(); await tick(); + assert.equal((await admission.snapshot()).ingress, 0); +}); diff --git a/server/services/desktop-http-route-coverage.test.ts b/server/services/desktop-http-route-coverage.test.ts new file mode 100644 index 00000000..9655f338 --- /dev/null +++ b/server/services/desktop-http-route-coverage.test.ts @@ -0,0 +1,189 @@ +import assert from 'node:assert/strict'; +import { readFileSync, readdirSync } from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +import ts from 'typescript'; + +type HandlerKind = 'wrapped' | 'raw-async' | 'raw-sync' | 'unresolved'; +type Registration = { file: string; line: number; method: string; route: string; handler: string; kind: HandlerKind }; +const routeMethods = new Set(['all', 'get', 'post', 'put', 'patch', 'delete', 'head', 'options']); +const wrapperSources = new Set(['@/shared/utils.js', '../shared/utils.js', './shared/utils.js']); + +function productionRouteFiles(): string[] { + const server = fileURLToPath(new URL('../', import.meta.url)); + const routes = readdirSync(path.join(server, 'routes')) + .filter((name) => name.endsWith('.js') && !name.endsWith('.test.js')) + .map((name) => path.join(server, 'routes', name)); + for (const module of readdirSync(path.join(server, 'modules'), { withFileTypes: true })) { + if (!module.isDirectory()) continue; + const directory = path.join(server, 'modules', module.name); + routes.push(...readdirSync(directory).filter((name) => /routes\.(?:ts|js)$/u.test(name)).map((name) => path.join(directory, name))); + } + return [...routes, path.join(server, 'voice-proxy.js'), path.join(server, 'index.js')].sort(); +} + +// Inspect direct HTTP registration arguments, including local named handlers, +// aliases and handler arrays. Import bodies, dynamic registration, middleware +// and work detached from a handler's returned Promise are NOT proven covered. +function registrations(sources: Map): Registration[] { + const options: ts.CompilerOptions = { allowJs: true, noLib: true, noResolve: true, types: [], target: ts.ScriptTarget.Latest }; + const host = ts.createCompilerHost(options); + host.getSourceFile = (filename, languageVersion) => { + const text = sources.get(filename); + return text === undefined ? undefined : ts.createSourceFile(filename, text, languageVersion, true); + }; + const program = ts.createProgram([...sources.keys()], options, host); + const checker = program.getTypeChecker(); + const found: Registration[] = []; + + function resolveLocal(node: ts.Node, seen = new Set()): ts.Node { + if (seen.has(node)) return node; + seen.add(node); + if (ts.isParenthesizedExpression(node) || ts.isAsExpression(node) || ts.isNonNullExpression(node)) { + return resolveLocal(node.expression, seen); + } + if (ts.isIdentifier(node)) { + const declarations = checker.getSymbolAtLocation(node)?.declarations ?? []; + for (const declaration of declarations) { + if (ts.isFunctionDeclaration(declaration)) return declaration; + if (ts.isVariableDeclaration(declaration) && declaration.initializer) return resolveLocal(declaration.initializer, seen); + } + } + return node; + } + + function isSharedWrapper(node: ts.Node): boolean { + if (!ts.isCallExpression(node)) return false; + const callee = resolveLocal(node.expression); + if (!ts.isIdentifier(callee)) return false; + return (checker.getSymbolAtLocation(callee)?.declarations ?? []).some((declaration) => { + if (!ts.isImportSpecifier(declaration) || (declaration.propertyName ?? declaration.name).text !== 'asyncHandler') return false; + const imported = declaration.parent.parent.parent; + return ts.isImportDeclaration(imported) && ts.isStringLiteral(imported.moduleSpecifier) + && wrapperSources.has(imported.moduleSpecifier.text); + }); + } + + for (const file of program.getSourceFiles()) { + const visit = (node: ts.Node): void => { + if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) + && routeMethods.has(node.expression.name.text) && node.arguments.length >= 2) { + const method = node.expression.name.text; + const route = node.arguments[0]; + if (ts.isStringLiteralLike(route) || ts.isTemplateExpression(route)) { + const record = (argument: ts.Expression): void => { + const resolved = resolveLocal(argument); + if (ts.isArrayLiteralExpression(resolved)) { + for (const element of resolved.elements) record(element); + return; + } + const callback = ts.isArrowFunction(resolved) || ts.isFunctionExpression(resolved) || ts.isFunctionDeclaration(resolved); + const kind: HandlerKind = isSharedWrapper(resolved) ? 'wrapped' + : callback ? resolved.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.AsyncKeyword) ? 'raw-async' : 'raw-sync' + : 'unresolved'; + found.push({ + file: file.fileName, + line: file.getLineAndCharacterOfPosition(argument.getStart(file)).line + 1, + method, + route: ts.isStringLiteralLike(route) ? route.text : route.getText(file), + handler: ts.isIdentifier(argument) ? argument.text : '', + kind, + }); + }; + for (const argument of node.arguments.slice(1)) record(argument); + } + } + ts.forEachChild(node, visit); + }; + visit(file); + } + return found; +} + +test('source scanner catches direct, named and aliased async HTTP handlers, including GET', () => { + const found = registrations(new Map([['fixture.ts', ` + import { asyncHandler as wrap } from '@/shared/utils.js'; + import { authenticateToken } from './auth.js'; + async function named(req, res) { await work(); } + const alias = named; + const wrapped = wrap(named); + router.get('/get', async (req, res) => { await work(); }); + app.post('/named', named); + router.patch('/alias', alias); + router.put('/array', [authenticateToken, async function callback(req, res) {}]); + router.delete('/wrapped', wrap(alias)); + router.options('/wrapped-variable', wrapped); + router.head('/sync', (req, res) => res.end()); + router.all('/dynamic', makeHandler()); + async function notARoute() { await work(); } + `]])); + assert.deepEqual(found.map(({ route, kind }) => [route, kind]), [ + ['/get', 'raw-async'], ['/named', 'raw-async'], ['/alias', 'raw-async'], + ['/array', 'unresolved'], ['/array', 'raw-async'], ['/wrapped', 'wrapped'], + ['/wrapped-variable', 'wrapped'], ['/sync', 'raw-sync'], ['/dynamic', 'unresolved'], + ]); +}); + +test('source scanner respects lexical shadowing and only trusts the shared wrapper import', () => { + const found = registrations(new Map([['fixture.ts', ` + import { asyncHandler } from '@/shared/utils.js'; + const named = async (req, res) => {}; + function register() { + const named = (req, res) => res.end(); + router.post('/inner', named); + } + function impostor(asyncHandler) { + router.post('/impostor', asyncHandler(named)); + } + router.post('/outer', named); + router.post('/wrapped', asyncHandler(named)); + `]])); + assert.deepEqual(found.map(({ route, kind }) => [route, kind]), [ + ['/inner', 'raw-sync'], ['/impostor', 'unresolved'], ['/outer', 'raw-async'], ['/wrapped', 'wrapped'], + ]); +}); + +test('production direct HTTP handlers use shared asyncHandler; bootstrap and authentication gaps stay explicit', (t) => { + const root = fileURLToPath(new URL('../../', import.meta.url)); + const files = productionRouteFiles(); + const found = registrations(new Map(files.map((file) => [file, readFileSync(file, 'utf8')]))); + const relative = (file: string): string => path.relative(root, file).split(path.sep).join('/'); + assert.deepEqual([...new Set(found.map(({ file }) => file))].sort(), files, 'Every scoped route file must actually be inspected.'); + assert.deepEqual(found.filter(({ kind }) => kind === 'raw-async'), [], 'Raw async registration bypasses the desktop admission lease.'); + + // Only parent-owned bootstrap/static registrations remain raw. This is an + // inspection allowlist, not permission to restart: sendFile still needs a + // lifetime owner and middleware still needs its own admission coverage. + const knownRawRoutes = new Set([ + 'server/index.js get /health', + 'server/index.js get *', + ]); + // authenticateToken may create the implicit owner before the inner route + // lease. Its outer /api admission fence is verified by separate integration + // tests; this direct-argument scanner cannot establish middleware ownership. + const knownImportedMiddleware = new Set([ + 'server/routes/auth.js get /user authenticateToken', + 'server/routes/user.js get /git-config authenticateToken', + 'server/routes/user.js post /git-config authenticateToken', + 'server/index.js get /api/browse-filesystem authenticateToken', + 'server/index.js post /api/create-folder authenticateToken', + 'server/index.js get /api/projects/:projectId/file authenticateToken', + 'server/index.js get /api/projects/:projectId/files/content authenticateToken', + 'server/index.js put /api/projects/:projectId/file authenticateToken', + 'server/index.js get /api/projects/:projectId/files authenticateToken', + 'server/index.js post /api/projects/:projectId/files/create authenticateToken', + 'server/index.js put /api/projects/:projectId/files/rename authenticateToken', + 'server/index.js delete /api/projects/:projectId/files authenticateToken', + 'server/index.js post /api/projects/:projectId/files/upload authenticateToken', + 'server/index.js get /api/projects/:projectId/sessions/:sessionId/token-usage authenticateToken', + ]); + for (const item of found) { + const key = `${relative(item.file)} ${item.method} ${item.route}`; + if (item.kind === 'raw-sync') assert.ok(knownRawRoutes.has(key), `Unreviewed raw registration: ${key}:${item.line}`); + if (item.kind === 'unresolved') assert.ok(knownImportedMiddleware.has(`${key} ${item.handler}`), `Unresolved registration: ${key}:${item.line} ${item.handler}`); + } + t.diagnostic(`${found.filter(({ kind }) => kind === 'wrapped').length} wrapped registration arguments; ${found.filter(({ kind }) => kind === 'raw-sync').length} raw synchronous/callback registrations; ${found.filter(({ kind }) => kind === 'unresolved').length} imported middleware arguments.`); + t.diagnostic('Wrapper presence does not prove stream, multer callback, spawned process, background job, or service-side producer completion.'); +}); diff --git a/server/services/desktop-restart-authority.test.ts b/server/services/desktop-restart-authority.test.ts new file mode 100644 index 00000000..e47518cc --- /dev/null +++ b/server/services/desktop-restart-authority.test.ts @@ -0,0 +1,705 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import type { DesktopOwnerActivity } from '../../shared/desktopUpdateProtocol.js'; + +import { + DesktopRestartAuthority, + type DesktopRestartAuthorityOptions, + type DesktopRestartPrepareResult, +} from './desktop-restart-authority.js'; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: Error) => void; + const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); + return { promise, resolve, reject }; +} + +class Clock { + time = 1_000; + private sequence = 0; + readonly timers = new Map void }>(); + now = () => this.time; + schedule = (callback: () => void, delayMs: number) => { + const id = ++this.sequence; + this.timers.set(id, { at: this.time + delayMs, callback }); + return () => { this.timers.delete(id); }; + }; + advance(ms: number) { + const end = this.time + ms; + for (;;) { + const next = [...this.timers].filter(([, timer]) => timer.at <= end).sort((a, b) => a[1].at - b[1].at)[0]; + if (!next) break; + this.time = next[1].at; + this.timers.delete(next[0]); + next[1].callback(); + } + this.time = end; + } +} + +const idle = (owner = 'worker', generation = 'g1', patch: Partial = {}): DesktopOwnerActivity => ({ + owner, generation, complete: true, starting: 0, queued: 0, running: 0, + settling: 0, approvals: 0, retained: 0, unknown: [], ...patch, +}); + +function fixture(options: Partial = {}) { + const clock = new Clock(); + const owner: { generation: string; reads: number; read: () => unknown | Promise } = { + generation: 'g1', reads: 0, read: () => idle('worker', owner.generation), + }; + const authority = new DesktopRestartAuthority({ + requiredOwners: ['worker'], + ownerReaders: { worker: { getGeneration: () => owner.generation, read: () => { owner.reads++; return owner.read(); } } }, + now: clock.now, schedule: clock.schedule, randomToken: () => 'deterministic-entropy', + ...options, + }); + return { authority, clock, owner }; +} + +const attempt = { attemptId: 'attempt-1', epoch: 'native-1' }; +function prepared(result: DesktopRestartPrepareResult): asserts result is Extract { + assert.equal(result.ok, true, JSON.stringify(result)); +} +function fenced(authority: DesktopRestartAuthority) { + assert.throws(() => authority.enter('http:start'), { code: 'DESKTOP_RESTART_FENCED' }); +} +// Only flush in-memory promise reactions; never launch a server or read app data. +const tick = () => new Promise((resolve) => setImmediate(resolve)); + +test('idle prepare fences synchronously, returns a bound expiring token, and commits after a fresh read', async () => { + const { authority, clock, owner } = fixture(); + assert.equal((await authority.snapshot()).idle, true); + const pending = authority.prepare(attempt); + assert.equal(authority.state, 'preparing'); + fenced(authority); + const result = await pending; + prepared(result); + assert.equal(result.attemptId, attempt.attemptId); + assert.equal(result.epoch, attempt.epoch); + assert.equal(result.expiresAt, clock.time + 10_000); + assert.equal(result.snapshot.state, 'prepared'); + assert.equal(result.snapshot.complete, true); + assert.equal(clock.timers.size, 1); + const previousReads = owner.reads; + const committing = authority.commit(result.token, attempt.epoch); + fenced(authority); + assert.deepEqual(await committing, { ok: true, state: 'committed', ...attempt }); + assert.equal(owner.reads, previousReads + 1); + assert.equal(clock.timers.size, 0); +}); + +test('known ingress returns busy immediately without waiting for any owner reader', async () => { + const { authority, owner } = fixture(); + const release = authority.enter('ws:chat.send'); + owner.read = () => new Promise(() => {}); + const result = await authority.prepare(attempt); + assert.deepEqual(result, { ok: false, code: 'busy', blockers: [{ kind: 'busy', code: 'ingress_busy' }] }); + assert.equal(owner.reads, 0); + assert.equal(authority.state, 'open'); + release(); +}); + +test('runtime admission closes only after top-level admission and before owner reads', async () => { + const closed = deferred(); + const released = deferred(); + const calls: string[] = []; + let fenceId = ''; + const { authority, owner } = fixture({ preparationFence: { + owner: 'worker', + close: async (id) => { fenced(authority); fenceId = id; calls.push('close'); await closed.promise; }, + release: async (id) => { assert.equal(id, fenceId); calls.push('release'); await released.promise; }, + } }); + owner.read = () => { calls.push('read'); return idle(); }; + const pending = authority.prepare(attempt); + fenced(authority); + await tick(); + assert.deepEqual(calls, ['close']); + closed.resolve(); + const result = await pending; prepared(result); + assert.deepEqual(calls, ['close', 'read']); + authority.cancel(result.token); + await tick(); + assert.deepEqual(calls, ['close', 'read', 'release']); + assert.equal((await authority.snapshot()).ingress, 1); + assert.equal((await authority.prepare({ attemptId: 'next', epoch: 'native-next' })).ok, false); + released.resolve(); await tick(); + assert.equal((await authority.snapshot()).idle, true); +}); + +test('known ingress never closes a worker admission fence', async () => { + let closes = 0; + const { authority } = fixture({ preparationFence: { + owner: 'worker', close: () => { closes++; }, release: () => {}, + } }); + const release = authority.enter('chat:accepted'); + const result = await authority.prepare(attempt); + assert.equal(result.ok, false); + assert.equal(closes, 0); + release(); +}); + +test('completion during worker fence setup invalidates preparation even if idle again', async () => { + const closed = deferred(); + let releases = 0; + const { authority, owner } = fixture({ preparationFence: { + owner: 'worker', close: () => closed.promise, release: () => { releases++; }, + } }); + const pending = authority.prepare(attempt); + await tick(); + const release = authority.enterCompletion('existing:completion'); release(); + closed.resolve(); + const result = await pending; + assert.equal(result.ok, false); + assert.equal(owner.reads, 0); + if (!result.ok) assert.ok(result.blockers.some((blocker) => blocker.code === 'activity_changed')); + await tick(); assert.equal(releases, 1); +}); + +test('worker fencing and owner observation share one preparation deadline', async () => { + const clock = new Clock(); + const observed = deferred(); + let releases = 0; + const { authority, owner } = fixture({ now: clock.now, schedule: clock.schedule, readTimeoutMs: 10, + preparationFence: { owner: 'worker', close: () => { clock.time += 6; }, release: () => { releases++; } }, + }); + owner.read = () => observed.promise; + const pending = authority.prepare(attempt); + await tick(); assert.equal(owner.reads, 1); + clock.advance(4); + const result = await pending; + assert.equal(result.ok, false); + assert.equal(clock.time, 1_010); + observed.resolve(idle()); await tick(); + assert.equal(releases, 1); + assert.equal(authority.state, 'open'); +}); + +test('controller loss retains a late worker close until exact-ID release settles', async () => { + const closed = deferred(); + const released = deferred(); + const calls: string[] = []; + let fenceId = ''; + const { authority, owner } = fixture({ preparationFence: { + owner: 'worker', close: async (id) => { fenceId = id; calls.push('close'); await closed.promise; }, + release: async (id) => { assert.equal(id, fenceId); calls.push('release'); await released.promise; }, + } }); + const pending = authority.prepare(attempt); + await tick(); authority.controllerLost(attempt.epoch); + assert.equal((await pending).ok, false); + assert.deepEqual(calls, ['close']); + assert.equal(owner.reads, 0); + assert.equal((await authority.prepare({ attemptId: 'next', epoch: 'native-next' })).ok, false); + closed.resolve(); await tick(); + assert.deepEqual(calls, ['close', 'release']); + assert.equal(owner.reads, 0, 'late setup cannot revive the lost attempt'); + assert.equal((await authority.snapshot()).ingress, 1); + released.resolve(); await tick(); + assert.equal((await authority.snapshot()).idle, true); +}); + +test('fence setup timeout does not release before the actual close request settles', async () => { + const closed = deferred(); + let released = false; + const { authority, clock } = fixture({ preparationFence: { + owner: 'worker', close: () => closed.promise, release: () => { released = true; }, + } }); + const pending = authority.prepare(attempt); + await tick(); clock.advance(5_000); + assert.equal((await pending).ok, false); + assert.equal(released, false); + assert.equal((await authority.snapshot()).ingress, 1); + closed.resolve(); await tick(); + assert.equal(released, true); +}); + +test('failed worker release remains unknown rather than pretending all admission reopened', async () => { + const { authority } = fixture({ preparationFence: { + owner: 'worker', close: () => {}, release: () => { throw new Error('release not acknowledged'); }, + } }); + const result = await authority.prepare(attempt); prepared(result); + authority.cancel(result.token); await tick(); + const snapshot = await authority.snapshot(); + assert.equal(snapshot.ingress, 0); + assert.equal(snapshot.complete, false); + assert.ok(snapshot.blockers.some((blocker) => blocker.owner === 'worker' && blocker.code === 'owner_failed')); + assert.equal((await authority.prepare({ attemptId: 'next', epoch: 'native-next' })).ok, false); +}); + +test('expiry releases the runtime fence but committed shutdown never does', async () => { + for (const commit of [false, true]) { + let releases = 0; + const { authority, clock } = fixture({ preparationFence: { + owner: 'worker', close: () => {}, release: () => { releases++; }, + } }); + const result = await authority.prepare(attempt); prepared(result); + if (commit) assert.equal((await authority.commit(result.token, attempt.epoch)).ok, true); + clock.advance(10_000); authority.controllerLost(attempt.epoch); authority.cancel(result.token); + await tick(); + assert.equal(releases, commit ? 0 : 1); + assert.equal(authority.state, commit ? 'committed' : 'open'); + } +}); + +test('owned completion may finish under a reversible fence but invalidates its prepared proof', async () => { + const { authority } = fixture(); + const result = await authority.prepare(attempt); + prepared(result); + const release = authority.enterCompletion('ws:approval'); + release(); + const committed = await authority.commit(result.token, attempt.epoch); + assert.equal(committed.ok, false); + assert.equal(authority.state, 'open'); +}); + +test('completion arriving during preparation cannot disappear behind a zero ingress count', async () => { + const { authority, owner } = fixture(); + const read = deferred(); + owner.read = () => read.promise; + const pending = authority.prepare(attempt); + const release = authority.enterCompletion('ws:abort'); + release(); + read.resolve(idle()); + const result = await pending; + assert.equal(result.ok, false); + if (!result.ok) assert.ok(result.blockers.some((blocker) => blocker.code === 'activity_changed')); +}); + +test('committed shutdown rejects even a formerly owned completion', async () => { + const { authority } = fixture(); + const result = await authority.prepare(attempt); + prepared(result); + assert.equal((await authority.commit(result.token, attempt.epoch)).ok, true); + assert.throws(() => authority.enterCompletion('ws:approval'), { code: 'DESKTOP_RESTART_FENCED' }); +}); + +for (const count of ['starting', 'queued', 'running', 'settling', 'approvals', 'retained'] as const) { + test(`owner ${count} blocks prepare without cancelling work`, async () => { + const { authority, owner } = fixture(); + const value = idle('worker', 'g1', { [count]: 1 }); + owner.read = () => value; + const result = await authority.prepare(attempt); + assert.equal(result.ok, false); + if (!result.ok) assert.equal(result.code, 'busy'); + assert.equal(value[count], 1); + assert.equal(authority.state, 'open'); + }); +} + +test('fixed required owner list does not silently lose missing or subsequently remapped readers', async () => { + const requiredOwners = ['worker', 'pty']; + const ownerReaders = { worker: { getGeneration: () => 'g1', read: () => idle() } }; + const { authority } = fixture({ requiredOwners, ownerReaders }); + requiredOwners.pop(); + ownerReaders.worker.read = () => idle('worker', 'g1', { running: 5 }); + const result = await authority.snapshot(); + assert.equal(result.complete, false); + assert.equal(result.idle, false); + assert.deepEqual(result.owners, [idle()]); + assert.ok(result.blockers.some((item) => item.owner === 'pty' && item.code === 'owner_missing')); + assert.equal((await authority.prepare(attempt)).ok, false); +}); + +test('empty, duplicate, malformed owner lists and out-of-budget timeouts reject configuration', () => { + for (const requiredOwners of [[], ['worker', 'worker'], [''], ['x'.repeat(129)]]) { + assert.throws(() => fixture({ requiredOwners }), TypeError); + } + for (const readTimeoutMs of [0, -1, 0.5, 5_001, Number.NaN, Infinity]) { + assert.throws(() => fixture({ readTimeoutMs }), TypeError); + } + for (const tokenTtlMs of [0, -1, 10_001, Infinity]) assert.throws(() => fixture({ tokenTtlMs }), TypeError); +}); + +test('owner snapshot validation rejects malformed values and does not evaluate activity getters', async (t) => { + const missing = { ...idle() } as Partial; + delete missing.retained; + let getterCalled = false; + const getter = { ...idle() }; + Object.defineProperty(getter, 'running', { enumerable: true, get() { getterCalled = true; return 0; } }); + const cases: unknown[] = [null, [], {}, missing, { ...idle(), extra: true }, idle('other'), + { ...idle(), complete: 1 }, { ...idle(), unknown: [''] }, { ...idle(), unknown: new Array(1) }, + { ...idle(), unknown: ['a'.repeat(129)] }, { ...idle(), unknown: new Array(33).fill('unknown') }, + { ...idle(), generation: '' }, { ...idle(), [Symbol('extra')]: true }, getter, Object.create(idle())]; + for (const field of ['starting', 'queued', 'running', 'settling', 'approvals', 'retained']) { + for (const value of [-1, 0.1, Number.NaN, Infinity, Number.MAX_SAFE_INTEGER + 1, '0', null]) { + cases.push({ ...idle(), [field]: value }); + } + } + for (const [index, value] of cases.entries()) { + await t.test(`invalid snapshot ${index}`, async () => { + const { authority, owner } = fixture(); + owner.read = () => value; + const result = await authority.snapshot(); + assert.equal(result.complete, false); + assert.equal(result.idle, false); + assert.ok(result.blockers.some((item) => item.code === 'owner_invalid')); + }); + } + assert.equal(getterCalled, false); +}); + +test('failed and incomplete readers remain unknown without exposing their exception payload', async () => { + for (const read of [ + () => { throw new Error('SENTINEL-private-data'); }, + () => Promise.reject(new Error('SENTINEL-private-data')), + () => idle('worker', 'g1', { complete: false }), + () => idle('worker', 'g1', { unknown: ['cleanup_unconfirmed'] }), + ]) { + const { authority, owner } = fixture(); + owner.read = read; + const result = await authority.prepare(attempt); + assert.equal(result.ok, false); + if (!result.ok) assert.equal(result.code, 'unknown'); + assert.doesNotMatch(JSON.stringify(result), /SENTINEL/); + assert.equal(authority.state, 'open'); + } +}); + +test('incorrect or failed generation observations are unknown', async () => { + for (const getGeneration of [() => 'g2', () => '', () => { throw new Error('private'); }]) { + const { authority } = fixture({ ownerReaders: { worker: { getGeneration, read: () => idle() } } }); + assert.equal((await authority.snapshot()).complete, false); + } +}); + +test('snapshot copies validated values and unknown codes instead of retaining mutable owner references', async () => { + const { authority, owner } = fixture(); + const value = idle('worker', 'g1', { unknown: ['pending_cleanup'] }); + owner.read = () => value; + const snapshot = await authority.snapshot(); + value.running = 42; + (value.unknown as string[]).push('late_change'); + assert.equal(snapshot.owners[0]?.running, 0); + assert.deepEqual(snapshot.owners[0]?.unknown, ['pending_cleanup']); +}); + +test('all owner reads share a bounded deadline and late results cannot prepare a token', async () => { + const pending = deferred(); + const { authority, owner, clock } = fixture(); + owner.read = () => pending.promise; + const preparing = authority.prepare(attempt); + clock.advance(5_000); + const result = await preparing; + assert.equal(result.ok, false); + if (!result.ok) assert.ok(result.blockers.some((item) => item.code === 'owner_timeout')); + assert.equal(authority.state, 'open'); + pending.resolve(idle()); + await tick(); + assert.equal(authority.state, 'open'); + assert.equal(clock.timers.size, 0); +}); + +test('a reader completing after its deadline is rejected even before a delayed timer callback runs', async () => { + const { authority, owner, clock } = fixture(); + owner.read = () => { clock.time += 5_001; return idle(); }; + const result = await authority.prepare(attempt); + assert.equal(result.ok, false); + if (!result.ok) assert.ok(result.blockers.some((item) => item.code === 'owner_timeout')); +}); + +test('the prepare budget includes time after aggregate collection and token generation', async () => { + const first = fixture(); + const snapshot = first.authority.snapshot.bind(first.authority); + first.authority.snapshot = async () => { const value = await snapshot(); first.clock.time += 5_000; return value; }; + const late = await first.authority.prepare(attempt); + assert.equal(late.ok, false); + if (!late.ok) assert.ok(late.blockers.some((item) => item.code === 'snapshot_timeout')); + const clock = new Clock(); + const second = fixture({ now: clock.now, schedule: clock.schedule, randomToken: () => { clock.time += 5_000; return 'nonce'; } }); + assert.equal((await second.authority.prepare(attempt)).ok, false); + assert.equal(second.authority.state, 'open'); +}); + +test('an earlier owner changing while another read awaits invalidates the entire aggregate', async () => { + let generation = 'g1'; + const pending = deferred(); + const { authority } = fixture({ requiredOwners: ['worker', 'pty'], ownerReaders: { + worker: { getGeneration: () => generation, read: () => idle() }, + pty: { getGeneration: () => 'p1', read: () => pending.promise }, + } }); + const preparing = authority.prepare(attempt); + await tick(); + generation = 'g2'; + pending.resolve(idle('pty', 'p1')); + const result = await preparing; + assert.equal(result.ok, false); + if (!result.ok) assert.ok(result.blockers.some((item) => item.code === 'owner_stale' && item.owner === 'worker')); +}); + +test('admission racing an unfenced diagnostic snapshot is observable even after its lease releases', async () => { + const pending = deferred(); + const { authority, owner } = fixture(); + owner.read = () => pending.promise; + const snapshot = authority.snapshot(); + const release = authority.enter('ws:chat.send'); + release(); + pending.resolve(idle()); + const result = await snapshot; + assert.equal(result.ingress, 0); + assert.equal(result.idle, false); + assert.ok(result.blockers.some((item) => item.code === 'activity_changed')); +}); + +test('duplicate concurrent prepare shares a single attempt; other attempts cannot displace its fence', async () => { + const pending = deferred(); + const { authority, owner } = fixture(); + owner.read = () => pending.promise; + const first = authority.prepare(attempt); + assert.equal(authority.prepare({ ...attempt }), first); + assert.deepEqual(await authority.prepare({ ...attempt, attemptId: 'other' }), { ok: false, code: 'in_progress', blockers: [] }); + assert.equal(owner.reads, 1); + fenced(authority); + pending.resolve(idle()); + const result = await first; + prepared(result); + assert.equal(authority.prepare(attempt), first); + authority.cancel(result.token); +}); + +test('new admission is rejected from inside owner reads during prepare and commit', async () => { + const { authority, owner } = fixture(); + owner.read = () => { fenced(authority); return idle(); }; + const result = await authority.prepare(attempt); + prepared(result); + assert.equal((await authority.commit(result.token, attempt.epoch)).ok, true); + fenced(authority); +}); + +test('commit rejects busy owners and preserves an independent health failure across update cancellation', async () => { + const { authority, owner } = fixture(); + let healthy = true; + owner.read = () => idle('worker', 'g1', healthy ? {} : { complete: false, unknown: ['health_failure'] }); + const result = await authority.prepare(attempt); + prepared(result); + healthy = false; + const committed = await authority.commit(result.token, attempt.epoch); + assert.equal(committed.ok, false); + authority.cancel(result.token); + authority.controllerLost(attempt.epoch); + assert.equal(healthy, false); + assert.equal((await authority.prepare({ attemptId: 'next', epoch: 'native-2' })).ok, false); + assert.equal((await authority.snapshot()).idle, false); +}); + +test('busy activity appearing at commit never reaches committed', async () => { + const { authority, owner } = fixture(); + const result = await authority.prepare(attempt); + prepared(result); + owner.read = () => idle('worker', 'g1', { retained: 1 }); + const committed = await authority.commit(result.token, attempt.epoch); + assert.equal(committed.ok, false); + if (!committed.ok) assert.equal(committed.code, 'busy'); + assert.equal(authority.state, 'open'); +}); + +test('owner generation changing between prepare and commit requires a fresh attempt even if idle again', async () => { + const { authority, owner } = fixture(); + const result = await authority.prepare(attempt); + prepared(result); + owner.generation = 'g2'; + const committed = await authority.commit(result.token, attempt.epoch); + assert.equal(committed.ok, false); + if (!committed.ok) assert.ok(committed.blockers.some((item) => item.code === 'owner_stale')); +}); + +test('commit checks generation again after the aggregate promise resolves', async () => { + const { authority, owner } = fixture(); + const result = await authority.prepare(attempt); + prepared(result); + const snapshot = authority.snapshot.bind(authority); + // Model the microtask boundary after collecting real snapshots, not a fake idle result. + authority.snapshot = async () => { const value = await snapshot(); owner.generation = 'g2'; return value; }; + assert.equal((await authority.commit(result.token, attempt.epoch)).ok, false); + assert.equal(authority.state, 'open'); +}); + +test('invalid token or controller epoch cannot cancel, replace, or commit the prepared attempt', async () => { + const { authority } = fixture(); + for (const input of [{ attemptId: '', epoch: 'native-1' }, { attemptId: 'a', epoch: '' }]) { + assert.equal((await authority.prepare(input)).ok, false); + } + const result = await authority.prepare(attempt); + prepared(result); + assert.equal((await authority.commit(result.token, 'native-other')).ok, false); + assert.equal((await authority.commit('invalid', attempt.epoch)).ok, false); + authority.cancel('invalid'); + authority.controllerLost('native-other'); + assert.equal(authority.state, 'prepared'); + assert.equal((await authority.commit(result.token, attempt.epoch)).ok, true); +}); + +test('expiry reopens only the reversible fence and invalidates the old token', async () => { + const { authority, clock } = fixture(); + const result = await authority.prepare(attempt); + prepared(result); + clock.advance(9_999); + fenced(authority); + clock.advance(1); + assert.equal(authority.state, 'open'); + assert.equal((await authority.commit(result.token, attempt.epoch)).ok, false); + authority.enter('http:start')(); + const next = await authority.prepare(attempt); + prepared(next); + assert.notEqual(next.token, result.token); // same injected entropy is still attempt-specific + authority.cancel(result.token); + assert.equal(authority.state, 'prepared'); + authority.cancel(next.token); +}); + +test('commit awaiting a snapshot cannot outlive token expiry', async () => { + const { authority, owner, clock } = fixture({ readTimeoutMs: 100, tokenTtlMs: 50 }); + const result = await authority.prepare(attempt); + prepared(result); + const pending = deferred(); + owner.read = () => pending.promise; + const committing = authority.commit(result.token, attempt.epoch); + clock.advance(50); + assert.equal((await committing).ok, false); + pending.resolve(idle()); + await tick(); + assert.equal(authority.state, 'open'); +}); + +test('commit also checks expiry after its asynchronous snapshot, without requiring timer delivery', async () => { + const { authority, clock } = fixture(); + const result = await authority.prepare(attempt); + prepared(result); + const snapshot = authority.snapshot.bind(authority); + authority.snapshot = async () => { const value = await snapshot(); clock.time += 10_000; return value; }; + assert.equal((await authority.commit(result.token, attempt.epoch)).ok, false); + assert.equal(authority.state, 'open'); +}); + +test('commit exceeding the snapshot budget fails even while its token has time remaining', async () => { + const { authority, clock } = fixture(); + const result = await authority.prepare(attempt); + prepared(result); + const snapshot = authority.snapshot.bind(authority); + authority.snapshot = async () => { const value = await snapshot(); clock.time += 5_001; return value; }; + const committed = await authority.commit(result.token, attempt.epoch); + assert.equal(committed.ok, false); + if (!committed.ok) assert.ok(committed.blockers.some((item) => item.code === 'snapshot_timeout')); + assert.equal(authority.state, 'open'); +}); + +test('a failed commit snapshot is unknown and never clears its owner health failure', async () => { + const { authority, owner, clock } = fixture(); + const result = await authority.prepare(attempt); + prepared(result); + const pending = deferred(); + owner.read = () => pending.promise; + const committing = authority.commit(result.token, attempt.epoch); + clock.advance(5_000); + const failed = await committing; + assert.equal(failed.ok, false); + if (!failed.ok) assert.ok(failed.blockers.some((item) => item.code === 'owner_timeout')); + assert.equal(authority.state, 'open'); + pending.reject(new Error('late private failure')); + await tick(); + owner.read = () => idle('worker', 'g1', { unknown: ['cleanup_unconfirmed'] }); + assert.equal((await authority.prepare({ ...attempt, attemptId: 'retry' })).ok, false); +}); + +test('controller loss while a commit is in flight is precommit cancellation, not restart', async () => { + const { authority, owner } = fixture(); + const result = await authority.prepare(attempt); + prepared(result); + const pending = deferred(); + owner.read = () => pending.promise; + const committing = authority.commit(result.token, attempt.epoch); + authority.controllerLost(attempt.epoch); + assert.equal((await committing).ok, false); + authority.enter('http:start')(); + pending.resolve(idle()); + await tick(); + assert.equal(authority.state, 'open'); +}); + +test('controller loss while preparing rejects promptly and late snapshots cannot displace a new attempt', async () => { + const { authority, owner } = fixture(); + const pending = deferred(); + owner.read = () => pending.promise; + const first = authority.prepare(attempt); + authority.controllerLost(attempt.epoch); + assert.equal((await first).ok, false); + authority.enter('http:start')(); + assert.equal((await authority.prepare(attempt)).ok, false); + owner.read = () => idle(); + const second = await authority.prepare({ attemptId: 'next', epoch: 'native-2' }); + prepared(second); + pending.resolve(idle()); + await tick(); + assert.equal(authority.state, 'prepared'); + assert.equal((await authority.commit(second.token, second.epoch)).ok, true); +}); + +test('cancel while commit reads releases only that fence; late commit cannot interrupt newly admitted work', async () => { + const { authority, owner } = fixture(); + const result = await authority.prepare(attempt); + prepared(result); + const pending = deferred(); + owner.read = () => pending.promise; + const committing = authority.commit(result.token, attempt.epoch); + authority.cancel(result.token); + const release = authority.enter('http:accepted-after-cancel'); + assert.equal((await committing).ok, false); + pending.resolve(idle()); + await tick(); + assert.equal(authority.state, 'open'); + assert.equal((await authority.snapshot()).ingress, 1); + release(); +}); + +test('concurrent commit shares its read and committed never reopens for cancel, expiry or controller loss', async () => { + const { authority, owner, clock } = fixture(); + const result = await authority.prepare(attempt); + prepared(result); + const pending = deferred(); + owner.read = () => pending.promise; + const first = authority.commit(result.token, attempt.epoch); + assert.equal(authority.commit(result.token, attempt.epoch), first); + pending.resolve(idle()); + assert.equal((await first).ok, true); + authority.cancel(result.token); + authority.cancel(result.token); + authority.controllerLost(attempt.epoch); + clock.advance(20_000); + assert.equal(authority.state, 'committed'); + fenced(authority); + assert.equal((await authority.prepare({ attemptId: 'next', epoch: 'native-2' })).ok, false); + assert.equal((await authority.commit(result.token, attempt.epoch)).ok, true); +}); + +test('caller-mutated prepare snapshot cannot rewrite the internally retained generation proof', async () => { + const { authority, owner } = fixture(); + const result = await authority.prepare(attempt); + prepared(result); + result.snapshot.owners[0]!.generation = 'g2'; + owner.generation = 'g2'; + assert.equal((await authority.commit(result.token, attempt.epoch)).ok, false); +}); + +test('lease release is idempotent and guard retains ownership through asynchronous work and failures', async () => { + const { authority } = fixture(); + const release = authority.enter('http:start'); + release(); release(); + assert.equal((await authority.snapshot()).ingress, 0); + const pending = deferred(); + const guarded = authority.guard('internal:work', () => pending.promise); + assert.equal((await authority.snapshot()).ingress, 1); + assert.equal((await authority.prepare(attempt)).ok, false); + pending.resolve(42); + assert.equal(await guarded, 42); + await assert.rejects(authority.guard('internal:work', () => { throw new Error('failed'); }), /failed/); + await assert.rejects(authority.guard('internal:work', () => Promise.reject(new Error('failed'))), /failed/); + assert.equal((await authority.snapshot()).ingress, 0); +}); + +test('token generation failure reopens the update fence without returning a token', async () => { + for (const randomToken of [() => '', () => { throw new Error('entropy unavailable'); }]) { + const { authority } = fixture({ randomToken }); + assert.deepEqual(await authority.prepare(attempt), { ok: false, code: 'token_unavailable', blockers: [] }); + assert.equal(authority.state, 'open'); + } +}); diff --git a/server/services/desktop-restart-authority.ts b/server/services/desktop-restart-authority.ts new file mode 100644 index 00000000..5fe36882 --- /dev/null +++ b/server/services/desktop-restart-authority.ts @@ -0,0 +1,444 @@ +import { randomUUID } from 'node:crypto'; +import { performance } from 'node:perf_hooks'; + +import type { DesktopOwnerActivity } from '../../shared/desktopUpdateProtocol.js'; + +const COUNTS = ['starting', 'queued', 'running', 'settling', 'approvals', 'retained'] as const; +const ACTIVITY_KEYS = ['owner', 'generation', 'complete', ...COUNTS, 'unknown']; +const identifier = (value: unknown): value is string => typeof value === 'string' + && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u.test(value); + +export type DesktopRestartState = 'open' | 'preparing' | 'prepared' | 'committed'; +export type DesktopRestartBlocker = { + kind: 'busy' | 'unknown'; + code: 'ingress_busy' | 'owner_busy' | 'owner_missing' | 'owner_failed' | 'owner_timeout' + | 'owner_invalid' | 'owner_stale' | 'owner_incomplete' | 'owner_unknown' | 'activity_changed' | 'snapshot_timeout'; + owner?: string; +}; +export type DesktopRestartSnapshot = { + state: DesktopRestartState; + revision: number; + ingress: number; + complete: boolean; + idle: boolean; + owners: readonly DesktopOwnerActivity[]; + blockers: readonly DesktopRestartBlocker[]; +}; +export type DesktopRestartFailure = { + ok: false; + code: 'busy' | 'unknown' | 'invalid_attempt' | 'in_progress' | 'committed' + | 'invalid_token' | 'stale_epoch' | 'cancelled' | 'expired' | 'token_unavailable'; + blockers: readonly DesktopRestartBlocker[]; +}; +export type DesktopRestartPrepareResult = DesktopRestartFailure | { + ok: true; + attemptId: string; + epoch: string; + token: string; + /** Deadline in the injected monotonic clock's milliseconds, not a wall-clock date. */ + expiresAt: number; + snapshot: DesktopRestartSnapshot; +}; +export type DesktopRestartCommitResult = DesktopRestartFailure | { + ok: true; + state: 'committed'; + attemptId: string; + epoch: string; +}; + +export type DesktopRestartOwnerReader = { + /** + * Pure synchronous revision of ALL activity, including queued work. Change it + * on every activity mutation and process replacement, not only on PID changes. + */ + getGeneration(): string; + /** Must be nonblocking/read-only; no lazy spawn, cancellation, or health reset. */ + read(): unknown | Promise; +}; +export type DesktopRestartAuthorityOptions = { + requiredOwners: readonly string[]; + ownerReaders?: Readonly>; + now?: () => number; + randomToken?: () => string; + /** Schedule a timer and return its cancellation function. Must not call inline. */ + schedule?: (callback: () => void, delayMs: number) => () => void; + readTimeoutMs?: number; + tokenTtlMs?: number; + /** Trusted runtime admission, not an owner read or browser-selected callback. */ + preparationFence?: { + owner: string; + close(fenceId: string): void | Promise; + release(fenceId: string): void | Promise; + }; +}; + +type Owner = { owner: string; reader?: DesktopRestartOwnerReader }; +type ReadResult = { activity?: DesktopOwnerActivity; blockers: DesktopRestartBlocker[] }; +type Attempt = { + attemptId: string; + epoch: string; + sequence: number; + prepareDeadline: number; + phase: Exclude; + prepared: Promise; + resolvePrepare: (result: DesktopRestartPrepareResult) => void; + token?: string; + expiresAt?: number; + cancelExpiry?: () => void; + generations?: ReadonlyMap; + preparedRevision?: number; + committing?: Promise; + resolveCommit?: (result: DesktopRestartCommitResult) => void; + fenceId?: string; + fenceClosing?: Promise; +}; + +const failure = (code: DesktopRestartFailure['code'], blockers: readonly DesktopRestartBlocker[] = []): DesktopRestartFailure => ({ ok: false, code, blockers }); +const unknown = (code: DesktopRestartBlocker['code'], owner?: string): DesktopRestartBlocker => ({ kind: 'unknown', code, ...(owner ? { owner } : {}) }); + +function duration(value: number | undefined, maximum: number): number { + if (value === undefined) return maximum; + if (!Number.isSafeInteger(value) || value < 1 || value > maximum) throw new TypeError('Invalid desktop restart timeout.'); + return value; +} + +function activity(value: unknown, owner: string): DesktopOwnerActivity | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value) + || (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null)) return; + const descriptors = Object.getOwnPropertyDescriptors(value); + if (Reflect.ownKeys(value).length !== ACTIVITY_KEYS.length + || ACTIVITY_KEYS.some((key) => !descriptors[key] || !Object.hasOwn(descriptors[key], 'value'))) return; + const record = Object.fromEntries(ACTIVITY_KEYS.map((key) => [key, descriptors[key]!.value])); + if (record.owner !== owner || !identifier(record.generation) || typeof record.complete !== 'boolean' + || COUNTS.some((key) => !Number.isSafeInteger(record[key]) || record[key] < 0) + || !Array.isArray(record.unknown) || record.unknown.length > 32 + || !Array.from(record.unknown).every(identifier)) return; + // Copy the entire observation: a reader must not mutate a previously returned proof. + return { + owner, generation: record.generation, complete: record.complete, + starting: record.starting, queued: record.queued, running: record.running, + settling: record.settling, approvals: record.approvals, retained: record.retained, + unknown: [...record.unknown], + }; +} + +/** + * Standalone safety primitive, NOT G3 acceptance or installation authority. + * Integration still must fence every producer and prove zero-gap transfer to + * these existing owners. It never cancels work, mutates health, or shuts down. + * Owner revisions must cover internal producers across the entire async read. + */ +export class DesktopRestartAuthority { + private readonly owners: readonly Owner[]; + private readonly now: () => number; + private readonly randomToken: () => string; + private readonly schedule: NonNullable; + private readonly readTimeoutMs: number; + private readonly tokenTtlMs: number; + private readonly preparationFence: DesktopRestartAuthorityOptions['preparationFence']; + private readonly fenceEpoch = randomUUID(); + private fenceReleaseFailed = false; + private readonly lostEpochs = new Set(); + private ingress = 0; + private revision = 0; + private sequence = 0; + private attempt?: Attempt; + + constructor(options: DesktopRestartAuthorityOptions) { + if (!Array.isArray(options.requiredOwners) || options.requiredOwners.length === 0 + || options.requiredOwners.length > 128 || !options.requiredOwners.every(identifier) + || new Set(options.requiredOwners).size !== options.requiredOwners.length) { + throw new TypeError('A nonempty unique required-owner list is required.'); + } + this.owners = options.requiredOwners.map((owner) => { + const reader = Object.hasOwn(options.ownerReaders ?? {}, owner) ? options.ownerReaders?.[owner] : undefined; + return { owner, ...(reader && typeof reader.read === 'function' && typeof reader.getGeneration === 'function' + ? { reader: { read: reader.read.bind(reader), getGeneration: reader.getGeneration.bind(reader) } } : {}) }; + }); + this.now = options.now ?? (() => performance.now()); + this.randomToken = options.randomToken ?? randomUUID; + this.schedule = options.schedule ?? ((callback, delayMs) => { + const timer = setTimeout(callback, delayMs); + timer.unref(); + return () => clearTimeout(timer); + }); + this.readTimeoutMs = duration(options.readTimeoutMs, 5_000); + this.tokenTtlMs = duration(options.tokenTtlMs, 10_000); + if (options.preparationFence && (!options.requiredOwners.includes(options.preparationFence.owner) + || typeof options.preparationFence.close !== 'function' || typeof options.preparationFence.release !== 'function')) { + throw new TypeError('Preparation fence must belong to a required runtime owner.'); + } + this.preparationFence = options.preparationFence ? Object.freeze({ + owner: options.preparationFence.owner, + close: options.preparationFence.close.bind(options.preparationFence), + release: options.preparationFence.release.bind(options.preparationFence), + }) : undefined; + } + + get state(): DesktopRestartState { + this.expire(); + return this.attempt?.phase ?? 'open'; + } + + enter(source: string): () => void { + if (typeof source !== 'string' || !source.trim() || source.length > 256) throw new TypeError('An admission source is required.'); + this.expire(); + if (this.attempt) throw Object.assign(new Error('Desktop restart admission is fenced.'), { code: 'DESKTOP_RESTART_FENCED' }); + return this.acquire(); + } + + /** Only callers which have validated existing ownership may use this path. */ + enterCompletion(source: string): () => void { + if (typeof source !== 'string' || !source.trim() || source.length > 256) throw new TypeError('An admission source is required.'); + this.expire(); + if (this.attempt?.phase === 'committed') throw Object.assign(new Error('Desktop restart admission is fenced.'), { code: 'DESKTOP_RESTART_FENCED' }); + return this.acquire(); + } + + private acquire(): () => void { + this.ingress += 1; + this.revision += 1; + let released = false; + return () => { + if (released) return; + released = true; + this.ingress -= 1; + this.revision += 1; + }; + } + + async guard(source: string, work: () => T | Promise): Promise { + const release = this.enter(source); + try { return await work(); } + finally { release(); } + } + + async snapshot(): Promise { + this.expire(); + const revision = this.revision; + // Worker fencing and all owner reads share the original prepare budget. + const deadline = Math.min(this.now() + this.readTimeoutMs, + this.attempt?.phase === 'preparing' ? this.attempt.prepareDeadline : Infinity); + const results = await Promise.all(this.owners.map((owner) => this.readOwner(owner, deadline))); + const owners = results.flatMap((result) => result.activity ? [result.activity] : []); + const blockers = results.flatMap((result) => result.blockers); + if (this.fenceReleaseFailed) blockers.push(unknown('owner_failed', this.preparationFence?.owner)); + blockers.push(...this.checkGenerations(owners)); + if (this.now() >= deadline) blockers.push(unknown('snapshot_timeout')); + this.expire(); + if (revision !== this.revision) blockers.push(unknown('activity_changed')); + if (this.ingress > 0) blockers.push({ kind: 'busy', code: 'ingress_busy' }); + const complete = !blockers.some((blocker) => blocker.kind === 'unknown'); + return { state: this.attempt?.phase ?? 'open', revision: this.revision, ingress: this.ingress, complete, idle: complete && blockers.length === 0, owners, blockers }; + } + + prepare(input: { attemptId: string; epoch: string; budgetMs?: number }): Promise { + this.expire(); + if (!input || !identifier(input.attemptId) || !identifier(input.epoch)) return Promise.resolve(failure('invalid_attempt')); + if (input.budgetMs !== undefined && (!Number.isSafeInteger(input.budgetMs) || input.budgetMs < 1 || input.budgetMs > this.readTimeoutMs)) return Promise.resolve(failure('invalid_attempt')); + if (this.lostEpochs.has(input.epoch)) return Promise.resolve(failure('stale_epoch')); + const current = this.attempt; + if (current?.phase === 'committed') return Promise.resolve(failure('committed')); + if (current) return current.attemptId === input.attemptId && current.epoch === input.epoch + ? current.prepared : Promise.resolve(failure('in_progress')); + // Known ingress cannot become idle by waiting or by forced cancellation. + if (this.ingress > 0) return Promise.resolve(failure('busy', [{ kind: 'busy', code: 'ingress_busy' }])); + let resolvePrepare!: Attempt['resolvePrepare']; + const prepared = new Promise((resolve) => { resolvePrepare = resolve; }); + const attempt: Attempt = { attemptId: input.attemptId, epoch: input.epoch, sequence: ++this.sequence, + prepareDeadline: this.now() + (input.budgetMs ?? this.readTimeoutMs), phase: 'preparing', prepared, resolvePrepare }; + this.attempt = attempt; // synchronous fence BEFORE any reader or await + this.revision += 1; + void this.prepareInner(attempt).then(resolvePrepare, () => { + this.reopen(attempt, failure('unknown', [unknown('owner_failed')])); + }); + return prepared; + } + + commit(token: string, epoch: string): Promise { + this.expire(); + const attempt = this.attempt; + if (!attempt || attempt.phase === 'preparing' || token !== attempt.token || epoch !== attempt.epoch) { + return Promise.resolve(failure('invalid_token')); + } + if (attempt.phase === 'committed') return Promise.resolve(this.committed(attempt)); + if (attempt.committing) return attempt.committing; + let resolveCommit!: NonNullable; + const committing = new Promise((resolve) => { resolveCommit = resolve; }); + attempt.committing = committing; + attempt.resolveCommit = resolveCommit; + void this.commitInner(attempt).then(resolveCommit, () => { + this.reopen(attempt, failure('unknown', [unknown('owner_failed')])); + }); + return committing; + } + + cancel(token: string): void { + this.expire(); + if (typeof token === 'string' && this.attempt?.token === token) this.reopen(this.attempt, failure('cancelled')); + } + + controllerLost(epoch: string): void { + if (!identifier(epoch) || this.attempt?.phase === 'committed') return; + this.lostEpochs.add(epoch); + if (this.attempt?.epoch === epoch) this.reopen(this.attempt, failure('stale_epoch')); + } + + private async prepareInner(attempt: Attempt): Promise { + const initialRevision = this.revision; + if (this.preparationFence) { + // The top-level ingress fence is already closed. Only now fence the + // existing worker, before obtaining its non-spawning activity proof. + const fence = this.preparationFence; + const fenceId = `restart:${this.fenceEpoch}:${attempt.sequence}`; + attempt.fenceId = fenceId; + attempt.fenceClosing = Promise.resolve().then(() => fence.close(fenceId)); + const closed = await this.readBeforeDeadline(() => attempt.fenceClosing, attempt.prepareDeadline); + if (this.attempt !== attempt) return failure('cancelled'); + if (closed.kind !== 'value') { + const result = failure('unknown', [unknown(closed.kind === 'timeout' ? 'owner_timeout' : 'owner_failed', fence.owner)]); + this.reopen(attempt, result); + return result; + } + if (initialRevision !== this.revision || this.ingress !== 0) { + const result = failure('unknown', [unknown('activity_changed')]); + this.reopen(attempt, result); + return result; + } + } + const snapshot = await this.snapshot(); + if (this.attempt !== attempt) return failure('cancelled'); + const blockers = [...snapshot.blockers, ...this.checkGenerations(snapshot.owners)]; + if (this.now() >= attempt.prepareDeadline) blockers.push(unknown('snapshot_timeout')); + if (this.revision !== snapshot.revision || this.ingress !== 0) blockers.push(unknown('activity_changed')); + if (blockers.length) { + const result = failure(blockers.some((blocker) => blocker.kind === 'unknown') ? 'unknown' : 'busy', blockers); + this.reopen(attempt, result); + return result; + } + let entropy: string; + try { entropy = this.randomToken(); } + catch { this.reopen(attempt, failure('token_unavailable')); return failure('token_unavailable'); } + if (!identifier(entropy)) { this.reopen(attempt, failure('token_unavailable')); return failure('token_unavailable'); } + const changed = this.checkGenerations(snapshot.owners); + if (this.now() >= attempt.prepareDeadline) changed.push(unknown('snapshot_timeout')); + if (this.attempt !== attempt || this.revision !== snapshot.revision || this.ingress !== 0) changed.push(unknown('activity_changed')); + if (changed.length) { const result = failure('unknown', changed); this.reopen(attempt, result); return result; } + attempt.token = `restart:${attempt.sequence}:${entropy}`; + attempt.generations = new Map(snapshot.owners.map((owner) => [owner.owner, owner.generation])); + attempt.expiresAt = this.now() + this.tokenTtlMs; + attempt.phase = 'prepared'; + this.revision += 1; + attempt.preparedRevision = this.revision; + attempt.cancelExpiry = this.schedule(() => this.expire(), this.tokenTtlMs); + return { ok: true, token: attempt.token, attemptId: attempt.attemptId, epoch: attempt.epoch, expiresAt: attempt.expiresAt, snapshot: { ...snapshot, state: 'prepared', revision: this.revision } }; + } + + private async commitInner(attempt: Attempt): Promise { + const deadline = this.now() + this.readTimeoutMs; + const snapshot = await this.snapshot(); + this.expire(); + if (this.attempt !== attempt) return failure('cancelled'); + const blockers = [...snapshot.blockers, ...this.checkGenerations(snapshot.owners)]; + if (attempt.preparedRevision !== snapshot.revision) blockers.push(unknown('activity_changed')); + for (const owner of snapshot.owners) { + if (attempt.generations?.get(owner.owner) !== owner.generation) blockers.push(unknown('owner_stale', owner.owner)); + } + this.expire(); + if (this.now() >= deadline) blockers.push(unknown('snapshot_timeout')); + // Last synchronous check, AFTER every asynchronous read and generation getter. + if (this.attempt !== attempt || this.revision !== snapshot.revision || this.ingress !== 0) blockers.push(unknown('activity_changed')); + if (blockers.length) { + const result = failure(blockers.some((blocker) => blocker.kind === 'unknown') ? 'unknown' : 'busy', blockers); + this.reopen(attempt, result); + return result; + } + attempt.phase = 'committed'; + this.revision += 1; + attempt.cancelExpiry?.(); + attempt.cancelExpiry = undefined; + return this.committed(attempt); + } + + private committed(attempt: Attempt): DesktopRestartCommitResult { + return { ok: true, state: 'committed', attemptId: attempt.attemptId, epoch: attempt.epoch }; + } + + private expire(): void { + const attempt = this.attempt; + if (attempt?.phase === 'prepared' && attempt.expiresAt !== undefined && this.now() >= attempt.expiresAt) { + this.reopen(attempt, failure('expired')); + } + } + + private reopen(attempt: Attempt, result: DesktopRestartFailure): void { + if (this.attempt !== attempt || attempt.phase === 'committed') return; + attempt.cancelExpiry?.(); + this.attempt = undefined; + this.revision += 1; + attempt.resolvePrepare(result); + attempt.resolveCommit?.(result); + if (attempt.fenceId && attempt.fenceClosing && this.preparationFence) { + // A timeout/cancel is not completion of an in-flight fence request. Join + // its actual settlement, then send the exact-ID release in order. Keep + // this cleanup counted so another prepare cannot overtake it. + const release = this.acquire(); + const fenceId = attempt.fenceId; + const fence = this.preparationFence; + void attempt.fenceClosing.catch(() => {}).then(() => fence.release(fenceId)).catch(() => { + this.fenceReleaseFailed = true; + }).finally(release); + } + } + + private checkGenerations(activities: readonly DesktopOwnerActivity[]): DesktopRestartBlocker[] { + const blockers: DesktopRestartBlocker[] = []; + for (const value of activities) { + try { + if (this.owners.find((owner) => owner.owner === value.owner)?.reader?.getGeneration() !== value.generation) { + blockers.push(unknown('owner_stale', value.owner)); + } + } catch { blockers.push(unknown('owner_failed', value.owner)); } + } + return blockers; + } + + private async readOwner({ owner, reader }: Owner, deadline: number): Promise { + if (!reader) return { blockers: [unknown('owner_missing', owner)] }; + try { + const generation = reader.getGeneration(); + if (!identifier(generation)) return { blockers: [unknown('owner_stale', owner)] }; + const result = await this.readBeforeDeadline(reader.read, deadline); + if (result.kind !== 'value') return { blockers: [unknown(result.kind === 'timeout' ? 'owner_timeout' : 'owner_failed', owner)] }; + const value = activity(result.value, owner); + if (!value) return { blockers: [unknown('owner_invalid', owner)] }; + if (value.generation !== generation) return { blockers: [unknown('owner_stale', owner)] }; + const blockers: DesktopRestartBlocker[] = []; + if (!value.complete) blockers.push(unknown('owner_incomplete', owner)); + if (value.unknown.length) blockers.push(unknown('owner_unknown', owner)); + if (COUNTS.some((key) => value[key] > 0)) blockers.push({ kind: 'busy', code: 'owner_busy', owner }); + return { activity: value, blockers }; + } catch { return { blockers: [unknown('owner_failed', owner)] }; } + } + + private readBeforeDeadline(read: () => unknown | Promise, deadline: number): Promise<{ kind: 'value'; value: unknown } | { kind: 'timeout' | 'failed' }> { + return new Promise((resolve) => { + let settled = false; + let cancelTimer = () => {}; + const finish = (result: { kind: 'value'; value: unknown } | { kind: 'timeout' | 'failed' }) => { + if (settled) return; + settled = true; + cancelTimer(); + resolve(result); + }; + if (this.now() >= deadline) { finish({ kind: 'timeout' }); return; } + cancelTimer = this.schedule(() => finish({ kind: 'timeout' }), deadline - this.now()); + try { + void Promise.resolve(read()).then( + (value) => finish(this.now() >= deadline ? { kind: 'timeout' } : { kind: 'value', value }), + () => finish({ kind: 'failed' }), + ); + } catch { finish({ kind: 'failed' }); } + }); + } +} diff --git a/server/services/desktop-restart-backend.test.ts b/server/services/desktop-restart-backend.test.ts new file mode 100644 index 00000000..c64fca2a --- /dev/null +++ b/server/services/desktop-restart-backend.test.ts @@ -0,0 +1,131 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { isRestartControlResult } from '../../shared/desktopRestartProtocol.js'; + +import { DesktopRestartAuthority } from './desktop-restart-authority.js'; +import { DesktopRestartBackend } from './desktop-restart-backend.js'; + +const native = 'a'.repeat(64); +const id = 'b'.repeat(64); +const prepare = { action: 'prepare', attemptId: id, draftEpoch: 1, remainingMs: 5000 } as const; +const idle = () => ({ owner: 'worker', generation: 'worker:1', complete: true, + starting: 0, queued: 0, running: 0, settling: 0, approvals: 0, retained: 0, unknown: [] }); +const tick = () => new Promise((resolve) => setImmediate(resolve)); +function deferred() { let resolve!: (value: T) => void; const promise = new Promise((done) => { resolve = done; }); return { promise, resolve }; } + +function fixture() { + let time = 1000; + const backend = new DesktopRestartBackend(() => time); + let reads = 0; + const reader = { read: (): unknown | Promise => idle() }; + const authority = new DesktopRestartAuthority({ now: () => time, + requiredOwners: ['worker', 'ui-drafts'], ownerReaders: { + worker: { getGeneration: () => 'worker:1', read: () => { reads++; return reader.read(); } }, + 'ui-drafts': backend.draftReader, + } }); + backend.attachAuthority(authority); + return { backend, authority, reader, readCount: () => reads, advance: (amount: number) => { time += amount; } }; +} + +test('unbound/malformed control cannot provide sealed UI evidence; status performs no owner reads', async () => { + const f = fixture(); + assert.equal(f.backend.draftReader.read().complete, false); + assert.equal((await f.backend.handle(prepare, native)).error, 'unauthorized'); + f.backend.bind(native); + assert.equal((await f.backend.handle({ action: 'status' }, native)).state, 'open'); + assert.equal(f.readCount(), 0); + assert.equal((await f.backend.handle({ ...prepare, install: '/tmp/foreign.app' } as never, native)).error, 'invalid_command'); + assert.equal(f.backend.draftReader.read().complete, false); + assert.equal(f.readCount(), 0); +}); + +test('native sealed prepare and exact token commit fence work without invoking any shutdown', async () => { + const f = fixture(); f.backend.bind(native); + const before = f.backend.draftReader.getGeneration(); + const result = await f.backend.handle(prepare, native); + assert.equal(result.ok, true); assert.equal(result.state, 'prepared'); assert.ok(result.token); + assert.equal(isRestartControlResult(result), true); + assert.notEqual(f.backend.draftReader.getGeneration(), before); + assert.equal(f.backend.draftReader.read().complete, true); + assert.throws(() => f.authority.enter('new:work'), { code: 'DESKTOP_RESTART_FENCED' }); + assert.equal((await f.backend.handle({ action: 'commit', attemptId: id, token: 'forged' }, native)).ok, false); + assert.equal(f.authority.state, 'prepared'); + const committed = await f.backend.handle({ action: 'commit', attemptId: id, token: result.token! }, native); + assert.equal(committed.state, 'committed'); assert.equal(committed.ok, true); + assert.equal(committed.token, null); + assert.equal((await f.backend.handle({ action: 'cancel', attemptId: id }, native)).error, 'committed'); + assert.throws(() => f.authority.enterCompletion('late:completion'), { code: 'DESKTOP_RESTART_FENCED' }); +}); + +test('busy runtime reopens without discarding work and a consumed draft epoch cannot be replayed', async () => { + const f = fixture(); f.backend.bind(native); + const release = f.authority.enter('accepted:work'); + assert.equal((await f.backend.handle(prepare, native)).error, 'busy'); + assert.equal(f.backend.draftReader.read().complete, false); + assert.equal((await f.authority.snapshot()).ingress, 1); + release(); + assert.equal((await f.backend.handle(prepare, native)).error, 'stale_epoch'); + const fresh = await f.backend.handle({ ...prepare, draftEpoch: 2, attemptId: 'c'.repeat(64) }, native); + assert.equal(fresh.ok, true); + await f.backend.handle({ action: 'cancel', attemptId: 'c'.repeat(64) }, native); +}); + +test('cancel during asynchronous prepare rejects late completion without reviving UI evidence', async () => { + const f = fixture(); f.backend.bind(native); + const held = deferred(); f.reader.read = () => held.promise; + const pending = f.backend.handle(prepare, native); + await tick(); + assert.equal(f.authority.state, 'preparing'); + const cancelled = await f.backend.handle({ action: 'cancel', attemptId: id }, native); + assert.equal(cancelled.ok, true); assert.equal(cancelled.state, 'open'); + assert.equal((await pending).ok, false); + const release = f.authority.enter('new:work'); + held.resolve(idle()); await tick(); + assert.equal(f.authority.state, 'open'); + assert.equal(f.backend.draftReader.read().complete, false); + release(); +}); + +test('controller loss during commit reopens precommit but never reopens a completed commit', async () => { + for (const finish of [false, true]) { + const f = fixture(); f.backend.bind(native); + const prepared = await f.backend.handle(prepare, native); assert.ok(prepared.token); + if (finish) { + assert.equal((await f.backend.handle({ action: 'commit', attemptId: id, token: prepared.token! }, native)).ok, true); + f.backend.disconnected(native); + assert.equal(f.authority.state, 'committed'); + assert.throws(() => f.authority.enter('work'), { code: 'DESKTOP_RESTART_FENCED' }); + } else { + const held = deferred(); f.reader.read = () => held.promise; + const pending = f.backend.handle({ action: 'commit', attemptId: id, token: prepared.token! }, native); + await tick(); f.backend.disconnected(native); + assert.equal((await pending).ok, false); + held.resolve(idle()); await tick(); + assert.equal(f.authority.state, 'open'); + } + assert.throws(() => f.backend.bind('c'.repeat(64)), /retired/u); + } +}); + +test('expiry and intervening owned completion invalidate a prepared token', async () => { + for (const expire of [false, true]) { + const f = fixture(); f.backend.bind(native); + const prepared = await f.backend.handle(prepare, native); assert.ok(prepared.token); + if (expire) f.advance(10_000); + else { const release = f.authority.enterCompletion('notification:accepted'); release(); } + const result = await f.backend.handle({ action: 'commit', attemptId: id, token: prepared.token! }, native); + assert.equal(result.ok, false); assert.equal(result.state, 'open'); + assert.equal(f.backend.draftReader.read().complete, false); + } +}); + +test('stale epoch disconnect cannot cancel a current native owner', async () => { + const f = fixture(); f.backend.bind(native); + const result = await f.backend.handle(prepare, native); + f.backend.disconnected('c'.repeat(64)); + assert.equal(f.authority.state, 'prepared'); + assert.equal((await f.backend.handle({ action: 'commit', attemptId: id, token: result.token! }, 'c'.repeat(64))).ok, false); + assert.equal(f.authority.state, 'prepared'); + await f.backend.handle({ action: 'cancel', attemptId: id }, native); +}); diff --git a/server/services/desktop-restart-backend.ts b/server/services/desktop-restart-backend.ts new file mode 100644 index 00000000..60a9a09e --- /dev/null +++ b/server/services/desktop-restart-backend.ts @@ -0,0 +1,146 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { performance } from 'node:perf_hooks'; + +import { isRestartControlCommand, isRestartId, type RestartControlCommand, type RestartControlResult } from '../../shared/desktopRestartProtocol.js'; +import type { DesktopOwnerActivity } from '../../shared/desktopUpdateProtocol.js'; + +import { DesktopRestartAuthority } from './desktop-restart-authority.js'; + +type Attempt = { + id: string; draftEpoch: number; epoch: string; token?: string; expiresAt?: number; + prepared?: Promise; +}; + +/** Consumes ONLY the authenticated native channel's sealed-current-view claim. + * No HTTP route accepts this controller or its commands. It never installs, + * signals a process, flushes a draft or invents missing runtime ownership. */ +export class DesktopRestartBackend { + private authority?: DesktopRestartAuthority; + private nativeEpoch?: string; + private everBound = false; + private active?: Attempt; + private latestDraftEpoch = 0; + private readonly generation = randomUUID(); + private revision = 0; + + constructor(private readonly now: () => number = () => performance.now()) {} + + readonly draftReader = { + getGeneration: (): string => `${this.generation}:${this.revision}`, + read: (): DesktopOwnerActivity => { + const valid = this.nativeEpoch !== undefined && this.active !== undefined; + return { owner: 'ui-drafts', generation: this.draftReader.getGeneration(), complete: valid, + starting: 0, queued: 0, running: 0, settling: 0, approvals: 0, retained: 0, + unknown: valid ? [] : ['native_draft_not_sealed'] }; + }, + }; + + attachAuthority(authority: DesktopRestartAuthority): void { + if (this.authority && this.authority !== authority) throw new Error('Restart authority is already attached.'); + this.authority = authority; + } + bind(nativeEpoch: string): void { + if (!isRestartId(nativeEpoch) || !this.authority) throw new Error('Invalid native restart binding.'); + if (this.nativeEpoch === nativeEpoch) return; + if (this.everBound) throw new Error('A retired native restart channel cannot be rebound.'); + this.everBound = true; + this.nativeEpoch = nativeEpoch; + this.revision++; + } + disconnected(nativeEpoch: string): void { + if (this.nativeEpoch !== nativeEpoch) return; + this.nativeEpoch = undefined; + this.revision++; + const active = this.active; + if (active && this.authority?.state !== 'committed') { + this.authority?.controllerLost(active.epoch); + this.clear(active); + } + } + + async handle(command: RestartControlCommand, nativeEpoch: string): Promise { + if (!this.authority || nativeEpoch !== this.nativeEpoch) return this.result(false, 'unauthorized'); + if (!isRestartControlCommand(command)) return this.result(false, 'invalid_command'); + this.refresh(); + switch (command.action) { + case 'status': return this.result(true); + case 'prepare': return this.prepare(command); + case 'cancel': { + const active = this.active; + if (this.authority.state === 'committed') return this.result(false, 'committed'); + if (!active) return this.result(true); + if (active.id !== command.attemptId) return this.result(false, 'invalid_attempt'); + this.authority.controllerLost(active.epoch); + this.clear(active); + return this.result(true); + } + case 'commit': { + const active = this.active; + if (!active || active.id !== command.attemptId || !active.token || active.token !== command.token) return this.result(false, 'invalid_token'); + const committed = await this.authority.commit(active.token, active.epoch); + if (committed.ok) return this.result(true); + this.clear(active); + return this.result(false, committed.code); + } + } + } + + private prepare(command: Extract): Promise { + const authority = this.authority!; + if (authority.state === 'committed') return Promise.resolve(this.result(false, 'committed')); + const current = this.active; + if (current) { + if (current.id === command.attemptId && current.draftEpoch === command.draftEpoch && current.prepared) return current.prepared; + return Promise.resolve(this.result(false, 'in_progress')); + } + if (command.draftEpoch <= this.latestDraftEpoch) return Promise.resolve(this.result(false, 'stale_epoch')); + this.latestDraftEpoch = command.draftEpoch; + const active: Attempt = { id: command.attemptId, draftEpoch: command.draftEpoch, + epoch: createHash('sha256').update(JSON.stringify([this.nativeEpoch, command.attemptId, command.draftEpoch])).digest('hex') }; + this.active = active; + this.revision++; + const binding = this.nativeEpoch; + const prepared = authority.prepare({ attemptId: active.id, epoch: active.epoch, budgetMs: command.remainingMs }).then((result) => { + if (this.active !== active || this.nativeEpoch !== binding) return this.result(false, 'cancelled'); + if (!result.ok) { + if (process.env.GJC_DESKTOP_UPDATE_PIPE === '1') { + console.error('[Desktop restart] Preparation deferred:', result.blockers.map(({ owner, code }) => ({ owner, code }))); + } + authority.controllerLost(active.epoch); + this.clear(active); + return this.result(false, result.code); + } + active.token = result.token; + active.expiresAt = result.expiresAt; + if (authority.state !== 'prepared' || result.expiresAt <= this.now()) { + authority.controllerLost(active.epoch); + this.clear(active); + return this.result(false, 'expired'); + } + return this.result(true); + }, () => { + authority.controllerLost(active.epoch); + this.clear(active); + return this.result(false, 'unknown'); + }); + active.prepared = prepared; + return prepared; + } + + private refresh(): void { + const active = this.active; + if (active?.token && this.authority?.state === 'open') this.clear(active); + } + private clear(active: Attempt): void { + if (this.active !== active) return; + this.active = undefined; + this.revision++; + } + private result(ok: boolean, error: string | null = null): RestartControlResult { + this.refresh(); + const state = this.authority?.state ?? 'open'; + const token = state === 'prepared' ? this.active?.token ?? null : null; + const expiresInMs = token && this.active?.expiresAt !== undefined ? Math.max(1, Math.min(10_000, Math.floor(this.active.expiresAt - this.now()))) : null; + return { ok, state, attemptId: this.active?.id ?? null, token, expiresInMs, error }; + } +} diff --git a/server/services/desktop-restart-channel.test.ts b/server/services/desktop-restart-channel.test.ts new file mode 100644 index 00000000..e6cd944d --- /dev/null +++ b/server/services/desktop-restart-channel.test.ts @@ -0,0 +1,565 @@ +import assert from 'node:assert/strict'; +import { createHmac } from 'node:crypto'; +import { EventEmitter, once } from 'node:events'; +import { chmod, mkdtemp, rm } from 'node:fs/promises'; +import { createServer, type connect as Connect, type Socket } from 'node:net'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test, { type TestContext } from 'node:test'; + +import type { RestartControlCommand, RestartControlResult } from '../../shared/desktopRestartProtocol.js'; + +import { DesktopRestartChannel, type DesktopRestartHandler } from './desktop-restart-channel.js'; + +// Every credential and endpoint is synthetic. Never import server startup or +// obtain a native binding, key, socket or data directory from the running app. +const binding = { protocolVersion: 1 as const, socket: '/unused-test-only/rpc', secret: 'a'.repeat(64), epoch: 'b'.repeat(64) }; +const attemptId = 'c'.repeat(64); +const openResult: RestartControlResult = { ok: true, state: 'open', attemptId: null, token: null, expiresInMs: null, error: null }; +const line = (value: unknown) => Buffer.from(`${JSON.stringify(value)}\n`); +const tick = () => new Promise((resolve) => setImmediate(resolve)); +const ack = (fields: Record = {}) => ({ protocolVersion: 1, kind: 'backendAttached', epoch: binding.epoch, ...fields }); +const control = (id = 1, command: RestartControlCommand = { action: 'status' }, fields: Record = {}) => ({ + protocolVersion: 1, kind: 'restartControl', id, epoch: binding.epoch, command, ...fields, +}); + +// Independent wire implementation: do not use the production proof helper. +function proof(challenge: Record, fields: Record = {}, key: string | Buffer = binding.secret) { + const digest = createHmac('sha256', key) + .update(`gajae-native-update-v1\0${challenge.epoch}\0${challenge.nonce}`, 'utf8').digest('hex'); + return { protocolVersion: 1, kind: 'challenge', epoch: challenge.epoch, nonce: challenge.nonce, proof: digest, ...fields }; +} + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: Error) => void; + const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); + return { promise, resolve, reject }; +} + +function handlerFixture(overrides: Partial = {}) { + const bound: string[] = []; + const handled: Array<{ command: RestartControlCommand; epoch: string }> = []; + const disconnected: string[] = []; + const handler: DesktopRestartHandler = { + bind(epoch) { bound.push(epoch); overrides.bind?.(epoch); }, + handle(command, epoch) { handled.push({ command, epoch }); return overrides.handle?.(command, epoch) ?? Promise.resolve(openResult); }, + disconnected(epoch) { disconnected.push(epoch); overrides.disconnected?.(epoch); }, + }; + return { handler, bound, handled, disconnected }; +} + +class TestSocket extends EventEmitter { + written = ''; + destroyed = false; + write(value: string) { this.written += value; return true; } + destroy() { if (!this.destroyed) { this.destroyed = true; this.emit('close'); } return this; } + frames(): Record[] { return this.written.split('\n').filter(Boolean).map((value) => JSON.parse(value)); } + receive(value: unknown) { this.emit('data', line(value)); } +} + +function fixture(t: TestContext, overrides: Partial = {}) { + const socket = new TestSocket(); + const observed = handlerFixture(overrides); + const connections: unknown[][] = []; + const channel = new DesktopRestartChannel({ + binding, pid: 42, handler: observed.handler, + connect: ((...args: unknown[]) => { connections.push(args); return socket; }) as unknown as typeof Connect, + }); + t.after(() => channel.close()); + function connect() { socket.emit('connect'); return socket.frames()[0]; } + function authenticate() { const challenge = connect(); socket.receive(proof(challenge)); } + function ready() { authenticate(); socket.receive(ack()); assert.equal(channel.isReady(), true); } + return { channel, socket, connections, connect, authenticate, ready, ...observed }; +} + +function onlyChallenge(frames: Record[], wire: string, pid = 42) { + assert.equal(frames.length, 1, 'unproven endpoint receives no credential-bearing second frame'); + const challenge = frames[0]; + assert.deepEqual(challenge, { protocolVersion: 1, kind: 'challenge', epoch: binding.epoch, pid, nonce: challenge.nonce }); + assert.match(String(challenge.nonce), /^[a-f0-9]{64}$/u); + assert.equal(wire.includes(binding.secret), false); + assert.doesNotMatch(wire, /"(?:secret|token|command|attemptId)"/u); +} + +test('same-socket HMAC proof precedes secret disclosure and exact attach ack precedes binding', async (t) => { + const h = fixture(t); + assert.deepEqual(h.connections, [[binding.socket]]); + assert.equal(h.socket.written, ''); + assert.equal(h.channel.isReady(), false); + const challenge = h.connect(); + onlyChallenge(h.socket.frames(), h.socket.written); + const response = line(proof(challenge)); + h.socket.emit('data', response.subarray(0, response.length - 1)); + onlyChallenge(h.socket.frames(), h.socket.written); + assert.deepEqual(h.bound, []); + h.socket.emit('data', response.subarray(-1)); + assert.deepEqual(h.socket.frames()[1], { protocolVersion: 1, kind: 'backendAttach', epoch: binding.epoch, secret: binding.secret, pid: 42 }); + assert.equal(h.connections.length, 1, 'authentication must not switch transports'); + assert.deepEqual(h.bound, []); + assert.equal(h.channel.isReady(), false); + const attached = line(ack()); + h.socket.emit('data', attached.subarray(0, -1)); + assert.deepEqual(h.bound, []); + h.socket.emit('data', attached.subarray(-1)); + assert.deepEqual(h.bound, [binding.epoch]); + assert.equal(h.channel.isReady(), true); + h.socket.receive(control()); + await tick(); + assert.deepEqual(h.handled, [{ command: { action: 'status' }, epoch: binding.epoch }]); + assert.deepEqual(h.socket.frames()[2], { protocolVersion: 1, kind: 'restartControlResult', epoch: binding.epoch, id: 1, result: openResult }); + assert.deepEqual(h.disconnected, []); +}); + +test('forged, copied and non-exact proof frames disclose no secret and never bind', async (t) => { + const changes: Record[] = [ + { protocolVersion: 2 }, { protocolVersion: '1' }, { kind: 'backendAttached' }, + { epoch: 'd'.repeat(64) }, { nonce: 'e'.repeat(64) }, { nonce: null }, + { proof: '0'.repeat(64) }, { proof: 'a'.repeat(63) }, { proof: 'A'.repeat(64) }, + { proof: 'g'.repeat(64) }, { proof: 1 }, { proof: undefined }, { secret: binding.secret }, + ]; + const variants: Array<[string, (challenge: Record) => unknown]> = [ + ...changes.map((fields): [string, (challenge: Record) => unknown] => [JSON.stringify(fields), (challenge) => proof(challenge, fields)]), + ['wrong key', (challenge) => proof(challenge, {}, 'attacker-test-key')], + ['hex-decoded key instead of UTF-8', (challenge) => proof(challenge, {}, Buffer.from(binding.secret, 'hex'))], + ['ack instead of proof', () => ack()], + ['control instead of proof', () => control()], + ]; + for (const [name, make] of variants) { + await t.test(name, () => { + const h = fixture(t); const challenge = h.connect(); + h.socket.receive(make(challenge)); + onlyChallenge(h.socket.frames(), h.socket.written); + assert.equal(h.socket.destroyed, true); + assert.equal(h.channel.isReady(), false); + assert.deepEqual(h.bound, []); + assert.deepEqual(h.handled, []); + assert.deepEqual(h.disconnected, []); + }); + } +}); + +test('a valid proof captured from another channel cannot authenticate a fresh nonce', (t) => { + const first = fixture(t); const captured = proof(first.connect()); + first.socket.receive(captured); first.socket.receive(ack()); first.channel.close(); + const second = fixture(t); const challenge = second.connect(); + assert.notEqual(challenge.nonce, captured.nonce); + second.socket.receive(captured); + assert.equal(second.socket.destroyed, true); + onlyChallenge(second.socket.frames(), second.socket.written); + assert.deepEqual(second.bound, []); +}); + +test('attach acknowledgments reject wrong epochs, fields, kinds and premature controls before bind', async (t) => { + for (const [name, value] of [ + ['copied epoch', ack({ epoch: 'd'.repeat(64) })], ['missing epoch', ack({ epoch: undefined })], + ['version', ack({ protocolVersion: 2 })], ['string version', ack({ protocolVersion: '1' })], + ['request echo', ack({ kind: 'backendAttach' })], ['extra pid', ack({ pid: 42 })], + ['extra secret', ack({ secret: binding.secret })], ['control before ack', control()], + ] as const) { + await t.test(name, async () => { + const h = fixture(t); h.authenticate(); h.socket.receive(value); await tick(); + assert.equal(h.socket.destroyed, true); + assert.equal(h.channel.isReady(), false); + assert.deepEqual(h.bound, []); + assert.deepEqual(h.handled, []); + assert.deepEqual(h.disconnected, []); + assert.equal(h.socket.frames().length, 2); + }); + } +}); + +test('coalesced attach ack and first control bind once and dispatch exactly once', async (t) => { + const h = fixture(t); h.authenticate(); + h.socket.emit('data', Buffer.concat([line(ack()), line(control())])); + assert.deepEqual(h.bound, [binding.epoch]); + await tick(); + assert.deepEqual(h.handled, [{ command: { action: 'status' }, epoch: binding.epoch }]); + assert.equal(h.socket.frames().length, 3); + assert.equal(h.channel.isReady(), true); +}); + +test('fragmented controls preserve ordering and accept only increasing IDs after settlement', async (t) => { + const h = fixture(t); h.ready(); + const commands: RestartControlCommand[] = [ + { action: 'status' }, { action: 'prepare', attemptId, draftEpoch: 1, remainingMs: 5_000 }, + { action: 'commit', attemptId, token: 'synthetic-token' }, { action: 'cancel', attemptId }, + ]; + const ids = [1, 3, 4, Number.MAX_SAFE_INTEGER]; + for (const [index, command] of commands.entries()) { + const bytes = line(control(ids[index], command)); + for (const byte of bytes.subarray(0, -1)) h.socket.emit('data', Buffer.from([byte])); + assert.equal(h.handled.length, index, 'incomplete frames must not dispatch'); + h.socket.emit('data', bytes.subarray(-1)); await tick(); + assert.deepEqual(h.handled[index], { command, epoch: binding.epoch }); + assert.equal(h.socket.frames()[index + 2].id, ids[index]); + assert.equal(h.channel.isReady(), true); + } +}); + +test('copied control epochs, duplicate IDs and replayed acknowledgments revoke a bound channel', async (t) => { + for (const [name, frame] of [ + ['copied epoch', control(11, { action: 'status' }, { epoch: 'd'.repeat(64) })], + ['duplicate ID', control(10)], ['older ID', control(9)], ['repeated ack', ack()], + ['extra authority field', control(11, { action: 'status' }, { install: true })], + ] as const) { + await t.test(name, async () => { + const h = fixture(t); h.ready(); h.socket.receive(control(10)); await tick(); + assert.equal(h.handled.length, 1); + h.socket.receive(frame); await tick(); + assert.equal(h.socket.destroyed, true); + assert.equal(h.channel.isReady(), false); + assert.deepEqual(h.disconnected, [binding.epoch]); + assert.equal(h.handled.length, 1); + assert.equal(h.socket.frames().length, 3); + }); + } +}); + +test('coalesced control pipelining revokes before a queued handler can start', async (t) => { + for (const nextId of [1, 2]) { + await t.test(`second ID ${nextId}`, async () => { + const h = fixture(t); h.ready(); + h.socket.emit('data', Buffer.concat([line(control()), line(control(nextId))])); + await tick(); + assert.deepEqual(h.handled, []); + assert.deepEqual(h.disconnected, [binding.epoch]); + assert.equal(h.socket.frames().length, 2); + assert.equal(h.channel.isReady(), false); + }); + } +}); + +test('in-flight replay or pipelining revokes transport but leaves the actual promise backend-owned', async (t) => { + for (const nextId of [1, 2]) { + await t.test(`second ID ${nextId}`, async () => { + const pending = deferred(); let settled = false; + const h = fixture(t, { handle: async () => { const result = await pending.promise; settled = true; return result; } }); + h.ready(); h.socket.receive(control()); await tick(); + assert.equal(h.handled.length, 1); + h.socket.receive(control(nextId)); await tick(); + assert.equal(settled, false, 'transport revocation must not manufacture backend settlement'); + assert.deepEqual(h.disconnected, [binding.epoch]); + assert.equal(h.socket.destroyed, true); + pending.resolve(openResult); await tick(); + assert.equal(settled, true); + assert.equal(h.handled.length, 1); + assert.equal(h.socket.frames().length, 2, 'late completion must not send a response'); + }); + } +}); + +test('close or socket failure between receipt and microtask dispatch prevents a late handler', async (t) => { + for (const cause of ['close', 'error', 'peer close'] as const) { + await t.test(cause, async () => { + const h = fixture(t); h.ready(); h.socket.receive(control()); + if (cause === 'close') h.channel.close(); + else if (cause === 'error') h.socket.emit('error', new Error('synthetic transport failure')); + else h.socket.destroy(); + h.socket.receive(control(2)); h.socket.emit('connect'); h.channel.close(); + await tick(); + assert.deepEqual(h.handled, []); + assert.deepEqual(h.disconnected, [binding.epoch]); + assert.deepEqual(h.bound, [binding.epoch]); + assert.equal(h.channel.isReady(), false); + assert.equal(h.socket.frames().length, 2); + }); + } +}); + +test('late handler resolution and rejection after close cannot respond or disconnect twice', async (t) => { + for (const outcome of ['resolve', 'reject'] as const) { + await t.test(outcome, async () => { + const pending = deferred(); + const h = fixture(t, { handle: () => pending.promise }); + h.ready(); h.socket.receive(control()); await tick(); + assert.equal(h.handled.length, 1); + h.channel.close(); h.channel.close(); + if (outcome === 'resolve') pending.resolve(openResult); + else pending.reject(new Error('synthetic late backend rejection')); + await tick(); + assert.deepEqual(h.disconnected, [binding.epoch]); + assert.equal(h.socket.frames().length, 2); + assert.equal(h.channel.isReady(), false); + }); + } +}); + +test('unbound handshake timeouts never call disconnected and ignore all late input', async (t) => { + for (const phase of ['connecting', 'challenge', 'attaching'] as const) { + await t.test(phase, async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const h = fixture(t); + if (phase === 'challenge') h.connect(); + if (phase === 'attaching') h.authenticate(); + t.mock.timers.tick(1_999); assert.equal(h.socket.destroyed, false); + t.mock.timers.tick(1); assert.equal(h.socket.destroyed, true); + const written = h.socket.written; + h.socket.emit('connect'); h.socket.receive(ack()); h.socket.receive(control()); await tick(); + assert.deepEqual(h.bound, []); + assert.deepEqual(h.handled, []); + assert.deepEqual(h.disconnected, []); + assert.equal(h.socket.written, written); + }); + } +}); + +test('attach acknowledgment cancels the handshake timer and idle readiness has no watchdog', (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const h = fixture(t); h.ready(); t.mock.timers.tick(20_000); + assert.equal(h.channel.isReady(), true); + assert.deepEqual(h.disconnected, []); +}); + +test('handler watchdog revokes only bound transport and does not settle accepted work', async (t) => { + for (const [name, command, budget] of [ + ['prepare', { action: 'prepare', attemptId, draftEpoch: 1, remainingMs: 7 }, 57], + ['status', { action: 'status' }, 5_050], + ] satisfies Array<[string, RestartControlCommand, number]>) { + await t.test(name, async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const pending = deferred(); let settled = false; + const h = fixture(t, { handle: async () => { const result = await pending.promise; settled = true; return result; } }); + h.ready(); h.socket.receive(control(1, command)); await tick(); + assert.equal(h.handled.length, 1); + t.mock.timers.tick(budget - 1); assert.equal(h.channel.isReady(), true); + t.mock.timers.tick(1); await tick(); + assert.equal(h.channel.isReady(), false); + assert.deepEqual(h.disconnected, [binding.epoch]); + assert.equal(settled, false); + assert.equal(h.socket.frames().length, 2, 'timeout is not a backend response'); + h.socket.receive(control(2)); pending.resolve(openResult); await tick(); + assert.equal(settled, true); + assert.equal(h.handled.length, 1); + assert.equal(h.socket.frames().length, 2); + }); + } +}); + +test('settled handlers clear their watchdog without revoking the bound channel', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const h = fixture(t); h.ready(); h.socket.receive(control()); await tick(); + t.mock.timers.tick(20_000); + assert.equal(h.channel.isReady(), true); + h.socket.receive(control(2)); await tick(); + assert.equal(h.handled.length, 2); + assert.deepEqual(h.disconnected, []); +}); + +test('handler exceptions, rejected promises and invalid results fail closed without output', async (t) => { + const handlers: Array<[string, DesktopRestartHandler['handle']]> = [ + ['throw', () => { throw new Error('synthetic failure'); }], + ['reject', () => Promise.reject(new Error('synthetic failure'))], + ['missing result', () => Promise.resolve(undefined as never)], + ['extra field', () => Promise.resolve({ ...openResult, path: '/tmp/payload' })], + ['malformed state', () => Promise.resolve({ ...openResult, state: 'installed' } as never)], + ['coerced state', () => Promise.resolve({ ...openResult, state: ['open'] } as never)], + ]; + for (const [name, handle] of handlers) { + await t.test(name, async () => { + const h = fixture(t, { handle }); h.ready(); h.socket.receive(control()); await tick(); + assert.deepEqual(h.socket.frames().slice(2), [], 'invalid results must never cross the native wire'); + assert.equal(h.channel.isReady(), false); + assert.deepEqual(h.disconnected, [binding.epoch]); + assert.equal(h.socket.frames().length, 2); + }); + } +}); + +test('connect and disconnect callback failures cannot revive or leak a channel', (t) => { + const observed = handlerFixture(); + const failed = new DesktopRestartChannel({ binding, pid: 42, handler: observed.handler, + connect: (() => { throw new Error('synthetic connect failure'); }) as unknown as typeof Connect, + }); + t.after(() => failed.close()); + assert.equal(failed.isReady(), false); + assert.deepEqual(observed.bound, []); + assert.deepEqual(observed.disconnected, []); + const h = fixture(t, { disconnected() { throw new Error('synthetic disconnect failure'); } }); + h.ready(); assert.doesNotThrow(() => h.channel.close()); h.channel.close(); + assert.deepEqual(h.disconnected, [binding.epoch]); + assert.equal(h.socket.destroyed, true); +}); + +// An invalid UTF-8 value in an overwritten JSON key would disappear under a +// replacement-character decoder. Fatal decoding must reject the wire itself. +function invalidUtf8Frame(valid: unknown, bytes = [0xff]) { + return Buffer.concat([Buffer.from('{"kind":"'), Buffer.from(bytes), Buffer.from(`",${JSON.stringify(valid).slice(1)}\n`)]); +} + +test('malformed, oversized and invalid UTF-8 frames fail closed in every phase', async (t) => { + for (const phase of ['challenge', 'attaching', 'ready'] as const) { + await t.test(phase, async (t) => { + const variants: Array<[string, (valid: unknown) => Buffer]> = [ + ['empty line', () => Buffer.from('\n')], ['malformed JSON', () => Buffer.from('{broken}\n')], + ['null', () => line(null)], ['array', () => line([])], ['unknown object', () => line({})], + ['oversized complete frame', (valid) => Buffer.concat([Buffer.alloc(4097, 32), line(valid)])], + ['oversized partial frame', () => Buffer.alloc(4097, 32)], + ['oversized receive buffer', (valid) => Buffer.concat([line(valid), Buffer.alloc(16 * 1024, 32)])], + ...[[0xff], [0x80], [0xc0, 0xaf], [0xe2, 0x82], [0xed, 0xa0, 0x80], [0xf4, 0x90, 0x80, 0x80]] + .map((bytes): [string, (valid: unknown) => Buffer] => [`invalid UTF-8 ${Buffer.from(bytes).toString('hex')}`, (valid) => invalidUtf8Frame(valid, bytes)]), + ]; + for (const [name, bytes] of variants) { + await t.test(name, async (t) => { + const h = fixture(t); const challenge = h.connect(); + if (phase !== 'challenge') h.socket.receive(proof(challenge)); + if (phase === 'ready') h.socket.receive(ack()); + const valid = phase === 'challenge' ? proof(challenge) : phase === 'attaching' ? ack() : control(); + const before = h.socket.written; + h.socket.emit('data', bytes(valid)); await tick(); + assert.equal(h.socket.destroyed, true); + assert.equal(h.channel.isReady(), false); + assert.deepEqual(h.handled, []); + assert.deepEqual(h.disconnected, phase === 'ready' ? [binding.epoch] : []); + assert.equal(h.socket.written, before); + }); + } + }); + } +}); + +test('truncated frames never dispatch and peer closure discards buffered input', async (t) => { + for (const phase of ['challenge', 'attaching', 'ready'] as const) { + await t.test(phase, async (t) => { + const h = fixture(t); const challenge = h.connect(); + if (phase !== 'challenge') h.socket.receive(proof(challenge)); + if (phase === 'ready') h.socket.receive(ack()); + const valid = phase === 'challenge' ? proof(challenge) : phase === 'attaching' ? ack() : control(); + const bytes = line(valid); const before = h.socket.written; + h.socket.emit('data', bytes.subarray(0, -1)); await tick(); + assert.deepEqual(h.handled, []); + assert.equal(h.socket.written, before); + h.socket.emit('end'); h.socket.destroy(); + h.socket.emit('data', bytes.subarray(-1)); await tick(); + assert.deepEqual(h.bound, phase === 'ready' ? [binding.epoch] : []); + assert.deepEqual(h.disconnected, phase === 'ready' ? [binding.epoch] : []); + assert.deepEqual(h.handled, []); + assert.equal(h.channel.isReady(), false); + assert.equal(h.socket.written, before); + }); + } +}); + +class NativePeer extends EventEmitter { + readonly frames: Record[] = []; + readonly captured: Buffer[] = []; + readonly closed: Promise; + private buffer = Buffer.alloc(0); + private ended = false; + private read = 0; + private failure?: Error; + + constructor(readonly socket: Socket) { + super(); + this.closed = new Promise((resolve) => socket.once('close', () => { this.ended = true; this.emit('frame'); resolve(); })); + socket.on('error', () => {}); // Refused endpoints may observe ECONNRESET. + socket.on('data', (chunk: Buffer) => { + this.captured.push(Buffer.from(chunk)); this.buffer = Buffer.concat([this.buffer, chunk]); + try { + for (;;) { + const newline = this.buffer.indexOf(10); + if (newline < 0) break; + this.frames.push(JSON.parse(this.buffer.subarray(0, newline).toString('utf8'))); + this.buffer = this.buffer.subarray(newline + 1); + } + } catch (error) { this.failure = error as Error; socket.destroy(); } + this.emit('frame'); + }); + } + + async nextFrame(): Promise> { + while (this.read >= this.frames.length) { + if (this.failure) throw this.failure; + assert.equal(this.ended, false, 'native peer closed before the expected frame'); + await once(this, 'frame'); + } + return this.frames[this.read++]; + } +} + +async function unixFixture(t: TestContext, overrides: Partial = {}) { + const directory = await mkdtemp(join(tmpdir(), 'gjr-')); + const socketPath = join(directory, 'rpc'); + const server = createServer(); const peers = new Set(); + const cleanup: { channel?: DesktopRestartChannel } = {}; let connections = 0; + const observed = handlerFixture(overrides); + t.after(async () => { + cleanup.channel?.close(); + for (const socket of peers) socket.destroy(); + if (server.listening) await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); + // Cleanup is restricted to the private directory returned by this mkdtemp. + await rm(directory, { recursive: true, force: true }); + }); + server.on('connection', (socket) => { connections++; peers.add(socket); socket.once('close', () => peers.delete(socket)); }); + await chmod(directory, 0o700); + const listening = once(server, 'listening'); server.listen(socketPath); await listening; + await chmod(socketPath, 0o600); + const accepted = once(server, 'connection'); + const channel = new DesktopRestartChannel({ binding: { ...binding, socket: socketPath }, pid: process.pid, handler: observed.handler }); + cleanup.channel = channel; + const [socket] = await accepted as [Socket]; + const peer = new NativePeer(socket); + return { channel, peer, ...observed, connections: () => connections }; +} + +const unixOptions = { skip: process.platform === 'win32', timeout: 5_000 }; + +test('real Unix socket authenticates before disclosing secret and accepts coalesced ack plus first control', unixOptions, async (t) => { + const h = await unixFixture(t); const challenge = await h.peer.nextFrame(); + onlyChallenge(h.peer.frames, Buffer.concat(h.peer.captured).toString('utf8'), process.pid); + assert.deepEqual(h.bound, []); + h.peer.socket.write(line(proof(challenge))); + assert.deepEqual(await h.peer.nextFrame(), { protocolVersion: 1, kind: 'backendAttach', epoch: binding.epoch, secret: binding.secret, pid: process.pid }); + assert.deepEqual(h.bound, [], 'proof alone must not bind the backend'); + assert.equal(h.channel.isReady(), false); + h.peer.socket.write(Buffer.concat([line(ack()), line(control())])); + assert.deepEqual(await h.peer.nextFrame(), { protocolVersion: 1, kind: 'restartControlResult', epoch: binding.epoch, id: 1, result: openResult }); + assert.deepEqual(h.bound, [binding.epoch]); + assert.deepEqual(h.handled, [{ command: { action: 'status' }, epoch: binding.epoch }]); + assert.equal(h.connections(), 1); + h.channel.close(); await h.peer.closed; + assert.deepEqual(h.disconnected, [binding.epoch]); +}); + +test('real substituted Unix endpoint with a forged HMAC receives only a challenge', unixOptions, async (t) => { + const h = await unixFixture(t); const challenge = await h.peer.nextFrame(); + h.peer.socket.write(line(proof(challenge, {}, 'synthetic-attacker-key'))); await h.peer.closed; + onlyChallenge(h.peer.frames, Buffer.concat(h.peer.captured).toString('utf8'), process.pid); + assert.equal(h.connections(), 1); + assert.equal(h.channel.isReady(), false); + assert.deepEqual(h.bound, []); + assert.deepEqual(h.handled, []); + assert.deepEqual(h.disconnected, []); +}); + +test('real Unix socket refuses a control before the exact attach acknowledgment', unixOptions, async (t) => { + const h = await unixFixture(t); const challenge = await h.peer.nextFrame(); + h.peer.socket.write(line(proof(challenge))); await h.peer.nextFrame(); + h.peer.socket.write(line(control())); await h.peer.closed; + assert.deepEqual(h.bound, []); + assert.deepEqual(h.handled, []); + assert.deepEqual(h.disconnected, []); + assert.equal(h.peer.frames.length, 2); +}); + +test('real Unix framing rejects malformed, oversized, invalid UTF-8 and truncated attach acknowledgments', unixOptions, async (t) => { + const variants: Array<[string, Buffer, boolean]> = [ + ['malformed JSON', Buffer.from('{broken}\n'), false], + ['oversized', Buffer.concat([Buffer.alloc(4097, 32), line(ack())]), false], + ['invalid UTF-8', invalidUtf8Frame(ack()), false], + ['truncated before EOF', line(ack()).subarray(0, -1), true], + ]; + for (const [name, bytes, eof] of variants) { + await t.test(name, unixOptions, async (t) => { + const h = await unixFixture(t); const challenge = await h.peer.nextFrame(); + h.peer.socket.write(line(proof(challenge))); await h.peer.nextFrame(); + if (eof) h.peer.socket.end(bytes); else h.peer.socket.write(bytes); + await h.peer.closed; + assert.equal(h.channel.isReady(), false); + assert.deepEqual(h.bound, []); + assert.deepEqual(h.handled, []); + assert.deepEqual(h.disconnected, []); + assert.equal(h.peer.frames.length, 2); + }); + } +}); diff --git a/server/services/desktop-restart-channel.ts b/server/services/desktop-restart-channel.ts new file mode 100644 index 00000000..393f20e0 --- /dev/null +++ b/server/services/desktop-restart-channel.ts @@ -0,0 +1,125 @@ +import { randomBytes } from 'node:crypto'; +import { connect as connectSocket, type Socket } from 'node:net'; +import { performance } from 'node:perf_hooks'; + +import { isRestartControlFrame, isRestartControlResult, type RestartControlCommand, type RestartControlResult } from '../../shared/desktopRestartProtocol.js'; + +import { authenticNativeChallenge, type DesktopNativeBinding } from './desktop-update-transport.js'; + +export type DesktopRestartHandler = { + bind(nativeEpoch: string): void; + handle(command: RestartControlCommand, nativeEpoch: string): Promise; + disconnected(nativeEpoch: string): void; +}; +type Options = { binding: DesktopNativeBinding; pid: number; handler: DesktopRestartHandler; connect?: typeof connectSocket }; +const MAX_FRAME = 4096; +const MAX_BUFFER = 16 * 1024; + +/** Only the endpoint-authenticated native channel can call the backend owner. + * Connection loss revokes its precommit epoch, never interrupts accepted work. */ +export class DesktopRestartChannel { + private socket?: Socket; + private closed = false; + private phase: 'challenge' | 'attaching' | 'ready' = 'challenge'; + private buffer = Buffer.alloc(0); + private latest = 0; + private activeId?: number; + private bound = false; + private timer?: ReturnType; + private readonly nonce = randomBytes(32).toString('hex'); + private readonly handshakeDeadline = performance.now() + 2_000; + + constructor(private readonly options: Options) { + this.timer = setTimeout(() => this.close(), 2_000); + this.timer.unref?.(); + try { + this.socket = (options.connect ?? connectSocket)(options.binding.socket); + this.socket.once('connect', () => { + if (this.closed || performance.now() >= this.handshakeDeadline) { this.close(); return; } + this.write({ protocolVersion: 1, kind: 'challenge', epoch: options.binding.epoch, pid: options.pid, nonce: this.nonce }); + }); + this.socket.on('data', (chunk: Buffer) => this.receive(chunk)); + this.socket.once('error', () => this.close()); + this.socket.once('close', () => this.close()); + } catch { this.close(); } + } + + isReady(): boolean { return !this.closed && this.phase === 'ready'; } + close(): void { + if (this.closed) return; + this.closed = true; + clearTimeout(this.timer); + this.buffer.fill(0); + this.buffer = Buffer.alloc(0); + this.socket?.destroy(); + // The handler retains in-flight work/worker release until it really settles. + if (this.bound) { + try { this.options.handler.disconnected(this.options.binding.epoch); } catch { /* Fail closed; never revive a binding. */ } + } + } + + private write(frame: unknown): void { + if (this.closed) return; + try { + const line = `${JSON.stringify(frame)}\n`; + if (Buffer.byteLength(line) > MAX_FRAME) { this.close(); return; } + this.socket?.write(line); + } catch { this.close(); } + } + + private receive(chunk: Buffer): void { + if (this.closed) return; + if (this.buffer.length + chunk.length > MAX_BUFFER) { this.close(); return; } + this.buffer = Buffer.concat([this.buffer, chunk]); + for (;;) { + const newline = this.buffer.indexOf(10); + if (newline === -1) { if (this.buffer.length > MAX_FRAME) this.close(); return; } + if (newline === 0 || newline > MAX_FRAME) { this.close(); return; } + const raw = this.buffer.subarray(0, newline); + this.buffer = this.buffer.subarray(newline + 1); + try { + const frame: unknown = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(raw)); + this.frame(frame); + } catch { this.close(); } + if (this.closed) return; + } + } + + private frame(value: unknown): void { + if (!value || typeof value !== 'object' || Array.isArray(value)) { this.close(); return; } + const frame = value as Record; + const { binding, handler, pid } = this.options; + if (this.phase !== 'ready' && performance.now() >= this.handshakeDeadline) { this.close(); return; } + if (this.phase === 'challenge') { + if (!authenticNativeChallenge(frame, binding, this.nonce)) { this.close(); return; } + this.phase = 'attaching'; + this.write({ protocolVersion: 1, kind: 'backendAttach', epoch: binding.epoch, secret: binding.secret, pid }); + return; + } + if (this.phase === 'attaching') { + if (Object.keys(frame).length !== 3 || frame.protocolVersion !== 1 || frame.kind !== 'backendAttached' || frame.epoch !== binding.epoch) { this.close(); return; } + handler.bind(binding.epoch); + this.bound = true; + this.phase = 'ready'; + clearTimeout(this.timer); + return; + } + if (!isRestartControlFrame(frame) || frame.epoch !== binding.epoch || frame.id <= this.latest || this.activeId !== undefined) { this.close(); return; } + this.latest = frame.id; + const id = this.activeId = frame.id; + // Native owns a matching total budget. This watchdog revokes only the + // control binding if a handler is defective; it never fabricates settlement. + this.timer = setTimeout(() => this.close(), frame.command.action === 'prepare' ? frame.command.remainingMs + 50 : 5_050); + this.timer.unref?.(); + void Promise.resolve().then(() => { + if (this.closed || this.activeId !== id) return undefined; + return handler.handle(frame.command, binding.epoch); + }).then((result) => { + if (this.closed || this.activeId !== id) return; + if (!isRestartControlResult(result)) { this.close(); return; } + clearTimeout(this.timer); + this.activeId = undefined; + this.write({ protocolVersion: 1, kind: 'restartControlResult', epoch: binding.epoch, id, result }); + }, () => this.close()); + } +} diff --git a/server/services/desktop-restart-protocol.test.ts b/server/services/desktop-restart-protocol.test.ts new file mode 100644 index 00000000..bda69860 --- /dev/null +++ b/server/services/desktop-restart-protocol.test.ts @@ -0,0 +1,209 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + DESKTOP_RESTART_PREPARE_MS, + DESKTOP_RESTART_PROTOCOL, + DESKTOP_RESTART_TOKEN_MS, + isDesktopNativeCommand, + isDesktopNativeReply, + isDesktopRestartCommand, + isRestartControlCommand, + isRestartControlFrame, + isRestartControlResult, + isRestartId, + type RestartControlCommand, + type RestartControlResult, +} from '../../shared/desktopRestartProtocol.js'; +import snapshot from '../../shared/fixtures/desktop-update-status.json' with { type: 'json' }; + +const attemptId = 'c'.repeat(64); +const epoch = 'b'.repeat(64); +const prepared = { action: 'restartPrepared', attemptId, draftEpoch: 1 }; +const prepare = { action: 'prepare', attemptId, draftEpoch: 1, remainingMs: 5_000 }; +const commit = { action: 'commit', attemptId, token: 'test-token:1' }; +const challenge = { protocolVersion: 1, kind: 'restartChallenge', attemptId, draftEpoch: 1, ttlMs: 5_000 }; +const disposition = { protocolVersion: 1, kind: 'restartAborted', attemptId, draftEpoch: 1, snapshot }; +const result: RestartControlResult = { + ok: true, state: 'prepared', attemptId, token: 'test-token:1', expiresInMs: 10_000, error: null, +}; +const frame = { protocolVersion: 1, kind: 'restartControl', id: 1, epoch, command: prepare }; + +const invalidPositive = [undefined, null, false, '1', 0, -1, 0.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1]; +const invalidIds = [undefined, null, 1, [], {}, '', 'a'.repeat(63), 'a'.repeat(65), 'A'.repeat(64), 'g'.repeat(64), `${'a'.repeat(63)}\n`]; +const invalidTokens = [ + undefined, false, 1, [], {}, '', 'a'.repeat(257), '.token', '_token', ':token', '-token', + 'has space', 'a\n', 'a\r', 'a\t', 'a\0b', '토큰', 'a/b', 'a\\b', 'https://attacker.test/update', + 'file:///tmp/update', '../payload', '/Applications/Gajae.app', +]; +const forbiddenFields = { + url: 'https://attacker.test/update', path: '/tmp/synthetic-installer', install: true, signal: 'SIGKILL', extra: true, +}; + +test('restart protocol constants and ID format are fixed and bounded', () => { + assert.equal(DESKTOP_RESTART_PROTOCOL, 1); + assert.equal(DESKTOP_RESTART_PREPARE_MS, 5_000); + assert.equal(DESKTOP_RESTART_TOKEN_MS, 10_000); + for (const id of ['0'.repeat(64), 'abcdef0123456789'.repeat(4)]) assert.equal(isRestartId(id), true); + for (const id of invalidIds) assert.equal(isRestartId(id), false, `ID ${String(id)}`); +}); + +test('public native commands and private backend controls remain separate allowlists', () => { + for (const command of [ + { action: 'status' }, { action: 'check' }, { action: 'restart', targetId: attemptId }, { action: 'download', targetId: attemptId }, + { action: 'setAutomatic', automatic: false }, { action: 'setAutomatic', automatic: true }, + prepared, { ...prepared, action: 'restartCancel' }, + ]) assert.equal(isDesktopNativeCommand(command), true, JSON.stringify(command)); + const controls: RestartControlCommand[] = [ + { action: 'status' }, { action: 'prepare', attemptId, draftEpoch: 1, remainingMs: 1 }, + { action: 'commit', attemptId, token: 'test-token:1' }, { action: 'cancel', attemptId }, + ]; + for (const command of controls) assert.equal(isRestartControlCommand(command), true); + for (const command of controls.slice(1)) assert.equal(isDesktopNativeCommand(command), false); + for (const command of [prepared, { ...prepared, action: 'restartCancel' }, { action: 'restart' }, { action: 'check' }]) { + assert.equal(isRestartControlCommand(command), false); + } + for (const action of ['install', 'signal', 'kill', 'exec', 'open', 'download', 'https://attacker.test/update', '/tmp/payload']) { + assert.equal(isDesktopNativeCommand({ action }), false, action); + assert.equal(isRestartControlCommand({ action, attemptId }), false, action); + } +}); + +test('all restart codecs require exact own fields and reject capability-bearing additions', async (t) => { + const shapes: Array<[string, (value: unknown) => boolean, Record]> = [ + ['restart prepared', isDesktopRestartCommand, prepared], + ['restart cancel', isDesktopRestartCommand, { ...prepared, action: 'restartCancel' }], + ['native status', isDesktopNativeCommand, { action: 'status' }], + ['native automatic', isDesktopNativeCommand, { action: 'setAutomatic', automatic: false }], + ['native download', isDesktopNativeCommand, { action: 'download', targetId: attemptId }], + ['native restart', isDesktopNativeCommand, { action: 'restart', targetId: attemptId }], + ['control status', isRestartControlCommand, { action: 'status' }], + ['control prepare', isRestartControlCommand, prepare], + ['control commit', isRestartControlCommand, commit], + ['control cancel', isRestartControlCommand, { action: 'cancel', attemptId }], + ['control frame', isRestartControlFrame, frame], + ['control result', isRestartControlResult, result], + ['native challenge', isDesktopNativeReply, challenge], + ['native disposition', isDesktopNativeReply, disposition], + ['native snapshot', isDesktopNativeReply, snapshot], + ]; + for (const [name, validate, value] of shapes) { + await t.test(name, () => { + assert.equal(validate(value), true); + for (const field of Object.keys(value)) { + const missing = { ...value }; delete missing[field]; + assert.equal(validate(missing), false, `missing ${field}`); + } + for (const [field, extra] of Object.entries(forbiddenFields)) { + assert.equal(validate({ ...value, [field]: extra }), false, `extra ${field}`); + } + for (const invalid of [null, undefined, true, 1, 'status', [], [value]]) assert.equal(validate(invalid), false); + }); + } +}); + +test('restart attempts and frame epochs reject copied-shape or malformed identifiers', () => { + for (const id of invalidIds) { + assert.equal(isDesktopNativeCommand({ action: 'download', targetId: id }), false); + assert.equal(isDesktopNativeCommand({ action: 'restart', targetId: id }), false); + assert.equal(isDesktopRestartCommand({ ...prepared, attemptId: id }), false); + assert.equal(isRestartControlCommand({ ...prepare, attemptId: id }), false); + assert.equal(isRestartControlCommand({ ...commit, attemptId: id }), false); + assert.equal(isRestartControlCommand({ action: 'cancel', attemptId: id }), false); + assert.equal(isRestartControlFrame({ ...frame, epoch: id }), false); + assert.equal(isDesktopNativeReply({ ...challenge, attemptId: id }), false); + assert.equal(isDesktopNativeReply({ ...disposition, attemptId: id }), false); + if (id !== null) assert.equal(isRestartControlResult({ ...result, attemptId: id }), false); + } +}); + +test('draft epochs and sequence IDs must be positive safe integers', () => { + for (const value of invalidPositive) { + assert.equal(isDesktopRestartCommand({ ...prepared, draftEpoch: value }), false); + assert.equal(isRestartControlCommand({ ...prepare, draftEpoch: value }), false); + assert.equal(isDesktopNativeReply({ ...challenge, draftEpoch: value }), false); + assert.equal(isDesktopNativeReply({ ...disposition, draftEpoch: value }), false); + assert.equal(isRestartControlFrame({ ...frame, id: value }), false); + } + for (const value of [1, Number.MAX_SAFE_INTEGER]) { + assert.equal(isDesktopRestartCommand({ ...prepared, draftEpoch: value }), true); + assert.equal(isRestartControlCommand({ ...prepare, draftEpoch: value }), true); + assert.equal(isDesktopNativeReply({ ...challenge, draftEpoch: value }), true); + assert.equal(isRestartControlFrame({ ...frame, id: value }), true); + } +}); + +test('preparation, challenge and token expiry budgets have inclusive upper limits', () => { + for (const value of [...invalidPositive, 5_001]) { + assert.equal(isRestartControlCommand({ ...prepare, remainingMs: value }), false, `prepare ${String(value)}`); + assert.equal(isDesktopNativeReply({ ...challenge, ttlMs: value }), false, `challenge ${String(value)}`); + } + for (const value of [1, 5_000]) { + assert.equal(isRestartControlCommand({ ...prepare, remainingMs: value }), true); + assert.equal(isDesktopNativeReply({ ...challenge, ttlMs: value }), true); + } + for (const value of [...invalidPositive.filter((item) => item !== null), 10_001]) { + assert.equal(isRestartControlResult({ ...result, expiresInMs: value }), false, `expiry ${String(value)}`); + } + for (const value of [null, 1, 10_000]) assert.equal(isRestartControlResult({ ...result, expiresInMs: value }), true); +}); + +test('commit and result tokens cannot carry paths, URLs, whitespace or unbounded data', () => { + for (const token of [...invalidTokens, null]) assert.equal(isRestartControlCommand({ ...commit, token }), false, String(token)); + for (const token of invalidTokens) assert.equal(isRestartControlResult({ ...result, token }), false, String(token)); + for (const token of ['a', 'A0._:-z', 'a'.repeat(256)]) { + assert.equal(isRestartControlCommand({ ...commit, token }), true); + assert.equal(isRestartControlResult({ ...result, token }), true); + } + assert.equal(isRestartControlResult({ ...result, token: null }), true); +}); + +test('native replies validate nested snapshots and have no extra native authority fields', () => { + assert.equal(isDesktopNativeReply(snapshot), true); + assert.equal(isDesktopNativeReply(challenge), true); + for (const kind of ['restartAborted', 'restartUncertain']) { + assert.equal(isDesktopNativeReply({ ...disposition, kind }), true); + for (const invalid of [null, {}, { ...snapshot, phase: 'installed' }, { ...snapshot, installationAvailable: 'yes' }, { ...snapshot, path: '/tmp/payload' }]) { + assert.equal(isDesktopNativeReply({ ...disposition, kind, snapshot: invalid }), false); + } + } + for (const kind of ['restartCommitted', 'restartInstalled', 'restartChallenge', null, 1]) { + assert.equal(isDesktopNativeReply({ ...disposition, kind }), false); + } + for (const protocolVersion of [undefined, null, 0, 2, '1']) { + assert.equal(isDesktopNativeReply({ ...challenge, protocolVersion }), false); + assert.equal(isDesktopNativeReply({ ...disposition, protocolVersion }), false); + assert.equal(isRestartControlFrame({ ...frame, protocolVersion }), false); + } +}); + +test('control frames recursively validate commands and disallow unrecognized kinds', () => { + for (const command of [null, [], {}, { ...prepare, path: '/tmp/payload' }, { ...commit, token: 'https://attacker.test' }, { action: 'install' }, prepared]) { + assert.equal(isRestartControlFrame({ ...frame, command }), false); + } + for (const kind of [undefined, null, 1, 'backendAttach', 'backendAttached', 'restartControlResult']) { + assert.equal(isRestartControlFrame({ ...frame, kind }), false); + } +}); + +test('control results validate bounded error codes and success/error agreement', () => { + for (const state of ['open', 'preparing', 'prepared', 'committed']) assert.equal(isRestartControlResult({ ...result, state }), true); + assert.equal(isRestartControlResult({ ok: true, state: 'open', attemptId: null, token: null, expiresInMs: null, error: null }), true); + for (const error of ['busy', 'native_disconnected', 'a'.repeat(64)]) { + assert.equal(isRestartControlResult({ ...result, ok: false, error }), true); + assert.equal(isRestartControlResult({ ...result, error }), false); + } + for (const error of [undefined, null, '', 'a'.repeat(65), 'BAD', 'has space', 'code1', 'a\n', '/tmp/path', 'https://attacker.test', 1, []]) { + assert.equal(isRestartControlResult({ ...result, ok: false, error }), false, String(error)); + } + for (const ok of [undefined, null, 0, 1, 'true']) assert.equal(isRestartControlResult({ ...result, ok }), false); + for (const state of [undefined, null, 1, '', 'installed', 'OPEN', {}]) assert.equal(isRestartControlResult({ ...result, state }), false); +}); + +test('control result state must be a literal string, never an array coerced to one', async (t) => { + for (const state of ['open', 'preparing', 'prepared', 'committed']) { + await t.test(state, () => { + assert.equal(isRestartControlResult({ ...result, state: [state] }), false); + }); + } +}); diff --git a/server/services/desktop-restart-runtime.test.ts b/server/services/desktop-restart-runtime.test.ts new file mode 100644 index 00000000..5ceb3e64 --- /dev/null +++ b/server/services/desktop-restart-runtime.test.ts @@ -0,0 +1,80 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +import ts from 'typescript'; + +import { createDesktopRestartRuntime, DESKTOP_RESTART_REQUIRED_OWNERS } from './desktop-restart-runtime.js'; + +test('production owner inventory cannot shrink when readers are not integrated', async () => { + const authority = createDesktopRestartRuntime(); + assert.ok(Object.isFrozen(DESKTOP_RESTART_REQUIRED_OWNERS)); + const snapshot = await authority.snapshot(); + assert.equal(snapshot.complete, false); + assert.equal(snapshot.idle, false); + assert.deepEqual(snapshot.blockers.map((blocker) => blocker.owner), [...DESKTOP_RESTART_REQUIRED_OWNERS]); + assert.ok(snapshot.blockers.every((blocker) => blocker.code === 'owner_missing')); + assert.equal((await authority.prepare({ attemptId: 'test', epoch: 'native-test' })).ok, false); + assert.equal(authority.state, 'open'); +}); + +test('one available reader does not authorize restart while the other producers are unknown', async () => { + const authority = createDesktopRestartRuntime({ + 'gjc-worker': { + getGeneration: () => 'g1', + read: () => ({ owner: 'gjc-worker', generation: 'g1', complete: true, starting: 0, queued: 0, running: 0, settling: 0, approvals: 0, retained: 0, unknown: [] }), + }, + }); + const snapshot = await authority.snapshot(); + assert.equal(snapshot.owners.length, 1); + assert.equal(snapshot.idle, false); + assert.ok(snapshot.blockers.some((blocker) => blocker.owner === 'ui-drafts')); +}); + +test('production connects ownership readers and the same admission before startup callbacks', () => { + // Construction coverage only: this does not import index.js, launch a server, + // touch user data, or substitute for the owners' behavioral race tests. + const source = ts.createSourceFile('index.js', readFileSync(new URL('../index.js', import.meta.url), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.JS); + let construction: ts.CallExpression | undefined; + let factory: ts.CallExpression | undefined; + const configureCalls: ts.CallExpression[] = []; + const visit = (node: ts.Node) => { + if (ts.isCallExpression(node)) { + const name = node.expression.getText(source); + if (name === 'createDesktopRestartRuntime') construction = node; + if (name === 'createGjcAppFactory') factory = node; + if (name.startsWith('configure') || name.endsWith('.configureDesktopRestartAdmission')) configureCalls.push(node); + } + ts.forEachChild(node, visit); + }; + visit(source); + assert.ok(construction && factory); + assert.ok(construction.getStart(source) < factory.getStart(source)); + const readers = construction.arguments[0]; + assert.ok(readers && ts.isObjectLiteralExpression(readers)); + const names = readers.properties.map((property) => { + assert.ok(ts.isPropertyAssignment(property)); + assert.ok(ts.isIdentifier(property.name) || ts.isStringLiteral(property.name)); + return property.name.text; + }); + assert.deepEqual(names, DESKTOP_RESTART_REQUIRED_OWNERS); + for (const name of [ + 'configureGjcJobOrchestratorDesktopAdmission', 'configureSessionWorktreeDesktopAdmission', + 'configureNativeDesktopRestartAdmission', 'configureAutomationDesktopAdmission', + 'configureSessionsWatcherDesktopAdmission', 'configureNotificationDesktopAdmission', + 'configureInternalDesktopAdmission', + ]) { + const call = configureCalls.find((candidate) => candidate.expression.getText(source) === name); + assert.ok(call, `${name} must configure the production owner`); + assert.equal(call.arguments[0]?.getText(source), 'desktopRestartAdmission'); + assert.ok(call.getStart(source) > construction.getStart(source)); + assert.ok(call.getStart(source) < factory.getStart(source), `${name} must precede startup catch-up`); + } + const worker = configureCalls.find((call) => call.expression.getText(source) === 'getGjcWorkerSupervisor().configureDesktopRestartAdmission'); + assert.ok(worker && worker.getStart(source) < factory.getStart(source)); + assert.match(worker.getText(source), /desktopRestartAdmission\.enter\(source\)/u); + const workerFence = construction.arguments[1]; + assert.ok(workerFence); + assert.match(workerFence.getText(source), /fenceForDesktopRestart\(fenceId\)/u); + assert.match(workerFence.getText(source), /releaseDesktopRestartFence\(fenceId\)/u); +}); diff --git a/server/services/desktop-restart-runtime.ts b/server/services/desktop-restart-runtime.ts new file mode 100644 index 00000000..4b7372b6 --- /dev/null +++ b/server/services/desktop-restart-runtime.ts @@ -0,0 +1,17 @@ +import { DesktopRestartAuthority, type DesktopRestartAuthorityOptions, type DesktopRestartOwnerReader } from './desktop-restart-authority.js'; + +// A fixed inventory is deliberate: implementing one reader must not silently +// remove every owner which is still unaccounted for from the all-idle proof. +export const DESKTOP_RESTART_REQUIRED_OWNERS = Object.freeze([ + 'chat', 'worktrees', 'orchestrator', 'native-jobs', 'gjc-worker', + 'automation', 'browser', 'computer', 'shell', 'native-clients', + 'watchers', 'notifications', 'http-callbacks', 'internal-producers', 'ui-drafts', +] as const); + +/** Composition only. Missing/incomplete owners keep prepare fail-closed. */ +export function createDesktopRestartRuntime( + ownerReaders: Readonly> = {}, + preparationFence?: DesktopRestartAuthorityOptions['preparationFence'], +): DesktopRestartAuthority { + return new DesktopRestartAuthority({ requiredOwners: DESKTOP_RESTART_REQUIRED_OWNERS, ownerReaders, preparationFence }); +} diff --git a/server/services/desktop-shutdown-order.test.ts b/server/services/desktop-shutdown-order.test.ts new file mode 100644 index 00000000..aaf1e897 --- /dev/null +++ b/server/services/desktop-shutdown-order.test.ts @@ -0,0 +1,115 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; +import { runInNewContext } from 'node:vm'; + +import ts from 'typescript'; + +function deferred() { + let resolve!: () => void; + const promise = new Promise((done) => { resolve = done; }); + return { promise, resolve }; +} +const tick = () => new Promise((resolve) => setImmediate(resolve)); + +function fixture(overrides: Record = {}) { + // Evaluate only the actual shutdown function with explicit fake services. + // Importing index.js would start the real server and access operator data. + const source = ts.createSourceFile('index.js', readFileSync(new URL('../index.js', import.meta.url), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.JS); + let shutdown: ts.ArrowFunction | undefined; + const visit = (node: ts.Node) => { + if (ts.isVariableDeclaration(node) && node.name.getText(source) === 'shutdownRuntimeServices' + && node.initializer && ts.isArrowFunction(node.initializer)) shutdown = node.initializer; + ts.forEachChild(node, visit); + }; + visit(source); assert.ok(shutdown); + const events: string[] = []; + const process = { exitCode: 0, exit: (code: number) => events.push(`exit:${code}`) }; + const context = { + shutdownStarted: false, + shutdownExitCode: 0, + startupFlight: Promise.resolve(), + jobsInitialized: true, + desktopRestartAdmission: { state: 'open' }, + markInternalActivityUncertain: () => events.push('shutdown'), + gjcJobOrchestrator: { interruptForShutdown: async () => { events.push('durable-fence'); }, close: () => events.push('jobs-close') }, + closeSessionsWatcher: async () => { events.push('watcher-close'); }, + drainWebSocketClients: async () => { events.push('drain'); }, + wss: { clients: [], close() {} }, server: { close() {}, closeAllConnections() {} }, + shutdownGjcWorker: async () => { events.push('worker-close'); }, + automationService: { shutdown: async () => {} }, + removeLocalServerMarker: async () => {}, + console: { error() {} }, process, + setInterval: () => events.push('hold-unconfirmed-exit'), + ...overrides, + }; + return { run: runInNewContext(`(${shutdown.getText(source)})`, context) as (exitCode?: number) => Promise, events, process, context }; +} + +test('durable interruption precedes watcher waits and late pre-start cancellation cannot erase it', async () => { + const watcherDone = deferred(); + let job: string = 'running'; + const f = fixture(); + f.context.gjcJobOrchestrator.interruptForShutdown = async () => { f.events.push('durable-fence'); job = 'interrupted'; }; + f.context.closeSessionsWatcher = async () => { + f.events.push('watcher-close'); + // Models/credentials may reject a pending worker during this await. Native + // lease checks only allow cancellation while its original run is running. + if (job === 'running') job = 'ready'; + await watcherDone.promise; + }; + const closing = f.run(); await tick(); + assert.deepEqual(f.events, ['shutdown', 'durable-fence', 'watcher-close']); + assert.equal(job, 'interrupted'); + watcherDone.resolve(); await closing; + assert.equal(f.events.at(-1), 'exit:0'); + assert.equal(job, 'interrupted'); +}); + +test('watcher close cannot begin before the durable fence acknowledges completion', async () => { + const fenced = deferred(); + const f = fixture(); + f.context.gjcJobOrchestrator.interruptForShutdown = async () => { f.events.push('durable-fence'); await fenced.promise; }; + const closing = f.run(); await tick(); + assert.deepEqual(f.events, ['shutdown', 'durable-fence']); + fenced.resolve(); await closing; + assert.equal(f.events.at(-1), 'exit:0'); + await f.run(); + assert.equal(f.events.filter((event) => event === 'durable-fence').length, 1); +}); + +test('unconfirmed watcher close retains the durable interruption and refuses normal process exit', async () => { + const f = fixture(); + f.context.closeSessionsWatcher = async () => { f.events.push('watcher-close'); throw new Error('physical close unconfirmed'); }; + await f.run(); + assert.deepEqual(f.events, ['shutdown', 'durable-fence', 'watcher-close']); + assert.equal(f.process.exitCode, 1); +}); + +test('committed idle restart closes resources without admitting a new native job mutation', async () => { + const f = fixture({ desktopRestartAdmission: { state: 'committed' } }); + f.context.gjcJobOrchestrator.interruptForShutdown = async () => { throw new Error('new work after commit'); }; + await f.run(); + assert.ok(!f.events.includes('durable-fence')); + assert.ok(f.events.includes('jobs-close')); + assert.equal(f.events.at(-1), 'exit:0'); +}); + +test('startup failure joins its initializer and closes resources before reporting exit 1', async () => { + const startup = deferred(); + const f = fixture({ startupFlight: startup.promise, jobsInitialized: false }); + const closing = f.run(1); await tick(); + assert.deepEqual(f.events, ['shutdown']); + startup.resolve(); await closing; + assert.ok(!f.events.includes('durable-fence'), 'early failure cannot lazily start a job mutation'); + assert.ok(f.events.includes('jobs-close')); + assert.equal(f.events.at(-1), 'exit:1'); +}); + +test('unconfirmed automation cleanup cannot report a clean exit', async () => { + const f = fixture({ automationService: { shutdown: async () => { throw new Error('unconfirmed'); } } }); + await f.run(); + assert.equal(f.process.exitCode, 1); + assert.ok(f.events.includes('hold-unconfirmed-exit')); + assert.ok(!f.events.some(event => event.startsWith('exit:'))); +}); diff --git a/server/services/desktop-update-http.test.js b/server/services/desktop-update-http.test.js new file mode 100644 index 00000000..432d412d --- /dev/null +++ b/server/services/desktop-update-http.test.js @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict'; +import { once } from 'node:events'; +import test from 'node:test'; + +import snapshot from '../../shared/fixtures/desktop-update-status.json' with { type: 'json' }; +import { createGjcAppFactory } from '../app-factory.js'; + +test('production HTTP composition requires the desktop cookie, exact Origin and native view binding', async (t) => { + const names = ['GJC_DESKTOP', 'GJC_DESKTOP_API_KEY', 'GJC_DESKTOP_BOOTSTRAP_NONCE']; + const previous = Object.fromEntries(names.map((name) => [name, process.env[name]])); + process.env.GJC_DESKTOP = '1'; process.env.GJC_DESKTOP_API_KEY = 'a'.repeat(64); process.env.GJC_DESKTOP_BOOTSTRAP_NONCE = 'b'.repeat(64); + t.after(() => { for (const name of names) { if (previous[name] === undefined) delete process.env[name]; else process.env[name] = previous[name]; } }); + let requests = 0; + const factory = createGjcAppFactory({ + authority: {}, orchestrator: { deps: {} }, gitService: {}, projection: { publish() {} }, + terminalNotificationAdapter: undefined, authenticateWebSocket: () => false, + authenticateGjcRoute: (_request, _response, next) => next(), validateApiKey: (_request, _response, next) => next(), + chat: {}, shell: {}, + desktopUpdateRelay: { + isAvailable: () => true, + async request(command, view) { + requests += 1; + if (view !== 'c'.repeat(64)) throw new Error('updater_unauthorized'); + if (command.action === 'restart') throw new Error('updater_installation_unavailable'); + return snapshot; + }, + }, + }); + factory.server.listen(0, '127.0.0.1'); await once(factory.server, 'listening'); + t.after(async () => { factory.wss.close(); await new Promise((resolve) => factory.server.close(resolve)); }); + const origin = `http://127.0.0.1:${factory.server.address().port}`; + const cookie = `gajae_desktop_api_key=${'a'.repeat(64)}`; + const call = (headers = {}, body = { action: 'status' }) => fetch(`${origin}/api/desktop/update`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...headers }, body: JSON.stringify(body) }); + assert.equal((await call()).status, 401); + assert.equal((await call({ Cookie: cookie })).status, 403); + assert.equal((await call({ Cookie: cookie, Origin: origin })).status, 403); + assert.equal(requests, 0); + const bound = { Cookie: cookie, Origin: origin, 'X-Gajae-Update-View': 'c'.repeat(64) }; + assert.equal((await call({ ...bound, Origin: 'https://foreign.test' })).status, 403); + assert.equal((await call({ ...bound, 'X-Gajae-Update-View': 'd'.repeat(64) })).status, 403); + const valid = await call(bound); + assert.equal(valid.status, 200); assert.equal(valid.headers.get('Cache-Control'), 'no-store'); + const response = await valid.json(); assert.deepEqual(response, snapshot); + assert.equal(JSON.stringify(response).includes('c'.repeat(64)), false); + assert.equal((await call(bound, { action: 'status', path: '/Applications' })).status, 400); + assert.equal((await call(bound, { action: 'setAutomatic', automatic: 'yes' })).status, 400); + assert.equal((await call(bound, { action: 'restart' })).status, 400); + assert.equal((await call(bound, { action: 'download' })).status, 400); + assert.equal((await call(bound, { action: 'restart', targetId: 'f'.repeat(64) })).status, 503); + assert.equal((await call(bound, { action: 'download', targetId: 'f'.repeat(64) })).status, 200); + assert.equal((await call(bound, { action: 'download', targetId: 'f'.repeat(64), url: 'https://foreign.test/update' })).status, 400); +}); + +test('a normal self-hosted app never exposes desktop update operations even if a relay is injected', async (t) => { + const previous = process.env.GJC_DESKTOP; delete process.env.GJC_DESKTOP; + t.after(() => { if (previous !== undefined) process.env.GJC_DESKTOP = previous; }); + const factory = createGjcAppFactory({ + authority: {}, orchestrator: { deps: {} }, gitService: {}, projection: { publish() {} }, terminalNotificationAdapter: undefined, + authenticateWebSocket: () => false, authenticateGjcRoute: (_request, _response, next) => next(), validateApiKey: (_request, _response, next) => next(), chat: {}, shell: {}, + desktopUpdateRelay: { isAvailable: () => true, request: () => { throw new Error('must not be called'); } }, + }); + factory.server.listen(0, '127.0.0.1'); await once(factory.server, 'listening'); + t.after(async () => { factory.wss.close(); await new Promise((resolve) => factory.server.close(resolve)); }); + const origin = `http://127.0.0.1:${factory.server.address().port}`; + const response = await fetch(`${origin}/api/desktop/update`, { method: 'POST', headers: { 'Content-Type': 'application/json', Origin: origin, 'X-Gajae-Update-View': 'c'.repeat(64) }, body: '{"action":"check"}' }); + assert.equal(response.status, 404); +}); diff --git a/server/services/desktop-update-relay.test.ts b/server/services/desktop-update-relay.test.ts new file mode 100644 index 00000000..48f52b44 --- /dev/null +++ b/server/services/desktop-update-relay.test.ts @@ -0,0 +1,391 @@ +import assert from 'node:assert/strict'; +import { createHmac } from 'node:crypto'; +import { EventEmitter, once } from 'node:events'; +import { chmod, mkdtemp, rm } from 'node:fs/promises'; +import { createServer, type connect as Connect, type Socket } from 'node:net'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { PassThrough } from 'node:stream'; +import test from 'node:test'; + +import snapshot from '../../shared/fixtures/desktop-update-status.json' with { type: 'json' }; +import { isDesktopUpdateCommand, isDesktopUpdateSnapshot } from '../../shared/desktopUpdateProtocol.js'; + +import { DesktopUpdateRelay } from './desktop-update-relay.js'; + +class TestSocket extends EventEmitter { + written = ''; + destroyed = false; + write(value: string) { this.written += value; } + destroy() { if (!this.destroyed) { this.destroyed = true; this.emit('close'); } } + frames(): Record[] { return this.written.trim().split('\n').filter(Boolean).map((line) => JSON.parse(line)); } + challengeResponse(extras: Record = {}, key: string | Buffer = 'a'.repeat(64)): string { + const challenge = this.frames()[0]; + assert.equal(challenge.kind, 'challenge'); + const { epoch, nonce } = challenge; + const proof = createHmac('sha256', key).update(`gajae-native-update-v1\0${epoch}\0${nonce}`, 'utf8').digest('hex'); + return `${JSON.stringify({ protocolVersion: 1, kind: 'challenge', epoch, nonce, proof, ...extras })}\n`; + } + /** Explicit native authentication on the SAME fake socket, before any reply. */ + authenticate(): string { + assert.equal(this.frames().length, 1); + const line = this.challengeResponse(); + this.emit('data', Buffer.from(line)); + assert.equal(this.frames().length, 2); + return line; + } + response(extras: Record = {}) { + const frames = this.frames(); + assert.equal(frames.length, 2, 'authenticate the socket before replying'); + this.emit('data', Buffer.from(`${JSON.stringify({ protocolVersion: 1, sequence: frames[1].sequence, ok: true, snapshot, ...extras })}\n`)); + } +} +function noCapabilities(socket: TestSocket) { + assert.equal(socket.frames().length, 1); + const challenge = socket.frames()[0]; + assert.deepEqual(Object.keys(challenge).sort(), ['epoch', 'kind', 'nonce', 'pid', 'protocolVersion']); + assert.doesNotMatch(socket.written, /"(?:secret|view|origin|command)"/); + assert.equal(socket.written.includes('a'.repeat(64)), false); + assert.equal(socket.written.includes('c'.repeat(64)), false); +} +const init = (extras: Record = {}) => `GJC_DESKTOP_UPDATE_INIT ${JSON.stringify({ protocolVersion: 1, socket: '/private/tmp/owned-native/rpc', secret: 'a'.repeat(64), epoch: 'b'.repeat(64), ...extras })}\n`; +function harness(enabled = true) { + const input = new PassThrough(); + const sockets: TestSocket[] = []; + const relay = new DesktopUpdateRelay({ input, pid: 42, platform: 'darwin', env: enabled ? { GJC_DESKTOP: '1', GJC_DESKTOP_UPDATE_PIPE: '1' } : {}, connect: (() => { const socket = new TestSocket(); sockets.push(socket); return socket; }) as unknown as typeof Connect }); + const request = () => relay.request({ action: 'status' }, 'c'.repeat(64), 'http://127.0.0.1:43123'); + return { input, relay, sockets, request }; +} + +test('ordinary web/Linux/no-update launch never reads stdin or obtains a native binding', async () => { + for (const platform of ['linux', 'darwin'] as const) { + const input = new PassThrough(); + const relay = new DesktopUpdateRelay({ input, platform, env: platform === 'linux' ? { GJC_DESKTOP: '1', GJC_DESKTOP_UPDATE_PIPE: '1' } : {} }); + assert.equal(input.listenerCount('data'), 0); + input.write(init()); + assert.equal(relay.isAvailable(), false); + await assert.rejects(relay.request({ action: 'status' }, 'c'.repeat(64), 'http://127.0.0.1:43123'), /unavailable/); + } +}); + +test('only one bounded fragmented owned initialization is accepted; duplicate/unknown input retires it', () => { + const { relay, input } = harness(); + const frame = init(); + input.write(frame.slice(0, 20)); assert.equal(relay.isAvailable(), false); + input.write(frame.slice(20)); assert.equal(relay.isAvailable(), true); + input.write(frame); assert.equal(relay.isAvailable(), false); + for (const frame of [init({ command: 'install' }), init({ secret: 'short' }), init({ socket: 'relative' }), 'x'.repeat(4097), '{}\n']) { + const h = harness(); h.input.write(frame); assert.equal(h.relay.isAvailable(), false); h.relay.retire(); + } +}); + +test('valid round trip authenticates transport and forwards only the validated public snapshot', async () => { + const h = harness(); h.input.write(init()); + const response = h.request(); h.sockets[0].emit('connect'); + const challenge = h.sockets[0].frames()[0]; + assert.deepEqual(challenge, { protocolVersion: 1, kind: 'challenge', epoch: 'b'.repeat(64), pid: 42, nonce: challenge.nonce }); + assert.match(String(challenge.nonce), /^[a-f0-9]{64}$/); + noCapabilities(h.sockets[0]); + h.sockets[0].authenticate(); + const sent = h.sockets[0].frames()[1]; + assert.deepEqual(sent, { protocolVersion: 1, secret: 'a'.repeat(64), epoch: 'b'.repeat(64), pid: 42, sequence: 1, view: 'c'.repeat(64), origin: 'http://127.0.0.1:43123', command: { action: 'status' } }); + assert.equal(h.sockets.length, 1, 'no reconnect between proof and command'); + h.sockets[0].response(); assert.deepEqual(await response, snapshot); + assert.equal(h.sockets[0].destroyed, true); h.relay.retire(); +}); + +test('unbound views and arbitrary updater commands are refused before opening a socket', async () => { + const h = harness(); h.input.write(init()); + await assert.rejects(h.relay.request({ action: 'status' }, 'copied-cookie', 'http://127.0.0.1:43123'), /unauthorized/); + await assert.rejects(h.relay.request({ action: 'status' }, 'c'.repeat(64), 'https://evil.test'), /unauthorized/); + await assert.rejects(h.relay.request({ action: 'status', path: '/Applications' } as never, 'c'.repeat(64), 'http://127.0.0.1:43123'), /unavailable/); + assert.equal(h.sockets.length, 0); h.relay.retire(); +}); + +test('oversized, forged sequence, malformed state and extra responses fail closed', async () => { + for (const value of ['x'.repeat(32769), '{}\n{}\n', JSON.stringify({ protocolVersion: 1, sequence: 2, ok: true, snapshot }) + '\n', JSON.stringify({ protocolVersion: 1, sequence: 1, ok: true, snapshot: {} }) + '\n']) { + const h = harness(); h.input.write(init()); const response = h.request(); h.sockets[0].emit('connect'); + h.sockets[0].authenticate(); + h.sockets[0].emit('data', Buffer.from(value)); await assert.rejects(response, /protocol_error/); h.relay.retire(); + } +}); + +test('native rejection and disconnect never report saved preferences or installation success', async () => { + const h = harness(); h.input.write(init()); const response = h.request(); h.sockets[0].emit('connect'); + h.sockets[0].authenticate(); + h.sockets[0].response({ ok: false, error: 'updater_unauthorized' }); await assert.rejects(response, /unauthorized/); + const pending = h.request(); h.relay.retire(); await assert.rejects(pending, /unavailable/); + assert.equal(h.relay.isAvailable(), false); +}); + +test('request queue and deadlines are bounded; timeout is not a cancellation acknowledgment', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const h = harness(); h.input.write(init()); + const requests = Array.from({ length: 4 }, () => assert.rejects(h.request(), /timeout|unavailable/)); + h.sockets.forEach((socket) => { socket.emit('connect'); noCapabilities(socket); }); + await assert.rejects(h.request(), /busy/); + t.mock.timers.tick(2_000); + await Promise.all(requests); assert.ok(h.sockets.every((s) => s.destroyed)); h.relay.retire(); +}); + +test('a substituted endpoint cannot obtain secret, view or command with a forged proof or public snapshot', async (t) => { + const forged: Array<(socket: TestSocket) => string> = [ + (socket) => socket.challengeResponse({ proof: 'd'.repeat(64) }), + (socket) => socket.challengeResponse({}, 'wrong-key'), + // Wire key is UTF-8 text, not a hex-decoded secret. + (socket) => socket.challengeResponse({}, Buffer.from('a'.repeat(64), 'hex')), + () => `${JSON.stringify({ protocolVersion: 1, sequence: 1, ok: true, snapshot })}\n`, + () => '{}\n', + () => 'null\n', + () => '[]\n', + () => '{invalid}\n', + ]; + for (const [index, response] of forged.entries()) { + await t.test(`substitution ${index}`, async () => { + const h = harness(); h.input.write(init()); + const pending = h.relay.request({ action: 'setAutomatic', automatic: false }, 'c'.repeat(64), 'http://127.0.0.1:43123'); + const rejected = assert.rejects(pending, /updater_unauthorized|updater_protocol_error/); + const socket = h.sockets[0]; socket.emit('connect'); + socket.emit('data', Buffer.from(response(socket))); + await rejected; + noCapabilities(socket); + assert.equal(socket.destroyed, true); + h.relay.retire(); + }); + } +}); + +test('real isolated Unix attacker endpoint receives only a challenge and no second request after an invalid proof', { + skip: process.platform === 'win32', timeout: 5_000, +}, async (t) => { + // This path and every credential below are synthetic, test-owned fixtures. + // No desktop process, production socket, app data or native key is accessed. + const directory = await mkdtemp(join(tmpdir(), 'gju-')); + const socketPath = join(directory, 'rpc'); + const input = new PassThrough(); + // No connect override: exercise a real net.Socket and kernel Unix transport. + const relay = new DesktopUpdateRelay({ input, platform: 'darwin', env: { GJC_DESKTOP: '1', GJC_DESKTOP_UPDATE_PIPE: '1' } }); + const peers = new Set(); + const captured: Buffer[] = []; + let total = 0; + let connections = 0; + let replied = false; + let fixtureError: unknown; + let resolveClosed!: () => void; + const peerClosed = new Promise((resolve) => { resolveClosed = resolve; }); + const attacker = createServer((socket) => { + connections += 1; + peers.add(socket); + socket.on('error', () => {}); // A rejected endpoint may receive ECONNRESET. + socket.once('close', () => { peers.delete(socket); resolveClosed(); }); + socket.on('data', (chunk: Buffer) => { + if (total + chunk.length > 4096) { fixtureError = new Error('Fixture request exceeded its bound.'); socket.destroy(); return; } + total += chunk.length; + captured.push(Buffer.from(chunk)); + if (replied) return; // Keep recording until the client actually closes. + const bytes = Buffer.concat(captured); + const newline = bytes.indexOf(10); + if (newline < 0) return; + try { + const challenge = JSON.parse(bytes.subarray(0, newline).toString('utf8')); + const proof = createHmac('sha256', 'attacker-does-not-have-native-key') + .update(`gajae-native-update-v1\0${challenge.epoch}\0${challenge.nonce}`, 'utf8').digest('hex'); + replied = true; + socket.write(`${JSON.stringify({ protocolVersion: 1, kind: 'challenge', epoch: challenge.epoch, nonce: challenge.nonce, proof })}\n`); + } catch (error) { fixtureError = error; socket.destroy(); } + }); + }); + t.after(async () => { + relay.retire(); input.destroy(); + for (const socket of peers) socket.destroy(); + if (attacker.listening) await new Promise((resolve, reject) => attacker.close((error) => error ? reject(error) : resolve())); + // Remove only the private directory returned by this test's mkdtemp. + await rm(directory, { recursive: true, force: true }); + }); + await chmod(directory, 0o700); + attacker.listen(socketPath); + await once(attacker, 'listening'); + await chmod(socketPath, 0o600); + input.write(init({ socket: socketPath })); + await assert.rejects(relay.request({ action: 'setAutomatic', automatic: false }, 'c'.repeat(64), 'http://127.0.0.1:43123'), /updater_unauthorized/); + await peerClosed; + assert.equal(fixtureError, undefined); + assert.equal(connections, 1); + assert.equal(replied, true); + const wire = Buffer.concat(captured).toString('utf8'); + const frames = wire.trim().split('\n'); + assert.equal(frames.length, 1, 'no credential-bearing second request was received before close'); + const challenge = JSON.parse(frames[0]); + assert.deepEqual(Object.keys(challenge).sort(), ['epoch', 'kind', 'nonce', 'pid', 'protocolVersion']); + assert.equal(challenge.protocolVersion, 1); + assert.equal(challenge.kind, 'challenge'); + assert.equal(challenge.epoch, 'b'.repeat(64)); + assert.equal(challenge.pid, process.pid); + assert.match(challenge.nonce, /^[a-f0-9]{64}$/); + assert.equal(wire.includes('a'.repeat(64)), false); + assert.equal(wire.includes('c'.repeat(64)), false); + assert.doesNotMatch(wire, /"(?:secret|view|origin|command)"/); +}); + +test('challenge verification requires exact fields, echo, type and lowercase 32-byte proof', async (t) => { + const changes = [ + { protocolVersion: 2 }, { protocolVersion: '1' }, { kind: 'response' }, + { epoch: 'd'.repeat(64) }, { epoch: null }, { nonce: 'e'.repeat(64) }, { nonce: 1 }, + { proof: 'a'.repeat(62) }, { proof: 'A'.repeat(64) }, { proof: 'g'.repeat(64) }, + { proof: 42 }, { proof: null }, { proof: undefined }, { extra: true }, + ]; + for (const [index, fields] of changes.entries()) { + await t.test(`invalid proof frame ${index}`, async () => { + const h = harness(); h.input.write(init()); const response = h.request(); + const rejected = assert.rejects(response, /unauthorized/); + const socket = h.sockets[0]; socket.emit('connect'); + socket.emit('data', Buffer.from(socket.challengeResponse(fields))); + await rejected; noCapabilities(socket); h.relay.retire(); + }); + } +}); + +test('fresh per-connection nonces reject a proof captured from another request', async () => { + const h = harness(); h.input.write(init()); + const first = h.request(); h.sockets[0].emit('connect'); + const captured = h.sockets[0].challengeResponse(); + h.sockets[0].authenticate(); h.sockets[0].response(); await first; + const second = h.request(); const rejected = assert.rejects(second, /unauthorized/); + h.sockets[1].emit('connect'); + assert.notEqual(h.sockets[0].frames()[0].nonce, h.sockets[1].frames()[0].nonce); + h.sockets[1].emit('data', Buffer.from(captured)); + await rejected; noCapabilities(h.sockets[1]); h.relay.retire(); +}); + +test('fragmented handshake withholds capabilities until the complete proof, then accepts fragmented reply', async () => { + const h = harness(); h.input.write(init()); const response = h.request(); + const socket = h.sockets[0]; socket.emit('connect'); + const proof = socket.challengeResponse(); + for (const character of proof.slice(0, -1)) { + socket.emit('data', Buffer.from(character)); + noCapabilities(socket); + } + socket.emit('data', Buffer.from('\n')); + assert.equal(socket.frames().length, 2); + const reply = `${JSON.stringify({ protocolVersion: 1, sequence: socket.frames()[1].sequence, ok: true, snapshot })}\n`; + for (let offset = 0; offset < reply.length; offset += 17) socket.emit('data', Buffer.from(reply.slice(offset, offset + 17))); + assert.deepEqual(await response, snapshot); h.relay.retire(); +}); + +test('coalesced duplicate challenge or premature command reply is rejected before capabilities are sent', async () => { + for (const suffix of ['duplicate', 'reply']) { + const h = harness(); h.input.write(init()); const response = h.request(); + const rejected = assert.rejects(response, /protocol_error/); + const socket = h.sockets[0]; socket.emit('connect'); + const proof = socket.challengeResponse(); + socket.emit('data', Buffer.from(proof + (suffix === 'duplicate' ? proof : `${JSON.stringify({ protocolVersion: 1, sequence: 1, ok: true, snapshot })}\n`))); + await rejected; noCapabilities(socket); h.relay.retire(); + } +}); + +test('a repeated challenge after authentication does not trigger another credential-bearing request', async () => { + const h = harness(); h.input.write(init()); const response = h.request(); + const rejected = assert.rejects(response, /protocol_error/); + const socket = h.sockets[0]; socket.emit('connect'); + const proof = socket.authenticate(); + socket.emit('data', Buffer.from(proof)); + await rejected; + assert.equal(socket.frames().length, 2); assert.equal(socket.destroyed, true); h.relay.retire(); +}); + +test('duplicate command responses in one frame fail; late data after settlement cannot write again', async () => { + const h = harness(); h.input.write(init()); const response = h.request(); + const rejected = assert.rejects(response, /protocol_error/); + const socket = h.sockets[0]; socket.emit('connect'); socket.authenticate(); + const reply = `${JSON.stringify({ protocolVersion: 1, sequence: 1, ok: true, snapshot })}\n`; + socket.emit('data', Buffer.from(reply + reply)); await rejected; + socket.emit('data', Buffer.from(socket.challengeResponse())); socket.emit('connect'); + assert.equal(socket.frames().length, 2); + const next = h.request(); h.sockets[1].emit('connect'); h.sockets[1].authenticate(); h.sockets[1].response(); + assert.deepEqual(await next, snapshot); + h.sockets[1].response(); // Already settled/closed: ignored, not another operation. + assert.equal(h.sockets[1].frames().length, 2); h.relay.retire(); +}); + +test('oversized unauthenticated data is rejected before Buffer.concat allocates it', async (t) => { + const h = harness(); h.input.write(init()); const response = h.request(); + const rejected = assert.rejects(response, /protocol_error/); + const socket = h.sockets[0]; socket.emit('connect'); + const concat = t.mock.method(Buffer, 'concat'); + socket.emit('data', Buffer.alloc(32 * 1024 + 1, 'x')); + assert.equal(concat.mock.callCount(), 0); + await rejected; noCapabilities(socket); h.relay.retire(); +}); + +test('the complete challenge plus reply exchange has a 32 KiB receive budget', async () => { + for (const excess of [0, 1]) { + const h = harness(); h.input.write(init()); const response = h.request(); + const socket = h.sockets[0]; socket.emit('connect'); + const proof = socket.authenticate(); + const reply = `${JSON.stringify({ protocolVersion: 1, sequence: 1, ok: true, snapshot })}\n`; + const padding = ' '.repeat(32 * 1024 - Buffer.byteLength(proof) - Buffer.byteLength(reply) + excess); + const expected = excess ? assert.rejects(response, /protocol_error/) : response; + socket.emit('data', Buffer.from(padding + reply)); + const result = await expected; + if (!excess) assert.deepEqual(result, snapshot); + assert.equal(socket.destroyed, true); h.relay.retire(); + } +}); + +test('handshake progress and authentication do not renew the total two-second deadline', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const h = harness(); h.input.write(init()); const response = h.request(); + const rejected = assert.rejects(response, /timeout/); + const socket = h.sockets[0]; socket.emit('connect'); + const proof = socket.challengeResponse(); + socket.emit('data', Buffer.from(proof.slice(0, 30))); + t.mock.timers.tick(1_500); + noCapabilities(socket); + socket.emit('data', Buffer.from(proof.slice(30))); + assert.equal(socket.frames().length, 2); + t.mock.timers.tick(499); assert.equal(socket.destroyed, false); + t.mock.timers.tick(1); await rejected; + socket.response(); assert.equal(socket.frames().length, 2); h.relay.retire(); +}); + +test('timeout, retirement or native close before authentication never sends capabilities', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + for (const ending of ['timeout', 'retire', 'close']) { + const h = harness(); h.input.write(init()); const response = h.request(); + const rejected = assert.rejects(response, /timeout|unavailable/); + const socket = h.sockets[0]; socket.emit('connect'); + const lateProof = socket.challengeResponse(); + if (ending === 'timeout') t.mock.timers.tick(2_000); + else if (ending === 'retire') h.relay.retire(); + else socket.destroy(); + await rejected; + socket.emit('data', Buffer.from(lateProof)); socket.emit('connect'); + noCapabilities(socket); h.relay.retire(); + } +}); + +test('a connection arriving after its deadline cannot even write a challenge', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const h = harness(); h.input.write(init()); const response = h.request(); + const rejected = assert.rejects(response, /timeout/); + t.mock.timers.tick(2_000); await rejected; + h.sockets[0].emit('connect'); assert.equal(h.sockets[0].written, ''); h.relay.retire(); +}); + +test('the validated command is captured before the handshake awaits native authentication', async () => { + const h = harness(); h.input.write(init()); + const command = { action: 'setAutomatic' as const, automatic: false }; + const response = h.relay.request(command, 'c'.repeat(64), 'http://127.0.0.1:43123'); + h.sockets[0].emit('connect'); noCapabilities(h.sockets[0]); + command.automatic = true; + h.sockets[0].authenticate(); + assert.deepEqual(h.sockets[0].frames()[1].command, { action: 'setAutomatic', automatic: false }); + h.sockets[0].response(); await response; h.relay.retire(); +}); + +test('shared state and command fixtures reject malformed partial payloads', () => { + assert.equal(isDesktopUpdateSnapshot(snapshot), true); + for (const payload of [{}, null, { ...snapshot, downloadedBytes: -1 }, { ...snapshot, totalBytes: 2 }, { ...snapshot, phase: 'installed' }, { ...snapshot, extra: 'not-in-contract' }]) assert.equal(isDesktopUpdateSnapshot(payload), false); + for (const command of [{ action: 'status' }, { action: 'check' }, { action: 'restart', targetId: 'f'.repeat(64) }, { action: 'download', targetId: 'f'.repeat(64) }, { action: 'setAutomatic', automatic: false }]) assert.equal(isDesktopUpdateCommand(command), true); + for (const command of [{ action: 'install' }, { action: 'status', url: 'https://evil.test' }, { action: 'setAutomatic' }]) assert.equal(isDesktopUpdateCommand(command), false); +}); diff --git a/server/services/desktop-update-relay.ts b/server/services/desktop-update-relay.ts new file mode 100644 index 00000000..cad0c716 --- /dev/null +++ b/server/services/desktop-update-relay.ts @@ -0,0 +1,186 @@ +import { randomBytes } from 'node:crypto'; +import { connect as connectSocket, type Socket } from 'node:net'; +import { performance } from 'node:perf_hooks'; +import type { Readable } from 'node:stream'; + +import { isDesktopNativeCommand, isDesktopNativeReply, type DesktopNativeCommand, type DesktopNativeReply } from '../../shared/desktopRestartProtocol.js'; + +import { DesktopRestartChannel, type DesktopRestartHandler } from './desktop-restart-channel.js'; +import { authenticNativeChallenge, isNativeSecret, type DesktopNativeBinding } from './desktop-update-transport.js'; + +const MAX_INIT_BYTES = 4096; +const MAX_RESPONSE_BYTES = 32 * 1024; +const REQUEST_TIMEOUT_MS = 2_000; +const MAX_PENDING = 4; +const initPrefix = 'GJC_DESKTOP_UPDATE_INIT '; +type Binding = DesktopNativeBinding; +type Options = { + input?: Readable; + env?: NodeJS.ProcessEnv; + platform?: NodeJS.Platform; + pid?: number; + connect?: typeof connectSocket; + restart?: DesktopRestartHandler; +}; + +/** A relay, not an updater: no downloads, lifecycle, installer or private key APIs. */ +export class DesktopUpdateRelay { + private binding: Binding | null = null; + private readonly pending = new Set(); + private sequence = 0; + private retired = false; + private readonly pid: number; + private readonly connect: typeof connectSocket; + private readonly input: Readable; + private inputBuffer = Buffer.alloc(0); + private readonly restart?: DesktopRestartHandler; + private restartChannel?: DesktopRestartChannel; + + constructor(options: Options = {}) { + this.input = options.input ?? process.stdin; + this.connect = options.connect ?? connectSocket; + this.pid = options.pid ?? process.pid; + this.restart = options.restart; + const env = options.env ?? process.env; + if ((options.platform ?? process.platform) !== 'darwin' || env.GJC_DESKTOP !== '1' || env.GJC_DESKTOP_UPDATE_PIPE !== '1') { + this.retired = true; + return; + } + // Only the supervisor's fresh stdin supplies this secret. It is never an + // environment variable, browser response, stdout frame or descendant input. + this.input.on('data', this.onData); + this.input.once('end', this.onEnd); + this.input.once('error', this.onEnd); + } + + private readonly onEnd = () => { this.retire(); }; + private readonly onData = (chunk: Buffer | string) => { + if (this.retired) return; + if (this.binding) { this.retire(); return; } + if (this.inputBuffer.length + Buffer.byteLength(chunk) > MAX_INIT_BYTES) { this.retire(); return; } + this.inputBuffer = Buffer.concat([this.inputBuffer, Buffer.from(chunk)]); + const newline = this.inputBuffer.indexOf(10); + if (newline === -1) return; + try { + const line = this.inputBuffer.toString('utf8', 0, newline); + if (newline !== this.inputBuffer.length - 1 || !line.startsWith(initPrefix)) throw new Error(); + const value = JSON.parse(line.slice(initPrefix.length)) as Record; + if (Object.keys(value).length !== 4 || value.protocolVersion !== 1 + || typeof value.socket !== 'string' || !value.socket.startsWith('/') || value.socket.length > 1024 + || !isNativeSecret(value.secret) || !isNativeSecret(value.epoch)) throw new Error(); + this.binding = value as Binding; + this.inputBuffer.fill(0); + this.inputBuffer = Buffer.alloc(0); + if (this.restart) this.restartChannel = new DesktopRestartChannel({ binding: this.binding, pid: this.pid, handler: this.restart, connect: this.connect }); + } catch { + this.retire(); + } + }; + + isAvailable(): boolean { return !this.retired && this.binding !== null; } + + retire(): void { + this.retired = true; + this.restartChannel?.close(); + this.binding = null; + this.inputBuffer.fill(0); + this.inputBuffer = Buffer.alloc(0); + this.input.off('data', this.onData); + this.input.off('end', this.onEnd); + this.input.off('error', this.onEnd); + for (const socket of this.pending) socket.destroy(); + } + + request(command: DesktopNativeCommand, view: string, origin: string): Promise { + const binding = this.binding; + if (this.retired || !binding || !isDesktopNativeCommand(command)) return Promise.reject(new Error('updater_unavailable')); + if (!isNativeSecret(view) || typeof origin !== 'string' + || !/^http:\/\/127\.0\.0\.1:[1-9][0-9]{0,4}$/.test(origin)) return Promise.reject(new Error('updater_unauthorized')); + if (this.pending.size >= MAX_PENDING) return Promise.reject(new Error('updater_busy')); + const sequence = ++this.sequence; + const acceptedCommand: DesktopNativeCommand = { ...command }; + const timeoutMs = command.action === 'restartPrepared' ? 20_000 : command.action === 'restartCancel' ? 10_000 : REQUEST_TIMEOUT_MS; + return new Promise((resolve, reject) => { + let settled = false; + let bytes = Buffer.alloc(0); + let receivedBytes = 0; + let phase: 'connecting' | 'challenge' | 'response' = 'connecting'; + let nonce = ''; + let socket: Socket | undefined; + const deadline = performance.now() + timeoutMs; + const finish = (error?: string, value?: DesktopNativeReply) => { + if (settled) return; + if (!error && performance.now() >= deadline) error = 'updater_timeout'; + settled = true; + clearTimeout(timer); + if (socket) { + this.pending.delete(socket); + socket.destroy(); + } + bytes.fill(0); + if (error) reject(new Error(error)); + else resolve(value!); + }; + // One deadline covers connect, proof verification AND the command reply. + // Neither authentication nor partial data renews the budget. + const timer = setTimeout(() => finish('updater_timeout'), timeoutMs); + try { socket = this.connect(binding.socket); } + catch { finish('updater_unavailable'); return; } + const connectedSocket = socket; + this.pending.add(socket); + socket.once('connect', () => { + if (settled) return; + if (performance.now() >= deadline) { finish('updater_timeout'); return; } + if (this.retired || this.binding !== binding) { finish('updater_unavailable'); return; } + try { + nonce = randomBytes(32).toString('hex'); + phase = 'challenge'; + // A replaceable same-UID socket path is not native identity. Disclose + // no secret, view, origin or command until this endpoint proves it. + connectedSocket.write(`${JSON.stringify({ protocolVersion: 1, kind: 'challenge', epoch: binding.epoch, pid: this.pid, nonce })}\n`); + } catch { finish('updater_unavailable'); } + }); + socket.on('data', (chunk: Buffer) => { + if (settled) return; + if (performance.now() >= deadline) { finish('updater_timeout'); return; } + // Bound the entire two-frame exchange before allocating a concatenation. + if (receivedBytes + chunk.length > MAX_RESPONSE_BYTES) { finish('updater_protocol_error'); return; } + receivedBytes += chunk.length; + bytes = Buffer.concat([bytes, chunk]); + const newline = bytes.indexOf(10); + if (newline === -1) return; + try { + if (newline !== bytes.length - 1) throw new Error(); + const response: unknown = JSON.parse(bytes.subarray(0, newline).toString('utf8')); + if (!response || typeof response !== 'object' || Array.isArray(response) + || this.retired || this.binding !== binding) throw new Error(); + const frame = response as Record; + if (phase === 'challenge') { + if (!authenticNativeChallenge(frame, binding, nonce)) { finish('updater_unauthorized'); return; } + if (performance.now() >= deadline) { finish('updater_timeout'); return; } + bytes.fill(0); + bytes = Buffer.alloc(0); + phase = 'response'; + // Keep this authenticated descriptor; reconnecting would discard + // the endpoint proof. Native separately enforces LOCAL_PEERPID. + connectedSocket.write(`${JSON.stringify({ protocolVersion: 1, secret: binding.secret, epoch: binding.epoch, pid: this.pid, sequence, view, origin, command: acceptedCommand })}\n`); + return; + } + if (phase !== 'response' || frame.protocolVersion !== 1 || frame.sequence !== sequence) throw new Error(); + if (frame.ok === true && isDesktopNativeReply(frame.snapshot)) { + const reply = frame.snapshot; + if ('kind' in reply) { + if (reply.kind === 'restartChallenge' ? acceptedCommand.action !== 'restart' + : !('attemptId' in acceptedCommand) || reply.attemptId !== acceptedCommand.attemptId || reply.draftEpoch !== acceptedCommand.draftEpoch) throw new Error(); + } + finish(undefined, reply); + } + else if (frame.ok === false && typeof frame.error === 'string' && /^[a-z_]{1,64}$/.test(frame.error)) finish(frame.error); + else throw new Error(); + } catch { finish('updater_protocol_error'); } + }); + socket.once('error', () => finish('updater_unavailable')); + socket.once('close', () => finish('updater_unavailable')); + }); + } +} diff --git a/server/services/desktop-update-transport.ts b/server/services/desktop-update-transport.ts new file mode 100644 index 00000000..1363a165 --- /dev/null +++ b/server/services/desktop-update-transport.ts @@ -0,0 +1,11 @@ +import { createHmac, timingSafeEqual } from 'node:crypto'; + +export type DesktopNativeBinding = { protocolVersion: 1; socket: string; secret: string; epoch: string }; +export const isNativeSecret = (value: unknown): value is string => typeof value === 'string' && value.length === 64 && /^[a-f0-9]+$/u.test(value); +export function authenticNativeChallenge(value: Record, binding: DesktopNativeBinding, nonce: string): boolean { + if (Object.keys(value).length !== 5 || value.protocolVersion !== 1 || value.kind !== 'challenge' + || value.epoch !== binding.epoch || value.nonce !== nonce || !isNativeSecret(value.proof)) return false; + const expected = createHmac('sha256', binding.secret) + .update(`gajae-native-update-v1\0${binding.epoch}\0${nonce}`, 'utf8').digest(); + return timingSafeEqual(expected, Buffer.from(value.proof, 'hex')); +} diff --git a/server/services/gjc-git-client.test.ts b/server/services/gjc-git-client.test.ts index f2c35cb2..e23d12f1 100644 --- a/server/services/gjc-git-client.test.ts +++ b/server/services/gjc-git-client.test.ts @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { EventEmitter } from 'node:events'; -import { GjcGitClient, type GjcNativeSpawn } from './gjc-git-client.js'; +import { GjcGitClient, NativeActivityGroup, type GjcNativeSpawn } from './gjc-git-client.js'; class FakeChild extends EventEmitter { readonly stdout = new EventEmitter(); @@ -31,6 +31,35 @@ async function ready(client: GjcGitClient, children: FakeChild[]): Promise { + const group = new NativeActivityGroup(); const children: FakeChild[] = []; + const client = new GjcGitClient({ workdir: '/fixture', spawn: spawn(children), activityGroup: group }); + const initial = group.getGeneration(); + const starting = client.start(); + assert.equal(group.read().starting, 1); + assert.notEqual(group.getGeneration(), initial); + const child = children[0]!; child.emitFrame({ protocolVersion: 1, kind: 'ready' }); await starting; + assert.equal(group.read().running, 0); + const operation = client.status(); await tick(); + assert.ok(group.read().running > 0); + child.emitFrame({ protocolVersion: 1, kind: 'response', id: requestId(child), ok: true, result: {} }); + assert.ok(group.read().running > 0, 'response is not settlement of the awaiting continuation'); + await operation; assert.equal(group.read().running, 0); + client.close(); assert.equal(group.read().settling, 1); + child.emit('close'); assert.equal(group.read().settling, 0); + assert.equal(group.read().complete, true); +}); + +test('failed Git work remains uncertain after losing and closing its leader', async () => { + const group = new NativeActivityGroup(); const children: FakeChild[] = []; + const client = new GjcGitClient({ workdir: '/fixture', spawn: spawn(children), activityGroup: group }); + const child = await ready(client, children); + const operation = client.create({}); await tick(); child.emit('exit', 1); + await assert.rejects(operation); client.close(); child.emit('close'); + assert.equal(group.read().complete, false); + assert.deepEqual(group.read().unknown, ['native_work_termination_unconfirmed']); +}); + test('git assembles staged list items and multi-chunk diff responses', async () => { const children: FakeChild[] = []; const client = new GjcGitClient({ workdir: '/repo', spawn: spawn(children) }); const child = await ready(client, children); diff --git a/server/services/gjc-git-client.ts b/server/services/gjc-git-client.ts index c7ec0f52..f933d7a2 100644 --- a/server/services/gjc-git-client.ts +++ b/server/services/gjc-git-client.ts @@ -2,6 +2,9 @@ import { randomUUID } from 'node:crypto'; import { spawn as spawnChild } from 'node:child_process'; import { fileURLToPath } from 'node:url'; +import type { DesktopOwnerActivity } from '../../shared/desktopUpdateProtocol.js'; +import type { DesktopWorkAdmission } from '../shared/interfaces.js'; + const MAX_FRAME_BYTES = 64 * 1024; const MAX_AGGREGATE_BYTES = 16 * 1024 * 1024; const FAILURE = 'GJC native client is unavailable.'; @@ -24,8 +27,41 @@ export type GjcNativeClientOptions = { corePath?: string; spawn?: GjcNativeSpawn; platform?: NodeJS.Platform; environment?: NodeJS.ProcessEnv; compiled?: boolean; readyTimeoutMs?: number; restartDelayMs?: number; maxRestartDelayMs?: number; aggregateLimitBytes?: number; onHealthChange?: (healthy: boolean, generation: number) => void; + activityGroup?: NativeActivityGroup; }; -type Pending = { method: string; resolve(value: unknown): void; reject(error: Error): void; items: unknown[]; chunks: Buffer[]; bytes: number; nextSequence: number }; +type Pending = { method: string; resolve(value: unknown): void; reject(error: Error): void; items: unknown[]; chunks: Buffer[]; bytes: number; nextSequence: number; observer?: boolean; timedOut?: boolean }; + +export class NativeActivityGroup { + private readonly epoch = randomUUID(); + private revision = 0n; + private readonly clients = new Set(); + private admission?: DesktopWorkAdmission; + configure(admission: DesktopWorkAdmission): void { + if (this.admission && this.admission !== admission) throw new Error('Native admission is already configured.'); + this.admission = admission; + } + attach(client: GjcNativeClient): void { this.clients.add(client); this.changed(); } + detach(client: GjcNativeClient): void { if (this.clients.delete(client)) this.changed(); } + changed(): void { this.revision += 1n; } + getGeneration = (): string => `${this.epoch}:${this.revision}`; + enter(): (() => void) | undefined { + // Native requests are dependencies of admitted HTTP/job/watcher owners. + // Accepted continuations may invalidate a reversible proof, never committed + // shutdown. Top-level producers must still use normal entry admission. + return this.admission?.enterCompletion('native:owned-operation'); + } + read = (): DesktopOwnerActivity => { + const parts = [...this.clients].map((client) => client.activity()); + const count = (key: 'starting' | 'queued' | 'running' | 'settling') => parts.reduce((total, item) => total + item[key], 0); + const unknown = [...new Set(parts.flatMap((item) => item.unknown))]; + return { owner: 'native-clients', generation: this.getGeneration(), complete: unknown.length === 0, + starting: count('starting'), queued: count('queued'), running: count('running'), settling: count('settling'), + approvals: 0, retained: 0, unknown }; + }; +} +const productionActivity = new NativeActivityGroup(); +export const configureNativeDesktopRestartAdmission = (admission: DesktopWorkAdmission): void => productionActivity.configure(admission); +export const createNativeDesktopRestartReader = () => ({ getGeneration: productionActivity.getGeneration, read: productionActivity.read }); /** Protocol v1 NDJSON process owner. Failed requests are deliberately never replayed. */ export class GjcNativeClient { @@ -41,18 +77,51 @@ export class GjcNativeClient { private backoff: number; private readyResolve?: () => void; private readyReject?: (error: Error) => void; + private readonly activityGroup: NativeActivityGroup; + private readonly activityEpoch = randomUUID(); + private activityRevision = 0n; + private operations = 0; + private readonly retiring = new Set(); + private readonly ended = new WeakSet(); + private uncertainWork = false; constructor(private readonly command: 'git' | 'jobs', options: GjcNativeClientOptions = {}, private readonly launchArgs?: string[]) { this.options = { spawn: options.spawn ?? spawnChild as unknown as GjcNativeSpawn, platform: options.platform ?? process.platform, environment: options.environment ?? process.env, readyTimeoutMs: options.readyTimeoutMs ?? 5_000, restartDelayMs: options.restartDelayMs ?? 50, maxRestartDelayMs: options.maxRestartDelayMs ?? 1_000, aggregateLimitBytes: options.aggregateLimitBytes ?? MAX_AGGREGATE_BYTES, corePath: options.corePath, compiled: options.compiled, onHealthChange: options.onHealthChange }; this.backoff = this.options.restartDelayMs; + this.activityGroup = options.activityGroup ?? productionActivity; + this.activityGroup.attach(this); + } + + getActivityGeneration(): string { return `${this.activityEpoch}:${this.activityRevision}`; } + activity() { + const pending = [...this.pending.values()]; + const unknown = [ + ...(this.uncertainWork ? ['native_work_termination_unconfirmed'] : []), + ...(pending.some((request) => request.timedOut) ? ['native_observation_unconfirmed'] : []), + ]; + return { starting: this.starting && !this.ready ? 1 : 0, queued: this.restart ? 1 : 0, + running: this.operations + pending.filter((request) => !request.observer).length, + settling: this.retiring.size, unknown }; + } + private changed(): void { this.activityRevision += 1n; this.activityGroup.changed(); } + private collected(): void { + if (this.closed && !this.child && this.retiring.size === 0 && this.operations === 0 && this.pending.size === 0 && !this.uncertainWork) this.activityGroup.detach(this); } async request(method: string, params: Record = {}): Promise { - await this.start(); + const release = this.activityGroup.enter(); + this.operations++; this.changed(); + try { return await this.requestOwned(method, params); } + finally { this.operations--; this.changed(); release?.(); this.collected(); } + } + + private async requestOwned(method: string, params: Record): Promise { + await this.startInner(); const child = this.child; if (!this.ready || !child) throw new Error(FAILURE); const id = randomUUID(); const result = new Promise((resolve, reject) => this.pending.set(id, { method, resolve, reject, items: [], chunks: [], bytes: 0, nextSequence: 0 })); + this.changed(); try { child.stdin.write(`${JSON.stringify(this.command === 'git' ? { protocolVersion: 1, kind: 'request', id, method, params } : { ...params, protocolVersion: 1, id, method })}\n`); } catch { @@ -63,14 +132,22 @@ export class GjcNativeClient { } start(): Promise { + let release: (() => void) | undefined; + try { release = this.activityGroup.enter(); } catch (error) { return Promise.reject(error); } + this.operations++; this.changed(); + return this.startInner().finally(() => { this.operations--; this.changed(); release?.(); this.collected(); }); + } + + private startInner(): Promise { if (this.closed) return Promise.reject(new Error(FAILURE)); if (this.ready) return Promise.resolve(); if (this.starting) return this.starting; - if (this.restart) return this.restart.then(() => this.start()); + if (this.restart) return this.restart.then(() => this.startInner()); let resolveStart!: () => void; let rejectStart!: (error: Error) => void; const starting = new Promise((resolve, reject) => { resolveStart = resolve; rejectStart = reject; }); this.starting = starting; + this.changed(); this.readyResolve = resolveStart; this.readyReject = rejectStart; const executable = this.options.platform === 'win32' ? 'gajae-core.exe' : 'gajae-core'; @@ -81,12 +158,18 @@ export class GjcNativeClient { const child = this.options.spawn(corePath, args, { detached: false, env: this.options.environment, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }); const generation = ++this.generation; this.child = child; + this.changed(); this.input = Buffer.alloc(0); child.stdout.on('data', (chunk) => this.onData(child, generation, chunk)); child.stdin.on?.('error', () => this.failed(child, generation)); child.on('error', () => this.failed(child, generation)); child.on('exit', () => this.failed(child, generation)); - child.on('close', () => this.failed(child, generation)); + child.on('close', () => { + this.ended.add(child); + this.failed(child, generation); + if (this.retiring.delete(child)) this.changed(); + this.collected(); + }); if (this.command === 'jobs') this.probe(child, generation); const timer = setTimeout(() => { if (!this.ready) this.failed(child, generation); }, this.options.readyTimeoutMs); timer.unref?.(); @@ -99,6 +182,7 @@ export class GjcNativeClient { private probe(child: Child, generation: number): void { const id = randomUUID(); this.pending.set(id, { method: 'job.list', resolve: () => {}, reject: () => {}, items: [], chunks: [], bytes: 0, nextSequence: 0 }); + this.changed(); try { child.stdin.write(`${JSON.stringify({ protocolVersion: 1, id, method: 'job.list', limit: 1 })}\n`); } catch { this.failed(child, generation); } } @@ -145,6 +229,7 @@ export class GjcNativeClient { if (this.command === 'git' && value.kind !== 'response') return this.failed(child, generation); if (typeof value.ok !== 'boolean') return this.failed(child, generation); this.pending.delete(value.id); + if (!pending.observer || pending.timedOut) this.changed(); if (value.ok) { pending.resolve(this.complete(value.result, pending)); if (this.command === 'jobs' && !this.ready) this.markReady(child, generation); @@ -173,14 +258,19 @@ export class GjcNativeClient { private markReady(child: Child, generation: number): void { if (!this.isCurrent(child, generation) || this.ready) return; this.ready = true; + this.starting = undefined; + this.changed(); this.backoff = this.options.restartDelayMs; this.readyResolve?.(); this.options.onHealthChange?.(true, generation); } - private rejectPending(id: string, error: Error): void { const pending = this.pending.get(id); if (pending) { this.pending.delete(id); pending.reject(error); } } + private rejectPending(id: string, error: Error): void { const pending = this.pending.get(id); if (pending) { this.pending.delete(id); if (!pending.observer || pending.timedOut) this.changed(); pending.reject(error); } } private failed(child?: Child, generation?: number): void { if (this.closed || this.restart || (child && (generation === undefined || !this.isCurrent(child, generation)))) return; const failedChild = child ?? this.child; + if (this.command === 'git' && [...this.pending.values()].some((request) => !request.observer)) this.uncertainWork = true; + if (failedChild && !this.ended.has(failedChild)) this.retiring.add(failedChild); + this.changed(); this.ready = false; this.readyReject?.(new Error(FAILURE)); this.options.onHealthChange?.(false, generation ?? this.generation); @@ -201,13 +291,21 @@ export class GjcNativeClient { }).then(() => { if (this.closed) throw new Error(FAILURE); this.restart = undefined; + this.changed(); return this.start(); }); this.restart = restarting; + this.changed(); void restarting.catch(() => {}); } close(): void { this.closed = true; + this.ready = false; + this.starting = undefined; + this.restart = undefined; + if (this.command === 'git' && [...this.pending.values()].some((request) => !request.observer)) this.uncertainWork = true; + if (this.child && !this.ended.has(this.child)) this.retiring.add(this.child); + this.changed(); this.readyReject?.(new Error(FAILURE)); for (const [id] of this.pending) this.rejectPending(id, new Error(FAILURE)); const child = this.child; @@ -222,6 +320,27 @@ export class GjcNativeClient { } catch { // best-effort cleanup } + this.collected(); + } + + /** Only the pure jobs aggregate. Never lazily starts or recovers a process. */ + protected observeActivity(): Promise { + const child = this.child; + if (this.command !== 'jobs' || !this.ready || !child || this.closed || this.restart + || [...this.pending.values()].filter((request) => request.observer).length >= 2) return Promise.reject(new Error(FAILURE)); + const id = randomUUID(); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + const pending = this.pending.get(id); + if (pending) { pending.timedOut = true; this.changed(); reject(new Error('Native activity observation timed out.')); } + }, 1000); + timer.unref?.(); + this.pending.set(id, { method: 'job.activity', observer: true, + resolve: (value) => { clearTimeout(timer); resolve(value); }, reject: (error) => { clearTimeout(timer); reject(error); }, + items: [], chunks: [], bytes: 0, nextSequence: 0 }); + try { child.stdin.write(`${JSON.stringify({ protocolVersion: 1, id, method: 'job.activity' })}\n`); } + catch { this.rejectPending(id, new Error(FAILURE)); this.failed(child, this.generation); } + }); } } diff --git a/server/services/gjc-job-git.service.test.ts b/server/services/gjc-job-git.service.test.ts index cd5b4376..6abd5ccc 100644 --- a/server/services/gjc-job-git.service.test.ts +++ b/server/services/gjc-job-git.service.test.ts @@ -1,15 +1,26 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { execFile as execFileCallback } from 'node:child_process'; +import childProcess, { execFile as execFileCallback } from 'node:child_process'; +import { EventEmitter } from 'node:events'; import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { syncBuiltinESMExports } from 'node:module'; import os from 'node:os'; import path from 'node:path'; +import { PassThrough } from 'node:stream'; import { promisify } from 'node:util'; +import { configureInternalDesktopAdmission, enterInternalActivity, getInternalActivityGeneration, snapshotInternalActivity, withInternalActivity } from '../shared/desktop-internal-activity.js'; +import { DesktopRestartAuthority } from './desktop-restart-authority.js'; import { GjcJobGitService } from './gjc-job-git.service.js'; const execFile = promisify(execFileCallback); +let desktopAuthority: DesktopRestartAuthority | undefined; +const sources: string[] = []; +configureInternalDesktopAdmission({ + enter(source) { sources.push(source); return desktopAuthority?.enter(source) ?? (() => {}); }, + enterCompletion(source) { sources.push(source); return desktopAuthority?.enterCompletion(source) ?? (() => {}); }, +}); test('job git status resolves only the stored managed worktree', async () => { const calls: Record[] = []; @@ -157,3 +168,182 @@ test('job git summaries allow 50 unique job IDs and reject larger batches', asyn assert.equal(Object.keys(await service.summaries(Array.from({ length: 50 }, (_, index) => `job-${index}`))).length, 50); await assert.rejects(service.summaries(Array.from({ length: 51 }, (_, index) => `job-${index}`)), { code: 'invalid_request' }); }); + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((yes) => { resolve = yes; }); + return { promise, resolve }; +} +const tick = () => new Promise((resolve) => setImmediate(resolve)); +class GitProcess extends EventEmitter { + stdout = new PassThrough(); + stderr = new PassThrough(); + kills = 0; + kill() { this.kills++; return true; } +} +function mockGit(t: test.TestContext, respond?: (child: GitProcess, args: string[]) => void) { + const children: GitProcess[] = []; + const commands: string[][] = []; + const spawned = deferred(); + const mocked = t.mock.method(childProcess, 'spawn', (command: string, args: string[]) => { + assert.equal(command, 'git'); + const child = new GitProcess(); + children.push(child); commands.push(args); spawned.resolve(); + if (respond) queueMicrotask(() => respond(child, args)); + return child as never; + }); + syncBuiltinESMExports(); + t.after(() => { mocked.mock.restore(); syncBuiltinESMExports(); }); + return { children, commands, spawned: spawned.promise }; +} +function activityFixture(t: test.TestContext) { + sources.length = 0; + const authority = new DesktopRestartAuthority({ requiredOwners: ['internal-producers'], ownerReaders: { + 'internal-producers': { getGeneration: getInternalActivityGeneration, read: snapshotInternalActivity }, + } }); + desktopAuthority = authority; + t.after(() => { desktopAuthority = undefined; }); + return authority; +} +const storedJob = { jobId: 'job-a', repositoryRoot: '/fixture/repo', worktreeId: 'worktree-a', branch: 'job/job-a', baseCommit: 'base' }; +function serviceFixture(overrides: Partial[0]> = {}) { + const events: Record[] = []; + let reads = 0; + const service = new GjcJobGitService({ + get: async () => { reads++; return storedJob; }, + appendAdminEvent: async (event) => { events.push(event); return {}; }, + ...overrides, + }, () => ({ + list: async () => ({ items: [{ worktreeId: 'worktree-a', path: '/fixture/worktree', branch: 'job/job-a' }] }), + status: async () => ({ clean: false }), diff: async () => ({ patch: '' }), + })); + return { service, events, reads: () => reads }; +} + +test('internal activity is pure, monotonic, source-labelled and retained across callback settlement', async (t) => { + const authority = activityFixture(t); + const before = snapshotInternalActivity(); + assert.equal(before.owner, 'internal-producers'); + assert.deepEqual(snapshotInternalActivity(), before); + assert.equal(getInternalActivityGeneration(), before.generation); + const finish = deferred(); + const pending = withInternalActivity('git-service:test', () => { + assert.equal(snapshotInternalActivity().running, 1); + return finish.promise; + }); + const busy = snapshotInternalActivity(); + assert.deepEqual(sources, ['git-service:test']); + assert.equal((await authority.prepare({ attemptId: 'internal-test', epoch: 'epoch-1' })).ok, false); + finish.resolve(); + await pending; + const after = snapshotInternalActivity(); + assert.deepEqual({ ...after, generation: before.generation }, before); + assert.ok(BigInt(after.generation.split(':').at(-1)!) > BigInt(busy.generation.split(':').at(-1)!)); + const release = enterInternalActivity('git-service:idempotent'); + release(); const generation = getInternalActivityGeneration(); release(); + assert.equal(getInternalActivityGeneration(), generation); + await assert.rejects(withInternalActivity('git-service:failure', () => { throw new Error('sync failed'); }), /sync failed/); + assert.equal((await authority.snapshot()).idle, true); +}); + +test('all public Git roots are fenced before authority reads, lifecycle writes, or subprocesses', async (t) => { + const authority = activityFixture(t); const f = serviceFixture(); const git = mockGit(t); + const prepared = await authority.prepare({ attemptId: 'git-roots', epoch: 'epoch-1' }); + assert.equal(prepared.ok, true); + for (const action of [ + () => f.service.resolve('job-a'), () => f.service.status('job-a'), () => f.service.diff('job-a'), + () => f.service.summaries(['job-a']), () => f.service.publish('job-a'), () => f.service.hasCommits('job-a'), + () => f.service.commit('job-a', 'commit', ['changed.txt']), + () => f.service.createPullRequest('job-a', async () => assert.fail('PR callback must not run')), () => f.service.prContext('job-a'), + ]) await assert.rejects(action(), { code: 'DESKTOP_RESTART_FENCED' }); + assert.equal(f.reads(), 0); assert.deepEqual(f.events, []); assert.deepEqual(git.commands, []); + if (prepared.ok) assert.equal((await authority.commit(prepared.token, 'epoch-1')).ok, true); +}); + +test('a Git root counts binding preparation before its first await', async (t) => { + const authority = activityFixture(t); const binding = deferred(); + const f = serviceFixture({ get: () => binding.promise }); + const status = f.service.status('job-a'); + assert.equal(snapshotInternalActivity().running, 1); + assert.equal((await authority.snapshot()).ingress, 1); + assert.equal((await authority.prepare({ attemptId: 'git-binding', epoch: 'epoch-1' })).ok, false); + binding.resolve(storedJob); await status; + assert.deepEqual(sources, ['git-service:status']); + assert.equal((await authority.snapshot()).idle, true); +}); + +test('a mocked publish error waits for child close and failed-event persistence without an updater kill', async (t) => { + const authority = activityFixture(t); const git = mockGit(t); + const persistence = deferred(); const persisting = deferred(); + const events: string[] = []; + const f = serviceFixture({ appendAdminEvent: async ({ eventId }) => { + events.push(String(eventId)); + if (String(eventId).endsWith('.failed')) { persisting.resolve(); await persistence.promise; } + return {}; + } }); + const pending = f.service.publish('job-a'); + const rejected = assert.rejects(pending, /child error before close/); + await git.spawned; + assert.equal(git.commands[0][0], 'push'); // Intercepted above; no real push is executed. + const child = git.children[0]; + let settled = false; void pending.catch(() => {}).then(() => { settled = true; }); + child.emit('error', new Error('child error before close')); + child.emit('exit', 1); + await tick(); + assert.equal(settled, false); assert.equal(events.length, 1); + assert.equal((await authority.prepare({ attemptId: 'git-close', epoch: 'epoch-1' })).ok, false); + assert.equal(child.kills, 0); + child.emit('close', 0); + await persisting.promise; + assert.equal(settled, false); assert.equal(snapshotInternalActivity().running, 1); + persistence.resolve(); await rejected; await tick(); + assert.equal((await authority.snapshot()).idle, true); + assert.equal(child.kills, 0); +}); + +test('commit private continuations retain one root through every mocked child and its admin event', async (t) => { + const authority = activityFixture(t); + const git = mockGit(t, (child, args) => { + if (args[0] === 'status') child.stdout.write(' M changed.txt\n'); + if (args[0] === 'rev-parse') child.stdout.write('fixture-commit\n'); + child.emit('close', 0); + }); + const persisting = deferred(); const persisted = deferred(); + const f = serviceFixture({ appendAdminEvent: async () => { persisting.resolve(); await persisted.promise; return {}; } }); + const pending = f.service.commit('job-a', 'message', ['changed.txt']); + await persisting.promise; + assert.deepEqual(git.commands.map((args) => args[0]), ['status', 'add', 'commit', 'rev-parse']); + assert.deepEqual(sources, ['git-service:commit']); + assert.equal(snapshotInternalActivity().running, 1); + assert.equal((await authority.prepare({ attemptId: 'git-persistence', epoch: 'epoch-1' })).ok, false); + persisted.resolve(); + assert.equal((await pending).commit, 'fixture-commit'); + assert.equal((await authority.snapshot()).idle, true); +}); + +test('a PR facade promise and its final admin event stay inside the accepted Git root', async (t) => { + const authority = activityFixture(t); + const git = mockGit(t, (child, args) => { + child.stdout.write(args[0] === 'rev-list' ? 'commit\n' : args[0] === 'symbolic-ref' ? 'refs/remotes/origin/main\n' : 'https://example.invalid/repo.git\n'); + child.emit('close', 0); + }); + const created = deferred(); const creating = deferred(); + const recorded = deferred(); const recording = deferred(); + const f = serviceFixture({ appendAdminEvent: async ({ eventId }) => { + if (String(eventId).endsWith('.completed')) { recording.resolve(); await recorded.promise; } + return {}; + } }); + const pending = f.service.createPullRequest('job-a', async (context) => { + assert.deepEqual(context, { branch: 'job/job-a', baseBranch: 'main', remoteUrl: 'https://example.invalid/repo.git' }); + creating.resolve(); return created.promise; + }); + await creating.promise; + assert.equal(snapshotInternalActivity().running, 1); + assert.equal((await authority.prepare({ attemptId: 'pr-facade', epoch: 'epoch-1' })).ok, false); + assert.deepEqual(git.commands.map((args) => args[0]), ['rev-list', 'symbolic-ref', 'remote']); + created.resolve('fixture-pr'); await recording.promise; + assert.equal(snapshotInternalActivity().running, 1); + recorded.resolve(); assert.equal(await pending, 'fixture-pr'); + assert.deepEqual(sources, ['git-service:pull-request']); + assert.equal((await authority.snapshot()).idle, true); +}); diff --git a/server/services/gjc-job-git.service.ts b/server/services/gjc-job-git.service.ts index 5cf3896c..5340c8e2 100644 --- a/server/services/gjc-job-git.service.ts +++ b/server/services/gjc-job-git.service.ts @@ -2,6 +2,7 @@ import { spawn } from 'node:child_process'; import { randomUUID } from 'node:crypto'; import type { JobGitDiffResponse } from '../../shared/gjc-job-projection-protocol.js'; +import { withInternalActivity } from '../shared/desktop-internal-activity.js'; import { GjcGitClient } from './gjc-git-client.js'; @@ -48,7 +49,22 @@ function diffResponse(value: unknown): JobGitDiffResponse { return { text, paths }; } /** Resolves git operations from an immutable job binding, never a client-supplied path. */ -function execute(cwd: string, args: string[]): Promise { return new Promise((resolve, reject) => { const child = spawn('git', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] }); let stdout = ''; let stderr = ''; child.stdout.on('data', value => { stdout += value; }); child.stderr.on('data', value => { stderr += value; }); child.on('error', reject); child.on('close', code => code === 0 ? resolve(stdout) : reject(new Error(stderr.trim() || `git ${args[0]} failed`))); }); } +function execute(cwd: string, args: string[]): Promise { + return new Promise((resolve, reject) => { + const child = spawn('git', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] }); + let stdout = ''; let stderr = ''; let failure: Error | undefined; + child.stdout.on('data', value => { stdout += value; }); + child.stderr.on('data', value => { stderr += value; }); + // An error (including a failed kill) is not an exit/stdio-close proof. + // The enclosing root must retain this child and its continuation until close. + child.on('error', error => { failure ??= error; }); + child.once('close', code => { + if (failure) reject(failure); + else if (code === 0) resolve(stdout); + else reject(new Error(stderr.trim() || `git ${args[0]} failed`)); + }); + }); +} export class GjcJobGitService { private readonly summaryCache = new Map(); constructor(private readonly jobs: Jobs, private readonly gitForRoot: (root: string) => Git, private readonly publishAdminEvent?: (jobId: string, eventId: string, payload: Record) => Promise) {} @@ -62,9 +78,15 @@ export class GjcJobGitService { return { job, path: worktree.path, git }; } async resolve(jobId: string): Promise<{ job: JobSnapshot; path: string; git: Git }> { + return withInternalActivity('git-service:resolve', () => this.resolveOwned(jobId)); + } + private async resolveOwned(jobId: string): Promise<{ job: JobSnapshot; path: string; git: Git }> { return this.resolveSnapshot(snapshot(await this.jobs.get({ jobId }))); } async summaries(jobIds: readonly string[], options: { forceRefresh?: boolean } = {}): Promise> { + return withInternalActivity('git-service:summaries', () => this.summariesOwned(jobIds, options)); + } + private async summariesOwned(jobIds: readonly string[], options: { forceRefresh?: boolean }): Promise> { const ids = [...new Set(jobIds)]; if (ids.length > 50) throw Object.assign(new Error('At most 50 job IDs are supported.'), { code: 'invalid_request' }); const summaries: Record = {}; @@ -114,23 +136,39 @@ export class GjcJobGitService { } } - async status(jobId: string): Promise { const binding = await this.resolve(jobId); return binding.git.status({ jobId, branch: binding.job.branch, path: binding.path }); } + async status(jobId: string): Promise { + return withInternalActivity('git-service:status', async () => { + const binding = await this.resolveOwned(jobId); + return binding.git.status({ jobId, branch: binding.job.branch, path: binding.path }); + }); + } async diff(jobId: string): Promise { - const binding = await this.resolve(jobId); - const value = await binding.git.diff({ jobId, branch: binding.job.branch, path: binding.path, mode: 'base', baseCommit: binding.job.baseCommit, includeUntracked: true }); - return diffResponse(value); + return withInternalActivity('git-service:diff', async () => { + const binding = await this.resolveOwned(jobId); + const value = await binding.git.diff({ jobId, branch: binding.job.branch, path: binding.path, mode: 'base', baseCommit: binding.job.baseCommit, includeUntracked: true }); + return diffResponse(value); + }); } async publish(jobId: string): Promise<{ branch: string }> { - return this.lifecycle(jobId, 'publish', async () => { - const binding = await this.resolve(jobId); + return withInternalActivity('git-service:publish', () => this.lifecycle(jobId, 'publish', async () => { + const binding = await this.resolveOwned(jobId); await execute(binding.path, ['push', '-u', 'origin', binding.job.branch!]); return { branch: binding.job.branch! }; - }); + })); + } + async hasCommits(jobId: string): Promise { + return withInternalActivity('git-service:has-commits', () => this.hasCommitsOwned(jobId)); + } + private async hasCommitsOwned(jobId: string): Promise { + const binding = await this.resolveOwned(jobId); + return Boolean((await execute(binding.path, ['rev-list', '--max-count=1', `${binding.job.baseCommit}..HEAD`])).trim()); } - async hasCommits(jobId: string): Promise { const binding = await this.resolve(jobId); return Boolean((await execute(binding.path, ['rev-list', '--max-count=1', `${binding.job.baseCommit}..HEAD`])).trim()); } async commit(jobId: string, message: unknown, paths: unknown): Promise<{ commit: string; eventId: string }> { + return withInternalActivity('git-service:commit', () => this.commitOwned(jobId, message, paths)); + } + private async commitOwned(jobId: string, message: unknown, paths: unknown): Promise<{ commit: string; eventId: string }> { const input = commitInput(message, paths); - const binding = await this.resolve(jobId); + const binding = await this.resolveOwned(jobId); const changed = new Set((await execute(binding.path, ['status', '--porcelain', '--untracked-files=all'])).split('\n').filter(Boolean).map(line => line.slice(3).replace(/^"|"$/gu, ''))); if (input.paths.some(path => !changed.has(path))) throw Object.assign(new Error('Commit paths must be currently changed relative paths.'), { code: 'invalid_request' }); await execute(binding.path, ['add', '--', ...input.paths]); @@ -141,9 +179,12 @@ export class GjcJobGitService { return { commit, eventId }; } async createPullRequest(jobId: string, create: (context: { branch: string; baseBranch: string; remoteUrl: string }) => Promise): Promise { + return withInternalActivity('git-service:pull-request', () => this.createPullRequestOwned(jobId, create)); + } + private async createPullRequestOwned(jobId: string, create: (context: { branch: string; baseBranch: string; remoteUrl: string }) => Promise): Promise { return this.lifecycle(jobId, 'pr', async () => { - const binding = await this.resolve(jobId); - if (!await this.hasCommits(jobId)) throw new Error('Cannot create a pull request: the job branch has no commits beyond its base commit.'); + const binding = await this.resolveOwned(jobId); + if (!await this.hasCommitsOwned(jobId)) throw new Error('Cannot create a pull request: the job branch has no commits beyond its base commit.'); const reference = await execute(binding.path, ['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD']); const baseBranch = reference.trim().replace(/^refs\/remotes\/origin\//u, ''); if (!baseBranch) throw new Error('Unable to determine the remote default branch.'); @@ -151,7 +192,7 @@ export class GjcJobGitService { }); } async prContext(jobId: string): Promise<{ branch: string; baseBranch: string; remoteUrl: string }> { - return this.createPullRequest(jobId, async context => context); + return withInternalActivity('git-service:pr-context', () => this.createPullRequestOwned(jobId, async context => context)); } } diff --git a/server/services/gjc-job-orchestrator.test.ts b/server/services/gjc-job-orchestrator.test.ts index 737f19a8..93f0cac2 100644 --- a/server/services/gjc-job-orchestrator.test.ts +++ b/server/services/gjc-job-orchestrator.test.ts @@ -3,7 +3,8 @@ import test from 'node:test'; import type { GjcWorkerOutcome } from '../gjc-worker-client.js'; -import { GjcCapacityExhaustedError, JobOrchestrator, type JobAuthority, type GitWorktrees, type JobSupervisor } from './gjc-job-orchestrator.js'; +import { DesktopRestartAuthority } from './desktop-restart-authority.js'; +import { createGjcJobOrchestratorDesktopRestartReader, GjcCapacityExhaustedError, JobOrchestrator, withOwnedJobDesktopContinuation, type JobAuthority, type GitWorktrees, type JobSupervisor } from './gjc-job-orchestrator.js'; type Snap = { jobId: string; state: string; lease: { owner: string; generation: number }; worktreeId?: string; repositoryRoot?: string; branch?: string; currentRun?: { runId: string; appSessionId: string }; dispatchCheckpoint?: { runId: string }; lastSequence?: number }; class Jobs implements JobAuthority { @@ -368,4 +369,231 @@ test('admin events broadcast only after a committed authority event is returned' const orchestrator = new JobOrchestrator({ jobs, git: new Git(), supervisor: new Supervisor(), broadcast: (_jobId, event) => events.push(event) }); await orchestrator.appendAdminEvent('job-admin', 'publish.started', { branch: 'job-admin' }); assert.deepEqual(events.map(({ eventId, sequence }) => ({ eventId, sequence })), [{ eventId: 'publish.started', sequence: 1 }]); -}); \ No newline at end of file +}); + +function desktopDeferred() { + let resolve!: (value: T) => void; + const promise = new Promise((yes) => { resolve = yes; }); + return { resolve, promise }; +} +const desktopTick = () => new Promise((resolve) => setImmediate(resolve)); +function desktopFixture() { + const jobs = new Jobs(); const git = new Git(); const supervisor = new Supervisor(); + const completed = desktopDeferred(); + supervisor.spawnRun = (input) => { + supervisor.input = input; + return { started: Promise.resolve(), completion: completed.promise, abortHandle: input.runId }; + }; + const orchestrator = new JobOrchestrator({ jobs, git, supervisor, owner: 'desktop-test', createId: () => 'abc' }); + const reader = createGjcJobOrchestratorDesktopRestartReader(orchestrator); + const authority = new DesktopRestartAuthority({ requiredOwners: ['orchestrator'], ownerReaders: { orchestrator: reader } }); + orchestrator.configureDesktopAdmission(authority); + return { jobs, git, supervisor, completed, orchestrator, reader, authority }; +} + +test('desktop reader is pure and its revision advances through an idle/busy/idle cycle', async () => { + const f = desktopFixture(); + const before = f.reader.read(); + assert.deepEqual(f.reader.read(), before); + assert.equal(f.reader.getGeneration(), before.generation); + assert.equal(before.owner, 'orchestrator'); + assert.deepEqual(f.jobs.calls, []); + assert.deepEqual(f.git.calls, []); + const write = desktopDeferred(); + f.jobs.state.state = 'ready'; + f.jobs.appendAdminEvent = async (params) => { await write.promise; return Jobs.prototype.appendAdminEvent.call(f.jobs, params); }; + const pending = f.orchestrator.appendAdminEvent('job-abc', 'admin', {}); + const busy = f.reader.read(); + assert.ok(busy.queued > 0 && busy.settling > 0); + assert.equal(f.reader.getGeneration(), busy.generation); + write.resolve(); + await pending; + await desktopTick(); + const after = f.reader.read(); + assert.deepEqual({ ...after, generation: before.generation }, before); + assert.ok(BigInt(after.generation.split(':').at(-1)!) > BigInt(busy.generation.split(':').at(-1)!)); + assert.ok(BigInt(busy.generation.split(':').at(-1)!) > BigInt(before.generation.split(':').at(-1)!)); +}); + +test('desktop admission fences every public job root before any authority or Git await', async () => { + const f = desktopFixture(); + const prepared = await f.authority.prepare({ attemptId: 'job-fence', epoch: 'desktop-1' }); + assert.equal(prepared.ok, true); + for (const action of [ + () => f.orchestrator.start('gjc', 'app-1', '/project', 'start', options), + () => f.orchestrator.turnStart('gjc', 'app-1', 'turn', options), + () => f.orchestrator.resume('job-abc', 'app-1', 'resume', options), + () => f.orchestrator.resolveBinding('gjc', 'app-1'), + () => f.orchestrator.reconcile(), + () => f.orchestrator.appendAdminEvent('job-abc', 'event', {}), + ]) await assert.rejects(action(), { code: 'DESKTOP_RESTART_FENCED' }); + assert.deepEqual(f.jobs.calls, []); + assert.deepEqual(f.git.calls, []); + assert.equal(f.supervisor.input, undefined); + if (prepared.ok) f.authority.cancel(prepared.token); +}); + +test('paused turn binding lookup owns ingress before there is a queue and transfers without an idle gap', async () => { + const f = desktopFixture(); + f.jobs.state = { ...f.jobs.state, jobId: 'job-abc', state: 'ready', worktreeId: '/project/.gjc-worktrees/job-abc', repositoryRoot: '/project', branch: 'job/job-abc' }; + const lookup = desktopDeferred(); + f.jobs.bindingResolve = () => lookup.promise as ReturnType; + const pending = f.orchestrator.turnStart('gjc', 'app-1', 'continue', options); + assert.equal(f.reader.read().starting, 1); + assert.equal(f.reader.read().queued, 0); + assert.equal((await f.authority.snapshot()).ingress, 1); + assert.equal((await f.authority.prepare({ attemptId: 'binding-race', epoch: 'desktop-1' })).ok, false); + assert.equal(f.supervisor.aborted, undefined); + lookup.resolve({ jobId: 'job-abc', state: 'ready', providerSessionId: 'provider-1' }); + const handle = await pending; + assert.equal((await f.authority.snapshot()).idle, false); + assert.equal(f.reader.read().running, 1); + f.completed.resolve(); + await handle.completion; + await desktopTick(); + assert.equal((await f.authority.snapshot()).idle, true); +}); + +test('owned continuation capability is checked at entry and is not inherited by new callback roots', async () => { + const f = desktopFixture(); + const prepared = await f.authority.prepare({ attemptId: 'continuation-scope', epoch: 'desktop-1' }); + assert.equal(prepared.ok, true); + assert.throws(() => withOwnedJobDesktopContinuation(() => false, () => assert.fail('expired owner dispatched')), /live owner/); + let callbackChecked = false; + const handle = await withOwnedJobDesktopContinuation(() => true, () => f.orchestrator.start('gjc', 'app-1', '/project', 'owned turn', { + ...options, + onPrepared: async () => { + await assert.rejects(f.orchestrator.start('gjc', 'other-app', '/project', 'new callback root', options), { code: 'DESKTOP_RESTART_FENCED' }); + callbackChecked = true; + }, + })); + assert.equal(callbackChecked, true); + f.completed.resolve(); + await handle.completion; + await desktopTick(); + if (prepared.ok) assert.equal((await f.authority.commit(prepared.token, 'desktop-1')).ok, false); +}); + +test('paused worktree creation and resume validation retain root ingress until dispatch', async () => { + for (const mode of ['start', 'resume'] as const) { + const f = desktopFixture(); + const gate = desktopDeferred(); + const reached = desktopDeferred(); + if (mode === 'start') { + f.git.create = async () => { reached.resolve(); await gate.promise; return Git.prototype.create.call(f.git); }; + } else { + f.jobs.state = { ...f.jobs.state, jobId: 'job-abc', state: 'interrupted', worktreeId: '/project/.gjc-worktrees/job-abc', repositoryRoot: '/project', branch: 'job/job-abc' }; + f.git.status = async () => { reached.resolve(); await gate.promise; return Git.prototype.status.call(f.git); }; + } + const pending = mode === 'start' + ? f.orchestrator.start('gjc', 'app-1', '/project', 'start', options) + : f.orchestrator.resume('job-abc', 'app-1', 'resume', options); + await reached.promise; + assert.ok(f.reader.read().starting > 0 && f.reader.read().queued > 0); + assert.equal((await f.authority.snapshot()).ingress, 1); + assert.equal((await f.authority.prepare({ attemptId: `paused-${mode}`, epoch: 'desktop-1' })).ok, false); + assert.equal(f.supervisor.aborted, undefined); + gate.resolve(); + const handle = await pending; + f.completed.resolve(); + await handle.completion; + await desktopTick(); + assert.equal((await f.authority.snapshot()).idle, true); + } +}); + +test('UI completion, worker completion, and registry clearing do not hide pending event persistence or finalization', async () => { + const f = desktopFixture(); + const write = desktopDeferred(); const writing = desktopDeferred(); + const finalize = desktopDeferred(); const finalizing = desktopDeferred(); + f.jobs.appendEvent = async (params) => { writing.resolve(); await write.promise; return Jobs.prototype.appendEvent.call(f.jobs, params); }; + f.jobs.runFinalize = async (params) => { finalizing.resolve(); await finalize.promise; return Jobs.prototype.runFinalize.call(f.jobs, params); }; + const handle = await f.orchestrator.start('gjc', 'app-1', '/project', 'run', options); + f.supervisor.input!.writer.send({ kind: 'complete', exitCode: 0 }); + await writing.promise; + f.completed.resolve(); + await f.orchestrator.interruptForShutdown(); + assert.equal(f.reader.read().running, 0); + assert.ok(f.reader.read().settling > 0 && f.reader.read().queued > 0); + assert.equal((await f.authority.snapshot()).idle, false); + write.resolve(); + await finalizing.promise; + assert.ok(f.reader.read().settling > 0); + assert.equal((await f.authority.prepare({ attemptId: 'finalize-race', epoch: 'desktop-1' })).ok, false); + assert.equal(f.supervisor.aborted, undefined); + finalize.resolve(); + await handle.completion; + await desktopTick(); + assert.equal(f.reader.read().settling, 0); + assert.ok(f.reader.read().unknown.includes('orchestrator_closed')); +}); + +test('registry clearing does not release the actual worker and completion promise lifetime', async () => { + const f = desktopFixture(); + const handle = await f.orchestrator.start('gjc', 'app-1', '/project', 'run', options); + await f.orchestrator.interruptForShutdown(); + assert.equal(f.reader.read().running, 0); + assert.ok(f.reader.read().settling > 0); + assert.equal((await f.authority.prepare({ attemptId: 'cleared-map', epoch: 'desktop-1' })).ok, false); + f.completed.resolve(); + await handle.completion; + await desktopTick(); + assert.equal(f.reader.read().settling, 0); + assert.ok(f.reader.read().unknown.includes('orchestrator_closed')); +}); + +test('late unowned writer callbacks cannot persist through a prepared restart fence', async () => { + const f = desktopFixture(); + const handle = await f.orchestrator.start('gjc', 'app-1', '/project', 'run', options); + f.completed.resolve(); + await handle.completion; + await desktopTick(); + const prepared = await f.authority.prepare({ attemptId: 'late-writer', epoch: 'desktop-1' }); + assert.equal(prepared.ok, true); + const calls = f.jobs.calls.length; + f.supervisor.input!.writer.send({ kind: 'delta', text: 'late' }); + await desktopTick(); + assert.equal(f.jobs.calls.length, calls); + if (prepared.ok) f.authority.cancel(prepared.token); +}); + +test('unconfirmed termination stays unknown even after an abort completion acknowledgement', async () => { + const f = desktopFixture(); + f.supervisor.spawnRun = (input) => ({ started: Promise.resolve(), completion: f.completed.promise, outcome: Promise.resolve('unconfirmed'), abortHandle: input.runId }); + const handle = await f.orchestrator.start('gjc', 'app-1', '/project', 'run', options); + f.completed.resolve(); + await assert.rejects(handle.completion, /unconfirmed/); + assert.equal(await f.orchestrator.abort(handle.jobId), false); + const activity = f.reader.read(); + assert.equal(activity.running, 1); + assert.equal(activity.complete, false); + assert.ok(activity.unknown.includes('orchestrator_settlement_unconfirmed')); +}); + +test('initialization and health recovery remain owned and update cancellation cannot reset health', async () => { + const initializing = desktopDeferred(); + const jobs = new Jobs(); + const orchestrator = new JobOrchestrator({ jobs, git: new Git(), supervisor: new Supervisor(), initialize: () => initializing.promise }); + const reader = createGjcJobOrchestratorDesktopRestartReader(orchestrator); + assert.equal(reader.read().starting, 1); + initializing.resolve(); + await desktopTick(); + assert.equal(reader.read().starting, 0); + const authority = new DesktopRestartAuthority({ requiredOwners: ['orchestrator'], ownerReaders: { orchestrator: reader } }); + orchestrator.configureDesktopAdmission(authority); + await orchestrator.authorityHealth(false); + const before = reader.read(); + assert.equal(before.complete, false); + assert.equal((await authority.prepare({ attemptId: 'unhealthy', epoch: 'desktop-1' })).ok, false); + await assert.rejects(orchestrator.start('gjc', 'app-1', '/project', 'run', options), { code: 'authority_unavailable' }); + const recovery = desktopDeferred(); + jobs.reconcile = async (params) => { await recovery.promise; return Jobs.prototype.reconcile.call(jobs, params); }; + const healthy = orchestrator.authorityHealth(true); + assert.ok(reader.read().settling > 0); + recovery.resolve(); + await healthy; + await desktopTick(); + assert.equal((await authority.snapshot()).idle, true); + orchestrator.markClosed(); + assert.ok(reader.read().unknown.includes('orchestrator_closed')); +}); diff --git a/server/services/gjc-job-orchestrator.ts b/server/services/gjc-job-orchestrator.ts index c18bd391..18f5d44a 100644 --- a/server/services/gjc-job-orchestrator.ts +++ b/server/services/gjc-job-orchestrator.ts @@ -1,14 +1,17 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; import { randomUUID } from 'node:crypto'; import { existsSync } from 'node:fs'; import { mkdir } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { createJobTerminalPayload, isJobProjectionEvent, jobTerminalEventId, type JobProjectionEvent } from '../../shared/gjc-job-projection-protocol.js'; +import type { DesktopOwnerActivity } from '../../shared/desktopUpdateProtocol.js'; import { getGjcWorkerSupervisor, type GjcWorkerAbortOutcome, type GjcWorkerOptions, type GjcWorkerOutcome, type GjcWorkerReapOutcome, type GjcWorkerRun, type GjcWorkerSpawnRun, type GjcWorkerWriter } from '../gjc-worker-client.js'; import { getDatabasePath } from '../modules/database/connection.js'; +import type { DesktopWorkAdmission } from '../shared/interfaces.js'; import { GjcGitClient } from './gjc-git-client.js'; -import { GjcJobsClient, GjcJobsClientError } from './gjc-jobs-client.js'; +import { createNativeJobsDesktopRestartReader, GjcJobsClient, GjcJobsClientError } from './gjc-jobs-client.js'; type Lease = { owner: string; generation: number }; type RunSnapshot = { runId: string; appSessionId?: string | null; providerSessionId?: string | null }; @@ -30,9 +33,18 @@ export type JobOrchestratorOptions = GjcWorkerOptions & { retainWorkspaceOnFailure?: boolean; }; export type JobRunHandle = { jobId: string; runId?: string; state: string; started: Promise; completion: Promise; abortHandle: string }; -export type JobOrchestratorDependencies = { jobs: JobAuthority; git?: GitWorktrees; gitForProject?: (projectRoot: string) => GitWorktrees; supervisor: JobSupervisor; owner?: string; createId?: () => string; broadcast?: (jobId: string, event: JobProjectionEvent) => void; stopCompletionTimeoutMs?: number }; +export type JobOrchestratorDependencies = { jobs: JobAuthority; git?: GitWorktrees; gitForProject?: (projectRoot: string) => GitWorktrees; supervisor: JobSupervisor; owner?: string; createId?: () => string; broadcast?: (jobId: string, event: JobProjectionEvent) => void; stopCompletionTimeoutMs?: number; desktopAdmission?: DesktopWorkAdmission; initialize?: () => Promise }; export class GjcCapacityExhaustedError extends Error { constructor(public readonly jobId: string) { super(`GJC job ${jobId} is waiting for capacity.`); this.name = 'GjcCapacityExhaustedError'; } } +const desktopContinuation = new AsyncLocalStorage<() => boolean>(); +/** Server-only continuation capability: the caller must prove a still-live owner. + * Never expose this through request options. Consumed at one entry, not inherited + * by arbitrary callbacks or new roots dispatched by that operation. */ +export function withOwnedJobDesktopContinuation(ownsWork: () => boolean, action: () => T): T { + if (!ownsWork()) throw new Error('Job continuation no longer has a live owner.'); + return desktopContinuation.run(ownsWork, action); +} + const safe = (value: unknown): value is Record => value !== null && typeof value === 'object' && !Array.isArray(value); function samePayload(left: unknown, right: unknown): boolean { if (Object.is(left, right)) return true; @@ -49,7 +61,7 @@ function lease(value: JobSnapshot): Lease { if (!value.lease || typeof value.lea function worktree(value: unknown): { worktreeId: string; path: string; head?: string } { const item = safe(value) && safe(value.worktree) ? value.worktree : undefined; if (!item || typeof item.worktreeId !== 'string' || typeof item.path !== 'string') throw new Error('Invalid git worktree.create response.'); return item as { worktreeId: string; path: string; head?: string }; } function worktreePath(value: unknown, id: string): string | undefined { const items = Array.isArray(value) ? value : safe(value) && Array.isArray(value.items) ? value.items : []; const item = items.find((candidate) => safe(candidate) && candidate.worktreeId === id && typeof candidate.path === 'string'); return safe(item) && typeof item.path === 'string' ? item.path : undefined; } function sameFence(current: JobSnapshot, runId: string, expected: Lease): boolean { return current.currentRun?.runId === runId && current.lease?.owner === expected.owner && current.lease?.generation === expected.generation; } -type PersistenceScope = { pending: Set>; failure?: unknown }; +type PersistenceScope = { pending: Set>; ownsWork: () => boolean; failure?: unknown }; function failureError(error: unknown): Error { return error instanceof Error ? new Error(error.message) : new Error('Worker failed.'); } function confirmedReap(outcome: GjcWorkerReapOutcome | undefined): boolean { return outcome === 'not_started' || outcome === 'reaped'; @@ -85,16 +97,81 @@ async function terminalCompletion(run: GjcWorkerRun, timeoutMs: number): Promise /** Durable v5 facade: Job is a bound workspace; every dispatch creates one fenced Run. */ export class JobOrchestrator { private readonly owner: string; private readonly createId: () => string; private readonly stopCompletionTimeoutMs: number; - private readonly queues = new Map>(); private readonly activeRuns = new Map(); + private readonly queues = new Map>(); private readonly activeRuns = new Map(); + private readonly activityEpoch = randomUUID(); + private activityRevision = 0n; + private readonly activityTasks = { starting: 0, settling: 0 }; + private desktopAdmission?: DesktopWorkAdmission; + private closed = false; + private initializationFailed = false; private admissionBlocked = false; private healthChain: Promise = Promise.resolve(); constructor(private readonly deps: JobOrchestratorDependencies) { this.owner = deps.owner ?? `orchestrator-${randomUUID()}`; this.createId = deps.createId ?? randomUUID; this.stopCompletionTimeoutMs = deps.stopCompletionTimeoutMs ?? STOP_COMPLETION_TIMEOUT_MS; + this.desktopAdmission = deps.desktopAdmission; + if (deps.initialize) void this.admitted('orchestrator:initialize', 'starting', deps.initialize).catch(() => { + this.initializationFailed = true; + this.activityChanged(); + }); + } + getGeneration(): string { return `${this.activityEpoch}:${this.activityRevision}`; } + snapshotActivity(): DesktopOwnerActivity { + const unknown: string[] = []; + if (this.admissionBlocked) unknown.push('orchestrator_authority_unavailable'); + if (this.initializationFailed) unknown.push('orchestrator_initialization_failed'); + if (this.closed) unknown.push('orchestrator_closed'); + const uncertain = [...this.activeRuns.values()].filter((run) => run.uncertain).length; + if (uncertain) unknown.push('orchestrator_settlement_unconfirmed'); + return { + owner: 'orchestrator', generation: this.getGeneration(), complete: unknown.length === 0, + starting: this.activityTasks.starting, queued: this.queues.size, running: this.activeRuns.size, + settling: this.activityTasks.settling, approvals: 0, retained: uncertain, unknown, + }; + } + configureDesktopAdmission(admission?: DesktopWorkAdmission): void { + this.desktopAdmission = admission; + this.activityChanged(); + } + /** Retirement is not a proof of idle; readers of an old instance fail closed. */ + markClosed(): void { this.closed = true; this.activityChanged(); } + private activityChanged(): void { this.activityRevision += 1n; } + private async activity(kind: keyof JobOrchestrator['activityTasks'], action: () => Promise): Promise { + this.activityTasks[kind] += 1; + this.activityChanged(); + try { return await action(); } + finally { this.activityTasks[kind] -= 1; this.activityChanged(); } } + private async admitted(source: string, kind: keyof JobOrchestrator['activityTasks'], action: () => Promise, completion = false): Promise { + const owned = completion || desktopContinuation.getStore()?.() === true; + const release = owned ? this.desktopAdmission?.enterCompletion(source) : this.desktopAdmission?.enter(source); + try { return await this.activity(kind, () => desktopContinuation.exit(action)); } + finally { release?.(); } + } + private forgetRun(jobId: string): void { + if (this.activeRuns.delete(jobId)) this.activityChanged(); + } + private uncertainRun(jobId: string, runId: string): void { + const active = this.activeRuns.get(jobId); + if (active?.runId === runId && !active.uncertain) { active.uncertain = true; this.activityChanged(); } + } + private clearRuns(): void { if (this.activeRuns.size) { this.activeRuns.clear(); this.activityChanged(); } } private git(root: string): GitWorktrees { const client = this.deps.gitForProject?.(root) ?? this.deps.git; if (!client) throw new Error('GJC Git worktree client is unavailable.'); return client; } - private serial(jobId: string, action: () => Promise): Promise { const prior = this.queues.get(jobId) ?? Promise.resolve(); const result = prior.catch(() => undefined).then(action); const tail = result.catch(() => undefined).finally(() => { if (this.queues.get(jobId) === tail) this.queues.delete(jobId); }); this.queues.set(jobId, tail); return result; } + private serial(jobId: string, action: () => Promise): Promise { + const prior = this.queues.get(jobId) ?? Promise.resolve(); + const result = prior.catch(() => undefined).then(() => { + this.activityChanged(); + return action(); + }); + const tail = result.catch(() => undefined).finally(() => { + if (this.queues.get(jobId) === tail) this.queues.delete(jobId); + this.activityChanged(); + }); + this.queues.set(jobId, tail); + this.activityChanged(); + return result; + } private params(jobId: string, current: JobSnapshot): Record { return { jobId, lease: lease(current) }; } private async mutate(jobId: string, action: () => Promise, confirmed: (value: JobSnapshot) => boolean): Promise { try { return snapshot(await action()); } catch (error) { const fresh = snapshot(await this.deps.jobs.get({ jobId })); if (confirmed(fresh)) return fresh; throw error; } } private publish(jobId: string, event: JobProjectionEvent, writer?: GjcWorkerWriter): void { @@ -114,8 +191,9 @@ export class JobOrchestrator { return result; } private trackPersistence(scope: PersistenceScope, action: () => Promise): void { - const pending = action().catch((error) => { scope.failure ??= error; }).finally(() => { scope.pending.delete(pending); }); + const pending = this.admitted('orchestrator:persistence', 'settling', action, scope.ownsWork()).catch((error) => { scope.failure ??= error; this.activityChanged(); }).finally(() => { scope.pending.delete(pending); this.activityChanged(); }); scope.pending.add(pending); + this.activityChanged(); } private async drainPersistence(scope: PersistenceScope): Promise { while (scope.pending.size) await Promise.all([...scope.pending]); @@ -151,7 +229,7 @@ export class JobOrchestrator { }; } private completion(jobId: string, runId: string, expected: Lease, run: GjcWorkerRun, scope: PersistenceScope, signal?: AbortSignal): Promise { - return run.completion.then( + return this.activity('settling', () => run.completion.then( async () => { await this.drainPersistence(scope); const outcome = await run.outcome; @@ -162,7 +240,7 @@ export class JobOrchestrator { const aborted = outcome === 'aborted' || signal?.aborted; if (!aborted && (outcome === 'not_started' || outcome === 'reaped')) scope.failure ??= new Error('Worker stopped without completing the turn.'); await this.finalize(jobId, fresh, runId, scope.failure ? 'failed' : aborted ? 'aborted' : 'succeeded', scope.failure ? failureError(scope.failure).message : aborted ? 'aborted' : 'completed'); - this.activeRuns.delete(jobId); + this.forgetRun(jobId); if (scope.failure) throw failureError(scope.failure); }); }, @@ -173,11 +251,11 @@ export class JobOrchestrator { const fresh = snapshot(await this.deps.jobs.get({ jobId })); if (!sameFence(fresh, runId, expected)) return; await this.finalize(jobId, fresh, runId, signal?.aborted ? 'aborted' : 'failed', failureError(scope.failure ?? error).message); - this.activeRuns.delete(jobId); + this.forgetRun(jobId); }); throw failureError(error); }, - ); + )).catch((error) => { this.uncertainRun(jobId, runId); throw error; }); } private async failRun(jobId: string, runId: string, expected: Lease, run: GjcWorkerRun | undefined, error: unknown, retainWorkspace = false): Promise { const outcome = await settledOutcome(run); @@ -187,24 +265,27 @@ export class JobOrchestrator { if (retainWorkspace && fresh.worktreeId && fresh.repositoryRoot) await this.finalize(jobId, fresh, runId, 'failed', failureError(error).message); else await this.cancelAdmission(jobId, fresh, error, runId); } - this.activeRuns.delete(jobId); + this.forgetRun(jobId); return; } if (outcome === 'reaped' || outcome === 'completed') { const fresh = snapshot(await this.deps.jobs.get({ jobId })); if (sameFence(fresh, runId, expected)) await this.finalize(jobId, fresh, runId, 'failed', failureError(error).message); - this.activeRuns.delete(jobId); + this.forgetRun(jobId); return; } - if (!run || !await this.stopRun(run)) return; + if (!run || !await this.stopRun(run)) { this.uncertainRun(jobId, runId); return; } const fresh = snapshot(await this.deps.jobs.get({ jobId })); if (sameFence(fresh, runId, expected)) await this.finalize(jobId, fresh, runId, 'failed', failureError(error).message); - this.activeRuns.delete(jobId); + this.forgetRun(jobId); } private async stopRun(run: GjcWorkerRun): Promise { const aborted = await this.deps.supervisor.abort(run.abortHandle).catch((): GjcWorkerAbortOutcome => 'unconfirmed'); if (aborted === 'not_started') return true; - if (await terminalCompletion(run, this.stopCompletionTimeoutMs)) return true; + if (await terminalCompletion(run, this.stopCompletionTimeoutMs)) { + const outcome = await settledOutcome(run); + if (!run.outcome || (outcome !== undefined && outcome !== 'unconfirmed')) return true; + } return confirmedReap(await this.deps.supervisor.terminate?.(run.abortHandle).catch((): GjcWorkerReapOutcome => 'unconfirmed')); } private async cancelAdmission(jobId: string, current: JobSnapshot, error: unknown, runId?: string): Promise { @@ -247,11 +328,12 @@ export class JobOrchestrator { let expected: Lease | undefined; const { signal, onPrepared: _prepared, onRun, writer: _writer, retainWorkspaceOnFailure, ...workerOptions } = options; let unsubscribe = () => {}; + let dispatching = true; const admissionLease = lease(current); const cancelled = async (): Promise => { const fresh = snapshot(await this.deps.jobs.get({ jobId })); if (sameFence(fresh, runId, admissionLease)) await this.finalize(jobId, fresh, runId, 'aborted', 'aborted before dispatch'); - this.activeRuns.delete(jobId); + this.forgetRun(jobId); return { jobId, runId, state: 'ready', started: Promise.resolve(), completion: Promise.resolve(), abortHandle: runId }; }; try { @@ -259,11 +341,17 @@ export class JobOrchestrator { current = await this.mutate(jobId, () => this.deps.jobs.markDispatching({ ...this.params(jobId, current), runId }), (fresh) => Boolean(fresh.dispatchCheckpoint)); expected = lease(current); if (signal?.aborted) return await cancelled(); - const scope: PersistenceScope = { pending: new Set() }; + const scope: PersistenceScope = { pending: new Set(), ownsWork: () => dispatching || this.activeRuns.get(jobId)?.runId === runId }; run = this.deps.supervisor.spawnRun({ runId, appSessionId, message, options: { ...workerOptions, cwd, sessionId, notificationOwner: 'terminal-adapter' }, writer: this.writer(jobId, current, runId, options.writer, scope) }); this.activeRuns.set(jobId, { runId, lease: expected, abortHandle: run.abortHandle, run }); + this.activityChanged(); const ownedRun = run; - const abort = () => { void this.stopRun(ownedRun); }; + // The worker promise can outlive startup failure or registry clearing. + // Keep its actual lifetime independently of the durable active-run map. + void this.activity('settling', async () => { + await Promise.all([ownedRun.completion.catch(() => undefined), ownedRun.outcome?.catch(() => 'unconfirmed')]); + }); + const abort = () => { void this.activity('settling', () => this.stopRun(ownedRun)).catch(() => this.uncertainRun(jobId, runId)); }; signal?.addEventListener('abort', abort, { once: true }); unsubscribe = () => signal?.removeEventListener('abort', abort); onRun?.(run); @@ -284,10 +372,15 @@ export class JobOrchestrator { if (signal?.aborted && expected && (!run || await this.stopRun(run))) return cancelled(); if (expected) await this.failRun(jobId, runId, expected, run, error, retainWorkspaceOnFailure); throw error; + } finally { + dispatching = false; } } - private ensureAdmission(): void { if (this.admissionBlocked) throw new GjcJobsClientError('GJC job authority is unavailable.', 'authority_unavailable'); } + private ensureAdmission(): void { if (this.admissionBlocked || this.closed) throw new GjcJobsClientError('GJC job authority is unavailable.', 'authority_unavailable'); } async start(provider: 'gjc', appSessionId: string, projectRoot: string, message: string, options: JobOrchestratorOptions): Promise { + return this.admitted('orchestrator:start', 'starting', () => this.startOwned(provider, appSessionId, projectRoot, message, options)); + } + private async startOwned(provider: 'gjc', appSessionId: string, projectRoot: string, message: string, options: JobOrchestratorOptions): Promise { if (provider !== 'gjc' || !appSessionId) throw new Error('GJC provider and app session are required.'); options.signal?.throwIfAborted(); this.ensureAdmission(); @@ -324,6 +417,9 @@ export class JobOrchestrator { }); } async turnStart(provider: 'gjc', appSessionId: string, message: string, options: JobOrchestratorOptions): Promise { + return this.admitted('orchestrator:turn-start', 'starting', () => this.turnStartOwned(provider, appSessionId, message, options)); + } + private async turnStartOwned(provider: 'gjc', appSessionId: string, message: string, options: JobOrchestratorOptions): Promise { if (provider !== 'gjc' || !appSessionId) throw new Error('GJC provider and app session are required.'); options.signal?.throwIfAborted(); this.ensureAdmission(); @@ -356,6 +452,9 @@ export class JobOrchestrator { }); } async resume(jobId: string, appSessionId: string, message: string, options: JobOrchestratorOptions): Promise { + return this.admitted('orchestrator:resume', 'starting', () => this.resumeOwned(jobId, appSessionId, message, options)); + } + private async resumeOwned(jobId: string, appSessionId: string, message: string, options: JobOrchestratorOptions): Promise { if (!appSessionId) throw new Error('GJC app session is required.'); options.signal?.throwIfAborted(); this.ensureAdmission(); @@ -385,13 +484,16 @@ export class JobOrchestrator { }); } async appendAdminEvent(jobId: string, eventId: string, payload: unknown): Promise { - await this.serial(jobId, async () => { + await this.admitted('orchestrator:admin-event', 'settling', () => this.serial(jobId, async () => { const event = await this.deps.jobs.appendAdminEvent({ jobId, eventId, payload }); if (!isJobProjectionEvent(event)) throw new Error('Invalid committed job event response.'); this.publish(jobId, event); - }); + })); } async abort(target: { jobId?: string; appSessionId?: string; provider?: string } | string): Promise { + return this.admitted('orchestrator:abort', 'settling', () => this.abortOwned(target), true); + } + private async abortOwned(target: { jobId?: string; appSessionId?: string; provider?: string } | string): Promise { const jobId = typeof target === 'string' ? target : target.jobId ?? binding(await this.deps.jobs.bindingResolve({ provider: target.provider, appSessionId: target.appSessionId })).jobId; return this.serial(jobId, async () => { let current = snapshot(await this.deps.jobs.get({ jobId })); @@ -403,12 +505,20 @@ export class JobOrchestrator { if (!stopped) return false; const fresh = snapshot(await this.deps.jobs.get({ jobId })); if (sameFence(fresh, active.runId, active.lease)) await this.finalize(jobId, fresh, active.runId, 'aborted', 'aborted'); - this.activeRuns.delete(jobId); + this.forgetRun(jobId); return true; }); } - async interruptForShutdown(): Promise { const result = await this.deps.jobs.interruptForShutdown(); this.activeRuns.clear(); return result; } + async interruptForShutdown(): Promise { + // Irreversible shutdown may clear registry entries without worker proof. + // Never turn that administrative clearing into a reusable idle reader. + this.markClosed(); + return this.activity('settling', async () => { const result = await this.deps.jobs.interruptForShutdown(); this.clearRuns(); return result; }); + } async resolveBinding(provider: string, appSessionId: string): Promise { + return this.admitted('orchestrator:binding', 'starting', () => this.resolveBindingOwned(provider, appSessionId)); + } + private async resolveBindingOwned(provider: string, appSessionId: string): Promise { try { return binding(await this.deps.jobs.bindingResolve({ provider, appSessionId })); } catch (error) { @@ -416,19 +526,25 @@ export class JobOrchestrator { throw error; } } - reconcile(): Promise { return this.deps.jobs.reconcile({}); } + reconcile(): Promise { return this.admitted('orchestrator:reconcile', 'settling', () => this.deps.jobs.reconcile({})); } authorityHealth(healthy: boolean): Promise { + if (!healthy) { this.admissionBlocked = true; this.activityChanged(); } + return this.admitted('orchestrator:authority-health', 'settling', () => this.authorityHealthOwned(healthy), true); + } + private authorityHealthOwned(healthy: boolean): Promise { const transition = async (): Promise => { if (!healthy) { this.admissionBlocked = true; + this.activityChanged(); const runs = [...this.activeRuns.values()]; const stopped = await Promise.all(runs.map((run) => this.stopRun(run.run))); - if (stopped.every(Boolean)) this.activeRuns.clear(); + if (stopped.every(Boolean)) this.clearRuns(); return; } if (this.activeRuns.size) throw new GjcJobsClientError('GJC worker reaping is unconfirmed.', 'authority_unavailable'); await this.deps.jobs.reconcile({}); this.admissionBlocked = false; + this.activityChanged(); }; const result = this.healthChain.catch(() => undefined).then(transition); this.healthChain = result.catch(() => undefined); @@ -436,7 +552,24 @@ export class JobOrchestrator { } } type ProductionOrchestrator = JobOrchestrator & { close(): void }; let production: ProductionOrchestrator | undefined; let productionAuthority: GjcJobsClient | undefined; +let productionDesktopAdmission: DesktopWorkAdmission | undefined; +export function configureGjcJobOrchestratorDesktopAdmission(admission?: DesktopWorkAdmission): void { + productionDesktopAdmission = admission; + production?.configureDesktopAdmission(admission); +} +/** Captures the owner once; reads never instantiate clients or query native jobs. + * Native durable jobs (including archived jobs) require their own parent reader. */ +export function createGjcJobOrchestratorDesktopRestartReader(orchestrator: JobOrchestrator = getProductionJobOrchestrator()): { + getGeneration(): string; read(): DesktopOwnerActivity; +} { + return Object.freeze({ getGeneration: () => orchestrator.getGeneration(), read: () => orchestrator.snapshotActivity() }); +} export function getProductionJobAuthority(): JobAuthority { getProductionJobOrchestrator(); if (!productionAuthority) throw new Error('GJC job authority is unavailable.'); return productionAuthority; } +export function getProductionNativeJobsDesktopRestartReader(): ReturnType { + getProductionJobOrchestrator(); + if (!productionAuthority) throw new Error('GJC job authority is unavailable.'); + return createNativeJobsDesktopRestartReader(productionAuthority); +} export function getProductionJobOrchestrator(): ProductionOrchestrator { if (production) return production; const database = join(dirname(getDatabasePath()), 'jobs.sqlite3'); @@ -450,9 +583,8 @@ export function getProductionJobOrchestrator(): ProductionOrchestrator { if (!client) { client = new GjcGitClient({ workdir: projectRoot }); clients.set(projectRoot, client); } return client; }; - const orchestrator = new JobOrchestrator({ jobs, gitForProject, supervisor: getGjcWorkerSupervisor() }) as ProductionOrchestrator; - orchestrator.close = () => { jobs.close(); productionAuthority = undefined; for (const client of clients.values()) client.close(); clients.clear(); production = undefined; }; - void mkdir(dirname(database), { recursive: true }).catch(() => {}); + const orchestrator = new JobOrchestrator({ jobs, gitForProject, supervisor: getGjcWorkerSupervisor(), desktopAdmission: productionDesktopAdmission, initialize: () => mkdir(dirname(database), { recursive: true }) }) as ProductionOrchestrator; + orchestrator.close = () => { orchestrator.markClosed(); jobs.close(); productionAuthority = undefined; for (const client of clients.values()) client.close(); clients.clear(); production = undefined; }; production = orchestrator; return orchestrator; } diff --git a/server/services/gjc-jobs-client.test.ts b/server/services/gjc-jobs-client.test.ts index 44027347..b406883d 100644 --- a/server/services/gjc-jobs-client.test.ts +++ b/server/services/gjc-jobs-client.test.ts @@ -2,8 +2,8 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { EventEmitter } from 'node:events'; -import { GjcJobsClient, GjcJobsClientError, GjcJobsEventTooLargeError } from './gjc-jobs-client.js'; -import type { GjcNativeSpawn } from './gjc-git-client.js'; +import { GjcJobsClient, GjcJobsClientError, GjcJobsEventTooLargeError, createNativeJobsDesktopRestartReader } from './gjc-jobs-client.js'; +import { NativeActivityGroup, type GjcNativeSpawn } from './gjc-git-client.js'; class FakeChild extends EventEmitter { readonly stdout = new EventEmitter(); @@ -15,6 +15,36 @@ class FakeChild extends EventEmitter { function fake(children: FakeChild[]): GjcNativeSpawn { return ((_command, _args, _options) => { const child = new FakeChild(); children.push(child); return child; }) as GjcNativeSpawn; } const idAt = (child: FakeChild, position: number) => JSON.parse(child.stdin.writes[position]!).id as string; +test('native jobs activity never starts a process and does not invalidate its own generation', async () => { + const children: FakeChild[] = []; const group = new NativeActivityGroup(); + const client = new GjcJobsClient({ database: '/fixture.sqlite', spawn: fake(children), activityGroup: group }); + const reader = createNativeJobsDesktopRestartReader(client); + await assert.rejects(reader.read()); assert.equal(children.length, 0); + const started = client.start(); const child = children[0]!; + child.frame({ protocolVersion: 1, id: idAt(child, 0), ok: true, result: [] }); await started; + const generation = reader.getGeneration(); const observed = reader.read(); + assert.equal(JSON.parse(child.stdin.writes.at(-1)!).method, 'job.activity'); + assert.equal(group.read().running, 0, 'readonly status is not new work'); + child.frame({ protocolVersion: 1, id: idAt(child, 1), ok: true, result: { schemaVersion: 1, reserved: 0, queued: 2, running: 1, aborting: 0, unknown: 0 } }); + const result = await observed; + assert.equal(result.generation, generation); assert.equal(reader.getGeneration(), generation); + assert.equal(result.queued, 2); assert.equal(result.running, 1); assert.equal(result.complete, true); + client.close(); child.emit('close'); assert.equal(group.read().settling, 0); +}); + +test('native observation rejects malformed and unknown state without certifying idle', async () => { + const children: FakeChild[] = []; const client = new GjcJobsClient({ database: '/fixture.sqlite', spawn: fake(children), activityGroup: new NativeActivityGroup() }); + const starting = client.start(); const child = children[0]!; + child.frame({ protocolVersion: 1, id: idAt(child, 0), ok: true, result: [] }); await starting; + const bad = client.snapshotDesktopActivity(); + child.frame({ protocolVersion: 1, id: idAt(child, 1), ok: true, result: { schemaVersion: 1, reserved: -1, queued: 0, running: 0, aborting: 0, unknown: 0 } }); + await assert.rejects(bad); + const uncertain = client.snapshotDesktopActivity(); + child.frame({ protocolVersion: 1, id: idAt(child, 2), ok: true, result: { schemaVersion: 1, reserved: 0, queued: 0, running: 0, aborting: 0, unknown: 1 } }); + assert.equal((await uncertain).complete, false); + client.close(); child.emit('close'); +}); + test('jobs proves readiness through job.list probe and dispatches wrappers', async () => { const children: FakeChild[] = []; const client = new GjcJobsClient({ database: '/jobs.sqlite', spawn: fake(children) }); const pending = client.admit({ id: 'run' }); const child = children[0]!; diff --git a/server/services/gjc-jobs-client.ts b/server/services/gjc-jobs-client.ts index 2fb870d9..09e815af 100644 --- a/server/services/gjc-jobs-client.ts +++ b/server/services/gjc-jobs-client.ts @@ -1,3 +1,5 @@ +import type { DesktopOwnerActivity } from '../../shared/desktopUpdateProtocol.js'; + import { GjcNativeClient, GjcNativeRequestError, type GjcNativeClientOptions } from './gjc-git-client.js'; type GjcArchivedFilter = 'exclude' | 'include' | 'only'; @@ -71,4 +73,24 @@ export class GjcJobsClient extends GjcNativeClient { bindingResolve(params: Record): Promise { return this.request('binding.resolve', params); } bindingRelease(params: Record): Promise { return this.request('binding.release', params); } interruptForShutdown(): Promise { return this.request('job.interruptForShutdown'); } + + async snapshotDesktopActivity(): Promise { + const generation = this.getActivityGeneration(); + const value = await this.observeActivity(); + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Invalid native jobs activity.'); + const result = value as Record; + const fields = ['reserved', 'queued', 'running', 'aborting', 'unknown'] as const; + if (Object.keys(result).length !== 6 || result.schemaVersion !== 1 + || fields.some((key) => !Number.isSafeInteger(result[key]) || Number(result[key]) < 0)) throw new Error('Invalid native jobs activity.'); + const own = this.activity(); + const unknown = [...own.unknown, ...(result.unknown !== 0 ? ['native_jobs_state_unknown'] : []), + ...(generation !== this.getActivityGeneration() ? ['native_jobs_changed'] : [])]; + return { owner: 'native-jobs', generation, complete: unknown.length === 0, + starting: Number(result.reserved), queued: Number(result.queued), running: Number(result.running), settling: Number(result.aborting), + approvals: 0, retained: 0, unknown }; + } +} + +export function createNativeJobsDesktopRestartReader(jobs: GjcJobsClient) { + return { getGeneration: () => jobs.getActivityGeneration(), read: () => jobs.snapshotDesktopActivity() }; } diff --git a/server/services/project-file-transfer.test.ts b/server/services/project-file-transfer.test.ts new file mode 100644 index 00000000..961c2707 --- /dev/null +++ b/server/services/project-file-transfer.test.ts @@ -0,0 +1,125 @@ +import assert from 'node:assert/strict'; +import { EventEmitter, once } from 'node:events'; +import fs from 'node:fs'; +import { mkdtemp, readFile, rm, stat } from 'node:fs/promises'; +import http from 'node:http'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { PassThrough, Readable, Writable } from 'node:stream'; +import test from 'node:test'; + +import express, { type Request } from 'express'; + +import { asyncHandler, getHttpActivityGeneration, snapshotHttpActivity } from '../shared/utils.js'; + +import { createProjectUploadStorage, streamProjectFile } from './project-file-transfer.js'; + +function deferred() { + let resolve!: () => void; + const promise = new Promise((done) => { resolve = done; }); + return { promise, resolve }; +} +const tick = () => new Promise((resolve) => setImmediate(resolve)); +function request(): Request { + return Object.assign(new EventEmitter(), { aborted: false }) as unknown as Request; +} +function file(stream: Readable): Express.Multer.File { + return { stream, originalname: 'fixture.txt', mimetype: 'text/plain' } as Express.Multer.File; +} + +test('project upload reports success only after an exclusive private file is closed', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'gajae-upload-owner-')); + try { + const owned = createProjectUploadStorage(root); + const input = file(Readable.from(['fixture'])); + let closed = false; + const info = await new Promise>((resolve, reject) => { + owned.storage._handleFile(request(), input, (error, value) => { + closed = input.stream.closed; + if (error) reject(error); else resolve(value!); + }); + }); + await owned.settle(); + assert.equal(closed, true); + assert.equal(await readFile(info.path!, 'utf8'), 'fixture'); + assert.equal((await stat(info.path!)).mode & 0o777, 0o600); + assert.equal(info.size, 7); + await new Promise((resolve, reject) => owned.storage._removeFile(request(), { ...input, ...info }, (error) => error ? reject(error) : resolve())); + await owned.settle(); + await assert.rejects(stat(info.path!), { code: 'ENOENT' }); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +test('aborted upload retains ownership through a delayed writer close and late removal', async () => { + const writeStarted = deferred(); + const closing = deferred(); + const releaseClose = deferred(); + const output = new Writable({ + write(_chunk, _encoding, done) { writeStarted.resolve(); done(); }, + destroy(error, done) { closing.resolve(); void releaseClose.promise.then(() => done(error)); }, + }); + const owned = createProjectUploadStorage('/unused-private-staging', (() => output) as unknown as typeof fs.createWriteStream); + const req = request(); + const input = file(new PassThrough()); + let callbackCount = 0; + let removed = false; + owned.storage._handleFile(req, input, (error) => { + callbackCount++; + assert.ok(error); + owned.storage._removeFile(req, input, () => { removed = true; }); + }); + input.stream.push('partial'); + await writeStarted.promise; + Object.assign(req, { aborted: true }); req.emit('aborted'); + await closing.promise; + let settled = false; + const settledPromise = owned.settle().then(() => { settled = true; }); + await tick(); + assert.equal(settled, false); + assert.equal(callbackCount, 0); + assert.equal(output.closed, false); + releaseClose.resolve(); + await settledPromise; + assert.equal(output.closed, true); + assert.equal(callbackCount, 1); + assert.equal(removed, true); + assert.equal(req.listenerCount('aborted'), 0); +}); + +test('a pre-aborted upload never starts a disk write', async () => { + const req = request(); Object.assign(req, { aborted: true }); + let writes = 0; + const owned = createProjectUploadStorage('/unused-private-staging', (() => { writes++; throw new Error('unexpected open'); }) as typeof fs.createWriteStream); + const result = new Promise((resolve) => owned.storage._handleFile(req, file(Readable.from(['x'])), resolve)); + await owned.settle(); + assert.equal((await result)?.message, 'Request aborted'); + assert.equal(writes, 0); + assert.equal(req.listenerCount('aborted'), 0); +}); + +test('response completion does not hide a still-closing project source from the HTTP owner', async (t) => { + const closing = deferred(); + const releaseClose = deferred(); + const initial = snapshotHttpActivity(); + const source = new Readable({ + read() { this.push('fixture'); this.push(null); }, + destroy(error, done) { closing.resolve(); void releaseClose.promise.then(() => done(error)); }, + }); + const app = express(); + app.get('/file', asyncHandler(async (_req, res) => { await streamProjectFile(source, res); })); + const server = http.createServer(app).listen(0, '127.0.0.1'); + await once(server, 'listening'); + t.after(async () => { + releaseClose.resolve(); + await new Promise((resolve) => server.close(() => resolve())); + }); + const address = server.address(); assert.ok(address && typeof address !== 'string'); + const response = await fetch(`http://127.0.0.1:${address.port}/file`); + assert.equal(await response.text(), 'fixture'); + await closing.promise; + assert.equal(snapshotHttpActivity().running, initial.running + 1); + const beforeClose = getHttpActivityGeneration(); + releaseClose.resolve(); await once(source, 'close'); await tick(); + assert.equal(snapshotHttpActivity().running, initial.running); + assert.notEqual(getHttpActivityGeneration(), beforeClose); +}); diff --git a/server/services/project-file-transfer.ts b/server/services/project-file-transfer.ts new file mode 100644 index 00000000..ebc43b61 --- /dev/null +++ b/server/services/project-file-transfer.ts @@ -0,0 +1,97 @@ +import { randomUUID } from 'node:crypto'; +import fs from 'node:fs'; +import { unlink } from 'node:fs/promises'; +import path from 'node:path'; +import type { Readable } from 'node:stream'; +import { finished, pipeline } from 'node:stream/promises'; + +import type { Response } from 'express'; +import type { StorageEngine } from 'multer'; + +/** The handler owns both the source descriptor and the response through close. */ +export async function streamProjectFile(source: Readable, response: Response): Promise { + const sourceClosed = new Promise((resolve) => source.once('close', resolve)); + const stopSource = () => { source.destroy(); }; + const reportError = (error: Error) => { + if (response.destroyed) return; + if (!response.headersSent) response.status(500).json({ error: 'Error reading file' }); + else response.destroy(error); + }; + source.on('error', reportError); + response.once('close', stopSource); + const responseDone = finished(response, { cleanup: true }).catch(stopSource); + try { + if (response.destroyed) stopSource(); + else source.pipe(response); + await Promise.all([sourceClosed, responseDone]); + } catch (error) { + response.destroy(error instanceof Error ? error : new Error('File stream failed.')); + throw error; + } finally { + stopSource(); + await Promise.all([sourceClosed, responseDone]); + source.off('error', reportError); + response.off('close', stopSource); + } +} + +/** + * A request-private staging directory has one cleanup owner. Multer's parser + * callback can precede a disk writer's close on abort; settle() joins the real + * pipelines and any removal callbacks before that directory may be removed. + */ +export function createProjectUploadStorage(destination: string, createOutput = fs.createWriteStream): { + storage: StorageEngine; + settle(): Promise; +} { + const operations: Promise[] = []; + const writes = new Map>>(); + const created = new Set(); + const storage: StorageEngine = { + _handleFile(request, file, done) { + const filename = `upload-${randomUUID()}`; + const target = path.join(destination, filename); + file.path = target; + const controller = new AbortController(); + const abort = () => controller.abort(); + request.once('aborted', abort); + const write = Promise.resolve().then(async () => { + if (request.aborted || file.stream.destroyed) throw new Error('Request aborted'); + const output = createOutput(target, { flags: 'wx', mode: 0o600 }); + output.once('open', () => created.add(target)); + await pipeline(file.stream, output, { signal: controller.signal }); + return { destination, filename, path: target, size: output.bytesWritten }; + }).finally(() => request.off('aborted', abort)); + writes.set(target, write); + operations.push(write.then((info) => done(null, info), (error: Error) => done(error))); + }, + _removeFile(_request, file, done) { + const remove = async () => { + await writes.get(file.path)?.catch(() => {}); + // A failed exclusive open never grants ownership of an existing file. + if (created.has(file.path)) { + await unlink(file.path).catch((error: NodeJS.ErrnoException) => { + if (error.code !== 'ENOENT') throw error; + }); + created.delete(file.path); + } + }; + operations.push(remove().then(() => done(null), (error: Error) => done(error))); + }, + }; + return { + storage, + async settle() { + const failures: unknown[] = []; + // A storage completion can schedule another removal callback. + for (let settled = 0; settled < operations.length;) { + const batch = operations.slice(settled); + settled += batch.length; + for (const result of await Promise.allSettled(batch)) { + if (result.status === 'rejected') failures.push(result.reason); + } + } + if (failures.length) throw new AggregateError(failures, 'Upload callback cleanup failed.'); + }, + }; +} diff --git a/server/services/server-listener.test.ts b/server/services/server-listener.test.ts new file mode 100644 index 00000000..3a9ec6a0 --- /dev/null +++ b/server/services/server-listener.test.ts @@ -0,0 +1,112 @@ +import assert from 'node:assert/strict'; +import { once } from 'node:events'; +import { readFileSync } from 'node:fs'; +import { lstat, mkdtemp, rm } from 'node:fs/promises'; +import http from 'node:http'; +import net from 'node:net'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { runInNewContext } from 'node:vm'; + +import ts from 'typescript'; +import { WebSocketServer } from 'ws'; + +import { listenForStartup } from './server-listener.js'; + +async function close(server: net.Server) { + await new Promise(resolve => server.close(() => resolve())); +} + +test('HTTP bind failure forwarded by ws rejects without an unhandled WebSocketServer error', async () => { + const owner = http.createServer(); + owner.listen(0, '127.0.0.1'); await once(owner, 'listening'); + const server = http.createServer(); const sockets = new WebSocketServer({ server }); + try { + await assert.rejects(listenForStartup(server, sockets, (owner.address() as net.AddressInfo).port, '127.0.0.1', async () => { + throw new Error('readiness must not run after failed bind'); + }), { code: 'EADDRINUSE' }); + assert.equal(owner.listening, true); + assert.equal(sockets.listenerCount('error'), 0); + } finally { sockets.close(); await close(server); await close(owner); } +}); + +test('listening alone does not finish initialization; readiness failure remains observable', async () => { + const server = http.createServer(); const sockets = new WebSocketServer({ server }); + let release!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + let done = false; + const started = listenForStartup(server, sockets, 0, '127.0.0.1', async () => { await gate; }).then(() => { done = true; }); + try { + await once(server, 'listening'); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(done, false); + release(); await started; + assert.equal(done, true); + } finally { release(); sockets.close(); await close(server); } + const second = http.createServer(); const secondSockets = new WebSocketServer({ server: second }); + try { await assert.rejects(listenForStartup(second, secondSockets, 0, '127.0.0.1', async () => { throw new Error('ready failed'); }), /ready failed/); } + finally { secondSockets.close(); await close(second); } +}); + +test('the actual index startup failure joins cleanup and removes only its owned Unix socket', { skip: process.platform === 'win32' }, async () => { + const directory = await mkdtemp(path.join(tmpdir(), 'gjc-listen-')); + const socketPath = path.join(directory, 'a.sock'); + const owner = http.createServer(); owner.listen(0, '127.0.0.1'); await once(owner, 'listening'); + const server = http.createServer(); const wss = new WebSocketServer({ server }); + const automation = net.createServer(); + let exited!: (code: number) => void; + const completion = new Promise(resolve => { exited = resolve; }); + let markerWrites = 0; let automationStopped = false; + const source = ts.createSourceFile('index.js', readFileSync(new URL('../index.js', import.meta.url), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.JS); + const startup = source.statements.find((node): node is ts.FunctionDeclaration => ts.isFunctionDeclaration(node) && node.name?.text === 'startServer'); + assert.ok(startup); + const identity = (value: unknown) => value; + const context = { + enterInternalActivity: () => () => {}, markInternalActivityUncertain() {}, + desktopRestartAdmission: { state: 'open' }, + initializeDatabase: async () => {}, + automationService: { + startBridge: async () => { automation.listen(socketPath); await once(automation, 'listening'); }, + shutdown: async () => { await close(automation); automationStopped = true; }, + }, + gjcJobOrchestrator: { reconcile: async () => {}, interruptForShutdown: async () => {}, close() {} }, + evaluateExposure: () => ({ level: 'allow' }), + fs: { existsSync: () => false }, path, APP_ROOT: directory, + c: { info: identity, warn: identity, bright: identity, dim: identity, tip: identity }, console: { log() {}, warn() {}, error() {} }, + SERVER_PORT: (owner.address() as net.AddressInfo).port, HOST: '127.0.0.1', DISPLAY_HOST: '127.0.0.1', VITE_PORT: 5173, + process: { env: {}, on() {}, exitCode: 0, exit: (code: number) => { assert.equal(automationStopped, true); exited(code); } }, + server, wss, listenForStartup, + writeLocalServerMarker: async () => { markerWrites++; }, removeLocalServerMarker: async () => {}, + initializeSessionsWatcher: async () => {}, closeSessionsWatcher: async () => {}, + drainWebSocketClients: async () => {}, shutdownGjcWorker: async () => {}, + setInterval: () => { throw new Error('unexpected incomplete cleanup'); }, + }; + try { + const run = runInNewContext(`(${startup.getText(source)})`, context) as () => Promise; + await run(); + assert.equal(await completion, 1); + assert.equal(markerWrites, 0); + await assert.rejects(lstat(socketPath), { code: 'ENOENT' }); + assert.equal(owner.listening, true); + } finally { + wss.close(); await close(server); await close(automation); await close(owner); + await rm(directory, { recursive: true, force: true }); + } +}); + +test('an error during readiness does not release the callback owner before its real completion', async () => { + const server = http.createServer(); const sockets = new WebSocketServer({ server }); + let release!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + let settled = false; + const pending = listenForStartup(server, sockets, 0, '127.0.0.1', async () => { await gate; }); + const checked = assert.rejects(pending, /during ready/).then(() => { settled = true; }); + try { + await once(server, 'listening'); + server.emit('error', new Error('during ready')); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(settled, false); + release(); await checked; + } finally { release(); sockets.close(); await close(server); } +}); diff --git a/server/services/server-listener.ts b/server/services/server-listener.ts new file mode 100644 index 00000000..6bab4543 --- /dev/null +++ b/server/services/server-listener.ts @@ -0,0 +1,44 @@ +import type { Server } from 'node:http'; + +import type { WebSocketServer } from 'ws'; + +/** ws forwards HTTP server errors through its own emitter. Observe both until + * startup finishes so EADDRINUSE rejects the initializer instead of escaping + * as an unhandled WebSocketServer error and skipping resource cleanup. */ +export function listenForStartup(server: Server, sockets: WebSocketServer, port: number, host: string, ready: () => Promise): Promise { + return new Promise((resolve, reject) => { + let finished = false; + let readyStarted = false; + let failure: Error | undefined; + const remove = () => { server.off('error', fail); sockets.off('error', fail); }; + const fail = (error: Error) => { + if (finished) return; + failure ??= error; + // A socket error does not finish an already accepted initialization + // callback. Its owner must join that callback before tearing services down. + if (readyStarted) return; + finished = true; + remove(); + reject(error); + }; + server.on('error', fail); + sockets.on('error', fail); + try { + server.listen({ port, host }, () => { + if (finished) return; + readyStarted = true; + void Promise.resolve().then(ready).then(() => { + if (finished) return; + finished = true; + remove(); + if (failure) reject(failure); else resolve(); + }, (error: Error) => { + if (finished) return; + finished = true; + remove(); + reject(failure ?? error); + }); + }); + } catch (error) { fail(error as Error); } + }); +} diff --git a/server/services/session-worktree-runtime.test.ts b/server/services/session-worktree-runtime.test.ts index 9da504c9..30ba9176 100644 --- a/server/services/session-worktree-runtime.test.ts +++ b/server/services/session-worktree-runtime.test.ts @@ -23,10 +23,11 @@ import { chatRunRegistry } from '../modules/websocket/services/chat-run-registry import { connectedClients } from '../modules/websocket/services/websocket-state.service.js'; import { GjcGitClient } from './gjc-git-client.js'; -import { JobOrchestrator, type GitWorktrees, type JobSupervisor } from './gjc-job-orchestrator.js'; +import { DesktopRestartAuthority } from './desktop-restart-authority.js'; +import { createGjcJobOrchestratorDesktopRestartReader, JobOrchestrator, type GitWorktrees, type JobSupervisor } from './gjc-job-orchestrator.js'; import { GjcJobsClient } from './gjc-jobs-client.js'; import { readSessionLocation, resolveSessionWorkspacePath, validateSessionRepository } from './session-worktree-paths.js'; -import { abortSessionWorktreeRun, prepareSessionWorktreeRun, sessionWorktreeWorkerHandle } from './session-worktree-runtime.js'; +import { abortSessionWorktreeRun, configureSessionWorktreeDesktopAdmission, createSessionWorktreeDesktopRestartReader, prepareSessionWorktreeRun, sessionWorktreeWorkerHandle } from './session-worktree-runtime.js'; const execFile = promisify(execFileCallback); const runOptions = { model: 'openai-codex/gpt-6-astra', effort: 'xhigh' }; @@ -128,6 +129,136 @@ async function fixture(t: test.TestContext, options: { delayPreparation?: boolea return { root, repository, project, created, jobs, git, supervisor, orchestrator, messages, writer, workers, makeTicket, preparation, isPreparing: () => preparing }; } +function worktreeDesktopAuthority(t: test.TestContext, orchestrator: JobOrchestrator) { + const reader = createSessionWorktreeDesktopRestartReader(); + const authority = new DesktopRestartAuthority({ + requiredOwners: ['worktrees', 'orchestrator'], + ownerReaders: { worktrees: reader, orchestrator: createGjcJobOrchestratorDesktopRestartReader(orchestrator) }, + }); + configureSessionWorktreeDesktopAdmission(authority); + orchestrator.configureDesktopAdmission(authority); + t.after(() => configureSessionWorktreeDesktopAdmission()); + return { authority, reader }; +} +const worktreeDesktopTick = () => new Promise((resolve) => setImmediate(resolve)); + +test('desktop worktree reader is pure and unused ticket disposal closes its single-use admission', async (t) => { + const f = await fixture(t); + const { authority, reader } = worktreeDesktopAuthority(t, f.orchestrator); + const before = reader.read(); + assert.equal(before.owner, 'worktrees'); + assert.deepEqual(reader.read(), before); + assert.equal(reader.getGeneration(), before.generation); + assert.equal(f.workers.length, 0); + const ticket = f.makeTicket(); + assert.equal(reader.read().starting, before.starting + 1); + assert.notEqual(reader.getGeneration(), before.generation); + assert.equal((await authority.prepare({ attemptId: 'unused-ticket', epoch: 'desktop-1' })).ok, false); + ticket.dispose(); + const after = reader.read(); + ticket.dispose(); + assert.deepEqual(reader.read(), after); + assert.deepEqual({ ...after, generation: before.generation }, before); + await assert.rejects(ticket.run('disposed', runOptions, f.writer), /disposed/); + assert.equal((await authority.snapshot()).idle, true); + const prepared = await authority.prepare({ attemptId: 'new-ticket-fenced', epoch: 'desktop-1' }); + assert.equal(prepared.ok, true); + assert.throws(() => prepareSessionWorktreeRun(f.created.sessionId, () => f.orchestrator), { code: 'DESKTOP_RESTART_FENCED' }); + assert.equal(f.workers.length, 0); + if (prepared.ok) authority.cancel(prepared.token); +}); + +test('disposal and chat registry clearing during paused preparation cannot hide the worktree lifetime', async (t) => { + const f = await fixture(t, { delayPreparation: true }); + const { authority, reader } = worktreeDesktopAuthority(t, f.orchestrator); + const ticket = f.makeTicket(); + const running = ticket.run('paused preparation', runOptions, f.writer); + await until(f.isPreparing); + ticket.dispose(); + chatRunRegistry.clearAll(); + assert.ok(reader.read().running > 0 && reader.read().settling > 0); + assert.equal((await authority.prepare({ attemptId: 'disposed-preparation', epoch: 'desktop-1' })).ok, false); + assert.equal(ticket.aborted, false); + f.preparation.resolve(); + await until(() => f.workers.length === 1); + assert.equal(sessionWorktreeWorkerHandle(ticket.abortHandle), f.workers[0].input.runId); + f.workers[0].input.writer.send({ kind: 'complete', exitCode: 0 }); + assert.equal((await authority.snapshot()).idle, false); + assert.equal(f.messages.some((message) => (message as { kind?: string }).kind === 'complete'), false); + f.workers[0].finish(); + await running; + await worktreeDesktopTick(); + assert.equal(sessionWorktreeWorkerHandle(ticket.abortHandle), undefined); + assert.equal((await authority.snapshot()).idle, true); +}); + +test('a live ticket may continue during a paused prepare while new roots remain fenced', async (t) => { + const f = await fixture(t); + // This accepted owner predates authority injection, so the reader (rather + // than a retained ingress lease) must protect the entire transfer. + const ticket = f.makeTicket(); + const { authority } = worktreeDesktopAuthority(t, f.orchestrator); + const preparing = authority.prepare({ attemptId: 'owned-continuation', epoch: 'desktop-1' }); + assert.equal(authority.state, 'preparing'); + const running = ticket.run('owned continuation', runOptions, f.writer); + const rejected = assert.rejects(f.orchestrator.start('gjc', 'other-session', f.repository, 'new root', { writer: f.writer }), { code: 'DESKTOP_RESTART_FENCED' }); + assert.throws(() => prepareSessionWorktreeRun(f.created.sessionId, () => f.orchestrator), { code: 'DESKTOP_RESTART_FENCED' }); + assert.equal((await preparing).ok, false); + await rejected; + await until(() => f.workers.length === 1); + assert.equal(ticket.aborted, false); + f.workers[0].finish(); + await running; + ticket.dispose(); + await worktreeDesktopTick(); + assert.equal((await authority.snapshot()).idle, true); +}); + +test('a disposed failed-start ticket stays owned until the actual worker promise settles', async (t) => { + const f = await fixture(t); + const completed = deferred(); const outcome = deferred(); + const supervisor: JobSupervisor = { + spawnRun: (input) => ({ started: Promise.reject(new Error('paused worker startup failure')), completion: completed.promise, outcome: outcome.promise, phase: () => 'request_issued', abortHandle: input.runId }), + abort: async () => 'unconfirmed', terminate: async () => 'reaped', + }; + const orchestrator = new JobOrchestrator({ jobs: f.jobs, git: f.git, supervisor, stopCompletionTimeoutMs: 1 }); + const { authority, reader } = worktreeDesktopAuthority(t, orchestrator); + const ticket = f.makeTicket(orchestrator); + const running = ticket.run('startup failure', runOptions, f.writer); + await assert.rejects(running, /paused worker startup failure/); + ticket.dispose(); + assert.ok(reader.read().retained > 0 && reader.read().settling > 0); + assert.ok(sessionWorktreeWorkerHandle(ticket.abortHandle)); + assert.equal((await authority.snapshot()).idle, false); + outcome.resolve('reaped'); + completed.resolve(); + await worktreeDesktopTick(); + assert.equal(sessionWorktreeWorkerHandle(ticket.abortHandle), undefined); + assert.equal((await authority.snapshot()).idle, true); +}); + +test('worktree abort timeout does not release cancellation or preparation ownership', async (t) => { + const f = await fixture(t, { delayPreparation: true }); + const { authority, reader } = worktreeDesktopAuthority(t, f.orchestrator); + const ticket = f.makeTicket(); + const running = ticket.run('cancel paused preparation', runOptions, f.writer); + await until(f.isPreparing); + t.mock.timers.enable({ apis: ['setTimeout'] }); + const aborting = abortSessionWorktreeRun(ticket.abortHandle); + t.mock.timers.tick(5000); + assert.equal(await aborting, false); + t.mock.timers.reset(); + ticket.dispose(); + assert.ok(reader.read().running > 0 && reader.read().settling > 0); + assert.equal((await authority.prepare({ attemptId: 'abort-timeout', epoch: 'desktop-1' })).ok, false); + f.preparation.resolve(); + await running; + await worktreeDesktopTick(); + assert.equal(ticket.aborted, true); + assert.equal(f.workers.length, 0); + assert.equal((await authority.snapshot()).idle, true); +}); + test('worktree session keeps canonical project policy, actual cwd, and provider identity across turns and reload', async (t) => { const f = await fixture(t); assert.equal(f.created.projectPath, f.repository); @@ -353,6 +484,12 @@ test('unconfirmed worker termination retains native ownership and cannot report assert.equal(ticket.aborted, false); await assert.rejects(f.makeTicket().run('must not start', runOptions, f.writer), { code: 'RUN_IN_PROGRESS' }); assert.equal(f.workers.length, 1); + ticket.dispose(); + const reader = createSessionWorktreeDesktopRestartReader(); + assert.equal(reader.read().complete, false); + assert.ok(reader.read().unknown.includes('worktree_stop_unconfirmed')); + assert.ok(reader.read().retained > 0); + assert.equal(sessionWorktreeWorkerHandle(ticket.abortHandle), f.workers[0].input.runId); }); test('an unsuccessful worker completion never becomes a successful native turn', async (t) => { diff --git a/server/services/session-worktree-runtime.ts b/server/services/session-worktree-runtime.ts index 61c402cd..8fe3e720 100644 --- a/server/services/session-worktree-runtime.ts +++ b/server/services/session-worktree-runtime.ts @@ -1,34 +1,97 @@ import { randomUUID } from 'node:crypto'; +import type { DesktopOwnerActivity } from '../../shared/desktopUpdateProtocol.js'; import type { GjcWorkerOptions, GjcWorkerRun, GjcWorkerWriter } from '../gjc-worker-client.js'; import { sessionsDb, sessionWorktreesDb } from '../modules/database/index.js'; import { resolveProjectRunPermissions } from '../modules/projects/index.js'; import { AppError, createNormalizedMessage } from '../shared/utils.js'; +import type { DesktopWorkAdmission } from '../shared/interfaces.js'; -import { getProductionJobOrchestrator, type JobOrchestrator } from './gjc-job-orchestrator.js'; +import { getProductionJobOrchestrator, withOwnedJobDesktopContinuation, type JobOrchestrator } from './gjc-job-orchestrator.js'; import { validateSessionRepository, validateSessionWorktree } from './session-worktree-paths.js'; -type Ticket = { controller: AbortController; aborted: boolean; worker?: GjcWorkerRun; finished?: Promise }; +type Ticket = { + controller: AbortController; aborted: boolean; worker?: GjcWorkerRun; finished?: Promise; + disposed: boolean; settled: boolean; workerSettled: boolean; unconfirmed: boolean; + release?: () => void; +}; const tickets = new Map(); +const activityEpoch = randomUUID(); +let activityRevision = 0n; +let abortTasks = 0; +let desktopAdmission: DesktopWorkAdmission | undefined; +const activityChanged = () => { activityRevision += 1n; }; + +export function configureSessionWorktreeDesktopAdmission(admission?: DesktopWorkAdmission): void { + desktopAdmission = admission; + activityChanged(); +} +export function snapshotSessionWorktreeActivity(): DesktopOwnerActivity { + let starting = 0; let running = 0; let settling = abortTasks; let retained = 0; + let unconfirmed = false; + for (const ticket of tickets.values()) { + if (!ticket.finished) starting += 1; + else if (!ticket.settled) running += 1; + else retained += 1; + if (!ticket.workerSettled || (ticket.disposed && !ticket.settled)) settling += 1; + unconfirmed ||= ticket.unconfirmed; + } + return { + owner: 'worktrees', generation: `${activityEpoch}:${activityRevision}`, complete: !unconfirmed, + starting, queued: 0, running, settling, approvals: 0, retained, + unknown: unconfirmed ? ['worktree_stop_unconfirmed'] : [], + }; +} +export function createSessionWorktreeDesktopRestartReader(): { getGeneration(): string; read(): DesktopOwnerActivity } { + return Object.freeze({ getGeneration: () => `${activityEpoch}:${activityRevision}`, read: snapshotSessionWorktreeActivity }); +} + +function releaseTicket(abortHandle: string, ticket: Ticket): void { + if (ticket.finished && (!ticket.settled || !ticket.workerSettled)) return; + // The map remains the owner of uncertainty even after the caller disposes. + ticket.release?.(); + ticket.release = undefined; + if (ticket.disposed && !ticket.unconfirmed && tickets.get(abortHandle) === ticket) { + tickets.delete(abortHandle); + activityChanged(); + } +} /** Installed synchronously when chat accepts a run, before model lookup. */ -export function prepareSessionWorktreeRun(sessionId: string, orchestratorFactory: () => JobOrchestrator = getProductionJobOrchestrator) { - const row = sessionWorktreesDb.get(sessionId); - if (!row) return null; - const ticket: Ticket = { controller: new AbortController(), aborted: false }; +export function prepareSessionWorktreeRun(sessionId: string, orchestratorFactory: () => JobOrchestrator = getProductionJobOrchestrator, admission = desktopAdmission) { + const release = admission?.enter('worktrees:prepare'); + let row; + try { row = sessionWorktreesDb.get(sessionId); } + catch (error) { release?.(); throw error; } + if (!row) { release?.(); return null; } + const ticket: Ticket = { controller: new AbortController(), aborted: false, disposed: false, settled: false, workerSettled: true, unconfirmed: false, release }; const abortHandle = `session-worktree-${randomUUID()}`; tickets.set(abortHandle, ticket); + activityChanged(); return { abortHandle, get aborted() { return ticket.aborted; }, - dispose() { if (tickets.get(abortHandle) === ticket) tickets.delete(abortHandle); }, + dispose() { + if (ticket.disposed) return; + ticket.disposed = true; + activityChanged(); + releaseTicket(abortHandle, ticket); + }, run(message: string, options: GjcWorkerOptions, writer: GjcWorkerWriter): Promise { if (ticket.finished) return Promise.reject(new Error('A worktree run ticket can only be used once.')); + if (ticket.disposed) return Promise.reject(new Error('A disposed worktree run ticket cannot be used.')); + let releaseRun: (() => void) | undefined; + try { releaseRun = (admission ?? desktopAdmission)?.enterCompletion('worktrees:run'); } + catch (error) { return Promise.reject(error); } + const continueOwned = (action: () => T) => withOwnedJobDesktopContinuation( + () => tickets.get(abortHandle) === ticket && !ticket.settled, action, + ); const operation = async () => { const signal = ticket.controller.signal; const stoppedBeforeAdmission = () => { if (!signal.aborted) return false; ticket.aborted = true; + activityChanged(); return true; }; if (stoppedBeforeAdmission()) return; @@ -37,7 +100,7 @@ export function prepareSessionWorktreeRun(sessionId: string, orchestratorFactory await validateSessionRepository(row.repository_root); if (stoppedBeforeAdmission()) return; const orchestrator = orchestratorFactory(); - const binding = await orchestrator.resolveBinding('gjc', sessionId); + const binding = await continueOwned(() => orchestrator.resolveBinding('gjc', sessionId)); if (stoppedBeforeAdmission()) return; let complete: Record | undefined; const durableWriter: GjcWorkerWriter = { @@ -53,20 +116,33 @@ export function prepareSessionWorktreeRun(sessionId: string, orchestratorFactory ...options, projectPath: row.repository_root, permissions: resolveProjectRunPermissions(row.repository_root), writer: durableWriter, signal, retainWorkspaceOnFailure: true, cap: 4, - onRun: (run: GjcWorkerRun) => { ticket.worker = run; }, + onRun: (run: GjcWorkerRun) => { + ticket.worker = run; + ticket.workerSettled = false; + activityChanged(); + void Promise.all([ + run.completion.then(() => true, () => false), + run.outcome?.catch(() => 'unconfirmed' as const), + ]).then(([completed, outcome]) => { + ticket.workerSettled = true; + ticket.unconfirmed = outcome === 'unconfirmed' || (!outcome && !completed); + activityChanged(); + releaseTicket(abortHandle, ticket); + }); + }, }; let handle; if (!binding) { // An existing provider transcript must never be moved to a fresh job // merely because its authority binding is missing. if (session.provider_session_id || row.worktree_path) throw new AppError('Worktree ownership is unavailable.', { code: 'SESSION_WORKTREE_BINDING_LOST', statusCode: 409 }); - handle = await orchestrator.start('gjc', sessionId, row.repository_root, message, { + handle = await continueOwned(() => orchestrator.start('gjc', sessionId, row.repository_root, message, { ...runOptions, jobId: row.job_id, onPrepared: async (cwd) => { await validateSessionWorktree(row, cwd); sessionWorktreesDb.setPreparedPath(sessionId, row.job_id, cwd); }, - }); + })); } else { if (binding.jobId !== row.job_id || (session.provider_session_id && binding.providerSessionId !== session.provider_session_id)) throw new AppError('Worktree ownership does not match this session.', { code: 'SESSION_WORKTREE_BINDING_MISMATCH', statusCode: 409 }); if (binding.providerSessionId && !session.provider_session_id) { @@ -78,19 +154,26 @@ export function prepareSessionWorktreeRun(sessionId: string, orchestratorFactory if (!current) throw new Error('Session worktree was removed.'); await validateSessionWorktree(current); if (stoppedBeforeAdmission()) return; - if (binding.state === 'ready') handle = await orchestrator.turnStart('gjc', sessionId, message, runOptions); - else if (binding.state === 'interrupted') handle = await orchestrator.resume(row.job_id, sessionId, message, runOptions); + if (binding.state === 'ready') handle = await continueOwned(() => orchestrator.turnStart('gjc', sessionId, message, runOptions)); + else if (binding.state === 'interrupted') handle = await continueOwned(() => orchestrator.resume(row.job_id, sessionId, message, runOptions)); else throw new AppError('This worktree already has an active run.', { code: 'RUN_IN_PROGRESS', statusCode: 409 }); } await handle.completion; const outcome = await ticket.worker?.outcome; if (outcome === 'unconfirmed') throw new AppError('Worktree execution has not confirmed termination.', { code: 'SESSION_WORKTREE_STOP_UNCONFIRMED', statusCode: 409 }); ticket.aborted = signal.aborted || outcome === 'aborted'; + activityChanged(); // Let queued chat follow-ups start only after native authority is ready. writer.send(createNormalizedMessage({ ...complete, kind: 'complete', provider: 'gjc', sessionId: sessionsDb.getSessionById(sessionId)?.provider_session_id ?? sessionId, exitCode: complete?.exitCode ?? 0, aborted: ticket.aborted })); }; - const finished = operation(); + const finished = operation().finally(() => { + ticket.settled = true; + activityChanged(); + releaseRun?.(); + releaseTicket(abortHandle, ticket); + }); ticket.finished = finished; + activityChanged(); return finished; }, }; @@ -103,19 +186,24 @@ export function sessionWorktreeWorkerHandle(handle: string): string | undefined export async function abortSessionWorktreeRun(handle: string): Promise { const ticket = tickets.get(handle); if (!ticket) return null; - ticket.controller.abort(); - // Before model lookup completes, no native work has started. Once preparation - // starts, wait for its cancellation too so the next turn sees a ready binding. - if (!ticket.finished) { ticket.aborted = true; return true; } + const release = desktopAdmission?.enterCompletion('worktrees:abort'); + abortTasks += 1; + activityChanged(); + const operation = (async () => { + ticket.controller.abort(); + activityChanged(); + // This task, not the timeout below, owns the actual cancellation unwind. + if (!ticket.finished) { ticket.aborted = true; activityChanged(); return true; } + await ticket.finished.catch(() => {}); + if (!ticket.worker) return true; + if (!ticket.worker.outcome) return ticket.worker.completion.then(() => true, () => false); + const outcome = await ticket.worker.outcome.catch(() => 'unconfirmed'); + return outcome !== 'unconfirmed'; + })().finally(() => { abortTasks -= 1; activityChanged(); release?.(); }); let timer: ReturnType | undefined; try { return await Promise.race([ - ticket.finished.catch(() => {}).then(async () => { - if (!ticket.worker) return true; - if (!ticket.worker.outcome) return ticket.worker.completion.then(() => true, () => false); - const outcome = await ticket.worker.outcome.catch(() => 'unconfirmed'); - return outcome !== 'unconfirmed'; - }), + operation, new Promise((resolve) => { timer = setTimeout(() => resolve(false), 5000); }), ]); } finally { diff --git a/server/shared/desktop-internal-activity.ts b/server/shared/desktop-internal-activity.ts new file mode 100644 index 00000000..96c54afe --- /dev/null +++ b/server/shared/desktop-internal-activity.ts @@ -0,0 +1,62 @@ +import { randomUUID } from 'node:crypto'; + +import type { DesktopOwnerActivity } from '../../shared/desktopUpdateProtocol.js'; + +import type { DesktopWorkAdmission } from './interfaces.js'; + +const epoch = randomUUID(); +let revision = 0n; +let active = 0; +let admission: DesktopWorkAdmission | undefined; +const uncertainty = new Set(); + +export function configureInternalDesktopAdmission(value: DesktopWorkAdmission): void { + if (admission && admission !== value) throw new Error('Internal desktop admission already configured.'); + if (admission === value) return; + admission = value; + revision++; +} + +/** Server-owned source names only. Acquire synchronously before the first await. + * Release after real settlement, or a synchronous transfer to a counted owner. + * `owned` is for a proven existing continuation, never a request-payload flag. */ +export function enterInternalActivity(source: string, owned = false): () => void { + if (!/^[a-z][a-z0-9:_-]{0,127}$/u.test(source)) throw new TypeError('An internal activity source is required.'); + const release = owned ? admission?.enterCompletion(source) : admission?.enter(source); + active++; + revision++; + let released = false; + return () => { + if (released) return; + released = true; + active--; + revision++; + release?.(); + }; +} + +export async function withInternalActivity(source: string, action: () => T | Promise, owned = false): Promise { + const release = enterInternalActivity(source, owned); + try { return await action(); } + finally { release(); } +} + +/** Bounded reason codes only; failed cleanup must not become an idle proof. */ +export function markInternalActivityUncertain(reason: string): void { + if (!/^[a-z][a-z0-9:_-]{0,127}$/u.test(reason)) throw new TypeError('An internal uncertainty code is required.'); + if (uncertainty.has(reason)) return; + const code = uncertainty.size < 31 ? reason : 'internal_uncertainty_overflow'; + if (!uncertainty.has(code)) { uncertainty.add(code); revision++; } +} + +export const getInternalActivityGeneration = (): string => `${epoch}:${revision}`; + +/** Only audited callers of this primitive, NOT an all-backend idle certificate. + * Child generations/native clients and other execution owners remain separate. */ +export function snapshotInternalActivity(): DesktopOwnerActivity { + return { + owner: 'internal-producers', generation: getInternalActivityGeneration(), complete: uncertainty.size === 0, + starting: 0, queued: 0, running: active, settling: 0, approvals: 0, retained: 0, + unknown: [...uncertainty], + }; +} diff --git a/server/shared/interfaces.ts b/server/shared/interfaces.ts index e7361ffb..ce0f0ba2 100644 --- a/server/shared/interfaces.ts +++ b/server/shared/interfaces.ts @@ -1,5 +1,13 @@ import type * as ProviderContract from '@/shared/types.js'; +/** Server-owned admission only; never accept this capability from a request. */ +export interface DesktopWorkAdmission { + /** Acquire before dispatch; release after settlement or proven owner transfer. */ + enter(source: string): () => void; + /** Already-owned completion may invalidate preparation, never committed shutdown. */ + enterCompletion(source: string): () => void; +} + type ProviderId = ProviderContract.LLMProvider; type ActiveModel = ProviderContract.ProviderCurrentActiveModel; type ActiveModelChange = ProviderContract.ProviderSessionActiveModelChange; diff --git a/server/shared/utils.ts b/server/shared/utils.ts index e069f4b9..3abc81f0 100644 --- a/server/shared/utils.ts +++ b/server/shared/utils.ts @@ -7,6 +7,7 @@ import readline from 'node:readline'; import type { NextFunction, Request, RequestHandler, Response } from 'express'; +import type { DesktopWorkAdmission } from '@/shared/interfaces.js'; import type { AnyRecord, ApiSuccessShape, @@ -42,12 +43,46 @@ export function createApiSuccessResponse(data: TData): ApiSuccessShape Promise): RequestHandler { +const httpActivityEpoch = randomUUID(); +let httpActivityRevision = 0n; +let httpHandlers = 0; +export const getHttpActivityGeneration = (): string => `${httpActivityEpoch}:${httpActivityRevision}`; +export function snapshotHttpActivity() { + return { + owner: 'http-callbacks', generation: getHttpActivityGeneration(), complete: true, + starting: 0, queued: 0, running: httpHandlers, settling: 0, approvals: 0, retained: 0, unknown: [], + }; +} + +export function asyncHandler(handler: (req: Request, res: Response, next: NextFunction) => unknown | Promise): RequestHandler { return (req, res, next) => { - // Promise.resolve tolerates handlers that return a plain value; a rejection - // is routed into Express error handling instead of an unhandled rejection. - const outcome = Promise.resolve(handler(req, res, next)); - void outcome.catch(next); + let release: (() => void) | undefined; + try { + const admission = req.app?.locals.desktopRestartAdmission as DesktopWorkAdmission | undefined; + // Use the registered route, not a caller-controlled URL/query/body. Even + // GET handlers can start native processes and must acquire before awaiting. + const releaseAdmission = admission?.enter('http:handler'); + httpHandlers += 1; + httpActivityRevision += 1n; + let released = false; + release = () => { + if (released) return; + released = true; + httpHandlers -= 1; + httpActivityRevision += 1n; + releaseAdmission?.(); + }; + const outcome = Promise.resolve(handler(req, res, next)); + // Response finish/close is not completion of the actual handler. In + // particular, client disconnect must not let prepare overtake a write. + void outcome.then(() => release?.(), (error) => { release?.(); next(error); }); + } catch (error) { + release?.(); + if (error && typeof error === 'object' && 'code' in error && error.code === 'DESKTOP_RESTART_FENCED') { + res.setHeader('Retry-After', '1'); + res.status(503).json({ error: 'Desktop restart is being prepared. Retry this request.', code: 'DESKTOP_RESTART_FENCED' }); + } else next(error); + } }; } diff --git a/server/voice-proxy.js b/server/voice-proxy.js index 1ea4a6d8..349b7a04 100644 --- a/server/voice-proxy.js +++ b/server/voice-proxy.js @@ -8,10 +8,13 @@ // // Config is resolved per-request from headers (set by the client's voice settings), // falling back to server env defaults. Mounted at /api/voice behind authenticateToken. -import { Readable } from 'node:stream'; +import { Readable, Writable } from 'node:stream'; +import { finished, pipeline } from 'node:stream/promises'; import express from 'express'; +import { asyncHandler } from './shared/utils.js'; + const ENV = { baseUrl: (process.env.VOICE_API_BASE_URL || '').replace(/\/$/, ''), apiKey: process.env.VOICE_API_KEY || '', @@ -120,18 +123,48 @@ function upstreamError(res, status, text) { return res.status(status).json({ error: text || 'voice backend error' }); } -let _upload = null; /** - * Lazily build a memory-storage multer instance (25 MB cap) for audio uploads, - * so multer is only imported when the voice feature is actually used. - * @returns {Promise} + * Await both Multer's callback and the owned memory pipelines. On request abort + * Multer may call next before its storage callbacks have completed. + * @param {import('express').Request} req + * @param {import('express').Response} res + * @returns {Promise} */ -async function getUpload() { - if (!_upload) { - const multer = (await import('multer')).default; - _upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 25 * 1024 * 1024 } }); +async function receiveAudio(req, res) { + const multer = (await import('multer')).default; + const operations = []; + const upload = multer({ + storage: { + _handleFile: (_request, file, done) => { + const chunks = []; + const output = new Writable({ + write(chunk, _encoding, callback) { chunks.push(chunk); callback(); }, + }); + operations.push(pipeline(file.stream, output).then(() => { + const buffer = Buffer.concat(chunks); + chunks.length = 0; + done(null, { buffer, size: buffer.length }); + }, (error) => { + chunks.length = 0; + done(error); + })); + }, + _removeFile: (_request, file, done) => { delete file.buffer; done(null); }, + }, + limits: { fileSize: 25 * 1024 * 1024 }, + }); + const failure = await new Promise((resolve) => upload.single('audio')(req, res, resolve)).catch((error) => error); + const storageFailures = []; + for (let settled = 0; settled < operations.length;) { + const batch = operations.slice(settled); + settled += batch.length; + const results = await Promise.allSettled(batch); + for (const result of results) if (result.status === 'rejected') storageFailures.push(result.reason); + } + if (failure || req.aborted || storageFailures.length) { + if (req.file) delete req.file.buffer; + throw failure || storageFailures[0] || new Error('Request aborted'); } - return _upload; } /** @@ -147,51 +180,52 @@ function authHeader(apiKey) { /** * GET /api/voice/health -> { configured } (true when a backend base URL is set). */ -router.get('/health', (req, res) => { +router.get('/health', asyncHandler((req, res) => { res.json({ configured: Boolean(resolveConfig(req).baseUrl) }); -}); +})); /** * POST /api/voice/transcribe (multipart 'audio') -> { text }. * Forwards the uploaded audio to the backend's /audio/transcriptions endpoint. */ -router.post('/transcribe', async (req, res) => { +router.post('/transcribe', asyncHandler(async (req, res) => { const cfg = resolveConfig(req); if (!cfg.baseUrl) return res.status(503).json({ error: 'No voice backend configured' }); if (!isAllowedBackendUrl(cfg.baseUrl)) return res.status(400).json({ error: 'Invalid voice backend URL.' }); - const upload = await getUpload(); - upload.single('audio')(req, res, async (err) => { - if (err) return res.status(400).json({ error: err.message }); - if (!req.file) return res.status(400).json({ error: 'No audio uploaded' }); - try { - const fd = new FormData(); - fd.append( - 'file', - new Blob([req.file.buffer], { type: req.file.mimetype || 'audio/webm' }), - req.file.originalname || 'recording.webm', - ); - fd.append('model', cfg.sttModel); - const r = await fetchWithTimeout(`${cfg.baseUrl}/audio/transcriptions`, { - method: 'POST', - headers: authHeader(cfg.apiKey), - body: fd, - }); - const text = await r.text(); - if (!r.ok) return upstreamError(res, r.status, text); - let data; - try { data = JSON.parse(text); } catch { data = { text }; } - res.json({ text: data.text ?? '' }); - } catch (e) { - backendError(res, e); - } - }); -}); + try { + await receiveAudio(req, res); + } catch (err) { + return res.status(400).json({ error: err.message }); + } + if (!req.file) return res.status(400).json({ error: 'No audio uploaded' }); + try { + const fd = new FormData(); + fd.append( + 'file', + new Blob([req.file.buffer], { type: req.file.mimetype || 'audio/webm' }), + req.file.originalname || 'recording.webm', + ); + fd.append('model', cfg.sttModel); + const r = await fetchWithTimeout(`${cfg.baseUrl}/audio/transcriptions`, { + method: 'POST', + headers: authHeader(cfg.apiKey), + body: fd, + }); + const text = await r.text(); + if (!r.ok) return upstreamError(res, r.status, text); + let data; + try { data = JSON.parse(text); } catch { data = { text }; } + res.json({ text: data.text ?? '' }); + } catch (e) { + backendError(res, e); + } +})); /** * POST /api/voice/tts { text } -> audio bytes. * Forwards the text to the backend's /audio/speech endpoint and streams the audio back. */ -router.post('/tts', async (req, res) => { +router.post('/tts', asyncHandler(async (req, res) => { const cfg = resolveConfig(req); if (!cfg.baseUrl) return res.status(503).json({ error: 'No voice backend configured' }); if (!isAllowedBackendUrl(cfg.baseUrl)) return res.status(400).json({ error: 'Invalid voice backend URL.' }); @@ -215,10 +249,32 @@ router.post('/tts', async (req, res) => { res.setHeader('Content-Type', r.headers.get('content-type') || 'audio/mpeg'); res.setHeader('Cache-Control', 'no-store'); if (!r.body) return res.end(); - Readable.fromWeb(r.body).on('error', (error) => res.destroy(error)).pipe(res); + const source = Readable.fromWeb(r.body); + const sourceClosed = new Promise((resolve) => source.once('close', resolve)); + const stopSource = () => { source.destroy(); }; + const reportError = (error) => { res.destroy(error); }; + source.on('error', reportError); + res.once('close', stopSource); + const responseDone = finished(res, { cleanup: true }).catch(stopSource); + try { + if (res.destroyed) stopSource(); + else source.pipe(res); + // Readable.fromWeb's close follows its async cancel/_destroy callback. + // A closed client alone must not release the backend stream's ownership. + await Promise.all([sourceClosed, responseDone]); + } catch (error) { + res.destroy(error); + throw error; + } finally { + stopSource(); + await Promise.all([sourceClosed, responseDone]); + res.off('close', stopSource); + source.off('error', reportError); + } } catch (e) { - backendError(res, e); + if (res.headersSent || res.destroyed) res.destroy(e); + else backendError(res, e); } -}); +})); export default router; diff --git a/server/voice-proxy.test.js b/server/voice-proxy.test.js new file mode 100644 index 00000000..7c6dada6 --- /dev/null +++ b/server/voice-proxy.test.js @@ -0,0 +1,187 @@ +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import { once } from 'node:events'; +import http from 'node:http'; +import test from 'node:test'; + +import express from 'express'; + +function deferred(t) { + let resolve; + const promise = new Promise((done) => { resolve = done; }); + t.after(() => resolve()); + return { promise, resolve }; +} + +async function serve(t, backend, baseUrl = 'http://voice.fixture') { + const previous = process.env.VOICE_API_BASE_URL; + process.env.VOICE_API_BASE_URL = baseUrl; + let router; + try { + router = (await import(`./voice-proxy.js?fixture=${randomUUID()}`)).default; + } finally { + if (previous === undefined) delete process.env.VOICE_API_BASE_URL; + else process.env.VOICE_API_BASE_URL = previous; + } + const fetchClient = globalThis.fetch; + t.mock.method(globalThis, 'fetch', backend); + const app = express(); + const requests = []; + let active = 0; + const waiters = []; + app.locals.desktopRestartAdmission = { enter: () => { + active++; + return () => { + active--; + if (!active) waiters.splice(0).forEach((resolve) => resolve()); + }; + } }; + app.use(express.json()); + app.use((req, _res, next) => { requests.push(req); next(); }); + app.use(router); + const server = app.listen(0, '127.0.0.1'); + await once(server, 'listening'); + const origin = `http://127.0.0.1:${server.address().port}`; + t.after(async () => { + server.closeAllConnections(); + await new Promise((resolve) => server.close(resolve)); + }); + return { + origin, requests, + active: () => active, + idle: () => active ? new Promise((resolve) => waiters.push(resolve)) : Promise.resolve(), + request: (path, options) => fetchClient(`${origin}${path}`, options), + }; +} + +function audioForm(field = 'audio') { + const form = new FormData(); + form.append(field, new Blob(['audio bytes'], { type: 'audio/webm' }), 'recording.webm'); + return form; +} +const json = (body) => ({ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }); + +test('transcription retains ownership after multipart parsing until the backend response body completes', { timeout: 10_000 }, async (t) => { + const backendStarted = deferred(t); + const release = deferred(t); + const server = await serve(t, async (url, options) => { + assert.equal(url, 'http://voice.fixture/audio/transcriptions'); + assert.equal(options.body.get('file').name, 'recording.webm'); + assert.equal(await options.body.get('file').text(), 'audio bytes'); + backendStarted.resolve(); + return new Response(new ReadableStream({ + async start(controller) { + await release.promise; + controller.enqueue(new TextEncoder().encode('{"text":"recognized"}')); + controller.close(); + }, + })); + }); + let answered = false; + const pending = server.request('/transcribe', { method: 'POST', body: audioForm() }).then((response) => { answered = true; return response; }); + await backendStarted.promise; + assert.equal(server.active(), 1); + assert.equal(answered, false); + release.resolve(); + const response = await pending; + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { text: 'recognized' }); + await server.idle(); + assert.equal(server.active(), 0); +}); + +test('disconnect after transcription starts does not release its unfinished backend work', { timeout: 10_000 }, async (t) => { + const backendStarted = deferred(t); + const release = deferred(t); + const server = await serve(t, async () => { + backendStarted.resolve(); + await release.promise; + return new Response('plain transcription'); + }); + const cancellation = new AbortController(); + const pending = server.request('/transcribe', { method: 'POST', body: audioForm(), signal: cancellation.signal }).catch(() => null); + await backendStarted.promise; + cancellation.abort(); + await pending; + assert.equal(server.active(), 1); + release.resolve(); + await server.idle(); +}); + +test('aborted partial audio uploads settle storage without starting a backend request', { timeout: 10_000 }, async (t) => { + let calls = 0; + const server = await serve(t, async () => { calls++; throw new Error('must not call backend'); }); + const request = http.request(`${server.origin}/transcribe`, { + method: 'POST', headers: { 'content-type': 'multipart/form-data; boundary=audio-upload' }, + }); + request.on('error', () => {}); + t.after(() => request.destroy()); + request.write('--audio-upload\r\nContent-Disposition: form-data; name="audio"; filename="recording.webm"\r\nContent-Type: audio/webm\r\n\r\npartial'); + while (!server.requests.length || !server.active()) await new Promise((resolve) => setImmediate(resolve)); + const aborted = once(server.requests[0], 'aborted'); + request.destroy(); + await aborted; + await server.idle(); + assert.equal(calls, 0); +}); + +test('transcription keeps missing-file, invalid multipart and backend failure responses', async (t) => { + let calls = 0; + const server = await serve(t, async () => { calls++; return new Response('denied', { status: 401 }); }); + const missing = await server.request('/transcribe', json({})); + assert.equal(missing.status, 400); + assert.deepEqual(await missing.json(), { error: 'No audio uploaded' }); + const invalid = await server.request('/transcribe', { method: 'POST', body: audioForm('wrong-field') }); + assert.equal(invalid.status, 400); + assert.equal((await invalid.json()).error, 'Unexpected field'); + assert.equal(calls, 0); + const denied = await server.request('/transcribe', { method: 'POST', body: audioForm() }); + assert.equal(denied.status, 502); + assert.equal((await denied.json()).error, 'Voice backend rejected the request (check the API key).'); + await server.idle(); +}); + +test('TTS keeps ownership while disconnect cancellation of the upstream stream is pending', { timeout: 10_000 }, async (t) => { + const cancelling = deferred(t); + const release = deferred(t); + const server = await serve(t, async () => new Response(new ReadableStream({ + start(controller) { controller.enqueue(new Uint8Array([1, 2, 3])); }, + async cancel() { cancelling.resolve(); await release.promise; }, + }), { headers: { 'content-type': 'audio/wav' } })); + const response = await server.request('/tts', json({ text: 'say it' })); + assert.equal(response.status, 200); + assert.equal(response.headers.get('content-type'), 'audio/wav'); + const reader = response.body.getReader(); + assert.deepEqual((await reader.read()).value, new Uint8Array([1, 2, 3])); + await reader.cancel(); + await cancelling.promise; + assert.equal(server.active(), 1); + release.resolve(); + await server.idle(); + assert.equal(server.active(), 0); +}); + +test('TTS streams exact bytes and releases after ordinary EOF', async (t) => { + const bytes = new Uint8Array([0, 5, 12, 255]); + const server = await serve(t, async (_url, options) => { + assert.equal(JSON.parse(options.body).input, 'hello'); + return new Response(bytes, { headers: { 'content-type': 'audio/mpeg' } }); + }); + const response = await server.request('/tts', json({ text: 'hello' })); + assert.equal(response.headers.get('cache-control'), 'no-store'); + assert.deepEqual(new Uint8Array(await response.arrayBuffer()), bytes); + await server.idle(); + assert.equal(server.active(), 0); +}); + +test('voice validation stays unchanged when no backend is configured', async (t) => { + const server = await serve(t, async () => { throw new Error('must not call backend'); }, ''); + const health = await server.request('/health'); + assert.deepEqual(await health.json(), { configured: false }); + for (const route of ['/transcribe', '/tts']) { + const response = await server.request(route, json({ text: 'hello' })); + assert.equal(response.status, 503); + assert.equal((await response.json()).error, 'No voice backend configured'); + } + await server.idle(); +}); diff --git a/shared/desktopRestartProtocol.ts b/shared/desktopRestartProtocol.ts new file mode 100644 index 00000000..a29a6474 --- /dev/null +++ b/shared/desktopRestartProtocol.ts @@ -0,0 +1,73 @@ +import { isDesktopUpdateCommand, isDesktopUpdateSnapshot, type DesktopUpdateCommand, type DesktopUpdateSnapshot } from './desktopUpdateProtocol.js'; + +export const DESKTOP_RESTART_PROTOCOL = 1 as const; +export const DESKTOP_RESTART_PREPARE_MS = 5_000; +export const DESKTOP_RESTART_TOKEN_MS = 10_000; +export const isRestartId = (value: unknown): value is string => typeof value === 'string' && value.length === 64 && /^[a-f0-9]+$/u.test(value); +const object = (value: unknown): value is Record => value !== null && typeof value === 'object' && !Array.isArray(value); +const exact = (value: Record, keys: readonly string[]) => Object.keys(value).length === keys.length && keys.every((key) => Object.prototype.hasOwnProperty.call(value, key)); +const positive = (value: unknown, maximum = Number.MAX_SAFE_INTEGER): value is number => typeof value === 'number' && Number.isSafeInteger(value) && value > 0 && value <= maximum; +const token = (value: unknown): value is string => typeof value === 'string' && value.length > 0 && value.length <= 256 + && /^[A-Za-z0-9]/u.test(value) && !/[^A-Za-z0-9._:-]/u.test(value); + +/** Private exchanges used by the injected native wrapper, not new public UI commands. */ +export type DesktopRestartCommand = { action: 'restartPrepared' | 'restartCancel'; attemptId: string; draftEpoch: number }; +export type DesktopNativeCommand = DesktopUpdateCommand | DesktopRestartCommand; +export function isDesktopRestartCommand(value: unknown): value is DesktopRestartCommand { + return object(value) && exact(value, ['action', 'attemptId', 'draftEpoch']) + && (value.action === 'restartPrepared' || value.action === 'restartCancel') + && isRestartId(value.attemptId) && positive(value.draftEpoch); +} +export const isDesktopNativeCommand = (value: unknown): value is DesktopNativeCommand => isDesktopUpdateCommand(value) || isDesktopRestartCommand(value); + +export type DesktopRestartChallenge = { + protocolVersion: 1; kind: 'restartChallenge'; attemptId: string; draftEpoch: number; ttlMs: number; +}; +export type DesktopRestartDisposition = { + protocolVersion: 1; kind: 'restartAborted' | 'restartUncertain'; attemptId: string; draftEpoch: number; snapshot: DesktopUpdateSnapshot; +}; +export type DesktopNativeReply = DesktopUpdateSnapshot | DesktopRestartChallenge | DesktopRestartDisposition; +export function isDesktopNativeReply(value: unknown): value is DesktopNativeReply { + if (isDesktopUpdateSnapshot(value)) return true; + if (!object(value) || value.protocolVersion !== 1 || !isRestartId(value.attemptId) || !positive(value.draftEpoch)) return false; + if (value.kind === 'restartChallenge') return exact(value, ['protocolVersion', 'kind', 'attemptId', 'draftEpoch', 'ttlMs']) + && positive(value.ttlMs, DESKTOP_RESTART_PREPARE_MS); + return (value.kind === 'restartAborted' || value.kind === 'restartUncertain') + && exact(value, ['protocolVersion', 'kind', 'attemptId', 'draftEpoch', 'snapshot']) && isDesktopUpdateSnapshot(value.snapshot); +} + +/** Native-only commands on the separately authenticated backend channel. + * No browser route accepts these frames, and no command installs or kills. */ +export type RestartControlCommand = + | { action: 'status' } + | { action: 'prepare'; attemptId: string; draftEpoch: number; remainingMs: number } + | { action: 'commit'; attemptId: string; token: string } + | { action: 'cancel'; attemptId: string }; +export function isRestartControlCommand(value: unknown): value is RestartControlCommand { + if (!object(value)) return false; + if (value.action === 'status') return exact(value, ['action']); + if (!isRestartId(value.attemptId)) return false; + if (value.action === 'prepare') return exact(value, ['action', 'attemptId', 'draftEpoch', 'remainingMs']) + && positive(value.draftEpoch) && positive(value.remainingMs, DESKTOP_RESTART_PREPARE_MS); + if (value.action === 'commit') return exact(value, ['action', 'attemptId', 'token']) && token(value.token); + return value.action === 'cancel' && exact(value, ['action', 'attemptId']); +} +export type RestartControlResult = { + ok: boolean; state: 'open' | 'preparing' | 'prepared' | 'committed'; + attemptId: string | null; token: string | null; expiresInMs: number | null; error: string | null; +}; +export function isRestartControlResult(value: unknown): value is RestartControlResult { + return object(value) && exact(value, ['ok', 'state', 'attemptId', 'token', 'expiresInMs', 'error']) + && typeof value.ok === 'boolean' && typeof value.state === 'string' && ['open', 'preparing', 'prepared', 'committed'].includes(value.state) + && (value.attemptId === null || isRestartId(value.attemptId)) + && (value.token === null || token(value.token)) + && (value.expiresInMs === null || positive(value.expiresInMs, DESKTOP_RESTART_TOKEN_MS)) + && (value.error === null || (typeof value.error === 'string' && value.error.length > 0 && value.error.length <= 64 && !/[^a-z_]/u.test(value.error))) + && (value.ok ? value.error === null : value.error !== null); +} +export type RestartControlFrame = { protocolVersion: 1; kind: 'restartControl'; id: number; epoch: string; command: RestartControlCommand }; +export function isRestartControlFrame(value: unknown): value is RestartControlFrame { + return object(value) && exact(value, ['protocolVersion', 'kind', 'id', 'epoch', 'command']) + && value.protocolVersion === 1 && value.kind === 'restartControl' && positive(value.id) + && isRestartId(value.epoch) && isRestartControlCommand(value.command); +} diff --git a/shared/desktopUpdateProtocol.ts b/shared/desktopUpdateProtocol.ts new file mode 100644 index 00000000..f505f536 --- /dev/null +++ b/shared/desktopUpdateProtocol.ts @@ -0,0 +1,104 @@ +/** Native-owned updater state. This protocol never grants installation authority. */ +export const DESKTOP_UPDATE_PROTOCOL = 1 as const; +export const DESKTOP_UPDATE_BRIDGE_EVENT = 'gajae:desktop-update-ready'; +export const DESKTOP_UPDATE_BRIDGE_NAME = '__GJC_DESKTOP_UPDATE__'; + +export type DesktopUpdateCommand = + | { action: 'status' | 'check' } + | { action: 'download' | 'restart'; targetId: string } + | { action: 'setAutomatic'; automatic: boolean }; + +export const DESKTOP_UPDATE_PHASES = ['disabled', 'idle', 'checking', 'available', 'downloading', 'verifying', 'ready', 'deferred', 'error', 'applying', 'restarting', 'recovery'] as const; +export type DesktopUpdateSnapshot = { + protocolVersion: typeof DESKTOP_UPDATE_PROTOCOL; + phase: typeof DESKTOP_UPDATE_PHASES[number]; + automatic: boolean; + productVersion: string; + desktopVersion: string; + targetProductVersion: string | null; + targetDesktopVersion: string | null; + /** Native-owned candidate identity; never a URL, path or install authority. */ + targetId: string | null; + discoveryIncomplete: boolean; + reason: string | null; + installationAvailable: boolean; + downloadedBytes: number | null; + totalBytes: number | null; + notes: string | null; +}; + +const record = (value: unknown): value is Record => value !== null && typeof value === 'object' && !Array.isArray(value); +export const isDesktopUpdateTargetId = (value: unknown): value is string => typeof value === 'string' && /^[a-f0-9]{64}$/u.test(value); + +export function isDesktopUpdateCommand(value: unknown): value is DesktopUpdateCommand { + if (!record(value)) return false; + if (value.action === 'setAutomatic') return Object.keys(value).length === 2 && typeof value.automatic === 'boolean'; + if (value.action === 'download' || value.action === 'restart') return Object.keys(value).length === 2 && isDesktopUpdateTargetId(value.targetId); + return Object.keys(value).length === 1 && (value.action === 'status' || value.action === 'check'); +} + +export function isDesktopUpdateSnapshot(value: unknown): value is DesktopUpdateSnapshot { + const keys = ['protocolVersion', 'phase', 'automatic', 'productVersion', 'desktopVersion', 'targetProductVersion', 'targetDesktopVersion', 'targetId', 'discoveryIncomplete', 'reason', 'installationAvailable', 'downloadedBytes', 'totalBytes', 'notes']; + if (!record(value) || Object.keys(value).length !== keys.length || !keys.every((key) => Object.prototype.hasOwnProperty.call(value, key)) + || value.protocolVersion !== DESKTOP_UPDATE_PROTOCOL + || !DESKTOP_UPDATE_PHASES.some((phase) => phase === value.phase) + || typeof value.automatic !== 'boolean' || typeof value.discoveryIncomplete !== 'boolean' + || typeof value.installationAvailable !== 'boolean' + || (value.targetId !== null && !isDesktopUpdateTargetId(value.targetId))) return false; + if (['available', 'downloading', 'verifying', 'ready'].includes(String(value.phase)) + && (value.targetId === null || !value.targetProductVersion || !value.targetDesktopVersion)) return false; + for (const key of ['productVersion', 'desktopVersion']) { + if (typeof value[key] !== 'string' || value[key].length === 0 || value[key].length > 256) return false; + } + for (const key of ['targetProductVersion', 'targetDesktopVersion', 'reason', 'notes']) { + const field = value[key]; + if (field !== null && (typeof field !== 'string' || field.length > (key === 'notes' ? 16_384 : 256))) return false; + } + for (const key of ['downloadedBytes', 'totalBytes']) { + const field = value[key]; + if (field !== null && (typeof field !== 'number' || !Number.isSafeInteger(field) || field < 0)) return false; + } + return value.downloadedBytes === null || value.totalBytes === null || (value.downloadedBytes as number) <= (value.totalBytes as number); +} + +/** Pure page-local draft evidence; none of these fields authorize installation. */ +export type DesktopDraftFreezeRequest = { token: string; epoch: number; ttlMs: number }; +export type DesktopDraftReceipt = { + routeKey: string; revision: number; generation: number; fileCount: number; queuedIntentCount: number; +}; +export type DesktopDraftFreezeReceipt = Readonly<{ + token: string; epoch: number; expiresAt: number; scope: 'page'; installerAuthority: false; + drafts: readonly Readonly[]; +}>; + +/** The injected provider calls these methods in the owning page. isCurrent + * receives the actual prepare result object, never a browser-posted copy. */ +export type DesktopDraftOwner = { + prepare(request: DesktopDraftFreezeRequest): Promise; + isCurrent(receipt: DesktopDraftFreezeReceipt): boolean; + /** Final synchronous input seal. The receipt must be the actual fresh object. */ + seal(receipt: DesktopDraftFreezeReceipt): boolean; + /** Explicit native rollback only after a confirmed pre-commit abort. A sealed + * hold otherwise survives expiry, retirement and same-document replacement. */ + cancel(request: Pick): boolean; +}; + +/** Supplied only to the current native-owned main document. Presence is not authentication. */ +export type DesktopUpdateBridge = { + protocolVersion: typeof DESKTOP_UPDATE_PROTOCOL; + request(command: DesktopUpdateCommand): Promise; + registerDraftOwner?(owner: DesktopDraftOwner): () => void; +}; + +export type DesktopOwnerActivity = { + owner: string; + generation: string; + complete: boolean; + starting: number; + queued: number; + running: number; + settling: number; + approvals: number; + retained: number; + unknown: readonly string[]; +}; diff --git a/shared/fixtures/desktop-update-status.json b/shared/fixtures/desktop-update-status.json new file mode 100644 index 00000000..8457b929 --- /dev/null +++ b/shared/fixtures/desktop-update-status.json @@ -0,0 +1,16 @@ +{ + "protocolVersion": 1, + "phase": "ready", + "automatic": true, + "productVersion": "2.0.0-beta.10", + "desktopVersion": "0.2.4", + "targetProductVersion": "2.0.0-beta.11", + "targetDesktopVersion": "0.2.5", + "targetId": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "discoveryIncomplete": false, + "reason": null, + "installationAvailable": false, + "downloadedBytes": 1024, + "totalBytes": 1024, + "notes": "Signed test fixture. Installation remains gated." +} diff --git a/shared/releaseVersion.js b/shared/releaseVersion.js new file mode 100644 index 00000000..ddfbb7bf --- /dev/null +++ b/shared/releaseVersion.js @@ -0,0 +1,32 @@ +import compare from 'semver/functions/compare.js'; +import parse from 'semver/functions/parse.js'; + +/** @typedef {{ version: string, channel: 'beta' | 'stable' }} ReleaseVersion */ + +/** + * Notification-only product versions, not desktop installation eligibility. + * Accept canonical SemVer with an optional tag prefix; never coerce partial tags. + * @param {unknown} tag + * @returns {ReleaseVersion | null} + */ +export function parseReleaseVersion(tag) { + if (typeof tag !== 'string' || tag.length > 256) return null; + const version = tag.replace(/^v/, ''); + const parsed = parse(version); + if (!parsed) return null; + const canonical = parsed.version + (parsed.build.length ? `+${parsed.build.join('.')}` : ''); + if (canonical !== version) return null; + if (parsed.prerelease.length === 0) return { version, channel: 'stable' }; + if (parsed.prerelease[0] === 'beta') return { version, channel: 'beta' }; + return null; +} + +/** + * Compare already validated product versions; build metadata has no precedence. + * @param {string} first + * @param {string} second + * @returns {number} + */ +export function compareReleaseVersions(first, second) { + return compare(first, second); +} diff --git a/shared/sdkLifecyclePolicy.json b/shared/sdkLifecyclePolicy.json new file mode 100644 index 00000000..6f5a6186 --- /dev/null +++ b/shared/sdkLifecyclePolicy.json @@ -0,0 +1,4 @@ +{ + "schemaVersion": 1, + "maxFiles": 32 +} diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 6095b86e..8d933693 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -640,6 +640,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -1029,13 +1030,14 @@ dependencies = [ [[package]] name = "gajae-app-desktop" -version = "0.2.4" +version = "0.2.6" dependencies = [ "base64 0.22.1", "flate2", "fs2", "futures-util", "getrandom 0.2.17", + "hmac", "libc", "minisign-verify", "plist", @@ -1406,6 +1408,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "html5ever" version = "0.29.1" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b7c2698d..7a654174 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gajae-app-desktop" -version = "0.2.4" +version = "0.2.6" description = "Gajae Code App desktop shell" license = "MIT" authors = ["Gajae Code App contributors"] @@ -33,6 +33,7 @@ tauri-runtime = "=2.7.0" tauri-runtime-wry = "=2.7.0" [target.'cfg(target_os = "macos")'.dependencies] +hmac = "=0.12.1" rustls-webpki = { version = "=0.103.15", default-features = false, features = ["std"] } rustls-pki-types = "=1.15.1" # Pin the updater release selected for the existing Tauri 2.6/runtime diff --git a/src-tauri/examples/qa_profile_init.rs b/src-tauri/examples/qa_profile_init.rs new file mode 100644 index 00000000..250edceb --- /dev/null +++ b/src-tauri/examples/qa_profile_init.rs @@ -0,0 +1,55 @@ +//! Initialize only a fresh, private updater QA profile without opening a window. +//! Reuses the application's profile guard; never manufactures its ownership marker. + +#[cfg(target_os = "macos")] +#[path = "../src/macos_instance.rs"] +#[allow(dead_code)] +mod macos_instance; +#[cfg(target_os = "macos")] +#[path = "../src/qa_profile.rs"] +#[allow(dead_code)] +mod qa_profile; + +#[cfg(target_os = "macos")] +fn main() -> Result<(), Box> { + use std::os::unix::fs::MetadataExt; + let args: Vec<_> = std::env::args_os().skip(1).collect(); + if args.len() != 1 { + return Err("Usage: qa_profile_init /absolute/fresh/gajae-update-qa-*".into()); + } + let root = std::path::PathBuf::from(&args[0]); + let temp = std::env::temp_dir().canonicalize()?; + let metadata = std::fs::symlink_metadata(&root)?; + if root.parent() != Some(temp.as_path()) + || root.canonicalize()? != root + || !root + .file_name() + .is_some_and(|name| name.to_string_lossy().starts_with("gajae-update-qa-")) + || !metadata.is_dir() + || metadata.file_type().is_symlink() + || metadata.uid() != unsafe { libc::geteuid() } + || metadata.mode() & 0o7777 != 0o700 + || std::fs::read_dir(&root)?.next().is_some() + { + return Err("Refusing a nonempty, foreign or noncanonical QA root.".into()); + } + let os = std::process::Command::new("/usr/bin/sw_vers") + .arg("-productVersion") + .output()?; + if !os.status.success() { + return Err("Could not verify QA OS support.".into()); + } + qa_profile::require_supported_os(&String::from_utf8(os.stdout)?)?; + let profile = qa_profile::QaProfile::open(&root)?; + println!( + "Initialized isolated updater QA profile: {}", + profile.root().display() + ); + Ok(()) +} + +#[cfg(not(target_os = "macos"))] +fn main() { + eprintln!("Updater QA profiles require macOS 14 or newer."); + std::process::exit(1); +} diff --git a/src-tauri/examples/support/updater_journal.rs b/src-tauri/examples/support/updater_journal.rs new file mode 100644 index 00000000..e5d36cef --- /dev/null +++ b/src-tauri/examples/support/updater_journal.rs @@ -0,0 +1,599 @@ +//! QA-only durability/ownership proof, not a product updater or an installer. +//! +//! A live handle is minted only after file + directory sync. Parsed records +//! never mint handles. Drop retains blockers. The private, exclusively claimed +//! fixture namespace is assumed cooperative: descriptor identity checks and an +//! exclusive rename are NOT a conditional-inode rename against an adversarial +//! same-UID writer in the final check/rename interval. Product integration needs +//! its own namespace/process proof; this module does not establish G0. + +use std::{ + ffi::{CStr, CString}, + fs::{File, Metadata}, + io::{Read, Seek, SeekFrom, Write}, + marker::PhantomData, + os::{ + fd::{AsRawFd, FromRawFd}, + unix::{ffi::OsStrExt, fs::MetadataExt}, + }, + path::{Component, Path, PathBuf}, + rc::Rc, +}; + +use serde::Serialize; +use sha2::{Digest, Sha256}; + +use crate::updater_attempt::ATTEMPT_RECORD; + +pub const ROOT_PREFIX: &str = "gajae-updater-journal-"; +const CLAIM: &str = ".qa-journal-owner"; +const MAX_RECORD: u64 = 4096; +const MAX_TARGET: u64 = 1024 * 1024; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Error { + Root, + NotFresh, + Ownership, + AlreadyPresent, + Io, + WrongPhase, + TargetMismatch, + #[cfg(test)] + Injected, +} +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "QA journal {self:?}") + } +} +impl std::error::Error for Error {} +pub type Result = std::result::Result; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum InstallerReturn { + Success, + Failed, + Cancelled, + Uncertain, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SyncPoint { + Created, + Truncated, + BeforeFileSync, + FileSynced, + BeforeDirectorySync, + DirectorySynced, +} + +#[derive(Serialize)] +struct Record { + schema: u8, + purpose: &'static str, + attempt_id: String, + owner_pid: u32, + root_device: u64, + root_inode: u64, + expected_target_sha256: String, + state: &'static str, +} + +struct Root { + path: PathBuf, + directory: File, + marker: File, + marker_bytes: Vec, + pid: u32, +} + +/// Cannot reopen a nonempty root or recover authority from a saved claim. +pub struct Journal { + root: Rc, +} + +/// Non-cloneable, non-Send live ownership; forked copies are PID-fenced too. +/// Dropping any unarchived handle intentionally leaves the blocking entry. +#[must_use = "Dropping an attempt retains its startup blocker"] +pub struct Attempt { + root: Rc, + file: File, + bytes: Vec, + record: Record, + expected_digest: [u8; 32], + verified_target: Option<(String, Metadata)>, + poisoned: bool, + _not_send: PhantomData>, +} + +#[derive(Debug)] +pub struct Archived { + pub name: String, +} + +impl Journal { + pub fn claim_fresh(path: &Path) -> Result { + let temp = std::env::temp_dir() + .canonicalize() + .map_err(|_| Error::Root)?; + let name = path + .file_name() + .and_then(|v| v.to_str()) + .ok_or(Error::Root)?; + if path.parent() != Some(temp.as_path()) + || !name.starts_with(ROOT_PREFIX) + || name.len() < ROOT_PREFIX.len() + 6 + || name.len() > ROOT_PREFIX.len() + 80 + || !name.bytes().all(|v| v.is_ascii_alphanumeric() || v == b'-') + || path.canonicalize().map_err(|_| Error::Root)? != path + { + return Err(Error::Root); + } + let directory = open_root(path)?; + private(&directory.metadata().map_err(|_| Error::Io)?, true)?; + // Cooperative process exclusion is held by the directory descriptor. + if unsafe { libc::flock(directory.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } != 0 { + return Err(Error::Ownership); + } + if !empty_directory(&directory)? { + return Err(Error::NotFresh); + } + let marker_bytes = + format!("qa-journal-only:{}:{}\n", std::process::id(), random_id()?).into_bytes(); + let mut marker = create_at(&directory, CLAIM)?; + marker.write_all(&marker_bytes).map_err(|_| Error::Io)?; + marker.sync_all().map_err(|_| Error::Io)?; + directory.sync_all().map_err(|_| Error::Io)?; + // The explicitly fresh root's own directory entry also needs a barrier. + open_root(&temp)?.sync_all().map_err(|_| Error::Io)?; + let root = Rc::new(Root { + path: path.to_owned(), + directory, + marker, + marker_bytes, + pid: std::process::id(), + }); + root.validate()?; + Ok(Self { root }) + } + + pub fn begin(&self, expected_digest: [u8; 32]) -> Result { + self.begin_inner(expected_digest, &mut |_| Ok(())) + } + + fn begin_inner( + &self, + expected_digest: [u8; 32], + observe: &mut dyn FnMut(SyncPoint) -> Result<()>, + ) -> Result { + self.root.validate()?; + let metadata = self.root.directory.metadata().map_err(|_| Error::Io)?; + let record = Record { + schema: 1, + purpose: "isolated-qa-journal-not-install-proof", + attempt_id: random_id()?, + owner_pid: self.root.pid, + root_device: metadata.dev(), + root_inode: metadata.ino(), + expected_target_sha256: hex(&expected_digest), + state: "pending", + }; + let bytes = serde_json::to_vec(&record).map_err(|_| Error::Io)?; + let mut file = create_at(&self.root.directory, ATTEMPT_RECORD)?; + // Any error from here retains the entry, even if empty or partial. + observe(SyncPoint::Created)?; + persist(&mut file, &bytes, &self.root.directory, observe)?; + self.root.validate()?; + named_owned(&self.root.directory, ATTEMPT_RECORD, &file, &bytes)?; + Ok(Attempt { + root: self.root.clone(), + file, + bytes, + record, + expected_digest, + verified_target: None, + poisoned: false, + _not_send: PhantomData, + }) + } + + #[cfg(test)] + pub fn begin_observed( + &self, + expected_digest: [u8; 32], + mut observe: impl FnMut(SyncPoint) -> Result<()>, + ) -> Result { + self.begin_inner(expected_digest, &mut observe) + } +} + +impl Root { + fn validate(&self) -> Result<()> { + // Reject copied live handles in a fork before any filesystem operation. + if std::process::id() != self.pid { + return Err(Error::Ownership); + } + let metadata = self.directory.metadata().map_err(|_| Error::Io)?; + private(&metadata, true)?; + let current = open_root(&self.path)?; + if !same_inode(&metadata, ¤t.metadata().map_err(|_| Error::Io)?) { + return Err(Error::Ownership); + } + named_owned(&self.directory, CLAIM, &self.marker, &self.marker_bytes) + } +} + +impl Attempt { + /// Writes only a small QA sentinel, never an app bundle. Requiring the live + /// attempt makes the simulation itself obey the pre-mutation sync barrier. + pub fn write_qa_target(&self, bytes: &[u8]) -> Result<()> { + self.validate()?; + if !matches!(self.record.state, "pending" | "installer_returned_success") { + return Err(Error::WrongPhase); + } + if bytes.len() as u64 > MAX_TARGET { + return Err(Error::TargetMismatch); + } + let mut file = create_at(&self.root.directory, "qa-target.bin")?; + file.write_all(bytes).map_err(|_| Error::Io)?; + file.sync_all().map_err(|_| Error::Io)?; + self.root.directory.sync_all().map_err(|_| Error::Io)?; + self.root.validate() + } + + fn validate(&self) -> Result<()> { + if self.poisoned { + return Err(Error::Ownership); + } + self.root.validate()?; + named_owned( + &self.root.directory, + ATTEMPT_RECORD, + &self.file, + &self.bytes, + ) + } + + /// Records what the caller says the installer RETURNED, not OS writer exit. + /// This helper never invokes an installer; the probe uses explicit simulation. + pub fn record_installer_returned(&mut self, outcome: InstallerReturn) -> Result<()> { + self.returned_inner(outcome, &mut |_| Ok(())) + } + + fn returned_inner( + &mut self, + outcome: InstallerReturn, + observe: &mut dyn FnMut(SyncPoint) -> Result<()>, + ) -> Result<()> { + if self.record.state != "pending" { + return Err(Error::WrongPhase); + } + let state = match outcome { + InstallerReturn::Success => "installer_returned_success", + InstallerReturn::Failed => "installer_returned_failure", + InstallerReturn::Cancelled => "installer_returned_cancelled", + InstallerReturn::Uncertain => "installer_returned_uncertain", + }; + self.record_state(state, observe) + } + + /// Separately hashes a small, descriptor-opened QA target, never version JSON. + /// This is NOT verification of an installed app signature, notarization or OS support. + pub fn verify_target(&mut self, name: &str) -> Result<()> { + if self.record.state != "installer_returned_success" { + return Err(Error::WrongPhase); + } + self.validate()?; + let (metadata, digest) = target_digest(&self.root.directory, name)?; + if digest != self.expected_digest { + self.poisoned = true; + return Err(Error::TargetMismatch); + } + self.verified_target = Some((name.to_owned(), metadata)); + self.record_state("target_bytes_verified", &mut |_| Ok(())) + } + + fn record_state( + &mut self, + state: &'static str, + observe: &mut dyn FnMut(SyncPoint) -> Result<()>, + ) -> Result<()> { + self.validate()?; + self.poisoned = true; // Any partial write/sync failure permanently blocks this handle. + self.record.state = state; + let bytes = serde_json::to_vec(&self.record).map_err(|_| Error::Io)?; + self.file.set_len(0).map_err(|_| Error::Io)?; + self.file.seek(SeekFrom::Start(0)).map_err(|_| Error::Io)?; + observe(SyncPoint::Truncated)?; + persist(&mut self.file, &bytes, &self.root.directory, observe)?; + self.root.validate()?; + named_owned(&self.root.directory, ATTEMPT_RECORD, &self.file, &bytes)?; + self.bytes = bytes; + self.poisoned = false; + Ok(()) + } + + pub fn archive_verified(mut self) -> Result { + self.validate()?; + if self.record.state != "target_bytes_verified" { + return Err(Error::WrongPhase); + } + let (name, expected) = self.verified_target.as_ref().ok_or(Error::WrongPhase)?; + let (current, digest) = target_digest(&self.root.directory, name)?; + if !same_inode(expected, ¤t) || digest != self.expected_digest { + return Err(Error::TargetMismatch); + } + let archive = format!( + "desktop-update-attempt.{}.verified.json", + self.record.attempt_id + ); + // Exclusive destination: never overwrite an unrelated archive. The + // source was descriptor-checked; this is a cooperative QA namespace, + // not a claim of an atomic inode-conditional rename against same-UID races. + self.validate()?; + rename_exclusive(&self.root.directory, ATTEMPT_RECORD, &archive)?; + let verified = named_owned(&self.root.directory, &archive, &self.file, &self.bytes) + .and_then(|()| self.root.directory.sync_all().map_err(|_| Error::Io)); + if verified.is_err() { + // Best-effort no-replace restoration of the EXACT owned inode only. + // Never overwrite a substitute that now occupies the canonical name. + if named_owned(&self.root.directory, &archive, &self.file, &self.bytes).is_ok() { + let _ = rename_exclusive(&self.root.directory, &archive, ATTEMPT_RECORD); + let _ = self.root.directory.sync_all(); + } + return Err(Error::Io); + } + self.poisoned = true; + Ok(Archived { name: archive }) + } + + #[cfg(test)] + pub fn returned_observed( + &mut self, + outcome: InstallerReturn, + mut observe: impl FnMut(SyncPoint) -> Result<()>, + ) -> Result<()> { + self.returned_inner(outcome, &mut observe) + } + + #[cfg(test)] + pub fn owner_pid_matches(&self) -> bool { + std::process::id() == self.root.pid + } +} + +fn persist( + file: &mut File, + bytes: &[u8], + directory: &File, + observe: &mut dyn FnMut(SyncPoint) -> Result<()>, +) -> Result<()> { + if bytes.len() as u64 > MAX_RECORD { + return Err(Error::Io); + } + file.write_all(bytes).map_err(|_| Error::Io)?; + observe(SyncPoint::BeforeFileSync)?; + file.sync_all().map_err(|_| Error::Io)?; + observe(SyncPoint::FileSynced)?; + observe(SyncPoint::BeforeDirectorySync)?; + directory.sync_all().map_err(|_| Error::Io)?; + observe(SyncPoint::DirectorySynced) +} + +fn target_digest(directory: &File, name: &str) -> Result<(Metadata, [u8; 32])> { + if name == ATTEMPT_RECORD || name == CLAIM { + return Err(Error::TargetMismatch); + } + let file = open_at(directory, name, libc::O_RDONLY | libc::O_NONBLOCK, 0)?; + let before = file.metadata().map_err(|_| Error::Io)?; + private(&before, false)?; + if before.len() > MAX_TARGET { + return Err(Error::TargetMismatch); + } + let bytes = read_bounded(&file, MAX_TARGET)?; + let after = file.metadata().map_err(|_| Error::Io)?; + private(&after, false)?; + if before.len() != after.len() + || before.mtime() != after.mtime() + || before.mtime_nsec() != after.mtime_nsec() + { + return Err(Error::TargetMismatch); + } + Ok((after, Sha256::digest(bytes).into())) +} + +fn named_owned(directory: &File, name: &str, owned: &File, expected: &[u8]) -> Result<()> { + let current = open_at(directory, name, libc::O_RDONLY | libc::O_NONBLOCK, 0)?; + let metadata = current.metadata().map_err(|_| Error::Io)?; + let original = owned.metadata().map_err(|_| Error::Io)?; + private(&metadata, false)?; + private(&original, false)?; + if !same_inode(&metadata, &original) || read_bounded(¤t, MAX_RECORD)? != expected { + return Err(Error::Ownership); + } + Ok(()) +} + +fn read_bounded(file: &File, limit: u64) -> Result> { + let mut file = file.try_clone().map_err(|_| Error::Io)?; + file.seek(SeekFrom::Start(0)).map_err(|_| Error::Io)?; + let mut bytes = Vec::new(); + file.take(limit + 1) + .read_to_end(&mut bytes) + .map_err(|_| Error::Io)?; + if bytes.len() as u64 > limit { + return Err(Error::Ownership); + } + Ok(bytes) +} + +fn private(metadata: &Metadata, directory: bool) -> Result<()> { + if metadata.uid() != unsafe { libc::geteuid() } + || metadata.mode() & 0o7777 != if directory { 0o700 } else { 0o600 } + || (directory && !metadata.is_dir()) + || (!directory && (!metadata.is_file() || metadata.nlink() != 1)) + { + return Err(Error::Ownership); + } + Ok(()) +} + +fn same_inode(a: &Metadata, b: &Metadata) -> bool { + a.dev() == b.dev() && a.ino() == b.ino() +} +fn component(name: &str) -> Result { + if name.is_empty() || name.len() > 255 || name == "." || name == ".." || name.contains('/') { + return Err(Error::Root); + } + CString::new(name).map_err(|_| Error::Root) +} +fn open_at(directory: &File, name: &str, flags: i32, mode: libc::mode_t) -> Result { + let name = component(name)?; + let fd = unsafe { + libc::openat( + directory.as_raw_fd(), + name.as_ptr(), + flags | libc::O_NOFOLLOW | libc::O_CLOEXEC, + mode as libc::c_uint, + ) + }; + if fd < 0 { + return Err( + if std::io::Error::last_os_error().kind() == std::io::ErrorKind::AlreadyExists { + Error::AlreadyPresent + } else { + Error::Io + }, + ); + } + Ok(unsafe { File::from_raw_fd(fd) }) +} +fn create_at(directory: &File, name: &str) -> Result { + let file = open_at( + directory, + name, + libc::O_RDWR | libc::O_CREAT | libc::O_EXCL | libc::O_NONBLOCK, + 0o600, + )?; + private(&file.metadata().map_err(|_| Error::Io)?, false)?; + Ok(file) +} +fn open_root(path: &Path) -> Result { + if !path.is_absolute() + || path + .components() + .any(|c| matches!(c, Component::CurDir | Component::ParentDir)) + { + return Err(Error::Root); + } + let mut directory = File::open("/").map_err(|_| Error::Root)?; + for part in path.components() { + if let Component::Normal(name) = part { + directory = open_at( + &directory, + name.to_str().ok_or(Error::Root)?, + libc::O_RDONLY | libc::O_DIRECTORY, + 0, + )?; + } + } + Ok(directory) +} +fn empty_directory(directory: &File) -> Result { + let fd = unsafe { libc::dup(directory.as_raw_fd()) }; + if fd < 0 { + return Err(Error::Io); + } + let stream = unsafe { libc::fdopendir(fd) }; + if stream.is_null() { + unsafe { libc::close(fd) }; + return Err(Error::Io); + } + let mut empty = true; + let mut failed = false; + loop { + #[cfg(target_os = "macos")] + let errno = unsafe { libc::__error() }; + #[cfg(target_os = "linux")] + let errno = unsafe { libc::__errno_location() }; + unsafe { *errno = 0 }; + let entry = unsafe { libc::readdir(stream) }; + if entry.is_null() { + failed = unsafe { *errno != 0 }; + break; + } + let name = unsafe { CStr::from_ptr((*entry).d_name.as_ptr()) }.to_bytes(); + if name != b"." && name != b".." { + empty = false; + break; + } + } + if unsafe { libc::closedir(stream) } != 0 || failed { + Err(Error::Io) + } else { + Ok(empty) + } +} +fn rename_exclusive(directory: &File, from: &str, to: &str) -> Result<()> { + let from = component(from)?; + let to = component(to)?; + #[cfg(target_os = "macos")] + let result = unsafe { + libc::renameatx_np( + directory.as_raw_fd(), + from.as_ptr(), + directory.as_raw_fd(), + to.as_ptr(), + libc::RENAME_EXCL, + ) + }; + #[cfg(target_os = "linux")] + let result = unsafe { + libc::syscall( + libc::SYS_renameat2, + directory.as_raw_fd(), + from.as_ptr(), + directory.as_raw_fd(), + to.as_ptr(), + libc::RENAME_NOREPLACE, + ) as i32 + }; + if result != 0 { + return Err(Error::Io); + } + Ok(()) +} +fn random_id() -> Result { + let mut bytes = [0u8; 16]; + getrandom::getrandom(&mut bytes).map_err(|_| Error::Io)?; + Ok(hex(&bytes)) +} +fn hex(bytes: &[u8]) -> String { + const DIGITS: &[u8; 16] = b"0123456789abcdef"; + let mut value = String::with_capacity(bytes.len() * 2); + for byte in bytes { + value.push(DIGITS[(byte >> 4) as usize] as char); + value.push(DIGITS[(byte & 15) as usize] as char); + } + value +} + +/// Tests/probe callers create the NEW root explicitly, never load an existing one. +pub fn create_fresh_temp_root() -> Result { + let temp = std::env::temp_dir() + .canonicalize() + .map_err(|_| Error::Root)?; + let mut template = temp + .join(format!("{ROOT_PREFIX}XXXXXX")) + .as_os_str() + .as_bytes() + .to_vec(); + template.push(0); + let created = unsafe { libc::mkdtemp(template.as_mut_ptr().cast()) }; + if created.is_null() { + return Err(Error::Io); + } + let bytes = unsafe { CStr::from_ptr(created) }.to_bytes(); + Ok(PathBuf::from(std::ffi::OsStr::from_bytes(bytes))) +} diff --git a/src-tauri/examples/updater_journal_probe.rs b/src-tauri/examples/updater_journal_probe.rs new file mode 100644 index 00000000..740304e5 --- /dev/null +++ b/src-tauri/examples/updater_journal_probe.rs @@ -0,0 +1,577 @@ +//! Isolated journal proof only. No Tauri app, official installer or server starts. +//! Default invocation only explains usage. Mutation requires a newly created, +//! empty private temp root; the CLI never resumes a record loaded from disk. + +#[cfg(any(target_os = "macos", target_os = "linux"))] +#[path = "../src/updater_attempt.rs"] +// This standalone historical probe uses only the admission reader. The live +// successor/health journal is exercised by the application binary's tests. +#[allow(dead_code)] +mod updater_attempt; +#[cfg(any(target_os = "macos", target_os = "linux"))] +#[path = "support/updater_journal.rs"] +mod updater_journal; + +#[cfg(any(target_os = "macos", target_os = "linux"))] +fn main() -> Result<(), Box> { + use sha2::{Digest, Sha256}; + use updater_journal::{InstallerReturn, Journal}; + let args: Vec<_> = std::env::args_os().skip(1).collect(); + if args.is_empty() || args == ["--help"] { + println!("QA journal proof only; no installation or G0 acceptance.\nUsage:\n updater_journal_probe --fresh-qa-root /canonical/temp/gajae-updater-journal-XXXXXX --simulate retain|success|failure|cancelled|uncertain\n updater_journal_probe --new-temp --simulate retain|success|failure|cancelled|uncertain\nExisting nonempty roots are never resumed or cleared."); + return Ok(()); + } + let (root, scenario) = + if args.len() == 4 && args[0] == "--fresh-qa-root" && args[2] == "--simulate" { + (Some(std::path::PathBuf::from(&args[1])), args[3].to_str()) + } else if args.len() == 3 && args[0] == "--new-temp" && args[1] == "--simulate" { + (None, args[2].to_str()) + } else { + return Err("Use --help; no mutation performed.".into()); + }; + let scenario = scenario + .filter(|s| ["retain", "success", "failure", "cancelled", "uncertain"].contains(s)) + .ok_or("Unknown simulation; no mutation performed.")?; + let root = match root { + Some(root) => root, + None => updater_journal::create_fresh_temp_root()?, + }; + let journal = Journal::claim_fresh(&root)?; + updater_attempt::check(&root)?; + let sentinel = b"isolated QA target B -- not an installed app\n"; + let mut attempt = journal.begin(Sha256::digest(sentinel).into())?; + if updater_attempt::check(&root).is_ok() { + return Err("Guard disagreement after journal publication.".into()); + } + let archive = match scenario { + "success" => { + attempt.write_qa_target(sentinel)?; + attempt.record_installer_returned(InstallerReturn::Success)?; + attempt.verify_target("qa-target.bin")?; + Some(attempt.archive_verified()?.name) + } + "retain" => { + drop(attempt); + None + } + value => { + attempt.record_installer_returned(match value { + "failure" => InstallerReturn::Failed, + "cancelled" => InstallerReturn::Cancelled, + _ => InstallerReturn::Uncertain, + })?; + drop(attempt); + None + } + }; + let blocked = updater_attempt::check(&root).is_err(); + if blocked == archive.is_some() { + return Err("Guard disagreement after simulation.".into()); + } + println!( + "{}", + serde_json::json!({ + "proof": "qa-journal-simulation-only", "root": root, "scenario": scenario, + "startupBlocked": blocked, "archive": archive, "officialInstallerCalled": false, + "osWriterTerminationProven": false, "g0Accepted": false, + "sameUidConcurrentNamespaceMutationProven": false, + }) + ); + Ok(()) +} + +#[cfg(not(any(target_os = "macos", target_os = "linux")))] +fn main() { + println!("QA journal probe is unavailable on this platform. No mutation performed."); +} + +#[cfg(all(test, any(target_os = "macos", target_os = "linux")))] +mod tests { + use super::{ + updater_attempt::{check, ATTEMPT_RECORD}, + updater_journal::{self, Error, InstallerReturn, Journal, SyncPoint}, + }; + use sha2::{Digest, Sha256}; + use std::{ + fs::{self, OpenOptions}, + io::Write, + os::unix::fs::{symlink, MetadataExt, OpenOptionsExt, PermissionsExt}, + path::{Path, PathBuf}, + process::{Command, Stdio}, + time::{Duration, Instant}, + }; + + const TARGET: &[u8] = b"isolated target B fixture\n"; + fn digest() -> [u8; 32] { + Sha256::digest(TARGET).into() + } + struct Temp(PathBuf); + impl Temp { + fn new() -> Self { + Self(updater_journal::create_fresh_temp_root().unwrap()) + } + } + impl Drop for Temp { + fn drop(&mut self) { + // Only directories allocated by this fixture; process children have been joined. + let _ = fs::remove_dir_all(&self.0); + } + } + fn write_private(path: &Path, bytes: &[u8]) { + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(path) + .unwrap(); + file.write_all(bytes).unwrap(); + file.sync_all().unwrap(); + } + + #[test] + fn real_guard_agrees_before_publication_through_verified_archive() { + let root = Temp::new(); + assert!(check(&root.0).is_ok()); + let journal = Journal::claim_fresh(&root.0).unwrap(); + assert!(check(&root.0).is_ok()); + let mut stages = Vec::new(); + let mut attempt = journal + .begin_observed(digest(), |stage| { + stages.push(stage); + Ok(()) + }) + .unwrap(); + assert_eq!( + stages, + [ + SyncPoint::Created, + SyncPoint::BeforeFileSync, + SyncPoint::FileSynced, + SyncPoint::BeforeDirectorySync, + SyncPoint::DirectorySynced + ] + ); + assert!(check(&root.0).is_err()); + attempt.write_qa_target(TARGET).unwrap(); + attempt + .record_installer_returned(InstallerReturn::Success) + .unwrap(); + assert!(check(&root.0).is_err()); + attempt.verify_target("qa-target.bin").unwrap(); + assert!(check(&root.0).is_err()); + let record = root.0.join(ATTEMPT_RECORD); + let before = fs::read(&record).unwrap(); + let identity = fs::metadata(&record).unwrap(); + let archive = attempt.archive_verified().unwrap(); + assert!(check(&root.0).is_ok()); + let archived = root.0.join(archive.name); + assert_eq!(fs::read(&archived).unwrap(), before); + assert_eq!(fs::metadata(&archived).unwrap().ino(), identity.ino()); + assert_eq!(fs::metadata(&archived).unwrap().nlink(), 1); + } + + #[test] + fn failed_cancelled_uncertain_and_drop_keep_canonical_blocker() { + for outcome in [ + None, + Some(InstallerReturn::Failed), + Some(InstallerReturn::Cancelled), + Some(InstallerReturn::Uncertain), + Some(InstallerReturn::Success), + ] { + let root = Temp::new(); + let journal = Journal::claim_fresh(&root.0).unwrap(); + let mut attempt = journal.begin(digest()).unwrap(); + if let Some(outcome) = outcome { + attempt.record_installer_returned(outcome).unwrap(); + } + drop(attempt); + drop(journal); + let bytes = fs::read(root.0.join(ATTEMPT_RECORD)).unwrap(); + assert!(check(&root.0).is_err()); + assert!(Journal::claim_fresh(&root.0).is_err()); + assert_eq!(fs::read(root.0.join(ATTEMPT_RECORD)).unwrap(), bytes); + } + } + + #[test] + fn success_return_alone_or_version_flags_cannot_clear_admission() { + let root = Temp::new(); + let journal = Journal::claim_fresh(&root.0).unwrap(); + let mut attempt = journal.begin(digest()).unwrap(); + attempt + .record_installer_returned(InstallerReturn::Success) + .unwrap(); + assert!(attempt.archive_verified().is_err()); + assert!(check(&root.0).is_err()); + for bytes in [b"".as_slice(), b"{", br#"{"installer_returned":true,"target_verified":true,"state":"relaunch","target_desktop_version":"0.2.4"}"#] { + let other = Temp::new(); write_private(&other.0.join(ATTEMPT_RECORD), bytes); + assert!(check(&other.0).is_err()); + assert!(Journal::claim_fresh(&other.0).is_err()); + assert_eq!(fs::read(other.0.join(ATTEMPT_RECORD)).unwrap(), bytes); + } + } + + #[test] + fn failed_cancelled_or_uncertain_return_cannot_be_upgraded_by_later_booleans() { + for outcome in [ + InstallerReturn::Failed, + InstallerReturn::Cancelled, + InstallerReturn::Uncertain, + ] { + let root = Temp::new(); + let journal = Journal::claim_fresh(&root.0).unwrap(); + let mut attempt = journal.begin(digest()).unwrap(); + attempt.record_installer_returned(outcome).unwrap(); + let before = fs::read(root.0.join(ATTEMPT_RECORD)).unwrap(); + assert_eq!( + attempt.record_installer_returned(InstallerReturn::Success), + Err(Error::WrongPhase) + ); + assert_eq!(attempt.write_qa_target(TARGET), Err(Error::WrongPhase)); + assert_eq!( + attempt.verify_target("qa-target.bin"), + Err(Error::WrongPhase) + ); + assert!(attempt.archive_verified().is_err()); + assert!(check(&root.0).is_err()); + assert_eq!(fs::read(root.0.join(ATTEMPT_RECORD)).unwrap(), before); + } + } + + #[test] + fn existing_symlink_hardlink_and_nonregular_record_are_not_opened_for_writing() { + for kind in ["symlink", "hardlink", "directory"] { + let root = Temp::new(); + let journal = Journal::claim_fresh(&root.0).unwrap(); + let victim = root.0.join("keep.bin"); + write_private(&victim, b"keep exact bytes"); + let record = root.0.join(ATTEMPT_RECORD); + match kind { + "symlink" => symlink(&victim, &record).unwrap(), + "hardlink" => fs::hard_link(&victim, &record).unwrap(), + _ => fs::create_dir(&record).unwrap(), + } + assert!(matches!( + journal.begin(digest()), + Err(Error::AlreadyPresent) + )); + assert!(check(&root.0).is_err()); + assert_eq!(fs::read(victim).unwrap(), b"keep exact bytes"); + } + } + + #[test] + fn duplicate_attempts_do_not_replace_or_truncate_the_first() { + let root = Temp::new(); + let journal = Journal::claim_fresh(&root.0).unwrap(); + let first = journal.begin(digest()).unwrap(); + let bytes = fs::read(root.0.join(ATTEMPT_RECORD)).unwrap(); + assert!(matches!( + journal.begin(digest()), + Err(Error::AlreadyPresent) + )); + assert!(Journal::claim_fresh(&root.0).is_err()); + drop(first); + assert_eq!(fs::read(root.0.join(ATTEMPT_RECORD)).unwrap(), bytes); + assert!(check(&root.0).is_err()); + } + + #[test] + fn no_live_handle_or_target_mutation_before_both_sync_barriers() { + for fault in [ + SyncPoint::Created, + SyncPoint::BeforeFileSync, + SyncPoint::FileSynced, + SyncPoint::BeforeDirectorySync, + SyncPoint::DirectorySynced, + ] { + let root = Temp::new(); + let journal = Journal::claim_fresh(&root.0).unwrap(); + let result = journal.begin_observed(digest(), |point| { + if point == fault { + Err(Error::Injected) + } else { + Ok(()) + } + }); + let live = result.map(|attempt| attempt.write_qa_target(TARGET)); + assert!(matches!(live, Err(Error::Injected))); + assert!(!root.0.join("qa-target.bin").exists()); + assert!(check(&root.0).is_err()); + } + } + + #[test] + fn truncated_state_write_poison_keeps_record_and_cannot_upgrade_to_success() { + let root = Temp::new(); + let journal = Journal::claim_fresh(&root.0).unwrap(); + let mut attempt = journal.begin(digest()).unwrap(); + assert_eq!( + attempt.returned_observed(InstallerReturn::Success, |point| { + if point == SyncPoint::Truncated { + Err(Error::Injected) + } else { + Ok(()) + } + }), + Err(Error::Injected) + ); + assert_eq!(fs::metadata(root.0.join(ATTEMPT_RECORD)).unwrap().len(), 0); + assert!(attempt.archive_verified().is_err()); + assert!(check(&root.0).is_err()); + } + + #[test] + fn replaced_record_same_bytes_symlink_hardlink_or_foreign_mode_is_never_accepted() { + for replacement in ["same-bytes", "symlink", "hardlink", "mode"] { + let root = Temp::new(); + let journal = Journal::claim_fresh(&root.0).unwrap(); + let mut attempt = journal.begin(digest()).unwrap(); + let record = root.0.join(ATTEMPT_RECORD); + let bytes = fs::read(&record).unwrap(); + let kept = root.0.join("owned-original.json"); + if replacement == "mode" { + fs::set_permissions(&record, fs::Permissions::from_mode(0o644)).unwrap(); + } else { + fs::rename(&record, &kept).unwrap(); + match replacement { + "same-bytes" => write_private(&record, &bytes), + "symlink" => symlink(&kept, &record).unwrap(), + _ => fs::hard_link(&kept, &record).unwrap(), + } + } + assert!(attempt + .record_installer_returned(InstallerReturn::Success) + .is_err()); + assert!(attempt.archive_verified().is_err()); + assert!(check(&root.0).is_err()); + assert_eq!(fs::read(&record).unwrap(), bytes); + } + } + + #[test] + fn target_verification_is_independent_and_rechecked_before_archive() { + for change in [ + "wrong-bytes", + "changed-after-verification", + "replaced-same-bytes", + ] { + let root = Temp::new(); + let journal = Journal::claim_fresh(&root.0).unwrap(); + let mut attempt = journal.begin(digest()).unwrap(); + attempt + .record_installer_returned(InstallerReturn::Success) + .unwrap(); + attempt + .write_qa_target(if change == "wrong-bytes" { + b"wrong" + } else { + TARGET + }) + .unwrap(); + if change == "wrong-bytes" { + assert!(attempt.verify_target("qa-target.bin").is_err()); + } else { + attempt.verify_target("qa-target.bin").unwrap(); + let target = root.0.join("qa-target.bin"); + if change == "changed-after-verification" { + fs::write(&target, b"changed").unwrap(); + } else { + fs::rename(&target, root.0.join("old-target.bin")).unwrap(); + write_private(&target, TARGET); + } + } + assert!(attempt.archive_verified().is_err()); + assert!(check(&root.0).is_err()); + } + } + + #[test] + fn fresh_root_rejects_symlink_alias_nonempty_wrong_mode_relative_and_parent_paths() { + let root = Temp::new(); + fs::set_permissions(&root.0, fs::Permissions::from_mode(0o755)).unwrap(); + assert!(Journal::claim_fresh(&root.0).is_err()); + fs::set_permissions(&root.0, fs::Permissions::from_mode(0o700)).unwrap(); + let alias = root.0.with_file_name(format!( + "{}-alias", + root.0.file_name().unwrap().to_str().unwrap() + )); + symlink(&root.0, &alias).unwrap(); + assert!(Journal::claim_fresh(&alias).is_err()); + fs::remove_file(alias).unwrap(); + assert!(Journal::claim_fresh(Path::new("relative")).is_err()); + assert!(Journal::claim_fresh(&root.0.join("..")).is_err()); + write_private(&root.0.join("keep"), b"unrelated"); + assert!(Journal::claim_fresh(&root.0).is_err()); + assert_eq!(fs::read(root.0.join("keep")).unwrap(), b"unrelated"); + } + + #[test] + fn archive_destination_collision_never_overwrites_existing_entry() { + let root = Temp::new(); + let journal = Journal::claim_fresh(&root.0).unwrap(); + let mut attempt = journal.begin(digest()).unwrap(); + attempt.write_qa_target(TARGET).unwrap(); + attempt + .record_installer_returned(InstallerReturn::Success) + .unwrap(); + attempt.verify_target("qa-target.bin").unwrap(); + let value: serde_json::Value = + serde_json::from_slice(&fs::read(root.0.join(ATTEMPT_RECORD)).unwrap()).unwrap(); + let name = format!( + "desktop-update-attempt.{}.verified.json", + value["attempt_id"].as_str().unwrap() + ); + write_private(&root.0.join(&name), b"unrelated archive"); + assert!(attempt.archive_verified().is_err()); + assert_eq!(fs::read(root.0.join(name)).unwrap(), b"unrelated archive"); + assert!(check(&root.0).is_err()); + } + + fn child(root: &Temp, scenario: &str) -> std::process::ExitStatus { + let mut child = Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "tests::process_fault_child", + "--ignored", + "--nocapture", + ]) + .env("GJC_JOURNAL_QA_ROOT", &root.0) + .env("GJC_JOURNAL_FAULT", scenario) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(5); + loop { + if let Some(status) = child.try_wait().unwrap() { + return status; + } + if Instant::now() >= deadline { + child.kill().unwrap(); + child.wait().unwrap(); + panic!("Owned QA child timed out."); + } + std::thread::sleep(Duration::from_millis(10)); + } + } + + #[test] + fn process_faults_retain_blockers_and_never_resume_from_saved_success_flags() { + for scenario in [ + "create", + "before-file-sync", + "file-synced", + "directory-synced", + "live", + "truncated", + "returned", + "verified", + "substituted", + ] { + let root = Temp::new(); + assert_eq!( + child(&root, scenario).code(), + Some(73), + "scenario {scenario}" + ); + assert!(check(&root.0).is_err(), "scenario {scenario}"); + assert!(Journal::claim_fresh(&root.0).is_err()); + if scenario != "verified" && scenario != "substituted" { + assert!(!root.0.join("qa-target.bin").exists()); + } + } + } + + #[test] + fn duplicate_process_cannot_claim_live_root() { + let root = Temp::new(); + let journal = Journal::claim_fresh(&root.0).unwrap(); + let _attempt = journal.begin(digest()).unwrap(); + let before = fs::read(root.0.join(ATTEMPT_RECORD)).unwrap(); + assert_eq!(child(&root, "duplicate").code(), Some(74)); + assert!(check(&root.0).is_err()); + assert_eq!(fs::read(root.0.join(ATTEMPT_RECORD)).unwrap(), before); + } + + #[test] + fn forked_copy_is_not_the_live_owner() { + let root = Temp::new(); + let journal = Journal::claim_fresh(&root.0).unwrap(); + let attempt = journal.begin(digest()).unwrap(); + let pid = unsafe { libc::fork() }; + assert!(pid >= 0); + if pid == 0 { + // No allocation/locks/filesystem operations in the forked child. + unsafe { libc::_exit(if attempt.owner_pid_matches() { 1 } else { 0 }) }; + } + let mut status = 0; + assert_eq!(unsafe { libc::waitpid(pid, &mut status, 0) }, pid); + assert!(libc::WIFEXITED(status)); + assert_eq!(libc::WEXITSTATUS(status), 0); + assert!(check(&root.0).is_err()); + } + + #[test] + #[ignore = "Only spawned with an explicitly fresh private QA root by process-fault tests"] + fn process_fault_child() { + let root = PathBuf::from(std::env::var_os("GJC_JOURNAL_QA_ROOT").expect("isolated root")); + let scenario = std::env::var("GJC_JOURNAL_FAULT").expect("fault scenario"); + if scenario == "duplicate" { + unsafe { + libc::_exit(if Journal::claim_fresh(&root).is_err() { + 74 + } else { + 1 + }) + }; + } + let journal = Journal::claim_fresh(&root).unwrap(); + let mut attempt = journal + .begin_observed(digest(), |point| { + let stop = matches!( + (scenario.as_str(), point), + ("create", SyncPoint::Created) + | ("before-file-sync", SyncPoint::BeforeFileSync) + | ("file-synced", SyncPoint::FileSynced) + | ("directory-synced", SyncPoint::DirectorySynced) + ); + if stop { + unsafe { libc::_exit(73) }; + } + Ok(()) + }) + .unwrap(); + if scenario == "live" { + unsafe { libc::_exit(73) }; + } + attempt + .returned_observed(InstallerReturn::Success, |point| { + if scenario == "truncated" && point == SyncPoint::Truncated { + unsafe { libc::_exit(73) }; + } + Ok(()) + }) + .unwrap(); + if scenario == "returned" { + unsafe { libc::_exit(73) }; + } + if scenario == "verified" || scenario == "substituted" { + attempt.write_qa_target(TARGET).unwrap(); + attempt.verify_target("qa-target.bin").unwrap(); + if scenario == "substituted" { + let record = root.join(ATTEMPT_RECORD); + let bytes = fs::read(&record).unwrap(); + fs::rename(&record, root.join("owned-before-substitution.json")).unwrap(); + write_private(&record, &bytes); + assert!(attempt.archive_verified().is_err()); + assert_eq!(fs::read(&record).unwrap(), bytes); + assert!(check(&root).is_err()); + } + unsafe { libc::_exit(73) }; + } + panic!("Unknown process fault scenario."); + } +} diff --git a/src-tauri/src/desktop_deep_links.rs b/src-tauri/src/desktop_deep_links.rs new file mode 100644 index 00000000..4a91b5ce --- /dev/null +++ b/src-tauri/src/desktop_deep_links.rs @@ -0,0 +1,449 @@ +//! Bounded notification navigation, not installer or browser authority. +//! Pending macOS links survive process replacement. Delivery is at-least-once: +//! a crash after navigation but before its durable ACK can repeat root focus. +use std::sync::Mutex; + +use tauri::{webview::PageLoadEvent, Url}; + +const MAX_LINKS: usize = 16; + +pub(crate) fn route(url: &Url) -> Option { + if url.scheme() != "gajae-app" + || url.host_str() != Some("open") + || !url.username().is_empty() + || url.password().is_some() + || url.port().is_some() + || url.query().is_some() + || url.fragment().is_some() + || url.as_str().len() > 256 + { + return None; + } + let segments: Vec<_> = url.path_segments()?.collect(); + match segments.as_slice() { + ["job", id] + if !id.is_empty() + && id.len() <= 128 + && id + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | ':' | '-')) => + { + Some("/".into()) + } + _ => None, + } +} + +#[derive(Clone)] +pub(crate) struct Delivery { + epoch: u64, + sequence: u64, + pub(crate) urls: Vec, +} + +#[derive(Default)] +struct State { + urls: Vec, + ready: bool, + epoch: u64, + sequence: u64, + in_flight: Option, + #[cfg(target_os = "macos")] + persistence: Option, +} + +#[cfg(target_os = "macos")] +struct Persistence { + root: std::path::PathBuf, + loaded: bool, +} + +impl State { + fn load(&mut self) -> Result<(), String> { + #[cfg(target_os = "macos")] + if let Some(persistence) = self.persistence.as_mut() { + if !persistence.loaded { + let stored = crate::updater_store::LinkStore::open(&persistence.root)?.read()?; + let mut urls = Vec::new(); + for text in stored { + let url = text + .parse::() + .map_err(|_| "Invalid stored desktop link.")?; + if route(&url).is_none() { + return Err("Invalid stored desktop link.".into()); + } + urls.push(url); + } + if urls.len() + self.urls.len() > MAX_LINKS { + return Err("Pending desktop links are full.".into()); + } + urls.append(&mut self.urls); + self.urls = urls; + persistence.loaded = true; + } + } + Ok(()) + } + + fn save(&self, urls: &[Url]) -> Result<(), String> { + #[cfg(target_os = "macos")] + if let Some(persistence) = &self.persistence { + return crate::updater_store::LinkStore::open(&persistence.root)? + .write(urls.iter().map(|url| url.as_str().to_owned()).collect()); + } + let _ = urls; + Ok(()) + } + + fn delivery(&mut self) -> Option { + if !self.ready || self.urls.is_empty() || self.in_flight.is_some() { + return None; + } + self.sequence = self.sequence.checked_add(1)?; + self.in_flight = Some(self.sequence); + Some(Delivery { + epoch: self.epoch, + sequence: self.sequence, + urls: self.urls.clone(), + }) + } +} + +#[derive(Default)] +pub(crate) struct StartupDeepLinks(Mutex); + +impl StartupDeepLinks { + pub(crate) fn new(urls: Vec) -> Self { + Self(Mutex::new(State { + urls: urls + .into_iter() + .filter(|url| route(url).is_some()) + .take(MAX_LINKS) + .collect(), + ..State::default() + })) + } + + #[cfg(target_os = "macos")] + pub(crate) fn persistent(root: std::path::PathBuf) -> Self { + let value = Self::new(Vec::new()); + value.0.lock().expect("new desktop links lock").persistence = Some(Persistence { + root, + loaded: false, + }); + value + } + + pub(crate) fn receive(&self, urls: Vec) -> Result, String> { + let mut state = self.0.lock().map_err(|_| "Desktop links lock failed.")?; + // Keep in-memory arrivals if the data root has not been created yet. + let loaded = state.load(); + let valid: Vec<_> = urls + .into_iter() + .filter(|url| route(url).is_some()) + .take(MAX_LINKS + 1) + .collect(); + if state.urls.len() + valid.len() > MAX_LINKS { + return Err("Pending desktop links are full.".into()); + } + state.urls.extend(valid); + loaded?; + state.save(&state.urls)?; + Ok(state.delivery()) + } + + pub(crate) fn reset(&self) { + if let Ok(mut state) = self.0.lock() { + state.ready = false; + state.epoch = state.epoch.wrapping_add(1); + state.in_flight = None; + } + } + + pub(crate) fn take_for_page( + &self, + label: &str, + url: &Url, + event: PageLoadEvent, + ) -> Result, String> { + if label == "main" && event == PageLoadEvent::Started { + self.reset(); + } + if label != "main" + || event != PageLoadEvent::Finished + || url.scheme() != "http" + || url.host_str() != Some("127.0.0.1") + || url.path() != "/" + || !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + { + return Ok(None); + } + let mut state = self.0.lock().map_err(|_| "Desktop links lock failed.")?; + state.load()?; + state.save(&state.urls)?; + state.ready = true; + Ok(state.delivery()) + } + + pub(crate) fn acknowledge(&self, delivery: Delivery) -> Result<(), String> { + let mut state = self.0.lock().map_err(|_| "Desktop links lock failed.")?; + if !state.ready + || state.epoch != delivery.epoch + || state.in_flight != Some(delivery.sequence) + || !state.urls.starts_with(&delivery.urls) + { + return Err("Desktop link delivery was retired.".into()); + } + let remaining = &state.urls[delivery.urls.len()..]; + state.save(remaining)?; + state.urls.drain(..delivery.urls.len()); + state.in_flight = None; + Ok(()) + } + + pub(crate) fn release(&self, delivery: &Delivery) { + if let Ok(mut state) = self.0.lock() { + if state.epoch == delivery.epoch && state.in_flight == Some(delivery.sequence) { + state.in_flight = None; + } + } + } + + #[cfg(target_os = "macos")] + pub(crate) fn flush(&self) -> Result<(), String> { + let mut state = self.0.lock().map_err(|_| "Desktop links lock failed.")?; + state.load()?; + state.save(&state.urls) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn link(id: &str) -> Url { + format!("gajae-app://open/job/{id}").parse().unwrap() + } + fn ready(links: &StartupDeepLinks) -> Option { + links + .take_for_page( + "main", + &"http://127.0.0.1:43123/".parse().unwrap(), + PageLoadEvent::Finished, + ) + .unwrap() + } + + #[test] + fn exact_navigation_schema_rejects_credentials_suffixes_and_foreign_targets() { + assert_eq!(route(&link("job_1:2.3-4")), Some("/".into())); + for raw in [ + "https://example.com/", + "gajae-app://user@open/job/1", + "gajae-app://open:123/job/1", + "gajae-app://open/job/1/extra", + "gajae-app://open/job/1/", + "gajae-app://open//job/1", + "gajae-app://open/job/1?target=x", + "gajae-app://open/job/1#x", + "gajae-app://open/job/a%2Fb", + ] { + assert!(route(&raw.parse().unwrap()).is_none(), "{raw}"); + } + } + + #[test] + fn delivery_is_not_consumption_and_new_arrivals_wait_behind_the_exact_prefix() { + let links = StartupDeepLinks::new(vec![link("first")]); + let first = ready(&links).unwrap(); + assert!(links.receive(vec![link("second")]).unwrap().is_none()); + assert!(ready(&links).is_none()); + links.acknowledge(first.clone()).unwrap(); + let next = links.receive(Vec::new()).unwrap().unwrap(); + assert_eq!(next.urls, vec![link("second")]); + assert!(links.acknowledge(first).is_err()); + links.acknowledge(next).unwrap(); + assert!(ready(&links).is_none()); + } + + #[test] + fn applying_or_recovery_retires_old_delivery_but_keeps_the_urls() { + let links = StartupDeepLinks::new(vec![link("saved")]); + let original = ready(&links).unwrap(); + links.reset(); + assert!(links.acknowledge(original.clone()).is_err()); + assert!(links + .take_for_page( + "main", + &"tauri://localhost/index.html".parse().unwrap(), + PageLoadEvent::Finished + ) + .unwrap() + .is_none()); + let restored = ready(&links).unwrap(); + links.release(&original); + assert!(links.receive(Vec::new()).unwrap().is_none()); + links.acknowledge(restored).unwrap(); + } + + #[test] + fn failed_navigation_can_retry_without_consuming_or_reusing_its_delivery() { + let links = StartupDeepLinks::new(vec![link("saved")]); + let failed = ready(&links).unwrap(); + links.release(&failed); + let retry = ready(&links).unwrap(); + assert!(links.acknowledge(failed).is_err()); + assert_eq!(retry.urls, vec![link("saved")]); + links.acknowledge(retry).unwrap(); + } + + #[test] + fn startup_and_new_arrivals_are_bounded_without_evicting_accepted_links() { + let links = StartupDeepLinks::new((0..32).map(|id| link(&id.to_string())).collect()); + assert!(links.receive(vec![link("overflow")]).is_err()); + let delivery = ready(&links).unwrap(); + assert_eq!(delivery.urls.len(), 16); + assert_eq!(delivery.urls[0], link("0")); + assert_eq!(delivery.urls[15], link("15")); + } + + #[cfg(target_os = "macos")] + mod persistent { + use super::*; + use std::{ + fs, + os::unix::fs::{symlink, PermissionsExt}, + }; + + struct Temp(std::path::PathBuf); + impl Temp { + fn new() -> Self { + let mut entropy = [0; 16]; + getrandom::getrandom(&mut entropy).unwrap(); + let path = fs::canonicalize(std::env::temp_dir()) + .unwrap() + .join(format!( + "gajae-deep-links-{:032x}", + u128::from_ne_bytes(entropy) + )); + fs::create_dir(&path).unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o700)).unwrap(); + Self(path) + } + fn path(&self) -> &std::path::Path { + &self.0 + } + } + impl Drop for Temp { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + #[test] + fn pending_and_unacknowledged_links_survive_new_owners_until_durable_ack() { + let temp = Temp::new(); + let root = temp.path().canonicalize().unwrap(); + let first = StartupDeepLinks::persistent(root.clone()); + assert!(first + .receive(vec![link("before-update")]) + .unwrap() + .is_none()); + let offered = ready(&first).unwrap(); + assert_eq!(offered.urls, vec![link("before-update")]); + drop(first); + let successor = StartupDeepLinks::persistent(root.clone()); + let replayed = ready(&successor).unwrap(); + assert_eq!(replayed.urls, offered.urls); + successor.acknowledge(replayed).unwrap(); + drop(successor); + assert!(ready(&StartupDeepLinks::persistent(root)).is_none()); + } + + #[test] + fn missing_first_boot_root_retains_arrival_for_later_flush() { + let temp = Temp::new(); + let root = temp.path().canonicalize().unwrap().join("data"); + let links = StartupDeepLinks::persistent(root.clone()); + assert!(links.receive(vec![link("early")]).is_err()); + fs::create_dir(&root).unwrap(); + fs::set_permissions(&root, fs::Permissions::from_mode(0o700)).unwrap(); + links.flush().unwrap(); + assert_eq!( + ready(&StartupDeepLinks::persistent(root)).unwrap().urls, + vec![link("early")] + ); + } + + #[test] + fn aliased_ack_is_refused_and_never_overwrites_an_external_file() { + let temp = Temp::new(); + let root = temp.path().canonicalize().unwrap(); + let links = StartupDeepLinks::persistent(root.clone()); + links.receive(vec![link("retained")]).unwrap(); + let offered = ready(&links).unwrap(); + let pending = root.join("desktop-deep-links/pending.json"); + let retained = root.join("desktop-deep-links/retained-test.json"); + let outside = root.join("sentinel"); + fs::write(&outside, b"unchanged").unwrap(); + fs::rename(&pending, &retained).unwrap(); + symlink(&outside, &pending).unwrap(); + assert!(links.acknowledge(offered.clone()).is_err()); + assert_eq!(fs::read(&outside).unwrap(), b"unchanged"); + links.release(&offered); + fs::remove_file(&pending).unwrap(); + fs::rename(&retained, &pending).unwrap(); + assert_eq!(ready(&links).unwrap().urls, vec![link("retained")]); + } + + #[test] + fn corrupt_and_foreign_persisted_urls_are_not_replayed_or_overwritten() { + let temp = Temp::new(); + let root = temp.path().canonicalize().unwrap(); + let store = crate::updater_store::LinkStore::open(&root).unwrap(); + store.write(vec!["https://example.com/".into()]).unwrap(); + let pending = root.join("desktop-deep-links/pending.json"); + let before = fs::read(&pending).unwrap(); + let links = StartupDeepLinks::persistent(root); + assert!(links.receive(vec![link("valid-new")]).is_err()); + assert!(links.flush().is_err()); + assert_eq!(fs::read(&pending).unwrap(), before); + } + + #[test] + #[ignore = "spawned by pending_links_cross_a_real_process_exit"] + fn pending_link_writer_child() { + let root = std::env::var_os("GJC_DEEP_LINK_TEST_ROOT").expect("isolated child root"); + StartupDeepLinks::persistent(root.into()) + .receive(vec![link("process-handoff")]) + .unwrap(); + } + + #[test] + fn pending_links_cross_a_real_process_exit() { + let temp = Temp::new(); + let root = temp.path().canonicalize().unwrap(); + let child = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "desktop_deep_links::tests::persistent::pending_link_writer_child", + "--ignored", + ]) + .env("GJC_DEEP_LINK_TEST_ROOT", &root) + .output() + .unwrap(); + assert!( + child.status.success(), + "{}", + String::from_utf8_lossy(&child.stderr) + ); + assert_eq!( + ready(&StartupDeepLinks::persistent(root)).unwrap().urls, + vec![link("process-handoff")] + ); + } + } +} diff --git a/src-tauri/src/expected_payload.rs b/src-tauri/src/expected_payload.rs index 38a3c26c..90ffb881 100644 --- a/src-tauri/src/expected_payload.rs +++ b/src-tauri/src/expected_payload.rs @@ -126,6 +126,39 @@ struct RuntimeManifest { natives: String, #[serde(deserialize_with = "unique_platforms")] platforms: BTreeMap, + sdk_lifecycle: SdkLifecycle, +} + +#[derive(Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +struct SdkLifecycle { + id: String, + #[serde(deserialize_with = "unique_sdk_packages")] + packages: BTreeMap, + files: Vec, +} + +fn unique_sdk_packages<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + struct Packages; + impl<'de> de::Visitor<'de> for Packages { + type Value = BTreeMap; + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("unique SDK package versions") + } + fn visit_map>(self, mut map: M) -> Result { + let mut result = BTreeMap::new(); + while let Some((key, value)) = map.next_entry::()? { + if result.insert(key, value).is_some() { + return Err(de::Error::custom("duplicate SDK package")); + } + } + Ok(result) + } + } + deserializer.deserialize_map(Packages) } #[derive(Deserialize, PartialEq, Eq)] @@ -173,9 +206,19 @@ where } fn parse_manifest(bytes: &[u8]) -> Result { + let target = match (std::env::consts::OS, std::env::consts::ARCH) { + ("macos", "aarch64") => "darwin-arm64", + ("linux", "x86_64") => "linux-x64", + _ => return Err("unsupported desktop runtime manifest target".to_owned()), + }; + parse_manifest_for_target(bytes, target) +} + +fn parse_manifest_for_target(bytes: &[u8], platform: &str) -> Result { + check_limit(bytes, MANIFEST_LIMIT, "runtime manifest")?; let failure = || "malformed payload runtime manifest".to_owned(); let manifest: RuntimeManifest = serde_json::from_slice(bytes).map_err(|_| failure())?; - if manifest.schema_version != 1 + if manifest.schema_version != 2 || !valid_identity_text(&manifest.gjc_sdk) || !valid_identity_text(&manifest.bun) || !valid_identity_text(&manifest.natives) @@ -183,6 +226,53 @@ fn parse_manifest(bytes: &[u8]) -> Result { { return Err(failure()); } + let sdk = &manifest.sdk_lifecycle; + let allowed = [ + "@gajae-code/coding-agent", + "@gajae-code/agent-core", + "@gajae-code/ai", + ]; + if sdk.id.is_empty() + || sdk.id.len() > 128 + || !sdk.id.as_bytes()[0].is_ascii_lowercase() + || !sdk + .id + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') + || sdk.packages.len() < 2 + || sdk.packages.len() > allowed.len() + || !sdk.packages.contains_key(allowed[0]) + || !sdk.packages.contains_key(allowed[1]) + || sdk.packages.iter().any(|(name, version)| { + !allowed.contains(&name.as_str()) + || version.split('.').count() != 3 + || version + .split('.') + .any(|part| part.is_empty() || !part.bytes().all(|byte| byte.is_ascii_digit())) + }) + || sdk.packages.get(allowed[0]) != Some(&manifest.gjc_sdk) + || sdk.files.is_empty() + || sdk.files.len() > sdk_file_limit()? + { + return Err(failure()); + } + let mut seen = BTreeSet::new(); + for file in &sdk.files { + if !sdk.packages.contains_key(&file.package) + || !valid_sdk_path(&file.path) + || !valid_sha256(&file.sha256) + || !seen.insert((&file.package, &file.path)) + { + return Err(failure()); + } + } + if sdk + .packages + .keys() + .any(|name| !sdk.files.iter().any(|file| &file.package == name)) + { + return Err(failure()); + } for (platform, closure) in &manifest.platforms { if !valid_identity_text(platform) || !platform @@ -205,11 +295,6 @@ fn parse_manifest(bytes: &[u8]) -> Result { } // fill:runtime-manifest permits empty closures for foreign platforms. The // actual desktop target must still have a populated closure. - let platform = match (std::env::consts::OS, std::env::consts::ARCH) { - ("macos", "aarch64") => "darwin-arm64", - ("linux", "x86_64") => "linux-x64", - _ => return Err("unsupported desktop runtime manifest target".to_owned()), - }; if manifest .platforms .get(platform) @@ -220,6 +305,41 @@ fn parse_manifest(bytes: &[u8]) -> Result { Ok(manifest) } +fn sdk_file_limit() -> Result { + #[derive(Deserialize)] + #[serde(deny_unknown_fields, rename_all = "camelCase")] + struct Policy { + schema_version: u8, + max_files: usize, + } + let policy: Policy = serde_json::from_str(include_str!("../../shared/sdkLifecyclePolicy.json")) + .map_err(|_| "Invalid compiled SDK lifecycle policy.")?; + if policy.schema_version != 1 || policy.max_files == 0 || policy.max_files > 128 { + return Err("Invalid compiled SDK lifecycle policy.".into()); + } + Ok(policy.max_files) +} + +/// One strict schema owns both the installed-payload and in-archive checks. +/// The archive validator still verifies every returned member's actual bytes. +#[cfg(target_os = "macos")] +pub(crate) fn runtime_manifest_files( + bytes: &[u8], + platform: &str, +) -> Result, String> { + let mut manifest = parse_manifest_for_target(bytes, platform)?; + let mut files = manifest + .platforms + .remove(platform) + .ok_or("Missing runtime target")? + .files; + files.extend(manifest.sdk_lifecycle.files); + Ok(files + .into_iter() + .map(|file| (file.package, file.path, file.sha256)) + .collect()) +} + fn valid_identity_text(value: &str) -> bool { !value.is_empty() && value.len() <= 256 @@ -243,6 +363,17 @@ fn valid_native_path(value: &str) -> bool { .all(|part| !part.is_empty() && part != "." && !part.chars().any(char::is_control)) } +fn valid_sdk_path(value: &str) -> bool { + value.starts_with("src/") + && value.ends_with(".ts") + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || b"._-/".contains(&byte)) + && value + .split('/') + .all(|part| !part.is_empty() && part != "." && part != "..") +} + fn check_limit(bytes: &[u8], limit: usize, label: &str) -> Result<(), String> { if bytes.len() > limit { return Err(format!("payload {label} exceeds its size limit")); @@ -310,7 +441,7 @@ mod tests { }) }; json!({ - "schemaVersion": 1, + "schemaVersion": 2, "gjcSdk": "0.16.4", "bun": "1.4.0", "natives": "0.16.4", @@ -318,6 +449,14 @@ mod tests { "darwin-arm64": {"files": [file("@gajae-code/natives")]}, "linux-x64": {"files": [file("@gajae-code/natives")]}, }, + "sdkLifecycle": { + "id": "gjc-sdk-lifecycle-v1", + "packages": {"@gajae-code/coding-agent":"0.16.4", "@gajae-code/agent-core":"0.16.4"}, + "files": [ + {"package":"@gajae-code/coding-agent","path":"src/sdk/session.ts","sha256":"a".repeat(64)}, + {"package":"@gajae-code/agent-core","path":"src/agent-loop.ts","sha256":"b".repeat(64)} + ] + } }) } @@ -454,9 +593,81 @@ mod tests { .is_err()); } + #[test] + fn current_source_manifest_is_accepted_by_the_native_pre_server_guard() { + let source = include_bytes!("../../server/gjc-runtime-manifest.json"); + let value: Value = serde_json::from_slice(source).unwrap(); + let formatted = serde_json::to_vec_pretty(&value).unwrap(); + expected(source) + .verify_manifests(source, &formatted) + .unwrap(); + } + + #[test] + fn shared_sdk_inventory_limit_accepts_the_boundary_and_rejects_overflow() { + let mut value = manifest(); + let files = value["sdkLifecycle"]["files"].as_array_mut().unwrap(); + let template = files[0].clone(); + while files.len() < sdk_file_limit().unwrap() { + let mut file = template.clone(); + file["path"] = json!(format!("src/provider-{}.ts", files.len())); + files.push(file); + } + let source = bytes(&value); + assert!(expected(&source).verify_manifests(&source, &source).is_ok()); + let mut overflow = template; + overflow["path"] = json!("src/overflow.ts"); + value["sdkLifecycle"]["files"] + .as_array_mut() + .unwrap() + .push(overflow); + reject_even_with_matching_digest(&value); + } + + #[test] + fn sdk_closure_is_required_unique_and_semantically_identical_in_worker_copy() { + let source = bytes(&manifest()); + let mut worker = manifest(); + worker["sdkLifecycle"]["files"][0]["sha256"] = json!("f".repeat(64)); + assert!(expected(&source) + .verify_manifests(&source, &bytes(&worker)) + .is_err()); + for bad in [ + json!({}), + json!({"id":"x","packages":{},"files":[]}), + Value::Null, + ] { + let mut value = manifest(); + value["sdkLifecycle"] = bad; + reject_even_with_matching_digest(&value); + } + let mut value = manifest(); + let file = value["sdkLifecycle"]["files"][0].clone(); + value["sdkLifecycle"]["files"] + .as_array_mut() + .unwrap() + .push(file); + reject_even_with_matching_digest(&value); + let mut value = manifest(); + value["sdkLifecycle"]["files"][0]["path"] = json!("src/../secret.ts"); + reject_even_with_matching_digest(&value); + let duplicate = String::from_utf8(source).unwrap().replace( + "\"@gajae-code/coding-agent\":\"0.16.4\"", + "\"@gajae-code/coding-agent\":\"0.16.4\",\"@gajae-code/coding-agent\":\"0.16.4\"", + ); + assert!(parse_manifest(duplicate.as_bytes()).is_err()); + } + #[test] fn manifest_requires_supported_schema_and_typed_nonempty_fields() { - for field in ["schemaVersion", "gjcSdk", "bun", "natives", "platforms"] { + for field in [ + "schemaVersion", + "gjcSdk", + "bun", + "natives", + "platforms", + "sdkLifecycle", + ] { let mut value = manifest(); value.as_object_mut().unwrap().remove(field); reject_even_with_matching_digest(&value); @@ -467,7 +678,7 @@ mod tests { } } for (field, bad) in [ - ("schemaVersion", json!(2)), + ("schemaVersion", json!(1)), ("schemaVersion", json!("1")), ("schemaVersion", json!(1.0)), ("gjcSdk", json!("")), diff --git a/src-tauri/src/lifecycle.rs b/src-tauri/src/lifecycle.rs index 911bcf6f..48957398 100644 --- a/src-tauri/src/lifecycle.rs +++ b/src-tauri/src/lifecycle.rs @@ -68,6 +68,19 @@ impl SidecarLifecycle { .is_some() } + #[cfg(target_os = "macos")] + pub(crate) fn owns_pid(&self, expected: u32) -> bool { + *self.pid.lock().expect("sidecar lifecycle lock poisoned") == Some(expected) + } + + /// Serialize the last pre-server update decision with Quit and every spawn. + /// The callback must only change in-memory admission; never do installer I/O. + #[cfg(target_os = "macos")] + pub(crate) fn begin_startup_update(&self, admit: impl FnOnce() -> bool) -> bool { + let pid = self.pid.lock().expect("sidecar lifecycle lock poisoned"); + pid.is_none() && !self.is_shutting_down() && admit() + } + /// The final app.exit() must be allowed through ExitRequested, but only /// after Quit has fenced off new spawns and the tracked server has exited. pub fn shutdown_complete(&self) -> bool { @@ -92,7 +105,7 @@ impl SidecarLifecycle { } } - async fn wait_for_exit(&self) -> Result<(), String> { + pub(crate) async fn wait_for_exit(&self) -> Result<(), String> { tokio::time::timeout(Duration::from_secs(30), async { loop { // Register before checking the durable state: exit can happen @@ -204,6 +217,10 @@ pub fn handle_close_request(window: &Window, event: &tauri::WindowEvent) { // Keep the window alive until the server finishes: shutdown errors // still need a visible window, and destroying it must not skip Quit. api.prevent_close(); + #[cfg(target_os = "macos")] + if crate::updater_launch::holds_exit(window.app_handle()) { + return; + } // Linux has no macOS Reopen event (and no tray UI in this app). Hiding // the last window would leave the server and instance lock invisible. diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 9b7af49b..d259d95d 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -8,7 +8,9 @@ use fs2::FileExt; use tauri::Manager; mod build_info; +mod desktop_deep_links; mod desktop_origin; +use desktop_deep_links::StartupDeepLinks; mod expected_payload; #[cfg(target_os = "linux")] mod instance; @@ -26,12 +28,30 @@ mod updater_archive; #[cfg(target_os = "macos")] mod updater_attempt; #[cfg(target_os = "macos")] +mod updater_backend; +#[cfg(target_os = "macos")] mod updater_binding; #[cfg(target_os = "macos")] +mod updater_bridge; +#[cfg(target_os = "macos")] +mod updater_bundle; +#[cfg(target_os = "macos")] mod updater_discovery; #[cfg(target_os = "macos")] +mod updater_install; +#[cfg(target_os = "macos")] +mod updater_launch; +#[cfg(target_os = "macos")] +mod updater_location; +#[cfg(target_os = "macos")] mod updater_manifest; #[cfg(target_os = "macos")] +mod updater_owners; +#[cfg(target_os = "macos")] +mod updater_restart; +#[cfg(target_os = "macos")] +mod updater_screen; +#[cfg(target_os = "macos")] mod updater_signature; #[cfg(target_os = "macos")] mod updater_store; @@ -81,104 +101,23 @@ fn is_gajae_deep_link(url: &tauri::Url) -> bool { } fn deep_link_route(url: &tauri::Url) -> Option { - if !is_gajae_deep_link(url) || url.host_str() != Some("open") { - return None; - } - let segments: Vec<&str> = url.path_segments()?.filter(|s| !s.is_empty()).collect(); - match segments.as_slice() { - ["job", id] - if !id.is_empty() - && id.len() <= 128 - && id - .chars() - .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | ':' | '-')) => - { - Some("/".to_owned()) - } - _ => None, - } -} - -#[cfg(any(target_os = "linux", test))] -#[derive(Default)] -struct DeepLinkState { - urls: Vec, - ready: bool, -} - -#[cfg(any(target_os = "linux", test))] -struct StartupDeepLinks(std::sync::Mutex); - -#[cfg(any(target_os = "linux", test))] -impl StartupDeepLinks { - fn new(urls: Vec) -> Self { - Self(std::sync::Mutex::new(DeepLinkState { - urls: urls - .into_iter() - .filter(|url| deep_link_route(url).is_some()) - .collect(), - ready: false, - })) - } - - fn receive(&self, urls: Vec) -> Vec { - let mut state = self.0.lock().expect("startup deep-link lock poisoned"); - // The notification route is the root shell; repeated activations while - // starting must not grow an unbounded queue. - for url in urls - .into_iter() - .filter(|url| deep_link_route(url).is_some()) - { - if state.urls.len() < 16 { - state.urls.push(url); - } - } - if state.ready { - std::mem::take(&mut state.urls) - } else { - Vec::new() - } - } - - fn reset(&self) { - self.0 - .lock() - .expect("startup deep-link lock poisoned") - .ready = false; - } - - fn take_for_page( - &self, - label: &str, - url: &tauri::Url, - event: tauri::webview::PageLoadEvent, - ) -> Vec { - // The navigation policy already restricts HTTP pages to the assigned - // loopback origin. Wait for bootstrap's redirect to the app root; - // recovery and nonce-exchange documents cannot consume startup links. - if label == "main" && event == tauri::webview::PageLoadEvent::Started { - self.reset(); - } - if label != "main" - || event != tauri::webview::PageLoadEvent::Finished - || url.scheme() != "http" - || url.host_str() != Some("127.0.0.1") - || url.path() != "/" - { - return Vec::new(); - } - let mut state = self.0.lock().expect("startup deep-link lock poisoned"); - state.ready = true; - std::mem::take(&mut state.urls) - } + is_gajae_deep_link(url) + .then(|| desktop_deep_links::route(url)) + .flatten() } -#[cfg(target_os = "linux")] fn route_startup_deep_links( webview: &tauri::Webview, payload: &tauri::webview::PageLoadPayload<'_>, ) { let app = webview.app_handle(); + if webview.label() == "main" && payload.event() == tauri::webview::PageLoadEvent::Started { + reset_deep_link_readiness(app); + } + #[cfg(target_os = "macos")] + if !updater_launch::allows_navigation_intents(app) { + return; + } if !app .try_state::() .is_some_and(|origin| origin.permits(payload.url())) @@ -186,26 +125,138 @@ fn route_startup_deep_links( return; } if let Some(startup) = app.try_state::() { - for url in startup.take_for_page(webview.label(), payload.url(), payload.event()) { - route_deep_link(app, url); + match startup.take_for_page(webview.label(), payload.url(), payload.event()) { + Ok(Some(delivery)) => deliver_deep_links(app, delivery), + Ok(None) => {} + Err(_) => eprintln!("Pending desktop links could not be loaded or persisted."), } } } fn desktop_page_load(webview: &tauri::Webview, payload: &tauri::webview::PageLoadPayload<'_>) { + #[cfg(target_os = "macos")] + updater_bridge::page_load(webview, payload); if payload.event() == tauri::webview::PageLoadEvent::Finished { + #[cfg(target_os = "macos")] + if updater_launch::restore_screen(webview) { + return; + } supervisor::restore_recovery(webview); } - #[cfg(target_os = "linux")] route_startup_deep_links(webview, payload); } -fn receive_deep_links(app: &tauri::AppHandle, urls: Vec) { - #[cfg(target_os = "linux")] - let urls = app.state::().receive(urls); - for url in urls { - route_deep_link(app, url); +fn receive_deep_links(app: &tauri::AppHandle, urls: Vec) -> bool { + let count = urls + .iter() + .filter(|url| deep_link_route(url).is_some()) + .count(); + #[cfg(target_os = "macos")] + if !updater_launch::allows_navigation_intents(app) { + reset_deep_link_readiness(app); + } + match app.state::().receive(urls) { + Ok(Some(delivery)) => { + trace_deep_links(app, "accepted", count); + deliver_deep_links(app, delivery); + true + } + Ok(None) => { + trace_deep_links(app, "queued", count); + true + } + Err(_) => { + eprintln!("Desktop link deferred: its bounded durable queue is unavailable."); + false + } + } +} + +fn trace_deep_links(app: &tauri::AppHandle, event: &str, count: usize) { + #[cfg(target_os = "macos")] + if cfg!(debug_assertions) + && updater_binding::Binding::compiled().mode == updater_binding::Mode::Qa + && app.try_state::().is_some() + { + eprintln!("[links-qa:{}] {event} count={count}", std::process::id()); + } + let _ = (app, event, count); +} + +fn deliver_deep_links(app: &tauri::AppHandle, delivery: desktop_deep_links::Delivery) { + for url in &delivery.urls { + if !route_deep_link(app, url.clone()) { + app.state::().release(&delivery); + return; + } + } + // eval() only accepts a script. Retain the durable record until native + // observes the requested root URL in this delivery's document epoch. + let handle = app.clone(); + tauri::async_runtime::spawn(async move { + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(2); + for _ in 0..40 { + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + if tokio::time::Instant::now() >= deadline { + break; + } + let app = handle.clone(); + let current = delivery.clone(); + let (sent, reply) = tokio::sync::oneshot::channel(); + if handle + .run_on_main_thread(move || { + let _ = sent.send(acknowledge_deep_links(&app, current)); + }) + .is_err() + { + break; + } + match tokio::time::timeout_at(deadline, reply).await { + Ok(Ok(true)) => return, + Ok(Ok(false)) => {} + _ => break, + } + } + handle.state::().release(&delivery); + }); +} + +fn acknowledge_deep_links(app: &tauri::AppHandle, delivery: desktop_deep_links::Delivery) -> bool { + let count = delivery.urls.len(); + #[cfg(target_os = "macos")] + if !updater_launch::allows_navigation_intents(app) { + return false; + } + if app + .state::() + .is_shutting_down() + { + return false; + } + let Some(window) = app.get_webview_window("main") else { + return false; + }; + if !window.url().is_ok_and(|url| { + url.scheme() == "http" + && url.path() == "/" + && url.query().is_none() + && url.fragment().is_none() + && app.state::().permits(&url) + }) { + return false; + } + if app + .state::() + .acknowledge(delivery) + .is_err() + { + return false; } + trace_deep_links(app, "delivered", count); + if let Ok(Some(next)) = app.state::().receive(Vec::new()) { + deliver_deep_links(app, next); + } + true } fn focus_main_window(app: &tauri::AppHandle) { @@ -217,32 +268,78 @@ fn focus_main_window(app: &tauri::AppHandle) { } pub(crate) fn reset_deep_link_readiness(app: &tauri::AppHandle) { - #[cfg(target_os = "linux")] if let Some(links) = app.try_state::() { links.reset(); } - #[cfg(not(target_os = "linux"))] - let _ = app; } -fn route_deep_link(app: &tauri::AppHandle, url: tauri::Url) { +#[cfg(target_os = "macos")] +pub(crate) fn flush_deep_links(app: &tauri::AppHandle) -> Result<(), String> { + app.state::().flush() +} + +#[cfg(target_os = "macos")] +pub(crate) fn resume_deep_links(app: &tauri::AppHandle) { + let handle = app.clone(); + let _ = app.run_on_main_thread(move || { + if !updater_launch::allows_navigation_intents(&handle) { + return; + } + if let Some(window) = handle.get_webview_window("main") { + if let Ok(url) = window.url() { + if !handle.state::().permits(&url) { + return; + } + if let Ok(Some(delivery)) = handle.state::().take_for_page( + "main", + &url, + tauri::webview::PageLoadEvent::Finished, + ) { + deliver_deep_links(&handle, delivery); + } + } + } + }); +} + +fn route_deep_link(app: &tauri::AppHandle, url: tauri::Url) -> bool { use tauri::{Emitter, Manager}; if deep_link_route(&url).is_none() { - return; + return false; + } + #[cfg(target_os = "macos")] + if !updater_launch::allows_navigation_intents(app) { + return false; + } + if app + .state::() + .is_shutting_down() + { + return false; } - let _ = app.emit_to("main", "desktop://deep-link", url.as_str()); if let Some(window) = app.get_webview_window("main") { + if !window.url().is_ok_and(|current| { + current.scheme() == "http" + && app.state::().permits(¤t) + && !current.path().starts_with("/api/") + && !current.path().starts_with("/desktop/") + }) { + return false; + } // The served UI is a remote loopback origin where Tauri IPC event // injection is not guaranteed, so navigate the SPA directly; the id // is validated above and contains no characters needing escaping. if let Some(path) = deep_link_route(&url) { - let _ = window.eval(format!( + if window.eval(format!( "window.history.pushState({{}},'','{path}');window.dispatchEvent(new PopStateEvent('popstate'));" - )); + )).is_err() { return false; } } + let _ = app.emit_to("main", "desktop://deep-link", url.as_str()); + focus_main_window(app); + return true; } - focus_main_window(app); + false } #[tauri::command] @@ -250,6 +347,12 @@ fn retry_desktop_server(app: tauri::AppHandle) { supervisor::start(app); } +#[cfg(target_os = "macos")] +#[tauri::command] +fn ack_updater_screen(app: tauri::AppHandle, window: tauri::WebviewWindow, epoch: u64) { + updater_launch::acknowledge_screen(&app, &window, epoch); +} + fn main() { match build_info::handle_cli(std::env::args_os().skip(1)) { Ok(Some(info)) => { @@ -266,7 +369,9 @@ fn main() { #[cfg(target_os = "macos")] let qa_profile = (|| -> Result, String> { - let Some(root) = qa_profile::requested_root(std::env::args().skip(1))? else { + let requested = qa_profile::requested_root(std::env::args().skip(1))?; + updater_binding::Binding::compiled().validate_launch_profile(requested.as_deref())?; + let Some(root) = requested else { return Ok(None); }; let version = std::process::Command::new("/usr/bin/sw_vers") @@ -283,6 +388,21 @@ fn main() { eprintln!("{error}"); std::process::exit(1); }); + #[cfg(target_os = "macos")] + let qa_install = { + let count = std::env::args() + .filter(|arg| arg == "--qa-update-install") + .count(); + if count > 1 + || (count == 1 + && (qa_profile.is_none() + || updater_binding::Binding::compiled().mode != updater_binding::Mode::Qa)) + { + eprintln!("--qa-update-install requires one compile-bound isolated QA profile."); + std::process::exit(2); + } + count == 1 + }; #[cfg(not(target_os = "macos"))] if std::env::args().any(|arg| arg == "--qa-profile" || arg.starts_with("--qa-profile=")) { eprintln!("--qa-profile is currently supported only on macOS 14 or newer."); @@ -292,6 +412,13 @@ fn main() { #[cfg(target_os = "macos")] let (context, qa_windows) = { let mut context = context; + if cfg!(target_arch = "aarch64") { + updater_binding::Binding::compiled().configure_plugin( + context.config_mut(), + qa_profile.as_ref().map(|profile| profile.root()), + !cfg!(debug_assertions), + ); + } let windows = qa_profile .as_ref() .map(|profile| profile.configure(context.config_mut())) @@ -317,121 +444,170 @@ fn main() { .plugin(tauri_plugin_deep_link::init()) .plugin(navigation::plugin()) .on_page_load(desktop_page_load) - .on_window_event(lifecycle::handle_close_request) - .invoke_handler(tauri::generate_handler![retry_desktop_server]) - .setup(move |app| { - // A held lock means another instance is running. Setup errors - // abort inside did_finish_launching (panic_cannot_unwind -> - // SIGABRT -> crash-reporter dialog), so report the bounded - // ownership failure and exit with a nonzero status instead; - // macOS LaunchServices focuses the running instance on reopen. - // A failed bounded handoff is still a failed launch: never report - // success when this process did not acquire ownership. - #[cfg(not(target_os = "linux"))] - let lock_result = { - #[cfg(target_os = "macos")] - { - if qa_profile.is_some() { - // QaProfile already owns its lock, before window - // creation. - Ok(None) - } else { - acquire_single_instance_lock().map(Some) - } - } - #[cfg(target_os = "windows")] - { + .on_window_event(lifecycle::handle_close_request); + #[cfg(target_os = "macos")] + let builder = builder.invoke_handler(tauri::generate_handler![ + retry_desktop_server, + ack_updater_screen + ]); + #[cfg(not(target_os = "macos"))] + let builder = builder.invoke_handler(tauri::generate_handler![retry_desktop_server]); + let builder = builder.setup(move |app| { + // A held lock means another instance is running. Setup errors + // abort inside did_finish_launching (panic_cannot_unwind -> + // SIGABRT -> crash-reporter dialog), so report the bounded + // ownership failure and exit with a nonzero status instead; + // macOS LaunchServices focuses the running instance on reopen. + // A failed bounded handoff is still a failed launch: never report + // success when this process did not acquire ownership. + #[cfg(not(target_os = "linux"))] + let lock_result = { + #[cfg(target_os = "macos")] + { + if qa_profile.is_some() { + // QaProfile already owns its lock, before window + // creation. + Ok(None) + } else { acquire_single_instance_lock().map(Some) } - }; - #[cfg(not(target_os = "linux"))] - let lock = match lock_result { - Ok(lock) => lock, - Err(message) => { - eprintln!("{message}"); - std::process::exit(1); - } - }; - #[cfg(not(target_os = "linux"))] - if let Some(lock) = lock { - app.manage(lock); - } - #[cfg(target_os = "macos")] - if let Some(profile) = qa_profile { - app.manage(profile); } - app.manage(navigation::LoopbackOrigin::default()); - app.manage(lifecycle::SidecarLifecycle::default()); - app.manage(supervisor::RecoveryScreen::default()); - #[cfg(target_os = "macos")] - app.manage(updater::Preparation::default()); - #[cfg(target_os = "macos")] - if let Some(profile) = app.try_state::() { - profile.create_windows(app, &qa_windows)?; - } - #[cfg(target_os = "linux")] + #[cfg(target_os = "windows")] { - app.manage(StartupDeepLinks::new( - activation - .urls - .into_iter() - .filter_map(|url| url.parse().ok()) - .collect(), - )); - let app_handle = app.handle().clone(); - app.manage(instance.listen(move |activation| { - if app_handle - .state::() - .is_shutting_down() - { - return false; - } - let app = app_handle.clone(); - let (sender, receiver) = std::sync::mpsc::sync_channel(1); - if app_handle - .run_on_main_thread(move || { - if app - .state::() - .is_shutting_down() - { - let _ = sender.send(false); - return; - } - receive_deep_links( - &app, - activation - .urls - .into_iter() - .filter_map(|url| url.parse().ok()) - .collect(), - ); - focus_main_window(&app); - let _ = sender.send(true); - }) - .is_err() - { - return false; - } - // Acknowledge only once the UI thread accepted the request; - // Close may fence activations while this callback is queued. - receiver - .recv_timeout(std::time::Duration::from_secs(2)) - .unwrap_or(false) - })?); + acquire_single_instance_lock().map(Some) } + }; + #[cfg(not(target_os = "linux"))] + let lock = match lock_result { + Ok(lock) => lock, + Err(message) => { + eprintln!("{message}"); + std::process::exit(1); + } + }; + #[cfg(not(target_os = "linux"))] + if let Some(lock) = lock { + app.manage(lock); + } + #[cfg(target_os = "macos")] + if let Some(profile) = qa_profile { + app.manage(profile); + } + app.manage(navigation::LoopbackOrigin::default()); + app.manage(lifecycle::SidecarLifecycle::default()); + app.manage(supervisor::RecoveryScreen::default()); + #[cfg(target_os = "macos")] + app.manage(StartupDeepLinks::persistent(supervisor::desktop_data_root( + app.handle(), + )?)); + #[cfg(target_os = "windows")] + app.manage(StartupDeepLinks::new(Vec::new())); + #[cfg(target_os = "macos")] + app.manage(updater_launch::LaunchGate::default()); + #[cfg(target_os = "macos")] + app.manage(updater_screen::ScreenState::default()); + #[cfg(target_os = "macos")] + app.manage(updater::Preparation::default()); + #[cfg(target_os = "macos")] + app.manage(updater_bridge::Bridge::default()); + #[cfg(target_os = "macos")] + app.manage(updater_restart::Restarts::default()); + #[cfg(target_os = "macos")] + if let Some(profile) = app.try_state::() { + profile.create_windows(app, &qa_windows)?; + } + #[cfg(target_os = "linux")] + { + app.manage(StartupDeepLinks::new( + activation + .urls + .into_iter() + .filter_map(|url| url.parse().ok()) + .collect(), + )); let app_handle = app.handle().clone(); - app.deep_link().on_open_url(move |event| { - receive_deep_links(&app_handle, event.urls()); + app.manage(instance.listen(move |activation| { + if app_handle + .state::() + .is_shutting_down() + { + return false; + } + let app = app_handle.clone(); + let (sender, receiver) = std::sync::mpsc::sync_channel(1); + if app_handle + .run_on_main_thread(move || { + if app + .state::() + .is_shutting_down() + { + let _ = sender.send(false); + return; + } + let accepted = receive_deep_links( + &app, + activation + .urls + .into_iter() + .filter_map(|url| url.parse().ok()) + .collect(), + ); + focus_main_window(&app); + let _ = sender.send(accepted); + }) + .is_err() + { + return false; + } + // Acknowledge only once the UI thread accepted the request; + // Close may fence activations while this callback is queued. + receiver + .recv_timeout(std::time::Duration::from_secs(2)) + .unwrap_or(false) + })?); + } + let app_handle = app.handle().clone(); + app.deep_link().on_open_url(move |event| { + let handle = app_handle.clone(); + let urls = event.urls(); + let _ = app_handle.run_on_main_thread(move || { + receive_deep_links(&handle, urls); }); - supervisor::start(app.handle().clone()); - Ok(()) }); + #[cfg(target_os = "macos")] + if let Some(urls) = app.deep_link().get_current()? { + receive_deep_links(app.handle(), urls); + } + #[cfg(target_os = "macos")] + updater_launch::start(app.handle().clone(), qa_install); + #[cfg(not(target_os = "macos"))] + supervisor::start(app.handle().clone()); + Ok(()) + }); let app = builder .build(context) .expect("failed to run Gajae Code App desktop shell"); app.run( |app: &tauri::AppHandle, event: tauri::RunEvent| match event { - tauri::RunEvent::ExitRequested { api, .. } => { + tauri::RunEvent::ExitRequested { api, code, .. } => { + #[cfg(target_os = "macos")] + if updater_launch::expected_restart(app, code) { + if flush_deep_links(app).is_err() { + api.prevent_exit(); + updater_launch::manual_recovery( + app, + "Pending notification links could not be saved. Restart was deferred.", + ); + } + return; + } + #[cfg(target_os = "macos")] + if updater_launch::holds_exit(app) { + api.prevent_exit(); + return; + } + #[cfg(not(target_os = "macos"))] + let _ = code; #[cfg(target_os = "macos")] updater::unhealthy(app); // graceful_quit finishes with app.exit(), which requests exit @@ -447,6 +623,12 @@ fn main() { } } tauri::RunEvent::Exit => { + #[cfg(target_os = "macos")] + if flush_deep_links(app).is_err() { + eprintln!("Pending desktop links could not be saved during exit."); + } + #[cfg(target_os = "macos")] + updater_bridge::retire(app); #[cfg(target_os = "macos")] updater::unhealthy(app); // macOS Quit Apple events (Cmd-Q, AppleScript quit) bypass a @@ -472,28 +654,55 @@ fn main() { mod tests { use super::*; + fn deliver_page( + links: &StartupDeepLinks, + label: &str, + url: &tauri::Url, + event: tauri::webview::PageLoadEvent, + ) -> Vec { + let Some(delivery) = links.take_for_page(label, url, event).unwrap() else { + return Vec::new(); + }; + let urls = delivery.urls.clone(); + links.acknowledge(delivery).unwrap(); + urls + } + #[test] fn forwarded_links_queue_during_startup_and_retry_then_route_immediately_when_ready() { let link: tauri::Url = "gajae-app://open/job/job-forwarded".parse().unwrap(); let app_url = "http://127.0.0.1:43123/".parse().unwrap(); let links = StartupDeepLinks::new(Vec::new()); - assert!(links.receive(vec![link.clone()]).is_empty()); + assert!(links.receive(vec![link.clone()]).unwrap().is_none()); assert_eq!( - links.take_for_page("main", &app_url, tauri::webview::PageLoadEvent::Finished), + deliver_page( + &links, + "main", + &app_url, + tauri::webview::PageLoadEvent::Finished + ), vec![link.clone()] ); - assert_eq!(links.receive(vec![link.clone()]), vec![link.clone()]); + let immediate = links.receive(vec![link.clone()]).unwrap().unwrap(); + assert_eq!(immediate.urls, vec![link.clone()]); + links.acknowledge(immediate).unwrap(); links.reset(); - assert!(links.receive(vec![link.clone()]).is_empty()); + assert!(links.receive(vec![link.clone()]).unwrap().is_none()); assert!(links .take_for_page( "main", &"tauri://localhost/".parse().unwrap(), tauri::webview::PageLoadEvent::Finished ) - .is_empty()); + .unwrap() + .is_none()); assert_eq!( - links.take_for_page("main", &app_url, tauri::webview::PageLoadEvent::Finished), + deliver_page( + &links, + "main", + &app_url, + tauri::webview::PageLoadEvent::Finished + ), vec![link] ); } @@ -503,15 +712,24 @@ mod tests { let link: tauri::Url = "gajae-app://open/job/job-forwarded".parse().unwrap(); let app_url = "http://127.0.0.1:43123/".parse().unwrap(); let links = StartupDeepLinks::new(Vec::new()); - links.take_for_page("main", &app_url, tauri::webview::PageLoadEvent::Finished); - links.take_for_page("main", &app_url, tauri::webview::PageLoadEvent::Started); - for _ in 0..32 { - assert!(links.receive(vec![link.clone()]).is_empty()); + links + .take_for_page("main", &app_url, tauri::webview::PageLoadEvent::Finished) + .unwrap(); + links + .take_for_page("main", &app_url, tauri::webview::PageLoadEvent::Started) + .unwrap(); + for _ in 0..16 { + assert!(links.receive(vec![link.clone()]).unwrap().is_none()); } + assert!(links.receive(vec![link]).is_err()); assert_eq!( - links - .take_for_page("main", &app_url, tauri::webview::PageLoadEvent::Finished) - .len(), + deliver_page( + &links, + "main", + &app_url, + tauri::webview::PageLoadEvent::Finished + ) + .len(), 16 ); } @@ -535,14 +753,18 @@ mod tests { ] { assert!(startup .take_for_page(label, &url.parse().unwrap(), event) - .is_empty()); + .unwrap() + .is_none()); } let app_url = "http://127.0.0.1:43123/".parse().unwrap(); assert_eq!( - startup.take_for_page("main", &app_url, Finished), + deliver_page(&startup, "main", &app_url, Finished), vec![link] ); - assert!(startup.take_for_page("main", &app_url, Finished).is_empty()); + assert!(startup + .take_for_page("main", &app_url, Finished) + .unwrap() + .is_none()); } #[test] @@ -559,7 +781,8 @@ mod tests { .collect(), ); assert_eq!( - startup.take_for_page( + deliver_page( + &startup, "main", &"http://127.0.0.1:43123/".parse().unwrap(), tauri::webview::PageLoadEvent::Finished, diff --git a/src-tauri/src/navigation.rs b/src-tauri/src/navigation.rs index 6ddd9fda..f77458e0 100644 --- a/src-tauri/src/navigation.rs +++ b/src-tauri/src/navigation.rs @@ -40,7 +40,13 @@ impl LoopbackOrigin { pub fn plugin() -> TauriPlugin { Builder::new("desktop-navigation") - .on_navigation(|webview, url| webview.app_handle().state::().permits(url)) + .on_navigation(|webview, url| { + #[cfg(target_os = "macos")] + if !crate::updater_restart::permits_navigation(webview.app_handle(), url) { + return false; + } + webview.app_handle().state::().permits(url) + }) .build() } diff --git a/src-tauri/src/qa_profile.rs b/src-tauri/src/qa_profile.rs index 41470442..4e534cf4 100644 --- a/src-tauri/src/qa_profile.rs +++ b/src-tauri/src/qa_profile.rs @@ -13,6 +13,23 @@ use serde::{Deserialize, Serialize}; use tauri::utils::config::{Config, WindowConfig}; const MANIFEST: &str = "desktop-qa-profile.json"; +const AUTOMATION_SOCKET: &str = "a.sock"; + +fn validate_automation_socket(root: &Path) -> Result<(), String> { + #[cfg(unix)] + { + use std::os::unix::ffi::OsStrExt; + // Use the platform sockaddr layout, not a guessed cross-platform cap. + let address: libc::sockaddr_un = unsafe { std::mem::zeroed() }; + if root.join(AUTOMATION_SOCKET).as_os_str().as_bytes().len() >= address.sun_path.len() { + return Err( + "QA root is too long for its private automation socket; choose a shorter path." + .into(), + ); + } + } + Ok(()) +} #[derive(Debug, Deserialize, Serialize)] #[serde(deny_unknown_fields)] @@ -125,6 +142,7 @@ impl QaProfile { private_directory(&path)?; } let root = path.canonicalize().map_err(|error| error.to_string())?; + validate_automation_socket(&root)?; let manifest_path = root.join(MANIFEST); if fs::symlink_metadata(&manifest_path).is_ok_and(|m| m.file_type().is_symlink()) { return Err("QA manifest cannot be a symlink.".into()); @@ -268,6 +286,7 @@ impl QaProfile { ("TMPDIR", "tmp"), ("GAJAE_BROWSER_PROFILE_DIR", "browser/profile"), ("GAJAE_BROWSER_CACHE_DIR", "browser/chromium"), + ("GAJAE_AUTOMATION_SOCKET", AUTOMATION_SOCKET), ] { result.insert( name.into(), @@ -300,6 +319,27 @@ mod tests { } } + #[test] + fn automation_socket_is_short_private_and_really_bindable() { + let root = Temp::new(); + let profile = QaProfile::open(&root.0).unwrap(); + let environment = profile.environment(); + let socket = PathBuf::from(&environment["GAJAE_AUTOMATION_SOCKET"]); + assert_eq!(socket, profile.root.join(AUTOMATION_SOCKET)); + #[cfg(unix)] + { + let listener = std::os::unix::net::UnixListener::bind(&socket).unwrap(); + assert!( + socket.exists(), + "the OS must not silently truncate the bound path" + ); + drop(listener); + } + assert!( + validate_automation_socket(&PathBuf::from(format!("/{}", "x".repeat(200)))).is_err() + ); + } + #[test] fn profile_switch_is_explicit_and_unambiguous() { assert_eq!( diff --git a/src-tauri/src/supervisor.rs b/src-tauri/src/supervisor.rs index f06c8b09..cc618c32 100644 --- a/src-tauri/src/supervisor.rs +++ b/src-tauri/src/supervisor.rs @@ -32,6 +32,26 @@ const EXPECTED_PAYLOAD_VERSION: &str = env!("GJC_EXPECTED_PAYLOAD_VERSION"); #[derive(Default)] pub(crate) struct RecoveryScreen(std::sync::Mutex>); +/// Created only in the owned sidecar's independently verified health branch. +/// The updater cannot manufacture this from its receipt or HTTP input. +#[cfg(target_os = "macos")] +pub(crate) struct HealthyServer { + target: crate::updater_attempt::Target, + pid: u32, +} + +#[cfg(target_os = "macos")] +impl crate::updater_attempt::proof_seal::Sealed for HealthyServer {} +#[cfg(target_os = "macos")] +impl crate::updater_attempt::VerifiedHealthProof for HealthyServer { + fn target(&self) -> &crate::updater_attempt::Target { + &self.target + } + fn server_pid(&self) -> u32 { + self.pid + } +} + #[derive(Debug, Deserialize)] struct ReadyFrame { kind: String, @@ -203,6 +223,8 @@ fn recovery_script(message: &str, retry_enabled: bool) -> String { } fn reset_desktop_readiness(app: &AppHandle) { + #[cfg(target_os = "macos")] + crate::updater_bridge::retire(app); #[cfg(target_os = "macos")] crate::updater::unhealthy(app); app.state::().clear(); @@ -215,6 +237,10 @@ fn show_error(window: &WebviewWindow, message: &str, retry_enabled: bool) { if lifecycle.is_shutting_down() || (retry_enabled && lifecycle.has_sidecar()) { return; } + #[cfg(target_os = "macos")] + if crate::updater_launch::server_failed(app, message) { + return; + } reset_desktop_readiness(app); *app.state::() .0 @@ -252,14 +278,14 @@ pub(crate) fn desktop_data_root(app: &AppHandle) -> Result { .map_err(|error| error.to_string()) } -fn update_attempt_admission(desktop_data_root: &Path) -> Result<(), String> { +fn update_attempt_admission(app: &AppHandle, desktop_data_root: &Path) -> Result<(), String> { #[cfg(target_os = "macos")] { - crate::updater_attempt::check(desktop_data_root) + crate::updater_launch::admit_server(app, desktop_data_root) } #[cfg(not(target_os = "macos"))] { - let _ = desktop_data_root; + let _ = (app, desktop_data_root); Ok(()) } } @@ -414,6 +440,11 @@ fn navigate_and_show( .map_err(|error| format!("could not show main window: {error}")) } +#[cfg(target_os = "macos")] +fn update_bridge_environment(enabled: bool) -> [(&'static str, &'static str); 1] { + [("GJC_DESKTOP_UPDATE_PIPE", if enabled { "1" } else { "0" })] +} + pub fn start(app: AppHandle) { tauri::async_runtime::spawn(async move { let window = match app.get_webview_window("main") { @@ -435,7 +466,7 @@ pub fn start(app: AppHandle) { // loading can turn it into an ordinary retryable origin error. The // lifecycle admission below repeats this check under its PID/shutdown // lock so neither startup nor Retry can skip admission. - if let Err(error) = update_attempt_admission(&desktop_data_root) { + if let Err(error) = update_attempt_admission(&app, &desktop_data_root) { show_error(&window, &error, false); return; } @@ -478,7 +509,7 @@ pub fn start(app: AppHandle) { let path = env::var("PATH").unwrap_or_default(); let entrypoint = payload.join("dist-server/server/index.js"); let command = lifecycle.start( - || update_attempt_admission(&desktop_data_root), + || update_attempt_admission(&app, &desktop_data_root), || { reset_desktop_readiness(&app); *app.state::() @@ -505,6 +536,11 @@ pub fn start(app: AppHandle) { } else { command.env("HOME", &home).env("PATH", &path) }; + // Apply after QA's env_clear so isolated children get the flag. + #[cfg(target_os = "macos")] + let command = command.envs(update_bridge_environment( + crate::updater_bridge::enabled(&app), + )); #[cfg(not(target_os = "macos"))] let command = command.env("HOME", &home).env("PATH", &path); let (events, child) = command @@ -532,6 +568,18 @@ pub fn start(app: AppHandle) { } }; let sidecar_pid = child.pid(); + #[cfg(target_os = "macos")] + let mut child = child; + #[cfg(target_os = "macos")] + match crate::updater_bridge::attach(&app, sidecar_pid) { + Ok(Some(frame)) => { + if child.write(&frame).is_err() { + crate::updater_bridge::retire(&app); + } + } + Ok(None) => {} + Err(_) => eprintln!("desktop updater bridge unavailable"), + } let deadline = Instant::now() + STARTUP_TIMEOUT; let mut output = OutputRing::default(); let mut ready = false; @@ -623,6 +671,55 @@ pub fn start(app: AppHandle) { if lifecycle.is_shutting_down() { break; } + #[cfg(target_os = "macos")] + { + let successor = + crate::updater_launch::revalidate_successor(&app).await; + let target = match successor { + Ok(target) => target, + Err(error) => { + handle_sidecar_failure( + &app, &window, child, events, error, false, + ) + .await; + return; + } + }; + if let Some(target) = target { + // Full bundle verification may take time. Recheck real + // server health afterward, with the SPA still withheld. + if lifecycle.is_shutting_down() { + break; + } + let verified = + health_check(ready_frame.port).and_then(|()| { + if !lifecycle.owns_pid(sidecar_pid) + || !crate::lifecycle::process_alive(sidecar_pid) + { + return Err( + "Successor server ownership was lost." + .into(), + ); + } + desktop_origin + .persist_verified_port(ready_frame.port)?; + crate::updater_launch::finish_health( + &app, + &HealthyServer { + target, + pid: sidecar_pid, + }, + ) + }); + if let Err(error) = verified { + handle_sidecar_failure( + &app, &window, child, events, error, false, + ) + .await; + return; + } + } + } if let Err(error) = desktop_origin .persist_verified_port(ready_frame.port) .and_then(|()| { @@ -689,6 +786,27 @@ pub fn start(app: AppHandle) { mod tests { use super::*; + #[cfg(target_os = "macos")] + #[test] + fn qa_environment_clear_preserves_only_the_explicit_late_update_flag() { + for enabled in [true, false] { + let output = std::process::Command::new("/usr/bin/env") + .env("GJC_DESKTOP_UPDATE_PIPE", "wrong") + .env_clear() + .envs(update_bridge_environment(enabled)) + .output() + .unwrap(); + assert!(output.status.success()); + assert_eq!( + String::from_utf8(output.stdout).unwrap(), + format!( + "GJC_DESKTOP_UPDATE_PIPE={}\n", + if enabled { "1" } else { "0" } + ) + ); + } + } + #[test] fn health_check_accepts_only_the_expected_server_identity() { for version in [EXPECTED_PAYLOAD_VERSION, "wrong"] { diff --git a/src-tauri/src/updater.rs b/src-tauri/src/updater.rs index 278dd302..8266ec07 100644 --- a/src-tauri/src/updater.rs +++ b/src-tauri/src/updater.rs @@ -1,6 +1,6 @@ -//! Preparation-only updater owner. Installation, restart, attempt resolution and -//! browser authority remain deliberately unavailable until their safety gates -//! are proven. No official plugin install/download API is called here. +//! Native discovery and explicitly requested archive staging. Scheduled checks +//! publish metadata only; a target-bound click permits download/verification. +//! Installation and restart remain in their separately guarded owners. use std::{ future::Future, sync::{ @@ -23,8 +23,9 @@ use crate::{ self, DiscoveryCompleteness, DiscoveryCursor, DiscoveryError, DiscoveryPolicy, SelectedRelease, }, - updater_manifest::{parse_manifest, Channel, Manifest, ProductIdentity}, - updater_signature::{digest, verify_archive}, + updater_install::{InstallError, VerifiedArchive}, + updater_manifest::{Channel, Manifest, ProductIdentity}, + updater_signature::verify_archive, updater_store::{PreparedRecord, Store}, updater_transport::{build_client, HttpsClient}, }; @@ -38,44 +39,66 @@ pub enum Phase { Disabled, Idle, Checking, + Available, Downloading, Verifying, Ready, Deferred, Error, + Applying, + Restarting, + Recovery, } #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct Snapshot { + pub protocol_version: u8, pub phase: Phase, pub automatic: bool, pub product_version: &'static str, pub desktop_version: &'static str, + pub target_id: Option, pub target_product_version: Option, pub target_desktop_version: Option, pub discovery_incomplete: bool, pub reason: Option<&'static str>, /// A staged archive is NOT installation permission or installation proof. pub installation_available: bool, + pub downloaded_bytes: Option, + pub total_bytes: Option, + pub notes: Option, } impl Default for Snapshot { fn default() -> Self { Self { + protocol_version: 1, phase: Phase::Disabled, automatic: false, product_version: env!("GJC_EXPECTED_PAYLOAD_VERSION"), desktop_version: env!("CARGO_PKG_VERSION"), + target_id: None, target_product_version: None, target_desktop_version: None, discovery_incomplete: true, reason: None, installation_available: false, + downloaded_bytes: None, + total_bytes: None, + notes: None, } } } +fn startup_snapshot(automatic: bool) -> Snapshot { + Snapshot { + phase: Phase::Idle, + automatic, + ..Snapshot::default() + } +} + struct Control { snapshot: Snapshot, snapshot_generation: u64, @@ -87,6 +110,9 @@ struct Control { failures: usize, store: Option>, verified: Option, + offered: Option, + download_requested: Option, + downloading: Option, } impl Default for Control { @@ -102,6 +128,9 @@ impl Default for Control { failures: 0, store: None, verified: None, + offered: None, + download_requested: None, + downloading: None, } } } @@ -119,13 +148,52 @@ struct Coordinator { #[derive(Default)] pub(crate) struct Preparation(Arc); +impl Preparation { + pub(crate) fn snapshot(&self, admit: impl FnOnce() -> bool) -> Result { + let control = self.0.control.lock().map_err(|_| "updater_unavailable")?; + if !admit() { + return Err("updater_unauthorized"); + } + Ok(self.0.snapshot_from(&control)) + } + + pub(crate) fn set_automatic( + &self, + automatic: bool, + admit: impl FnOnce() -> bool, + ) -> Result { + self.0.set_automatic_if(automatic, admit)?; + Ok(self.0.snapshot()) + } + + pub(crate) fn manual_check( + &self, + admit: impl FnOnce() -> bool, + ) -> Result { + self.0.manual_check_if(admit)?; + Ok(self.0.snapshot()) + } + + pub(crate) fn manual_download( + &self, + target_id: &str, + admit: impl FnOnce() -> bool, + ) -> Result { + self.0.manual_download_if(target_id, admit)?; + Ok(self.0.snapshot()) + } +} + impl Coordinator { /// The only snapshot publication boundary. Raw state may have been written /// by a worker racing nonblocking invalidation; its epoch cannot be exposed /// as Ready after that epoch has retired. - #[allow(dead_code)] fn snapshot(&self) -> Snapshot { let control = self.control.lock().expect("update owner lock poisoned"); + self.snapshot_from(&control) + } + + fn snapshot_from(&self, control: &Control) -> Snapshot { let mut snapshot = control.snapshot.clone(); if snapshot.phase != Phase::Disabled && !self.valid(control.snapshot_generation) { snapshot.phase = Phase::Deferred; @@ -139,6 +207,8 @@ impl Coordinator { let was_healthy = self.healthy.swap(true, Ordering::AcqRel); if !was_healthy { self.generation.fetch_add(1, Ordering::AcqRel); + control.offered = None; + control.download_requested = None; } control.next_due = Instant::now(); if self.started.load(Ordering::Acquire) { @@ -174,7 +244,7 @@ impl Coordinator { if !self.valid(generation) { return Err(PrepareError::Cancelled); } - set_target(&mut control.snapshot, &target.manifest); + set_target(&mut control.snapshot, &target.manifest, &target.id); control.snapshot.phase = Phase::Ready; control.snapshot_generation = generation; control.verified = Some(target); @@ -186,6 +256,9 @@ impl Coordinator { control.in_flight = false; control.store = None; control.verified = None; + control.offered = None; + control.download_requested = None; + control.downloading = None; if let Err(error) = result { control.snapshot.phase = Phase::Error; control.snapshot.reason = Some(error.code()); @@ -215,11 +288,21 @@ impl Coordinator { Ok(()) } - /// Used only by a future authenticated native bridge. No remote Tauri grant - /// or backend/browser route is installed by this preparation slice. - #[allow(dead_code)] + #[cfg(test)] fn set_automatic(&self, automatic: bool) -> Result<(), &'static str> { + self.set_automatic_if(automatic, || true) + } + + /// Check authority after acquiring the preference serialization lock. + fn set_automatic_if( + &self, + automatic: bool, + admit: impl FnOnce() -> bool, + ) -> Result<(), &'static str> { let mut control = self.control.lock().map_err(|_| "updater_unavailable")?; + if !admit() { + return Err("updater_unauthorized"); + } let store = control.store.as_ref().ok_or("updater_inactive")?.clone(); // Serialize the durable preference acknowledgement with ready publication. // Even a disk failure cancels this generation in memory, without claiming @@ -229,6 +312,8 @@ impl Coordinator { .fetch_add(1, Ordering::AcqRel) .wrapping_add(1); control.requested = false; + control.download_requested = None; + control.downloading = None; control.snapshot.automatic = false; if store.set_automatic(automatic).is_err() { control.snapshot.phase = Phase::Error; @@ -237,16 +322,23 @@ impl Coordinator { return Err("preferences_not_persisted"); } control.snapshot.automatic = automatic; - control.snapshot.phase = Phase::Idle; + restore_target(&mut control); control.requested = automatic; control.next_due = Instant::now(); self.changed.notify_one(); Ok(()) } - #[allow(dead_code)] + #[cfg(test)] fn manual_check(&self) -> Result<(), &'static str> { + self.manual_check_if(|| true) + } + + fn manual_check_if(&self, admit: impl FnOnce() -> bool) -> Result<(), &'static str> { let mut control = self.control.lock().map_err(|_| "updater_unavailable")?; + if !admit() { + return Err("updater_unauthorized"); + } if control.store.is_none() || !self.healthy.load(Ordering::Acquire) || !self.started.load(Ordering::Acquire) @@ -254,13 +346,156 @@ impl Coordinator { return Err("updater_inactive"); } // Coalesce repeated requests; manual checking never modifies consent. - if !control.in_flight { + if !control.in_flight && control.download_requested.is_none() { control.requested = true; control.restart_requested = true; self.changed.notify_one(); } Ok(()) } + + fn manual_download_if( + &self, + target_id: &str, + admit: impl FnOnce() -> bool, + ) -> Result<(), &'static str> { + let mut control = self.control.lock().map_err(|_| "updater_unavailable")?; + if !admit() { + return Err("updater_unauthorized"); + } + if control.store.is_none() + || !self.started.load(Ordering::Acquire) + || !self.valid(control.snapshot_generation) + { + return Err("updater_inactive"); + } + if !crate::updater_backend::hex_id(target_id) + || control.snapshot.target_id.as_deref() != Some(target_id) + { + return Err("updater_target_changed"); + } + // Duplicate clicks reuse a ready archive or the one already queued. + if control.snapshot.phase == Phase::Ready + && control + .verified + .as_ref() + .is_some_and(|target| target.id == target_id) + || control + .download_requested + .as_ref() + .is_some_and(|target| target.target_id() == target_id) + || control.downloading.as_deref() == Some(target_id) + { + return Ok(()); + } + if control.in_flight { + return Err("updater_busy"); + } + let selected = control + .offered + .as_ref() + .filter(|target| target.target_id() == target_id) + .cloned() + .ok_or("updater_target_changed")?; + // A click during Retry-After fails now; it must not become a delayed + // download after the UI has discarded its intent. Ready cache reuse and + // coalescing an already admitted download above do not issue new I/O. + if Instant::now() < control.not_before { + return Err("updater_retry_later"); + } + control.snapshot.phase = Phase::Downloading; + control.snapshot.reason = None; + control.snapshot.downloaded_bytes = None; + control.snapshot.total_bytes = Some(selected.archive_asset.size); + control.download_requested = Some(selected); + control.restart_requested = true; + self.changed.notify_one(); + Ok(()) + } + + fn claim_work(&self, now: Instant) -> Option<(u64, Work)> { + let mut control = self.control.lock().expect("update owner lock poisoned"); + control.restart_requested = false; + if !self.healthy.load(Ordering::Acquire) || control.in_flight || now < control.not_before { + return None; + } + let work = if let Some(selected) = control.download_requested.take() { + control.downloading = Some(selected.target_id()); + Work::Download(Box::new(selected)) + } else if control.requested || control.snapshot.automatic && now >= control.next_due { + Work::Check + } else { + return None; + }; + control.in_flight = true; + control.requested = false; + Some((self.generation.load(Ordering::Acquire), work)) + } + + /// Finish the single owned operation. True discards its discovery cursor; + /// failures never restore a consumed manual-download request. + fn finish_work( + &self, + generation: u64, + result: Result<(bool, Option), PrepareError>, + ) -> bool { + let mut control = self.control.lock().expect("update owner lock poisoned"); + control.in_flight = false; + control.downloading = None; + if !self.valid(generation) { + if control.snapshot_generation == generation { + control.snapshot.phase = Phase::Deferred; + control.snapshot.reason = Some("preparation_cancelled"); + } + return true; + } + match result { + Ok((incomplete, delay)) => { + control.failures = 0; + control.snapshot.discovery_incomplete = incomplete; + if !matches!(control.snapshot.phase, Phase::Ready | Phase::Available) { + restore_target(&mut control); + } + let minimum = delay; + let delay = delay.unwrap_or_else(|| { + if incomplete { + CONTINUATION_DELAY + } else { + interval_delay() + } + }); + control.next_due = Instant::now() + delay; + control.not_before = Instant::now() + minimum.unwrap_or(Duration::ZERO); + } + Err(PrepareError::Cancelled) => { + // A healthy/manual wake can cancel a network waiter without an + // epoch change. Do not leave the UI polling Downloading forever. + control.snapshot.phase = Phase::Deferred; + control.snapshot.reason = Some("preparation_cancelled"); + return true; + } + Err(error) => { + let delay = match &error { + PrepareError::Discovery(DiscoveryError::RetryAfter(delay)) => *delay, + _ => retry_delay(control.failures), + }; + control.failures = control.failures.saturating_add(1); + control.next_due = Instant::now() + delay; + control.not_before = match &error { + PrepareError::Discovery(DiscoveryError::RetryAfter(_)) => control.next_due, + _ => Instant::now(), + }; + control.snapshot.phase = Phase::Deferred; + control.snapshot.reason = Some(error.code()); + } + } + false + } +} + +enum Work { + Check, + Download(Box), } pub(crate) fn unhealthy(app: &AppHandle) { @@ -279,6 +514,11 @@ pub(crate) fn after_healthy(app: &AppHandle) { { return; } + // Do not begin automatic preparation if the authenticated control path + // failed to initialize; that would leave the user without its opt-out UI. + if !crate::updater_bridge::available(app) { + return; + } let binding = Binding::compiled(); let profile = app.try_state::(); if !cfg!(target_arch = "aarch64") @@ -301,7 +541,7 @@ pub(crate) fn after_healthy(app: &AppHandle) { } #[derive(Debug)] -enum PrepareError { +pub(crate) enum PrepareError { Cancelled, Binding, Cache, @@ -312,7 +552,7 @@ enum PrepareError { Discovery(DiscoveryError), } impl PrepareError { - fn code(&self) -> &'static str { + pub(crate) fn code(&self) -> &'static str { match self { Self::Cancelled => "preparation_cancelled", Self::Binding => "binding_mismatch", @@ -326,31 +566,27 @@ impl PrepareError { } } -struct Runtime { - store: Arc, - client: HttpsClient, - policy: DiscoveryPolicy, - binding: Binding, - os: String, +pub(crate) struct Runtime { + pub(crate) store: Arc, + pub(crate) client: HttpsClient, + pub(crate) policy: DiscoveryPolicy, + pub(crate) binding: Binding, + pub(crate) os: String, + pub(crate) certificate: Option, } #[derive(Clone)] struct VerifiedTarget { manifest: Manifest, - release_id: u64, - manifest_asset_id: u64, - archive_asset_id: u64, + id: String, } impl VerifiedTarget { fn matches(&self, selected: &SelectedRelease) -> bool { - self.manifest == selected.manifest - && self.release_id == selected.release.id - && self.manifest_asset_id == selected.manifest_asset.id - && self.archive_asset_id == selected.archive_asset.id + self.id == selected.target_id() } } -fn initialize(app: &AppHandle, binding: Binding) -> Result { +pub(crate) fn initialize(app: &AppHandle, binding: Binding) -> Result { let profile = app.try_state::(); let root = crate::supervisor::desktop_data_root(app).map_err(|_| PrepareError::Binding)?; let executable = std::env::current_exe().map_err(|_| PrepareError::Binding)?; @@ -402,7 +638,7 @@ fn initialize(app: &AppHandle, binding: Binding) -> Result Result= control.not_before - && (control.requested - || (control.snapshot.automatic && now >= control.next_due)) - { - control.in_flight = true; - control.requested = false; - Some(owner.generation.load(Ordering::Acquire)) - } else { - None - } - }; - let Some(generation) = decision else { + let Some(work) = owner.claim_work(Instant::now()) else { // A bounded heartbeat coalesces OS wake/suspend without an event // listener storm. Check requests and health changes wake immediately. let _ = tokio::time::timeout(Duration::from_secs(30), notified).await; @@ -485,56 +706,27 @@ async fn run( } continue; }; - generation + work }; - let result = prepare(&owner, generation, &runtime, &mut cursor).await; - let mut control = owner.control.lock().expect("update owner lock poisoned"); - control.in_flight = false; - if !owner.valid(generation) { - control.snapshot.phase = Phase::Deferred; - control.snapshot.reason = Some("preparation_cancelled"); - cursor = DiscoveryCursor::default(); - continue; - } - match result { - Ok((incomplete, delay)) => { - control.failures = 0; - control.snapshot.discovery_incomplete = incomplete; - if control.snapshot.phase != Phase::Ready { - control.snapshot.phase = if control.verified.is_some() { - Phase::Ready - } else { - Phase::Idle - }; - } - let minimum = delay; - let delay = delay.unwrap_or_else(|| { - if incomplete { - CONTINUATION_DELAY - } else { - interval_delay() - } - }); - control.next_due = Instant::now() + delay; - control.not_before = Instant::now() + minimum.unwrap_or(Duration::ZERO); - } - Err(PrepareError::Cancelled) => { - cursor = DiscoveryCursor::default(); - } - Err(error) => { - let delay = match &error { - PrepareError::Discovery(DiscoveryError::RetryAfter(delay)) => *delay, - _ => retry_delay(control.failures), - }; - control.failures = control.failures.saturating_add(1); - control.next_due = Instant::now() + delay; - control.not_before = match &error { - PrepareError::Discovery(DiscoveryError::RetryAfter(_)) => control.next_due, - _ => Instant::now(), - }; - control.snapshot.phase = Phase::Deferred; - control.snapshot.reason = Some(error.code()); + let result = match work { + Work::Check => { + discover( + &owner, + generation, + updater_discovery::discover_burst( + &runtime.client, + &runtime.policy, + &mut cursor, + ), + ) + .await } + Work::Download(selected) => download(&owner, generation, &runtime, *selected) + .await + .map(|()| (owner.snapshot().discovery_incomplete, None)), + }; + if owner.finish_work(generation, result) { + cursor = DiscoveryCursor::default(); } } } @@ -556,41 +748,88 @@ async fn cancellable( } } -async fn prepare( +async fn discover( owner: &Coordinator, generation: u64, - runtime: &Runtime, - cursor: &mut DiscoveryCursor, + discovery: impl Future>, ) -> Result<(bool, Option), PrepareError> { owner.phase(generation, Phase::Checking)?; - let result = cancellable( - owner, - generation, - updater_discovery::discover_burst(&runtime.client, &runtime.policy, cursor), - ) - .await?; + let result = cancellable(owner, generation, discovery).await?; let incomplete = !matches!( result.completeness, DiscoveryCompleteness::CompleteObservedScan ); let Some(selected) = result.selected else { - return Ok((incomplete, result.retry_after)); - }; - { let mut control = owner.control.lock().expect("update owner lock poisoned"); if !owner.valid(generation) { return Err(PrepareError::Cancelled); } - if control + restore_target(&mut control); + return Ok((incomplete, result.retry_after)); + }; + offer(owner, generation, selected)?; + Ok((incomplete, result.retry_after)) +} + +fn offer( + owner: &Coordinator, + generation: u64, + selected: SelectedRelease, +) -> Result<(), PrepareError> { + let mut control = owner.control.lock().expect("update owner lock poisoned"); + if !owner.valid(generation) { + return Err(PrepareError::Cancelled); + } + // Retain the complete native candidate; a later click never runs discovery. + control.offered = Some(selected); + restore_target(&mut control); + control.snapshot_generation = generation; + Ok(()) +} + +fn restore_target(control: &mut Control) { + if let Some(selected) = &control.offered { + set_target( + &mut control.snapshot, + &selected.manifest, + &selected.target_id(), + ); + control.snapshot.phase = if control .verified .as_ref() - .is_some_and(|verified| verified.matches(&selected)) + .is_some_and(|verified| verified.matches(selected)) { - control.snapshot.phase = Phase::Ready; - return Ok((incomplete, result.retry_after)); - } + Phase::Ready + } else { + Phase::Available + }; + control.snapshot.downloaded_bytes = None; + control.snapshot.total_bytes = Some(selected.archive_asset.size); + } else if let Some(verified) = &control.verified { + set_target(&mut control.snapshot, &verified.manifest, &verified.id); + control.snapshot.phase = Phase::Ready; + } else { + control.snapshot.phase = Phase::Idle; } +} + +async fn download( + owner: &Coordinator, + generation: u64, + runtime: &Runtime, + selected: SelectedRelease, +) -> Result<(), PrepareError> { owner.phase(generation, Phase::Downloading)?; + { + let mut control = owner.control.lock().expect("update owner lock poisoned"); + if !owner.valid(generation) { + return Err(PrepareError::Cancelled); + } + // The transport is not progress-reporting. Do not synthesize a percent + // until the complete, bounded response has actually arrived. + control.snapshot.downloaded_bytes = None; + control.snapshot.total_bytes = Some(selected.archive_asset.size); + } let bytes = cancellable( owner, generation, @@ -602,14 +841,19 @@ async fn prepare( ), ) .await?; + { + let mut control = owner.control.lock().expect("update owner lock poisoned"); + if !owner.valid(generation) { + return Err(PrepareError::Cancelled); + } + control.snapshot.downloaded_bytes = Some(bytes.len() as u64); + } owner.phase(generation, Phase::Verifying)?; let key = runtime.binding.public_key.clone(); let manifest = selected.manifest.clone(); let target = VerifiedTarget { manifest: manifest.clone(), - release_id: selected.release.id, - manifest_asset_id: selected.manifest_asset.id, - archive_asset_id: selected.archive_asset.id, + id: selected.target_id(), }; let store = runtime.store.clone(); // Never drop this blocking worker on cancellation. It has no installer @@ -636,12 +880,11 @@ async fn prepare( if !owner.valid(generation) { return Err(PrepareError::Cancelled); } - set_target(&mut control.snapshot, &manifest); + set_target(&mut control.snapshot, &manifest, &target.id); control.snapshot.phase = Phase::Ready; control.snapshot_generation = generation; - control.snapshot.discovery_incomplete = incomplete; control.verified = Some(target); - Ok((incomplete, result.retry_after)) + Ok(()) } fn identity() -> ProductIdentity<'static> { @@ -690,28 +933,22 @@ fn validate_cache( key: &str, os: &str, ) -> Result, PrepareError> { - let Some((record, bytes)) = store.load().map_err(|_| PrepareError::Cache)? else { + let Some(archive) = VerifiedArchive::load(store, key).map_err(|error| match error { + InstallError::Signature => PrepareError::Signature, + InstallError::Archive => PrepareError::Archive, + _ => PrepareError::Cache, + })? + else { return Ok(None); }; - let manifest = - parse_manifest(record.manifest.as_bytes(), &identity()).map_err(|_| PrepareError::Cache)?; - if !eligible_cached(&manifest, os)? { + let manifest = archive.manifest(); + let record = archive.record(); + if !eligible_cached(manifest, os)? { return Ok(None); } - if digest(&bytes) != record.archive_sha256 { - return Err(PrepareError::Cache); - } - verify_archive(&bytes, key, &manifest.signature).map_err(|_| PrepareError::Signature)?; - let inventory = - inspect_archive(&bytes, &archive_identity(&manifest)).map_err(|_| PrepareError::Archive)?; - if serde_json::to_value(inventory).map_err(|_| PrepareError::Archive)? != record.inventory { - return Err(PrepareError::Cache); - } Ok(Some(VerifiedTarget { - manifest, - release_id: record.release_id, - manifest_asset_id: record.manifest_asset_id, - archive_asset_id: record.archive_asset_id, + manifest: manifest.clone(), + id: record.target_id(), })) } @@ -725,7 +962,7 @@ fn installed_channel() -> Result { } } -fn eligible_cached(manifest: &Manifest, os: &str) -> Result { +pub(crate) fn eligible_cached(manifest: &Manifest, os: &str) -> Result { let current = Version::parse(env!("CARGO_PKG_VERSION")).map_err(|_| PrepareError::Policy)?; let floor = Version::new(0, 2, 3); let parse_os = |value: &str| -> Result<[u16; 3], PrepareError> { @@ -745,9 +982,15 @@ fn eligible_cached(manifest: &Manifest, os: &str) -> Result && parse_os(&manifest.minimum_system_version)? <= parse_os(os)?) } -fn set_target(snapshot: &mut Snapshot, manifest: &Manifest) { +fn set_target(snapshot: &mut Snapshot, manifest: &Manifest, target_id: &str) { + snapshot.target_id = Some(target_id.to_owned()); snapshot.target_product_version = Some(manifest.product_version.to_string()); snapshot.target_desktop_version = Some(manifest.version.to_string()); + let mut notes: String = manifest.notes.chars().take(4096).collect(); + if notes.len() < manifest.notes.len() { + notes.push('…'); + } + snapshot.notes = Some(notes); snapshot.reason = Some("installation_safety_gate_pending"); } @@ -770,6 +1013,7 @@ fn interval_delay() -> Duration { #[cfg(test)] mod tests { use super::*; + use crate::{updater_manifest::parse_manifest, updater_signature::digest}; use std::{fs, os::unix::fs::PermissionsExt, path::PathBuf}; struct Temp(PathBuf); impl Temp { @@ -790,6 +1034,508 @@ mod tests { } } + fn candidate() -> SelectedRelease { + use crate::updater_discovery::{AssetIdentity, ReleaseIdentity}; + let bytes = include_bytes!("../../shared/fixtures/desktop-update-manifest.json").to_vec(); + let manifest = parse_manifest(&bytes, &identity()).unwrap(); + let base = format!( + "https://api.github.com/repos/{}/releases", + identity().repository + ); + let tag = format!("v{}", manifest.product_version); + let archive_name = manifest + .archive_url + .path_segments() + .unwrap() + .next_back() + .unwrap() + .to_owned(); + SelectedRelease { + release: ReleaseIdentity { + id: 1, + tag_name: tag.clone(), + api_url: format!("{base}/1").parse().unwrap(), + html_url: format!( + "https://github.com/{}/releases/tag/{tag}", + identity().repository + ) + .parse() + .unwrap(), + prerelease: true, + }, + manifest_asset: AssetIdentity { + id: 2, + name: "desktop-update.json".into(), + size: bytes.len() as u64, + api_url: format!("{base}/assets/2").parse().unwrap(), + download_url: manifest.archive_url.join("desktop-update.json").unwrap(), + }, + archive_asset: AssetIdentity { + id: 3, + name: archive_name, + size: 4, + api_url: format!("{base}/assets/3").parse().unwrap(), + download_url: manifest.archive_url.clone(), + }, + manifest, + manifest_bytes: bytes, + } + } + + fn active_owner(automatic: bool) -> (Temp, Arc) { + let temp = Temp::new(); + let store = Arc::new(Store::open(&temp.0).unwrap()); + store.set_automatic(automatic).unwrap(); + let owner = Arc::new(Coordinator::default()); + owner.healthy.store(true, Ordering::Release); + owner.started.store(true, Ordering::Release); + { + let mut control = owner.control.lock().unwrap(); + control.store = Some(store); + control.snapshot = startup_snapshot(automatic); + } + (temp, owner) + } + + #[test] + fn scheduled_and_manual_discovery_publish_available_without_downloading_or_staging() { + for automatic in [true, false] { + let (_temp, owner) = active_owner(automatic); + if !automatic { + owner.manual_check().unwrap(); + } + let (generation, work) = owner.claim_work(Instant::now()).unwrap(); + assert!(matches!(work, Work::Check)); + let selected = candidate(); + let target_id = selected.target_id(); + tauri::async_runtime::block_on(discover(&owner, generation, async { + Ok(updater_discovery::DiscoveryResult { + selected: Some(selected), + completeness: DiscoveryCompleteness::CompleteObservedScan, + pages_observed: 1, + retry_after: None, + }) + })) + .unwrap(); + let snapshot = owner.snapshot(); + assert_eq!(snapshot.phase, Phase::Available); + assert_eq!(snapshot.target_id.as_deref(), Some(target_id.as_str())); + assert_eq!(snapshot.downloaded_bytes, None); + let control = owner.control.lock().unwrap(); + assert!(control.download_requested.is_none()); + assert!(control.downloading.is_none()); + assert!(control.verified.is_none()); + assert!(control + .store + .as_ref() + .unwrap() + .prepared_record() + .unwrap() + .is_none()); + assert_eq!( + control + .store + .as_ref() + .unwrap() + .preferences() + .unwrap() + .automatic, + automatic + ); + } + } + + #[test] + fn download_admission_is_prompt_bound_coalesced_and_preserves_check_consent() { + let (_temp, owner) = active_owner(false); + let selected = candidate(); + let target_id = selected.target_id(); + offer(&owner, 0, selected.clone()).unwrap(); + let before = Instant::now(); + let state = Preparation(owner.clone()) + .manual_download(&target_id, || true) + .unwrap(); + assert!(before.elapsed() < Duration::from_secs(1)); + assert_eq!(state.phase, Phase::Downloading); + assert_eq!(state.target_id.as_deref(), Some(target_id.as_str())); + owner.manual_download_if(&target_id, || true).unwrap(); + owner.manual_check().unwrap(); + let (generation, work) = owner.claim_work(Instant::now()).unwrap(); + let Work::Download(queued) = work else { + panic!("click must not rediscover") + }; + assert_eq!(*queued, selected); + owner.manual_download_if(&target_id, || true).unwrap(); + assert!(owner.control.lock().unwrap().download_requested.is_none()); + assert!(owner.claim_work(Instant::now()).is_none()); + // A verified completion preserves the exact offered identity. Repeated + // clicks on that ready target must not queue another archive request. + owner + .accept_cached( + generation, + VerifiedTarget { + id: target_id.clone(), + manifest: selected.manifest, + }, + ) + .unwrap(); + { + let mut control = owner.control.lock().unwrap(); + control.in_flight = false; + control.downloading = None; + } + owner.manual_download_if(&target_id, || true).unwrap(); + assert_eq!(owner.snapshot().phase, Phase::Ready); + assert_eq!( + owner.snapshot().target_id.as_deref(), + Some(target_id.as_str()) + ); + assert!(owner.claim_work(Instant::now()).is_none()); + assert!( + !owner + .control + .lock() + .unwrap() + .store + .as_ref() + .unwrap() + .preferences() + .unwrap() + .automatic + ); + } + + #[test] + fn wrong_stale_unoffered_or_retired_download_targets_are_rejected_without_queueing() { + let (_temp, owner) = active_owner(false); + let selected = candidate(); + let target_id = selected.target_id(); + assert_eq!( + owner.manual_download_if(&target_id, || true), + Err("updater_target_changed") + ); + offer(&owner, 0, selected.clone()).unwrap(); + for wrong in ["", "not-a-target", &"f".repeat(64)] { + assert_eq!( + owner.manual_download_if(wrong, || true), + Err("updater_target_changed") + ); + } + let mut replacement = selected.clone(); + replacement.manifest_bytes.push(b'\n'); + offer(&owner, 0, replacement).unwrap(); + assert_eq!( + owner.manual_download_if(&target_id, || true), + Err("updater_target_changed") + ); + assert!(owner.control.lock().unwrap().download_requested.is_none()); + offer(&owner, 0, selected).unwrap(); + owner.invalidate(); + assert_eq!( + owner.manual_download_if(&target_id, || true), + Err("updater_inactive") + ); + assert!(owner.control.lock().unwrap().download_requested.is_none()); + } + + #[test] + fn rate_limited_manual_download_rejects_without_queueing_or_changing_phase() { + for automatic in [false, true] { + for phase in [Phase::Available, Phase::Deferred] { + let (_temp, owner) = active_owner(automatic); + let selected = candidate(); + let target_id = selected.target_id(); + offer(&owner, 0, selected).unwrap(); + let not_before = Instant::now() + Duration::from_secs(24 * 60 * 60); + { + let mut control = owner.control.lock().unwrap(); + control.snapshot.phase = phase; + control.not_before = not_before; + control.next_due = not_before; + } + let original = serde_json::to_value(owner.snapshot()).unwrap(); + let preparation = Preparation(owner.clone()); + let before = Instant::now(); + for _ in 0..3 { + assert_eq!( + preparation + .manual_download(&target_id, || true) + .unwrap_err(), + "updater_retry_later" + ); + } + assert!(before.elapsed() < Duration::from_secs(1)); + assert_eq!(serde_json::to_value(owner.snapshot()).unwrap(), original); + { + let control = owner.control.lock().unwrap(); + assert!(control.download_requested.is_none()); + assert!(control.downloading.is_none()); + assert!(!control.requested); + assert!(!control.restart_requested); + assert!(!control.in_flight); + assert!(control + .store + .as_ref() + .unwrap() + .prepared_record() + .unwrap() + .is_none()); + } + assert!(owner.claim_work(Instant::now()).is_none()); + let later = owner.claim_work(not_before); + if automatic { + assert!(matches!(later, Some((_, Work::Check)))); + } else { + assert!(later.is_none()); + } + } + } + } + + #[test] + fn expired_retry_after_requires_a_new_explicit_download_click() { + let (_temp, owner) = active_owner(false); + let selected = candidate(); + let target_id = selected.target_id(); + offer(&owner, 0, selected).unwrap(); + owner.control.lock().unwrap().not_before = Instant::now() + Duration::from_secs(60); + assert_eq!( + owner.manual_download_if(&target_id, || true), + Err("updater_retry_later") + ); + owner.control.lock().unwrap().not_before = Instant::now(); + assert!(owner.claim_work(Instant::now()).is_none()); + owner.manual_download_if(&target_id, || true).unwrap(); + assert_eq!(owner.snapshot().phase, Phase::Downloading); + assert!(matches!( + owner.claim_work(Instant::now()), + Some((_, Work::Download(_))) + )); + } + + #[test] + fn rate_limit_does_not_prevent_verified_ready_cache_reuse() { + let (_temp, owner) = active_owner(false); + let selected = candidate(); + let target_id = selected.target_id(); + owner + .accept_cached( + 0, + VerifiedTarget { + id: target_id.clone(), + manifest: selected.manifest, + }, + ) + .unwrap(); + owner.control.lock().unwrap().not_before = + Instant::now() + Duration::from_secs(24 * 60 * 60); + owner.manual_download_if(&target_id, || true).unwrap(); + assert_eq!(owner.snapshot().phase, Phase::Ready); + assert!(owner.control.lock().unwrap().download_requested.is_none()); + } + + #[test] + fn opt_out_cancels_queued_download_without_later_auto_download() { + let (_temp, owner) = active_owner(true); + let selected = candidate(); + let target_id = selected.target_id(); + offer(&owner, 0, selected).unwrap(); + owner.manual_download_if(&target_id, || true).unwrap(); + assert_eq!(owner.snapshot().phase, Phase::Downloading); + owner.set_automatic(false).unwrap(); + assert!(owner.claim_work(Instant::now()).is_none()); + assert!(owner.control.lock().unwrap().download_requested.is_none()); + owner.set_automatic(true).unwrap(); + assert!(matches!( + owner.claim_work(Instant::now()), + Some((_, Work::Check)) + )); + } + + #[test] + fn retained_verified_cache_remains_ready_on_discovery_but_not_for_same_version_substitution() { + let (_temp, owner) = active_owner(true); + let selected = candidate(); + owner + .accept_cached( + 0, + VerifiedTarget { + id: selected.target_id(), + manifest: selected.manifest.clone(), + }, + ) + .unwrap(); + offer(&owner, 0, selected.clone()).unwrap(); + assert_eq!(owner.snapshot().phase, Phase::Ready); + let mut replaced = selected; + replaced.archive_asset.id += 1; + offer(&owner, 0, replaced).unwrap(); + assert_eq!(owner.snapshot().phase, Phase::Available); + assert!(owner.control.lock().unwrap().download_requested.is_none()); + } + + #[test] + fn download_authority_is_rechecked_after_waiting_for_the_coordinator_lock() { + let (_temp, owner) = active_owner(false); + let selected = candidate(); + let target_id = selected.target_id(); + offer(&owner, 0, selected).unwrap(); + let admission = Arc::new(AtomicBool::new(true)); + let lock = owner.control.lock().unwrap(); + let (entered, waiting) = std::sync::mpsc::sync_channel(1); + let worker = { + let owner = owner.clone(); + let admission = admission.clone(); + std::thread::spawn(move || { + entered.send(()).unwrap(); + owner.manual_download_if(&target_id, || admission.load(Ordering::Acquire)) + }) + }; + waiting.recv().unwrap(); + admission.store(false, Ordering::Release); + drop(lock); + assert_eq!(worker.join().unwrap(), Err("updater_unauthorized")); + assert!(owner.control.lock().unwrap().download_requested.is_none()); + } + + #[test] + fn status_reads_do_not_queue_download_or_check_work() { + let (_temp, owner) = active_owner(false); + offer(&owner, 0, candidate()).unwrap(); + let preparation = Preparation(owner.clone()); + for _ in 0..5 { + assert_eq!( + preparation.snapshot(|| true).unwrap().phase, + Phase::Available + ); + } + let control = owner.control.lock().unwrap(); + assert!(!control.requested); + assert!(!control.in_flight); + assert!(control.download_requested.is_none()); + assert!(control + .store + .as_ref() + .unwrap() + .prepared_record() + .unwrap() + .is_none()); + } + + #[test] + fn failed_or_cancelled_manual_downloads_never_retry_automatically() { + for automatic in [false, true] { + for error in [ + PrepareError::Cancelled, + PrepareError::Discovery(DiscoveryError::Network), + PrepareError::Discovery(DiscoveryError::RetryAfter(Duration::from_secs(120))), + PrepareError::Signature, + PrepareError::Archive, + ] { + let (_temp, owner) = active_owner(automatic); + let selected = candidate(); + let target_id = selected.target_id(); + offer(&owner, 0, selected).unwrap(); + owner.manual_download_if(&target_id, || true).unwrap(); + let (generation, work) = owner.claim_work(Instant::now()).unwrap(); + assert!(matches!(work, Work::Download(_))); + owner.finish_work(generation, Err(error)); + assert_eq!(owner.snapshot().phase, Phase::Deferred); + assert!(owner.snapshot().reason.is_some()); + assert_eq!( + owner.snapshot().target_id.as_deref(), + Some(target_id.as_str()) + ); + let due = { + let control = owner.control.lock().unwrap(); + control.next_due.max(control.not_before).max(Instant::now()) + }; + let next = owner.claim_work(due); + if automatic { + assert!(matches!(next, Some((_, Work::Check)))); + } else { + assert!(next.is_none()); + } + assert!(owner.control.lock().unwrap().download_requested.is_none()); + } + } + } + + #[test] + fn retiring_owner_drops_queued_target_and_cannot_resume_it_on_a_new_generation() { + let (_temp, owner) = active_owner(false); + let selected = candidate(); + let target_id = selected.target_id(); + offer(&owner, 0, selected).unwrap(); + owner.manual_download_if(&target_id, || true).unwrap(); + owner.invalidate(); + assert!(!owner.retire(Ok(()))); + assert_eq!( + owner.manual_download_if(&target_id, || true), + Err("updater_inactive") + ); + owner.healthy_start(); + let control = owner.control.lock().unwrap(); + assert!(control.download_requested.is_none()); + assert!(control.offered.is_none()); + } + + #[test] + fn healthy_startup_with_automatic_off_does_not_keep_a_stale_server_error() { + let snapshot = startup_snapshot(false); + assert_eq!(snapshot.phase, Phase::Idle); + assert!(!snapshot.automatic); + assert_eq!(snapshot.reason, None); + assert_eq!(snapshot.target_desktop_version, None); + assert!( + snapshot.discovery_incomplete, + "no new discovery was performed" + ); + assert!(!snapshot.installation_available); + } + + #[test] + fn native_snapshot_keys_match_the_shared_frontend_fixture() { + let native = serde_json::to_value(Snapshot::default()).unwrap(); + let fixture: serde_json::Value = serde_json::from_str(include_str!( + "../../shared/fixtures/desktop-update-status.json" + )) + .unwrap(); + assert_eq!( + native.as_object().unwrap().keys().collect::>(), + fixture.as_object().unwrap().keys().collect::>() + ); + assert_eq!(native["protocolVersion"], fixture["protocolVersion"]); + assert_eq!(native["installationAvailable"], false); + assert!(native.as_object().unwrap().contains_key("targetId")); + assert!(native["targetId"].is_null()); + assert_eq!(serde_json::to_value(Phase::Available).unwrap(), "available"); + } + + #[test] + fn revoked_authority_is_rechecked_after_waiting_for_the_preference_lock() { + let temp = Temp::new(); + let owner = Arc::new(Coordinator::default()); + let store = Arc::new(Store::open(&temp.0).unwrap()); + owner.control.lock().unwrap().store = Some(store.clone()); + let admission = Arc::new(AtomicBool::new(true)); + let lock = owner.control.lock().unwrap(); + let (entered, waiting) = std::sync::mpsc::sync_channel(1); + let worker = { + let owner = owner.clone(); + let admission = admission.clone(); + std::thread::spawn(move || { + entered.send(()).unwrap(); + owner.set_automatic_if(false, || admission.load(Ordering::Acquire)) + }) + }; + waiting.recv().unwrap(); + admission.store(false, Ordering::Release); + drop(lock); + assert_eq!(worker.join().unwrap(), Err("updater_unauthorized")); + assert!(store.preferences().unwrap().automatic); + } + #[test] fn manual_check_does_not_grant_consent_and_busy_requests_coalesce() { let temp = Temp::new(); @@ -980,9 +1726,7 @@ mod tests { generation, VerifiedTarget { manifest, - release_id: 1, - manifest_asset_id: 2, - archive_asset_id: 3 + id: "a".repeat(64), } ), Err(PrepareError::Cancelled) diff --git a/src-tauri/src/updater_archive.rs b/src-tauri/src/updater_archive.rs index bd97d1cc..c1166923 100644 --- a/src-tauri/src/updater_archive.rs +++ b/src-tauri/src/updater_archive.rs @@ -691,9 +691,7 @@ fn inventory_hash(entries: &[ArchiveEntry]) -> String { #[derive(Debug)] enum MetadataValue { String(String), - Integer(u64), Map(BTreeMap), - Array(Vec), Other, } struct Document(MetadataValue); @@ -759,13 +757,11 @@ impl<'de> Visitor<'de> for MetadataSeed<'_> { self.charge(value.len())?; Ok(MetadataValue::String(value)) } - fn visit_u64(self, value: u64) -> Result { - Ok(MetadataValue::Integer(value)) + fn visit_u64(self, _value: u64) -> Result { + Ok(MetadataValue::Other) } - fn visit_i64(self, value: i64) -> Result { - Ok(u64::try_from(value) - .map(MetadataValue::Integer) - .unwrap_or(MetadataValue::Other)) + fn visit_i64(self, _value: i64) -> Result { + Ok(MetadataValue::Other) } fn visit_f64(self, _: f64) -> Result { Ok(MetadataValue::Other) @@ -799,14 +795,16 @@ impl<'de> Visitor<'de> for MetadataSeed<'_> { Ok(MetadataValue::Map(result)) } fn visit_seq>(self, mut sequence: A) -> Result { - let mut result = Vec::new(); - while let Some(value) = sequence.next_element_seed(MetadataSeed { - depth: self.depth + 1, - budget: self.budget, - })? { - result.push(value); - } - Ok(MetadataValue::Array(result)) + while sequence + .next_element_seed(MetadataSeed { + depth: self.depth + 1, + budget: self.budget, + })? + .is_some() + {} + // Scalar identity reads do not need array storage. The recursive visit + // still enforces every node/depth/byte/duplicate limit before discarding. + Ok(MetadataValue::Other) } } @@ -839,7 +837,11 @@ impl MetadataValue { } } -fn validate_plist(bytes: &[u8], identity: &ArchiveIdentity) -> Result<(), String> { +fn parse_plist(bytes: &[u8]) -> Result { + require( + bytes.len() <= MAX_METADATA_BYTES, + "Info.plist exceeds metadata limit", + )?; let Document(value) = if bytes.starts_with(b"bplist00") { plist::from_reader(Cursor::new(bytes)) } else { @@ -860,6 +862,32 @@ fn validate_plist(bytes: &[u8], identity: &ArchiveIdentity) -> Result<(), String result } .map_err(|_| "Invalid, duplicate or excessive Info.plist metadata")?; + Ok(value) +} + +/// The running app's pre-install check shares the archive's bounded semantic +/// parser, including binary reference expansion/depth and duplicate-key limits. +pub(crate) fn validate_installed_plist( + bytes: &[u8], + identifier: &str, + executable: &str, + desktop_version: &str, +) -> Result<(), String> { + let value = parse_plist(bytes)?; + for (key, expected) in [ + ("CFBundleIdentifier", identifier), + ("CFBundleExecutable", executable), + ("CFBundleShortVersionString", desktop_version), + ("CFBundleVersion", desktop_version), + ("CFBundlePackageType", "APPL"), + ] { + value.equals(key, expected)?; + } + Ok(()) +} + +fn validate_plist(bytes: &[u8], identity: &ArchiveIdentity) -> Result<(), String> { + let value = parse_plist(bytes)?; for (key, expected) in [ ("CFBundleName", identity.product_name.as_str()), ("CFBundleDisplayName", identity.product_name.as_str()), @@ -924,46 +952,13 @@ fn validate_runtime_manifest( entries: &BTreeMap, root: &str, ) -> Result<(), String> { - let value = json_metadata(bytes)?; - require( - matches!(value.field("schemaVersion")?, MetadataValue::Integer(1)), - "Unsupported runtime manifest schema", - )?; - for key in ["gjcSdk", "bun", "natives"] { - let version = value.field(key)?.string()?; - require( - version.len() <= 128 && semver::Version::parse(version).is_ok(), - "Invalid runtime manifest version", - )?; - } - let MetadataValue::Array(files) = value - .field("platforms")? - .field("darwin-arm64")? - .field("files")? - else { - return Err("Runtime manifest files must be an array".to_owned()); - }; - require(!files.is_empty(), "Runtime manifest closure is empty")?; + // Preserve the archive parser's node/depth/duplicate bounds, then use the + // same strict schema as native pre-server validation. Schema drift must not + // let a packaged payload boot but make its signed update archive unusable. + json_metadata(bytes)?; + let files = crate::expected_payload::runtime_manifest_files(bytes, "darwin-arm64")?; let mut seen = BTreeSet::new(); - for file in files { - let package = file.field("package")?.string()?; - relative_components(package)?; - let parts: Vec<_> = package.split('/').collect(); - require( - (parts.len() == 1 && !package.starts_with('@')) - || (parts.len() == 2 && parts[0].starts_with('@') && parts[0].len() > 1), - "Invalid runtime package name", - )?; - let relative = file.field("path")?.string()?; - relative_components(relative)?; - let digest = file.field("sha256")?.string()?; - require( - digest.len() == 64 - && digest - .bytes() - .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)), - "Invalid runtime manifest hash", - )?; + for (package, relative, digest) in files { let path = format!("{root}/{PAYLOAD}/node_modules/{package}/{relative}"); require( seen.insert(path.clone()), @@ -973,7 +968,7 @@ fn validate_runtime_manifest( .get(&path) .ok_or("Runtime manifest member is missing")?; require( - matches!(&entry.kind, ArchiveEntryKind::File { sha256, .. } if sha256 == digest), + matches!(&entry.kind, ArchiveEntryKind::File { sha256, .. } if sha256 == &digest), "Runtime manifest member hash/type mismatch", )?; } @@ -1287,10 +1282,16 @@ mod tests { .unwrap(); let native = b"export const fixture = true;\n"; let runtime = serde_json::to_vec(&serde_json::json!({ - "schemaVersion": 1, "gjcSdk": "0.16.4", "bun": "1.4.0", "natives": "0.16.4", + "schemaVersion": 2, "gjcSdk": "0.16.4", "bun": "1.4.0", "natives": "0.16.4", "platforms": { "darwin-arm64": { "files": [{ "package": "@gajae-code/natives", "path": "native/index.js", "sha256": hash_bytes(native) - }] } } + }] } }, + "sdkLifecycle": {"id":"gjc-sdk-lifecycle-v1", + "packages":{"@gajae-code/coding-agent":"0.16.4","@gajae-code/agent-core":"0.16.4"}, + "files":[ + {"package":"@gajae-code/coding-agent","path":"src/sdk/session.ts","sha256":hash_bytes(native)}, + {"package":"@gajae-code/agent-core","path":"src/agent-loop.ts","sha256":hash_bytes(native)} + ]} })).unwrap(); let mut files = vec![ Fixture::file(PLIST, &plist_bytes(&plist_value(), false)), @@ -1301,6 +1302,16 @@ mod tests { Fixture::file(PACKAGE, &package), Fixture::file(RUNTIME, &runtime), Fixture::file(NATIVE, native), + Fixture::file( + &format!( + "{ROOT}/{PAYLOAD}/node_modules/@gajae-code/coding-agent/src/sdk/session.ts" + ), + native, + ), + Fixture::file( + &format!("{ROOT}/{PAYLOAD}/node_modules/@gajae-code/agent-core/src/agent-loop.ts"), + native, + ), ]; let mut parents = BTreeSet::new(); for file in &files { @@ -1388,6 +1399,40 @@ mod tests { } } + #[test] + fn installed_identity_uses_the_same_bounded_duplicate_rejecting_plist_parser() { + let identity = identity(); + let value = plist_value(); + for binary in [false, true] { + assert!(validate_installed_plist( + &plist_bytes(&value, binary), + &identity.bundle_identifier, + &identity.executable, + &identity.desktop_version + ) + .is_ok()); + } + let xml = String::from_utf8(plist_bytes(&value, false)).unwrap(); + let duplicate = xml.replace( + "", + "CFBundleIdentifierspoof", + ); + assert!(validate_installed_plist( + duplicate.as_bytes(), + &identity.bundle_identifier, + &identity.executable, + &identity.desktop_version + ) + .is_err()); + assert!(validate_installed_plist( + &vec![b'x'; MAX_METADATA_BYTES + 1], + &identity.bundle_identifier, + &identity.executable, + &identity.desktop_version + ) + .is_err()); + } + #[test] fn inventory_hash_commits_to_every_file_mode_link_and_path_not_order() { let mut fixtures = fixture(); @@ -2003,7 +2048,7 @@ mod tests { .unwrap() .push(duplicate); } - 6 => value["schemaVersion"] = 2.into(), + 6 => value["schemaVersion"] = 1.into(), 7 => value["platforms"]["darwin-arm64"]["files"] = serde_json::json!([]), _ => value["platforms"] = serde_json::json!({ "linux-x64": {} }), } @@ -2015,6 +2060,24 @@ mod tests { rejected(&fixtures, "hash/type mismatch"); } + #[test] + fn runtime_v2_sdk_members_are_verified_before_any_extraction() { + let path = + format!("{ROOT}/{PAYLOAD}/node_modules/@gajae-code/coding-agent/src/sdk/session.ts"); + let mut fixtures = fixture(); + get(&mut fixtures, &path).data.push(0); + rejected(&fixtures, "hash/type mismatch"); + let mut fixtures = fixture(); + fixtures.retain(|file| file.path != path); + rejected(&fixtures, "member is missing"); + let mut fixtures = fixture(); + let runtime = get(&mut fixtures, RUNTIME); + let mut metadata: serde_json::Value = serde_json::from_slice(&runtime.data).unwrap(); + metadata.as_object_mut().unwrap().remove("sdkLifecycle"); + runtime.data = serde_json::to_vec(&metadata).unwrap(); + assert!(inspect(&fixtures).is_err()); + } + #[test] fn rejects_macho_wrong_arch_type_load_command_floor_and_segment_bounds() { for (offset, value) in [ @@ -2118,6 +2181,13 @@ mod tests { .read_to_end(&mut bytes) .unwrap(); let inventory = inspect_archive(&bytes, &identity).unwrap(); + if let Some(app) = std::env::var_os("GJC_ARCHIVE_FIXTURE_BUNDLE") { + let proof = + crate::updater_bundle::verify_inventory(std::path::Path::new(&app), &inventory) + .expect("existing installed fixture must match the complete archive inventory"); + assert_eq!(proof.inventory_sha256(), inventory.inventory_sha256); + eprintln!("Existing bundle matched {} signed-archive inventory entries (inventory equivalence only).", inventory.entries.len()); + } if let Some(path) = std::env::var_os("GJC_ARCHIVE_FIXTURE_INVENTORY") { let file = std::fs::File::open(path).unwrap(); let mut bytes = Vec::new(); diff --git a/src-tauri/src/updater_attempt.rs b/src-tauri/src/updater_attempt.rs index 03546081..2899413b 100644 --- a/src-tauri/src/updater_attempt.rs +++ b/src-tauri/src/updater_attempt.rs @@ -1,18 +1,32 @@ -//! Presence-only admission guard for an interrupted desktop update attempt. +//! Durable desktop install attempts and cross-process completion admission. //! -//! A future writer must durably publish the record before beginning install, -//! and must not clear it without proving the complete operation integrity and -//! completion. This reader intentionally never reads, creates, mutates, or -//! removes the record. +//! The journal publishes and syncs the canonical blocker before granting a +//! process-bound live permit. Loaded records are inspection data, never permits. +//! Nothing here installs or starts a server. Only sealed native successor and +//! health proofs can retire a blocker; parsed state can never do so. `check` +//! still refuses EVERY present canonical entry. With that entry absent, a +//! pending/invalid completion receipt also blocks; only schema-2 Committed can +//! dispose of a completed transaction. Legacy "success" is never sufficient. use std::{ fs, path::{Component, Path, PathBuf}, }; -const ATTEMPT_RECORD: &str = "desktop-update-attempt.json"; +pub(crate) const ATTEMPT_RECORD: &str = "desktop-update-attempt.json"; +const COMPLETED_RECORD: &str = "desktop-update-completed.json"; +const COMPLETION_STAGE: &str = "desktop-update-completed.next.json"; -/// Admit a startup only when the update-attempt record is validated absent. -/// Any present directory entry, regardless of its contents or type, blocks. +// The standalone QA probe also includes this file, without the product bundle +// verifier. Keep the journal independent of that crate's module topology. +#[cfg(target_os = "macos")] +#[allow(unused_imports)] +pub(crate) use durable::{ + proof_seal, Journal, LiveAttempt, LoadedAttempt, Phase, SuccessorAttempt, Target, + VerifiedBundleProof, VerifiedHealthProof, VerifiedSuccessorProof, +}; + +/// Every present canonical entry blocks, regardless of contents/type. Absence +/// alone is insufficient when persistent completion state exists. pub(crate) fn check(desktop_data_root: &Path) -> Result<(), String> { let root = normalize_absolute(desktop_data_root)?; if !validate_real_directory_ancestors(&root)? { @@ -24,7 +38,23 @@ pub(crate) fn check(desktop_data_root: &Path) -> Result<(), String> { "Desktop update attempt state is present at {}; startup is blocked.", record.display() )), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + // Preserve the old no-record/no-I/O behavior for fresh data roots, + // including roots that have not yet been made journal-private. + for name in [COMPLETION_STAGE, COMPLETED_RECORD] { + match fs::symlink_metadata(root.join(name)) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(_) => return Err("Could not validate desktop completion state.".into()), + Ok(_) => { + #[cfg(target_os = "macos")] + return durable::check_completion(&root); + #[cfg(not(target_os = "macos"))] + return Err("Desktop completion state requires native verification.".into()); + } + } + } + Ok(()) + } Err(error) => Err(format!( "Could not validate desktop update attempt state at {}: {error}", record.display() @@ -90,6 +120,3383 @@ fn validate_real_directory_ancestors(root: &Path) -> Result { Ok(true) } +#[cfg(target_os = "macos")] +mod durable { + use super::{ATTEMPT_RECORD, COMPLETED_RECORD, COMPLETION_STAGE}; + use std::{ + cell::Cell, + ffi::{CString, OsStr}, + fs::{File, Metadata}, + io::{self, Seek, SeekFrom, Write}, + os::{ + fd::{AsRawFd, FromRawFd}, + unix::{ffi::OsStrExt, fs::FileExt, fs::MetadataExt}, + }, + path::{Component, Path, PathBuf}, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }, + }; + + use serde::{Deserialize, Serialize}; + + const MAX_RECORD_BYTES: usize = 16 * 1024; + const MAX_PATH_BYTES: usize = 4096; + const MAX_VERSION_BYTES: usize = 128; + type Result = std::result::Result; + + /// Closed, bounded install identity. Public fields allow construction from + /// the parent's freshly signature-checked archive; begin validates EVERY + /// field. Deserialization/versions alone never establish install authority. + /// Strict upgrade ordering/channel/OS eligibility is revalidated by the + /// parent's revalidate_prepared/eligible immediately before reconstruction. + #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] + #[serde(deny_unknown_fields)] + pub(crate) struct Target { + pub(crate) app_path: PathBuf, + pub(crate) source_desktop_version: String, + pub(crate) target_desktop_version: String, + pub(crate) target_product_version: String, + pub(crate) archive_sha256: String, + pub(crate) inventory_sha256: String, + pub(crate) runtime_manifest_sha256: String, + } + + impl Target { + fn validate(&self) -> Result<()> { + canonical_path(&self.app_path)?; + let name = self.app_path.file_name().and_then(OsStr::to_str); + require( + name.is_some_and(|name| name.len() > 4 && name.ends_with(".app")), + "target must be a canonical .app directory", + )?; + for version in [ + &self.source_desktop_version, + &self.target_desktop_version, + &self.target_product_version, + ] { + require( + !version.is_empty() && version.len() <= MAX_VERSION_BYTES, + "version length limit", + )?; + let parsed = semver::Version::parse(version) + .map_err(|_| error("invalid semantic version"))?; + require(parsed.to_string() == *version, "noncanonical version")?; + } + for digest in [ + &self.archive_sha256, + &self.inventory_sha256, + &self.runtime_manifest_sha256, + ] { + require(lower_hex(digest, 64), "invalid SHA-256")?; + } + Ok(()) + } + } + + /// Native-only sealing boundary. Implement only for opaque inventory, + /// successor-verification, and supervisor-owned health proofs. Journal tests + /// use local projections. No blanket or serialized-state impls exist. + pub(crate) mod proof_seal { + pub(crate) trait Sealed {} + } + + /// Implement ONLY for the inventory verifier's opaque VerifiedBundle. + /// The adapter belongs alongside that type/the parent installer, not the + /// standalone probe. Never implement for Target, JSON, strings, or booleans. + /// This evidence says a full inventory matched at verification time; it is + /// neither installer-return evidence nor runtime-health evidence. + pub(crate) trait VerifiedBundleProof: proof_seal::Sealed { + fn root(&self) -> &Path; + fn inventory_sha256(&self) -> &str; + } + + /// Parent-owned live proof of cached Minisign verification, full installed + /// target inventory, compiled payload identity and old-owner exit. The + /// parent must hold its instance/startup gate throughout this handoff. + pub(crate) trait VerifiedSuccessorProof: proof_seal::Sealed { + fn target(&self) -> &Target; + } + + /// Implement ONLY on a supervisor-owned HealthyServer retaining ownership + /// of this child: owned ready + real health, full installed-B re-verification, + /// a second independent health check, then stable-port persistence. Mint it + /// BEFORE SPA navigation/exposure so new work cannot mutate the app before + /// journal completion. GUI acceptance is separate G5 evidence, not this proof. + /// A PID alone proves neither child ownership nor health. Never reconstruct + /// this proof from a receipt, a version, or a previously observed PID. + pub(crate) trait VerifiedHealthProof: proof_seal::Sealed { + fn target(&self) -> &Target; + fn server_pid(&self) -> u32; + } + + #[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] + #[serde(rename_all = "snake_case")] + pub(crate) enum Phase { + Installing, + AwaitingHealth, + } + + #[derive(Clone, Debug, Deserialize, Serialize)] + #[serde(deny_unknown_fields)] + struct Record { + schema: u8, + attempt_id: String, + owner_pid: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + recovery_owner_pid: Option, + data_root: PathBuf, + root_device: u64, + root_inode: u64, + record_device: u64, + record_inode: u64, + phase: Phase, + target: Target, + } + + /// Opens an EXISTING, canonical, owner-private (0700) data root. Does not + /// create/chmod ancestors or claim a QA marker. A directory-descriptor flock + /// excludes cooperating journal owners for this handle's entire lifetime. + /// All writes use its anchored directory and exclusively-created 0600 file. + /// + /// Descriptor/snapshot rechecks refuse observed substitutions; they are not + /// an atomic filesystem snapshot against a hostile concurrent same-UID + /// writer. The caller must also own the admitted namespace/process lifetime. + pub(crate) struct Journal { + root: Arc, + } + + struct Root { + anchor: Anchor, + pid: u32, + successor_claimed: AtomicBool, + } + + /// Not Clone/Deserialize. Copies inherited across fork cannot validate or + /// mutate. There is intentionally no cleanup Drop: failure, unwind, process + /// death, or an abandoned handle leaves the canonical entry blocking. + #[must_use = "An abandoned live attempt retains its startup blocker"] + pub(crate) struct LiveAttempt { + root: Arc, + source_app: Anchor, + file: File, + bytes: Vec, + snapshot: Metadata, + record: Record, + poisoned: bool, + } + + /// Read-only snapshot, explicitly NOT a resumable LiveAttempt. Even a full + /// target match cannot install, admit startup, or remove/archive the record. + #[derive(Debug)] + pub(crate) struct LoadedAttempt { + record: Record, + bytes: Vec, + snapshot: Metadata, + from_completion: bool, + completion: Option, + } + + /// Non-Clone, non-deserializable, PID-bound startup/finish capability. Drop + /// before successful finish retains the blocker. It grants no install API. + #[must_use = "An unfinished successor retains its startup blocker"] + pub(crate) struct SuccessorAttempt { + root: Arc, + installed_app: Anchor, + file: File, + record: Record, + bytes: Vec, + snapshot: Metadata, + poisoned: Cell, + } + + #[derive(Clone, Debug, Deserialize, Serialize)] + #[serde(deny_unknown_fields)] + struct CompletedRecord { + schema: u8, + state: CompletionState, + completed_by_pid: u32, + server_pid: u32, + archive_device: u64, + archive_inode: u64, + attempt: Record, + } + + #[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)] + #[serde(rename_all = "snake_case")] + enum CompletionState { + PreparedSuccess, + Committed, + // v1 published this BEFORE retirement. Always treat it as pending. + VerifiedSuccess, + } + + #[derive(Debug)] + struct CompletionSnapshot { + receipt: CompletedRecord, + bytes: Vec, + snapshot: Metadata, + } + + /// Reader only: no flock acquisition, file creation, or repair. Normal + /// startup still owns the parent's instance/lifecycle admission gate. + pub(super) fn check_completion(data_root: &Path) -> Result<()> { + let root = Root { + anchor: Anchor::open(data_root)?, + pid: std::process::id(), + successor_claimed: AtomicBool::new(false), + }; + root.validate()?; + require_absent(&root, ATTEMPT_RECORD)?; + completion_allows_absence(&root)?; + require_absent(&root, ATTEMPT_RECORD) + } + + fn completion_is_committed(receipt: &CompletedRecord) -> bool { + receipt.schema == 2 && receipt.state == CompletionState::Committed + } + + fn completion_allows_absence(root: &Root) -> Result<()> { + require_absent(root, COMPLETION_STAGE)?; + if let Some(completion) = read_completion(root, COMPLETED_RECORD)? { + require( + completion_is_committed(&completion.receipt), + "pending or legacy completion requires verified successor recovery", + )?; + verify_completion_snapshot(root, COMPLETED_RECORD, &completion)?; + } + require_absent(root, COMPLETION_STAGE) + } + + fn entry_absent(root: &Root, name: &str) -> Result { + root.validate()?; + let name = CString::new(name).map_err(|_| error("invalid journal entry name"))?; + let mut stat = std::mem::MaybeUninit::::uninit(); + let result = unsafe { + libc::fstatat( + root.anchor.directory().as_raw_fd(), + name.as_ptr(), + stat.as_mut_ptr(), + libc::AT_SYMLINK_NOFOLLOW, + ) + }; + let absent = match (result, io::Error::last_os_error().raw_os_error()) { + (0, _) => false, + (-1, Some(libc::ENOENT)) => true, + _ => return Err(error("journal entry absence is uncertain")), + }; + root.validate()?; + Ok(absent) + } + + fn require_absent(root: &Root, name: &str) -> Result<()> { + require( + entry_absent(root, name)?, + "journal entry is present; startup remains blocked", + ) + } + + fn validate_completion_record( + root: &Root, + receipt: &CompletedRecord, + snapshot: &Metadata, + ) -> Result<()> { + root.validate()?; + private(snapshot, false)?; + receipt.attempt.target.validate()?; + let directory = metadata(root.anchor.directory())?; + require( + matches!( + (receipt.schema, receipt.state), + ( + 2, + CompletionState::PreparedSuccess | CompletionState::Committed + ) | (1, CompletionState::VerifiedSuccess) + ) && receipt.attempt.schema == 1 + && receipt.attempt.phase == Phase::AwaitingHealth + && lower_hex(&receipt.attempt.attempt_id, 32) + && valid_pid(receipt.attempt.owner_pid) + && receipt.attempt.recovery_owner_pid.is_none_or(valid_pid) + && valid_pid(receipt.completed_by_pid) + && valid_pid(receipt.server_pid) + && receipt.completed_by_pid != receipt.attempt.owner_pid + && receipt.server_pid != receipt.completed_by_pid + && receipt.server_pid != receipt.attempt.owner_pid + && Some(receipt.server_pid) != receipt.attempt.recovery_owner_pid + && receipt.archive_device == snapshot.dev() + && receipt.archive_inode == snapshot.ino() + && receipt.attempt.data_root.as_os_str() == root.anchor.path.as_os_str() + && receipt.attempt.root_device == directory.dev() + && receipt.attempt.root_inode == directory.ino() + && receipt.attempt.record_device == directory.dev() + && receipt.attempt.record_inode != 0, + "invalid completion schema, phase, ownership, or fingerprint", + )?; + let source = semver::Version::parse(&receipt.attempt.target.source_desktop_version) + .map_err(|_| error("invalid completion source version"))?; + let target = semver::Version::parse(&receipt.attempt.target.target_desktop_version) + .map_err(|_| error("invalid completion target version"))?; + require( + target.cmp_precedence(&source).is_gt(), + "completion does not describe a strict upgrade", + ) + } + + fn read_completion(root: &Root, name: &str) -> Result> { + root.validate()?; + let file = match open_at( + root.anchor.directory(), + OsStr::new(name), + libc::O_RDONLY | libc::O_NONBLOCK, + 0, + ) { + Ok(file) => file, + Err(cause) if cause.kind() == io::ErrorKind::NotFound => { + require_absent(root, name)?; + return Ok(None); + } + Err(_) => return Err(error("completion receipt is inaccessible or aliased")), + }; + let snapshot = metadata(&file)?; + private(&snapshot, false)?; + let bytes = read_bounded(&file)?; + named_owned_at(root, name, &file, &bytes, &snapshot)?; + let receipt: CompletedRecord = serde_json::from_slice(&bytes) + .map_err(|_| error("malformed completion receipt; startup remains blocked"))?; + validate_completion_record(root, &receipt, &snapshot)?; + named_owned_at(root, name, &file, &bytes, &snapshot)?; + Ok(Some(CompletionSnapshot { + receipt, + bytes, + snapshot, + })) + } + + fn verify_completion_snapshot( + root: &Root, + name: &str, + completion: &CompletionSnapshot, + ) -> Result<()> { + root.validate()?; + let file = open_at( + root.anchor.directory(), + OsStr::new(name), + libc::O_RDONLY | libc::O_NONBLOCK, + 0, + ) + .map_err(|_| error("inspected completion receipt disappeared or changed"))?; + validate_completion_record(root, &completion.receipt, &completion.snapshot)?; + named_owned_at(root, name, &file, &completion.bytes, &completion.snapshot) + } + + fn loaded_owners_gone(loaded: &LoadedAttempt) -> Result<()> { + old_owner_gone(loaded.record.owner_pid)?; + if let Some(pid) = loaded.record.recovery_owner_pid { + old_owner_gone(pid)?; + } + if let Some(completion) = &loaded.completion { + old_owner_gone(completion.receipt.completed_by_pid)?; + require( + process_status(completion.receipt.server_pid)? == ProcessStatus::Gone, + "previous completion server is still alive", + )?; + } + Ok(()) + } + + impl Journal { + pub(crate) fn open(data_root: &Path) -> Result { + let anchor = Anchor::open(data_root)?; + private(&metadata(anchor.directory())?, true)?; + // Never explicitly unlock on Drop: forked copies share the open + // description and must not unlock the parent's lifetime ownership. + if unsafe { + libc::flock( + anchor.directory().as_raw_fd(), + libc::LOCK_EX | libc::LOCK_NB, + ) + } != 0 + { + return Err(error("another journal owner holds this data root")); + } + let root = Arc::new(Root { + anchor, + pid: std::process::id(), + successor_claimed: AtomicBool::new(false), + }); + root.validate()?; + Ok(Self { root }) + } + + pub(crate) fn begin(&self, target: Target) -> Result { + self.begin_inner(target, &mut |_| Ok(())) + } + + fn begin_inner( + &self, + target: Target, + observe: &mut dyn FnMut(SyncPoint) -> Result<()>, + ) -> Result { + self.root.validate()?; + require( + !self.root.successor_claimed.load(Ordering::Acquire), + "successor owns startup admission", + )?; + completion_allows_absence(&self.root)?; + target.validate()?; + // Read-only: no app files are created or modified by this module. + let source_app = Anchor::open(&target.app_path)?; + let root_metadata = metadata(self.root.anchor.directory())?; + let attempt_id = random_id()?; + self.root.validate()?; + let mut file = open_at( + self.root.anchor.directory(), + OsStr::new(ATTEMPT_RECORD), + libc::O_RDWR | libc::O_CREAT | libc::O_EXCL | libc::O_NONBLOCK, + 0o600, + ) + .map_err(|_| error("cannot exclusively create attempt; present state blocks"))?; + // EVERY exit after create retains the entry, including zero bytes. + observe(SyncPoint::Created)?; + let file_metadata = metadata(&file)?; + private(&file_metadata, false)?; + let record = Record { + schema: 1, + attempt_id, + owner_pid: self.root.pid, + recovery_owner_pid: None, + data_root: self.root.anchor.path.clone(), + root_device: root_metadata.dev(), + root_inode: root_metadata.ino(), + record_device: file_metadata.dev(), + record_inode: file_metadata.ino(), + phase: Phase::Installing, + target, + }; + let bytes = encode(&record)?; + // Recheck the exclusive name before writing, including test-injected + // substitutions at the creation boundary. + self.root.validate()?; + named_owned(&self.root, &file, &[], &file_metadata)?; + persist(&self.root, &mut file, &bytes, observe)?; + let snapshot = metadata(&file)?; + let attempt = LiveAttempt { + root: self.root.clone(), + source_app, + file, + bytes, + snapshot, + record, + poisoned: false, + }; + attempt.validate_install_permit()?; + Ok(attempt) + } + + pub(crate) fn load(&self) -> Result> { + self.root.validate()?; + require( + !self.root.successor_claimed.load(Ordering::Acquire), + "successor owns startup admission", + )?; + let completion = read_completion(&self.root, COMPLETED_RECORD)?; + let file = match open_at( + self.root.anchor.directory(), + OsStr::new(ATTEMPT_RECORD), + libc::O_RDONLY | libc::O_NONBLOCK, + 0, + ) { + Ok(file) => file, + Err(error) if error.kind() == io::ErrorKind::NotFound => { + self.root.validate()?; + require_absent(&self.root, ATTEMPT_RECORD)?; + return match completion { + Some(completion) + if !completion_is_committed(&completion.receipt) + || !entry_absent(&self.root, COMPLETION_STAGE)? => + { + Ok(Some(LoadedAttempt { + record: completion.receipt.attempt.clone(), + bytes: Vec::new(), + snapshot: completion.snapshot.clone(), + from_completion: true, + completion: Some(completion), + })) + } + _ => { + require_absent(&self.root, COMPLETION_STAGE)?; + Ok(None) + } + }; + } + Err(_) => return Err(error("cannot safely open present attempt")), + }; + let snapshot = metadata(&file)?; + private(&snapshot, false)?; + let bytes = read_bounded(&file)?; + named_owned(&self.root, &file, &bytes, &snapshot)?; + let record: Record = serde_json::from_slice(&bytes) + .map_err(|_| error("malformed attempt; startup remains blocked"))?; + record.target.validate()?; + let root_metadata = metadata(self.root.anchor.directory())?; + require( + record.schema == 1 + && lower_hex(&record.attempt_id, 32) + && record.owner_pid > 0 + && record.owner_pid <= i32::MAX as u32 + && record.recovery_owner_pid.is_none_or(valid_pid) + && record.data_root.as_os_str() == self.root.anchor.path.as_os_str() + && record.root_device == root_metadata.dev() + && record.root_inode == root_metadata.ino() + && record.record_device == snapshot.dev() + && record.record_inode == snapshot.ino(), + "attempt identity/schema mismatch", + )?; + // Do not require a surviving .app: interrupted installs may leave it + // missing. This is inspection only, not target or health verification. + named_owned(&self.root, &file, &bytes, &snapshot)?; + let completion = match completion { + Some(completion) + if completion.receipt.attempt.attempt_id == record.attempt_id + && completion.receipt.attempt.target == record.target => + { + Some(completion) + } + Some(completion) if !completion_is_committed(&completion.receipt) => { + return Err(error("conflicting pending completion state")); + } + _ => None, + }; + Ok(Some(LoadedAttempt { + record, + bytes, + snapshot, + from_completion: false, + completion, + })) + } + + /// Canonical handoff is read-only. A pending receipt with no canonical + /// entry can recreate ONLY AwaitingHealth, after this opaque proof and + /// all recorded writer/server exits are verified. Never mints an install + /// permit. ESRCH is the ONLY accepted exit status; uncertainty blocks. + pub(crate) fn resume_verified( + &self, + loaded: &LoadedAttempt, + proof: &impl VerifiedSuccessorProof, + ) -> Result { + self.root.validate()?; + require( + loaded.phase() == Phase::AwaitingHealth, + "successor requires awaiting health", + )?; + match_target(&loaded.record.target, proof.target())?; + loaded_owners_gone(loaded)?; + if loaded.from_completion { + return self.recover_completion(loaded, proof); + } + let file = open_at( + self.root.anchor.directory(), + OsStr::new(ATTEMPT_RECORD), + libc::O_RDONLY | libc::O_NONBLOCK, + 0, + ) + .map_err(|_| error("cannot open exact successor attempt"))?; + named_owned(&self.root, &file, &loaded.bytes, &loaded.snapshot)?; + let root_metadata = metadata(self.root.anchor.directory())?; + require( + loaded.record.data_root.as_os_str() == self.root.anchor.path.as_os_str() + && loaded.record.root_device == root_metadata.dev() + && loaded.record.root_inode == root_metadata.ino(), + "successor root differs from inspected attempt", + )?; + let installed_app = Anchor::open(&loaded.record.target.app_path)?; + loaded_owners_gone(loaded)?; + if let Some(completion) = &loaded.completion { + verify_completion_snapshot(&self.root, COMPLETED_RECORD, completion)?; + } + named_owned(&self.root, &file, &loaded.bytes, &loaded.snapshot)?; + // Even two callers using the SAME Journal cannot mint duplicate + // startup permits. The flock also excludes other Journal objects. + require( + self.root + .successor_claimed + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_ok(), + "successor already claimed", + )?; + Ok(SuccessorAttempt { + root: self.root.clone(), + installed_app, + file, + record: loaded.record.clone(), + bytes: loaded.bytes.clone(), + snapshot: loaded.snapshot.clone(), + poisoned: Cell::new(false), + }) + } + + fn recover_completion( + &self, + loaded: &LoadedAttempt, + proof: &impl VerifiedSuccessorProof, + ) -> Result { + let completion = loaded + .completion + .as_ref() + .ok_or_else(|| error("missing pending receipt"))?; + self.root.validate()?; + match_target(&loaded.record.target, proof.target())?; + loaded_owners_gone(loaded)?; + verify_completion_snapshot(&self.root, COMPLETED_RECORD, completion)?; + require_absent(&self.root, ATTEMPT_RECORD)?; + let installed_app = Anchor::open(&loaded.record.target.app_path)?; + require( + self.root + .successor_claimed + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_ok(), + "successor already claimed", + )?; + let recovered = (|| { + self.root.validate()?; + verify_completion_snapshot(&self.root, COMPLETED_RECORD, completion)?; + loaded_owners_gone(loaded)?; + let mut file = open_at( + self.root.anchor.directory(), + OsStr::new(ATTEMPT_RECORD), + libc::O_RDWR | libc::O_CREAT | libc::O_EXCL | libc::O_NONBLOCK, + 0o600, + ) + .map_err(|_| error("cannot exclusively restore awaiting-health journal"))?; + let snapshot = metadata(&file)?; + private(&snapshot, false)?; + let mut record = loaded.record.clone(); + record.record_device = snapshot.dev(); + record.record_inode = snapshot.ino(); + record.recovery_owner_pid = Some(self.root.pid); + record.phase = Phase::AwaitingHealth; + let bytes = encode(&record)?; + named_owned(&self.root, &file, &[], &snapshot)?; + persist(&self.root, &mut file, &bytes, &mut |_| Ok(()))?; + let snapshot = metadata(&file)?; + named_owned(&self.root, &file, &bytes, &snapshot)?; + installed_app.validate()?; + Ok(SuccessorAttempt { + root: self.root.clone(), + installed_app, + file, + record, + bytes, + snapshot, + poisoned: Cell::new(false), + }) + })(); + if recovered.is_err() { + self.root.successor_claimed.store(false, Ordering::Release); + } + recovered + } + } + + impl SuccessorAttempt { + #[cfg(test)] + pub(crate) fn target(&self) -> &Target { + &self.record.target + } + + /// Explicit exception under the parent's startup gate, never a change + /// to check(). Any failed validation poisons THIS live capability. + pub(crate) fn validate_startup(&self, proof: &impl VerifiedSuccessorProof) -> Result<()> { + let result = self + .validate() + .and_then(|()| match_target(&self.record.target, proof.target())); + if result.is_err() { + self.poisoned.set(true); + } + result + } + + fn validate(&self) -> Result<()> { + require(!self.poisoned.get(), "successor is poisoned")?; + self.validate_bound() + } + + fn validate_bound(&self) -> Result<()> { + self.root.validate()?; + require( + self.record.phase == Phase::AwaitingHealth, + "successor phase changed", + )?; + old_owner_gone(self.record.owner_pid)?; + if let Some(pid) = self.record.recovery_owner_pid { + if pid != self.root.pid { + old_owner_gone(pid)?; + } + } + self.installed_app.validate()?; + named_owned(&self.root, &self.file, &self.bytes, &self.snapshot) + } + + fn validate_health(&self, proof: &impl VerifiedHealthProof) -> Result { + self.root.validate()?; + match_target(&self.record.target, proof.target())?; + let pid = proof.server_pid(); + require( + valid_pid(pid) && pid != self.root.pid && pid != self.record.owner_pid, + "invalid healthy server PID", + )?; + require( + process_status(pid)? == ProcessStatus::Alive, + "healthy server is no longer alive", + )?; + // The sealed supervisor proof certifies the pre-exposure ready, + // re-verification, second-health, and stable-port checks above. + // Also require a current direct child with our UID, and pin its + // birth time across barriers rather than trusting a reusable PID. + owned_server_identity(pid, self.root.pid) + } + + /// Persist PreparedSuccess BEFORE retirement; publish schema-2 Committed + /// only AFTER retirement fsync and another live health/identity check. + /// The fixed receipt and bounded staging slot are cross-process barriers: + /// Drop/in-memory recovery is never relied on for next-launch admission. + /// Any error requires recovery in this process. A publication/cleanup + /// fsync error can have an uncertain commit outcome; next launch resolves + /// the strict receipt state, NEVER canonical absence or the old Result. + pub(crate) fn finish(mut self, proof: &impl VerifiedHealthProof) -> Result<()> { + self.finish_inner(proof, &mut |_| Ok(())) + } + + fn finish_inner( + &mut self, + proof: &impl VerifiedHealthProof, + observe: &mut dyn FnMut(FinishPoint) -> Result<()>, + ) -> Result<()> { + let initial = self.validate(); + self.poisoned.set(true); + let result = initial.and_then(|()| self.finish_owned(proof, observe)); + match result { + Ok(()) => Ok(()), + Err(cause) => Err(format!("Desktop update completion requires recovery: {cause} Resolve persistent completion state; canonical absence is not admission.")), + } + } + + fn finish_owned( + &mut self, + proof: &impl VerifiedHealthProof, + observe: &mut dyn FnMut(FinishPoint) -> Result<()>, + ) -> Result<()> { + let server = self.validate_health(proof)?; + self.validate_bound()?; + let (mut archive, old_bytes, old_snapshot) = self.open_completion()?; + observe(FinishPoint::ArchiveOpened)?; + let receipt = CompletedRecord { + schema: 2, + state: CompletionState::PreparedSuccess, + completed_by_pid: self.root.pid, + server_pid: server.pid, + archive_device: old_snapshot.dev(), + archive_inode: old_snapshot.ino(), + attempt: self.record.clone(), + }; + let bytes = + serde_json::to_vec(&receipt).map_err(|_| error("cannot encode completion"))?; + require( + bytes.len() <= MAX_RECORD_BYTES, + "completed receipt byte limit", + )?; + self.validate_bound()?; + named_owned_at( + &self.root, + COMPLETED_RECORD, + &archive, + &old_bytes, + &old_snapshot, + )?; + archive + .set_len(0) + .map_err(|_| error("cannot truncate owned completed receipt"))?; + archive + .seek(SeekFrom::Start(0)) + .map_err(|_| error("cannot rewind completed receipt"))?; + observe(FinishPoint::ArchiveTruncated)?; + self.validate_bound()?; + named_owned_at( + &self.root, + COMPLETED_RECORD, + &archive, + &[], + &metadata(&archive)?, + )?; + archive + .write_all(&bytes) + .map_err(|_| error("cannot write completed receipt"))?; + let snapshot = metadata(&archive)?; + observe(FinishPoint::BeforeArchiveSync)?; + named_owned_at(&self.root, COMPLETED_RECORD, &archive, &bytes, &snapshot)?; + archive + .sync_all() + .map_err(|_| error("cannot synchronize completed receipt"))?; + observe(FinishPoint::ArchiveFileSynced)?; + self.root + .anchor + .directory() + .sync_all() + .map_err(|_| error("cannot synchronize completed receipt directory"))?; + observe(FinishPoint::ArchiveDirectorySynced)?; + // This durable PENDING receipt remains a startup veto even if unlink + // succeeds but retirement fsync fails. It is not success evidence. + self.validate_bound()?; + require( + self.validate_health(proof)? == server, + "healthy server identity changed", + )?; + named_owned_at(&self.root, COMPLETED_RECORD, &archive, &bytes, &snapshot)?; + observe(FinishPoint::BeforeRetirement)?; + self.validate_bound()?; + require( + self.validate_health(proof)? == server, + "healthy server identity changed", + )?; + named_owned_at(&self.root, COMPLETED_RECORD, &archive, &bytes, &snapshot)?; + let name = CString::new(ATTEMPT_RECORD).expect("literal"); + if unsafe { libc::unlinkat(self.root.anchor.directory().as_raw_fd(), name.as_ptr(), 0) } + != 0 + { + return Err(error("cannot retire owned canonical attempt")); + } + observe(FinishPoint::Retired)?; + self.validate_retired(proof, server, &archive, &bytes, &snapshot)?; + observe(FinishPoint::BeforeRetirementSync)?; + self.root + .anchor + .directory() + .sync_all() + .map_err(|_| error("cannot synchronize attempt retirement"))?; + observe(FinishPoint::RetirementSynced)?; + self.validate_retired(proof, server, &archive, &bytes, &snapshot)?; + self.publish_commit(proof, server, &archive, &bytes, &snapshot, observe) + } + + fn publish_commit( + &self, + proof: &impl VerifiedHealthProof, + server: HealthyIdentity, + prepared: &File, + prepared_bytes: &[u8], + prepared_snapshot: &Metadata, + observe: &mut dyn FnMut(FinishPoint) -> Result<()>, + ) -> Result<()> { + self.validate_retired(proof, server, prepared, prepared_bytes, prepared_snapshot)?; + let (mut stage, previous, stage_snapshot) = self.open_receipt_slot(COMPLETION_STAGE)?; + observe(FinishPoint::CommitOpened)?; + let committed = CompletedRecord { + schema: 2, + state: CompletionState::Committed, + completed_by_pid: self.root.pid, + server_pid: server.pid, + archive_device: stage_snapshot.dev(), + archive_inode: stage_snapshot.ino(), + attempt: self.record.clone(), + }; + let bytes = serde_json::to_vec(&committed) + .map_err(|_| error("cannot encode committed receipt"))?; + require( + bytes.len() <= MAX_RECORD_BYTES, + "committed receipt byte limit", + )?; + named_owned_at( + &self.root, + COMPLETION_STAGE, + &stage, + &previous, + &stage_snapshot, + )?; + stage + .set_len(0) + .map_err(|_| error("cannot truncate owned commit stage"))?; + stage + .seek(SeekFrom::Start(0)) + .map_err(|_| error("cannot rewind commit stage"))?; + stage + .write_all(&bytes) + .map_err(|_| error("cannot write commit stage"))?; + let written = metadata(&stage)?; + observe(FinishPoint::BeforeCommitFileSync)?; + named_owned_at(&self.root, COMPLETION_STAGE, &stage, &bytes, &written)?; + stage + .sync_all() + .map_err(|_| error("cannot synchronize commit stage"))?; + self.root + .anchor + .directory() + .sync_all() + .map_err(|_| error("cannot synchronize commit staging entry"))?; + observe(FinishPoint::CommitFileSynced)?; + observe(FinishPoint::BeforeCommitPublication)?; + self.validate_retired(proof, server, prepared, prepared_bytes, prepared_snapshot)?; + named_owned_at(&self.root, COMPLETION_STAGE, &stage, &bytes, &written)?; + let from = CString::new(COMPLETION_STAGE).expect("literal"); + let to = CString::new(COMPLETED_RECORD).expect("literal"); + // Exchange retains the displaced inode until it is checked: never + // blindly overwrite/delete a substituted archive. Both fixed slots + // remain guard-visible if publication or its fsync is uncertain. + let result = unsafe { + libc::renameatx_np( + self.root.anchor.directory().as_raw_fd(), + from.as_ptr(), + self.root.anchor.directory().as_raw_fd(), + to.as_ptr(), + libc::RENAME_SWAP, + ) + }; + require(result == 0, "cannot atomically publish committed receipt")?; + observe(FinishPoint::CommitPublished)?; + let committed_snapshot = metadata(&stage)?; + let displaced_snapshot = metadata(prepared)?; + require( + same_inode(&written, &committed_snapshot) + && same_inode(prepared_snapshot, &displaced_snapshot), + "commit exchange changed owned inodes", + )?; + named_owned_at( + &self.root, + COMPLETED_RECORD, + &stage, + &bytes, + &committed_snapshot, + )?; + named_owned_at( + &self.root, + COMPLETION_STAGE, + prepared, + prepared_bytes, + &displaced_snapshot, + )?; + require_absent(&self.root, ATTEMPT_RECORD)?; + observe(FinishPoint::BeforeCommitDirectorySync)?; + self.root + .anchor + .directory() + .sync_all() + .map_err(|_| error("committed receipt publication is uncertain"))?; + observe(FinishPoint::CommitDirectorySynced)?; + require( + self.validate_health(proof)? == server, + "healthy server identity changed", + )?; + self.installed_app.validate()?; + named_owned_at( + &self.root, + COMPLETED_RECORD, + &stage, + &bytes, + &committed_snapshot, + )?; + named_owned_at( + &self.root, + COMPLETION_STAGE, + prepared, + prepared_bytes, + &displaced_snapshot, + )?; + require_absent(&self.root, ATTEMPT_RECORD)?; + // Committed is now durable. Retire only the checked displaced + // PreparedSuccess slot. A leftover slot causes conservative recovery. + require( + unsafe { + libc::unlinkat(self.root.anchor.directory().as_raw_fd(), from.as_ptr(), 0) + } == 0, + "cannot retire checked commit staging entry", + )?; + observe(FinishPoint::CommitStageRetired)?; + self.root + .anchor + .directory() + .sync_all() + .map_err(|_| error("commit staging cleanup durability is uncertain"))?; + require( + metadata(prepared)?.nlink() == 0, + "displaced receipt acquired another link", + )?; + require_absent(&self.root, COMPLETION_STAGE)?; + require_absent(&self.root, ATTEMPT_RECORD)?; + named_owned_at( + &self.root, + COMPLETED_RECORD, + &stage, + &bytes, + &committed_snapshot, + ) + } + + fn validate_retired( + &self, + proof: &impl VerifiedHealthProof, + server: HealthyIdentity, + archive: &File, + bytes: &[u8], + snapshot: &Metadata, + ) -> Result<()> { + self.root.validate()?; + self.installed_app.validate()?; + require( + metadata(&self.file)?.nlink() == 0, + "retired attempt acquired another link", + )?; + // Any new entry, including a dangling symlink, is recovery, not success. + let name = CString::new(ATTEMPT_RECORD).expect("literal"); + let mut stat = std::mem::MaybeUninit::::uninit(); + let result = unsafe { + libc::fstatat( + self.root.anchor.directory().as_raw_fd(), + name.as_ptr(), + stat.as_mut_ptr(), + libc::AT_SYMLINK_NOFOLLOW, + ) + }; + require( + result == -1 && io::Error::last_os_error().raw_os_error() == Some(libc::ENOENT), + "canonical attempt name is not validated absent", + )?; + require( + self.validate_health(proof)? == server, + "healthy server identity changed", + )?; + named_owned_at(&self.root, COMPLETED_RECORD, archive, bytes, snapshot) + } + + fn open_completion(&self) -> Result<(File, Vec, Metadata)> { + self.validate_bound()?; + self.open_receipt_slot(COMPLETED_RECORD) + } + + fn open_receipt_slot(&self, name: &str) -> Result<(File, Vec, Metadata)> { + self.root.validate()?; + let created = open_at( + self.root.anchor.directory(), + OsStr::new(name), + libc::O_RDWR | libc::O_CREAT | libc::O_EXCL | libc::O_NONBLOCK, + 0o600, + ); + let (file, existing) = match created { + Ok(file) => (file, false), + Err(cause) if cause.kind() == io::ErrorKind::AlreadyExists => ( + open_at( + self.root.anchor.directory(), + OsStr::new(name), + libc::O_RDWR | libc::O_NONBLOCK, + 0, + ) + .map_err(|_| error("completed receipt is inaccessible or aliased"))?, + true, + ), + Err(_) => return Err(error("cannot exclusively create completed receipt")), + }; + let snapshot = metadata(&file)?; + private(&snapshot, false)?; + let bytes = read_bounded(&file)?; + named_owned_at(&self.root, name, &file, &bytes, &snapshot)?; + if existing { + let prior: CompletedRecord = serde_json::from_slice(&bytes).map_err(|_| { + error("alien or malformed completed receipt; will not overwrite") + })?; + validate_completion_record(&self.root, &prior, &snapshot)?; + require( + prior.attempt.target.app_path.as_os_str() + == self.record.target.app_path.as_os_str(), + "completed receipt ownership/identity mismatch", + )?; + // Rotation is authorized by THIS live health proof, never by the + // old receipt. Do not overwrite a different/future update chain. + let previous = semver::Version::parse(&prior.attempt.target.target_desktop_version) + .map_err(|_| error("invalid previous completed version"))?; + let source = semver::Version::parse(&self.record.target.source_desktop_version) + .map_err(|_| error("invalid source version"))?; + require( + previous.cmp_precedence(&source).is_le() + || (prior.attempt.attempt_id == self.record.attempt_id + && prior.attempt.target == self.record.target), + "completed receipt belongs to a different update chain", + )?; + } else { + require( + bytes.is_empty(), + "new completed receipt changed before write", + )?; + } + Ok((file, bytes, snapshot)) + } + } + + impl Drop for SuccessorAttempt { + fn drop(&mut self) { + if !self.root.owns_pid() { + return; + } + self.root.successor_claimed.store(false, Ordering::Release); + } + } + + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + enum ProcessStatus { + Alive, + Gone, + } + + fn valid_pid(pid: u32) -> bool { + pid > 1 && pid <= i32::MAX as u32 + } + + fn process_status(pid: u32) -> Result { + require(valid_pid(pid), "invalid process PID")?; + let result = unsafe { libc::kill(pid as libc::pid_t, 0) }; + classify_process_status(result, io::Error::last_os_error().raw_os_error()) + } + + fn classify_process_status(result: i32, errno: Option) -> Result { + match (result, errno) { + (0, _) => Ok(ProcessStatus::Alive), + (-1, Some(libc::ESRCH)) => Ok(ProcessStatus::Gone), + _ => Err(error("process status is unknown; startup remains blocked")), + } + } + + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + struct HealthyIdentity { + pid: u32, + started_seconds: u64, + started_microseconds: u64, + } + + fn owned_server_identity(pid: u32, owner: u32) -> Result { + let mut info = std::mem::MaybeUninit::::uninit(); + let size = std::mem::size_of::(); + let read = unsafe { + libc::proc_pidinfo( + pid as libc::pid_t, + libc::PROC_PIDTBSDINFO, + 0, + info.as_mut_ptr().cast(), + size as libc::c_int, + ) + }; + require( + read == size as i32, + "cannot establish healthy server ownership", + )?; + let info = unsafe { info.assume_init() }; + require( + info.pbi_pid == pid + && info.pbi_ppid == owner + && info.pbi_uid == unsafe { libc::geteuid() } + && info.pbi_status != libc::SZOMB + && info.pbi_start_tvsec != 0, + "healthy server is not our live owned child", + )?; + Ok(HealthyIdentity { + pid, + started_seconds: info.pbi_start_tvsec, + started_microseconds: info.pbi_start_tvusec, + }) + } + + fn old_owner_gone(pid: u32) -> Result<()> { + require(pid != std::process::id(), "old attempt owner is this PID")?; + require( + process_status(pid)? == ProcessStatus::Gone, + "old attempt owner is still alive", + ) + } + + fn match_target(expected: &Target, proof: &Target) -> Result<()> { + proof.validate()?; + require( + expected == proof, + "live proof target does not match the complete attempt target", + ) + } + + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + enum FinishPoint { + ArchiveOpened, + ArchiveTruncated, + BeforeArchiveSync, + ArchiveFileSynced, + ArchiveDirectorySynced, + BeforeRetirement, + Retired, + BeforeRetirementSync, + RetirementSynced, + CommitOpened, + BeforeCommitFileSync, + CommitFileSynced, + BeforeCommitPublication, + CommitPublished, + BeforeCommitDirectorySync, + CommitDirectorySynced, + CommitStageRetired, + } + + impl Root { + fn owns_pid(&self) -> bool { + std::process::id() == self.pid + } + + fn validate(&self) -> Result<()> { + // This MUST precede even a metadata call in copied live handles. + require(self.owns_pid(), "attempt belongs to another PID")?; + self.anchor.validate()?; + private(&metadata(self.anchor.directory())?, true) + } + } + + impl LiveAttempt { + #[cfg(test)] + pub(crate) fn phase(&self) -> Phase { + self.record.phase + } + + #[cfg(test)] + pub(crate) fn target(&self) -> &Target { + &self.record.target + } + + /// Parent MUST call immediately before its official install invocation. + /// It does not establish G0, consent, archive verification, or OS writer + /// termination; those are separate parent-owned gates. + pub(crate) fn validate_install_permit(&self) -> Result<()> { + self.validate()?; + require( + self.record.phase == Phase::Installing, + "attempt is not installing", + )?; + self.source_app.validate() + } + + fn validate(&self) -> Result<()> { + require(!self.poisoned, "attempt is poisoned")?; + named_owned(&self.root, &self.file, &self.bytes, &self.snapshot) + } + + /// Call ONLY after the official installer has returned success AND a + /// fresh full verification of the installed bundle. The narrow proof + /// adapter is implemented only for the real verifier's VerifiedBundle. + /// A successful return durably records AwaitingHealth, not completion. + /// No bool/status/version-only overload or disk-to-live conversion exists. + pub(crate) fn record_installed(&mut self, proof: &impl VerifiedBundleProof) -> Result<()> { + self.installed_inner(proof, &mut |_| Ok(())) + } + + fn installed_inner( + &mut self, + proof: &impl VerifiedBundleProof, + observe: &mut dyn FnMut(SyncPoint) -> Result<()>, + ) -> Result<()> { + self.validate()?; + require( + self.record.phase == Phase::Installing, + "attempt is not installing", + )?; + // The official installer legitimately replaces the source .app. + // Do NOT compare its old inode here: freshly verified target bytes + // are the required evidence after the caller's successful return. + // Poison even a rejected verification. It cannot later be replaced + // with a different claimed outcome on the same live handle. + self.poisoned = true; + require( + proof.root().as_os_str() == self.record.target.app_path.as_os_str() + && proof.inventory_sha256() == self.record.target.inventory_sha256, + "verified installed bundle does not match the full target inventory/path", + )?; + Anchor::open(proof.root())?.validate()?; + let old_phase = self.record.phase; + self.record.phase = Phase::AwaitingHealth; + let bytes = encode(&self.record)?; + self.record.phase = old_phase; + named_owned(&self.root, &self.file, &self.bytes, &self.snapshot)?; + // Rewrite the SAME owned inode. A crash can leave malformed JSON, + // but never removes/replaces the canonical blocker. There is no + // unlink/rename cleanup or authority reconstructed from that JSON. + self.file + .set_len(0) + .map_err(|_| error("cannot truncate owned attempt"))?; + self.file + .seek(SeekFrom::Start(0)) + .map_err(|_| error("cannot rewind attempt"))?; + observe(SyncPoint::Truncated)?; + // Catch a substituted name/hardlink before the subsequent write. + let truncated = metadata(&self.file)?; + named_owned(&self.root, &self.file, &[], &truncated)?; + persist(&self.root, &mut self.file, &bytes, observe)?; + let snapshot = metadata(&self.file)?; + named_owned(&self.root, &self.file, &bytes, &snapshot)?; + self.record.phase = Phase::AwaitingHealth; + self.bytes = bytes; + self.snapshot = snapshot; + self.poisoned = false; + Ok(()) + } + } + + impl LoadedAttempt { + #[cfg(test)] + pub(crate) fn load(data_root: &Path) -> Result> { + Journal::open(data_root)?.load() + } + + pub(crate) fn phase(&self) -> Phase { + self.record.phase + } + + pub(crate) fn target(&self) -> &Target { + &self.record.target + } + + #[cfg(test)] + pub(crate) fn matches_target(&self, expected: &Target) -> bool { + expected.validate().is_ok() && self.record.target == *expected + } + + #[cfg(test)] + pub(crate) fn owner_pid(&self) -> u32 { + self.record.owner_pid + } + + #[cfg(test)] + pub(crate) fn attempt_id(&self) -> &str { + &self.record.attempt_id + } + } + + /// Full descriptor chain, not just a final path check: an exchanged ancestor + /// also fails, even if someone subsequently moves the same leaf back under it. + struct Anchor { + path: PathBuf, + directories: Vec, + } + + impl Anchor { + fn open(path: &Path) -> Result { + canonical_path(path)?; + let root = CString::new("/").expect("literal"); + let fd = unsafe { + libc::open( + root.as_ptr(), + libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC, + ) + }; + require(fd >= 0, "cannot open filesystem root")?; + let mut directories = vec![unsafe { File::from_raw_fd(fd) }]; + for part in path.components() { + if let Component::Normal(name) = part { + let next = open_at( + directories.last().expect("root descriptor"), + name, + libc::O_RDONLY | libc::O_DIRECTORY, + 0, + ) + .map_err(|_| error("directory path is missing, inaccessible, or aliased"))?; + directories.push(next); + } + } + let anchor = Self { + path: path.to_owned(), + directories, + }; + anchor.validate()?; + Ok(anchor) + } + + fn directory(&self) -> &File { + self.directories.last().expect("root descriptor") + } + + fn validate(&self) -> Result<()> { + let names = self.path.components().filter_map(|part| match part { + Component::Normal(name) => Some(name), + _ => None, + }); + for (name, pair) in names.zip(self.directories.windows(2)) { + let current = open_at(&pair[0], name, libc::O_RDONLY | libc::O_DIRECTORY, 0) + .map_err(|_| error("directory anchor was replaced"))?; + require( + same_inode(&metadata(¤t)?, &metadata(&pair[1])?), + "directory anchor inode changed", + )?; + } + Ok(()) + } + } + + fn canonical_path(path: &Path) -> Result<()> { + let text = path.to_str().ok_or_else(|| error("path must be UTF-8"))?; + let normalized: PathBuf = path.components().collect(); + require( + path.is_absolute() + && text.len() <= MAX_PATH_BYTES + && !text.chars().any(char::is_control) + && path.as_os_str() == normalized.as_os_str() + && !path + .components() + .any(|part| matches!(part, Component::ParentDir | Component::CurDir)) + && path + .components() + .filter(|part| matches!(part, Component::Normal(_))) + .count() + <= 128, + "path must be bounded, absolute and canonical", + ) + } + + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + enum SyncPoint { + Created, + Truncated, + BeforeWrite, + BeforeFileSync, + FileSynced, + BeforeDirectorySync, + DirectorySynced, + } + + fn persist( + root: &Root, + file: &mut File, + bytes: &[u8], + observe: &mut dyn FnMut(SyncPoint) -> Result<()>, + ) -> Result<()> { + observe(SyncPoint::BeforeWrite)?; + root.validate()?; + // Revalidate the still-empty owned inode immediately before mutation. + let empty = metadata(file)?; + named_owned(root, file, &[], &empty)?; + file.write_all(bytes) + .map_err(|_| error("cannot write attempt"))?; + observe(SyncPoint::BeforeFileSync)?; + let written = metadata(file)?; + named_owned(root, file, bytes, &written)?; + file.sync_all() + .map_err(|_| error("cannot synchronize attempt file"))?; + observe(SyncPoint::FileSynced)?; + observe(SyncPoint::BeforeDirectorySync)?; + named_owned(root, file, bytes, &written)?; + root.anchor + .directory() + .sync_all() + .map_err(|_| error("cannot synchronize attempt directory"))?; + observe(SyncPoint::DirectorySynced)?; + named_owned(root, file, bytes, &written) + } + + fn named_owned(root: &Root, owned: &File, expected: &[u8], snapshot: &Metadata) -> Result<()> { + named_owned_at(root, ATTEMPT_RECORD, owned, expected, snapshot) + } + + fn named_owned_at( + root: &Root, + name: &str, + owned: &File, + expected: &[u8], + snapshot: &Metadata, + ) -> Result<()> { + root.validate()?; + let file = open_at( + root.anchor.directory(), + OsStr::new(name), + libc::O_RDONLY | libc::O_NONBLOCK, + 0, + ) + .map_err(|_| error("attempt name is missing, inaccessible, or aliased"))?; + let current = metadata(&file)?; + let original = metadata(owned)?; + private(¤t, false)?; + private(&original, false)?; + require( + same_snapshot(snapshot, &original) && same_snapshot(&original, ¤t), + "attempt inode or metadata changed", + )?; + require(read_bounded(&file)? == expected, "attempt bytes changed")?; + require( + same_snapshot(¤t, &metadata(&file)?), + "attempt changed during read", + )?; + let final_name = open_at( + root.anchor.directory(), + OsStr::new(name), + libc::O_RDONLY | libc::O_NONBLOCK, + 0, + ) + .map_err(|_| error("attempt name changed during read"))?; + require( + same_snapshot(¤t, &metadata(&final_name)?) + && same_snapshot(¤t, &metadata(owned)?), + "attempt name or owned inode changed during read", + )?; + root.validate() + } + + fn read_bounded(file: &File) -> Result> { + require( + metadata(file)?.len() <= MAX_RECORD_BYTES as u64, + "attempt byte limit", + )?; + // Positional reads never share or change the live writer's file offset. + let mut bytes = vec![0; MAX_RECORD_BYTES + 1]; + let mut used = 0; + while used < bytes.len() { + match file.read_at(&mut bytes[used..], used as u64) { + Ok(0) => break, + Ok(read) => used += read, + Err(error) if error.kind() == io::ErrorKind::Interrupted => continue, + Err(_) => return Err(error("cannot read attempt")), + } + } + require(used <= MAX_RECORD_BYTES, "attempt byte limit")?; + bytes.truncate(used); + Ok(bytes) + } + + fn open_at(parent: &File, name: &OsStr, flags: i32, mode: libc::mode_t) -> io::Result { + let bytes = name.as_bytes(); + if bytes.is_empty() + || bytes.len() > 255 + || bytes == b"." + || bytes == b".." + || bytes.contains(&b'/') + { + return Err(io::ErrorKind::InvalidInput.into()); + } + let name = CString::new(bytes).map_err(|_| io::Error::from(io::ErrorKind::InvalidInput))?; + let fd = unsafe { + libc::openat( + parent.as_raw_fd(), + name.as_ptr(), + flags | libc::O_NOFOLLOW | libc::O_CLOEXEC, + mode as libc::c_uint, + ) + }; + if fd < 0 { + Err(io::Error::last_os_error()) + } else { + Ok(unsafe { File::from_raw_fd(fd) }) + } + } + + fn private(metadata: &Metadata, directory: bool) -> Result<()> { + require( + metadata.uid() == unsafe { libc::geteuid() } + && metadata.mode() & 0o7777 == if directory { 0o700 } else { 0o600 } + && if directory { + metadata.is_dir() + } else { + metadata.is_file() && metadata.nlink() == 1 + }, + "journal must be owner-private, regular and unaliased", + ) + } + + fn same_inode(a: &Metadata, b: &Metadata) -> bool { + a.dev() == b.dev() && a.ino() == b.ino() + } + + fn same_snapshot(a: &Metadata, b: &Metadata) -> bool { + same_inode(a, b) + && a.len() == b.len() + && a.mode() == b.mode() + && a.nlink() == b.nlink() + && a.uid() == b.uid() + && a.gid() == b.gid() + && a.mtime() == b.mtime() + && a.mtime_nsec() == b.mtime_nsec() + && a.ctime() == b.ctime() + && a.ctime_nsec() == b.ctime_nsec() + } + + fn metadata(file: &File) -> Result { + file.metadata() + .map_err(|_| error("cannot inspect journal descriptor")) + } + + fn encode(record: &Record) -> Result> { + let bytes = serde_json::to_vec(record).map_err(|_| error("cannot encode attempt"))?; + require(bytes.len() <= MAX_RECORD_BYTES, "attempt byte limit")?; + Ok(bytes) + } + + fn lower_hex(text: &str, length: usize) -> bool { + text.len() == length + && text + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + } + + fn random_id() -> Result { + let mut bytes = [0; 16]; + getrandom::getrandom(&mut bytes).map_err(|_| error("cannot allocate attempt identity"))?; + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut id = String::with_capacity(32); + for byte in bytes { + id.push(HEX[(byte >> 4) as usize] as char); + id.push(HEX[(byte & 15) as usize] as char); + } + Ok(id) + } + + fn error(message: &str) -> String { + format!("Desktop update attempt: {message}.") + } + + fn require(condition: bool, message: &str) -> Result<()> { + if condition { + Ok(()) + } else { + Err(error(message)) + } + } + + #[cfg(test)] + mod journal_tests { + use super::*; + use crate::updater_attempt::check; + use std::{ + fs::{self, DirBuilder, OpenOptions}, + os::unix::{ + fs::{symlink, DirBuilderExt, OpenOptionsExt, PermissionsExt}, + net::UnixListener, + }, + panic::{catch_unwind, AssertUnwindSafe}, + process::{Command, ExitStatus, Stdio}, + time::{Duration, Instant}, + }; + + struct Fixture { + temp: PathBuf, + root: PathBuf, + app: PathBuf, + } + + impl Fixture { + fn new() -> Self { + // Keep AF_UNIX fixture paths below macOS's small sun_path limit. + let temp = fs::canonicalize("/tmp").unwrap().join(format!( + "gjc-attempt-{}-{}", + std::process::id(), + &random_id().unwrap()[..16] + )); + private_dir(&temp); + let root = temp.join("data"); + let app = temp.join("Fixture.app"); + private_dir(&root); + private_dir(&app); + write_private( + &app.join("untouched-fixture"), + b"not an installed application", + ); + Self { temp, root, app } + } + + fn target(&self) -> Target { + target_for(&self.app) + } + + fn record(&self) -> PathBuf { + self.root.join(ATTEMPT_RECORD) + } + } + + impl Drop for Fixture { + fn drop(&mut self) { + // Only this test's randomly allocated temp tree, after joining + // all children. Never an installed application or real data root. + let _ = fs::remove_dir_all(&self.temp); + } + } + + fn private_dir(path: &Path) { + DirBuilder::new().mode(0o700).create(path).unwrap(); + } + + fn write_private(path: &Path, bytes: &[u8]) { + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(path) + .unwrap(); + file.write_all(bytes).unwrap(); + } + + fn after_descriptor_release(mut operation: impl FnMut() -> Result) -> T { + // A parallel fork/posix_spawn can briefly retain our just-dropped + // flock until exec closes its CLOEXEC descriptor. Product APIs + // remain nonblocking and fail closed; only this test helper waits. + let deadline = Instant::now() + Duration::from_secs(1); + loop { + match operation() { + Ok(value) => return value, + Err(message) + if message.contains("another journal owner") + && Instant::now() < deadline => + { + std::thread::sleep(Duration::from_millis(1)); + } + Err(message) => panic!("{message}"), + } + } + } + + fn target_for(app: &Path) -> Target { + Target { + app_path: app.to_owned(), + source_desktop_version: "1.0.0".into(), + target_desktop_version: "1.1.0".into(), + target_product_version: "2.0.0-beta.10".into(), + archive_sha256: "a".repeat(64), + inventory_sha256: "b".repeat(64), + runtime_manifest_sha256: "c".repeat(64), + } + } + + // Unit-only proof projection exercises the journal's state/ownership + // boundary, NOT inventory verification or official installation. Product + // callers implement the trait only for the real opaque VerifiedBundle; + // its full-tree verification is tested in updater_bundle.rs. + struct ProofProjection { + path: PathBuf, + digest: String, + } + + impl ProofProjection { + fn for_target(target: &Target) -> Self { + Self { + path: target.app_path.clone(), + digest: target.inventory_sha256.clone(), + } + } + } + + impl VerifiedBundleProof for ProofProjection { + fn root(&self) -> &Path { + &self.path + } + fn inventory_sha256(&self) -> &str { + &self.digest + } + } + + impl proof_seal::Sealed for ProofProjection {} + + #[test] + fn permit_follows_both_sync_barriers_and_only_mutates_canonical_journal() { + let fixture = Fixture::new(); + write_private(&fixture.root.join("unrelated"), b"keep me"); + let journal = Journal::open(&fixture.root).unwrap(); + assert!(journal.load().unwrap().is_none()); + assert!(check(&fixture.root).is_ok()); + let mut stages = Vec::new(); + let attempt = journal + .begin_inner(fixture.target(), &mut |point| { + stages.push(point); + assert!(check(&fixture.root).is_err()); + Ok(()) + }) + .unwrap(); + assert_eq!( + stages, + [ + SyncPoint::Created, + SyncPoint::BeforeWrite, + SyncPoint::BeforeFileSync, + SyncPoint::FileSynced, + SyncPoint::BeforeDirectorySync, + SyncPoint::DirectorySynced + ] + ); + attempt.validate_install_permit().unwrap(); + assert_eq!(attempt.phase(), Phase::Installing); + assert_eq!(attempt.target(), &fixture.target()); + assert_eq!(journal.load().unwrap().unwrap().target(), &fixture.target()); + let metadata = fs::metadata(fixture.record()).unwrap(); + assert_eq!(metadata.mode() & 0o7777, 0o600); + assert_eq!(metadata.nlink(), 1); + assert_eq!( + fs::read(fixture.root.join("unrelated")).unwrap(), + b"keep me" + ); + assert_eq!( + fs::read(fixture.app.join("untouched-fixture")).unwrap(), + b"not an installed application" + ); + assert_eq!(fs::read_dir(&fixture.root).unwrap().count(), 2); + } + + #[test] + fn sync_errors_and_partial_publication_never_return_a_permit_or_remove_blocker() { + for fail in [ + SyncPoint::Created, + SyncPoint::BeforeWrite, + SyncPoint::BeforeFileSync, + SyncPoint::FileSynced, + SyncPoint::BeforeDirectorySync, + SyncPoint::DirectorySynced, + ] { + let fixture = Fixture::new(); + let journal = Journal::open(&fixture.root).unwrap(); + assert!( + journal + .begin_inner(fixture.target(), &mut |point| { + if point == fail { + Err(error("injected sync failure")) + } else { + Ok(()) + } + }) + .is_err(), + "{fail:?}" + ); + let before = fs::read(fixture.record()).unwrap(); + assert!(check(&fixture.root).is_err()); + assert!(journal.begin(fixture.target()).is_err()); + drop(journal); + let reopened = after_descriptor_release(|| Journal::open(&fixture.root)); + assert!(reopened.begin(fixture.target()).is_err()); + assert_eq!(fs::read(fixture.record()).unwrap(), before); + } + } + + #[test] + fn drop_and_unwind_leave_inspectable_installing_without_resumption() { + for panic in [false, true] { + let fixture = Fixture::new(); + let journal = Journal::open(&fixture.root).unwrap(); + let result = catch_unwind(AssertUnwindSafe(|| { + let _attempt = journal.begin(fixture.target()).unwrap(); + assert!(!panic, "injected abandoned install"); + })); + assert_eq!(result.is_err(), panic); + drop(journal); + let loaded = + after_descriptor_release(|| LoadedAttempt::load(&fixture.root)).unwrap(); + assert_eq!(loaded.phase(), Phase::Installing); + assert_eq!(loaded.owner_pid(), std::process::id()); + assert_eq!(loaded.attempt_id().len(), 32); + assert!(loaded.matches_target(&fixture.target())); + assert!(check(&fixture.root).is_err()); + assert!(Journal::open(&fixture.root) + .unwrap() + .begin(fixture.target()) + .is_err()); + } + } + + #[test] + fn installed_transition_is_durable_but_never_acknowledges_health_or_clears() { + let fixture = Fixture::new(); + let journal = Journal::open(&fixture.root).unwrap(); + let mut attempt = journal.begin(fixture.target()).unwrap(); + let inode = fs::metadata(fixture.record()).unwrap().ino(); + let proof = ProofProjection::for_target(&fixture.target()); + let mut stages = Vec::new(); + attempt + .installed_inner(&proof, &mut |point| { + stages.push(point); + Ok(()) + }) + .unwrap(); + assert_eq!( + stages, + [ + SyncPoint::Truncated, + SyncPoint::BeforeWrite, + SyncPoint::BeforeFileSync, + SyncPoint::FileSynced, + SyncPoint::BeforeDirectorySync, + SyncPoint::DirectorySynced + ] + ); + assert_eq!(attempt.phase(), Phase::AwaitingHealth); + assert!(attempt.validate_install_permit().is_err()); + assert!(attempt.record_installed(&proof).is_err()); + assert_eq!(fs::metadata(fixture.record()).unwrap().ino(), inode); + assert_eq!( + journal.load().unwrap().unwrap().phase(), + Phase::AwaitingHealth + ); + drop(attempt); + drop(journal); + let loaded = after_descriptor_release(|| LoadedAttempt::load(&fixture.root)).unwrap(); + assert_eq!(loaded.phase(), Phase::AwaitingHealth); + assert!(loaded.matches_target(&fixture.target())); + assert!(check(&fixture.root).is_err()); + assert_eq!(fs::read_dir(&fixture.root).unwrap().count(), 1); + } + + #[test] + fn transition_sync_failures_and_panics_poison_handle_and_retain_blocker() { + for panic in [false, true] { + for fail in [ + SyncPoint::Truncated, + SyncPoint::BeforeWrite, + SyncPoint::BeforeFileSync, + SyncPoint::FileSynced, + SyncPoint::BeforeDirectorySync, + SyncPoint::DirectorySynced, + ] { + let fixture = Fixture::new(); + let journal = Journal::open(&fixture.root).unwrap(); + let mut attempt = journal.begin(fixture.target()).unwrap(); + let proof = ProofProjection::for_target(&fixture.target()); + let result = catch_unwind(AssertUnwindSafe(|| { + attempt.installed_inner(&proof, &mut |point| { + if point == fail { + assert!(!panic, "injected transition unwind"); + Err(error("injected transition sync failure")) + } else { + Ok(()) + } + }) + })); + assert!(result.is_err() || result.unwrap().is_err()); + let before = fs::read(fixture.record()).unwrap(); + assert!(attempt.validate_install_permit().is_err()); + assert!(attempt.record_installed(&proof).is_err()); + assert!(check(&fixture.root).is_err()); + assert_eq!(fs::read(fixture.record()).unwrap(), before); + } + } + } + + #[test] + fn mismatched_bundle_path_or_inventory_never_records_installed() { + for path in [false, true] { + let fixture = Fixture::new(); + let journal = Journal::open(&fixture.root).unwrap(); + let mut attempt = journal.begin(fixture.target()).unwrap(); + let mut proof = ProofProjection::for_target(&fixture.target()); + if path { + proof.path = fixture.temp.join("Different.app"); + } else { + proof.digest = "d".repeat(64); + } + let before = fs::read(fixture.record()).unwrap(); + assert!(attempt.record_installed(&proof).is_err()); + assert!(attempt + .record_installed(&ProofProjection::for_target(&fixture.target())) + .is_err()); + assert_eq!(fs::read(fixture.record()).unwrap(), before); + assert_eq!(journal.load().unwrap().unwrap().phase(), Phase::Installing); + } + } + + #[test] + fn inspection_matches_every_target_field_not_just_versions_and_tolerates_missing_app() { + let fixture = Fixture::new(); + let journal = Journal::open(&fixture.root).unwrap(); + drop(journal.begin(fixture.target()).unwrap()); + let loaded = journal.load().unwrap().unwrap(); + for field in 0..7 { + let mut other = fixture.target(); + match field { + 0 => other.app_path = fixture.temp.join("Other.app"), + 1 => other.source_desktop_version = "0.9.0".into(), + 2 => other.target_desktop_version = "9.0.0".into(), + 3 => other.target_product_version = "9.0.0".into(), + 4 => other.archive_sha256 = "d".repeat(64), + 5 => other.inventory_sha256 = "d".repeat(64), + _ => other.runtime_manifest_sha256 = "d".repeat(64), + } + assert!(!loaded.matches_target(&other), "field {field}"); + } + fs::rename(&fixture.app, fixture.temp.join("missing-fixture-app")).unwrap(); + assert!(journal + .load() + .unwrap() + .unwrap() + .matches_target(&fixture.target())); + assert!(check(&fixture.root).is_err()); + } + + #[test] + fn target_bounds_types_versions_and_canonical_app_paths_are_enforced_before_create() { + let fixture = Fixture::new(); + let journal = Journal::open(&fixture.root).unwrap(); + symlink(&fixture.app, fixture.temp.join("Alias.app")).unwrap(); + write_private(&fixture.temp.join("File.app"), b"not directory"); + for case in 0..19 { + let mut target = fixture.target(); + match case { + 0 => target.app_path = PathBuf::from("relative.app"), + 1 => target.app_path = fixture.temp.join("Alias.app"), + 2 => target.app_path = fixture.temp.join("File.app"), + 3 => target.app_path = fixture.temp.join("Missing.app"), + 4 => target.app_path = fixture.root.clone(), + 5 => target.app_path = fixture.temp.join("../Fixture.app"), + 6 => target.app_path = fixture.temp.join("./Fixture.app"), + 7 => { + target.app_path = + PathBuf::from(format!("{}//Fixture.app", fixture.temp.display())) + } + 8 => target.app_path = fixture.temp.join("Nul\0.app"), + 9 => { + target.app_path = fixture + .temp + .join(format!("{}.app", "a".repeat(MAX_PATH_BYTES))) + } + 10 => target.source_desktop_version = "1.0".into(), + 11 => target.target_desktop_version = "01.0.0".into(), + 12 => target.target_product_version = "bad".into(), + 13 => { + target.target_product_version = + format!("1.0.0-{}", "a".repeat(MAX_VERSION_BYTES)) + } + 14 => target.archive_sha256 = "A".repeat(64), + 15 => target.inventory_sha256 = "b".repeat(63), + 16 => target.runtime_manifest_sha256 = "g".repeat(64), + 17 => target.app_path = fixture.temp.join(".app"), + _ => target.app_path = PathBuf::from(format!("{}/", fixture.app.display())), + } + assert!(journal.begin(target).is_err(), "case {case}"); + assert!(!fixture.record().exists()); + } + } + + #[test] + fn open_never_creates_or_repairs_roots_and_rejects_unsafe_ancestors() { + let fixture = Fixture::new(); + let missing = fixture.temp.join("absent/data"); + assert!(Journal::open(&missing).is_err()); + assert!(!fixture.temp.join("absent").exists()); + assert!(Journal::open(Path::new("relative")).is_err()); + assert!(Journal::open(&fixture.root.join("..")).is_err()); + symlink(&fixture.root, fixture.temp.join("alias")).unwrap(); + assert!(Journal::open(&fixture.temp.join("alias")).is_err()); + assert!(Journal::open(&fixture.temp.join("alias/absent")).is_err()); + for mode in [0o755, 0o750, 0o770, 0o1700] { + fs::set_permissions(&fixture.root, fs::Permissions::from_mode(mode)).unwrap(); + assert!(Journal::open(&fixture.root).is_err()); + assert_eq!(fs::metadata(&fixture.root).unwrap().mode() & 0o7777, mode); + } + fs::set_permissions(&fixture.root, fs::Permissions::from_mode(0o700)).unwrap(); + assert!(Journal::open(&fixture.root).is_ok()); + assert!(fs::read_dir(&fixture.root).unwrap().next().is_none()); + } + + #[test] + fn malformed_oversized_unknown_and_forged_identity_records_remain_untouched() { + for case in 0..14 { + let fixture = Fixture::new(); + let journal = Journal::open(&fixture.root).unwrap(); + drop(journal.begin(fixture.target()).unwrap()); + let mut value: serde_json::Value = + serde_json::from_slice(&fs::read(fixture.record()).unwrap()).unwrap(); + match case { + 0 => value["schema"] = 2.into(), + 1 => value["extra"] = true.into(), + 2 => value["phase"] = "archive_verified_success".into(), + 3 => value["phase"] = "relaunch".into(), + 4 => value["root_inode"] = 0.into(), + 5 => value["record_inode"] = 0.into(), + 6 => value["attempt_id"] = "forged".into(), + 7 => value["owner_pid"] = 0.into(), + 8 => value["data_root"] = "/wrong-root".into(), + 9 => value["target"]["archive_sha256"] = 7.into(), + 10 => value["target"]["extra"] = true.into(), + _ => {} + } + let body = match case { + 11 => vec![], + 12 => b"not json".to_vec(), + 13 => vec![b' '; MAX_RECORD_BYTES + 1], + _ => serde_json::to_vec(&value).unwrap(), + }; + fs::write(fixture.record(), &body).unwrap(); + assert!(journal.load().is_err(), "case {case}"); + assert!(journal.begin(fixture.target()).is_err()); + assert!(check(&fixture.root).is_err()); + assert_eq!(fs::read(fixture.record()).unwrap(), body); + } + } + + #[test] + fn present_symlink_hardlink_directory_fifo_socket_and_public_file_refuse_without_hanging() { + for kind in 0..7 { + let fixture = Fixture::new(); + let journal = Journal::open(&fixture.root).unwrap(); + let outside = fixture.temp.join("outside-data-root"); + write_private(&outside, b"do not modify"); + let _socket = match kind { + 0 => { + symlink(&outside, fixture.record()).unwrap(); + None + } + 1 => { + symlink(fixture.temp.join("missing"), fixture.record()).unwrap(); + None + } + 2 => { + fs::hard_link(&outside, fixture.record()).unwrap(); + None + } + 3 => { + private_dir(&fixture.record()); + None + } + 4 => { + let name = CString::new(fixture.record().as_os_str().as_bytes()).unwrap(); + assert_eq!(unsafe { libc::mkfifo(name.as_ptr(), 0o600) }, 0); + None + } + 5 => Some(UnixListener::bind(fixture.record()).unwrap()), + _ => { + write_private(&fixture.record(), b"public"); + fs::set_permissions(fixture.record(), fs::Permissions::from_mode(0o644)) + .unwrap(); + None + } + }; + let start = Instant::now(); + assert!(journal.load().is_err()); + assert!(journal.begin(fixture.target()).is_err()); + assert!(check(&fixture.root).is_err()); + assert!(start.elapsed() < Duration::from_secs(1)); + assert_eq!(fs::read(outside).unwrap(), b"do not modify"); + } + } + + #[test] + fn live_inode_bytes_link_and_mode_substitutions_refuse_without_mutating_either_file() { + for kind in 0..6 { + let fixture = Fixture::new(); + let journal = Journal::open(&fixture.root).unwrap(); + let mut attempt = journal.begin(fixture.target()).unwrap(); + let bytes = fs::read(fixture.record()).unwrap(); + let saved = fixture.root.join("saved-owned.json"); + match kind { + 0 => { + fs::rename(fixture.record(), &saved).unwrap(); + write_private(&fixture.record(), &bytes); + } + 1 => { + fs::rename(fixture.record(), &saved).unwrap(); + symlink(&saved, fixture.record()).unwrap(); + } + 2 => { + fs::hard_link(fixture.record(), &saved).unwrap(); + } + 3 => { + fs::write(fixture.record(), b"changed").unwrap(); + } + 4 => { + fs::set_permissions(fixture.record(), fs::Permissions::from_mode(0o640)) + .unwrap(); + } + _ => { + fs::rename(fixture.record(), &saved).unwrap(); + private_dir(&fixture.record()); + } + } + let before = read_bounded(&attempt.file).unwrap(); + assert!(attempt.validate_install_permit().is_err()); + assert!(attempt + .record_installed(&ProofProjection::for_target(&fixture.target())) + .is_err()); + assert_eq!(read_bounded(&attempt.file).unwrap(), before); + assert!(check(&fixture.root).is_err()); + if kind == 0 { + assert!(journal.load().is_err()); + } + } + } + + #[test] + fn root_and_ancestor_swaps_refuse_including_same_leaf_moved_under_replacement_parent() { + for ancestor in [false, true] { + let fixture = Fixture::new(); + let parent = fixture.root.join("parent"); + private_dir(&parent); + let root = parent.join("private-data"); + private_dir(&root); + let journal = Journal::open(&root).unwrap(); + let mut attempt = journal.begin(fixture.target()).unwrap(); + let before = read_bounded(&attempt.file).unwrap(); + if ancestor { + let moved = fixture.root.join("moved-parent"); + fs::rename(&parent, &moved).unwrap(); + private_dir(&parent); + fs::rename(moved.join("private-data"), &root).unwrap(); + } else { + fs::rename(&root, parent.join("moved-root")).unwrap(); + private_dir(&root); + } + assert!(attempt.validate_install_permit().is_err()); + assert!(attempt + .record_installed(&ProofProjection::for_target(&fixture.target())) + .is_err()); + assert!(journal.begin(fixture.target()).is_err()); + assert!(journal.load().is_err()); + assert_eq!(read_bounded(&attempt.file).unwrap(), before); + } + } + + #[test] + fn publication_boundary_swaps_never_grant_permit_or_touch_an_outside_target() { + for point in [ + SyncPoint::Created, + SyncPoint::BeforeWrite, + SyncPoint::BeforeFileSync, + SyncPoint::BeforeDirectorySync, + SyncPoint::DirectorySynced, + ] { + let fixture = Fixture::new(); + let journal = Journal::open(&fixture.root).unwrap(); + let outside = fixture.temp.join("outside"); + write_private(&outside, b"untouched outside bytes"); + assert!(journal + .begin_inner(fixture.target(), &mut |current| { + if current == point { + fs::rename(fixture.record(), fixture.root.join("saved-owned")).unwrap(); + symlink(&outside, fixture.record()).unwrap(); + } + Ok(()) + }) + .is_err()); + assert_eq!(fs::read(outside).unwrap(), b"untouched outside bytes"); + assert!(check(&fixture.root).is_err()); + } + } + + #[test] + fn transition_boundary_substitution_never_writes_through_an_outside_alias() { + for point in [ + SyncPoint::Truncated, + SyncPoint::BeforeWrite, + SyncPoint::BeforeFileSync, + SyncPoint::BeforeDirectorySync, + SyncPoint::DirectorySynced, + ] { + let fixture = Fixture::new(); + let journal = Journal::open(&fixture.root).unwrap(); + let mut attempt = journal.begin(fixture.target()).unwrap(); + let proof = ProofProjection::for_target(&fixture.target()); + let outside = fixture.temp.join("outside"); + write_private(&outside, b"untouched outside bytes"); + assert!(attempt + .installed_inner(&proof, &mut |current| { + if current == point { + fs::rename(fixture.record(), fixture.root.join("saved-owned")).unwrap(); + symlink(&outside, fixture.record()).unwrap(); + } + Ok(()) + }) + .is_err()); + assert!(attempt.record_installed(&proof).is_err()); + assert_eq!(fs::read(outside).unwrap(), b"untouched outside bytes"); + assert!(check(&fixture.root).is_err()); + } + } + + #[test] + fn app_path_swaps_refuse_pre_apply_but_legitimate_replacement_can_await_health() { + for symlink_replacement in [false, true] { + let fixture = Fixture::new(); + let journal = Journal::open(&fixture.root).unwrap(); + let mut attempt = journal.begin(fixture.target()).unwrap(); + let old = fixture.temp.join("old-Fixture.app"); + fs::rename(&fixture.app, &old).unwrap(); + if symlink_replacement { + symlink(&old, &fixture.app).unwrap(); + } else { + private_dir(&fixture.app); + } + assert!(attempt.validate_install_permit().is_err()); + let proof = ProofProjection::for_target(&fixture.target()); + let result = attempt.record_installed(&proof); + if symlink_replacement { + assert!(result.is_err()); + } else { + // Unit-level projection of the legitimate post-install path; + // no installer was called and no real app was touched. + result.unwrap(); + assert_eq!(attempt.phase(), Phase::AwaitingHealth); + } + assert!(check(&fixture.root).is_err()); + } + } + + #[test] + fn copied_record_in_a_different_root_never_becomes_a_loaded_or_live_permit() { + let fixture = Fixture::new(); + let other = fixture.temp.join("other-data"); + private_dir(&other); + let journal = Journal::open(&fixture.root).unwrap(); + drop(journal.begin(fixture.target()).unwrap()); + write_private( + &other.join(ATTEMPT_RECORD), + &fs::read(fixture.record()).unwrap(), + ); + let copied = Journal::open(&other).unwrap(); + assert!(copied.load().is_err()); + assert!(copied.begin(fixture.target()).is_err()); + assert!(check(&other).is_err()); + } + + #[test] + fn exclusive_directory_lock_survives_journal_drop_while_permit_is_live() { + let fixture = Fixture::new(); + let journal = Journal::open(&fixture.root).unwrap(); + assert!(Journal::open(&fixture.root).is_err()); + let attempt = journal.begin(fixture.target()).unwrap(); + drop(journal); + assert!(Journal::open(&fixture.root).is_err()); + drop(attempt); + let reopened = after_descriptor_release(|| Journal::open(&fixture.root)); + assert!(reopened.load().unwrap().is_some()); + assert!(reopened.begin(fixture.target()).is_err()); + } + + #[test] + fn copied_pid_refuses_all_live_operations_before_filesystem_checks() { + let fixture = Fixture::new(); + let mut journal = Journal::open(&fixture.root).unwrap(); + Arc::get_mut(&mut journal.root).unwrap().pid = std::process::id() + 1; + assert!(journal.load().unwrap_err().contains("another PID")); + assert!(journal.begin(fixture.target()).is_err()); + assert!(!fixture.record().exists()); + Arc::get_mut(&mut journal.root).unwrap().pid = std::process::id(); + let mut attempt = journal.begin(fixture.target()).unwrap(); + drop(journal); + Arc::get_mut(&mut attempt.root).unwrap().pid = std::process::id() + 1; + let before = fs::read(fixture.record()).unwrap(); + assert!(attempt + .validate_install_permit() + .unwrap_err() + .contains("another PID")); + assert!(attempt + .record_installed(&ProofProjection::for_target(&fixture.target())) + .is_err()); + assert_eq!(fs::read(fixture.record()).unwrap(), before); + } + + #[test] + fn actual_fork_copy_fails_the_same_pid_fence_used_by_every_operation() { + let fixture = Fixture::new(); + // Isolate the fork from parallel tests' directory-lock descriptors. + assert_eq!(child(&fixture, "fork").code(), Some(75)); + assert!(check(&fixture.root).is_err()); + } + + fn child(fixture: &Fixture, scenario: &str) -> ExitStatus { + let mut child = Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "updater_attempt::durable::journal_tests::process_fault_child", + "--ignored", + "--nocapture", + ]) + .env("GJC_DURABLE_ATTEMPT_TEST_ROOT", &fixture.root) + .env("GJC_DURABLE_ATTEMPT_TEST_APP", &fixture.app) + .env("GJC_DURABLE_ATTEMPT_TEST_FAULT", scenario) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(10); + loop { + if let Some(status) = child.try_wait().unwrap() { + return status; + } + if Instant::now() >= deadline { + child.kill().unwrap(); + child.wait().unwrap(); + panic!("owned journal test child timed out"); + } + std::thread::sleep(Duration::from_millis(10)); + } + } + + #[test] + fn crash_orphans_before_after_sync_and_during_transition_always_block_successor() { + for scenario in [ + "created", + "before-file-sync", + "file-synced", + "directory-synced", + "live", + "truncated", + "installed", + ] { + let fixture = Fixture::new(); + assert_eq!(child(&fixture, scenario).code(), Some(73), "{scenario}"); + assert!(check(&fixture.root).is_err()); + let journal = Journal::open(&fixture.root).unwrap(); + assert!(journal.begin(fixture.target()).is_err()); + if scenario == "installed" { + assert_eq!( + journal.load().unwrap().unwrap().phase(), + Phase::AwaitingHealth + ); + } + assert_eq!( + fs::read(fixture.app.join("untouched-fixture")).unwrap(), + b"not an installed application" + ); + } + } + + #[test] + fn another_process_cannot_open_live_journal() { + let fixture = Fixture::new(); + let _journal = Journal::open(&fixture.root).unwrap(); + assert_eq!(child(&fixture, "duplicate").code(), Some(74)); + assert!(!fixture.record().exists()); + } + + mod successor_tests { + use super::*; + use std::io::Read; + + struct SuccessorProjection(Target); + impl proof_seal::Sealed for SuccessorProjection {} + impl VerifiedSuccessorProof for SuccessorProjection { + fn target(&self) -> &Target { + &self.0 + } + } + + struct HealthProjection { + target: Target, + pid: u32, + } + impl proof_seal::Sealed for HealthProjection {} + impl VerifiedHealthProof for HealthProjection { + fn target(&self) -> &Target { + &self.target + } + fn server_pid(&self) -> u32 { + self.pid + } + } + + // These projections exercise only journal authority/state handling. + // A pipe-bound cat is an owned PID fixture, NOT an application server + // or evidence of real ready/health/persistence, install or G0/G5 success. + struct OwnedChild(std::process::Child); + impl OwnedChild { + fn server() -> Self { + Self( + Command::new("/bin/cat") + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .unwrap(), + ) + } + fn proof(&self, target: &Target) -> HealthProjection { + HealthProjection { + target: target.clone(), + pid: self.0.id(), + } + } + fn wait(&mut self) -> ExitStatus { + let deadline = Instant::now() + Duration::from_secs(10); + loop { + if let Some(status) = self.0.try_wait().unwrap() { + return status; + } + assert!( + Instant::now() < deadline, + "owned successor test child timed out" + ); + std::thread::sleep(Duration::from_millis(5)); + } + } + } + impl Drop for OwnedChild { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } + } + + fn spawn_role(fixture: &Fixture, target: &Target, role: &str) -> OwnedChild { + OwnedChild(Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "updater_attempt::durable::journal_tests::successor_tests::process_successor_child", + "--ignored", "--nocapture"]) + .env("GJC_SUCCESSOR_TEST_ROOT", &fixture.root) + .env("GJC_SUCCESSOR_TEST_TARGET", serde_json::to_string(target).unwrap()) + .env("GJC_SUCCESSOR_TEST_ROLE", role) + .stdin(Stdio::piped()).stdout(Stdio::null()).stderr(Stdio::null()) + .spawn().unwrap()) + } + + fn publish(fixture: &Fixture, target: &Target, role: &str) { + assert_eq!(spawn_role(fixture, target, role).wait().code(), Some(81)); + } + + fn prepared(fixture: &Fixture) -> (Journal, LoadedAttempt, SuccessorProjection) { + publish(fixture, &fixture.target(), "publish"); + let journal = after_descriptor_release(|| Journal::open(&fixture.root)); + let loaded = journal.load().unwrap().unwrap(); + let proof = SuccessorProjection(fixture.target()); + (journal, loaded, proof) + } + + #[test] + fn real_process_exit_resume_is_read_only_and_requires_an_independent_live_proof() { + let fixture = Fixture::new(); + let (journal, loaded, proof) = prepared(&fixture); + let before = fs::read(fixture.record()).unwrap(); + let before_stat = fs::metadata(fixture.record()).unwrap(); + assert_ne!(loaded.owner_pid(), std::process::id()); + assert_eq!( + process_status(loaded.owner_pid()).unwrap(), + ProcessStatus::Gone + ); + let successor = journal.resume_verified(&loaded, &proof).unwrap(); + successor.validate_startup(&proof).unwrap(); + assert_eq!(successor.target(), &fixture.target()); + assert_eq!(fs::read(fixture.record()).unwrap(), before); + assert!(same_snapshot( + &before_stat, + &fs::metadata(fixture.record()).unwrap() + )); + assert!(!fixture.root.join(COMPLETED_RECORD).exists()); + assert!(journal.resume_verified(&loaded, &proof).is_err()); + assert!(journal.load().is_err()); + assert!(journal.begin(fixture.target()).is_err()); + assert!(check(&fixture.root).is_err()); + drop(successor); + assert!(check(&fixture.root).is_err()); + journal + .resume_verified(&loaded, &proof) + .unwrap() + .validate_startup(&proof) + .unwrap(); + } + + #[test] + fn installing_same_pid_live_old_pid_and_unknown_process_status_refuse() { + let fixture = Fixture::new(); + publish(&fixture, &fixture.target(), "publish-installing"); + let journal = Journal::open(&fixture.root).unwrap(); + let loaded = journal.load().unwrap().unwrap(); + assert!(journal + .resume_verified(&loaded, &SuccessorProjection(fixture.target())) + .is_err()); + let other = Fixture::new(); + let same = Journal::open(&other.root).unwrap(); + let mut attempt = same.begin(other.target()).unwrap(); + attempt + .record_installed(&ProofProjection::for_target(&other.target())) + .unwrap(); + drop(attempt); + assert!(same + .resume_verified( + &same.load().unwrap().unwrap(), + &SuccessorProjection(other.target()) + ) + .is_err()); + + let live = Fixture::new(); + let owner = spawn_role(&live, &live.target(), "publish-live"); + let deadline = Instant::now() + Duration::from_secs(10); + let (journal, loaded) = loop { + if let Ok(bytes) = fs::read(live.record()) { + if serde_json::from_slice::(&bytes) + .is_ok_and(|r| r.phase == Phase::AwaitingHealth) + { + if let Ok(journal) = Journal::open(&live.root) { + let loaded = journal.load().unwrap().unwrap(); + break (journal, loaded); + } + } + } + assert!(Instant::now() < deadline); + std::thread::sleep(Duration::from_millis(5)); + }; + assert_eq!(loaded.owner_pid(), owner.0.id()); + assert!(journal + .resume_verified(&loaded, &SuccessorProjection(live.target())) + .is_err()); + drop(owner); + assert!(journal + .resume_verified(&loaded, &SuccessorProjection(live.target())) + .is_ok()); + for errno in [Some(libc::EPERM), Some(libc::EINVAL), Some(libc::EIO), None] { + assert!(classify_process_status(-1, errno).is_err()); + } + assert_eq!( + classify_process_status(-1, Some(libc::ESRCH)).unwrap(), + ProcessStatus::Gone + ); + assert_eq!( + classify_process_status(0, Some(libc::ESRCH)).unwrap(), + ProcessStatus::Alive + ); + assert!(classify_process_status(1, Some(libc::ESRCH)).is_err()); + } + + #[test] + fn wrong_successor_target_and_changed_loaded_bytes_inode_or_root_are_not_resumable() { + for case in 0..5 { + let fixture = Fixture::new(); + let (journal, loaded, proof) = prepared(&fixture); + let mut wrong = SuccessorProjection(fixture.target()); + wrong.0.runtime_manifest_sha256 = "d".repeat(64); + assert!(journal.resume_verified(&loaded, &wrong).is_err()); + let before = fs::read(fixture.record()).unwrap(); + match case { + 0 => fs::write(fixture.record(), b"stale content").unwrap(), + 1 => { + fs::rename(fixture.record(), fixture.root.join("saved.json")).unwrap(); + write_private(&fixture.record(), &before); + } + 2 => fs::hard_link(fixture.record(), fixture.temp.join("alias")).unwrap(), + 3 => fs::set_permissions(&fixture.root, fs::Permissions::from_mode(0o750)) + .unwrap(), + _ => { + fs::rename(&fixture.root, fixture.temp.join("old-root")).unwrap(); + private_dir(&fixture.root); + } + } + assert!(journal.resume_verified(&loaded, &proof).is_err()); + if case == 3 { + fs::set_permissions(&fixture.root, fs::Permissions::from_mode(0o700)) + .unwrap(); + } + } + } + + #[test] + fn bad_startup_proof_or_changed_live_record_poison_the_successor() { + for stale in [false, true] { + let fixture = Fixture::new(); + let (journal, loaded, proof) = prepared(&fixture); + let successor = journal.resume_verified(&loaded, &proof).unwrap(); + let mut wrong = SuccessorProjection(fixture.target()); + wrong.0.archive_sha256 = "d".repeat(64); + if stale { + fs::write(fixture.record(), b"changed after resume").unwrap(); + } + assert!(successor + .validate_startup(if stale { &proof } else { &wrong }) + .is_err()); + assert!(successor.validate_startup(&proof).is_err()); + let server = OwnedChild::server(); + assert!(successor + .finish(&server.proof(&fixture.target())) + .unwrap_err() + .contains("requires recovery")); + assert!(check(&fixture.root).is_err()); + assert!(!fixture.root.join(COMPLETED_RECORD).exists()); + } + } + + #[test] + fn healthy_finish_is_durable_bounded_and_allows_the_next_real_update_attempt() { + let fixture = Fixture::new(); + for iteration in 0..2 { + let mut target = fixture.target(); + if iteration == 1 { + target.source_desktop_version = "1.1.0".into(); + target.target_desktop_version = "1.2.0".into(); + } + publish(&fixture, &target, "publish"); + let journal = Journal::open(&fixture.root).unwrap(); + let loaded = journal.load().unwrap().unwrap(); + let proof = SuccessorProjection(target.clone()); + let mut successor = journal.resume_verified(&loaded, &proof).unwrap(); + successor.validate_startup(&proof).unwrap(); + let server = OwnedChild::server(); + let mut points = Vec::new(); + successor + .finish_inner(&server.proof(&target), &mut |point| { + points.push(point); + if point != FinishPoint::CommitStageRetired { + assert!(check(&fixture.root).is_err()); + } + Ok(()) + }) + .unwrap(); + assert_eq!(points, finish_points()); + assert!(successor.validate_startup(&proof).is_err()); + drop(successor); + assert!(journal.load().unwrap().is_none()); + assert!(check(&fixture.root).is_ok()); + let receipt_path = fixture.root.join(COMPLETED_RECORD); + let receipt: CompletedRecord = + serde_json::from_slice(&fs::read(&receipt_path).unwrap()).unwrap(); + assert!(completion_is_committed(&receipt)); + assert_eq!(receipt.attempt.target, target); + assert_eq!(receipt.server_pid, server.0.id()); + assert_eq!(receipt.completed_by_pid, std::process::id()); + assert_eq!( + receipt.archive_inode, + fs::metadata(receipt_path).unwrap().ino() + ); + assert_eq!(fs::read_dir(&fixture.root).unwrap().count(), 1); + } + assert_eq!( + fs::read(fixture.app.join("untouched-fixture")).unwrap(), + b"not an installed application" + ); + } + + #[test] + fn wrong_dead_self_foreign_or_invalid_health_pid_cannot_clear() { + let fixture = Fixture::new(); + let (journal, loaded, proof) = prepared(&fixture); + let mut dead = OwnedChild::server(); + dead.0.kill().unwrap(); + dead.wait(); + let live = OwnedChild::server(); + for pid in [ + 0, + 1, + u32::MAX, + std::process::id(), + loaded.owner_pid(), + dead.0.id(), + unsafe { libc::getppid() } as u32, + ] { + let successor = journal.resume_verified(&loaded, &proof).unwrap(); + let bad = HealthProjection { + target: fixture.target(), + pid, + }; + assert!(successor.finish(&bad).is_err(), "PID {pid}"); + assert!(check(&fixture.root).is_err()); + assert!(!fixture.root.join(COMPLETED_RECORD).exists()); + } + let successor = journal.resume_verified(&loaded, &proof).unwrap(); + let mut wrong = live.proof(&fixture.target()); + wrong.target.target_product_version = "9.0.0".into(); + assert!(successor.finish(&wrong).is_err()); + assert!(check(&fixture.root).is_err()); + journal + .resume_verified(&loaded, &proof) + .unwrap() + .finish(&live.proof(&fixture.target())) + .unwrap(); + assert!(check(&fixture.root).is_ok()); + } + + fn finish_points() -> [FinishPoint; 17] { + [ + FinishPoint::ArchiveOpened, + FinishPoint::ArchiveTruncated, + FinishPoint::BeforeArchiveSync, + FinishPoint::ArchiveFileSynced, + FinishPoint::ArchiveDirectorySynced, + FinishPoint::BeforeRetirement, + FinishPoint::Retired, + FinishPoint::BeforeRetirementSync, + FinishPoint::RetirementSynced, + FinishPoint::CommitOpened, + FinishPoint::BeforeCommitFileSync, + FinishPoint::CommitFileSynced, + FinishPoint::BeforeCommitPublication, + FinishPoint::CommitPublished, + FinishPoint::BeforeCommitDirectorySync, + FinishPoint::CommitDirectorySynced, + FinishPoint::CommitStageRetired, + ] + } + + #[test] + fn precommit_failures_persist_a_barrier_and_postcommit_outcomes_require_receipt_disposition( + ) { + for fail in finish_points() { + let fixture = Fixture::new(); + let (journal, loaded, proof) = prepared(&fixture); + let mut successor = journal.resume_verified(&loaded, &proof).unwrap(); + let server = OwnedChild::server(); + let message = successor + .finish_inner(&server.proof(&fixture.target()), &mut |point| { + if point == fail { + Err(error("injected completion I/O failure")) + } else { + Ok(()) + } + }) + .unwrap_err(); + assert!(message.contains("requires recovery"), "{fail:?}"); + assert!(successor.validate_startup(&proof).is_err()); + assert_eq!( + check(&fixture.root).is_ok(), + fail == FinishPoint::CommitStageRetired, + "{fail:?}" + ); + drop(successor); + assert_eq!( + check(&fixture.root).is_ok(), + fail == FinishPoint::CommitStageRetired + ); + } + } + + #[test] + fn unwind_during_retirement_retains_persistent_barrier_and_never_overwrites_replacements( + ) { + for replacement in [false, true] { + let fixture = Fixture::new(); + let (journal, loaded, proof) = prepared(&fixture); + let successor = journal.resume_verified(&loaded, &proof).unwrap(); + let server = OwnedChild::server(); + assert!(catch_unwind(AssertUnwindSafe(|| { + let mut owned = successor; + owned + .finish_inner(&server.proof(&fixture.target()), &mut |point| { + if point == FinishPoint::Retired { + if replacement { + write_private( + &fixture.record(), + b"replacement must survive", + ); + } + panic!("injected retirement unwind"); + } + Ok(()) + }) + .unwrap(); + })) + .is_err()); + assert!(check(&fixture.root).is_err()); + if replacement { + assert_eq!( + fs::read(fixture.record()).unwrap(), + b"replacement must survive" + ); + } + } + } + + #[test] + fn archive_collision_types_and_alien_receipts_are_never_overwritten() { + for kind in 0..6 { + let fixture = Fixture::new(); + let (journal, loaded, proof) = prepared(&fixture); + let successor = journal.resume_verified(&loaded, &proof).unwrap(); + let outside = fixture.temp.join("outside"); + write_private(&outside, b"alien bytes"); + let archive = fixture.root.join(COMPLETED_RECORD); + match kind { + 0 => symlink(&outside, &archive).unwrap(), + 1 => fs::hard_link(&outside, &archive).unwrap(), + 2 => private_dir(&archive), + 3 => write_private(&archive, b"not a completed receipt"), + 4 => { + let name = CString::new(archive.as_os_str().as_bytes()).unwrap(); + assert_eq!(unsafe { libc::mkfifo(name.as_ptr(), 0o600) }, 0); + } + _ => { + write_private(&archive, b"private-looking but wrong mode"); + fs::set_permissions(&archive, fs::Permissions::from_mode(0o644)) + .unwrap(); + } + } + let server = OwnedChild::server(); + assert!(successor.finish(&server.proof(&fixture.target())).is_err()); + assert_eq!(fs::read(outside).unwrap(), b"alien bytes"); + assert!(check(&fixture.root).is_err()); + if kind == 3 { + assert_eq!(fs::read(archive).unwrap(), b"not a completed receipt"); + } + } + } + + #[test] + fn archive_or_canonical_substitution_and_health_loss_at_commit_never_succeed() { + for kind in 0..3 { + let fixture = Fixture::new(); + let (journal, loaded, proof) = prepared(&fixture); + let mut successor = journal.resume_verified(&loaded, &proof).unwrap(); + let mut server = OwnedChild::server(); + let health = server.proof(&fixture.target()); + let outside = fixture.temp.join("outside"); + write_private(&outside, b"untouched"); + assert!(successor + .finish_inner(&health, &mut |point| { + if point == FinishPoint::BeforeRetirement { + match kind { + 0 => { + let archive = fixture.root.join(COMPLETED_RECORD); + fs::rename(&archive, fixture.root.join("saved-completed")) + .unwrap(); + symlink(&outside, &archive).unwrap(); + } + 1 => { + fs::rename( + fixture.record(), + fixture.root.join("saved-attempt"), + ) + .unwrap(); + symlink(&outside, fixture.record()).unwrap(); + } + _ => { + server.0.kill().unwrap(); + server.wait(); + } + } + } + Ok(()) + }) + .is_err()); + assert_eq!(fs::read(outside).unwrap(), b"untouched"); + assert!(check(&fixture.root).is_err()); + } + } + + #[test] + fn root_swap_after_retirement_reports_explicit_uncertainty_without_mutating_new_root() { + let fixture = Fixture::new(); + let (journal, loaded, proof) = prepared(&fixture); + let mut successor = journal.resume_verified(&loaded, &proof).unwrap(); + let server = OwnedChild::server(); + let message = successor + .finish_inner(&server.proof(&fixture.target()), &mut |point| { + if point == FinishPoint::Retired { + fs::rename(&fixture.root, fixture.temp.join("moved-root")).unwrap(); + private_dir(&fixture.root); + } + Ok(()) + }) + .unwrap_err(); + assert!(message.contains("requires recovery")); + drop(successor); + assert_eq!(fs::read_dir(&fixture.root).unwrap().count(), 0); + } + + #[test] + fn copied_successor_pid_is_fenced_before_startup_finish_or_drop_mutation() { + let fixture = Fixture::new(); + let (journal, loaded, proof) = prepared(&fixture); + let mut successor = journal.resume_verified(&loaded, &proof).unwrap(); + drop(journal); + Arc::get_mut(&mut successor.root).unwrap().pid = std::process::id() + 1; + let before = fs::read(fixture.record()).unwrap(); + assert!(successor + .validate_startup(&proof) + .unwrap_err() + .contains("another PID")); + let server = OwnedChild::server(); + assert!(successor.finish(&server.proof(&fixture.target())).is_err()); + assert_eq!(fs::read(fixture.record()).unwrap(), before); + assert!(!fixture.root.join(COMPLETED_RECORD).exists()); + } + + #[test] + fn serialized_state_and_booleans_cannot_satisfy_either_proof_or_clone_a_successor() { + trait NotSuccessorProof { + fn check() {} + } + impl NotSuccessorProof<()> for T {} + impl NotSuccessorProof for T {} + let _ = >::check; + let _ = >::check; + let _ = >::check; + trait NotHealthProof { + fn check() {} + } + impl NotHealthProof<()> for T {} + impl NotHealthProof for T {} + let _ = >::check; + let _ = >::check; + let _ = >::check; + trait NotCloneOrDeserialize { + fn check() {} + } + impl NotCloneOrDeserialize<()> for T {} + impl NotCloneOrDeserialize for T {} + impl NotCloneOrDeserialize for T {} + let _ = >::check; + } + + #[test] + fn subprocess_crashes_preserve_precommit_blockers_and_postcommit_durable_receipts() { + for role in [ + "successor-drop", + "successor-fork", + "crash-archive", + "crash-before-retirement", + "crash-retired", + ] { + let fixture = Fixture::new(); + publish(&fixture, &fixture.target(), "publish"); + let code = spawn_role(&fixture, &fixture.target(), role).wait().code(); + assert_eq!(code, Some(82), "{role}"); + if role == "crash-retired" { + // The old unsafe implementation admitted this absence. + // PreparedSuccess is now a durable, cross-process veto. + assert!(check(&fixture.root).is_err()); + let receipt: CompletedRecord = serde_json::from_slice( + &fs::read(fixture.root.join(COMPLETED_RECORD)).unwrap(), + ) + .unwrap(); + assert_eq!(receipt.attempt.target, fixture.target()); + assert_eq!(receipt.state, CompletionState::PreparedSuccess); + } else { + assert!(check(&fixture.root).is_err(), "{role}"); + } + } + } + + fn wait_for_loaded_owners(loaded: &LoadedAttempt) { + let deadline = Instant::now() + Duration::from_secs(10); + while loaded_owners_gone(loaded).is_err() { + assert!( + Instant::now() < deadline, + "owned fixture processes did not exit" + ); + std::thread::sleep(Duration::from_millis(5)); + } + } + + #[test] + fn fresh_process_blocks_after_retirement_sync_and_restoration_fail_then_recovers_only_with_proof( + ) { + let fixture = Fixture::new(); + publish(&fixture, &fixture.target(), "publish"); + assert_eq!( + spawn_role(&fixture, &fixture.target(), "fail-retirement-unrestorable") + .wait() + .code(), + Some(84) + ); + assert!(!fixture.record().exists()); + // This child has a fresh LaunchGate/address space/resource limit. + // No in-memory latch or Drop from the failed owner survives. + assert_eq!( + spawn_role(&fixture, &fixture.target(), "probe-pending") + .wait() + .code(), + Some(83) + ); + assert!(check(&fixture.root).is_err()); + assert_eq!( + spawn_role(&fixture, &fixture.target(), "recover-drop") + .wait() + .code(), + Some(85) + ); + let restored: Record = + serde_json::from_slice(&fs::read(fixture.record()).unwrap()).unwrap(); + assert_eq!(restored.phase, Phase::AwaitingHealth); + assert!(restored.recovery_owner_pid.is_some()); + assert!(check(&fixture.root).is_err()); + assert_eq!( + spawn_role(&fixture, &fixture.target(), "recover-finish") + .wait() + .code(), + Some(86) + ); + assert!(check(&fixture.root).is_ok()); + assert!(Journal::open(&fixture.root) + .unwrap() + .load() + .unwrap() + .is_none()); + assert_eq!(fs::read_dir(&fixture.root).unwrap().count(), 1); + } + + #[test] + fn pending_recovery_is_read_only_until_proof_and_requires_completion_writer_exit() { + let fixture = Fixture::new(); + publish(&fixture, &fixture.target(), "publish"); + let writer = spawn_role(&fixture, &fixture.target(), "fail-retirement-live"); + let deadline = Instant::now() + Duration::from_secs(10); + let (journal, loaded) = loop { + if !fixture.record().exists() && fixture.root.join(COMPLETED_RECORD).exists() { + if let Ok(journal) = Journal::open(&fixture.root) { + if let Ok(Some(loaded)) = journal.load() { + break (journal, loaded); + } + } + } + assert!(Instant::now() < deadline); + std::thread::sleep(Duration::from_millis(5)); + }; + assert!(loaded.from_completion); + let before = fs::read(fixture.root.join(COMPLETED_RECORD)).unwrap(); + let proof = SuccessorProjection(fixture.target()); + assert!(journal.resume_verified(&loaded, &proof).is_err()); + assert!(journal.begin(fixture.target()).is_err()); + assert!(!fixture.record().exists()); + assert_eq!( + fs::read(fixture.root.join(COMPLETED_RECORD)).unwrap(), + before + ); + drop(writer); + wait_for_loaded_owners(&loaded); + let mut wrong = SuccessorProjection(fixture.target()); + wrong.0.inventory_sha256 = "d".repeat(64); + assert!(journal.resume_verified(&loaded, &wrong).is_err()); + assert!(!fixture.record().exists()); + let successor = journal.resume_verified(&loaded, &proof).unwrap(); + successor.validate_startup(&proof).unwrap(); + assert!(check(&fixture.root).is_err()); + drop(successor); + // The new canonical record names the process that restored it; + // dropping the capability cannot manufacture a second successor. + let restored = journal.load().unwrap().unwrap(); + assert_eq!(restored.record.recovery_owner_pid, Some(std::process::id())); + assert!(journal.resume_verified(&restored, &proof).is_err()); + } + + #[test] + fn strict_committed_reader_rejects_legacy_pending_malformed_aliases_and_staging_orphans( + ) { + for case in 0..12 { + let fixture = Fixture::new(); + publish(&fixture, &fixture.target(), "publish"); + assert_eq!( + spawn_role(&fixture, &fixture.target(), "recover-finish") + .wait() + .code(), + Some(86) + ); + assert!(check(&fixture.root).is_ok()); + let path = fixture.root.join(COMPLETED_RECORD); + let original = fs::read(&path).unwrap(); + let mut value: serde_json::Value = serde_json::from_slice(&original).unwrap(); + match case { + 0 => { + value["schema"] = 1.into(); + value["state"] = "verified_success".into(); + } + 1 => value["state"] = "prepared_success".into(), + 2 => value["state"] = "verified_success".into(), + 3 => value["attempt"]["root_inode"] = 0.into(), + 4 => value["attempt"]["target"]["archive_sha256"] = "not a hash".into(), + 5 => value["unexpected"] = true.into(), + 6 => value["attempt"]["target"]["target_desktop_version"] = "1.0.0".into(), + 7 => { + fs::rename(&path, fixture.root.join("saved-receipt")).unwrap(); + write_private(&path, &original); + } + 8 => fs::hard_link(&path, fixture.temp.join("hardlink")).unwrap(), + 9 => fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap(), + 10 => write_private( + &fixture.root.join(COMPLETION_STAGE), + b"unresolved staging entry", + ), + _ => fs::write(&path, b"{").unwrap(), + } + if case <= 6 { + fs::write(&path, serde_json::to_vec(&value).unwrap()).unwrap(); + } + assert!(check(&fixture.root).is_err(), "case {case}"); + assert!(!fixture.record().exists()); + let journal = Journal::open(&fixture.root).unwrap(); + assert!( + !matches!(journal.load(), Ok(None)), + "unsafe None in case {case}" + ); + assert!(journal.begin(fixture.target()).is_err()); + } + let fixture = Fixture::new(); + write_private( + &fixture.root.join(COMPLETION_STAGE), + b"orphan with no receipt", + ); + assert!(check(&fixture.root).is_err()); + assert!(Journal::open(&fixture.root).unwrap().load().is_err()); + } + + #[test] + #[ignore = "Re-executed only by owned, bounded successor process fixtures"] + fn process_successor_child() { + let root = + PathBuf::from(std::env::var_os("GJC_SUCCESSOR_TEST_ROOT").expect("test root")); + let target: Target = serde_json::from_str( + &std::env::var("GJC_SUCCESSOR_TEST_TARGET").expect("test target"), + ) + .unwrap(); + let role = std::env::var("GJC_SUCCESSOR_TEST_ROLE").expect("test role"); + let journal = Journal::open(&root).unwrap(); + if role == "probe-pending" { + assert!(check(&root).is_err()); + let loaded = journal + .load() + .unwrap() + .expect("pending state must never be None"); + assert!(loaded.from_completion); + assert_eq!(loaded.phase(), Phase::AwaitingHealth); + assert_eq!(loaded.target(), &target); + assert!(!root.join(ATTEMPT_RECORD).exists()); + assert!(journal.begin(target).is_err()); + unsafe { libc::_exit(83) }; + } + if role.starts_with("publish") { + let mut attempt = journal.begin(target.clone()).unwrap(); + if role != "publish-installing" { + attempt + .record_installed(&ProofProjection::for_target(&target)) + .unwrap(); + } + drop(attempt); + drop(journal); + if role == "publish-live" { + let _ = std::io::stdin().read(&mut [0u8; 1]); + } + unsafe { libc::_exit(81) }; + } + let proof = SuccessorProjection(target.clone()); + let loaded = journal.load().unwrap().unwrap(); + wait_for_loaded_owners(&loaded); + let mut successor = journal.resume_verified(&loaded, &proof).unwrap(); + successor.validate_startup(&proof).unwrap(); + if role == "recover-drop" { + assert!(root.join(ATTEMPT_RECORD).exists()); + assert!(check(&root).is_err()); + unsafe { libc::_exit(85) }; + } + if role == "recover-finish" { + let server = OwnedChild::server(); + successor.finish(&server.proof(&target)).unwrap(); + assert!(check(&root).is_ok()); + drop(server); + unsafe { libc::_exit(86) }; + } + if role == "successor-drop" { + unsafe { libc::_exit(82) }; + } + if role == "successor-fork" { + let pid = unsafe { libc::fork() }; + assert!(pid >= 0); + if pid == 0 { + unsafe { libc::_exit(if successor.root.owns_pid() { 1 } else { 0 }) }; + } + let mut status = 0; + assert_eq!(unsafe { libc::waitpid(pid, &mut status, 0) }, pid); + assert!(libc::WIFEXITED(status)); + assert_eq!(libc::WEXITSTATUS(status), 0); + successor.validate_startup(&proof).unwrap(); + unsafe { libc::_exit(82) }; + } + let server = OwnedChild::server(); + if role.starts_with("fail-retirement-") { + assert!(successor + .finish_inner(&server.proof(&target), &mut |point| { + if point == FinishPoint::BeforeRetirementSync { + Err(error("injected retirement fsync failure")) + } else { + Ok(()) + } + }) + .is_err()); + drop(successor); + assert!(!root.join(ATTEMPT_RECORD).exists()); + assert!(check(&root).is_err()); + if role == "fail-retirement-live" { + drop(journal); + let _ = std::io::stdin().read(&mut [0_u8; 1]); + } else { + drop(server); + // Isolated child only: make restoration genuinely fail + // with EMFILE without changing the root or its receipts. + let limit = libc::rlimit { + rlim_cur: 0, + rlim_max: 0, + }; + assert_eq!(unsafe { libc::setrlimit(libc::RLIMIT_NOFILE, &limit) }, 0); + assert_eq!( + open_at( + journal.root.anchor.directory(), + OsStr::new(ATTEMPT_RECORD), + libc::O_RDWR | libc::O_CREAT | libc::O_EXCL | libc::O_NONBLOCK, + 0o600 + ) + .unwrap_err() + .raw_os_error(), + Some(libc::EMFILE) + ); + } + unsafe { libc::_exit(84) }; + } + successor + .finish_inner(&server.proof(&target), &mut |point| { + if matches!( + (role.as_str(), point), + ("crash-archive", FinishPoint::ArchiveDirectorySynced) + | ("crash-before-retirement", FinishPoint::BeforeRetirement) + | ("crash-retired", FinishPoint::Retired) + ) { + unsafe { libc::_exit(82) }; + } + Ok(()) + }) + .unwrap(); + panic!("unknown fixture role"); + } + } + + #[test] + #[ignore = "Re-executed only with a fresh isolated journal fixture by process fault tests"] + fn process_fault_child() { + let root = PathBuf::from( + std::env::var_os("GJC_DURABLE_ATTEMPT_TEST_ROOT").expect("test root"), + ); + let app = + PathBuf::from(std::env::var_os("GJC_DURABLE_ATTEMPT_TEST_APP").expect("test app")); + let scenario = std::env::var("GJC_DURABLE_ATTEMPT_TEST_FAULT").expect("fault scenario"); + if scenario == "duplicate" { + unsafe { libc::_exit(if Journal::open(&root).is_err() { 74 } else { 1 }) }; + } + let journal = Journal::open(&root).unwrap(); + let target = target_for(&app); + let proof = ProofProjection::for_target(&target); + let mut attempt = journal + .begin_inner(target, &mut |point| { + if matches!( + (scenario.as_str(), point), + ("created", SyncPoint::Created) + | ("before-file-sync", SyncPoint::BeforeFileSync) + | ("file-synced", SyncPoint::FileSynced) + | ("directory-synced", SyncPoint::DirectorySynced) + ) { + unsafe { libc::_exit(73) }; + } + Ok(()) + }) + .unwrap(); + if scenario == "live" { + unsafe { libc::_exit(73) }; + } + if scenario == "fork" { + let pid = unsafe { libc::fork() }; + assert!(pid >= 0); + if pid == 0 { + // No allocation, locks, or filesystem access after fork. + unsafe { libc::_exit(if attempt.root.owns_pid() { 1 } else { 0 }) }; + } + let mut status = 0; + assert_eq!(unsafe { libc::waitpid(pid, &mut status, 0) }, pid); + assert!(libc::WIFEXITED(status)); + assert_eq!(libc::WEXITSTATUS(status), 0); + attempt.validate_install_permit().unwrap(); + assert!(Journal::open(&root).is_err()); + unsafe { libc::_exit(75) }; + } + attempt + .installed_inner(&proof, &mut |point| { + if scenario == "truncated" && point == SyncPoint::Truncated { + unsafe { libc::_exit(73) }; + } + Ok(()) + }) + .unwrap(); + assert_eq!(scenario, "installed"); + unsafe { libc::_exit(73) }; + } + } +} + #[cfg(all(test, unix))] mod tests { use super::*; diff --git a/src-tauri/src/updater_backend.rs b/src-tauri/src/updater_backend.rs new file mode 100644 index 00000000..ed4030a3 --- /dev/null +++ b/src-tauri/src/updater_backend.rs @@ -0,0 +1,278 @@ +//! Authenticated backend control transport. No installer or process signals. +use std::{ + io::{Read, Write}, + net::Shutdown, + os::unix::net::UnixStream, + sync::{ + atomic::{AtomicBool, Ordering}, + Mutex, + }, + time::Instant, +}; + +use serde::{Deserialize, Serialize}; + +const MAX_FRAME: usize = 4096; +const MAX_SEQUENCE: u64 = 9_007_199_254_740_991; + +#[derive(Serialize)] +#[serde( + tag = "action", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +pub(crate) enum Control { + Status, + Prepare { + attempt_id: String, + draft_epoch: u64, + remaining_ms: u64, + }, + Commit { + attempt_id: String, + token: String, + }, + Cancel { + attempt_id: String, + }, +} + +#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub(crate) enum State { + Open, + Preparing, + Prepared, + Committed, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct Outcome { + pub ok: bool, + pub state: State, + pub attempt_id: Option, + pub token: Option, + pub expires_in_ms: Option, + pub error: Option, +} + +impl Outcome { + fn valid(&self) -> bool { + self.attempt_id.as_deref().is_none_or(hex_id) + && self.token.as_deref().is_none_or(|value| { + !value.is_empty() + && value.len() <= 256 + && value.as_bytes()[0].is_ascii_alphanumeric() + && value + .bytes() + .all(|c| c.is_ascii_alphanumeric() || b"._:-".contains(&c)) + }) + && self + .expires_in_ms + .is_none_or(|value| value > 0 && value <= 10_000) + && self.error.as_deref().is_none_or(|value| { + !value.is_empty() + && value.len() <= 64 + && value.bytes().all(|c| c.is_ascii_lowercase() || c == b'_') + }) + && self.ok == self.error.is_none() + } +} + +pub(crate) fn hex_id(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c)) +} + +struct Connection { + stream: UnixStream, + sequence: u64, +} +pub(crate) struct Backend { + epoch: String, + connection: Mutex, + shutdown: UnixStream, + retired: AtomicBool, +} + +impl Backend { + /// The bridge calls this only AFTER endpoint proof, kernel peer-PID and + /// exact initialization-secret/epoch authentication. + pub(crate) fn new(stream: UnixStream, epoch: String) -> Result { + if !hex_id(&epoch) { + return Err("updater_backend_invalid"); + } + stream + .set_nonblocking(false) + .map_err(|_| "updater_backend_unavailable")?; + let shutdown = stream + .try_clone() + .map_err(|_| "updater_backend_unavailable")?; + Ok(Self { + epoch, + connection: Mutex::new(Connection { + stream, + sequence: 0, + }), + shutdown, + retired: AtomicBool::new(false), + }) + } + pub(crate) fn available(&self) -> bool { + !self.retired.load(Ordering::Acquire) + } + pub(crate) fn retire(&self) { + self.retired.store(true, Ordering::Release); + // No mutex: retiring a run on the GUI thread must unblock a concurrent + // control read rather than waiting behind its deadline. + let _ = self.shutdown.shutdown(Shutdown::Both); + } + pub(crate) fn request( + &self, + command: Control, + deadline: Instant, + ) -> Result { + let result = self.request_inner(command, deadline); + if result.is_err() { + self.retire(); + } + result + } + fn request_inner(&self, command: Control, deadline: Instant) -> Result { + let mut connection = self + .connection + .lock() + .map_err(|_| "updater_backend_unavailable")?; + if !self.available() || Instant::now() >= deadline || connection.sequence >= MAX_SEQUENCE { + return Err("updater_backend_unavailable"); + } + connection.sequence += 1; + let id = connection.sequence; + let frame = serde_json::json!({ "protocolVersion": 1, "kind": "restartControl", "epoch": self.epoch, "id": id, "command": command }); + let mut bytes = serde_json::to_vec(&frame).map_err(|_| "updater_backend_invalid")?; + bytes.push(b'\n'); + if bytes.len() > MAX_FRAME { + return Err("updater_backend_invalid"); + } + connection + .stream + .set_write_timeout(Some(deadline.saturating_duration_since(Instant::now()))) + .map_err(|_| "updater_backend_unavailable")?; + connection + .stream + .write_all(&bytes) + .map_err(|_| "updater_backend_unavailable")?; + let frame = read_frame(&mut connection.stream, deadline)?; + let object = frame.as_object().ok_or("updater_backend_invalid")?; + if object.len() != 5 + || frame["protocolVersion"] != 1 + || frame["kind"] != "restartControlResult" + || frame["epoch"] != self.epoch + || frame["id"].as_u64() != Some(id) + { + return Err("updater_backend_invalid"); + } + let result = frame["result"] + .as_object() + .ok_or("updater_backend_invalid")?; + if result.len() != 6 + || ["ok", "state", "attemptId", "token", "expiresInMs", "error"] + .iter() + .any(|key| !result.contains_key(*key)) + { + return Err("updater_backend_invalid"); + } + let outcome: Outcome = serde_json::from_value(frame["result"].clone()) + .map_err(|_| "updater_backend_invalid")?; + if !outcome.valid() || !self.available() || Instant::now() >= deadline { + return Err("updater_backend_invalid"); + } + Ok(outcome) + } +} + +fn read_frame( + stream: &mut UnixStream, + deadline: Instant, +) -> Result { + let mut bytes = Vec::new(); + let mut chunk = [0u8; 1024]; + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err("updater_backend_timeout"); + } + stream + .set_read_timeout(Some(remaining)) + .map_err(|_| "updater_backend_unavailable")?; + let count = stream + .read(&mut chunk) + .map_err(|_| "updater_backend_unavailable")?; + if count == 0 || bytes.len() + count > MAX_FRAME { + return Err("updater_backend_invalid"); + } + bytes.extend_from_slice(&chunk[..count]); + if let Some(newline) = bytes.iter().position(|byte| *byte == b'\n') { + if newline != bytes.len() - 1 { + return Err("updater_backend_invalid"); + } + return serde_json::from_slice(&bytes[..newline]) + .map_err(|_| "updater_backend_invalid"); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + + #[test] + fn exact_correlated_exchange_and_retirement() { + let (native, mut node) = UnixStream::pair().unwrap(); + let backend = Backend::new(native, "a".repeat(64)).unwrap(); + let peer = std::thread::spawn(move || { + let request = read_frame(&mut node, Instant::now() + Duration::from_secs(1)).unwrap(); + assert_eq!(request["command"], serde_json::json!({"action":"status"})); + let reply = serde_json::json!({"protocolVersion":1,"kind":"restartControlResult","epoch":request["epoch"],"id":request["id"], + "result":{"ok":true,"state":"open","attemptId":null,"token":null,"expiresInMs":null,"error":null}}); + writeln!(node, "{reply}").unwrap(); + }); + assert_eq!( + backend + .request(Control::Status, Instant::now() + Duration::from_secs(1)) + .unwrap() + .state, + State::Open + ); + backend.retire(); + assert!(backend + .request(Control::Status, Instant::now() + Duration::from_secs(1)) + .is_err()); + peer.join().unwrap(); + } + + #[test] + fn forged_or_incomplete_results_retire_the_channel() { + for malformed in [ + serde_json::json!({}), + serde_json::json!({"ok":true,"state":"open","attemptId":null,"token":null,"expiresInMs":null}), + ] { + let (native, mut node) = UnixStream::pair().unwrap(); + let backend = Backend::new(native, "b".repeat(64)).unwrap(); + let peer = std::thread::spawn(move || { + let request = + read_frame(&mut node, Instant::now() + Duration::from_secs(1)).unwrap(); + writeln!(node, "{}", serde_json::json!({"protocolVersion":1,"kind":"restartControlResult","epoch":request["epoch"],"id":request["id"],"result":malformed})).unwrap(); + }); + assert!(backend + .request(Control::Status, Instant::now() + Duration::from_secs(1)) + .is_err()); + assert!(!backend.available()); + peer.join().unwrap(); + } + } +} diff --git a/src-tauri/src/updater_binding.rs b/src-tauri/src/updater_binding.rs index 36127e48..156cccb9 100644 --- a/src-tauri/src/updater_binding.rs +++ b/src-tauri/src/updater_binding.rs @@ -46,6 +46,38 @@ impl Binding { } } + /// A compiled updater QA executable must never fall back to the real HOME + /// or WebKit store when launched by Finder or a UI automation tool without + /// its arguments. This check precedes profile creation and every webview. + pub(crate) fn validate_launch_profile(&self, requested: Option<&Path>) -> Result<(), String> { + if self.mode == Mode::Qa && (requested.is_none() || requested != self.qa_root.as_deref()) { + return Err("Updater QA builds require their exact compiled --qa-profile.".into()); + } + Ok(()) + } + + /// The official plugin deserializes its Config BEFORE Builder::pubkey can + /// override it. Supply the public-only config in the native context, while + /// preserving disabled/unbound modes and all remote IPC restrictions. + pub(crate) fn configure_plugin( + &self, + config: &mut tauri::Config, + qa_profile: Option<&Path>, + release_arm64: bool, + ) { + if !self.admits_profile(qa_profile, release_arm64) { + return; + } + config.plugins.0.insert( + "updater".into(), + serde_json::json!({ + "pubkey": self.public_key, + "endpoints": [], + "dangerousInsecureTransportProtocol": false, + }), + ); + } + pub fn validate_runtime( &self, qa_profile: Option<&Path>, @@ -122,6 +154,53 @@ mod tests { use super::*; use std::os::unix::fs::PermissionsExt; + #[test] + fn official_plugin_configuration_is_supplied_only_for_admitted_profiles() { + let root = Path::new("/qa-profile"); + let mut config = tauri::Config::default(); + let mut binding = Binding { + mode: Mode::Disabled, + feed_origin: String::new(), + public_key: "public-test-key".into(), + qa_root: None, + }; + binding.configure_plugin(&mut config, None, true); + assert!(!config.plugins.0.contains_key("updater")); + binding.mode = Mode::Qa; + binding.qa_root = Some(root.into()); + binding.configure_plugin(&mut config, None, true); + assert!(!config.plugins.0.contains_key("updater")); + binding.configure_plugin(&mut config, Some(root), false); + let decoded: tauri_plugin_updater::Config = + serde_json::from_value(config.plugins.0["updater"].clone()).unwrap(); + assert_eq!(decoded.pubkey, "public-test-key"); + assert!(decoded.endpoints.is_empty()); + assert!(!decoded.dangerous_insecure_transport_protocol); + assert!( + serde_json::from_value::(serde_json::json!({})).is_err(), + "Builder's key override cannot repair missing Config.pubkey during deserialization" + ); + } + + #[test] + fn qa_executable_cannot_launch_with_missing_or_foreign_profile_before_any_io() { + let root = Path::new("/uncreated-qa-profile"); + let mut binding = Binding { + mode: Mode::Qa, + feed_origin: String::new(), + public_key: String::new(), + qa_root: Some(root.into()), + }; + assert!(binding.validate_launch_profile(None).is_err()); + assert!(binding + .validate_launch_profile(Some(Path::new("/foreign"))) + .is_err()); + assert!(binding.validate_launch_profile(Some(root)).is_ok()); + binding.mode = Mode::Disabled; + assert!(binding.validate_launch_profile(None).is_ok()); + assert!(binding.validate_launch_profile(Some(root)).is_ok()); + } + #[test] fn disabled_development_and_unbound_qa_are_inert_before_io() { let absent = Path::new("/does-not-exist/updater-tests"); diff --git a/src-tauri/src/updater_bridge.js b/src-tauri/src/updater_bridge.js new file mode 100644 index 00000000..7117f65d --- /dev/null +++ b/src-tauri/src/updater_bridge.js @@ -0,0 +1,136 @@ +function installDesktopUpdateBridge(token, origin, qaDiagnostics = false) { + const name = '__GJC_DESKTOP_UPDATE__'; + const readyEvent = 'gajae:desktop-update-ready'; + let retired = false; + let draftRegistration; + let restartPromise; + const outcomeEvent = `gajae:desktop-restart:${token}`; + const id = (value) => typeof value === 'string' && value.length === 64 && /^[a-f0-9]+$/.test(value); + const ensureCurrent = () => { + if (retired || location.origin !== origin || window[name] !== bridge) { + throw Error('updater_unauthorized'); + } + }; + const rpc = async (command) => { + ensureCurrent(); + const response = await fetch('/api/desktop/update', { + method: 'POST', credentials: 'same-origin', + headers: { 'Content-Type': 'application/json', 'X-Gajae-Update-View': token }, + body: JSON.stringify(command), + }); + const data = await response.json(); + ensureCurrent(); + if (!response.ok) throw Error(data.error || 'updater_unavailable'); + return data; + }; + const sameAttempt = (reply, request) => reply && reply.attemptId === request.token && reply.draftEpoch === request.epoch; + const draftFailure = (error) => { + // Surface a bounded diagnostic, never draft contents, paths or arbitrary + // exception messages. A confirmed cancellation must not hide its cause. + if (error?.name === 'ComposerFreezeError' && ['busy', 'changed', 'cancelled', 'timeout', 'stale', 'invalid', 'sealed'].includes(error.reason)) { + return `updater_draft_${error.reason}`; + } + if (error?.name === 'ComposerStorageError' && ['unavailable', 'quota', 'limit', 'invalid', 'conflict', 'timeout', 'storage'].includes(error.reason)) { + return `updater_draft_storage_${error.reason}`; + } + const browserErrors = { TypeError: 'type', ReferenceError: 'reference', SecurityError: 'security', + DataCloneError: 'data_clone', NotReadableError: 'not_readable', NotFoundError: 'not_found', UnknownError: 'unknown', + AbortError: 'abort', InvalidStateError: 'invalid_state' }; + if (Object.prototype.hasOwnProperty.call(browserErrors, error?.name)) return `updater_draft_browser_${browserErrors[error.name]}`; + return error?.message === 'updater_draft_changed' ? 'updater_draft_changed' : 'updater_draft_prepare_failed'; + }; + const onAborted = (event) => { + try { + ensureCurrent(); + const reply = event.detail; + if (reply?.kind !== 'restartAborted' || !id(reply.attemptId) || !Number.isSafeInteger(reply.draftEpoch) || reply.draftEpoch < 1) return; + draftRegistration?.owner.cancel({ token: reply.attemptId, epoch: reply.draftEpoch }); + } catch { /* A retired/incompatible owner gains no editing or install authority. */ } + }; + const restart = async (command) => { + const registration = draftRegistration; + if (!registration) throw Error('updater_draft_owner_unavailable'); + const challenge = await rpc(command); + if (challenge?.kind !== 'restartChallenge') return challenge; + if (challenge.protocolVersion !== 1 || !id(challenge.attemptId) + || !Number.isSafeInteger(challenge.draftEpoch) || challenge.draftEpoch < 1 + || !Number.isSafeInteger(challenge.ttlMs) || challenge.ttlMs < 1 || challenge.ttlMs > 5000) throw Error('updater_protocol_error'); + const request = { token: challenge.attemptId, epoch: challenge.draftEpoch, ttlMs: challenge.ttlMs }; + let prepared = false; + try { + const receipt = await registration.owner.prepare(request); + ensureCurrent(); + if (draftRegistration !== registration || !registration.owner.isCurrent(receipt) + || !registration.owner.seal(receipt)) throw Error('updater_draft_changed'); + prepared = true; + const reply = await rpc({ action: 'restartPrepared', attemptId: request.token, draftEpoch: request.epoch }); + if (reply?.kind) { + if (!sameAttempt(reply, request)) throw Error('updater_protocol_error'); + if (reply.kind === 'restartAborted') registration.owner.cancel(request); + else if (reply.kind !== 'restartUncertain') throw Error('updater_protocol_error'); + return reply.snapshot; + } + return reply; + } catch (error) { + if (qaDiagnostics) console.warn('[updater-qa] restart preparation failed', error); + // Native owns the transaction after the sealed ACK is sent. Its expected + // Applying navigation can abort this document's fetch before pagehide; + // cancelling here would race and cancel our own successful handoff. + // Lost replies retain the seal until native confirms abort (or replaces + // the document). Native deadlines also cover an ACK that never arrived. + if (prepared) throw error; + // A timeout/lost response is not cancellation: only native's exact + // confirmed aborted reply/event may release the sealed page. + try { + const reply = await rpc({ action: 'restartCancel', attemptId: request.token, draftEpoch: request.epoch }); + if (sameAttempt(reply, request) && reply.kind === 'restartAborted') { + registration.owner.cancel(request); + return { ...reply.snapshot, reason: draftFailure(error) }; + } + if (sameAttempt(reply, request) && reply.kind === 'restartUncertain') return reply.snapshot; + } catch { /* Native's rollback event may still release the page later. */ } + throw error; + } + }; + const request = (command) => { + try { + ensureCurrent(); + if (!command || typeof command !== 'object' || Array.isArray(command) + || (command.action === 'setAutomatic' ? Object.keys(command).length !== 2 || typeof command.automatic !== 'boolean' + : ['download', 'restart'].includes(command.action) ? Object.keys(command).length !== 2 || !id(command.targetId) + : Object.keys(command).length !== 1 || !['status', 'check'].includes(command.action))) { + return Promise.reject(Error('updater_invalid_command')); + } + if (command.action !== 'restart') return rpc(command); + if (!restartPromise) restartPromise = restart({ ...command }).finally(() => { restartPromise = undefined; }); + return restartPromise; + } catch (error) { return Promise.reject(error); } + }; + // Registration stores an in-page owner, not a durability acknowledgement. + // No input is frozen and no restart is requested by registering this owner. + const registerDraftOwner = (owner) => { + ensureCurrent(); + if (!owner || typeof owner.prepare !== 'function' || typeof owner.isCurrent !== 'function' + || typeof owner.seal !== 'function' || typeof owner.cancel !== 'function') throw Error('updater_invalid_draft_owner'); + if (draftRegistration) throw Error('updater_draft_owner_conflict'); + const registration = { owner }; + draftRegistration = registration; + return () => { + if (draftRegistration === registration) draftRegistration = undefined; + }; + }; + const bridge = Object.freeze({ protocolVersion: 1, request, registerDraftOwner }); + const retire = () => { + retired = true; + draftRegistration = undefined; + window.removeEventListener(readyEvent, replaced); + window.removeEventListener('pagehide', retire); + window.removeEventListener(outcomeEvent, onAborted); + }; + const replaced = () => { if (window[name] !== bridge) retire(); }; + window.addEventListener(readyEvent, replaced); + window.addEventListener('pagehide', retire); + window.addEventListener(outcomeEvent, onAborted); + Object.defineProperty(window, name, { configurable: true, value: bridge }); + window.dispatchEvent(new Event(readyEvent)); +} diff --git a/src-tauri/src/updater_bridge.rs b/src-tauri/src/updater_bridge.rs new file mode 100644 index 00000000..8fe8c6eb --- /dev/null +++ b/src-tauri/src/updater_bridge.rs @@ -0,0 +1,1188 @@ +//! Main-view-bound updater bridge. Target-bound user requests enter the existing +//! guarded preparation and draft/backend-safe restart owners. +//! A dedicated owner-only socket keeps control data out of mixed child logs. +//! Its secret is written once to the owned child's stdin, never to its env. +use std::{ + fs, + io::{Read, Write}, + os::fd::AsRawFd, + os::unix::{ + ffi::OsStrExt, + fs::{DirBuilderExt, MetadataExt, PermissionsExt}, + net::{UnixListener, UnixStream}, + }, + path::PathBuf, + sync::{ + atomic::{AtomicBool, AtomicUsize, Ordering}, + Arc, Mutex, OnceLock, + }, + time::{Duration, Instant}, +}; + +use hmac::{Hmac, Mac}; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use tauri::{AppHandle, Manager}; + +use crate::updater_backend::Backend; +use crate::updater_binding::{Binding, Mode}; + +const MAX_REQUEST: usize = 4096; +const MAX_RESPONSE: usize = 32 * 1024; +const MAX_PENDING: usize = 4; +const DEADLINE: Duration = Duration::from_secs(2); + +#[derive(Deserialize)] +#[serde(tag = "action", deny_unknown_fields)] +enum Command { + #[serde(rename = "status")] + Status {}, + #[serde(rename = "check")] + Check {}, + #[serde(rename = "download")] + Download { + #[serde(rename = "targetId")] + target_id: String, + }, + #[serde(rename = "setAutomatic")] + SetAutomatic { automatic: bool }, + #[serde(rename = "restart")] + Restart { + #[serde(rename = "targetId")] + target_id: String, + }, + #[serde(rename = "restartPrepared")] + RestartPrepared { + #[serde(rename = "attemptId")] + attempt_id: String, + #[serde(rename = "draftEpoch")] + draft_epoch: u64, + }, + #[serde(rename = "restartCancel")] + RestartCancel { + #[serde(rename = "attemptId")] + attempt_id: String, + #[serde(rename = "draftEpoch")] + draft_epoch: u64, + }, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Request { + protocol_version: u8, + secret: String, + epoch: String, + pid: u32, + sequence: u64, + view: String, + origin: String, + command: Command, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Challenge { + protocol_version: u8, + kind: String, + epoch: String, + pid: u32, + nonce: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct BackendAttach { + protocol_version: u8, + kind: String, + secret: String, + epoch: String, + pid: u32, +} + +fn server_proof(secret: &str, epoch: &str, nonce: &str) -> String { + use std::fmt::Write; + let mut mac = Hmac::::new_from_slice(secret.as_bytes()).expect("HMAC key"); + mac.update(format!("gajae-native-update-v1\0{epoch}\0{nonce}").as_bytes()); + let mut encoded = String::with_capacity(64); + for byte in mac.finalize().into_bytes() { + write!(&mut encoded, "{byte:02x}").expect("string writing"); + } + encoded +} + +#[derive(Default)] +struct ReplayWindow { + latest: u64, + seen: u64, +} +impl ReplayWindow { + fn accept(&mut self, sequence: u64) -> bool { + if sequence == 0 || sequence > 9_007_199_254_740_991 { + return false; + } + if sequence > self.latest { + let difference = sequence - self.latest; + self.seen = if difference >= 64 { + 0 + } else { + self.seen << difference + }; + self.latest = sequence; + } + let difference = self.latest - sequence; + if difference >= 64 || self.seen & (1 << difference) != 0 { + return false; + } + self.seen |= 1 << difference; + true + } +} + +struct View { + token: String, + origin: String, +} +struct Authority { + active: bool, + secret: String, + epoch: String, + pid: u32, + view: Option, + replay: ReplayWindow, + last_mutation_sequence: u64, +} +impl Authority { + fn admit(&mut self, request: &Request, peer: u32) -> bool { + let mutating = !matches!(request.command, Command::Status {}); + let admitted = self.active + && request.protocol_version == 1 + && peer == self.pid + && request.pid == self.pid + && equal_secret(&request.secret, &self.secret) + && equal_secret(&request.epoch, &self.epoch) + && self.view.as_ref().is_some_and(|view| { + request.origin == view.origin && equal_secret(&request.view, &view.token) + }) + && (!mutating || request.sequence > self.last_mutation_sequence) + && self.replay.accept(request.sequence); + if admitted && mutating { + self.last_mutation_sequence = request.sequence; + } + admitted + } + + fn challenge(&self, challenge: &Challenge, peer: u32) -> Option { + (self.active + && challenge.protocol_version == 1 + && challenge.kind == "challenge" + && peer == self.pid + && challenge.pid == self.pid + && equal_secret(&challenge.epoch, &self.epoch) + && challenge.nonce.len() == 64 + && challenge + .nonce + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))) + .then(|| server_proof(&self.secret, &self.epoch, &challenge.nonce)) + } +} + +fn equal_secret(left: &str, right: &str) -> bool { + if left.len() != 64 || right.len() != 64 { + return false; + } + left.bytes() + .zip(right.bytes()) + .fold(0u8, |difference, (a, b)| difference | (a ^ b)) + == 0 +} + +fn secret() -> Result { + use std::fmt::Write; + let mut bytes = [0u8; 32]; + getrandom::getrandom(&mut bytes).map_err(|_| "updater_bridge_unavailable")?; + let mut value = String::with_capacity(64); + for byte in bytes { + write!(&mut value, "{byte:02x}").expect("string writing"); + } + Ok(value) +} + +struct Run { + authority: Mutex, + retired: AtomicBool, + pending: AtomicUsize, + socket: PathBuf, + backend_claimed: AtomicBool, + backend: OnceLock>, +} + +impl Run { + fn retire(&self) { + // Same short lock as admission. It is never held across preference I/O. + if let Ok(mut authority) = self.authority.lock() { + authority.active = false; + authority.view = None; + } + self.retired.store(true, Ordering::Release); + if let Some(backend) = self.backend.get() { + backend.retire(); + } + } + + fn attach_backend(&self, stream: &mut UnixStream, value: BackendAttach, peer: u32) { + let admitted = self.authority.lock().is_ok_and(|authority| { + authority.active + && value.protocol_version == 1 + && value.kind == "backendAttach" + && peer == authority.pid + && value.pid == authority.pid + && equal_secret(&value.epoch, &authority.epoch) + && equal_secret(&value.secret, &authority.secret) + }); + if !admitted + || self.retired.load(Ordering::Acquire) + || self.backend_claimed.swap(true, Ordering::AcqRel) + { + return; + } + let Ok(clone) = stream.try_clone() else { + return; + }; + let Ok(backend) = Backend::new(clone, value.epoch.clone()) else { + return; + }; + let backend = Arc::new(backend); + // Publish the channel only after its acknowledgement is on the wire, + // so a concurrent UI request cannot send control before backendAttached. + if !write_response( + stream, + &serde_json::json!({"protocolVersion":1,"kind":"backendAttached","epoch":value.epoch}), + ) { + backend.retire(); + return; + } + let _ = self.backend.set(backend.clone()); + if self.retired.load(Ordering::Acquire) { + backend.retire(); + } + } +} + +#[derive(Default)] +pub(crate) struct Bridge(Mutex>>); + +pub(crate) fn available(app: &AppHandle) -> bool { + app.try_state::().is_some_and(|bridge| { + bridge.0.lock().is_ok_and(|slot| { + slot.as_ref() + .is_some_and(|run| !run.retired.load(Ordering::Acquire)) + }) + }) +} + +pub(crate) fn enabled(app: &AppHandle) -> bool { + let binding = Binding::compiled(); + let profile = app.try_state::(); + binding.mode != Mode::Disabled + && cfg!(target_arch = "aarch64") + && binding.admits_profile(profile.as_ref().map(|p| p.root()), !cfg!(debug_assertions)) +} + +/// Caller writes the small initialization frame to fresh, otherwise-unused +/// owned stdin. Later requests use the bounded socket, not blocking pipe writes. +pub(crate) fn attach(app: &AppHandle, pid: u32) -> Result>, String> { + if !enabled(app) { + return Ok(None); + } + let binding = Binding::compiled(); + let profile = app.try_state::(); + let root = crate::supervisor::desktop_data_root(app)?; + binding.validate_runtime( + profile.as_ref().map(|p| p.root()), + &std::env::current_exe().map_err(|_| "updater_bridge_unavailable")?, + &root, + !cfg!(debug_assertions), + )?; + let key = secret()?; + let epoch = secret()?; + let directory = std::env::temp_dir() + .canonicalize() + .map_err(|_| "updater_bridge_unavailable")? + .join(format!("gju-{}", &epoch[..16])); + let socket = directory.join("rpc"); + if socket.as_os_str().as_bytes().len() >= 100 { + return Err("updater_bridge_unavailable".into()); + } + fs::DirBuilder::new() + .mode(0o700) + .create(&directory) + .map_err(|_| "updater_bridge_unavailable")?; + let listener = UnixListener::bind(&socket).map_err(|_| "updater_bridge_unavailable")?; + fs::set_permissions(&socket, fs::Permissions::from_mode(0o600)) + .map_err(|_| "updater_bridge_unavailable")?; + listener + .set_nonblocking(true) + .map_err(|_| "updater_bridge_unavailable")?; + let inode = fs::symlink_metadata(&socket) + .map_err(|_| "updater_bridge_unavailable")? + .ino(); + let run = Arc::new(Run { + authority: Mutex::new(Authority { + active: true, + secret: key.clone(), + epoch: epoch.clone(), + pid, + view: None, + replay: ReplayWindow::default(), + last_mutation_sequence: 0, + }), + retired: AtomicBool::new(false), + pending: AtomicUsize::new(0), + socket, + backend_claimed: AtomicBool::new(false), + backend: OnceLock::new(), + }); + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + struct Init<'a> { + protocol_version: u8, + socket: &'a std::path::Path, + secret: &'a str, + epoch: &'a str, + } + let frame = format!( + "GJC_DESKTOP_UPDATE_INIT {}\n", + serde_json::to_string(&Init { + protocol_version: 1, + socket: &run.socket, + secret: &key, + epoch: &epoch + }) + .map_err(|_| "updater_bridge_unavailable")? + ) + .into_bytes(); + if frame.len() > 1024 { + return Err("updater_bridge_unavailable".into()); + } + let managed = app.state::(); + if let Some(old) = managed + .0 + .lock() + .map_err(|_| "updater_bridge_unavailable")? + .replace(run.clone()) + { + old.retire(); + } + let app = app.clone(); + std::thread::spawn(move || { + while !run.retired.load(Ordering::Acquire) { + match listener.accept() { + Ok((stream, _)) => { + if run + .pending + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |n| { + (n < MAX_PENDING).then_some(n + 1) + }) + .is_err() + { + continue; + } + let run = run.clone(); + let app = app.clone(); + std::thread::spawn(move || { + serve(stream, &run, &app); + run.pending.fetch_sub(1, Ordering::AcqRel); + }); + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(25)) + } + Err(_) => break, + } + } + drop(listener); + // Remove only this socket inode and its now-empty private directory. + if fs::symlink_metadata(&run.socket).is_ok_and(|m| m.ino() == inode) { + let _ = fs::remove_file(&run.socket); + } + let _ = fs::remove_dir(directory); + }); + Ok(Some(frame)) +} + +pub(crate) fn retire(app: &AppHandle) { + if let Some(bridge) = app.try_state::() { + if let Ok(mut slot) = bridge.0.lock() { + if let Some(run) = slot.take() { + run.retire(); + } + } + } +} + +fn read_frame(stream: &mut UnixStream, deadline: Instant) -> Result { + let mut bytes = Vec::new(); + let mut chunk = [0u8; 1024]; + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err(()); + } + stream.set_read_timeout(Some(remaining)).map_err(|_| ())?; + let count = stream.read(&mut chunk).map_err(|_| ())?; + if count == 0 || bytes.len() + count > MAX_REQUEST { + return Err(()); + } + bytes.extend_from_slice(&chunk[..count]); + if let Some(newline) = bytes.iter().position(|b| *b == b'\n') { + if newline != bytes.len() - 1 { + return Err(()); + } + return serde_json::from_slice(&bytes[..newline]).map_err(|_| ()); + } + } +} + +fn peer_pid(stream: &UnixStream) -> Option { + let mut pid: libc::pid_t = 0; + let mut size = std::mem::size_of::() as libc::socklen_t; + // macOS binds this to the connecting process; descendants cannot simply + // claim the server pid in JSON, even if they obtained a copied secret. + let result = unsafe { + libc::getsockopt( + stream.as_raw_fd(), + libc::SOL_LOCAL, + libc::LOCAL_PEERPID, + (&mut pid as *mut libc::pid_t).cast(), + &mut size, + ) + }; + (result == 0 && size as usize == std::mem::size_of::() && pid > 0) + .then_some(pid as u32) +} + +fn serve(stream: UnixStream, run: &Arc, app: &AppHandle) { + serve_protocol(stream, run, |request, peer| { + // Authority is checked after acquiring the coordinator's operation lock. + let admit = || { + !app.state::() + .is_shutting_down() + && run + .authority + .lock() + .is_ok_and(|mut authority| authority.admit(request, peer)) + }; + let updater = app.state::(); + let restarts = app.state::(); + match &request.command { + Command::Status {} => updater.snapshot(admit).map(|state| { + crate::updater_restart::Reply::Snapshot(restarts.decorate( + app, + run.backend.get(), + state, + )) + }), + Command::Check {} => { + if crate::updater_restart::blocks_start(app) { + return Err("updater_busy"); + } + updater.manual_check(admit).map(|state| { + crate::updater_restart::Reply::Snapshot(restarts.decorate( + app, + run.backend.get(), + state, + )) + }) + } + Command::SetAutomatic { automatic } => { + if crate::updater_restart::blocks_start(app) { + return Err("updater_busy"); + } + updater.set_automatic(*automatic, admit).map(|state| { + crate::updater_restart::Reply::Snapshot(restarts.decorate( + app, + run.backend.get(), + state, + )) + }) + } + Command::Download { target_id } => { + if crate::updater_restart::blocks_start(app) { + return Err("updater_busy"); + } + updater.manual_download(target_id, admit).map(|state| { + crate::updater_restart::Reply::Snapshot(restarts.decorate( + app, + run.backend.get(), + state, + )) + }) + } + Command::Restart { target_id } => { + if !admit() { + return Err("updater_unauthorized"); + } + restarts.begin(app, restart_context(app, run, request, target_id)?) + } + Command::RestartPrepared { + attempt_id, + draft_epoch, + } => { + if !admit() + || !crate::updater_backend::hex_id(attempt_id) + || *draft_epoch == 0 + || *draft_epoch > 9_007_199_254_740_991 + { + return Err("updater_unauthorized"); + } + restarts.prepared(app, attempt_id, *draft_epoch) + } + Command::RestartCancel { + attempt_id, + draft_epoch, + } => { + if !admit() + || !crate::updater_backend::hex_id(attempt_id) + || *draft_epoch == 0 + || *draft_epoch > 9_007_199_254_740_991 + { + return Err("updater_unauthorized"); + } + restarts.cancel(app, attempt_id, *draft_epoch) + } + } + }); +} + +fn restart_context( + app: &AppHandle, + run: &Arc, + request: &Request, + target_id: &str, +) -> Result { + if !crate::updater_backend::hex_id(target_id) { + return Err("updater_target_changed"); + } + let backend = run + .backend + .get() + .filter(|backend| backend.available()) + .cloned() + .ok_or("updater_backend_unavailable")?; + let window = app + .get_webview_window("main") + .ok_or("updater_unavailable")?; + let return_url = window.url().map_err(|_| "updater_unavailable")?; + if return_url.origin().ascii_serialization() != request.origin || !spa_page(&return_url) { + return Err("updater_unauthorized"); + } + let view = request.view.clone(); + let epoch = request.epoch.clone(); + let pid = request.pid; + let original = run.clone(); + let handle = app.clone(); + let current = Arc::new(move || { + !original.retired.load(Ordering::Acquire) + && handle + .state::() + .owns_pid(pid) + && !handle + .state::() + .is_shutting_down() + && original.authority.lock().is_ok_and(|authority| { + authority.active + && authority.epoch == epoch + && authority + .view + .as_ref() + .is_some_and(|current| equal_secret(¤t.token, &view)) + }) + }); + let original = run.clone(); + let handle = app.clone(); + let epoch = request.epoch.clone(); + let same_run = Arc::new(move || { + !original.retired.load(Ordering::Acquire) + && handle + .state::() + .owns_pid(pid) + && !handle + .state::() + .is_shutting_down() + && original + .authority + .lock() + .is_ok_and(|authority| authority.active && authority.epoch == epoch) + }); + Ok(crate::updater_restart::Context { + target_id: target_id.to_owned(), + backend, + server_pid: pid, + return_url, + current, + same_run, + }) +} + +fn spa_page(url: &tauri::Url) -> bool { + url.scheme() == "http" + && url.host_str() == Some("127.0.0.1") + && url.username().is_empty() + && url.password().is_none() + && !url.path().starts_with("/api/") + && !url.path().starts_with("/desktop/") + && !url.path().starts_with("/assets/") + && !url.path().ends_with(".html") + && !url.path().ends_with(".svg") +} + +/// A precommit owner sends a confirmed rollback directly to the currently bound +/// view even when its HTTP waiter disconnected. The event name is the private +/// current-view capability, not a cookie or public global property. +pub(crate) fn notify_restart_aborted(app: &AppHandle, attempt_id: &str, epoch: u64) { + crate::resume_deep_links(app); + let Some(run) = app + .try_state::() + .and_then(|bridge| bridge.0.lock().ok().and_then(|run| run.clone())) + else { + return; + }; + let Some((token, origin)) = run.authority.lock().ok().and_then(|authority| { + authority + .view + .as_ref() + .map(|view| (view.token.clone(), view.origin.clone())) + }) else { + return; + }; + let Some(window) = app.get_webview_window("main") else { + return; + }; + if !window + .url() + .is_ok_and(|url| spa_page(&url) && url.origin().ascii_serialization() == origin) + { + return; + } + let event = + serde_json::to_string(&format!("gajae:desktop-restart:{token}")).expect("event name"); + let detail = + serde_json::json!({"kind":"restartAborted","attemptId":attempt_id,"draftEpoch":epoch}); + let _ = window.eval(format!( + "window.dispatchEvent(new CustomEvent({event},{{detail:{detail}}}));" + )); +} + +fn serve_protocol( + mut stream: UnixStream, + run: &Run, + execute: impl FnOnce(&Request, u32) -> Result, +) { + // BSD accepted sockets can retain the listener's nonblocking flag. A read + // timeout does not clear O_NONBLOCK: frame 2 then spuriously fails before + // the authenticated Node peer has time to send it. Only this accepted + // stream becomes blocking; all reads keep their existing total deadline. + if stream.set_nonblocking(false).is_err() { + return; + } + let deadline = Instant::now() + DEADLINE; + let Some(peer) = peer_pid(&stream) else { + return; + }; + let Ok(challenge) = read_frame::(&mut stream, deadline) else { + return; + }; + let Some(proof) = run + .authority + .lock() + .ok() + .and_then(|authority| authority.challenge(&challenge, peer)) + else { + return; + }; + // Authenticate this native endpoint before Node discloses its view token. + // Kernel peer-pid validation prevents a substituted same-UID socket from + // forwarding the challenge to the real native listener for an answer. + let response = serde_json::json!({"protocolVersion":1,"kind":"challenge","epoch":challenge.epoch,"nonce":challenge.nonce,"proof":proof}); + if !write_response(&mut stream, &response) { + return; + } + let Ok(message) = read_frame::(&mut stream, deadline) else { + return; + }; + if message.get("kind").is_some() { + if let Ok(attach) = serde_json::from_value::(message) { + run.attach_backend(&mut stream, attach, peer); + } + return; + } + let Ok(request) = serde_json::from_value::(message) else { + return; + }; + let result = execute(&request, peer); + let response = match result { + Ok(snapshot) => { + serde_json::json!({"protocolVersion":1,"sequence":request.sequence,"ok":true,"snapshot":snapshot}) + } + Err(error) => { + serde_json::json!({"protocolVersion":1,"sequence":request.sequence,"ok":false,"error":error}) + } + }; + write_response(&mut stream, &response); +} + +fn write_response(stream: &mut UnixStream, response: &serde_json::Value) -> bool { + if let Ok(mut bytes) = serde_json::to_vec(response) { + if bytes.len() + 1 > MAX_RESPONSE { + return false; + } + bytes.push(b'\n'); + let _ = stream.set_write_timeout(Some(DEADLINE)); + return stream.write_all(&bytes).is_ok(); + } + false +} + +pub(crate) fn page_load(webview: &tauri::Webview, payload: &tauri::webview::PageLoadPayload<'_>) { + if webview.label() != "main" { + return; + } + let app = webview.app_handle(); + let Some(bridge) = app.try_state::() else { + return; + }; + let Some(run) = bridge.0.lock().ok().and_then(|r| r.clone()) else { + return; + }; + let Ok(mut authority) = run.authority.lock() else { + return; + }; + authority.view = None; + if payload.event() == tauri::webview::PageLoadEvent::Started { + crate::updater_restart::view_lost(app); + } + if payload.event() != tauri::webview::PageLoadEvent::Finished + || run.retired.load(Ordering::Acquire) + || !spa_page(payload.url()) + || !app + .state::() + .permits(payload.url()) + { + return; + } + let Ok(token) = secret() else { + return; + }; + let origin = payload.url().origin().ascii_serialization(); + authority.view = Some(View { + token: token.clone(), + origin: origin.clone(), + }); + drop(authority); + let script = bridge_script(&token, &origin); + if webview.eval(script).is_err() { + if let Ok(mut authority) = run.authority.lock() { + authority.view = None; + } + } +} + +fn bridge_script(token: &str, origin: &str) -> String { + let token = serde_json::to_string(token).expect("token string"); + let origin = serde_json::to_string(origin).expect("origin string"); + let qa_diagnostics = cfg!(debug_assertions) && Binding::compiled().mode == Mode::Qa; + format!( + "({})({token},{origin},{qa_diagnostics});", + include_str!("updater_bridge.js") + ) +} + +#[cfg(test)] +mod tests { + use super::*; + fn authority() -> Authority { + Authority { + active: true, + secret: "a".repeat(64), + epoch: "b".repeat(64), + pid: 42, + view: Some(View { + token: "c".repeat(64), + origin: "http://127.0.0.1:43123".into(), + }), + replay: ReplayWindow::default(), + last_mutation_sequence: 0, + } + } + fn request(sequence: u64) -> Request { + Request { + protocol_version: 1, + secret: "a".repeat(64), + epoch: "b".repeat(64), + pid: 42, + sequence, + view: "c".repeat(64), + origin: "http://127.0.0.1:43123".into(), + command: Command::Status {}, + } + } + + #[test] + fn real_node_relay_completes_both_frames_on_a_nonblocking_accepted_socket() { + let directory = std::env::temp_dir() + .canonicalize() + .unwrap() + .join(format!("gu-{}", &secret().unwrap()[..12])); + fs::DirBuilder::new() + .mode(0o700) + .create(&directory) + .unwrap(); + let socket_path = directory.join("rpc"); + let listener = UnixListener::bind(&socket_path).unwrap(); + listener.set_nonblocking(true).unwrap(); + let repo = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap(); + let script = r#" + import {PassThrough} from 'node:stream'; + import {DesktopUpdateRelay} from './server/services/desktop-update-relay.ts'; + const input=new PassThrough(); + const relay=new DesktopUpdateRelay({input,platform:'darwin',env:{GJC_DESKTOP:'1',GJC_DESKTOP_UPDATE_PIPE:'1'}}); + input.write('GJC_DESKTOP_UPDATE_INIT '+JSON.stringify({protocolVersion:1,socket:process.argv[1],secret:'a'.repeat(64),epoch:'b'.repeat(64)})+'\n'); + try { const state=await relay.request({action:'status'},'c'.repeat(64),'http://127.0.0.1:43123'); if(state.phase!=='disabled')throw Error('wrong state');console.log('verified'); } + finally {relay.retire();} + "#; + let mut child = std::process::Command::new("node") + .args(["--import", "tsx", "--input-type=module", "--eval", script]) + .arg(&socket_path) + .current_dir(repo) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(5); + let stream = loop { + match listener.accept() { + Ok((stream, _)) => break Some(stream), + Err(error) + if error.kind() == std::io::ErrorKind::WouldBlock + && Instant::now() < deadline => + { + std::thread::sleep(Duration::from_millis(5)) + } + Err(_) => break None, + } + }; + let mut auth = authority(); + auth.pid = child.id(); + let run = Run { + authority: Mutex::new(auth), + retired: AtomicBool::new(false), + pending: AtomicUsize::new(0), + socket: socket_path.clone(), + backend_claimed: AtomicBool::new(false), + backend: OnceLock::new(), + }; + if let Some(stream) = stream { + // Deterministically exercise the BSD accept inheritance, regardless + // of the host's default behavior. This must still wait for frame 2. + stream.set_nonblocking(true).unwrap(); + serve_protocol(stream, &run, |request, peer| { + if run.authority.lock().unwrap().admit(request, peer) { + Ok(crate::updater::Snapshot::default()) + } else { + Err("updater_unauthorized") + } + }); + } else { + let _ = child.kill(); + } + let result = child.wait_with_output().unwrap(); + drop(listener); + fs::remove_file(socket_path).unwrap(); + fs::remove_dir(directory).unwrap(); + assert!( + result.status.success(), + "Node relay failed: {}", + String::from_utf8_lossy(&result.stderr) + ); + assert_eq!(String::from_utf8_lossy(&result.stdout).trim(), "verified"); + } + + #[test] + fn real_node_backend_channel_prepares_commits_and_never_reopens_on_disconnect() { + use crate::updater_backend::{Control, State}; + let directory = std::env::temp_dir() + .canonicalize() + .unwrap() + .join(format!("gub-{}", &secret().unwrap()[..12])); + fs::DirBuilder::new() + .mode(0o700) + .create(&directory) + .unwrap(); + let socket_path = directory.join("rpc"); + let listener = UnixListener::bind(&socket_path).unwrap(); + listener.set_nonblocking(true).unwrap(); + let repo = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap(); + let script = r#" + import {PassThrough} from 'node:stream'; + import {DesktopUpdateRelay} from './server/services/desktop-update-relay.ts'; + import {DesktopRestartBackend} from './server/services/desktop-restart-backend.ts'; + import {DesktopRestartAuthority} from './server/services/desktop-restart-authority.ts'; + const backend=new DesktopRestartBackend(); + // Transport fixture only: no execution owner is fabricated in an app. + const authority=new DesktopRestartAuthority({requiredOwners:['ui-drafts'],ownerReaders:{'ui-drafts':backend.draftReader}}); + backend.attachAuthority(authority); + const timer=setTimeout(()=>{process.exitCode=1;relay.retire();},5000); + const handler={bind:e=>backend.bind(e),handle:(c,e)=>backend.handle(c,e),disconnected:e=>{ + backend.disconnected(e);clearTimeout(timer); + if(authority.state!=='committed')process.exitCode=1; + else console.log('committed fence retained'); + }}; + const input=new PassThrough(); + const relay=new DesktopUpdateRelay({input,restart:handler,platform:'darwin',env:{GJC_DESKTOP:'1',GJC_DESKTOP_UPDATE_PIPE:'1'}}); + input.write('GJC_DESKTOP_UPDATE_INIT '+JSON.stringify({protocolVersion:1,socket:process.argv[1],secret:'a'.repeat(64),epoch:'b'.repeat(64)})+'\n'); + "#; + let mut child = std::process::Command::new("node") + .args(["--import", "tsx", "--input-type=module", "--eval", script]) + .arg(&socket_path) + .current_dir(repo) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(4); + let mut stream = None; + while Instant::now() < deadline { + if let Ok((accepted, _)) = listener.accept() { + stream = Some(accepted); + break; + } + std::thread::sleep(Duration::from_millis(5)); + } + let mut auth = authority(); + auth.pid = child.id(); + auth.view = None; + let run = Run { + authority: Mutex::new(auth), + retired: AtomicBool::new(false), + pending: AtomicUsize::new(0), + socket: socket_path.clone(), + backend_claimed: AtomicBool::new(false), + backend: OnceLock::new(), + }; + if let Some(stream) = stream { + serve_protocol( + stream, + &run, + |_, _| -> Result { + panic!("backend attachment is not a UI command"); + }, + ); + } else { + let _ = child.kill(); + } + let transport = run + .backend + .get() + .expect("native authenticated backend attachment"); + let status = transport + .request(Control::Status, Instant::now() + Duration::from_secs(1)) + .unwrap(); + assert_eq!(status.state, State::Open); + let id = "c".repeat(64); + let prepared = transport + .request( + Control::Prepare { + attempt_id: id.clone(), + draft_epoch: 1, + remaining_ms: 1000, + }, + Instant::now() + Duration::from_secs(2), + ) + .unwrap(); + assert!(prepared.ok); + assert_eq!(prepared.state, State::Prepared); + let committed = transport + .request( + Control::Commit { + attempt_id: id.clone(), + token: prepared.token.unwrap(), + }, + Instant::now() + Duration::from_secs(1), + ) + .unwrap(); + assert!(committed.ok); + assert_eq!(committed.state, State::Committed); + let cancelled = transport + .request( + Control::Cancel { attempt_id: id }, + Instant::now() + Duration::from_secs(1), + ) + .unwrap(); + assert!(!cancelled.ok); + assert_eq!(cancelled.state, State::Committed); + run.retire(); + let output = child.wait_with_output().unwrap(); + drop(listener); + fs::remove_file(socket_path).unwrap(); + fs::remove_dir(directory).unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!( + String::from_utf8_lossy(&output.stdout).trim(), + "committed fence retained" + ); + } + #[test] + fn replay_window_is_bounded_and_accepts_reordered_live_requests_only_once() { + let mut window = ReplayWindow::default(); + assert!(!window.accept(0)); + assert!(window.accept(2)); + assert!(window.accept(1)); + assert!(!window.accept(2)); + assert!(window.accept(100)); + assert!(!window.accept(1)); + assert!(window.accept(99)); + assert!(!window.accept(99)); + } + #[test] + fn copied_cookie_or_key_does_not_replace_current_main_view_or_peer_identity() { + let mut auth = authority(); + let mut req = request(1); + assert!(!auth.admit(&req, 43)); + req.view = "x".repeat(64); + assert!(!auth.admit(&req, 42)); + req.view = "c".repeat(64); + req.origin = "http://127.0.0.1:43124".into(); + assert!(!auth.admit(&req, 42)); + req.origin = "http://127.0.0.1:43123".into(); + assert!(auth.admit(&req, 42)); + assert!(!auth.admit(&req, 42)); + auth.view = None; + assert!(!auth.admit(&request(2), 42)); + } + #[test] + fn stale_spawn_epoch_and_unknown_commands_are_rejected() { + let mut auth = authority(); + let mut req = request(1); + req.epoch = "d".repeat(64); + assert!(!auth.admit(&req, 42)); + for input in [ + r#"{"action":"install","path":"/Applications"}"#, + r#"{"action":"status","url":"https://evil.test"}"#, + r#"{"action":"setAutomatic"}"#, + r#"{"action":"download"}"#, + r#"{"action":"download","targetId":null}"#, + r#"{"action":"restart"}"#, + r#"{"action":"restart","targetId":null}"#, + r#"{"action":"restart","targetId":42}"#, + ] { + assert!(serde_json::from_str::(input).is_err()); + } + } + + #[test] + fn manual_download_and_restart_require_a_bound_target() { + let target = "a".repeat(64); + for action in ["download", "restart"] { + let command: Command = serde_json::from_value(serde_json::json!({ + "action": action, "targetId": target, + })) + .unwrap(); + match command { + Command::Download { target_id } | Command::Restart { target_id } => { + assert_eq!(target_id, target) + } + _ => panic!("unexpected command"), + } + } + } + #[test] + fn unix_peer_pid_is_the_actual_connecting_process() { + let (left, right) = UnixStream::pair().unwrap(); + assert_eq!(peer_pid(&left), Some(std::process::id())); + assert_eq!(peer_pid(&right), Some(std::process::id())); + } + #[test] + fn framing_rejects_oversized_truncated_and_extra_requests() { + for bytes in [ + vec![b'x'; MAX_REQUEST + 1], + b"{}\n{}\n".to_vec(), + b"{".to_vec(), + ] { + let (mut reader, mut writer) = UnixStream::pair().unwrap(); + writer.write_all(&bytes).unwrap(); + drop(writer); + assert!(read_frame::(&mut reader, Instant::now() + DEADLINE).is_err()); + } + } + #[test] + fn injected_surface_contains_only_bounded_preparation_request_wrapper() { + let script = bridge_script(&"c".repeat(64), "http://127.0.0.1:43123"); + assert!(script.contains("X-Gajae-Update-View")); + assert!(!script.contains("__TAURI__")); + assert!(!script.contains("updater_install")); + assert!(script.contains("Object.freeze")); + } + + #[test] + fn endpoint_challenge_matches_node_hmac_and_refuses_a_forwarding_descendant() { + let auth = authority(); + let challenge = Challenge { + protocol_version: 1, + kind: "challenge".into(), + epoch: "b".repeat(64), + pid: 42, + nonce: "e".repeat(64), + }; + assert_eq!( + auth.challenge(&challenge, 42).as_deref(), + Some("43414b0668a3c9af729f1b9a179354596ce5ac70c5085e779a2afe43bd1546a3") + ); + assert!(auth.challenge(&challenge, 43).is_none()); + let mut retired = auth; + retired.active = false; + assert!(retired.challenge(&challenge, 42).is_none()); + } + + #[test] + fn retirement_between_receipt_and_execution_refuses_the_queued_request() { + let authority = Arc::new(Mutex::new(authority())); + let received = Arc::new(std::sync::Barrier::new(2)); + let execute = Arc::new(std::sync::Barrier::new(2)); + let worker = { + let authority = authority.clone(); + let received = received.clone(); + let execute = execute.clone(); + std::thread::spawn(move || { + let request = request(1); + received.wait(); + execute.wait(); + authority.lock().unwrap().admit(&request, 42) + }) + }; + received.wait(); + authority.lock().unwrap().active = false; + execute.wait(); + assert!(!worker.join().unwrap()); + } + + #[test] + fn a_delayed_preference_write_cannot_overtake_a_newer_opt_out() { + let mut authority = authority(); + let mut newer = request(2); + newer.command = Command::SetAutomatic { automatic: false }; + let mut older = request(1); + older.command = Command::SetAutomatic { automatic: true }; + assert!(authority.admit(&newer, 42)); + assert!(!authority.admit(&older, 42)); + assert!(authority.admit(&request(3), 42)); + } +} diff --git a/src-tauri/src/updater_bundle.rs b/src-tauri/src/updater_bundle.rs new file mode 100644 index 00000000..79be6267 --- /dev/null +++ b/src-tauri/src/updater_bundle.rs @@ -0,0 +1,1809 @@ +//! Read-only, macOS installed-bundle comparison against an archive inventory. +//! +//! The caller MUST obtain `expected` by inspecting the same bytes whose updater +//! signature it has verified. An inventory digest is not a signature. Matching +//! every member also binds the Info.plist/payload/runtime fields independently +//! checked by `updater_archive`; this module does not parse them again. +//! +//! No extraction, installation, explicit filesystem writes, process execution, +//! Apple code-signature or notarization verification happens here. Descriptor- +//! relative no-follow reads, before/after metadata and a second complete walk +//! detect substitutions, but are not an atomic filesystem snapshot or absolute +//! protection against a hostile process with the same UID. Evidence expires as +//! soon as the tree changes; the installer/journal owns subsequent decisions. + +use std::{ + collections::{BTreeMap, BTreeSet, VecDeque}, + ffi::{CStr, CString}, + fs::File, + io::Read, + mem::MaybeUninit, + os::{ + fd::{AsRawFd, FromRawFd, IntoRawFd}, + unix::ffi::OsStrExt, + }, + path::{Path, PathBuf}, +}; + +use sha2::{Digest, Sha256}; + +use crate::updater_archive::{ + ArchiveEntry, ArchiveEntryKind, ArchiveInventory, MAX_COMPRESSED_BYTES, MAX_EXPANDED_BYTES, +}; + +const MAX_ENTRIES: usize = 100_000; +const MAX_PATH_BYTES: usize = 4096; +const MAX_DEPTH: usize = 128; +const MAX_METADATA_BYTES: usize = 32 * 1024 * 1024; +const MAX_LINK_DEREFERENCES: usize = 64; + +/// Evidence of a complete inventory match at verification time, not a durable +/// authorization to install or relaunch. Intentionally not deserializable and +/// not constructible through public fields. +#[derive(Debug)] +pub(crate) struct VerifiedBundle { + root: PathBuf, + inventory_sha256: String, +} + +impl VerifiedBundle { + pub(crate) fn root(&self) -> &Path { + &self.root + } + + pub(crate) fn inventory_sha256(&self) -> &str { + &self.inventory_sha256 + } +} + +pub(crate) fn verify_inventory( + app_dir: &Path, + expected: &ArchiveInventory, +) -> Result { + verify_with_limits(app_dir, expected, Limits::default(), || Ok(())) +} + +#[derive(Clone, Copy)] +struct Limits { + entries: usize, + bytes: u64, + depth: usize, + metadata: usize, +} + +impl Default for Limits { + fn default() -> Self { + Self { + entries: MAX_ENTRIES, + bytes: MAX_EXPANDED_BYTES, + depth: MAX_DEPTH, + metadata: MAX_METADATA_BYTES, + } + } +} + +fn require(ok: bool, message: &str) -> Result<(), String> { + if ok { + Ok(()) + } else { + Err(format!("Installed bundle inventory: {message}")) + } +} + +fn io_error(operation: &str) -> String { + format!( + "Installed bundle inventory: {operation}: {}", + std::io::Error::last_os_error() + ) +} + +fn verify_with_limits( + app_dir: &Path, + expected: &ArchiveInventory, + limits: Limits, + between_walks: impl FnOnce() -> Result<(), String>, +) -> Result { + let entries = validate_inventory(expected, limits)?; + let anchor = RootAnchor::open(app_dir, &expected.root)?; + let root = anchor.directory(); + let root_before = stat_fd(root)?; + let mut first = Walk::new(&entries, limits, root_before.dev, None); + first.visit_directory(root, &expected.root, root_before, 1)?; + require(first.seen.len() == entries.len(), "missing members")?; + require( + first.bytes == expected.total_file_bytes, + "total file bytes differ", + )?; + + // Private hook makes mutation tests deterministic without introducing a + // callback into the public verification API. + between_walks()?; + anchor.revalidate()?; + let mut second = Walk::new(&entries, limits, root_before.dev, Some(&first.seen)); + second.visit_directory(root, &expected.root, stat_fd(root)?, 1)?; + require( + second.seen == first.seen, + "tree changed after content reads", + )?; + require( + stat_fd(root)? == root_before, + "app root changed during reads", + )?; + anchor.revalidate()?; + Ok(VerifiedBundle { + root: app_dir.to_path_buf(), + inventory_sha256: expected.inventory_sha256.clone(), + }) +} + +fn ascii_path(text: &str) -> Result<(), String> { + require( + !text.is_empty() && text.len() <= MAX_PATH_BYTES, + "path length limit", + )?; + require( + text.bytes() + .all(|b| (0x20..0x7f).contains(&b) && b != b'\\' && b != b':'), + "unsupported path character (ASCII paths only)", + ) +} + +fn member_path(text: &str, depth: usize) -> Result<(), String> { + ascii_path(text)?; + require(text.split('/').count() <= depth, "path depth limit")?; + for part in text.split('/') { + require( + !part.is_empty() + && part.len() <= 255 + && part != "." + && part != ".." + && !part.starts_with("._"), + "noncanonical path component", + )?; + } + Ok(()) +} + +fn digest_text(text: &str) -> Result<(), String> { + require( + text.len() == 64 + && text + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)), + "invalid SHA-256", + ) +} + +fn charge_metadata(total: &mut usize, amount: usize, limits: Limits) -> Result<(), String> { + *total = total + .checked_add(amount) + .ok_or("Inventory metadata overflow")?; + require(*total <= limits.metadata, "metadata byte limit") +} + +/// ArchiveInventory is public/serializable, so reject invalid shapes before any +/// filesystem access. Keep the framing/path policy in sync with updater_archive. +/// This is NOT a replacement for the caller's signature and archive inspection. +fn validate_inventory( + expected: &ArchiveInventory, + limits: Limits, +) -> Result, String> { + require( + !expected.entries.is_empty() && expected.entries.len() <= limits.entries, + "entry count limit", + )?; + member_path(&expected.root, 1)?; + for component in [ + &expected.identity.product_name, + &expected.identity.executable, + &expected.identity.bundle_identifier, + &expected.identity.package_name, + ] { + member_path(component, 1)?; + } + for version in [ + &expected.identity.desktop_version, + &expected.identity.product_version, + ] { + require(version.len() <= 128, "identity version length limit")?; + let version = + semver::Version::parse(version).map_err(|_| "Invalid inventory identity version")?; + require(version.build.is_empty(), "identity version build metadata")?; + } + let os = &expected.identity.minimum_system_version; + require(os.len() <= 32, "identity OS version length limit")?; + let parts: Vec<_> = os.split('.').collect(); + require(matches!(parts.len(), 2 | 3), "invalid identity OS version")?; + for (index, part) in parts.iter().enumerate() { + require( + !part.is_empty() + && part.bytes().all(|b| b.is_ascii_digit()) + && (part.len() == 1 || !part.starts_with('0')) + && part + .parse::() + .is_ok_and(|value| value <= if index == 0 { 65535 } else { 255 }), + "invalid identity OS version component", + )?; + } + require( + expected.root == format!("{}.app", expected.identity.product_name), + "app root does not match archive identity", + )?; + require( + expected.compressed_bytes > 0 + && expected.compressed_bytes <= MAX_COMPRESSED_BYTES as u64 + && expected.expanded_bytes <= MAX_EXPANDED_BYTES + && expected.expanded_bytes % 512 == 0 + && expected.total_file_bytes <= limits.bytes, + "archive byte limits", + )?; + for digest in [ + &expected.archive_sha256, + &expected.inventory_sha256, + &expected.runtime_manifest_sha256, + ] { + digest_text(digest)?; + } + let mut entries = BTreeMap::new(); + let mut aliases = BTreeSet::new(); + let mut metadata = 0; + let mut total_bytes = 0_u64; + let mut minimum_expanded = 1024 + 512 * expected.entries.len() as u64; + let mut previous: Option<&str> = None; + for entry in &expected.entries { + member_path(&entry.path, limits.depth)?; + require( + entry.path == expected.root + || entry + .path + .strip_prefix(&expected.root) + .is_some_and(|tail| tail.starts_with('/')), + "foreign inventory root", + )?; + require( + previous.is_none_or(|path| path < entry.path.as_str()), + "duplicate or unsorted inventory paths", + )?; + previous = Some(&entry.path); + require( + aliases.insert(entry.path.to_ascii_lowercase()), + "case-alias inventory paths", + )?; + require(entry.mode <= 0o777, "special or invalid mode bits")?; + charge_metadata(&mut metadata, entry.path.len(), limits)?; + match &entry.kind { + ArchiveEntryKind::File { size, sha256 } => { + digest_text(sha256)?; + total_bytes = total_bytes.checked_add(*size).ok_or("File size overflow")?; + require(total_bytes <= limits.bytes, "file byte limit")?; + minimum_expanded = minimum_expanded + .checked_add(size.checked_add(511).ok_or("File size overflow")? & !511) + .ok_or("Expanded size overflow")?; + } + ArchiveEntryKind::Directory => {} + ArchiveEntryKind::Symlink { target } => { + ascii_path(target)?; + require( + !target.starts_with('/') + && !target.contains("//") + && target.split('/').count() <= limits.depth, + "absolute or noncanonical link target", + )?; + charge_metadata(&mut metadata, target.len(), limits)?; + } + } + entries.insert(entry.path.as_str(), entry); + } + require( + total_bytes == expected.total_file_bytes && minimum_expanded <= expected.expanded_bytes, + "inconsistent inventory byte totals", + )?; + require( + entries + .get(expected.root.as_str()) + .is_some_and(|entry| matches!(entry.kind, ArchiveEntryKind::Directory)), + "explicit app directory root is required", + )?; + for entry in entries.values() { + if entry.path != expected.root { + let parent = entry.path.rsplit_once('/').ok_or("Missing parent")?.0; + require( + entries + .get(parent) + .is_some_and(|entry| matches!(entry.kind, ArchiveEntryKind::Directory)), + "member beneath missing, file or symlink parent", + )?; + } + if let ArchiveEntryKind::Symlink { target } = &entry.kind { + validate_link(entry, target, &entries, &expected.root, limits)?; + } + } + require( + inventory_hash(&expected.entries) == expected.inventory_sha256, + "inventory digest mismatch", + )?; + Ok(entries) +} + +fn validate_link<'a>( + entry: &'a ArchiveEntry, + target: &'a str, + entries: &BTreeMap<&str, &'a ArchiveEntry>, + root: &str, + limits: Limits, +) -> Result<(), String> { + let mut stack: Vec<_> = entry + .path + .rsplit_once('/') + .ok_or("Symlink root")? + .0 + .split('/') + .collect(); + let mut pending: VecDeque<_> = target.split('/').collect(); + let mut dereferences = 0; + while let Some(part) = pending.pop_front() { + require( + entries + .get(stack.join("/").as_str()) + .is_some_and(|entry| matches!(entry.kind, ArchiveEntryKind::Directory)), + "link traverses a non-directory", + )?; + match part { + "" | "." => {} + ".." => { + require(stack.len() > 1, "link escapes app root")?; + stack.pop(); + } + part => { + stack.push(part); + require(stack.len() <= limits.depth, "resolved link depth limit")?; + let resolved = stack.join("/"); + let next = entries + .get(resolved.as_str()) + .ok_or("Link target missing or aliased")?; + if let ArchiveEntryKind::Symlink { target } = &next.kind { + dereferences += 1; + require( + dereferences <= MAX_LINK_DEREFERENCES, + "link cycle or hop limit", + )?; + stack.pop(); + for part in target.split('/').rev() { + pending.push_front(part); + } + } + } + } + } + require( + stack.first().copied() == Some(root), + "link escapes app root", + ) +} + +fn inventory_hash(entries: &[ArchiveEntry]) -> String { + fn field(hash: &mut Sha256, bytes: &[u8]) { + hash.update((bytes.len() as u64).to_be_bytes()); + hash.update(bytes); + } + let mut hash = Sha256::new(); + hash.update(b"gajae-updater-inventory-v1\0"); + hash.update((entries.len() as u64).to_be_bytes()); + for entry in entries { + field(&mut hash, entry.path.as_bytes()); + hash.update(entry.mode.to_be_bytes()); + match &entry.kind { + ArchiveEntryKind::Directory => hash.update([0]), + ArchiveEntryKind::File { size, sha256 } => { + hash.update([1]); + hash.update(size.to_be_bytes()); + field(&mut hash, sha256.as_bytes()); + } + ArchiveEntryKind::Symlink { target } => { + hash.update([2]); + field(&mut hash, target.as_bytes()); + } + } + } + format!("{:x}", hash.finalize()) +} + +/// Ignore atime: reading can legitimately change it. ctime catches same-length +/// rewrites even if the writer restores mtime. No timestamp is a same-UID proof. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct Snapshot { + dev: libc::dev_t, + ino: libc::ino_t, + mode: libc::mode_t, + links: libc::nlink_t, + uid: libc::uid_t, + gid: libc::gid_t, + size: libc::off_t, + modified: (libc::time_t, libc::c_long), + changed: (libc::time_t, libc::c_long), + flags: u32, + generation: u32, +} + +impl Snapshot { + fn from_stat(stat: libc::stat) -> Self { + Self { + dev: stat.st_dev, + ino: stat.st_ino, + mode: stat.st_mode, + links: stat.st_nlink, + uid: stat.st_uid, + gid: stat.st_gid, + size: stat.st_size, + modified: (stat.st_mtime, stat.st_mtime_nsec), + changed: (stat.st_ctime, stat.st_ctime_nsec), + flags: stat.st_flags, + generation: stat.st_gen, + } + } + + fn is_dir(self) -> bool { + self.mode & libc::S_IFMT == libc::S_IFDIR + } + + fn same_ancestor(self, other: Self) -> bool { + // Unrelated sibling activity may change ancestor timestamps/link counts. + self.dev == other.dev + && self.ino == other.ino + && self.mode == other.mode + && self.uid == other.uid + && self.gid == other.gid + && self.flags == other.flags + && self.generation == other.generation + } +} + +fn stat_fd(file: &File) -> Result { + let mut stat = MaybeUninit::::uninit(); + // SAFETY: live owned descriptor and valid writable stat storage; initialize + // only on successful fstat. + if unsafe { libc::fstat(file.as_raw_fd(), stat.as_mut_ptr()) } != 0 { + return Err(io_error("fstat")); + } + Ok(Snapshot::from_stat(unsafe { stat.assume_init() })) +} + +fn stat_at(parent: &File, name: &CStr) -> Result { + let mut stat = MaybeUninit::::uninit(); + // SAFETY: callers supply one component (never a member path) and a live + // directory descriptor. AT_SYMLINK_NOFOLLOW inspects the link itself. + if unsafe { + libc::fstatat( + parent.as_raw_fd(), + name.as_ptr(), + stat.as_mut_ptr(), + libc::AT_SYMLINK_NOFOLLOW, + ) + } != 0 + { + return Err(io_error("fstatat (no-follow)")); + } + Ok(Snapshot::from_stat(unsafe { stat.assume_init() })) +} + +fn open_at(parent: &File, name: &CStr, directory: bool) -> Result { + let flags = libc::O_RDONLY + | libc::O_NOFOLLOW + | libc::O_CLOEXEC + | libc::O_NONBLOCK + | if directory { libc::O_DIRECTORY } else { 0 }; + // SAFETY: a single component relative to a live directory. NONBLOCK keeps a + // regular-file-to-FIFO substitution from blocking before the following stat. + let fd = unsafe { libc::openat(parent.as_raw_fd(), name.as_ptr(), flags) }; + if fd < 0 { + return Err(io_error("openat (no-follow)")); + } + Ok(unsafe { File::from_raw_fd(fd) }) +} + +struct RootAnchor { + path: PathBuf, + // Anchor each ancestor once; never reopen an ancestor through its pathname. + chain: Vec<(File, Snapshot)>, + names: Vec, +} + +impl RootAnchor { + fn open(path: &Path, root: &str) -> Result { + let bytes = path.as_os_str().as_bytes(); + require( + path.is_absolute() && bytes.len() <= MAX_PATH_BYTES && !bytes.contains(&0), + "app root must be a bounded absolute canonical path", + )?; + let parts: Vec<_> = bytes[1..].split(|b| *b == b'/').collect(); + require(parts.len() <= MAX_DEPTH, "app root ancestor depth limit")?; + require( + parts.last().copied() == Some(root.as_bytes()), + "app root name mismatch", + )?; + for part in &parts { + require( + !part.is_empty() && part.len() <= 255 && *part != b"." && *part != b"..", + "noncanonical app root component", + )?; + } + let slash = File::open("/").map_err(|error| format!("Open filesystem root: {error}"))?; + let before = stat_fd(&slash)?; + let mut anchor = Self { + path: path.to_path_buf(), + chain: vec![(slash, before)], + names: Vec::new(), + }; + for part in parts { + let name = CString::new(part).map_err(|_| "NUL in app root")?; + let parent = &anchor.chain.last().unwrap().0; + let before = stat_at(parent, &name)?; + require( + before.is_dir(), + "symlink or non-directory app root ancestor", + )?; + let directory = open_at(parent, &name, true)?; + require( + before.same_ancestor(stat_fd(&directory)?), + "app root ancestor changed while opening", + )?; + require( + before.same_ancestor(stat_at(parent, &name)?), + "app root ancestor path changed", + )?; + anchor.chain.push((directory, before)); + anchor.names.push(name); + } + anchor.revalidate()?; + Ok(anchor) + } + + fn directory(&self) -> &File { + &self.chain.last().unwrap().0 + } + + fn revalidate(&self) -> Result<(), String> { + for (index, (directory, before)) in self.chain.iter().enumerate() { + require( + before.same_ancestor(stat_fd(directory)?), + "app root ancestor identity changed", + )?; + if index > 0 { + require( + before + .same_ancestor(stat_at(&self.chain[index - 1].0, &self.names[index - 1])?), + "app root ancestor pathname changed", + )?; + } + } + // macOS F_GETPATH returns the descriptor's actual spelling/path without + // resolving member symlinks. Byte equality also rejects case aliases, + // renamed ancestors and lexical spellings Path's component equality can + // otherwise normalize (e.g. repeated separators). + let mut path = [0_u8; libc::PATH_MAX as usize]; + if unsafe { + libc::fcntl( + self.directory().as_raw_fd(), + libc::F_GETPATH, + path.as_mut_ptr(), + ) + } < 0 + { + return Err(io_error("F_GETPATH")); + } + let end = path + .iter() + .position(|byte| *byte == 0) + .ok_or("Unterminated app root path")?; + require( + &path[..end] == self.path.as_os_str().as_bytes(), + "app root is not canonical or changed path", + ) + } +} + +/// An independent directory stream, closed even when validation exits early. +struct DirectoryStream(*mut libc::DIR); + +impl DirectoryStream { + fn open(directory: &File) -> Result { + // The only non-member component opened by the walker is this literal + // dot, relative to an already-anchored real directory. A fresh open file + // description avoids sharing readdir offsets across the two walks. + let file = open_at(directory, c".", true)?; + require( + stat_fd(&file)? == stat_fd(directory)?, + "directory changed before enumeration", + )?; + let fd = file.into_raw_fd(); + let stream = unsafe { libc::fdopendir(fd) }; + if stream.is_null() { + let error = io_error("fdopendir"); + unsafe { libc::close(fd) }; + return Err(error); + } + Ok(Self(stream)) + } + + fn next(&mut self) -> Result, String> { + loop { + // SAFETY: this stream is uniquely owned. errno distinguishes EOF + // from a failed enumeration; copy the dirent name before next read. + unsafe { *libc::__error() = 0 }; + let entry = unsafe { libc::readdir(self.0) }; + if entry.is_null() { + if unsafe { *libc::__error() } != 0 { + return Err(io_error("readdir")); + } + return Ok(None); + } + let name = unsafe { CStr::from_ptr((*entry).d_name.as_ptr()) }.to_bytes(); + if name == b"." || name == b".." { + continue; + } + let name = std::str::from_utf8(name).map_err(|_| "Non-UTF8 installed member")?; + member_path(name, 1)?; + return Ok(Some(name.to_owned())); + } + } +} + +impl Drop for DirectoryStream { + fn drop(&mut self) { + // SAFETY: fdopendir transferred sole descriptor ownership to this stream. + unsafe { libc::closedir(self.0) }; + } +} + +struct Walk<'a> { + expected: &'a BTreeMap<&'a str, &'a ArchiveEntry>, + previous: Option<&'a BTreeMap>, + limits: Limits, + root_device: libc::dev_t, + seen: BTreeMap, + inodes: BTreeSet<(libc::dev_t, libc::ino_t)>, + bytes: u64, + metadata: usize, +} + +impl<'a> Walk<'a> { + fn new( + expected: &'a BTreeMap<&'a str, &'a ArchiveEntry>, + limits: Limits, + root_device: libc::dev_t, + previous: Option<&'a BTreeMap>, + ) -> Self { + Self { + expected, + previous, + limits, + root_device, + seen: BTreeMap::new(), + inodes: BTreeSet::new(), + bytes: 0, + metadata: 0, + } + } + + fn record(&mut self, path: &str, metadata: Snapshot) -> Result<&'a ArchiveEntry, String> { + require( + self.seen.len() < self.limits.entries, + "actual entry count limit", + )?; + member_path(path, self.limits.depth)?; + charge_metadata(&mut self.metadata, path.len(), self.limits)?; + let entry = *self + .expected + .get(path) + .ok_or_else(|| format!("Unexpected installed member: {path}"))?; + require( + self.seen.insert(path.to_owned(), metadata).is_none(), + "duplicate actual path", + )?; + require( + self.inodes.insert((metadata.dev, metadata.ino)), + "aliased installed inode", + )?; + require( + metadata.dev == self.root_device, + "mounted member crosses app device", + )?; + let kind = match entry.kind { + ArchiveEntryKind::File { .. } => libc::S_IFREG, + ArchiveEntryKind::Directory => libc::S_IFDIR, + ArchiveEntryKind::Symlink { .. } => libc::S_IFLNK, + }; + require( + metadata.mode & libc::S_IFMT == kind, + &format!("member type differs: {path}"), + )?; + require( + u32::from(metadata.mode & 0o7777) == entry.mode, + &format!("member mode differs: {path}"), + )?; + require( + metadata.is_dir() || metadata.links == 1, + "hard-linked member", + )?; + if let Some(previous) = self.previous { + require( + previous.get(path) == Some(&metadata), + &format!("member changed after reads: {path}"), + )?; + } + Ok(entry) + } + + fn visit_directory( + &mut self, + directory: &File, + path: &str, + before: Snapshot, + depth: usize, + ) -> Result<(), String> { + require(depth <= self.limits.depth, "actual directory depth limit")?; + let entry = self.record(path, before)?; + require( + matches!(entry.kind, ArchiveEntryKind::Directory), + "directory type differs", + )?; + let mut stream = DirectoryStream::open(directory)?; + while let Some(name) = stream.next()? { + let child = format!("{path}/{name}"); + let name = CString::new(name).map_err(|_| "NUL in member name")?; + let before = stat_at(directory, &name)?; + // Reject unknown paths/types before opening anything. A symlink is + // handled with readlinkat only, never passed to openat or descended. + let entry = self + .expected + .get(child.as_str()) + .ok_or_else(|| format!("Unexpected installed member: {child}"))?; + if matches!(entry.kind, ArchiveEntryKind::Directory) { + require( + before.is_dir(), + "expected directory is a symlink or other type", + )?; + let subdir = open_at(directory, &name, true)?; + require( + stat_fd(&subdir)? == before, + "directory changed while opening", + )?; + self.visit_directory(&subdir, &child, before, depth + 1)?; + } else { + let entry = self.record(&child, before)?; + match &entry.kind { + ArchiveEntryKind::File { size, sha256 } => { + require( + before.size >= 0 && before.size as u64 == *size, + "file size differs", + )?; + if self.previous.is_none() { + let mut file = open_at(directory, &name, false)?; + require(stat_fd(&file)? == before, "file changed while opening")?; + let digest = + hash_file(&mut file, *size, &mut self.bytes, self.limits.bytes)?; + require(digest == *sha256, &format!("file SHA-256 differs: {child}"))?; + require( + stat_fd(&file)? == before, + "file changed during content read", + )?; + } + } + ArchiveEntryKind::Symlink { target } => { + charge_metadata(&mut self.metadata, target.len(), self.limits)?; + require( + before.size >= 0 && before.size as usize == target.len(), + "symlink size differs", + )?; + let mut bytes = [0_u8; MAX_PATH_BYTES + 1]; + let length = unsafe { + libc::readlinkat( + directory.as_raw_fd(), + name.as_ptr(), + bytes.as_mut_ptr().cast(), + bytes.len(), + ) + }; + if length < 0 { + return Err(io_error("readlinkat")); + } + require( + &bytes[..length as usize] == target.as_bytes(), + "symlink target differs", + )?; + } + ArchiveEntryKind::Directory => unreachable!(), + } + } + require( + stat_at(directory, &name)? == before, + "member pathname changed during reads", + )?; + } + require( + stat_fd(directory)? == before, + "directory changed during enumeration", + ) + } +} + +fn hash_file( + file: &mut impl Read, + size: u64, + total: &mut u64, + limit: u64, +) -> Result { + let mut hash = Sha256::new(); + let mut read_bytes = 0_u64; + let mut buffer = [0_u8; 64 * 1024]; + loop { + // At most one extra probe byte; a growing file cannot create an + // unbounded read or cause allocation proportional to its declared size. + let length = buffer + .len() + .min((size - read_bytes).saturating_add(1) as usize); + let read = file + .read(&mut buffer[..length]) + .map_err(|error| format!("Read installed file: {error}"))?; + if read == 0 { + break; + } + read_bytes = read_bytes + .checked_add(read as u64) + .ok_or("File read overflow")?; + *total = total + .checked_add(read as u64) + .ok_or("Total file read overflow")?; + require( + read_bytes <= size && *total <= limit, + "file grew or read byte limit", + )?; + hash.update(&buffer[..read]); + } + require(read_bytes == size, "file truncated during read")?; + Ok(format!("{:x}", hash.finalize())) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::updater_archive::ArchiveIdentity; + use std::{ + fs, + io::{self, Cursor}, + os::unix::{ + fs::{symlink, MetadataExt, PermissionsExt}, + net::UnixListener, + }, + time::{Duration, Instant}, + }; + + const ROOT: &str = "Fixture.app"; + + struct Fixture { + temp: PathBuf, + app: PathBuf, + inventory: ArchiveInventory, + } + + impl Fixture { + fn new() -> Self { + let mut entropy = [0; 8]; + getrandom::getrandom(&mut entropy).unwrap(); + // Keep Unix-domain socket fixture names within macOS SUN_LEN. + let temp = fs::canonicalize("/private/tmp").unwrap().join(format!( + "gjb-{}-{:x}", + std::process::id(), + u64::from_ne_bytes(entropy) + )); + fs::create_dir(&temp).unwrap(); + let mut fixture = Self { + app: temp.join(ROOT), + temp, + inventory: ArchiveInventory { + identity: ArchiveIdentity { + product_name: "Fixture".into(), + executable: "app".into(), + bundle_identifier: "dev.fixture.app".into(), + package_name: "fixture".into(), + desktop_version: "1.0.0".into(), + product_version: "2.0.0-beta.1".into(), + minimum_system_version: "12.0".into(), + }, + root: ROOT.into(), + compressed_bytes: 123, + expanded_bytes: 0, + total_file_bytes: 0, + archive_sha256: "a".repeat(64), + inventory_sha256: String::new(), + runtime_manifest_sha256: "b".repeat(64), + entries: Vec::new(), + }, + }; + for dir in [ + "", + "Contents", + "Contents/MacOS", + "Contents/Resources", + "Contents/Resources/empty", + ] { + fixture.directory(dir); + } + fixture.file("Contents/MacOS/app", b"executable", 0o755); + fixture.file("Contents/Resources/payload", b"payload", 0o644); + fixture.file("Contents/Resources/zero", b"", 0o600); + fixture.link("Contents/Resources/current", "payload"); + fixture.refresh(); + fixture + } + + fn entry_path(&self, relative: &str) -> String { + if relative.is_empty() { + ROOT.into() + } else { + format!("{ROOT}/{relative}") + } + } + + fn directory(&mut self, relative: &str) { + let path = self.app.join(relative); + fs::create_dir(&path).unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).unwrap(); + self.inventory.entries.push(ArchiveEntry { + path: self.entry_path(relative), + mode: 0o755, + kind: ArchiveEntryKind::Directory, + }); + } + + fn file(&mut self, relative: &str, contents: &[u8], mode: u32) { + let path = self.app.join(relative); + fs::write(&path, contents).unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(mode)).unwrap(); + self.inventory.entries.push(ArchiveEntry { + path: self.entry_path(relative), + mode, + kind: ArchiveEntryKind::File { + size: contents.len() as u64, + sha256: digest(contents), + }, + }); + } + + fn link(&mut self, relative: &str, target: &str) { + let path = self.app.join(relative); + symlink(target, &path).unwrap(); + let mode = fs::symlink_metadata(&path).unwrap().mode() & 0o7777; + self.inventory.entries.push(ArchiveEntry { + path: self.entry_path(relative), + mode, + kind: ArchiveEntryKind::Symlink { + target: target.into(), + }, + }); + } + + fn entry(&mut self, relative: &str) -> &mut ArchiveEntry { + let path = self.entry_path(relative); + self.inventory + .entries + .iter_mut() + .find(|entry| entry.path == path) + .unwrap() + } + + fn refresh(&mut self) { + self.inventory.entries.sort_by(|a, b| a.path.cmp(&b.path)); + let mut bytes = 0; + let mut expanded = 1024 + self.inventory.entries.len() as u64 * 512; + for entry in &self.inventory.entries { + if let ArchiveEntryKind::File { size, .. } = entry.kind { + bytes += size; + expanded += (size + 511) & !511; + } + } + self.inventory.total_file_bytes = bytes; + self.inventory.expanded_bytes = expanded; + self.inventory.inventory_sha256 = inventory_hash(&self.inventory.entries); + } + + fn verify(&self) -> Result { + verify_inventory(&self.app, &self.inventory) + } + + fn rejects(&self, fragment: &str) { + let error = self.verify().unwrap_err(); + assert!( + error.contains(fragment), + "expected {fragment:?}, got {error:?}" + ); + } + } + + impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.temp); + } + } + + fn digest(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) + } + + #[test] + fn complete_inventory_is_read_only_and_proof_exposes_only_root_and_digest() { + let fixture = Fixture::new(); + let before = fixture + .inventory + .entries + .iter() + .map(|entry| { + let path = fixture.temp.join(&entry.path); + let metadata = fs::symlink_metadata(&path).unwrap(); + ( + metadata.mode(), + metadata.len(), + metadata.mtime(), + metadata.ctime(), + ) + }) + .collect::>(); + let verified = fixture.verify().unwrap(); + assert_eq!(verified.root(), fixture.app); + assert_eq!( + verified.inventory_sha256(), + fixture.inventory.inventory_sha256 + ); + for (entry, before) in fixture.inventory.entries.iter().zip(before) { + let path = fixture.temp.join(&entry.path); + let metadata = fs::symlink_metadata(&path).unwrap(); + assert_eq!( + ( + metadata.mode(), + metadata.len(), + metadata.mtime(), + metadata.ctime() + ), + before + ); + match &entry.kind { + ArchiveEntryKind::File { sha256, .. } => { + assert_eq!(digest(&fs::read(path).unwrap()), *sha256) + } + ArchiveEntryKind::Symlink { target } => { + assert_eq!(fs::read_link(path).unwrap(), Path::new(target)) + } + ArchiveEntryKind::Directory => {} + } + } + assert_eq!(fs::read_dir(&fixture.temp).unwrap().count(), 1); + } + + #[test] + fn accepts_real_archive_parser_inventory_including_identity_and_runtime_closure() { + use crate::updater_archive::inspect_archive; + use flate2::{write::GzEncoder, Compression}; + use std::io::Write; + + let mut fixture = Fixture::new(); + let identity = fixture.inventory.identity.clone(); + let fields = [ + ("CFBundleName", identity.product_name.as_str()), + ("CFBundleDisplayName", identity.product_name.as_str()), + ("CFBundleExecutable", identity.executable.as_str()), + ("CFBundleIdentifier", identity.bundle_identifier.as_str()), + ("CFBundlePackageType", "APPL"), + ( + "CFBundleShortVersionString", + identity.desktop_version.as_str(), + ), + ("CFBundleVersion", identity.desktop_version.as_str()), + ( + "LSMinimumSystemVersion", + identity.minimum_system_version.as_str(), + ), + ]; + let plist = plist::Value::Dictionary( + fields + .into_iter() + .map(|(key, value)| (key.to_owned(), plist::Value::String(value.to_owned()))) + .collect(), + ); + let mut plist_bytes = Vec::new(); + plist.to_writer_xml(&mut plist_bytes).unwrap(); + fixture.file("Contents/Info.plist", &plist_bytes, 0o644); + + // Structural Mach-O data only, never executed and not Apple-signed. + let mut macho = vec![0_u8; 256]; + for (offset, value) in [ + (0, 0xfeed_facf_u32), + (4, 0x0100_000c), + (12, 2), + (16, 3), + (20, 120), + (32, 0x19), + (36, 72), + (80, 256), + (92, 5), + (104, 0x32), + (108, 24), + (112, 1), + (116, 12 << 16), + (128, 0x8000_0028), + (132, 24), + (136, 160), + ] { + macho[offset..offset + 4].copy_from_slice(&value.to_le_bytes()); + } + fs::write(fixture.app.join("Contents/MacOS/app"), &macho).unwrap(); + fixture.entry("Contents/MacOS/app").kind = ArchiveEntryKind::File { + size: macho.len() as u64, + sha256: digest(&macho), + }; + + const PAYLOAD: &str = "Contents/Resources/resources/server-payload"; + for dir in [ + "Contents/Resources/resources".to_owned(), + PAYLOAD.into(), + format!("{PAYLOAD}/server"), + format!("{PAYLOAD}/node_modules"), + format!("{PAYLOAD}/node_modules/@gajae-code"), + format!("{PAYLOAD}/node_modules/@gajae-code/natives"), + format!("{PAYLOAD}/node_modules/@gajae-code/natives/native"), + format!("{PAYLOAD}/node_modules/@gajae-code/coding-agent"), + format!("{PAYLOAD}/node_modules/@gajae-code/coding-agent/src"), + format!("{PAYLOAD}/node_modules/@gajae-code/agent-core"), + format!("{PAYLOAD}/node_modules/@gajae-code/agent-core/src"), + ] { + fixture.directory(&dir); + } + let package = serde_json::to_vec(&serde_json::json!({ + "name": identity.package_name, "version": identity.product_version, + "desktopVersion": identity.desktop_version, "productName": identity.product_name, + "build": { "appId": identity.bundle_identifier, "productName": identity.product_name }, + })) + .unwrap(); + fixture.file(&format!("{PAYLOAD}/package.json"), &package, 0o644); + fixture.file( + &format!("{PAYLOAD}/node_modules/@gajae-code/natives/native/index.js"), + b"native", + 0o644, + ); + for package in ["coding-agent", "agent-core"] { + fixture.file( + &format!("{PAYLOAD}/node_modules/@gajae-code/{package}/src/index.ts"), + b"sdk", + 0o644, + ); + } + let runtime = serde_json::to_vec(&serde_json::json!({ + "schemaVersion": 2, "gjcSdk": "0.16.4", "bun": "1.4.0", "natives": "0.16.4", + "platforms": { "darwin-arm64": { "files": [{ "package": "@gajae-code/natives", "path": "native/index.js", "sha256": digest(b"native") }] } }, + "sdkLifecycle": { + "id": "gjc-sdk-lifecycle-v1", + "packages": {"@gajae-code/coding-agent":"0.16.4", "@gajae-code/agent-core":"0.16.4"}, + "files": [ + {"package":"@gajae-code/coding-agent", "path":"src/index.ts", "sha256":digest(b"sdk")}, + {"package":"@gajae-code/agent-core", "path":"src/index.ts", "sha256":digest(b"sdk")} + ] + }, + })).unwrap(); + fixture.file( + &format!("{PAYLOAD}/server/gjc-runtime-manifest.json"), + &runtime, + 0o644, + ); + fixture.refresh(); + let mut archive = tar::Builder::new(Vec::new()); + for entry in &fixture.inventory.entries { + let mut header = tar::Header::new_ustar(); + header.set_path(&entry.path).unwrap(); + header.set_mode(entry.mode); + let contents = match &entry.kind { + ArchiveEntryKind::Directory => { + header.set_entry_type(tar::EntryType::Directory); + Vec::new() + } + ArchiveEntryKind::Symlink { target } => { + header.set_entry_type(tar::EntryType::Symlink); + header.set_link_name(target).unwrap(); + Vec::new() + } + ArchiveEntryKind::File { .. } => { + header.set_entry_type(tar::EntryType::Regular); + fs::read(fixture.temp.join(&entry.path)).unwrap() + } + }; + header.set_size(contents.len() as u64); + header.set_cksum(); + archive.append(&header, contents.as_slice()).unwrap(); + } + let mut gzip = GzEncoder::new(Vec::new(), Compression::fast()); + gzip.write_all(&archive.into_inner().unwrap()).unwrap(); + let archive_inventory = inspect_archive(&gzip.finish().unwrap(), &identity).unwrap(); + assert_eq!(archive_inventory.entries, fixture.inventory.entries); + assert_eq!( + archive_inventory.inventory_sha256, + fixture.inventory.inventory_sha256 + ); + let verified = verify_inventory(&fixture.app, &archive_inventory).unwrap(); + assert_eq!( + verified.inventory_sha256(), + archive_inventory.inventory_sha256 + ); + fs::write( + fixture.app.join("Contents/Info.plist"), + b"different identity", + ) + .unwrap(); + assert!(verify_inventory(&fixture.app, &archive_inventory).is_err()); + } + + #[test] + fn accepts_expected_framework_links_and_directory_back_links_without_walking_them() { + let mut fixture = Fixture::new(); + for dir in [ + "Contents/Frameworks", + "Contents/Frameworks/F.framework", + "Contents/Frameworks/F.framework/Versions", + "Contents/Frameworks/F.framework/Versions/A", + ] { + fixture.directory(dir); + } + fixture.file( + "Contents/Frameworks/F.framework/Versions/A/F", + b"framework", + 0o755, + ); + fixture.link("Contents/Frameworks/F.framework/Versions/Current", "A"); + fixture.link("Contents/Frameworks/F.framework/F", "Versions/Current/F"); + fixture.link("Contents/Resources/back", ".."); + fixture.refresh(); + fixture.verify().unwrap(); + } + + #[test] + fn rejects_changed_hash_same_size_and_changed_size() { + for contents in [ + b"changed".as_slice(), + b"short".as_slice(), + b"much longer payload".as_slice(), + ] { + let fixture = Fixture::new(); + fs::write(fixture.app.join("Contents/Resources/payload"), contents).unwrap(); + fixture.rejects(if contents.len() == 7 { + "SHA-256" + } else { + "file size" + }); + } + } + + #[test] + fn rejects_changed_modes_on_files_directories_root_and_symlinks() { + for relative in [ + "", + "Contents", + "Contents/MacOS/app", + "Contents/Resources/current", + ] { + let fixture = Fixture::new(); + let path = fixture.app.join(relative); + let mode = fs::symlink_metadata(&path).unwrap().mode() & 0o777; + if relative.ends_with("current") { + let path = CString::new(path.as_os_str().as_bytes()).unwrap(); + assert_eq!( + unsafe { + libc::fchmodat( + libc::AT_FDCWD, + path.as_ptr(), + (mode ^ 0o001) as libc::mode_t, + libc::AT_SYMLINK_NOFOLLOW, + ) + }, + 0 + ); + } else { + fs::set_permissions(&path, fs::Permissions::from_mode(mode ^ 0o001)).unwrap(); + } + fixture.rejects("mode differs"); + if !relative.ends_with("current") { + fs::set_permissions(&path, fs::Permissions::from_mode(mode)).unwrap(); + } + } + } + + #[test] + fn rejects_special_permission_bits_in_actual_tree() { + let fixture = Fixture::new(); + fs::set_permissions( + fixture.app.join("Contents/MacOS/app"), + fs::Permissions::from_mode(0o4755), + ) + .unwrap(); + fixture.rejects("mode differs"); + } + + #[test] + fn rejects_missing_files_symlinks_and_empty_or_nonempty_directories() { + for relative in [ + "Contents/MacOS/app", + "Contents/Resources/current", + "Contents/Resources/empty", + "Contents/MacOS", + ] { + let fixture = Fixture::new(); + let path = fixture.app.join(relative); + if fs::symlink_metadata(&path).unwrap().is_dir() { + fs::remove_dir_all(path).unwrap(); + } else { + fs::remove_file(path).unwrap(); + } + fixture.rejects("missing members"); + } + } + + #[test] + fn rejects_extra_regular_hidden_directory_and_symlink_members() { + for kind in 0..4 { + let fixture = Fixture::new(); + let extra = fixture.app.join("Contents/Resources/.extra"); + match kind { + 0 => fs::write(extra, b"extra").unwrap(), + 1 => fs::create_dir(extra).unwrap(), + 2 => symlink("payload", extra).unwrap(), + _ => fs::write( + fixture.app.join("Contents/Resources/empty/nested"), + b"extra", + ) + .unwrap(), + } + fixture.rejects("Unexpected installed member"); + } + } + + #[test] + fn rejects_file_directory_type_changes() { + let fixture = Fixture::new(); + let file = fixture.app.join("Contents/MacOS/app"); + fs::remove_file(&file).unwrap(); + fs::create_dir(file).unwrap(); + fixture.rejects("member type differs"); + let fixture = Fixture::new(); + let dir = fixture.app.join("Contents/Resources/empty"); + fs::remove_dir(&dir).unwrap(); + fs::write(dir, b"").unwrap(); + fixture.rejects("expected directory"); + } + + #[test] + fn refuses_symlink_file_or_directory_substitution_without_following() { + for relative in ["Contents/MacOS/app", "Contents/Resources/empty"] { + let fixture = Fixture::new(); + let path = fixture.app.join(relative); + if path.is_dir() { + fs::remove_dir(&path).unwrap(); + } else { + fs::remove_file(&path).unwrap(); + } + symlink("/dev/zero", &path).unwrap(); + assert!(fixture.verify().is_err()); + } + } + + #[test] + fn rejects_modified_symlink_text_even_when_it_resolves_to_same_contents() { + for target in [ + "./payload", + "../Resources/payload", + "zero", + "/dev/zero", + "../../../../outside", + ] { + let fixture = Fixture::new(); + let link = fixture.app.join("Contents/Resources/current"); + fs::remove_file(&link).unwrap(); + symlink(target, &link).unwrap(); + fixture.rejects("symlink"); + } + let fixture = Fixture::new(); + let link = fixture.app.join("Contents/Resources/current"); + fs::remove_file(&link).unwrap(); + symlink("zero///", &link).unwrap(); // same seven-byte length as payload + fixture.rejects("symlink target differs"); + } + + #[test] + fn fifo_and_socket_members_are_rejected_promptly_without_opening() { + for fifo in [true, false] { + let fixture = Fixture::new(); + let path = fixture.app.join("Contents/MacOS/app"); + fs::remove_file(&path).unwrap(); + let _socket = if fifo { + let name = CString::new(path.as_os_str().as_bytes()).unwrap(); + assert_eq!(unsafe { libc::mkfifo(name.as_ptr(), 0o755) }, 0); + None + } else { + Some(UnixListener::bind(&path).unwrap()) + }; + let started = Instant::now(); + fixture.rejects("member type differs"); + assert!(started.elapsed() < Duration::from_secs(2)); + } + } + + #[test] + fn rejects_hardlinks_including_links_outside_inventory() { + let fixture = Fixture::new(); + fs::hard_link( + fixture.app.join("Contents/MacOS/app"), + fixture.temp.join("outside-link"), + ) + .unwrap(); + fixture.rejects("hard-linked"); + } + + #[test] + fn rejects_root_symlink_symlink_ancestors_aliases_and_noncanonical_spellings() { + let fixture = Fixture::new(); + let container = fixture.temp.join("container"); + fs::create_dir(&container).unwrap(); + symlink(&fixture.app, container.join(ROOT)).unwrap(); + assert!(verify_inventory(&container.join(ROOT), &fixture.inventory) + .unwrap_err() + .contains("ancestor")); + let alias = fixture.temp.join("alias"); + symlink(&fixture.temp, &alias).unwrap(); + assert!(verify_inventory(&alias.join(ROOT), &fixture.inventory) + .unwrap_err() + .contains("ancestor")); + let text = fixture.app.display().to_string(); + for path in [ + PathBuf::from(ROOT), + fixture.app.join("."), + PathBuf::from(format!("{text}/")), + fixture.temp.join(".").join(ROOT), + fixture.temp.join("container/../").join(ROOT), + PathBuf::from(text.replace("/Fixture.app", "//Fixture.app")), + fixture.temp.join("fixture.app"), + fixture.app.join("Contents"), + ] { + assert!( + verify_inventory(&path, &fixture.inventory).is_err(), + "accepted {}", + path.display() + ); + } + // Case alias in an ancestor, rather than just the already-bound app name. + let upper = fixture + .temp + .with_file_name( + fixture + .temp + .file_name() + .unwrap() + .to_str() + .unwrap() + .to_ascii_uppercase(), + ) + .join(ROOT); + assert!(verify_inventory(&upper, &fixture.inventory).is_err()); + } + + #[test] + fn rejects_actual_case_renames_and_unicode_names() { + let fixture = Fixture::new(); + fs::rename( + fixture.app.join("Contents/Resources/payload"), + fixture.app.join("Contents/Resources/PAYLOAD"), + ) + .unwrap(); + fixture.rejects("Unexpected installed member"); + let fixture = Fixture::new(); + fs::write(fixture.app.join("Contents/Resources/café"), b"").unwrap(); + fixture.rejects("ASCII paths only"); + } + + #[test] + fn schema_rejects_duplicate_case_alias_unsorted_and_unsafe_paths() { + for path in [ + "Fixture.app/../escape", + "Fixture.app//extra", + "/Fixture.app/extra", + "Other.app/extra", + "Fixture.app/./extra", + "Fixture.app/._extra", + "Fixture.app/café", + "Fixture.app/a:b", + "Fixture.app/a\\b", + "Fixture.app/extra/", + ] { + let mut fixture = Fixture::new(); + fixture.inventory.entries.push(ArchiveEntry { + path: path.into(), + mode: 0o755, + kind: ArchiveEntryKind::Directory, + }); + fixture.refresh(); + assert!( + validate_inventory(&fixture.inventory, Limits::default()).is_err(), + "accepted {path}" + ); + } + for case in 0..3 { + let mut fixture = Fixture::new(); + if case == 2 { + fixture.inventory.entries.swap(0, 1); + } else { + let mut duplicate = fixture.inventory.entries[1].clone(); + if case == 1 { + duplicate.path = format!("{ROOT}/CONTENTS"); + } + fixture.inventory.entries.push(duplicate); + fixture.refresh(); + } + assert!(validate_inventory(&fixture.inventory, Limits::default()).is_err()); + } + } + + #[test] + fn schema_rejects_missing_root_parents_and_children_beneath_links_or_files() { + for relative in ["", "Contents", "Contents/Resources"] { + let mut fixture = Fixture::new(); + let path = fixture.entry_path(relative); + fixture.inventory.entries.retain(|entry| entry.path != path); + fixture.refresh(); + assert!(validate_inventory(&fixture.inventory, Limits::default()).is_err()); + } + for parent in ["current", "payload"] { + let mut fixture = Fixture::new(); + fixture.inventory.entries.push(ArchiveEntry { + path: format!("{ROOT}/Contents/Resources/{parent}/injected"), + mode: 0o755, + kind: ArchiveEntryKind::Directory, + }); + fixture.refresh(); + fixture.rejects("parent"); + } + } + + #[test] + fn schema_rejects_escape_dangling_cycles_case_alias_and_file_dotdot_links() { + for target in [ + "/dev/zero", + "../../../outside", + "missing", + "current", + "PAYLOAD", + "payload/..", + "payload/.", + "../Resources//payload", + "../../../../", + "", + ] { + let mut fixture = Fixture::new(); + fixture.entry("Contents/Resources/current").kind = ArchiveEntryKind::Symlink { + target: target.into(), + }; + fixture.refresh(); + assert!( + validate_inventory(&fixture.inventory, Limits::default()).is_err(), + "accepted {target}" + ); + } + let mut fixture = Fixture::new(); + fixture.link("Contents/Resources/a", "b"); + fixture.link("Contents/Resources/b", "a"); + fixture.refresh(); + fixture.rejects("cycle"); + } + + #[test] + fn schema_rejects_bad_digests_modes_totals_and_identity() { + for case in 0..13 { + let mut fixture = Fixture::new(); + match case { + 0 => fixture.inventory.inventory_sha256 = "0".repeat(64), + 1 => fixture.inventory.archive_sha256 = "A".repeat(64), + 2 => fixture.inventory.runtime_manifest_sha256 = "not-a-digest".into(), + 3 => fixture.entry("Contents/MacOS/app").mode = 0o4755, + 4 => fixture.inventory.total_file_bytes += 1, + 5 => fixture.inventory.expanded_bytes = 512, + 6 => fixture.inventory.compressed_bytes = 0, + 7 => fixture.inventory.identity.product_name = "Other".into(), + 8 => fixture.inventory.identity.executable = "../app".into(), + 9 => fixture.inventory.identity.product_version = "bad".into(), + 10 => fixture.inventory.identity.minimum_system_version = "12.999".into(), + 11 => { + fixture.entry("Contents/MacOS/app").kind = ArchiveEntryKind::File { + size: 10, + sha256: "g".repeat(64), + } + } + _ => fixture.inventory.entries.clear(), + } + assert!( + validate_inventory(&fixture.inventory, Limits::default()).is_err(), + "case {case}" + ); + } + } + + #[test] + fn entry_byte_metadata_and_depth_limits_accept_boundary_and_reject_overflow() { + let fixture = Fixture::new(); + let metadata = fixture + .inventory + .entries + .iter() + .map(|entry| { + entry.path.len() + + match &entry.kind { + ArchiveEntryKind::Symlink { target } => target.len(), + _ => 0, + } + }) + .sum(); + let exact = Limits { + entries: fixture.inventory.entries.len(), + bytes: fixture.inventory.total_file_bytes, + depth: 4, + metadata, + }; + verify_with_limits(&fixture.app, &fixture.inventory, exact, || Ok(())).unwrap(); + for limits in [ + Limits { + entries: exact.entries - 1, + ..exact + }, + Limits { + bytes: exact.bytes - 1, + ..exact + }, + Limits { + depth: exact.depth - 1, + ..exact + }, + Limits { + metadata: exact.metadata - 1, + ..exact + }, + ] { + assert!( + verify_with_limits(&fixture.app, &fixture.inventory, limits, || Ok(())).is_err() + ); + } + let mut inventory = fixture.inventory.clone(); + inventory.compressed_bytes = MAX_COMPRESSED_BYTES as u64 + 1; + assert!(validate_inventory(&inventory, Limits::default()).is_err()); + inventory = fixture.inventory.clone(); + inventory.expanded_bytes = MAX_EXPANDED_BYTES + 512; + assert!(validate_inventory(&inventory, Limits::default()).is_err()); + inventory = fixture.inventory.clone(); + inventory.entries[0].kind = ArchiveEntryKind::File { + size: u64::MAX, + sha256: digest(b""), + }; + assert!(validate_inventory(&inventory, Limits::default()).is_err()); + assert!(member_path(&format!("{ROOT}/{}", "x".repeat(256)), MAX_DEPTH).is_err()); + assert!(member_path(&"x/".repeat(MAX_DEPTH), MAX_DEPTH).is_err()); + assert!(ascii_path(&"x".repeat(MAX_PATH_BYTES + 1)).is_err()); + } + + #[test] + fn validates_limits_in_actual_walk_too() { + let fixture = Fixture::new(); + let limits = Limits::default(); + let entries = validate_inventory(&fixture.inventory, limits).unwrap(); + let anchor = RootAnchor::open(&fixture.app, ROOT).unwrap(); + let root = stat_fd(anchor.directory()).unwrap(); + for limits in [ + Limits { + entries: 1, + ..limits + }, + Limits { bytes: 0, ..limits }, + Limits { + metadata: 1, + ..limits + }, + Limits { depth: 1, ..limits }, + ] { + let mut walk = Walk::new(&entries, limits, root.dev, None); + assert!(walk + .visit_directory(anchor.directory(), ROOT, root, 1) + .is_err()); + } + } + + #[test] + fn bounded_hasher_detects_truncation_growth_io_error_and_stops_at_one_probe_byte() { + for (bytes, size, cap) in [ + (b"ab".as_slice(), 3, 3), + (b"abcd".as_slice(), 3, 4), + (b"abc".as_slice(), 3, 2), + ] { + assert!(hash_file(&mut Cursor::new(bytes), size, &mut 0, cap).is_err()); + } + struct Infinite(usize); + impl Read for Infinite { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + self.0 += buf.len(); + buf.fill(b'a'); + Ok(buf.len()) + } + } + let mut infinite = Infinite(0); + assert!(hash_file(&mut infinite, 5, &mut 0, 5).is_err()); + assert_eq!(infinite.0, 6); + struct Broken; + impl Read for Broken { + fn read(&mut self, _: &mut [u8]) -> io::Result { + Err(io::Error::other("fixture read failure")) + } + } + assert!(hash_file(&mut Broken, 1, &mut 0, 1).is_err()); + assert_eq!( + hash_file(&mut Cursor::new(b""), 0, &mut 0, 0).unwrap(), + digest(b"") + ); + } + + #[test] + fn second_walk_rejects_mutation_of_already_hashed_file_link_and_directory() { + for case in 0..6 { + let fixture = Fixture::new(); + let result = + verify_with_limits(&fixture.app, &fixture.inventory, Limits::default(), || { + let payload = fixture.app.join("Contents/Resources/payload"); + match case { + 0 => fs::write(payload, b"changed").unwrap(), + 1 => { + fs::set_permissions(payload, fs::Permissions::from_mode(0o600)).unwrap() + } + 2 => fs::write(fixture.app.join("extra"), b"extra").unwrap(), + 3 => fs::remove_file(payload).unwrap(), + 4 => { + let link = fixture.app.join("Contents/Resources/current"); + fs::remove_file(&link).unwrap(); + symlink("zero///", &link).unwrap(); + } + _ => { + let dir = fixture.app.join("Contents/Resources/empty"); + fs::remove_dir(&dir).unwrap(); + fs::create_dir(&dir).unwrap(); + fs::set_permissions(&dir, fs::Permissions::from_mode(0o755)).unwrap(); + } + } + Ok(()) + }); + assert!(result.is_err(), "case {case}"); + } + } + + #[test] + fn revalidates_original_root_identity_and_ancestor_path_after_reads() { + for ancestor in [false, true] { + let fixture = Fixture::new(); + let moved = fixture.temp.with_extension("moved"); + let result = + verify_with_limits(&fixture.app, &fixture.inventory, Limits::default(), || { + if ancestor { + fs::rename(&fixture.temp, &moved).unwrap(); + symlink(&moved, &fixture.temp).unwrap(); + } else { + fs::rename(&fixture.app, fixture.temp.join("old.app")).unwrap(); + fs::create_dir(&fixture.app).unwrap(); + fs::set_permissions(&fixture.app, fs::Permissions::from_mode(0o755)) + .unwrap(); + } + Ok(()) + }); + if ancestor { + fs::remove_file(&fixture.temp).unwrap(); + fs::rename(&moved, &fixture.temp).unwrap(); + } + assert!(result.unwrap_err().contains("ancestor")); + } + } +} diff --git a/src-tauri/src/updater_discovery.rs b/src-tauri/src/updater_discovery.rs index c0bcb5b9..fee3a4b7 100644 --- a/src-tauri/src/updater_discovery.rs +++ b/src-tauri/src/updater_discovery.rs @@ -181,6 +181,50 @@ pub struct SelectedRelease { pub manifest: Manifest, } +/// A click binds the exact native offer, not just its display version. Keep the +/// domain and fixed-width little-endian IDs shared with persisted cache records. +pub(crate) fn target_id( + release_id: u64, + manifest_asset_id: u64, + archive_asset_id: u64, + manifest_bytes: &[u8], +) -> String { + let mut hash = Sha256::new(); + hash.update(b"gajae-desktop-update-target-v1\0"); + hash.update(release_id.to_le_bytes()); + hash.update(manifest_asset_id.to_le_bytes()); + hash.update(archive_asset_id.to_le_bytes()); + hash.update(manifest_bytes); + format!("{:x}", hash.finalize()) +} + +impl SelectedRelease { + pub(crate) fn target_id(&self) -> String { + target_id( + self.release.id, + self.manifest_asset.id, + self.archive_asset.id, + &self.manifest_bytes, + ) + } +} + +/// Immutable identities from a signature-verified cache. This is not install consent. +pub struct PreparedIdentity<'a> { + pub release_id: u64, + pub manifest_asset_id: u64, + pub archive_asset_id: u64, + pub archive_size: u64, + pub manifest_bytes: &'a [u8], +} + +/// The exact final endpoint native has just validated. It can contain an +/// expiring delivery token, so never serialize, persist or debug-print it. +pub struct CheckedManifest { + pub selected: SelectedRelease, + pub endpoint: Url, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub enum IncompleteReason { PageBudget, @@ -277,6 +321,86 @@ pub async fn fetch_archive( fetch_archive_with(client, policy, selected, timeout).await } +/// Reconstruct only the staged release, not an entire discovery scan. The +/// caller shares its absolute five-second preflight deadline with plugin check. +pub async fn revalidate_prepared( + client: &HttpsClient, + policy: &DiscoveryPolicy, + prepared: PreparedIdentity<'_>, + deadline: Instant, +) -> Result { + revalidate_prepared_with(client, policy, prepared, deadline).await +} + +async fn revalidate_prepared_with( + transport: &impl Transport, + policy: &DiscoveryPolicy, + prepared: PreparedIdentity<'_>, + deadline: Instant, +) -> Result { + let manifest = parse_manifest(prepared.manifest_bytes, &policy.identity()) + .map_err(|_| DiscoveryError::InvalidManifest)?; + if prepared.release_id == 0 + || prepared.manifest_asset_id == 0 + || prepared.archive_asset_id == 0 + || prepared.archive_size == 0 + || prepared.archive_size > MAX_ARCHIVE_BYTES + { + return Err(DiscoveryError::IdentityChanged); + } + let mut budget = Budget { + deadline, + requests: 0, + pages: 0, + }; + let response = budget + .fetch( + transport, + &policy.wire_url(&policy.api(&format!("/{}", prepared.release_id))), + Accept::GithubJson, + MAX_PAGE_BYTES, + ) + .await?; + require_ok(&response)?; + let release: ReleaseRecord = + serde_json::from_slice(&response.body).map_err(|_| DiscoveryError::InvalidRelease)?; + let (release, manifest_asset, archive_asset) = + release_assets(policy, &release)?.ok_or(DiscoveryError::IdentityChanged)?; + if release.id != prepared.release_id + || manifest_asset.id != prepared.manifest_asset_id + || archive_asset.id != prepared.archive_asset_id + || archive_asset.size != prepared.archive_size + || manifest_asset.size != prepared.manifest_bytes.len() as u64 + { + return Err(DiscoveryError::IdentityChanged); + } + let selected = SelectedRelease { + release, + manifest_asset, + archive_asset, + manifest, + manifest_bytes: prepared.manifest_bytes.to_vec(), + }; + if !eligible(policy, &selected)? { + return Err(DiscoveryError::IdentityChanged); + } + // Plugin check always requests application/json, even when given a custom + // Accept header. Start at the canonical public download URL, not GitHub's + // asset API whose octet-stream and JSON representations can differ. + let (bytes, endpoint) = fetch_asset_at( + transport, + policy, + &mut budget, + &selected.manifest_asset, + policy.wire_url(&selected.manifest_asset.download_url), + ) + .await?; + if bytes != prepared.manifest_bytes { + return Err(DiscoveryError::IdentityChanged); + } + Ok(CheckedManifest { selected, endpoint }) +} + type FetchFuture<'a> = Pin> + Send + 'a>>; @@ -757,7 +881,24 @@ async fn fetch_asset( budget: &mut Budget, asset: &AssetIdentity, ) -> Result, DiscoveryError> { - let mut url = policy.wire_url(&asset.api_url); + fetch_asset_at( + transport, + policy, + budget, + asset, + policy.wire_url(&asset.api_url), + ) + .await + .map(|(bytes, _endpoint)| bytes) +} + +async fn fetch_asset_at( + transport: &impl Transport, + policy: &DiscoveryPolicy, + budget: &mut Budget, + asset: &AssetIdentity, + mut url: Url, +) -> Result<(Vec, Url), DiscoveryError> { let mut visited = HashSet::new(); for redirects in 0..=MAX_REDIRECTS { // URLs with query tokens are ephemeral, not included in any result, @@ -772,7 +913,7 @@ async fn fetch_asset( if response.location.is_some() || response.body.len() as u64 != asset.size { return Err(DiscoveryError::SizeMismatch); } - return Ok(response.body); + return Ok((response.body, url)); } if !matches!(response.status.as_u16(), 301 | 302 | 303 | 307 | 308) { return Err(DiscoveryError::HttpStatus(response.status.as_u16())); @@ -1220,6 +1361,112 @@ mod tests { panic!("injected finite scan did not complete") } + fn prepared<'a>(release: &Value, bytes: &'a [u8]) -> PreparedIdentity<'a> { + let id = release["id"].as_u64().unwrap(); + PreparedIdentity { + release_id: id, + manifest_asset_id: id * 10 + 1, + archive_asset_id: id * 10 + 2, + archive_size: 4, + manifest_bytes: bytes, + } + } + + #[test] + fn reconstruction_revalidates_ids_and_returns_only_the_final_delivery_endpoint() { + let policy = policy(Channel::Beta); + let fake = Fake::default(); + let (release, bytes) = release(&policy, 7, "2.0.0-beta.11", "0.2.5", "13.0"); + fake.release(&policy, &release, &bytes); + let public = policy.download("v2.0.0-beta.11", "desktop-update.json"); + let delivery: Url = "https://release-assets.githubusercontent.com/github-production-release-asset/123/abcdef?token=ephemeral".parse().unwrap(); + fake.set(&public, Reply::redirect(delivery.as_str())); + fake.set(&delivery, Reply::ok(bytes.clone())); + let checked = tauri::async_runtime::block_on(revalidate_prepared_with( + &fake, + &policy, + prepared(&release, &bytes), + Instant::now() + Duration::from_secs(5), + )) + .unwrap(); + assert_eq!(checked.endpoint, delivery); + assert_eq!(checked.selected.manifest_bytes, bytes); + assert_eq!(fake.calls().len(), 3); + assert_eq!(fake.calls()[1], public.as_str()); + assert!(fake + .calls() + .iter() + .all(|url| !url.contains("per_page") && !url.ends_with("tar.gz"))); + } + + #[test] + fn reconstruction_refuses_replaced_assets_metadata_and_download_redirects() { + for alteration in [ + "manifest-id", + "archive-id", + "archive-size", + "bytes", + "redirect", + ] { + let policy = policy(Channel::Beta); + let fake = Fake::default(); + let (release, bytes) = release(&policy, 3, "2.0.0-beta.11", "0.2.5", "13.0"); + fake.release(&policy, &release, &bytes); + let public = policy.download("v2.0.0-beta.11", "desktop-update.json"); + let mut source = prepared(&release, &bytes); + match alteration { + "manifest-id" => source.manifest_asset_id += 9, + "archive-id" => source.archive_asset_id += 9, + "archive-size" => source.archive_size += 1, + "bytes" => { + let mut altered = bytes.clone(); + let i = altered.iter().position(|byte| *byte == b'2').unwrap(); + altered[i] = b'3'; + fake.set(&public, Reply::ok(altered)); + } + "redirect" => { + fake.set(&public, Reply::redirect("https://evil.invalid/update.json")) + } + _ => unreachable!(), + } + assert!( + tauri::async_runtime::block_on(revalidate_prepared_with( + &fake, + &policy, + source, + Instant::now() + Duration::from_secs(5) + )) + .is_err(), + "{alteration}" + ); + assert!(fake.calls().len() <= 2); + } + } + + #[test] + fn expired_reconstruction_budget_and_invalid_cache_issue_no_requests() { + let policy = policy(Channel::Beta); + let fake = Fake::default(); + let (release, bytes) = release(&policy, 3, "2.0.0-beta.11", "0.2.5", "13.0"); + let result = tauri::async_runtime::block_on(revalidate_prepared_with( + &fake, + &policy, + prepared(&release, &bytes), + Instant::now(), + )); + assert!(matches!(result, Err(DiscoveryError::Deadline))); + let mut invalid = prepared(&release, &bytes); + invalid.archive_asset_id = 0; + assert!(tauri::async_runtime::block_on(revalidate_prepared_with( + &fake, + &policy, + invalid, + Instant::now() + Duration::from_secs(5) + )) + .is_err()); + assert!(fake.calls().is_empty()); + } + #[test] fn maximum_desktop_not_product_or_release_order_and_exact_identity() { let policy = policy(Channel::Beta); @@ -1244,6 +1491,62 @@ mod tests { ); } + #[test] + fn discovery_never_fetches_an_archive_and_manual_fetch_keeps_the_offered_release() { + let policy = policy(Channel::Beta); + let fake = Fake::default(); + let (first, first_bytes) = release(&policy, 1, "2.0.0-beta.11", "0.2.6", "13.0"); + fake.release(&policy, &first, &first_bytes); + fake.page(&policy, 1, json!([first])); + // No archive route exists: any implicit download would fail this fake. + let selected = complete(&fake, &policy, &mut DiscoveryCursor::default()) + .selected + .unwrap(); + assert!(!fake + .calls() + .contains(&selected.archive_asset.api_url.to_string())); + let target_id = selected.target_id(); + let (newer, newer_bytes) = release(&policy, 2, "2.0.0-beta.12", "0.2.7", "13.0"); + fake.release(&policy, &newer, &newer_bytes); + fake.page(&policy, 1, json!([newer])); + fake.set(&selected.archive_asset.api_url, Reply::ok(b"test".to_vec())); + let before = fake.calls().len(); + let bytes = tauri::async_runtime::block_on(fetch_archive_with( + &fake, + &policy, + &selected, + Duration::from_secs(5), + )) + .unwrap(); + assert_eq!(bytes, b"test"); + assert_eq!(selected.target_id(), target_id); + assert_eq!(&fake.calls()[before..], &[ + selected.release.api_url.to_string(), + selected.manifest_asset.api_url.to_string(), + selected.archive_asset.api_url.to_string(), + ], "manual download must rebind the retained candidate, never list or select a newer target"); + // A replacement asset with the SAME display versions is still refused + // before its archive is fetched. + let mut replacement = first; + replacement["assets"][1]["id"] = json!(99); + replacement["assets"][1]["url"] = json!(policy.api("/assets/99").as_str()); + fake.release(&policy, &replacement, &first_bytes); + let before = fake.calls().len(); + assert_eq!( + tauri::async_runtime::block_on(fetch_archive_with( + &fake, + &policy, + &selected, + Duration::from_secs(5), + )), + Err(DiscoveryError::IdentityChanged) + ); + assert_eq!( + &fake.calls()[before..], + &[selected.release.api_url.to_string()] + ); + } + #[test] fn channel_floor_current_and_os_policies() { for (channel, product, desktop, os, accepted) in [ diff --git a/src-tauri/src/updater_install.rs b/src-tauri/src/updater_install.rs new file mode 100644 index 00000000..b08defe8 --- /dev/null +++ b/src-tauri/src/updater_install.rs @@ -0,0 +1,444 @@ +//! Native installation input. Own one immutable, freshly verified archive buffer +//! through supported plugin reconstruction and (later) the installation attempt. +//! Cache records and network metadata are never installation consent. +use std::{ + panic::{catch_unwind, AssertUnwindSafe}, + path::PathBuf, + time::Duration, +}; + +use reqwest::redirect::Policy; +use tauri::{AppHandle, Runtime}; +use tauri_plugin_updater::{Update, UpdaterExt}; +use tokio::time::Instant; + +use crate::{ + updater_archive::{inspect_archive, ArchiveIdentity, ArchiveInventory}, + updater_attempt::{Journal, Target, VerifiedBundleProof, VerifiedSuccessorProof}, + updater_bundle::{verify_inventory, VerifiedBundle}, + updater_discovery::{revalidate_prepared, DiscoveryPolicy, PreparedIdentity}, + updater_location::InstallLocation, + updater_manifest::{parse_manifest, Manifest, ProductIdentity}, + updater_signature::{digest, verify_archive}, + updater_store::{PreparedRecord, Store}, + updater_transport::HttpsClient, +}; + +pub(crate) const PREFLIGHT_TIMEOUT: Duration = Duration::from_secs(5); + +/// Native-owned reconstruction inputs, after compiled build/profile admission. +pub(crate) struct Reconstruction<'a> { + pub(crate) client: &'a HttpsClient, + pub(crate) policy: &'a DiscoveryPolicy, + pub(crate) location: &'a InstallLocation, + pub(crate) key: &'a str, + pub(crate) certificate: Option, + pub(crate) deadline: Instant, +} + +pub(crate) struct VerifiedArchive { + record: PreparedRecord, + manifest: Manifest, + inventory: ArchiveInventory, + bytes: Box<[u8]>, + key_sha256: String, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum InstallError { + Cache, + Signature, + Archive, + Network, + Deadline, + Changed, + Configuration, +} + +impl VerifiedArchive { + pub(crate) fn load(store: &Store, key: &str) -> Result, InstallError> { + store + .load() + .map_err(|_| InstallError::Cache)? + .map(|(record, bytes)| Self::verify(record, bytes, key)) + .transpose() + } + + fn verify(record: PreparedRecord, bytes: Vec, key: &str) -> Result { + let manifest = parse_manifest(record.manifest.as_bytes(), &product_identity()) + .map_err(|_| InstallError::Cache)?; + if bytes.len() as u64 != record.archive_size || digest(&bytes) != record.archive_sha256 { + return Err(InstallError::Cache); + } + verify_archive(&bytes, key, &manifest.signature).map_err(|_| InstallError::Signature)?; + let inventory = inspect_archive(&bytes, &archive_identity(&manifest)) + .map_err(|_| InstallError::Archive)?; + if serde_json::to_value(&inventory).map_err(|_| InstallError::Archive)? != record.inventory + { + return Err(InstallError::Cache); + } + Ok(Self { + record, + manifest, + inventory, + bytes: bytes.into_boxed_slice(), + key_sha256: digest(key.as_bytes()), + }) + } + + pub(crate) fn manifest(&self) -> &Manifest { + &self.manifest + } + pub(crate) fn record(&self) -> &PreparedRecord { + &self.record + } + /// Matching receipt/version strings are insufficient. Verify the signed + /// cached artifact, this compiled B identity and the entire current B tree. + pub(crate) fn verify_successor( + self, + target: &Target, + location: &InstallLocation, + ) -> Result { + let source = semver::Version::parse(&target.source_desktop_version) + .map_err(|_| InstallError::Changed)?; + if !self.manifest.version.cmp_precedence(&source).is_gt() + || target.app_path != location.app() + || target.target_desktop_version != env!("CARGO_PKG_VERSION") + || target.target_product_version != env!("GJC_EXPECTED_PAYLOAD_VERSION") + || self.manifest.version.to_string() != target.target_desktop_version + || self.manifest.product_version.to_string() != target.target_product_version + || self.inventory.archive_sha256 != target.archive_sha256 + || self.inventory.inventory_sha256 != target.inventory_sha256 + || self.inventory.runtime_manifest_sha256 != target.runtime_manifest_sha256 + || target.runtime_manifest_sha256 != env!("GJC_EXPECTED_RUNTIME_MANIFEST_SHA256") + { + return Err(InstallError::Changed); + } + location.revalidate().map_err(|_| InstallError::Changed)?; + let proof = + verify_inventory(location.app(), &self.inventory).map_err(|_| InstallError::Archive)?; + crate::expected_payload::ExpectedPayload::compiled() + .and_then(|expected| { + expected.verify_payload( + &location + .app() + .join("Contents/Resources/resources/server-payload"), + ) + }) + .map_err(|_| InstallError::Changed)?; + Ok(VerifiedSuccessor { + archive: self, + target: target.clone(), + proof, + }) + } + + /// The registered plugin uses exactly the final native-validated endpoint. + /// Its metadata allocation remains timeout-bounded, NOT byte-bounded. No + /// plugin download API is called and no private Update fields are fabricated. + pub(crate) async fn reconstruct( + self, + app: &AppHandle, + context: Reconstruction<'_>, + ) -> Result { + let Reconstruction { + client, + policy, + location, + key, + certificate, + deadline, + } = context; + if digest(key.as_bytes()) != self.key_sha256 { + return Err(InstallError::Configuration); + } + // Cap a caller-supplied budget, including the native reread and plugin + // check. Local signature/inventory validation precedes this network timer. + let deadline = deadline.min(Instant::now() + PREFLIGHT_TIMEOUT); + let checked = revalidate_prepared( + client, + policy, + PreparedIdentity { + release_id: self.record.release_id, + manifest_asset_id: self.record.manifest_asset_id, + archive_asset_id: self.record.archive_asset_id, + archive_size: self.record.archive_size, + manifest_bytes: self.record.manifest.as_bytes(), + }, + deadline, + ) + .await + .map_err(|_| InstallError::Network)?; + exact_endpoint(&checked.endpoint)?; + let remaining = deadline + .checked_duration_since(Instant::now()) + .filter(|time| !time.is_zero()) + .ok_or(InstallError::Deadline)?; + let updater = app + .updater_builder() + .endpoints(vec![checked.endpoint]) + .map_err(|_| InstallError::Configuration)? + .pubkey(key) + .target("darwin-aarch64") + .executable_path(location.executable()) + .timeout(remaining) + .configure_client(move |builder| { + let builder = builder + .https_only(true) + .redirect(Policy::none()) + .connect_timeout(remaining.min(Duration::from_secs(2))) + .timeout(remaining); + match certificate.clone() { + Some(certificate) => builder.add_root_certificate(certificate), + None => builder, + } + }) + .build() + .map_err(|_| InstallError::Configuration)?; + let update = tokio::time::timeout_at(deadline, updater.check()) + .await + .map_err(|_| InstallError::Deadline)? + .map_err(|_| InstallError::Network)? + .ok_or(InstallError::Changed)?; + validate_plugin_result(&self.manifest, &update)?; + if checked.selected.manifest != self.manifest || Instant::now() >= deadline { + return Err(InstallError::Changed); + } + Ok(PreparedInstall { + archive: self, + update, + app: location.app().to_owned(), + }) + } +} + +/// Opaque evidence retained while B starts. No serialized receipt can construct it. +pub(crate) struct VerifiedSuccessor { + archive: VerifiedArchive, + target: Target, + proof: VerifiedBundle, +} + +impl crate::updater_attempt::proof_seal::Sealed for VerifiedSuccessor {} +impl VerifiedSuccessorProof for VerifiedSuccessor { + fn target(&self) -> &Target { + &self.target + } +} +impl VerifiedSuccessor { + pub(crate) fn target(&self) -> &Target { + &self.target + } + + /// Before committing health, compare the complete B tree again while its + /// SPA is still withheld. This is not an unbounded event-thread operation. + pub(crate) fn revalidate_bundle(&self) -> Result<(), InstallError> { + let verified = verify_inventory(self.proof.root(), &self.archive.inventory) + .map_err(|_| InstallError::Archive)?; + if verified.inventory_sha256() != self.proof.inventory_sha256() { + return Err(InstallError::Changed); + } + Ok(()) + } +} + +/// No Clone and no mutable buffer accessor: the later attempt owner consumes +/// this object, never reloads a second potentially changed archive from disk. +pub(crate) struct PreparedInstall { + archive: VerifiedArchive, + update: Update, + app: PathBuf, +} + +impl crate::updater_attempt::proof_seal::Sealed for VerifiedBundle {} + +impl VerifiedBundleProof for VerifiedBundle { + fn root(&self) -> &std::path::Path { + self.root() + } + fn inventory_sha256(&self) -> &str { + self.inventory_sha256() + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ApplyError { + /// No installer was invoked; the old app may continue only after launch gates revalidate. + Precondition, + /// An attempt exists or mutation may have begun. Never start a server or automatically retry. + RecoveryRequired, +} + +/// Successful install return + full B inventory + durable awaiting-health record. +/// It is not successor health, and exposes no ability to erase the attempt. +pub(crate) struct InstalledTarget { + target: Target, +} + +impl InstalledTarget { + pub(crate) fn target(&self) -> &Target { + &self.target + } +} + +impl PreparedInstall { + /// Blocking: invoke off the event thread, and only after the native owner + /// has proven startup/manual-restart admission and G0 installation eligibility. + /// There is intentionally NO timeout around an active official installer. + pub(crate) fn apply( + self, + journal: &Journal, + location: &InstallLocation, + ) -> Result { + if self.app != location.app() { + return Err(ApplyError::Precondition); + } + location + .revalidate() + .map_err(|_| ApplyError::Precondition)?; + let target = Target { + app_path: self.app.clone(), + source_desktop_version: env!("CARGO_PKG_VERSION").into(), + target_desktop_version: self.archive.manifest.version.to_string(), + target_product_version: self.archive.manifest.product_version.to_string(), + archive_sha256: self.archive.inventory.archive_sha256.clone(), + inventory_sha256: self.archive.inventory.inventory_sha256.clone(), + runtime_manifest_sha256: self.archive.inventory.runtime_manifest_sha256.clone(), + }; + let mut attempt = journal + .begin(target.clone()) + .map_err(|_| ApplyError::RecoveryRequired)?; + location + .revalidate() + .map_err(|_| ApplyError::RecoveryRequired)?; + attempt + .validate_install_permit() + .map_err(|_| ApplyError::RecoveryRequired)?; + // This is exactly the immutable allocation signature/inventory verified + // by VerifiedArchive; no disk reload and no plugin-owned download. + match catch_unwind(AssertUnwindSafe(|| { + self.update.install(self.archive.bytes.as_ref()) + })) { + Ok(Ok(())) => {} + // Even PermissionDenied does not distinguish cancellation from a + // privileged move failure in updater 2.6. Preserve the blocker. + _ => return Err(ApplyError::RecoveryRequired), + } + let proof = verify_inventory(&self.app, &self.archive.inventory) + .map_err(|_| ApplyError::RecoveryRequired)?; + attempt + .record_installed(&proof) + .map_err(|_| ApplyError::RecoveryRequired)?; + Ok(InstalledTarget { target }) + } +} + +fn validate_plugin_result(expected: &Manifest, update: &Update) -> Result<(), InstallError> { + let parsed = parse_plugin_manifest(&update.raw_json)?; + if &parsed != expected + || update.version != expected.version.to_string() + || update.current_version != env!("CARGO_PKG_VERSION") + || update.download_url != expected.archive_url + || update.signature != expected.signature + || update.target != "darwin-aarch64" + { + return Err(InstallError::Changed); + } + Ok(()) +} + +fn parse_plugin_manifest(value: &serde_json::Value) -> Result { + // The plugin already allocated raw_json under its documented timeout-only + // contract. Do not allocate another unbounded copy in the native validator. + struct Bounded(Vec); + impl std::io::Write for Bounded { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + if bytes.len() > (64 * 1024usize).saturating_sub(self.0.len()) { + return Err(std::io::Error::other("manifest size limit")); + } + self.0.extend_from_slice(bytes); + Ok(bytes.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + let mut bytes = Bounded(Vec::new()); + serde_json::to_writer(&mut bytes, value).map_err(|_| InstallError::Changed)?; + parse_manifest(&bytes.0, &product_identity()).map_err(|_| InstallError::Changed) +} + +fn exact_endpoint(url: &reqwest::Url) -> Result<(), InstallError> { + // updater 2.6 substitutes template tokens even in a supplied final endpoint. + // Such a query/path would no longer be the endpoint we actually prefetched. + let lower = url.as_str().to_ascii_lowercase(); + if lower.contains("{{") || lower.contains("%7b%7b") { + return Err(InstallError::Changed); + } + Ok(()) +} + +pub(crate) fn product_identity() -> ProductIdentity<'static> { + ProductIdentity { + repository: env!("GJC_UPDATE_REPOSITORY"), + artifact_prefix: env!("GJC_UPDATE_ARTIFACT_PREFIX"), + } +} + +pub(crate) fn archive_identity(manifest: &Manifest) -> ArchiveIdentity { + ArchiveIdentity { + product_name: env!("GJC_UPDATE_PRODUCT_NAME").into(), + executable: env!("CARGO_PKG_NAME").into(), + bundle_identifier: env!("GJC_UPDATE_BUNDLE_IDENTIFIER").into(), + package_name: env!("GJC_UPDATE_PACKAGE_NAME").into(), + desktop_version: manifest.version.to_string(), + product_version: manifest.product_version.to_string(), + minimum_system_version: manifest.minimum_system_version.clone(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn plugin_parsed_metadata_is_revalidated_without_an_unbounded_second_copy() { + let valid: serde_json::Value = serde_json::from_str(include_str!( + "../../shared/fixtures/desktop-update-manifest.json" + )) + .unwrap(); + assert!(parse_plugin_manifest(&valid).is_ok()); + let mut oversized = valid.clone(); + oversized["notes"] = serde_json::json!("x".repeat(64 * 1024)); + assert_eq!( + parse_plugin_manifest(&oversized), + Err(InstallError::Changed) + ); + let mut foreign = valid; + foreign["repository"] = serde_json::json!("other/repository"); + assert_eq!(parse_plugin_manifest(&foreign), Err(InstallError::Changed)); + } + + #[test] + fn plugin_cannot_rewrite_the_prefetched_endpoint_through_template_substitution() { + for query in [ + "{{arch}}", + "{{target}}", + "{{current_version}}", + "%7B%7Barch%7D%7D", + "%7b%7btarget%7d%7d", + ] { + let endpoint = + format!("https://release-assets.githubusercontent.com/path?token={query}") + .parse() + .unwrap(); + assert_eq!(exact_endpoint(&endpoint), Err(InstallError::Changed)); + } + assert!(exact_endpoint( + &"https://release-assets.githubusercontent.com/path?token=abc-123" + .parse() + .unwrap() + ) + .is_ok()); + } +} diff --git a/src-tauri/src/updater_launch.rs b/src-tauri/src/updater_launch.rs new file mode 100644 index 00000000..5bd106ba --- /dev/null +++ b/src-tauri/src/updater_launch.rs @@ -0,0 +1,733 @@ +//! Native launch admission and verified successor health. Installation is +//! requires explicit compiled mode/profile admission. Default builds are inert; +//! production release publication still requires the qualification gates. +use std::{ + path::Path, + sync::{ + atomic::{AtomicU64, AtomicU8, Ordering}, + Mutex, + }, + time::Duration, +}; + +use tauri::{AppHandle, Manager}; +use tokio::sync::Notify; + +use crate::{ + updater_attempt::{self, Journal, Phase as AttemptPhase, SuccessorAttempt, Target}, + updater_binding::{Binding, Mode}, + updater_install::{ + ApplyError, PreparedInstall, Reconstruction, VerifiedArchive, VerifiedSuccessor, + PREFLIGHT_TIMEOUT, + }, + updater_location::InstallLocation, + updater_screen::{self, Screen, ScreenState}, +}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u8)] +enum Phase { + Checking, + Normal, + AwaitingHealth, + Installing, + Restarting, + Recovery, + ManualRestart, +} + +struct PendingSuccessor { + attempt: SuccessorAttempt, + proof: VerifiedSuccessor, +} + +pub(crate) struct LaunchGate { + phase: AtomicU8, + pending: Mutex>, + rendered_epoch: AtomicU64, + rendered: Notify, +} + +impl Default for LaunchGate { + fn default() -> Self { + Self { + phase: AtomicU8::new(Phase::Checking as u8), + pending: Mutex::new(None), + rendered_epoch: AtomicU64::new(0), + rendered: Notify::new(), + } + } +} + +impl LaunchGate { + fn phase(&self) -> Phase { + match self.phase.load(Ordering::Acquire) { + 1 => Phase::Normal, + 2 => Phase::AwaitingHealth, + 3 => Phase::Installing, + 4 => Phase::Restarting, + 5 => Phase::Recovery, + 6 => Phase::ManualRestart, + _ => Phase::Checking, + } + } + fn set_phase(&self, phase: Phase) { + self.phase.store(phase as u8, Ordering::Release); + } + + fn begin_install(&self) -> bool { + self.phase + .compare_exchange( + Phase::Checking as u8, + Phase::Installing as u8, + Ordering::AcqRel, + Ordering::Acquire, + ) + .is_ok() + } + + fn admit(&self, root: &Path) -> Result<(), String> { + match self.phase() { + Phase::Normal => updater_attempt::check(root), + Phase::AwaitingHealth => { + let pending = self + .pending + .lock() + .map_err(|_| "Update successor lock failed.")?; + let pending = pending + .as_ref() + .ok_or("Missing verified update successor.")?; + pending.attempt.validate_startup(&pending.proof) + } + _ => Err("Desktop update verification/recovery blocks server startup.".into()), + } + } +} + +pub(crate) fn admit_server(app: &AppHandle, root: &Path) -> Result<(), String> { + if crate::updater_restart::blocks_start(app) { + return Err("Manual restart transaction blocks server startup.".into()); + } + app.state::().admit(root) +} + +pub(crate) fn holds_exit(app: &AppHandle) -> bool { + crate::updater_restart::holds_exit(app) + || app + .try_state::() + .is_some_and(|gate| matches!(gate.phase(), Phase::Installing | Phase::Restarting)) +} + +pub(crate) fn expected_restart(app: &AppHandle, code: Option) -> bool { + code == Some(tauri::RESTART_EXIT_CODE) + && app + .try_state::() + .is_some_and(|gate| gate.phase() == Phase::Restarting) +} + +pub(crate) fn allows_navigation_intents(app: &AppHandle) -> bool { + !crate::updater_restart::blocks_start(app) + && app + .try_state::() + .is_some_and(|gate| gate.phase() == Phase::Normal) +} + +fn local_page(url: &tauri::Url) -> bool { + url.scheme() == "tauri" + && url.host_str() == Some("localhost") + && matches!(url.path(), "/" | "/index.html") + && url.username().is_empty() + && url.password().is_none() + && url.query().is_none() + && url.fragment().is_none() +} + +fn screen_script(app: &AppHandle) -> Option { + let (epoch, screen) = app.state::().published()?; + Some(paint_script(epoch, &screen)) +} + +fn paint_script(epoch: u64, screen: &Screen) -> String { + let script = updater_screen::script(screen); + // The callback is an embedded-page paint acknowledgment only. It cannot + // choose an archive, command, location, key, or installation outcome. + format!("(()=>{{const epoch={epoch};const html=document.documentElement;if(Number(html.dataset.gajaeUpdaterEpoch||0)>epoch)return;html.dataset.gajaeUpdaterEpoch=String(epoch);{script}const mounted=document.getElementById('gajae-updater-screen');if(!mounted)return;mounted.dataset.epoch=String(epoch);requestAnimationFrame(()=>requestAnimationFrame(()=>{{if(!mounted.isConnected||document.getElementById('gajae-updater-screen')!==mounted||mounted.dataset.epoch!==String(epoch)||html.dataset.gajaeUpdaterEpoch!==String(epoch))return;const t=window.__TAURI__?.core??window.__TAURI_INTERNALS__;if(t?.invoke)t.invoke('ack_updater_screen',{{epoch}}).catch(()=>{{}});}}));}})();") +} + +pub(crate) fn restore_screen(webview: &tauri::Webview) -> bool { + if webview.label() != "main" || !webview.url().is_ok_and(|url| local_page(&url)) { + return false; + } + let app = webview.app_handle(); + if app.state::().phase() == Phase::Normal { + return false; + } + if let Some(script) = screen_script(app) { + let _ = webview.eval(script); + return true; + } + false +} + +pub(crate) fn acknowledge_screen(app: &AppHandle, window: &tauri::WebviewWindow, epoch: u64) { + if window.label() != "main" || !window.url().is_ok_and(|url| local_page(&url)) { + return; + } + let gate = app.state::(); + if epoch == 0 + || app + .state::() + .published() + .map(|(current, _)| current) + != Some(epoch) + { + return; + } + gate.rendered_epoch.store(epoch, Ordering::Release); + gate.rendered.notify_one(); +} + +fn show(app: &AppHandle, screen: Screen) -> Result { + let window = app + .get_webview_window("main") + .ok_or("Update window is unavailable.")?; + let epoch = app.state::().publish(screen); + if window.url().is_ok_and(|url| local_page(&url)) { + window + .eval(screen_script(app).ok_or("Update screen is unavailable.")?) + .map_err(|_| "Could not display update screen.")?; + } else { + window + .navigate( + "tauri://localhost/index.html" + .parse() + .expect("static recovery URL"), + ) + .map_err(|_| "Could not open embedded update screen.")?; + } + let _ = window.unminimize(); + window.show().map_err(|_| "Could not show update window.")?; + window + .set_focus() + .map_err(|_| "Could not focus update window.")?; + Ok(epoch) +} + +async fn show_confirmed(app: &AppHandle, screen: Screen) -> Result<(), String> { + let epoch = show(app, screen)?; + let gate = app.state::(); + tokio::time::timeout(Duration::from_secs(5), async { + loop { + let notified = gate.rendered.notified(); + if gate.rendered_epoch.load(Ordering::Acquire) == epoch { + return; + } + notified.await; + } + }) + .await + .map_err(|_| "Embedded update screen did not acknowledge display.".into()) +} + +fn trace(event: &'static str) { + if cfg!(debug_assertions) && Binding::compiled().mode == Mode::Qa { + eprintln!("[updater-qa:{}] {event}", std::process::id()); + } +} + +fn handoff_verified_install( + gate: &LaunchGate, + present: impl FnOnce() -> Result, + restart: impl FnOnce(), +) { + gate.set_phase(Phase::Restarting); + let _ = present(); + restart(); +} + +fn recover(app: &AppHandle, message: &str) { + trace("recovery"); + app.state::().set_phase(Phase::Recovery); + let _ = show( + app, + Screen::Recovery { + message: message.to_owned(), + }, + ); +} + +pub(crate) fn server_failed(app: &AppHandle, message: &str) -> bool { + match app.state::().phase() { + // A rejected Retry must not overwrite an in-flight updater document or + // turn a live installer into a false terminal recovery state. + Phase::Checking | Phase::Installing | Phase::Restarting | Phase::ManualRestart => true, + Phase::AwaitingHealth | Phase::Recovery => { + recover(app, message); + true + } + Phase::Normal => false, + } +} + +pub(crate) async fn show_manual_applying(app: &AppHandle) -> Result<(), String> { + let gate = app.state::(); + gate.phase + .compare_exchange( + Phase::Normal as u8, + Phase::ManualRestart as u8, + Ordering::AcqRel, + Ordering::Acquire, + ) + .map_err(|_| "Native launch state changed before restart.".to_owned())?; + show_confirmed(app, Screen::Applying).await +} +pub(crate) fn cancel_manual_display(app: &AppHandle, return_url: &tauri::Url) { + let gate = app.state::(); + if gate + .phase + .compare_exchange( + Phase::ManualRestart as u8, + Phase::Normal as u8, + Ordering::AcqRel, + Ordering::Acquire, + ) + .is_ok() + { + app.state::().clear(); + if let Some(window) = app.get_webview_window("main") { + let _ = window.navigate(return_url.clone()); + } + } +} +pub(crate) fn manual_recovery(app: &AppHandle, message: &str) { + recover(app, message); +} +pub(crate) fn request_manual_restart(app: &AppHandle) { + app.state::().set_phase(Phase::Restarting); + let _ = show(app, Screen::Restarting); + app.request_restart(); +} + +fn normal_start(app: &AppHandle) { + if app + .state::() + .is_shutting_down() + { + return; + } + app.state::().clear(); + app.state::().set_phase(Phase::Normal); + crate::supervisor::start(app.clone()); +} + +pub(crate) fn start(app: AppHandle, _qa_install: bool) { + // Preserve the older explicit QA CLI flag for qualification tooling. A + // matching QA build now exercises the same durable consent path as release. + tauri::async_runtime::spawn(async move { + if let Err(error) = start_inner(&app).await { + recover(&app, &error); + } + }); +} + +async fn start_inner(app: &AppHandle) -> Result<(), String> { + trace("launch-start"); + let root = crate::supervisor::desktop_data_root(app)?; + let binding = Binding::compiled(); + let profile = app.try_state::(); + let active = cfg!(target_arch = "aarch64") + && binding.admits_profile( + profile.as_ref().map(|profile| profile.root()), + !cfg!(debug_assertions), + ); + let absent = updater_attempt::check(&root).is_ok(); + if !active { + if !absent { + return Err( + "Unfinished update requires the matching updater-enabled app for verification." + .into(), + ); + } + normal_start(app); + return Ok(()); + } + // With no cached candidate or pending click, ordinary startup adds no + // update I/O/network work. + let cached = root.join("desktop-update-cache/ready.json"); + if absent + && !cached.exists() + && !root + .join("desktop-update-cache/manual-intent.json") + .exists() + { + normal_start(app); + return Ok(()); + } + show(app, Screen::Checking)?; + let handle = app.clone(); + let runtime = + tauri::async_runtime::spawn_blocking(move || crate::updater::initialize(&handle, binding)) + .await; + let runtime = match runtime { + Ok(Ok(runtime)) => runtime, + _ if absent => { + normal_start(app); + return Ok(()); + } + _ => return Err("Update successor initialization failed.".into()), + }; + let journal = Journal::open(&root)?; + let loaded = journal.load()?; + let manual_request = if loaded.is_none() { + // Only a successfully consumed explicit selection can reach preflight. + // Missing/legacy/corrupt intent grants no authority. Existing install + // journals retain their separate verified-successor recovery path. + runtime.store.consume_manual().unwrap_or(None) + } else { + None + }; + let location = InstallLocation::validate( + &runtime.binding, + &std::env::current_exe().map_err(|_| "Current executable is unavailable.")?, + ); + let location = match location { + Ok(location) => location, + Err(_) if loaded.is_none() => { + normal_start(app); + return Ok(()); + } + Err(error) => return Err(error), + }; + let store = runtime.store.clone(); + let key = runtime.binding.public_key.clone(); + let archive = + tauri::async_runtime::spawn_blocking(move || VerifiedArchive::load(&store, &key)).await; + let archive = match archive { + Ok(Ok(archive)) => archive, + _ if loaded.is_none() => { + normal_start(app); + return Ok(()); + } + _ => return Err("Cached archive needed for successor verification is invalid.".into()), + }; + if let Some(loaded) = loaded { + if loaded.phase() != AttemptPhase::AwaitingHealth { + return Err("An interrupted installation cannot be retried automatically.".into()); + } + let archive = + archive.ok_or("The signed archive needed to verify the successor is missing.")?; + let target = loaded.target().clone(); + let proof = tauri::async_runtime::spawn_blocking(move || { + archive.verify_successor(&target, &location) + }) + .await + .map_err(|_| "Successor verification failed.")? + .map_err(|_| "The running app does not match the signed update target.")?; + let attempt = journal.resume_verified(&loaded, &proof)?; + trace("successor-verified"); + *app.state::() + .pending + .lock() + .map_err(|_| "Update successor lock failed.")? = + Some(PendingSuccessor { attempt, proof }); + app.state::().set_phase(Phase::AwaitingHealth); + crate::supervisor::start(app.clone()); + return Ok(()); + } + let Some(archive) = archive else { + normal_start(app); + return Ok(()); + }; + if !installation_requested(manual_request.as_ref(), archive.record()) + || !crate::updater::eligible_cached(archive.manifest(), &runtime.os) + .map_err(|error| error.code().to_owned())? + { + normal_start(app); + return Ok(()); + } + // A manual intent only selects the cached target. Actual process absence, + // native bundle identity and signature gates are independently re-proved. + let deadline = tokio::time::Instant::now() + PREFLIGHT_TIMEOUT; + stored_port_is_unoccupied(&root)?; + let owners = crate::updater_owners::prove_no_packaged_owners(location.app())?; + if app + .plugin( + tauri_plugin_updater::Builder::new() + .pubkey(runtime.binding.public_key.clone()) + .build(), + ) + .is_err() + { + // No installer or journal mutation has begun. Initialization failure is + // a deferred update, not a reason to strand the otherwise valid old app. + normal_start(app); + return Ok(()); + } + let prepared = archive + .reconstruct( + app, + Reconstruction { + client: &runtime.client, + policy: &runtime.policy, + location: &location, + key: &runtime.binding.public_key, + certificate: runtime.certificate.clone(), + deadline, + }, + ) + .await; + let prepared = match prepared { + Ok(prepared) => prepared, + Err(_) => { + normal_start(app); + return Ok(()); + } + }; + show_confirmed(app, Screen::Applying).await?; + owners.revalidate()?; + trace("applying-visible"); + if !app + .state::() + .begin_startup_update(|| app.state::().begin_install()) + { + return Ok(()); + } + run_install(app, prepared, journal, location).await +} + +fn installation_requested( + request: Option<&crate::updater_store::ManualRequest>, + record: &crate::updater_store::PreparedRecord, +) -> bool { + // The persisted preference controls discovery only. Even a fully verified + // cache cannot select an install without a matching explicit restart intent. + request.is_some_and(|request| request.matches(record)) +} + +async fn run_install( + app: &AppHandle, + prepared: PreparedInstall, + journal: Journal, + location: InstallLocation, +) -> Result<(), String> { + trace("install-begin"); + let result = + tauri::async_runtime::spawn_blocking(move || prepared.apply(&journal, &location)).await; + match result { + Ok(Ok(installed)) => { + trace("install-returned-verified"); + if installed.target().target_desktop_version == env!("CARGO_PKG_VERSION") { + return Err("An update cannot relaunch the same desktop version.".into()); + } + // Mutation has completed and AwaitingHealth is durable. Occluded + // WebKit pages may stop producing animation frames: do not strand + // a verified replacement waiting for another paint acknowledgement. + // The strict Applying paint barrier before mutation is unchanged. + handoff_verified_install( + &app.state::(), + || show(app, Screen::Restarting), + || { + trace("restart-requested"); + app.request_restart(); + }, + ); + Ok(()) + } + Ok(Err(ApplyError::Precondition)) => { + normal_start(app); + Ok(()) + } + _ => Err( + "Installation did not prove a complete replacement. Automatic retry is blocked.".into(), + ), + } +} + +fn stored_port_is_unoccupied(root: &Path) -> Result<(), String> { + let port = crate::desktop_origin::DesktopOrigin::load(root.to_owned())?.requested_port(); + if port == 0 { + return Ok(()); + } + match std::net::TcpStream::connect_timeout( + &std::net::SocketAddr::from(([127, 0, 0, 1], port)), + Duration::from_millis(250), + ) { + Err(error) if error.kind() == std::io::ErrorKind::ConnectionRefused => Ok(()), + _ => Err("The desktop origin is occupied or its previous owner is uncertain.".into()), + } +} + +pub(crate) async fn revalidate_successor(app: &AppHandle) -> Result, String> { + if app.state::().phase() != Phase::AwaitingHealth { + return Ok(None); + } + let app = app.clone(); + tauri::async_runtime::spawn_blocking(move || { + let gate = app.state::(); + let pending = gate + .pending + .lock() + .map_err(|_| "Update successor lock failed.")?; + let pending = pending + .as_ref() + .ok_or("Verified update successor disappeared.")?; + pending + .proof + .revalidate_bundle() + .map_err(|_| "Installed update changed during startup.")?; + pending.attempt.validate_startup(&pending.proof)?; + Ok(Some(pending.proof.target().clone())) + }) + .await + .map_err(|_| "Successor verification task failed.")? +} + +pub(crate) fn finish_health( + app: &AppHandle, + proof: &crate::supervisor::HealthyServer, +) -> Result<(), String> { + let gate = app.state::(); + let mut pending = gate + .pending + .lock() + .map_err(|_| "Update successor lock failed.")?; + let successor = pending + .take() + .ok_or("Verified update successor is missing.")?; + if let Err(error) = successor.attempt.finish(proof) { + gate.set_phase(Phase::Recovery); + return Err(error); + } + gate.set_phase(Phase::Normal); + trace("successor-health-committed"); + app.state::().clear(); + crate::resume_deep_links(app); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn automatic_checks_never_authorize_installation_without_matching_manual_intent() { + use crate::updater_store::{PreparedRecord, Store}; + use std::{fs, os::unix::fs::PermissionsExt}; + struct Temp(std::path::PathBuf); + impl Drop for Temp { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + let mut random = [0; 8]; + getrandom::getrandom(&mut random).unwrap(); + let root = Temp( + fs::canonicalize(std::env::temp_dir()) + .unwrap() + .join(format!( + "gajae-launch-policy-{:x}", + u64::from_ne_bytes(random) + )), + ); + fs::create_dir(&root.0).unwrap(); + fs::set_permissions(&root.0, fs::Permissions::from_mode(0o700)).unwrap(); + let record = PreparedRecord { + schema: 1, + release_id: 1, + manifest_asset_id: 2, + archive_asset_id: 3, + archive_size: 4, + archive_sha256: "a".repeat(64), + manifest: "{}".into(), + inventory: serde_json::json!({}), + }; + let store = Store::open(&root.0).unwrap(); + store + .commit(store.stage(&record, b"data").unwrap()) + .unwrap(); + assert!(store.preferences().unwrap().automatic); + assert!(!installation_requested( + store.consume_manual().unwrap().as_ref(), + &record + )); + let intent_path = root.0.join("desktop-update-cache/manual-intent.json"); + fs::write( + &intent_path, + serde_json::to_vec(&serde_json::json!({ + "schema":1,"archive_sha256":record.archive_sha256, + })) + .unwrap(), + ) + .unwrap(); + fs::set_permissions(&intent_path, fs::Permissions::from_mode(0o600)).unwrap(); + assert!( + !installation_requested(store.consume_manual().unwrap_or(None).as_ref(), &record), + "legacy unbound manual intent is not authority" + ); + store + .request_manual(&record.target_id(), &record.archive_sha256) + .unwrap(); + drop(store); + let store = Store::open(&root.0).unwrap(); + for automatic in [true, false] { + store.set_automatic(automatic).unwrap(); + store + .request_manual(&record.target_id(), &record.archive_sha256) + .unwrap(); + let request = store.consume_manual().unwrap(); + assert!( + installation_requested(request.as_ref(), &record), + "matching persisted manual intent admits the install gate" + ); + let changed = PreparedRecord { + archive_asset_id: 4, + ..record.clone() + }; + assert!(!installation_requested(request.as_ref(), &changed)); + assert!( + !installation_requested(store.consume_manual().unwrap().as_ref(), &record), + "a later ordinary launch cannot replay this click" + ); + } + } + #[test] + fn gate_starts_closed_and_only_one_install_can_claim_it() { + let gate = LaunchGate::default(); + assert!(gate.admit(Path::new("/does-not-exist")).is_err()); + assert!(gate.begin_install()); + assert!(!gate.begin_install()); + assert!(gate.admit(Path::new("/does-not-exist")).is_err()); + gate.set_phase(Phase::Recovery); + assert!(!gate.begin_install()); + } + #[test] + fn normal_admission_retains_the_existing_presence_guard() { + let gate = LaunchGate::default(); + gate.set_phase(Phase::Normal); + assert!(gate.admit(Path::new("/does-not-exist")).is_ok()); + gate.set_phase(Phase::AwaitingHealth); + assert!(gate.admit(Path::new("/does-not-exist")).is_err()); + } + + #[test] + fn verified_install_handoff_does_not_depend_on_another_paint_success() { + use std::cell::RefCell; + for presentation in [Ok(4), Err("window unavailable".to_owned())] { + let gate = LaunchGate::default(); + gate.set_phase(Phase::Installing); + let events = RefCell::new(Vec::new()); + handoff_verified_install( + &gate, + || { + assert_eq!(gate.phase(), Phase::Restarting); + events.borrow_mut().push("present"); + presentation + }, + || { + assert_eq!(gate.phase(), Phase::Restarting); + events.borrow_mut().push("restart"); + }, + ); + assert_eq!(*events.borrow(), ["present", "restart"]); + } + } +} diff --git a/src-tauri/src/updater_location.rs b/src-tauri/src/updater_location.rs new file mode 100644 index 00000000..53dca869 --- /dev/null +++ b/src-tauri/src/updater_location.rs @@ -0,0 +1,255 @@ +//! Validate the native-owned target before entering the official macOS installer. +//! No location, executable, key or temporary-directory override comes from IPC. +use std::{ + fs::{self, OpenOptions}, + io::Read, + os::unix::fs::{MetadataExt, OpenOptionsExt}, + path::{Component, Path, PathBuf}, +}; + +use crate::updater_binding::{Binding, Mode}; + +pub(crate) struct InstallLocation { + app: PathBuf, + executable: PathBuf, + temporary: PathBuf, + temporary_literal: PathBuf, + device: u64, + inode: u64, +} + +impl InstallLocation { + pub(crate) fn validate(binding: &Binding, executable: &Path) -> Result { + if binding.mode == Mode::Disabled { + return Err("Installation is disabled.".into()); + } + let canonical = + fs::canonicalize(executable).map_err(|_| "Installer executable is unavailable.")?; + if canonical != executable { + return Err("Installer executable must not be an alias.".into()); + } + let app = canonical + .parent() + .and_then(Path::parent) + .and_then(Path::parent) + .ok_or("Installer executable is not in an application.")? + .to_path_buf(); + let expected = app.join("Contents/MacOS").join(env!("CARGO_PKG_NAME")); + if canonical != expected + || app.file_name().and_then(|name| name.to_str()) + != Some(&format!("{}.app", env!("GJC_UPDATE_PRODUCT_NAME"))) + { + return Err("Installer target does not match the running product.".into()); + } + match binding.mode { + Mode::Disabled => return Err("Installation is disabled.".into()), + Mode::Qa => { + if binding + .qa_root + .as_ref() + .map(|root| root.join(format!("{}.app", env!("GJC_UPDATE_PRODUCT_NAME")))) + != Some(app.clone()) + { + return Err("Installer target is outside the compiled QA application.".into()); + } + } + Mode::Production => { + let in_system = app.parent() == Some(Path::new("/Applications")); + let in_user = std::env::var_os("HOME") + .map(PathBuf::from) + .and_then(|root| fs::canonicalize(root.join("Applications")).ok()) + .is_some_and(|root| app.parent() == Some(root.as_path())); + if !in_system && !in_user { + return Err("Move the application to Applications before updating.".into()); + } + } + } + validate_ancestors(&app)?; + let metadata = + fs::symlink_metadata(&app).map_err(|_| "Installer target is unavailable.")?; + if !metadata.is_dir() + || metadata.file_type().is_symlink() + || (metadata.uid() != 0 && metadata.uid() != unsafe { libc::geteuid() }) + { + return Err("Installer target ownership is unsupported.".into()); + } + let temporary_literal = std::env::temp_dir(); + safe_script_path(&temporary_literal)?; + let temporary = fs::canonicalize(&temporary_literal) + .map_err(|_| "Installer temporary directory is unavailable.")?; + // The pinned plugin interpolates paths in an AppleScript/shell string. + // Reject characters it cannot quote safely before authorization is possible. + safe_script_path(&app)?; + safe_script_path(&temporary)?; + same_volume(&metadata, &temporary)?; + writable_volume(&app)?; + writable_volume(&temporary)?; + validate_bundle_identity(&app)?; + let extracted = tauri_plugin_updater::extract_path_from_executable(&canonical) + .map_err(|_| "Official installer target could not be derived.")?; + if extracted != app { + return Err("Official installer target differs from the running application.".into()); + } + Ok(Self { + app, + executable: canonical, + temporary, + temporary_literal, + device: metadata.dev(), + inode: metadata.ino(), + }) + } + + pub(crate) fn app(&self) -> &Path { + &self.app + } + pub(crate) fn executable(&self) -> &Path { + &self.executable + } + + /// Immediately before mutation, require the same source directory and volume. + /// This is a race detector, not an atomic filesystem namespace guarantee. + pub(crate) fn revalidate(&self) -> Result<(), String> { + validate_ancestors(&self.app)?; + let metadata = fs::symlink_metadata(&self.app).map_err(|_| "Installer target changed.")?; + if metadata.dev() != self.device || metadata.ino() != self.inode { + return Err("Installer target changed.".into()); + } + let literal = std::env::temp_dir(); + safe_script_path(&literal)?; + if literal != self.temporary_literal { + return Err("Installer temporary directory spelling changed.".into()); + } + let temporary = + fs::canonicalize(&literal).map_err(|_| "Installer temporary directory changed.")?; + if temporary != self.temporary { + return Err("Installer temporary directory changed.".into()); + } + same_volume(&metadata, &temporary)?; + writable_volume(&self.app)?; + writable_volume(&temporary)?; + validate_bundle_identity(&self.app) + } +} + +fn safe_script_path(path: &Path) -> Result<(), String> { + let value = path.to_str().ok_or("Installer path must be UTF-8.")?; + if !path.is_absolute() + || value.len() > 4096 + || value + .chars() + .any(|c| c.is_control() || matches!(c, '\'' | '"' | '\\')) + { + return Err( + "Installer path cannot be safely passed to the official authorization dialog.".into(), + ); + } + Ok(()) +} + +fn same_volume(app: &fs::Metadata, temporary: &Path) -> Result<(), String> { + let metadata = + fs::metadata(temporary).map_err(|_| "Installer temporary directory is unavailable.")?; + if !metadata.is_dir() || metadata.dev() != app.dev() { + return Err("Application and installer temporary directory must share one volume.".into()); + } + Ok(()) +} + +fn writable_volume(path: &Path) -> Result<(), String> { + use std::{ffi::CString, mem::MaybeUninit, os::unix::ffi::OsStrExt}; + let path = + CString::new(path.as_os_str().as_bytes()).map_err(|_| "Installer volume is invalid.")?; + let mut info = MaybeUninit::::uninit(); + if unsafe { libc::statfs(path.as_ptr(), info.as_mut_ptr()) } != 0 { + return Err("Installer volume could not be inspected.".into()); + } + let info = unsafe { info.assume_init() }; + if info.f_flags & libc::MNT_RDONLY as u32 != 0 { + return Err("Installer volume is read-only.".into()); + } + Ok(()) +} + +fn validate_ancestors(path: &Path) -> Result<(), String> { + if !path.is_absolute() { + return Err("Installer target must be absolute.".into()); + } + let mut current = PathBuf::new(); + for component in path.components() { + if matches!(component, Component::ParentDir | Component::CurDir) { + return Err("Installer path is not canonical.".into()); + } + current.push(component); + let metadata = + fs::symlink_metadata(¤t).map_err(|_| "Installer path is unavailable.")?; + if !metadata.is_dir() || metadata.file_type().is_symlink() { + return Err("Installer path contains an alias or non-directory.".into()); + } + } + Ok(()) +} + +fn validate_bundle_identity(app: &Path) -> Result<(), String> { + validate_ancestors(&app.join("Contents"))?; + let mut file = OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_CLOEXEC) + .open(app.join("Contents/Info.plist")) + .map_err(|_| "Application identity is unavailable.")?; + let metadata = file + .metadata() + .map_err(|_| "Application identity is unavailable.")?; + if !metadata.is_file() || metadata.len() > 64 * 1024 { + return Err("Application identity is invalid.".into()); + } + let mut bytes = Vec::new(); + (&mut file) + .take(64 * 1024 + 1) + .read_to_end(&mut bytes) + .map_err(|_| "Application identity could not be read.")?; + if bytes.len() > 64 * 1024 { + return Err("Application identity is oversized.".into()); + } + crate::updater_archive::validate_installed_plist( + &bytes, + env!("GJC_UPDATE_BUNDLE_IDENTIFIER"), + env!("CARGO_PKG_NAME"), + env!("CARGO_PKG_VERSION"), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn authorization_paths_reject_script_metacharacters_not_innocent_spaces_or_unicode() { + for path in [ + "/Applications/Gajae Code App.app", + "/Users/사용자/Applications/Gajae Code App.app", + ] { + assert!(safe_script_path(Path::new(path)).is_ok()); + } + for path in [ + "/tmp/o'brien/A.app", + "/tmp/a\"b/A.app", + "/tmp/a\\b/A.app", + "/tmp/a\nb/A.app", + ] { + assert!(safe_script_path(Path::new(path)).is_err()); + } + } + + #[test] + fn disabled_and_out_of_bundle_targets_are_refused() { + let binding = Binding { + mode: Mode::Disabled, + feed_origin: String::new(), + public_key: String::new(), + qa_root: None, + }; + assert!(InstallLocation::validate(&binding, Path::new("/bin/sh")).is_err()); + assert!(InstallLocation::validate(&binding, Path::new("/does-not-exist")).is_err()); + } +} diff --git a/src-tauri/src/updater_owners.rs b/src-tauri/src/updater_owners.rs new file mode 100644 index 00000000..2c7a3e3d --- /dev/null +++ b/src-tauri/src/updater_owners.rs @@ -0,0 +1,2337 @@ +//! Read-only macOS packaged-owner evidence, never installation authority. +//! +//! Call under the native single-instance/startup gate and revalidate immediately +//! before the consuming action. Two bounded censuses detect observed changes; +//! libproc does NOT provide an atomic history or prevent unmanaged new starts. +//! A captured tree covers identities connected to the owned sidecar at capture, +//! not previously reparented processes or arbitrary escaped external daemons. +//! Only the compile-bound QA app can narrow packaged-owner vetoes to its whole +//! isolated QA root. Production and all other modes share the cross-installation +//! domain. This does not narrow PID enumeration or the captured tree. A foreign +//! executable may have path-bound exclusion evidence instead of BSD identity; +//! candidates/current/captured owners always require their full birth identity. +//! Unrelated PID/path churn and unclassified evidence still make either unknown. +//! Post-shutdown verification neither signals processes nor waits for idle. +//! The elapsed budget is checked around bounded operations; it cannot interrupt +//! an in-progress OS/filesystem call. Run off the UI thread. No successful proof +//! is returned after its budget, and no retry waits for the machine to go idle. +//! +//! ABI checked against the installed macOS SDK libproc.h/sys/proc_info.h and +//! Apple xnu libsyscall/wrappers/libproc/libproc.c + bsd/kern/proc_info.c: +//! proc_listpids returns BYTES (0 on wrapper failure); proc_pidinfo returns the +//! complete requested structure size; proc_pidpath returns strlen, excluding NUL. +//! Only ESRCH establishes a vanished PID. ENOENT/EPERM/zero/short reads do not. + +use std::{ + collections::{BTreeMap, BTreeSet, VecDeque}, + ffi::{CString, OsString}, + fs::File, + io::{self, Cursor, Read, Seek, SeekFrom}, + mem::{size_of, MaybeUninit}, + os::{ + fd::{AsRawFd, FromRawFd}, + unix::{ + ffi::{OsStrExt, OsStringExt}, + fs::MetadataExt, + }, + }, + path::{Component, Path, PathBuf}, + time::{Duration, Instant}, +}; + +use serde::de::{self, DeserializeSeed, MapAccess, SeqAccess, Visitor}; +use sha2::{Digest, Sha256}; + +use crate::updater_binding::{Binding, Mode}; + +const MAX_PIDS: usize = 8192; +const MAX_TREE: usize = 512; +const MAX_BUNDLES: usize = 256; +const MAX_PATH: usize = 4096; +const MAX_PATH_TOTAL: usize = 8 * 1024 * 1024; +const MAX_PLIST: usize = 64 * 1024; +const BUDGET: Duration = Duration::from_secs(2); +// These selectors are not exported by libc 0.2.186. Values are from proc_info.h. +const PROC_UID_ONLY: u32 = 4; +const PROC_RUID_ONLY: u32 = 5; +const PROC_PPID_ONLY: u32 = 6; +// Apple xnu bsd/sys/codesign.h and osfmk/kern/cs_blobs.h. CS_OPS_STATUS is +// read-only; no mark/kill/entitlement operation is used. Not exported by libc. +const CS_OPS_STATUS: u32 = 0; +const CS_VALID: u32 = 0x0000_0001; +const CS_PLATFORM_BINARY: u32 = 0x0400_0000; +const CS_DEBUGGED: u32 = 0x1000_0000; +unsafe extern "C" { + fn csops(pid: libc::pid_t, ops: u32, useraddr: *mut libc::c_void, usersize: usize) -> i32; +} +type Result = std::result::Result; + +fn unknown(reason: &'static str) -> String { + format!("Updater owner evidence is unknown: {reason}.") +} +fn require(value: bool, reason: &'static str) -> Result<()> { + if value { + Ok(()) + } else { + Err(unknown(reason)) + } +} + +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +struct Birth { + seconds: u64, + microseconds: u64, +} +#[derive(Clone, Copy, PartialEq, Eq)] +struct Identity { + pid: u32, + parent: u32, + uid: u32, + real_uid: u32, + birth: Birth, + zombie: bool, +} +impl Identity { + fn same_lifetime(&self, other: &Self) -> bool { + self.pid == other.pid + && self.uid == other.uid + && self.real_uid == other.real_uid + && self.birth == other.birth + } +} +#[derive(Clone, PartialEq, Eq)] +struct Process { + identity: Identity, + executable: PathBuf, +} +#[derive(Clone, PartialEq, Eq)] +struct Bundle { + identifier: String, + // Present only after validating OUR complete APPL/executable identity. + // A foreign helper's identifier is enough; its other fields are not ours. + executable: Option, + digest: [u8; 32], +} +#[derive(Clone, Copy)] +enum Selection { + Effective(u32), + Real(u32), + Children(u32), +} +struct Listing { + pids: Vec, + complete: bool, +} + +/// No public probe injection: only this module's tests can mint synthetic evidence. +trait Probe { + fn current_pid(&self) -> u32; + fn current_uid(&self) -> u32; + fn list(&mut self, selection: Selection) -> Result; + fn identity(&mut self, pid: u32) -> Result>; + fn executable(&mut self, pid: u32) -> Result>; + fn bundle(&mut self, root: &Path) -> Result; + fn code_status(&mut self, pid: u32) -> Result; +} + +struct Product { + identifier: &'static str, + executable: &'static str, + binding: Binding, +} +impl Product { + fn compiled() -> Self { + Self { + identifier: env!("GJC_UPDATE_BUNDLE_IDENTIFIER"), + executable: env!("CARGO_PKG_NAME"), + binding: Binding::compiled(), + } + } +} + +// No domain/Binding argument is exposed by a proof API. Production entrypoints +// construct Product::compiled; only private injectable tests can supply a binding. +enum OwnerDomain { + Shared, + QaRoot(PathBuf), +} +impl OwnerDomain { + fn for_current_app(product: &Product, current_app: &Path) -> Result { + if product.binding.mode != Mode::Qa { + return Ok(Self::Shared); + } + let root = product + .binding + .qa_root + .as_deref() + .ok_or_else(|| unknown("compiled QA root missing"))?; + valid_path(root)?; + let name = format!("{}.app", env!("GJC_UPDATE_PRODUCT_NAME")); + require( + root.parent().is_some() + && Path::new(&name).components().count() == 1 + && matches!(Path::new(&name).components().next(), Some(Component::Normal(_))) + // Byte equality, not canonicalization or string-prefix matching: + // a copy, alias spelling or JS path cannot select the QA domain. + && current_app.as_os_str() == root.join(name).as_os_str(), + "current application does not match compiled QA root", + )?; + Ok(Self::QaRoot(root.to_path_buf())) + } + + fn includes(&self, process: &Process) -> bool { + self.includes_path(&process.executable) + } + + fn includes_path(&self, executable: &Path) -> bool { + match self { + Self::Shared => true, + // Path component boundary includes every nested/renamed installation + // and orphan within the root, never an adjacent prefix-lookalike root. + Self::QaRoot(root) => executable.starts_with(root), + } + } +} + +struct ScanScope<'a> { + product: &'a Product, + domain: OwnerDomain, + // Never substitute foreign-path evidence for a required birth identity. + required: BTreeSet, +} + +#[derive(PartialEq, Eq)] +enum ForeignBasis { + OutsideQaRoot, + BundleIdentifiers, + ApplePlatform(u32), +} +#[derive(PartialEq, Eq)] +struct ForeignProcess { + executable: PathBuf, + basis: ForeignBasis, +} + +#[derive(PartialEq, Eq)] +struct Census { + // Retain raw membership too: disappearing short-lived PIDs must not be + // dropped into two falsely equal live-record snapshots under churn. + listed: BTreeSet, + processes: BTreeMap, + foreign: BTreeMap, + bundles: BTreeMap, +} + +/// Process-bound observation, not Clone/Serialize/Deserialize or browser input. +#[must_use] +pub(crate) struct OwnerAbsence { + creator: Identity, + current_app: PathBuf, +} + +/// Captured, birth-bound owned sidecar tree. Never accepts a saved JSON PID list. +#[must_use] +pub(crate) struct ServerTree { + creator: Identity, + current_app: PathBuf, + root: Identity, + members: BTreeMap, +} + +pub(crate) fn prove_no_packaged_owners(current_app: &Path) -> Result { + prove_absence(&mut Native, &Product::compiled(), current_app, None) +} + +impl OwnerAbsence { + pub(crate) fn revalidate(&self) -> Result<()> { + prove_absence( + &mut Native, + &Product::compiled(), + &self.current_app, + Some(self.creator), + ) + .map(|_| ()) + } +} + +/// Both arguments are native supervisor values. The parent must be THIS process, +/// and the server must independently prove its live packaged-child identity. +pub(crate) fn capture_owned_server(server_pid: u32, parent_pid: u32) -> Result { + capture(&mut Native, &Product::compiled(), server_pid, parent_pid) +} + +impl ServerTree { + /// One fresh check. False means a captured birth identity is still present; + /// incomplete/ambiguous enumeration is Err. No sleep, signal, kill or health I/O. + pub(crate) fn all_gone(&self) -> Result { + all_gone(&mut Native, &Product::compiled(), self) + } + + /// Optional precommit freshness check; new/reparented descendants invalidate it. + pub(crate) fn revalidate(&self) -> Result<()> { + let fresh = capture( + &mut Native, + &Product::compiled(), + self.root.pid, + self.creator.pid, + )?; + require( + fresh.creator.same_lifetime(&self.creator) && fresh.members == self.members, + "captured server tree changed", + ) + } +} + +struct Deadline(Instant); +impl Deadline { + fn new() -> Self { + Self(Instant::now()) + } + fn check(&self) -> Result<()> { + require(self.0.elapsed() < BUDGET, "observation budget exceeded") + } +} + +fn validated_list( + probe: &mut impl Probe, + selection: Selection, + deadline: &Deadline, +) -> Result> { + deadline.check()?; + let listing = probe.list(selection)?; + deadline.check()?; + require( + listing.complete && listing.pids.len() <= MAX_PIDS, + "incomplete process enumeration", + )?; + let mut result = BTreeSet::new(); + for pid in listing.pids { + require( + pid > 0 && pid <= i32::MAX as u32 && result.insert(pid), + "invalid or duplicate PID enumeration", + )?; + } + Ok(result) +} + +fn read_process(probe: &mut impl Probe, pid: u32, deadline: &Deadline) -> Result> { + deadline.check()?; + let Some(before) = probe.identity(pid)? else { + return Ok(None); + }; + read_process_from_identity(probe, pid, before, deadline) +} + +fn read_process_from_identity( + probe: &mut impl Probe, + pid: u32, + before: Identity, + deadline: &Deadline, +) -> Result> { + require( + before.pid == pid && before.birth.seconds != 0 && before.birth.microseconds < 1_000_000, + "invalid BSD process identity", + )?; + let Some(executable) = probe.executable(pid)? else { + return match probe.identity(pid)? { + None => Ok(None), + Some(_) => Err(unknown("executable vanished without process disappearance")), + }; + }; + valid_path(&executable)?; + let Some(after) = probe.identity(pid)? else { + return Ok(None); + }; + require( + before == after, + "PID identity changed during executable read", + )?; + deadline.check()?; + Ok(Some(Process { + identity: before, + executable, + })) +} + +fn app_roots(path: &Path) -> Vec { + let mut root = PathBuf::new(); + let mut roots = Vec::new(); + for component in path.components() { + root.push(component.as_os_str()); + if component + .as_os_str() + .as_bytes() + .to_ascii_lowercase() + .ends_with(b".app") + { + roots.push(root.clone()); + } + } + roots +} + +fn app_root(path: &Path) -> Option { + app_roots(path).pop() +} + +fn valid_path(path: &Path) -> Result<()> { + require( + path.is_absolute() + && path.as_os_str() == path.components().collect::().as_os_str() + && path.as_os_str().as_bytes().len() < MAX_PATH + && path.components().count() <= 128 + && !path + .components() + .any(|part| matches!(part, Component::ParentDir | Component::CurDir)), + "invalid executable or bundle path", + ) +} + +fn read_bundles( + probe: &mut impl Probe, + executable: &Path, + bundles: &mut BTreeMap, + deadline: &Deadline, +) -> Result<()> { + for root in app_roots(executable) { + if !bundles.contains_key(&root) { + require(bundles.len() < MAX_BUNDLES, "bundle identity count limit")?; + deadline.check()?; + bundles.insert(root.clone(), probe.bundle(&root)?); + } + } + deadline.check() +} + +fn system_executable_path(path: &Path) -> bool { + [ + "/System/Library", + "/usr/bin", + "/usr/sbin", + "/usr/libexec", + "/bin", + "/sbin", + ] + .iter() + .any(|root| path.starts_with(root) && path != Path::new(root)) +} + +fn foreign_path_evidence( + probe: &mut impl Probe, + pid: u32, + executable: &Path, + scope: &ScanScope<'_>, + bundles: &mut BTreeMap, + deadline: &Deadline, +) -> Result { + require( + !reserved_role(executable, scope.product), + "candidate executable requires BSD birth identity", + )?; + if !scope.domain.includes_path(executable) { + // Native compile-bound domain only. No out-of-scope Info.plist access + // is necessary; the complete PID list and repeated native path remain. + return Ok(ForeignBasis::OutsideQaRoot); + } + if system_executable_path(executable) { + let flags = probe.code_status(pid)?; + require( + flags & (CS_VALID | CS_PLATFORM_BINARY) == (CS_VALID | CS_PLATFORM_BINARY) + && flags & CS_DEBUGGED == 0, + "system executable lacks valid platform code identity", + )?; + // A pathname/name alone is not enough. The running image must also have + // kernel platform identity. This excludes its executable role, not any + // arbitrary script/daemon it might own; captured descendants stay strict. + return Ok(ForeignBasis::ApplePlatform(flags)); + } + require( + !app_roots(executable).is_empty(), + "executable has no proven foreign identity", + )?; + read_bundles(probe, executable, bundles, deadline)?; + require( + !is_packaged_path(executable, bundles, scope.product)?, + "product executable requires BSD birth identity", + )?; + Ok(ForeignBasis::BundleIdentifiers) +} + +fn scan(probe: &mut impl Probe, scope: &ScanScope<'_>, deadline: &Deadline) -> Result { + let uid = probe.current_uid(); + require(uid != 0, "elevated owner is unsupported")?; + let mut pids = validated_list(probe, Selection::Effective(uid), deadline)?; + pids.extend(validated_list(probe, Selection::Real(uid), deadline)?); + require( + pids.len() <= MAX_PIDS && pids.contains(&probe.current_pid()), + "incomplete same-user census", + )?; + let mut census = Census { + listed: pids.clone(), + processes: BTreeMap::new(), + foreign: BTreeMap::new(), + bundles: BTreeMap::new(), + }; + let mut path_bytes = 0usize; + for pid in pids { + deadline.check()?; + // Path first permits positive foreign-role evidence for protected RUID + // helpers. Neither an unreadable BSD record nor RUID membership itself + // is an exclusion, and both unavailable always remain unknown. + let Some(executable) = probe.executable(pid)? else { + require( + probe.identity(pid)?.is_none(), + "path vanished without BSD disappearance", + )?; + continue; + }; + valid_path(&executable)?; + path_bytes = path_bytes + .checked_add(executable.as_os_str().as_bytes().len()) + .ok_or_else(|| unknown("path byte limit"))?; + require(path_bytes <= MAX_PATH_TOTAL, "path byte limit")?; + let before = match probe.identity(pid) { + Ok(Some(before)) => before, + Ok(None) => continue, // ESRCH only, never an unreadable identity. + Err(error) => { + if pid == probe.current_pid() || scope.required.contains(&pid) { + return Err(error); + } + let basis = foreign_path_evidence( + probe, + pid, + &executable, + scope, + &mut census.bundles, + deadline, + )?; + let after = probe + .executable(pid)? + .ok_or_else(|| unknown("foreign executable vanished during exclusion proof"))?; + valid_path(&after)?; + require( + after.as_os_str() == executable.as_os_str(), + "foreign executable changed during exclusion proof", + )?; + census + .foreign + .insert(pid, ForeignProcess { executable, basis }); + continue; + } + }; + if let Some(process) = read_process_from_identity(probe, pid, before, deadline)? { + require( + process.executable == executable, + "executable changed before BSD identity", + )?; + require( + process.identity.uid == uid || process.identity.real_uid == uid, + "UID changed during enumeration", + )?; + if scope.domain.includes(&process) { + read_bundles(probe, &process.executable, &mut census.bundles, deadline)?; + } + census.processes.insert(pid, process); + } + } + deadline.check()?; + Ok(census) +} + +fn stable_census( + probe: &mut impl Probe, + scope: &ScanScope<'_>, + deadline: &Deadline, +) -> Result { + let first = scan(probe, scope, deadline)?; + let second = scan(probe, scope, deadline)?; + require(first == second, "process or bundle census changed")?; + Ok(second) +} + +/// Tail guard after plist/tree work. This is a fixed additional sample, not a +/// retry-until-idle loop; newly observed processes/execs make the proof unknown. +fn revalidate_census( + probe: &mut impl Probe, + scope: &ScanScope<'_>, + census: &Census, + deadline: &Deadline, +) -> Result<()> { + // Recheck positive foreign evidence too, not only birth-accounted processes. + let fresh = scan(probe, scope, deadline)?; + require( + fresh == *census, + "process, foreign evidence or bundle changed after census", + ) +} + +fn creator( + census: &Census, + probe: &impl Probe, + product: &Product, + current_app: &Path, +) -> Result { + valid_path(current_app)?; + let current = census + .processes + .get(&probe.current_pid()) + .ok_or_else(|| unknown("current process missing"))?; + require( + current.identity.uid == probe.current_uid() + && current.identity.real_uid == probe.current_uid() + && !current.identity.zombie, + "current process ownership is ambiguous", + )?; + require( + current.executable == current_app.join("Contents/MacOS").join(product.executable), + "current process is not the native packaged app", + )?; + let bundle = census + .bundles + .get(current_app) + .ok_or_else(|| unknown("current bundle identity missing"))?; + require( + bundle.identifier == product.identifier + && bundle.executable.as_deref() == Some(product.executable), + "current product bundle identity mismatch", + )?; + Ok(current.identity) +} + +fn reserved_role(executable: &Path, product: &Product) -> bool { + let basename = executable + .file_name() + .map(|value| value.as_bytes()) + .unwrap_or_default(); + let reserved_name = [ + product.executable.as_bytes(), + b"gajae-app-server", + b"gajae-core", + ] + .contains(&basename); + let roots = app_roots(executable); + reserved_name + || roots.iter().any(|root| { + [ + "Contents/Resources/server-payload", + "Contents/Resources/resources/server-payload", + ] + .iter() + .any(|base| { + ["node/bin/node", "dist-native/bun", "dist-native/gajae-core"] + .iter() + .any(|role| executable == root.join(base).join(role)) + }) + }) +} + +fn is_packaged_path( + executable: &Path, + bundles: &BTreeMap, + product: &Product, +) -> Result { + let reserved = reserved_role(executable, product); + let roots = app_roots(executable); + if roots.is_empty() { + // An unpackaged generic node/bun is outside this proof's scope. A + // reserved product executable without bundle identity is ambiguous. + require(!reserved, "reserved executable has no packaged identity")?; + return Ok(false); + } + for root in &roots { + let bundle = bundles + .get(root) + .ok_or_else(|| unknown("bundle identity missing"))?; + if bundle.identifier == product.identifier { + require( + bundle.executable.as_deref() == Some(product.executable), + "product bundle executable identity is ambiguous", + )?; + // Inspect every app ancestor: a copied installation can itself be + // nested in a foreign bundle, and helpers can have their own plist. + return Ok(true); + } + } + require( + !reserved, + "reserved executable conflicts with foreign bundle identity", + )?; + Ok(false) +} + +fn deny_other_packaged( + census: &Census, + product: &Product, + domain: &OwnerDomain, + current_pid: u32, + allowed_tree: &BTreeMap, +) -> Result<()> { + for (&pid, process) in &census.processes { + if pid == current_pid + || allowed_tree.get(&pid) == Some(process) + || !domain.includes(process) + { + continue; + } + if is_packaged_path(&process.executable, &census.bundles, product)? { + return Err("A packaged Gajae owner is still present.".into()); + } + } + Ok(()) +} + +fn prove_absence( + probe: &mut impl Probe, + product: &Product, + current_app: &Path, + expected: Option, +) -> Result { + let deadline = Deadline::new(); + let scope = ScanScope { + product, + domain: OwnerDomain::for_current_app(product, current_app)?, + required: BTreeSet::from([probe.current_pid()]), + }; + let census = stable_census(probe, &scope, &deadline)?; + let owner = creator(&census, probe, product, current_app)?; + if let Some(expected) = expected { + require( + owner.same_lifetime(&expected), + "proof used by a different process incarnation", + )?; + } + deny_other_packaged(&census, product, &scope.domain, owner.pid, &BTreeMap::new())?; + revalidate_census(probe, &scope, &census, &deadline)?; + deadline.check()?; + Ok(OwnerAbsence { + creator: owner, + current_app: current_app.to_path_buf(), + }) +} + +fn is_server_path(path: &Path, app: &Path) -> bool { + [ + "Contents/MacOS/gajae-app-server", + "Contents/Resources/server-payload/node/bin/node", + "Contents/Resources/resources/server-payload/node/bin/node", + ] + .iter() + .any(|role| path == app.join(role)) +} + +fn tree( + probe: &mut impl Probe, + census: &Census, + root: Identity, + deadline: &Deadline, +) -> Result> { + let mut members = BTreeMap::new(); + let mut queue = VecDeque::from([root.pid]); + while let Some(pid) = queue.pop_front() { + require(members.len() < MAX_TREE, "owned tree size limit")?; + let node = census + .processes + .get(&pid) + .ok_or_else(|| unknown("owned child missing from same-user census"))?; + require( + node.identity.uid == root.uid && node.identity.real_uid == root.uid, + "owned child UID mismatch", + )?; + require( + members.insert(pid, node.clone()).is_none(), + "cyclic owned tree", + )?; + // PPID enumeration is not UID-filtered: a cross-UID child must fail, + // not vanish silently from a same-UID-only traversal. + let children = validated_list(probe, Selection::Children(pid), deadline)?; + let expected: BTreeSet<_> = census + .processes + .values() + .filter(|other| other.identity.parent == pid) + .map(|other| other.identity.pid) + .collect(); + require( + children == expected, + "child enumeration is incomplete or changed", + )?; + for child in children { + let child_node = census + .processes + .get(&child) + .ok_or_else(|| unknown("child identity missing"))?; + require( + child_node.identity.birth >= node.identity.birth, + "parent PID incarnation does not own child", + )?; + queue.push_back(child); + } + let current = probe + .identity(pid)? + .ok_or_else(|| unknown("owned parent vanished during capture"))?; + require( + current == node.identity, + "owned parent identity changed during capture", + )?; + } + Ok(members) +} + +fn capture( + probe: &mut impl Probe, + product: &Product, + server_pid: u32, + parent_pid: u32, +) -> Result { + require( + parent_pid == probe.current_pid() && server_pid > 0 && server_pid != parent_pid, + "untrusted server parent or PID", + )?; + let deadline = Deadline::new(); + let anchor = read_process(probe, parent_pid, &deadline)? + .ok_or_else(|| unknown("native app identity unavailable"))?; + let current_app = + app_root(&anchor.executable).ok_or_else(|| unknown("native app location unavailable"))?; + let scope = ScanScope { + product, + domain: OwnerDomain::for_current_app(product, ¤t_app)?, + required: BTreeSet::from([parent_pid, server_pid]), + }; + let first = scan(probe, &scope, &deadline)?; + let owner = creator(&first, probe, product, ¤t_app)?; + require( + owner == anchor.identity, + "native app identity changed before census", + )?; + let server = first + .processes + .get(&server_pid) + .ok_or_else(|| unknown("owned server unavailable"))?; + // Same predicates as updater_attempt::owned_server_identity, plus executable + // role and parent-birth checks; no PID supplied by IPC can bypass ownership. + require( + server.identity.parent == owner.pid + && server.identity.uid == owner.uid + && server.identity.real_uid == owner.uid + && !server.identity.zombie + && server.identity.birth >= owner.birth + && is_server_path(&server.executable, ¤t_app), + "server is not our live packaged child", + )?; + let members = tree(probe, &first, server.identity, &deadline)?; + let second = scan(probe, &scope, &deadline)?; + require( + first == second, + "process or bundle census changed during capture", + )?; + require( + tree(probe, &second, server.identity, &deadline)? == members, + "owned tree changed during capture", + )?; + deny_other_packaged(&second, product, &scope.domain, owner.pid, &members)?; + revalidate_census(probe, &scope, &second, &deadline)?; + deadline.check()?; + Ok(ServerTree { + creator: owner, + current_app, + root: server.identity, + members, + }) +} + +fn all_gone(probe: &mut impl Probe, product: &Product, captured: &ServerTree) -> Result { + let deadline = Deadline::new(); + let scope = ScanScope { + product, + domain: OwnerDomain::for_current_app(product, &captured.current_app)?, + required: captured + .members + .keys() + .copied() + .chain([probe.current_pid()]) + .collect(), + }; + let census = stable_census(probe, &scope, &deadline)?; + let current = creator(&census, probe, product, &captured.current_app)?; + require( + current.same_lifetime(&captured.creator), + "tree proof used by a different process incarnation", + )?; + let mut alive = false; + for process in captured.members.values() { + deadline.check()?; + if let Some(now) = probe.identity(process.identity.pid)? { + require( + census + .processes + .get(&now.pid) + .is_some_and(|entry| entry.identity == now), + "captured PID changed after census", + )?; + if now.birth == process.identity.birth { + require( + now.uid == process.identity.uid && now.real_uid == process.identity.real_uid, + "captured identity changed UID", + )?; + alive = true; // Reparenting and zombies are NOT disappearance. + } + // Different birth proves the old identity is gone, not that the new + // PID is harmless. The complete census below checks new packaged owners. + } + } + revalidate_census(probe, &scope, &census, &deadline)?; + if alive { + return Ok(false); + } + deny_other_packaged( + &census, + product, + &scope.domain, + current.pid, + &BTreeMap::new(), + )?; + deadline.check()?; + Ok(true) +} + +struct Native; +fn complete_bsd_read(count: i32, errno: Option) -> Result { + if count == size_of::() as i32 { + return Ok(true); + } + if count == 0 && errno == Some(libc::ESRCH) { + return Ok(false); + } + // Report only the failure class, never the PID or other process details. + // In particular, a protected same-UID process cannot become an absence. + Err(unknown(if count != 0 { + "BSD identity size was not exact" + } else if matches!(errno, Some(libc::EPERM | libc::EACCES)) { + "BSD identity permission denied" + } else if errno.is_none() || errno == Some(0) { + "BSD identity empty without ESRCH" + } else { + "BSD identity query failed without ESRCH" + })) +} +impl Probe for Native { + fn current_pid(&self) -> u32 { + std::process::id() + } + fn current_uid(&self) -> u32 { + unsafe { libc::geteuid() } + } + fn list(&mut self, selection: Selection) -> Result { + require( + unsafe { libc::getuid() } == self.current_uid(), + "set-UID observer is unsupported", + )?; + let (kind, value) = match selection { + Selection::Effective(uid) => (PROC_UID_ONLY, uid), + Selection::Real(uid) => (PROC_RUID_ONLY, uid), + Selection::Children(pid) => (PROC_PPID_ONLY, pid), + }; + let mut buffer = vec![0i32; MAX_PIDS + 1]; + let capacity = buffer.len() * size_of::(); + unsafe { + *libc::__error() = 0; + } + let bytes = unsafe { + libc::proc_listpids(kind, value, buffer.as_mut_ptr().cast(), capacity as i32) + }; + let errno = io::Error::last_os_error().raw_os_error().unwrap_or(0); + require( + bytes >= 0 && !(bytes == 0 && errno != 0), + "process listing failed", + )?; + require( + (bytes as usize) < capacity && bytes as usize % size_of::() == 0, + "truncated or malformed process listing", + )?; + buffer.truncate(bytes as usize / size_of::()); + require( + buffer.iter().all(|pid| *pid > 0), + "invalid PID in process listing", + )?; + Ok(Listing { + pids: buffer.into_iter().map(|pid| pid as u32).collect(), + complete: true, + }) + } + fn identity(&mut self, pid: u32) -> Result> { + require(pid > 0 && pid <= i32::MAX as u32, "invalid PID")?; + let mut info = MaybeUninit::::uninit(); + unsafe { + *libc::__error() = 0; + } + let count = unsafe { + libc::proc_pidinfo( + pid as i32, + libc::PROC_PIDTBSDINFO, + 0, + info.as_mut_ptr().cast(), + size_of::() as i32, + ) + }; + if !complete_bsd_read(count, io::Error::last_os_error().raw_os_error())? { + return Ok(None); + } + let info = unsafe { info.assume_init() }; + require( + info.pbi_pid == pid && info.pbi_start_tvsec != 0 && info.pbi_start_tvusec < 1_000_000, + "invalid BSD birth identity", + )?; + Ok(Some(Identity { + pid, + parent: info.pbi_ppid, + uid: info.pbi_uid, + real_uid: info.pbi_ruid, + birth: Birth { + seconds: info.pbi_start_tvsec, + microseconds: info.pbi_start_tvusec, + }, + zombie: info.pbi_status == libc::SZOMB, + })) + } + fn executable(&mut self, pid: u32) -> Result> { + let mut bytes = vec![0u8; libc::PROC_PIDPATHINFO_MAXSIZE as usize]; + unsafe { + *libc::__error() = 0; + } + let count = unsafe { + libc::proc_pidpath(pid as i32, bytes.as_mut_ptr().cast(), bytes.len() as u32) + }; + if count == 0 && io::Error::last_os_error().raw_os_error() == Some(libc::ESRCH) { + return Ok(None); + } + require( + count > 0 + && (count as usize) < bytes.len() - 1 + && bytes[count as usize] == 0 + && !bytes[..count as usize].contains(&0), + "executable path missing, denied or truncated", + )?; + bytes.truncate(count as usize); + Ok(Some(PathBuf::from(OsString::from_vec(bytes)))) + } + fn bundle(&mut self, root: &Path) -> Result { + read_bundle(root) + } + + fn code_status(&mut self, pid: u32) -> Result { + require( + pid > 0 && pid <= i32::MAX as u32, + "invalid code identity PID", + )?; + let mut flags = 0u32; + let status = unsafe { + csops( + pid as i32, + CS_OPS_STATUS, + (&mut flags as *mut u32).cast(), + size_of::(), + ) + }; + require(status == 0, "platform code identity unavailable")?; + Ok(flags) + } +} + +#[derive(PartialEq, Eq)] +struct Stamp { + device: u64, + inode: u64, + size: u64, + mode: u32, + uid: u32, + modified: (i64, i64), + changed: (i64, i64), +} +fn stamp(file: &File) -> Result { + let value = file + .metadata() + .map_err(|_| unknown("bundle metadata unavailable"))?; + Ok(Stamp { + device: value.dev(), + inode: value.ino(), + size: value.len(), + mode: value.mode(), + uid: value.uid(), + modified: (value.mtime(), value.mtime_nsec()), + changed: (value.ctime(), value.ctime_nsec()), + }) +} +fn open_at(parent: &File, name: &std::ffi::OsStr, directory: bool) -> Result { + let name = CString::new(name.as_bytes()).map_err(|_| unknown("invalid bundle component"))?; + let flags = libc::O_RDONLY + | libc::O_NOFOLLOW + | libc::O_CLOEXEC + | libc::O_NONBLOCK + | if directory { libc::O_DIRECTORY } else { 0 }; + let fd = unsafe { libc::openat(parent.as_raw_fd(), name.as_ptr(), flags) }; + if fd < 0 { + return Err(unknown("bundle component unavailable or aliased")); + } + Ok(unsafe { File::from_raw_fd(fd) }) +} +fn open_directory(path: &Path) -> Result { + valid_path(path)?; + let mut directory = File::open("/").map_err(|_| unknown("filesystem root unavailable"))?; + for part in path.components() { + if let Component::Normal(name) = part { + directory = open_at(&directory, name, true)?; + } + } + Ok(directory) +} +fn read_bundle(root: &Path) -> Result { + let app = open_directory(root)?; + let mut filesystem = MaybeUninit::::uninit(); + require( + unsafe { libc::fstatfs(app.as_raw_fd(), filesystem.as_mut_ptr()) } == 0, + "bundle filesystem unavailable", + )?; + let filesystem = unsafe { filesystem.assume_init() }; + require( + filesystem.f_flags & libc::MNT_LOCAL as u32 != 0, + "nonlocal bundle filesystem is unsupported", + )?; + let app_before = stamp(&app)?; + let contents = open_at(&app, std::ffi::OsStr::new("Contents"), true)?; + let contents_before = stamp(&contents)?; + let mut file = open_at(&contents, std::ffi::OsStr::new("Info.plist"), false)?; + let before = stamp(&file)?; + require( + before.mode & u32::from(libc::S_IFMT) == u32::from(libc::S_IFREG) + && before.size > 0 + && before.size <= MAX_PLIST as u64, + "Info.plist is not a bounded regular file", + )?; + let mut bytes = Vec::new(); + (&mut file) + .take(MAX_PLIST as u64 + 1) + .read_to_end(&mut bytes) + .map_err(|_| unknown("Info.plist read failed"))?; + require( + bytes.len() == before.size as usize && bytes.len() <= MAX_PLIST && stamp(&file)? == before, + "Info.plist changed while reading", + )?; + let bundle = parse_bundle(&bytes)?; + require( + stamp(&open_at( + &contents, + std::ffi::OsStr::new("Info.plist"), + false, + )?)? == before + && stamp(&contents)? == contents_before + && stamp(&open_at(&app, std::ffi::OsStr::new("Contents"), true)?)? == contents_before + && stamp(&app)? == app_before + && stamp(&open_directory(root)?)? == app_before, + "bundle bindings changed while reading", + )?; + Ok(bundle) +} + +// Mirrors updater_archive's bounded, duplicate-free visitor and strict XML +// footer check. Its public validate_installed_plist requires a known version; +// owner discovery must also recognize OTHER installed versions/copy names. +enum Metadata { + Text(String), + Map(BTreeMap), + Other, +} +struct Budget { + nodes: usize, + bytes: usize, +} +struct Seed<'a> { + depth: usize, + budget: &'a mut Budget, +} +impl<'de> DeserializeSeed<'de> for Seed<'_> { + type Value = Metadata; + fn deserialize>( + self, + deserializer: D, + ) -> std::result::Result { + if self.depth > 32 || self.budget.nodes == 0 { + return Err(de::Error::custom("metadata limit")); + } + self.budget.nodes -= 1; + deserializer.deserialize_any(self) + } +} +impl Seed<'_> { + fn charge(&mut self, length: usize) -> std::result::Result<(), E> { + self.budget.bytes = self + .budget + .bytes + .checked_sub(length) + .ok_or_else(|| E::custom("metadata limit"))?; + Ok(()) + } +} +impl<'de> Visitor<'de> for Seed<'_> { + type Value = Metadata; + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("bounded bundle metadata") + } + fn visit_str(mut self, value: &str) -> std::result::Result { + self.charge(value.len())?; + Ok(Metadata::Text(value.to_owned())) + } + fn visit_string(mut self, value: String) -> std::result::Result { + self.charge(value.len())?; + Ok(Metadata::Text(value)) + } + fn visit_bool(self, _: bool) -> std::result::Result { + Ok(Metadata::Other) + } + fn visit_u64(self, _: u64) -> std::result::Result { + Ok(Metadata::Other) + } + fn visit_i64(self, _: i64) -> std::result::Result { + Ok(Metadata::Other) + } + fn visit_f64(self, _: f64) -> std::result::Result { + Ok(Metadata::Other) + } + fn visit_unit(self) -> std::result::Result { + Ok(Metadata::Other) + } + fn visit_bytes(mut self, value: &[u8]) -> std::result::Result { + self.charge(value.len())?; + Ok(Metadata::Other) + } + fn visit_byte_buf(self, value: Vec) -> std::result::Result { + self.visit_bytes(&value) + } + fn visit_map>( + mut self, + mut map: A, + ) -> std::result::Result { + let mut values = BTreeMap::new(); + while let Some(key) = map.next_key::()? { + self.charge(key.len())?; + if values.contains_key(&key) { + return Err(de::Error::custom("duplicate metadata")); + } + let value = map.next_value_seed(Seed { + depth: self.depth + 1, + budget: self.budget, + })?; + values.insert(key, value); + } + Ok(Metadata::Map(values)) + } + fn visit_seq>(self, mut seq: A) -> std::result::Result { + while seq + .next_element_seed(Seed { + depth: self.depth + 1, + budget: self.budget, + })? + .is_some() + {} + Ok(Metadata::Other) + } +} +struct Document(Metadata); +impl<'de> serde::Deserialize<'de> for Document { + fn deserialize>( + deserializer: D, + ) -> std::result::Result { + Seed { + depth: 0, + budget: &mut Budget { + nodes: 4096, + bytes: 4 * MAX_PLIST, + }, + } + .deserialize(deserializer) + .map(Self) + } +} +struct ExactXml<'a, 'b>(&'a mut Cursor<&'b [u8]>); +impl Read for ExactXml<'_, '_> { + fn read(&mut self, buffer: &mut [u8]) -> io::Result { + let length = buffer.len().min(1); + self.0.read(&mut buffer[..length]) + } +} +impl Seek for ExactXml<'_, '_> { + fn seek(&mut self, position: SeekFrom) -> io::Result { + self.0.seek(position) + } +} +fn parse_bundle(bytes: &[u8]) -> Result { + require( + !bytes.is_empty() && bytes.len() <= MAX_PLIST, + "Info.plist size limit", + )?; + let document: Document = if bytes.starts_with(b"bplist00") { + plist::from_reader(Cursor::new(bytes)) + .map_err(|_| unknown("invalid bounded binary plist"))? + } else { + let mut cursor = Cursor::new(bytes); + let parsed = plist::from_reader(ExactXml(&mut cursor)) + .map_err(|_| unknown("invalid bounded XML plist"))?; + require( + bytes[cursor.position() as usize..].trim_ascii() == b"", + "ambiguous plist footer", + )?; + parsed + }; + let Metadata::Map(values) = document.0 else { + return Err(unknown("plist root is not a dictionary")); + }; + let text = |name: &str| -> Result { + let Some(Metadata::Text(value)) = values.get(name) else { + return Err(unknown(match name { + "CFBundleIdentifier" => "bundle identifier missing or mistyped", + "CFBundleExecutable" => "bundle executable missing or mistyped", + "CFBundlePackageType" => "bundle package type missing or mistyped", + _ => "bundle identity field missing or mistyped", + })); + }; + require( + !value.is_empty() && value.len() <= 255 && !value.chars().any(char::is_control), + "invalid bundle identity string", + )?; + Ok(value.clone()) + }; + let identifier = text("CFBundleIdentifier")?; + let product = Product::compiled(); + let executable = if identifier == product.identifier { + let executable = text("CFBundleExecutable")?; + require( + text("CFBundlePackageType")? == "APPL" + && executable == product.executable + && Path::new(&executable).components().count() == 1 + && matches!( + Path::new(&executable).components().next(), + Some(Component::Normal(_)) + ), + "invalid application role identity", + )?; + Some(executable) + } else { + // The entire document above must still be bounded, unique and valid; + // the full digest below still participates in census stability. Do not + // impose our application schema on unrelated helpers. is_packaged also + // checks the actual executable path for conflicting reserved Gajae roles. + None + }; + Ok(Bundle { + identifier, + executable, + digest: Sha256::digest(bytes).into(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + const APP: &str = "/Applications/Gajae Code App.app"; + fn product() -> Product { + Product { + identifier: "app.gajae.desktop", + executable: "gajae-app-desktop", + binding: Binding { + mode: Mode::Production, + feed_origin: String::new(), + public_key: String::new(), + qa_root: None, + }, + } + } + fn process(pid: u32, parent: u32, seconds: u64, executable: &str) -> Process { + Process { + identity: Identity { + pid, + parent, + uid: 501, + real_uid: 501, + birth: Birth { + seconds, + microseconds: 1, + }, + zombie: false, + }, + executable: executable.into(), + } + } + fn bundle(identifier: &str, executable: &str) -> Bundle { + Bundle { + identifier: identifier.into(), + executable: Some(executable.into()), + digest: [0; 32], + } + } + struct Fake { + pid: u32, + records: BTreeMap, + bundles: BTreeMap, + partial: bool, + denied: BTreeSet, + paths_denied: BTreeSet, + code_flags: BTreeMap, + path_reads: BTreeMap>>, + flag_reads: BTreeMap>, + phantom: Vec, + extra_child: Option<(u32, u32)>, + reads: BTreeMap>>, + listing_calls: usize, + mutate_on_second: bool, + mutate_on_tail: bool, + gone_on_second: bool, + } + impl Fake { + fn new() -> Self { + Self { + pid: 10, + records: BTreeMap::from([( + 10, + process( + 10, + 1, + 100, + "/Applications/Gajae Code App.app/Contents/MacOS/gajae-app-desktop", + ), + )]), + bundles: BTreeMap::from([( + APP.into(), + bundle("app.gajae.desktop", "gajae-app-desktop"), + )]), + partial: false, + denied: BTreeSet::new(), + paths_denied: BTreeSet::new(), + code_flags: BTreeMap::new(), + path_reads: BTreeMap::new(), + flag_reads: BTreeMap::new(), + phantom: vec![], + extra_child: None, + reads: BTreeMap::new(), + listing_calls: 0, + mutate_on_second: false, + mutate_on_tail: false, + gone_on_second: false, + } + } + fn with_tree() -> Self { + let mut fake = Self::new(); + fake.records.insert( + 20, + process( + 20, + 10, + 110, + "/Applications/Gajae Code App.app/Contents/MacOS/gajae-app-server", + ), + ); + fake.records.insert(21, process(21, 20, 120, "/Applications/Gajae Code App.app/Contents/Resources/resources/server-payload/dist-native/bun")); + fake.records.insert(22, process(22, 21, 130, "/bin/sh")); + fake + } + } + impl Probe for Fake { + fn current_pid(&self) -> u32 { + self.pid + } + fn current_uid(&self) -> u32 { + 501 + } + fn list(&mut self, selection: Selection) -> Result { + self.listing_calls += 1; + if self.gone_on_second && self.listing_calls == 3 { + self.phantom.push(99); + } + if (self.mutate_on_second && self.listing_calls == 3) + || (self.mutate_on_tail && self.listing_calls == 5) + { + self.records + .insert(30, process(30, 1, 140, "/usr/bin/true")); + } + let mut pids: Vec<_> = self + .records + .values() + .filter(|process| match selection { + Selection::Effective(uid) => process.identity.uid == uid, + Selection::Real(uid) => process.identity.real_uid == uid, + Selection::Children(pid) => process.identity.parent == pid, + }) + .map(|process| process.identity.pid) + .collect(); + if let Selection::Children(pid) = selection { + if let Some((owner, child)) = self.extra_child { + if owner == pid { + pids.push(child); + } + } + } else { + pids.extend(&self.phantom); + } + Ok(Listing { + pids, + complete: !self.partial, + }) + } + fn identity(&mut self, pid: u32) -> Result> { + if self.denied.contains(&pid) { + return Err(unknown("permission denied")); + } + if let Some(queue) = self.reads.get_mut(&pid) { + if let Some(next) = queue.pop_front() { + return Ok(next); + } + } + Ok(self.records.get(&pid).map(|process| process.identity)) + } + fn executable(&mut self, pid: u32) -> Result> { + if self.paths_denied.contains(&pid) { + return Err(unknown("executable permission denied")); + } + if let Some(queue) = self.path_reads.get_mut(&pid) { + if let Some(path) = queue.pop_front() { + return Ok(path); + } + } + Ok(self + .records + .get(&pid) + .map(|process| process.executable.clone())) + } + fn bundle(&mut self, root: &Path) -> Result { + self.bundles + .get(root) + .cloned() + .ok_or_else(|| unknown("Info.plist unavailable")) + } + fn code_status(&mut self, pid: u32) -> Result { + if let Some(queue) = self.flag_reads.get_mut(&pid) { + if let Some(flags) = queue.pop_front() { + return Ok(flags); + } + } + self.code_flags + .get(&pid) + .copied() + .ok_or_else(|| unknown("code identity denied")) + } + } + + #[test] + fn only_current_native_app_pid_is_excluded_and_another_copy_blocks() { + let mut fake = Fake::new(); + assert!(prove_absence(&mut fake, &product(), Path::new(APP), None).is_ok()); + fake.records.insert( + 11, + process( + 11, + 1, + 200, + "/Volumes/Other/Copy.app/Contents/MacOS/gajae-app-desktop", + ), + ); + fake.bundles.insert( + "/Volumes/Other/Copy.app".into(), + bundle("app.gajae.desktop", "gajae-app-desktop"), + ); + assert!(prove_absence(&mut fake, &product(), Path::new(APP), None).is_err()); + } + + const QA_ROOT: &str = "/private/tmp/gjc-updater-owner-qa-fixture"; + fn qa_app() -> PathBuf { + Path::new(QA_ROOT).join(format!("{}.app", env!("GJC_UPDATE_PRODUCT_NAME"))) + } + fn qa_product() -> Product { + let mut product = product(); + product.binding.mode = Mode::Qa; + product.binding.qa_root = Some(QA_ROOT.into()); + product + } + fn qa_fixture(mut fake: Fake) -> Fake { + for process in fake.records.values_mut() { + if let Ok(role) = process.executable.strip_prefix(APP) { + process.executable = qa_app().join(role); + } + } + let identity = fake.bundles.remove(Path::new(APP)).unwrap(); + fake.bundles.insert(qa_app(), identity.clone()); + // The user's independent production app stays live throughout every + // synthetic QA scenario. No test has to stop it to manufacture absence. + fake.records.insert( + 90, + process( + 90, + 1, + 80, + &format!("{APP}/Contents/MacOS/gajae-app-desktop"), + ), + ); + fake.bundles.insert(APP.into(), identity); + fake + } + + #[test] + fn qa_domain_excludes_production_but_includes_other_copies_and_orphans_in_its_root() { + let mut fake = qa_fixture(Fake::new()); + assert!(prove_absence(&mut fake, &qa_product(), &qa_app(), None).is_ok()); + for role in [ + "Contents/MacOS/gajae-app-desktop", + "Contents/MacOS/gajae-app-server", + "Contents/Resources/server-payload/node/bin/node", + "Contents/Resources/resources/server-payload/dist-native/bun", + "Contents/Resources/resources/server-payload/dist-native/gajae-core", + ] { + let mut fake = qa_fixture(Fake::new()); + let copy = format!("{QA_ROOT}/old/nested/Renamed Copy.app"); + fake.records + .insert(30, process(30, 1, 200, &format!("{copy}/{role}"))); + fake.bundles.insert( + copy.into(), + bundle(product().identifier, product().executable), + ); + assert_eq!( + prove_absence(&mut fake, &qa_product(), &qa_app(), None).err(), + Some("A packaged Gajae owner is still present.".into()), + ); + } + } + + #[test] + fn qa_domain_requires_exact_compiled_app_and_other_modes_keep_shared_scope() { + let product = qa_product(); + for wrong in [ + PathBuf::from(APP), + Path::new(QA_ROOT).join("Other.app"), + Path::new(QA_ROOT) + .join("nested") + .join(qa_app().file_name().unwrap()), + PathBuf::from(format!( + "{QA_ROOT}/./{}", + qa_app().file_name().unwrap().to_str().unwrap() + )), + PathBuf::from(format!( + "{QA_ROOT}-other/{}", + qa_app().file_name().unwrap().to_str().unwrap() + )), + ] { + assert!(OwnerDomain::for_current_app(&product, &wrong).is_err()); + } + let domain = OwnerDomain::for_current_app(&product, &qa_app()).unwrap(); + assert!(!domain.includes(&process( + 90, + 1, + 80, + &format!("{QA_ROOT}-other/Copy.app/Contents/MacOS/gajae-app-desktop") + ))); + let mut unbound = qa_product(); + unbound.binding.qa_root = None; + assert!(OwnerDomain::for_current_app(&unbound, &qa_app()).is_err()); + for mode in [Mode::Production, Mode::Disabled] { + // Even a matching root field does not grant QA isolation in another mode. + let mut product = qa_product(); + product.binding.mode = mode; + let mut fake = qa_fixture(Fake::new()); + assert_eq!( + prove_absence(&mut fake, &product, &qa_app(), None).err(), + Some("A packaged Gajae owner is still present.".into()), + ); + } + } + + #[test] + fn qa_tree_joins_outside_root_descendants_without_requiring_production_shutdown() { + let mut fake = qa_fixture(Fake::with_tree()); + let captured = capture(&mut fake, &qa_product(), 20, 10).unwrap(); + assert_eq!(captured.members.len(), 3); + assert!(!captured.members.contains_key(&90)); + assert!(!all_gone(&mut fake, &qa_product(), &captured).unwrap()); + fake.records.remove(&20); + fake.records.remove(&21); + fake.records.get_mut(&22).unwrap().identity.parent = 1; + // /bin/sh is outside the QA root but its captured birth is still owned. + assert!(!all_gone(&mut fake, &qa_product(), &captured).unwrap()); + fake.records.remove(&22); + assert!(all_gone(&mut fake, &qa_product(), &captured).unwrap()); + assert!(fake.records.contains_key(&90)); + let copy = format!("{QA_ROOT}/old/Copy.app"); + fake.records.insert( + 30, + process( + 30, + 1, + 200, + &format!("{copy}/Contents/Resources/server-payload/dist-native/bun"), + ), + ); + fake.bundles.insert( + copy.into(), + bundle(product().identifier, product().executable), + ); + assert!(all_gone(&mut fake, &qa_product(), &captured).is_err()); + } + + #[test] + fn qa_scope_preserves_ambiguous_candidates_and_complete_foreign_census() { + let copy = format!("{QA_ROOT}/Old.app"); + for foreign_identity in [false, true] { + let mut fake = qa_fixture(Fake::new()); + fake.records.insert( + 30, + process( + 30, + 1, + 200, + &format!("{copy}/Contents/MacOS/gajae-app-server"), + ), + ); + if foreign_identity { + fake.bundles + .insert(copy.clone().into(), bundle("org.other", "other")); + } // Otherwise missing Info.plist for the in-scope orphan is unknown. + assert!(prove_absence(&mut fake, &qa_product(), &qa_app(), None).is_err()); + } + for case in 0..4 { + let mut fake = qa_fixture(Fake::new()); + match case { + 0 => fake.partial = true, + 1 => { + fake.denied.insert(90); + } + 2 => fake.mutate_on_second = true, + _ => { + let before = fake.records[&90].identity; + let mut after = before; + after.birth.microseconds += 1; + fake.reads + .insert(90, VecDeque::from([Some(before), Some(after)])); + } + } + assert!(prove_absence(&mut fake, &qa_product(), &qa_app(), None).is_err()); + } + } + + fn denied_platform_helper() -> Fake { + let mut fake = Fake::new(); + let mut helper = process(30, 1, 200, "/usr/bin/helper-fixture"); + helper.identity.uid = 0; // RUID-only, like the actual protected helper. + fake.records.insert(30, helper); + fake.denied.insert(30); + fake.code_flags.insert(30, CS_VALID | CS_PLATFORM_BINARY); + fake + } + + #[test] + fn ruid_only_bsd_denied_requires_stable_positive_platform_evidence() { + let mut fake = denied_platform_helper(); + let product = product(); + let scope = ScanScope { + product: &product, + domain: OwnerDomain::Shared, + required: BTreeSet::from([10]), + }; + let census = stable_census(&mut fake, &scope, &Deadline::new()).unwrap(); + assert!(census.listed.contains(&30)); // Never drop RUID-only membership. + assert!(!census.processes.contains_key(&30)); // No invented birth identity. + assert!(matches!( + census.foreign[&30].basis, + ForeignBasis::ApplePlatform(_) + )); + revalidate_census(&mut fake, &scope, &census, &Deadline::new()).unwrap(); + assert!(prove_absence(&mut fake, &product, Path::new(APP), None).is_ok()); + + for case in 0..6 { + let mut fake = denied_platform_helper(); + match case { + 0 => { + fake.code_flags.clear(); + } // Code-signing query denied. + 1 => { + fake.code_flags.insert(30, CS_VALID); + } // Name/path is not proof. + 2 => { + fake.code_flags.insert(30, CS_PLATFORM_BINARY); + } + 3 => { + fake.code_flags + .insert(30, CS_VALID | CS_PLATFORM_BINARY | CS_DEBUGGED); + } + 4 => { + fake.paths_denied.insert(30); + } // Both unavailable is unknown. + _ => { + fake.partial = true; + } + } + assert!(prove_absence(&mut fake, &product, Path::new(APP), None).is_err()); + } + } + + #[test] + fn foreign_path_or_platform_evidence_changes_in_any_sample_fail_closed() { + let path = PathBuf::from("/usr/bin/helper-fixture"); + let other = PathBuf::from("/usr/bin/different-helper-fixture"); + for change_at in [1, 2, 4] { + // Within first read, second census, and tail census. + let mut fake = denied_platform_helper(); + let mut reads = VecDeque::from(vec![Some(path.clone()); 6]); + reads[change_at] = Some(other.clone()); + fake.path_reads.insert(30, reads); + assert!(prove_absence(&mut fake, &product(), Path::new(APP), None).is_err()); + } + for flags in [0, CS_VALID | CS_PLATFORM_BINARY | 0x10] { + let mut fake = denied_platform_helper(); + fake.flag_reads + .insert(30, VecDeque::from([CS_VALID | CS_PLATFORM_BINARY, flags])); + assert!(prove_absence(&mut fake, &product(), Path::new(APP), None).is_err()); + } + let mut fake = denied_platform_helper(); + fake.path_reads + .insert(30, VecDeque::from([Some(path), None])); + assert!(prove_absence(&mut fake, &product(), Path::new(APP), None).is_err()); + let mut fake = denied_platform_helper(); + fake.path_reads.insert( + 30, + VecDeque::from([ + Some("/usr/bin/helper-fixture".into()), + Some("/usr/bin/./helper-fixture".into()), + ]), + ); + assert!(prove_absence(&mut fake, &product(), Path::new(APP), None).is_err()); + } + + #[test] + fn denied_foreign_bundle_needs_all_ancestor_ids_and_no_reserved_role() { + let mut fake = Fake::new(); + fake.records.insert( + 30, + process(30, 1, 200, "/Applications/Other.app/Contents/MacOS/helper"), + ); + fake.denied.insert(30); + let xml = String::from_utf8(plist_xml("org.other.helper")) + .unwrap() + .replace("CFBundlePackageTypeAPPL", ""); + fake.bundles.insert( + "/Applications/Other.app".into(), + parse_bundle(xml.as_bytes()).unwrap(), + ); + assert!(prove_absence(&mut fake, &product(), Path::new(APP), None).is_ok()); + fake.bundles.clear(); + assert!(prove_absence(&mut fake, &product(), Path::new(APP), None).is_err()); + fake.bundles.insert( + "/Applications/Other.app".into(), + bundle(product().identifier, product().executable), + ); + // Even a non-reserved helper name is a candidate under our identifier. + assert!(prove_absence(&mut fake, &product(), Path::new(APP), None).is_err()); + } + + #[test] + fn denied_candidate_or_suspicious_path_never_uses_foreign_fallback() { + for path in [ + "/Applications/Other.app/Contents/MacOS/gajae-app-desktop", + "/Applications/Other.app/Contents/MacOS/gajae-app-server", + "/Applications/Other.app/Contents/Resources/server-payload/node/bin/node", + "/Applications/Other.app/Contents/Resources/resources/server-payload/dist-native/bun", + "/usr/bin/gajae-core", + "/usr/bin-lookalike/helper-fixture", + "/private/tmp/helper-fixture", + "/usr/bin/./helper-fixture", + "/usr/bin/../bin/helper-fixture", + "/usr//bin/helper-fixture", + ] { + let mut fake = denied_platform_helper(); + fake.records.get_mut(&30).unwrap().executable = path.into(); + fake.bundles.insert( + "/Applications/Other.app".into(), + bundle("org.other", "helper"), + ); + assert!(prove_absence(&mut fake, &product(), Path::new(APP), None).is_err()); + } + } + + #[test] + fn current_and_captured_descendants_require_birth_even_with_platform_paths() { + let mut fake = Fake::with_tree(); + fake.code_flags.insert(22, CS_VALID | CS_PLATFORM_BINARY); + let captured = capture(&mut fake, &product(), 20, 10).unwrap(); + fake.denied.insert(22); + // The /bin/sh child cannot disappear from a PPID census via exclusion. + assert!(capture(&mut fake, &product(), 20, 10).is_err()); + assert!(all_gone(&mut fake, &product(), &captured).is_err()); + fake.denied.remove(&22); + fake.records.get_mut(&10).unwrap().executable = "/usr/bin/helper-fixture".into(); + fake.denied.insert(10); + fake.code_flags.insert(10, CS_VALID | CS_PLATFORM_BINARY); + assert!(prove_absence(&mut fake, &product(), Path::new(APP), None).is_err()); + } + + #[test] + fn qa_outside_paths_do_not_require_foreign_metadata_but_in_scope_paths_do() { + let mut fake = qa_fixture(Fake::new()); + fake.bundles.remove(Path::new(APP)); // Production Info.plist is not touched in QA. + fake.records.insert( + 30, + process( + 30, + 1, + 200, + "/Applications/Unknown.app/Contents/MacOS/helper", + ), + ); + fake.denied.insert(30); + assert!(prove_absence(&mut fake, &qa_product(), &qa_app(), None).is_ok()); + fake.records.get_mut(&30).unwrap().executable = + format!("{QA_ROOT}/Unknown.app/Contents/MacOS/helper").into(); + assert!(prove_absence(&mut fake, &qa_product(), &qa_app(), None).is_err()); + fake.paths_denied.insert(30); + assert!(prove_absence(&mut fake, &qa_product(), &qa_app(), None).is_err()); + } + + #[test] + fn packaged_server_bun_and_core_block_even_without_a_desktop_parent() { + for path in [ + "/Volumes/Copy.app/Contents/MacOS/gajae-app-server", + "/Volumes/Copy.app/Contents/Resources/server-payload/node/bin/node", + "/Volumes/Copy.app/Contents/Resources/resources/server-payload/dist-native/bun", + "/Volumes/Copy.app/Contents/Resources/resources/server-payload/dist-native/gajae-core", + ] { + let mut fake = Fake::new(); + fake.records.insert(20, process(20, 1, 200, path)); + fake.bundles.insert( + "/Volumes/Copy.app".into(), + bundle("app.gajae.desktop", "gajae-app-desktop"), + ); + assert!(prove_absence(&mut fake, &product(), Path::new(APP), None).is_err()); + } + } + + #[test] + fn unrelated_bun_and_foreign_bundles_are_not_product_owners_but_conflicts_are_unknown() { + let mut fake = Fake::new(); + fake.records + .insert(20, process(20, 1, 200, "/opt/homebrew/bin/bun")); + fake.records.insert( + 21, + process(21, 1, 200, "/Applications/Other.app/Contents/MacOS/bun"), + ); + fake.bundles.insert( + "/Applications/Other.app".into(), + bundle("org.other.app", "bun"), + ); + assert!(prove_absence(&mut fake, &product(), Path::new(APP), None).is_ok()); + fake.records.get_mut(&21).unwrap().executable = + "/Applications/Other.app/Contents/MacOS/gajae-app-server".into(); + assert!(prove_absence(&mut fake, &product(), Path::new(APP), None).is_err()); + } + + #[test] + fn partial_listing_missing_plist_and_permissions_never_prove_absence() { + let mut partial = Fake::new(); + partial.partial = true; + assert!(prove_absence(&mut partial, &product(), Path::new(APP), None).is_err()); + for path_error in [false, true] { + let mut fake = Fake::new(); + fake.records + .insert(20, process(20, 1, 200, "/usr/bin/true")); + if path_error { + fake.paths_denied.insert(20); + } else { + fake.denied.insert(20); + } + assert!(prove_absence(&mut fake, &product(), Path::new(APP), None).is_err()); + } + let mut fake = Fake::new(); + fake.bundles.clear(); + assert!(prove_absence(&mut fake, &product(), Path::new(APP), None).is_err()); + let mut foreign = Fake::new(); + foreign.records.insert( + 20, + process(20, 1, 200, "/Applications/Unknown.app/Contents/MacOS/other"), + ); + assert!(prove_absence(&mut foreign, &product(), Path::new(APP), None).is_err()); + } + + #[test] + fn over_limit_or_duplicate_pid_lists_are_not_deduplicated_into_complete_evidence() { + let mut fake = Fake::new(); + fake.phantom = (100..100 + MAX_PIDS as u32).collect(); + assert!(prove_absence(&mut fake, &product(), Path::new(APP), None).is_err()); + let mut duplicate = Fake::new(); + duplicate.phantom.push(10); + assert!(prove_absence(&mut duplicate, &product(), Path::new(APP), None).is_err()); + } + + #[test] + fn missing_pid_requires_esrch_and_short_bsd_reads_are_unknown() { + assert!(!complete_bsd_read(0, Some(libc::ESRCH)).unwrap()); + for errno in [ + None, + Some(0), + Some(libc::EPERM), + Some(libc::EACCES), + Some(libc::ENOENT), + Some(libc::EINVAL), + ] { + assert!(complete_bsd_read(0, errno).is_err()); + } + assert!(complete_bsd_read(1, Some(libc::ESRCH)).is_err()); + assert!(complete_bsd_read(size_of::() as i32, None).unwrap()); + let mut fake = Fake::new(); + fake.phantom.push(99); // Probe None represents ESRCH only. + assert!(prove_absence(&mut fake, &product(), Path::new(APP), None).is_ok()); + } + + #[test] + fn pid_reuse_between_bsd_and_executable_reads_is_unknown() { + let mut fake = Fake::new(); + let before = fake.records[&10].identity; + let mut after = before; + after.birth.microseconds += 1; + fake.reads + .insert(10, VecDeque::from([Some(before), Some(after)])); + assert!(read_process(&mut fake, 10, &Deadline::new()).is_err()); + } + + #[test] + fn changing_membership_or_invalid_path_invalidates_census() { + let mut fake = Fake::new(); + fake.mutate_on_second = true; + assert!(prove_absence(&mut fake, &product(), Path::new(APP), None).is_err()); + let mut tail = Fake::new(); + tail.mutate_on_tail = true; + assert!(prove_absence(&mut tail, &product(), Path::new(APP), None).is_err()); + let mut gone = Fake::new(); + gone.gone_on_second = true; + assert!(prove_absence(&mut gone, &product(), Path::new(APP), None).is_err()); + for path in ["relative/bun", "/Applications/../Other.app/x"] { + let mut fake = Fake::new(); + fake.records.insert(20, process(20, 1, 200, path)); + assert!(prove_absence(&mut fake, &product(), Path::new(APP), None).is_err()); + } + } + + #[test] + fn nested_installation_copy_is_detected_even_when_only_bun_remains() { + let mut fake = Fake::new(); + fake.records.insert(20, process(20, 1, 200, + "/Applications/Foreign.app/Contents/Resources/Copy.app/Contents/Resources/resources/server-payload/dist-native/bun")); + fake.bundles.insert( + "/Applications/Foreign.app".into(), + bundle("org.foreign", "Foreign"), + ); + fake.bundles.insert( + "/Applications/Foreign.app/Contents/Resources/Copy.app".into(), + bundle("app.gajae.desktop", "gajae-app-desktop"), + ); + assert!(prove_absence(&mut fake, &product(), Path::new(APP), None).is_err()); + } + + #[test] + fn captured_tree_includes_nonpackaged_children_and_reparenting_is_not_gone() { + let mut fake = Fake::with_tree(); + let captured = capture(&mut fake, &product(), 20, 10).unwrap(); + assert_eq!(captured.members.len(), 3); + fake.records.remove(&20); + fake.records.remove(&21); + fake.records.get_mut(&22).unwrap().identity.parent = 1; + assert!(!all_gone(&mut fake, &product(), &captured).unwrap()); + fake.records.remove(&22); + assert!(all_gone(&mut fake, &product(), &captured).unwrap()); + } + + #[test] + fn server_capture_rejects_arbitrary_parent_pid_role_uid_and_birth() { + for case in 0..6 { + let mut fake = Fake::with_tree(); + match case { + 0 => assert!(capture(&mut fake, &product(), 20, 999).is_err()), + 1 => { + fake.records.get_mut(&20).unwrap().identity.parent = 1; + assert!(capture(&mut fake, &product(), 20, 10).is_err()); + } + 2 => { + fake.records.get_mut(&20).unwrap().identity.uid = 0; + assert!(capture(&mut fake, &product(), 20, 10).is_err()); + } + 3 => { + fake.records.get_mut(&20).unwrap().identity.birth.seconds = 99; + assert!(capture(&mut fake, &product(), 20, 10).is_err()); + } + 4 => { + fake.records.get_mut(&20).unwrap().identity.zombie = true; + assert!(capture(&mut fake, &product(), 20, 10).is_err()); + } + _ => { + fake.records.get_mut(&20).unwrap().executable = "/usr/bin/node".into(); + assert!(capture(&mut fake, &product(), 20, 10).is_err()); + } + } + } + } + + #[test] + fn incomplete_or_cross_uid_child_enumeration_fails_capture() { + let mut fake = Fake::with_tree(); + fake.extra_child = Some((21, 99)); + assert!(capture(&mut fake, &product(), 20, 10).is_err()); + let mut fake = Fake::with_tree(); + fake.records.get_mut(&22).unwrap().identity.uid = 0; + assert!(capture(&mut fake, &product(), 20, 10).is_err()); + } + + #[test] + fn reused_pid_is_not_old_process_but_new_packaged_owner_still_blocks() { + let mut fake = Fake::with_tree(); + let captured = capture(&mut fake, &product(), 20, 10).unwrap(); + fake.records.remove(&21); + fake.records.remove(&22); + fake.records + .insert(20, process(20, 1, 300, "/usr/bin/true")); + assert!(all_gone(&mut fake, &product(), &captured).unwrap()); + fake.records.get_mut(&20).unwrap().executable = + "/Applications/Gajae Code App.app/Contents/MacOS/gajae-app-server".into(); + assert!(all_gone(&mut fake, &product(), &captured).is_err()); + } + + #[test] + fn permission_partial_listing_and_observer_reuse_block_post_shutdown_proof() { + let mut fake = Fake::with_tree(); + let captured = capture(&mut fake, &product(), 20, 10).unwrap(); + fake.records.remove(&20); + fake.records.remove(&21); + fake.records.remove(&22); + fake.denied.insert(22); + assert!(all_gone(&mut fake, &product(), &captured).is_err()); + fake.denied.clear(); + fake.partial = true; + assert!(all_gone(&mut fake, &product(), &captured).is_err()); + fake.partial = false; + fake.records + .get_mut(&10) + .unwrap() + .identity + .birth + .microseconds += 1; + assert!(all_gone(&mut fake, &product(), &captured).is_err()); + } + + fn plist_xml(identifier: &str) -> Vec { + format!("CFBundleIdentifier{identifier}CFBundleExecutablegajae-app-desktopCFBundlePackageTypeAPPLCFBundleShortVersionString0.1.0").into_bytes() + } + + #[test] + fn foreign_identifier_without_application_fields_is_excluded() { + // Helper bundles need not be applications. Only their unique bounded + // identifier discriminates them; their executable/type schema is not ours. + for fields in [ + "CFBundleExecutablehelper", + "", + "CFBundlePackageTypeBNDL", + "CFBundleExecutableCFBundlePackageType0", + ] { + let xml = format!("CFBundleIdentifierorg.other.helper{fields}"); + let parsed = parse_bundle(xml.as_bytes()).unwrap(); + let mut fake = Fake::new(); + fake.records.insert( + 20, + process(20, 1, 200, "/Applications/Other.app/Contents/MacOS/helper"), + ); + fake.bundles + .insert("/Applications/Other.app".into(), parsed); + assert!(prove_absence(&mut fake, &product(), Path::new(APP), None).is_ok()); + } + } + + #[test] + fn our_identifier_requires_full_exact_application_identity() { + let xml = String::from_utf8(plist_xml(product().identifier)).unwrap(); + for invalid in [ + xml.replace("CFBundlePackageTypeAPPL", ""), + xml.replace("APPL", "BNDL"), + xml.replace("APPL", ""), + xml.replace( + "CFBundleExecutablegajae-app-desktop", + "", + ), + xml.replace( + "gajae-app-desktop", + "other", + ), + xml.replace( + "gajae-app-desktop", + "./gajae-app-desktop", + ), + ] { + assert!(parse_bundle(invalid.as_bytes()).is_err()); + } + } + + #[test] + fn foreign_identifier_cannot_hide_reserved_product_roles_or_current_app() { + let xml = String::from_utf8(plist_xml("org.other.helper")) + .unwrap() + .replace("CFBundlePackageTypeAPPL", ""); + let foreign = parse_bundle(xml.as_bytes()).unwrap(); + for role in [ + "Contents/MacOS/gajae-app-desktop", + "Contents/MacOS/gajae-app-server", + "Contents/MacOS/gajae-core", + "Contents/Resources/server-payload/node/bin/node", + "Contents/Resources/server-payload/dist-native/bun", + "Contents/Resources/resources/server-payload/node/bin/node", + "Contents/Resources/resources/server-payload/dist-native/bun", + ] { + let mut fake = Fake::new(); + fake.records.insert( + 20, + process(20, 1, 200, &format!("/Applications/Other.app/{role}")), + ); + fake.bundles + .insert("/Applications/Other.app".into(), foreign.clone()); + assert_eq!( + prove_absence(&mut fake, &product(), Path::new(APP), None).err(), + Some(unknown( + "reserved executable conflicts with foreign bundle identity" + )), + ); + } + let mut fake = Fake::new(); + fake.bundles.insert(APP.into(), foreign); + assert_eq!( + prove_absence(&mut fake, &product(), Path::new(APP), None).err(), + Some(unknown("current product bundle identity mismatch")), + ); + } + + #[test] + fn foreign_classification_still_requires_unique_bounded_identifier_and_full_bytes() { + let xml = String::from_utf8(plist_xml("org.other.helper")) + .unwrap() + .replace("CFBundlePackageTypeAPPL", ""); + for invalid in [ + xml.replace( + "CFBundleIdentifierorg.other.helper", + "", + ), + xml.replace("org.other.helper", "1"), + xml.replace("org.other.helper", ""), + xml.replace("org.other.helper", &"x".repeat(256)), + xml.replace("org.other.helper", "org.other. helper"), + xml.replace( + "", + "CFBundleIdentifierorg.other.helper", + ), + xml.replace( + "", + "CFBundleIdentifierapp.gajae.desktop", + ), + xml.replace( + "", + "CFBundleExecutableother", + ), + ] { + assert!(parse_bundle(invalid.as_bytes()).is_err()); + } + let foreign = parse_bundle(xml.as_bytes()).unwrap(); + let changed = parse_bundle(xml.replace("0.1.0", "0.1.1").as_bytes()).unwrap(); + assert!(foreign != changed); // Even foreign metadata's full digest binds the census. + assert!(parse_bundle(&xml.as_bytes()[..xml.len() - 8]).is_err()); + assert!(parse_bundle(&[xml.as_bytes(), xml.as_bytes()].concat()).is_err()); + } + + #[test] + fn plist_identity_accepts_other_versions_but_rejects_duplicates_truncation_and_amplification() { + let xml = plist_xml("app.gajae.desktop"); + assert_eq!(parse_bundle(&xml).unwrap().identifier, "app.gajae.desktop"); + let duplicate = String::from_utf8(xml.clone()).unwrap().replace( + "", + "CFBundleIdentifierforeign", + ); + assert!(parse_bundle(duplicate.as_bytes()).is_err()); + assert!(parse_bundle(&xml[..xml.len() - 8]).is_err()); + assert!(parse_bundle(&[xml.clone(), xml].concat()).is_err()); + let mut value = + plist::Value::from_reader_xml(plist_xml("app.gajae.desktop").as_slice()).unwrap(); + value.as_dictionary_mut().unwrap().insert( + "large".into(), + plist::Value::Array(vec![plist::Value::String("x".repeat(4096)); 100]), + ); + let mut bytes = Vec::new(); + value.to_writer_binary(&mut bytes).unwrap(); + assert!(bytes.len() < MAX_PLIST); + assert!(parse_bundle(&bytes).is_err()); + } + + #[test] + fn bounded_filesystem_reader_rejects_symlink_directory_and_oversized_plist() { + let name = format!( + "gjc-owner-plist-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + ); + let scratch = std::env::temp_dir().join(name); + std::fs::create_dir(&scratch).unwrap(); + let scratch = scratch.canonicalize().unwrap(); + let app = scratch.join("Copy.app"); + std::fs::create_dir_all(app.join("Contents")).unwrap(); + let info = app.join("Contents/Info.plist"); + std::fs::write(&info, plist_xml("app.gajae.desktop")).unwrap(); + assert!(read_bundle(&app).is_ok()); + std::fs::rename(&info, scratch.join("identity.plist")).unwrap(); + std::os::unix::fs::symlink(scratch.join("identity.plist"), &info).unwrap(); + assert!(read_bundle(&app).is_err()); + std::fs::remove_file(&info).unwrap(); + std::fs::create_dir(&info).unwrap(); + assert!(read_bundle(&app).is_err()); + std::fs::remove_dir(&info).unwrap(); + std::fs::write(&info, vec![b'x'; MAX_PLIST + 1]).unwrap(); + assert!(read_bundle(&app).is_err()); + std::fs::remove_dir_all(scratch).unwrap(); + } + + #[test] + fn read_only_real_mac_libproc_abi_smoke() { + let mut probe = Native; + let pid = std::process::id(); + let identity = probe.identity(pid).unwrap().unwrap(); + assert_eq!(identity.pid, pid); + assert_eq!(identity.uid, unsafe { libc::geteuid() }); + assert!(identity.birth.seconds > 0 && identity.birth.microseconds < 1_000_000); + assert!(probe.executable(pid).unwrap().unwrap().is_absolute()); + let listing = validated_list( + &mut probe, + Selection::Effective(identity.uid), + &Deadline::new(), + ) + .unwrap(); + assert!(listing.contains(&pid)); + // No process names, paths, arguments, or credentials are printed, and + // this unbundled test runner cannot manufacture an app absence proof. + assert!(capture_owned_server(pid, pid).is_err()); + } + + #[test] + #[ignore = "Optional live census; process churn or inaccessible metadata must fail closed"] + fn read_only_real_mac_census_smoke() -> Result<()> { + let mut probe = Native; + let deadline = Deadline::new(); + let product = Product::compiled(); + // The unbundled runner cannot request a QA domain or mint a capability. + let scope = ScanScope { + product: &product, + domain: OwnerDomain::Shared, + required: BTreeSet::from([std::process::id()]), + }; + let census = stable_census(&mut probe, &scope, &deadline)?; + revalidate_census(&mut probe, &scope, &census, &deadline)?; + assert!(census.processes.contains_key(&std::process::id())); + eprintln!("Read-only same-UID census complete: {} BSD identities, {} positive foreign exclusions, {} bundle identities; no absence capability minted.", + census.processes.len(), census.foreign.len(), census.bundles.len()); + Ok(()) + } +} diff --git a/src-tauri/src/updater_restart.rs b/src-tauri/src/updater_restart.rs new file mode 100644 index 00000000..184e6e2c --- /dev/null +++ b/src-tauri/src/updater_restart.rs @@ -0,0 +1,901 @@ +//! Native-owned manual restart transaction. Browser data selects no installer, +//! path or URL. A sealed draft acknowledgement precedes backend admission; +//! only a committed idle proof may reach controlled shutdown. +use std::{ + sync::{ + atomic::{AtomicBool, AtomicU64, AtomicU8, Ordering}, + Arc, Mutex, + }, + time::{Duration, Instant}, +}; + +use serde::Serialize; +use tauri::{AppHandle, Manager}; + +use crate::{ + updater::{Phase as UpdatePhase, Snapshot}, + updater_backend::{Backend, Control, State as BackendState}, + updater_binding::{Binding, Mode}, + updater_location::InstallLocation, + updater_store::Store, +}; + +#[derive(Clone)] +pub(crate) struct Context { + pub target_id: String, + pub backend: Arc, + pub server_pid: u32, + pub return_url: tauri::Url, + pub current: Arc bool + Send + Sync>, + pub same_run: Arc bool + Send + Sync>, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +enum Phase { + Draft, + Preparing, + Navigating, + CommitSent, + Stopping, + Restarting, + Aborted, + Recovery, + AbortRequested, +} + +struct Attempt { + id: String, + draft_epoch: u64, + deadline: Instant, + phase: AtomicU8, + cancelled: AtomicBool, + context: Context, + archive_sha256: String, + desktop_version: String, + prepare_sent: AtomicBool, + displayed: AtomicBool, +} +#[derive(Debug, PartialEq, Eq)] +enum Cancellation { + Aborted, + Pending, + TooLate, +} +impl Attempt { + fn phase(&self) -> Phase { + match self.phase.load(Ordering::Acquire) { + 0 => Phase::Draft, + 1 => Phase::Preparing, + 2 => Phase::Navigating, + 3 => Phase::CommitSent, + 4 => Phase::Stopping, + 5 => Phase::Restarting, + 6 => Phase::Aborted, + 7 => Phase::Recovery, + _ => Phase::AbortRequested, + } + } + fn set(&self, phase: Phase) { + self.phase.store(phase as u8, Ordering::Release); + } + fn precommit(&self) -> bool { + matches!( + self.phase(), + Phase::Draft | Phase::Preparing | Phase::Navigating | Phase::AbortRequested + ) + } + fn current(&self) -> bool { + !self.cancelled.load(Ordering::Acquire) && (self.context.current)() + } + fn request_cancel(&self) -> Cancellation { + loop { + let phase = self.phase(); + if phase == Phase::Aborted { + return Cancellation::Aborted; + } + if !self.precommit() { + return Cancellation::TooLate; + } + if phase == Phase::AbortRequested { + return Cancellation::Pending; + } + let target = if phase == Phase::Draft { + Phase::Aborted + } else { + Phase::AbortRequested + }; + if self + .phase + .compare_exchange( + phase as u8, + target as u8, + Ordering::AcqRel, + Ordering::Acquire, + ) + .is_ok() + { + self.cancelled.store(true, Ordering::Release); + return if target == Phase::Aborted { + Cancellation::Aborted + } else { + Cancellation::Pending + }; + } + } + } +} + +#[derive(Default)] +pub(crate) struct Restarts { + sequence: AtomicU64, + current: Mutex>>, +} + +#[derive(Serialize)] +#[serde(untagged)] +pub(crate) enum Reply { + Snapshot(Snapshot), + Challenge { + #[serde(rename = "protocolVersion")] + protocol_version: u8, + kind: &'static str, + #[serde(rename = "attemptId")] + attempt_id: String, + #[serde(rename = "draftEpoch")] + draft_epoch: u64, + #[serde(rename = "ttlMs")] + ttl_ms: u64, + }, + Disposition { + #[serde(rename = "protocolVersion")] + protocol_version: u8, + kind: &'static str, + #[serde(rename = "attemptId")] + attempt_id: String, + #[serde(rename = "draftEpoch")] + draft_epoch: u64, + snapshot: Snapshot, + }, +} + +fn qualified(app: &AppHandle) -> bool { + // Build/profile admission is shared with startup. Disabled builds remain + // inert; production is enabled only by an explicit release-arm64 binding. + // Native location/ownership and runtime/draft gates are still mandatory. + let binding = Binding::compiled(); + let profile = app.try_state::(); + cfg!(target_arch = "aarch64") + && binding.admits_profile( + profile.as_ref().map(|profile| profile.root()), + !cfg!(debug_assertions), + ) +} +fn snapshot(app: &AppHandle) -> Result { + app.state::().snapshot(|| true) +} +fn nonce() -> Result { + use std::fmt::Write; + let mut bytes = [0u8; 32]; + getrandom::getrandom(&mut bytes).map_err(|_| "updater_unavailable")?; + let mut text = String::with_capacity(64); + for byte in bytes { + write!(text, "{byte:02x}").expect("hex"); + } + Ok(text) +} + +fn trace(event: &'static str) { + if cfg!(debug_assertions) && Binding::compiled().mode == Mode::Qa { + eprintln!("[restart-qa:{}] {event}", std::process::id()); + } +} + +impl Restarts { + fn attempt(&self) -> Option> { + self.current.lock().ok().and_then(|value| value.clone()) + } + fn active(&self) -> bool { + let Some(attempt) = self.attempt() else { + return false; + }; + // Status decoration is read-only; the dedicated expiry task owns the + // draft transition and its rollback notification. + !matches!(attempt.phase(), Phase::Aborted) + } + pub(crate) fn decorate( + &self, + app: &AppHandle, + backend: Option<&Arc>, + mut state: Snapshot, + ) -> Snapshot { + state.installation_available = + qualified(app) && backend.is_some_and(|backend| backend.available()) && !self.active(); + clear_satisfied_installation_gate(&mut state); + if let Some(attempt) = self.attempt() { + match attempt.phase() { + Phase::Navigating | Phase::CommitSent | Phase::Stopping => { + state.phase = UpdatePhase::Applying + } + Phase::Restarting => state.phase = UpdatePhase::Restarting, + Phase::Recovery => state.phase = UpdatePhase::Recovery, + _ => {} + } + } + state + } + pub(crate) fn begin(&self, app: &AppHandle, context: Context) -> Result { + if !qualified(app) { + return Err("updater_installation_unavailable"); + } + if self.active() { + return Err("updater_busy"); + } + let state = snapshot(app)?; + if !snapshot_matches_target(&state, &context.target_id) { + return Err("updater_target_changed"); + } + if !matches!(state.phase, UpdatePhase::Ready) || !context.current.as_ref()() { + return Err("updater_unavailable"); + } + let idle = context + .backend + .request(Control::Status, Instant::now() + Duration::from_secs(1))?; + if !idle.ok || idle.state != BackendState::Open { + return Err("updater_busy"); + } + let root = crate::supervisor::desktop_data_root(app).map_err(|_| "updater_unavailable")?; + let store = Store::open(&root).map_err(|_| "updater_unavailable")?; + let record = store + .prepared_record() + .map_err(|_| "updater_unavailable")? + .ok_or("updater_unavailable")?; + if !record_matches_target(&state, &record, &context.target_id) { + return Err("updater_target_changed"); + } + let manifest = crate::updater_manifest::parse_manifest( + record.manifest.as_bytes(), + &crate::updater_install::product_identity(), + ) + .map_err(|_| "updater_unavailable")?; + if state.target_desktop_version.as_deref() != Some(manifest.version.to_string().as_str()) + || state.target_product_version.as_deref() + != Some(manifest.product_version.to_string().as_str()) + { + return Err("updater_unavailable"); + } + InstallLocation::validate( + &Binding::compiled(), + &std::env::current_exe().map_err(|_| "updater_unavailable")?, + ) + .map_err(|_| "updater_installation_unavailable")?; + if !(context.current)() { + return Err("updater_unauthorized"); + } + recheck_target(app, &context.target_id, &record.archive_sha256)?; + let epoch = self + .sequence + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |value| { + (value < 9_007_199_254_740_991).then_some(value + 1) + }) + .map_err(|_| "updater_unavailable")? + + 1; + let attempt = Arc::new(Attempt { + id: nonce()?, + draft_epoch: epoch, + deadline: Instant::now() + Duration::from_secs(5), + phase: AtomicU8::new(Phase::Draft as u8), + cancelled: AtomicBool::new(false), + context, + archive_sha256: record.archive_sha256, + desktop_version: manifest.version.to_string(), + prepare_sent: AtomicBool::new(false), + displayed: AtomicBool::new(false), + }); + let mut slot = self.current.lock().map_err(|_| "updater_unavailable")?; + if slot + .as_ref() + .is_some_and(|current| current.phase() != Phase::Aborted) + { + return Err("updater_busy"); + } + *slot = Some(attempt.clone()); + trace("draft-challenge"); + let expiry = attempt.clone(); + let handle = app.clone(); + tauri::async_runtime::spawn(async move { + tokio::time::sleep(Duration::from_secs(5)).await; + if expiry + .phase + .compare_exchange( + Phase::Draft as u8, + Phase::Aborted as u8, + Ordering::AcqRel, + Ordering::Acquire, + ) + .is_ok() + { + crate::updater_bridge::notify_restart_aborted( + &handle, + &expiry.id, + expiry.draft_epoch, + ); + } + }); + Ok(Reply::Challenge { + protocol_version: 1, + kind: "restartChallenge", + attempt_id: attempt.id.clone(), + draft_epoch: epoch, + ttl_ms: 5_000, + }) + } + + fn matching(&self, id: &str, epoch: u64) -> Result, &'static str> { + self.attempt() + .filter(|attempt| attempt.id == id && attempt.draft_epoch == epoch) + .ok_or("updater_stale_restart") + } + + pub(crate) fn cancel( + &self, + app: &AppHandle, + id: &str, + epoch: u64, + ) -> Result { + let attempt = self.matching(id, epoch)?; + match attempt.request_cancel() { + Cancellation::Aborted => { + crate::updater_bridge::notify_restart_aborted( + app, + &attempt.id, + attempt.draft_epoch, + ); + return disposition(app, &attempt, true, "updater_restart_cancelled"); + } + Cancellation::TooLate => { + return disposition(app, &attempt, false, "updater_restart_uncertain") + } + Cancellation::Pending => {} + } + // The active owner performs ordered backend cancellation after its + // in-flight read; an explicit cancel never races an independent commit. + if attempt.phase() == Phase::Aborted { + crate::updater_bridge::notify_restart_aborted(app, &attempt.id, attempt.draft_epoch); + return disposition(app, &attempt, true, "updater_restart_cancelled"); + } + Err("updater_restart_cancelling") + } + + pub(crate) fn prepared( + &self, + app: &AppHandle, + id: &str, + epoch: u64, + ) -> Result { + let attempt = self.matching(id, epoch)?; + if attempt.phase() == Phase::Aborted { + return disposition(app, &attempt, true, "updater_restart_cancelled"); + } + if attempt + .phase + .compare_exchange( + Phase::Draft as u8, + Phase::Preparing as u8, + Ordering::AcqRel, + Ordering::Acquire, + ) + .is_err() + { + return Err("updater_busy"); + } + tauri::async_runtime::block_on(prepare_restart(app, attempt)) + } +} + +fn clear_satisfied_installation_gate(state: &mut Snapshot) { + if state.installation_available && state.reason == Some("installation_safety_gate_pending") { + state.reason = None; + } +} + +fn snapshot_matches_target(state: &Snapshot, target_id: &str) -> bool { + crate::updater_backend::hex_id(target_id) + && state.phase == UpdatePhase::Ready + && state.target_id.as_deref() == Some(target_id) +} + +fn record_matches_target( + state: &Snapshot, + record: &crate::updater_store::PreparedRecord, + target_id: &str, +) -> bool { + snapshot_matches_target(state, target_id) && record.target_id() == target_id +} + +fn recheck_target( + app: &AppHandle, + target_id: &str, + archive_sha256: &str, +) -> Result<(), &'static str> { + let state = snapshot(app)?; + let root = crate::supervisor::desktop_data_root(app).map_err(|_| "updater_unavailable")?; + let store = Store::open(&root).map_err(|_| "updater_unavailable")?; + let record = store + .prepared_record() + .map_err(|_| "updater_unavailable")? + .ok_or("updater_target_changed")?; + if !record_matches_target(&state, &record, target_id) || record.archive_sha256 != archive_sha256 + { + return Err("updater_target_changed"); + } + Ok(()) +} + +fn disposition( + app: &AppHandle, + attempt: &Attempt, + aborted: bool, + reason: &'static str, +) -> Result { + let mut state = snapshot(app)?; + state.phase = if aborted { + UpdatePhase::Deferred + } else { + UpdatePhase::Recovery + }; + state.reason = Some(reason); + state.installation_available = false; + Ok(Reply::Disposition { + protocol_version: 1, + kind: if aborted { + "restartAborted" + } else { + "restartUncertain" + }, + attempt_id: attempt.id.clone(), + draft_epoch: attempt.draft_epoch, + snapshot: state, + }) +} + +async fn abort( + app: &AppHandle, + attempt: &Attempt, + reason: &'static str, +) -> Result { + trace(reason); + if !attempt.precommit() { + return recover(app, attempt, "updater_restart_uncertain"); + } + if attempt.prepare_sent.load(Ordering::Acquire) { + // Even a failed cancellation cannot cause a later commit: this native + // owner will issue none, and channel retirement revokes its Node epoch. + match attempt.context.backend.request( + Control::Cancel { + attempt_id: attempt.id.clone(), + }, + Instant::now() + Duration::from_secs(2), + ) { + Ok(value) if value.state == BackendState::Open => {} + Ok(_) => return recover(app, attempt, "updater_abort_uncertain"), + Err(_) => attempt.context.backend.retire(), + } + } + let displayed = attempt.displayed.load(Ordering::Acquire); + attempt.set(Phase::Aborted); + if displayed { + crate::updater_launch::cancel_manual_display(app, &attempt.context.return_url); + } + crate::updater_bridge::notify_restart_aborted(app, &attempt.id, attempt.draft_epoch); + disposition(app, attempt, true, reason) +} + +fn recover( + app: &AppHandle, + attempt: &Attempt, + reason: &'static str, +) -> Result { + trace(reason); + attempt.set(Phase::Recovery); + crate::updater_launch::manual_recovery( + app, + "Restart ownership could not be confirmed. The application was not automatically retried.", + ); + disposition(app, attempt, false, reason) +} + +async fn prepare_restart(app: &AppHandle, attempt: Arc) -> Result { + if !attempt.current() || Instant::now() >= attempt.deadline { + return abort(app, &attempt, "updater_restart_cancelled").await; + } + crate::reset_deep_link_readiness(app); + if crate::flush_deep_links(app).is_err() { + return abort(app, &attempt, "updater_pending_links_unavailable").await; + } + let state = match snapshot(app) { + Ok(state) => state, + Err(_) => return abort(app, &attempt, "updater_unavailable").await, + }; + if !matches!(state.phase, UpdatePhase::Ready) + || !snapshot_matches_target(&state, &attempt.context.target_id) + || state.target_desktop_version.as_deref() != Some(&attempt.desktop_version) + || recheck_target(app, &attempt.context.target_id, &attempt.archive_sha256).is_err() + || !attempt.current() + { + return abort(app, &attempt, "updater_restart_cancelled").await; + } + if attempt + .phase + .compare_exchange( + Phase::Preparing as u8, + Phase::Navigating as u8, + Ordering::AcqRel, + Ordering::Acquire, + ) + .is_err() + { + return abort(app, &attempt, "updater_restart_cancelled").await; + } + attempt.displayed.store(true, Ordering::Release); + if crate::updater_launch::show_manual_applying(app) + .await + .is_err() + { + return abort(app, &attempt, "updater_display_unavailable").await; + } + trace("applying-visible"); + // Dispose the sealed document before taking runtime evidence. Its expected + // WebSocket/HTTP disconnects are activity changes, not an exception to the + // authority's generation checks. This stage installs/stops nothing: busy or + // unknown owners still cancel and restore the saved page. + let remaining = attempt + .deadline + .saturating_duration_since(Instant::now()) + .as_millis() as u64; + if remaining == 0 || attempt.cancelled.load(Ordering::Acquire) || !(attempt.context.same_run)() + { + return abort(app, &attempt, "updater_restart_cancelled").await; + } + attempt.prepare_sent.store(true, Ordering::Release); + trace("backend-prepare"); + let prepared = attempt.context.backend.request( + Control::Prepare { + attempt_id: attempt.id.clone(), + draft_epoch: attempt.draft_epoch, + remaining_ms: remaining.min(5_000), + }, + attempt.deadline, + ); + let prepared = match prepared { + Ok(value) + if value.ok + && value.state == BackendState::Prepared + && value.attempt_id.as_deref() == Some(&attempt.id) + && value.token.is_some() + && value.expires_in_ms.is_some() => + { + value + } + _ => return abort(app, &attempt, "updater_runtime_busy").await, + }; + let token_deadline = + Instant::now() + Duration::from_millis(prepared.expires_in_ms.unwrap_or(0)); + trace("backend-prepared"); + if attempt.cancelled.load(Ordering::Acquire) + || !(attempt.context.same_run)() + || Instant::now() >= attempt.deadline + { + return abort(app, &attempt, "updater_restart_cancelled").await; + } + let tree = match crate::updater_owners::capture_owned_server( + attempt.context.server_pid, + std::process::id(), + ) { + Ok(tree) => tree, + Err(error) => { + trace_owner_failure(&error); + return abort(app, &attempt, "updater_owner_unknown").await; + } + }; + let state = match snapshot(app) { + Ok(state) => state, + Err(_) => return abort(app, &attempt, "updater_unavailable").await, + }; + if !matches!(state.phase, UpdatePhase::Ready) + || !snapshot_matches_target(&state, &attempt.context.target_id) + || state.target_desktop_version.as_deref() != Some(&attempt.desktop_version) + || recheck_target(app, &attempt.context.target_id, &attempt.archive_sha256).is_err() + || attempt.cancelled.load(Ordering::Acquire) + || !(attempt.context.same_run)() + { + return abort(app, &attempt, "updater_restart_cancelled").await; + } + if attempt.cancelled.load(Ordering::Acquire) + || !(attempt.context.same_run)() + || Instant::now() >= token_deadline + { + return abort(app, &attempt, "updater_restart_cancelled").await; + } + if let Err(error) = tree.revalidate() { + trace_owner_failure(&error); + return abort(app, &attempt, "updater_owner_changed").await; + } + // Cancellation and commit elect one winner at this final synchronous point. + if attempt + .phase + .compare_exchange( + Phase::Navigating as u8, + Phase::CommitSent as u8, + Ordering::AcqRel, + Ordering::Acquire, + ) + .is_err() + { + return abort(app, &attempt, "updater_restart_cancelled").await; + } + let committed = attempt.context.backend.request( + Control::Commit { + attempt_id: attempt.id.clone(), + token: prepared.token.unwrap_or_default(), + }, + token_deadline.min(Instant::now() + Duration::from_secs(5)), + ); + match committed { + Ok(value) + if value.ok + && value.state == BackendState::Committed + && value.attempt_id.as_deref() == Some(&attempt.id) => {} + Ok(value) if !value.ok && value.state != BackendState::Committed => { + attempt.set(Phase::Navigating); + return abort(app, &attempt, "updater_runtime_changed").await; + } + _ => return recover(app, &attempt, "updater_commit_uncertain"), + } + attempt.set(Phase::Stopping); + trace("backend-committed"); + let handle = app.clone(); + tauri::async_runtime::spawn(async move { + let result: Result<(), String> = async { + let lifecycle = handle.state::(); + if lifecycle.begin_shutdown() != Some(attempt.context.server_pid) { + return Err("Restart lost its owned server.".into()); + } + lifecycle.terminate(attempt.context.server_pid)?; + trace("server-stop-requested"); + lifecycle.wait_for_exit().await?; + trace("server-exited"); + let deadline = Instant::now() + Duration::from_secs(5); + while !tree.all_gone()? { + if Instant::now() >= deadline { + return Err("Owned server descendants did not finish shutdown.".into()); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + let root = crate::supervisor::desktop_data_root(&handle)?; + trace("owned-tree-exited"); + Store::open(&root)? + .request_manual(&attempt.context.target_id, &attempt.archive_sha256)?; + trace("manual-intent-saved"); + attempt.set(Phase::Restarting); + crate::updater_launch::request_manual_restart(&handle); + Ok(()) + } + .await; + if result.is_err() { + let _ = recover(&handle, &attempt, "updater_shutdown_unconfirmed"); + } + }); + Ok(Reply::Snapshot(snapshot(app)?)) +} + +fn trace_owner_failure(error: &str) { + if cfg!(debug_assertions) && Binding::compiled().mode == Mode::Qa { + // The owner scanner returns static reason descriptions, not process + // argv, user content, or authentication material. + eprintln!("[restart-qa:{}] {error}", std::process::id()); + } +} + +pub(crate) fn blocks_start(app: &AppHandle) -> bool { + app.try_state::() + .is_some_and(|state| state.active()) +} +pub(crate) fn holds_exit(app: &AppHandle) -> bool { + app.try_state::() + .and_then(|state| state.attempt()) + .is_some_and(|attempt| { + matches!( + attempt.phase(), + Phase::CommitSent | Phase::Stopping | Phase::Restarting + ) + }) +} +pub(crate) fn permits_navigation(app: &AppHandle, url: &tauri::Url) -> bool { + let Some(attempt) = app + .try_state::() + .and_then(|state| state.attempt()) + else { + return true; + }; + if matches!( + attempt.phase(), + Phase::Navigating + | Phase::CommitSent + | Phase::Stopping + | Phase::Restarting + | Phase::Recovery + ) || (attempt.phase() == Phase::AbortRequested && attempt.displayed.load(Ordering::Acquire)) + { + return url.scheme() == "tauri" + && url.host_str() == Some("localhost") + && url.username().is_empty() + && url.password().is_none() + && url.port().is_none() + && matches!(url.path(), "/" | "/index.html") + && url.query().is_none() + && url.fragment().is_none(); + } + true +} +pub(crate) fn view_lost(app: &AppHandle) { + if let Some(attempt) = app + .try_state::() + .and_then(|state| state.attempt()) + { + loop { + let phase = attempt.phase(); + let next = match phase { + Phase::Draft => Phase::Aborted, + Phase::Preparing => Phase::AbortRequested, + _ => break, + }; + if attempt + .phase + .compare_exchange(phase as u8, next as u8, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + { + attempt.cancelled.store(true, Ordering::Release); + break; + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::os::unix::net::UnixStream; + + #[test] + fn enabled_installation_does_not_display_a_satisfied_gate_as_pending() { + let mut state = Snapshot { + reason: Some("installation_safety_gate_pending"), + ..Snapshot::default() + }; + clear_satisfied_installation_gate(&mut state); + assert!(state.reason.is_some()); + state.installation_available = true; + clear_satisfied_installation_gate(&mut state); + assert_eq!(state.reason, None); + state.reason = Some("updater_runtime_busy"); + clear_satisfied_installation_gate(&mut state); + assert_eq!(state.reason, Some("updater_runtime_busy")); + } + + #[test] + fn restart_binds_both_snapshot_and_record_even_when_versions_are_identical() { + let record = crate::updater_store::PreparedRecord { + schema: 1, + release_id: 1, + manifest_asset_id: 2, + archive_asset_id: 3, + archive_size: 4, + archive_sha256: "a".repeat(64), + manifest: include_str!("../../shared/fixtures/desktop-update-manifest.json").into(), + inventory: serde_json::json!({}), + }; + let requested = record.target_id(); + let mut state = Snapshot { + phase: UpdatePhase::Ready, + target_id: Some(requested.clone()), + ..Snapshot::default() + }; + assert!(record_matches_target(&state, &record, &requested)); + assert!(!record_matches_target(&state, &record, "")); + assert!(!record_matches_target(&state, &record, &"b".repeat(64))); + let mut replaced = record.clone(); + replaced.archive_asset_id += 1; + assert!(!record_matches_target(&state, &replaced, &requested)); + replaced = record.clone(); + replaced.manifest.push('\n'); + assert!(!record_matches_target(&state, &replaced, &requested)); + state.target_id = Some(replaced.target_id()); + assert!(!record_matches_target(&state, &record, &requested)); + state.target_id = None; + assert!(!record_matches_target(&state, &record, &requested)); + } + + fn attempt(phase: Phase) -> (Arc, UnixStream) { + let (native, peer) = UnixStream::pair().unwrap(); + ( + Arc::new(Attempt { + id: "a".repeat(64), + draft_epoch: 1, + deadline: Instant::now() + Duration::from_secs(5), + phase: AtomicU8::new(phase as u8), + cancelled: AtomicBool::new(false), + context: Context { + target_id: "d".repeat(64), + backend: Arc::new(Backend::new(native, "b".repeat(64)).unwrap()), + server_pid: 42, + return_url: "http://127.0.0.1:43123/".parse().unwrap(), + current: Arc::new(|| true), + same_run: Arc::new(|| true), + }, + archive_sha256: "c".repeat(64), + desktop_version: "0.2.5".into(), + prepare_sent: AtomicBool::new(false), + displayed: AtomicBool::new(false), + }), + peer, + ) + } + + #[test] + fn draft_cancel_is_final_but_committed_paths_never_acknowledge_rollback() { + let (draft, _peer) = attempt(Phase::Draft); + assert_eq!(draft.request_cancel(), Cancellation::Aborted); + assert_eq!(draft.request_cancel(), Cancellation::Aborted); + assert!(draft + .phase + .compare_exchange( + Phase::Draft as u8, + Phase::Preparing as u8, + Ordering::AcqRel, + Ordering::Acquire + ) + .is_err()); + for phase in [ + Phase::CommitSent, + Phase::Stopping, + Phase::Restarting, + Phase::Recovery, + ] { + let (current, _peer) = attempt(phase); + assert_eq!(current.request_cancel(), Cancellation::TooLate); + assert!(!current.cancelled.load(Ordering::Acquire)); + } + } + + #[test] + fn status_activity_reads_do_not_expire_or_change_a_draft() { + let (mut draft, _peer) = attempt(Phase::Draft); + Arc::get_mut(&mut draft).unwrap().deadline = Instant::now() - Duration::from_secs(1); + let restarts = Restarts::default(); + *restarts.current.lock().unwrap() = Some(draft.clone()); + assert!(restarts.active()); + assert!(draft.phase() == Phase::Draft); + assert!(!draft.cancelled.load(Ordering::Acquire)); + } + + #[test] + fn cancellation_and_commit_have_one_atomic_winner() { + for _ in 0..100 { + let (current, _peer) = attempt(Phase::Navigating); + let cancel = current.clone(); + let worker = std::thread::spawn(move || cancel.request_cancel()); + let committed = current + .phase + .compare_exchange( + Phase::Navigating as u8, + Phase::CommitSent as u8, + Ordering::AcqRel, + Ordering::Acquire, + ) + .is_ok(); + let cancelled = worker.join().unwrap() == Cancellation::Pending; + assert_ne!(committed, cancelled); + } + } +} diff --git a/src-tauri/src/updater_screen.rs b/src-tauri/src/updater_screen.rs new file mode 100644 index 00000000..55813733 --- /dev/null +++ b/src-tauri/src/updater_screen.rs @@ -0,0 +1,599 @@ +//! Bundled, server-independent updater presentation. This module grants no +//! installation authority and performs no navigation, IPC, or network access. +//! The caller publishes the screen before navigation and restores it only on the +//! bundled main document, after checking its own shutdown/lifecycle state. +use std::sync::Mutex; + +use serde_json::{json, Value}; + +const PRODUCT_NAME: &str = env!("GJC_UPDATE_PRODUCT_NAME"); + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum Screen { + Checking, + Applying, + Restarting, + Recovery { message: String }, +} + +/// A presentation snapshot, not a durable attempt record or lifecycle guard. +/// Epoch and screen share one lock so restoration cannot pair old content with +/// a newer acknowledgement epoch. No lock is held across navigation/evaluation. +#[derive(Default)] +pub(crate) struct ScreenState(Mutex<(u64, Option)>); + +impl ScreenState { + pub(crate) fn publish(&self, screen: Screen) -> u64 { + let mut state = self.0.lock().expect("updater screen lock poisoned"); + let epoch = state + .0 + .checked_add(1) + .expect("updater screen epoch exhausted"); + *state = (epoch, Some(screen)); + epoch + } + + pub(crate) fn published(&self) -> Option<(u64, Screen)> { + let state = self.0.lock().expect("updater screen lock poisoned"); + state.1.as_ref().map(|screen| (state.0, screen.clone())) + } + + /// Invalidate even an empty presentation; no prior epoch is ever reused. + pub(crate) fn clear(&self) { + let mut state = self.0.lock().expect("updater screen lock poisoned"); + let epoch = state + .0 + .checked_add(1) + .expect("updater screen epoch exhausted"); + *state = (epoch, None); + } +} + +/// JavaScript for native webview evaluation after the bundled document loads. +/// Every variable is JSON encoded; all displayed text uses `textContent`. +/// Even diagnostic text resembling HTML, links, or commands stays inert text. +pub(crate) fn script(screen: &Screen) -> String { + format!("({RENDER})({});", script_json(&content(screen))) +} + +fn script_json(value: &Value) -> String { + // JSON already escapes quotes, backslashes, and control characters. Also + // escape HTML delimiters and JS line separators, so the returned source + // cannot terminate a script element if a test/host embeds it in markup. + value + .to_string() + .replace('&', "\\u0026") + .replace('<', "\\u003c") + .replace('>', "\\u003e") + .replace('\u{2028}', "\\u2028") + .replace('\u{2029}', "\\u2029") +} + +fn content(screen: &Screen) -> Value { + let (kind, en_heading, ko_heading, en_body, ko_body) = match screen { + Screen::Checking => ( + "checking", + "Checking the update".to_owned(), + "업데이트 확인 중".to_owned(), + format!("Verifying the {PRODUCT_NAME} update before installation. Keep this window open."), + format!("설치 전에 {PRODUCT_NAME} 업데이트를 확인하고 있습니다. 이 창을 열어 두세요."), + ), + Screen::Applying => ( + "applying", + "Applying the update".to_owned(), + "업데이트 설치 중".to_owned(), + format!("Updating {PRODUCT_NAME}. Keep this window open while installation is in progress."), + format!("{PRODUCT_NAME}을(를) 업데이트하고 있습니다. 설치가 진행되는 동안 이 창을 열어 두세요."), + ), + Screen::Restarting => ( + "restarting", + format!("Restarting {PRODUCT_NAME}"), + format!("{PRODUCT_NAME} 다시 시작 중"), + "Waiting for the updated app to start and pass its health check. Keep this window open.".to_owned(), + "업데이트된 앱이 시작되고 정상 작동이 확인될 때까지 기다리고 있습니다. 이 창을 열어 두세요.".to_owned(), + ), + Screen::Recovery { .. } => ( + "recovery", + "Manual recovery is required".to_owned(), + "수동 복구가 필요합니다".to_owned(), + "The update result could not be confirmed. App startup is paused. No automatic retry or rollback will be attempted.".to_owned(), + "업데이트 결과를 확인할 수 없어 앱 시작을 중단했습니다. 자동으로 재시도하거나 이전 버전으로 되돌리지 않습니다.".to_owned(), + ), + }; + let announce_authorization = matches!(screen, Screen::Checking | Screen::Applying); + let recovery = matches!(screen, Screen::Recovery { .. }); + json!({ + "kind": kind, + "product": PRODUCT_NAME, + "message": match screen { + Screen::Recovery { message } => Some(message.as_str()), + _ => None, + }, + "en": { + "heading": en_heading, + "body": en_body, + "authorization": announce_authorization.then(|| format!( + "macOS may show its official administrator authorization prompt for {PRODUCT_NAME}. Enter administrator credentials only in that macOS system prompt, never in this window." + )), + "data": recovery.then_some("Your existing user data is kept. Do not delete it during recovery."), + "manual": recovery.then(|| format!( + "Quit {PRODUCT_NAME}, then manually reinstall it using a trusted official installer. If startup is still blocked, contact support for manual recovery." + )), + "details": "Diagnostic details (not instructions)", + }, + "ko": { + "heading": ko_heading, + "body": ko_body, + "authorization": announce_authorization.then(|| format!( + "macOS에서 {PRODUCT_NAME}의 공식 관리자 인증 창이 표시될 수 있습니다. 관리자 인증 정보는 macOS 시스템 인증 창에만 입력하세요. 이 창에는 입력하지 마세요." + )), + "data": recovery.then_some("기존 사용자 데이터는 보존됩니다. 복구 중에도 사용자 데이터를 삭제하지 마세요."), + "manual": recovery.then(|| format!( + "{PRODUCT_NAME}을(를) 종료한 다음, 신뢰할 수 있는 공식 설치 파일로 직접 재설치하세요. 계속 시작이 차단되면 지원팀에 수동 복구를 문의하세요." + )), + "details": "진단 정보(실행 지침이 아님)", + }, + }) +} + +const RENDER: &str = r#"function(screen) { + const firstLanguage = navigator.languages && navigator.languages[0]; + const language = firstLanguage || navigator.language || 'en'; + const locale = /^ko(?:-|$)/i.test(language) ? 'ko' : 'en'; + const copy = screen[locale]; + const recovery = screen.kind === 'recovery'; + document.documentElement.lang = locale; + document.title = copy.heading + ' — ' + screen.product; + + const main = document.createElement('main'); + main.id = 'gajae-updater-screen'; + main.dataset.state = screen.kind; + main.dataset.updaterScreen = screen.kind; + main.style.boxSizing = 'border-box'; + main.style.width = '100%'; + main.style.maxHeight = '100vh'; + main.style.overflowY = 'auto'; + main.style.overflowWrap = 'anywhere'; + + const status = document.createElement('section'); + status.setAttribute('role', recovery ? 'alert' : 'status'); + status.setAttribute('aria-live', recovery ? 'assertive' : 'polite'); + status.setAttribute('aria-atomic', 'true'); + status.setAttribute('aria-labelledby', 'gajae-updater-heading'); + main.append(status); + // Replace old server-failure controls as well as any previous updater + // state. No retry/install/security controls or bridge listeners survive. + document.body.replaceChildren(main); + + const heading = document.createElement('h1'); + heading.id = 'gajae-updater-heading'; + heading.tabIndex = -1; + heading.textContent = copy.heading; + status.append(heading); + for (const key of ['body', 'authorization', 'data', 'manual']) { + if (!copy[key]) continue; + const paragraph = document.createElement('p'); + paragraph.dataset.copy = key; + paragraph.textContent = copy[key]; + status.append(paragraph); + } + + if (recovery && screen.message) { + // Diagnostics are outside the live region: do not automatically read + // untrusted diagnostic content as if it were recovery instructions. + const label = document.createElement('h2'); + label.id = 'gajae-updater-details-heading'; + label.textContent = copy.details; + const details = document.createElement('pre'); + details.id = 'gajae-updater-details'; + details.setAttribute('aria-labelledby', label.id); + details.dir = 'auto'; + details.tabIndex = 0; + details.style.whiteSpace = 'pre-wrap'; + details.style.overflowWrap = 'anywhere'; + details.style.textAlign = 'start'; + details.style.maxHeight = '12rem'; + details.style.overflowY = 'auto'; + details.textContent = screen.message; + main.append(label, details); + } + // Focus gives the replacement document an entry point even on the first + // render, when a screen reader has not yet observed the live region. + heading.focus({ preventScroll: true }); +}"#; + +#[cfg(test)] +mod tests { + use super::*; + + const HOSTILE: &str = "
run sudo helper\"'\\\n\r\t\0\u{2028}\u{2029}한글 & ${globalThis.injected=true}"; + + #[test] + fn state_publishes_owned_snapshots_and_clears_without_installation_effects() { + let state = ScreenState::default(); + assert_eq!(state.published(), None); + let mut previous_epoch = 0; + for screen in [Screen::Checking, Screen::Applying, Screen::Restarting] { + let epoch = state.publish(screen.clone()); + assert_eq!(epoch, previous_epoch + 1); + assert_eq!(state.published(), Some((epoch, screen))); + previous_epoch = epoch; + } + let recovery_epoch = state.publish(Screen::Recovery { + message: "kept".into(), + }); + let Some((epoch, Screen::Recovery { mut message })) = state.published() else { + panic!("expected recovery snapshot"); + }; + assert_eq!(epoch, recovery_epoch); + message.clear(); + assert_eq!( + state.published(), + Some(( + recovery_epoch, + Screen::Recovery { + message: "kept".into() + } + )) + ); + state.clear(); + state.clear(); + assert_eq!(state.published(), None); + assert_eq!(state.publish(Screen::Checking), recovery_epoch + 3); + } + + #[test] + fn captured_snapshot_keeps_its_epoch_across_concurrent_replacement() { + let state = ScreenState::default(); + let checking_epoch = state.publish(Screen::Checking); + let checking = state.published(); + std::thread::scope(|scope| { + let applying_epoch = scope + .spawn(|| state.publish(Screen::Applying)) + .join() + .unwrap(); + assert_eq!(state.published(), Some((applying_epoch, Screen::Applying))); + assert!(applying_epoch > checking_epoch); + assert_eq!(checking, Some((checking_epoch, Screen::Checking))); + scope.spawn(|| state.clear()).join().unwrap(); + }); + assert_eq!(state.published(), None); + } + + #[test] + fn concurrent_publications_never_mix_epoch_and_content() { + let state = ScreenState::default(); + state.publish(Screen::Recovery { + message: "1".into(), + }); + let ready = std::sync::Barrier::new(2); + std::thread::scope(|scope| { + scope.spawn(|| { + ready.wait(); + for expected in 2..=1024 { + let epoch = state.publish(Screen::Recovery { + message: expected.to_string(), + }); + assert_eq!(epoch, expected); + std::thread::yield_now(); + } + }); + ready.wait(); + for _ in 0..2048 { + let Some((epoch, Screen::Recovery { message })) = state.published() else { + panic!("expected published recovery snapshot"); + }; + assert_eq!(message, epoch.to_string()); + std::thread::yield_now(); + } + }); + assert_eq!( + state.published(), + Some(( + 1024, + Screen::Recovery { + message: "1024".into() + } + )) + ); + } + + #[test] + fn clearing_empty_state_still_invalidates_its_epoch() { + let state = ScreenState::default(); + state.clear(); + assert_eq!(state.published(), None); + assert_eq!(state.publish(Screen::Applying), 2); + } + + #[test] + fn json_round_trips_hostile_text_without_literal_html_or_line_separators() { + let value = content(&Screen::Recovery { + message: HOSTILE.into(), + }); + let encoded = script_json(&value); + for forbidden in ['<', '>', '&', '\u{2028}', '\u{2029}', '\0', '\n', '\r'] { + assert!(!encoded.contains(forbidden), "literal {forbidden:?}"); + } + let decoded: Value = serde_json::from_str(&encoded).unwrap(); + assert_eq!(decoded, value); + assert_eq!(decoded["message"], HOSTILE); + assert!(script(&Screen::Recovery { + message: HOSTILE.into() + }) + .contains(&encoded)); + } + + #[test] + fn checking_and_applying_preannounce_system_authorization_in_both_languages() { + for screen in [Screen::Checking, Screen::Applying] { + let value = content(&screen); + let en = value["en"]["authorization"].as_str().unwrap(); + let ko = value["ko"]["authorization"].as_str().unwrap(); + assert!(en.contains("official administrator authorization prompt")); + assert!(en.contains("never in this window")); + assert!(ko.contains("공식 관리자 인증 창")); + assert!(ko.contains("이 창에는 입력하지 마세요")); + for text in [en, ko] { + assert!(text.contains("macOS")); + assert!(text.contains(PRODUCT_NAME)); + } + } + } + + #[test] + fn recovery_explains_uncertainty_data_preservation_and_manual_reinstall() { + let value = content(&Screen::Recovery { + message: String::new(), + }); + assert!(value["en"]["body"] + .as_str() + .unwrap() + .contains("could not be confirmed")); + assert!(value["en"]["body"] + .as_str() + .unwrap() + .contains("No automatic retry or rollback")); + assert!(value["en"]["data"] + .as_str() + .unwrap() + .contains("user data is kept")); + assert!(value["en"]["manual"] + .as_str() + .unwrap() + .contains("manually reinstall")); + assert!(value["ko"]["body"] + .as_str() + .unwrap() + .contains("결과를 확인할 수 없어")); + assert!(value["ko"]["body"] + .as_str() + .unwrap() + .contains("자동으로 재시도하거나 이전 버전으로 되돌리지 않습니다")); + assert!(value["ko"]["data"] + .as_str() + .unwrap() + .contains("사용자 데이터는 보존")); + assert!(value["ko"]["manual"] + .as_str() + .unwrap() + .contains("직접 재설치")); + assert!(value["en"]["authorization"].is_null()); + assert!(value["ko"]["authorization"].is_null()); + } + + #[test] + fn restarting_does_not_claim_health_or_success_early() { + let value = content(&Screen::Restarting); + assert!(value["en"]["body"] + .as_str() + .unwrap() + .contains("Waiting for")); + assert!(value["en"]["body"] + .as_str() + .unwrap() + .contains("pass its health check")); + assert!(value["ko"]["body"] + .as_str() + .unwrap() + .contains("정상 작동이 확인될 때까지")); + assert!(value["en"]["authorization"].is_null()); + assert!(value["ko"]["authorization"].is_null()); + } + + #[test] + fn renderer_has_no_html_sinks_network_bridges_or_action_controls() { + for forbidden in [ + "innerHTML", + "outerHTML", + "insertAdjacentHTML", + "document.write", + "onclick", + "__TAURI", + "fetch(", + "XMLHttpRequest", + "WebSocket", + "localStorage", + "sessionStorage", + "location", + "http:", + "https:", + "createElement('button')", + "createElement('form')", + "createElement('input')", + "createElement('a')", + "createElement('script')", + ] { + assert!( + !RENDER.contains(forbidden), + "unexpected renderer capability: {forbidden}" + ); + } + assert!(RENDER.contains("details.textContent = screen.message")); + } + + /// Run explicitly after npm dependencies are installed: + /// cargo test --locked --manifest-path src-tauri/Cargo.toml updater_screen::tests::scripts_execute_in_dom -- --ignored --nocapture + /// This exercises a DOM implementation, not a packaged webview or VoiceOver. + #[test] + #[ignore = "requires Node and the repository's installed happy-dom dependency"] + fn scripts_execute_in_dom() { + use std::{ + io::Write, + path::Path, + process::{Command, Stdio}, + }; + + let screens: Vec<_> = [ + Screen::Checking, + Screen::Applying, + Screen::Restarting, + Screen::Recovery { + message: HOSTILE.into(), + }, + Screen::Recovery { + message: String::new(), + }, + ] + .into_iter() + .map(|screen| { + json!({ + "script": script(&screen), + "content": content(&screen), + }) + }) + .collect(); + let fixtures = json!({ + "html": include_str!("../recovery/index.html"), + "screens": screens, + }); + let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap(); + let mut child = Command::new("node") + .args(["--input-type=module", "--eval", DOM_TEST]) + .current_dir(root) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("Node is required for this explicit DOM test"); + child + .stdin + .take() + .unwrap() + .write_all(fixtures.to_string().as_bytes()) + .unwrap(); + let output = child.wait_with_output().unwrap(); + assert!( + output.status.success(), + "DOM test failed:\n{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + print!("{}", String::from_utf8_lossy(&output.stdout)); + } + + const DOM_TEST: &str = r#" +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { Window } from 'happy-dom'; + +const fixtures = JSON.parse(readFileSync(0, 'utf8')); +const locales = [ + ['en-US', ['en-US'], 'en'], + ['ko-KR', ['ko-KR'], 'ko'], + ['en-US', ['ko-KR', 'en-US'], 'ko'], + ['ko-KR', ['en-US', 'ko-KR'], 'en'], + ['ko', [], 'ko'], + ['KO-kr', [], 'ko'], + ['fr-FR', ['fr-FR'], 'en'], + ['kok-IN', [], 'en'], + ['', ['ko-KR'], 'ko'], + ['', [], 'en'], +]; +let renders = 0; +for (const [language, languages, locale] of locales) { + const window = new Window({ url: 'https://tauri.localhost/index.html' }); + try { + const { document } = window; + document.write(fixtures.html); + const originalStyle = document.head.querySelector('style').textContent; + Object.defineProperty(window.navigator, 'language', { value: language }); + Object.defineProperty(window.navigator, 'languages', { value: languages }); + window.fetch = () => { throw new Error('network access forbidden'); }; + window.__TAURI__ = { core: { invoke: () => { throw new Error('IPC forbidden'); } } }; + + // Start with the existing server recovery's Retry control, then check + // state transitions, repeat evaluation, and diagnostic removal. + const retry = document.createElement('button'); + retry.id = 'gajae-retry'; + retry.textContent = 'Retry'; + document.body.append(retry); + for (const fixture of [...fixtures.screens, ...fixtures.screens.slice().reverse()]) { + const previousRoot = document.querySelector('main[data-updater-screen]'); + window.eval(fixture.script); + renders += 1; + const { content } = fixture; + const copy = content[locale]; + const recovery = content.kind === 'recovery'; + const main = document.querySelector('main'); + const heading = document.querySelector('h1'); + const status = document.querySelector('[role]'); + assert.equal(document.body.children.length, 1); + assert.equal(document.querySelectorAll('main').length, 1); + assert.equal(main.dataset.state, content.kind); + assert.equal(main.dataset.updaterScreen, content.kind); + assert.equal(document.querySelectorAll('main[data-updater-screen]').length, 1); + assert.equal(document.querySelector('main[data-updater-screen]'), main); + assert.equal(main.isConnected, true); + if (previousRoot) { + assert.notEqual(previousRoot, main); + assert.equal(previousRoot.isConnected, false); + } + assert.equal(document.documentElement.lang, locale); + assert.equal(document.title, copy.heading + ' — ' + content.product); + assert.equal(heading.textContent, copy.heading); + assert.equal(document.activeElement, heading); + assert.equal(status.getAttribute('role'), recovery ? 'alert' : 'status'); + assert.equal(status.getAttribute('aria-live'), recovery ? 'assertive' : 'polite'); + assert.equal(status.getAttribute('aria-atomic'), 'true'); + assert.equal(status.getAttribute('aria-labelledby'), heading.id); + assert.equal(document.head.querySelector('style').textContent, originalStyle); + for (const key of ['body', 'authorization', 'data', 'manual']) { + const paragraph = document.querySelector('[data-copy="' + key + '"]'); + assert.equal(paragraph?.textContent ?? null, copy[key]); + } + assert.equal(document.body.querySelector('button, a, form, input, textarea, select, iframe, img, script, [href], [src], [onclick]'), null); + assert.equal(window.injected, undefined); + for (const element of document.body.querySelectorAll('*')) { + for (const attribute of element.attributes) { + assert.ok(!attribute.name.startsWith('on'), 'no inline handlers'); + } + } + const details = document.querySelector('pre'); + if (recovery && content.message) { + assert.equal(details.textContent, content.message); + assert.equal(details.children.length, 0); + assert.equal(details.dir, 'auto'); + assert.equal(details.tabIndex, 0); + assert.equal(document.getElementById(details.getAttribute('aria-labelledby')).textContent, copy.details); + assert.ok(!status.contains(details), 'untrusted diagnostics are not auto-announced'); + assert.equal(details.style.whiteSpace, 'pre-wrap'); + assert.equal(details.style.overflowWrap, 'anywhere'); + details.focus(); + assert.equal(document.activeElement, details); + } else { + assert.equal(details, null); + assert.equal(document.querySelector('h2'), null); + } + } + const previousRoot = document.querySelector('main[data-updater-screen]'); + document.body.replaceChildren(document.createElement('main')); + assert.equal(previousRoot.isConnected, false); + assert.equal(document.querySelector('main[data-updater-screen]'), null); + } finally { + await window.happyDOM.close(); + } +} +console.log('Passed ' + renders + ' DOM renders: root markers/identity, locale selection, state transitions, live regions, focus, inert diagnostics, and no action controls.'); +"#; +} diff --git a/src-tauri/src/updater_store.rs b/src-tauri/src/updater_store.rs index 831b9d74..579e3930 100644 --- a/src-tauri/src/updater_store.rs +++ b/src-tauri/src/updater_store.rs @@ -22,13 +22,87 @@ const MAX_MANIFEST_BYTES: usize = 64 * 1024; const MAX_PREFERENCES_BYTES: usize = 4096; const MAX_CACHE_FILES: usize = 8; +/// Notification navigation state, separate from the updater cache. Reuse the +/// same descriptor-relative atomic I/O; these URLs grant no update authority. +pub(crate) struct LinkStore(Store); + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct PendingLinks { + schema: u8, + urls: Vec, +} + +impl LinkStore { + pub(crate) fn open(root: &Path) -> Result { + Store::open_named(root, "desktop-deep-links").map(Self) + } + + pub(crate) fn read(&self) -> Result, String> { + let Some(bytes) = self.0.read("pending.json", 8192)? else { + return Ok(Vec::new()); + }; + let record: PendingLinks = + serde_json::from_slice(&bytes).map_err(|_| "Invalid pending desktop links.")?; + Self::validate(&record)?; + Ok(record.urls) + } + + pub(crate) fn write(&self, urls: Vec) -> Result<(), String> { + let record = PendingLinks { schema: 1, urls }; + Self::validate(&record)?; + let _guard = self + .0 + .mutation + .lock() + .map_err(|_| "Pending links lock failed.")?; + self.0.atomic_json("pending.json", &record, 8192) + } + + fn validate(record: &PendingLinks) -> Result<(), String> { + if record.schema != 1 + || record.urls.len() > 16 + || record + .urls + .iter() + .any(|url| url.is_empty() || url.len() > 256 || url.chars().any(char::is_control)) + { + return Err("Invalid pending desktop links.".into()); + } + Ok(()) + } +} + #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct Preferences { pub schema: u8, + /// Periodic discovery only; never permission to download or install. pub automatic: bool, } +/// Durable user intent only. It never proves owner absence or authorizes an +/// installer; the next launch must independently reverify every native gate. +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct ManualIntent { + schema: u8, + target_id: String, + archive_sha256: String, + consumed: bool, +} + +/// A consumed user selection, not an installation or owner-absence permit. +pub(crate) struct ManualRequest { + target_id: String, + archive_sha256: String, +} +impl ManualRequest { + pub(crate) fn matches(&self, record: &PreparedRecord) -> bool { + self.target_id == record.target_id() && self.archive_sha256 == record.archive_sha256 + } +} + impl Default for Preferences { fn default() -> Self { Self { @@ -54,6 +128,15 @@ pub struct PreparedRecord { } impl PreparedRecord { + pub(crate) fn target_id(&self) -> String { + crate::updater_discovery::target_id( + self.release_id, + self.manifest_asset_id, + self.archive_asset_id, + self.manifest.as_bytes(), + ) + } + fn validate(&self) -> Result<(), String> { if self.schema != 1 || self.release_id == 0 @@ -149,8 +232,12 @@ enum SyncPoint { impl Store { pub fn open(data_root: &Path) -> Result { + Self::open_named(data_root, "desktop-update-cache") + } + + fn open_named(data_root: &Path, directory_name: &str) -> Result { let parent = open_root(data_root)?; - let name = c_name("desktop-update-cache")?; + let name = c_name(directory_name)?; let result = unsafe { libc::mkdirat(parent.as_raw_fd(), name.as_ptr(), 0o700) }; if result != 0 && std::io::Error::last_os_error().kind() != std::io::ErrorKind::AlreadyExists @@ -159,7 +246,7 @@ impl Store { } let directory = open_at( &parent, - "desktop-update-cache", + directory_name, libc::O_RDONLY | libc::O_DIRECTORY, 0, )?; @@ -208,7 +295,7 @@ impl Store { self.atomic_json("preferences.json", &value, MAX_PREFERENCES_BYTES) } - pub fn load(&self) -> Result)>, String> { + fn selected_record(&self) -> Result, String> { let Some(pointer) = self.pointer()? else { return Ok(None); }; @@ -218,6 +305,100 @@ impl Store { let record: PreparedRecord = serde_json::from_slice(&record).map_err(|_| "Invalid prepared update metadata.")?; record.validate()?; + Ok(Some((pointer, record))) + } + + pub(crate) fn prepared_record(&self) -> Result, String> { + Ok(self.selected_record()?.map(|(_, record)| record)) + } + + pub(crate) fn request_manual( + &self, + target_id: &str, + archive_sha256: &str, + ) -> Result<(), String> { + let _mutation = self + .mutation + .lock() + .map_err(|_| "Update cache lock failed.")?; + if !self.prepared_record()?.is_some_and(|record| { + record.target_id() == target_id && record.archive_sha256 == archive_sha256 + }) { + return Err("Prepared update changed before recording manual intent.".into()); + } + self.atomic_json( + "manual-intent.json", + &ManualIntent { + schema: 2, + target_id: target_id.to_owned(), + archive_sha256: archive_sha256.to_owned(), + consumed: false, + }, + MAX_PREFERENCES_BYTES, + ) + } + + fn read_manual_intent(&self) -> Result, String> { + let Some(bytes) = self.read("manual-intent.json", MAX_PREFERENCES_BYTES)? else { + return Ok(None); + }; + let intent: ManualIntent = + serde_json::from_slice(&bytes).map_err(|_| "Invalid manual update intent.")?; + if intent.schema != 2 + || intent.target_id.len() != 64 + || !intent + .target_id + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + || intent.archive_sha256.len() != 64 + || !intent + .archive_sha256 + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err("Invalid manual update intent.".into()); + } + Ok(Some(intent)) + } + + /// Retire the click durably before startup preflight. A failed preflight + /// must not make the next ordinary launch retry an earlier user action. + pub(crate) fn consume_manual(&self) -> Result, String> { + let _mutation = self + .mutation + .lock() + .map_err(|_| "Update cache lock failed.")?; + let Some(mut intent) = self.read_manual_intent()? else { + return Ok(None); + }; + if intent.consumed { + return Ok(None); + } + intent.consumed = true; + self.atomic_json("manual-intent.json", &intent, MAX_PREFERENCES_BYTES)?; + Ok(Some(ManualRequest { + target_id: intent.target_id, + archive_sha256: intent.archive_sha256, + })) + } + + #[cfg(test)] + pub(crate) fn manual_requested( + &self, + target_id: &str, + archive_sha256: &str, + ) -> Result { + Ok(self.read_manual_intent()?.is_some_and(|intent| { + !intent.consumed + && intent.target_id == target_id + && intent.archive_sha256 == archive_sha256 + })) + } + + pub fn load(&self) -> Result)>, String> { + let Some((pointer, record)) = self.selected_record()? else { + return Ok(None); + }; let archive = self .read(&format!("archive-{}", pointer.id), MAX_ARCHIVE_BYTES)? .ok_or("Prepared update archive is missing.")?; @@ -707,6 +888,145 @@ mod tests { } } + #[test] + fn manual_click_is_consumed_durably_once_and_only_a_fresh_click_rearms_it() { + let root = Temp::new(); + let store = Store::open(&root.0).unwrap(); + let record = record(); + store + .commit(store.stage(&record, b"data").unwrap()) + .unwrap(); + store + .request_manual(&record.target_id(), &record.archive_sha256) + .unwrap(); + assert!(store.consume_manual().unwrap().unwrap().matches(&record)); + drop(store); + let store = Store::open(&root.0).unwrap(); + for automatic in [false, true] { + store.set_automatic(automatic).unwrap(); + assert!(store.consume_manual().unwrap().is_none()); + assert!(!store + .manual_requested(&record.target_id(), &record.archive_sha256) + .unwrap()); + } + let saved: serde_json::Value = + serde_json::from_slice(&fs::read(root.cache().join("manual-intent.json")).unwrap()) + .unwrap(); + assert_eq!(saved["consumed"], true); + store + .request_manual(&record.target_id(), &record.archive_sha256) + .unwrap(); + assert!(store.consume_manual().unwrap().unwrap().matches(&record)); + assert!(store.consume_manual().unwrap().is_none()); + } + + #[test] + fn concurrent_consumers_share_one_manual_click() { + let root = Temp::new(); + let store = std::sync::Arc::new(Store::open(&root.0).unwrap()); + let record = record(); + store + .commit(store.stage(&record, b"data").unwrap()) + .unwrap(); + store + .request_manual(&record.target_id(), &record.archive_sha256) + .unwrap(); + let consumers: Vec<_> = (0..2) + .map(|_| { + let store = store.clone(); + std::thread::spawn(move || usize::from(store.consume_manual().unwrap().is_some())) + }) + .collect(); + assert_eq!( + consumers + .into_iter() + .map(|thread| thread.join().unwrap()) + .sum::(), + 1 + ); + } + + #[test] + fn target_id_has_a_stable_domain_and_fixed_little_endian_release_asset_ids() { + assert_eq!( + record().target_id(), + "1fda47fc12357e0d1dcdc71b25d7c6741934edabab89ea7019cef1484e236ce9" + ); + let mut changed = record(); + changed.manifest.push('\n'); + assert_ne!(changed.target_id(), record().target_id()); + } + + #[test] + fn manual_intent_is_target_specific_without_changing_automatic_consent() { + let root = Temp::new(); + let store = Store::open(&root.0).unwrap(); + let first = record(); + store.commit(store.stage(&first, b"data").unwrap()).unwrap(); + store.set_automatic(false).unwrap(); + assert!(!store + .manual_requested(&first.target_id(), &first.archive_sha256) + .unwrap()); + store + .request_manual(&first.target_id(), &first.archive_sha256) + .unwrap(); + assert!(store + .manual_requested(&first.target_id(), &first.archive_sha256) + .unwrap()); + assert!(!store.preferences().unwrap().automatic); + assert!(store + .request_manual(&first.target_id(), &"b".repeat(64)) + .is_err()); + assert!(store + .request_manual(&"b".repeat(64), &first.archive_sha256) + .is_err()); + let mut next = record(); + next.archive_sha256 = "b".repeat(64); + store.commit(store.stage(&next, b"next").unwrap()).unwrap(); + assert!(!store + .manual_requested(&next.target_id(), &next.archive_sha256) + .unwrap()); + assert!(!store.preferences().unwrap().automatic); + } + + #[test] + fn manual_intent_rejects_same_archive_with_replaced_release_or_manifest() { + let root = Temp::new(); + let store = Store::open(&root.0).unwrap(); + let first = record(); + store.publish(&first, b"data").unwrap(); + store + .request_manual(&first.target_id(), &first.archive_sha256) + .unwrap(); + for replacement in [ + PreparedRecord { + release_id: 10, + ..first.clone() + }, + PreparedRecord { + manifest_asset_id: 20, + ..first.clone() + }, + PreparedRecord { + archive_asset_id: 30, + ..first.clone() + }, + PreparedRecord { + manifest: "{}\n".into(), + ..first.clone() + }, + ] { + store.publish(&replacement, b"data").unwrap(); + assert!(!store + .manual_requested(&replacement.target_id(), &replacement.archive_sha256) + .unwrap()); + assert!(store + .request_manual(&first.target_id(), &first.archive_sha256) + .is_err()); + } + assert!(store.preferences().unwrap().automatic); + } + #[test] fn preferences_survive_reopen_and_manual_cache_does_not_change_consent() { let temp = Temp::new(); diff --git a/src/App.tsx b/src/App.tsx index 2ca176f6..af75e5da 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,6 +9,7 @@ import AppContent from './components/app/AppContent'; import DesktopDeepLinkBridge from './components/app/DesktopDeepLinkBridge'; import { appShellRoutePaths, rootFallbackRoutePath } from './components/app/appRoutes'; import i18n from './i18n/config.js'; +import { useComposerFreezeBridge } from './shared/composerFreezeBridge'; const DEPLOYMENT_ASSET_DIRECTORIES = new Set(['assets', 'static', 'icons', 'images']); @@ -104,5 +105,6 @@ function ApplicationLayout({ routerBasename }: ApplicationLayoutProps) { } export default function App() { + useComposerFreezeBridge(); return ; } diff --git a/src/components/app/DesktopDeepLinkBridge.tsx b/src/components/app/DesktopDeepLinkBridge.tsx index 4d26a871..91c65e5c 100644 --- a/src/components/app/DesktopDeepLinkBridge.tsx +++ b/src/components/app/DesktopDeepLinkBridge.tsx @@ -26,9 +26,10 @@ export function deepLinkPath(rawUrl: unknown): string | null { } catch { return null; } - if (url.protocol !== 'gajae-app:') return null; - const segments = `${url.host}${url.pathname}`.split('/').filter(Boolean); - if (segments[0] === 'open' && segments[1] === 'job' && /^[A-Za-z0-9._:-]{1,128}$/u.test(segments[2] ?? '')) { + if (rawUrl.length > 256 || url.protocol !== 'gajae-app:' || url.hostname !== 'open' + || url.username || url.password || url.port || url.search || url.hash) return null; + const segments = url.pathname.split('/'); + if (segments.length === 3 && segments[0] === '' && segments[1] === 'job' && /^[A-Za-z0-9._:-]{1,128}$/u.test(segments[2] ?? '')) { return '/'; } return null; diff --git a/src/components/app/tests/desktopDeepLink.test.tsx b/src/components/app/tests/desktopDeepLink.test.tsx index 25567647..d2728d71 100644 --- a/src/components/app/tests/desktopDeepLink.test.tsx +++ b/src/components/app/tests/desktopDeepLink.test.tsx @@ -14,6 +14,13 @@ test('foreign schemes, malformed urls, and unknown shapes are rejected', () => { 'gajae-app://open/job/', 'gajae-app://open/job/../../etc', 'gajae-app://other/job/job-1', + 'gajae-app://user@open/job/job-1', + 'gajae-app://open:123/job/job-1', + 'gajae-app://open/job/job-1/extra', + 'gajae-app://open/job/job-1/', + 'gajae-app://open//job/job-1', + 'gajae-app://open/job/job-1?redirect=https://example.com', + 'gajae-app://open/job/job-1#anything', 'not a url', 42, null, diff --git a/src/components/chat/hooks/composerDraftDurability.dom.bun.test.tsx b/src/components/chat/hooks/composerDraftDurability.dom.bun.test.tsx new file mode 100644 index 00000000..d42a2883 --- /dev/null +++ b/src/components/chat/hooks/composerDraftDurability.dom.bun.test.tsx @@ -0,0 +1,332 @@ +import assert from 'node:assert/strict'; +import { afterEach, test } from 'node:test'; + +import { act, cleanup, fireEvent, render, renderHook, waitFor } from '@testing-library/react'; + +import type { Project, ProjectSession } from '../../../types/app'; +import { resetComposerFreezeForTests } from '../../../shared/composerFreeze'; +import { decideQueuedDispatch, useQueuedMessageAutoSend } from '../../../hooks/useQueuedMessageAutoSend'; +import type { SessionActivityMap } from '../../../hooks/useSessionProtection'; +import { draftInputKey, queuedMessageKey, readQueuedMessages, writeQueuedMessages } from '../utils/chatStorage'; +import { boundedComposerDraft, COMPOSER_STORAGE_LIMITS, composerRouteKey, ComposerStorageError, type ComposerDraftRepository, type ComposerDraft, type StoredComposerDraft } from '../utils/composerDraftStorage'; +import { ComposerDraftPersistenceHarness } from '../tests/fixtures/ComposerDraftPersistenceHarness'; + +import { useChatComposerState } from './useChatComposerState'; + +// happy-dom and Bun have no IndexedDB; this repository seam controls async +// races/errors, not IDB conformance. Real structured clone is exercised by the +// same harness in the isolated in-app browser. +class Repository implements ComposerDraftRepository { + records = new Map(); + writes = 0; + beforeLoad?: (route: { projectId: string; conversation: string | null }) => Promise; + beforeSave?: () => Promise; + async load(route: { projectId: string; conversation: string | null }) { + await this.beforeLoad?.(route); + const value = this.records.get(composerRouteKey(route)); + return value ? clone(value) : null; + } + async save(value: ComposerDraft, expectedRevision: number) { + const { draft } = boundedComposerDraft(value); + this.writes += 1; + await this.beforeSave?.(); + const key = composerRouteKey(draft); + if ((this.records.get(key)?.revision ?? 0) !== expectedRevision) throw new ComposerStorageError('conflict'); + const revision = expectedRevision + 1; + this.records.set(key, clone({ ...draft, revision })); + return revision; + } +} +function clone(value: StoredComposerDraft): StoredComposerDraft { + const files = (items: File[]) => items.map((file) => new File([file], file.name, { type: file.type, lastModified: file.lastModified })); + return { ...value, images: files(value.images), queue: value.queue.map((item) => ({ ...item, options: item.options ? structuredClone(item.options) : undefined, images: files(item.images) })) }; +} +const deferred = () => { let resolve!: () => void; const promise = new Promise((done) => { resolve = done; }); return { promise, resolve }; }; +const project: Project = { projectId: 'project-a', fullPath: '/qa/project-a', displayName: 'A', origin: 'explicit' }; +const session = (id = 'session-a') => ({ id, __provider: 'gjc' }) as ProjectSession; +type Args = Parameters[0]; +const base: Args = { selectedProject: project, selectedSession: session(), currentSessionId: null, gjcModel: 'test/model', reasoningEffort: 'xhigh', isLoading: true, canAbortSession: false, tokenBudget: null, sendMessage() {}, addMessage() {}, scrollToBottom() {}, setIsUserScrolledUp() {}, setPendingPermissionRequests() {} }; +const composer = (repository: Repository, props: Partial = {}) => renderHook((overrides: Partial) => useChatComposerState({ ...base, draftRepository: repository, ...overrides }), { initialProps: props }); +const saved = async (view: ReturnType) => { await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); await waitFor(() => assert.equal(view.result.current.draftPersistence.phase, 'saved')); }; +const submit = () => ({ preventDefault() {} }) as never; +const image = (body = 'fixture') => new File([body], 'fixture.png', { type: 'image/png', lastModified: 12345 }); +const snapshot = (input = 'old', conversation = 'session-a'): StoredComposerDraft => ({ projectId: project.projectId, conversation, input, images: [image()], queue: [], revision: 1 }); +globalThis.fetch = (async () => new Response('[]', { headers: { 'content-type': 'application/json' } })) as typeof fetch; +afterEach(() => { cleanup(); resetComposerFreezeForTests(); localStorage.clear(); }); + +test('actual composer form events save and hydrate a File attachment after remount', async () => { + const repository = new Repository(); + let view = render(); + await waitFor(() => assert.equal(view.getByRole('status').textContent, 'saved:none')); + fireEvent.change(view.getByLabelText('Draft'), { target: { value: 'keep this image' } }); + fireEvent.click(view.getByText('Paste fixture image')); + await waitFor(() => assert.equal(view.getByRole('status').textContent, 'saved:none')); + view.unmount(); + view = render(); + await waitFor(() => assert.match(view.getByLabelText('Fixture file hydration').textContent ?? '', /true:draft-fixture.svg:image\/svg\+xml:1234567:.*fixture attachment/)); + assert.equal((view.getByLabelText('Draft') as HTMLTextAreaElement).value, 'keep this image'); +}); + +test('queued File bytes, id, order and options survive remount without automatic replay', async () => { + const repository = new Repository(); + const sent: unknown[] = []; + const props = { sendMessage: (message: unknown) => { sent.push(message); } }; + const view = composer(repository, props); + await saved(view); + act(() => { view.result.current.setInput('follow up'); view.result.current.setAttachedImages([image('queued image')]); }); + await act(async () => view.result.current.handleSubmit(submit())); + await saved(view); + const id = view.result.current.queuedDrafts[0].id; + assert.ok(id); + assert.equal(decideQueuedDispatch(readQueuedMessages('session-a')[0], true).action, 'hold', 'text-only offscreen sender cannot consume IDB intents'); + view.unmount(); + const reopened = composer(repository, { ...props, isLoading: false }); + await saved(reopened); + assert.equal(reopened.result.current.queuedDrafts[0].id, id); + assert.equal(reopened.result.current.queuedDrafts[0].requiresReview, true); + assert.equal(await reopened.result.current.queuedDrafts[0].images[0].text(), 'queued image'); + assert.deepEqual(reopened.result.current.queuedDrafts[0].options, { model: 'test/model', effort: 'xhigh', sessionSummary: 'follow up' }); + await act(async () => { await new Promise((resolve) => setTimeout(resolve, 800)); }); + assert.deepEqual(sent, []); +}); + +test('stale restore cannot overwrite a live keystroke or pasted File', async () => { + const repository = new Repository(); + repository.records.set(composerRouteKey(snapshot()), snapshot()); + const gate = deferred(); repository.beforeLoad = () => gate.promise; + const view = composer(repository); + act(() => { view.result.current.handleInputChange({ target: { value: 'live edit', selectionStart: 9 } } as never); view.result.current.setAttachedImages([image('new image')]); }); + assert.equal(view.result.current.draftReady, false); + await act(async () => gate.resolve()); + await saved(view); + assert.equal(view.result.current.input, 'live edit'); + assert.equal(await view.result.current.attachedImages[0].text(), 'new image'); + assert.equal(repository.records.get(composerRouteKey(snapshot()))?.input, 'live edit'); +}); + +test('late A restore stays in A after switching to B with the same conversation id', async () => { + const repository = new Repository(); + repository.records.set(composerRouteKey(snapshot()), snapshot('A from disk')); + const gate = deferred(); repository.beforeLoad = (route) => route.projectId === 'project-a' ? gate.promise : Promise.resolve(); + const view = composer(repository); + const other = { ...project, projectId: 'project-b' }; + view.rerender({ selectedProject: other }); + act(() => view.result.current.setInput('live B')); + await saved(view); + await act(async () => gate.resolve()); + assert.equal(view.result.current.input, 'live B'); + assert.equal(view.result.current.attachedImages.length, 0); + view.rerender({ selectedProject: project }); + assert.equal(view.result.current.input, 'A from disk'); + assert.equal(await view.result.current.attachedImages[0].text(), 'fixture'); +}); + +test('storage remains pending until commit and serializes keystrokes behind an in-flight save', async () => { + const repository = new Repository(); const view = composer(repository); await saved(view); + const gate = deferred(); repository.beforeSave = () => gate.promise; + act(() => view.result.current.setInput('first')); + await act(async () => { await Promise.resolve(); }); + const writes = repository.writes; + act(() => { for (let i = 0; i < 50; i += 1) view.result.current.setInput(`new ${i}`); }); + assert.equal(repository.writes, writes, 'only one in-flight write, no per-keystroke promise backlog'); + assert.equal(view.result.current.draftPersistence.phase, 'pending'); + assert.notEqual(repository.records.get(composerRouteKey(snapshot()))?.input, 'new 49'); + await act(async () => gate.resolve()); await saved(view); + assert.equal(repository.records.get(composerRouteKey(snapshot()))?.input, 'new 49'); +}); + +test('quota failure retains live File and old committed record without a success acknowledgement', async () => { + const repository = new Repository(); const view = composer(repository); await saved(view); + const old = repository.records.get(composerRouteKey(snapshot())); + localStorage.setItem(draftInputKey('unrelated'), 'must survive'); + repository.beforeSave = async () => { throw new DOMException('full', 'QuotaExceededError'); }; + act(() => { view.result.current.setInput('not yet durable'); view.result.current.setAttachedImages([image()]); }); + await waitFor(() => assert.deepEqual(view.result.current.draftPersistence, { phase: 'error', reason: 'quota' })); + assert.equal(view.result.current.input, 'not yet durable'); + assert.equal(await view.result.current.attachedImages[0].text(), 'fixture'); + assert.deepEqual(repository.records.get(composerRouteKey(snapshot())), old); + assert.equal(localStorage.getItem(draftInputKey('unrelated')), 'must survive'); +}); + +test('delete/reorder/edit survive remount and editing keeps the unrelated active draft', async () => { + const repository = new Repository(); const view = composer(repository); await saved(view); + for (const text of ['one', 'two', 'three']) { + act(() => { view.result.current.setInput(text); view.result.current.setAttachedImages([image(text)]); }); + await act(async () => view.result.current.handleSubmit(submit())); + } + const ids = view.result.current.queuedDrafts.map((item) => item.id); + act(() => view.result.current.moveQueuedDraft(2, 0)); + act(() => view.result.current.deleteQueuedDraft(1)); + act(() => { view.result.current.setInput('other draft'); view.result.current.setAttachedImages([image('other')]); }); + act(() => view.result.current.editQueuedDraft(0)); + await saved(view); + assert.equal(view.result.current.input, 'three'); + assert.equal(await view.result.current.attachedImages[0].text(), 'three'); + view.unmount(); const reopened = composer(repository); await saved(reopened); + assert.equal(reopened.result.current.queuedDrafts[0].id, ids[1]); + assert.deepEqual(reopened.result.current.queuedDrafts.map((item) => item.content), ['two', 'other draft']); + assert.equal(await reopened.result.current.queuedDrafts[1].images[0].text(), 'other'); +}); + +test('save conflicts never overwrite another window or acknowledge unsaved input', async () => { + const repository = new Repository(); const a = composer(repository); await saved(a); + const b = composer(repository); await saved(b); + act(() => a.result.current.setInput('window A')); await saved(a); + act(() => b.result.current.setInput('window B')); + await waitFor(() => assert.equal(b.result.current.draftPersistence.reason, 'conflict')); + assert.equal(b.result.current.input, 'window B'); + assert.equal(repository.records.get(composerRouteKey(snapshot()))?.input, 'window A'); +}); + +test('limits reject the entire snapshot rather than truncating text or losing files', async () => { + const repository = new Repository(); const view = composer(repository); await saved(view); + const text = 'x'.repeat(COMPOSER_STORAGE_LIMITS.textLength + 1); + act(() => view.result.current.setInput(text)); + await waitFor(() => assert.equal(view.result.current.draftPersistence.reason, 'limit')); + assert.equal(view.result.current.input, text); + assert.equal(repository.records.get(composerRouteKey(snapshot())), undefined); +}); + +test('explicit clear while restore is pending does not resurrect an IDB-only draft', async () => { + const repository = new Repository(); repository.records.set(composerRouteKey(snapshot()), snapshot()); + const gate = deferred(); repository.beforeLoad = () => gate.promise; + const view = composer(repository); + act(() => view.result.current.handleClearInput()); + await act(async () => gate.resolve()); await saved(view); + assert.equal(view.result.current.input, ''); + assert.equal(view.result.current.attachedImages.length, 0); + assert.equal(repository.records.get(composerRouteKey(snapshot()))?.input, ''); +}); + +test('recovered intents announce the existing Edit and Send recovery path once', async () => { + const repository = new Repository(); + const record = { ...snapshot(''), images: [], queue: [{ id: 'recovered-id', content: 'review first', images: [image()] }] }; + repository.records.set(composerRouteKey(record), record); + const notices: Array<{ content?: string }> = []; + const view = composer(repository, { addMessage: (message) => { notices.push(message); } }); + await saved(view); + assert.equal(notices.length, 1); + assert.match(notices[0].content ?? '', /paused.*Edit.*Send/); + act(() => view.result.current.setInput('another draft')); await saved(view); + assert.equal(notices.length, 1, 'typing does not repeat the warning'); + act(() => view.result.current.editQueuedDraft(0)); await saved(view); + assert.equal(view.result.current.input, 'review first'); + assert.equal(await view.result.current.attachedImages[0].text(), 'fixture'); +}); + +test('an image pasted during upload survives the earlier successful send', async () => { + const repository = new Repository(); const oldFetch = globalThis.fetch; + const gate = deferred(); + globalThis.fetch = async (url) => { + if (String(url).endsWith('/images')) { await gate.promise; return new Response('{"images":[]}'); } + return new Response('[]'); + }; + try { + const view = composer(repository, { isLoading: false }); await saved(view); + act(() => { view.result.current.setInput('text with image'); view.result.current.setAttachedImages([image('original')]); }); + let sending!: Promise; + act(() => { sending = view.result.current.handleSubmit(submit()); }); + act(() => view.result.current.setAttachedImages([image('new paste')])); + await act(async () => { gate.resolve(); await sending; }); await saved(view); + assert.equal(await view.result.current.attachedImages[0].text(), 'new paste'); + assert.equal(view.result.current.input, 'text with image'); + } finally { globalThis.fetch = oldFetch; } +}); + +test('late steer acknowledgement persists to its original project, not the currently selected project', async () => { + const repository = new Repository(); const view = composer(repository); await saved(view); + act(() => view.result.current.setInput('steer in A')); + act(() => view.result.current.handleSteer(submit())); await saved(view); + view.rerender({ selectedProject: { ...project, projectId: 'project-b' }, selectedSession: session('session-b') }); + act(() => view.result.current.setInput('keep project B')); await saved(view); + await act(async () => view.result.current.resolveSteerResult('steer in A', true, 'session-a')); + await saved(view); + assert.deepEqual(repository.records.get(composerRouteKey(snapshot()))?.queue, []); + assert.equal(repository.records.has(JSON.stringify(['project-b', 'session-a'])), false); + assert.equal(view.result.current.input, 'keep project B'); +}); + +test('conflict retry rebases with live text and Files intact and re-enables manual Send', async () => { + const repository = new Repository(); const a = composer(repository); await saved(a); + const sent: unknown[] = []; + const b = composer(repository, { isLoading: false, sendMessage: (message) => { sent.push(message); } }); await saved(b); + act(() => a.result.current.setInput('other window')); await saved(a); + act(() => { b.result.current.setInput('live local'); b.result.current.setAttachedImages([image('live file')]); }); + await waitFor(() => assert.equal(b.result.current.draftPersistence.reason, 'conflict')); + await act(async () => b.result.current.handleSubmit(submit())); + assert.equal(b.result.current.draftReady, true); + assert.equal(b.result.current.input, 'live local'); + assert.equal(await b.result.current.attachedImages[0].text(), 'live file'); + assert.deepEqual(sent, [], 'recovery click itself does not silently send'); + const oldFetch = globalThis.fetch; + globalThis.fetch = async () => new Response('{"images":[]}'); + try { await act(async () => b.result.current.handleSubmit(submit())); } finally { globalThis.fetch = oldFetch; } + assert.equal(sent.length, 1); +}); + +test('a failed initial load is retryable without overwriting typing that happened during recovery', async () => { + const repository = new Repository(); repository.beforeLoad = async () => { throw new Error('temporary failure'); }; + const view = composer(repository); + await waitFor(() => assert.equal(view.result.current.draftPersistence.phase, 'error')); + act(() => { view.result.current.setInput('live while unavailable'); view.result.current.setAttachedImages([image('paste')]); }); + repository.beforeLoad = undefined; + await act(async () => assert.equal(await view.result.current.retryDraftPersistence(), true)); + assert.equal(view.result.current.draftReady, true); + assert.equal(view.result.current.input, 'live while unavailable'); + assert.equal(await view.result.current.attachedImages[0].text(), 'paste'); +}); + +test('incomplete legacy migrations preserve the complete raw queue on load, typing and retry', async () => { + const cases = [ + JSON.stringify(Array.from({ length: 101 }, (_, id) => ({ id: String(id), content: `intent ${id}` }))), + 'x'.repeat(COMPOSER_STORAGE_LIMITS.textLength * 2 + 1), + JSON.stringify([{ id: 'ok', content: 'keep this' }, { id: 'broken' }]), + ]; + for (const raw of cases) { + const repository = new Repository(); + localStorage.setItem(queuedMessageKey('session-a'), raw); + const view = composer(repository); + await waitFor(() => assert.equal(view.result.current.draftPersistence.phase, 'error')); + act(() => view.result.current.setInput('live input')); + await act(async () => assert.equal(await view.result.current.retryDraftPersistence(), false)); + assert.equal(localStorage.getItem(queuedMessageKey('session-a')), raw); + assert.equal(repository.writes, 0); + view.unmount(); localStorage.clear(); + } +}); + +test('IndexedDB-backed text queues retain real offscreen auto-send and reconcile the consumed id', async () => { + const repository = new Repository(); + const sent: Array<{ type: string }> = []; + const socket = Object.assign(new EventTarget(), { readyState: WebSocket.OPEN }) as WebSocket; + const running: SessionActivityMap = new Map([['session-a', { startedAt: 1, statusText: null, canInterrupt: true, awaitingInput: false }]]); + const view = renderHook(({ active, processing }: { active: string; processing: SessionActivityMap }) => { + const sendMessage = (message: unknown) => { sent.push(message as { type: string }); return true; }; + const result = useChatComposerState({ ...base, draftRepository: repository, selectedSession: session(active), isLoading: processing.has(active), sendMessage }); + useQueuedMessageAutoSend({ processingSessions: processing, activeSessionId: active, ws: socket, sendMessage, markSessionProcessing() {} }); + return result; + }, { initialProps: { active: 'session-a', processing: running } }); + await saved(view as ReturnType); + act(() => view.result.current.setInput('text follow up')); + await act(async () => view.result.current.handleSubmit(submit())); await saved(view as ReturnType); + assert.equal(readQueuedMessages('session-a')[0].pendingSteer, undefined); + view.rerender({ active: 'session-b', processing: running }); + view.rerender({ active: 'session-b', processing: new Map() }); + assert.deepEqual(sent.map((item) => item.type), ['chat.send']); + await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); + assert.deepEqual(repository.records.get(composerRouteKey(snapshot()))?.queue, []); + view.rerender({ active: 'session-a', processing: new Map() }); + act(() => view.result.current.setInput('next input')); + assert.deepEqual(view.result.current.queuedDrafts, []); + assert.deepEqual(readQueuedMessages('session-a'), []); +}); + +test('external projection consumption is reconciled before fallback composer writes', async () => { + const view = renderHook(() => useChatComposerState(base)); + act(() => view.result.current.setInput('queued once')); + await act(async () => view.result.current.handleSubmit(submit())); + act(() => writeQueuedMessages('session-a', [])); + act(() => view.result.current.setInput('fresh input')); + assert.deepEqual(view.result.current.queuedDrafts, []); + assert.deepEqual(readQueuedMessages('session-a'), []); +}); diff --git a/src/components/chat/hooks/composerFreeze.dom.bun.test.tsx b/src/components/chat/hooks/composerFreeze.dom.bun.test.tsx new file mode 100644 index 00000000..a5e3f575 --- /dev/null +++ b/src/components/chat/hooks/composerFreeze.dom.bun.test.tsx @@ -0,0 +1,903 @@ +import assert from 'node:assert/strict'; +import { afterEach, beforeEach, test } from 'node:test'; + +import { act, cleanup, fireEvent, render, renderHook, waitFor } from '@testing-library/react'; +import { createInstance } from 'i18next'; +import { StrictMode } from 'react'; +import { I18nextProvider } from 'react-i18next'; + +import { useQueuedMessageAutoSend } from '../../../hooks/useQueuedMessageAutoSend'; +import type { SessionActivityMap } from '../../../hooks/useSessionProtection'; +import english from '../../../i18n/locales/en/chat.json'; +import { beginComposerOperation, cancelComposerFreeze, finishComposerOperation, invalidateComposerFreeze, isComposerFreezeCurrent, isComposerFrozen, isComposerSealed, prepareComposerFreeze, registerComposerSealRollbackOwner, resetComposerFreezeForTests, sealComposerFreeze, type ComposerFreezeReceipt } from '../../../shared/composerFreeze'; +import { draftInputKey, readQueuedMessages, safeLocalStorage, writeQueuedMessages } from '../utils/chatStorage'; +import { boundedComposerDraft, browserComposerDraftRepository, composerRouteKey, ComposerStorageError, type ComposerDraft, type ComposerDraftRepository, type ComposerRoute, type StoredComposerDraft } from '../utils/composerDraftStorage'; +import { installComposerDraftStorageTestDriver } from '../utils/composerDraftStorage.testDriver'; +import ChatComposer from '../view/ChatComposer'; + +import { useChatComposerState } from './useChatComposerState'; +import { useDurableComposerDraft } from './useDurableComposerDraft'; + +// A deterministic commit/read-back seam, not an IDB polyfill or native G3 +// certification. composerDraftStorage's transaction tests cover strict commit. +class Repository implements ComposerDraftRepository { + records = new Map(); + beforeLoad?: () => Promise; + beforeSave?: () => Promise; + readBack?: (draft: StoredComposerDraft) => StoredComposerDraft; + writes = 0; + async load(route: ComposerRoute) { + await this.beforeLoad?.(); + const record = this.records.get(composerRouteKey(route)); + return record ? this.readBack?.(clone(record)) ?? clone(record) : null; + } + async save(value: ComposerDraft, expectedRevision: number) { + const { draft } = boundedComposerDraft(value); + this.writes += 1; + await this.beforeSave?.(); + const key = composerRouteKey(draft); + if ((this.records.get(key)?.revision ?? 0) !== expectedRevision) throw new ComposerStorageError('conflict'); + const revision = expectedRevision + 1; + this.records.set(key, clone({ ...draft, revision })); + return revision; + } +} +function clone(record: StoredComposerDraft): StoredComposerDraft { + const files = (items: File[]) => items.map((file) => new File([file], file.name, { type: file.type, lastModified: file.lastModified })); + return { ...record, images: files(record.images), queue: record.queue.map((item) => ({ ...item, options: item.options ? structuredClone(item.options) : undefined, images: files(item.images) })) }; +} +const dataFiles = () => [ + new File([new Uint8Array([0, 255, 17, 0, 96])], 'first.png', { type: 'image/png', lastModified: 111 }), + new File([new Uint8Array([128, 0, 254, 21])], 'second.png', { type: 'image/png', lastModified: 222 }), +]; +const svgFile = () => new File(['restart fixture'.padEnd(172, ' ')], 'fixture.svg', { type: 'image/svg+xml', lastModified: 1234567 }); +const deferred = () => { let resolve!: () => void; const promise = new Promise((done) => { resolve = done; }); return { promise, resolve }; }; +type Args = Parameters[0]; +const base: Args = { + selectedProject: { projectId: 'freeze-project', fullPath: '/fixture', displayName: 'Fixture', origin: 'explicit' }, + selectedSession: { id: 'a', __provider: 'gjc' }, currentSessionId: null, + gjcModel: 'fixture/model', reasoningEffort: 'xhigh', isLoading: true, canAbortSession: false, tokenBudget: null, + sendMessage() {}, addMessage() {}, scrollToBottom() {}, setIsUserScrolledUp() {}, setPendingPermissionRequests() {}, +}; +const composer = (repository: ComposerDraftRepository, overrides: Partial = {}) => renderHook((props: Partial) => useChatComposerState({ ...base, draftRepository: repository, ...props }), { initialProps: overrides }); +const saved = (view: ReturnType) => waitFor(() => assert.equal(view.result.current.draftPersistence.phase, 'saved')); +const submit = () => ({ preventDefault() {} }) as never; +const rejectedWith = (reason: string) => (error: unknown) => Boolean(error && typeof error === 'object' && 'reason' in error && error.reason === reason); +let epoch = 0; +const request = (ttlMs = 2000) => ({ token: `fixture-${++epoch}`, epoch, ttlMs }); +async function freeze(ttlMs?: number) { + let receipt!: ComposerFreezeReceipt; + await act(async () => { receipt = await prepareComposerFreeze(request(ttlMs)); }); + return receipt; +} +const i18n = createInstance(); +await i18n.init({ lng: 'en', resources: { en: { chat: english } } }); +const originalFetch = globalThis.fetch; +const originalIndexedDB = Object.getOwnPropertyDescriptor(globalThis, 'indexedDB'); +beforeEach(() => { resetComposerFreezeForTests(); epoch = 0; localStorage.clear(); globalThis.fetch = async () => new Response('[]'); }); +afterEach(() => { + cleanup(); + document.querySelectorAll('[data-composer-attachment-picker]').forEach((input) => input.dispatchEvent(new Event('cancel'))); + resetComposerFreezeForTests(); localStorage.clear(); globalThis.fetch = originalFetch; + if (originalIndexedDB) Object.defineProperty(globalThis, 'indexedDB', originalIndexedDB); + else Reflect.deleteProperty(globalThis, 'indexedDB'); +}); + +test('operation IDs reject duplicate/reused admission and stale releases cannot clear another lifetime', async () => { + const first = beginComposerOperation('steer', 'one-shot-id')!; + assert.ok(first); + assert.equal(beginComposerOperation('send', 'one-shot-id'), null); + await assert.rejects(prepareComposerFreeze(request()), rejectedWith('busy')); + finishComposerOperation('one-shot-id'); first(); first(); + assert.equal(beginComposerOperation('steer', 'one-shot-id'), null, 'an exact-ID late ACK must never address a reused lifetime'); + const newer = beginComposerOperation('steer', 'different-id')!; + finishComposerOperation('one-shot-id'); first(); + await assert.rejects(prepareComposerFreeze(request()), rejectedWith('busy')); + newer(); await freeze(); +}); + +test('even test-only page reset cannot let an old release closure delete a replacement entry', async () => { + const old = beginComposerOperation('send', 'fixture-id')!; + resetComposerFreezeForTests(); + const current = beginComposerOperation('send', 'fixture-id')!; + old(); old(); + await assert.rejects(prepareComposerFreeze(request()), rejectedWith('busy')); + current(); await freeze(); +}); + +/** Real production ChatComposer plus production hook; no alternative send UI. */ +function Composer({ repository, overrides = {} }: { repository: Repository; overrides?: Partial }) { + const c = useChatComposerState({ ...base, draftRepository: repository, ...overrides }); + return + {c.draftPersistence.phase} + {}} onAbortSession={c.handleAbortSession} + onSubmit={c.handleSubmit} onSteer={c.handleSteer} + onEditQueuedDraft={c.editQueuedDraft} onDeleteQueuedDraft={c.deleteQueuedDraft} onMoveQueuedDraft={c.moveQueuedDraft} + onConfirmCommandGate={c.confirmCommandGate} onCancelCommandGate={c.cancelCommandGate} + onRemoveImage={(index) => c.setAttachedImages((files) => files.filter((_, position) => position !== index))} + onSelectFile={c.selectFile} onCommandSelect={c.handleCommandSelect} + onCloseCommandMenu={c.resetCommandMenuState} isCommandMenuOpen={c.showCommandMenu} + onInputChange={c.handleInputChange} onTextareaClick={c.handleTextareaClick} onTextareaKeyDown={c.handleKeyDown} + onTextareaPaste={c.handlePaste} onTextareaScrollSync={c.syncInputOverlayScroll} onTextareaInput={c.handleTextareaInput} + placeholder="Freeze fixture" onRetryDraftPersistence={c.retryDraftPersistence} + /> + ; +} + +for (const conversation of ['a', null]) { + for (const unmount of [false, true]) { + test(`fresh-page SVG restore keeps prepare/seal current through frozen rerenders (${conversation ?? 'new chat'}, ${unmount ? 'unmounted' : 'mounted'})`, async () => { + const repository = new Repository(); + const overrides: Partial = { selectedSession: conversation ? { id: conversation, __provider: 'gjc' } : null, isLoading: false }; + const previous = composer(repository, overrides); await saved(previous); + const file = svgFile(); + act(() => { previous.result.current.setInput('persisted unsent draft'); previous.result.current.setAttachedImages([file]); }); + await saved(previous); previous.unmount(); + // A restart loses the page registry, but keeps both committed records and + // localStorage projections. The repository seam clones each restored File. + resetComposerFreezeForTests(); + const view = render(, { wrapper: StrictMode }); + await waitFor(() => assert.equal(view.getByTestId('persistence').textContent, 'saved')); + const textarea = view.getByPlaceholderText('Freeze fixture') as HTMLTextAreaElement; + assert.equal(textarea.value, 'persisted unsent draft'); + assert.ok(view.getByRole('img', { name: 'fixture.svg' })); + const writes = repository.writes; + const commit = deferred(); repository.beforeSave = () => commit.promise; + let pending!: Promise; let acknowledged = false; + act(() => { pending = prepareComposerFreeze(request()).then((receipt) => { acknowledged = true; return receipt; }); }); + // Exercise a committed frozen render before verification can finish, not + // only the pre-paint state inside one asynchronous act(). + view.rerender(); + assert.equal(isComposerFrozen(), true); + assert.equal(textarea.readOnly, false); + await waitFor(() => assert.equal(repository.writes, writes + 1)); + assert.equal(acknowledged, false); + if (unmount) view.unmount(); + let receipt!: ComposerFreezeReceipt; + await act(async () => { commit.resolve(); receipt = await pending; }); + assert.equal(isComposerFreezeCurrent(receipt), true); + assert.equal(receipt.drafts.length, 1, 'rerenders must not replace or duplicate the registered owner'); + assert.equal(receipt.drafts[0].routeKey, composerRouteKey({ projectId: 'freeze-project', conversation })); + assert.equal(receipt.drafts[0].fileCount, 1); + assert.equal(receipt.drafts[0].queuedIntentCount, 0); + act(() => { assert.equal(sealComposerFreeze(receipt), true); }); + if (!unmount) { + view.rerender(); + assert.equal(textarea.readOnly, true); + fireEvent.input(textarea, { target: { value: 'blocked after seal' } }); + assert.equal(textarea.value, 'persisted unsent draft'); + } + assert.equal(isComposerFreezeCurrent(receipt), true, 'the seal notification must not re-register or reactivate unchanged ownership'); + assert.equal(repository.writes, writes + 1); + const stored = repository.records.get(receipt.drafts[0].routeKey)!; + assert.equal(stored.input, 'persisted unsent draft'); + assert.notEqual(stored.images[0], file); + assert.equal(stored.images[0].size, 172); + assert.equal(stored.images[0].type, file.type); + assert.equal(stored.images[0].lastModified, file.lastModified); + assert.deepEqual(await stored.images[0].arrayBuffer(), await file.arrayBuffer()); + }); + } +} + +test('prepare waits for a restored SVG and its complete read-back bytes without invalidating the frozen rerender', async () => { + const repository = new Repository(); const file = svgFile(); + const record = { projectId: 'freeze-project', conversation: 'a', input: 'still restoring', images: [file], queue: [], revision: 1 }; + repository.records.set(composerRouteKey(record), record); + const key = draftInputKey(record.projectId, record.conversation); + localStorage.setItem(key, record.input); localStorage.setItem(`composer_owner_${key}`, composerRouteKey(record)); + const restore = deferred(); repository.beforeLoad = () => restore.promise; + const view = composer(repository, { isLoading: false }); + assert.equal(view.result.current.draftPersistence.phase, 'loading'); + assert.equal(view.result.current.attachedImages.length, 0); + let pending!: Promise; let acknowledged = false; + act(() => { pending = prepareComposerFreeze(request()).then((receipt) => { acknowledged = true; return receipt; }); }); + const bytes = deferred(); let reading = false; + repository.readBack = (draft) => { + if (repository.writes) { + const arrayBuffer = draft.images[0].arrayBuffer.bind(draft.images[0]); + draft.images[0].arrayBuffer = async () => { reading = true; await bytes.promise; return arrayBuffer(); }; + } + return draft; + }; + await act(async () => { restore.resolve(); }); + await waitFor(() => assert.equal(reading, true)); + assert.equal(view.result.current.input, record.input); + assert.equal(view.result.current.attachedImages[0].size, 172); + assert.equal(view.result.current.composerFrozen, true); + view.rerender({ isLoading: false, selectedSession: { ...base.selectedSession! } }); + assert.equal(acknowledged, false, 'saved metadata alone must not acknowledge unread attachment bytes'); + let receipt!: ComposerFreezeReceipt; + await act(async () => { bytes.resolve(); receipt = await pending; }); + act(() => { assert.equal(sealComposerFreeze(receipt), true); }); + assert.equal(isComposerFreezeCurrent(receipt), true); + assert.equal(receipt.drafts[0].fileCount, 1); +}); + +for (const stage of ['read-back load', 'restored File bytes', 'read-back File bytes'] as const) { + test(`unexpected ${stage} errors reject prepare without losing the restored SVG or its original exception`, async () => { + const repository = new Repository(); + const record = { projectId: 'freeze-project', conversation: 'a', input: 'saved but unreadable', images: [svgFile()], queue: [], revision: 1 }; + repository.records.set(composerRouteKey(record), record); + const view = composer(repository, { isLoading: false }); await saved(view); + const failure = new DOMException('Synthetic storage read failure', stage === 'read-back load' ? 'UnknownError' : 'NotReadableError'); + const restored = view.result.current.attachedImages[0]; + const arrayBuffer = restored.arrayBuffer.bind(restored); + if (stage === 'read-back load') repository.beforeLoad = async () => { throw failure; }; + else if (stage === 'restored File bytes') restored.arrayBuffer = async () => { throw failure; }; + else repository.readBack = (draft) => { draft.images[0].arrayBuffer = async () => { throw failure; }; return draft; }; + // Normalize browser exceptions, retaining the cause for private diagnostics. + // Neither the saved status nor File metadata can stand in for readable bytes. + await act(async () => { await assert.rejects(prepareComposerFreeze(request()), (error) => error instanceof ComposerStorageError && error.reason === 'storage' && error.cause === failure); }); + assert.equal(isComposerFrozen(), false); + assert.equal(isComposerSealed(), false); + assert.deepEqual(view.result.current.draftPersistence, { phase: 'error', reason: 'storage' }); + assert.equal(view.result.current.input, record.input); + assert.equal(restored.size, 172); + const stored = repository.records.get(composerRouteKey(record))!; + assert.equal(stored.input, record.input); + assert.deepEqual(await stored.images[0].arrayBuffer(), await record.images[0].arrayBuffer()); + repository.beforeLoad = undefined; repository.readBack = undefined; restored.arrayBuffer = arrayBuffer; + await act(async () => { assert.equal(await view.result.current.retryDraftPersistence(), true); }); + const receipt = await freeze(); + act(() => { assert.equal(sealComposerFreeze(receipt), true); }); + assert.equal(isComposerFreezeCurrent(receipt), true); + }); +} + +test('matching empty byte reads cannot acknowledge nonempty SVG metadata', async () => { + const repository = new Repository(); const view = composer(repository); await saved(view); + act(() => { view.result.current.setInput('nonempty SVG'); view.result.current.setAttachedImages([svgFile()]); }); await saved(view); + view.result.current.attachedImages[0].arrayBuffer = async () => new ArrayBuffer(0); + repository.readBack = (draft) => { draft.images[0].arrayBuffer = async () => new ArrayBuffer(0); return draft; }; + await act(async () => { await assert.rejects(prepareComposerFreeze(request()), rejectedWith('conflict')); }); + assert.deepEqual(view.result.current.draftPersistence, { phase: 'error', reason: 'conflict' }); + assert.equal(isComposerFrozen(), false); assert.equal(view.result.current.attachedImages[0].size, 172); +}); + +test('a superseded verifier failing late cannot replace a newer saved status or invalidate its seal', async () => { + const repository = new Repository(); const view = composer(repository); await saved(view); + act(() => { view.result.current.setInput('current SVG'); view.result.current.setAttachedImages([svgFile()]); }); await saved(view); + const gate = deferred(); let reading = false; + repository.readBack = (draft) => { + if (!reading) draft.images[0].arrayBuffer = async () => { reading = true; await gate.promise; throw new DOMException('Synthetic retired read', 'NotFoundError'); }; + return draft; + }; + let pending!: Promise; + act(() => { pending = prepareComposerFreeze(request()); }); + const rejected = assert.rejects(pending, rejectedWith('stale')); + await waitFor(() => assert.equal(reading, true)); + const current = await freeze(); await rejected; + act(() => { assert.equal(sealComposerFreeze(current), true); }); + await act(async () => { gate.resolve(); }); + assert.deepEqual(view.result.current.draftPersistence, { phase: 'saved', reason: null }); + assert.equal(isComposerFreezeCurrent(current), true); +}); + +test('real repository prepare migrates restored legacy Files before rewrite and seals again after reopening', async () => { + const driver = installComposerDraftStorageTestDriver(); driver.controls.invalidateLegacyOnPut = true; + const legacy = { projectId: 'freeze-project', conversation: 'a', input: 'persisted draft with SVG', images: [svgFile()], + queue: [{ id: 'review', content: 'queued SVG', images: [svgFile()], options: { effort: 'xhigh' }, requiresReview: true }], revision: 7 }; + const key = composerRouteKey(legacy); + driver.records.set(key, legacy); driver.sizes.set(key, { bytes: boundedComposerDraft(legacy).bytes, revision: 7 }); + const view = composer(browserComposerDraftRepository, { isLoading: false }); await saved(view); + const restored = view.result.current.attachedImages[0]; + const first = await freeze(); + assert.equal(first.drafts[0].fileCount, 2); assert.equal(first.drafts[0].queuedIntentCount, 1); + act(() => { assert.equal(sealComposerFreeze(first), true); }); + assert.equal((driver.records.get(key) as { schemaVersion: number }).schemaVersion, 2); + assert.deepEqual(await restored.arrayBuffer(), await legacy.images[0].arrayBuffer()); + assert.equal(view.result.current.input, legacy.input); + view.unmount(); resetComposerFreezeForTests(); + const reopened = composer(browserComposerDraftRepository, { isLoading: false }); await saved(reopened); + assert.equal(reopened.result.current.input, legacy.input); + assert.equal(reopened.result.current.queuedDrafts[0].requiresReview, true); + assert.deepEqual(reopened.result.current.queuedDrafts[0].options, { effort: 'xhigh' }); + const second = await freeze(); + act(() => { assert.equal(sealComposerFreeze(second), true); }); + assert.equal(isComposerFreezeCurrent(second), true); + assert.deepEqual(await reopened.result.current.attachedImages[0].arrayBuffer(), await legacy.images[0].arrayBuffer()); +}); + +test('an unreadable real-repository legacy draft stays errored through prepare, typing, clear, and retry without overwrite', async () => { + const driver = installComposerDraftStorageTestDriver(); + const file = svgFile(); file.arrayBuffer = async () => { throw new DOMException('Synthetic missing persisted Blob', 'NotFoundError'); }; + const legacy = { projectId: 'freeze-project', conversation: 'a', input: 'unreadable legacy draft', images: [file], queue: [], revision: 7 }; + const key = composerRouteKey(legacy); const projection = draftInputKey(legacy.projectId, legacy.conversation); + driver.records.set(key, legacy); driver.sizes.set(key, { bytes: boundedComposerDraft(legacy).bytes, revision: 7 }); + localStorage.setItem(projection, legacy.input); localStorage.setItem(`composer_owner_${projection}`, key); + const view = composer(browserComposerDraftRepository); + await waitFor(() => assert.deepEqual(view.result.current.draftPersistence, { phase: 'error', reason: 'storage' })); + assert.equal(view.result.current.draftReady, false); assert.equal(view.result.current.input, legacy.input); + await act(async () => { await assert.rejects(prepareComposerFreeze(request()), rejectedWith('storage')); }); + act(() => view.result.current.handleClearInput()); + act(() => view.result.current.setInput('new typing must not erase the unreadable record')); + await act(async () => { assert.equal(await view.result.current.retryDraftPersistence(), false); }); + assert.deepEqual(view.result.current.draftPersistence, { phase: 'error', reason: 'storage' }); + assert.equal(driver.commits, 0); assert.equal(driver.records.get(key), legacy); + assert.equal((driver.sizes.get(key) as { revision: number }).revision, 7); +}); + +test('a prior-page offscreen projection blocks a restored visible SVG until its durable owner is hydrated', async () => { + const repository = new Repository(); const previous = composer(repository); await saved(previous); + act(() => { previous.result.current.setInput('visible unsent'); previous.result.current.setAttachedImages([svgFile()]); }); await saved(previous); + previous.rerender({ selectedSession: { id: 'offscreen', __provider: 'gjc' } }); await saved(previous); + act(() => previous.result.current.setInput('offscreen unsent')); await saved(previous); + previous.unmount(); resetComposerFreezeForTests(); + const view = composer(repository); await saved(view); + await act(async () => { await assert.rejects(prepareComposerFreeze(request()), rejectedWith('unavailable')); }); + assert.equal(isComposerFrozen(), false); + assert.equal(view.result.current.input, 'visible unsent'); + assert.equal(view.result.current.attachedImages[0].size, 172); + assert.equal(localStorage.getItem(draftInputKey('freeze-project', 'offscreen')), 'offscreen unsent'); + const offscreen = renderHook(() => useDurableComposerDraft('freeze-project', 'offscreen', repository)); + await waitFor(() => assert.equal(offscreen.result.current.persistence.phase, 'saved')); + offscreen.unmount(); + const receipt = await freeze(); + assert.equal(receipt.drafts.length, 2); + act(() => { assert.equal(sealComposerFreeze(receipt), true); }); + assert.equal(isComposerFreezeCurrent(receipt), true); +}); + +test('real composer freezes synchronously, commits all dataFiles and queued options, and keeps new edits', async () => { + const repository = new Repository(); + const view = render(); + await waitFor(() => assert.equal(view.getByTestId('persistence').textContent, 'saved')); + const textarea = view.getByPlaceholderText('Freeze fixture') as HTMLTextAreaElement; + const form = textarea.closest('form')!; + const files = dataFiles(); + fireEvent.change(textarea, { target: { value: 'queued with file' } }); + fireEvent.paste(textarea, { clipboardData: { items: [], files: [files[0]] } }); + fireEvent.submit(form); + fireEvent.change(textarea, { target: { value: 'active with file' } }); + fireEvent.paste(textarea, { clipboardData: { items: [], files: [files[1]] } }); + const commit = deferred(); repository.beforeSave = () => commit.promise; + let acknowledged = false; + let pending!: Promise; + act(() => { + pending = prepareComposerFreeze(request()).then((receipt) => { acknowledged = true; return receipt; }); + assert.equal(isComposerFrozen(), true); + fireEvent.submit(form); // Before React has rerendered the disabled buttons. + }); + assert.equal(textarea.value, 'active with file'); + await act(async () => { await Promise.resolve(); }); + assert.equal(acknowledged, false); + let receipt!: ComposerFreezeReceipt; + await act(async () => { commit.resolve(); receipt = await pending; }); + assert.equal(receipt.drafts[0].fileCount, 2); + assert.equal(receipt.drafts[0].queuedIntentCount, 1); + assert.equal(receipt.installerAuthority, false); + assert.equal(receipt.scope, 'page'); + assert.equal(isComposerFreezeCurrent(receipt), true); + assert.equal(isComposerFreezeCurrent({ ...receipt }), false, 'serialized evidence is not a live page lease'); + const stored = [...repository.records.values()][0]; + assert.deepEqual(new Uint8Array(await stored.queue[0].images[0].arrayBuffer()), new Uint8Array(await files[0].arrayBuffer())); + assert.deepEqual(new Uint8Array(await stored.images[0].arrayBuffer()), new Uint8Array(await files[1].arrayBuffer())); + assert.equal(stored.queue[0].options?.effort, 'xhigh'); + assert.equal(view.getByRole('button', { name: english.input.queue.sendNext }).hasAttribute('disabled'), true); + fireEvent.change(textarea, { target: { value: 'late input must survive' } }); + assert.equal(isComposerFreezeCurrent(receipt), false); + assert.equal(isComposerFrozen(), false); + assert.equal(textarea.value, 'late input must survive'); +}); + +test('offscreen and unmounted pending writes remain in the receipt until their commit', async () => { + const repository = new Repository(); const view = composer(repository); await saved(view); + const files = dataFiles(); const commit = deferred(); repository.beforeSave = () => commit.promise; + act(() => { view.result.current.setInput('A'); view.result.current.setAttachedImages([files[0]]); }); + view.rerender({ selectedSession: { id: 'b', __provider: 'gjc' } }); + act(() => { view.result.current.setInput('B'); view.result.current.setAttachedImages([files[1]]); }); + await waitFor(() => assert.ok(repository.writes >= 2)); + view.unmount(); + let done = false; + const pending = prepareComposerFreeze(request()).then((receipt) => { done = true; return receipt; }); + await Promise.resolve(); assert.equal(done, false); + commit.resolve(); + const receipt = await pending; + assert.deepEqual(receipt.drafts.map((item) => [JSON.parse(item.routeKey)[1], item.fileCount]), [['a', 1], ['b', 1]]); + assert.equal(isComposerFreezeCurrent(receipt), true); + cancelComposerFreeze(receipt); + const reopened = composer(repository); await saved(reopened); + assert.equal(reopened.result.current.input, 'A'); + assert.deepEqual(new Uint8Array(await reopened.result.current.attachedImages[0].arrayBuffer()), new Uint8Array(await files[0].arrayBuffer())); + const next = await freeze(); + assert.equal(next.drafts.filter((item) => JSON.parse(item.routeKey)[1] === 'a').length, 1, 'settled remount replaces only its prior owner'); +}); + +for (const operation of ['upload', 'allocation'] as const) { + test(`an accepted ${operation} makes freeze fail/reopen without aborting its eventual send`, async () => { + const repository = new Repository(); const work = deferred(); let entered = false; const sent: unknown[] = []; + globalThis.fetch = async (url) => { + if (String(url).endsWith(operation === 'upload' ? '/images' : '/providers/sessions')) { + entered = true; await work.promise; + return new Response(operation === 'upload' ? '{"images":[]}' : '{"data":{"sessionId":"allocated"}}'); + } + return new Response('[]'); + }; + const view = composer(repository, { isLoading: false, ...(operation === 'allocation' ? { selectedSession: null } : {}), sendMessage: (message) => { sent.push(message); } }); + await saved(view); + act(() => { view.result.current.setInput('accepted work'); if (operation === 'upload') view.result.current.setAttachedImages(dataFiles()); }); + let sending!: Promise; + act(() => { sending = view.result.current.handleSubmit(submit()); }); + await waitFor(() => assert.equal(entered, true)); + await act(async () => { await assert.rejects(prepareComposerFreeze(request()), rejectedWith('busy')); }); + assert.equal(isComposerFrozen(), false); + assert.equal(view.result.current.input, 'accepted work'); + await act(async () => { work.resolve(); await sending; }); + assert.equal(sent.length, 1); + await freeze(); + }); +} + +test('unmount does not remove the active-upload fence, and late upload keeps the old route draft', async () => { + const repository = new Repository(); const work = deferred(); let entered = false; + globalThis.fetch = async (url) => { + if (String(url).endsWith('/images')) { entered = true; await work.promise; return new Response('{"images":[]}'); } + return new Response('[]'); + }; + const view = composer(repository, { isLoading: false }); await saved(view); + act(() => { view.result.current.setInput('unsent original route'); view.result.current.setAttachedImages(dataFiles()); }); + let sending!: Promise; + act(() => { sending = view.result.current.handleSubmit(submit()); }); + await waitFor(() => assert.equal(entered, true)); view.unmount(); + await assert.rejects(prepareComposerFreeze(request()), rejectedWith('busy')); + work.resolve(); await sending; + const receipt = await freeze(); + assert.equal(receipt.drafts[0].fileCount, 2); + assert.equal([...repository.records.values()][0].input, 'unsent original route'); +}); + +test('steering stays busy offscreen until its acknowledgement durably settles the original intent', async () => { + const repository = new Repository(); const view = composer(repository); await saved(view); + act(() => view.result.current.setInput('steer A')); + act(() => view.result.current.handleSteer(submit())); await saved(view); + view.rerender({ selectedSession: { id: 'b', __provider: 'gjc' } }); await saved(view); + await act(async () => { await assert.rejects(prepareComposerFreeze(request()), rejectedWith('busy')); }); + act(() => view.result.current.resolveSteerResult('steer A', true, 'a')); + const receipt = await freeze(); + assert.equal(receipt.drafts.reduce((sum, item) => sum + item.queuedIntentCount, 0), 0); +}); + +test('a replacement composer settles an unmounted steer using its retained original project', async () => { + const repository = new Repository(); const original = composer(repository); await saved(original); + act(() => original.result.current.setInput('unmounted steer')); + act(() => original.result.current.handleSteer(submit())); await saved(original); original.unmount(); + const replacement = composer(repository, { selectedProject: { ...base.selectedProject!, projectId: 'other-project' }, selectedSession: { id: 'b', __provider: 'gjc' } }); + await saved(replacement); + act(() => replacement.result.current.setInput('keep B')); await saved(replacement); + await act(async () => { await assert.rejects(prepareComposerFreeze(request()), rejectedWith('busy')); }); + act(() => replacement.result.current.resolveSteerResult('unmounted steer', true, 'a')); + await freeze(); + assert.equal(repository.records.get(JSON.stringify(['freeze-project', 'a']))?.queue.length, 0); + assert.equal(repository.records.has(JSON.stringify(['other-project', 'a'])), false); + assert.equal(replacement.result.current.input, 'keep B'); +}); + +test('cancel, superseding epochs and TTL invalidate late commits and never unlock a newer lease', async () => { + const repository = new Repository(); const view = composer(repository); await saved(view); + act(() => view.result.current.setInput('durable after cancellation')); await saved(view); + const commit = deferred(); repository.beforeSave = () => commit.promise; + const first = request(); let old!: Promise; + act(() => { old = prepareComposerFreeze(first); }); + const rejected = assert.rejects(old, rejectedWith('cancelled')); + assert.equal(cancelComposerFreeze({ ...first, token: 'wrong' }), false); + act(() => { assert.equal(cancelComposerFreeze(first), true); }); + await rejected; + const second = request(); let next!: Promise; + act(() => { next = prepareComposerFreeze(second); }); + assert.equal(cancelComposerFreeze(first), false); + await assert.rejects(prepareComposerFreeze(first), rejectedWith('stale')); + let receipt!: ComposerFreezeReceipt; + await act(async () => { commit.resolve(); receipt = await next; }); + assert.equal(isComposerFreezeCurrent(receipt), true); + const expiring = await freeze(30); + assert.equal(isComposerFreezeCurrent(receipt), false); + await act(async () => { await new Promise((resolve) => setTimeout(resolve, 50)); }); + assert.equal(isComposerFreezeCurrent(expiring), false); + assert.equal(isComposerFrozen(), false); +}); + +test('a genuinely stalled commit expires without aborting the write or issuing a late ACK', async () => { + const repository = new Repository(); const view = composer(repository); await saved(view); + const commit = deferred(); repository.beforeSave = () => commit.promise; + act(() => { view.result.current.setInput('late commit'); view.result.current.setAttachedImages(dataFiles()); }); + await act(async () => { await assert.rejects(prepareComposerFreeze(request(25)), rejectedWith('timeout')); }); + assert.equal(isComposerFrozen(), false); + assert.equal(repository.records.size, 0); + await act(async () => { commit.resolve(); }); await saved(view); + assert.equal([...repository.records.values()][0].input, 'late commit'); + assert.equal(isComposerFrozen(), false); + assert.equal((await freeze()).drafts[0].fileCount, 2); +}); + +test('a superseded verifier cannot start later-route writes after the new lease has acknowledged', async () => { + const repository = new Repository(); const view = composer(repository); await saved(view); + act(() => { view.result.current.setInput('route A'); view.result.current.setAttachedImages(dataFiles()); }); await saved(view); + view.rerender({ selectedSession: { id: 'b', __provider: 'gjc' } }); await saved(view); + act(() => view.result.current.setInput('route B')); await saved(view); + const reading = deferred(); let intercepted = false; + repository.readBack = (record) => { + if (!intercepted && record.conversation === 'a') { + intercepted = true; + const file = record.images[0]; + const arrayBuffer = file.arrayBuffer.bind(file); + file.arrayBuffer = async () => { await reading.promise; return arrayBuffer(); }; + } + return record; + }; + let pending!: Promise; + act(() => { pending = prepareComposerFreeze(request()); }); + const rejected = assert.rejects(pending, rejectedWith('stale')); + await waitFor(() => assert.equal(intercepted, true)); + const current = await freeze(); await rejected; + const writes = repository.writes; + await act(async () => { reading.resolve(); }); + assert.equal(repository.writes, writes, 'late old verification must not write B'); + assert.equal(isComposerFreezeCurrent(current), true); +}); + +test('a synchronous native freeze request inside accepted send fails busy without duplicate sending', async () => { + const repository = new Repository(); let attempted!: Promise; let sends = 0; + const view = composer(repository, { isLoading: false, sendMessage: () => { sends += 1; attempted = prepareComposerFreeze(request()); void attempted.catch(() => {}); } }); + await saved(view); + act(() => view.result.current.setInput('one accepted send')); + await act(async () => view.result.current.handleSubmit(submit())); + await assert.rejects(attempted, rejectedWith('busy')); + assert.equal(sends, 1); + assert.equal(isComposerFrozen(), false); +}); + +test('steer and voice-send callbacks captured before freeze cannot dispatch new work', async () => { + const repository = new Repository(); const sent: unknown[] = []; + const view = composer(repository, { sendMessage: (message) => { sent.push(message); } }); await saved(view); + act(() => view.result.current.setInput('kept input')); await saved(view); + const steer = view.result.current.handleSteer; + const receipt = await freeze(); + act(() => { steer(submit()); }); + act(() => { view.result.current.handlePermissionDecision('pending-request', { allow: true }); }); + assert.equal(sent.length, 0); + assert.equal(view.result.current.input, 'kept input'); + act(() => { view.result.current.handleVoiceTranscript('late transcript', true); }); + assert.equal(sent.length, 0); + assert.equal(view.result.current.input, 'kept input late transcript'); + assert.equal(view.result.current.queuedDrafts.length, 0); + assert.equal(isComposerFreezeCurrent(receipt), false); +}); + +test('a pending command confirmation retains its text and Files in the durable draft', async () => { + const repository = new Repository(); const view = composer(repository, { isLoading: false }); await saved(view); + act(() => { view.result.current.setInput('/clear'); view.result.current.setAttachedImages(dataFiles()); }); + await act(async () => view.result.current.handleSubmit(submit())); + assert.ok(view.result.current.pendingCommandGate); + const receipt = await freeze(); + assert.equal(receipt.drafts[0].fileCount, 2); + assert.equal([...repository.records.values()][0].input, '/clear'); + act(() => { view.result.current.confirmCommandGate(); }); + assert.ok(view.result.current.pendingCommandGate); + act(() => { view.result.current.handleInputChange({ target: { value: 'replacement draft', selectionStart: 17 } } as never); }); + assert.equal(view.result.current.pendingCommandGate, null, 'editing invalidates the old confirmation'); + assert.equal(view.result.current.input, 'replacement draft'); +}); + +test('storage events and direct projection changes invalidate an already-issued receipt', async () => { + const repository = new Repository(); const view = composer(repository); await saved(view); + act(() => view.result.current.setInput('local')); await saved(view); + const first = await freeze(); + act(() => { window.dispatchEvent(new Event('storage')); }); + assert.equal(isComposerFreezeCurrent(first), false); + const second = await freeze(); + localStorage.setItem('draft_input_session_a', 'external overwrite'); + act(() => { assert.equal(isComposerFreezeCurrent(second), false); }); + assert.equal(isComposerFrozen(), false); +}); + +for (const reason of ['quota', 'unavailable', 'conflict', 'timeout'] as const) { + test(`${reason} rejects a receipt and reopens without dropping File input`, async () => { + const repository = new Repository(); const view = composer(repository); await saved(view); + act(() => { view.result.current.setInput('keep me'); view.result.current.setAttachedImages(dataFiles()); }); await saved(view); + repository.beforeSave = async () => { throw new ComposerStorageError(reason); }; + await act(async () => { await assert.rejects(prepareComposerFreeze(request()), rejectedWith(reason)); }); + assert.equal(isComposerFrozen(), false); + assert.equal(view.result.current.input, 'keep me'); + assert.equal(view.result.current.attachedImages.length, 2); + }); +} + +test('same File metadata with different committed bytes is a conflict, not an ACK', async () => { + const repository = new Repository(); const view = composer(repository); await saved(view); + act(() => { view.result.current.setInput('file verification'); view.result.current.setAttachedImages(dataFiles()); }); await saved(view); + repository.readBack = (record) => ({ ...record, images: record.images.map((file) => new File([new Uint8Array(file.size)], file.name, { type: file.type, lastModified: file.lastModified })) }); + await act(async () => { await assert.rejects(prepareComposerFreeze(request()), rejectedWith('conflict')); }); + assert.equal(isComposerFrozen(), false); +}); + +test('an edit during a pending commit revokes the receipt and preserves the newer dataFiles', async () => { + const repository = new Repository(); const view = composer(repository); await saved(view); + const commit = deferred(); repository.beforeSave = () => commit.promise; + act(() => view.result.current.setInput('first')); + let pending!: Promise; + act(() => { pending = prepareComposerFreeze(request()); }); + const rejected = assert.rejects(pending, rejectedWith('changed')); + act(() => { view.result.current.setInput('newest'); view.result.current.setAttachedImages(dataFiles()); }); + await rejected; + await act(async () => commit.resolve()); await saved(view); + assert.equal([...repository.records.values()][0].input, 'newest'); + assert.equal([...repository.records.values()][0].images.length, 2); +}); + +test('orphan legacy queues and unavailable IndexedDB never masquerade as durable receipts', async () => { + writeQueuedMessages('orphan', [{ content: 'no durable owner' }]); + await assert.rejects(prepareComposerFreeze(request()), rejectedWith('unavailable')); + localStorage.clear(); + const view = renderHook(() => useChatComposerState(base)); + act(() => view.result.current.setInput('no IndexedDB')); + await act(async () => { await assert.rejects(prepareComposerFreeze(request()), rejectedWith('unavailable')); }); + assert.equal(view.result.current.input, 'no IndexedDB'); +}); + +test('a scheduled visible queue holds on freeze and resumes exactly once on cancellation', async () => { + const repository = new Repository(); const sent: unknown[] = []; + const sendMessage = (message: unknown) => { sent.push(message); }; + const view = composer(repository, { sendMessage }); await saved(view); + act(() => view.result.current.setInput('queued')); + await act(async () => view.result.current.handleSubmit(submit())); await saved(view); + view.rerender({ sendMessage, isLoading: false }); + const receipt = await freeze(); + await act(async () => { await new Promise((resolve) => setTimeout(resolve, 80)); }); + assert.equal(sent.length, 0); + assert.equal(view.result.current.queuedDrafts.length, 1); + act(() => { cancelComposerFreeze(receipt); }); + await waitFor(() => assert.equal(sent.length, 1)); + assert.equal(view.result.current.queuedDrafts.length, 0); +}); + +test('offscreen auto-dispatch holds completions/reconnects and retires an unmounted durable intent once', async () => { + const repository = new Repository(); const draft = composer(repository); await saved(draft); + act(() => draft.result.current.setInput('offscreen intent')); + await act(async () => draft.result.current.handleSubmit(submit())); await saved(draft); draft.unmount(); + const receipt = await freeze(); + const sent: unknown[] = []; + const socket = Object.assign(new EventTarget(), { readyState: WebSocket.OPEN }) as WebSocket; + const busy: SessionActivityMap = new Map([['a', { startedAt: 1, statusText: null, canInterrupt: true, awaitingInput: false }]]); + const view = renderHook(({ processingSessions }: { processingSessions: SessionActivityMap }) => useQueuedMessageAutoSend({ + processingSessions, activeSessionId: 'b', ws: socket, sendMessage: (message) => { sent.push(message); }, markSessionProcessing() {}, + }), { initialProps: { processingSessions: busy } }); + view.rerender({ processingSessions: new Map() }); + act(() => { socket.dispatchEvent(new Event('open')); }); + assert.equal(sent.length, 0); + assert.equal(readQueuedMessages('a').length, 1); + act(() => { cancelComposerFreeze(receipt); socket.dispatchEvent(new Event('open')); }); + assert.equal(sent.length, 1); + await waitFor(() => assert.equal([...repository.records.values()][0].queue.length, 0)); + assert.deepEqual(readQueuedMessages('a'), []); +}); + +for (const outcome of ['change', 'cancel'] as const) { + test(`the attachment picker retains its actual root through unmount and settles on ${outcome}`, async () => { + const repository = new Repository(); const view = composer(repository); await saved(view); + const receipt = await freeze(); + act(() => view.result.current.openImagePicker()); + assert.equal(document.querySelector('[data-composer-attachment-picker]'), null); + act(() => { cancelComposerFreeze(receipt); view.result.current.openImagePicker(); }); + const picker = document.querySelector('[data-composer-attachment-picker]')!; + assert.ok(picker); view.unmount(); + await assert.rejects(prepareComposerFreeze(request()), rejectedWith('busy')); + await act(async () => { + if (outcome === 'change') fireEvent.change(picker, { target: { files: dataFiles() } }); + else picker.dispatchEvent(new Event('cancel')); + }); + await waitFor(() => assert.equal(picker.isConnected, false)); + if (outcome === 'change') await waitFor(() => assert.equal([...repository.records.values()][0]?.images.length, 2)); + const final = await freeze(); + assert.equal(final.drafts.reduce((count, draft) => count + draft.fileCount, 0), outcome === 'change' ? 2 : 0); + }); +} + +test('asynchronous dropped File allocation remains admitted and saves to its unmounted original composer', async () => { + const repository = new Repository(); const view = render(); + await waitFor(() => assert.equal(view.getByTestId('persistence').textContent, 'saved')); + const file = dataFiles()[0]; let materialize!: () => void; + const entry = { isFile: true, file(resolve: (value: File) => void) { materialize = () => resolve(file); } }; + fireEvent.drop(view.getByPlaceholderText('Freeze fixture').closest('form')!, { dataTransfer: { + types: ['Files'], files: [], items: [{ kind: 'file', type: file.type, webkitGetAsEntry: () => entry, getAsFile: () => file }], + } }); + assert.equal(typeof materialize, 'function'); view.unmount(); + await assert.rejects(prepareComposerFreeze(request()), rejectedWith('busy')); + await act(async () => materialize()); + await waitFor(() => assert.equal([...repository.records.values()][0]?.images.length, 1)); + assert.equal((await freeze()).drafts[0].fileCount, 1); +}); + +test('workspace resolution, descent and session allocation share the accepted send root until settlement', async () => { + const repository = new Repository(); const stages = [deferred(), deferred(), deferred()]; let entered = -1; + const sent: unknown[] = []; const text = 'work in child repo'; + globalThis.fetch = async (url) => { + const path = String(url); + const stage = path.includes(`/resolve-target?text=${encodeURIComponent(text)}`) ? 0 : path.endsWith('/descend') ? 1 : path.endsWith('/providers/sessions') ? 2 : -1; + if (stage >= 0) { entered = stage; await stages[stage].promise; } + if (stage === 0) return new Response(JSON.stringify({ data: { isWorkspace: true, candidates: [{ path: '/fixture/child', name: 'child', score: 100, reason: 'mention' }] } })); + if (stage === 1) return new Response(JSON.stringify({ data: { ...base.selectedProject, projectId: 'child', fullPath: '/fixture/child' } })); + if (stage === 2) return new Response('{"data":{"sessionId":"allocated-child"}}'); + return new Response('[]'); + }; + const view = composer(repository, { selectedSession: null, isLoading: false, sendMessage: (message) => { sent.push(message); } }); await saved(view); + act(() => view.result.current.setInput(text)); + let sending!: Promise; act(() => { sending = view.result.current.handleSubmit(submit()); }); + for (let stage = 0; stage < stages.length; stage += 1) { + await waitFor(() => assert.equal(entered, stage)); + await act(async () => { await assert.rejects(prepareComposerFreeze(request()), rejectedWith('busy')); stages[stage].resolve(); }); + } + await act(async () => sending); + assert.equal(sent.length, 1); await freeze(); +}); + +test('prepare remains editable; sealing requires the exact fresh receipt and never reopens on expiry or invalidation', async () => { + const repository = new Repository(); const view = composer(repository); await saved(view); + act(() => view.result.current.setInput('prepared draft')); await saved(view); + const editable = await freeze(); + act(() => view.result.current.setInput('edit still allowed')); + assert.equal(isComposerFrozen(), false); assert.equal(sealComposerFreeze(editable), false); await saved(view); + const receipt = await freeze(50); + assert.equal(sealComposerFreeze({ ...receipt }), false); + act(() => { assert.equal(sealComposerFreeze(receipt), true); }); + assert.equal(isComposerSealed(), true); assert.equal(isComposerFreezeCurrent(receipt), true); + await act(async () => { await new Promise((resolve) => setTimeout(resolve, 70)); }); + assert.equal(isComposerFrozen(), true); assert.equal(isComposerSealed(), true); + assert.equal(isComposerFreezeCurrent(receipt), false, 'expired evidence is reported, not silently thawed'); + act(() => invalidateComposerFreeze()); + assert.equal(cancelComposerFreeze(receipt), false, 'ordinary token cancellation is not native rollback'); + await assert.rejects(prepareComposerFreeze(request()), rejectedWith('sealed')); + act(() => view.result.current.setInput('must not overwrite')); + assert.equal(view.result.current.input, 'edit still allowed'); + const rollback = registerComposerSealRollbackOwner(() => true); // Test-only native rollback capability. + assert.equal(rollback.cancel({ ...receipt, token: 'wrong' }), false); + act(() => { assert.equal(rollback.cancel(receipt), true); }); + assert.equal(isComposerSealed(), false); + act(() => view.result.current.setInput('after confirmed rollback')); await saved(view); + assert.equal(view.result.current.input, 'after confirmed rollback'); rollback.unregister(); +}); + +test('Window capture seals and rejects an input in flight before React paints or receives the event', async () => { + let receipt: ComposerFreezeReceipt | undefined; let sealedDuringEvent = false; let readOnlyDuringSeal: boolean | undefined; + const firstCapture = (event: Event) => { + if (!receipt) return; + sealedDuringEvent = sealComposerFreeze(receipt); + readOnlyDuringSeal = (event.target as HTMLTextAreaElement).readOnly; + }; + window.addEventListener('input', firstCapture, true); // Earlier than the root/field guard. + let reachedDocument = 0; const downstream = () => { reachedDocument += 1; }; + document.addEventListener('input', downstream, true); + try { + const repository = new Repository(); const view = render(); + await waitFor(() => assert.equal(view.getByTestId('persistence').textContent, 'saved')); + const textarea = view.getByPlaceholderText('Freeze fixture') as HTMLTextAreaElement; + fireEvent.change(textarea, { target: { value: 'saved before seal' } }); + fireEvent.paste(textarea, { clipboardData: { items: [], files: dataFiles() } }); + await waitFor(() => assert.equal(view.getByTestId('persistence').textContent, 'saved')); + receipt = await freeze(); + assert.equal(textarea.readOnly, false, 'prepare alone is editable'); + const input = new Event('input', { bubbles: true, cancelable: true }); + act(() => { textarea.value = 'racing browser input'; textarea.dispatchEvent(input); }); + assert.equal(sealedDuringEvent, true); assert.equal(readOnlyDuringSeal, false, 'capture closes before React paint'); + assert.equal(input.defaultPrevented, true); assert.equal(reachedDocument, 0); + assert.equal(textarea.value, 'saved before seal'); assert.equal(textarea.readOnly, true); + assert.equal([...repository.records.values()][0].input, 'saved before seal'); + assert.equal([...repository.records.values()][0].images.length, 2); + } finally { window.removeEventListener('input', firstCapture, true); document.removeEventListener('input', downstream, true); } +}); + +test('sealed real composer captures paste, drop, beforeinput and queue edits without reading new File data', async () => { + const repository = new Repository(); const view = render(); + await waitFor(() => assert.equal(view.getByTestId('persistence').textContent, 'saved')); + const textarea = view.getByPlaceholderText('Freeze fixture') as HTMLTextAreaElement; + fireEvent.change(textarea, { target: { value: 'queued original' } }); fireEvent.submit(textarea.closest('form')!); + fireEvent.change(textarea, { target: { value: 'live original' } }); + await waitFor(() => assert.equal(view.getByTestId('persistence').textContent, 'saved')); + const receipt = await freeze(); act(() => { assert.equal(sealComposerFreeze(receipt), true); }); + let reads = 0; + for (const [type, property] of [['paste', 'clipboardData'], ['drop', 'dataTransfer'], ['beforeinput', 'data']] as const) { + const event = new Event(type, { bubbles: true, cancelable: true }); + Object.defineProperty(event, property, { get() { reads += 1; throw new Error('sealed input must not be consumed'); } }); + act(() => { textarea.dispatchEvent(event); }); assert.equal(event.defaultPrevented, true); + } + fireEvent.click(view.getByRole('button', { name: english.input.queue.edit })); + fireEvent.click(view.getByRole('button', { name: english.input.queue.delete })); + fireEvent.submit(textarea.closest('form')!); + assert.equal(reads, 0); assert.equal(textarea.value, 'live original'); + const record = [...repository.records.values()][0]; assert.equal(record.input, 'live original'); + assert.equal(record.queue[0].content, 'queued original'); assert.equal(isComposerSealed(), true); +}); + +test('sealed setters and programmatic composer callbacks cannot mutate snapshots, queue projections, or input refs', async () => { + const repository = new Repository(); const view = composer(repository); await saved(view); + for (const text of ['one', 'two']) { + act(() => view.result.current.setInput(text)); await act(async () => view.result.current.handleSubmit(submit())); + } + act(() => { view.result.current.setInput('live'); view.result.current.setAttachedImages(dataFiles()); }); await saved(view); + const receipt = await freeze(); act(() => { assert.equal(sealComposerFreeze(receipt), true); }); + const writes = repository.writes; const before = readQueuedMessages('a'); + const noUpdate = () => { throw new Error('sealed updater executed'); }; + act(() => { + view.result.current.setInput(noUpdate); view.result.current.setAttachedImages(noUpdate); + view.result.current.editQueuedDraft(0); view.result.current.deleteQueuedDraft(0); view.result.current.moveQueuedDraft(0, 1); + view.result.current.handleVoiceTranscript('new voice', true); view.result.current.insertAtEnd('new comment'); + view.result.current.handleClearInput(); view.result.current.openImagePicker(); + view.result.current.handleInputChange({ target: { value: 'new DOM value' } } as never); + view.result.current.handlePaste({ preventDefault() {}, get clipboardData() { throw new Error('sealed paste read'); } } as never); + assert.equal(writeQueuedMessages('a', []), false); + safeLocalStorage.setItem(draftInputKey('freeze-project', 'a'), 'replacement'); + safeLocalStorage.removeItem(draftInputKey('freeze-project', 'a')); + }); + assert.equal(await view.result.current.retryDraftPersistence(), false); + assert.equal(view.result.current.input, 'live'); assert.equal(view.result.current.attachedImages.length, 2); + assert.deepEqual(view.result.current.queuedDrafts.map((item) => item.content), ['one', 'two']); + assert.deepEqual(readQueuedMessages('a'), before); assert.equal(repository.writes, writes); + const rollback = registerComposerSealRollbackOwner(() => true); + act(() => { rollback.cancel(receipt); view.result.current.handleVoiceTranscript('appended'); }); await saved(view); + assert.equal(view.result.current.input, 'live appended', 'blocked operations did not corrupt the live input ref'); + rollback.unregister(); +}); + +test('sealed durable queue/update/retry APIs do not invoke new-route updaters or start new writes', async () => { + const repository = new Repository(); + const view = renderHook(() => useDurableComposerDraft('project', 'A', repository)); + await waitFor(() => assert.equal(view.result.current.persistence.phase, 'saved')); + act(() => view.result.current.setQueue([{ id: 'queued', content: 'keep', images: dataFiles() }])); + await waitFor(() => assert.equal(view.result.current.persistence.phase, 'saved')); + const receipt = await freeze(); act(() => { sealComposerFreeze(receipt); }); + const writes = repository.writes; const noUpdate = () => { throw new Error('sealed updater evaluated'); }; + act(() => { + view.result.current.setQueue(noUpdate); view.result.current.setInput(noUpdate); view.result.current.setImages(noUpdate); + view.result.current.updateQueue({ projectId: 'other', conversation: 'new' }, noUpdate); + }); + assert.equal(await view.result.current.retryPersistence(), false); + assert.equal(repository.writes, writes); assert.equal(repository.records.has(JSON.stringify(['other', 'new'])), false); + assert.equal(view.result.current.queue[0].content, 'keep'); +}); + +test('new routes and controller mounts remain sealed; rollback resumes deferred activation with the correct DOM value', async () => { + const repository = new Repository(); const view = render(); + const ready = () => waitFor(() => assert.equal(view.getByTestId('persistence').textContent, 'saved')); + await ready(); fireEvent.change(view.getByPlaceholderText('Freeze fixture'), { target: { value: 'original A' } }); await ready(); + const routeB = { selectedSession: { id: 'b', __provider: 'gjc' as const } }; + view.rerender(); await ready(); + fireEvent.change(view.getByPlaceholderText('Freeze fixture'), { target: { value: 'original B' } }); await ready(); + view.rerender(); await ready(); + const receipt = await freeze(); act(() => { sealComposerFreeze(receipt); }); const writes = repository.writes; + view.rerender(); + const textarea = view.getByPlaceholderText('Freeze fixture') as HTMLTextAreaElement; + fireEvent.input(textarea, { target: { value: 'bad B' } }); + assert.equal(textarea.value, 'original B'); assert.equal(isComposerSealed(), true); assert.equal(repository.writes, writes); + const other = renderHook(() => useDurableComposerDraft('other', 'new', repository)); + act(() => other.result.current.setInput('blocked new owner')); + assert.equal(other.result.current.input, ''); assert.equal(isComposerFrozen(), true); + assert.equal(isComposerFreezeCurrent(receipt), false, 'new ownership invalidation is reported without reopening'); + const rollback = registerComposerSealRollbackOwner(() => true); + act(() => { assert.equal(rollback.cancel(receipt), true); }); + await waitFor(() => assert.equal(other.result.current.persistence.phase, 'saved')); + fireEvent.change(textarea, { target: { value: 'B after rollback' } }); await ready(); + assert.equal(repository.records.get(JSON.stringify(['freeze-project', 'b']))?.input, 'B after rollback'); rollback.unregister(); +}); + +test('sealed visible and offscreen queues stay paused past TTL and resume exactly once after explicit rollback', async () => { + const repository = new Repository(); const sent: unknown[] = []; + const sendMessage = (message: unknown) => { sent.push(message); }; + const view = composer(repository, { sendMessage }); await saved(view); + act(() => view.result.current.setInput('visible queued')); await act(async () => view.result.current.handleSubmit(submit())); await saved(view); + view.rerender({ sendMessage, isLoading: false }); + const receipt = await freeze(50); act(() => { sealComposerFreeze(receipt); }); + await act(async () => { await new Promise((resolve) => setTimeout(resolve, 850)); invalidateComposerFreeze(); }); + assert.equal(sent.length, 0); assert.equal(view.result.current.queuedDrafts.length, 1); assert.equal(isComposerSealed(), true); + view.unmount(); + const socket = Object.assign(new EventTarget(), { readyState: WebSocket.OPEN }) as WebSocket; + const busy: SessionActivityMap = new Map([['a', { startedAt: 1, statusText: null, canInterrupt: true, awaitingInput: false }]]); + const background = renderHook(({ processingSessions }: { processingSessions: SessionActivityMap }) => useQueuedMessageAutoSend({ processingSessions, activeSessionId: 'b', ws: socket, sendMessage, markSessionProcessing() {} }), { initialProps: { processingSessions: busy } }); + background.rerender({ processingSessions: new Map() }); act(() => socket.dispatchEvent(new Event('open'))); + assert.equal(sent.length, 0); + const rollback = registerComposerSealRollbackOwner(() => true); + await act(async () => { rollback.cancel(receipt); }); + await waitFor(() => assert.equal(sent.length, 1)); act(() => socket.dispatchEvent(new Event('open'))); + assert.equal(sent.length, 1); assert.deepEqual(readQueuedMessages('a'), []); rollback.unregister(); +}); diff --git a/src/components/chat/hooks/useChatComposerState.dom.bun.test.tsx b/src/components/chat/hooks/useChatComposerState.dom.bun.test.tsx index 890dbe2d..ef8176e6 100644 --- a/src/components/chat/hooks/useChatComposerState.dom.bun.test.tsx +++ b/src/components/chat/hooks/useChatComposerState.dom.bun.test.tsx @@ -4,6 +4,7 @@ import { afterEach, test } from 'node:test'; import { act, cleanup, renderHook, waitFor } from '@testing-library/react'; import type { Project, ProjectSession } from '../../../types/app'; +import { resetComposerFreezeForTests } from '../../../shared/composerFreeze'; import { draftInputKey, readQueuedMessages, writeQueuedMessages } from '../utils/chatStorage'; import { useChatComposerState } from './useChatComposerState'; @@ -58,6 +59,7 @@ globalThis.fetch = (async () => new Response('[]', { afterEach(() => { cleanup(); + resetComposerFreezeForTests(); localStorage.clear(); }); diff --git a/src/components/chat/hooks/useChatComposerState.ts b/src/components/chat/hooks/useChatComposerState.ts index 3fa64eb3..9cccedff 100644 --- a/src/components/chat/hooks/useChatComposerState.ts +++ b/src/components/chat/hooks/useChatComposerState.ts @@ -1,9 +1,12 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react'; import type { ChangeEvent, ClipboardEvent, Dispatch, FormEvent, KeyboardEvent, MouseEvent, MutableRefObject, RefObject, SetStateAction, TouchEvent } from 'react'; import { useDropzone } from 'react-dropzone'; +import type { DropEvent } from 'react-dropzone'; +import { useTranslation } from 'react-i18next'; import { useAppShellStore } from '../../../stores/useAppShellStore'; import { usePaletteOps } from '../../../stores/usePaletteOpsStore'; +import { beginComposerOperation, finishComposerOperation, invalidateComposerFreeze, isComposerFrozen, isComposerSealed, subscribeComposerFreeze } from '../../../shared/composerFreeze'; import type { MarkSessionProcessing } from '../../../hooks/useSessionProtection'; import type { ChatMessage, PendingPermissionRequest, PermissionDecision, SessionEstablishedContext } from '../types/types'; import type { LLMProvider, Project, ProjectSession, ProviderModelsCacheInfo } from '../../../types/app'; @@ -12,14 +15,17 @@ import { classifyCommandInput, isAutoSendable } from '../commandDispatchPolicy'; import { findAppUiCommand, getLocalCommandNotice, resolveCommandAlias, runAppUiCommand, type AppUiCommand } from '../appUiCommands'; import { gateForCommand, type CommandGate } from '../commandGatePolicy'; import { permissionResponseMessage } from '../utils/chatPermissions'; -import { clearQueuedMessages, draftInputKey, draftKeysToClear, readQueuedMessages, reorderQueue, safeLocalStorage, writeQueuedMessages, type QueuedSendOptions } from '../utils/chatStorage'; +import { draftKeysToClear, readQueuedMessages, reorderQueue, safeLocalStorage, type QueuedSendOptions } from '../utils/chatStorage'; +import type { ComposerDraftRepository, ComposerRoute, DurableQueuedDraft } from '../utils/composerDraftStorage'; import { decideQueueFlush } from '../utils/queueFlush'; +import { chooseComposerAttachments, composerFilesFromEvent } from '../utils/composerAttachmentIntake'; import { useFileMentions } from './useFileMentions'; import { useSlashCommands } from './useSlashCommands'; import { useWorkspaceTarget, type WorkspaceCandidate } from './useWorkspaceTarget'; +import { newQueuedDraftId, settleRetainedComposerSteer, useDurableComposerDraft } from './useDurableComposerDraft'; -interface UseChatComposerStateArgs { executionCwd?: string | null; selectedProject: Project | null; selectedSession: ProjectSession | null; currentSessionId: string | null; gjcModel: string; reasoningEffort?: string; isLoading: boolean; canAbortSession: boolean; tokenBudget: Record | null; sendMessage: (message: unknown) => boolean | void; sendByCtrlEnter?: boolean; onSessionProcessing?: MarkSessionProcessing; onSessionEstablished?: (sessionId: string, context: SessionEstablishedContext) => void; onInputFocusChange?: (focused: boolean) => void; onCommandGateChange?: (gate: PendingCommandGate | null) => void; onShowSettings?: () => void; onLogin?: (providerId?: string) => void; scrollToBottom: () => void; addMessage: (msg: ChatMessage) => void; setIsUserScrolledUp: (isScrolledUp: boolean) => void; setPendingPermissionRequests: Dispatch>; } +interface UseChatComposerStateArgs { draftRepository?: ComposerDraftRepository; executionCwd?: string | null; selectedProject: Project | null; selectedSession: ProjectSession | null; currentSessionId: string | null; gjcModel: string; reasoningEffort?: string; isLoading: boolean; canAbortSession: boolean; tokenBudget: Record | null; sendMessage: (message: unknown) => boolean | void; sendByCtrlEnter?: boolean; onSessionProcessing?: MarkSessionProcessing; onSessionEstablished?: (sessionId: string, context: SessionEstablishedContext) => void; onInputFocusChange?: (focused: boolean) => void; onCommandGateChange?: (gate: PendingCommandGate | null) => void; onShowSettings?: () => void; onLogin?: (providerId?: string) => void; scrollToBottom: () => void; addMessage: (msg: ChatMessage) => void; setIsUserScrolledUp: (isScrolledUp: boolean) => void; setPendingPermissionRequests: Dispatch>; } interface MentionableFile { name: string; path: string; } export type ModelCommandData = { current?: { provider?: string; providerLabel?: string; model?: string }; available?: Partial>; availableModels?: string[]; availableOptions?: Array<{ value: string; label?: string; description?: string }>; defaultModel?: string; cache?: ProviderModelsCacheInfo; }; export type CostCommandData = { tokenUsage?: { used?: number; total?: number }; tokenBreakdown?: { input?: number; output?: number }; provider?: string; model?: string; }; @@ -27,23 +33,24 @@ export type StatusCommandData = { version?: string; packageName?: string; uptime export type HelpCommandData = { content?: string; format?: string; commands?: Array<{ name: string; description?: string; namespace?: string }>; }; type CommandModalKind = 'help' | 'models' | 'cost' | 'status'; export type CommandModalPayload = { kind: CommandModalKind; data: HelpCommandData | ModelCommandData | CostCommandData | StatusCommandData; }; -export type QueuedDraft = { id?: string; content: string; images: File[]; options?: QueuedSendOptions; pendingSteer?: boolean; }; +export type QueuedDraft = DurableQueuedDraft; export type PendingCommandGate = CommandGate & { text: string }; const TURN_START_GRACE = 5000; const syntheticSubmit = () => ({ preventDefault() {} }) as unknown as FormEvent; -const storedQueue = (id: string): QueuedDraft[] => readQueuedMessages(id).map((draft) => ({ ...draft, images: [] })); const steerKey = (sessionId: string, content: string) => JSON.stringify([sessionId, content]); const shorten = (text: string) => { const compact = text.replace(/\s+/g, ' ').trim(); return compact ? (compact.length > 80 ? `${compact.slice(0, 77)}...` : compact) : null; }; const sessionLabel = (session: ProjectSession | null, input: string) => shorten(String(session?.summary || session?.name || session?.title || '')) || shorten(input); -const resetBox = (setInput: (value: string) => void, value: MutableRefObject, setImages: (files: File[]) => void, setUploads: (items: Map) => void, setErrors: (items: Map) => void, resetCommands: () => void, setExpanded: (open: boolean) => void, area: RefObject) => { setInput(''); value.current = ''; setImages([]); setUploads(new Map()); setErrors(new Map()); resetCommands(); setExpanded(false); if (area.current) area.current.style.height = 'auto'; }; +const resetBox = (setInput: (value: string) => void, value: MutableRefObject, setImages: (files: File[]) => void, setUploads: (items: Map) => void, setErrors: (items: Map) => void, resetCommands: () => void, setExpanded: (open: boolean) => void, area: RefObject) => { if (isComposerSealed()) return; setInput(''); value.current = ''; setImages([]); setUploads(new Map()); setErrors(new Map()); resetCommands(); setExpanded(false); if (area.current) area.current.style.height = 'auto'; }; export function useChatComposerState(args: UseChatComposerStateArgs) { + const { t } = useTranslation('chat'); const { executionCwd, selectedProject, selectedSession, currentSessionId, gjcModel, reasoningEffort = 'default', isLoading, canAbortSession, tokenBudget, sendMessage, sendByCtrlEnter, onSessionProcessing, onSessionEstablished, onInputFocusChange, onCommandGateChange, onShowSettings, onLogin, scrollToBottom, addMessage, setIsUserScrolledUp, setPendingPermissionRequests } = args; const projectId = selectedProject?.projectId; const conversation = selectedSession?.id || currentSessionId || null; - const [input, setInput] = useState(() => projectId && typeof window !== 'undefined' ? safeLocalStorage.getItem(draftInputKey(projectId, conversation)) || '' : ''); - const [attachedImages, setAttachedImages] = useState([]); + const drafts = useDurableComposerDraft(projectId, conversation, args.draftRepository); + const composerFrozen = useSyncExternalStore(subscribeComposerFreeze, isComposerFrozen, () => false); + const { input, setInput, images: attachedImages, setImages: setAttachedImages, queue: queuedDrafts, setQueue: setQueuedDrafts, getQueue: restoreQueue, updateQueue, persistence: draftPersistence, ready: draftReady, retryPersistence: retryDraftPersistence } = drafts; const [uploadingImages, setUploadingImages] = useState>(new Map()); const [imageErrors, setImageErrors] = useState>(new Map()); const [isTextareaExpanded, setExpanded] = useState(false); @@ -51,29 +58,46 @@ export function useChatComposerState(args: UseChatComposerStateArgs) { const [commandModalPayload, setModal] = useState(null); const [modelPickerTrigger, setModelPickerTrigger] = useState(0); const [pendingCommandGate, setGateState] = useState(null); - const [queuedDrafts, setQueuedDrafts] = useState(() => conversation && typeof window !== 'undefined' ? storedQueue(conversation) : []); const [queuePulse, setQueuePulse] = useState(0); const textareaRef = useRef(null); const inputHighlightRef = useRef(null); const inputRef = useRef(input); + const liveImages = useRef(attachedImages); const lineHeight = useRef(null); const resized = useRef(null); const submitRef = useRef<((event: FormEvent | MouseEvent | TouchEvent | KeyboardEvent, queued?: QueuedDraft) => Promise) | null>(null); - const queueOwner = useRef(conversation); + const composerOwner = JSON.stringify([projectId, conversation]); + const queueOwner = useRef(composerOwner); const queueInFlight = useRef(false); const dispatchTimer = useRef | null>(null); const priorLoading = useRef(isLoading); - const priorConversation = useRef(conversation); + const priorConversation = useRef(composerOwner); const bypassGate = useRef(false); const gateRef = useRef(null); - const steerWaiting = useRef(new Map>()); + const steerWaiting = useRef(new Map>()); const submissionOwner = useRef({}); const submissionInFlight = useRef(null); - const draftImages = useRef(new Map()); - const attachedImagesRef = useRef(attachedImages); - attachedImagesRef.current = attachedImages; const gateChangeRef = useRef(onCommandGateChange); gateChangeRef.current = onCommandGateChange; + const recoveryNotice = useRef(''); + const storageErrorNotice = useRef(''); + + useEffect(() => { + if (!draftReady || !queuedDrafts.some((item) => item.requiresReview)) return; + const key = JSON.stringify([composerOwner, queuedDrafts.filter((item) => item.requiresReview).map((item) => item.id)]); + if (recoveryNotice.current === key) return; + recoveryNotice.current = key; + addMessage({ type: 'system', isSystemNotice: true, noticeLevel: 'warning', timestamp: new Date(), + content: t('input.queue.recoveryNotice', { defaultValue: 'Recovered queued messages are paused to avoid duplicate sending. Use Edit on a queued message, review its text and attachments, then Send. Your current draft is kept when you edit a queued message.' }) }); + }, [addMessage, composerOwner, draftReady, queuedDrafts, t]); + useEffect(() => { + if (draftPersistence.phase !== 'error') return; + const key = JSON.stringify([composerOwner, draftPersistence.reason]); + if (storageErrorNotice.current === key) return; + storageErrorNotice.current = key; + addMessage({ type: 'system', isSystemNotice: true, noticeLevel: 'warning', timestamp: new Date(), + content: t('input.draftPersistence.failedInline', { reason: draftPersistence.reason ?? 'storage', defaultValue: 'Draft and attachment saving failed ({{reason}}). Your live input is still here. Use Retry draft saving. Keep this window open; do not restart until saving succeeds.' }) }); + }, [addMessage, composerOwner, draftPersistence.phase, draftPersistence.reason, t]); useEffect(() => { const owner = {}; @@ -83,13 +107,17 @@ export function useChatComposerState(args: UseChatComposerStateArgs) { if (submissionOwner.current === owner) submissionOwner.current = null; }; }, [conversation, projectId]); + useEffect(() => { inputRef.current = input; }, [input]); + useEffect(() => { liveImages.current = attachedImages; }, [attachedImages]); const eraseDraft = useCallback((settled?: string | null) => { if (projectId) draftKeysToClear(projectId, conversation, settled).forEach((key) => safeLocalStorage.removeItem(key)); }, [conversation, projectId]); const announceGate = useCallback((gate: PendingCommandGate | null) => { gateRef.current = gate; setGateState(gate); onCommandGateChange?.(gate); }, [onCommandGateChange]); + // The pending command stays in the durable input. Editing it revokes the old + // confirmation instead of retaining a second, volatile send intent. + useEffect(() => { + if (gateRef.current && input.trimEnd() !== gateRef.current.text) announceGate(null); + }, [announceGate, input]); useEffect(() => { - const key = draftInputKey(projectId ?? '', conversation); - const imagesByDraft = draftImages.current; - setAttachedImages(imagesByDraft.get(key) ?? []); setUploadingImages(new Map()); setImageErrors(new Map()); setModal(null); @@ -97,16 +125,15 @@ export function useChatComposerState(args: UseChatComposerStateArgs) { setGateState(null); gateChangeRef.current?.(null); bypassGate.current = false; - return () => { imagesByDraft.set(key, attachedImagesRef.current); }; }, [conversation, projectId]); - const login = useCallback((provider?: string) => { resetBox(setInput, inputRef, setAttachedImages, setUploadingImages, setImageErrors, () => undefined, setExpanded, textareaRef); eraseDraft(); onLogin?.(provider); }, [eraseDraft, onLogin]); + const login = useCallback((provider?: string) => { if (isComposerSealed()) return; resetBox(setInput, inputRef, setAttachedImages, setUploadingImages, setImageErrors, () => undefined, setExpanded, textareaRef); eraseDraft(); onLogin?.(provider); }, [eraseDraft, onLogin, setAttachedImages, setInput]); const palette = usePaletteOps(); const showCostModal = useCallback(() => { const parts = tokenBudget?.breakdown && typeof tokenBudget.breakdown === 'object' ? tokenBudget.breakdown as Record : {}; const inTokens = Number(tokenBudget?.inputTokens ?? parts.input); const outTokens = Number(tokenBudget?.outputTokens ?? parts.output); const used = Number(tokenBudget?.used); const total = Number(tokenBudget?.total); setModal({ kind: 'cost', data: { tokenUsage: { used: Number.isFinite(used) ? used : (Number.isFinite(inTokens) ? inTokens : 0) + (Number.isFinite(outTokens) ? outTokens : 0), total: Number.isFinite(total) ? total : 0 }, ...(Number.isFinite(inTokens) || Number.isFinite(outTokens) ? { tokenBreakdown: { input: Number.isFinite(inTokens) ? inTokens : 0, output: Number.isFinite(outTokens) ? outTokens : 0 } } : {}), provider: typeof tokenBudget?.provider === 'string' ? tokenBudget.provider : 'gjc', model: typeof tokenBudget?.model === 'string' ? tokenBudget.model : gjcModel } }); }, [gjcModel, tokenBudget]); - const applyAppCommand = useCallback((command: AppUiCommand) => runAppUiCommand(command, { openSessionPicker: palette.openSessionPicker, startNewChat: palette.startNewChat, openSettings: () => onShowSettings ? onShowSettings() : palette.openSettings(), openModelPicker: () => setModelPickerTrigger((n) => n + 1), openCostModal: showCostModal }), [onShowSettings, palette, showCostModal]); + const applyAppCommand = useCallback((command: AppUiCommand) => { if (isComposerSealed()) return; return runAppUiCommand(command, { openSessionPicker: palette.openSessionPicker, startNewChat: palette.startNewChat, openSettings: () => onShowSettings ? onShowSettings() : palette.openSettings(), openModelPicker: () => setModelPickerTrigger((n) => n + 1), openCostModal: showCostModal }); }, [onShowSettings, palette, showCostModal]); const { slashCommands, slashCommandsCount, filteredCommands, frequentCommands, commandQuery, showCommandMenu, selectedCommandIndex, resetCommandMenuState, handleCommandSelect, handleToggleCommandMenu, handleCommandInputChange, handleCommandMenuKeyDown } = useSlashCommands({ selectedProject, executionCwd, provider: 'gjc', sessionId: conversation, input, setInput, textareaRef, onLoginCommand: login, onAppCommand: (command) => { const app = findAppUiCommand(command.name); if (app) applyAppCommand(app); } }); const { showFileDropdown, filteredFiles, selectedFileIndex, renderInputWithMentions, selectFile, setCursorPosition, handleFileMentionsKeyDown } = useFileMentions({ selectedProject, executionCwd, sessionId: conversation, input, setInput, textareaRef }); - const clearComposer = useCallback(() => resetBox(setInput, inputRef, setAttachedImages, setUploadingImages, setImageErrors, resetCommandMenuState, setExpanded, textareaRef), [resetCommandMenuState]); + const clearComposer = useCallback(() => resetBox(setInput, inputRef, setAttachedImages, setUploadingImages, setImageErrors, resetCommandMenuState, setExpanded, textareaRef), [resetCommandMenuState, setAttachedImages, setInput]); // Permissions are deliberately absent here: the policy is the project's, read // by the server when the run starts, so nothing the browser sends can widen it. @@ -137,14 +164,25 @@ export function useChatComposerState(args: UseChatComposerStateArgs) { const handleSubmit = useCallback(async (event: FormEvent | MouseEvent | TouchEvent | KeyboardEvent, queued?: QueuedDraft) => { event.preventDefault(); const text = queued?.content ?? inputRef.current; if (!text.trim() || !selectedProject) return; + if (isComposerFrozen()) return; + if (!draftReady) { + if (draftPersistence.phase === 'error') { + const owner = submissionOwner.current; + const recovered = await retryDraftPersistence(); + if (submissionOwner.current === owner) addMessage({ type: 'system', isSystemNotice: true, noticeLevel: recovered ? 'info' : 'warning', content: recovered ? t('input.draftPersistence.recoveredRetry', { defaultValue: 'Draft saving recovered. Review your input and attachments, then press Send again.' }) : t('input.draftPersistence.recoveryStillFailed', { defaultValue: 'Draft recovery still failed. Your live input and existing stored data have been kept.' }), timestamp: new Date() }); + } else addMessage({ type: 'error', content: t('input.draftPersistence.recoveryNotReady', { defaultValue: 'Draft recovery is not ready. Your input has been kept; retry after recovery completes.' }), timestamp: new Date() }); + return; + } const sendOptions = queued?.options ?? optionsFor(text); const files = queued?.images ?? attachedImages; const signIn = /^\/login(?:\s+(.*))?$/.exec(text.trim()); if (signIn) { login(signIn[1]?.trim() || undefined); resetCommandMenuState(); return; } - if (isLoading) { queueOwner.current = conversation; setQueuedDrafts((q) => [...q, { content: text, images: files, options: sendOptions }]); clearComposer(); eraseDraft(); return; } + if (isLoading) { queueOwner.current = composerOwner; setQueuedDrafts((q) => [...q, { id: newQueuedDraftId(), content: text, images: files, options: sendOptions }]); clearComposer(); eraseDraft(); return; } const candidate = text.trimEnd(); const help = candidate.trim().toLowerCase() === 'help'; - if (candidate.startsWith('/') || help) { const gap = candidate.indexOf(' '); const name = help ? '/help' : gap > 0 ? candidate.slice(0, gap) : candidate; const commandArgs = gap > 0 ? candidate.slice(gap).trim() : ''; const app = findAppUiCommand(resolveCommandAlias(name)); if (app && (app.interceptWithArgs !== false || !commandArgs)) { clearComposer(); applyAppCommand(app); return; } const notice = getLocalCommandNotice(name, commandArgs); if (notice) { clearComposer(); addMessage({ type: 'assistant', content: notice, timestamp: Date.now() }); return; } if (!bypassGate.current) { const gate = gateForCommand(resolveCommandAlias(name), commandArgs); if (gate) { clearComposer(); announceGate({ ...gate, text: candidate }); return; } } bypassGate.current = false; } + if (candidate.startsWith('/') || help) { const gap = candidate.indexOf(' '); const name = help ? '/help' : gap > 0 ? candidate.slice(0, gap) : candidate; const commandArgs = gap > 0 ? candidate.slice(gap).trim() : ''; const app = findAppUiCommand(resolveCommandAlias(name)); if (app && (app.interceptWithArgs !== false || !commandArgs)) { clearComposer(); applyAppCommand(app); return; } const notice = getLocalCommandNotice(name, commandArgs); if (notice) { clearComposer(); addMessage({ type: 'assistant', content: notice, timestamp: Date.now() }); return; } if (!bypassGate.current) { const gate = gateForCommand(resolveCommandAlias(name), commandArgs); if (gate) { announceGate({ ...gate, text: candidate }); return; } } bypassGate.current = false; } const owner = submissionOwner.current; if (!owner || submissionInFlight.current === owner) return; + const finishOperation = beginComposerOperation('send'); + if (!finishOperation) return; submissionInFlight.current = owner; const isCurrent = () => submissionOwner.current === owner; try { @@ -176,91 +214,180 @@ export function useChatComposerState(args: UseChatComposerStateArgs) { setIsUserScrolledUp(false); setTimeout(() => { if (isCurrent()) scrollToBottom(); }, 100); // Typing during an upload belongs to the next draft, even in this session. - if (inputRef.current === text) { clearComposer(); eraseDraft(id); } + if (inputRef.current === text && liveImages.current === files) { clearComposer(); eraseDraft(id); } } finally { if (submissionInFlight.current === owner) submissionInFlight.current = null; + finishOperation(); } - }, [addMessage, allocate, announceGate, applyAppCommand, attachedImages, clearComposer, conversation, eraseDraft, isLoading, login, onSessionEstablished, onSessionProcessing, optionsFor, resetCommandMenuState, scrollToBottom, selectedProject, selectedSession, sendMessage, setIsUserScrolledUp, upload]); + }, [addMessage, allocate, announceGate, applyAppCommand, attachedImages, clearComposer, composerOwner, draftPersistence.phase, draftReady, eraseDraft, isLoading, login, onSessionEstablished, onSessionProcessing, optionsFor, resetCommandMenuState, retryDraftPersistence, scrollToBottom, selectedProject, selectedSession, sendMessage, setIsUserScrolledUp, setQueuedDrafts, t, upload]); useEffect(() => { submitRef.current = handleSubmit; }, [handleSubmit]); - const restoreQueue = useCallback((id: string) => storedQueue(id), []); const handleSteer = useCallback((event: FormEvent | MouseEvent | TouchEvent | KeyboardEvent) => { event.preventDefault(); const text = inputRef.current; const id = selectedSession?.id || currentSessionId || null; - if (!isLoading || !text.trim() || !selectedProject || !id || attachedImages.length || !isAutoSendable(classifyCommandInput(text))) return; - if (sendMessage({ type: 'chat.steer', sessionId: id, content: text }) === false) { + if (!draftReady || !isLoading || !text.trim() || !selectedProject || !id || attachedImages.length || !isAutoSendable(classifyCommandInput(text))) return; + const draft: QueuedDraft = { id: `steer_${crypto.randomUUID()}`, content: text, images: [], options: optionsFor(text), pendingSteer: true }; + const finishOperation = beginComposerOperation('steer', draft.id); + if (!finishOperation) return; + let sent: boolean | void; + try { sent = sendMessage({ type: 'chat.steer', sessionId: id, content: text }); } catch (error) { finishOperation(); throw error; } + if (sent === false) { + finishOperation(); addMessage({ type: 'error', content: 'Connection lost. Your draft has been kept; retry when connected.', timestamp: new Date() }); return; } - const draft: QueuedDraft = { id: `steer_${Date.now()}_${Math.random().toString(36).slice(2)}`, content: text, images: [], options: optionsFor(text), pendingSteer: true }; // A missing reply does not mean rejection. Persist the unresolved claim so // neither a later turn nor a remount can send the same instruction again. const key = steerKey(id, text); const pending = steerWaiting.current.get(key) || []; - pending.push({ draft }); + pending.push({ draft, route: { projectId: selectedProject.projectId, conversation: id } }); steerWaiting.current.set(key, pending); - queueOwner.current = conversation; + queueOwner.current = composerOwner; setQueuedDrafts((q) => [...q, { ...draft, pendingSteer: true }]); clearComposer(); eraseDraft(id); - }, [addMessage, attachedImages.length, clearComposer, conversation, currentSessionId, eraseDraft, isLoading, optionsFor, selectedProject, selectedSession?.id, sendMessage]); + }, [addMessage, attachedImages.length, clearComposer, composerOwner, currentSessionId, draftReady, eraseDraft, isLoading, optionsFor, selectedProject, selectedSession?.id, sendMessage, setQueuedDrafts]); const resolveSteerResult = useCallback((content: string, accepted: boolean, sessionId: string | null = conversation) => { if (!sessionId) return; const key = steerKey(sessionId, content); const list = steerWaiting.current.get(key); - const restored = storedQueue(sessionId).find((draft) => draft.pendingSteer && draft.content === content); - const pending = list?.shift() ?? (restored ? { draft: restored } : undefined); - if (!pending) return; + const route = { projectId: projectId ?? '', conversation: sessionId }; + const restored = sessionId === conversation ? restoreQueue(route).find((draft) => draft.pendingSteer && draft.content === content) : undefined; + const pending = list?.shift() ?? (restored ? { draft: restored, route } : undefined); + if (!pending) { + const orphan = settleRetainedComposerSteer(sessionId, content, accepted); + if (orphan?.id) finishComposerOperation(orphan.id); + if (orphan && accepted) onSessionProcessing?.(sessionId, { statusText: null, canInterrupt: true }); + return; + } if (!list?.length) steerWaiting.current.delete(key); const settle = (queue: QueuedDraft[]) => accepted ? queue.filter((item) => item.id !== pending.draft.id) : queue.map((item) => item.id === pending.draft.id ? { ...item, pendingSteer: false } : item); - if (sessionId === conversation) { + if (sessionId === conversation && pending.route.projectId === projectId) { setQueuedDrafts(settle); if (accepted) { addMessage({ type: 'user', content: pending.draft.content, timestamp: new Date() }); scrollToBottom(); } } else { - writeQueuedMessages(sessionId, settle(storedQueue(sessionId))); + updateQueue(pending.route, settle); } + if (pending.draft.id) finishComposerOperation(pending.draft.id); if (accepted) onSessionProcessing?.(sessionId, { statusText: null, canInterrupt: true }); - }, [addMessage, conversation, onSessionProcessing, scrollToBottom]); + }, [addMessage, conversation, onSessionProcessing, projectId, restoreQueue, scrollToBottom, setQueuedDrafts, updateQueue]); - useEffect(() => { const switched = priorConversation.current !== conversation; priorConversation.current = conversation; const wasBusy = priorLoading.current; priorLoading.current = isLoading; if (isLoading) { queueInFlight.current = false; if (dispatchTimer.current) clearTimeout(dispatchTimer.current); } const head = queuedDrafts[0]; const verdict = decideQueueFlush({ sessionSwitched: switched, isLoading, wasLoading: wasBusy, queueLength: queuedDrafts.length, awaitingDispatchedTurn: queueInFlight.current, composerHasInput: Boolean(input.trim()), headAwaitingSteer: Boolean(head?.pendingSteer) }); if (verdict.action !== 'flush' || !head) return; const timer = setTimeout(() => { const disk = conversation ? readQueuedMessages(conversation) : []; if (conversation && disk.length < queuedDrafts.length) { setQueuedDrafts(restoreQueue(conversation)); return; } queueInFlight.current = true; if (dispatchTimer.current) clearTimeout(dispatchTimer.current); dispatchTimer.current = setTimeout(() => { queueInFlight.current = false; setQueuePulse((n) => n + 1); }, TURN_START_GRACE); setQueuedDrafts((q) => q.slice(1)); setInput(head.content); inputRef.current = head.content; setAttachedImages(head.images); setTimeout(() => { if (queueOwner.current === conversation) void submitRef.current?.(syntheticSubmit(), head); }, 0); }, verdict.delayMs); return () => clearTimeout(timer); }, [conversation, input, isLoading, queuePulse, queuedDrafts, restoreQueue]); - useEffect(() => () => { if (dispatchTimer.current) clearTimeout(dispatchTimer.current); }, []); - useEffect(() => { if (!projectId) return; const value = safeLocalStorage.getItem(draftInputKey(projectId, conversation)) || ''; setInput((old) => { inputRef.current = value; return old === value ? old : value; }); }, [conversation, projectId]); - useEffect(() => { if (!projectId) return; const key = draftInputKey(projectId, conversation); if (input) safeLocalStorage.setItem(key, input); else safeLocalStorage.removeItem(key); }, [conversation, input, projectId]); - useEffect(() => { if (conversation && queueOwner.current === conversation) { if (queuedDrafts.length) writeQueuedMessages(conversation, queuedDrafts.map(({ id, content, options, pendingSteer }) => ({ id, content, options, ...(pendingSteer ? { pendingSteer: true } : {}) }))); else clearQueuedMessages(conversation); } }, [conversation, queuedDrafts]); - useEffect(() => { queueOwner.current = conversation; queueInFlight.current = false; setQueuedDrafts(conversation ? restoreQueue(conversation) : []); }, [conversation, restoreQueue]); + useEffect(() => { + const switched = priorConversation.current !== composerOwner; + priorConversation.current = composerOwner; + queueOwner.current = composerOwner; + const wasBusy = priorLoading.current; + priorLoading.current = isLoading; + if (isLoading || switched) { queueInFlight.current = false; if (dispatchTimer.current) clearTimeout(dispatchTimer.current); } + const head = queuedDrafts[0]; + const verdict = decideQueueFlush({ sessionSwitched: switched, isLoading, wasLoading: wasBusy, queueLength: queuedDrafts.length, awaitingDispatchedTurn: queueInFlight.current, composerHasInput: Boolean(input.trim()) || attachedImages.length > 0, headAwaitingSteer: Boolean(head?.pendingSteer || head?.requiresReview) }); + if (composerFrozen || !draftReady || draftPersistence.phase === 'error' || verdict.action !== 'flush' || !head) return; + const timer = setTimeout(() => { + if (isComposerFrozen()) return; + // Only legacy text-only queues can be consumed by the offscreen sender. + // Never hydrate a File-bearing intent from that lossy projection. + const disk = conversation ? readQueuedMessages(conversation) : []; + if (draftPersistence.phase === 'unavailable' && conversation && !head.images.length && disk.length < queuedDrafts.length) { + setQueuedDrafts(disk.map((item) => ({ ...item, images: [] }))); + return; + } + const finishOperation = beginComposerOperation('queue-dispatch'); + if (!finishOperation) return; + queueInFlight.current = true; + if (dispatchTimer.current) clearTimeout(dispatchTimer.current); + dispatchTimer.current = setTimeout(() => { queueInFlight.current = false; setQueuePulse((n) => n + 1); }, TURN_START_GRACE); + setQueuedDrafts((q) => q.slice(1)); + setInput(head.content); + inputRef.current = head.content; + setAttachedImages(head.images); + setTimeout(() => { + try { if (queueOwner.current === composerOwner) void submitRef.current?.(syntheticSubmit(), head); } + finally { finishOperation(); } + }, 0); + }, verdict.delayMs); + return () => clearTimeout(timer); + }, [attachedImages.length, composerFrozen, composerOwner, conversation, draftPersistence.phase, draftReady, input, isLoading, queuePulse, queuedDrafts, setAttachedImages, setInput, setQueuedDrafts]); + useEffect(() => () => { queueOwner.current = ''; submitRef.current = null; if (dispatchTimer.current) clearTimeout(dispatchTimer.current); }, []); const resize = useCallback((target: HTMLTextAreaElement) => { target.style.height = 'auto'; const height = Math.max(22, target.scrollHeight); target.style.height = `${height}px`; if (!lineHeight.current) { const parsed = parseInt(window.getComputedStyle(target).lineHeight); lineHeight.current = Number.isFinite(parsed) ? parsed : 24; } setExpanded(height > lineHeight.current * 2); resized.current = target.value; }, []); useEffect(() => { if (textareaRef.current && resized.current !== input) resize(textareaRef.current); }, [input, resize]); - const handleImageFiles = useCallback((files: File[]) => { const accepted = files.filter((file) => { try { if (!file || typeof file !== 'object') { console.warn('Invalid file object:', file); return false; } if (!file.type?.startsWith('image/')) return false; if (!file.size || file.size > 5 * 1024 * 1024) { setImageErrors((old) => new Map(old).set(file.name || 'Unknown file', 'File too large (max 5MB)')); return false; } return true; } catch (error) { console.error('Error validating file:', error, file); return false; } }); if (accepted.length) setAttachedImages((old) => [...old, ...accepted].slice(0, 5)); }, []); - const { getRootProps, getInputProps, isDragActive, open } = useDropzone({ accept: { 'image/*': ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'] }, maxSize: 5 * 1024 * 1024, maxFiles: 5, onDrop: handleImageFiles, noClick: true, noKeyboard: true }); - const handleInputChange = useCallback((event: ChangeEvent) => { const value = event.target.value; const position = event.target.selectionStart; setInput(value); inputRef.current = value; setCursorPosition(position); if (!value.trim()) { event.target.style.height = 'auto'; setExpanded(false); resetCommandMenuState(); } else handleCommandInputChange(value, position); }, [handleCommandInputChange, resetCommandMenuState, setCursorPosition]); - const handlePaste = useCallback((event: ClipboardEvent) => { const items = Array.from(event.clipboardData.items); items.forEach((item) => { if (item.type.startsWith('image/')) { const file = item.getAsFile(); if (file) handleImageFiles([file]); } }); if (!items.length && event.clipboardData.files.length) handleImageFiles(Array.from(event.clipboardData.files).filter((file) => file.type.startsWith('image/'))); }, [handleImageFiles]); + const handleImageFiles = useCallback((files: File[]) => { if (isComposerSealed()) return; const accepted = files.filter((file) => { try { if (!file || typeof file !== 'object') { console.warn('Invalid file object:', file); return false; } if (!file.type?.startsWith('image/')) return false; if (!file.size || file.size > 5 * 1024 * 1024) { setImageErrors((old) => new Map(old).set(file.name || 'Unknown file', 'File too large (max 5MB)')); return false; } return true; } catch (error) { console.error('Error validating file:', error, file); return false; } }); if (accepted.length) setAttachedImages((old) => [...old, ...accepted].slice(0, 5)); }, [setAttachedImages]); + const attachmentError = (error: Error) => { + if (queueOwner.current === composerOwner && submissionOwner.current) setImageErrors((old) => new Map(old).set('attachment', error.message)); + }; + const acceptSelectedImages = (files: File[]) => { + const accepted = files.filter((file) => file.type.startsWith('image/') && file.size > 0 && file.size <= 5 * 1024 * 1024); + if (accepted.length > 5) { attachmentError(new Error('At most 5 images can be attached at once.')); return; } + handleImageFiles(accepted); + }; + const getFilesFromEvent = async (event: DropEvent) => { + if (isComposerSealed()) return []; + if (!Array.isArray(event) && event.type !== 'drop' && event.type !== 'change') return composerFilesFromEvent(event); + // Dropped/pasted input revokes a freeze rather than discarding the Files. + invalidateComposerFreeze(); + const finish = beginComposerOperation('attachment'); + if (!finish) return []; + try { + const files = (await composerFilesFromEvent(event)).filter((file): file is File => file instanceof File); + acceptSelectedImages(files); + return files; + } finally { finish(); } + }; + const { getRootProps, getInputProps, isDragActive } = useDropzone({ accept: { 'image/*': ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'] }, maxSize: 5 * 1024 * 1024, maxFiles: 5, getFilesFromEvent, onError: attachmentError, noClick: true, noKeyboard: true }); + const open = () => chooseComposerAttachments(acceptSelectedImages, attachmentError); + const handleInputChange = useCallback((event: ChangeEvent) => { if (isComposerSealed()) { event.preventDefault?.(); event.target.value = inputRef.current; return; } const value = event.target.value; const position = event.target.selectionStart; setInput(value); inputRef.current = value; setCursorPosition(position); if (!value.trim()) { event.target.style.height = 'auto'; setExpanded(false); resetCommandMenuState(); } else handleCommandInputChange(value, position); }, [handleCommandInputChange, resetCommandMenuState, setCursorPosition, setInput]); + const handlePaste = useCallback((event: ClipboardEvent) => { if (isComposerSealed()) { event.preventDefault?.(); return; } const items = Array.from(event.clipboardData.items); items.forEach((item) => { if (item.type.startsWith('image/')) { const file = item.getAsFile(); if (file) handleImageFiles([file]); } }); if (!items.length && event.clipboardData.files.length) handleImageFiles(Array.from(event.clipboardData.files).filter((file) => file.type.startsWith('image/'))); }, [handleImageFiles]); const syncInputOverlayScroll = useCallback((target: HTMLTextAreaElement) => { if (inputHighlightRef.current) { inputHighlightRef.current.scrollTop = target.scrollTop; inputHighlightRef.current.scrollLeft = target.scrollLeft; } }, []); - const handleTextareaInput = useCallback((event: FormEvent) => { resize(event.currentTarget); setCursorPosition(event.currentTarget.selectionStart); syncInputOverlayScroll(event.currentTarget); }, [resize, setCursorPosition, syncInputOverlayScroll]); - const handleKeyDown = useCallback((event: KeyboardEvent) => { if (handleCommandMenuKeyDown(event) || handleFileMentionsKeyDown(event) || event.key !== 'Enter' || event.nativeEvent.isComposing) return; if ((event.ctrlKey || event.metaKey) && !event.shiftKey || (!event.shiftKey && !event.ctrlKey && !event.metaKey && !sendByCtrlEnter)) { event.preventDefault(); void handleSubmit(event); } }, [handleCommandMenuKeyDown, handleFileMentionsKeyDown, handleSubmit, sendByCtrlEnter]); - const handleVoiceTranscript = useCallback((text: string, send?: boolean) => { const next = inputRef.current.trim() ? `${inputRef.current.trim()} ${text}` : text; setInput(next); inputRef.current = next; if (send) void submitRef.current?.(syntheticSubmit()); }, []); - const editQueuedDraft = useCallback((index: number) => setQueuedDrafts((q) => { const item = q[index]; if (!item) return q; setInput(item.content); inputRef.current = item.content; setAttachedImages(item.images); textareaRef.current?.focus(); return q.filter((_, position) => position !== index); }), []); - const deleteQueuedDraft = useCallback((index: number) => setQueuedDrafts((q) => q.filter((_, position) => position !== index)), []); - const moveQueuedDraft = useCallback((from: number, to: number) => setQueuedDrafts((q) => reorderQueue(q, from, to)), []); - const confirmCommandGate = useCallback(() => { const gate = gateRef.current; if (!gate) return; announceGate(null); bypassGate.current = true; + const handleTextareaInput = useCallback((event: FormEvent) => { if (isComposerSealed()) { event.preventDefault?.(); event.currentTarget.value = inputRef.current; return; } resize(event.currentTarget); setCursorPosition(event.currentTarget.selectionStart); syncInputOverlayScroll(event.currentTarget); }, [resize, setCursorPosition, syncInputOverlayScroll]); + const handleKeyDown = useCallback((event: KeyboardEvent) => { if (isComposerSealed()) { event.preventDefault(); return; } if (handleCommandMenuKeyDown(event) || handleFileMentionsKeyDown(event) || event.key !== 'Enter' || event.nativeEvent.isComposing) return; if ((event.ctrlKey || event.metaKey) && !event.shiftKey || (!event.shiftKey && !event.ctrlKey && !event.metaKey && !sendByCtrlEnter)) { event.preventDefault(); void handleSubmit(event); } }, [handleCommandMenuKeyDown, handleFileMentionsKeyDown, handleSubmit, sendByCtrlEnter]); + const handleVoiceTranscript = useCallback((text: string, send?: boolean) => { + if (isComposerSealed()) return; + const isCurrent = queueOwner.current === composerOwner && submissionOwner.current !== null; + const shouldSend = send && isCurrent && !isComposerFrozen(); + setInput((previous) => { + const next = previous.trim() ? `${previous.trim()} ${text}` : text; + if (isCurrent) inputRef.current = next; + return next; + }); + if (shouldSend) void submitRef.current?.(syntheticSubmit()); + }, [composerOwner, setInput]); + const editQueuedDraft = useCallback((index: number) => { + if (!draftReady || isComposerSealed()) return; + const item = queuedDrafts[index]; + if (!item) return; + // Keep an unrelated active draft instead of replacing it during queue edit. + setQueuedDrafts((q) => [...q.filter((_, position) => position !== index), ...(inputRef.current || attachedImages.length ? [{ id: newQueuedDraftId(), content: inputRef.current, images: attachedImages, requiresReview: true }] : [])]); + setInput(item.content); inputRef.current = item.content; setAttachedImages(item.images); textareaRef.current?.focus(); + }, [attachedImages, draftReady, queuedDrafts, setAttachedImages, setInput, setQueuedDrafts]); + const deleteQueuedDraft = useCallback((index: number) => { if (draftReady && !isComposerSealed()) setQueuedDrafts((q) => q.filter((_, position) => position !== index)); }, [draftReady, setQueuedDrafts]); + const moveQueuedDraft = useCallback((from: number, to: number) => { if (draftReady && !isComposerSealed()) setQueuedDrafts((q) => reorderQueue(q, from, to)); }, [draftReady, setQueuedDrafts]); + const confirmCommandGate = useCallback(() => { const gate = gateRef.current; if (!gate || isComposerFrozen() || inputRef.current.trimEnd() !== gate.text) return; announceGate(null); bypassGate.current = true; // A confirmed handoff moves the runtime to a fresh session; the next // session_upserted for a new id in this project is it, and the app should // follow instead of staying on the old session (issue #6). if (/^\/handoff\b/.test(gate.text.trim())) useAppShellStore.getState().setPendingHandoff({ fromSessionId: conversation, projectId, at: Date.now() }); - setInput(gate.text); inputRef.current = gate.text; void handleSubmit(syntheticSubmit()); }, [announceGate, conversation, handleSubmit, projectId]); - const cancelCommandGate = useCallback(() => { announceGate(null); bypassGate.current = false; }, [announceGate]); + setInput(gate.text); inputRef.current = gate.text; void handleSubmit(syntheticSubmit()); }, [announceGate, conversation, handleSubmit, projectId, setInput]); + const cancelCommandGate = useCallback(() => { if (isComposerSealed()) return; announceGate(null); bypassGate.current = false; }, [announceGate]); const handleClearInput = useCallback(() => { clearComposer(); textareaRef.current?.focus(); }, [clearComposer]); // The Changes tab's line comments arrive here: one new paragraph with the // reference and the quote, focus moved to the composer, ready to send. - const insertAtEnd = useCallback((text: string) => { if (!text.trim()) return; const next = inputRef.current.trim() ? `${inputRef.current.trimEnd()}\n\n${text}` : text; setInput(next); inputRef.current = next; textareaRef.current?.focus(); }, []); - const handleAbortSession = useCallback(() => { if (!canAbortSession) return; const id = selectedSession?.id || currentSessionId; if (!id) { console.warn('Abort requested but no session ID is available.'); return; } sendMessage({ type: 'chat.abort', sessionId: id }); }, [canAbortSession, currentSessionId, selectedSession?.id, sendMessage]); - const handlePermissionDecision = useCallback((requestIds: string | string[], decision: PermissionDecision) => { const ids = (Array.isArray(requestIds) ? requestIds : [requestIds]).filter(Boolean); const sent = ids.filter((requestId) => sendMessage(permissionResponseMessage(requestId, decision)) !== false); if (sent.length) setPendingPermissionRequests((requests) => requests.filter((request) => !sent.includes(request.requestId))); }, [sendMessage, setPendingPermissionRequests]); + const insertAtEnd = useCallback((text: string) => { if (isComposerSealed() || !text.trim()) return; const next = inputRef.current.trim() ? `${inputRef.current.trimEnd()}\n\n${text}` : text; setInput(next); inputRef.current = next; textareaRef.current?.focus(); }, [setInput]); + const handleAbortSession = useCallback(() => { if (isComposerSealed() || !canAbortSession) return; const id = selectedSession?.id || currentSessionId; if (!id) { console.warn('Abort requested but no session ID is available.'); return; } sendMessage({ type: 'chat.abort', sessionId: id }); }, [canAbortSession, currentSessionId, selectedSession?.id, sendMessage]); + const handlePermissionDecision = useCallback((requestIds: string | string[], decision: PermissionDecision) => { + const finishOperation = beginComposerOperation('send'); + if (!finishOperation) return; + try { + const ids = (Array.isArray(requestIds) ? requestIds : [requestIds]).filter(Boolean); + const sent = ids.filter((requestId) => sendMessage(permissionResponseMessage(requestId, decision)) !== false); + if (sent.length) setPendingPermissionRequests((requests) => requests.filter((request) => !sent.includes(request.requestId))); + } finally { finishOperation(); } + }, [sendMessage, setPendingPermissionRequests]); const handleInputFocusChange = useCallback((focused: boolean) => { setFocused(focused); onInputFocusChange?.(focused); }, [onInputFocusChange]); - return { input, setInput, textareaRef, inputHighlightRef, isTextareaExpanded, slashCommandsCount, skillCommands: slashCommands.filter((command) => command.type === 'skill'), filteredCommands, frequentCommands, commandQuery, showCommandMenu, selectedCommandIndex, resetCommandMenuState, handleCommandSelect, handleToggleCommandMenu, showFileDropdown, filteredFiles: filteredFiles as MentionableFile[], selectedFileIndex, renderInputWithMentions, selectFile, attachedImages, setAttachedImages, uploadingImages, imageErrors, getRootProps, getInputProps, isDragActive, openImagePicker: open, handleSubmit, handleSteer, modelPickerTrigger, queuedDrafts, editQueuedDraft, deleteQueuedDraft, moveQueuedDraft, resolveSteerResult, pendingCommandGate, confirmCommandGate, cancelCommandGate, handleVoiceTranscript, insertAtEnd, handleInputChange, handleKeyDown, handlePaste, handleTextareaClick: (event: MouseEvent) => setCursorPosition(event.currentTarget.selectionStart), handleTextareaInput, syncInputOverlayScroll, handleClearInput, handleAbortSession, handlePermissionDecision, handleInputFocusChange, isInputFocused, commandModalPayload, closeCommandModal: () => setModal(null), showCostModal, isWorkspace: workspaceTarget.isWorkspace, workspaceCandidates: workspaceTarget.candidates, workspaceTargetValue: workspaceTarget.target, pickWorkspaceTarget: workspaceTarget.pickTarget }; + return { composerFrozen, draftPersistence, draftReady, retryDraftPersistence, input, setInput, textareaRef, inputHighlightRef, isTextareaExpanded, slashCommandsCount, skillCommands: slashCommands.filter((command) => command.type === 'skill'), filteredCommands, frequentCommands, commandQuery, showCommandMenu, selectedCommandIndex, resetCommandMenuState, handleCommandSelect, handleToggleCommandMenu, showFileDropdown, filteredFiles: filteredFiles as MentionableFile[], selectedFileIndex, renderInputWithMentions, selectFile, attachedImages, setAttachedImages, uploadingImages, imageErrors, getRootProps, getInputProps, isDragActive, openImagePicker: open, handleSubmit, handleSteer, modelPickerTrigger, queuedDrafts, editQueuedDraft, deleteQueuedDraft, moveQueuedDraft, resolveSteerResult, pendingCommandGate, confirmCommandGate, cancelCommandGate, handleVoiceTranscript, insertAtEnd, handleInputChange, handleKeyDown, handlePaste, handleTextareaClick: (event: MouseEvent) => setCursorPosition(event.currentTarget.selectionStart), handleTextareaInput, syncInputOverlayScroll, handleClearInput, handleAbortSession, handlePermissionDecision, handleInputFocusChange, isInputFocused, commandModalPayload, closeCommandModal: () => setModal(null), showCostModal, isWorkspace: workspaceTarget.isWorkspace, workspaceCandidates: workspaceTarget.candidates, workspaceTargetValue: workspaceTarget.target, pickWorkspaceTarget: workspaceTarget.pickTarget }; } diff --git a/src/components/chat/hooks/useDurableComposerDraft.ts b/src/components/chat/hooks/useDurableComposerDraft.ts new file mode 100644 index 00000000..3cab17be --- /dev/null +++ b/src/components/chat/hooks/useDurableComposerDraft.ts @@ -0,0 +1,431 @@ +import { useCallback, useEffect, useState, useSyncExternalStore } from 'react'; +import type { SetStateAction } from 'react'; + +import { invalidateComposerFreeze, isComposerSealed, registerComposerFreezeParticipant, subscribeComposerFreeze, type ComposerDraftReceipt } from '../../../shared/composerFreeze'; +import { draftInputKey, notifyQueuedMessages, queuedMessageKey, subscribeQueuedMessages } from '../utils/chatStorage'; +import { composerQueueOwnerKey, readComposerQueueProjection } from '../utils/composerQueueProjection'; +import { verifyCommittedComposerDraft } from '../utils/composerDraftVerification'; +import { + boundedComposerDraft, browserComposerDraftRepository, COMPOSER_STORAGE_LIMITS, + composerRouteKey, composerStorageReason, ComposerStorageError, normalizeComposerStorageError, + type ComposerDraft, type ComposerDraftRepository, type ComposerRoute, type DurableQueuedDraft, +} from '../utils/composerDraftStorage'; + +export type DraftPersistenceStatus = { + phase: 'loading' | 'pending' | 'saved' | 'error' | 'unavailable'; + reason: ReturnType | null; +}; +type Snapshot = ComposerDraft & { persistence: DraftPersistenceStatus }; +type Entry = { + snapshot: Snapshot; + generation: number; + revision: number; + inputChanged: boolean; + imagesChanged: boolean; + queueChanged: boolean; + loading?: Promise; + writing?: Promise; + retrying?: Promise; + loaded: boolean; + loadFailed: boolean; + writable: boolean; + migrationBlocked: boolean; + queueRaw: string | null; + baseQueueIds: Set; +}; +const nextValue = (action: SetStateAction, value: T): T => typeof action === 'function' ? (action as (old: T) => T)(value) : action; +export const newQueuedDraftId = () => `queued_${crypto.randomUUID()}`; +const retainedControllers = new Set(); + +/** A steer reply may reach a replacement composer after its owner unmounted. + * The retained draft, not the replacement viewer's project, supplies the route. */ +export function settleRetainedComposerSteer(sessionId: string, content: string, accepted: boolean): DurableQueuedDraft | undefined { + for (const controller of retainedControllers) { + const draft = controller.settleSteer(sessionId, content, accepted); + if (draft) return draft; + } +} + +function legacyDraft(route: ComposerRoute): ComposerDraft { + const empty: ComposerDraft = { ...route, input: '', images: [], queue: [] }; + if (!route.projectId || typeof localStorage === 'undefined') return empty; + const inputKey = draftInputKey(route.projectId, route.conversation); + const owner = localStorage.getItem(`composer_owner_${inputKey}`); + const input = owner && owner !== composerRouteKey(route) ? '' : localStorage.getItem(inputKey) ?? ''; + const projection = readComposerQueueProjection(route); + return { ...empty, input, queue: projection.foreign ? [] : projection.queue.map((item) => ({ ...item, images: [] })) }; +} + +/** One controller per mounted composer; records and async completions keep their own route. */ +class ComposerDraftController { + private entries = new Map(); + private listeners = new Set<() => void>(); + private publishing = false; + private mounted = false; + private activeRoute?: string; + private unregister?: () => void; + private disconnect?: () => void; + constructor(private repository: ComposerDraftRepository) {} + subscribe = (listener: () => void) => { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; }; + private notify() { this.listeners.forEach((listener) => listener()); } + entry(route: ComposerRoute): Entry { + const key = composerRouteKey(route); + let entry = this.entries.get(key); + if (!entry) { + const available = this.repository !== browserComposerDraftRepository || typeof indexedDB !== 'undefined'; + let initial: ComposerDraft = { ...route, input: '', images: [], queue: [] }; + let failure: ReturnType | null = null; + let raw: string | null = null; + try { initial = legacyDraft(route); raw = readComposerQueueProjection(route).raw; } catch (error) { failure = composerStorageReason(error); } + entry = { + snapshot: { ...initial, persistence: { phase: failure ? 'error' : available ? 'loading' : 'unavailable', reason: failure ?? (available ? null : 'unavailable') } }, + generation: 0, revision: 0, inputChanged: false, imagesChanged: false, queueChanged: false, + loaded: !available, loadFailed: Boolean(failure), writable: available, + migrationBlocked: Boolean(failure), queueRaw: raw, baseQueueIds: new Set(initial.queue.map((item) => item.id)), + }; + this.entries.set(key, entry); + } + return entry; + } + private status(entry: Entry, persistence: DraftPersistenceStatus) { + entry.snapshot = { ...entry.snapshot, persistence }; + this.notify(); + } + connect() { + this.mounted = true; + this.retain(); + return () => { this.mounted = false; this.releaseEmpty(); }; + } + private retain() { + if (this.unregister || typeof window === 'undefined') return; + retainedControllers.add(this); + this.unregister = registerComposerFreezeParticipant(this); + const refresh = (sessionId?: string) => { + if (this.publishing) return; + for (const entry of this.entries.values()) if (!sessionId || entry.snapshot.conversation === sessionId) { + try { if (this.reconcile(entry)) this.schedule(entry); } catch (error) { this.failMigration(entry, error); } + } + }; + const unsubscribe = subscribeQueuedMessages(refresh); + const storage = () => refresh(); + window.addEventListener('storage', storage); + this.disconnect = () => { unsubscribe(); window.removeEventListener('storage', storage); }; + } + dispose = () => { + this.disconnect?.(); + this.disconnect = undefined; + this.unregister?.(); + this.unregister = undefined; + retainedControllers.delete(this); + }; + private releaseEmpty() { + if (this.mounted) return; + for (const [key, entry] of this.entries) { + if (entry.loading || entry.writing || entry.retrying || entry.snapshot.persistence.phase !== 'saved') continue; + const live = entry.snapshot; + const replaced = [...retainedControllers].some((other) => other !== this && other.mounted && other.repository === this.repository && other.entries.has(key)); + if (replaced || (!live.input && !live.images.length && !live.queue.length)) this.entries.delete(key); + } + if (!this.entries.size) this.dispose(); + } + /** Retain unmounted dirty/error entries and their Files. A remount may replace + * only a settled prior owner; CAS still rejects simultaneous live writers. */ + private retireSettledOwner(route: ComposerRoute) { + const key = composerRouteKey(route); + for (const prior of retainedControllers) { + if (prior === this || prior.mounted || prior.repository !== this.repository) continue; + const entry = prior.entries.get(key); + if (!entry || entry.loading || entry.writing || entry.retrying || entry.snapshot.persistence.phase !== 'saved') continue; + prior.entries.delete(key); + prior.releaseEmpty(); + } + } + private failMigration(entry: Entry, error: unknown) { + entry.migrationBlocked = true; + entry.loadFailed = true; + this.status(entry, { phase: 'error', reason: composerStorageReason(error) }); + } + settleSteer(sessionId: string, content: string, accepted: boolean): DurableQueuedDraft | undefined { + if (isComposerSealed()) return; + for (const entry of this.entries.values()) { + if (entry.snapshot.conversation !== sessionId) continue; + const draft = entry.snapshot.queue.find((item) => item.pendingSteer && item.content === content); + if (!draft) continue; + this.change(entry.snapshot, 'queue', (queue) => accepted + ? queue.filter((item) => draft.id ? item.id !== draft.id : item !== draft) + : queue.map((item) => (draft.id ? item.id === draft.id : item === draft) ? { ...item, pendingSteer: false } : item)); + return draft; + } + } + /** The legacy consumer can retire a cached queue while another route is open. */ + private reconcile(entry: Entry): boolean { + if (isComposerSealed()) return false; + if (entry.migrationBlocked) return false; + const projection = readComposerQueueProjection(entry.snapshot); + if (projection.foreign || projection.raw === entry.queueRaw) return false; + const remaining = [...entry.snapshot.queue]; + const queue = projection.queue.map((item) => { + const index = remaining.findIndex((old) => item.id ? old.id === item.id : !old.id && old.content === item.content); + const old = index < 0 ? undefined : remaining.splice(index, 1)[0]; + return { ...item, images: old?.images ?? [], ...(old?.requiresReview ? { requiresReview: true } : {}) }; + }); + entry.queueRaw = projection.raw; + entry.queueChanged = true; + entry.generation += 1; + entry.snapshot = { ...entry.snapshot, queue }; + this.notify(); + return true; + } + activate(route: ComposerRoute) { + const changed = this.activeRoute !== composerRouteKey(route); + this.activeRoute = composerRouteKey(route); + if (isComposerSealed()) { if (changed) invalidateComposerFreeze(); return; } + invalidateComposerFreeze(); + this.retireSettledOwner(route); + const entry = this.entry(route); + try { if (this.reconcile(entry)) this.schedule(entry); } catch (error) { this.failMigration(entry, error); } + void this.load(route); + } + load(route: ComposerRoute): Promise { + if (isComposerSealed()) return Promise.resolve(); + const entry = this.entry(route); + if (entry.loading) return entry.loading; + if (entry.loaded || entry.migrationBlocked || !route.projectId) return Promise.resolve(); + entry.loading = this.repository.load(route).then((record) => { + if (record) entry.revision = record.revision; + if (record && !record.absent) { + const { draft } = boundedComposerDraft(record); + if (composerRouteKey(draft) !== composerRouteKey(route)) throw new Error('Draft route mismatch'); + entry.snapshot = { + ...entry.snapshot, + // Typing/attachment events that beat IndexedDB always win. Never + // restore an old draft over a newer keystroke or paste event. + input: entry.inputChanged ? entry.snapshot.input : draft.input, + images: entry.imagesChanged ? entry.snapshot.images : draft.images, + queue: entry.queueChanged + ? entry.snapshot.queue.map((item) => ({ ...item, images: item.images.length ? item.images : draft.queue.find((stored) => stored.id && stored.id === item.id)?.images ?? [], requiresReview: true })) + : draft.queue.map((item) => ({ ...item, requiresReview: true })), + }; + entry.baseQueueIds = new Set(draft.queue.map((item) => item.id)); + } + entry.loaded = true; + entry.loadFailed = false; + // Recovered intents need review; newly queued text remains auto-sendable. + this.mirror(entry); + const needsSave = entry.generation > 0 || ((!record || record.absent) && Boolean(entry.snapshot.input || entry.snapshot.images.length || entry.snapshot.queue.length)); + this.status(entry, { phase: needsSave ? 'pending' : 'saved', reason: null }); + if (needsSave) this.schedule(entry); + }).catch((error: unknown) => { + entry.loadFailed = true; + this.status(entry, { phase: 'error', reason: composerStorageReason(error) }); + }).finally(() => { entry.loading = undefined; }); + return entry.loading; + } + /** Compatibility projection only: never evict another draft to make room. */ + private mirror(entry: Entry) { + if (isComposerSealed()) return; + const state = entry.snapshot; + if (!state.projectId || typeof localStorage === 'undefined') return; + if (entry.migrationBlocked) throw new ComposerStorageError(entry.snapshot.persistence.reason ?? 'invalid'); + boundedComposerDraft(state); + const inputKey = draftInputKey(state.projectId, state.conversation); + const routeKey = composerRouteKey(state); + if (state.input) { + localStorage.setItem(`composer_owner_${inputKey}`, routeKey); + localStorage.setItem(inputKey, state.input); + } else if (!localStorage.getItem(`composer_owner_${inputKey}`) || localStorage.getItem(`composer_owner_${inputKey}`) === routeKey) localStorage.removeItem(inputKey); + if (state.conversation) { + const key = queuedMessageKey(state.conversation); + const prior = readComposerQueueProjection(state); + if (prior.foreign && !state.queue.length) return; + const projection = state.queue.map(({ id, content, options, pendingSteer, images, requiresReview }) => ({ + ...(id ? { id } : {}), content, ...(options === undefined ? {} : { options }), + ...(pendingSteer ? { pendingSteer: true } : {}), + ...(images.length ? { attachmentCount: images.length } : {}), + ...(requiresReview ? { requiresReview: true } : {}), + ...(entry.writable || images.length ? { composerRoute: routeKey } : {}), + })); + const raw = projection.length ? JSON.stringify(projection) : null; + if (raw && raw.length > COMPOSER_STORAGE_LIMITS.textLength * 2) throw new ComposerStorageError('limit'); + localStorage.setItem(composerQueueOwnerKey(state.conversation), routeKey); + if (raw === null) localStorage.removeItem(key); else localStorage.setItem(key, raw); + entry.queueRaw = raw; + if (prior.raw !== raw) { + this.publishing = true; + try { notifyQueuedMessages(state.conversation); } finally { this.publishing = false; } + // A listener can synchronously send and consume this very projection. + this.reconcile(entry); + } + } + } + change(route: ComposerRoute, field: K, action: SetStateAction) { + if (isComposerSealed()) return; + invalidateComposerFreeze(); + this.retain(); + const entry = this.entry(route); + try { this.reconcile(entry); } catch (error) { this.failMigration(entry, error); } + const value = nextValue(action, entry.snapshot[field]); + if (field === 'input') entry.inputChanged = true; + if (field === 'images') entry.imagesChanged = true; + if (field === 'queue') entry.queueChanged = true; + if (value === entry.snapshot[field] && entry.loaded) return; + entry.snapshot = { ...entry.snapshot, [field]: value }; + entry.generation += 1; + try { + this.mirror(entry); + if (entry.loadFailed) this.notify(); + else this.status(entry, { phase: entry.writable ? 'pending' : 'unavailable', reason: entry.writable ? null : 'unavailable' }); + } catch (error) { this.status(entry, { phase: 'error', reason: composerStorageReason(error) }); } + this.schedule(entry); + // A captured voice/attachment callback may arrive after an empty owner was + // unmounted and released. Its new route entry still needs an actual load. + if (!entry.loaded) void this.load(route); + } + private schedule(entry: Entry) { + if (isComposerSealed()) return; + if (!entry.writable || !entry.loaded || entry.loadFailed || entry.writing || !entry.snapshot.projectId) return; + // Coalesce same-event text/files/queue mutations; never create an unbounded + // promise backlog while typing. At most one write and one latest snapshot. + entry.writing = Promise.resolve().then(async () => { + if (isComposerSealed()) return; + let savedGeneration: number; + do { + this.reconcile(entry); + this.mirror(entry); + savedGeneration = entry.generation; + const { draft } = boundedComposerDraft(entry.snapshot); + entry.revision = await this.repository.save(draft, entry.revision); + } while (savedGeneration !== entry.generation); + this.status(entry, { phase: 'saved', reason: null }); + }).catch((error: unknown) => { + const reason = composerStorageReason(error); + // A concurrent writer is not permission to overwrite its newer revision. + if (reason === 'conflict') entry.loadFailed = true; + this.status(entry, { phase: 'error', reason }); + }).finally(() => { entry.writing = undefined; this.releaseEmpty(); }); + } + flushAndVerify = async (isCurrent: () => boolean): Promise => { + const receipts: ComposerDraftReceipt[] = []; + for (const entry of this.entries.values()) { + if (!isCurrent()) return []; + const route = entry.snapshot; + if (!route.projectId) { + if (route.input || route.images.length || route.queue.length) throw new ComposerStorageError('unavailable'); + continue; + } + await this.load(route); + await entry.retrying; + await entry.writing; + if (!isCurrent()) return []; + if (!entry.writable) throw new ComposerStorageError('unavailable'); + if (entry.loadFailed || entry.snapshot.persistence.phase === 'error') throw new ComposerStorageError(entry.snapshot.persistence.reason ?? 'storage'); + // Also commit recovered review flags and any offscreen queue retirement. + // No retry/rebase here: a restart receipt cannot overwrite a CAS conflict. + this.schedule(entry); + await entry.writing; + if (!isCurrent()) return []; + if (entry.snapshot.persistence.phase !== 'saved') throw new ComposerStorageError(entry.snapshot.persistence.reason ?? 'storage'); + const { draft } = boundedComposerDraft(entry.snapshot); + const generation = entry.generation; + const revision = entry.revision; + try { await verifyCommittedComposerDraft(this.repository, draft, revision); } catch (error) { + const failure = normalizeComposerStorageError(error); + // A successful put is not proof that every committed attachment can be + // read. Do not leave a failed restart verification displaying 'saved', + // or let a stale verifier change a newer write/lease's status. + if (isCurrent() && generation === entry.generation && revision === entry.revision) { + if (failure.reason === 'conflict') entry.loadFailed = true; + this.status(entry, { phase: 'error', reason: failure.reason }); + } + throw failure; + } + if (!isCurrent()) return []; + if (generation !== entry.generation || revision !== entry.revision) throw new ComposerStorageError('conflict'); + receipts.push({ routeKey: composerRouteKey(draft), revision, generation, + fileCount: draft.images.length + draft.queue.reduce((count, item) => count + item.images.length, 0), queuedIntentCount: draft.queue.length }); + } + return receipts; + }; + /** Explicit user retry, not a restart receipt. Rebase against the current CAS revision. */ + retry(route: ComposerRoute): Promise { + if (isComposerSealed()) return Promise.resolve(false); + invalidateComposerFreeze(); + this.retain(); + const entry = this.entry(route); + if (!entry.retrying) entry.retrying = this.retryOnce(route).finally(() => { entry.retrying = undefined; }); + return entry.retrying; + } + private async retryOnce(route: ComposerRoute): Promise { + const entry = this.entry(route); + await entry.loading; + await entry.writing; + try { + // Incomplete legacy migration cannot be made successful by overwriting + // its raw source with the empty placeholder displayed by a failed load. + const legacy = readComposerQueueProjection(route); + const record = await this.repository.load(route); + const stored = record && !record.absent ? boundedComposerDraft(record).draft : null; + if (stored && composerRouteKey(stored) !== composerRouteKey(route)) throw new ComposerStorageError('invalid'); + const live = entry.snapshot; + const liveIds = new Set(live.queue.map((item) => item.id)); + const deleted = new Set([...entry.baseQueueIds].filter((id) => id && !liveIds.has(id))); + const canonical = stored?.queue ?? []; + const canonicalIds = new Set(canonical.map((item) => item.id)); + const additions = live.queue.filter((item) => !entry.baseQueueIds.has(item.id) && !canonicalIds.has(item.id)); + const queue = stored + ? [...canonical.filter((item) => !deleted.has(item.id)), ...additions].map((item) => ({ ...item, requiresReview: true })) + : live.queue; + entry.snapshot = { ...live, + input: entry.loaded || entry.inputChanged ? live.input : stored?.input ?? live.input, + images: entry.loaded || entry.imagesChanged ? live.images : stored?.images ?? live.images, + queue, + }; + entry.revision = record?.revision ?? 0; + entry.baseQueueIds = new Set(canonical.map((item) => item.id)); + entry.queueRaw = legacy.raw; + entry.loaded = true; + entry.writable = true; + entry.loadFailed = false; + entry.migrationBlocked = false; + entry.generation += 1; + this.status(entry, { phase: 'pending', reason: null }); + this.schedule(entry); + await entry.writing; + return entry.snapshot.persistence.phase === 'saved'; + } catch (error) { + entry.loadFailed = true; + this.status(entry, { phase: 'error', reason: composerStorageReason(error) }); + return false; + } + } +} + +/** + * Unsent drafts only. The page registry includes offscreen entries and retains + * unmounted pending writes. Neither `saved` nor its receipt grants native + * restart authority, attests to other windows, or prevents browser eviction. + */ +export function useDurableComposerDraft(projectId: string | undefined, conversation: string | null, repository = browserComposerDraftRepository) { + const [controller] = useState(() => new ComposerDraftController(repository)); + const sealed = useSyncExternalStore(subscribeComposerFreeze, isComposerSealed, () => false); + const routeProject = projectId ?? ''; + const snapshot = useSyncExternalStore(controller.subscribe, + () => controller.entry({ projectId: routeProject, conversation }).snapshot, + () => controller.entry({ projectId: routeProject, conversation }).snapshot); + useEffect(() => controller.connect(), [controller]); + useEffect(() => { controller.activate({ projectId: routeProject, conversation }); }, [controller, conversation, routeProject, sealed]); + const setInput = useCallback((action: SetStateAction) => controller.change({ projectId: routeProject, conversation }, 'input', action), [controller, conversation, routeProject]); + const setImages = useCallback((action: SetStateAction) => controller.change({ projectId: routeProject, conversation }, 'images', action), [controller, conversation, routeProject]); + const setQueue = useCallback((action: SetStateAction) => controller.change({ projectId: routeProject, conversation }, 'queue', action), [controller, conversation, routeProject]); + const getQueue = useCallback((route: ComposerRoute) => controller.entry(route).snapshot.queue, [controller]); + const updateQueue = useCallback((route: ComposerRoute, action: SetStateAction) => { + if (isComposerSealed()) return; + invalidateComposerFreeze(); + const entry = controller.entry(route); + if (entry.loaded) controller.change(route, 'queue', action); + else void controller.load(route).then(() => { if (!entry.loadFailed) controller.change(route, 'queue', action); }); + }, [controller]); + const retryPersistence = useCallback(() => controller.retry({ projectId: routeProject, conversation }), [controller, conversation, routeProject]); + const current = controller.entry({ projectId: routeProject, conversation }); + return { input: snapshot.input, images: snapshot.images, queue: snapshot.queue, persistence: snapshot.persistence, ready: current.loaded && !current.loadFailed, retryPersistence, setInput, setImages, setQueue, getQueue, updateQueue }; +} diff --git a/src/components/chat/hooks/useVoiceInput.dom.bun.test.tsx b/src/components/chat/hooks/useVoiceInput.dom.bun.test.tsx new file mode 100644 index 00000000..d43b21e3 --- /dev/null +++ b/src/components/chat/hooks/useVoiceInput.dom.bun.test.tsx @@ -0,0 +1,168 @@ +import assert from 'node:assert/strict'; +import { afterEach, beforeEach, test } from 'node:test'; + +import { act, cleanup, renderHook, waitFor } from '@testing-library/react'; + +import { cancelComposerFreeze, prepareComposerFreeze, resetComposerFreezeForTests } from '../../../shared/composerFreeze'; +import { boundedComposerDraft, composerRouteKey, type ComposerDraftRepository, type StoredComposerDraft } from '../utils/composerDraftStorage'; + +import { useChatComposerState } from './useChatComposerState'; +import { useVoiceInput } from './useVoiceInput'; + +const deferred = () => { let resolve!: (value: T) => void; let reject!: (error: unknown) => void; const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); return { promise, resolve, reject }; }; +const audio = () => new Blob([new Uint8Array(1024)], { type: 'audio/webm' }); +class Recorder { + static instances: Recorder[] = []; + static startFailure = false; + static isTypeSupported() { return true; } + state = 'inactive'; mimeType = 'audio/webm'; stops = 0; stopFailure = false; + ondataavailable: ((event: { data: Blob }) => void) | null = null; + onstop: (() => void | Promise) | null = null; + onerror: (() => void) | null = null; + constructor() { Recorder.instances.push(this); } + start() { if (Recorder.startFailure) throw new Error('start failed'); this.state = 'recording'; } + stop() { this.stops += 1; if (this.stopFailure) throw new Error('stop failed'); this.state = 'inactive'; } + async complete(data = audio()) { this.state = 'inactive'; this.ondataavailable?.({ data }); await this.onstop?.(); } +} +function microphone() { + const track = { stops: 0, stop() { this.stops += 1; } }; + return { track, stream: { getTracks: () => [track] } as unknown as MediaStream }; +} +const originalRecorder = Object.getOwnPropertyDescriptor(globalThis, 'MediaRecorder'); +const originalMedia = Object.getOwnPropertyDescriptor(navigator, 'mediaDevices'); +const originalFetch = globalThis.fetch; +let epoch = 0; +let device = microphone(); +let acquire: () => Promise; +beforeEach(() => { + resetComposerFreezeForTests(); localStorage.clear(); epoch = 0; + Recorder.instances = []; Recorder.startFailure = false; device = microphone(); + acquire = async () => device.stream; + Object.defineProperty(globalThis, 'MediaRecorder', { configurable: true, value: Recorder }); + Object.defineProperty(navigator, 'mediaDevices', { configurable: true, value: { getUserMedia: () => acquire() } }); + globalThis.fetch = async () => new Response('{"text":"spoken text"}'); +}); +afterEach(() => { + cleanup(); resetComposerFreezeForTests(); localStorage.clear(); globalThis.fetch = originalFetch; + if (originalRecorder) Object.defineProperty(globalThis, 'MediaRecorder', originalRecorder); else Reflect.deleteProperty(globalThis, 'MediaRecorder'); + if (originalMedia) Object.defineProperty(navigator, 'mediaDevices', originalMedia); else Reflect.deleteProperty(navigator, 'mediaDevices'); +}); +const request = () => ({ token: `voice-${++epoch}`, epoch, ttlMs: 2000 }); +async function busy() { await act(async () => { await assert.rejects(prepareComposerFreeze(request()), (error) => (error as { reason?: string }).reason === 'busy'); }); } +async function idle() { + await act(async () => { const receipt = await prepareComposerFreeze(request()); assert.equal(receipt.installerAuthority, false); cancelComposerFreeze(receipt); }); +} +const voice = (onTranscript: (text: string, send?: boolean) => void | Promise = () => {}, onError?: (message: string) => void) => renderHook(() => useVoiceInput(onTranscript, onError, 'A')); +async function start(view: ReturnType) { act(() => view.result.current.toggle()); await waitFor(() => assert.equal(view.result.current.state, 'recording')); return Recorder.instances.at(-1)!; } + +test('new voice admission is blocked synchronously, while pending permission owns its actual lifetime', async () => { + const permission = deferred(); let calls = 0; + acquire = () => { calls += 1; return permission.promise; }; + const view = voice(); const captured = view.result.current.toggle; + const receipt = await prepareComposerFreeze(request()); + act(() => captured()); assert.equal(calls, 0); + cancelComposerFreeze(receipt); + act(() => { captured(); captured(); }); + assert.equal(calls, 1); assert.equal(view.result.current.state, 'starting'); await busy(); + view.unmount(); await busy(); + await act(async () => permission.resolve(device.stream)); + assert.equal(device.track.stops, 1); assert.equal(Recorder.instances.length, 0); await idle(); +}); + +test('recording, final data, HTTP body, and transcript delivery stay admitted until each actually settles', async () => { + const body = deferred<{ text: string }>(); const delivered = deferred(); let delivering = false; + const received: Array<[string, boolean | undefined]> = []; let url = ''; + globalThis.fetch = async (input) => { url = String(input); return { ok: true, json: () => body.promise } as Response; }; + const view = voice(async (text, send) => { received.push([text, send]); delivering = true; await delivered.promise; }); + const recorder = await start(view); await busy(); + assert.equal(recorder.stops, 0, 'freeze never stops an accepted recording'); + act(() => view.result.current.stop({ send: true })); + assert.equal(view.result.current.state, 'stopping'); await busy(); + let completion!: Promise; + act(() => { completion = recorder.complete(); }); + await waitFor(() => assert.equal(url, '/api/voice/transcribe')); await busy(); + await act(async () => body.resolve({ text: 'final voice' })); + await waitFor(() => assert.equal(delivering, true)); await busy(); + await act(async () => { delivered.resolve(); await completion; }); + assert.deepEqual(received, [['final voice', true]]); + assert.equal(view.result.current.state, 'idle'); assert.equal(device.track.stops, 1); await idle(); +}); + +test('unmount flushes the last recorded audio and retains an external transcription through settlement', async () => { + localStorage.setItem('voiceConfig', JSON.stringify({ baseUrl: 'https://voice.fixture/v1' })); + const response = deferred(); const received: Array<[string, boolean | undefined]> = []; let requested = ''; + globalThis.fetch = async (url) => { requested = String(url); return response.promise; }; + const view = voice((text, send) => { received.push([text, send]); }); + const recorder = await start(view); view.unmount(); + assert.equal(recorder.stops, 1); assert.equal(device.track.stops, 1); await busy(); + const completing = recorder.complete(); + await waitFor(() => assert.equal(requested, 'https://voice.fixture/v1/audio/transcriptions')); await busy(); + response.resolve(new Response('{"text":"keep after departure"}')); await completing; + assert.deepEqual(received, [['keep after departure', false]]); await idle(); +}); + +test('failed stop requests and recorder errors do not fabricate terminal settlement', async () => { + const errors: string[] = []; const view = voice(() => {}, (message) => { errors.push(message); }); + const recorder = await start(view); recorder.stopFailure = true; + act(() => view.result.current.stop()); await busy(); + assert.equal(view.result.current.state, 'recording'); assert.match(errors[0], /could not stop/); + recorder.stopFailure = false; + act(() => recorder.onerror?.()); await busy(); + await act(async () => recorder.complete()); await idle(); +}); + +for (const failure of ['permission', 'start', 'short', 'network-abort', 'body', 'empty', 'delivery'] as const) { + test(`${failure} failure releases the actual voice owner without leaving a permanent busy placeholder`, async () => { + const errors: string[] = []; + if (failure === 'permission') acquire = async () => { throw new DOMException('denied', 'NotAllowedError'); }; + if (failure === 'start') Recorder.startFailure = true; + if (failure === 'network-abort') globalThis.fetch = async () => { throw new DOMException('network settled aborted', 'AbortError'); }; + if (failure === 'body') globalThis.fetch = async () => new Response('not JSON'); + if (failure === 'empty') globalThis.fetch = async () => new Response('{"text":""}'); + const view = voice(() => { if (failure === 'delivery') throw new Error('delivery failed'); }, (message) => { errors.push(message); }); + act(() => view.result.current.toggle()); + if (failure !== 'permission' && failure !== 'start') { + await waitFor(() => assert.equal(view.result.current.state, 'recording')); + act(() => view.result.current.stop()); + await act(async () => Recorder.instances.at(-1)!.complete(failure === 'short' ? new Blob(['short']) : audio())); + } + await waitFor(() => assert.equal(view.result.current.state, 'idle')); + assert.equal(errors.length, 1); await idle(); + }); +} + +test('late voice output belongs to the original composer route, including an empty owner unmounted before delivery', async () => { + const records = new Map(); + const repository: ComposerDraftRepository = { + async load(route) { return records.get(composerRouteKey(route)) ?? null; }, + async save(value, revision) { records.set(composerRouteKey(value), { ...boundedComposerDraft(value).draft, revision: revision + 1 }); return revision + 1; }, + }; + const body = deferred(); let requested = false; const sends: unknown[] = []; + globalThis.fetch = async (url) => { + if (String(url).endsWith('/voice/transcribe')) { requested = true; return body.promise; } + return new Response('[]'); + }; + const view = renderHook(({ route }: { route: string }) => { + const c = useChatComposerState({ draftRepository: repository, + selectedProject: { projectId: route, fullPath: '/fixture', displayName: route, origin: 'explicit' }, selectedSession: null, + currentSessionId: null, gjcModel: 'fixture', isLoading: false, canAbortSession: false, tokenBudget: null, + sendMessage: (message) => { sends.push(message); }, addMessage() {}, scrollToBottom() {}, setIsUserScrolledUp() {}, setPendingPermissionRequests() {}, + }); + return { c, v: useVoiceInput(c.handleVoiceTranscript, undefined, route) }; + }, { initialProps: { route: 'A' } }); + await waitFor(() => assert.equal(view.result.current.c.draftPersistence.phase, 'saved')); + act(() => view.result.current.v.toggle()); + await waitFor(() => assert.equal(view.result.current.v.state, 'recording')); + const recorder = Recorder.instances[0]; + act(() => view.result.current.v.stop({ send: true })); + let completing!: Promise; act(() => { completing = recorder.complete(); }); + await waitFor(() => assert.equal(requested, true)); + view.rerender({ route: 'B' }); + act(() => view.result.current.c.setInput('B draft stays B')); + await waitFor(() => assert.equal(view.result.current.c.draftPersistence.phase, 'saved')); + view.unmount(); await busy(); + await act(async () => { body.resolve(new Response('{"text":"voice for A"}')); await completing; }); + await waitFor(() => assert.equal(records.get(JSON.stringify(['A', null]))?.input, 'voice for A')); + assert.equal(records.get(JSON.stringify(['B', null]))?.input, 'B draft stays B'); + assert.deepEqual(sends, []); await idle(); +}); diff --git a/src/components/chat/hooks/useVoiceInput.ts b/src/components/chat/hooks/useVoiceInput.ts index 7126eef1..23ac3758 100644 --- a/src/components/chat/hooks/useVoiceInput.ts +++ b/src/components/chat/hooks/useVoiceInput.ts @@ -1,5 +1,6 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; +import { beginComposerOperation } from '../../../shared/composerFreeze'; import { transcribeVoice } from '../../../utils/voiceApi'; const RECORDING_TYPES = [ @@ -29,130 +30,130 @@ function extensionFor(type: string): string { return type.includes('ogg') ? 'ogg' : 'webm'; } -export type VoiceInputState = 'idle' | 'recording' | 'transcribing'; +export type VoiceInputState = 'idle' | 'starting' | 'recording' | 'stopping' | 'transcribing'; +type Recording = { phase: VoiceInputState; stop(send?: boolean): void; depart(): void }; export function useVoiceInput( - onTranscript: (text: string, send?: boolean) => void, + onTranscript: (text: string, send?: boolean) => void | Promise, onError?: (msg: string) => void, + ownerKey = '', ) { const [state, setState] = useState('idle'); - const recording = useRef({ - recorder: null as MediaRecorder | null, - stream: null as MediaStream | null, - chunks: [] as Blob[], - starting: false, - disposed: false, - sendWhenDone: false, - }); - - const releaseMicrophone = useCallback(() => { - const { stream } = recording.current; - stream?.getTracks().forEach((track) => track.stop()); - recording.current.stream = null; - }, []); + const recording = useRef(null); + const owner = useRef(null); useEffect(() => { - const session = recording.current; - session.disposed = false; - + const visit = {}; + owner.current = visit; + setState('idle'); return () => { - session.disposed = true; - session.starting = false; - session.stream?.getTracks().forEach((track) => track.stop()); - session.stream = null; - session.recorder = null; + owner.current = null; + const prior = recording.current; + recording.current = null; + // Stop capturing on departure, but retain the operation through the + // recorder's final data/stop events and any already accepted HTTP work. + // A pending permission request cannot be cancelled: its promise owns it. + prior?.depart(); }; - }, []); - - const begin = useCallback(async () => { - const session = recording.current; - if (session.starting || (session.recorder && session.recorder.state !== 'inactive')) return; + }, [ownerKey]); + + async function begin() { + const visit = owner.current; + if (!visit || recording.current) return; + const finishOperation = beginComposerOperation('voice'); + if (!finishOperation) return; + let stream: MediaStream | null = null; + let recorder: MediaRecorder | null = null; + let chunks: Blob[] = []; + let sendWhenDone = false; + let finished = false; + const session: Recording = { phase: 'starting', stop, depart() { stop(); releaseMicrophone(); } }; + const isVisible = () => owner.current === visit && recording.current === session; + const report = (message: string) => { if (isVisible()) onError?.(message); }; + const releaseMicrophone = () => { stream?.getTracks().forEach((track) => track.stop()); stream = null; }; + const phase = (next: VoiceInputState) => { session.phase = next; if (isVisible()) setState(next); }; + const finish = () => { + if (finished) return; + finished = true; + releaseMicrophone(); + if (recorder) { recorder.ondataavailable = null; recorder.onstop = null; recorder.onerror = null; } + chunks = []; + phase('idle'); + if (recording.current === session) recording.current = null; + finishOperation(); + }; + function stop(send = false) { + if (finished || session.phase !== 'recording' || !recorder || recorder.state === 'inactive') return; + sendWhenDone = send; + phase('stopping'); + try { recorder.stop(); } catch (error) { + // A failed stop request is not evidence that recording finished. Keep + // its actual owner, report the failure, and allow a real Stop retry. + phase('recording'); + report(`Recording could not stop: ${error instanceof Error ? error.message : String(error)}`); + } + } + recording.current = session; + phase('starting'); - session.starting = true; try { - const stream = await navigator.mediaDevices.getUserMedia({ + stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true }, }); - if (recording.current.disposed) { - stream.getTracks().forEach((track) => track.stop()); + if (!isVisible()) { + finish(); return; } - - recording.current.stream = stream; const type = recordingType(); - const recorder = type ? new MediaRecorder(stream, { mimeType: type }) : new MediaRecorder(stream); - recording.current.recorder = recorder; - recording.current.chunks = []; + recorder = type ? new MediaRecorder(stream, { mimeType: type }) : new MediaRecorder(stream); recorder.ondataavailable = ({ data }) => { - if (data.size > 0) recording.current.chunks.push(data); + if (!finished && data.size > 0) chunks.push(data); }; recorder.onstop = async () => { + if (finished || session.phase === 'transcribing') return; releaseMicrophone(); - if (recording.current.disposed) return; - - const send = recording.current.sendWhenDone; - recording.current.sendWhenDone = false; - const recordedType = recorder.mimeType || 'audio/webm'; - const audio = new Blob(recording.current.chunks, { type: recordedType }); - - if (audio.size < 800) { - setState('idle'); - onError?.('Recording too short'); - return; - } - - setState('transcribing'); + phase('transcribing'); + const recordedType = recorder?.mimeType || 'audio/webm'; + const audio = new Blob(chunks, { type: recordedType }); try { + if (audio.size < 800) { report('Recording too short'); return; } const response = await transcribeVoice(audio, `recording.${extensionFor(recordedType)}`); if (!response.ok) throw new Error(`transcribe ${response.status}`); const payload = await response.json(); - if (recording.current.disposed) return; const transcript = String(payload?.text || '').trim(); - if (transcript) onTranscript(transcript, send); - else onError?.('No speech detected'); + // Capture the original delivery callback at begin(), not a newer + // route's callback. Offscreen completion saves input but never sends. + if (transcript) await onTranscript(transcript, isVisible() && sendWhenDone); + else report('No speech detected'); } catch (error) { - if (!recording.current.disposed) { - onError?.(`Transcription failed: ${error instanceof Error ? error.message : String(error)}`); - } + report(`Transcription failed: ${error instanceof Error ? error.message : String(error)}`); } finally { - if (!recording.current.disposed) setState('idle'); + finish(); } }; + recorder.onerror = () => { + report('Recording failed.'); + // MediaRecorder supplies final data and a stop event after an error. + // Do not release admission before that terminal event arrives. + stop(); + }; recorder.start(); - setState('recording'); + phase('recording'); } catch (error) { - recording.current.recorder = null; - releaseMicrophone(); - if (recording.current.disposed) return; - const microphoneError = error as { name?: string; message?: string }; let message = `Mic error: ${microphoneError?.message || error}`; if (microphoneError?.name === 'NotAllowedError') message = 'Microphone access denied.'; else if (microphoneError?.name === 'NotFoundError') message = 'No microphone found.'; - onError?.(message); - setState('idle'); - } finally { - recording.current.starting = false; + try { report(message); } finally { finish(); } } - }, [onError, onTranscript, releaseMicrophone]); - - const stop = useCallback((opts?: { send?: boolean }) => { - const recorder = recording.current.recorder; - if (!recorder || recorder.state === 'inactive') return; - - recording.current.sendWhenDone = opts?.send ?? false; - recorder.stop(); - }, []); - - const toggle = useCallback(() => { - if (state === 'idle') begin(); - else if (state === 'recording') stop(); - }, [begin, state, stop]); + } + const stop = (opts?: { send?: boolean }) => recording.current?.stop(opts?.send); + const toggle = () => { if (!recording.current) void begin(); else recording.current.stop(); }; return { state, toggle, stop }; } diff --git a/src/components/chat/tests/appUiCommands.test.tsx b/src/components/chat/tests/appUiCommands.test.tsx index c6c653a5..79984287 100644 --- a/src/components/chat/tests/appUiCommands.test.tsx +++ b/src/components/chat/tests/appUiCommands.test.tsx @@ -85,6 +85,9 @@ test('getTuiOnlyCommandNotice covers /retry and skips app/unknown commands', () }); function captureComposer(sentMessages: unknown[], addedMessages: unknown[]) { + // Each command case starts with its own empty draft; pending confirmations + // now deliberately survive instead of clearing the previous case's text. + storage.clear(); let composer: ReturnType | undefined; function Capture() { diff --git a/src/components/chat/tests/commandGate.test.tsx b/src/components/chat/tests/commandGate.test.tsx index d103ae07..f92e31ed 100644 --- a/src/components/chat/tests/commandGate.test.tsx +++ b/src/components/chat/tests/commandGate.test.tsx @@ -6,6 +6,7 @@ import { renderToStaticMarkup } from 'react-dom/server'; import type { Project } from '../../../types/app'; import { useChatComposerState, type PendingCommandGate } from '../hooks/useChatComposerState'; +import { draftInputKey } from '../utils/chatStorage'; /* * The confirmation gate. @@ -73,6 +74,7 @@ function captureComposer( * the change callback rather than the returned state. */ async function submit(text: string) { + storage.clear(); const sentMessages: unknown[] = []; const addedMessages: unknown[] = []; const gates: Array = []; @@ -102,12 +104,14 @@ test('a destructive form sends nothing and raises a gate instead', async () => { } }); -test('the gate holds the text out of the input so Enter cannot resubmit it', async () => { +test('the gate retains its draft but repeated Enter cannot bypass confirmation', async () => { const { composer, sentMessages, gate } = await submit('/session delete'); assert.deepEqual(sentMessages, []); - assert.equal(composer.input, ''); + assert.equal(storage.get(draftInputKey(selectedProject.projectId, 'session-1')), '/session delete'); assert.equal(gate?.text, '/session delete'); + await composer.handleSubmit(submitEvent); + assert.deepEqual(sentMessages, []); }); test('an unclassified form gates rather than running unannounced', async () => { diff --git a/src/components/chat/tests/fixtures/ComposerDraftPersistenceHarness.tsx b/src/components/chat/tests/fixtures/ComposerDraftPersistenceHarness.tsx new file mode 100644 index 00000000..7504e2c3 --- /dev/null +++ b/src/components/chat/tests/fixtures/ComposerDraftPersistenceHarness.tsx @@ -0,0 +1,58 @@ +import { useEffect, useState } from 'react'; + +import type { Project, ProjectSession } from '../../../../types/app'; +import { useChatComposerState } from '../../hooks/useChatComposerState'; +import type { ComposerDraftRepository } from '../../utils/composerDraftStorage'; + +// Test-only surface, not routed by the app. Both DOM tests and isolated browser +// QA exercise the production composer hook, not a parallel persistence UI. +export function ComposerDraftPersistenceHarness({ repository }: { repository?: ComposerDraftRepository }) { + const [projectId, setProjectId] = useState('draft-qa-project-a'); + const [conversation, setConversation] = useState('draft-qa-session-a'); + const [busy, setBusy] = useState(true); + const [sent, setSent] = useState(0); + const [bodies, setBodies] = useState(''); + const project: Project = { projectId, fullPath: '/isolated-qa', displayName: projectId, origin: 'explicit' }; + const composer = useChatComposerState({ + draftRepository: repository, selectedProject: project, + selectedSession: { id: conversation, __provider: 'gjc' } as ProjectSession, + currentSessionId: null, gjcModel: 'fixture/model', isLoading: busy, + canAbortSession: false, tokenBudget: null, + sendMessage: () => { setSent((n) => n + 1); return true; }, + scrollToBottom() {}, addMessage() {}, setIsUserScrolledUp() {}, setPendingPermissionRequests() {}, + }); + useEffect(() => { + let current = true; + const files = [...composer.attachedImages, ...composer.queuedDrafts.flatMap((item) => item.images)]; + void Promise.all(files.map(async (file) => `${file instanceof File}:${file.name}:${file.type}:${file.lastModified}:${await file.text()}`)) + .then((text) => { if (current) setBodies(text.join('\n')); }); + return () => { current = false; }; + }, [composer.attachedImages, composer.queuedDrafts]); // File bytes are shown for synthetic fixtures only. + return
+

Isolated composer draft persistence QA

+ + + +

{composer.draftPersistence.phase}:{composer.draftPersistence.reason ?? 'none'}

+

Ready: {String(composer.draftReady)}; sends: {sent}

+
+