From 31cee9b79a8f3338ef5e945904fa7062049226b4 Mon Sep 17 00:00:00 2001 From: devswha <25837994+devswha@users.noreply.github.com> Date: Mon, 7 Sep 2026 23:37:07 +0900 Subject: [PATCH 01/15] docs(updater): map safe restart admission ownership --- docs/DESKTOP-UPDATE-ADMISSION.md | 205 +++++++++++++++++++++++++++++++ 1 file changed, 205 insertions(+) create mode 100644 docs/DESKTOP-UPDATE-ADMISSION.md diff --git a/docs/DESKTOP-UPDATE-ADMISSION.md b/docs/DESKTOP-UPDATE-ADMISSION.md new file mode 100644 index 0000000..d43a315 --- /dev/null +++ b/docs/DESKTOP-UPDATE-ADMISSION.md @@ -0,0 +1,205 @@ +# Desktop update admission: safe manual 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. From 96915697e3de2758c468e6bb561383d9e0f1c295 Mon Sep 17 00:00:00 2001 From: devswha <25837994+devswha@users.noreply.github.com> Date: Tue, 8 Sep 2026 00:20:52 +0900 Subject: [PATCH 02/15] feat(updater): add authenticated preparation controls --- docs/MACOS-UPDATER-HANDOFF.md | 97 +++ docs/V2-SESSION-HANDOFF.md | 8 + docs/images/updater/about-preparation-qa.png | Bin 0 -> 66415 bytes server/app-factory.js | 12 + server/index.js | 2 + .../desktop-restart-authority.test.ts | 532 ++++++++++++++ server/services/desktop-restart-authority.ts | 372 ++++++++++ server/services/desktop-update-http.test.js | 63 ++ server/services/desktop-update-relay.test.ts | 391 +++++++++++ server/services/desktop-update-relay.ts | 184 +++++ shared/desktopUpdateProtocol.ts | 73 ++ shared/fixtures/desktop-update-status.json | 15 + shared/releaseVersion.js | 32 + src-tauri/Cargo.lock | 11 + src-tauri/Cargo.toml | 1 + src-tauri/src/main.rs | 8 + src-tauri/src/supervisor.rs | 45 ++ src-tauri/src/updater.rs | 137 +++- src-tauri/src/updater_bridge.rs | 662 ++++++++++++++++++ .../view/tabs/AboutTab.dom.bun.test.tsx | 278 ++++++++ .../settings/view/tabs/AboutTab.tsx | 18 +- .../settings/view/tabs/DesktopUpdatePanel.tsx | 116 +++ src/hooks/useDesktopUpdate.dom.bun.test.tsx | 320 +++++++++ src/hooks/useDesktopUpdate.ts | 167 +++++ src/hooks/useVersionCheck.dom.bun.test.tsx | 251 +++++++ src/hooks/useVersionCheck.test.ts | 199 ++++++ src/hooks/useVersionCheck.ts | 135 +++- src/i18n/locales/de/settings.json | 54 ++ src/i18n/locales/en/settings.json | 54 ++ src/i18n/locales/fr/settings.json | 54 ++ src/i18n/locales/it/settings.json | 54 ++ src/i18n/locales/ja/settings.json | 54 ++ src/i18n/locales/ko/settings.json | 54 ++ src/i18n/locales/ru/settings.json | 54 ++ src/i18n/locales/tr/settings.json | 54 ++ src/i18n/locales/zh-CN/settings.json | 54 ++ src/i18n/locales/zh-TW/settings.json | 54 ++ 37 files changed, 4630 insertions(+), 39 deletions(-) create mode 100644 docs/images/updater/about-preparation-qa.png create mode 100644 server/services/desktop-restart-authority.test.ts create mode 100644 server/services/desktop-restart-authority.ts create mode 100644 server/services/desktop-update-http.test.js create mode 100644 server/services/desktop-update-relay.test.ts create mode 100644 server/services/desktop-update-relay.ts create mode 100644 shared/desktopUpdateProtocol.ts create mode 100644 shared/fixtures/desktop-update-status.json create mode 100644 shared/releaseVersion.js create mode 100644 src-tauri/src/updater_bridge.rs create mode 100644 src/components/settings/view/tabs/AboutTab.dom.bun.test.tsx create mode 100644 src/components/settings/view/tabs/DesktopUpdatePanel.tsx create mode 100644 src/hooks/useDesktopUpdate.dom.bun.test.tsx create mode 100644 src/hooks/useDesktopUpdate.ts create mode 100644 src/hooks/useVersionCheck.dom.bun.test.tsx create mode 100644 src/hooks/useVersionCheck.test.ts diff --git a/docs/MACOS-UPDATER-HANDOFF.md b/docs/MACOS-UPDATER-HANDOFF.md index b73c123..c317e31 100644 --- a/docs/MACOS-UPDATER-HANDOFF.md +++ b/docs/MACOS-UPDATER-HANDOFF.md @@ -1,5 +1,102 @@ # macOS 자동 업데이트 — 남은 작업 인계 +## 추가 구현: 메인 화면 준비 제어와 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/V2-SESSION-HANDOFF.md b/docs/V2-SESSION-HANDOFF.md index 8c3e964..176bbd3 100644 --- a/docs/V2-SESSION-HANDOFF.md +++ b/docs/V2-SESSION-HANDOFF.md @@ -26,6 +26,14 @@ not changed. The older session records below are historical. ## TL;DR +- **Unreleased updater preparation controls** now connect About to a native-owned, + main-view-bound relay, with endpoint HMAC authentication and kernel peer-PID + checks. The web notification fallback handles beta SemVer correctly. A tested + reversible restart-admission primitive exists but is not wired to all producers. + Actual install/restart still rejects; G0/G3 and final signed A→B qualification, + macOS 13 execution and production key custody remain pending. No updater-enabled + release is published. See `MACOS-UPDATER-HANDOFF.md` for current evidence and gaps. + - **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-preparation-qa.png b/docs/images/updater/about-preparation-qa.png new file mode 100644 index 0000000000000000000000000000000000000000..3c7dd934ec58088d321a932ac68391c9175c48d1 GIT binary patch literal 66415 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 literal 0 HcmV?d00001 diff --git a/server/app-factory.js b/server/app-factory.js index 2677a6e..e2717c1 100644 --- a/server/app-factory.js +++ b/server/app-factory.js @@ -4,6 +4,7 @@ import cors from 'cors'; import express from 'express'; import { parseAllowedHosts } from '../shared/networkHosts.js'; +import { isDesktopUpdateCommand } from '../shared/desktopUpdateProtocol.js'; import { createDesktopAuth, DESKTOP_BOOTSTRAP_PATH } from './middleware/desktop-auth.js'; import { createWebSocketServer } from './modules/websocket/index.js'; @@ -26,6 +27,7 @@ export function createGjcAppFactory({ chat, shell, browser = undefined, + desktopUpdateRelay = undefined, }) { orchestrator.deps.broadcast = (jobId, event) => { try { projection.publish(jobId, event); } catch { /* Durable replay recovers isolated websocket fan-out failures. */ } @@ -89,6 +91,16 @@ 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 (!isDesktopUpdateCommand(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) => response.json(snapshot)) + .catch((error) => 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); app.use('/api/gjc', authenticateGjcRoute, createGjcJobsRouter({ authority, orchestrator, gitService })); diff --git a/server/index.js b/server/index.js index 79d3525..e191db0 100755 --- a/server/index.js +++ b/server/index.js @@ -49,6 +49,7 @@ 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 { isWorkspaceRoot } from './modules/projects/index.js'; import projectModuleRoutes from './modules/projects/projects.routes.js'; import notificationRoutes from './modules/notifications/notifications.routes.js'; @@ -131,6 +132,7 @@ function steerGjcChatRun(runId, message) { } const { app, server, wss } = createGjcAppFactory({ + desktopUpdateRelay: new DesktopUpdateRelay(), authority: gjcJobAuthority, orchestrator: gjcJobOrchestrator, gitService: getProductionGjcJobGitService( diff --git a/server/services/desktop-restart-authority.test.ts b/server/services/desktop-restart-authority.test.ts new file mode 100644 index 0000000..adf07b4 --- /dev/null +++ b/server/services/desktop-restart-authority.test.ts @@ -0,0 +1,532 @@ +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(); +}); + +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 0000000..e21c478 --- /dev/null +++ b/server/services/desktop-restart-authority.ts @@ -0,0 +1,372 @@ +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; +}; + +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; + committing?: Promise; + resolveCommit?: (result: DesktopRestartCommitResult) => void; +}; + +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 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); + } + + 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' }); + 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; + const deadline = this.now() + this.readTimeoutMs; + 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); + 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 }): Promise { + this.expire(); + if (!input || !identifier(input.attemptId) || !identifier(input.epoch)) 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 = { ...input, sequence: ++this.sequence, prepareDeadline: this.now() + 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 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.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)]; + 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); + } + + 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-update-http.test.js b/server/services/desktop-update-http.test.js new file mode 100644 index 0000000..8e2f0d1 --- /dev/null +++ b/server/services/desktop-update-http.test.js @@ -0,0 +1,63 @@ +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, 503); +}); + +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 0000000..19d8e17 --- /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' }, { 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 0000000..3ec7ae0 --- /dev/null +++ b/server/services/desktop-update-relay.ts @@ -0,0 +1,184 @@ +import { createHmac, randomBytes, timingSafeEqual } 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 { isDesktopUpdateCommand, isDesktopUpdateSnapshot, type DesktopUpdateCommand, type DesktopUpdateSnapshot } from '../../shared/desktopUpdateProtocol.js'; + +const MAX_INIT_BYTES = 4096; +const MAX_RESPONSE_BYTES = 32 * 1024; +const REQUEST_TIMEOUT_MS = 2_000; +const MAX_PENDING = 4; +const secretPattern = /^[a-f0-9]{64}$/; +const initPrefix = 'GJC_DESKTOP_UPDATE_INIT '; +const CHALLENGE_DOMAIN = 'gajae-native-update-v1\0'; + +type Binding = { protocolVersion: 1; socket: string; secret: string; epoch: string }; +type Options = { + input?: Readable; + env?: NodeJS.ProcessEnv; + platform?: NodeJS.Platform; + pid?: number; + connect?: typeof connectSocket; +}; + +function authenticChallenge(value: Record, binding: Binding, nonce: string): boolean { + if (Object.keys(value).length !== 5 || value.protocolVersion !== 1 || value.kind !== 'challenge' + || value.epoch !== binding.epoch || value.nonce !== nonce + || typeof value.proof !== 'string' || !secretPattern.test(value.proof)) return false; + // The key is the UTF-8 initialization secret, NOT its hex-decoded bytes. + const expected = createHmac('sha256', binding.secret) + .update(`${CHALLENGE_DOMAIN}${binding.epoch}\0${nonce}`, 'utf8').digest(); + return timingSafeEqual(expected, Buffer.from(value.proof, 'hex')); +} + +/** 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); + + constructor(options: Options = {}) { + this.input = options.input ?? process.stdin; + this.connect = options.connect ?? connectSocket; + this.pid = options.pid ?? process.pid; + 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 + || typeof value.secret !== 'string' || !secretPattern.test(value.secret) + || typeof value.epoch !== 'string' || !secretPattern.test(value.epoch)) throw new Error(); + this.binding = value as Binding; + this.inputBuffer.fill(0); + this.inputBuffer = Buffer.alloc(0); + } catch { + this.retire(); + } + }; + + isAvailable(): boolean { return !this.retired && this.binding !== null; } + + retire(): void { + this.retired = true; + 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: DesktopUpdateCommand, view: string, origin: string): Promise { + const binding = this.binding; + if (this.retired || !binding || !isDesktopUpdateCommand(command)) return Promise.reject(new Error('updater_unavailable')); + if (typeof view !== 'string' || !secretPattern.test(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: DesktopUpdateCommand = command.action === 'setAutomatic' + ? { action: command.action, automatic: command.automatic } : { action: command.action }; + 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() + REQUEST_TIMEOUT_MS; + const finish = (error?: string, value?: DesktopUpdateSnapshot) => { + 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'), REQUEST_TIMEOUT_MS); + 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 (!authenticChallenge(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 && isDesktopUpdateSnapshot(frame.snapshot)) finish(undefined, frame.snapshot); + 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/shared/desktopUpdateProtocol.ts b/shared/desktopUpdateProtocol.ts new file mode 100644 index 0000000..eaaf4f9 --- /dev/null +++ b/shared/desktopUpdateProtocol.ts @@ -0,0 +1,73 @@ +/** 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' | 'restart' } + | { action: 'setAutomatic'; automatic: boolean }; + +export const DESKTOP_UPDATE_PHASES = ['disabled', 'idle', 'checking', '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; + 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 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'; + return Object.keys(value).length === 1 && (value.action === 'status' || value.action === 'check' || value.action === 'restart'); +} + +export function isDesktopUpdateSnapshot(value: unknown): value is DesktopUpdateSnapshot { + const keys = ['protocolVersion', 'phase', 'automatic', 'productVersion', 'desktopVersion', 'targetProductVersion', 'targetDesktopVersion', 'discoveryIncomplete', 'reason', 'installationAvailable', 'downloadedBytes', 'totalBytes', 'notes']; + if (!record(value) || Object.keys(value).length !== keys.length || !keys.every((key) => Object.hasOwn(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') 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); +} + +/** 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; +}; + +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 0000000..63d02bd --- /dev/null +++ b/shared/fixtures/desktop-update-status.json @@ -0,0 +1,15 @@ +{ + "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", + "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 0000000..ddfbb7b --- /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/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 6095b86..abe430d 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -640,6 +640,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -1036,6 +1037,7 @@ dependencies = [ "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 b7c2698..0446637 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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/src/main.rs b/src-tauri/src/main.rs index 9b7af49..7c5841c 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -28,6 +28,8 @@ mod updater_attempt; #[cfg(target_os = "macos")] mod updater_binding; #[cfg(target_os = "macos")] +mod updater_bridge; +#[cfg(target_os = "macos")] mod updater_discovery; #[cfg(target_os = "macos")] mod updater_manifest; @@ -193,6 +195,8 @@ fn route_startup_deep_links( } 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 { supervisor::restore_recovery(webview); } @@ -366,6 +370,8 @@ fn main() { #[cfg(target_os = "macos")] app.manage(updater::Preparation::default()); #[cfg(target_os = "macos")] + app.manage(updater_bridge::Bridge::default()); + #[cfg(target_os = "macos")] if let Some(profile) = app.try_state::() { profile.create_windows(app, &qa_windows)?; } @@ -447,6 +453,8 @@ fn main() { } } tauri::RunEvent::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 diff --git a/src-tauri/src/supervisor.rs b/src-tauri/src/supervisor.rs index f06c8b0..fcb7214 100644 --- a/src-tauri/src/supervisor.rs +++ b/src-tauri/src/supervisor.rs @@ -203,6 +203,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(); @@ -414,6 +416,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") { @@ -505,6 +512,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 +544,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; @@ -689,6 +713,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 278dd30..844b882 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. +//! Preparation-only updater owner. The authenticated main-view bridge exposes +//! status, consent and checks. Installation, restart and attempt resolution +//! remain gated. No official plugin install/download API is called here. use std::{ future::Future, sync::{ @@ -48,6 +48,7 @@ pub enum Phase { #[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, @@ -58,11 +59,15 @@ pub struct Snapshot { 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"), @@ -72,6 +77,9 @@ impl Default for Snapshot { discovery_incomplete: true, reason: None, installation_available: false, + downloaded_bytes: None, + total_bytes: None, + notes: None, } } } @@ -119,13 +127,43 @@ 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()) + } +} + 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; @@ -215,11 +253,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 @@ -244,9 +292,16 @@ impl Coordinator { 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) @@ -279,6 +334,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") @@ -591,6 +651,16 @@ async fn prepare( } } 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,6 +672,13 @@ 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(); @@ -748,6 +825,11 @@ fn eligible_cached(manifest: &Manifest, os: &str) -> Result fn set_target(snapshot: &mut Snapshot, manifest: &Manifest) { 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"); } @@ -790,6 +872,45 @@ mod tests { } } + #[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); + } + + #[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(); diff --git a/src-tauri/src/updater_bridge.rs b/src-tauri/src/updater_bridge.rs new file mode 100644 index 0000000..2145d66 --- /dev/null +++ b/src-tauri/src/updater_bridge.rs @@ -0,0 +1,662 @@ +//! Main-view-bound preparation bridge. No installation or shutdown entrypoint. +//! 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, + }, + time::{Duration, Instant}, +}; + +use hmac::{Hmac, Mac}; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use tauri::{AppHandle, Manager}; + +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 = "setAutomatic")] + SetAutomatic { automatic: bool }, + #[serde(rename = "restart")] + Restart {}, +} + +#[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, +} + +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, +} + +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); + } +} + +#[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, + }); + #[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(mut stream: UnixStream, run: &Run, app: &AppHandle) { + 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(request) = read_frame::(&mut stream, deadline) else { + return; + }; + // This closure runs INSIDE the coordinator's command serialization lock, + // immediately before the operation. Retirement and later mutation sequence + // claims cannot be overtaken by a queued old preference write. + let admit = || { + !app.state::() + .is_shutting_down() + && run + .authority + .lock() + .is_ok_and(|mut authority| authority.admit(&request, peer)) + }; + let updater = app.state::(); + let result = match &request.command { + Command::Status {} => updater.snapshot(admit), + Command::Check {} => updater.manual_check(admit), + Command::SetAutomatic { automatic } => updater.set_automatic(*automatic, admit), + Command::Restart {} => { + if admit() { + Err("updater_installation_unavailable") + } else { + Err("updater_unauthorized") + } + } + }; + 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::Finished + || run.retired.load(Ordering::Acquire) + || payload.url().scheme() != "http" + || payload.url().host_str() != Some("127.0.0.1") + || payload.url().path().starts_with("/desktop/bootstrap") + || !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"); + format!( + r#"(()=>{{const token={token},origin={origin};const request=async(command)=>{{if(location.origin!==origin)throw Error('updater_unauthorized');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();if(!response.ok)throw Error(data.error||'updater_unavailable');return data;}};Object.defineProperty(window,'__GJC_DESKTOP_UPDATE__',{{configurable:true,value:Object.freeze({{protocolVersion:1,request}})}});window.dispatchEvent(new Event('gajae:desktop-update-ready'));}})();"# + ) +} + +#[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 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"}"#, + ] { + assert!(serde_json::from_str::(input).is_err()); + } + } + #[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/components/settings/view/tabs/AboutTab.dom.bun.test.tsx b/src/components/settings/view/tabs/AboutTab.dom.bun.test.tsx new file mode 100644 index 0000000..ea6c618 --- /dev/null +++ b/src/components/settings/view/tabs/AboutTab.dom.bun.test.tsx @@ -0,0 +1,278 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { afterEach, test } from 'node:test'; + +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { createInstance } from 'i18next'; +import { I18nextProvider } from 'react-i18next'; + +import { version } from '../../../../../package.json'; +import { DESKTOP_UPDATE_BRIDGE_EVENT, DESKTOP_UPDATE_BRIDGE_NAME, DESKTOP_UPDATE_PHASES, type DesktopUpdateCommand, type DesktopUpdateSnapshot } from '../../../../../shared/desktopUpdateProtocol'; +import english from '../../../../i18n/locales/en/settings.json'; +import korean from '../../../../i18n/locales/ko/settings.json'; + +import AboutTab from './AboutTab'; + +const futureWebVersion = `${Number(version.split('.')[0]) + 1}.0.0`; +const retiredWebVersion = `${Number(version.split('.')[0]) + 2}.0.0`; + +const globals = window as unknown as Record; +const originalInjection = Object.getOwnPropertyDescriptor(window, DESKTOP_UPDATE_BRIDGE_NAME); +const originalFetch = globalThis.fetch; +afterEach(() => { + cleanup(); + globalThis.fetch = originalFetch; + if (originalInjection) Object.defineProperty(window, DESKTOP_UPDATE_BRIDGE_NAME, originalInjection); + else delete globals[DESKTOP_UPDATE_BRIDGE_NAME]; +}); + +function native(extra: Partial = {}): DesktopUpdateSnapshot { + return { protocolVersion: 1, phase: 'idle', automatic: true, productVersion: '2.0.0-beta.10', + desktopVersion: '0.2.4', targetProductVersion: null, targetDesktopVersion: null, + discoveryIncomplete: false, reason: null, installationAvailable: false, + downloadedBytes: null, totalBytes: null, notes: null, ...extra }; +} +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 mount(language = 'en') { + const i18n = createInstance(); + await i18n.init({ lng: language, fallbackLng: 'en', resources: { en: { settings: english }, ko: { settings: korean } }, interpolation: { escapeValue: false } }); + const view = render(); + await act(async () => {}); + return { ...view, i18n }; +} +function inject(request: (command: DesktopUpdateCommand) => Promise) { + globals[DESKTOP_UPDATE_BRIDGE_NAME] = { protocolVersion: 1, request }; +} +async function replace(snapshot: DesktopUpdateSnapshot) { + inject(async () => snapshot); + await act(async () => { window.dispatchEvent(new Event(DESKTOP_UPDATE_BRIDGE_EVENT)); }); +} + +test('ordinary web retains notification-only About with no desktop controls or credential inputs', async () => { + delete globals[DESKTOP_UPDATE_BRIDGE_NAME]; + let calls = 0; + globalThis.fetch = async () => { + calls += 1; + return new Response(JSON.stringify([{ tag_name: `v${futureWebVersion}`, draft: false, prerelease: false }])); + }; + await mount(); + await waitFor(() => assert.equal(calls, 1)); + assert.ok(screen.getByRole('link', { name: english.about.updateAvailable.replace('{{version}}', futureWebVersion) })); + assert.equal(screen.queryByText(english.desktopUpdate.title), null); + assert.equal(screen.queryByRole('checkbox'), null); + assert.equal(screen.queryByRole('button'), null); + assert.equal(document.querySelector('input[type="password"]'), null); +}); + +test('injected but rejected/unvalidated bridge exposes no settings or install authority and never fetches GitHub', async () => { + let calls = 0; + globalThis.fetch = async () => { calls += 1; return new Response('[]'); }; + inject(async () => { throw new Error('private capability must not be rendered'); }); + const view = await mount(); + assert.equal(calls, 0); + assert.equal(screen.queryByRole('checkbox'), null); + assert.equal(screen.queryByRole('button', { name: english.desktopUpdate.check }), null); + assert.equal(screen.queryByRole('button', { name: english.desktopUpdate.restart }), null); + assert.ok(screen.getByText(english.desktopUpdate.unconfirmed)); + assert.ok(screen.getByRole('button', { name: english.desktopUpdate.refresh })); + assert.equal(view.container.textContent?.includes('private capability'), false); + assert.ok(screen.getByText(english.desktopUpdate.unknownVersion)); +}); + +test('late native injection aborts web discovery; retirement never resumes a web latest claim', async () => { + delete globals[DESKTOP_UPDATE_BRIDGE_NAME]; + const waiting = deferred(); + let calls = 0; + let signal: AbortSignal | null = null; + globalThis.fetch = async (_url, init) => { calls += 1; signal = init?.signal as AbortSignal; return waiting.promise; }; + await mount(); + await replace(native({ productVersion: '3.0.0', desktopVersion: '0.9.0' })); + assert.equal((signal as AbortSignal | null)?.aborted, true); + await act(async () => { waiting.resolve(new Response(JSON.stringify([{ tag_name: `v${retiredWebVersion}`, draft: false, prerelease: false }]))); }); + assert.ok(screen.getByText('v3.0.0')); + assert.equal(screen.queryByRole('link', { name: english.about.updateAvailable.replace('{{version}}', retiredWebVersion) }), null); + delete globals[DESKTOP_UPDATE_BRIDGE_NAME]; + await act(async () => { window.dispatchEvent(new Event(DESKTOP_UPDATE_BRIDGE_EVENT)); }); + assert.equal(calls, 1); + assert.ok(screen.getByText(english.desktopUpdate.lastConfirmed)); + assert.equal((screen.getByRole('checkbox') as HTMLInputElement).disabled, true); + assert.ok(screen.getByText('v3.0.0'), 'retirement retains native version rather than the web bundle version'); +}); + +test('all native phases have truthful localized live status; preparation never claims installation', async () => { + inject(async () => native()); + await mount(); + for (const phase of DESKTOP_UPDATE_PHASES) { + await replace(native({ phase, discoveryIncomplete: true, reason: `native reason: ${phase}`, targetProductVersion: '2.0.0-beta.11', targetDesktopVersion: '0.2.5' })); + const status = screen.getByRole('status'); + assert.equal(status.getAttribute('aria-live'), 'polite'); + assert.ok(status.textContent?.includes(english.desktopUpdate.phases[phase])); + assert.ok(status.textContent?.includes(`native reason: ${phase}`)); + assert.ok(status.textContent?.includes(english.desktopUpdate.incomplete)); + assert.ok(screen.getByText(english.desktopUpdate.preparationOnly)); + assert.equal(screen.queryByRole('button', { name: english.desktopUpdate.restart }), null); + assert.ok(screen.getByText('0.2.5')); + if (phase === 'disabled') { + assert.equal((screen.getByRole('checkbox') as HTMLInputElement).disabled, true); + assert.equal((screen.getByRole('button', { name: english.desktopUpdate.check }) as HTMLButtonElement).disabled, true); + assert.equal((screen.getByRole('button', { name: english.desktopUpdate.refresh }) as HTMLButtonElement).disabled, false); + } + } + assert.equal((screen.getByRole('checkbox') as HTMLInputElement).disabled, true, 'recovery cannot change automatic settings'); +}); + +test('known and unknown download sizes preserve null progress without inventing percentages', async () => { + inject(async () => native({ phase: 'downloading' })); + await mount(); + assert.equal(screen.getByRole('progressbar').getAttribute('value'), null); + assert.ok(screen.getByText(english.desktopUpdate.unknownProgress)); + await replace(native({ phase: 'downloading', downloadedBytes: 50 })); + assert.equal(screen.getByRole('progressbar').getAttribute('value'), null); + assert.ok(screen.getByText('50 bytes downloaded; total size unknown.')); + await replace(native({ phase: 'downloading', downloadedBytes: 50, totalBytes: 100 })); + assert.equal(screen.getByRole('progressbar').getAttribute('value'), '50'); + assert.equal(screen.getByRole('progressbar').getAttribute('max'), '100'); + assert.ok(screen.getByText('50 / 100 bytes')); + await replace(native({ phase: 'downloading', downloadedBytes: 0, totalBytes: 0 })); + assert.equal(screen.getByRole('progressbar').getAttribute('value'), null, 'zero bytes do not imply completion'); +}); + +test('auto opt-out waits for native confirmation and a rejected setting never appears saved', async () => { + let waiting = deferred(); + let confirmed = native(); + const commands: DesktopUpdateCommand[] = []; + inject(async (command) => { commands.push(command); return command.action === 'status' ? confirmed : waiting.promise; }); + await mount(); + const checkbox = screen.getByRole('checkbox', { name: english.desktopUpdate.automatic }) as HTMLInputElement; + assert.equal(checkbox.checked, true); + fireEvent.click(checkbox); + await act(async () => {}); + assert.equal(checkbox.checked, true); + assert.equal(checkbox.disabled, true); + assert.ok(screen.getByText(english.desktopUpdate.confirmingSetting)); + await act(async () => { waiting.reject(new Error('persistence failed')); }); + assert.equal(checkbox.checked, true); + assert.ok(screen.getByText(english.desktopUpdate.lastConfirmed)); + await act(async () => { fireEvent.click(screen.getByRole('button', { name: english.desktopUpdate.refresh })); }); + waiting = deferred(); + fireEvent.click(checkbox); + await act(async () => {}); + confirmed = native({ automatic: false }); + await act(async () => { waiting.resolve(confirmed); }); + assert.equal(checkbox.checked, false); + assert.deepEqual(commands.filter((command) => command.action === 'setAutomatic'), [ + { action: 'setAutomatic', automatic: false }, { action: 'setAutomatic', automatic: false }, + ]); +}); + +test('manual checks work when automatic is off, errors retry checks and recovery only refreshes status', async () => { + const commands: DesktopUpdateCommand[] = []; + let snapshot = native({ automatic: false }); + inject(async (command) => { commands.push(command); return snapshot; }); + await mount(); + await act(async () => { fireEvent.click(screen.getByRole('button', { name: english.desktopUpdate.check })); }); + assert.deepEqual(commands.at(-1), { action: 'check' }); + snapshot = native({ phase: 'error', automatic: false, reason: 'Offline' }); + await act(async () => { window.dispatchEvent(new Event(DESKTOP_UPDATE_BRIDGE_EVENT)); }); + await act(async () => { fireEvent.click(screen.getByRole('button', { name: english.desktopUpdate.retry })); }); + assert.deepEqual(commands.at(-1), { action: 'check' }); + snapshot = native({ phase: 'recovery', reason: 'Native recovery required' }); + await act(async () => { window.dispatchEvent(new Event(DESKTOP_UPDATE_BRIDGE_EVENT)); }); + assert.equal((screen.getByRole('button', { name: english.desktopUpdate.check }) as HTMLButtonElement).disabled, true); + await act(async () => { fireEvent.click(screen.getByRole('button', { name: english.desktopUpdate.refresh })); }); + assert.deepEqual(commands.at(-1), { action: 'status' }); +}); + +test('restart appears only for native available+ready, with OS prompt explanation and no credential inputs', async () => { + const commands: DesktopUpdateCommand[] = []; + let snapshot = native({ phase: 'ready' }); + inject(async (command) => { commands.push(command); return snapshot; }); + await mount(); + assert.equal(screen.queryByRole('button', { name: english.desktopUpdate.restart }), null); + snapshot = native({ phase: 'deferred', installationAvailable: true }); + await act(async () => { window.dispatchEvent(new Event(DESKTOP_UPDATE_BRIDGE_EVENT)); }); + assert.equal(screen.queryByRole('button', { name: english.desktopUpdate.restart }), null); + snapshot = native({ phase: 'ready', installationAvailable: true }); + await act(async () => { window.dispatchEvent(new Event(DESKTOP_UPDATE_BRIDGE_EVENT)); }); + const restart = screen.getByRole('button', { name: english.desktopUpdate.restart }); + const description = restart.getAttribute('aria-describedby'); + assert.equal(document.getElementById(description ?? '')?.textContent, english.desktopUpdate.osPrompt); + assert.equal(document.querySelector('input:not([type="checkbox"])'), null); + act(() => restart.focus()); + assert.equal(document.activeElement === restart, true); + await act(async () => { fireEvent.click(restart); }); + assert.deepEqual(commands.at(-1), { action: 'restart' }); + assert.ok(screen.getByText(english.desktopUpdate.phases.ready), 'a successful request does not invent a restart phase'); +}); + +test('native notes, versions and reasons remain plaintext, bounded and keyboard focusable', async () => { + const text = '\n[Install](javascript:alert(1))'; + inject(async () => native({ phase: 'deferred', notes: text, reason: text, targetProductVersion: 'not HTML' })); + const view = await mount(); + assert.equal(view.container.querySelector('img, script, b'), null); + const notes = screen.getByRole('region', { name: english.desktopUpdate.notes }); + assert.equal(notes.textContent, text); + assert.equal(notes.getAttribute('tabindex'), '0'); + assert.ok(notes.className.includes('max-h-48')); + assert.ok(notes.className.includes('overflow-y-auto')); + assert.equal(notes.querySelector('a'), null); +}); + +test('English and Korean expose translated controls, progress, reasons and OS prompt instructions', async () => { + inject(async () => native({ phase: 'downloading', downloadedBytes: 50, totalBytes: null, reason: '작업 완료 대기 중' })); + const view = await mount('ko'); + assert.ok(screen.getByRole('heading', { name: korean.desktopUpdate.title })); + assert.ok(screen.getByRole('checkbox', { name: korean.desktopUpdate.automatic })); + assert.ok(screen.getByText('50바이트 다운로드됨 · 전체 크기 알 수 없음')); + assert.ok(screen.getByText('작업 완료 대기 중')); + await replace(native({ phase: 'ready', installationAvailable: true })); + assert.ok(screen.getByRole('button', { name: korean.desktopUpdate.restart })); + assert.ok(screen.getByText(korean.desktopUpdate.osPrompt)); + await act(async () => { await view.i18n.changeLanguage('en'); }); + assert.ok(screen.getByRole('button', { name: english.desktopUpdate.restart })); + assert.ok(screen.getByText(english.desktopUpdate.osPrompt)); +}); + +test('known native reason codes are localized while unknown reasons remain literal', async () => { + inject(async () => native({ phase: 'error' })); + const view = await mount(); + const keys = { + discovery_failed: 'discoveryFailed', cache_invalid: 'cacheInvalid', + preparation_cancelled: 'preparationCancelled', preferences_not_persisted: 'preferencesNotPersisted', + } as const; + for (const [language, translations] of [['en', english], ['ko', korean]] as const) { + await act(async () => { await view.i18n.changeLanguage(language); }); + for (const [reason, key] of Object.entries(keys)) { + await replace(native({ phase: 'error', reason })); + assert.ok(screen.getByText(translations.desktopUpdate.reasons[key])); + } + await replace(native({ phase: 'error', reason: 'future_native_reason' })); + assert.ok(screen.getByText('future_native_reason')); + await replace(native({ phase: 'error', reason: 'constructor' })); + assert.ok(screen.getByText('constructor')); + } +}); + +test('desktop update keys and interpolation placeholders have parity across all ten settings locales', () => { + function leaves(value: unknown, path = ''): Record { + if (typeof value === 'string') return { [path]: value }; + assert.ok(value && typeof value === 'object'); + return Object.assign({}, ...Object.entries(value).map(([key, item]) => leaves(item, `${path}.${key}`))); + } + const expected = leaves({ desktopUpdate: english.desktopUpdate, about: english.about }); + for (const locale of ['en', 'ko', 'de', 'fr', 'it', 'ja', 'ru', 'tr', 'zh-CN', 'zh-TW']) { + const file = new URL(`../../../../i18n/locales/${locale}/settings.json`, import.meta.url); + const translated = JSON.parse(readFileSync(file, 'utf8')); + const actual = leaves({ desktopUpdate: translated.desktopUpdate, about: translated.about }); + assert.deepEqual(Object.keys(actual).sort(), Object.keys(expected).sort(), locale); + for (const [key, text] of Object.entries(actual)) { + assert.ok(text.trim().length > 0, `${locale}${key}`); + assert.deepEqual(text.match(/\{\{\w+\}\}/g)?.sort() ?? [], expected[key].match(/\{\{\w+\}\}/g)?.sort() ?? [], `${locale}${key}`); + } + } +}); diff --git a/src/components/settings/view/tabs/AboutTab.tsx b/src/components/settings/view/tabs/AboutTab.tsx index 812209c..c3a2136 100644 --- a/src/components/settings/view/tabs/AboutTab.tsx +++ b/src/components/settings/view/tabs/AboutTab.tsx @@ -12,8 +12,11 @@ import { LICENSE_URL, RELEASES_URL, } from '../../../../constants/branding'; +import { useDesktopUpdate } from '../../../../hooks/useDesktopUpdate'; import { useVersionCheck } from '../../../../hooks/useVersionCheck'; +import DesktopUpdatePanel from './DesktopUpdatePanel'; + const GITHUB_MARK = 'M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z'; function GitHubIcon({ className }: { className?: string }) { @@ -32,11 +35,14 @@ function ExternalAnchor({ children, className, href }: ExternalAnchorProps) { export default function AboutTab() { const { t } = useTranslation('settings'); + const desktopUpdate = useDesktopUpdate(); const { updateAvailable, latestVersion, currentVersion, releaseInfo } = useVersionCheck( GITHUB_REPOSITORY_OWNER, GITHUB_REPOSITORY_NAME, + !desktopUpdate.bridgeActive, ); const releasesUrl = releaseInfo?.htmlUrl || RELEASES_URL; + const displayedVersion = desktopUpdate.bridgeActive ? desktopUpdate.snapshot?.productVersion : currentVersion; return (
@@ -44,8 +50,8 @@ export default function AboutTab() {
-
-
+
+
- v{currentVersion} + {displayedVersion ? `v${displayedVersion}` : t('desktopUpdate.unknownVersion')} {updateAvailable && latestVersion && ( - {t('apiKeys.version.updateAvailable', { version: latestVersion })} + {t('about.updateAvailable', { version: latestVersion })} )} @@ -74,6 +80,8 @@ export default function AboutTab() {
+ {desktopUpdate.bridgeActive && } + }; + +const REASON_KEYS: Record = { + discovery_failed: 'desktopUpdate.reasons.discoveryFailed', + cache_invalid: 'desktopUpdate.reasons.cacheInvalid', + preparation_cancelled: 'desktopUpdate.reasons.preparationCancelled', + preferences_not_persisted: 'desktopUpdate.reasons.preferencesNotPersisted', +}; + +export default function DesktopUpdatePanel({ update }: Props) { + const { t, i18n } = useTranslation('settings'); + const id = useId(); + const { snapshot, connected, pending, error, awaitingOperation } = update; + const locked = !connected || pending !== null || awaitingOperation; + const lifecycleLocked = snapshot && ['disabled', 'recovery', 'applying', 'restarting'].includes(snapshot.phase); + const statusOnly = snapshot && ['disabled', 'recovery'].includes(snapshot.phase); + const reason = snapshot?.reason; + const reasonKey = reason && Object.prototype.hasOwnProperty.call(REASON_KEYS, reason) ? REASON_KEYS[reason] : null; + const preparing = snapshot && ['checking', 'downloading', 'verifying'].includes(snapshot.phase); + const numbers = new Intl.NumberFormat(i18n.resolvedLanguage || i18n.language || 'en'); + const downloaded = snapshot?.downloadedBytes; + const total = snapshot?.totalBytes; + const determinate = downloaded != null && total != null && total > 0; + + return ( +
+

{t('desktopUpdate.title')}

+
+ {snapshot ? <> + {!connected &&

{t('desktopUpdate.lastConfirmed')}

} +

{t(`desktopUpdate.phases.${snapshot.phase}`)}

+ {reason &&

{reasonKey ? t(reasonKey) : reason}

} + {snapshot.discoveryIncomplete &&

{t('desktopUpdate.incomplete')}

} + :

{t(error ? 'desktopUpdate.unconfirmed' : 'desktopUpdate.connecting')}

} + {error &&

{t(`desktopUpdate.errors.${error}`)}

} + {pending === 'setAutomatic' &&

{t('desktopUpdate.confirmingSetting')}

} + {pending === 'restart' &&

{t('desktopUpdate.confirmingRestart')}

} +
+ + {snapshot && <> +
+
{t('desktopUpdate.productVersion')}
+
{snapshot.productVersion}
+
{t('desktopUpdate.desktopVersion')}
+
{snapshot.desktopVersion}
+ {snapshot.targetProductVersion !== null && <> +
{t('desktopUpdate.targetProductVersion')}
+
{snapshot.targetProductVersion}
+ } + {snapshot.targetDesktopVersion !== null && <> +
{t('desktopUpdate.targetDesktopVersion')}
+
{snapshot.targetDesktopVersion}
+ } +
+ {!snapshot.installationAvailable &&

{t('desktopUpdate.preparationOnly')}

} + {snapshot.phase === 'downloading' &&
+ +

+ {determinate + ? t('desktopUpdate.knownProgress', { downloaded: numbers.format(downloaded), total: numbers.format(total) }) + : downloaded != null && total === null + ? t('desktopUpdate.unknownTotal', { downloaded: numbers.format(downloaded) }) + : t('desktopUpdate.unknownProgress')} +

+
} +
+ +

{t('desktopUpdate.automaticHelp')}

+
+ } + +
+ {snapshot && } + {(error || snapshot?.phase === 'error' || statusOnly) && } + {snapshot?.installationAvailable && snapshot.phase === 'ready' && } +
+ {snapshot?.installationAvailable && snapshot.phase === 'ready' &&

{t('desktopUpdate.osPrompt')}

} + {snapshot?.notes &&
+

{t('desktopUpdate.notes')}

+
+ {snapshot.notes} +
+
} +
+ ); +} diff --git a/src/hooks/useDesktopUpdate.dom.bun.test.tsx b/src/hooks/useDesktopUpdate.dom.bun.test.tsx new file mode 100644 index 0000000..2eea651 --- /dev/null +++ b/src/hooks/useDesktopUpdate.dom.bun.test.tsx @@ -0,0 +1,320 @@ +import assert from 'node:assert/strict'; +import { afterEach, test } from 'node:test'; + +import { act, cleanup, renderHook } from '@testing-library/react'; + +import { + DESKTOP_UPDATE_BRIDGE_EVENT, DESKTOP_UPDATE_BRIDGE_NAME, + isDesktopUpdateCommand, type DesktopUpdateCommand, type DesktopUpdateSnapshot, +} from '../../shared/desktopUpdateProtocol'; + +import { useDesktopUpdate } from './useDesktopUpdate'; + +const globals = window as unknown as Record; +const originalInjection = Object.getOwnPropertyDescriptor(window, DESKTOP_UPDATE_BRIDGE_NAME); +const originalSetInterval = window.setInterval; +const originalClearInterval = window.clearInterval; +const originalSetTimeout = window.setTimeout; +const originalClearTimeout = window.clearTimeout; +afterEach(() => { + cleanup(); + if (originalInjection) Object.defineProperty(window, DESKTOP_UPDATE_BRIDGE_NAME, originalInjection); + else delete globals[DESKTOP_UPDATE_BRIDGE_NAME]; + window.setInterval = originalSetInterval; + window.clearInterval = originalClearInterval; + window.setTimeout = originalSetTimeout; + window.clearTimeout = originalClearTimeout; +}); + +function native(extra: Partial = {}): DesktopUpdateSnapshot { + return { protocolVersion: 1, phase: 'idle', automatic: true, productVersion: '2.0.0-beta.10', + desktopVersion: '0.2.4', targetProductVersion: null, targetDesktopVersion: null, + discoveryIncomplete: false, reason: null, installationAvailable: false, + downloadedBytes: null, totalBytes: null, notes: null, ...extra }; +} +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 }; +} +function bridge(request: (command: DesktopUpdateCommand) => Promise) { + globals[DESKTOP_UPDATE_BRIDGE_NAME] = { protocolVersion: 1, request }; +} +const flush = async () => { await act(async () => {}); }; +const changed = () => act(() => { window.dispatchEvent(new Event(DESKTOP_UPDATE_BRIDGE_EVENT)); }); + +function timers() { + const intervals = new Map void>(); + const timeouts = new Map void>(); + let id = 50_000; + window.setInterval = ((...args: Parameters) => { + const [callback, delay] = args; + if (delay !== 3_000) return originalSetInterval.apply(window, args); + intervals.set(++id, callback as () => void); + return id; + }) as typeof window.setInterval; + window.clearInterval = (timer) => { + if (typeof timer === 'number' && intervals.delete(timer)) return; + originalClearInterval.call(window, timer); + }; + window.setTimeout = ((...args: Parameters) => { + const [callback, delay] = args; + if (delay !== 10_000) return originalSetTimeout.apply(window, args); + timeouts.set(++id, callback as () => void); + return id; + }) as typeof window.setTimeout; + window.clearTimeout = (timer) => { + if (typeof timer === 'number' && timeouts.delete(timer)) return; + originalClearTimeout.call(window, timer); + }; + return { + intervals, timeouts, + poll: async () => { await act(async () => { for (const callback of [...intervals.values()]) callback(); }); }, + expire: async () => { await act(async () => { for (const callback of [...timeouts.values()]) callback(); }); }, + }; +} + +test('ordinary web mounts have no native state, polling or command authority', async () => { + delete globals[DESKTOP_UPDATE_BRIDGE_NAME]; + const clock = timers(); + const view = renderHook(useDesktopUpdate); + await act(async () => { + await view.result.current.refresh(); + await view.result.current.check(); + await view.result.current.setAutomatic(false); + await view.result.current.restart(); + }); + assert.equal(view.result.current.bridgeActive, false); + assert.equal(view.result.current.snapshot, null); + assert.equal(view.result.current.connected, false); + assert.equal(clock.intervals.size, 0); +}); + +test('injected presence is not authority: rejected status never enables mutations', async () => { + timers(); + const commands: DesktopUpdateCommand[] = []; + bridge(async (command) => { commands.push(command); throw new Error('private diagnostic must not reach UI'); }); + const view = renderHook(useDesktopUpdate); + await flush(); + assert.equal(view.result.current.bridgeActive, true); + assert.equal(view.result.current.snapshot, null); + assert.equal(view.result.current.connected, false); + assert.equal(view.result.current.error, 'unavailable'); + await act(async () => { + await view.result.current.check(); await view.result.current.setAutomatic(false); await view.result.current.restart(); + }); + assert.deepEqual(commands, [{ action: 'status' }]); +}); + +test('wrong protocol, malformed and null status responses fail closed', async () => { + timers(); + let calls = 0; + globals[DESKTOP_UPDATE_BRIDGE_NAME] = { protocolVersion: 2, request: () => { calls += 1; return Promise.resolve(native()); } }; + const view = renderHook(useDesktopUpdate); + await flush(); + assert.equal(calls, 0); + for (const value of [null, {}, native({ protocolVersion: 2 as 1 }), native({ downloadedBytes: -1 }), native({ totalBytes: 1, downloadedBytes: 2 })]) { + bridge(async () => value); + changed(); + await flush(); + assert.equal(view.result.current.snapshot, null); + assert.equal(view.result.current.connected, false); + assert.equal(view.result.current.error, 'invalidResponse'); + } +}); + +test('status requests coalesce and only a validated status establishes native truth', async () => { + const clock = timers(); + const waiting = deferred(); + let calls = 0; + bridge(async () => { calls += 1; return waiting.promise; }); + const view = renderHook(useDesktopUpdate); + await flush(); + assert.equal(view.result.current.connected, false); + let first!: Promise; + let second!: Promise; + act(() => { first = view.result.current.refresh(); second = view.result.current.refresh(); }); + assert.equal(first, second); + await clock.poll(); + assert.equal(calls, 1); + const snapshot = native({ phase: 'ready', installationAvailable: false }); + await act(async () => { waiting.resolve(snapshot); await first; }); + assert.deepEqual(view.result.current.snapshot, snapshot); + assert.equal(view.result.current.connected, true); + assert.equal(clock.timeouts.size, 0); + snapshot.automatic = false; + assert.equal(view.result.current.snapshot?.automatic, true, 'native object mutation is not an event'); +}); + +test('automatic settings are never optimistic; rejected/malformed writes retain confirmed state', async () => { + timers(); + const commands: DesktopUpdateCommand[] = []; + let waiting = deferred(); + bridge(async (command) => { commands.push(command); return command.action === 'status' ? native() : waiting.promise; }); + const view = renderHook(useDesktopUpdate); + await flush(); + act(() => { void view.result.current.setAutomatic(false); }); + await flush(); + assert.equal(view.result.current.snapshot?.automatic, true); + assert.equal(view.result.current.pending, 'setAutomatic'); + act(() => { void view.result.current.setAutomatic(true); }); + await act(async () => { waiting.reject(new Error('not saved')); }); + assert.equal(view.result.current.snapshot?.automatic, true); + assert.equal(view.result.current.connected, false); + assert.equal(view.result.current.error, 'unavailable'); + await act(async () => { await view.result.current.refresh(); }); + waiting = deferred(); + act(() => { void view.result.current.setAutomatic(false); }); + await flush(); + await act(async () => { waiting.resolve({ automatic: false }); }); + assert.equal(view.result.current.snapshot?.automatic, true); + assert.equal(view.result.current.error, 'invalidResponse'); + await act(async () => { await view.result.current.refresh(); }); + waiting = deferred(); + act(() => { void view.result.current.setAutomatic(false); }); + await flush(); + await act(async () => { waiting.resolve(native({ automatic: false })); }); + assert.equal(view.result.current.snapshot?.automatic, false); + assert.equal(commands.filter((command) => command.action === 'setAutomatic').length, 3); + assert.ok(commands.every(isDesktopUpdateCommand)); +}); + +test('check and restart commands do not invent phases; restart requires available AND ready', async () => { + timers(); + let snapshot = native({ automatic: false }); + const commands: DesktopUpdateCommand[] = []; + bridge(async (command) => { commands.push(command); return snapshot; }); + const view = renderHook(useDesktopUpdate); + await flush(); + await act(async () => { await view.result.current.check(); await view.result.current.restart(); }); + assert.equal(view.result.current.snapshot?.phase, 'idle'); + assert.deepEqual(commands, [{ action: 'status' }, { action: 'check' }]); + for (const next of [native({ phase: 'ready' }), native({ phase: 'deferred', installationAvailable: true })]) { + snapshot = next; + await act(async () => { await view.result.current.refresh(); await view.result.current.restart(); }); + } + assert.equal(commands.some((command) => command.action === 'restart'), false); + snapshot = native({ phase: 'ready', installationAvailable: true }); + await act(async () => { await view.result.current.refresh(); await view.result.current.restart(); }); + assert.deepEqual(commands.at(-1), { action: 'restart' }); + assert.equal(view.result.current.snapshot?.phase, 'ready', 'request success is not a claim of restarting'); + const count = commands.length; + await act(async () => { await view.result.current.setAutomatic('yes' as unknown as boolean); }); + assert.equal(commands.length, count, 'strict command validation rejects forged setter values'); +}); + +test('disabled/recovery/applying/restarting states allow status reads, not new mutation commands', async () => { + timers(); + let snapshot = native(); + const commands: DesktopUpdateCommand[] = []; + bridge(async (command) => { commands.push(command); return snapshot; }); + const view = renderHook(useDesktopUpdate); + await flush(); + for (const phase of ['disabled', 'recovery', 'applying', 'restarting'] as const) { + snapshot = native({ phase, installationAvailable: true }); + await act(async () => { + await view.result.current.refresh(); await view.result.current.check(); + await view.result.current.setAutomatic(false); await view.result.current.restart(); + }); + } + assert.ok(commands.every((command) => command.action === 'status')); +}); + +test('retirement preserves the last snapshot but removes authority and retires old polling', async () => { + const clock = timers(); + bridge(async () => native()); + const view = renderHook(useDesktopUpdate); + await flush(); + const savedPoll = [...clock.intervals.values()][0]; + const late = deferred(); + bridge(async () => late.promise); + changed(); + await flush(); + delete globals[DESKTOP_UPDATE_BRIDGE_NAME]; + changed(); + assert.equal(view.result.current.bridgeActive, true, 'retirement must not activate web fallback'); + assert.deepEqual(view.result.current.snapshot, native()); + assert.equal(view.result.current.connected, false); + assert.equal(view.result.current.error, 'unavailable'); + assert.equal(clock.intervals.size, 0); + let calls = 0; + bridge(async () => { calls += 1; return native({ productVersion: '2.0.0-beta.11' }); }); + changed(); + await flush(); + await act(async () => { savedPoll(); late.resolve(native({ productVersion: '9.0.0' })); }); + assert.equal(view.result.current.snapshot?.productVersion, '2.0.0-beta.11'); + assert.equal(calls, 1); +}); + +test('replaced injection without an event cannot publish an older response', async () => { + timers(); + const late = deferred(); + bridge(async () => late.promise); + const view = renderHook(useDesktopUpdate); + await flush(); + bridge(async () => native({ productVersion: '2.0.0-beta.11' })); + await act(async () => { late.resolve(native({ productVersion: '9.0.0' })); }); + assert.equal(view.result.current.snapshot?.productVersion, '2.0.0-beta.11'); +}); + +test('status timeout is bounded; late responses cannot overwrite a successful retry', async () => { + const clock = timers(); + const late = deferred(); + let calls = 0; + bridge(async () => ++calls === 1 ? late.promise : native()); + const view = renderHook(useDesktopUpdate); + await flush(); + let completed = false; + void view.result.current.refresh().then(() => { completed = true; }); + await clock.expire(); + assert.equal(completed, true); + assert.equal(view.result.current.error, 'timeout'); + assert.equal(({ ...view.result.current }).snapshot, null); + await clock.poll(); + assert.equal(view.result.current.connected, true); + await act(async () => { late.resolve(native({ productVersion: '9.0.0' })); }); + assert.equal(view.result.current.snapshot?.productVersion, '2.0.0-beta.10'); +}); + +test('timed-out native writes are not cancelled or repeated, even after status becomes readable', async () => { + const clock = timers(); + const late = deferred(); + const commands: DesktopUpdateCommand[] = []; + bridge(async (command) => { commands.push(command); return command.action === 'status' ? native() : late.promise; }); + const view = renderHook(useDesktopUpdate); + await flush(); + act(() => { void view.result.current.setAutomatic(false); }); + await flush(); + await clock.expire(); + assert.equal(view.result.current.awaitingOperation, true); + assert.equal(view.result.current.snapshot?.automatic, true); + await clock.poll(); + assert.equal(view.result.current.connected, true); + assert.equal(view.result.current.error, 'timeout'); + assert.equal(view.result.current.awaitingOperation, true); + await act(async () => { await view.result.current.setAutomatic(false); await view.result.current.check(); }); + assert.equal(commands.filter((command) => command.action !== 'status').length, 1); + await act(async () => { late.resolve(native({ automatic: false })); }); + assert.equal(view.result.current.awaitingOperation, false); + assert.equal(view.result.current.snapshot?.automatic, true, 'late write result is ignored; a fresh status supplies truth'); + assert.equal(view.result.current.error, null); + assert.deepEqual(commands.at(-1), { action: 'status' }); +}); + +test('unmount clears timers/listeners, ignores saved callbacks and never cancels native execution', async () => { + const clock = timers(); + const waiting = deferred(); + let calls = 0; + bridge(async () => { calls += 1; return waiting.promise; }); + const view = renderHook(useDesktopUpdate); + await flush(); + const poll = [...clock.intervals.values()][0]; + const timeout = [...clock.timeouts.values()][0]; + const refresh = view.result.current.refresh; + view.unmount(); + assert.equal(clock.intervals.size, 0); + assert.equal(clock.timeouts.size, 0); + await act(async () => { poll(); timeout(); changed(); await refresh(); waiting.resolve(native()); }); + assert.equal(calls, 1); +}); diff --git a/src/hooks/useDesktopUpdate.ts b/src/hooks/useDesktopUpdate.ts new file mode 100644 index 0000000..d250ff8 --- /dev/null +++ b/src/hooks/useDesktopUpdate.ts @@ -0,0 +1,167 @@ +import { useEffect, useRef, useState } from 'react'; + +import { + DESKTOP_UPDATE_BRIDGE_EVENT, + DESKTOP_UPDATE_BRIDGE_NAME, + DESKTOP_UPDATE_PROTOCOL, + isDesktopUpdateCommand, + isDesktopUpdateSnapshot, + type DesktopUpdateBridge, + type DesktopUpdateCommand, + type DesktopUpdateSnapshot, +} from '../../shared/desktopUpdateProtocol'; + +const POLL_INTERVAL = 3_000; +const REQUEST_TIMEOUT = 10_000; +type ConnectionError = 'unavailable' | 'invalidResponse' | 'timeout'; +type UpdateState = { + bridgeActive: boolean; + connected: boolean; + snapshot: DesktopUpdateSnapshot | null; + pending: DesktopUpdateCommand['action'] | null; + error: ConnectionError | null; + awaitingOperation: boolean; +}; + +function injection(): unknown { + return typeof window === 'undefined' ? undefined + : (window as unknown as Record)[DESKTOP_UPDATE_BRIDGE_NAME]; +} + +function isBridge(value: unknown): value is DesktopUpdateBridge { + return value !== null && typeof value === 'object' + && (value as DesktopUpdateBridge).protocolVersion === DESKTOP_UPDATE_PROTOCOL + && typeof (value as DesktopUpdateBridge).request === 'function'; +} + +export function useDesktopUpdate() { + const [state, setState] = useState(() => ({ + bridgeActive: injection() !== undefined, connected: false, snapshot: null, + pending: null, error: null, awaitingOperation: false, + })); + const dispatch = useRef<(command: DesktopUpdateCommand) => Promise>(async () => {}); + + useEffect(() => { + let disposed = false; + let epoch = 0; + let current: unknown; + let authenticated = false; + let snapshot: DesktopUpdateSnapshot | null = null; + let poll: number | undefined; + type Request = { promise: Promise; finish: () => void; timedOut: boolean }; + let active: Request | null = null; + let operation: Request | null = null; + + function send(command: DesktopUpdateCommand): Promise { + if (disposed || !isDesktopUpdateCommand(command)) return Promise.resolve(); + if (injection() !== current) { + attach(); + return active?.promise ?? Promise.resolve(); + } + if (!isBridge(current)) return Promise.resolve(); + if (command.action !== 'status') { + if (!authenticated || !snapshot || operation) return Promise.resolve(); + if (['disabled', 'recovery', 'applying', 'restarting'].includes(snapshot.phase)) return Promise.resolve(); + if (command.action === 'restart' && (!snapshot.installationAvailable || snapshot.phase !== 'ready')) return Promise.resolve(); + } + if (active) return active.promise; + + const bridge = current; + const requestEpoch = epoch; + let resolve!: () => void; + const promise = new Promise((done) => { resolve = done; }); + const token: Request = { promise, finish, timedOut: false }; + active = token; + if (command.action !== 'status') operation = token; + setState((previous) => ({ ...previous, pending: command.action })); + const timeout = window.setTimeout(() => { + if (!ownsRequest()) return; + token.timedOut = true; + authenticated = false; + setState((previous) => ({ ...previous, connected: false, pending: null, + error: 'timeout', awaitingOperation: operation === token })); + finish(); + }, REQUEST_TIMEOUT); + + function finish() { + window.clearTimeout(timeout); + if (active === token) active = null; + resolve(); + } + function sameEpoch() { + if (disposed || requestEpoch !== epoch) return false; + if (injection() !== bridge) { attach(); return false; } + return true; + } + function ownsRequest() { return sameEpoch() && active === token; } + function fail(error: ConnectionError) { + if (!ownsRequest()) return; + authenticated = false; + setState((previous) => ({ ...previous, connected: false, pending: null, error })); + } + + // The bridge owns authentication and native execution. A UI deadline does + // not cancel that execution, and a timed-out write is never reissued here. + void Promise.resolve().then(() => { + if (!ownsRequest()) return undefined; + return bridge.request(command); + }).then((value: unknown) => { + if (!ownsRequest()) return; + if (!isDesktopUpdateSnapshot(value)) { fail('invalidResponse'); return; } + snapshot = { ...value }; + if (command.action === 'status') authenticated = true; + setState((previous) => ({ ...previous, connected: true, snapshot, pending: null, + error: operation?.timedOut ? 'timeout' : null })); + }, () => fail('unavailable')).finally(() => { + finish(); + if (sameEpoch() && operation === token) { + operation = null; + setState((previous) => ({ ...previous, awaitingOperation: false })); + // Ignore a late command result; obtain a new authoritative snapshot. + if (token.timedOut) void send({ action: 'status' }); + } + }); + return promise; + } + + function attach() { + if (disposed) return; + epoch += 1; + active?.finish(); + operation = null; + authenticated = false; + window.clearInterval(poll); + current = injection(); + setState((previous) => ({ ...previous, + bridgeActive: previous.bridgeActive || current !== undefined, + connected: false, pending: null, awaitingOperation: false, + error: isBridge(current) ? null : (previous.bridgeActive || current !== undefined ? 'unavailable' : null), + })); + if (isBridge(current)) { + void send({ action: 'status' }); + const pollEpoch = epoch; + poll = window.setInterval(() => { if (epoch === pollEpoch) void send({ action: 'status' }); }, POLL_INTERVAL); + } + } + + dispatch.current = send; + attach(); + window.addEventListener(DESKTOP_UPDATE_BRIDGE_EVENT, attach); + return () => { + disposed = true; + epoch += 1; + active?.finish(); + window.clearInterval(poll); + window.removeEventListener(DESKTOP_UPDATE_BRIDGE_EVENT, attach); + dispatch.current = async () => {}; + }; + }, []); + + return { + ...state, + refresh: () => dispatch.current({ action: 'status' }), + check: () => dispatch.current({ action: 'check' }), + setAutomatic: (automatic: boolean) => dispatch.current({ action: 'setAutomatic', automatic }), + restart: () => dispatch.current({ action: 'restart' }), + }; +} diff --git a/src/hooks/useVersionCheck.dom.bun.test.tsx b/src/hooks/useVersionCheck.dom.bun.test.tsx new file mode 100644 index 0000000..f87619d --- /dev/null +++ b/src/hooks/useVersionCheck.dom.bun.test.tsx @@ -0,0 +1,251 @@ +import assert from 'node:assert/strict'; +import { afterEach, test } from 'node:test'; + +import { act, cleanup, renderHook, waitFor } from '@testing-library/react'; + +import { version } from '../../package.json'; + +import { useVersionCheck } from './useVersionCheck'; + +// Future major fixtures stay newer after beta and stable release bumps. +const currentMajor = Number(version.split('.')[0]); +const nextVersion = `${currentMajor + 1}.0.0`; +const laterVersion = `${currentMajor + 2}.0.0`; +const retiredVersion = `${currentMajor + 3}.0.0`; + +const originalFetch = globalThis.fetch; +const originalSetInterval = window.setInterval; +const originalClearInterval = window.clearInterval; +const originalSetTimeout = window.setTimeout; +const originalClearTimeout = window.clearTimeout; +const originalNow = Date.now; + +afterEach(() => { + cleanup(); + globalThis.fetch = originalFetch; + window.setInterval = originalSetInterval; + window.clearInterval = originalClearInterval; + window.setTimeout = originalSetTimeout; + window.clearTimeout = originalClearTimeout; + Date.now = originalNow; +}); + +function release(tag: string) { + return { tag_name: tag, draft: false, prerelease: tag.includes('-'), published_at: '2026-09-07T00:00:00Z' }; +} +const page = (tag: string) => new Response(JSON.stringify([release(tag)])); +const emptyState = { updateAvailable: false, latestVersion: null, currentVersion: version, releaseInfo: null }; + +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 }; +} + +function timers() { + const intervals = new Map void>(); + const timeouts = new Map void>(); + let nextId = 10_000; + window.setInterval = ((...args: Parameters) => { + const [callback, delay] = args; + if (delay !== 5 * 60 * 1000) return originalSetInterval.apply(window, args); + assert.equal(typeof callback, 'function'); + const id = nextId++; + intervals.set(id, callback as () => void); + return id; + }) as typeof window.setInterval; + window.clearInterval = (id) => { + if (typeof id === 'number' && intervals.delete(id)) return; + originalClearInterval.call(window, id); + }; + window.setTimeout = ((...args: Parameters) => { + const [callback, delay] = args; + if (delay !== 30_000) return originalSetTimeout.apply(window, args); + assert.equal(typeof callback, 'function'); + const id = nextId++; + timeouts.set(id, callback as () => void); + return id; + }) as typeof window.setTimeout; + window.clearTimeout = (id) => { + if (typeof id === 'number' && timeouts.delete(id)) return; + originalClearTimeout.call(window, id); + }; + return { + intervals, timeouts, + poll: async () => { await act(async () => { for (const callback of intervals.values()) await callback(); }); }, + }; +} + +test('the public hook retains its notification-only shape and displays a complete list result', async () => { + timers(); + globalThis.fetch = async () => page(`v${nextVersion}`); + const view = renderHook(() => useVersionCheck('owner', 'repo')); + await waitFor(() => assert.equal(view.result.current.latestVersion, nextVersion)); + assert.deepEqual(Object.keys(view.result.current).sort(), Object.keys(emptyState).sort()); + assert.equal(view.result.current.currentVersion, version); + assert.equal(view.result.current.updateAvailable, true); + assert.equal(view.result.current.releaseInfo?.htmlUrl, `https://github.com/owner/repo/releases/tag/v${nextVersion}`); +}); + +test('disabled web checks do no I/O and retire an active callback when native ownership arrives', async () => { + const clock = timers(); + const waiting = deferred(); + let calls = 0; + let signal: AbortSignal | null = null; + globalThis.fetch = async (_url, init) => { calls += 1; signal = init?.signal as AbortSignal; return waiting.promise; }; + const view = renderHook(({ enabled }) => useVersionCheck('owner', 'repo', enabled), { initialProps: { enabled: false } }); + await act(async () => {}); + assert.equal(calls, 0); + assert.equal(clock.intervals.size, 0); + view.rerender({ enabled: true }); + assert.equal(calls, 1); + view.rerender({ enabled: false }); + assert.equal((signal as AbortSignal | null)?.aborted, true); + assert.equal(clock.intervals.size, 0); + await act(async () => { waiting.resolve(page(`v${retiredVersion}`)); }); + assert.deepEqual(view.result.current, emptyState); +}); + +test('a failed refresh clears an earlier result, and a later poll can recover', async () => { + const clock = timers(); + globalThis.fetch = async () => page(`v${nextVersion}`); + const view = renderHook(() => useVersionCheck('owner', 'repo')); + await waitFor(() => assert.equal(view.result.current.updateAvailable, true)); + globalThis.fetch = async () => { throw new TypeError('offline'); }; + await clock.poll(); + assert.deepEqual(view.result.current, emptyState); + globalThis.fetch = async () => page(`v${laterVersion}`); + await clock.poll(); + assert.equal(view.result.current.latestVersion, laterVersion); +}); + +test('HTTP failures and bounded partial results never become a latest-version notification', async () => { + const clock = timers(); + globalThis.fetch = async () => new Response('not found', { status: 404 }); + const view = renderHook(() => useVersionCheck('owner', 'repo')); + await act(async () => {}); + assert.deepEqual(view.result.current, emptyState); + let calls = 0; + globalThis.fetch = async () => { + calls += 1; + return new Response(JSON.stringify([release(`v${retiredVersion}`)]), { headers: { Link: '; rel="next"' } }); + }; + await clock.poll(); + assert.equal(calls, 5); + assert.deepEqual(view.result.current, emptyState); +}); + +test('429 honors Retry-After seconds and dates without an immediate retry loop', async () => { + const clock = timers(); + const start = Date.parse('2026-09-07T00:00:00Z'); + let now = start; + Date.now = () => now; + let calls = 0; + globalThis.fetch = async () => { + calls += 1; + if (calls === 1) return new Response('', { status: 429, headers: { 'Retry-After': '900' } }); + if (calls === 2) return new Response('', { status: 429, headers: { 'Retry-After': new Date(start + 1_800_000).toUTCString() } }); + return page(`v${nextVersion}`); + }; + const view = renderHook(() => useVersionCheck('owner', 'repo')); + await act(async () => {}); + now += 300_000; + await clock.poll(); + assert.equal(calls, 1); + assert.deepEqual(view.result.current, emptyState); + now = start + 900_000; + await clock.poll(); + assert.equal(calls, 2); + now += 300_000; + await clock.poll(); + assert.equal(calls, 2); + now = start + 1_800_000; + await clock.poll(); + assert.equal(calls, 3); + assert.equal(view.result.current.latestVersion, nextVersion); +}); + +test('repository changes clear the old result and retire late successes and failures', async () => { + timers(); + const pending = [deferred(), deferred(), deferred()]; + const signals: AbortSignal[] = []; + globalThis.fetch = async (_url, init) => { + signals.push(init?.signal as AbortSignal); + return pending[signals.length - 1].promise; + }; + const view = renderHook(({ owner, repo }) => useVersionCheck(owner, repo), { initialProps: { owner: 'old', repo: 'repo' } }); + view.rerender({ owner: 'new', repo: 'repo' }); + assert.equal(signals[0].aborted, true); + await act(async () => { pending[1].resolve(page(`v${nextVersion}`)); }); + assert.equal(view.result.current.latestVersion, nextVersion); + await act(async () => { pending[0].resolve(page(`v${retiredVersion}`)); }); + assert.equal(view.result.current.latestVersion, nextVersion, 'retired success cannot overwrite the new repository'); + + view.rerender({ owner: 'new', repo: 'other' }); + assert.deepEqual(view.result.current, emptyState); + const lateFailure = pending[2]; + globalThis.fetch = async () => page(`v${laterVersion}`); + view.rerender({ owner: 'final', repo: 'other' }); + await waitFor(() => assert.equal(view.result.current.latestVersion, laterVersion)); + await act(async () => { lateFailure.reject(new TypeError('old request failed')); }); + assert.equal(view.result.current.latestVersion, laterVersion, 'retired failure cannot clear the new repository'); +}); + +test('pending polls never overlap, and unmount retires saved timer and request callbacks', async () => { + const clock = timers(); + const pending = deferred(); + let calls = 0; + let signal: AbortSignal | null = null; + globalThis.fetch = async (_url, init) => { calls += 1; signal = init?.signal as AbortSignal; return pending.promise; }; + const view = renderHook(() => useVersionCheck('owner', 'repo')); + const retiredPoll = [...clock.intervals.values()][0]; + await clock.poll(); + await clock.poll(); + assert.equal(calls, 1); + view.unmount(); + assert.equal(clock.intervals.size, 0); + assert.equal(clock.timeouts.size, 0, 'unmount clears the deadline even if the transport ignores abort'); + assert.equal((signal as AbortSignal | null)?.aborted, true); + await act(async () => { await retiredPoll(); pending.resolve(page(`v${retiredVersion}`)); }); + assert.equal(calls, 1); + assert.equal(clock.timeouts.size, 0); +}); + +test('request deadlines abort stalled reads and allow the next scheduled poll', async () => { + const clock = timers(); + let signal: AbortSignal | null = null; + globalThis.fetch = async (_url, init) => new Promise((_resolve, reject) => { + signal = init?.signal as AbortSignal; + signal.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')), { once: true }); + }); + const view = renderHook(() => useVersionCheck('owner', 'repo')); + await act(async () => { for (const callback of clock.timeouts.values()) callback(); }); + assert.equal((signal as AbortSignal | null)?.aborted, true); + assert.deepEqual(view.result.current, emptyState); + assert.equal(clock.timeouts.size, 0); + globalThis.fetch = async () => page(`v${nextVersion}`); + await clock.poll(); + assert.equal(view.result.current.latestVersion, nextVersion); +}); + +test('StrictMode effect replay aborts the retired request without suppressing the active one', async () => { + const clock = timers(); + const pending: ReturnType>[] = []; + const signals: AbortSignal[] = []; + globalThis.fetch = async (_url, init) => { + const request = deferred(); + pending.push(request); + signals.push(init?.signal as AbortSignal); + return request.promise; + }; + const view = renderHook(() => useVersionCheck('owner', 'repo'), { + reactStrictMode: true, + }); + assert.equal(pending.length, 2); + assert.equal(signals[0].aborted, true); + assert.equal(clock.intervals.size, 1); + await act(async () => { pending[1].resolve(page(`v${nextVersion}`)); }); + await act(async () => { pending[0].resolve(page(`v${retiredVersion}`)); }); + assert.equal(view.result.current.latestVersion, nextVersion); +}); diff --git a/src/hooks/useVersionCheck.test.ts b/src/hooks/useVersionCheck.test.ts new file mode 100644 index 0000000..4395031 --- /dev/null +++ b/src/hooks/useVersionCheck.test.ts @@ -0,0 +1,199 @@ +import assert from 'node:assert/strict'; +import { afterEach, test } from 'node:test'; + +import { compareReleaseVersions, parseReleaseVersion } from '../../shared/releaseVersion.js'; + +import { fetchReleaseNotification } from './useVersionCheck'; + +const originalFetch = globalThis.fetch; +afterEach(() => { globalThis.fetch = originalFetch; }); + +function release(tag: string, extra: Record = {}) { + return { + tag_name: tag, name: `Release ${tag}`, body: 'Release notes', + draft: false, prerelease: tag.includes('-'), published_at: '2026-09-07T00:00:00Z', + ...extra, + }; +} + +function page(releases: unknown, link?: string) { + return new Response(JSON.stringify(releases), { headers: link ? { Link: link } : undefined }); +} + +const nextLink = '; rel="next"'; +const check = (current = '2.0.0-beta.9', signal = new AbortController().signal) => ( + fetchReleaseNotification('owner', 'repo', current, signal) +); + +test('beta.9 discovers beta.10 from the releases list, with notification metadata only', async () => { + globalThis.fetch = async (url, init) => { + assert.equal(String(url), 'https://api.github.com/repos/owner/repo/releases?per_page=100&page=1'); + assert.ok(init?.signal); + return page([release('v2.0.0-beta.10', { html_url: 'https://untrusted.example/install' })]); + }; + assert.deepEqual(await check(), { + latestVersion: '2.0.0-beta.10', updateAvailable: true, + releaseInfo: { + title: 'Release v2.0.0-beta.10', body: 'Release notes', + htmlUrl: 'https://github.com/owner/repo/releases/tag/v2.0.0-beta.10', + publishedAt: '2026-09-07T00:00:00Z', + }, + }); +}); + +test('beta accepts stable; stable excludes beta and other prerelease channels', async () => { + globalThis.fetch = async () => page([ + release('v3.0.0-beta.1'), release('v4.0.0-rc.1'), + release('v5.0.0', { prerelease: true }), release('v6.0.0-beta.1', { prerelease: false }), + release('v2.0.0-beta.10'), release('v2.0.0'), + ]); + assert.equal((await check('2.0.0'))?.latestVersion, '2.0.0'); + assert.equal((await check('2.0.0'))?.updateAvailable, false); + assert.equal((await check())?.latestVersion, '3.0.0-beta.1'); + globalThis.fetch = async () => page([release('v2.0.0-beta.10'), release('v2.0.0')]); + assert.equal((await check())?.latestVersion, '2.0.0'); + assert.equal((await check())?.updateAvailable, true); + globalThis.fetch = async () => page([release('v2.0.1'), release('v2.1.0-beta.1')]); + assert.equal((await check('2.0.0'))?.latestVersion, '2.0.1'); + assert.equal((await check('2.0.0'))?.updateAvailable, true); +}); + +test('maximum SemVer wins across out-of-order pages, not list order or publication date', async () => { + const urls: string[] = []; + globalThis.fetch = async (url) => { + urls.push(String(url)); + return urls.length === 1 + ? page([release('v2.0.0-beta.10'), release('v2.0.0-beta.2')], nextLink) + : page([release('v2.0.0-beta.12', { published_at: '2020-01-01T00:00:00Z' }), release('v2.0.0-beta.9')]); + }; + assert.equal((await check())?.latestVersion, '2.0.0-beta.12'); + assert.deepEqual(urls, [1, 2].map((n) => `https://api.github.com/repos/owner/repo/releases?per_page=100&page=${n}`)); +}); + +test('drafts, malformed tags/records and unsupported channels are ignored', async () => { + globalThis.fetch = async () => page([ + null, false, [], 'v9.0.0', {}, release('v9.0.0', { draft: true }), + release('v8.0.0', { draft: undefined }), release('v7.0.0', { prerelease: undefined }), + ...['v9.0', ' v9.0.0', 'v9.0.0 ', 'vv9.0.0', 'v09.0.0', 'v9.0.0-beta.01', + 'v9.0.0-beta1', 'v9.0.0-alpha.1', 'v9.0.0-rc.1', '=9.0.0', 'latest'].map((tag) => release(tag)), + release('v2.0.0-beta.10', { name: null, body: null, published_at: null }), + ]); + const result = await check(); + assert.equal(result?.latestVersion, '2.0.0-beta.10'); + assert.equal(result?.releaseInfo.title, 'v2.0.0-beta.10'); + assert.equal(result?.releaseInfo.body, ''); + assert.equal(result?.releaseInfo.publishedAt, ''); +}); + +test('SemVer parsing is strict, supports build metadata, and treats beta.10 numerically', () => { + assert.deepEqual(parseReleaseVersion('v2.0.0-beta.10+build.4'), { version: '2.0.0-beta.10+build.4', channel: 'beta' }); + assert.deepEqual(parseReleaseVersion('2.0.0+build.4'), { version: '2.0.0+build.4', channel: 'stable' }); + assert.equal(compareReleaseVersions('2.0.0-beta.10', '2.0.0-beta.9'), 1); + assert.equal(compareReleaseVersions('2.0.0-beta.10+one', '2.0.0-beta.10+two'), 0); + for (const value of [null, 42, {}, '', '2.0', '2.0.0\n', '2.0.0-alpha.1', '9'.repeat(257)]) { + assert.equal(parseReleaseVersion(value), null); + } +}); + +test('same/older releases do not announce an update, and an empty channel stays unknown', async () => { + for (const tag of ['v2.0.0-beta.10', 'v2.0.0-beta.9', 'v2.0.0-beta.10+other']) { + globalThis.fetch = async () => page([release(tag)]); + assert.equal((await check('2.0.0-beta.10'))?.updateAvailable, false); + } + globalThis.fetch = async () => page([release('v3.0.0-beta.1')]); + assert.equal(await check('2.0.0'), null); + globalThis.fetch = async () => page([]); + assert.equal(await check(), null); +}); + +test('an invalid or unsupported installed version cannot choose an update channel', async () => { + let calls = 0; + globalThis.fetch = async () => { calls += 1; return page([release('v9.0.0')]); }; + assert.equal(await check('invalid'), null); + assert.equal(await check('2.0.0-rc.1'), null); + assert.equal(calls, 0); +}); + +test('a full page without Link requires another page; provided URLs are never followed', async () => { + const urls: string[] = []; + globalThis.fetch = async (url) => { + urls.push(String(url)); + if (urls.length === 1) return page(Array.from({ length: 100 }, () => release('v2.0.0-beta.10'))); + if (urls.length === 2) return page([release('v2.0.0-beta.11')], '; rel="next"'); + return page([release('v2.0.0-beta.12')]); + }; + assert.equal((await check())?.latestVersion, '2.0.0-beta.12'); + assert.deepEqual(urls, [1, 2, 3].map((n) => `https://api.github.com/repos/owner/repo/releases?per_page=100&page=${n}`)); +}); + +test('bounded incomplete traversals return unknown, never a partial maximum as latest', async () => { + for (const fullPage of [false, true]) { + let calls = 0; + globalThis.fetch = async () => { + calls += 1; + return fullPage + ? page(Array.from({ length: 100 }, () => release('v9.0.0'))) + : page([release('v9.0.0')], nextLink); + }; + assert.equal(await check(), null); + assert.equal(calls, 5); + } +}); + +test('HTTP failures reject without treating an error body as release metadata', async () => { + for (const status of [404, 429, 500]) { + globalThis.fetch = async () => new Response(JSON.stringify([release('v9.0.0')]), { status }); + await assert.rejects(check(), new RegExp(`HTTP ${status}`)); + } + globalThis.fetch = async () => { throw new TypeError('offline'); }; + await assert.rejects(check(), /offline/); +}); + +test('malformed JSON, non-list and oversized list responses fail safely', async () => { + globalThis.fetch = async () => new Response('{'); + await assert.rejects(check()); + for (const value of [{ tag_name: 'v9.0.0' }, null, Array.from({ length: 101 }, () => release('v9.0.0'))]) { + globalThis.fetch = async () => page(value); + await assert.rejects(check(), /malformed releases list/); + } +}); + +test('a later page failure discards an otherwise eligible partial result', async () => { + let calls = 0; + globalThis.fetch = async () => ++calls === 1 + ? page([release('v2.0.0-beta.10')], nextLink) + : new Response('rate limited', { status: 429 }); + await assert.rejects(check(), /HTTP 429/); + assert.equal(calls, 2); +}); + +test('pre-aborted requests never fetch, and late responses cannot continue a retired traversal', async () => { + const controller = new AbortController(); + controller.abort(); + let calls = 0; + globalThis.fetch = async () => { calls += 1; return page([]); }; + await assert.rejects(check(undefined, controller.signal), { name: 'AbortError' }); + assert.equal(calls, 0); + + const late = new AbortController(); + globalThis.fetch = async () => { + calls += 1; + late.abort(); // Simulate a transport resolving despite cancellation. + return page([release('v9.0.0')], nextLink); + }; + await assert.rejects(check(undefined, late.signal), { name: 'AbortError' }); + assert.equal(calls, 1); +}); + +test('abort during JSON parsing prevents publication or another page read', async () => { + const controller = new AbortController(); + let calls = 0; + globalThis.fetch = async () => { + calls += 1; + const response = page([], nextLink); + response.json = async () => { controller.abort(); return [release('v9.0.0')]; }; + return response; + }; + await assert.rejects(check(undefined, controller.signal), { name: 'AbortError' }); + assert.equal(calls, 1); +}); diff --git a/src/hooks/useVersionCheck.ts b/src/hooks/useVersionCheck.ts index 1478e52..4ec7a1c 100644 --- a/src/hooks/useVersionCheck.ts +++ b/src/hooks/useVersionCheck.ts @@ -1,29 +1,97 @@ import { useEffect, useState } from 'react'; import { version } from '../../package.json'; +import { compareReleaseVersions, parseReleaseVersion } from '../../shared/releaseVersion.js'; import type { ReleaseInfo } from '../types/sharedTypes'; const RELEASE_CHECK_DELAY = 5 * 60 * 1000; +const RELEASE_CHECK_TIMEOUT = 30 * 1000; +const RELEASE_PAGE_SIZE = 100; +const MAX_RELEASE_PAGES = 5; -function compareVersions(first: string, second: string): number { - const firstParts = first.split('.').map(Number); - const secondParts = second.split('.').map(Number); - const partCount = Math.max(firstParts.length, secondParts.length); +type ReleaseNotification = { + latestVersion: string; + updateAvailable: boolean; + releaseInfo: ReleaseInfo; +}; + +class ReleaseCheckError extends Error { + readonly retryAt: number; - for (let index = 0; index < partCount; index += 1) { - const difference = (firstParts[index] || 0) - (secondParts[index] || 0); - if (difference !== 0) return difference; + constructor(response: Response) { + super(`Version check failed: HTTP ${response.status}`); + const retryAfter = response.status === 429 ? response.headers.get('Retry-After') : null; + const seconds = retryAfter && /^\d+$/.test(retryAfter) ? Number(retryAfter) : NaN; + const retryAt = Number.isFinite(seconds) + ? Date.now() + seconds * 1000 + : Date.parse(retryAfter ?? ''); + this.retryAt = Number.isFinite(retryAt) ? retryAt : 0; } +} + +/** Web notification metadata only. An incomplete traversal cannot name a latest release. */ +export async function fetchReleaseNotification( + owner: string, + repo: string, + currentVersion: string, + signal: AbortSignal, +): Promise { + signal.throwIfAborted(); + const current = parseReleaseVersion(currentVersion); + if (!current) return null; + const slug = `${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; + let latest: ReleaseNotification | null = null; + + for (let page = 1; page <= MAX_RELEASE_PAGES; page += 1) { + signal.throwIfAborted(); + // Construct every URL ourselves: pagination metadata never supplies fetch authority. + const response = await fetch( + `https://api.github.com/repos/${slug}/releases?per_page=${RELEASE_PAGE_SIZE}&page=${page}`, + { signal, headers: { Accept: 'application/vnd.github+json' } }, + ); + signal.throwIfAborted(); + if (!response.ok) throw new ReleaseCheckError(response); + const releases: unknown = await response.json(); + signal.throwIfAborted(); + if (!Array.isArray(releases) || releases.length > RELEASE_PAGE_SIZE) { + throw new Error('Version check failed: malformed releases list'); + } - return 0; + for (const release of releases) { + if (!release || typeof release !== 'object' || release.draft !== false) continue; + const candidate = parseReleaseVersion(release.tag_name); + if (!candidate || release.prerelease !== (candidate.channel === 'beta')) continue; + if (current.channel === 'stable' && candidate.channel !== 'stable') continue; + if (latest && compareReleaseVersions(candidate.version, latest.latestVersion) <= 0) continue; + latest = { + latestVersion: candidate.version, + updateAvailable: compareReleaseVersions(candidate.version, current.version) > 0, + releaseInfo: { + title: typeof release.name === 'string' && release.name ? release.name : release.tag_name, + body: typeof release.body === 'string' ? release.body : '', + htmlUrl: `https://github.com/${slug}/releases/tag/${encodeURIComponent(release.tag_name)}`, + publishedAt: typeof release.published_at === 'string' ? release.published_at : '', + }, + }; + } + + const hasNext = /;\s*rel\s*=\s*"next"/i.test(response.headers.get('Link') ?? ''); + // A full page without an exposed Link header is not proof of completion. + if (!hasNext && releases.length < RELEASE_PAGE_SIZE) return latest; + } + return null; } -export const useVersionCheck = (owner: string, repo: string) => { +export const useVersionCheck = (owner: string, repo: string, enabled = true) => { const [updateAvailable, setUpdateAvailable] = useState(false); const [latestVersion, setLatestVersion] = useState(null); const [releaseInfo, setReleaseInfo] = useState(null); useEffect(() => { + let retired = false; + let activeRequest: AbortController | null = null; + let requestTimeout: number | undefined; + let nextCheckAt = 0; const clearRelease = () => { setUpdateAvailable(false); setLatestVersion(null); @@ -31,33 +99,48 @@ export const useVersionCheck = (owner: string, repo: string) => { }; const refreshRelease = async () => { + if (retired || activeRequest || Date.now() < nextCheckAt) return; + const controller = new AbortController(); + activeRequest = controller; + requestTimeout = window.setTimeout(() => controller.abort(), RELEASE_CHECK_TIMEOUT); try { - const response = await fetch(`https://api.github.com/repos/${owner}/${repo}/releases/latest`); - const release = await response.json(); - if (!release.tag_name) { + const release = await fetchReleaseNotification(owner, repo, version, controller.signal); + if (retired) return; + controller.signal.throwIfAborted(); + if (!release) { clearRelease(); return; } - - const tag = release.tag_name.replace(/^v/, ''); - setLatestVersion(tag); - setUpdateAvailable(compareVersions(tag, version) > 0); - setReleaseInfo({ - title: release.name || release.tag_name, - body: release.body || '', - htmlUrl: release.html_url || `https://github.com/${owner}/${repo}/releases/latest`, - publishedAt: release.published_at, - }); + setLatestVersion(release.latestVersion); + setUpdateAvailable(release.updateAvailable); + setReleaseInfo(release.releaseInfo); } catch (error) { - console.error('Version check failed:', error); + if (retired) return; + if (error instanceof ReleaseCheckError) nextCheckAt = error.retryAt; clearRelease(); + } finally { + window.clearTimeout(requestTimeout); + requestTimeout = undefined; + activeRequest = null; } }; + clearRelease(); + if (!enabled) return; void refreshRelease(); const timer = window.setInterval(refreshRelease, RELEASE_CHECK_DELAY); - return () => window.clearInterval(timer); - }, [owner, repo]); + return () => { + retired = true; + window.clearInterval(timer); + window.clearTimeout(requestTimeout); + activeRequest?.abort(); + }; + }, [owner, repo, enabled]); - return { updateAvailable, latestVersion, currentVersion: version, releaseInfo }; + return { + updateAvailable: enabled && updateAvailable, + latestVersion: enabled ? latestVersion : null, + currentVersion: version, + releaseInfo: enabled ? releaseInfo : null, + }; }; diff --git a/src/i18n/locales/de/settings.json b/src/i18n/locales/de/settings.json index c0e4537..178102b 100644 --- a/src/i18n/locales/de/settings.json +++ b/src/i18n/locales/de/settings.json @@ -1,4 +1,58 @@ { + "about": { + "updateAvailable": "Update verfügbar: {{version}}" + }, + "desktopUpdate": { + "reasons": { + "discoveryFailed": "Die Release-Suche ist fehlgeschlagen.", + "cacheInvalid": "Das zwischengespeicherte Update hat die Prüfung nicht bestanden.", + "preparationCancelled": "Die Update-Vorbereitung wurde abgebrochen.", + "preferencesNotPersisted": "Die Einstellung für automatische Updates konnte nicht gespeichert werden." + }, + "title": "Desktop-Updates", + "connecting": "Verbindung zur Desktop-Aktualisierung…", + "unconfirmed": "Der Zugriff auf die Desktop-Aktualisierung wurde nicht bestätigt.", + "lastConfirmed": "Zuletzt bestätigter nativer Zustand; der aktuelle Zustand ist unbestätigt.", + "unknownVersion": "Version nicht bestätigt", + "incomplete": "Die Release-Suche ist unvollständig. Die neueste Version wurde damit nicht bestätigt.", + "preparationOnly": "Die Installation ist derzeit nicht verfügbar. Ein vorbereitetes Update ist noch nicht installiert.", + "productVersion": "App-Version", + "desktopVersion": "Desktop-Build", + "targetProductVersion": "Zielversion der App", + "targetDesktopVersion": "Ziel-Desktop-Build", + "progress": "Download-Fortschritt", + "knownProgress": "{{downloaded}} / {{total}} Bytes", + "unknownTotal": "{{downloaded}} Bytes geladen; Gesamtgröße unbekannt.", + "unknownProgress": "Warten auf Download-Fortschritt.", + "automatic": "Automatische Updates", + "automaticHelp": "Änderungen erscheinen erst nach Bestätigung durch die Desktop-App. Ausschalten fordert den Abbruch geplanter Updates an.", + "confirmingSetting": "Warten auf native Bestätigung der Einstellung…", + "confirmingRestart": "Sicherer Neustart wird angefordert…", + "check": "Nach Updates suchen", + "refresh": "Status aktualisieren", + "retry": "Erneut prüfen", + "restart": "Aktualisieren und neu starten", + "osPrompt": "macOS kann in einem eigenen Systemdialog eine Administratorfreigabe anfordern. Bestätigen oder widerrufen Sie dort; geben Sie hier keine Zugangsdaten ein. Bei laufenden Aufgaben kann der Neustart verschoben werden.", + "notes": "Versionshinweise", + "phases": { + "disabled": "Updater deaktiviert", + "idle": "Warten auf die nächste Prüfung", + "checking": "Releases werden geprüft", + "downloading": "Update wird geladen", + "verifying": "Update wird verifiziert", + "ready": "Update vorbereitet", + "deferred": "Update verschoben", + "error": "Update-Fehler", + "applying": "Update wird angewendet", + "restarting": "Neustart läuft", + "recovery": "Wiederherstellung erforderlich" + }, + "errors": { + "unavailable": "Die authentifizierte Desktop-Aktualisierung ist nicht erreichbar. Ihr aktueller Zustand ist unbekannt.", + "invalidResponse": "Die Desktop-Aktualisierung hat eine nicht unterstützte Antwort geliefert. Installationsaktionen bleiben deaktiviert.", + "timeout": "Das Warten auf die Antwort wurde beendet; der native Vorgang läuft möglicherweise weiter. Aktualisieren Sie vor einem erneuten Versuch den Status." + } + }, "close": "Einstellungen schließen", "title": "Optionen", "account": { diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index 98d1033..822c667 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -1,4 +1,58 @@ { + "about": { + "updateAvailable": "Update available: {{version}}" + }, + "desktopUpdate": { + "reasons": { + "discoveryFailed": "Release discovery failed.", + "cacheInvalid": "The cached update failed validation.", + "preparationCancelled": "Update preparation was cancelled.", + "preferencesNotPersisted": "The automatic update setting could not be saved." + }, + "title": "Desktop updates", + "connecting": "Connecting to the desktop updater…", + "unconfirmed": "Desktop updater access has not been confirmed.", + "lastConfirmed": "Last confirmed native state; the current state is not confirmed.", + "unknownVersion": "Version not confirmed", + "incomplete": "Release discovery is incomplete. This is not a claim that the latest version was found.", + "preparationOnly": "Installation is currently unavailable. A prepared update is not an installed update.", + "productVersion": "App version", + "desktopVersion": "Desktop build", + "targetProductVersion": "Target app version", + "targetDesktopVersion": "Target desktop build", + "progress": "Download progress", + "knownProgress": "{{downloaded}} / {{total}} bytes", + "unknownTotal": "{{downloaded}} bytes downloaded; total size unknown.", + "unknownProgress": "Waiting for download progress.", + "automatic": "Automatic updates", + "automaticHelp": "Changes are shown only after the desktop app confirms them. Turning this off requests cancellation of scheduled updates.", + "confirmingSetting": "Waiting for native confirmation of this setting…", + "confirmingRestart": "Requesting a safe restart…", + "check": "Check for updates", + "refresh": "Refresh status", + "retry": "Retry check", + "restart": "Update and restart", + "osPrompt": "macOS may ask for administrator approval in its own system dialog. Approve or cancel there; do not enter credentials here. Restart may be deferred while work is active.", + "notes": "Release notes", + "phases": { + "disabled": "Updater disabled", + "idle": "Waiting for the next check", + "checking": "Checking releases", + "downloading": "Downloading update", + "verifying": "Verifying update", + "ready": "Update prepared", + "deferred": "Update deferred", + "error": "Update error", + "applying": "Applying update", + "restarting": "Restarting", + "recovery": "Recovery required" + }, + "errors": { + "unavailable": "Cannot reach the authenticated desktop updater. Its current state is unknown.", + "invalidResponse": "The desktop updater returned an unsupported response. Installation controls remain disabled.", + "timeout": "Stopped waiting for a response; the native operation may still be running. Refresh status before retrying." + } + }, "close": "Close settings", "title": "Settings", "account": { diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json index fbd49d8..459b4c9 100644 --- a/src/i18n/locales/fr/settings.json +++ b/src/i18n/locales/fr/settings.json @@ -1,4 +1,58 @@ { + "about": { + "updateAvailable": "Mise à jour disponible : {{version}}" + }, + "desktopUpdate": { + "reasons": { + "discoveryFailed": "La recherche des versions a échoué.", + "cacheInvalid": "La validation de la mise à jour en cache a échoué.", + "preparationCancelled": "La préparation de la mise à jour a été annulée.", + "preferencesNotPersisted": "Le réglage des mises à jour automatiques n’a pas pu être enregistré." + }, + "title": "Mises à jour du bureau", + "connecting": "Connexion au service de mise à jour…", + "unconfirmed": "L’accès au service de mise à jour du bureau n’est pas confirmé.", + "lastConfirmed": "Dernier état natif confirmé ; l’état actuel n’est pas confirmé.", + "unknownVersion": "Version non confirmée", + "incomplete": "La recherche des versions est incomplète. Cela ne confirme pas la version la plus récente.", + "preparationOnly": "L’installation est actuellement indisponible. Une mise à jour préparée n’est pas une mise à jour installée.", + "productVersion": "Version de l’application", + "desktopVersion": "Build du bureau", + "targetProductVersion": "Version cible de l’application", + "targetDesktopVersion": "Build cible du bureau", + "progress": "Progression du téléchargement", + "knownProgress": "{{downloaded}} / {{total}} octets", + "unknownTotal": "{{downloaded}} octets téléchargés ; taille totale inconnue.", + "unknownProgress": "En attente de la progression du téléchargement.", + "automatic": "Mises à jour automatiques", + "automaticHelp": "Les modifications s’affichent après confirmation de l’application de bureau. Désactiver demande l’annulation des mises à jour programmées.", + "confirmingSetting": "En attente de la confirmation native du réglage…", + "confirmingRestart": "Demande de redémarrage sécurisé…", + "check": "Rechercher des mises à jour", + "refresh": "Actualiser l’état", + "retry": "Relancer la recherche", + "restart": "Mettre à jour et redémarrer", + "osPrompt": "macOS peut demander une autorisation administrateur dans sa propre boîte de dialogue. Acceptez ou annulez dans celle-ci ; ne saisissez pas d’identifiants ici. Le redémarrage peut être différé si des tâches sont actives.", + "notes": "Notes de version", + "phases": { + "disabled": "Mise à jour désactivée", + "idle": "En attente de la prochaine recherche", + "checking": "Recherche de versions", + "downloading": "Téléchargement de la mise à jour", + "verifying": "Vérification de la mise à jour", + "ready": "Mise à jour préparée", + "deferred": "Mise à jour différée", + "error": "Erreur de mise à jour", + "applying": "Application de la mise à jour", + "restarting": "Redémarrage en cours", + "recovery": "Récupération nécessaire" + }, + "errors": { + "unavailable": "Le service authentifié de mise à jour du bureau est inaccessible. Son état actuel est inconnu.", + "invalidResponse": "Le service de mise à jour a renvoyé une réponse non prise en charge. Les commandes d’installation restent désactivées.", + "timeout": "L’attente de la réponse est terminée ; l’opération native peut encore être en cours. Actualisez l’état avant de réessayer." + } + }, "close": "Fermer les paramètres", "title": "Options", "account": { diff --git a/src/i18n/locales/it/settings.json b/src/i18n/locales/it/settings.json index d16f74f..df220d3 100644 --- a/src/i18n/locales/it/settings.json +++ b/src/i18n/locales/it/settings.json @@ -1,4 +1,58 @@ { + "about": { + "updateAvailable": "Aggiornamento disponibile: {{version}}" + }, + "desktopUpdate": { + "reasons": { + "discoveryFailed": "La ricerca delle versioni non è riuscita.", + "cacheInvalid": "La verifica dell’aggiornamento nella cache non è riuscita.", + "preparationCancelled": "La preparazione dell’aggiornamento è stata annullata.", + "preferencesNotPersisted": "Impossibile salvare l’impostazione degli aggiornamenti automatici." + }, + "title": "Aggiornamenti desktop", + "connecting": "Connessione al servizio di aggiornamento…", + "unconfirmed": "L’accesso agli aggiornamenti desktop non è stato confermato.", + "lastConfirmed": "Ultimo stato nativo confermato; lo stato attuale non è confermato.", + "unknownVersion": "Versione non confermata", + "incomplete": "La ricerca delle versioni è incompleta. Non è confermata l’individuazione della versione più recente.", + "preparationOnly": "L’installazione non è attualmente disponibile. Un aggiornamento preparato non è ancora installato.", + "productVersion": "Versione dell’app", + "desktopVersion": "Build desktop", + "targetProductVersion": "Versione di destinazione dell’app", + "targetDesktopVersion": "Build desktop di destinazione", + "progress": "Avanzamento del download", + "knownProgress": "{{downloaded}} / {{total}} byte", + "unknownTotal": "{{downloaded}} byte scaricati; dimensione totale sconosciuta.", + "unknownProgress": "In attesa dell’avanzamento del download.", + "automatic": "Aggiornamenti automatici", + "automaticHelp": "Le modifiche appaiono solo dopo la conferma dell’app desktop. La disattivazione richiede l’annullamento degli aggiornamenti pianificati.", + "confirmingSetting": "In attesa della conferma nativa dell’impostazione…", + "confirmingRestart": "Richiesta di riavvio sicuro…", + "check": "Cerca aggiornamenti", + "refresh": "Aggiorna stato", + "retry": "Ripeti ricerca", + "restart": "Aggiorna e riavvia", + "osPrompt": "macOS potrebbe chiedere l’approvazione dell’amministratore in una finestra di sistema. Approva o annulla lì; non inserire credenziali qui. Il riavvio può essere rinviato durante attività in corso.", + "notes": "Note di rilascio", + "phases": { + "disabled": "Aggiornamenti disattivati", + "idle": "In attesa del prossimo controllo", + "checking": "Ricerca delle versioni", + "downloading": "Download dell’aggiornamento", + "verifying": "Verifica dell’aggiornamento", + "ready": "Aggiornamento preparato", + "deferred": "Aggiornamento rinviato", + "error": "Errore di aggiornamento", + "applying": "Applicazione dell’aggiornamento", + "restarting": "Riavvio in corso", + "recovery": "Ripristino necessario" + }, + "errors": { + "unavailable": "Il servizio di aggiornamento desktop autenticato non è raggiungibile. Lo stato attuale è sconosciuto.", + "invalidResponse": "Il servizio di aggiornamento ha restituito una risposta non supportata. I controlli di installazione restano disabilitati.", + "timeout": "Attesa della risposta terminata; l’operazione nativa potrebbe essere ancora in corso. Aggiorna lo stato prima di riprovare." + } + }, "close": "Chiudi impostazioni", "title": "Opzioni", "account": { diff --git a/src/i18n/locales/ja/settings.json b/src/i18n/locales/ja/settings.json index bad302e..d60dd07 100644 --- a/src/i18n/locales/ja/settings.json +++ b/src/i18n/locales/ja/settings.json @@ -1,4 +1,58 @@ { + "about": { + "updateAvailable": "更新があります: {{version}}" + }, + "desktopUpdate": { + "reasons": { + "discoveryFailed": "リリースの検索に失敗しました。", + "cacheInvalid": "キャッシュされた更新の検証に失敗しました。", + "preparationCancelled": "更新の準備がキャンセルされました。", + "preferencesNotPersisted": "自動更新の設定を保存できませんでした。" + }, + "title": "デスクトップの更新", + "connecting": "デスクトップ更新に接続中…", + "unconfirmed": "デスクトップ更新へのアクセスは確認されていません。", + "lastConfirmed": "最後に確認したネイティブの状態です。現在の状態は未確認です。", + "unknownVersion": "バージョン未確認", + "incomplete": "リリースの検索は未完了です。最新バージョンが見つかったことを意味しません。", + "preparationOnly": "現在、インストールは利用できません。更新の準備完了はインストール完了ではありません。", + "productVersion": "アプリのバージョン", + "desktopVersion": "デスクトップビルド", + "targetProductVersion": "更新先のアプリバージョン", + "targetDesktopVersion": "更新先のデスクトップビルド", + "progress": "ダウンロードの進行状況", + "knownProgress": "{{downloaded}} / {{total}} バイト", + "unknownTotal": "{{downloaded}} バイトをダウンロード済み・合計サイズ不明", + "unknownProgress": "ダウンロードの進行情報を待っています。", + "automatic": "自動更新", + "automaticHelp": "設定はデスクトップアプリの確認後に反映されます。オフにすると、予定された更新のキャンセルを要求します。", + "confirmingSetting": "ネイティブによる設定の確認を待機中…", + "confirmingRestart": "安全な再起動を要求中…", + "check": "更新を確認", + "refresh": "状態を更新", + "retry": "もう一度確認", + "restart": "更新して再起動", + "osPrompt": "macOS が専用のシステムダイアログで管理者の承認を求める場合があります。その画面で承認またはキャンセルしてください。ここに認証情報を入力しないでください。作業中は再起動が保留される場合があります。", + "notes": "リリースノート", + "phases": { + "disabled": "更新は無効です", + "idle": "次の確認を待機中", + "checking": "リリースを確認中", + "downloading": "更新をダウンロード中", + "verifying": "更新を検証中", + "ready": "更新の準備完了", + "deferred": "更新は保留中", + "error": "更新エラー", + "applying": "更新を適用中", + "restarting": "再起動中", + "recovery": "復旧が必要です" + }, + "errors": { + "unavailable": "認証済みのデスクトップ更新に接続できません。現在の状態は不明です。", + "invalidResponse": "デスクトップ更新が未対応の応答を返しました。インストール操作は無効のままです。", + "timeout": "応答の待機を終了しましたが、ネイティブの処理は続いている可能性があります。再試行の前に状態を更新してください。" + } + }, "close": "設定を閉じる", "title": "設定。", "account": { diff --git a/src/i18n/locales/ko/settings.json b/src/i18n/locales/ko/settings.json index 29fea51..926a8d5 100644 --- a/src/i18n/locales/ko/settings.json +++ b/src/i18n/locales/ko/settings.json @@ -1,4 +1,58 @@ { + "about": { + "updateAvailable": "새 버전: {{version}}" + }, + "desktopUpdate": { + "reasons": { + "discoveryFailed": "릴리즈 탐색에 실패했습니다.", + "cacheInvalid": "캐시된 업데이트를 검증하지 못했습니다.", + "preparationCancelled": "업데이트 준비가 취소되었습니다.", + "preferencesNotPersisted": "자동 업데이트 설정을 저장하지 못했습니다." + }, + "title": "데스크톱 업데이트", + "connecting": "데스크톱 업데이트에 연결하는 중…", + "unconfirmed": "데스크톱 업데이트 접근 권한이 확인되지 않았습니다.", + "lastConfirmed": "마지막으로 확인한 네이티브 상태입니다. 현재 상태는 확인되지 않았습니다.", + "unknownVersion": "버전 확인 전", + "incomplete": "릴리즈 탐색이 완료되지 않았습니다. 최신 버전을 찾았다는 의미가 아닙니다.", + "preparationOnly": "현재 설치 기능을 사용할 수 없습니다. 업데이트 준비 완료는 설치 완료가 아닙니다.", + "productVersion": "앱 버전", + "desktopVersion": "데스크톱 빌드", + "targetProductVersion": "대상 앱 버전", + "targetDesktopVersion": "대상 데스크톱 빌드", + "progress": "다운로드 진행률", + "knownProgress": "{{downloaded}} / {{total}}바이트", + "unknownTotal": "{{downloaded}}바이트 다운로드됨 · 전체 크기 알 수 없음", + "unknownProgress": "다운로드 진행 정보 대기 중입니다.", + "automatic": "자동 업데이트", + "automaticHelp": "데스크톱 앱이 확인한 뒤에만 설정을 반영합니다. 끄면 예약된 업데이트 취소를 요청합니다.", + "confirmingSetting": "네이티브 설정 확인을 기다리는 중…", + "confirmingRestart": "안전한 재시작을 요청하는 중…", + "check": "업데이트 확인", + "refresh": "상태 새로고침", + "retry": "다시 확인", + "restart": "업데이트 후 재시작", + "osPrompt": "macOS가 별도 시스템 창에서 관리자 승인을 요청할 수 있습니다. 해당 창에서 승인하거나 취소하세요. 여기에 인증 정보를 입력하지 마세요. 작업 중이면 재시작이 보류될 수 있습니다.", + "notes": "릴리즈 노트", + "phases": { + "disabled": "업데이트 비활성화됨", + "idle": "다음 확인 대기 중", + "checking": "릴리즈 확인 중", + "downloading": "업데이트 다운로드 중", + "verifying": "업데이트 검증 중", + "ready": "업데이트 준비 완료", + "deferred": "업데이트 보류됨", + "error": "업데이트 오류", + "applying": "업데이트 적용 중", + "restarting": "재시작 중", + "recovery": "복구 필요" + }, + "errors": { + "unavailable": "인증된 데스크톱 업데이트에 연결할 수 없습니다. 현재 상태를 알 수 없습니다.", + "invalidResponse": "데스크톱 업데이트가 지원하지 않는 응답을 반환했습니다. 설치 조작은 비활성화됩니다.", + "timeout": "응답 대기를 종료했지만 네이티브 작업은 아직 실행 중일 수 있습니다. 다시 시도하기 전에 상태를 새로고침하세요." + } + }, "close": "설정 닫기", "title": "환경설정", "account": { diff --git a/src/i18n/locales/ru/settings.json b/src/i18n/locales/ru/settings.json index d856eaf..7671061 100644 --- a/src/i18n/locales/ru/settings.json +++ b/src/i18n/locales/ru/settings.json @@ -1,4 +1,58 @@ { + "about": { + "updateAvailable": "Доступно обновление: {{version}}" + }, + "desktopUpdate": { + "reasons": { + "discoveryFailed": "Не удалось найти выпуски.", + "cacheInvalid": "Проверка кэшированного обновления не пройдена.", + "preparationCancelled": "Подготовка обновления отменена.", + "preferencesNotPersisted": "Не удалось сохранить настройку автоматических обновлений." + }, + "title": "Обновления приложения", + "connecting": "Подключение к службе обновлений…", + "unconfirmed": "Доступ к службе обновлений не подтверждён.", + "lastConfirmed": "Последнее подтверждённое нативное состояние; текущее состояние не подтверждено.", + "unknownVersion": "Версия не подтверждена", + "incomplete": "Поиск выпусков не завершён. Нельзя утверждать, что найдена последняя версия.", + "preparationOnly": "Установка сейчас недоступна. Подготовленное обновление ещё не установлено.", + "productVersion": "Версия приложения", + "desktopVersion": "Сборка настольного приложения", + "targetProductVersion": "Целевая версия приложения", + "targetDesktopVersion": "Целевая сборка", + "progress": "Ход загрузки", + "knownProgress": "{{downloaded}} / {{total}} байт", + "unknownTotal": "Загружено {{downloaded}} байт; общий размер неизвестен.", + "unknownProgress": "Ожидание данных о загрузке.", + "automatic": "Автоматические обновления", + "automaticHelp": "Изменения отображаются только после подтверждения приложением. Выключение запрашивает отмену запланированных обновлений.", + "confirmingSetting": "Ожидание нативного подтверждения настройки…", + "confirmingRestart": "Запрос безопасного перезапуска…", + "check": "Проверить обновления", + "refresh": "Обновить состояние", + "retry": "Повторить проверку", + "restart": "Обновить и перезапустить", + "osPrompt": "macOS может запросить разрешение администратора в отдельном системном окне. Подтвердите или отмените там; не вводите учётные данные здесь. Перезапуск может быть отложен, пока выполняются задачи.", + "notes": "Примечания к выпуску", + "phases": { + "disabled": "Обновления отключены", + "idle": "Ожидание следующей проверки", + "checking": "Проверка выпусков", + "downloading": "Загрузка обновления", + "verifying": "Проверка обновления", + "ready": "Обновление подготовлено", + "deferred": "Обновление отложено", + "error": "Ошибка обновления", + "applying": "Применение обновления", + "restarting": "Перезапуск", + "recovery": "Требуется восстановление" + }, + "errors": { + "unavailable": "Аутентифицированная служба обновлений недоступна. Её текущее состояние неизвестно.", + "invalidResponse": "Служба обновлений вернула неподдерживаемый ответ. Элементы установки остаются отключёнными.", + "timeout": "Ожидание ответа завершено; нативная операция может продолжаться. Обновите состояние перед повторной попыткой." + } + }, "close": "Закрыть настройки", "title": "Параметры", "account": { diff --git a/src/i18n/locales/tr/settings.json b/src/i18n/locales/tr/settings.json index 2d0ccbb..f7c509a 100644 --- a/src/i18n/locales/tr/settings.json +++ b/src/i18n/locales/tr/settings.json @@ -1,4 +1,58 @@ { + "about": { + "updateAvailable": "Güncelleme mevcut: {{version}}" + }, + "desktopUpdate": { + "reasons": { + "discoveryFailed": "Sürüm araması başarısız oldu.", + "cacheInvalid": "Önbellekteki güncelleme doğrulanamadı.", + "preparationCancelled": "Güncelleme hazırlığı iptal edildi.", + "preferencesNotPersisted": "Otomatik güncelleme ayarı kaydedilemedi." + }, + "title": "Masaüstü güncellemeleri", + "connecting": "Masaüstü güncelleyicisine bağlanılıyor…", + "unconfirmed": "Masaüstü güncelleyicisine erişim doğrulanmadı.", + "lastConfirmed": "Son doğrulanmış yerel durum; mevcut durum doğrulanmadı.", + "unknownVersion": "Sürüm doğrulanmadı", + "incomplete": "Sürüm araması tamamlanmadı. En son sürümün bulunduğu doğrulanmış değildir.", + "preparationOnly": "Kurulum şu anda kullanılamıyor. Hazırlanmış bir güncelleme henüz kurulmuş değildir.", + "productVersion": "Uygulama sürümü", + "desktopVersion": "Masaüstü derlemesi", + "targetProductVersion": "Hedef uygulama sürümü", + "targetDesktopVersion": "Hedef masaüstü derlemesi", + "progress": "İndirme ilerlemesi", + "knownProgress": "{{downloaded}} / {{total}} bayt", + "unknownTotal": "{{downloaded}} bayt indirildi; toplam boyut bilinmiyor.", + "unknownProgress": "İndirme ilerlemesi bekleniyor.", + "automatic": "Otomatik güncellemeler", + "automaticHelp": "Değişiklikler yalnızca masaüstü uygulaması doğruladıktan sonra görünür. Kapatmak, planlanmış güncellemelerin iptalini ister.", + "confirmingSetting": "Ayarın yerel olarak doğrulanması bekleniyor…", + "confirmingRestart": "Güvenli yeniden başlatma isteniyor…", + "check": "Güncellemeleri denetle", + "refresh": "Durumu yenile", + "retry": "Yeniden denetle", + "restart": "Güncelle ve yeniden başlat", + "osPrompt": "macOS kendi sistem penceresinde yönetici onayı isteyebilir. Onayı veya iptali oradan yapın; buraya kimlik bilgileri girmeyin. Etkin işler varken yeniden başlatma ertelenebilir.", + "notes": "Sürüm notları", + "phases": { + "disabled": "Güncelleyici devre dışı", + "idle": "Sonraki denetim bekleniyor", + "checking": "Sürümler denetleniyor", + "downloading": "Güncelleme indiriliyor", + "verifying": "Güncelleme doğrulanıyor", + "ready": "Güncelleme hazırlandı", + "deferred": "Güncelleme ertelendi", + "error": "Güncelleme hatası", + "applying": "Güncelleme uygulanıyor", + "restarting": "Yeniden başlatılıyor", + "recovery": "Kurtarma gerekiyor" + }, + "errors": { + "unavailable": "Kimliği doğrulanmış masaüstü güncelleyicisine ulaşılamıyor. Mevcut durumu bilinmiyor.", + "invalidResponse": "Masaüstü güncelleyicisi desteklenmeyen bir yanıt döndürdü. Kurulum kontrolleri devre dışı kalır.", + "timeout": "Yanıt bekleme sona erdi; yerel işlem hâlâ çalışıyor olabilir. Yeniden denemeden önce durumu yenileyin." + } + }, "close": "Ayarları kapat", "title": "Uygulama ayarları", "account": { diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index 35ad036..b3f16f1 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -1,4 +1,58 @@ { + "about": { + "updateAvailable": "有可用更新:{{version}}" + }, + "desktopUpdate": { + "reasons": { + "discoveryFailed": "发行版搜索失败。", + "cacheInvalid": "缓存的更新未通过验证。", + "preparationCancelled": "更新准备已取消。", + "preferencesNotPersisted": "无法保存自动更新设置。" + }, + "title": "桌面更新", + "connecting": "正在连接桌面更新服务…", + "unconfirmed": "尚未确认桌面更新访问权限。", + "lastConfirmed": "上次确认的原生状态;当前状态尚未确认。", + "unknownVersion": "版本未确认", + "incomplete": "发行版搜索尚未完成,不能据此认定已找到最新版本。", + "preparationOnly": "目前无法安装。更新准备完成并不代表已安装。", + "productVersion": "应用版本", + "desktopVersion": "桌面构建版本", + "targetProductVersion": "目标应用版本", + "targetDesktopVersion": "目标桌面构建版本", + "progress": "下载进度", + "knownProgress": "{{downloaded}} / {{total}} 字节", + "unknownTotal": "已下载 {{downloaded}} 字节;总大小未知。", + "unknownProgress": "正在等待下载进度。", + "automatic": "自动更新", + "automaticHelp": "仅在桌面应用确认后才显示设置变更。关闭此项会请求取消计划中的更新。", + "confirmingSetting": "正在等待原生设置确认…", + "confirmingRestart": "正在请求安全重启…", + "check": "检查更新", + "refresh": "刷新状态", + "retry": "重新检查", + "restart": "更新并重启", + "osPrompt": "macOS 可能会在系统对话框中请求管理员批准。请在该窗口批准或取消,不要在此输入凭据。有任务运行时,重启可能会推迟。", + "notes": "发行说明", + "phases": { + "disabled": "更新已禁用", + "idle": "等待下次检查", + "checking": "正在检查发行版", + "downloading": "正在下载更新", + "verifying": "正在验证更新", + "ready": "更新准备完成", + "deferred": "更新已推迟", + "error": "更新错误", + "applying": "正在应用更新", + "restarting": "正在重启", + "recovery": "需要恢复" + }, + "errors": { + "unavailable": "无法连接已认证的桌面更新服务,当前状态未知。", + "invalidResponse": "桌面更新服务返回了不受支持的响应,安装控件将保持禁用。", + "timeout": "已停止等待响应,但原生操作可能仍在运行。重试前请刷新状态。" + } + }, "close": "关闭设置", "title": "设置", "account": { diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json index 3b8e576..541ecbd 100644 --- a/src/i18n/locales/zh-TW/settings.json +++ b/src/i18n/locales/zh-TW/settings.json @@ -1,4 +1,58 @@ { + "about": { + "updateAvailable": "有可用更新:{{version}}" + }, + "desktopUpdate": { + "reasons": { + "discoveryFailed": "發行版搜尋失敗。", + "cacheInvalid": "快取的更新未通過驗證。", + "preparationCancelled": "更新準備已取消。", + "preferencesNotPersisted": "無法儲存自動更新設定。" + }, + "title": "桌面更新", + "connecting": "正在連線至桌面更新服務…", + "unconfirmed": "尚未確認桌面更新存取權限。", + "lastConfirmed": "上次確認的原生狀態;目前狀態尚未確認。", + "unknownVersion": "版本未確認", + "incomplete": "發行版搜尋尚未完成,不能據此認定已找到最新版本。", + "preparationOnly": "目前無法安裝。更新準備完成不代表已安裝。", + "productVersion": "應用程式版本", + "desktopVersion": "桌面組建版本", + "targetProductVersion": "目標應用程式版本", + "targetDesktopVersion": "目標桌面組建版本", + "progress": "下載進度", + "knownProgress": "{{downloaded}} / {{total}} 位元組", + "unknownTotal": "已下載 {{downloaded}} 位元組;總大小未知。", + "unknownProgress": "正在等待下載進度。", + "automatic": "自動更新", + "automaticHelp": "僅在桌面應用程式確認後才顯示設定變更。關閉此項會要求取消排程中的更新。", + "confirmingSetting": "正在等待原生設定確認…", + "confirmingRestart": "正在要求安全重新啟動…", + "check": "檢查更新", + "refresh": "重新整理狀態", + "retry": "重新檢查", + "restart": "更新並重新啟動", + "osPrompt": "macOS 可能會在系統對話框中要求管理員核准。請在該視窗核准或取消,不要在此輸入憑證。有工作執行時,重新啟動可能會延後。", + "notes": "發行說明", + "phases": { + "disabled": "更新已停用", + "idle": "等待下次檢查", + "checking": "正在檢查發行版", + "downloading": "正在下載更新", + "verifying": "正在驗證更新", + "ready": "更新準備完成", + "deferred": "更新已延後", + "error": "更新錯誤", + "applying": "正在套用更新", + "restarting": "正在重新啟動", + "recovery": "需要復原" + }, + "errors": { + "unavailable": "無法連線至已驗證的桌面更新服務,目前狀態未知。", + "invalidResponse": "桌面更新服務傳回不支援的回應,安裝控制項將保持停用。", + "timeout": "已停止等待回應,但原生作業可能仍在執行。重試前請重新整理狀態。" + } + }, "close": "關閉設定", "title": "設定", "account": { From 6f99642a106d076a293fbc0477e30402e8be216e Mon Sep 17 00:00:00 2001 From: devswha <25837994+devswha@users.noreply.github.com> Date: Tue, 8 Sep 2026 00:46:38 +0900 Subject: [PATCH 03/15] fix(updater): keep snapshot validation compatible with ES2020 --- shared/desktopUpdateProtocol.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared/desktopUpdateProtocol.ts b/shared/desktopUpdateProtocol.ts index eaaf4f9..6f39da3 100644 --- a/shared/desktopUpdateProtocol.ts +++ b/shared/desktopUpdateProtocol.ts @@ -34,7 +34,7 @@ export function isDesktopUpdateCommand(value: unknown): value is DesktopUpdateCo export function isDesktopUpdateSnapshot(value: unknown): value is DesktopUpdateSnapshot { const keys = ['protocolVersion', 'phase', 'automatic', 'productVersion', 'desktopVersion', 'targetProductVersion', 'targetDesktopVersion', 'discoveryIncomplete', 'reason', 'installationAvailable', 'downloadedBytes', 'totalBytes', 'notes']; - if (!record(value) || Object.keys(value).length !== keys.length || !keys.every((key) => Object.hasOwn(value, key)) + 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' From 49c1010145850436e28406d40de9038df4b2b422 Mon Sep 17 00:00:00 2001 From: devswha <25837994+devswha@users.noreply.github.com> Date: Tue, 8 Sep 2026 01:33:07 +0900 Subject: [PATCH 04/15] fix(updater): validate preparation in the isolated desktop app --- docs/DESKTOP-QA-PROFILE.md | 7 + docs/DESKTOP-UPDATER-QA-PREPARATION.md | 109 ++++ docs/MACOS-UPDATER-HANDOFF.md | 26 + docs/images/updater/about-native-qa.png | Bin 0 -> 73550 bytes src-tauri/examples/qa_profile_init.rs | 55 ++ src-tauri/examples/support/updater_journal.rs | 599 ++++++++++++++++++ src-tauri/examples/updater_journal_probe.rs | 574 +++++++++++++++++ src-tauri/src/qa_profile.rs | 40 ++ src-tauri/src/updater.rs | 28 +- src-tauri/src/updater_attempt.rs | 2 +- src-tauri/src/updater_bridge.rs | 145 ++++- 11 files changed, 1557 insertions(+), 28 deletions(-) create mode 100644 docs/DESKTOP-UPDATER-QA-PREPARATION.md create mode 100644 docs/images/updater/about-native-qa.png create mode 100644 src-tauri/examples/qa_profile_init.rs create mode 100644 src-tauri/examples/support/updater_journal.rs create mode 100644 src-tauri/examples/updater_journal_probe.rs diff --git a/docs/DESKTOP-QA-PROFILE.md b/docs/DESKTOP-QA-PROFILE.md index 4366888..1e23bf0 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-UPDATER-QA-PREPARATION.md b/docs/DESKTOP-UPDATER-QA-PREPARATION.md new file mode 100644 index 0000000..326b21e --- /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/MACOS-UPDATER-HANDOFF.md b/docs/MACOS-UPDATER-HANDOFF.md index c317e31..038432e 100644 --- a/docs/MACOS-UPDATER-HANDOFF.md +++ b/docs/MACOS-UPDATER-HANDOFF.md @@ -1,5 +1,31 @@ # macOS 자동 업데이트 — 남은 작업 인계 +## 사용자 환경 확인 후 실제 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`의 미배포 변경이다. **전체 자동 diff --git a/docs/images/updater/about-native-qa.png b/docs/images/updater/about-native-qa.png new file mode 100644 index 0000000000000000000000000000000000000000..fc33ac1046f9d1f0fed5a14282140b3b7127e5f6 GIT binary patch literal 73550 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 V4(ZOiAvRa!#w*KGZ@10SzX5i2nIHfF literal 0 HcmV?d00001 diff --git a/src-tauri/examples/qa_profile_init.rs b/src-tauri/examples/qa_profile_init.rs new file mode 100644 index 0000000..250edce --- /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 0000000..e5d36ce --- /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 0000000..def865e --- /dev/null +++ b/src-tauri/examples/updater_journal_probe.rs @@ -0,0 +1,574 @@ +//! 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"] +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/qa_profile.rs b/src-tauri/src/qa_profile.rs index 4147044..4e534cf 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/updater.rs b/src-tauri/src/updater.rs index 844b882..93b6ff7 100644 --- a/src-tauri/src/updater.rs +++ b/src-tauri/src/updater.rs @@ -84,6 +84,14 @@ impl Default for Snapshot { } } +fn startup_snapshot(automatic: bool) -> Snapshot { + Snapshot { + phase: Phase::Idle, + automatic, + ..Snapshot::default() + } +} + struct Control { snapshot: Snapshot, snapshot_generation: u64, @@ -489,8 +497,10 @@ async fn run( { let mut control = owner.control.lock().expect("update owner lock poisoned"); control.store = Some(runtime.store.clone()); - control.snapshot.automatic = preferences.automatic; - control.snapshot.phase = Phase::Idle; + // A startup invalidation may have left server_not_ready or an old + // target behind. Healthy initialization is fresh even with auto off, + // when no later network phase will clear that stale reason for us. + control.snapshot = startup_snapshot(preferences.automatic); control.snapshot_generation = owner.generation.load(Ordering::Acquire); } // Every restart re-verifies cache bytes, even when automatic checking is off. @@ -872,6 +882,20 @@ mod tests { } } + #[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(); diff --git a/src-tauri/src/updater_attempt.rs b/src-tauri/src/updater_attempt.rs index 0354608..5d8e69b 100644 --- a/src-tauri/src/updater_attempt.rs +++ b/src-tauri/src/updater_attempt.rs @@ -9,7 +9,7 @@ use std::{ path::{Component, Path, PathBuf}, }; -const ATTEMPT_RECORD: &str = "desktop-update-attempt.json"; +pub(crate) const ATTEMPT_RECORD: &str = "desktop-update-attempt.json"; /// Admit a startup only when the update-attempt record is validated absent. /// Any present directory entry, regardless of its contents or type, blocks. diff --git a/src-tauri/src/updater_bridge.rs b/src-tauri/src/updater_bridge.rs index 2145d66..4638d80 100644 --- a/src-tauri/src/updater_bridge.rs +++ b/src-tauri/src/updater_bridge.rs @@ -383,7 +383,45 @@ fn peer_pid(stream: &UnixStream) -> Option { .then_some(pid as u32) } -fn serve(mut stream: UnixStream, run: &Run, app: &AppHandle) { +fn serve(stream: UnixStream, run: &Run, 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::(); + match &request.command { + Command::Status {} => updater.snapshot(admit), + Command::Check {} => updater.manual_check(admit), + Command::SetAutomatic { automatic } => updater.set_automatic(*automatic, admit), + Command::Restart {} => { + if admit() { + Err("updater_installation_unavailable") + } else { + Err("updater_unauthorized") + } + } + } + }); +} + +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; @@ -409,30 +447,7 @@ fn serve(mut stream: UnixStream, run: &Run, app: &AppHandle) { let Ok(request) = read_frame::(&mut stream, deadline) else { return; }; - // This closure runs INSIDE the coordinator's command serialization lock, - // immediately before the operation. Retirement and later mutation sequence - // claims cannot be overtaken by a queued old preference write. - let admit = || { - !app.state::() - .is_shutting_down() - && run - .authority - .lock() - .is_ok_and(|mut authority| authority.admit(&request, peer)) - }; - let updater = app.state::(); - let result = match &request.command { - Command::Status {} => updater.snapshot(admit), - Command::Check {} => updater.manual_check(admit), - Command::SetAutomatic { automatic } => updater.set_automatic(*automatic, admit), - Command::Restart {} => { - if admit() { - Err("updater_installation_unavailable") - } else { - Err("updater_unauthorized") - } - } - }; + let result = execute(&request, peer); let response = match result { Ok(snapshot) => { serde_json::json!({"protocolVersion":1,"sequence":request.sequence,"ok":true,"snapshot":snapshot}) @@ -536,6 +551,86 @@ mod tests { 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(), + }; + 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 replay_window_is_bounded_and_accepts_reordered_live_requests_only_once() { let mut window = ReplayWindow::default(); From da8a0c5218f78f827a013f6c70534ecac3f2ceeb Mon Sep 17 00:00:00 2001 From: devswha <25837994+devswha@users.noreply.github.com> Date: Tue, 8 Sep 2026 15:09:47 +0900 Subject: [PATCH 05/15] feat(updater): connect admission and preserve unsent drafts --- ...DESKTOP-UPDATE-ADMISSION-IMPLEMENTATION.md | 175 +++++ docs/DESKTOP-UPDATE-ADMISSION.md | 6 + docs/MACOS-UPDATER-HANDOFF.md | 16 + server/app-factory.js | 15 +- server/gjc-bun-oauth-controller.bun.test.ts | 271 +++++++ server/gjc-bun-oauth-controller.ts | 78 ++- server/gjc-bun-sdk-adapter.ts | 142 +++- server/gjc-sdk-contract.bun.test.ts | 291 ++++++++ server/gjc-worker-client.test.ts | 661 ++++++++++++++++++ server/gjc-worker-client.ts | 276 +++++++- server/index.js | 56 +- server/modules/assets/assets.routes.ts | 177 +++-- .../assets/tests/assets.routes.test.ts | 237 ++++++- .../modules/automation/automation.routes.ts | 42 +- .../notifications/notifications.routes.ts | 17 +- server/modules/projects/projects.routes.ts | 8 +- .../services/chat-run-registry.service.ts | 47 +- .../services/chat-websocket.service.ts | 25 +- .../services/shell-websocket.service.test.ts | 341 ++++++++- .../services/shell-websocket.service.ts | 103 ++- .../services/websocket-auth.service.ts | 14 +- .../websocket/tests/chat-run-registry.test.ts | 27 + .../tests/websocket-auth.service.test.ts | 28 + server/routes/auth.js | 5 +- server/routes/git.js | 33 +- server/routes/gjc-jobs.js | 45 +- server/routes/settings.js | 41 +- server/routes/system.js | 17 +- server/routes/user.js | 9 +- .../services/desktop-chat-admission.test.ts | 119 ++++ .../services/desktop-http-admission.test.ts | 119 ++++ .../desktop-http-route-coverage.test.ts | 189 +++++ .../desktop-restart-authority.test.ts | 32 + server/services/desktop-restart-authority.ts | 15 + .../services/desktop-restart-runtime.test.ts | 29 + server/services/desktop-restart-runtime.ts | 16 + server/shared/interfaces.ts | 8 + server/shared/utils.ts | 24 +- server/voice-proxy.js | 144 ++-- server/voice-proxy.test.js | 187 +++++ .../composerDraftDurability.dom.bun.test.tsx | 331 +++++++++ .../chat/hooks/useChatComposerState.ts | 151 ++-- .../chat/hooks/useDurableComposerDraft.ts | 302 ++++++++ .../ComposerDraftPersistenceHarness.tsx | 58 ++ .../tests/fixtures/composerDraftBrowserQa.mjs | 43 ++ src/components/chat/utils/chatStorage.ts | 29 +- .../composerDraftStorage.dom.bun.test.tsx | 121 ++++ .../chat/utils/composerDraftStorage.ts | 263 +++++++ .../chat/utils/composerQueueProjection.ts | 32 + .../chat/view/ChatComposer.dom.bun.test.tsx | 24 +- src/components/chat/view/ChatComposer.tsx | 16 + src/components/chat/view/ChatInterface.tsx | 2 + .../chat/view/QueuedMessageCard.tsx | 4 +- .../useQueuedMessageAutoSend.dom.bun.test.tsx | 14 + src/hooks/useQueuedMessageAutoSend.ts | 3 + src/i18n/locales/de/chat.json | 10 + src/i18n/locales/en/chat.json | 10 + src/i18n/locales/fr/chat.json | 10 + src/i18n/locales/it/chat.json | 10 + src/i18n/locales/ja/chat.json | 10 + src/i18n/locales/ko/chat.json | 10 + src/i18n/locales/ru/chat.json | 10 + src/i18n/locales/tr/chat.json | 10 + src/i18n/locales/zh-CN/chat.json | 10 + src/i18n/locales/zh-TW/chat.json | 10 + 65 files changed, 5237 insertions(+), 341 deletions(-) create mode 100644 docs/DESKTOP-UPDATE-ADMISSION-IMPLEMENTATION.md create mode 100644 server/gjc-bun-oauth-controller.bun.test.ts create mode 100644 server/services/desktop-chat-admission.test.ts create mode 100644 server/services/desktop-http-admission.test.ts create mode 100644 server/services/desktop-http-route-coverage.test.ts create mode 100644 server/services/desktop-restart-runtime.test.ts create mode 100644 server/services/desktop-restart-runtime.ts create mode 100644 server/voice-proxy.test.js create mode 100644 src/components/chat/hooks/composerDraftDurability.dom.bun.test.tsx create mode 100644 src/components/chat/hooks/useDurableComposerDraft.ts create mode 100644 src/components/chat/tests/fixtures/ComposerDraftPersistenceHarness.tsx create mode 100644 src/components/chat/tests/fixtures/composerDraftBrowserQa.mjs create mode 100644 src/components/chat/utils/composerDraftStorage.dom.bun.test.tsx create mode 100644 src/components/chat/utils/composerDraftStorage.ts create mode 100644 src/components/chat/utils/composerQueueProjection.ts diff --git a/docs/DESKTOP-UPDATE-ADMISSION-IMPLEMENTATION.md b/docs/DESKTOP-UPDATE-ADMISSION-IMPLEMENTATION.md new file mode 100644 index 0000000..990a6c0 --- /dev/null +++ b/docs/DESKTOP-UPDATE-ADMISSION-IMPLEMENTATION.md @@ -0,0 +1,175 @@ +# Desktop updater: partial runtime admission and draft durability + +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 index d43a315..213a49e 100644 --- a/docs/DESKTOP-UPDATE-ADMISSION.md +++ b/docs/DESKTOP-UPDATE-ADMISSION.md @@ -1,5 +1,11 @@ # 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. diff --git a/docs/MACOS-UPDATER-HANDOFF.md b/docs/MACOS-UPDATER-HANDOFF.md index 038432e..bbc916f 100644 --- a/docs/MACOS-UPDATER-HANDOFF.md +++ b/docs/MACOS-UPDATER-HANDOFF.md @@ -1,5 +1,21 @@ # macOS 자동 업데이트 — 남은 작업 인계 +## 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 테스트 diff --git a/server/app-factory.js b/server/app-factory.js index e2717c1..a04a2b9 100644 --- a/server/app-factory.js +++ b/server/app-factory.js @@ -10,6 +10,7 @@ import { createDesktopAuth, DESKTOP_BOOTSTRAP_PATH } from './middleware/desktop- 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 @@ -28,6 +29,7 @@ export function createGjcAppFactory({ 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. */ } @@ -36,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; @@ -102,6 +107,10 @@ export function createGjcAppFactory({ .catch((error) => 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/gjc-bun-oauth-controller.bun.test.ts b/server/gjc-bun-oauth-controller.bun.test.ts new file mode 100644 index 0000000..c7576e6 --- /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 b51b960..a5505d0 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 0738d7a..b990f0b 100644 --- a/server/gjc-bun-sdk-adapter.ts +++ b/server/gjc-bun-sdk-adapter.ts @@ -1,3 +1,4 @@ +import { randomUUID } from 'node:crypto'; import { realpath } from 'node:fs/promises'; import { createAgentSession, discoverAuthStorage } from '@gajae-code/coding-agent/sdk/session'; @@ -16,7 +17,7 @@ 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 { GjcBunAskController } from './gjc-bun-ask-controller.js'; @@ -84,6 +85,8 @@ export type GjcSessionTitleGenerator = (firstMessage: string, registry: ModelReg export type GjcBunSdkAdapterOptions = { 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 +95,20 @@ 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 ('sdk_background_ownership_unproven' | 'sdk_cleanup_unconfirmed')[]; +}>; + type ActiveRun = { goals?: GjcGoalSession; goalScope?: GjcGoalScope; @@ -111,6 +128,7 @@ type ActiveRun = { askController: GjcBunAskController; state: SdkRunState; abortState: 'idle' | 'aborting' | 'aborted'; + settling: boolean; appSessionId?: string; delegation?: GjcDelegationExecutor; }; @@ -118,7 +136,7 @@ type ActiveRun = { const FAILURE = 'GJC SDK configuration is invalid.'; 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 +402,61 @@ 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; + #sdkBackgroundOwnershipUnproven = false; #cleanupFailure?: GjcCleanupUnconfirmedError; + getGeneration(): string { + return `${this.#generation}:${this.#revision}:${this.oauth.getGeneration()}`; + } + + /** Fixed-size, credential-free observation. Never polls or disposes the SDK. */ + snapshotActivity(): GjcSdkActivitySnapshot { + const oauth = this.oauth.snapshotActivity(); + 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'); + return { + generation: this.getGeneration(), revision: this.#revision + oauth.revision, + starting, running, settling, background: this.#backgroundTitles, operations: this.#operations, + oauth, complete: unknown.length === 0, 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 +464,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 +484,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, @@ -431,11 +503,17 @@ export class GjcBunSdkAdapter implements GjcWorkerRuntime { cancel: (attemptId) => oauth.cancel(attemptId), subscribe: (listener) => oauth.subscribe(listener), close: () => oauth.close(), + snapshotActivity: () => oauth.snapshotActivity(), + getGeneration: () => oauth.getGeneration(), }; } async modelCatalog() { this.#assertHealthy(); + return this.#withOperation(() => this.#modelCatalog()); + } + + async #modelCatalog() { const seen = new Set(); const models = []; const candidates = await modelsForCredential(this.authStorage, this.modelRegistry, { kind: 'stored' }); @@ -471,6 +549,7 @@ export class GjcBunSdkAdapter implements GjcWorkerRuntime { 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 +559,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 +581,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 +601,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 +614,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 +623,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 +639,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 +647,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; } @@ -568,6 +664,10 @@ export class GjcBunSdkAdapter implements GjcWorkerRuntime { async inspectGjcGoal(scope: GjcGoalScope, providerSessionId: string, sessionRoot: string): Promise { this.#assertHealthy(); + 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 +683,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,6 +726,8 @@ 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(), @@ -638,6 +744,7 @@ export class GjcBunSdkAdapter implements GjcWorkerRuntime { } this.#assertHealthy(); this.#runs.delete(runId); + this.#revision += 1; forwardPromptTerminal(writer, run.state, didRunFail ? runError ?? new Error(FAILURE) : undefined); } this.#assertHealthy(); @@ -735,6 +842,17 @@ export class GjcBunSdkAdapter implements GjcWorkerRuntime { delegation.setToolUIContext(askController.uiContext); } this.#assertHealthy(); + // SDK 0.16.4 exposes diagnostic job/message counts, but no atomic, + // revisioned proof covering registrations, deliveries, continuations + // and physical bash descendants. Even dispose() can outlive its public + // deadline. Withholding job/cron or seeing empty snapshots is not proof. + // Retain one bounded unknown across normal cleanup and failed creation; + // only verified reaping of the owning worker can discharge it. Do + // not invoke teardown, disable async bash, or read SDK private state. + if (!this.#sdkBackgroundOwnershipUnproven) { + this.#sdkBackgroundOwnershipUnproven = true; + this.#revision += 1; + } const result = await (this.options.createSessionFactory ?? createAgentSession)({ ...sessionOptions, // CustomTool is the public SDK replacement API. Never construct the @@ -797,6 +915,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 +933,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 +957,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 +1029,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 +1048,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(); diff --git a/server/gjc-sdk-contract.bun.test.ts b/server/gjc-sdk-contract.bun.test.ts index 681ec5e..6bd3290 100644 --- a/server/gjc-sdk-contract.bun.test.ts +++ b/server/gjc-sdk-contract.bun.test.ts @@ -147,6 +147,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 +205,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 { @@ -2709,6 +2711,295 @@ 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('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('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 88df0f9..fba41cb 100644 --- a/server/gjc-worker-client.test.ts +++ b/server/gjc-worker-client.test.ts @@ -7,10 +7,14 @@ import { join } from 'node:path'; import { PassThrough } from 'node:stream'; import { after, test } from 'node:test'; +import type { DesktopOwnerActivity } from '../shared/desktopUpdateProtocol.js'; + import { DEFAULT_INITIALIZE_TIMEOUT_MS, DEFAULT_SHUTDOWN_TIMEOUT_MS, GjcWorkerSupervisor, + createGjcWorkerDesktopRestartReader, + getGjcWorkerSupervisor, killWorkerTree, resolveGjcResumeSessionRoot, } from './gjc-worker-client.js'; @@ -1320,3 +1324,660 @@ 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); + } +} + +test('desktop reader is inert, detached from returned snapshots, and bound to the production singleton by default', () => { + 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 = reader.read(); + assertDesktopIdle(first); + assert.equal(first.generation, generation); + (first.unknown as string[]).push('caller_mutation'); + first.queued = 100; + assertDesktopIdle(reader.read()); + assert.equal(reader.getGeneration(), generation); + assert.notEqual(new GjcWorkerSupervisor().getGeneration(), generation); + assert.deepEqual(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 = reader.read(); + 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 = reader.read(); + assert.equal(pending.queued, 1); + assert.equal(pending.starting, 0); + assert.notEqual(pending.generation, initializing.generation); + peer.respond(request); + const acknowledged = reader.read(); + 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 = reader.read(); + 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 = reader.read(); + 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 = reader.read(); + 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 = reader.read(); + 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(reader.read().queued, 0); + assert.equal(reader.read().running, 1, 'run finalization is still queued after acknowledgement'); + await run; + await new Promise((resolve) => setImmediate(resolve)); + const finished = reader.read(); + 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.message, 'replacement message'); + assert.equal((start.payload.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 4543285..9a01b97 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 { @@ -133,7 +134,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 +425,17 @@ 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; constructor(runtime: GjcWorkerSupervisorRuntime = {}) { + this.hasRunProcessReaper = runtime.killProcessTree !== undefined; this.runtime = { spawn: runtime.spawn ?? spawnChild as unknown as Spawn, corePath: runtime.corePath, @@ -445,6 +457,65 @@ 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}`; + } + + /** + * Read-only restart evidence, NOT an idle-worker probe. Protocol v1 does not + * account for SDK continuations, title tasks, background bash or OAuth unwind. + * Keep that uncertainty until the existing process-tree reap barrier succeeds. + * 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 +524,53 @@ export class GjcWorkerSupervisor { method: GjcWorkerOAuthRequestMethod, payload: JsonObject, ): Promise { - await this.ensureWorker(); - return this.request(method, undefined, payload); + const release = this.beginActivity('settling'); + try { + await this.ensureWorker(); + return await this.request(method, undefined, payload); + } finally { + release(); + } } async modelCatalog(): Promise { - await this.ensureWorker(); - return this.request('models.catalog', undefined, {}); + const release = this.beginActivity('settling'); + try { + await this.ensureWorker(); + return await this.request('models.catalog', undefined, {}); + } finally { + release(); + } } 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 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(); + } } 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 +616,7 @@ export class GjcWorkerSupervisor { } private emitOAuthEvent(event: GjcWorkerOAuthEvent): void { + this.activityChanged(); for (const listener of this.oauthListeners) { try { listener(event); @@ -544,10 +636,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 +669,44 @@ export class GjcWorkerSupervisor { resolveOutcome, resolveStarted, rejectStarted, started: false, }; this.runs.set(runId, run); + this.activityChanged(); void this.startRun(run, message); 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 +727,7 @@ export class GjcWorkerSupervisor { () => { run.phase = 'request_issued'; run.started = true; + this.activityChanged(); run.resolveStarted(); }, ); @@ -621,6 +736,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 +747,8 @@ export class GjcWorkerSupervisor { true, error instanceof GjcConfigurationError ? error.message : SAFE_FAILURE, ); + } finally { + release(); } } @@ -641,6 +759,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 +812,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 +829,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 +892,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 +904,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 +940,7 @@ export class GjcWorkerSupervisor { return Promise.reject(error); } const tracked = this.tracker.track(request); + this.activityChanged(); try { child.stdin.write(frame); onWritten?.(); @@ -816,6 +954,9 @@ 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. + this.requestTimeoutUncertainty = true; this.expiredRequests.set(request.id, { method: request.method, ...('sessionId' in request ? { sessionId: request.sessionId } : {}), @@ -824,6 +965,7 @@ export class GjcWorkerSupervisor { const oldest = this.expiredRequests.keys().next().value; if (oldest) this.expiredRequests.delete(oldest); } + this.activityChanged(); } }, timeout); timer.unref?.(); @@ -859,6 +1001,7 @@ export class GjcWorkerSupervisor { 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 +1011,7 @@ export class GjcWorkerSupervisor { const expired = this.expiredRequests.get(response.id); if (!expired) { this.tracker.settle(response); + this.activityChanged(); return; } @@ -879,6 +1023,7 @@ export class GjcWorkerSupervisor { ); } this.expiredRequests.delete(response.id); + this.activityChanged(); } private handleEvent(event: GjcWorkerEventFrame): void { @@ -895,10 +1040,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 +1057,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 +1110,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 +1128,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 +1138,8 @@ export class GjcWorkerSupervisor { return object(response.result)?.steered === true; } catch { return false; + } finally { + release(); } } @@ -994,20 +1150,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 +1199,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 +1209,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 +1219,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 +1240,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 +1260,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 +1276,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 +1339,15 @@ 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); 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 +1374,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 +1433,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 +1505,18 @@ function reportWorkerDiagnostic(message: string): void { const supervisor = new GjcWorkerSupervisor({ enrichOptions: enrichGjcSdkRunOptions, diagnostic: reportWorkerDiagnostic }); registerGjcRuntimeModelCatalogLoader(() => supervisor.modelCatalog()); + +/** No lazy spawn, shutdown, admission mutation or SDK idle claim. */ +export function createGjcWorkerDesktopRestartReader(worker: GjcWorkerSupervisor = supervisor): { + getGeneration(): string; + read(): DesktopOwnerActivity; +} { + return Object.freeze({ + getGeneration: () => worker.getGeneration(), + read: () => worker.snapshotActivity(), + }); +} + 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/index.js b/server/index.js index e191db0..a05a85c 100755 --- a/server/index.js +++ b/server/index.js @@ -10,7 +10,7 @@ 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, getOpenCodeDatabasePath, validateWorkspacePath } from '@/shared/utils.js'; import { openProjectFileForWrite, resolveProjectEntryForMutation, @@ -22,6 +22,7 @@ import { closeSessionsWatcher, configureSessionWorktrees, initializeSessionsWatc 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,6 +32,7 @@ import { steerGjcRun, getPendingGjcApprovalsForSession, getGjcWorkerSupervisor, + createGjcWorkerDesktopRestartReader, resolveGjcToolApproval, shutdownGjcWorker, spawnGjcRun, @@ -50,6 +52,8 @@ 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 { 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'; @@ -110,6 +114,13 @@ 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 desktopRestartAdmission = createDesktopRestartRuntime({ + chat: { getGeneration: chatRunRegistry.getGeneration, read: chatRunRegistry.snapshotActivity }, + 'gjc-worker': createGjcWorkerDesktopRestartReader(), + shell: { getGeneration: getShellActivityGeneration, read: snapshotShellActivity }, +}); function gjcSpawn(message, options, writer) { return spawnGjcRun(message, { ...options, @@ -132,6 +143,7 @@ function steerGjcChatRun(runId, message) { } const { app, server, wss } = createGjcAppFactory({ + desktopRestartAdmission, desktopUpdateRelay: new DesktopUpdateRelay(), authority: gjcJobAuthority, orchestrator: gjcJobOrchestrator, @@ -259,7 +271,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; @@ -337,9 +349,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) { @@ -377,10 +389,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; @@ -426,10 +438,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; @@ -489,10 +501,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; @@ -566,9 +578,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 @@ -599,7 +611,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 @@ -649,7 +661,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; @@ -724,10 +736,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; @@ -805,10 +817,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; @@ -871,7 +883,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 @@ -1059,14 +1071,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(); @@ -1338,7 +1350,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) => { diff --git a/server/modules/assets/assets.routes.ts b/server/modules/assets/assets.routes.ts index dec524f..f2094f1 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 bfa0b75..9592448 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 9b91abd..235767b 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/notifications/notifications.routes.ts b/server/modules/notifications/notifications.routes.ts index c86960d..50d916c 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/projects/projects.routes.ts b/server/modules/projects/projects.routes.ts index 329a186..63d739d 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/websocket/services/chat-run-registry.service.ts b/server/modules/websocket/services/chat-run-registry.service.ts index ea23344..e7a7d35 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 80624ba..2c457f3 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 491c311..1862bc8 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 ff6e603..8eb033d 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 2ddc540..3cae57e 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 7f9b474..e26d915 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 bacfef2..6528d92 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 adfe37b..0ccf4f1 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 e88be8e..6ebfa04 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 f2de364..b5dd684 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 cc1832e..ffb8bc0 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 e8cef27..564a80e 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 86431e8..e9f9470 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 0000000..40ff6e6 --- /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 0000000..47bab6b --- /dev/null +++ b/server/services/desktop-http-admission.test.ts @@ -0,0 +1,119 @@ +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 } 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 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']) { + assert.equal((await fetch(`http://127.0.0.1:${address.port}/${route}`)).status, 500); + assert.equal((await admission.snapshot()).ingress, 0); + } +}); + +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 0000000..9655f33 --- /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 index adf07b4..930596b 100644 --- a/server/services/desktop-restart-authority.test.ts +++ b/server/services/desktop-restart-authority.test.ts @@ -101,6 +101,38 @@ test('known ingress returns busy immediately without waiting for any owner reade release(); }); +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(); diff --git a/server/services/desktop-restart-authority.ts b/server/services/desktop-restart-authority.ts index e21c478..9f8428b 100644 --- a/server/services/desktop-restart-authority.ts +++ b/server/services/desktop-restart-authority.ts @@ -80,6 +80,7 @@ type Attempt = { expiresAt?: number; cancelExpiry?: () => void; generations?: ReadonlyMap; + preparedRevision?: number; committing?: Promise; resolveCommit?: (result: DesktopRestartCommitResult) => void; }; @@ -163,6 +164,18 @@ export class DesktopRestartAuthority { 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; @@ -270,6 +283,7 @@ export class DesktopRestartAuthority { 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 } }; } @@ -280,6 +294,7 @@ export class DesktopRestartAuthority { 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)); } diff --git a/server/services/desktop-restart-runtime.test.ts b/server/services/desktop-restart-runtime.test.ts new file mode 100644 index 0000000..b327d5f --- /dev/null +++ b/server/services/desktop-restart-runtime.test.ts @@ -0,0 +1,29 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +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')); +}); diff --git a/server/services/desktop-restart-runtime.ts b/server/services/desktop-restart-runtime.ts new file mode 100644 index 0000000..1d5baa5 --- /dev/null +++ b/server/services/desktop-restart-runtime.ts @@ -0,0 +1,16 @@ +import { DesktopRestartAuthority, 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> = {}, +): DesktopRestartAuthority { + return new DesktopRestartAuthority({ requiredOwners: DESKTOP_RESTART_REQUIRED_OWNERS, ownerReaders }); +} diff --git a/server/shared/interfaces.ts b/server/shared/interfaces.ts index e7361ff..ce0f0ba 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 e069f4b..897b0c7 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,25 @@ export function createApiSuccessResponse(data: TData): ApiSuccessShape Promise): RequestHandler { +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. + release = admission?.enter('http:handler'); + 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 1ea4a6d..349b7a0 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 0000000..7c6dada --- /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/src/components/chat/hooks/composerDraftDurability.dom.bun.test.tsx b/src/components/chat/hooks/composerDraftDurability.dom.bun.test.tsx new file mode 100644 index 0000000..d2647dc --- /dev/null +++ b/src/components/chat/hooks/composerDraftDurability.dom.bun.test.tsx @@ -0,0 +1,331 @@ +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 { 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(); 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/useChatComposerState.ts b/src/components/chat/hooks/useChatComposerState.ts index 3fa64eb..f6a67a4 100644 --- a/src/components/chat/hooks/useChatComposerState.ts +++ b/src/components/chat/hooks/useChatComposerState.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import type { ChangeEvent, ClipboardEvent, Dispatch, FormEvent, KeyboardEvent, MouseEvent, MutableRefObject, RefObject, SetStateAction, TouchEvent } from 'react'; import { useDropzone } from 'react-dropzone'; +import { useTranslation } from 'react-i18next'; import { useAppShellStore } from '../../../stores/useAppShellStore'; import { usePaletteOps } from '../../../stores/usePaletteOpsStore'; @@ -12,14 +13,16 @@ 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 { useFileMentions } from './useFileMentions'; import { useSlashCommands } from './useSlashCommands'; import { useWorkspaceTarget, type WorkspaceCandidate } from './useWorkspaceTarget'; +import { newQueuedDraftId, 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 +30,23 @@ 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'; }; 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 { 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 +54,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 +103,12 @@ 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]); useEffect(() => { - const key = draftInputKey(projectId ?? '', conversation); - const imagesByDraft = draftImages.current; - setAttachedImages(imagesByDraft.get(key) ?? []); setUploadingImages(new Map()); setImageErrors(new Map()); setModal(null); @@ -97,16 +116,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) => { 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 { 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,10 +155,18 @@ 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 (!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; } const owner = submissionOwner.current; @@ -176,19 +202,18 @@ 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; } - }, [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 (!draftReady || !isLoading || !text.trim() || !selectedProject || !id || attachedImages.length || !isAutoSendable(classifyCommandInput(text))) return; if (sendMessage({ type: 'chat.steer', sessionId: id, content: text }) === false) { addMessage({ type: 'error', content: 'Connection lost. Your draft has been kept; retry when connected.', timestamp: new Date() }); return; @@ -198,69 +223,101 @@ export function useChatComposerState(args: UseChatComposerStateArgs) { // 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); + 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) 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 (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 (!draftReady || draftPersistence.phase === 'error' || verdict.action !== 'flush' || !head) return; + const timer = setTimeout(() => { + // 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; + } + 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 === composerOwner) void submitRef.current?.(syntheticSubmit(), head); }, 0); + }, verdict.delayMs); + return () => clearTimeout(timer); + }, [attachedImages.length, 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 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)); }, [setAttachedImages]); 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 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, setInput]); 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 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 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()); }, [setInput]); + const editQueuedDraft = useCallback((index: number) => { + if (!draftReady) 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) setQueuedDrafts((q) => q.filter((_, position) => position !== index)); }, [draftReady, setQueuedDrafts]); + const moveQueuedDraft = useCallback((from: number, to: number) => { if (draftReady) setQueuedDrafts((q) => reorderQueue(q, from, to)); }, [draftReady, setQueuedDrafts]); const confirmCommandGate = useCallback(() => { const gate = gateRef.current; if (!gate) 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]); + setInput(gate.text); inputRef.current = gate.text; void handleSubmit(syntheticSubmit()); }, [announceGate, conversation, handleSubmit, projectId, setInput]); const cancelCommandGate = useCallback(() => { 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 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(); }, [setInput]); 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 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 { 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 0000000..56511b1 --- /dev/null +++ b/src/components/chat/hooks/useDurableComposerDraft.ts @@ -0,0 +1,302 @@ +import { useCallback, useEffect, useState, useSyncExternalStore } from 'react'; +import type { SetStateAction } from 'react'; + +import { draftInputKey, notifyQueuedMessages, queuedMessageKey, subscribeQueuedMessages } from '../utils/chatStorage'; +import { composerQueueOwnerKey, readComposerQueueProjection } from '../utils/composerQueueProjection'; +import { + boundedComposerDraft, browserComposerDraftRepository, COMPOSER_STORAGE_LIMITS, + composerRouteKey, composerStorageReason, ComposerStorageError, + 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()}`; + +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; + 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() { + 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); + return () => { unsubscribe(); window.removeEventListener('storage', storage); }; + } + private failMigration(entry: Entry, error: unknown) { + entry.migrationBlocked = true; + entry.loadFailed = true; + this.status(entry, { phase: 'error', reason: composerStorageReason(error) }); + } + /** The legacy consumer can retire a cached queue while another route is open. */ + private reconcile(entry: Entry): boolean { + 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 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 { + 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) { + 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) { + 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); + } + private schedule(entry: Entry) { + 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 () => { + 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; }); + } + /** Explicit user retry, not a restart receipt. Rebase against the current CAS revision. */ + retry(route: ComposerRoute): Promise { + 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; + } + } +} + +/** + * Practical draft durability, not a G3 freeze/ack API. Other windows, offscreen + * producers, upload/steer requests and browser eviction remain outside this + * controller. A `saved` status never grants native restart authority. + */ +export function useDurableComposerDraft(projectId: string | undefined, conversation: string | null, repository = browserComposerDraftRepository) { + const [controller] = useState(() => new ComposerDraftController(repository)); + 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]); + 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) => { + 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/tests/fixtures/ComposerDraftPersistenceHarness.tsx b/src/components/chat/tests/fixtures/ComposerDraftPersistenceHarness.tsx new file mode 100644 index 0000000..7504e2c --- /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}

+
+