Compare commits

...

37 commits
v0.18.0 ... dev

Author SHA1 Message Date
Pascal André
269cff64ee
fix(server): prevent Linux workspace launch identity timeouts (#625)
## Summary
- prevent Linux workspace startup from failing when process identity
discovery exceeds its one-second command deadline
- capture the launched process and its existing process-group members
without spawning helper commands for every `/proc` entry
- preserve the leader-exit cleanup guarantees introduced by #602
- read immutable Linux process start ticks correctly and tolerate
multiline task names

## Root cause
Workspace startup captures an immutable process identity before
accepting the OpenCode runtime. The Linux implementation scanned every
process and launched several `cat`, `cut`, `sed`, `basename`, and
`dirname` helpers per entry. On the affected Mint host, that synchronous
shell command exceeded its one-second timeout. Identity capture then
failed closed and stopped the newly launched OpenCode process; cleanup
retried the same expensive probes and produced the repeated `spawnSync
sh ETIMEDOUT` errors.

The identity parser also used `$20`, which POSIX shells interpret as
`$2` followed by `0`, rather than the twentieth positional field. This
did not cause the startup timeout but weakened immutable process
matching and is corrected here.

## Safety
- launch discovery still retains every already-started member of the
observed process group
- guarded cleanup remains identity- and launch-token-based; no
unverified PID fallback is introduced
- WSL, macOS, and Windows paths retain their existing platform-specific
probes

## Validation
- server TypeScript typecheck
- focused process identity and runtime suite: 23 passed, 1 Linux-only
test skipped on Windows
- real WSL `/proc` probe: correct start identity, 2 group members, 4 ms
- broader workspace suite: 79 passed, 3 platform skips
- `git diff --check`

The broader workspace run still has the unrelated existing Windows
fixture failure in `git-worktrees.test.ts` (`undefined` instead of
`main`).

Closes #624
2026-07-26 20:22:49 +01:00
Pascal André
70c9548f93
fix(permissions): ignore stale permission updates (#621)
## Summary
- Preserve the original V2 source when permission.updated changes an
already-pending request, so approval continues through the V2 reply API
instead of the legacy endpoint.
- Keep standalone permission.updated events as valid legacy requests for
older supported OpenCode binaries.
- Continue suppressing delayed post-reply updates through the existing
replied-request tombstone, preventing an approved request from
reappearing.

## Reproduced failure
The regression test fails on dev before the fix: permission.v2.asked
followed by permission.updated changes the request source to legacy, so
approval calls the legacy endpoint and never reaches the V2 reply API.

## Compatibility coverage
- V2 request plus permission.updated replies through V2
- delayed permission.updated after a successful local reply does not
re-enter the queue
- standalone legacy permission.updated remains visible and replies
through legacy

## Scope
This PR intentionally does not add speculative timeout, reconnect, or
modal-rendering changes. It only fixes the reproduced source-routing bug
and preserves verified legacy behavior.

## Validation
- npm run typecheck
- npm run build --workspace @codenomad/ui
- 144 existing runnable UI tests passed
- browser integration tests passed, including all permission lifecycle
regressions

Closes #566
2026-07-26 17:57:48 +01:00
Pascal André
9d47f73ea7
fix(files): search large workspaces beyond candidate cap (#623)
## Summary
- count only fuzzy-qualified paths toward the 8000 workspace search
candidate cap
- scope the bounded cache to the current query and entry type
- stop recursive directory-link cycles without hiding legitimate alias
paths
- expand the @file picker to its parent width and horizontally scroll
long paths

## Validation
- 6 focused filesystem search/cache tests pass
- server typecheck passes
- workspace UI/Electron typecheck passes
- UI production build passes
- gatekeeper round 3: zero findings
- full server suite: 241 pass, 3 skip; the unrelated existing Windows
git-worktrees fixture fails to parse its mocked branch output

Closes #503
2026-07-26 17:51:56 +01:00
Pascal André
b1bb8a723a
fix(ui): avoid mobile selection action overlap (#622)
## Summary
- place CodeNomad selection actions below the final selected line on
touch-only devices
- keep desktop and hybrid fine-pointer positioning unchanged
- fall back to the message stream's top edge when bottom placement would
be clipped

## Validation
- 146 UI Node tests
- npm run typecheck
- npm run build --workspace @codenomad/ui
- git diff --check

Closes #598
2026-07-26 17:51:30 +01:00
Pascal André
b81044ffa2
fix(auth): submit the displayed default username (#620)
## Summary
- Populate the remote login username input with the configured default
value instead of displaying it only as a placeholder.
- Disable mobile capitalization, autocorrection, and spellchecking for
username and password inputs.
- Add a route-level regression test for the rendered username value and
mobile credential attributes.

## Validation
- node --import tsx --test
packages/server/src/server/routes/auth.test.ts
- npm run typecheck --workspace @neuralnomads/codenomad
- node packages/server/scripts/copy-auth-pages.mjs

Closes #616
2026-07-26 17:50:45 +01:00
Pascal André
30563a22e2
feat(ui): add right panel customization (#615)
Summary
- Add a typed right-panel registry for native tabs and Status sections.
- Let users show, hide, and reorder right-panel tabs and Status
subpanels, including drag/drop and Up/Down controls.
- Persist customization through the existing client layout preference
store.

Why
- Progresses #193 with the smallest reviewable UI customization layer
first.
- Keeps plugin manifest/lifecycle loading separate from this PR.

Validation
- npm exec --no -- tsx --test
packages/ui/src/components/instance/shell/right-panel/registry.test.ts
- npm run typecheck --workspace @codenomad/ui
- npm run build --workspace @codenomad/ui
- git diff --check
2026-07-26 17:50:28 +01:00
Pascal André
25af76c243
feat(settings): reorganize settings and add chat block visibility (#614)
## Summary
- reorganize Settings into focused General, Chat, Notifications, Speech,
Remote Access, OpenCode, Providers, SideCars, Config files, Advanced,
and Info sections
- add independent expanded, collapsed, and hidden controls for Thinking,
Tool output, Diagnostics, Tool inputs, and Token usage
- embed the shared Provider manager directly in Settings while retaining
the model-selector dialog entry point
- move environment, session cleanup, sub-agent idle markers, and Tauri
transport controls into Advanced

## Reliability
- scope provider loading and authentication operations to the current
ready instance
- clean up OAuth callbacks and popups when the instance or view changes
- preserve failed disconnect state and prevent conflicting provider
operations
- improve focus management and live status/error announcements

## Validation
- npm run typecheck
- npm run build:ui
- 144 runnable UI tests from the settings review cycle
- git diff --check
- independent settings and accessibility gatekeepers: SHIP

Closes #609
2026-07-26 17:50:08 +01:00
Pascal André
d74a8a39f1
fix(ui): label new local instance action (#613)
## Summary
- add an Open new instance hover tooltip to local workspace rows
- translate the tooltip across all supported locales
- reuse the existing native title pattern without new styling or
dependencies

## Validation
- npm run typecheck --workspace @codenomad/ui
- npm run build --workspace @codenomad/ui
- git diff --check

Closes #608
2026-07-20 16:13:42 +01:00
Pascal André
4c50829da0
fix(ui): defer session virtualization for closed left panel (#612)
## Summary
- do not mount SessionList while the temporary left drawer is floating
and closed
- keep the session-list error state mutually exclusive with virtualized
rows
- register focused visibility-policy tests in the PR workflow

## Root cause
SUID constructs temporary Drawer children while open is false. This
occurs both after restarting with a persisted closed panel and when
narrowing the window into mobile mode, which forces the left panel to
become unpinned and closed. SessionSidebar then mounted the virtua
Virtualizer in a detached staging document. virtua resolves
ResizeObserver through ownerDocument.defaultView, which is null for that
document.

The failure is timing-dependent: if session hydration publishes rows
while that closed mobile Drawer is detached, the synchronous render
exception escapes through setSessionPage and is caught by fetchSessions
as if the successful API request had failed. Opening the panel later
therefore reveals an empty list or the misleading Unable to load
sessions error. If hydration finishes under a different drawer
lifecycle, the bug does not appear.

Because the error UI and virtualized rows were both mounted, Retry
cleared the error and immediately hit the same poisoned lifecycle again.

## Behavior
Session fetching and startup restore continue while the panel is closed.
The virtualized DOM is created only after the panel is open or pinned.
Genuine list errors dispose the rows; Retry can then mount a clean
virtualizer after succeeding.

## Reproduction
1. Narrow the window until CodeNomad enters mobile mode and the left
panel can no longer remain pinned.
2. Leave the sessions panel closed while sessions hydrate, or restart in
that state.
3. Open the left panel.
4. Before this fix, the list may be empty or show Unable to load
sessions with a ResizeObserver null error.

## Validation
- 19 focused session visibility, tree, and pagination tests
- UI TypeScript typecheck
- production Vite build
- full Windows Tauri release build
- NSIS installer bundle
- regression test included in PR CI
2026-07-19 17:19:32 +01:00
Pascal André
24a26807e0
feat(restore): persist Yolo and session expansion state (#611)
## Summary

- Persist server-owned Yolo state through OpenCode session metadata so
it survives workspace and CodeNomad restarts.
- Restore expanded and collapsed parent/subsession state from the
desktop client snapshot.
- Serialize CodeNomad metadata mutations so Yolo and worktree metadata
cannot overwrite each other.

## Yolo persistence

- Stores state under metadata.codenomad.yolo with enabled and
rootSessionId fields.
- Accepts a persisted marker only when it is owned by that session,
preventing copied fork metadata from enabling an unrelated family.
- Hydrates the project session tree before processing permission events
and queues events while hydration is pending.
- Keeps the server authoritative for family inheritance, permission
replies, toggles, and cleanup.
- Uses get/merge/update writes that preserve worktreeSlug and
third-party metadata.
- Serializes writes by session across CodeNomad instances and routes
worktree slug updates through the same server-owned path.
- Reconciles late ancestry and repeated root migrations without
re-enabling a family after a concurrent disable.

## Expansion restoration

- Captures bounded, deduplicated expanded session IDs per workspace in
the local desktop snapshot.
- Restores explicit expanded and collapsed state while preserving IDs
that are temporarily unavailable during startup reconciliation.
- Gives live user changes, deletions, and loaded-session authority
precedence over preserved state.
- Cleans expansion state when sessions or instances are removed.
- Keeps the active child visible when loading legacy snapshots that
predate expansion persistence.

## Validation

- 39 focused server permission and metadata tests pass.
- 66 focused client snapshot and restoration tests pass.
- Server and UI TypeScript typechecks pass.
- git diff --check passes.

## Limits

- Project hydration follows the existing 10,000-session list ceiling.
- Snapshot expansion persistence is capped at 256 session IDs.

Closes #607

Depends on anomalyco/opencode#23068.
2026-07-19 17:16:56 +01:00
Pascal André
47ae6626dd
fix(ui): polish provider usage status panel ( and Yolo panel ) (#610)
## Summary
- keep the Status tab visible while provider usage refreshes in the
background every minute
- simplify Yolo controls, and rename the quota subsection to Usage
- render the provider name prominently and each quota as one discreet
text row plus a progress bar
- show Included for subscription-model cost and remove the supplemental
Codex credit-balance row
- shorten the session-header Yolo badge while preserving its accessible
label

## Provider safety
- remove the Claude Pro/Max OAuth quota adapter because it's not usable
anymore

## Refresh behavior
- replace the suspense-aware provider resource with contained
signal-based polling
- preserve the last successful result during background refresh failures
- ignore stale requests after the active session or provider changes

## Validation
- 8 targeted server usage tests
- server typecheck
- UI typecheck
- production UI build
- git diff checks
- focused regression review of polling, failures, and session changes

Closes #605
2026-07-19 17:16:24 +01:00
Pascal André
8f6aa44b15
perf(ui): virtualize nested session list (#586)
## Summary
- flatten expanded session trees into a stable pre-order row projection
- render only the viewport and buffer with the existing Solid virtua
integration
- preserve nested connectors, dynamic heights, search expansion, focus,
active-session scrolling, and loading sentinel behavior

## Why
Large or deeply expanded session trees currently mount every visible
row. Status updates then affect a large DOM tree even when most sessions
are far outside the sidebar viewport. This PR bounds mounted rows while
leaving selection, deletion, filtering, and store semantics unchanged.

## Validation
- UI TypeScript typecheck
- 47 focused session-tree, pagination, and virtual-scroll tests
- 1,000-session projection regression test
- production Vite build
- git diff whitespace check
- final focused regression review
- manual test
2026-07-17 22:17:35 +01:00
Pascal André
b766b76a69
fix(tauri): add native About and Help menus (#596)
## Summary

- replace the inert Tauri macOS About handler with the native About
dialog
- add a conventional Help menu with About on Windows and Linux
- expose Get Updates through each platform menu and through native About
only where the platform supports an actionable link
- run the native WinGet updater from the Windows Help menu, with GitHub
Releases as the error fallback

## Platform behavior

- macOS: CodeNomad > About CodeNomad opens the native dialog; CodeNomad
> Get Updates... opens the latest GitHub release
- Windows: Help > About CodeNomad opens the native dialog; Help > Get
Updates... runs the WinGet updater asynchronously and opens the latest
GitHub release only if WinGet cannot start, fails, or cannot be
monitored
- Linux: Help > About CodeNomad opens the native dialog with an
actionable Get updates link; Help > Get Updates... also provides direct
access
- the displayed application version comes from Tauri package metadata

Tauri ignores website metadata in the native macOS About panel and
renders it as inert text on Windows, so website metadata is included
only on Linux.

## Windows updater integration

PR #597 is now merged. The Windows menu action invokes its
install_stable_update command without blocking the Tauri menu event
loop. macOS and Linux retain the direct Releases URL behavior.

## Validation

- cargo test --manifest-path packages/tauri-app/src-tauri/Cargo.toml: 65
passed
- cargo check --manifest-path packages/tauri-app/src-tauri/Cargo.toml:
passed
- focused failed-update fallback regression test
- git diff --check: passed

Closes #592
2026-07-17 22:17:26 +01:00
Pascal André
0bab9e3438
fix(restore): simplify persistence and harden cross-platform cleanup (#602)
## Summary

- Follow up #578 by consolidating desktop persistence, restore
reconciliation, lifecycle coordination, and regression coverage.
- Preserve active drafts and attachments, request-scoped workspace
ownership, deletion tombstones, renderer authority, and bounded shutdown
behavior.
- Fix the reported macOS cleanup failure with targeted BSD process
queries and random-token-guarded process-group cleanup, without an
unverified PID fallback.

## Platform hardening

- Ignore development renderer origins in packaged Electron builds.
- Preserve staged Tauri navigation authority and handle confirmed
Windows session-end shutdown on the UI thread.
- Bound workspace launch preflight, runtime startup, and health
readiness.
- Retain cleanup ownership after unexpected leaders exit and verify
portable POSIX descendants by immutable identity or inherited launch
token.
- Add real Darwin-only process-group integration tests for macOS CI.

## Scope

- 96 files changed.
- 6,295 additions and 12,167 deletions, a net reduction of 5,872 lines
from the merged implementation.
- Consolidated duplicated tests while retaining focused race,
durability, cleanup, and platform contracts.

## Validation

- pm run typecheck
- pm run typecheck --workspace @neuralnomads/codenomad
- Electron native suite: 60 passed
- Tauri suite: 49 passed
- Focused server lifecycle/identity suite: 31 passed, 2 Darwin-only
skipped on Windows
- Focused UI restore/codec/reconciliation suite: 36 passed
- Broader server suite: 59 passed, 3 platform skips
- Broader UI suite: 97 passed, 1 skip; 2 Node 25 solid-toast loader
failures reproduced on the merged baseline
- git diff --check
- Final limited gatekeeper: PASS for server/macOS, UI restore, and
Electron/Tauri
2026-07-17 22:17:15 +01:00
Pascal André
e586ea39f4
feat(windows): update CodeNomad from the in-app notification (#597)
## Summary

- launch the official CodeNomad WinGet upgrade from the upgrade-required
notification in the local Windows Tauri client
- keep non-Windows, Electron and development release actions on their
existing release links
- wait for WinGet without blocking the async runtime and propagate
launch or non-zero exit failures back to the UI
- open the official release URL when the native update action fails
- preserve the same explicit update action in the live toast and
notification history

## Update behavior

- run an exact silent upgrade for the NeuralNomadsAI.CodeNomad package
from the WinGet source
- do not force a reinstall or downgrade
- do not attempt to restart CodeNomad after a successful update; the
installer owns application shutdown and replacement

## Validation

- npm run typecheck in packages/ui
- npm run build in packages/ui
- cargo test in packages/tauri-app/src-tauri (20 tests)
2026-07-17 10:51:14 +01:00
Pascal André
7584ccfe29
feat(ui): make open workspace action explicit (#600)
## Summary

- turn the existing `Open` badge into a prominent action that returns
directly to the running workspace
- make the rest of every recent-folder row consistently start a new
instance
- remove the repeated already-open choice dialog and its unused
translations

## Why

Choosing between the existing workspace and another instance on every
activation adds avoidable cognitive load. The two intentions are now
represented directly by separate, stable targets: `Open` resumes
existing work, while the folder row starts fresh.

## Details

- force recent-row activations to request a new instance even during
hydration or workspace-creation races
- keep Browse, drag/drop, and clone fallback reuse behavior, but switch
directly without a dialog
- preserve the exact recent-folder alias when returning to an existing
instance
- keep native Enter/Space behavior on focused controls while preserving
list and browse shortcuts
- use project-specific accessible names and semantic button/focus tokens

## Validation

- `npm run typecheck --workspace @codenomad/ui`
- `npm run build --workspace @codenomad/ui`
- `bun test --conditions=browser
packages/ui/src/stores/session-tree.test.ts
packages/ui/src/stores/session-pagination-model.test.ts`
- `git diff --check upstream/dev...HEAD`
- independent gatekeeper review: PASS
2026-07-17 10:50:32 +01:00
Pascal André
18564d4094
fix(ui): temporarily raise project session list ceiling (#604)
## Summary

- raise PROJECT_SESSION_LIST_LIMIT from 1,000 to 10,000
- keep sessions visible for projects whose subagent activity exceeds the
current 1,000-session cap
- leave the existing one-shot project-scoped request behavior unchanged

## Context

This is an urgent temporary mitigation for the regression described in
https://github.com/NeuralNomadsAI/CodeNomad/pull/565#issuecomment-4990950140.

PR #565 replaced paginated loading with a single request capped at 1,000
sessions. Projects that exceed that cap can still have all sessions in
OpenCode's database, but older sessions disappear from the CodeNomad UI.

This PR deliberately does not solve the underlying scalability problem.
The follow-up fix should restore proper paginated loading and remove
reliance on a fixed high ceiling.

## Validation

- npm run typecheck --workspace @codenomad/ui
- node --test packages/ui/src/stores/session-pagination.test.ts (5
passed)
2026-07-17 10:49:51 +01:00
Pascal André
e169c0aaa1
feat(settings): add OpenCode update action (#585)
## Summary

- add an OpenCode update card to the OpenCode settings section
- show the installed version and a versioned update button only when npm
advertises a newer release
- run the upgrade through the generated OpenCode SDK against a ready
instance using the configured binary
- keep running instances uninterrupted; newly started instances use the
installed version
- localize loading, unavailable-instance, success, and failure states
across all supported locales

## Server behavior

- resolve the selected binary with the existing binary probe
- check opencode-ai/latest with a 10-second timeout and a five-minute
cache
- require a ready workspace using that exact binary before enabling the
upgrade
- invoke client.global.upgrade({ target }) rather than assembling an
OpenCode API request manually
- return stable error codes without exposing server details

## Validation

- pm run typecheck in packages/server
- pm run typecheck in packages/ui
- ode --import tsx --test src/opencode-update/service.test.ts
- pm run build in packages/ui
- pm run build in packages/server
- git diff --check

<img width="1034" height="688" alt="image"
src="https://github.com/user-attachments/assets/a71a01c4-d3e2-46f6-a333-a408c058255c"
/>
<img width="1018" height="360" alt="image"
src="https://github.com/user-attachments/assets/983a87a9-6103-48d7-8bcf-e10c5ce6a105"
/>
2026-07-17 10:48:58 +01:00
Pascal André
f6cf6c5352
fix(ui): surface session loading failures (#582)
## Summary

- preserve and display detailed OpenCode errors when the session list
cannot be loaded instead of failing silently
- share error extraction and retry UI with per-session message loading
while keeping each error scoped correctly
- continue instance hydration after list failures and ignore stale
overlapping list responses
- show loading feedback during retries and localize the new states for
every supported locale

Closes #543.

## Validation

- `bun test --conditions=browser
packages/ui/src/stores/session-list-error.test.ts
packages/ui/src/stores/session-tree.test.ts`
- `npm run typecheck --workspace @codenomad/ui`
- `npm run build --workspace @codenomad/ui`
- `git diff --check upstream/dev...HEAD`
2026-07-17 10:48:37 +01:00
Shantur Rathore
40cee071a3
feat(ui): add line wrap toggle to markdown code blocks (#599)
## Summary
- add a wrap toggle beside the copy action in Markdown code block
headers
- default Markdown code blocks to wrapped lines for both plain and
Shiki-highlighted output
- preserve per-block wrap choices across async Markdown and
syntax-highlight re-renders
- add localized labels for the new Markdown code block wrap action

## Validation
- npm run typecheck --workspace @codenomad/ui

## Notes
- Left unrelated untracked .opencode/package-lock.json out of the
commit.
2026-07-15 10:34:43 +01:00
Pascal André
709557d12d
feat(usage): show provider quota in session status (#584)
## Summary
- Add provider quota usage to an expanded-by-default subsection in the
session Status panel.
- Resolve the active provider and model automatically from the current
session.
- Display localized quota windows, reset times, values, and color-coded
progress bars inside the same bordered rounded container used by
neighboring subsections.

## Server implementation
- Add GET /api/usage/:providerId with optional modelId filtering.
- Add a 60-second in-memory cache and deduplicate concurrent provider
requests.
- Read credentials only on the server and never return secrets through
the API.
- Support Claude, Codex, GitHub Copilot, Google/Gemini/Antigravity,
Kimi, NanoGPT, OpenRouter, z.ai, Zhipu, MiniMax global/CN, Cursor,
Ollama Cloud, Wafer, and OpenCode Go.
- Use existing OpenCode and Antigravity access tokens for Google calls;
optional environment-provided OAuth client values are required only for
token refresh.

## UI behavior
- Keep Provider usage open by default while allowing it to collapse like
the other Status subsections.
- Show loading, unsupported, not configured, unavailable, and no-session
states.
- Localize all user-visible strings across the nine existing locales.

## Attribution
- Include the MIT notice for the OpenChamber usage-provider
implementation used as the reference.

## Validation
- Server typecheck
- UI typecheck
- 7 targeted usage tests
- Production UI build
- Server build and npm package-content verification
- Tauri release build without bundling
- git diff checks
<img width="484" height="444" alt="image"
src="https://github.com/user-attachments/assets/69afbaf8-4c9a-4cb3-b316-e5788e416141"
/>
2026-07-15 10:25:00 +01:00
Pascal André
e815eba872
fix(ui): refresh worktrees from OpenCode ready events (#583)
## Problem

Worktrees created through OpenCode can leave CodeNomad's cached worktree
inventory and workspace mapping stale. The previous approach in #516
polled on `session.idle` and attempted to infer which session created a
newly observed worktree, which introduced race conditions and unsafe
attribution.

## Fix

- route OpenCode's `worktree.ready` event through the existing UI SSE
manager;
- reload the live Git worktree inventory when that event arrives;
- synchronize OpenCode workspace mappings only after the refreshed
inventory is available;
- avoid idle polling and avoid auto-switching a session because the
event identifies the new worktree directory but does not identify an
originating session.

The server bridge already forwards arbitrary OpenCode events and
preserves the global event `directory`, so no server change is required.

Supersedes #516 and implements the SSE approach suggested by @shantur
https://github.com/NeuralNomadsAI/CodeNomad/pull/516#issuecomment-4658638464.

## Validation

- `bun test --conditions browser packages/ui/src` (`126` passing)
- `npm run typecheck --workspace @codenomad/ui`
- `git diff --check`
2026-07-15 10:24:00 +01:00
Pascal André
f31d8c17f6
fix(ui): persist environment variable removal (#581)
## Summary

- Send an explicit JSON Merge Patch deletion when removing an
environment variable.
- Prevent deleted variables from remaining in config.yaml and being
injected into future OpenCode instances.
- Maybe closes #575

## Root Cause

The previous removal flow submitted the remaining environment-variable
record without the selected key. The settings API recursively applies
JSON Merge Patch, where an omitted nested key means no change rather
than deletion.

## Validation

- npm run typecheck --workspace @codenomad/ui
- npm run build --workspace @codenomad/ui
- git diff --check
2026-07-15 10:22:42 +01:00
Pascal André
1efe5e91d1
feat(desktop): persist and restore UI state across restarts (#578)
## Summary

- Restore the primary desktop client's window bounds, zoom, workspace
tabs, active sessions, drafts, bounded attachments, scroll positions,
and panel layout across launches.
- Keep additional Electron and Tauri processes independent: they still
start normally but do not read or write the shared native snapshot.
- Add translated settings to disable startup restoration or clear the
saved state.

## Implementation

- Persist a versioned, bounded snapshot atomically in Electron and
Tauri, preserving unknown future envelopes until an explicit clear.
- Elect one primary process with stale-owner recovery and process
identity checks, then protect renderer access with per-document
capability tokens.
- Flush renderer state before close, quit, reload, and app-owned
navigation with bounded failure handling and fresh token rotation.
- Reconcile partial or timed-out workspace hydration without losing
unsent prompt state or resurrecting explicitly cleared drafts,
attachments, sessions, or tabs.
- Correlate restore-created workspaces so cancellation and delayed SSE
events cannot produce ghost tabs.
- Harden workspace launch cancellation and cross-platform process
cleanup so failed starts cannot orphan detached POSIX, WSL, or Windows
children; incomplete shutdown exits nonzero.

The restored shell state remains local to the desktop client. Durable
semantic session properties continue to use OpenCode session metadata
separately.

## Validation

pm run typecheck --workspace @neuralnomads/codenomad
- Electron native suite: 43 passed
- Tauri suite: 54 passed
- Focused UI restore, reconciliation, attachment, authority, and race
suites passed
- Focused server workspace runtime, manager, process identity, route,
and shutdown suites passed
- pm run build
- pm run build:tauri
- git diff --check
- 16-pass adversarial review loop completed with final No findings
- extensivly optimized, tested and used day by day

## Platform notes

- Native menu reloads and app-owned navigation have awaited durability
guarantees. Browser-engine crashes or forced process termination remain
inherently best effort.
- Process cleanup uses identity-guarded platform adapters and fails
conservatively when target ownership cannot be proven.
2026-07-15 10:22:23 +01:00
Pascal André
b1d6421b0d
feat(ui): focus messages after prompt submission (#577)
## Summary

Move keyboard focus from the prompt to the active message stream
immediately after a desktop prompt submission.

This makes conversation navigation keys such as Page Up and Page Down
available without requiring a click. Starting to type printable text
returns focus to the prompt for a follow-up message.

## Changes

- Focus the current session's message stream before waiting for the
message, command, or shell request.
- Carry an explicit focus intent through first-prompt session creation
so the newly mounted session focuses its message stream instead of the
detached draft view.
- Do not overwrite focus if the user changes controls or opens a modal
during first-session activation.
- Restore the active prompt when a submission fails.
- Make the message stream programmatically focusable without adding it
to the normal Tab order.
- Keep type-to-focus active on hybrid touch/mouse devices and preserve
`!` shell-mode activation from the message stream.

## Unchanged behavior

- Touch-only submissions keep their existing focus behavior.
- Existing global Escape handling is unchanged.

## Validation

- Focused prompt and scrolling tests pass: `24/24`.
- UI typecheck passes.
- UI production build passes.
- `git diff --check` passes.
- Independent gatekeeper re-review found no remaining blockers.
2026-07-15 10:21:58 +01:00
Yao Jianxuan
07e8ce6810
feat(ui): support bracket math delimiters (#588)
## Why

LaTeX content commonly uses `\(...\)` and `\[...\]`, but CodeNomad only
recognizes `$...$` and `$$...$$` through `marked-katex-extension`.

## What

- Add parser-native Marked rules for inline `\(...\)` and display
`\[...\]` formulas.
- Preserve existing dollar math, code spans and fences, escaped
delimiters, and unmatched text.
- Add focused regression tests and document the delimiter design.
2026-07-14 14:00:26 +02:00
heunghingwan
f900af1b4d
fix(ui): cancel pending microphone acquisition on early release (#550) (#580)
## Problem

The voice input button uses push-to-talk: press to start recording,
release to stop. On the **second and subsequent recordings**, the button
fails to enter the recording state (doesn't turn red) and jumps directly
to "transcribing", producing empty or header-only audio (~110 bytes)
that the STT provider rejects with HTTP 400.

This affects **all platforms** (Linux/Electron, Windows/Chrome), not
just Linux as originally reported in #550.

## Root Cause

`startRecording()` is async — it awaits `getUserMedia()`. But
`beginVoicePress()` in `prompt-input.tsx` fires it with `void`
(fire-and-forget):

```typescript
void voiceInput.startRecording()   // not awaited
```

When the user releases the button before `getUserMedia()` resolves
(common on second+ recordings where mic permission is already granted
and acquisition is fast), `stopRecording()` finds no active recorder
(`mediaRecorder === null`, `state() === "idle"`) and **silently
returns**. The pending `getUserMedia()` promise later resolves and
starts an **orphan recording** with no matching stop.

The user perceives: button doesn't turn red → presses again → the orphan
recorder is stopped → jumps to "transcribing". On fast taps, the orphan
recording captures zero audio frames, producing a header-only WebM blob
(~110 bytes).

### Why it only manifests on second+ recordings

On the **first** recording, `getUserMedia()` may show a permission
prompt or have slower initial device setup, giving the user time to hold
the button. On **subsequent** recordings, the device is already warm and
`getUserMedia()` resolves faster, but the user's tap duration remains
the same — releasing before acquisition completes.

### What this is NOT

- **Not a PipeWire/Linux-specific issue** — reproduced on Windows Chrome
(server on Linux, browser on Windows)
- **Not a MediaRecorder encoder bug** — tested with Chrome's fake audio
device: `start()` vs `start(timeslice)` produce identical results across
consecutive recordings
- **Not a recorder reuse issue** — the code already creates fresh
`MediaStream` + `MediaRecorder` per recording

## Fix

Add a `requestGeneration` counter (mirroring the pattern from
`use-transcription-test.ts` in PR #579):

- `startRecording()` increments the generation before awaiting
`getUserMedia()`
- After the await resolves, if the generation is stale, the stream is
immediately stopped and discarded
- `stopRecording()` / `cancelRecording()` increment the generation when
called while `state() === "idle"` (pending acquisition), immediately
invalidating any in-flight request
- All event listeners (`dataavailable`, `stop`) and
`finalizeRecording()` check the generation before mutating state
- `cleanupMedia()` and `onCleanup()` increment the generation to cover
unmount and error paths

Additionally, `stopTracks(stream)` is called **before** the
transcription network round-trip (instead of after), releasing the audio
device sooner.

## Verification

- `tsc --noEmit` passes (packages/ui)
- Code review: no regressions, no stream leaks, no double-free paths
found
- Reproduction evidence: confirmed the 110-byte header-only blob
signature matches the orphan-recording failure mode

Closes #550
2026-07-13 20:12:24 +02:00
heunghingwan
483fac2e07
feat(speech): separate STT/TTS provider configuration (#579)
## Summary

Voice input (speech-to-text / STT) and voice output (text-to-speech /
TTS) currently share a single API key, base URL, and provider
configuration. Users cannot use different providers for each direction —
for example, Groq for transcription (STT) and OpenAI for synthesis
(TTS).

This PR adds a `separateProviders` toggle to the speech settings. When
**off** (default), behavior is unchanged — existing configs work as-is.
When **on**, STT and TTS each get their own `apiKey`, `baseUrl`, and
`model` fields, allowing independent OpenAI-compatible endpoints per
direction.

## Changes

### Server
- **`api-types.ts`**: Added `separateProviders`, `sttConfigured`,
`ttsConfigured`, `sttBaseUrl`, `ttsBaseUrl` to
`SpeechCapabilitiesResponse`
- **`speech/service.ts`**: Extended Zod schema with `separateProviders`
+ nested `stt`/`tts` sub-objects. Split `createProvider()` into
direction-aware `createSttProvider()`/`createTtsProvider()` with
`resolveSttSettings()`/`resolveTtsSettings()` resolvers that fall back
to shared values when per-direction fields are absent
- **`settings/public-config.ts`**: Extended `sanitizeServerOwner` to
strip per-direction `stt.apiKey`/`tts.apiKey` and set
`stt.hasApiKey`/`tts.hasApiKey` booleans (same pattern as the shared
`apiKey`)
- **`OpenAICompatibleSpeechProvider`**: Completely unchanged — it
receives the same flat `NormalizedSpeechSettings` it always has

### UI
- **`stores/preferences.tsx`**: Extended `SpeechSettings` type with
`separateProviders`, `stt`, `tts` sub-objects. Updated
`normalizeSpeechSettings()` and `updateSpeechSettings()` to handle
per-direction patches
- **`components/settings/speech-settings-card.tsx`**: Added toggle at
the top of the card. When enabled, shows two sections (Input/STT and
Output/TTS) with their own API Key, Base URL, and Model fields. Playback
Mode and TTS Format remain shared. Also includes "Test input" button
alongside existing "Test playback"
- **`lib/audio-utils.ts`**: Extracted shared `blobToBase64`,
`createMediaRecorder`, `stopTracks` utilities (used by both
`usePromptVoiceInput` and `useTranscriptionTest`)
- **`lib/hooks/use-transcription-test.ts`**: New hook for testing STT
transcription from the settings card
- **`components/prompt-input/usePromptVoiceInput.ts`**: Uses
`sttConfigured` instead of combined `configured`
- **`stores/conversation-speech.ts`**, **`lib/hooks/use-speech.ts`**,
**`speech-settings-card.tsx`**: TTS consumers use `ttsConfigured`
instead of combined `configured`
- **`styles/components/settings-screen.css`**: Added
`settings-card-section-header`/`settings-card-section-title` styles
- **i18n**: Added new keys per locale across all 9 locales (en, es, ja,
zh-Hans, fr, de, he, ne, ru)

## Backward Compatibility

- `separateProviders` defaults to `false` — existing configs work
unchanged
- The `configured` field remains in capabilities response as
`(sttConfigured || ttsConfigured)` so any consumer not yet updated still
works
- `NormalizedSpeechSettings` (the provider-facing flat interface) is
unchanged

## Testing

- 13 unit tests covering direction-aware resolution (shared mode,
separate mode, partial config, fallback chains, model overrides) and
config sanitization
- All tests pass: `npx tsx --test
packages/server/src/speech/service.test.ts
packages/server/src/settings/public-config.test.ts`
- Server typecheck: clean
- UI typecheck: clean
- UI build: succeeds (no duplicate i18n keys)
2026-07-13 13:27:50 +02:00
bluelovers
f34e807108
feat: detect realpath when open workspace (#486)
detect realpath when open workspace

try avoid path issue in windows

- d:\xxx
- D:\xxx
- symbol link

now they are same path when try detect exists workspace

---------

Co-authored-by: Pascal André <pascalandr@gmail.com>
2026-07-12 23:42:16 +02:00
Shantur Rathore
c8d4a5099a
build: publish deb and portable tar.gz Linux artifacts (#492)
## Summary
- Publish one supported installer on Linux: a Tauri .deb package for
x64.
- Publish one portable fallback: an Electron .tar.gz archive for x64.
- Remove the temporary Flatpak path and stop publishing Linux AppImage,
RPM, and zip variants.
- Remove obsolete Linux assets from reused releases before uploading
replacements.
- Closes #487, Closes #488

## Artifact validation
- Extract the Electron archive and verify its executable, bundled server
resources, app.asar renderer entry, referenced assets, and
shared-library closure.
- Verify Tauri Debian metadata, bundled resources, desktop entry, shared
libraries, and package installation in Ubuntu 24.04.
- Synchronize Tauri package versions before every platform build so
generated metadata follows release inputs.

## Validation
- Parsed the workflow YAML and Electron package JSON.
- Verified the local @electron/asar APIs used by CI.
- Ran the Tauri version synchronization script.
- Ran git diff --check.
- Reviewed the final diff against current dev; full Linux packaging and
Docker installation are delegated to PR CI.

## Compatibility
The Tauri deb is built and installation-tested on Ubuntu 24.04. Older
Debian-based distributions are not yet guaranteed.

---------

Co-authored-by: Pascal André <pascalandr@gmail.com>
2026-07-12 19:29:33 +02:00
VooDisss
56d1e00f2f
PR: Extend delete overlays and clean companion parts during tool deletion (#247)
# PR: Extend delete overlays and clean companion parts during tool
deletion
Fixes: #246 
## Purpose
This PR addresses incomplete deletions when users bulk-delete specific
tool calls from the timeline. Previously, deleting a tool part left
behind orphaned reasoning logs (thinking tokens) and generated text
parts, and the destructive hover overlay only highlighted the tool cards
themselves.

This update ensures that deleting tool parts cleans up the entire
associated response context (`step-finish`, `reasoning`, and `text`
parts) from that message. It also extends the red delete overlay to
message headers, reasoning cards, and text parts to accurately reflect
the scope of deletion to the user.

## Changes by File

### `packages/ui/src/components/message-block.tsx`
- Threaded the `selectedToolPartKeys` prop through `MessageContentItem`,
`MessageBlock`, and `ReasoningCard`.
- Updated `ReasoningCard`'s `isSelectedForDeletion` logic to check both
`selectedMessageIds` (whole message) and `selectedToolPartKeys`
(tool-part selection via a `messageId:` prefix match).
- Moved the block-level selection highlight out of
`isDeleteMessageHovered` to be handled at the part level via
`data-delete-part-hover` attributes avoiding double overlays.
- Added `data-delete-part-hover` to the `ReasoningCard`'s root `div`.

### `packages/ui/src/components/message-item.tsx`
- Added the `selectedToolPartKeys` prop to `MessageItemProps`.
- Expanded `isSelectedForDeletion` using the same prefix-match pattern
to detect tool-part selections.
- The `<header>` element now includes a `delete-hover-scope` class and
`data-delete-part-hover` attribute to trigger the overlay at the header
level.
- Each `.message-part-shell` rendering text content now includes
`data-part-type` and `data-delete-part-hover` for per-part overlay
highlighting.

### `packages/ui/src/components/message-section.tsx`
- Updated the `deleteSelectedMessages` bulk action. It now additionally
collects and deletes `step-finish`, `reasoning`, and `text` parts when a
message has its tool parts selected for deletion. This ensures no
orphaned assistant content remains.
- Simplified the deletion guard condition from `selectedToolPartIds.size
=== 0 || stepFinishPartIds.length === 0` down to
`selectedToolPartIds.size === 0`, ensuring reasoning/text parts evaluate
correctly even if `step-finish` parts are absent.

### `packages/ui/src/styles/messaging/delete-overlays.css`
- Widened the part-level overlay inset from `-2px` to `-4px` for visual
consistency with the message-level overlay.
- Added a new rule for `.message-reasoning-card[data-delete-part-hover]`
using `inset: 0` for flush overlay rendering within reasoning card
boundaries.

---------

Co-authored-by: Pascal André <pascalandr@gmail.com>
2026-07-12 18:40:28 +02:00
Joe Huss
34706228bb
feat(ui): implement recursive nested session expansion (#478)
This change enables the UI to display nested sessions within nested
sessions in a foldable display, recursively, up to 10 levels deep.

## What Changed

### Core Data Structure (session-state.ts)
- Redefined `SessionThread` type to support true recursive nesting:
- Old: `{ parent: Session, children: Session[], latestUpdated: number }`
- New: `{ session: Session, children: SessionThread[], depth: number,
hasChildren: boolean, latestUpdated: number }`
- Renamed `expandedSessionParents` signal to `expandedSessions` to
reflect that ANY session with children can now be expanded, not just
top-level parents
- Updated `getSessionThreads()` to build a recursive tree structure
using new `buildSessionThreadTree()` and `computeThreadSignature()`
helpers
- Updated `getSessionFamily()` to recursively collect ALL descendants,
not just direct children
- Updated `getVisibleSessionIds()` with `collectVisibleSessionIds()`
helper to recursively collect visible session IDs based on expansion
state
- Maintained backward compatibility aliases for renamed functions:
`isSessionParentExpanded`, `setSessionParentExpanded`, etc.

### Session State Exports (sessions.ts)
- Updated imports and exports to use the new function names:
`ensureSessionExpanded`, `isSessionExpanded`, `setSessionExpanded`,
`toggleSessionExpanded`

### Session Events (session-events.ts)
- Updated `ensureSessionParentExpanded` → `ensureSessionExpanded` in
auto-expand logic for child sessions that start working

### Permission Modal (permission-approval-modal.tsx)
- Updated import and usage of `ensureSessionParentExpanded` →
`ensureSessionExpanded`

### UI Rendering (session-list.tsx)
- Updated `SessionRow` component to accept `session` object directly
instead of `sessionId`, plus `depth` and `isLastChild` props
- Derived `isChild` from `depth > 0` instead of explicit prop
- Added `depthClass()` for CSS depth-based indentation
- Created new `SessionThreadRow` recursive component that:
  - Renders the current session via SessionRow
- If expanded and has children, recursively renders children with
increased depth
- Updated `filteredThreads` with `subtreeHasMatch()` and
`filterThreadTree()` helpers for recursive filtering
- Updated `allMatchingSessionIds` with `collectThreadIds()` helper for
recursive ID collection
- Removed child-specific `Bot` icon - all sessions now use `User` icon
- Updated expander visibility to show for ANY session with children,
regardless of depth

### Styling (session-layout.css)
- Added depth-based CSS classes `.session-item-depth-{1-10}` with:
- Progressive indentation: 2.25rem for depth 1, up to 13.5rem for depth
10
  - Tree connector styling via `::before` and `::after` pseudo-elements
  - Proper vertical line handling for last-child at each depth level

### Tests (session-state.test.ts)
- Added comprehensive test suite (683 lines) covering:
- `getSessionThreads`: empty sessions, single sessions, single-level
children, multi-level nested children, sorting, hasChildren computation
  - `getSessionFamily`: recursive descendant collection
  - Expansion state: toggle, explicit set, ensure logic
- `getVisibleSessionIds`: visibility based on expansion state at
multiple levels

## User-Facing Behavior
- Nested sessions can now be collapsed/expanded at any depth level
- A chevron expander appears on any session that has children
- Children are indented based on their nesting depth
- Tree lines connect parent-child relationships visually
- Expanding a parent auto-expands ancestors when selecting a deeply
nested child session

## Edge Cases Handled
- Sessions with no children show no expander
- Last child at each depth level has shortened vertical tree line
- Thread sorting by latestUpdated works correctly with nested updates
- Cache invalidation properly tracks thread changes at all depth levels

## Implementation Notes
- Depth is limited to 10 levels to prevent excessive indentation
- The `hasChildren` flag is computed once during tree building for
performance
- The Session type's `parentId` field already supported arbitrary
nesting - only the UI rendering needed to be updated

---------

Co-authored-by: Pascal André <pascalandr@gmail.com>
2026-07-12 01:18:31 +02:00
heunghingwan
ca06bd99d7
feat(yolo): move permission auto-accept (Yolo) to the server (#561)
## Summary

Yolo (permission auto-accept) currently lives entirely in the UI. Each
browser keeps its own toggle in `localStorage` and auto-replies to
permission requests over a 4-hop path (`OpenCode → server SSE → UI →
server proxy → OpenCode`). The server — which already sits on the event
stream that carries every `permission.asked` — does none of the work.

This PR makes the **server authoritative**: it owns the toggle state,
resolves family-root inheritance, and performs the auto-reply in-process
via loopback using the same `"once"` semantics the UI used to send. The
UI becomes a pure view: it toggles via REST and mirrors state from a
`yolo.stateChanged` SSE event.

## Why

- **Correctness**: the server already consumes the instance SSE stream
(`InstanceEventBridge`); auto-accepting there is the natural choke point
instead of bouncing to the UI and back.
- **Multi-client**: previously each browser had independent
`localStorage` state and never synced. Toggles now broadcast to all
connected clients in real time.
- **Headless**: Yolo keeps auto-accepting even when no UI is connected
(useful for long autonomous runs).
- **Latency**: drops from 4 hops to a single in-process loopback call.

## What changed

**Server (new, authoritative)**
- `permissions/auto-accept-store.ts` — in-memory state keyed by family
root. Faithful port of `resolvePermissionAutoAcceptFamilyRoot`:
fork/`revert` sessions root at themselves; enabling any member enables
the whole family.
- `permissions/auto-accept-manager.ts` — subscribes to `instance.event`,
builds the session tree from `session.created/updated/deleted`
(`properties.info`), intercepts `permission.v2.asked` /
`permission.asked`, dedupes in-flight replies, emits `yolo.stateChanged`
/ `yolo.autoAccepted`, clears per-instance state on
`workspace.stopped/error`.
- `permissions/opencode-replier.ts` — default replier calling OpenCode
directly (`getInstancePort` + auth header), mirroring
`background-processes/manager.ts`.
- `server/routes/yolo.ts` — `GET/POST
/workspaces/:id/yolo/sessions/:sid[/toggle]`, following existing route
conventions.
- `api-types.ts` / `events/bus.ts` — `YoloStateResponse` + the two new
event types registered in `onEvent` so they flow over `/api/events`.

**UI (pure view)**
- `permission-auto-accept.ts` — removed `localStorage`, persistence, and
drain logic; now a runtime (non-persisted) projection.
`resolvePermissionAutoAcceptFamilyRoot` is **retained as a display aid**
so the badge still lights up for child/sub-sessions of an enabled family
(preserving the inheritance UX).
- `instances.ts` — toggle calls REST (optimistic, reconciled on success,
reverted on failure); subscribes to `yolo.stateChanged`;
`ensureYoloStateSynced` backfills the active session's state on first
connect (deduped per session, reset on SSE reconnect so it re-syncs
after a server restart).
- `session-events.ts` — removed the three
`drainAutoAcceptPermissionsForInstance` hooks (the server drains now).
- `api-client.ts` — `getYoloState` / `toggleYolo`.

## Behavior parity

| Aspect | Before | After |
|---|---|---|
| Reply semantics | `"once"` | `"once"` (unchanged) |
| Inheritance | whole family (root + non-fork descendants) | **same** |
| Fork isolation | `revert` session is its own root | **same** |
| Persistence | UI `localStorage` | none (intentional, see Notes) |
| Multi-client sync |  independent per browser |  real-time via SSE |
| Works with UI closed |  |  |
| Auto-reply path | 4 hops | 1 in-process hop |

## Testing

32 unit tests added (`node:test`), all passing. Server + UI typecheck
clean.

- **Store (16)**: inheritance (parent/child/sibling), fork isolation,
cyclic parent chains, late parent discovery, `revert` re-rooting,
per-instance independence, tree maintenance.
- **Manager (13)**: real `properties.info` event shapes, v2 vs legacy
reply, in-flight dedup + retry-after-resolve, `yolo.stateChanged`
emission, `session.deleted` keeps toggle, `workspace.stopped` clears
state, `stop()` unsubscribes.
- **UI (3)**: retained `resolvePermissionAutoAcceptFamilyRoot`
display-projection tests.

## Notes for reviewers

- **No persistence is intentional** for this milestone — server restart
resets all Yolo state (matches the agreed scope). The UI mirror
self-heals via SSE reconnect + `ensureYoloStateSynced`. Persistence can
be layered on later (e.g. into `~/.config/codenomad/config.json`)
without touching the manager.
- **Family-root resolution lives in two places on purpose**: the server
resolves it to decide whether to auto-reply; the UI resolves the same
pure function to render the badge instantly (synchronous memo). Both are
faithful ports; no network round-trip is added for display.
- **`properties.info` nesting**: OpenCode wraps session records under
`properties.info` for `session.*` events (permission events are flat).
The manager handles both and the tests use the real nested shape to
guard against regressions.
- `getYoloState` / `yolo.autoAccepted` are wired but `yolo.autoAccepted`
is not yet consumed by the UI — it's available on the wire for future
observability (e.g. an audit log / toast).

## Risk / rollback

The change is additive on the server and the UI gracefully degrades to
the SSE mirror. If the server lacks the new routes (mixed-version), the
UI's optimistic toggle still flips locally and `toggleYolo` failures are
logged + reverted, so no hard breakage.

---------

Co-authored-by: Pascal André <pascalandr@gmail.com>
2026-07-08 21:13:44 +02:00
Pascal André
bce975d940
fix(ui): stabilize strict follow autoscroll (#576)
## Summary
- replace message stream follow behavior with a strict following/escaped
state model
- keep explicit submit bottom pinning deterministic while preserving
user scroll-up escape intent
- prevent repeated pending question/permission polling from rewriting
identical store entries and snapping back to bottom
- isolate pending request dedupe logic in a raw-node-testable helper

Fixes #574

## Validation
- node --test
"packages/ui/src/components/virtual-follow-behavior.test.ts"
"packages/ui/src/components/session/session-bottom-pin-intent.test.ts"
"packages/ui/src/stores/message-v2/pending-request-dedupe.test.ts"
- npm run typecheck --workspace @codenomad/ui
- npm run build --workspace @codenomad/ui
- git diff --check
2026-07-08 18:46:55 +02:00
Dark
275770b51b
fix(ui): remove scope=project from session list requests (#572)
## Problem

PR #565 (commit e29c3a08) added `scope=project` to session list requests
so worktree/project sessions remain visible. With OpenCode 1.17.x, the
`/session` endpoint can ignore the `directory` filter when
`scope=project` is present and return sessions from unrelated folders
that share the same project/global bucket.

This can make sessions appear in the wrong project tab. Deleting one of
those sessions still deletes it from the shared OpenCode database, so the
UI must avoid showing unrelated sessions in the wrong tab.

## Reproduction

```bash
PORT=4096

curl -s -u "codenomad:$PASSWORD" \
  "http://127.0.0.1:$PORT/session?directory=/home/user/project-a&scope=project" \
  | jq 'length'
# Can include sessions from other folders too.

curl -s -u "codenomad:$PASSWORD" \
  "http://127.0.0.1:$PORT/session?directory=/home/user/project-a" \
  | jq 'length'
# Filters to the requested folder, but may miss project/worktree sessions.

Related: anomalyco/opencode#33113

Fix
Keep scope: "project" so OpenCode still returns project/worktree
sessions, then filter the returned sessions client-side to the current
root directory plus known CodeNomad worktree directories.

This preserves the worktree-session visibility behavior from #565 while
avoiding unrelated cross-directory leakage when OpenCode ignores
directory alongside scope=project.

Validation
Focused session-pagination tests pass.
UI typecheck passes.
UI build passes.
Tauri release build passes.
Manual exe tested for validation.

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Pascal André <pascalandr@gmail.com>
2026-07-08 17:56:12 +02:00
Dark
e8623d3132
fix(ui): SSE connection status indicators for mobile network drops (#549)
## Problem

Mobile users experience SSE disconnections (wifi drops, Cloudflare idle
timeouts) but get no visual feedback. The per-instance status dots
remain green even when the SSE transport is down, because instance
status updates travel over the broken SSE pipe.

## Solution

This PR implements **immediate connection status feedback** by
propagating SSE transport state to all instance status indicators:

### Changes

1. **server-events.ts**: Add `onDisconnect()` handler
   - Mirrors existing `onOpen()` pattern
- Fire `disconnectHandlers` in `scheduleReconnect()` when connection
drops

2. **sse-manager.ts**: Register SSE lifecycle handlers
   - `onDisconnect`: set ALL instances to `connecting` (amber dot)
- `onOpen`: clear `connecting` status (green dot restored by subsequent
events)
   - Log transitions for debug visibility

3. **AGENTS.md**: Add PR Review Principles
   - Check regressions first, look for better implementations
   - Be the PR gatekeeper, ruthless code quality
   - Test before responding, UI/server version parity

## Verification

-  TypeScript compilation clean (pre-existing SDK errors unrelated)
-  Vite build successful
-  Mobile testing: SSE disconnect → immediate amber dots
-  Reconnect → green dots restored

## Edge Cases Handled

- **Transient drop**: amber → green on reconnect (no false disconnect
modal)
- **Instance dies during outage**: amber → green → `disconnected` event
→ red
- **0 instances**: Map empty, loop is no-op
- **Rapid reconnect cycles**: idempotent (setting `connecting` on
`connecting` is no-op)

## Related

- Complements PR #519 (pong retry with timeout) - merged upstream
- Addresses mobile UX gap: workspace-level SSE state now visible to all
users (not just debug overlay)

---------

Co-authored-by: Shantur Rathore <i@shantur.com>
Co-authored-by: Pascal André <pascalandr@gmail.com>
2026-06-27 20:39:13 +02:00
Shantur Rathore
31414521f6 Min server 0.18.0 2026-06-21 20:09:49 +01:00
392 changed files with 36240 additions and 6405 deletions

View file

@ -317,15 +317,98 @@ jobs:
- name: Verify bundled Node resource (Electron Linux) - name: Verify bundled Node resource (Electron Linux)
run: node scripts/verify-bundled-node.cjs packages/electron-app/electron/resources@linux-x64 run: node scripts/verify-bundled-node.cjs packages/electron-app/electron/resources@linux-x64
- name: Verify Electron portable archive
run: |
set -euo pipefail
shopt -s nullglob
archives=(packages/electron-app/release/*.tar.gz)
if [ "${#archives[@]}" -ne 1 ]; then
echo "Expected one Electron Linux tar.gz, found ${#archives[@]}" >&2
exit 1
fi
extract_dir=$(mktemp -d)
trap 'rm -rf "$extract_dir"' EXIT
tar -xzf "${archives[0]}" -C "$extract_dir"
binary=$(find "$extract_dir" -type f -name CodeNomad -print -quit)
if [ -z "$binary" ] || [ ! -x "$binary" ]; then
echo "Electron portable archive does not contain an executable CodeNomad binary" >&2
exit 1
fi
resources_dir="$(dirname "$binary")/resources"
node scripts/smoke-packaged-resources.cjs \
--resources "$resources_dir" \
--target linux-x64
app_asar="$resources_dir/app.asar"
if [ ! -f "$app_asar" ]; then
echo "Electron portable archive does not contain resources/app.asar" >&2
exit 1
fi
node - "$app_asar" <<'NODE'
const asar = require("@electron/asar")
const path = require("node:path")
const archive = process.argv[2]
const files = asar.listPackage(archive).map((file) =>
file.replace(/^[/\\]/, "").replaceAll("\\", "/"),
)
const fileSet = new Set(files)
const loadingPath = "dist/renderer/loading.html"
if (!fileSet.has(loadingPath)) {
throw new Error("Electron app.asar is missing dist/renderer/loading.html")
}
if (!files.some((file) => file.startsWith("dist/renderer/assets/") && !file.endsWith("/"))) {
throw new Error("Electron app.asar does not contain renderer assets")
}
const html = asar.extractFile(archive, loadingPath).toString("utf8")
for (const match of html.matchAll(/\b(?:src|href)=["']([^"']+)["']/g)) {
const reference = match[1]
if (/^(?:[a-z]+:|#)/i.test(reference)) continue
const cleanReference = reference.split(/[?#]/, 1)[0]
const target = cleanReference.startsWith("/")
? cleanReference.slice(1)
: path.posix.normalize(path.posix.join(path.posix.dirname(loadingPath), cleanReference))
if (!fileSet.has(target)) {
throw new Error(`Electron app.asar is missing renderer reference ${target}`)
}
}
NODE
ldd "$binary" | tee /tmp/codenomad-electron-ldd.txt
if grep -q "not found" /tmp/codenomad-electron-ldd.txt; then
echo "Electron portable binary has unresolved shared libraries" >&2
exit 1
fi
- name: Upload release assets - name: Upload release assets
if: ${{ inputs.upload && inputs.tag != '' }} if: ${{ inputs.upload && inputs.tag != '' }}
run: | run: |
set -euo pipefail set -euo pipefail
shopt -s nullglob shopt -s nullglob
for file in packages/electron-app/release/*.zip packages/electron-app/release/*.AppImage; do files=(packages/electron-app/release/*.tar.gz)
[ -f "$file" ] || continue if [ "${#files[@]}" -ne 1 ]; then
echo "Uploading $file" echo "Expected one Electron Linux release asset, found ${#files[@]}" >&2
gh release upload "$TAG" "$file" --clobber exit 1
fi
file="${files[0]}"
asset_name=$(basename "$file")
echo "Uploading $file"
gh release upload "$TAG" "$file" --clobber
if ! gh release view "$TAG" --json assets --jq '.assets[].name' | grep -Fxq -- "$asset_name"; then
echo "Uploaded Electron Linux asset is missing from release: $asset_name" >&2
exit 1
fi
gh release view "$TAG" --json assets --jq '.assets[].name' | while IFS= read -r asset; do
case "$asset" in
CodeNomad-Electron-linux-*.zip|CodeNomad-Electron-linux-*.AppImage|CodeNomad-Electron-linux-*.flatpak)
echo "Deleting obsolete Linux asset $asset"
gh release delete-asset "$TAG" "$asset" --yes
;;
esac
done done
- name: Upload Actions artifacts (Electron Linux) - name: Upload Actions artifacts (Electron Linux)
@ -333,9 +416,7 @@ jobs:
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: ${{ inputs.actions_artifacts_name_prefix }}electron-linux name: ${{ inputs.actions_artifacts_name_prefix }}electron-linux
path: | path: packages/electron-app/release/*.tar.gz
packages/electron-app/release/*.zip
packages/electron-app/release/*.AppImage
retention-days: ${{ inputs.actions_artifacts_retention_days }} retention-days: ${{ inputs.actions_artifacts_retention_days }}
if-no-files-found: error if-no-files-found: error
@ -364,6 +445,9 @@ jobs:
if: ${{ inputs.set_versions && inputs.version != '' }} if: ${{ inputs.set_versions && inputs.version != '' }}
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
- name: Sync Tauri package version
run: npm run sync:version --workspace @codenomad/tauri-app
- name: Install dependencies - name: Install dependencies
run: npm ci --workspaces --include=optional run: npm ci --workspaces --include=optional
@ -451,6 +535,9 @@ jobs:
if: ${{ inputs.set_versions && inputs.version != '' }} if: ${{ inputs.set_versions && inputs.version != '' }}
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
- name: Sync Tauri package version
run: npm run sync:version --workspace @codenomad/tauri-app
- name: Install dependencies - name: Install dependencies
run: npm ci --workspaces --include=optional run: npm ci --workspaces --include=optional
@ -539,6 +626,9 @@ jobs:
run: npm version ${{ env.VERSION }} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version run: npm version ${{ env.VERSION }} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
shell: bash shell: bash
- name: Sync Tauri package version
run: npm run sync:version --workspace @codenomad/tauri-app
- name: Install dependencies - name: Install dependencies
run: npm ci --workspaces --include=optional run: npm ci --workspaces --include=optional
@ -643,6 +733,9 @@ jobs:
if: ${{ inputs.set_versions && inputs.version != '' }} if: ${{ inputs.set_versions && inputs.version != '' }}
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
- name: Sync Tauri package version
run: npm run sync:version --workspace @codenomad/tauri-app
- name: Install dependencies - name: Install dependencies
run: npm ci --workspaces --include=optional run: npm ci --workspaces --include=optional
@ -669,53 +762,135 @@ jobs:
echo "Tauri CLI failed to load after retries" >&2 echo "Tauri CLI failed to load after retries" >&2
exit 1 exit 1
- name: Build Linux bundle (Tauri) - name: Build Debian package (Tauri)
working-directory: packages/tauri-app working-directory: packages/tauri-app
run: npm exec -- tauri build run: npm exec -- tauri build --bundles deb
- name: Package Tauri artifacts (Linux) - name: Package and verify Tauri deb
if: ${{ inputs.upload || inputs.upload_actions_artifacts }} if: ${{ inputs.upload || inputs.upload_actions_artifacts }}
run: | run: |
set -euo pipefail set -euo pipefail
SEARCH_ROOT="packages/tauri-app/target" BUNDLE_ROOT="packages/tauri-app/target/release/bundle/deb"
ARTIFACT_DIR="packages/tauri-app/release-tauri" ARTIFACT_DIR="packages/tauri-app/release-tauri"
VERSION_TO_USE="${VERSION:-}"
if [ -z "$VERSION_TO_USE" ]; then
VERSION_TO_USE=$(node -p "require('./packages/tauri-app/package.json').version")
fi
rm -rf "$ARTIFACT_DIR" rm -rf "$ARTIFACT_DIR"
mkdir -p "$ARTIFACT_DIR" mkdir -p "$ARTIFACT_DIR"
shopt -s nullglob globstar shopt -s nullglob
find_one() { debs=("$BUNDLE_ROOT"/*.deb)
find "$SEARCH_ROOT" -type f -iname "$1" | head -n1 if [ "${#debs[@]}" -ne 1 ]; then
} echo "Expected one Tauri deb under $BUNDLE_ROOT, found ${#debs[@]}" >&2
appimage=$(find_one "*.AppImage")
fallback_bin="$SEARCH_ROOT/release/codenomad-tauri"
if [ -z "$appimage" ] || [ ! -f "$fallback_bin" ]; then
echo "Missing bundle(s): appimage=${appimage:-none} binary=$fallback_bin" >&2
exit 1 exit 1
fi fi
cp "$appimage" "$ARTIFACT_DIR/CodeNomad-Tauri-linux-x64-${VERSION}.AppImage" artifact="$ARTIFACT_DIR/CodeNomad-Tauri-linux-x64-${VERSION_TO_USE}.deb"
zip -j "$ARTIFACT_DIR/CodeNomad-Tauri-linux-x64-${VERSION}.zip" "$fallback_bin" cp "${debs[0]}" "$artifact"
dpkg-deb --info "$artifact"
dpkg-deb --contents "$artifact"
package_version=$(dpkg-deb --field "$artifact" Version)
if [ "$package_version" != "$VERSION_TO_USE" ]; then
echo "Tauri deb version mismatch: expected $VERSION_TO_USE, found $package_version" >&2
exit 1
fi
extract_dir=$(mktemp -d)
trap 'rm -rf "$extract_dir"' EXIT
dpkg-deb --extract "$artifact" "$extract_dir"
binary="$extract_dir/usr/bin/codenomad-tauri"
resources_dir="$extract_dir/usr/lib/CodeNomad/resources"
desktop_file="$extract_dir/usr/share/applications/CodeNomad.desktop"
for required in "$binary" "$resources_dir" "$desktop_file"; do
if [ ! -e "$required" ]; then
echo "Missing path in Tauri deb: $required" >&2
exit 1
fi
done
grep -Fx "Exec=codenomad-tauri" "$desktop_file"
if grep -Fxq "NoDisplay=true" "$desktop_file"; then
echo "Generated Tauri desktop entry must be visible" >&2
exit 1
fi
node scripts/smoke-packaged-resources.cjs \
--resources "$resources_dir" \
--loading "$resources_dir/ui-loading" \
--target linux-x64
ldd "$binary" | tee /tmp/codenomad-tauri-ldd.txt
if grep -q "not found" /tmp/codenomad-tauri-ldd.txt; then
echo "Tauri deb binary has unresolved shared libraries" >&2
exit 1
fi
- name: Verify Tauri deb installation on Ubuntu 24.04
if: ${{ inputs.upload || inputs.upload_actions_artifacts }}
run: |
set -euo pipefail
shopt -s nullglob
debs=(packages/tauri-app/release-tauri/*.deb)
if [ "${#debs[@]}" -ne 1 ]; then
echo "Expected one packaged Tauri deb, found ${#debs[@]}" >&2
exit 1
fi
artifact=$(realpath "${debs[0]}")
docker run --rm \
--volume "$artifact:/tmp/codenomad.deb:ro" \
ubuntu:24.04 \
bash -euc '
export DEBIAN_FRONTEND=noninteractive
apt-get update >/dev/null
apt-get install -y /tmp/codenomad.deb >/dev/null
test -x /usr/bin/codenomad-tauri
test -x /usr/lib/CodeNomad/resources/node/linux-x64/bin/node
test -f /usr/lib/CodeNomad/resources/server/dist/bin.js
test -f /usr/lib/CodeNomad/resources/ui-loading/loading.html
/usr/lib/CodeNomad/resources/node/linux-x64/bin/node \
/usr/lib/CodeNomad/resources/server/dist/bin.js --version
ldd /usr/bin/codenomad-tauri | tee /tmp/codenomad-tauri-ldd.txt
! grep -q "not found" /tmp/codenomad-tauri-ldd.txt
'
- name: Upload Actions artifacts (Tauri Linux) - name: Upload Actions artifacts (Tauri Linux)
if: ${{ inputs.upload_actions_artifacts }} if: ${{ inputs.upload_actions_artifacts }}
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: ${{ inputs.actions_artifacts_name_prefix }}tauri-linux name: ${{ inputs.actions_artifacts_name_prefix }}tauri-linux
path: packages/tauri-app/release-tauri/* path: packages/tauri-app/release-tauri/*.deb
retention-days: ${{ inputs.actions_artifacts_retention_days }} retention-days: ${{ inputs.actions_artifacts_retention_days }}
if-no-files-found: warn if-no-files-found: error
- name: Upload Tauri release assets (Linux) - name: Upload Tauri release assets (Linux)
if: ${{ inputs.upload && inputs.tag != '' }} if: ${{ inputs.upload && inputs.tag != '' }}
run: | run: |
set -euo pipefail set -euo pipefail
shopt -s nullglob shopt -s nullglob
for file in packages/tauri-app/release-tauri/*; do files=(packages/tauri-app/release-tauri/*.deb)
[ -f "$file" ] || continue if [ "${#files[@]}" -ne 1 ]; then
echo "Uploading $file" echo "Expected one Tauri Linux release asset, found ${#files[@]}" >&2
gh release upload "$TAG" "$file" --clobber exit 1
fi
file="${files[0]}"
asset_name=$(basename "$file")
echo "Uploading $file"
gh release upload "$TAG" "$file" --clobber
if ! gh release view "$TAG" --json assets --jq '.assets[].name' | grep -Fxq -- "$asset_name"; then
echo "Uploaded Tauri Linux asset is missing from release: $asset_name" >&2
exit 1
fi
gh release view "$TAG" --json assets --jq '.assets[].name' | while IFS= read -r asset; do
case "$asset" in
CodeNomad-Tauri-linux-*.zip|CodeNomad-Tauri-linux-*.AppImage|CodeNomad-Tauri-linux-*.rpm|CodeNomad-Tauri-linux-*.flatpak)
echo "Deleting obsolete Linux asset $asset"
gh release delete-asset "$TAG" "$asset" --yes
;;
esac
done done
build-tauri-linux-arm64: build-tauri-linux-arm64:
@ -772,6 +947,9 @@ jobs:
- name: Set workspace versions - name: Set workspace versions
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
- name: Sync Tauri package version
run: npm run sync:version --workspace @codenomad/tauri-app
- name: Install dependencies - name: Install dependencies
run: npm ci --workspaces --include=optional run: npm ci --workspaces --include=optional

View file

@ -93,6 +93,11 @@ jobs:
return; return;
} }
if (matchedRun.conclusion !== 'success') {
core.setFailed(`PR Build Validation run ${matchedRun.id} concluded ${matchedRun.conclusion}.`);
return;
}
const artifacts = await github.paginate( const artifacts = await github.paginate(
github.rest.actions.listWorkflowRunArtifacts, github.rest.actions.listWorkflowRunArtifacts,
{ owner, repo, run_id: matchedRun.id, per_page: 100 } { owner, repo, run_id: matchedRun.id, per_page: 100 }

View file

@ -46,7 +46,10 @@ jobs:
fi fi
build: build:
needs: authorize needs:
- authorize
- tests
- tests-tauri-windows
if: ${{ needs.authorize.outputs.allowed == 'true' && !github.event.pull_request.draft }} if: ${{ needs.authorize.outputs.allowed == 'true' && !github.event.pull_request.draft }}
uses: ./.github/workflows/build-and-upload.yml uses: ./.github/workflows/build-and-upload.yml
with: with:
@ -56,3 +59,119 @@ jobs:
actions_artifacts_retention_days: 7 actions_artifacts_retention_days: 7
actions_artifacts_name_prefix: pr-${{ github.event.pull_request.number }}-${{ github.event.pull_request.head.sha }}- actions_artifacts_name_prefix: pr-${{ github.event.pull_request.number }}-${{ github.event.pull_request.head.sha }}-
set_versions: false set_versions: false
tests:
needs: authorize
if: ${{ needs.authorize.outputs.allowed == 'true' && !github.event.pull_request.draft }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
- name: Install Linux test dependencies (Tauri)
run: |
sudo apt-get update
sudo apt-get install -y \
build-essential \
pkg-config \
libgtk-3-dev \
libglib2.0-dev \
libwebkit2gtk-4.1-dev \
libsoup-3.0-dev \
libayatana-appindicator3-dev \
librsvg2-dev
- name: Install dependencies
run: npm ci
- name: Typecheck desktop clients
run: npm run typecheck
- name: Test Electron client state
run: npm run test:native --workspace @neuralnomads/codenomad-electron-app
- name: Test changed runnable UI behavior
run: >-
node --import tsx --test
packages/ui/src/components/session-list-visibility.test.ts
packages/ui/src/lib/hooks/use-app-session-capture.test.ts
packages/ui/src/lib/message-selection-position.test.ts
packages/ui/src/lib/trailing-resync.test.ts
packages/ui/src/stores/abort-created-workspace-cleanup.test.ts
packages/ui/src/stores/app-session-reconciliation.test.ts
packages/ui/src/stores/app-session-restore-gate.test.ts
packages/ui/src/stores/app-session-restore-queue.test.ts
packages/ui/src/stores/app-session-restore-timeout.test.ts
packages/ui/src/stores/app-session-snapshot-merge.test.ts
packages/ui/src/stores/restore-workspace-commit-gates.test.ts
packages/ui/src/stores/client-state-codec.test.ts
packages/ui/src/stores/client-state.test.ts
packages/ui/src/stores/instances-restore-cancellation.test.ts
packages/ui/src/stores/message-v2/message-hydration-authority.test.ts
packages/ui/src/stores/session-generation-recovery.test.ts
packages/ui/src/stores/session-metadata.test.ts
packages/ui/src/stores/session-pagination.test.ts
packages/ui/src/stores/workspace-list-reconciliation-fence.test.ts
- name: Test restore ownership integration
run: >-
node --conditions=browser --import tsx --test --test-force-exit
packages/ui/src/stores/instances-restore-ownership.test.ts
packages/ui/src/stores/permission-lifecycle.test.ts
- name: Test server
run: node --import tsx --test "packages/server/src/**/*.test.ts"
- name: Prepare Tauri test resources
run: >-
npm run dev:prep --workspace @codenomad/tauri-app &&
node -e "require('fs').mkdirSync('packages/tauri-app/src-tauri/resources/server',{recursive:true})"
- name: Test Tauri crate
working-directory: packages/tauri-app/src-tauri
run: cargo test --locked
tests-tauri-windows:
needs: authorize
if: ${{ needs.authorize.outputs.allowed == 'true' && !github.event.pull_request.draft }}
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
- name: Install dependencies
run: npm ci
- name: Test Windows server spawn behavior
run: node --import tsx --test packages/server/src/workspaces/__tests__/spawn.test.ts
- name: Prepare Tauri test resources
run: >-
npm run dev:prep --workspace @codenomad/tauri-app &&
node -e "require('fs').mkdirSync('packages/tauri-app/src-tauri/resources/server',{recursive:true})"
- name: Test Tauri crate on Windows
working-directory: packages/tauri-app/src-tauri
run: cargo test --locked

View file

@ -7,11 +7,12 @@ End-to-end feature flows with decision branches and mechanism references.
1. **Server:** Backend emits SSE event `permission.asked` or `permission.updated` 1. **Server:** Backend emits SSE event `permission.asked` or `permission.updated`
- Events are pushed through the instance event stream - Events are pushed through the instance event stream
2. **UI Store:** `packages/ui/src/stores/instances.ts` receives via `serverEvents` handler 2. **Server AutoAcceptManager** intercepts permission events (if Yolo is enabled)
- **Branch:** IF `isPermissionAutoAcceptEnabled(instanceId, sessionId)` is true - **File:** `packages/server/src/permissions/auto-accept-manager.ts`
- **Mechanism:** `drainAutoAcceptPermissions()` in `packages/ui/src/stores/permission-auto-accept.ts` - **Action:** Auto-replies via SDK client, emits `yolo.autoAccepted` to UI for immediate queue cleanup
- **Action:** Automatically calls `Permission.reply()`, skips modal display - **Pending drain:** Re-drains pending permissions on toggle(enable) and session ancestry changes
- **File:** `packages/ui/src/stores/permission-auto-accept.ts:drainAutoAcceptPermission()` 3. **UI Store:** `packages/ui/src/stores/instances.ts` receives via `serverEvents`
- **Branch:** IF `yolo.autoAccepted` event arrives → marks replied + removes from queue immediately
- **Branch:** ELSE (normal flow) - **Branch:** ELSE (normal flow)
- **Mechanism:** Permission queued in `permissionQueues` signal - **Mechanism:** Permission queued in `permissionQueues` signal
- **Action:** Display approval modal - **Action:** Display approval modal

View file

@ -129,20 +129,20 @@ Rapid successive operations can cause temporary desync:
1. **Server emits** `permission.asked` or `permission.updated` SSE event 1. **Server emits** `permission.asked` or `permission.updated` SSE event
- Pushed through instance event stream - Pushed through instance event stream
2. **UI Store receives** via `serverEvents` 2. **Server AutoAcceptManager** intercepts the event (if Yolo is enabled)
- File: `packages/server/src/permissions/auto-accept-manager.ts`
- Action: Auto-replies via SDK client (`createInstanceClient`), tracks pending permissions, drains on enable/ancestry change
- Emits `yolo.autoAccepted` + `yolo.stateChanged` events to UI
3. **UI Store receives** via `serverEvents`
- File: `packages/ui/src/stores/instances.ts` - File: `packages/ui/src/stores/instances.ts`
- **Branch:** IF `isPermissionAutoAcceptEnabled()` - **Branch:** IF `yolo.autoAccepted` event arrives → immediately marks replied + removes from queue
- Mechanism: `drainAutoAcceptPermissions()` in `packages/ui/src/stores/permission-auto-accept.ts` - **Branch:** ELSE (user must reply) → Queued in `permissionQueues` → Display modal
- Action: Calls reply immediately, skips modal 4. **UI Store:** `packages/ui/src/stores/message-v2/bridge.ts` calls `upsertPermissionV2()`
- **Branch:** ELSE 5. **UI Component:** `packages/ui/src/components/permission-approval-modal.tsx` displays
- Mechanism: Queued in `permissionQueues` 6. **User Action:** Calls `packages/ui/src/stores/instances.ts:sendPermissionResponse()`
- Action: Display modal 7. **SDK Call:** `client.permission.reply()` via `packages/ui/src/lib/opencode-api.ts`
3. **UI Store:** `packages/ui/src/stores/message-v2/bridge.ts` calls `upsertPermissionV2()` 8. **Optimistic Update:** `removePermissionV2()` in bridge
4. **UI Component:** `packages/ui/src/components/permission-approval-modal.tsx` displays 9. **SSE Confirmation:** `permission.replied` event
5. **User Action:** Calls `packages/ui/src/stores/instances.ts:sendPermissionResponse()`
6. **SDK Call:** `client.permission.reply()` via `packages/ui/src/lib/opencode-api.ts`
7. **Optimistic Update:** `removePermissionV2()` in bridge
8. **SSE Confirmation:** `permission.replied` event
- **Branch:** IF SSE disconnected → `syncPendingPermissions()` reconciles on reconnect - **Branch:** IF SSE disconnected → `syncPendingPermissions()` reconciles on reconnect
## Session Event Handling ## Session Event Handling

View file

@ -56,14 +56,14 @@ bun run build:win-arm64
### Linux ### Linux
```bash ```bash
# x64 (64-bit) # Portable Electron archive (x64)
bun run build:linux npm run build:linux --workspace @neuralnomads/codenomad-electron-app
# ARM64 # Tauri Debian package (x64)
bun run build:linux-arm64 npm exec --workspace @codenomad/tauri-app -- tauri build --bundles deb
``` ```
**Output formats:** `.AppImage`, `.deb`, `.tar.gz` **Release formats:** Electron `.tar.gz` portable archive and Tauri `.deb` installer.
### Build All Platforms ### Build All Platforms
@ -83,27 +83,29 @@ The build script performs these steps:
## Output ## Output
Binaries are generated in the `release/` directory: Build artifacts are generated in package-specific output directories:
``` ```
release/ packages/electron-app/release/
├── CodeNomad-0.1.0-mac-universal.dmg └── CodeNomad-Electron-linux-x64-0.18.0.tar.gz
├── CodeNomad-0.1.0-mac-universal.zip
├── CodeNomad-0.1.0-win-x64.exe packages/tauri-app/target/release/bundle/deb/
├── CodeNomad-0.1.0-linux-x64.AppImage └── CodeNomad_0.18.0_amd64.deb
└── ...
``` ```
## File Naming Convention ## File Naming Convention
``` ```
CodeNomad-{version}-{os}-{arch}.{ext} CodeNomad-Electron-{os}-{arch}-{version}.{ext}
CodeNomad-Tauri-{os}-{arch}-{version}.{ext}
``` ```
- **version**: From package.json (e.g., `0.1.0`) - **version**: From package.json (e.g., `0.18.0`)
- **os**: `mac`, `win`, `linux` - **os**: `macos`, `windows`, `linux`
- **arch**: `x64`, `arm64`, `universal` - **arch**: `x64`, `arm64`, `universal`
- **ext**: `dmg`, `zip`, `exe`, `AppImage`, `deb`, `tar.gz` - **ext**: `dmg`, `zip`, `exe`, `deb`, `tar.gz`
The Tauri build directory uses Tauri's native Debian filename. CI renames the package to the convention above when preparing release assets.
## Platform Requirements ## Platform Requirements
@ -121,9 +123,9 @@ CodeNomad-{version}-{os}-{arch}.{ext}
### Linux ### Linux
- **Build on:** Any platform - **Build on:** Linux x64
- **Run on:** Ubuntu 18.04+, Debian 10+, Fedora 32+, Arch - **Electron portable:** extract the tar.gz and run the `CodeNomad` executable
- **Dependencies:** Varies by distro - **Tauri deb:** built and installation-tested on Ubuntu 24.04; older distributions are not yet guaranteed
## Troubleshooting ## Troubleshooting
@ -136,13 +138,7 @@ xcode-select --install
### Build fails on Linux ### Build fails on Linux
```bash Install the Electron and Tauri build dependencies documented by their upstream projects. Release builds currently target Linux x64 and produce an Electron portable archive plus a Tauri Debian package.
# Install dependencies (Debian/Ubuntu)
sudo apt-get install -y rpm
# Install dependencies (Fedora)
sudo dnf install -y rpm-build
```
### "electron-builder not found" ### "electron-builder not found"

View file

@ -40,7 +40,9 @@ Download the latest installer for your platform from [Releases](https://github.c
|----------|---------| |----------|---------|
| macOS | DMG, ZIP (Universal: Intel + Apple Silicon) | | macOS | DMG, ZIP (Universal: Intel + Apple Silicon) |
| Windows | NSIS Installer, ZIP (x64, ARM64) | | Windows | NSIS Installer, ZIP (x64, ARM64) |
| Linux | AppImage, deb, tar.gz (x64, ARM64) | | Linux | Tauri deb, Electron portable tar.gz (x64) |
The Tauri deb is currently built and installation-tested on Ubuntu 24.04. Compatibility with older Debian-based distributions is not yet guaranteed.
### 💻 CodeNomad Server ### 💻 CodeNomad Server
@ -169,7 +171,7 @@ On Intel Macs, also check **System Settings → Privacy & Security** on first la
WebKitGTK DMA-BUF/GBM issue. Run with: WebKitGTK DMA-BUF/GBM issue. Run with:
```bash ```bash
WEBKIT_DISABLE_DMABUF_RENDERER=1 codenomad WEBKIT_DISABLE_DMABUF_RENDERER=1 codenomad-tauri
``` ```
See full workaround in the original README. See full workaround in the original README.

28
THIRD_PARTY_NOTICES.md Normal file
View file

@ -0,0 +1,28 @@
# Third-Party Notices
## OpenChamber
The provider usage adapters are derived from OpenChamber:
https://github.com/openchamber/openchamber
MIT License
Copyright (c) 2025 Bohdan Triapitsyn
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -584,7 +584,7 @@ npm run package # Create distributable
- macOS: DMG + auto-update - macOS: DMG + auto-update
- Windows: NSIS installer + auto-update - Windows: NSIS installer + auto-update
- Linux: AppImage + deb/rpm - Linux: Electron portable tar.gz + Tauri deb
## Configuration Files ## Configuration Files

View file

@ -0,0 +1,38 @@
# Bracket Math Delimiters Design
## Goal
Support inline `\(...\)` and display `\[...\]` math in CodeNomad Markdown while preserving existing `$...$` and `$$...$$` behavior. No unrelated Markdown changes.
## Approach
Keep `marked-katex-extension` as owner of dollar-delimited math. Add two small parser-native Marked extensions in `packages/ui/src/lib/markdown.ts`:
- Inline rule: recognize `\(...\)` and render with KaTeX `displayMode: false`.
- Block rule: recognize `\[...\]` and render with KaTeX `displayMode: true`.
Both rules will use `katex.renderToString()` with the same error policy as the existing integration: `throwOnError: false` and `strict: "ignore"`. Parser-native rules avoid source rewriting, ignore fenced/code content through Marked's normal lexer flow, and preserve unmatched delimiters as plain Markdown text.
## Integration
Register the two rules beside the existing `markedKatex(...)` registration in `setupRenderer()`. Existing renderer setup, syntax highlighting, HTML handling, styles, and component APIs remain unchanged. No new runtime dependency is needed because KaTeX is already a direct UI dependency.
## Edge Cases
- `\\(` and `\\[` remain escaped literal text rather than opening math.
- Inline math must close with `\)` before a line break.
- Display math may span lines and must close with `\]`.
- Empty or unmatched delimiter pairs remain text.
- Existing dollar delimiter boundary behavior remains controlled by `marked-katex-extension` with `nonStandard: true`.
## Tests
Add focused Markdown rendering regression tests covering:
- `\(x^2\)` renders inline KaTeX.
- `\[x^2\]` renders display KaTeX, including multiline content.
- `$x^2$` and `$$x^2$$` still render.
- Delimiters inside inline and fenced code remain literal.
- Escaped, empty, and unmatched bracket delimiters remain literal.
Run the repository's confirmed UI test command, UI typecheck, and inspect the final diff.

71
package-lock.json generated
View file

@ -73,6 +73,7 @@
"version": "7.28.5", "version": "7.28.5",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.27.1", "@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5", "@babel/generator": "^7.28.5",
@ -4295,6 +4296,7 @@
"version": "7.20.5", "version": "7.20.5",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/parser": "^7.20.7", "@babel/parser": "^7.20.7",
"@babel/types": "^7.20.7", "@babel/types": "^7.20.7",
@ -4396,6 +4398,7 @@
"version": "22.19.0", "version": "22.19.0",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
@ -4470,6 +4473,7 @@
"integrity": "sha512-MCbrb508JZHqe7bUibmZj/lyojdhLRnfkmyXnkrCM2zVrjTgL89U8UEfInpKTvPeTnxsw2hmyZxnhsdNR6yhwg==", "integrity": "sha512-MCbrb508JZHqe7bUibmZj/lyojdhLRnfkmyXnkrCM2zVrjTgL89U8UEfInpKTvPeTnxsw2hmyZxnhsdNR6yhwg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"cac": "^6.7.14", "cac": "^6.7.14",
"colorette": "^2.0.20", "colorette": "^2.0.20",
@ -4552,6 +4556,7 @@
"version": "6.12.6", "version": "6.12.6",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"fast-deep-equal": "^3.1.1", "fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0", "fast-json-stable-stringify": "^2.0.0",
@ -4754,7 +4759,6 @@
"version": "5.3.2", "version": "5.3.2",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"archiver-utils": "^2.1.0", "archiver-utils": "^2.1.0",
"async": "^3.2.4", "async": "^3.2.4",
@ -4772,7 +4776,6 @@
"version": "2.1.0", "version": "2.1.0",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"glob": "^7.1.4", "glob": "^7.1.4",
"graceful-fs": "^4.2.0", "graceful-fs": "^4.2.0",
@ -4793,7 +4796,6 @@
"version": "2.3.8", "version": "2.3.8",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"core-util-is": "~1.0.0", "core-util-is": "~1.0.0",
"inherits": "~2.0.3", "inherits": "~2.0.3",
@ -4807,14 +4809,12 @@
"node_modules/archiver-utils/node_modules/safe-buffer": { "node_modules/archiver-utils/node_modules/safe-buffer": {
"version": "5.1.2", "version": "5.1.2",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/archiver-utils/node_modules/string_decoder": { "node_modules/archiver-utils/node_modules/string_decoder": {
"version": "1.1.1", "version": "1.1.1",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"safe-buffer": "~5.1.0" "safe-buffer": "~5.1.0"
} }
@ -5128,7 +5128,6 @@
"version": "4.1.0", "version": "4.1.0",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"buffer": "^5.5.0", "buffer": "^5.5.0",
"inherits": "^2.0.4", "inherits": "^2.0.4",
@ -5192,6 +5191,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.9.0", "baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759", "caniuse-lite": "^1.0.30001759",
@ -5682,7 +5682,6 @@
"version": "4.1.2", "version": "4.1.2",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"buffer-crc32": "^0.2.13", "buffer-crc32": "^0.2.13",
"crc32-stream": "^4.0.2", "crc32-stream": "^4.0.2",
@ -5812,7 +5811,6 @@
"version": "1.2.2", "version": "1.2.2",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"crc32": "bin/crc32.njs" "crc32": "bin/crc32.njs"
}, },
@ -5824,7 +5822,6 @@
"version": "4.0.3", "version": "4.0.3",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"crc-32": "^1.2.0", "crc-32": "^1.2.0",
"readable-stream": "^3.4.0" "readable-stream": "^3.4.0"
@ -6190,6 +6187,7 @@
"version": "24.13.3", "version": "24.13.3",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"app-builder-lib": "24.13.3", "app-builder-lib": "24.13.3",
"builder-util": "24.13.1", "builder-util": "24.13.1",
@ -6356,7 +6354,6 @@
"version": "24.13.3", "version": "24.13.3",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"app-builder-lib": "24.13.3", "app-builder-lib": "24.13.3",
"archiver": "^5.3.1", "archiver": "^5.3.1",
@ -6368,7 +6365,6 @@
"version": "10.1.0", "version": "10.1.0",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"graceful-fs": "^4.2.0", "graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1", "jsonfile": "^6.0.1",
@ -6382,7 +6378,6 @@
"version": "6.2.0", "version": "6.2.0",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"universalify": "^2.0.0" "universalify": "^2.0.0"
}, },
@ -6394,7 +6389,6 @@
"version": "2.0.1", "version": "2.0.1",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">= 10.0.0" "node": ">= 10.0.0"
} }
@ -7129,8 +7123,7 @@
"node_modules/fs-constants": { "node_modules/fs-constants": {
"version": "1.0.0", "version": "1.0.0",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/fs-extra": { "node_modules/fs-extra": {
"version": "8.1.0", "version": "8.1.0",
@ -8347,8 +8340,7 @@
"node_modules/isarray": { "node_modules/isarray": {
"version": "1.0.0", "version": "1.0.0",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/isbinaryfile": { "node_modules/isbinaryfile": {
"version": "5.0.6", "version": "5.0.6",
@ -8398,6 +8390,7 @@
"version": "1.21.7", "version": "1.21.7",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"jiti": "bin/jiti.js" "jiti": "bin/jiti.js"
} }
@ -8554,7 +8547,6 @@
"version": "1.0.1", "version": "1.0.1",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"readable-stream": "^2.0.5" "readable-stream": "^2.0.5"
}, },
@ -8566,7 +8558,6 @@
"version": "2.3.8", "version": "2.3.8",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"core-util-is": "~1.0.0", "core-util-is": "~1.0.0",
"inherits": "~2.0.3", "inherits": "~2.0.3",
@ -8580,14 +8571,12 @@
"node_modules/lazystream/node_modules/safe-buffer": { "node_modules/lazystream/node_modules/safe-buffer": {
"version": "5.1.2", "version": "5.1.2",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/lazystream/node_modules/string_decoder": { "node_modules/lazystream/node_modules/string_decoder": {
"version": "1.1.1", "version": "1.1.1",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"safe-buffer": "~5.1.0" "safe-buffer": "~5.1.0"
} }
@ -8652,26 +8641,22 @@
"node_modules/lodash.defaults": { "node_modules/lodash.defaults": {
"version": "4.2.0", "version": "4.2.0",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/lodash.difference": { "node_modules/lodash.difference": {
"version": "4.5.0", "version": "4.5.0",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/lodash.flatten": { "node_modules/lodash.flatten": {
"version": "4.4.0", "version": "4.4.0",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/lodash.isplainobject": { "node_modules/lodash.isplainobject": {
"version": "4.0.6", "version": "4.0.6",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/lodash.sortby": { "node_modules/lodash.sortby": {
"version": "4.7.0", "version": "4.7.0",
@ -8683,8 +8668,7 @@
"node_modules/lodash.union": { "node_modules/lodash.union": {
"version": "4.6.0", "version": "4.6.0",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/lowercase-keys": { "node_modules/lowercase-keys": {
"version": "2.0.0", "version": "2.0.0",
@ -8738,6 +8722,7 @@
"node_modules/marked": { "node_modules/marked": {
"version": "12.0.2", "version": "12.0.2",
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"marked": "bin/marked.js" "marked": "bin/marked.js"
}, },
@ -9498,6 +9483,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"nanoid": "^3.3.11", "nanoid": "^3.3.11",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
@ -9645,8 +9631,7 @@
"node_modules/process-nextick-args": { "node_modules/process-nextick-args": {
"version": "2.0.1", "version": "2.0.1",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/process-warning": { "node_modules/process-warning": {
"version": "3.0.0", "version": "3.0.0",
@ -9895,7 +9880,6 @@
"version": "3.6.2", "version": "3.6.2",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"inherits": "^2.0.3", "inherits": "^2.0.3",
"string_decoder": "^1.1.1", "string_decoder": "^1.1.1",
@ -9909,7 +9893,6 @@
"version": "1.1.3", "version": "1.1.3",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"minimatch": "^5.1.0" "minimatch": "^5.1.0"
} }
@ -10212,6 +10195,7 @@
"version": "4.52.5", "version": "4.52.5",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/estree": "1.0.8" "@types/estree": "1.0.8"
}, },
@ -10435,6 +10419,7 @@
"node_modules/seroval": { "node_modules/seroval": {
"version": "1.3.2", "version": "1.3.2",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=10" "node": ">=10"
} }
@ -10758,6 +10743,7 @@
"node_modules/solid-js": { "node_modules/solid-js": {
"version": "1.9.10", "version": "1.9.10",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"csstype": "^3.1.0", "csstype": "^3.1.0",
"seroval": "~1.3.0", "seroval": "~1.3.0",
@ -10898,7 +10884,6 @@
"version": "1.3.0", "version": "1.3.0",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"safe-buffer": "~5.2.0" "safe-buffer": "~5.2.0"
} }
@ -11232,7 +11217,6 @@
"version": "2.2.0", "version": "2.2.0",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"bl": "^4.0.3", "bl": "^4.0.3",
"end-of-stream": "^1.4.1", "end-of-stream": "^1.4.1",
@ -11425,6 +11409,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@ -11674,6 +11659,7 @@
"version": "5.9.3", "version": "5.9.3",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@ -12021,6 +12007,7 @@
"version": "5.4.21", "version": "5.4.21",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.21.3", "esbuild": "^0.21.3",
"postcss": "^8.4.43", "postcss": "^8.4.43",
@ -12879,6 +12866,7 @@
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"fast-deep-equal": "^3.1.3", "fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1", "fast-uri": "^3.0.1",
@ -13073,6 +13061,7 @@
"integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==", "integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"rollup": "dist/bin/rollup" "rollup": "dist/bin/rollup"
}, },
@ -13361,7 +13350,6 @@
"version": "4.1.1", "version": "4.1.1",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"archiver-utils": "^3.0.4", "archiver-utils": "^3.0.4",
"compress-commons": "^4.1.2", "compress-commons": "^4.1.2",
@ -13375,7 +13363,6 @@
"version": "3.0.4", "version": "3.0.4",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"glob": "^7.2.3", "glob": "^7.2.3",
"graceful-fs": "^4.2.0", "graceful-fs": "^4.2.0",
@ -13395,6 +13382,7 @@
"node_modules/zod": { "node_modules/zod": {
"version": "3.25.76", "version": "3.25.76",
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }
@ -13454,6 +13442,7 @@
"@fastify/cors": "^8.5.0", "@fastify/cors": "^8.5.0",
"@fastify/reply-from": "^9.8.0", "@fastify/reply-from": "^9.8.0",
"@fastify/static": "^7.0.4", "@fastify/static": "^7.0.4",
"@opencode-ai/sdk": "^1.17.8",
"commander": "^12.1.0", "commander": "^12.1.0",
"fastify": "^4.28.1", "fastify": "^4.28.1",
"fuzzysort": "^2.0.4", "fuzzysort": "^2.0.4",

View file

@ -1,4 +1,4 @@
{ {
"minServerVersion": "0.17.0", "minServerVersion": "0.18.0",
"latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest" "latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest"
} }

View file

@ -0,0 +1,47 @@
import { existsSync, writeFileSync } from "node:fs"
import { CrossHostRegistration, createCrossHostOwner } from "./client-state-cross-host"
import { getProcessStartIdentity } from "./client-state-process-identity"
import { isPidAlive } from "./client-state-process"
import { ClientStateManager } from "./client-state"
const [directory, startPath, readyPath, mode, userDataPath, participantReadyPath, participantContinuePath, legacyTauriDataPath, operation, payload] = process.argv.slice(2)
if (!directory || !startPath) throw new Error("Expected election directory and start path")
if (readyPath) writeFileSync(readyPath, "")
while (!existsSync(startPath)) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5)
const manager = mode === "full" && userDataPath
? new ClientStateManager(userDataPath, undefined, {
crossHostElectionDirectory: directory,
legacyTauriDataPath: legacyTauriDataPath || null,
crossHostDependencies: {
pidAlive: isPidAlive,
processStartIdentity: getProcessStartIdentity,
onParticipantPublished: participantReadyPath && participantContinuePath
? () => {
writeFileSync(participantReadyPath, "")
while (!existsSync(participantContinuePath)) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5)
}
: undefined,
},
})
: undefined
const owner = manager ? undefined : createCrossHostOwner()
const registration = owner && CrossHostRegistration.register(directory, owner, true, {
pidAlive: mode === "retire-crash" ? () => false : isPidAlive,
processStartIdentity: getProcessStartIdentity,
onOwnerPrepared: mode === "owner-crash" ? () => process.exit(91) : undefined,
onOwnerRetired: mode === "retire-crash" ? () => process.exit(91) : undefined,
})
if (manager?.isPrimary && operation === "save") {
await manager.setRestoreEnabled(true)
await manager.saveClientState(JSON.parse(payload))
}
process.stdout.write(`${JSON.stringify({
acquired: manager?.isPrimary ?? Boolean(registration?.isPrimary),
state: operation === "load" ? manager?.loadClientState() : undefined,
})}\n`)
process.stdin.resume()
process.stdin.once("end", () => {
if (manager) void manager.drainAndReleasePrimary().finally(() => process.exit())
else registration?.release()
})

View file

@ -0,0 +1,226 @@
import assert from "node:assert/strict"
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"
import { once } from "node:events"
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join, posix, win32 } from "node:path"
import { fileURLToPath } from "node:url"
import test from "node:test"
import {
CrossHostRegistration,
CROSS_HOST_OWNER_DIRECTORY,
resolveCrossHostElectionDirectory,
resolveCrossHostStatePath,
type CrossHostLeaseDependencies,
} from "./client-state-cross-host"
import type { ProcessOwner } from "./client-state-process"
function temp(t: test.TestContext): string {
const path = mkdtempSync(join(tmpdir(), "codenomad-cross-host-"))
t.after(() => rmSync(path, { recursive: true, force: true }))
return path
}
function owner(pid: number, token: string, identity = `${token}-start`): ProcessOwner {
return { pid, runToken: token, processStartIdentity: identity }
}
function dependencies(alive: boolean, identity?: string): CrossHostLeaseDependencies {
return { pidAlive: () => alive, processStartIdentity: () => identity }
}
function ownerFile(directory: string): string {
return join(directory, CROSS_HOST_OWNER_DIRECTORY, "owner.json")
}
interface Child {
process: ChildProcessWithoutNullStreams
result: Promise<boolean>
}
function child(directory: string, start: string, mode = ""): Child {
const process = spawn(globalThis.process.execPath, [
"--import", "tsx", fileURLToPath(new URL("./client-state-cross-host-child.ts", import.meta.url)), directory, start, "", mode,
]) as ChildProcessWithoutNullStreams
process.stdout.setEncoding("utf8"); process.stderr.setEncoding("utf8")
const result = new Promise<boolean>((resolve, reject) => {
let output = "", errors = ""
process.stdout.on("data", (chunk: string) => {
output += chunk
if (output.includes("\n")) resolve(JSON.parse(output).acquired)
})
process.stderr.on("data", (chunk: string) => { errors += chunk })
process.once("error", reject)
process.once("exit", (code) => { if (!output && code !== 91) reject(new Error(`child ${code}: ${errors}`)) })
})
return { process, result }
}
async function stop(...children: Child[]): Promise<void> {
const running = children.filter(({ process }) => process.exitCode === null && process.signalCode === null)
const exits = running.map(({ process }) => once(process, "exit"))
running.forEach(({ process }) => process.stdin.end())
await Promise.all(exits)
}
async function waitForExit(child: Child): Promise<void> {
if (child.process.exitCode === null && child.process.signalCode === null) await once(child.process, "exit")
}
test("simultaneous acquisition across processes yields one owner", async (t) => {
const directory = temp(t), start = join(directory, "start")
const children = Array.from({ length: 4 }, () => child(directory, start))
try {
writeFileSync(start, "")
assert.equal((await Promise.all(children.map(({ result }) => result))).filter(Boolean).length, 1)
} finally { await stop(...children) }
})
test("owner publication crash leaves no visible owner", async (t) => {
const directory = temp(t), start = join(directory, "start")
const crashed = child(directory, start, "owner-crash")
writeFileSync(start, "")
await waitForExit(crashed)
assert.equal(existsSync(join(directory, CROSS_HOST_OWNER_DIRECTORY)), false)
const winner = CrossHostRegistration.register(directory, owner(101, "winner"), true, dependencies(true, "winner-start"))!
assert.equal(winner.isPrimary, true)
})
test("stale retirement crash cannot retire a successor", async (t) => {
const directory = temp(t), staleDirectory = join(directory, CROSS_HOST_OWNER_DIRECTORY)
mkdirSync(staleDirectory)
writeFileSync(join(staleDirectory, "owner.json"), JSON.stringify(owner(4_000_000_000, "stale")))
const start = join(directory, "start"), crashed = child(directory, start, "retire-crash")
writeFileSync(start, "")
await waitForExit(crashed)
const winner = CrossHostRegistration.register(directory, owner(102, "successor"), true, dependencies(true, "successor-start"))!
assert.equal(winner.isPrimary, true)
assert.equal(JSON.parse(readFileSync(ownerFile(directory), "utf8")).runToken, "successor")
})
test("stale recovery is identity guarded and blocked by non-claiming live participants", (t) => {
for (const value of [
{ alive: false, identity: undefined, recover: true },
{ alive: true, identity: "reused", recover: true },
{ alive: true, identity: "old-start", recover: false },
{ alive: true, identity: undefined, recover: false },
]) {
const directory = temp(t), staleDirectory = join(directory, CROSS_HOST_OWNER_DIRECTORY)
mkdirSync(staleDirectory); writeFileSync(join(staleDirectory, "owner.json"), JSON.stringify(owner(201, "old", "old-start")))
const registration = CrossHostRegistration.register(directory, owner(202, "new"), true, dependencies(value.alive, value.identity))!
assert.equal(registration.isPrimary, value.recover)
}
const directory = temp(t), staleDirectory = join(directory, CROSS_HOST_OWNER_DIRECTORY)
mkdirSync(staleDirectory); writeFileSync(join(staleDirectory, "owner.json"), JSON.stringify(owner(301, "dead")))
writeFileSync(join(directory, "participant.302.secondary.json"), JSON.stringify(owner(302, "secondary")))
const identities = new Map([[302, "secondary-start"]])
const blocked = CrossHostRegistration.register(directory, owner(303, "next"), true, {
pidAlive: (pid) => pid === 302,
processStartIdentity: (pid) => identities.get(pid),
})!
assert.equal(blocked.isPrimary, false)
})
test("simultaneous claimants deterministically recover a stale owner", (t) => {
const directory = temp(t), staleDirectory = join(directory, CROSS_HOST_OWNER_DIRECTORY)
const stale = owner(601, "stale"), first = owner(602, "a"), second = owner(603, "b")
mkdirSync(staleDirectory)
const observed = JSON.stringify(stale)
writeFileSync(join(staleDirectory, "owner.json"), observed)
writeFileSync(join(directory, "participant.603.b.json"), JSON.stringify(second))
writeFileSync(join(directory, "recovery.603.b.claim"), observed)
const identities = new Map([[602, first.processStartIdentity], [603, second.processStartIdentity]])
const deps = {
pidAlive: (pid: number) => pid !== stale.pid,
processStartIdentity: (pid: number) => identities.get(pid),
}
const winner = CrossHostRegistration.register(directory, first, true, deps)!
const loser = CrossHostRegistration.register(directory, second, true, deps)!
assert.equal(winner.isPrimary, true)
assert.equal(loser.isPrimary, false)
})
test("graceful primary release allows a successor while a secondary remains", (t) => {
const directory = temp(t)
const primary = CrossHostRegistration.register(directory, owner(401, "primary"), true, dependencies(true, "primary-start"))!
const secondary = CrossHostRegistration.register(directory, owner(402, "secondary"), true, dependencies(true, "primary-start"))!
assert.equal(secondary.isPrimary, false)
assert.equal(primary.release(), true)
const successor = CrossHostRegistration.register(directory, owner(403, "successor"), true, dependencies(true, "successor-start"))!
assert.equal(successor.isPrimary, true)
})
test("graceful handoff retires the old cohort so a crashed successor can recover", (t) => {
const directory = temp(t), secondaryOwner = owner(422, "secondary"), successorOwner = owner(423, "successor"), lateOwner = owner(425, "late")
const malformed = join(directory, "participant.malformed.json")
const primary = CrossHostRegistration.register(directory, owner(421, "primary"), true, {
pidAlive: () => true,
processStartIdentity: () => "primary-start",
onGracefulOwnerChecked: () => {
writeFileSync(join(directory, "participant.423.successor.json"), JSON.stringify(successorOwner))
writeFileSync(join(directory, "participant.425.late.json"), JSON.stringify(lateOwner))
writeFileSync(malformed, "malformed")
},
onOwnerRetired: () => {
mkdirSync(join(directory, CROSS_HOST_OWNER_DIRECTORY))
writeFileSync(ownerFile(directory), JSON.stringify(successorOwner))
},
})!
CrossHostRegistration.register(directory, secondaryOwner, true, dependencies(true, "primary-start"))!
primary.release()
assert.equal(readdirSync(directory).some((name) => name.startsWith("retired.")), false)
assert.equal(JSON.parse(readFileSync(ownerFile(directory), "utf8")).runToken, "successor")
assert.equal(existsSync(join(directory, "participant.423.successor.json")), false)
assert.equal(existsSync(join(directory, "participant.425.late.json")), false)
assert.equal(existsSync(malformed), false)
const claimantOwner = owner(424, "claimant"), identities = new Map([
[secondaryOwner.pid, secondaryOwner.processStartIdentity],
[lateOwner.pid, lateOwner.processStartIdentity],
[claimantOwner.pid, claimantOwner.processStartIdentity],
])
const claimant = CrossHostRegistration.register(directory, claimantOwner, true, {
pidAlive: (pid) => identities.has(pid),
processStartIdentity: (pid) => identities.get(pid),
})!
assert.equal(claimant.isPrimary, true)
})
test("non-owner release does not remove a live owner's record", (t) => {
const directory = temp(t)
const primary = CrossHostRegistration.register(directory, owner(411, "primary"), true, dependencies(true, "primary-start"))!
const secondary = CrossHostRegistration.register(directory, owner(412, "secondary"), true, dependencies(true, "primary-start"))!
assert.equal(secondary.release(), true)
assert.equal(primary.isPrimary, true)
assert.equal(JSON.parse(readFileSync(ownerFile(directory), "utf8")).runToken, "primary")
})
test("primary crash remains fenced by its non-claiming secondary cohort", async (t) => {
const directory = temp(t), firstStart = join(directory, "first-start")
const primary = child(directory, firstStart), secondary = child(directory, firstStart)
writeFileSync(firstStart, "")
const roles = await Promise.all([primary.result, secondary.result])
const winner = roles[0] ? primary : secondary, survivor = roles[0] ? secondary : primary
winner.process.kill()
await waitForExit(winner)
const blocked = CrossHostRegistration.register(directory, owner(501, "blocked"), true)!
assert.equal(blocked.isPrimary, false)
blocked.release()
survivor.process.kill()
await waitForExit(survivor)
const successor = CrossHostRegistration.register(directory, owner(502, "successor"), true)!
assert.equal(successor.isPrimary, true)
})
test("platform paths match the Rust contract", () => {
assert.equal(resolveCrossHostElectionDirectory({ HOME: "/Users/dev" }, "darwin", "/fallback"), posix.join("/Users/dev", ".codenomad", "client-state", "election"))
assert.equal(resolveCrossHostElectionDirectory({ HOME: "/home/dev" }, "linux", "/fallback"), posix.join("/home/dev", ".codenomad", "client-state", "election"))
assert.equal(resolveCrossHostElectionDirectory({ USERPROFILE: "", HOME: "D:\\Home" }, "win32", "C:\\Fallback"), win32.join("D:\\Home", ".codenomad", "client-state", "election"))
assert.equal(resolveCrossHostStatePath({ HOME: "/Users/dev" }, "darwin", "/fallback"), posix.join("/Users/dev", ".codenomad", "client-state", "client-state.json"))
assert.equal(resolveCrossHostStatePath({ HOME: "/home/dev" }, "linux", "/fallback"), posix.join("/home/dev", ".codenomad", "client-state", "client-state.json"))
assert.equal(resolveCrossHostStatePath({ USERPROFILE: "", HOME: "D:\\Home" }, "win32", "C:\\Fallback"), win32.join("D:\\Home", ".codenomad", "client-state", "client-state.json"))
})

View file

@ -0,0 +1,344 @@
import { randomUUID } from "node:crypto"
import { closeSync, existsSync, fsyncSync, linkSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync } from "node:fs"
import { homedir } from "node:os"
import { basename, dirname, join, posix, win32 } from "node:path"
import { getProcessStartIdentity, type ProcessStartIdentityLookup } from "./client-state-process-identity"
import { hasErrorCode, isPidAlive, type ProcessOwner } from "./client-state-process"
export const CROSS_HOST_OWNER_DIRECTORY = "primary.owner.json"
const OWNER_FILENAME = "owner.json"
const PARTICIPANT_PREFIX = "participant."
const PARTICIPANT_SUFFIX = ".json"
const RECOVERY_PREFIX = "recovery."
const RECOVERY_SUFFIX = ".claim"
const RETIRED_PREFIX = "retired."
const ACQUIRE_ATTEMPTS = 10
export interface CrossHostLeaseDependencies {
pidAlive(pid: number): boolean
processStartIdentity: ProcessStartIdentityLookup
onParticipantPublished?(): void
onOwnerPrepared?(): void
onOwnerRetired?(): void
onGracefulOwnerChecked?(): void
}
const defaultDependencies: CrossHostLeaseDependencies = {
pidAlive: isPidAlive,
processStartIdentity: getProcessStartIdentity,
}
function validHome(value: string | undefined, platform: NodeJS.Platform): string | undefined {
if (!value) return undefined
if (platform !== "win32") return posix.isAbsolute(value) ? value : undefined
return /^(?:[A-Za-z]:[\\/]|\\\\)/.test(value) ? value : undefined
}
export function resolveCrossHostElectionDirectory(
environment: NodeJS.ProcessEnv = process.env,
platform: NodeJS.Platform = process.platform,
fallbackHome = homedir(),
): string {
const pathApi = platform === "win32" ? win32 : posix
const configured = platform === "win32"
? validHome(environment.USERPROFILE, platform) ?? validHome(environment.HOME, platform)
: validHome(environment.HOME, platform)
return pathApi.join(configured ?? fallbackHome, ".codenomad", "client-state", "election")
}
export function resolveCrossHostStatePath(
environment: NodeJS.ProcessEnv = process.env,
platform: NodeJS.Platform = process.platform,
fallbackHome = homedir(),
): string {
const pathApi = platform === "win32" ? win32 : posix
const configured = platform === "win32"
? validHome(environment.USERPROFILE, platform) ?? validHome(environment.HOME, platform)
: validHome(environment.HOME, platform)
return pathApi.join(configured ?? fallbackHome, ".codenomad", "client-state", "client-state.json")
}
export function resolveLegacyTauriDataDirectory(
environment: NodeJS.ProcessEnv = process.env,
platform: NodeJS.Platform = process.platform,
fallbackHome = homedir(),
): string {
const pathApi = platform === "win32" ? win32 : posix
const home = platform === "win32"
? validHome(environment.USERPROFILE, platform) ?? validHome(environment.HOME, platform) ?? fallbackHome
: validHome(environment.HOME, platform) ?? fallbackHome
const root = platform === "win32"
? validHome(environment.APPDATA, platform) ?? pathApi.join(home, "AppData", "Roaming")
: platform === "darwin"
? pathApi.join(home, "Library", "Application Support")
: validHome(environment.XDG_DATA_HOME, platform) ?? pathApi.join(home, ".local", "share")
return pathApi.join(root, "ai.neuralnomads.codenomad.client")
}
export function createCrossHostOwner(): ProcessOwner | undefined {
const processStartIdentity = getProcessStartIdentity(process.pid)
return processStartIdentity ? { pid: process.pid, runToken: randomUUID(), processStartIdentity } : undefined
}
function serializeOwner(owner: ProcessOwner): string {
return JSON.stringify({ pid: owner.pid, runToken: owner.runToken, processStartIdentity: owner.processStartIdentity })
}
function parseOwner(value: string): ProcessOwner | undefined {
try {
const owner = JSON.parse(value) as Partial<ProcessOwner>
if (Number.isInteger(owner.pid) && Number(owner.pid) > 0 && Number(owner.pid) <= 0xffff_ffff &&
typeof owner.runToken === "string" && /^[A-Za-z0-9_-]+$/.test(owner.runToken) &&
typeof owner.processStartIdentity === "string" && owner.processStartIdentity) {
return { pid: Number(owner.pid), runToken: owner.runToken, processStartIdentity: owner.processStartIdentity }
}
} catch {}
return undefined
}
function sameOwner(left: ProcessOwner, right: ProcessOwner): boolean {
return left.pid === right.pid && left.runToken === right.runToken && left.processStartIdentity === right.processStartIdentity
}
function readIfExists(path: string): string | undefined {
try { return readFileSync(path, "utf8") } catch (error) {
if (hasErrorCode(error, "ENOENT")) return undefined
throw error
}
}
function sync(descriptor: number): void {
try { fsyncSync(descriptor) } catch (error) {
if (!["EINVAL", "ENOTSUP", "ENOSYS"].some((code) => hasErrorCode(error, code))) throw error
}
}
function publishFile(path: string, value: string): void {
const temporary = join(dirname(path), `.publish.${randomUUID()}.tmp`)
let descriptor: number | undefined
try {
descriptor = openSync(temporary, "wx", 0o600)
writeFileSync(descriptor, value, "utf8")
sync(descriptor)
closeSync(descriptor)
descriptor = undefined
linkSync(temporary, path)
} finally {
if (descriptor !== undefined) try { closeSync(descriptor) } catch {}
try { unlinkSync(temporary) } catch {}
}
}
function participantPath(directory: string, owner: ProcessOwner): string {
return join(directory, `${PARTICIPANT_PREFIX}${owner.pid}.${owner.runToken}${PARTICIPANT_SUFFIX}`)
}
function recoveryPath(directory: string, owner: ProcessOwner): string {
return join(directory, `${RECOVERY_PREFIX}${owner.pid}.${owner.runToken}${RECOVERY_SUFFIX}`)
}
function publishParticipant(path: string, owner: ProcessOwner): void {
const value = serializeOwner(owner)
try { publishFile(path, value) } catch (error) {
if (!hasErrorCode(error, "EEXIST") || readIfExists(path) !== value) throw error
}
}
function ownerPath(directory: string): string {
return join(directory, CROSS_HOST_OWNER_DIRECTORY, OWNER_FILENAME)
}
function ownerIsStale(owner: ProcessOwner, dependencies: CrossHostLeaseDependencies): boolean | undefined {
if (!dependencies.pidAlive(owner.pid)) return true
const identity = dependencies.processStartIdentity(owner.pid)
return identity ? identity !== owner.processStartIdentity : undefined
}
function removeParticipantIfOwned(path: string, owner: ProcessOwner): void {
const observed = readIfExists(path)
const current = observed === undefined ? undefined : parseOwner(observed)
if (!current || !sameOwner(current, owner)) return
try { unlinkSync(path) } catch (error) {
if (!hasErrorCode(error, "ENOENT")) throw error
}
}
function retireOwnerIfOwned(directory: string, owner: ProcessOwner, dependencies: CrossHostLeaseDependencies): void {
const observed = readIfExists(ownerPath(directory))
const current = parseOwner(observed ?? "")
if (!current || !sameOwner(current, owner)) return
dependencies.onGracefulOwnerChecked?.()
if (readIfExists(ownerPath(directory)) !== observed) return
const retired = join(directory, `${RETIRED_PREFIX}${owner.pid}.${owner.runToken}`)
try { renameSync(join(directory, CROSS_HOST_OWNER_DIRECTORY), retired) } catch (error) {
if (["ENOENT", "EEXIST", "ENOTEMPTY"].some((code) => hasErrorCode(error, code)) || existsSync(retired)) return
throw error
}
try {
dependencies.onOwnerRetired?.()
for (const name of readdirSync(directory)) {
if (!name.startsWith(PARTICIPANT_PREFIX) || !name.endsWith(PARTICIPANT_SUFFIX)) continue
const path = join(directory, name), observedParticipant = readIfExists(path)
if (observedParticipant === undefined) continue
const participant = parseOwner(observedParticipant)
if (participant) {
removeParticipantIfOwned(path, participant)
try { unlinkSync(recoveryPath(directory, participant)) } catch {}
} else if (readIfExists(path) === observedParticipant) {
try { unlinkSync(path) } catch (error) {
if (!hasErrorCode(error, "ENOENT")) throw error
}
}
}
} finally {
try { rmSync(retired, { recursive: true, force: true }) } catch {}
}
}
function recoveryClaimants(
directory: string,
current: ProcessOwner,
observedOwner: string,
dependencies: CrossHostLeaseDependencies,
): ProcessOwner[] | undefined {
const claimants = [current]
for (const name of readdirSync(directory)) {
if (!name.startsWith(PARTICIPANT_PREFIX) || !name.endsWith(PARTICIPANT_SUFFIX)) continue
const path = join(directory, name)
const participant = parseOwner(readIfExists(path) ?? "")
if (!participant) return undefined
if (sameOwner(participant, current)) continue
const stale = ownerIsStale(participant, dependencies)
if (stale === true) {
removeParticipantIfOwned(path, participant)
try { unlinkSync(recoveryPath(directory, participant)) } catch {}
continue
}
const claimPath = recoveryPath(directory, participant)
let claim = readIfExists(claimPath)
for (let attempt = 0; claim !== observedOwner && attempt < 20; attempt += 1) {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5)
claim = readIfExists(claimPath)
}
if (claim !== observedOwner) return undefined
claimants.push(participant)
}
return claimants
}
function retireOwner(directory: string, observed: string, owner: ProcessOwner, claimant: ProcessOwner, dependencies: CrossHostLeaseDependencies): boolean {
if (ownerIsStale(owner, dependencies) !== true) return false
const claimants = recoveryClaimants(directory, claimant, observed, dependencies)
if (!claimants) return false
claimants.sort((left, right) => serializeOwner(left) < serializeOwner(right) ? -1 : 1)
if (!sameOwner(claimants[0]!, claimant)) return false
if (readIfExists(ownerPath(directory)) !== observed) return false
const retired = join(directory, `${RETIRED_PREFIX}${owner.pid}.${owner.runToken}`)
try {
renameSync(join(directory, CROSS_HOST_OWNER_DIRECTORY), retired)
dependencies.onOwnerRetired?.()
return true
} catch (error) {
if (["ENOENT", "EEXIST", "ENOTEMPTY"].some((code) => hasErrorCode(error, code)) || existsSync(retired)) return false
throw error
}
}
function publishOwner(directory: string, owner: ProcessOwner, dependencies: CrossHostLeaseDependencies): boolean {
const temporary = join(directory, `.owner.${randomUUID()}.tmp`)
try {
mkdirSync(temporary, { mode: 0o700 })
const descriptor = openSync(join(temporary, OWNER_FILENAME), "wx", 0o600)
try { writeFileSync(descriptor, serializeOwner(owner), "utf8"); sync(descriptor) } finally { closeSync(descriptor) }
dependencies.onOwnerPrepared?.()
renameSync(temporary, join(directory, CROSS_HOST_OWNER_DIRECTORY))
return true
} catch (error) {
if (hasErrorCode(error, "EEXIST") || hasErrorCode(error, "ENOTEMPTY") || existsSync(join(directory, CROSS_HOST_OWNER_DIRECTORY))) return false
throw error
} finally {
try { rmSync(temporary, { recursive: true, force: true }) } catch {}
}
}
export function crossHostParticipants(directory: string): ProcessOwner[] {
try {
return readdirSync(directory)
.filter((name) => name.startsWith(PARTICIPANT_PREFIX) && name.endsWith(PARTICIPANT_SUFFIX))
.map((name) => parseOwner(readIfExists(join(directory, name)) ?? ""))
.filter((owner): owner is ProcessOwner => Boolean(owner))
} catch (error) {
if (hasErrorCode(error, "ENOENT")) return []
throw error
}
}
export class CrossHostRegistration {
private released = false
private constructor(
private readonly directory: string,
readonly owner: ProcessOwner,
private readonly participant: string,
private readonly recoveryClaim: string | undefined,
private primary: boolean,
private readonly dependencies: CrossHostLeaseDependencies,
) {}
get path(): string { return this.directory }
static register(
directory: string,
owner: ProcessOwner,
primaryCandidate: boolean | (() => boolean),
dependencies: CrossHostLeaseDependencies = defaultDependencies,
): CrossHostRegistration | undefined {
if (!owner.processStartIdentity || !/^[A-Za-z0-9_-]+$/.test(owner.runToken)) return undefined
mkdirSync(directory, { recursive: true, mode: 0o700 })
const participant = participantPath(directory, owner)
publishParticipant(participant, owner)
dependencies.onParticipantPublished?.()
let primary = false
let recoveryClaim: string | undefined
try {
if (typeof primaryCandidate === "function" ? primaryCandidate() : primaryCandidate) {
for (let attempt = 0; attempt < ACQUIRE_ATTEMPTS; attempt += 1) {
if (publishOwner(directory, owner, dependencies)) { primary = true; break }
const observed = readIfExists(ownerPath(directory))
if (observed === undefined) continue
const existing = parseOwner(observed)
if (!existing) break
if (sameOwner(existing, owner)) { primary = true; break }
if (ownerIsStale(existing, dependencies) === true) {
recoveryClaim ??= recoveryPath(directory, owner)
try { publishFile(recoveryClaim, observed) } catch (error) {
if (!hasErrorCode(error, "EEXIST") || readIfExists(recoveryClaim) !== observed) throw error
}
}
if (!retireOwner(directory, observed, existing, owner, dependencies)) break
}
}
return new CrossHostRegistration(directory, owner, participant, recoveryClaim, primary, dependencies)
} catch (error) {
removeParticipantIfOwned(participant, owner)
if (recoveryClaim) try { unlinkSync(recoveryClaim) } catch {}
throw error
}
}
get isPrimary(): boolean {
if (this.released || !this.primary) return false
const current = parseOwner(readIfExists(ownerPath(this.directory)) ?? "")
return Boolean(current && sameOwner(current, this.owner))
}
release(): boolean {
if (this.released) return false
retireOwnerIfOwned(this.directory, this.owner, this.dependencies)
removeParticipantIfOwned(this.participant, this.owner)
if (this.recoveryClaim) try { unlinkSync(this.recoveryClaim) } catch {}
this.primary = false
this.released = true
return true
}
}

View file

@ -0,0 +1,62 @@
import { existsSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import {
electClientStateProcess,
isPidAlive,
REGISTRATION_LOCK_WAIT_MS,
removeProcessOwnerLockIfOwned,
removeRunningMarkerIfOwned,
type ProcessOwner,
} from "./client-state-process"
import { getProcessStartIdentity } from "./client-state-process-identity"
const [directory, runToken, startPath, registrationWaitArgument, primaryPausedPath, primaryReleasePath] =
process.argv.slice(2)
if (!directory || !runToken || !startPath) {
throw new Error("Expected election directory, run token, and start path")
}
while (!existsSync(startPath)) {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5)
}
const owner: ProcessOwner = {
pid: process.pid,
runToken,
processStartIdentity: getProcessStartIdentity(process.pid),
}
const primaryLockPath = join(directory, "client-state.primary.lock")
const registrationLockPath = join(directory, "client-state.registration.lock")
const registrationLockWaitMs = registrationWaitArgument
? Number(registrationWaitArgument)
: REGISTRATION_LOCK_WAIT_MS
const warnings: string[] = []
const election = electClientStateProcess(
directory,
owner,
{ primaryLockPath, registrationLockPath },
(message, error) => warnings.push(`${message}: ${String(error)}`),
isPidAlive,
registrationLockWaitMs,
() => {
if (!primaryPausedPath || !primaryReleasePath) {
return
}
try {
writeFileSync(primaryPausedPath, JSON.stringify(owner), { encoding: "utf8", flag: "wx" })
} catch {
// Only the contender that published the synchronization point owns the pause gate.
return
}
while (!existsSync(primaryReleasePath)) {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5)
}
},
)
process.stdout.write(`${JSON.stringify({ isPrimary: election, owner, warnings })}\n`)
process.stdin.resume()
process.stdin.once("end", () => {
removeRunningMarkerIfOwned(join(directory, `client-state.running.${owner.pid}.${owner.runToken}.json`), owner)
removeProcessOwnerLockIfOwned(primaryLockPath, owner)
})

View file

@ -0,0 +1,64 @@
import assert from "node:assert/strict"
import test from "node:test"
import type { IpcMainInvokeEvent } from "electron"
import { setupClientStateIPC } from "./client-state-ipc"
function harness() {
const handlers = new Map<string, (event: IpcMainInvokeEvent, ...args: unknown[]) => unknown>()
const listeners = new Map<string, (...args: unknown[]) => void>()
const frame = { url: "http://127.0.0.1:3000/app" }
const webContents = {
mainFrame: frame,
getURL: () => "http://127.0.0.1:3000/app",
on: (event: string, listener: (...args: unknown[]) => void) => listeners.set(event, listener),
}
const window = { isDestroyed: () => false, webContents }
let current: typeof window | null = window
const calls: string[] = []
const state = {
claimClientStateAccess: (token: unknown) => { calls.push(`claim:${token}`); return true },
assertRendererAccessToken: (token: unknown) => calls.push(`assert:${token}`),
loadClientState: () => ({ isPrimary: true }),
saveClientState: () => true,
setRestoreEnabled: () => true,
clearClientState: () => true,
resetRendererAccessToken: () => calls.push("reset"),
}
const bind = setupClientStateIPC(
{ handle: (channel, listener) => handlers.set(channel, listener) },
state as never,
() => current as never,
() => ["http://127.0.0.1:3000"],
)
bind(window as never)
return { calls, frame, handlers, listeners, setCurrent: (value: typeof window | null) => { current = value }, webContents, window }
}
test("IPC channels enforce the current main sender, frame, origin, and token", async () => {
const h = harness()
assert.deepEqual([...h.handlers.keys()], [
"client-state:claimAccess", "client-state:load", "client-state:save",
"client-state:setRestoreEnabled", "client-state:clear",
])
const event = { sender: h.webContents, senderFrame: h.frame }
await h.handlers.get("client-state:claimAccess")!(event as never, "token")
await h.handlers.get("client-state:load")!(event as never, "token")
assert.deepEqual(h.calls, ["claim:token", "assert:token"])
for (const invalid of [
{ sender: {}, senderFrame: h.frame },
{ sender: h.webContents, senderFrame: { url: h.frame.url } },
{ sender: h.webContents, senderFrame: { ...h.frame, url: "https://example.com" } },
]) await assert.rejects(h.handlers.get("client-state:load")!(invalid as never, "token") as Promise<unknown>)
})
test("only the registered current window can reset renderer authority", () => {
const h = harness()
h.listeners.get("did-navigate")!({}, "http://127.0.0.1:3000/next")
h.listeners.get("render-process-gone")!()
assert.deepEqual(h.calls, ["reset", "reset"])
h.setCurrent(null)
h.listeners.get("did-navigate")!({}, "http://127.0.0.1:3000/late")
h.listeners.get("destroyed")!()
assert.deepEqual(h.calls, ["reset", "reset"])
})

View file

@ -0,0 +1,78 @@
import type { BrowserWindow, IpcMainInvokeEvent } from "electron"
import type { ClientStateManager } from "./client-state"
import { shouldResetRendererAccessTokenForNavigation } from "./client-state-navigation"
import { isAllowedRendererOrigin } from "./renderer-origin"
interface IPCRegistrar {
handle(channel: string, listener: (event: IpcMainInvokeEvent, ...args: unknown[]) => unknown): void
}
function validateSender(event: IpcMainInvokeEvent, mainWindow: BrowserWindow | null, allowedOrigins: string[]) {
if (
!mainWindow ||
mainWindow.isDestroyed() ||
event.sender !== mainWindow.webContents ||
event.senderFrame !== mainWindow.webContents.mainFrame
) {
throw new Error("Client state IPC is only available to the local main window")
}
const currentUrl = mainWindow.webContents.getURL()
if (
!isAllowedRendererOrigin(currentUrl, allowedOrigins) ||
!isAllowedRendererOrigin(event.senderFrame.url, allowedOrigins) ||
new URL(currentUrl).origin !== new URL(event.senderFrame.url).origin
) {
throw new Error("Client state IPC is not available to the current renderer origin")
}
}
export function setupClientStateIPC(
ipcMain: IPCRegistrar,
clientState: ClientStateManager,
getMainWindow: () => BrowserWindow | null,
getAllowedOrigins: (window: BrowserWindow | null) => string[],
) {
const validate = (event: IpcMainInvokeEvent) => {
const window = getMainWindow()
validateSender(event, window, getAllowedOrigins(window))
}
const handle = (
channel: string,
operation: (argument: unknown, token: unknown) => unknown,
) => ipcMain.handle(channel, async (event, token: unknown, argument: unknown) => {
validate(event)
clientState.assertRendererAccessToken(token)
return operation(argument, token)
})
ipcMain.handle("client-state:claimAccess", async (event, token: unknown) => {
validate(event)
return clientState.claimClientStateAccess(token)
})
handle("client-state:load", () => clientState.loadClientState())
handle("client-state:save", (snapshot, token) => clientState.saveClientState(snapshot, token))
handle("client-state:setRestoreEnabled", (enabled, token) => {
if (typeof enabled !== "boolean") throw new Error("Restore enabled must be a boolean")
return clientState.setRestoreEnabled(enabled, token)
})
handle("client-state:clear", (_argument, token) => clientState.clearClientState(token))
return (window: BrowserWindow): void => {
window.webContents.on("did-navigate", (_event, url) => {
if (getMainWindow() === window && shouldResetRendererAccessTokenForNavigation(
url,
false,
true,
(target) => isAllowedRendererOrigin(target, getAllowedOrigins(window)),
)) {
clientState.resetRendererAccessToken()
}
})
const resetDestroyedRenderer = () => {
if (getMainWindow() === window) clientState.resetRendererAccessToken()
}
window.webContents.on("render-process-gone", resetDestroyedRenderer)
window.webContents.on("destroyed", resetDestroyedRenderer)
}
}

View file

@ -0,0 +1,159 @@
import assert from "node:assert/strict"
import { setTimeout as delay } from "node:timers/promises"
import test from "node:test"
import type { App, BrowserWindow } from "electron"
import { ClientStateLifecycle } from "./client-state-lifecycle"
import type { ClientStateManager } from "./client-state"
import type { CliProcessManager } from "./process-manager"
import type { WindowStateTracker } from "./window-state"
const tick = () => new Promise((resolve) => setImmediate(resolve))
function harness(options: {
flush?: () => Promise<unknown>
stop?: () => Promise<void>
nativeFlush?: () => Promise<void>
otherWindow?: boolean
sessionEndCleanupTimeoutMs?: number
sessionEndReleaseTimeoutMs?: number
release?: () => Promise<void>
} = {}) {
const windows = new Map<string, (event?: { preventDefault(): void }) => void>()
const appEvents = new Map<string, (event?: { preventDefault(): void }) => void>()
const calls: string[] = []
let exits = 0
const window = {
on: (name: string, handler: (event?: { preventDefault(): void }) => void) => windows.set(name, handler),
isDestroyed: () => false,
close: () => { calls.push("close"); windows.get("close")?.({ preventDefault: () => assert.fail("approved close prevented") }) },
hide: () => { calls.push("hide") },
show: () => { calls.push("show") },
webContents: { isDestroyed: () => false, getURL: () => "http://127.0.0.1:43123/workspace", executeJavaScript: () => { calls.push("renderer"); return options.flush?.() ?? Promise.resolve() } },
} as unknown as BrowserWindow
const other = { isDestroyed: () => false, hide: () => { calls.push("hide-other") } } as unknown as BrowserWindow
const app = { on: (name: string, handler: never) => appEvents.set(name, handler), quit: () => calls.push("quit"), exit: () => { exits++ } } as unknown as App
const manager = { isPrimary: true, flush: async () => {}, drainAndReleasePrimary: async () => { calls.push("release"); await options.release?.() } } as ClientStateManager
const cli = { shutdown: async () => { calls.push("stop"); await options.stop?.() } } as unknown as CliProcessManager
const lifecycle = new ClientStateLifecycle({ app, clientStateManager: manager, cliManager: cli, getMainWindow: () => window, getAllWindows: () => options.otherWindow ? [window, other] : [window], getAllowedRendererOrigins: () => ["http://127.0.0.1:43123"], isTrustedRendererOrigin: () => true, isWindows: true, sessionEndCleanupTimeoutMs: options.sessionEndCleanupTimeoutMs, sessionEndReleaseTimeoutMs: options.sessionEndReleaseTimeoutMs })
lifecycle.attachMainWindow(window, { flush: async () => { calls.push("native"); await options.nativeFlush?.() } } as unknown as WindowStateTracker)
lifecycle.registerAppEvents()
const close = () => { let prevented = false; windows.get("close")?.({ preventDefault: () => { prevented = true } }); return prevented }
return { appEvents, calls, close, exits: () => exits, lifecycle, window, windows }
}
test("close flushes renderer/native once before approval, even when repeated or renderer fails", async (t) => {
await t.test("ordinary", async () => {
const h = harness({ otherWindow: true })
assert.equal(h.close(), true)
await tick()
assert.deepEqual(h.calls, ["renderer", "native", "close"])
})
await t.test("coalesced", async () => {
let release!: () => void
const h = harness({ otherWindow: true, flush: () => new Promise<void>((resolve) => { release = resolve }) })
assert.equal(h.close(), true); assert.equal(h.close(), true)
assert.deepEqual(h.calls, ["renderer"])
release(); await tick()
assert.deepEqual(h.calls, ["renderer", "native", "close"])
})
await t.test("renderer failure", async () => {
const h = harness({ otherWindow: true, flush: async () => { throw new Error("failed") } })
assert.equal(h.close(), true); await tick()
assert.deepEqual(h.calls, ["renderer", "native", "close"])
})
})
test("late old-window detach preserves replacement tracker during shutdown", async () => {
const h = harness()
const replacement = { on: () => {} } as unknown as BrowserWindow
h.lifecycle.attachMainWindow(replacement, { flush: async () => { h.calls.push("replacement-native") } } as unknown as WindowStateTracker)
h.lifecycle.detachMainWindow(h.window)
h.appEvents.get("before-quit")?.({ preventDefault: () => {} })
await (h.lifecycle as any).shutdown
assert.deepEqual(h.calls, ["hide", "renderer", "replacement-native", "stop", "release"])
})
test("Windows session end vetoes termination until cleanup exits explicitly", async () => {
const h = harness()
let prevented = false
h.windows.get("query-session-end")?.({ preventDefault: () => { prevented = true } })
h.windows.get("session-end")?.()
await (h.lifecycle as any).sessionEnd; await tick()
assert.equal(prevented, true)
assert.deepEqual(h.calls, ["renderer", "native", "stop", "release"])
assert.equal(h.exits(), 1)
})
test("session end force-exits after the bounded window when an ordinary shutdown is hung", async () => {
const h = harness({ flush: () => new Promise(() => {}), sessionEndCleanupTimeoutMs: 10 })
let prevented = false
h.appEvents.get("before-quit")?.({ preventDefault: () => {} })
h.windows.get("query-session-end")?.({ preventDefault: () => { prevented = true } })
await delay(25)
assert.equal(prevented, true)
assert.deepEqual(h.calls, ["hide", "renderer", "release"])
assert.equal(h.exits(), 1)
})
test("ordinary quit hides promptly and waits for CLI stop confirmation", async () => {
let confirmStop!: () => void
const h = harness({ stop: () => new Promise<void>((resolve) => { confirmStop = resolve }) })
h.appEvents.get("before-quit")?.({ preventDefault: () => {} })
await tick()
assert.deepEqual(h.calls, ["hide", "renderer", "native", "stop"])
assert.equal(h.exits(), 0)
confirmStop()
await (h.lifecycle as any).shutdown; await tick()
assert.deepEqual(h.calls, ["hide", "renderer", "native", "stop", "release"])
assert.equal(h.exits(), 1)
})
test("ordinary quit does not exit when CLI cleanup is unconfirmed", async () => {
const h = harness({ stop: async () => { throw new Error("unconfirmed") } })
h.appEvents.get("before-quit")?.({ preventDefault: () => {} })
await assert.rejects((h.lifecycle as any).shutdown, /unconfirmed/)
await tick()
assert.equal(h.exits(), 0)
assert.deepEqual(h.calls, ["hide", "renderer", "native", "stop", "show"])
})
test("Windows session-end rejection fails open at the bounded deadline", async () => {
const h = harness({ stop: async () => { throw new Error("unconfirmed") }, sessionEndCleanupTimeoutMs: 10 })
h.appEvents.get("before-quit")?.({ preventDefault: () => {} })
h.windows.get("query-session-end")?.({ preventDefault: () => {} })
await delay(25)
assert.equal(h.exits(), 1)
assert.deepEqual(h.calls, ["hide", "renderer", "native", "stop", "release"])
})
test("Windows fail-open bounds a hanging primary release before app.exit", async () => {
const h = harness({
flush: () => new Promise(() => {}),
release: () => new Promise(() => {}),
sessionEndCleanupTimeoutMs: 30,
sessionEndReleaseTimeoutMs: 10,
})
h.windows.get("query-session-end")?.({ preventDefault: () => {} })
await delay(25)
assert.deepEqual(h.calls, ["renderer", "release"])
assert.equal(h.exits(), 0)
await delay(15)
assert.equal(h.exits(), 1)
})
test("CLI termination waits for the native snapshot flush", async () => {
let release!: () => void
const h = harness({ nativeFlush: () => new Promise<void>((resolve) => { release = resolve }) })
h.appEvents.get("before-quit")?.({ preventDefault: () => {} })
await tick()
assert.deepEqual(h.calls, ["hide", "renderer", "native"])
assert.equal(h.exits(), 0)
release(); await (h.lifecycle as any).shutdown
assert.deepEqual(h.calls, ["hide", "renderer", "native", "stop", "release"])
})
test("closing the final window hides it before requesting quit", () => {
const h = harness()
assert.equal(h.close(), true)
assert.deepEqual(h.calls, ["hide", "quit"])
})

View file

@ -0,0 +1,192 @@
import type { App, BrowserWindow } from "electron"
import type { ClientStateManager } from "./client-state"
import type { CliProcessManager } from "./process-manager"
import { flushRendererClientStateBeforeShutdown } from "./renderer-client-state-flush"
import type { WindowStateTracker } from "./window-state"
interface ClientStateLifecycleDependencies {
app: App
clientStateManager: ClientStateManager
cliManager: CliProcessManager
getMainWindow(): BrowserWindow | null
getAllWindows(): BrowserWindow[]
getAllowedRendererOrigins(window?: BrowserWindow | null): string[]
isTrustedRendererOrigin(url: string, allowedOrigins: string[]): boolean
rendererFlushTimeoutMs?: number
sessionEndCleanupTimeoutMs?: number
sessionEndReleaseTimeoutMs?: number
isWindows?: boolean
}
export class ClientStateLifecycle {
private shutdown: Promise<void> | null = null
private sessionEnd: Promise<void> | null = null
private exitAllowed = false
private trackedMainWindow: BrowserWindow | null = null
private windowStateTracker: WindowStateTracker | null = null
private windowsHiddenForShutdown = false
private primaryRelease: Promise<void> | null = null
constructor(private readonly dependencies: ClientStateLifecycleDependencies) {}
attachMainWindow(window: BrowserWindow, tracker: WindowStateTracker | null): void {
this.trackedMainWindow = window
this.windowStateTracker = tracker
let closeApproved = false
let closeInProgress = false
window.on("close", (event) => {
if (this.exitAllowed || closeApproved) return
event.preventDefault()
if (this.shutdown) return
const hasOtherWindow = this.dependencies
.getAllWindows()
.some((candidate) => candidate !== window && !candidate.isDestroyed())
if (!hasOtherWindow) {
window.hide()
this.dependencies.app.quit()
} else if (!closeInProgress) {
closeInProgress = true
void this.flushForClose(window).finally(() => {
closeApproved = true
try {
window.close()
} catch (error) {
closeApproved = false
closeInProgress = false
console.warn("[client-state] main-window close failed", error)
}
})
}
})
if (this.dependencies.isWindows ?? process.platform === "win32") {
window.on("query-session-end", (event) => {
if (this.exitAllowed) return
event.preventDefault()
this.promoteToSessionEnd(window)
})
window.on("session-end", () => this.promoteToSessionEnd(window))
}
}
detachMainWindow(window: BrowserWindow): void {
if (this.trackedMainWindow !== window) return
this.trackedMainWindow = null
this.windowStateTracker = null
}
registerAppEvents(): void {
const { app } = this.dependencies
app.on("before-quit", (event) => {
if (this.exitAllowed) return
event.preventDefault()
this.hideWindows()
void this.startShutdown(this.dependencies.getMainWindow()).then(() => this.exit(), (error) => {
if (!this.sessionEnd) this.restoreWindowAfterRejectedShutdown(this.dependencies.getMainWindow())
console.warn("[client-state] desktop shutdown remains pending because cleanup was not contained", error)
})
})
app.on("window-all-closed", () => app.quit())
}
private async flushForClose(window: BrowserWindow): Promise<void> {
await this.runStage("renderer main-window close flush", () => this.flushRenderer(window))
await this.runStage("native main-window close flush", () => this.flushNative())
}
private startShutdown(window: BrowserWindow | null): Promise<void> {
if (this.shutdown) return this.shutdown
const stages = (async () => {
await this.runStage("renderer shutdown flush", () => this.flushRenderer(window))
await this.runStage("native shutdown flush", () => this.flushNative())
await this.dependencies.cliManager.shutdown()
await this.releasePrimary()
})()
this.shutdown = stages.catch((error) => {
this.shutdown = null
throw error
})
return this.shutdown
}
private hideWindows(): void {
for (const window of this.dependencies.getAllWindows()) {
if (!window.isDestroyed()) {
window.hide()
this.windowsHiddenForShutdown = true
}
}
}
private restoreWindowAfterRejectedShutdown(preferred: BrowserWindow | null): void {
if (!this.windowsHiddenForShutdown) return
this.windowsHiddenForShutdown = false
const window = preferred && !preferred.isDestroyed()
? preferred
: this.dependencies.getAllWindows().find((candidate) => !candidate.isDestroyed())
if (window) window.show()
}
private promoteToSessionEnd(window: BrowserWindow): void {
if (this.exitAllowed || this.sessionEnd) return
const cleanup = this.startShutdown(window)
this.sessionEnd = new Promise<void>((resolve) => {
const timeoutMs = this.dependencies.sessionEndCleanupTimeoutMs ?? 5_000
const releaseTimeoutMs = Math.min(timeoutMs, this.dependencies.sessionEndReleaseTimeoutMs ?? 250)
const releaseTimer = setTimeout(() => {
void this.releasePrimary()
}, Math.max(0, timeoutMs - releaseTimeoutMs))
const exitTimer = setTimeout(() => {
console.warn(`[client-state] OS session-end cleanup exceeded ${timeoutMs}ms; exiting without containment`)
resolve()
}, timeoutMs)
void cleanup.then(() => {
clearTimeout(releaseTimer)
clearTimeout(exitTimer)
resolve()
}, (error) => {
console.warn("[client-state] OS session-end cleanup was not contained; waiting for forced exit", error)
})
}).then(() => this.exit())
}
private releasePrimary(): Promise<void> {
if (!this.primaryRelease) {
this.primaryRelease = this.runStage("primary release", () => this.dependencies.clientStateManager.drainAndReleasePrimary())
}
return this.primaryRelease
}
private async flushRenderer(window: BrowserWindow | null): Promise<void> {
const result = await flushRendererClientStateBeforeShutdown(
window,
this.dependencies.clientStateManager.isPrimary,
(url) => this.dependencies.isTrustedRendererOrigin(url, this.dependencies.getAllowedRendererOrigins(window)),
this.dependencies.rendererFlushTimeoutMs,
)
if (result === "untrusted-origin") {
console.warn("[client-state] skipped renderer flush for an untrusted origin")
}
}
private async flushNative(): Promise<void> {
if (this.windowStateTracker) await this.windowStateTracker.flush()
else await this.dependencies.clientStateManager.flush()
}
private async runStage(name: string, operation: () => Promise<unknown>): Promise<void> {
try {
await operation()
} catch (error) {
console.warn(`[client-state] ${name} failed; continuing`, error)
}
}
private exit(): void {
if (this.exitAllowed) return
this.exitAllowed = true
this.dependencies.app.exit(0)
}
}

View file

@ -0,0 +1,89 @@
import assert from "node:assert/strict"
import { mkdtempSync, rmSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import test from "node:test"
import { ClientStateManager } from "./client-state"
import { ClientStateNavigationController, shouldResetRendererAccessTokenForNavigation } from "./client-state-navigation"
const tick = () => new Promise((resolve) => setImmediate(resolve))
function window(executeJavaScript: () => Promise<unknown> = async () => {}) {
return { isDestroyed: () => false, webContents: { isDestroyed: () => false, getURL: () => "http://127.0.0.1:3000/app", executeJavaScript } }
}
function controller(win: ReturnType<typeof window>, manager: { isPrimary: boolean }, report: (error: unknown) => void = (error) => assert.fail(String(error))) {
return new ClientStateNavigationController(win as never, { clientStateManager: manager, isTrustedOrigin: () => true, reportFlushError: report })
}
function managerHarness(t: test.TestContext) {
const directory = mkdtempSync(join(tmpdir(), "codenomad-navigation-"))
const manager = new ClientStateManager(directory, undefined, { crossHostElectionDirectory: join(directory, "election") })
t.after(async () => { await manager.drainAndReleasePrimary().catch(() => {}); rmSync(directory, { recursive: true, force: true }) })
return manager
}
test("renderer access resets only for trusted full main-frame navigation", () => {
const trusted = (url: string) => new URL(url).origin === "http://127.0.0.1:3000"
for (const [url, inPlace, mainFrame, expected] of [
["http://127.0.0.1:3000/reload", false, true, true],
["http://127.0.0.1:3000/frame", false, false, false],
["http://127.0.0.1:3000/#route", true, true, false],
["https://untrusted.example/reload", false, true, false],
] as const) assert.equal(shouldResetRendererAccessTokenForNavigation(url, inPlace, mainFrame, trusted), expected)
})
test("immediate reload flushes latest state before rotating document access", async (t) => {
const manager = managerHarness(t)
await manager.setRestoreEnabled(true)
manager.claimClientStateAccess("outgoing")
const load = (token: string) => { manager.assertRendererAccessToken(token); return manager.loadClientState() }
const save = (token: string, state: unknown) => { manager.assertRendererAccessToken(token); return manager.saveClientState(state) }
for (const denied of [() => load("other"), () => save("other", {})]) assert.throws(denied, /has not been claimed/)
let navigated = false
await controller(window(() => save("outgoing", { revision: 7, editor: "latest" })), manager).navigate(() => {
navigated = true
assert.deepEqual(load("outgoing").snapshot, { revision: 7, editor: "latest" })
manager.resetRendererAccessToken()
})
assert.equal(navigated, true)
assert.throws(() => load("outgoing"), /has not been claimed/)
manager.claimClientStateAccess("incoming")
assert.deepEqual(load("incoming").snapshot, { revision: 7, editor: "latest" })
})
test("failed navigation retains current document access", async (t) => {
for (const [name, operation] of [
["loadURL", () => Promise.reject(new Error("loadURL failed"))],
["reload", () => { throw new Error("reload failed") }],
] as const) await t.test(name, async (st) => {
const manager = managerHarness(st)
manager.claimClientStateAccess("current")
await assert.rejects(controller(window(), manager).navigate(operation), new RegExp(`${name} failed`))
manager.assertRendererAccessToken("current")
assert.equal(await manager.saveClientState({ retained: name }), true)
})
})
test("hung renderer flush is bounded without rotating access or blocking navigation", async () => {
const manager = { isPrimary: true, resets: 0, resetRendererAccessToken() { this.resets++ } }
let navigated = false, reported = false
const started = Date.now()
await controller(window(() => new Promise(() => {})), manager, () => { reported = true }).navigate(() => { navigated = true })
const elapsed = Date.now() - started
assert.equal(reported, true)
assert.equal(manager.resets, 0)
assert.equal(navigated, true)
assert.ok(elapsed >= 900 && elapsed < 2_000, `unexpected timeout: ${elapsed}ms`)
})
test("queued navigation preserves order and distinct generations", async () => {
const calls: string[] = []
let release!: () => void
const gate = new Promise<void>((resolve) => { release = resolve })
const navigation = controller(window(), { isPrimary: true })
const first = navigation.navigate(async (_window, generation) => { calls.push(`start-${generation}`); await gate; calls.push(`end-${generation}`) })
const second = navigation.navigate((_window, generation) => { calls.push(`run-${generation}`) })
await tick()
assert.deepEqual(calls, ["start-1"])
release()
await Promise.all([first, second])
assert.deepEqual(calls, ["start-1", "end-1", "run-2"])
})

View file

@ -0,0 +1,56 @@
import type { BrowserWindow } from "electron"
import type { ClientStateManager } from "./client-state"
import { flushRendererClientStateBeforeShutdown } from "./renderer-client-state-flush"
interface ClientStateNavigationDependencies {
clientStateManager: Pick<ClientStateManager, "isPrimary">
isTrustedOrigin(url: string): boolean
reportFlushError(error: unknown): void
}
export class ClientStateNavigationController {
private queue: Promise<void> = Promise.resolve()
private generation = 0
constructor(
private readonly window: BrowserWindow,
private readonly dependencies: ClientStateNavigationDependencies,
) {}
navigate(operation: (window: BrowserWindow, generation: number) => void | Promise<void>): Promise<void> {
const generation = ++this.generation
const request = this.queue.catch(() => {}).then(() => this.performNavigation(operation, generation))
this.queue = request
return request
}
private async performNavigation(
operation: (window: BrowserWindow, generation: number) => void | Promise<void>,
generation: number,
): Promise<void> {
const { window } = this
if (window.isDestroyed() || window.webContents.isDestroyed()) return
try {
await flushRendererClientStateBeforeShutdown(
window,
this.dependencies.clientStateManager.isPrimary,
this.dependencies.isTrustedOrigin,
)
} catch (error) {
this.dependencies.reportFlushError(error)
}
if (window.isDestroyed() || window.webContents.isDestroyed()) return
await operation(window, generation)
}
}
export function shouldResetRendererAccessTokenForNavigation(
url: string,
isInPlace: boolean,
isMainFrame: boolean,
isTrustedOrigin: (url: string) => boolean,
): boolean {
return isMainFrame && !isInPlace && isTrustedOrigin(url)
}

View file

@ -0,0 +1,129 @@
import { execFile, spawnSync } from "node:child_process"
import { readFile as readFileAsync } from "node:fs/promises"
import { readFileSync, readlinkSync } from "node:fs"
import { basename, resolve } from "node:path"
export type ProcessStartIdentityLookup = (pid: number) => string | undefined
export type AsyncProcessStartIdentityLookup = (pid: number, timeoutMs: number) => Promise<string | undefined> | string | undefined
export type ExpectedProcessLookup = (pid: number) => boolean | undefined
function readLinuxProcessStartIdentity(pid: number): string | undefined {
const stat = readFileSync(`/proc/${pid}/stat`, "utf8")
const commandEnd = stat.lastIndexOf(")")
if (commandEnd < 0) return undefined
// Fields after the command begin with field 3; process start time is field 22.
const fields = stat.slice(commandEnd + 1).trim().split(/\s+/)
const startTicks = fields[19]
if (!startTicks) return undefined
const bootId = readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim()
return bootId ? `linux:${bootId}:${startTicks}` : undefined
}
function readCommandIdentity(command: string, args: string[], prefix: string): string | undefined {
for (let attempt = 0; attempt < 2; attempt += 1) {
const result = spawnSync(command, args, {
encoding: "utf8",
windowsHide: true,
timeout: 5_000,
})
if (result.status === 0 && !result.error) {
const value = result.stdout.trim()
if (value) return `${prefix}:${value}`
}
}
return undefined
}
function readCommandIdentityAsync(command: string, args: string[], prefix: string, timeoutMs: number): Promise<string | undefined> {
if (timeoutMs <= 0) return Promise.resolve(undefined)
return new Promise((resolve) => {
execFile(command, args, { encoding: "utf8", windowsHide: true, timeout: timeoutMs }, (error, stdout) => {
const value = error ? "" : stdout.trim()
resolve(value ? `${prefix}:${value}` : undefined)
})
})
}
export function getProcessStartIdentity(pid: number): string | undefined {
if (!Number.isInteger(pid) || pid <= 0) return undefined
try {
if (process.platform === "linux") {
return readLinuxProcessStartIdentity(pid)
}
if (process.platform === "darwin") {
return readCommandIdentity("ps", ["-p", String(pid), "-o", "lstart="], "darwin")
}
if (process.platform === "win32") {
return readCommandIdentity(
"powershell.exe",
[
"-NoProfile",
"-NonInteractive",
"-Command",
`(Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}" -ErrorAction Stop).CreationDate.ToUniversalTime().Ticks`,
],
"win32",
)
}
} catch {
// Identity lookup is best-effort; callers preserve election safety when it is unavailable.
}
return undefined
}
export async function getProcessStartIdentityAsync(
pid: number,
timeoutMs: number,
platform: NodeJS.Platform = process.platform,
): Promise<string | undefined> {
if (!Number.isInteger(pid) || pid <= 0 || timeoutMs <= 0) return undefined
try {
if (platform === "linux") {
const signal = AbortSignal.timeout(timeoutMs)
const stat = await readFileAsync(`/proc/${pid}/stat`, { encoding: "utf8", signal })
const commandEnd = stat.lastIndexOf(")")
const startTicks = commandEnd < 0 ? undefined : stat.slice(commandEnd + 1).trim().split(/\s+/)[19]
if (!startTicks) return undefined
const bootId = (await readFileAsync("/proc/sys/kernel/random/boot_id", { encoding: "utf8", signal })).trim()
return bootId ? `linux:${bootId}:${startTicks}` : undefined
}
if (platform === "darwin") {
return readCommandIdentityAsync("ps", ["-p", String(pid), "-o", "lstart="], "darwin", timeoutMs)
}
if (platform === "win32") {
return readCommandIdentityAsync("powershell.exe", [
"-NoProfile",
"-NonInteractive",
"-Command",
`(Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}" -ErrorAction Stop).CreationDate.ToUniversalTime().Ticks`,
], "win32", timeoutMs)
}
} catch {
// Identity lookup is best-effort; callers refuse destructive actions when it is unavailable.
}
return undefined
}
export function isExpectedTauriProcess(pid: number): boolean | undefined {
try {
const executable = process.platform === "linux"
? readlinkSync(`/proc/${pid}/exe`)
: readCommandIdentity(
process.platform === "win32" ? "powershell.exe" : "ps",
process.platform === "win32"
? ["-NoProfile", "-NonInteractive", "-Command", `(Get-Process -Id ${pid} -ErrorAction Stop).Path`]
: ["-p", String(pid), "-o", "comm="],
"path",
)?.slice(5)
if (!executable) return undefined
if (resolve(executable).toLowerCase() === resolve(process.execPath).toLowerCase()) return false
return ["codenomad", "codenomad.exe", "codenomad-tauri", "codenomad-tauri.exe"]
.includes(basename(executable).toLowerCase())
} catch {
return undefined
}
}

View file

@ -0,0 +1,190 @@
import assert from "node:assert/strict"
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"
import { randomUUID } from "node:crypto"
import { once } from "node:events"
import fs, { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"
import { syncBuiltinESMExports } from "node:module"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { fileURLToPath } from "node:url"
import test from "node:test"
import { cleanStaleRunningMarkers, createRunningMarker, electClientStateProcess, getRunningMarkerPath, hasLiveTauriClient, REGISTRATION_LOCK_WAIT_MS, removeProcessOwnerLockIfOwned, removeRunningMarkerIfOwned, type ProcessOwner } from "./client-state-process"
import { getProcessStartIdentity, getProcessStartIdentityAsync } from "./client-state-process-identity"
function temp(t: test.TestContext) { const directory = mkdtempSync(join(tmpdir(), "codenomad-election-")); t.after(() => rmSync(directory, { recursive: true, force: true })); return directory }
test("legacy Tauri markers block only while their PID may be live", (t) => {
const directory = temp(t)
writeFileSync(join(directory, "client-state.running.123.1.lock"), "")
writeFileSync(join(directory, "client-state.running.456.2.lock"), "")
assert.equal(hasLiveTauriClient(directory, (pid) => pid === 456), true)
assert.equal(hasLiveTauriClient(directory, () => false), false)
assert.equal(hasLiveTauriClient(directory, (pid) => pid === 456, () => "reused", () => false), false)
assert.equal(hasLiveTauriClient(directory, (pid) => pid === 456, () => undefined, () => undefined), true)
assert.equal(hasLiveTauriClient(directory, (pid) => pid === 456, () => "tauri-start", () => true), true)
assert.equal(hasLiveTauriClient(directory, (pid) => pid === 456, () => "tauri-start", () => true, [
{ pid: 456, runToken: "upgraded", processStartIdentity: "tauri-start" },
]), false)
})
interface Child { process: ChildProcessWithoutNullStreams; result: Promise<{ isPrimary: boolean }> }
function child(directory: string, start: string, wait = "", paused = "", release = ""): Child {
const process = spawn(globalThis.process.execPath, ["--import", "tsx", fileURLToPath(new URL("./client-state-election-child.ts", import.meta.url)), directory, randomUUID(), start, wait, paused, release])
process.stdout.setEncoding("utf8"); process.stderr.setEncoding("utf8")
const result = new Promise<{ isPrimary: boolean }>((resolve, reject) => {
let output = "", errors = "", settled = false
process.stdout.on("data", (chunk: string) => {
output += chunk
const end = output.indexOf("\n")
if (end >= 0 && !settled) { settled = true; resolve(JSON.parse(output.slice(0, end))) }
})
process.stderr.on("data", (chunk: string) => { errors += chunk }); process.once("error", reject)
process.once("exit", (code) => { if (!settled) reject(new Error(`child ${code}: ${errors}`)) })
})
return { process, result }
}
async function stop(children: Child[]) { const exits = children.map(({ process }) => once(process, "exit")); children.forEach(({ process }) => process.stdin.end()); await Promise.all(exits) }
async function contenders(directory: string, configure?: () => void, count = 2) {
const start = join(directory, "start")
configure?.()
const children = Array.from({ length: count }, () => child(directory, start, "40"))
try {
writeFileSync(start, "")
const roles = await Promise.all(children.map(({ result }) => result))
assert.equal(roles.filter(({ isPrimary }) => isPrimary).length, 1, JSON.stringify(roles))
} finally { await stop(children) }
}
test("current process start identity is stable", () => {
const identity = getProcessStartIdentity(process.pid)
assert.ok(identity, `identity unavailable on ${process.platform}`)
assert.equal(getProcessStartIdentity(process.pid), identity)
})
test("async process identity matches the spawn-time identity", async () => {
const identity = getProcessStartIdentity(process.pid)
assert.ok(identity)
assert.equal(await getProcessStartIdentityAsync(process.pid, 1_500), identity)
})
test("marker cleanup preserves only election-relevant live cohorts", async (t) => {
const cases: Array<{ name: string; marker: ProcessOwner; current: ProcessOwner; alive: boolean; identity?: string; primary?: ProcessOwner; blocking: boolean; remains: boolean }> = [
{ name: "live secondary", marker: { pid: 2, runToken: "live" }, current: { pid: 3, runToken: "new" }, alive: true, blocking: true, remains: true },
{ name: "same PID old run", marker: { pid: 4, runToken: "old" }, current: { pid: 4, runToken: "new" }, alive: true, blocking: false, remains: false },
{ name: "reused PID", marker: { pid: 5, runToken: "old", processStartIdentity: "old" }, current: { pid: 6, runToken: "new" }, alive: true, identity: "reused", blocking: false, remains: false },
{ name: "unknown identity", marker: { pid: 7, runToken: "unknown", processStartIdentity: "old" }, current: { pid: 8, runToken: "new" }, alive: true, blocking: true, remains: true },
{ name: "acknowledged primary", marker: { pid: 9, runToken: "secondary" }, current: { pid: 10, runToken: "primary" }, alive: true, primary: { pid: 10, runToken: "primary" }, blocking: false, remains: true },
]
for (const value of cases) await t.test(value.name, (st) => {
const directory = temp(st)
const path = createRunningMarker(directory, value.marker, value.primary)
const identity = () => value.identity
assert.equal(cleanStaleRunningMarkers(directory, value.current, () => value.alive, identity), value.blocking)
assert.equal(existsSync(path), value.remains)
})
})
test("marker removal and mismatched contents never discard a possible live owner", (t) => {
const directory = temp(t)
const filenameOwner = { pid: 11, runToken: "filename" }
const storedOwner = { pid: 12, runToken: "stored" }
const current = { pid: 13, runToken: "current" }
const path = createRunningMarker(directory, filenameOwner)
writeFileSync(path, JSON.stringify(storedOwner))
assert.equal(removeRunningMarkerIfOwned(path, filenameOwner), false)
assert.deepEqual(JSON.parse(readFileSync(path, "utf8")), storedOwner)
assert.equal(cleanStaleRunningMarkers(directory, current, () => false), false)
assert.equal(existsSync(path), false)
createRunningMarker(directory, filenameOwner)
writeFileSync(path, JSON.stringify(storedOwner))
assert.equal(cleanStaleRunningMarkers(directory, current, (pid) => pid === filenameOwner.pid), true)
assert.equal(existsSync(path), true)
})
test("process files tolerate only unsupported fsync and never clobber an owner", (t) => {
const directory = temp(t)
const first = { pid: 14, runToken: "first" }
const path = createRunningMarker(directory, first)
assert.throws(() => createRunningMarker(directory, { pid: 14, runToken: "first" }), { code: "EEXIST" })
assert.deepEqual(JSON.parse(readFileSync(path, "utf8")), first)
const original = fs.fsyncSync
t.after(() => { fs.fsyncSync = original; syncBuiltinESMExports() })
for (const [code, retained] of [["EINVAL", true], ["ENOTSUP", true], ["ENOSYS", true], ["EIO", false]] as const) {
fs.fsyncSync = () => { throw Object.assign(new Error(code), { code }) }; syncBuiltinESMExports()
const owner = { pid: 15, runToken: code }, next = getRunningMarkerPath(directory, owner)
if (retained) createRunningMarker(directory, owner)
else assert.throws(() => createRunningMarker(directory, owner), { code })
assert.equal(existsSync(next), retained)
}
})
test("simultaneous registration and stale lock recovery elect exactly one primary", async (t) => {
await t.test("clean", (st) => contenders(temp(st)))
await t.test("unrelated live registration PID", (st) => {
const directory = temp(st)
return contenders(directory, () => writeFileSync(join(directory, "client-state.registration.lock"), JSON.stringify({ pid: process.pid, runToken: "old" })))
})
for (let round = 0; round < 10; round++) await t.test(`reused primary PID ${round}`, (st) => {
const directory = temp(st)
return contenders(directory, () => {
const owner = { pid: process.pid, runToken: `old-${round}`, processStartIdentity: `old-start-${round}` }
writeFileSync(join(directory, "client-state.primary.lock"), JSON.stringify(owner))
createRunningMarker(directory, owner)
})
})
})
test("overlapping stale-registration recovery cannot leave all contenders secondary", async (t) => {
const directory = temp(t), start = join(directory, "start"), paused = join(directory, "paused"), release = join(directory, "release")
writeFileSync(join(directory, "client-state.registration.lock"), JSON.stringify({ pid: process.pid, runToken: "old" }))
const children = [child(directory, start, "40", paused, release), child(directory, start, "40", paused, release)]
try {
writeFileSync(start, "")
const deadline = Date.now() + 2_000
while (!existsSync(paused) && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, 5))
assert.equal(existsSync(paused), true)
await Promise.race(children.map(({ result }) => result))
writeFileSync(release, "")
const roles = await Promise.all(children.map(({ result }) => result))
assert.equal(roles.filter(({ isPrimary }) => isPrimary).length, 1, JSON.stringify(roles))
} finally { if (!existsSync(release)) writeFileSync(release, ""); await stop(children) }
})
test("a surviving older secondary keeps later processes secondary", async (t) => {
const directory = temp(t)
const launch = async (name: string) => {
const start = join(directory, name), next = child(directory, start)
writeFileSync(start, "")
return { next, role: await next.result }
}
const first = await launch("first"); assert.equal(first.role.isPrimary, true)
const second = await launch("second"); assert.equal(second.role.isPrimary, false)
await stop([first.next])
const third = await launch("third")
await stop([second.next, third.next])
assert.equal(third.role.isPrimary, false)
})
test("lock recovery handles PID reuse, malformed files, and verified live owners", async (t) => {
const cases = [
{ name: "same PID old token", owner: { pid: process.pid, runToken: "new" }, file: { pid: process.pid, runToken: "old" }, lock: "primary", alive: () => true, expected: true },
{ name: "malformed registration", owner: { pid: 21, runToken: "new" }, file: "malformed", lock: "registration", alive: () => true, expected: false },
{ name: "live registration marker", owner: { pid: 22, runToken: "new" }, file: { pid: 23, runToken: "live" }, lock: "registration", alive: (pid: number) => pid === 23, marker: true, expected: false },
{ name: "identity-verified registration", owner: { pid: 24, runToken: "new" }, file: { pid: 25, runToken: "live", processStartIdentity: "start" }, lock: "registration", alive: (pid: number) => pid === 25, identity: () => "start", expected: false },
{ name: "live primary marker", owner: { pid: 26, runToken: "new" }, file: { pid: 27, runToken: "live" }, lock: "primary", alive: (pid: number) => pid === 27, marker: true, expected: false },
] as const
for (const value of cases) await t.test(value.name, (st) => {
const directory = temp(st), primary = join(directory, "client-state.primary.lock"), registration = join(directory, "client-state.registration.lock")
const path = value.lock === "primary" ? primary : registration
writeFileSync(path, typeof value.file === "string" ? value.file : JSON.stringify(value.file))
if ("marker" in value && value.marker && typeof value.file !== "string") createRunningMarker(directory, value.file)
const started = Date.now()
const elected = electClientStateProcess(directory, value.owner, { primaryLockPath: primary, registrationLockPath: registration }, () => {}, value.alive, 30, () => {}, "identity" in value ? value.identity : undefined)
assert.equal(elected, value.expected)
assert.ok(Date.now() - started < 500)
if (!value.expected) assert.deepEqual(readFileSync(path, "utf8"), typeof value.file === "string" ? value.file : JSON.stringify(value.file))
removeRunningMarkerIfOwned(getRunningMarkerPath(directory, value.owner), value.owner)
removeProcessOwnerLockIfOwned(primary, value.owner)
})
assert.equal(REGISTRATION_LOCK_WAIT_MS, 1_000)
})

View file

@ -0,0 +1,511 @@
import { closeSync, fsyncSync, openSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from "node:fs"
import { basename, join } from "node:path"
import {
type ExpectedProcessLookup,
getProcessStartIdentity,
isExpectedTauriProcess,
type ProcessStartIdentityLookup,
} from "./client-state-process-identity"
const RUNNING_MARKER_PREFIX = "client-state.running."
const RUNNING_MARKER_SUFFIX = ".json"
const PRIMARY_LOCK_ACQUIRE_ATTEMPTS = 5
const LOCK_RETRY_DELAY_MS = 10
export const REGISTRATION_LOCK_WAIT_MS = 1_000
export interface ProcessOwner {
pid: number
runToken: string
processStartIdentity?: string
}
export type RunningMarkerStatus = "current" | "other-live" | "stale"
export interface ClientStateElectionPaths {
primaryLockPath: string
registrationLockPath: string
}
interface ProcessOwnerLockAcquisition {
acquired: boolean
liveOwner?: {
owner: ProcessOwner
observed: string
}
}
export function hasErrorCode(error: unknown, code: string): boolean {
return error instanceof Error && "code" in error && error.code === code
}
function isTransientFileContentionError(error: unknown): boolean {
return hasErrorCode(error, "EPERM") || hasErrorCode(error, "EACCES") || hasErrorCode(error, "EBUSY")
}
export function parseProcessOwner(value: string): ProcessOwner | undefined {
try {
return normalizeProcessOwner(JSON.parse(value))
} catch {
// Incomplete process files are handled conservatively using their filename owner.
}
return undefined
}
function normalizeProcessOwner(candidate: unknown): ProcessOwner | undefined {
if (!candidate || typeof candidate !== "object") {
return undefined
}
const owner = candidate as Partial<ProcessOwner>
if (Number.isInteger(owner.pid) && Number(owner.pid) > 0 && typeof owner.runToken === "string" && owner.runToken) {
return {
pid: Number(owner.pid),
runToken: owner.runToken,
...(typeof owner.processStartIdentity === "string" && owner.processStartIdentity
? { processStartIdentity: owner.processStartIdentity }
: {}),
}
}
return undefined
}
function parseAcknowledgedPrimary(value: string): ProcessOwner | undefined {
try {
const candidate = JSON.parse(value) as { primaryOwner?: unknown }
return normalizeProcessOwner(candidate.primaryOwner)
} catch {
return undefined
}
}
export function isSameProcessOwner(left: ProcessOwner, right: ProcessOwner): boolean {
return left.pid === right.pid && left.runToken === right.runToken
}
export function isPidAlive(pid: number): boolean {
if (pid === process.pid) {
return true
}
try {
process.kill(pid, 0)
return true
} catch (error) {
return !hasErrorCode(error, "ESRCH")
}
}
export function hasLiveTauriClient(
tauriDataPath: string,
pidAlive: (pid: number) => boolean = isPidAlive,
processStartIdentity: ProcessStartIdentityLookup = getProcessStartIdentity,
expectedProcess: ExpectedProcessLookup = isExpectedTauriProcess,
upgradedParticipants: readonly ProcessOwner[] = [],
): boolean {
let entries: string[]
try {
entries = readdirSync(tauriDataPath)
} catch (error) {
if (hasErrorCode(error, "ENOENT")) return false
throw error
}
return entries.some((name) => {
const match = /^client-state\.running\.(\d+)\..+\.lock$/.exec(name)
if (!match) return false
const pid = Number(match[1])
if (!Number.isInteger(pid) || pid <= 0 || !pidAlive(pid)) return false
const liveIdentity = processStartIdentity(pid)
if (liveIdentity && upgradedParticipants.some((owner) => owner.pid === pid && owner.processStartIdentity === liveIdentity)) return false
return expectedProcess(pid) !== false
})
}
export function classifyRunningMarker(
markerOwner: ProcessOwner,
currentOwner: ProcessOwner,
pidAlive: (pid: number) => boolean = isPidAlive,
processStartIdentity: ProcessStartIdentityLookup = getProcessStartIdentity,
): RunningMarkerStatus {
if (isSameProcessOwner(markerOwner, currentOwner)) {
return "current"
}
// Two live processes cannot share a PID. A different token therefore belongs to an old run.
if (markerOwner.pid === currentOwner.pid) {
return "stale"
}
if (!pidAlive(markerOwner.pid)) return "stale"
if (markerOwner.processStartIdentity) {
const liveIdentity = processStartIdentity(markerOwner.pid)
if (liveIdentity && liveIdentity !== markerOwner.processStartIdentity) return "stale"
}
return "other-live"
}
export function getRunningMarkerPath(userDataPath: string, owner: ProcessOwner): string {
return join(userDataPath, `${RUNNING_MARKER_PREFIX}${owner.pid}.${owner.runToken}${RUNNING_MARKER_SUFFIX}`)
}
function parseRunningMarkerFilename(filename: string): ProcessOwner | undefined {
if (!filename.startsWith(RUNNING_MARKER_PREFIX) || !filename.endsWith(RUNNING_MARKER_SUFFIX)) {
return undefined
}
const value = filename.slice(RUNNING_MARKER_PREFIX.length, -RUNNING_MARKER_SUFFIX.length)
const separator = value.indexOf(".")
if (separator < 1) {
return undefined
}
const pid = Number(value.slice(0, separator))
const runToken = value.slice(separator + 1)
if (!Number.isInteger(pid) || pid <= 0 || !runToken) {
return undefined
}
return { pid, runToken }
}
export function createRunningMarker(
userDataPath: string,
owner: ProcessOwner,
primaryOwner?: ProcessOwner,
): string {
const markerPath = getRunningMarkerPath(userDataPath, owner)
publishProcessFile(markerPath, JSON.stringify(primaryOwner ? { ...owner, primaryOwner } : owner))
return markerPath
}
function publishProcessFile(path: string, contents: string): void {
let descriptor: number | undefined
try {
descriptor = openSync(path, "wx", 0o600)
writeFileSync(descriptor, contents, "utf8")
try {
fsyncSync(descriptor)
} catch (error) {
if (!["EINVAL", "ENOTSUP", "ENOSYS"].some((code) => hasErrorCode(error, code))) throw error
}
closeSync(descriptor)
descriptor = undefined
} catch (error) {
if (descriptor !== undefined) {
try { closeSync(descriptor) } catch {}
try { unlinkSync(path) } catch {}
}
throw error
}
}
function removeFileIfUnchanged(path: string, observed: string): boolean {
try {
if (readFileSync(path, "utf8") !== observed) {
return false
}
unlinkSync(path)
return true
} catch (error) {
if (hasErrorCode(error, "ENOENT")) {
return false
}
throw error
}
}
function readFileIfExists(path: string): string | undefined {
try {
return readFileSync(path, "utf8")
} catch (error) {
if (hasErrorCode(error, "ENOENT")) return undefined
throw error
}
}
function waitForLockRetry(delayMs = LOCK_RETRY_DELAY_MS) {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, Math.max(0, delayMs))
}
function removeContendedFile(path: string, observed: string): void {
try {
removeFileIfUnchanged(path, observed)
} catch (error) {
if (!isTransientFileContentionError(error)) throw error
waitForLockRetry()
}
}
function acquireProcessOwnerLockWithStatus(
path: string,
owner: ProcessOwner,
waitForLiveOwner: boolean,
pidAlive: (pid: number) => boolean = isPidAlive,
liveOwnerWaitMs = REGISTRATION_LOCK_WAIT_MS,
processStartIdentity: ProcessStartIdentityLookup = getProcessStartIdentity,
): ProcessOwnerLockAcquisition {
const serializedOwner = JSON.stringify(owner)
const waitDeadline = Date.now() + Math.max(0, liveOwnerWaitMs)
let liveOwner: ProcessOwnerLockAcquisition["liveOwner"]
for (let attempt = 0; ; attempt += 1) {
if (
(!waitForLiveOwner && attempt >= PRIMARY_LOCK_ACQUIRE_ATTEMPTS) ||
(waitForLiveOwner && attempt > 0 && Date.now() >= waitDeadline)
) {
return { acquired: false, liveOwner }
}
try {
publishProcessFile(path, serializedOwner)
return { acquired: true }
} catch (error) {
if (!hasErrorCode(error, "EEXIST")) {
throw error
}
}
const observed = readFileIfExists(path)
if (observed === undefined) continue
const existingOwner = parseProcessOwner(observed)
if (existingOwner) {
const status = classifyRunningMarker(existingOwner, owner, pidAlive, processStartIdentity)
if (status === "other-live") {
liveOwner = { owner: existingOwner, observed }
if (!waitForLiveOwner) {
return { acquired: false, liveOwner }
}
const remainingWaitMs = waitDeadline - Date.now()
if (remainingWaitMs <= 0) {
return { acquired: false, liveOwner }
}
waitForLockRetry(Math.min(LOCK_RETRY_DELAY_MS, remainingWaitMs))
continue
}
} else if (waitForLiveOwner && attempt < PRIMARY_LOCK_ACQUIRE_ATTEMPTS - 1) {
// The owner may still be writing a newly-created lock file.
waitForLockRetry()
continue
}
removeContendedFile(path, observed)
}
return { acquired: false, liveOwner }
}
export function removeProcessOwnerLockIfOwned(path: string, owner: ProcessOwner): boolean {
const observed = readFileIfExists(path)
const current = observed === undefined ? undefined : parseProcessOwner(observed)
return Boolean(current && isSameProcessOwner(current, owner) && removeFileIfUnchanged(path, observed!))
}
function releaseProcessOwnerLock(
path: string,
owner: ProcessOwner,
onWarning: (message: string, error: unknown) => void,
warning: string,
): void {
try {
removeProcessOwnerLockIfOwned(path, owner)
} catch (error) {
onWarning(warning, error)
}
}
export function isProcessOwnerLockOwned(path: string, owner: ProcessOwner): boolean {
const value = readFileIfExists(path)
const current = value === undefined ? undefined : parseProcessOwner(value)
return Boolean(current && isSameProcessOwner(current, owner))
}
export function removeRunningMarkerIfOwned(markerPath: string, owner: ProcessOwner): boolean {
const filenameOwner = parseRunningMarkerFilename(basename(markerPath))
if (!filenameOwner || !isSameProcessOwner(filenameOwner, owner)) {
return false
}
const observed = readFileIfExists(markerPath)
const storedOwner = observed === undefined ? undefined : parseProcessOwner(observed)
return Boolean(storedOwner && isSameProcessOwner(storedOwner, owner) && removeFileIfUnchanged(markerPath, observed!))
}
export function cleanStaleRunningMarkers(
userDataPath: string,
currentOwner: ProcessOwner,
pidAlive: (pid: number) => boolean = isPidAlive,
processStartIdentity: ProcessStartIdentityLookup = getProcessStartIdentity,
): boolean {
let hasOtherLiveProcess = false
for (const filename of readdirSync(userDataPath)) {
const filenameOwner = parseRunningMarkerFilename(filename)
if (!filenameOwner) {
continue
}
const markerPath = join(userDataPath, filename)
const observed = readFileIfExists(markerPath)
if (observed === undefined) continue
const storedOwner = parseProcessOwner(observed)
if (storedOwner && !isSameProcessOwner(storedOwner, filenameOwner)) {
const storedStatus = classifyRunningMarker(storedOwner, currentOwner, pidAlive, processStartIdentity)
const filenameStatus = classifyRunningMarker(filenameOwner, currentOwner, pidAlive, processStartIdentity)
if (storedStatus === "other-live" || filenameStatus === "other-live") {
hasOtherLiveProcess = true
} else {
removeFileIfUnchanged(markerPath, observed)
}
continue
}
const markerOwner = storedOwner ?? filenameOwner
const status = classifyRunningMarker(markerOwner, currentOwner, pidAlive, processStartIdentity)
if (status === "other-live") {
const acknowledgedPrimary = parseAcknowledgedPrimary(observed)
if (!acknowledgedPrimary || !isSameProcessOwner(acknowledgedPrimary, currentOwner)) {
hasOtherLiveProcess = true
}
} else if (status === "stale") {
removeFileIfUnchanged(markerPath, observed)
}
}
return hasOtherLiveProcess
}
function hasMatchingLiveRunningMarker(
userDataPath: string,
owner: ProcessOwner,
pidAlive: (pid: number) => boolean,
processStartIdentity: ProcessStartIdentityLookup,
): boolean {
if (!pidAlive(owner.pid)) {
return false
}
const value = readFileIfExists(getRunningMarkerPath(userDataPath, owner))
const markerOwner = value === undefined ? undefined : parseProcessOwner(value)
if (!markerOwner || !isSameProcessOwner(markerOwner, owner)) return false
const liveIdentity = markerOwner.processStartIdentity && processStartIdentity(markerOwner.pid)
return !liveIdentity || liveIdentity === markerOwner.processStartIdentity
}
function acquireMarkerBackedProcessOwnerLock(
userDataPath: string,
path: string,
owner: ProcessOwner,
pidAlive: (pid: number) => boolean,
liveOwnerWaitMs: number,
processStartIdentity: ProcessStartIdentityLookup,
): ProcessOwnerLockAcquisition {
let lastAcquisition: ProcessOwnerLockAcquisition = { acquired: false }
for (let recoveryAttempt = 0; recoveryAttempt < PRIMARY_LOCK_ACQUIRE_ATTEMPTS; recoveryAttempt += 1) {
const acquisition = acquireProcessOwnerLockWithStatus(
path,
owner,
true,
pidAlive,
liveOwnerWaitMs,
processStartIdentity,
)
lastAcquisition = acquisition
if (acquisition.acquired || !acquisition.liveOwner) {
if (acquisition.acquired) return acquisition
continue
}
// An identity-backed owner only reaches this point after an exact identity match
// or an inconclusive lookup. Never steal its lock while its PID remains live.
if (acquisition.liveOwner.owner.processStartIdentity) {
return acquisition
}
if (hasMatchingLiveRunningMarker(
userDataPath,
acquisition.liveOwner.owner,
pidAlive,
processStartIdentity,
)) {
return acquisition
}
removeContendedFile(path, acquisition.liveOwner.observed)
}
return lastAcquisition
}
export function electClientStateProcess(
userDataPath: string,
owner: ProcessOwner,
paths: ClientStateElectionPaths,
onWarning: (message: string, error: unknown) => void = () => {},
pidAlive: (pid: number) => boolean = isPidAlive,
registrationLockWaitMs = REGISTRATION_LOCK_WAIT_MS,
onPrimaryLockAcquired: () => void = () => {},
processStartIdentity: ProcessStartIdentityLookup = getProcessStartIdentity,
): boolean {
let registrationAcquired = false
let registeringOwner: ProcessOwner | undefined
try {
const registration = acquireMarkerBackedProcessOwnerLock(
userDataPath,
paths.registrationLockPath,
owner,
pidAlive,
registrationLockWaitMs,
processStartIdentity,
)
registrationAcquired = registration.acquired
registeringOwner = registration.liveOwner?.owner
} catch (error) {
onWarning("failed to acquire registration lock", error)
}
if (!registrationAcquired) {
try {
createRunningMarker(userDataPath, owner, registeringOwner)
} catch (error) {
onWarning("failed to create running marker", error)
}
return false
}
try {
let isPrimary = false
let acknowledgedPrimary: ProcessOwner | undefined
try {
const acquisition = acquireMarkerBackedProcessOwnerLock(
userDataPath,
paths.primaryLockPath,
owner,
pidAlive,
registrationLockWaitMs,
processStartIdentity,
)
isPrimary = acquisition.acquired
acknowledgedPrimary = acquisition.liveOwner?.owner
} catch (error) {
onWarning("failed to acquire primary lock", error)
}
if (isPrimary) {
try {
onPrimaryLockAcquired()
if (cleanStaleRunningMarkers(userDataPath, owner, pidAlive, processStartIdentity)) {
removeProcessOwnerLockIfOwned(paths.primaryLockPath, owner)
isPrimary = false
}
} catch (error) {
onWarning("failed to inspect running markers", error)
releaseProcessOwnerLock(paths.primaryLockPath, owner, onWarning, "failed to release primary lock")
isPrimary = false
}
}
try {
createRunningMarker(userDataPath, owner, acknowledgedPrimary)
} catch (error) {
onWarning("failed to create running marker", error)
if (isPrimary) releaseProcessOwnerLock(paths.primaryLockPath, owner, onWarning, "failed to release primary lock")
return false
}
return isPrimary
} finally {
releaseProcessOwnerLock(paths.registrationLockPath, owner, onWarning, "failed to release registration lock")
}
}

View file

@ -0,0 +1,282 @@
import assert from "node:assert/strict"
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"
import { writeFile } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import test from "node:test"
import { ClientStateManager, type ClientStateWriter } from "./client-state"
function harness(t: test.TestContext, initial?: object) {
const directory = mkdtempSync(join(tmpdir(), "codenomad-state-"))
const statePath = join(directory, "client-state.json")
if (initial) writeFileSync(statePath, JSON.stringify(initial))
let failing = false
let writes = 0
const managers: ClientStateManager[] = []
const create = (writer: ClientStateWriter = async (path, value) => {
writes++
if (failing) throw new Error("injected write failure")
await writeFile(path, value, "utf8")
}, processOwner?: { pid: number; runToken: string; processStartIdentity: string }) => {
const manager = new ClientStateManager(directory, writer, { crossHostElectionDirectory: join(directory, "election"), processOwner })
managers.push(manager)
return manager
}
t.after(async () => {
await Promise.all(managers.map((manager) => manager.drainAndReleasePrimary().catch(() => {})))
rmSync(directory, { recursive: true, force: true })
})
return { create, directory, statePath, fail: (value: boolean) => { failing = value }, writes: () => writes }
}
test("renderer access is exclusive per document and resettable", async (t) => {
const manager = harness(t, { version: 1, restoreEnabled: true }).create()
assert.throws(() => manager.claimClientStateAccess(""), /nonempty string/)
assert.throws(() => manager.assertRendererAccessToken("unclaimed"), /has not been claimed/)
assert.equal(manager.claimClientStateAccess("document-1"), true)
assert.equal(manager.claimClientStateAccess("document-1"), true)
assert.throws(() => manager.claimClientStateAccess("document-2"), /does not match/)
manager.assertRendererAccessToken("document-1")
assert.equal(await manager.saveClientState({ saved: true }), true)
manager.resetRendererAccessToken()
assert.throws(() => manager.assertRendererAccessToken("document-1"), /has not been claimed/)
assert.equal(manager.claimClientStateAccess("document-2"), true)
})
test("restore defaults on unless explicitly disabled", (t) => {
assert.equal(harness(t).create().loadClientState().restoreEnabled, true)
assert.equal(harness(t, { version: 1 }).create().loadClientState().restoreEnabled, true)
assert.equal(harness(t, { version: 1, restoreEnabled: false }).create().loadClientState().restoreEnabled, false)
})
test("cross-host ownership is required in addition to each host-local election", async (t) => {
const root = mkdtempSync(join(tmpdir(), "codenomad-cross-host-state-"))
const electronDirectory = join(root, "electron"), tauriDirectory = join(root, "tauri"), election = join(root, "election")
t.after(() => rmSync(root, { recursive: true, force: true }))
const identities = new Map([[8101, "tauri-start"], [8102, "electron-start"], [8103, "successor-start"]])
const crossHostDependencies = { pidAlive: (pid: number) => identities.has(pid), processStartIdentity: (pid: number) => identities.get(pid) }
const primary = new ClientStateManager(tauriDirectory, undefined, {
crossHostElectionDirectory: election,
crossHostDependencies,
processOwner: { pid: 8101, runToken: "tauri", processStartIdentity: "tauri-start" },
})
mkdirSync(electronDirectory)
writeFileSync(join(electronDirectory, "client-state.json"), JSON.stringify({
version: 1,
restoreEnabled: true,
snapshot: { tabs: ["must-not-restore"] },
}))
const secondary = new ClientStateManager(electronDirectory, undefined, {
crossHostElectionDirectory: election,
crossHostDependencies,
processOwner: { pid: 8102, runToken: "electron", processStartIdentity: "electron-start" },
})
assert.deepEqual(secondary.loadClientState(), { isPrimary: false, restoreEnabled: false, snapshot: null })
await secondary.drainAndReleasePrimary()
await primary.drainAndReleasePrimary()
identities.delete(8101); identities.delete(8102)
const successor = new ClientStateManager(electronDirectory, undefined, {
crossHostElectionDirectory: election,
crossHostDependencies,
processOwner: { pid: 8103, runToken: "successor", processStartIdentity: "successor-start" },
})
assert.equal(successor.isPrimary, true)
await successor.drainAndReleasePrimary()
})
test("first shared primary deterministically migrates legacy host envelopes", async (t) => {
const root = mkdtempSync(join(tmpdir(), "codenomad-migration-"))
const electron = join(root, "electron"), tauri = join(root, "tauri"), election = join(root, "shared", "election")
mkdirSync(electron, { recursive: true }); mkdirSync(tauri, { recursive: true })
t.after(() => rmSync(root, { recursive: true, force: true }))
writeFileSync(join(electron, "client-state.json"), JSON.stringify({ version: 1, restoreEnabled: true, snapshot: { revision: 999, savedAt: 10, host: "electron" } }))
writeFileSync(join(tauri, "client-state.json"), JSON.stringify({
version: 1,
restoreEnabled: true,
snapshot: { savedAt: 20, host: "tauri" },
window: { bounds: { x: 10, y: 10, width: 1000, height: 700 }, maximized: false, fullscreen: false, zoomFactor: 1 },
}))
const manager = new ClientStateManager(electron, undefined, { crossHostElectionDirectory: election, legacyTauriDataPath: tauri })
assert.deepEqual(manager.loadClientState().snapshot, { savedAt: 20, host: "tauri" })
assert.equal(manager.getWindowState(), undefined)
assert.equal(existsSync(join(electron, "client-state.json")), false)
assert.equal(existsSync(join(tauri, "client-state.json")), false)
await manager.drainAndReleasePrimary()
})
test("legacy migration prefers disabled and ignores malformed candidates", async (t) => {
const root = mkdtempSync(join(tmpdir(), "codenomad-migration-"))
const electron = join(root, "electron"), tauri = join(root, "tauri"), election = join(root, "shared", "election")
mkdirSync(electron, { recursive: true }); mkdirSync(tauri, { recursive: true })
t.after(() => rmSync(root, { recursive: true, force: true }))
writeFileSync(join(electron, "client-state.json"), "malformed")
writeFileSync(join(tauri, "client-state.json"), JSON.stringify({ version: 1, restoreEnabled: false, snapshot: { savedAt: 1 } }))
const manager = new ClientStateManager(electron, undefined, { crossHostElectionDirectory: election, legacyTauriDataPath: tauri })
assert.deepEqual(manager.loadClientState(), { isPrimary: true, restoreEnabled: false, snapshot: null })
assert.equal(JSON.parse(readFileSync(join(root, "shared", "client-state.json"), "utf8")).restoreEnabled, false)
await manager.drainAndReleasePrimary()
})
test("legacy migration does not resurrect a snapshot after clear", async (t) => {
const root = mkdtempSync(join(tmpdir(), "codenomad-migration-"))
const electron = join(root, "electron"), tauri = join(root, "tauri"), election = join(root, "shared", "election")
mkdirSync(electron, { recursive: true }); mkdirSync(tauri, { recursive: true })
t.after(() => rmSync(root, { recursive: true, force: true }))
writeFileSync(join(electron, "client-state.json"), JSON.stringify({ version: 1, restoreEnabled: true }))
writeFileSync(join(tauri, "client-state.json"), JSON.stringify({ version: 1, restoreEnabled: true, snapshot: { savedAt: 20 } }))
const manager = new ClientStateManager(electron, undefined, { crossHostElectionDirectory: election, legacyTauriDataPath: tauri })
assert.equal(manager.loadClientState().snapshot, null)
await manager.drainAndReleasePrimary()
})
test("legacy cleanup failure cannot abort startup after shared state replacement", async (t) => {
const root = mkdtempSync(join(tmpdir(), "codenomad-migration-"))
const electron = join(root, "electron"), shared = join(root, "shared"), election = join(shared, "election")
mkdirSync(electron, { recursive: true })
t.after(() => rmSync(root, { recursive: true, force: true }))
writeFileSync(join(electron, "client-state.json"), JSON.stringify({ version: 1, restoreEnabled: true, snapshot: { savedAt: 10 } }))
const manager = new ClientStateManager(electron, undefined, {
crossHostElectionDirectory: election,
removeLegacyState: () => { throw new Error("injected cleanup failure") },
})
assert.deepEqual(manager.loadClientState().snapshot, { savedAt: 10 })
assert.equal(existsSync(join(shared, "client-state.json")), true)
await manager.drainAndReleasePrimary()
})
test("ownership loss immediately disables restore reads and mutations", async (t) => {
const h = harness(t, {
version: 1,
restoreEnabled: true,
snapshot: { tabs: ["must-stop"] },
window: { width: 900, height: 700 },
})
const manager = h.create()
writeFileSync(join(h.directory, "election", "primary.owner.json", "owner.json"), "malformed")
assert.equal(manager.isPrimary, false)
assert.deepEqual(manager.loadClientState(), { isPrimary: false, restoreEnabled: false, snapshot: null })
assert.equal(manager.getWindowState(), undefined)
assert.equal(await manager.saveClientState({ ignored: true }), false)
})
test("failed preference and clear writes roll memory and suppression back", async (t) => {
const h = harness(t, { version: 1, restoreEnabled: true })
const manager = h.create()
await manager.saveClientState({ kept: true })
h.fail(true)
await assert.rejects(manager.setRestoreEnabled(false), /injected write failure/)
assert.deepEqual(manager.loadClientState(), { isPrimary: true, restoreEnabled: true, snapshot: { kept: true } })
assert.equal(JSON.parse(readFileSync(h.statePath, "utf8")).restoreEnabled, true)
await assert.rejects(manager.clearClientState(), /injected write failure/)
h.fail(false)
await manager.setRestoreEnabled(true)
const before = h.writes()
await manager.saveClientState({ replacement: true })
assert.equal(h.writes(), before + 1)
assert.deepEqual(manager.loadClientState().snapshot, { replacement: true })
})
test("successful clear suppresses saves, including after failed re-enable", async (t) => {
const h = harness(t, { version: 1, restoreEnabled: true })
const manager = h.create()
await manager.saveClientState({ kept: true })
await manager.clearClientState()
h.fail(true)
await assert.rejects(manager.setRestoreEnabled(true), /injected write failure/)
const before = h.writes()
assert.equal(await manager.saveClientState({ ignored: true }), true)
assert.equal(h.writes(), before)
assert.equal(manager.loadClientState().snapshot, null)
})
test("disabling restore atomically removes snapshot/window and survives restart", async (t) => {
const h = harness(t, { version: 1, restoreEnabled: true })
const manager = h.create(undefined, { pid: process.pid, runToken: "before-restart", processStartIdentity: "old-start" })
await manager.saveClientState({ kept: true })
await manager.saveWindowState({ bounds: { x: 10, y: 20, width: 1200, height: 800 }, maximized: true, fullscreen: false, zoomFactor: 1.25 })
const before = h.writes()
assert.equal(await manager.setRestoreEnabled(false), true)
assert.equal(h.writes(), before + 1)
assert.deepEqual(manager.loadClientState(), { isPrimary: true, restoreEnabled: false, snapshot: null })
assert.equal(manager.getWindowState(), undefined)
const disabled = JSON.stringify({ version: 1, restoreEnabled: false })
assert.equal(readFileSync(h.statePath, "utf8"), disabled)
await manager.drainAndReleasePrimary()
const restarted = h.create()
assert.equal(await restarted.saveWindowState({ bounds: { x: 0, y: 0, width: 800, height: 600 }, maximized: false, fullscreen: false, zoomFactor: 1 }), true)
assert.equal(readFileSync(h.statePath, "utf8"), disabled)
})
test("drain freezes mutations and waits for admitted writes", async (t) => {
const h = harness(t, { version: 1, restoreEnabled: true })
let started!: () => void
let release!: () => void
const began = new Promise<void>((resolve) => { started = resolve })
const gate = new Promise<void>((resolve) => { release = resolve })
const manager = h.create(async (path, value) => { await writeFile(path, value); started(); await gate })
const admitted = manager.saveClientState({ admitted: true })
await began
let settled = false
const drain = manager.drainAndReleasePrimary().finally(() => { settled = true })
await assert.rejects(manager.saveClientState({ late: true }), /frozen for shutdown/)
await new Promise((resolve) => setImmediate(resolve))
assert.equal(settled, false)
assert.equal(manager.isPrimary, true)
release()
await Promise.all([admitted, drain])
assert.equal(manager.isPrimary, false)
assert.deepEqual(JSON.parse(readFileSync(h.statePath, "utf8")).snapshot, { admitted: true })
})
test("an old writer cannot replace a successor after PID reuse", async (t) => {
const h = harness(t, { version: 1, restoreEnabled: true })
let started!: () => void
let release!: () => void
const began = new Promise<void>((resolve) => { started = resolve })
const gate = new Promise<void>((resolve) => { release = resolve })
const old = h.create(
async (path, value) => { await writeFile(path, value); started(); await gate },
{ pid: process.pid, runToken: "old-run", processStartIdentity: "old-start" },
)
const staleWrite = old.saveClientState({ stale: true })
await began
const oldDrain = old.drainAndReleasePrimary()
const successor = h.create()
assert.equal(successor.isPrimary, true)
await successor.saveClientState({ successor: true })
release()
await assert.rejects(staleWrite, /ownership changed before atomic replacement/)
await assert.rejects(oldDrain, /ownership changed before atomic replacement/)
assert.deepEqual(JSON.parse(readFileSync(h.statePath, "utf8")).snapshot, { successor: true })
})
test("future envelopes are preserved until a successful explicit clear", async (t) => {
const future = { version: 7, restoreEnabled: false, snapshot: { future: true }, futurePreference: "keep" }
const h = harness(t, future)
const manager = h.create(undefined, { pid: process.pid, runToken: "future-before-restart", processStartIdentity: "old-start" })
assert.deepEqual(manager.loadClientState(), { isPrimary: true, restoreEnabled: false, snapshot: null })
assert.equal(await manager.saveClientState({ ignored: true }), true)
assert.equal(await manager.setRestoreEnabled(false), false)
assert.deepEqual(JSON.parse(readFileSync(h.statePath, "utf8")), future)
await manager.drainAndReleasePrimary()
const restarted = h.create()
assert.deepEqual(JSON.parse(readFileSync(h.statePath, "utf8")), future)
assert.equal(await restarted.clearClientState(), true)
assert.deepEqual(JSON.parse(readFileSync(h.statePath, "utf8")), { version: 1, restoreEnabled: false })
assert.equal(await restarted.saveClientState({ supported: true }), true)
assert.deepEqual(JSON.parse(readFileSync(h.statePath, "utf8")).snapshot, { supported: true })
})
test("failed future-envelope clear leaves persistence blocked", async (t) => {
const future = { version: 7, future: true }
const h = harness(t, future)
let writes = 0
const manager = h.create(async () => { writes++; throw new Error("clear failed") })
await assert.rejects(manager.clearClientState(), /clear failed/)
assert.equal(await manager.setRestoreEnabled(false), false)
assert.equal(await manager.saveClientState({ ignored: true }), true)
assert.equal(writes, 1)
assert.deepEqual(JSON.parse(readFileSync(h.statePath, "utf8")), future)
})

View file

@ -0,0 +1,511 @@
import { randomUUID } from "node:crypto"
import { closeSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"
import { open, rename, rm } from "node:fs/promises"
import { dirname, join } from "node:path"
import {
electClientStateProcess,
getRunningMarkerPath,
hasLiveTauriClient,
hasErrorCode,
isProcessOwnerLockOwned,
removeProcessOwnerLockIfOwned,
type ProcessOwner,
removeRunningMarkerIfOwned,
} from "./client-state-process"
import { getProcessStartIdentity } from "./client-state-process-identity"
import {
CrossHostRegistration,
crossHostParticipants,
resolveCrossHostElectionDirectory,
resolveCrossHostStatePath,
resolveLegacyTauriDataDirectory,
type CrossHostLeaseDependencies,
} from "./client-state-cross-host"
import { normalizeNativeWindowState } from "./window-state"
const CLIENT_STATE_VERSION = 1
const CLIENT_STATE_FILENAME = "client-state.json"
const PRIMARY_LOCK_FILENAME = "client-state.primary.lock"
const REGISTRATION_LOCK_FILENAME = "client-state.registration.lock"
export const MAX_CLIENT_SNAPSHOT_BYTES = 1024 * 1024
export interface WindowBounds {
x: number
y: number
width: number
height: number
}
export interface NativeWindowState {
bounds: WindowBounds
maximized: boolean
fullscreen: boolean
zoomFactor: number
}
export interface ClientStateLoadResult {
isPrimary: boolean
restoreEnabled: boolean
snapshot: unknown | null
}
interface PersistedClientState {
version: typeof CLIENT_STATE_VERSION
restoreEnabled: boolean
snapshot?: unknown
window?: NativeWindowState
}
export type ClientStateWriter = (
temporaryPath: string,
serializedState: string,
) => Promise<void>
interface ClientStateManagerOptions {
crossHostElectionDirectory?: string
crossHostDependencies?: CrossHostLeaseDependencies
legacyTauriDataPath?: string | null
processOwner?: ProcessOwner
removeLegacyState?(path: string): void
}
async function writeClientStateTemporary(temporaryPath: string, serializedState: string): Promise<void> {
const file = await open(temporaryPath, "w", 0o600)
try {
await file.writeFile(serializedState, "utf8")
await file.sync()
} finally {
await file.close()
}
}
interface ParsedClientState {
state: PersistedClientState
unsupportedFutureEnvelope: boolean
}
function parseClientState(value: string): ParsedClientState {
const defaults: PersistedClientState = { version: CLIENT_STATE_VERSION, restoreEnabled: true }
try {
const candidate = JSON.parse(value) as Record<string, unknown>
if (candidate && typeof candidate.version === "number" && candidate.version > CLIENT_STATE_VERSION) {
return { state: { ...defaults, restoreEnabled: false }, unsupportedFutureEnvelope: true }
}
if (!candidate || candidate.version !== CLIENT_STATE_VERSION) {
return { state: defaults, unsupportedFutureEnvelope: false }
}
const state: PersistedClientState = {
version: CLIENT_STATE_VERSION,
restoreEnabled: typeof candidate.restoreEnabled === "boolean" ? candidate.restoreEnabled : true,
}
if (Object.prototype.hasOwnProperty.call(candidate, "snapshot")) {
state.snapshot = candidate.snapshot
}
const windowState = normalizeNativeWindowState(candidate.window)
if (windowState) {
state.window = windowState
}
return { state, unsupportedFutureEnvelope: false }
} catch (error) {
console.warn("[client-state] ignored invalid state file", error)
return { state: defaults, unsupportedFutureEnvelope: false }
}
}
function legacyCandidate(path: string, host: "electron" | "tauri"): { host: string; state: PersistedClientState; savedAt: number; hasSnapshot: boolean } | undefined {
try {
const candidate = JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>
if (!candidate || candidate.version !== CLIENT_STATE_VERSION) return undefined
const parsed = parseClientState(JSON.stringify(candidate)).state
delete parsed.window
const snapshot = candidate.snapshot as Record<string, unknown> | undefined
const savedAt = typeof snapshot?.savedAt === "number" && Number.isFinite(snapshot.savedAt) ? snapshot.savedAt : -1
return { host, state: parsed, savedAt, hasSnapshot: snapshot !== undefined }
} catch {
return undefined
}
}
function isFutureLegacyCandidate(path: string): boolean {
try {
const candidate = JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>
return typeof candidate?.version === "number" && candidate.version > CLIENT_STATE_VERSION
} catch {
return false
}
}
export class ClientStateManager {
private readonly userDataPath: string
private readonly statePath: string
private readonly lockPath: string
private readonly legacyTauriDataPath: string | null
private readonly owner: ProcessOwner
private state: PersistedClientState = { version: CLIENT_STATE_VERSION, restoreEnabled: true }
private writeQueue: Promise<void> = Promise.resolve()
private drainAndReleasePromise: Promise<void> | undefined
private crossHostRegistration: CrossHostRegistration | undefined
private primary = false
private persistenceSuppressed = false
private unsupportedFutureEnvelope = false
private frozen = false
private rendererAccessToken: string | undefined
constructor(
userDataPath: string,
private readonly writeState: ClientStateWriter = writeClientStateTemporary,
options?: ClientStateManagerOptions,
) {
this.owner = options?.processOwner ?? {
pid: process.pid,
runToken: randomUUID(),
processStartIdentity: getProcessStartIdentity(process.pid),
}
mkdirSync(userDataPath, { recursive: true })
this.userDataPath = userDataPath
const crossHostElectionDirectory = options?.crossHostElectionDirectory ?? resolveCrossHostElectionDirectory()
this.statePath = options?.crossHostElectionDirectory
? join(dirname(crossHostElectionDirectory), CLIENT_STATE_FILENAME)
: resolveCrossHostStatePath()
mkdirSync(dirname(this.statePath), { recursive: true })
this.lockPath = join(userDataPath, PRIMARY_LOCK_FILENAME)
const registrationLockPath = join(userDataPath, REGISTRATION_LOCK_FILENAME)
const election = electClientStateProcess(
userDataPath,
this.owner,
{ primaryLockPath: this.lockPath, registrationLockPath },
(message, error) => console.warn(`[client-state] ${message}`, error),
)
const legacyTauriDataPath = options?.legacyTauriDataPath === undefined
? (options?.crossHostElectionDirectory ? null : resolveLegacyTauriDataDirectory())
: options.legacyTauriDataPath
this.legacyTauriDataPath = legacyTauriDataPath
this.primary = election
try {
this.crossHostRegistration = CrossHostRegistration.register(
crossHostElectionDirectory,
this.owner,
() => {
if (!this.primary || !legacyTauriDataPath) return this.primary
try {
return !hasLiveTauriClient(
legacyTauriDataPath,
options?.crossHostDependencies?.pidAlive,
options?.crossHostDependencies?.processStartIdentity,
undefined,
crossHostParticipants(crossHostElectionDirectory),
)
} catch (error) {
console.warn("[client-state] failed to inspect legacy Tauri process markers; continuing as secondary", error)
return false
}
},
options?.crossHostDependencies,
)
} catch (error) {
console.warn("[client-state] failed to register cross-host ownership", error)
}
if (!this.crossHostRegistration?.isPrimary) {
if (election) removeProcessOwnerLockIfOwned(this.lockPath, this.owner)
this.primary = false
}
if (this.isPrimary) {
const legacyPaths = [
["electron", join(userDataPath, CLIENT_STATE_FILENAME)],
...(legacyTauriDataPath ? [["tauri", join(legacyTauriDataPath, CLIENT_STATE_FILENAME)] as const] : []),
] as ReadonlyArray<readonly ["electron" | "tauri", string]>
this.migrateLegacyStateIfNeeded(legacyPaths, options?.removeLegacyState)
const futureLegacyBlocked = this.unsupportedFutureEnvelope
const persisted = this.readState()
this.state = futureLegacyBlocked
? { version: CLIENT_STATE_VERSION, restoreEnabled: false }
: persisted.state
this.persistenceSuppressed = !this.state.restoreEnabled
this.unsupportedFutureEnvelope = futureLegacyBlocked || persisted.unsupportedFutureEnvelope
}
}
get isPrimary(): boolean {
if (!this.primary || !this.crossHostRegistration?.isPrimary) return false
if (!this.legacyTauriDataPath) return true
try {
return !hasLiveTauriClient(this.legacyTauriDataPath, undefined, undefined, undefined, crossHostParticipants(this.crossHostRegistration.path))
} catch (error) {
console.warn("[client-state] failed to recheck legacy Tauri process markers; ownership disabled", error)
return false
}
}
loadClientState(): ClientStateLoadResult {
if (!this.isPrimary) {
return { isPrimary: false, restoreEnabled: false, snapshot: null }
}
return {
isPrimary: true,
restoreEnabled: this.state.restoreEnabled,
snapshot: this.state.restoreEnabled ? (this.state.snapshot ?? null) : null,
}
}
getWindowState(): NativeWindowState | undefined {
return this.isPrimary && !this.unsupportedFutureEnvelope && this.state.restoreEnabled ? this.state.window : undefined
}
claimClientStateAccess(token: unknown): true {
this.validateRendererAccessTokenValue(token)
if (this.rendererAccessToken === undefined) {
this.rendererAccessToken = token
return true
}
if (this.rendererAccessToken !== token) {
throw new Error("Client state access token does not match the claimed renderer")
}
return true
}
assertRendererAccessToken(token: unknown): void {
this.validateRendererAccessTokenValue(token)
if (this.rendererAccessToken === undefined || this.rendererAccessToken !== token) {
throw new Error("Client state access has not been claimed by this renderer")
}
}
resetRendererAccessToken(): void {
this.rendererAccessToken = undefined
}
saveClientState(snapshot: unknown, rendererToken?: unknown): Promise<boolean> {
const disposition = this.getMutationDisposition()
if (disposition) return disposition
const serialized = JSON.stringify(snapshot)
if (serialized === undefined) {
throw new TypeError("Client snapshot must be JSON-serializable")
}
if (Buffer.byteLength(serialized, "utf8") > MAX_CLIENT_SNAPSHOT_BYTES) {
throw new RangeError("Client snapshot exceeds the 1 MiB limit")
}
const normalizedSnapshot = JSON.parse(serialized) as unknown
return this.mutateAndPersist((state) => {
state.snapshot = normalizedSnapshot
}, true, rendererToken)
}
setRestoreEnabled(enabled: boolean, rendererToken?: unknown): Promise<boolean> {
const disposition = this.getMutationDisposition(false)
if (disposition) return disposition
if (typeof enabled !== "boolean") {
throw new TypeError("Restore enabled must be a boolean")
}
return this.mutateAndPersist((state) => {
state.restoreEnabled = enabled
if (enabled) {
this.persistenceSuppressed = false
} else {
delete state.snapshot
delete state.window
this.persistenceSuppressed = true
}
}, false, rendererToken)
}
clearClientState(rendererToken?: unknown): Promise<boolean> {
if (!this.isPrimary) {
return Promise.resolve(false)
}
if (this.frozen) {
return Promise.reject(new Error("Client state persistence is frozen for shutdown"))
}
const clearingFutureEnvelope = this.unsupportedFutureEnvelope
return this.mutateAndPersist((state) => {
delete state.snapshot
delete state.window
this.unsupportedFutureEnvelope = false
this.persistenceSuppressed = !clearingFutureEnvelope
}, false, rendererToken)
}
saveWindowState(windowState: NativeWindowState): Promise<boolean> {
const disposition = this.getMutationDisposition()
if (disposition) return disposition
const normalized = normalizeNativeWindowState(windowState)
if (!normalized) {
return Promise.resolve(false)
}
return this.mutateAndPersist((state) => {
state.window = normalized
}, true)
}
async flush(): Promise<void> {
await this.writeQueue
}
drainAndReleasePrimary(): Promise<void> {
if (this.drainAndReleasePromise) {
return this.drainAndReleasePromise
}
this.frozen = true
this.drainAndReleasePromise = this.writeQueue.finally(() => {
this.primary = false
this.releaseOwnedProcessFiles()
})
return this.drainAndReleasePromise
}
private readState(): ParsedClientState {
try {
return parseClientState(readFileSync(this.statePath, "utf8"))
} catch (error) {
if (!hasErrorCode(error, "ENOENT")) {
console.warn("[client-state] failed to read state", error)
}
return {
state: { version: CLIENT_STATE_VERSION, restoreEnabled: true },
unsupportedFutureEnvelope: false,
}
}
}
private migrateLegacyStateIfNeeded(
paths: ReadonlyArray<readonly ["electron" | "tauri", string]>,
removeLegacyState = (path: string) => rmSync(path, { force: true }),
): void {
try {
readFileSync(this.statePath)
return
} catch (error) {
if (!hasErrorCode(error, "ENOENT")) return
}
if (paths.some(([, path]) => isFutureLegacyCandidate(path))) {
this.unsupportedFutureEnvelope = true
return
}
const winner = paths
.map(([host, path]) => legacyCandidate(path, host))
.filter((candidate): candidate is NonNullable<typeof candidate> => Boolean(candidate))
.sort((left, right) =>
Number(left.state.restoreEnabled) - Number(right.state.restoreEnabled) ||
Number(left.hasSnapshot) - Number(right.hasSnapshot) ||
right.savedAt - left.savedAt ||
right.host.localeCompare(left.host),
)[0]
if (!winner) return
const temporaryPath = join(dirname(this.statePath), `.${CLIENT_STATE_FILENAME}.${this.owner.pid}.${this.owner.runToken}.migration.tmp`)
let descriptor: number | undefined
try {
descriptor = openSync(temporaryPath, "wx", 0o600)
writeFileSync(descriptor, JSON.stringify(winner.state), "utf8")
fsyncSync(descriptor)
closeSync(descriptor)
descriptor = undefined
this.assertReplacementAllowed()
renameSync(temporaryPath, this.statePath)
for (const [, path] of paths) {
try {
removeLegacyState(path)
} catch (error) {
console.warn(`[client-state] failed to remove migrated legacy state at ${path}`, error)
}
}
} finally {
if (descriptor !== undefined) closeSync(descriptor)
rm(temporaryPath, { force: true }).catch(() => {})
}
}
private getMutationDisposition(futureEnvelopeResult = true): Promise<boolean> | undefined {
if (!this.isPrimary) {
return Promise.resolve(false)
}
if (this.frozen) {
return Promise.reject(new Error("Client state persistence is frozen for shutdown"))
}
if (this.unsupportedFutureEnvelope) {
return Promise.resolve(futureEnvelopeResult)
}
return undefined
}
private mutateAndPersist(
mutate: (state: PersistedClientState) => void,
skipWhenSuppressed = false,
rendererToken?: unknown,
): Promise<boolean> {
const operation = this.writeQueue.catch(() => {}).then(async () => {
if (rendererToken !== undefined) this.assertRendererAccessToken(rendererToken)
if (skipWhenSuppressed && this.persistenceSuppressed) {
return
}
const previousState = { ...this.state }
const previousPersistenceSuppressed = this.persistenceSuppressed
const previousUnsupportedFutureEnvelope = this.unsupportedFutureEnvelope
try {
mutate(this.state)
await this.writeAtomically(JSON.stringify(this.state), rendererToken)
} catch (error) {
this.state = previousState
this.persistenceSuppressed = previousPersistenceSuppressed
this.unsupportedFutureEnvelope = previousUnsupportedFutureEnvelope
throw error
}
})
this.writeQueue = operation
return operation.then(() => true)
}
private async writeAtomically(serializedState: string, rendererToken?: unknown): Promise<void> {
const temporaryPath = join(
dirname(this.statePath),
`.${CLIENT_STATE_FILENAME}.${this.owner.pid}.${this.owner.runToken}.tmp`,
)
try {
await this.writeState(temporaryPath, serializedState)
this.assertReplacementAllowed(rendererToken)
await rename(temporaryPath, this.statePath)
} catch (error) {
await rm(temporaryPath, { force: true }).catch(() => {})
throw error
}
}
private assertReplacementAllowed(rendererToken?: unknown): void {
if (rendererToken !== undefined) this.assertRendererAccessToken(rendererToken)
if (!this.isPrimary || !isProcessOwnerLockOwned(this.lockPath, this.owner)) {
throw new Error("Client state ownership changed before atomic replacement")
}
}
private releaseOwnedProcessFiles(): void {
const releases: Array<[string, () => void]> = [
["remove running marker", () => { removeRunningMarkerIfOwned(getRunningMarkerPath(this.userDataPath, this.owner), this.owner) }],
["release primary lock", () => { removeProcessOwnerLockIfOwned(this.lockPath, this.owner) }],
["release cross-host registration", () => { this.crossHostRegistration?.release(); this.crossHostRegistration = undefined }],
]
for (const [action, release] of releases) {
try {
release()
} catch (error) {
console.warn(`[client-state] failed to ${action}`, error)
}
}
}
private validateRendererAccessTokenValue(token: unknown): asserts token is string {
if (typeof token !== "string" || token.trim().length === 0) {
throw new TypeError("Client state access token must be a nonempty string")
}
}
}

View file

@ -41,8 +41,7 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan
ipcMain.handle("cli:restart", async () => { ipcMain.handle("cli:restart", async () => {
const devMode = process.env.NODE_ENV === "development" const devMode = process.env.NODE_ENV === "development"
await cliManager.stop() return cliManager.restart({ dev: devMode })
return cliManager.start({ dev: devMode })
}) })
ipcMain.handle("dialog:open", async (_, request: DialogOpenRequest): Promise<DialogOpenResult> => { ipcMain.handle("dialog:open", async (_, request: DialogOpenRequest): Promise<DialogOpenResult> => {

View file

@ -1,13 +1,26 @@
import { app, BrowserView, BrowserWindow, nativeImage, session, shell } from "electron" import { app, BrowserView, BrowserWindow, ipcMain, nativeImage, screen, session, shell } from "electron"
import http from "node:http" import http from "node:http"
import https from "node:https" import https from "node:https"
import { existsSync, mkdirSync, rmSync } from "fs" import { existsSync, mkdirSync, rmSync } from "fs"
import { dirname, join } from "path" import { dirname, join } from "path"
import { fileURLToPath } from "url" import { fileURLToPath } from "url"
import { createApplicationMenu } from "./menu" import { createApplicationMenu } from "./menu"
import { ClientStateManager } from "./client-state"
import { setupClientStateIPC } from "./client-state-ipc"
import { ClientStateLifecycle } from "./client-state-lifecycle"
import { ClientStateNavigationController } from "./client-state-navigation"
import { setupCliIPC } from "./ipc" import { setupCliIPC } from "./ipc"
import { configureMediaPermissionHandlers } from "./permissions" import { configureMediaPermissionHandlers, isAllowedRendererOrigin } from "./permissions"
import { resolveConfiguredRendererOrigins } from "./renderer-origin"
import { CliProcessManager } from "./process-manager" import { CliProcessManager } from "./process-manager"
import {
clampWindowBounds,
DEFAULT_WINDOW_HEIGHT,
DEFAULT_WINDOW_WIDTH,
installWindowZoomInput,
restoreWindowState,
WindowStateTracker,
} from "./window-state"
const mainFilename = fileURLToPath(import.meta.url) const mainFilename = fileURLToPath(import.meta.url)
const mainDirname = dirname(mainFilename) const mainDirname = dirname(mainFilename)
@ -82,6 +95,7 @@ function cleanupPackagedChromiumStorage() {
cleanupPackagedChromiumStorage() cleanupPackagedChromiumStorage()
const clientStateManager = new ClientStateManager(app.getPath("userData"))
const cliManager = new CliProcessManager() const cliManager = new CliProcessManager()
let mainWindow: BrowserWindow | null = null let mainWindow: BrowserWindow | null = null
let currentCliUrl: string | null = null let currentCliUrl: string | null = null
@ -89,8 +103,24 @@ let pendingCliUrl: string | null = null
let pendingBootstrapToken: string | null = null let pendingBootstrapToken: string | null = null
let showingLoadingScreen = false let showingLoadingScreen = false
let preloadingView: BrowserView | null = null let preloadingView: BrowserView | null = null
let mainNavigationController: ClientStateNavigationController | null = null
const remoteWindowOrigins = new Map<number, Set<string>>() const remoteWindowOrigins = new Map<number, Set<string>>()
const insecureWindowOrigins = new Map<number, Set<string>>() const insecureWindowOrigins = new Map<number, Set<string>>()
const clientStateLifecycle = new ClientStateLifecycle({
app,
clientStateManager,
cliManager,
getMainWindow: () => mainWindow,
getAllWindows: () => BrowserWindow.getAllWindows(),
getAllowedRendererOrigins,
isTrustedRendererOrigin: isAllowedRendererOrigin,
})
const bindClientStateWindow = setupClientStateIPC(
ipcMain,
clientStateManager,
() => mainWindow,
getAllowedRendererOrigins,
)
if (isMac) { if (isMac) {
app.commandLine.appendSwitch("disable-spell-checking") app.commandLine.appendSwitch("disable-spell-checking")
@ -151,19 +181,18 @@ function resolveLoadingFilePath() {
return join(app.getAppPath(), "dist/renderer/loading.html") return join(app.getAppPath(), "dist/renderer/loading.html")
} }
function loadLoadingScreen(window: BrowserWindow) { async function loadLoadingScreen(window: BrowserWindow): Promise<boolean> {
const target = resolveLoadingTarget() const target = resolveLoadingTarget()
const loader = try {
target.type === "url" await (target.type === "url" ? window.loadURL(target.source) : window.loadFile(target.source))
? window.loadURL(target.source) return true
: window.loadFile(target.source) } catch (error) {
loader.catch((error) => {
if (isIgnorableNavigationError(error)) { if (isIgnorableNavigationError(error)) {
return return false
} }
console.error("[cli] failed to load loading screen:", error) console.error("[cli] failed to load loading screen:", error)
}) return false
}
} }
function isIgnorableNavigationError(error: unknown): boolean { function isIgnorableNavigationError(error: unknown): boolean {
@ -183,16 +212,11 @@ function getAllowedRendererOrigins(window?: BrowserWindow | null): string[] {
origins.add(origin) origins.add(origin)
} }
} }
const rendererCandidates = [currentCliUrl, process.env.VITE_DEV_SERVER_URL, process.env.ELECTRON_RENDERER_URL] for (const origin of resolveConfiguredRendererOrigins(currentCliUrl, app.isPackaged, [
for (const candidate of rendererCandidates) { process.env.VITE_DEV_SERVER_URL,
if (!candidate) { process.env.ELECTRON_RENDERER_URL,
continue ])) {
} origins.add(origin)
try {
origins.add(new URL(candidate).origin)
} catch (error) {
console.warn("[cli] failed to parse origin for", candidate, error)
}
} }
return Array.from(origins) return Array.from(origins)
} }
@ -210,7 +234,7 @@ function shouldOpenExternally(url: string, window?: BrowserWindow | null): boole
} }
} }
function setupNavigationGuards(window: BrowserWindow) { function setupNavigationGuards(window: BrowserWindow, navigationController?: ClientStateNavigationController) {
const handleExternal = (url: string) => { const handleExternal = (url: string) => {
shell.openExternal(url).catch((error) => console.error("[cli] failed to open external URL", url, error)) shell.openExternal(url).catch((error) => console.error("[cli] failed to open external URL", url, error))
} }
@ -224,6 +248,20 @@ function setupNavigationGuards(window: BrowserWindow) {
}) })
window.webContents.on("will-navigate", (event, url) => { window.webContents.on("will-navigate", (event, url) => {
if (shouldOpenExternally(url, window)) {
event.preventDefault()
handleExternal(url)
} else if (navigationController) {
event.preventDefault()
void navigationController.navigate((target) => target.loadURL(url)).catch((error) => {
if (!isIgnorableNavigationError(error)) {
console.error("[client-state] trusted renderer navigation failed", error)
}
})
}
})
window.webContents.on("will-redirect", (event, url) => {
if (shouldOpenExternally(url, window)) { if (shouldOpenExternally(url, window)) {
event.preventDefault() event.preventDefault()
handleExternal(url) handleExternal(url)
@ -240,6 +278,21 @@ function setWindowAllowedOrigin(window: BrowserWindow, url: string) {
} }
} }
function stageWindowAllowedOrigin(window: BrowserWindow, url: string): () => void {
const previous = remoteWindowOrigins.get(window.id)
try {
const origins = new Set(previous)
origins.add(new URL(url).origin)
remoteWindowOrigins.set(window.id, origins)
} catch (error) {
console.warn("[cli] failed to stage allowed origin", url, error)
}
return () => {
if (previous) remoteWindowOrigins.set(window.id, previous)
else remoteWindowOrigins.delete(window.id)
}
}
function clearWindowAllowedOrigin(window: BrowserWindow) { function clearWindowAllowedOrigin(window: BrowserWindow) {
remoteWindowOrigins.delete(window.id) remoteWindowOrigins.delete(window.id)
} }
@ -320,10 +373,19 @@ function createWindow() {
const prefersDark = true const prefersDark = true
const backgroundColor = prefersDark ? "#1a1a1a" : "#ffffff" const backgroundColor = prefersDark ? "#1a1a1a" : "#ffffff"
const iconPath = getIconPath() const iconPath = getIconPath()
const savedWindowState = clientStateManager.getWindowState()
const restoredBounds = savedWindowState
? clampWindowBounds(
savedWindowState.bounds,
screen.getAllDisplays().map((display) => display.workArea),
)
: undefined
mainWindow = new BrowserWindow({ mainWindow = new BrowserWindow({
width: 1400, width: restoredBounds?.width ?? DEFAULT_WINDOW_WIDTH,
height: 900, height: restoredBounds?.height ?? DEFAULT_WINDOW_HEIGHT,
useContentSize: true,
...(restoredBounds ? { x: restoredBounds.x, y: restoredBounds.y } : {}),
minWidth: 800, minWidth: 800,
minHeight: 600, minHeight: 600,
backgroundColor, backgroundColor,
@ -332,14 +394,36 @@ function createWindow() {
preload: getPreloadPath(), preload: getPreloadPath(),
contextIsolation: true, contextIsolation: true,
nodeIntegration: false, nodeIntegration: false,
...(savedWindowState ? { zoomFactor: savedWindowState.zoomFactor } : {}),
spellcheck: !isMac, spellcheck: !isMac,
additionalArguments: ["--codenomad-window-context=local"], additionalArguments: ["--codenomad-window-context=local"],
}, },
}) })
const window = mainWindow const window = mainWindow
const navigationController = new ClientStateNavigationController(
window,
{
clientStateManager,
isTrustedOrigin: (url) => isAllowedRendererOrigin(url, getAllowedRendererOrigins(window)),
reportFlushError: (error) => {
console.warn("[client-state] renderer pre-navigation flush failed; continuing navigation", error)
},
},
)
mainNavigationController = navigationController
setupNavigationGuards(window) let windowStateTracker: WindowStateTracker | null = null
if (clientStateManager.isPrimary) {
restoreWindowState(window, savedWindowState, restoredBounds)
windowStateTracker = new WindowStateTracker(window, clientStateManager, savedWindowState)
}
installWindowZoomInput(window, (level) => {
if (windowStateTracker) windowStateTracker.setZoomLevel(level)
else window.webContents.setZoomLevel(level)
})
setupNavigationGuards(window, navigationController)
if (isMac) { if (isMac) {
window.webContents.session.setSpellCheckerEnabled(false) window.webContents.session.setSpellCheckerEnabled(false)
@ -348,23 +432,34 @@ function createWindow() {
showingLoadingScreen = true showingLoadingScreen = true
currentCliUrl = null currentCliUrl = null
clearWindowAllowedOrigin(window) clearWindowAllowedOrigin(window)
loadLoadingScreen(window) void loadLoadingScreen(window)
if (process.env.NODE_ENV === "development") { if (process.env.NODE_ENV === "development") {
window.webContents.openDevTools({ mode: "detach" }) window.webContents.openDevTools({ mode: "detach" })
} }
createApplicationMenu(window) createApplicationMenu(window, {
reload: () => {
void navigationController.navigate((target) => target.webContents.reload())
},
forceReload: () => {
void navigationController.navigate((target) => target.webContents.reloadIgnoringCache())
},
})
setupCliIPC(window, cliManager) setupCliIPC(window, cliManager)
bindClientStateWindow(window)
clientStateLifecycle.attachMainWindow(window, windowStateTracker)
window.on("closed", () => { window.on("closed", () => {
destroyPreloadingView() destroyPreloadingView()
clearWindowAllowedOrigin(window) clearWindowAllowedOrigin(window)
clearWindowInsecureOrigin(window) clearWindowInsecureOrigin(window)
mainWindow = null mainWindow = null
if (mainNavigationController === navigationController) mainNavigationController = null
currentCliUrl = null currentCliUrl = null
pendingCliUrl = null pendingCliUrl = null
showingLoadingScreen = false showingLoadingScreen = false
clientStateLifecycle.detachMainWindow(window)
}) })
if (pendingCliUrl) { if (pendingCliUrl) {
@ -383,11 +478,19 @@ function showLoadingScreen(force = false) {
return return
} }
destroyPreloadingView() const window = mainWindow
const wasShowingLoadingScreen = showingLoadingScreen
showingLoadingScreen = true showingLoadingScreen = true
currentCliUrl = null destroyPreloadingView()
pendingCliUrl = null pendingCliUrl = null
loadLoadingScreen(mainWindow) void mainNavigationController?.navigate(async (target) => {
if (!(await loadLoadingScreen(target))) {
showingLoadingScreen = wasShowingLoadingScreen
return
}
currentCliUrl = null
clearWindowAllowedOrigin(target)
})
} }
function isBootstrapTokenUrl(url: string): boolean { function isBootstrapTokenUrl(url: string): boolean {
@ -460,16 +563,23 @@ function finalizeCliSwap(url: string) {
return return
} }
const window = mainWindow const navigate = async (target: BrowserWindow) => {
showingLoadingScreen = false const rollbackOrigin = stageWindowAllowedOrigin(target, url)
currentCliUrl = url try {
setWindowAllowedOrigin(window, url) await target.loadURL(url)
pendingCliUrl = null } catch (error) {
window.loadURL(url).catch((error) => { rollbackOrigin()
if (isIgnorableNavigationError(error)) { throw error
return
} }
console.error("[cli] failed to load CLI view:", error) showingLoadingScreen = false
currentCliUrl = url
setWindowAllowedOrigin(target, url)
pendingCliUrl = null
}
void mainNavigationController?.navigate(navigate).then(() => {
if (cliManager.getStatus().state !== "ready") showLoadingScreen()
}).catch((error) => {
if (!isIgnorableNavigationError(error)) console.error("[cli] failed to load CLI view:", error)
}) })
} }
@ -727,13 +837,4 @@ app.whenReady().then(() => {
}) })
}) })
app.on("before-quit", async (event) => { clientStateLifecycle.registerAppEvents()
event.preventDefault()
await cliManager.stop().catch(() => {})
app.exit(0)
})
app.on("window-all-closed", () => {
// CodeNomad supports a single window; closing it should quit the app on all platforms.
app.quit()
})

View file

@ -1,6 +1,11 @@
import { Menu, BrowserWindow, MenuItemConstructorOptions } from "electron" import { Menu, BrowserWindow, MenuItemConstructorOptions } from "electron"
export function createApplicationMenu(mainWindow: BrowserWindow) { interface ApplicationMenuActions {
reload(): void
forceReload(): void
}
export function createApplicationMenu(mainWindow: BrowserWindow, actions: ApplicationMenuActions) {
const isMac = process.platform === "darwin" const isMac = process.platform === "darwin"
const template: MenuItemConstructorOptions[] = [ const template: MenuItemConstructorOptions[] = [
@ -51,8 +56,8 @@ export function createApplicationMenu(mainWindow: BrowserWindow) {
{ {
label: "View", label: "View",
submenu: [ submenu: [
{ role: "reload" as const }, { label: "Reload", accelerator: "CmdOrCtrl+R", click: actions.reload },
{ role: "forceReload" as const }, { label: "Force Reload", accelerator: "CmdOrCtrl+Shift+R", click: actions.forceReload },
{ role: "toggleDevTools" as const }, { role: "toggleDevTools" as const },
{ type: "separator" as const }, { type: "separator" as const },
{ role: "resetZoom" as const }, { role: "resetZoom" as const },

View file

@ -1,20 +1,10 @@
import { session, systemPreferences } from "electron" import { session, systemPreferences } from "electron"
import { isAllowedRendererOrigin } from "./renderer-origin"
export { isAllowedRendererOrigin } from "./renderer-origin"
const isMac = process.platform === "darwin" const isMac = process.platform === "darwin"
export function isAllowedRendererOrigin(origin: string | undefined | null, allowedOrigins: string[]): boolean {
if (!origin) {
return false
}
try {
const normalized = new URL(origin).origin
return allowedOrigins.includes(normalized)
} catch {
return false
}
}
export function configureMediaPermissionHandlers(getAllowedOrigins: () => string[]) { export function configureMediaPermissionHandlers(getAllowedOrigins: () => string[]) {
const isAudioMediaRequest = (permission: string, details?: unknown) => { const isAudioMediaRequest = (permission: string, details?: unknown) => {
if (permission !== "media") { if (permission !== "media") {

View file

@ -1,5 +1,5 @@
import { spawn, spawnSync, type ChildProcess } from "child_process" import { spawn, type ChildProcess } from "child_process"
import { app, utilityProcess, type UtilityProcess } from "electron" import { app } from "electron"
import { createRequire } from "module" import { createRequire } from "module"
import { EventEmitter } from "events" import { EventEmitter } from "events"
import { existsSync, readFileSync } from "fs" import { existsSync, readFileSync } from "fs"
@ -8,6 +8,16 @@ import path from "path"
import { fileURLToPath } from "url" import { fileURLToPath } from "url"
import { parse as parseYaml } from "yaml" import { parse as parseYaml } from "yaml"
import { ensureManagedNodeBinary } from "./managed-node" import { ensureManagedNodeBinary } from "./managed-node"
import { getProcessStartIdentityAsync } from "./client-state-process-identity"
import {
CLI_STOP_DEADLINE_MS,
captureInitialProcessTree,
captureProcessTree,
forceCapturedProcessTree,
mergeCapturedProcessTrees,
stopManagedChild,
} from "./process-stop"
import { SerializedLifecycle } from "./serialized-lifecycle"
import { buildUserShellCommand, getUserShellEnv, supportsUserShell } from "./user-shell" import { buildUserShellCommand, getUserShellEnv, supportsUserShell } from "./user-shell"
const nodeRequire = createRequire(import.meta.url) const nodeRequire = createRequire(import.meta.url)
@ -15,8 +25,9 @@ const mainFilename = fileURLToPath(import.meta.url)
const mainDirname = path.dirname(mainFilename) const mainDirname = path.dirname(mainFilename)
const BOOTSTRAP_TOKEN_PREFIX = "CODENOMAD_BOOTSTRAP_TOKEN:" const BOOTSTRAP_TOKEN_PREFIX = "CODENOMAD_BOOTSTRAP_TOKEN:"
const SERVER_SHUTDOWN_COMPLETE = "CODENOMAD_SHUTDOWN_STATUS:complete"
const SERVER_SHUTDOWN_INCOMPLETE = "CODENOMAD_SHUTDOWN_STATUS:incomplete"
const SESSION_COOKIE_NAME_PREFIX = "codenomad_session" const SESSION_COOKIE_NAME_PREFIX = "codenomad_session"
type CliState = "starting" | "ready" | "error" | "stopped" type CliState = "starting" | "ready" | "error" | "stopped"
type ListeningMode = "local" | "all" type ListeningMode = "local" | "all"
@ -45,9 +56,6 @@ interface CliEntryResolution {
nodeArgs?: string[] nodeArgs?: string[]
} }
type ManagedChild = ChildProcess | UtilityProcess
type ChildLaunchMode = "spawn" | "utility"
const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json" const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json"
function isYamlPath(filePath: string): boolean { function isYamlPath(filePath: string): boolean {
@ -127,18 +135,42 @@ export declare interface CliProcessManager {
} }
export class CliProcessManager extends EventEmitter { export class CliProcessManager extends EventEmitter {
private child?: ManagedChild private child?: ChildProcess
private childLaunchMode: ChildLaunchMode = "spawn" private childStartIdentity?: Promise<string | undefined>
private status: CliStatus = { state: "stopped" } private status: CliStatus = { state: "stopped" }
private stdoutBuffer = "" private stdoutBuffer = ""
private stderrBuffer = "" private stderrBuffer = ""
private bootstrapToken: string | null = null private bootstrapToken: string | null = null
private authCookieName = `${SESSION_COOKIE_NAME_PREFIX}_${process.pid}_${Date.now()}` private authCookieName = `${SESSION_COOKIE_NAME_PREFIX}_${process.pid}_${Date.now()}`
private requestedStop = false private requestedStop = false
private shutdownStatus: "complete" | "incomplete" | null = null
private lifecycle = new SerializedLifecycle()
async start(options: StartOptions): Promise<CliStatus> { start(options: StartOptions): Promise<CliStatus> {
return this.lifecycle.enqueue(() => this.startNow(options))
}
restart(options: StartOptions): Promise<CliStatus> {
return this.lifecycle.enqueue(async () => {
await this.stopNow()
if (this.lifecycle.stopped) throw new Error("CLI process manager is shutting down")
return this.startNow(options)
})
}
stop(): Promise<void> {
return this.lifecycle.enqueue(() => this.stopNow())
}
shutdown(): Promise<void> {
return this.lifecycle.stop(() => this.stopNow())
}
private async startNow(options: StartOptions): Promise<CliStatus> {
if (this.lifecycle.stopped) throw new Error("CLI process manager is shutting down")
if (this.child) { if (this.child) {
await this.stop() await this.stopNow()
if (this.child) throw new Error("CLI process did not exit before restart")
} }
this.stdoutBuffer = "" this.stdoutBuffer = ""
@ -146,6 +178,8 @@ export class CliProcessManager extends EventEmitter {
this.bootstrapToken = null this.bootstrapToken = null
this.authCookieName = `${SESSION_COOKIE_NAME_PREFIX}_${process.pid}_${Date.now()}` this.authCookieName = `${SESSION_COOKIE_NAME_PREFIX}_${process.pid}_${Date.now()}`
this.requestedStop = false this.requestedStop = false
this.shutdownStatus = null
this.childStartIdentity = undefined
this.updateStatus({ state: "starting", port: undefined, pid: undefined, url: undefined, error: undefined }) this.updateStatus({ state: "starting", port: undefined, pid: undefined, url: undefined, error: undefined })
const listeningMode = this.resolveListeningMode() const listeningMode = this.resolveListeningMode()
@ -153,63 +187,32 @@ export class CliProcessManager extends EventEmitter {
const args = this.buildCliArgs(options, host) const args = this.buildCliArgs(options, host)
const cliEntry = await this.resolveCliEntry(options) const cliEntry = await this.resolveCliEntry(options)
let child: ManagedChild console.info(
`[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) using ${cliEntry.runner} at ${cliEntry.entry} (host=${host})`,
)
if (this.shouldUsePackagedShellSupervisor(options)) { const env = supportsUserShell() ? getUserShellEnv() : { ...process.env }
const runtimePath = this.resolveShellNodeCommand() env.ELECTRON_RUN_AS_NODE = "1"
const entryPath = this.resolveBundledProdEntry()
const supervisorPath = this.resolveCliSupervisorPath()
const shellEnv = supportsUserShell() ? getUserShellEnv() : { ...process.env }
const shellTarget = this.buildCommand(cliEntry, args)
const shellCommand = buildUserShellCommand(`exec ${shellTarget}`)
const supervisorPayload = JSON.stringify({
command: shellCommand.command,
args: shellCommand.args,
cwd: process.cwd(),
})
console.info( const spawnDetails = supportsUserShell()
`[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) via utility supervisor using node at ${runtimePath} (host=${host})`, ? buildUserShellCommand(`ELECTRON_RUN_AS_NODE=1 exec ${this.buildCommand(cliEntry, args)}`)
) : this.buildDirectSpawn(cliEntry, args)
console.info(`[cli] utility supervisor: ${supervisorPath}`)
console.info(`[cli] shell command: ${shellCommand.command} ${shellCommand.args.join(" ")}`)
child = utilityProcess.fork(supervisorPath, [supervisorPayload], { const child = spawn(spawnDetails.command, spawnDetails.args, {
env: { ...shellEnv, ELECTRON_RUN_AS_NODE: "1" }, cwd: process.cwd(),
stdio: "pipe", stdio: ["pipe", "pipe", "pipe"],
serviceName: "CodeNomad CLI Supervisor", env,
}) shell: false,
this.childLaunchMode = "utility" detached: process.platform !== "win32",
} else { })
console.info(
`[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) using ${cliEntry.runner} at ${cliEntry.entry} (host=${host})`,
)
const env = supportsUserShell() ? getUserShellEnv() : { ...process.env } console.info(`[cli] spawn command: ${spawnDetails.command} ${spawnDetails.args.join(" ")}`)
env.ELECTRON_RUN_AS_NODE = "1" if (!child.pid) {
const spawnDetails = supportsUserShell()
? buildUserShellCommand(`ELECTRON_RUN_AS_NODE=1 exec ${this.buildCommand(cliEntry, args)}`)
: this.buildDirectSpawn(cliEntry, args)
const detached = process.platform !== "win32"
child = spawn(spawnDetails.command, spawnDetails.args, {
cwd: process.cwd(),
stdio: ["ignore", "pipe", "pipe"],
env,
shell: false,
detached,
})
console.info(`[cli] spawn command: ${spawnDetails.command} ${spawnDetails.args.join(" ")}`)
this.childLaunchMode = "spawn"
}
if (this.childLaunchMode === "spawn" && !child.pid) {
console.error("[cli] spawn failed: no pid") console.error("[cli] spawn failed: no pid")
} }
this.child = child this.child = child
this.childStartIdentity = child.pid ? getProcessStartIdentityAsync(child.pid, 1_500) : Promise.resolve(undefined)
this.updateStatus({ pid: child.pid ?? undefined }) this.updateStatus({ pid: child.pid ?? undefined })
const stdout = child.stdout as NodeJS.ReadableStream | undefined const stdout = child.stdout as NodeJS.ReadableStream | undefined
@ -223,48 +226,25 @@ export class CliProcessManager extends EventEmitter {
this.handleStream(data.toString(), "stderr") this.handleStream(data.toString(), "stderr")
}) })
if (this.childLaunchMode === "utility") { child.on("error", (error) => {
const utilityChild = child as UtilityProcess console.error("[cli] failed to start CLI:", error)
this.updateStatus({ state: "error", error: error.message })
this.emit("error", error)
})
utilityChild.on("error", (error) => { child.on("exit", (code, signal) => {
const message = this.describeUtilityProcessError(error) if (this.child !== child) return
console.error("[cli] utility supervisor failed:", error) const failed = this.status.state !== "ready"
this.updateStatus({ state: "error", error: message }) const error = failed ? this.status.error ?? `CLI exited with code ${code ?? 0}${signal ? ` (${signal})` : ""}` : undefined
this.emit("error", new Error(message)) console.info(`[cli] exit (code=${code}, signal=${signal || ""})${error ? ` error=${error}` : ""}`)
}) this.updateStatus({ state: failed ? "error" : "stopped", error })
if (failed && error) {
utilityChild.on("exit", (code) => { this.emit("error", new Error(error))
const failed = this.status.state !== "ready" }
const error = failed ? this.status.error ?? `CLI exited with code ${code ?? 0}` : undefined this.emit("exit", this.status)
console.info(`[cli] exit (code=${code ?? ""})${error ? ` error=${error}` : ""}`) this.child = undefined
this.updateStatus({ state: failed ? "error" : "stopped", error }) this.childStartIdentity = undefined
if (failed && error) { })
this.emit("error", new Error(error))
}
this.emit("exit", this.status)
this.child = undefined
})
} else {
const spawnedChild = child as ChildProcess
spawnedChild.on("error", (error) => {
console.error("[cli] failed to start CLI:", error)
this.updateStatus({ state: "error", error: error.message })
this.emit("error", error)
})
spawnedChild.on("exit", (code, signal) => {
const failed = this.status.state !== "ready"
const error = failed ? this.status.error ?? `CLI exited with code ${code ?? 0}${signal ? ` (${signal})` : ""}` : undefined
console.info(`[cli] exit (code=${code}, signal=${signal || ""})${error ? ` error=${error}` : ""}`)
this.updateStatus({ state: failed ? "error" : "stopped", error })
if (failed && error) {
this.emit("error", new Error(error))
}
this.emit("exit", this.status)
this.child = undefined
})
}
return new Promise<CliStatus>((resolve, reject) => { return new Promise<CliStatus>((resolve, reject) => {
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
@ -284,18 +264,14 @@ export class CliProcessManager extends EventEmitter {
}) })
} }
async stop(): Promise<void> { private async stopNow(): Promise<void> {
const child = this.child const child = this.child
if (!child) { if (!child) {
this.updateStatus({ state: "stopped" }) this.updateStatus({ state: "stopped" })
return return
} }
if (this.childLaunchMode === "utility") { const spawnedChild = child
return this.stopUtilityChild(child as UtilityProcess)
}
const spawnedChild = child as ChildProcess
this.requestedStop = true this.requestedStop = true
@ -308,138 +284,64 @@ export class CliProcessManager extends EventEmitter {
const isAlreadyExited = () => spawnedChild.exitCode !== null || spawnedChild.signalCode !== null const isAlreadyExited = () => spawnedChild.exitCode !== null || spawnedChild.signalCode !== null
const tryKillPosixGroup = (signal: NodeJS.Signals) => { const deadlineAt = Date.now() + CLI_STOP_DEADLINE_MS
try { const spawnedIdentity = this.childStartIdentity ?? Promise.resolve(undefined)
// Negative PID targets the process group (POSIX). const { tree: initialCapture, rootStartIdentity } = await captureInitialProcessTree(
process.kill(-pid, signal) pid,
return true process.platform,
} catch (error) { undefined,
const err = error as NodeJS.ErrnoException () => spawnedIdentity,
if (err?.code === "ESRCH") { deadlineAt,
return true )
} let processTree = rootStartIdentity
return false ? mergeCapturedProcessTrees(undefined, initialCapture, pid, rootStartIdentity)
} : initialCapture
let enforcement: Promise<boolean> | null = null
const forceProcessTree = (enforcementDeadline = deadlineAt) => {
if (enforcement) return enforcement
enforcement = (async () => {
const latest = await captureProcessTree(pid, process.platform, undefined, Math.min(1_500, enforcementDeadline - Date.now()))
processTree = mergeCapturedProcessTrees(processTree, latest, pid, rootStartIdentity)
return processTree ? forceCapturedProcessTree(processTree, undefined, undefined, process.kill, { deadlineAt: enforcementDeadline }) : false
})().finally(() => { enforcement = null })
return enforcement
} }
const tryKillSinglePid = (signal: NodeJS.Signals) => { let forceConfirmed = false
try { const enforceIncompleteCleanup = () => {
process.kill(pid, signal) void forceProcessTree().then((confirmed) => {
return true forceConfirmed = confirmed
} catch (error) { if (!confirmed) console.warn(`[cli] immediate enforcement after incomplete cleanup was not confirmed (pid=${pid})`)
const err = error as NodeJS.ErrnoException }, (error) => {
if (err?.code === "ESRCH") { console.warn(`[cli] immediate enforcement after incomplete cleanup failed (pid=${pid})`, error)
return true forceConfirmed = false
}
return false
}
}
const tryTaskkill = (force: boolean) => {
const args = ["/PID", String(pid), "/T"]
if (force) {
args.push("/F")
}
try {
const result = spawnSync("taskkill", args, { encoding: "utf8" })
const exitCode = result.status
if (exitCode === 0) {
return true
}
// If the PID is already gone, treat it as success.
const stderr = (result.stderr ?? "").toString().toLowerCase()
const stdout = (result.stdout ?? "").toString().toLowerCase()
const combined = `${stdout}\n${stderr}`
if (combined.includes("not found") || combined.includes("no running instance")) {
return true
}
return false
} catch {
return false
}
}
const sendStopSignal = (signal: NodeJS.Signals) => {
if (process.platform === "win32") {
tryTaskkill(signal === "SIGKILL")
return
}
// Prefer process-group signaling so wrapper launchers (shell/tsx) don't outlive Electron.
const groupOk = tryKillPosixGroup(signal)
if (!groupOk) {
tryKillSinglePid(signal)
}
}
return new Promise((resolve) => {
const killTimeout = setTimeout(() => {
console.warn(
`[cli] stop timed out after 30000ms; sending SIGKILL (pid=${child.pid ?? "unknown"})`,
)
sendStopSignal("SIGKILL")
}, 30000)
spawnedChild.on("exit", () => {
clearTimeout(killTimeout)
this.child = undefined
console.info("[cli] CLI process exited")
this.updateStatus({ state: "stopped" })
resolve()
}) })
}
this.once("shutdownIncomplete", enforceIncompleteCleanup)
if (isAlreadyExited()) { try {
clearTimeout(killTimeout) await stopManagedChild({
this.child = undefined child: spawnedChild,
this.updateStatus({ state: "stopped" }) isExited: isAlreadyExited,
resolve() force: async (forceDeadline) => forceConfirmed || await forceProcessTree(forceDeadline),
return isCleanupComplete: () => this.shutdownStatus === "complete",
} deadlineMs: CLI_STOP_DEADLINE_MS,
deadlineAt,
sendStopSignal("SIGTERM") forceReserveMs: 5_000,
}) warn: (message, error) => console.warn(`[cli] ${message} (pid=${pid})`, error ?? ""),
} })
} finally {
private stopUtilityChild(child: UtilityProcess): Promise<void> { this.off("shutdownIncomplete", enforceIncompleteCleanup)
this.requestedStop = true }
if (this.shutdownStatus !== "complete") {
const pid = child.pid console.warn(`[cli] CLI exited without a complete graceful-shutdown handshake (status=${this.shutdownStatus ?? "missing"})`)
if (!pid) { }
console.info("[cli] CLI process exited")
if (this.child === spawnedChild) {
this.child = undefined this.child = undefined
this.childStartIdentity = undefined
this.updateStatus({ state: "stopped" }) this.updateStatus({ state: "stopped" })
return Promise.resolve()
} }
return new Promise((resolve) => {
const killTimeout = setTimeout(() => {
console.warn(`[cli] stop timed out after 30000ms; sending SIGKILL (pid=${pid})`)
try {
process.kill(pid, "SIGKILL")
} catch {
// no-op
}
}, 30000)
child.once("exit", () => {
clearTimeout(killTimeout)
this.child = undefined
console.info("[cli] CLI process exited")
this.updateStatus({ state: "stopped" })
resolve()
})
if (child.pid === undefined) {
clearTimeout(killTimeout)
this.child = undefined
this.updateStatus({ state: "stopped" })
resolve()
return
}
child.kill()
})
} }
getStatus(): CliStatus { getStatus(): CliStatus {
@ -455,26 +357,23 @@ export class CliProcessManager extends EventEmitter {
} }
private handleTimeout() { private handleTimeout() {
if (this.child) { const timedOutChild = this.child
const pid = this.child.pid if (timedOutChild) {
if (this.childLaunchMode === "utility") { const pid = timedOutChild.pid
if (pid) { if (pid) {
try { const deadlineAt = Date.now() + 5_000
process.kill(pid, "SIGKILL") const spawnedIdentity = this.childStartIdentity ?? Promise.resolve(undefined)
} catch { void captureInitialProcessTree(pid, process.platform, undefined, () => spawnedIdentity, deadlineAt).then(async ({ tree, rootStartIdentity }) => {
// no-op const latest = await captureProcessTree(pid, process.platform, undefined, Math.min(1_500, deadlineAt - Date.now()))
const processTree = mergeCapturedProcessTrees(tree, latest, pid, rootStartIdentity)
const forced = processTree ? await forceCapturedProcessTree(processTree, undefined, undefined, process.kill, { deadlineAt }) : false
if (!forced) console.warn(`[cli] startup-timeout process tree cleanup was not confirmed (pid=${pid})`)
else if (this.child === timedOutChild) {
this.child = undefined
this.childStartIdentity = undefined
} }
} }).catch((error) => console.warn(`[cli] startup-timeout process tree cleanup failed (pid=${pid})`, error))
} else if (pid && process.platform !== "win32") {
try {
process.kill(-pid, "SIGKILL")
} catch {
;(this.child as ChildProcess).kill("SIGKILL")
}
} else {
;(this.child as ChildProcess).kill("SIGKILL")
} }
this.child = undefined
} }
this.updateStatus({ state: "error", error: "CLI did not start in time" }) this.updateStatus({ state: "error", error: "CLI did not start in time" })
this.emit("error", new Error("CLI did not start in time")) this.emit("error", new Error("CLI did not start in time"))
@ -505,6 +404,19 @@ export class CliProcessManager extends EventEmitter {
const trimmed = line.trim() const trimmed = line.trim()
if (!trimmed) continue if (!trimmed) continue
if (trimmed === SERVER_SHUTDOWN_COMPLETE) {
if (this.shutdownStatus === "incomplete") continue
this.shutdownStatus = "complete"
console.info("[cli] server confirmed graceful shutdown")
continue
}
if (trimmed === SERVER_SHUTDOWN_INCOMPLETE) {
if (this.shutdownStatus === "incomplete") continue
this.shutdownStatus = "incomplete"
console.warn("[cli] server reported incomplete cleanup; requesting final process-tree enforcement")
this.emit("shutdownIncomplete")
continue
}
if (trimmed.startsWith(BOOTSTRAP_TOKEN_PREFIX)) { if (trimmed.startsWith(BOOTSTRAP_TOKEN_PREFIX)) {
const token = trimmed.slice(BOOTSTRAP_TOKEN_PREFIX.length).trim() const token = trimmed.slice(BOOTSTRAP_TOKEN_PREFIX.length).trim()
if (token && !this.bootstrapToken) { if (token && !this.bootstrapToken) {
@ -661,57 +573,4 @@ export class CliProcessManager extends EventEmitter {
throw new Error("Unable to locate the packaged CodeNomad server entrypoint (dist/bin.js). Rebuild the desktop bundle.") throw new Error("Unable to locate the packaged CodeNomad server entrypoint (dist/bin.js). Rebuild the desktop bundle.")
} }
private shouldUsePackagedShellSupervisor(options: StartOptions): boolean {
return false
}
private resolveCliSupervisorPath(): string {
const candidates = [
path.join(process.resourcesPath, "cli-supervisor.cjs"),
path.join(mainDirname, "../resources/cli-supervisor.cjs"),
]
for (const candidate of candidates) {
if (existsSync(candidate)) {
return candidate
}
}
throw new Error("Unable to locate CodeNomad CLI supervisor script.")
}
private resolveShellNodeCommand(): string {
const configured = process.env.NODE_BINARY?.trim()
return configured && configured.length > 0 ? configured : "node"
}
private resolveBundledProdEntry(): string {
const candidates = [
path.join(process.resourcesPath, "server", "dist", "bin.js"),
path.join(mainDirname, "../resources/server/dist/bin.js"),
]
for (const candidate of candidates) {
if (existsSync(candidate)) {
return candidate
}
}
throw new Error("Unable to locate bundled CodeNomad CLI build in app resources.")
}
private describeUtilityProcessError(error: unknown): string {
if (error instanceof Error && error.message) {
return error.message
}
if (error && typeof error === "object") {
const typed = error as { type?: unknown; location?: unknown }
if (typeof typed.type === "string") {
return typeof typed.location === "string" ? `${typed.type} at ${typed.location}` : typed.type
}
}
return String(error)
}
} }

View file

@ -0,0 +1,542 @@
import assert from "node:assert/strict"
import { spawn } from "node:child_process"
import { EventEmitter, once } from "node:events"
import { registerHooks } from "node:module"
import { setTimeout as delay } from "node:timers/promises"
import test from "node:test"
import {
CLI_SHUTDOWN_COMMAND,
CLI_STOP_DEADLINE_MS,
captureProcessTree,
forceCapturedProcessTree,
mergeCapturedProcessTrees,
stopManagedChild,
} from "./process-stop"
class FakeChild extends EventEmitter {
writes: string[] = []
exited = false
stdin = {
writable: true,
destroyed: false,
end: (chunk: string, callback: (error?: Error | null) => void) => {
this.writes.push(chunk)
callback()
},
}
}
test("force success terminates stop without requiring an exit event", async () => {
const child = new FakeChild()
let forces = 0
let resolved = false
const stopped = stopManagedChild({
child,
isExited: () => child.exited,
deadlineMs: 100,
forceReserveMs: 80,
force: () => { forces++; return true },
}).then(() => { resolved = true })
assert.equal(CLI_STOP_DEADLINE_MS, 30_000)
assert.deepEqual(child.writes, [CLI_SHUTDOWN_COMMAND])
await delay(50)
assert.equal(forces, 1)
await stopped
assert.equal(resolved, true)
})
test("an absolute stop deadline includes work completed before stopManagedChild starts", async () => {
const child = new FakeChild()
const deadlineAt = Date.now() + 500
await delay(100)
const started = Date.now()
await stopManagedChild({
child,
isExited: () => false,
deadlineMs: 1_000,
deadlineAt,
forceReserveMs: 200,
force: () => true,
})
assert.ok(Date.now() - started < 800)
})
test("the hard stop deadline rejects even when force never settles", { timeout: 500 }, async () => {
const child = new FakeChild()
const started = Date.now()
await assert.rejects(stopManagedChild({
child,
isExited: () => false,
deadlineMs: 30,
forceReserveMs: 20,
force: () => new Promise<boolean>(() => {}),
}), /overall deadline/)
assert.ok(Date.now() - started < 100)
})
test("the hard deadline also bounds enforcement for an already-exited child", { timeout: 500 }, async () => {
const child = new FakeChild()
child.exited = true
await assert.rejects(stopManagedChild({
child,
isExited: () => true,
isCleanupComplete: () => false,
deadlineMs: 20,
force: () => new Promise<boolean>(() => {}),
}), /overall deadline/)
})
test("confirmed exit cancels the delayed force command", async () => {
const child = new FakeChild()
let forces = 0
const stopped = stopManagedChild({
child,
isExited: () => child.exited,
deadlineMs: 15,
force: () => { forces++; return true },
})
child.exited = true
child.emit("exit")
await stopped
await delay(30)
assert.equal(forces, 0)
})
test("unconfirmed final enforcement retries until the process exits", async () => {
const child = new FakeChild()
let forces = 0
const stopped = stopManagedChild({
child,
isExited: () => child.exited,
deadlineMs: 100,
forceReserveMs: 90,
forceRetryMs: 5,
force: () => {
forces++
if (forces < 2) return false
child.exited = true
child.emit("exit")
return true
},
})
await stopped
assert.equal(forces, 2)
})
test("exit without a complete shutdown handshake enforces the captured tree", async () => {
const child = new FakeChild()
let forces = 0
const stopped = stopManagedChild({
child,
isExited: () => child.exited,
isCleanupComplete: () => false,
force: () => { forces++; return true },
})
child.exited = true
child.emit("exit")
await stopped
assert.equal(forces, 1)
})
test("ending CLI stdin permits a real child to exit naturally", { timeout: 5_000 }, async () => {
const child = spawn(process.execPath, ["-e", `
let buffer = ""
process.stdin.on("data", (chunk) => {
buffer += chunk
if (!buffer.includes(${JSON.stringify(CLI_SHUTDOWN_COMMAND.trim())})) return
process.stdin.removeAllListeners("data")
process.stdin.pause()
setTimeout(() => { process.exitCode = 0 }, 10)
})
`], { stdio: ["pipe", "ignore", "inherit"] })
let forces = 0
await stopManagedChild({
child,
isExited: () => child.exitCode !== null || child.signalCode !== null,
deadlineMs: 1_000,
force: () => { forces++; child.kill("SIGKILL"); return true },
})
assert.equal(child.exitCode, 0)
assert.equal(forces, 0)
})
test("tree capture records immutable root and nested descendant identities", async () => {
const list = (() => ({ status: 0, stdout: "100|1|linux:boot:10\n200|100|linux:boot:20\n201|200|linux:boot:21\n999|1|linux:boot:99\n", stderr: "", pid: 1,
signal: null, output: [] }))
const tree = await captureProcessTree(100, "linux", list)
assert.deepEqual(tree, { platform: "linux", members: [
{ pid: 100, startIdentity: "linux:boot:10" },
{ pid: 200, startIdentity: "linux:boot:20" },
{ pid: 201, startIdentity: "linux:boot:21" },
] })
})
test("Windows tree capture ignores the system idle PID without rejecting the process table", async () => {
const list = (() => ({
status: 0,
error: undefined,
stdout: "0|0|win32:system\n100|0|win32:100\n101|100|win32:101\n",
stderr: "",
}))
assert.deepEqual((await captureProcessTree(100, "win32", list))?.members, [
{ pid: 100, startIdentity: "win32:100" },
{ pid: 101, startIdentity: "win32:101" },
])
})
test("a malformed descendant row invalidates the entire process snapshot", async () => {
const list = (() => ({
status: 0,
stdout: "100|0|win32:100\n101|100|win32:\n",
stderr: "",
}))
assert.equal(await captureProcessTree(100, "win32", list), undefined)
})
test("Windows verifies creation time and terminates through one native process handle", async () => {
const commands: Array<{ command: string; args: readonly string[] }> = []
let terminated = false
const tree = { platform: "win32" as const, members: [{ pid: 42, startIdentity: "win32:638800000000000000" }] }
const runner = async (command: string, args: readonly string[]) => {
commands.push({ command, args })
terminated = true
return { status: 0, stdout: "terminated\n", stderr: "" }
}
const lookup = async () => {
assert.equal(terminated, true, "identity was queried separately before the handle-bound termination")
return undefined
}
const kill = (() => { const error = new Error("gone") as NodeJS.ErrnoException; error.code = "ESRCH"; throw error }) as typeof process.kill
assert.equal(await forceCapturedProcessTree(tree, lookup, runner, kill), true)
assert.equal(commands.length, 1)
assert.equal(commands[0]!.command, "powershell.exe")
const script = commands[0]!.args.join(" ")
assert.match(script, /OpenProcess/)
assert.match(script, /GetProcessTimes/)
assert.match(script, /TerminateProcess/)
assert.match(script, /CloseHandle/)
assert.match(script, /638800000000000000/)
assert.doesNotMatch(script, /taskkill/i)
})
test("Windows native-handle termination refuses a live process with a mismatched creation time", {
skip: process.platform !== "win32",
timeout: 5_000,
}, async () => {
const tree = { platform: "win32" as const, members: [{ pid: process.pid, startIdentity: "win32:1" }] }
assert.equal(await forceCapturedProcessTree(tree), true)
assert.doesNotThrow(() => process.kill(process.pid, 0))
})
test("Windows native-handle termination accepts CIM precision for an owned process", {
skip: process.platform !== "win32",
timeout: 10_000,
}, async (t) => {
const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "ignore" })
t.after(() => { if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL") })
assert.ok(child.pid)
const exited = once(child, "exit")
const tree = await captureProcessTree(child.pid, "win32")
assert.ok(tree)
assert.equal(await forceCapturedProcessTree(tree), true)
await exited
})
test("Windows native-handle termination refuses a sub-microsecond identity mismatch", {
skip: process.platform !== "win32",
timeout: 10_000,
}, async (t) => {
const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "ignore" })
t.after(() => { if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL") })
assert.ok(child.pid)
const tree = await captureProcessTree(child.pid, "win32")
assert.ok(tree)
tree.members[0]!.startIdentity = `win32:${BigInt(tree.members[0]!.startIdentity.slice(6)) + 1n}`
assert.equal(await forceCapturedProcessTree(tree), true)
assert.doesNotThrow(() => process.kill(child.pid!, 0))
})
test("PID reuse is identity-guarded on Windows and POSIX", async () => {
for (const platform of ["win32", "linux"] as const) {
const commands: string[][] = []
const signals: number[] = []
const tree = { platform, members: [{ pid: 42, startIdentity: platform === "win32" ? "win32:1" : "old" }] }
const runTaskkill = ((_command: string, args: readonly string[]) => {
commands.push([...args])
return { status: 0, stdout: "mismatch\n", stderr: "", pid: 1, signal: null, output: [] }
})
const kill = ((pid: number) => { signals.push(pid); return true }) as typeof process.kill
assert.equal(await forceCapturedProcessTree(tree, () => "reused", runTaskkill, kill), true)
assert.equal(commands.length, platform === "win32" ? 1 : 0)
assert.deepEqual(signals, [])
}
})
test("a stale captured identity cannot authorize termination after handle-bound PID reuse", async () => {
let commands = 0
const tree = { platform: "win32" as const, members: [{ pid: 42, startIdentity: "win32:1" }] }
const runTaskkill = (() => {
commands++
return { status: 0, stdout: "mismatch\n", stderr: "", pid: 1, signal: null, output: [] }
})
assert.equal(await forceCapturedProcessTree(tree, undefined, runTaskkill, process.kill, {
revalidateIdentity: async () => "reused",
}), true)
assert.equal(commands, 1)
})
test("a successful snapshot omission still requires a liveness check", async () => {
let lookups = 0
let livenessChecks = 0
const tree = { platform: "win32" as const, members: [{ pid: 42, startIdentity: "win32:1" }] }
const runTaskkill = (() => ({ status: 0, stdout: "terminated\n", stderr: "", pid: 1, signal: null, output: [] }))
const kill = ((_pid: number, signal?: NodeJS.Signals | number) => {
if (signal === 0) livenessChecks++
return true
}) as typeof process.kill
assert.equal(await forceCapturedProcessTree(
tree,
() => { lookups++; return undefined },
runTaskkill,
kill,
), false)
assert.equal(lookups, 1)
assert.equal(livenessChecks, 1)
})
test("enforcement awaits asynchronous commands without blocking timers", async () => {
let settled = false
let timerFired = false
const tree = { platform: "win32" as const, members: [{ pid: 42, startIdentity: "win32:1" }] }
const enforcement = Promise.resolve(forceCapturedProcessTree(
tree,
async () => undefined,
(async () => {
await delay(30)
return { status: 0, stdout: "terminated\n", stderr: "", pid: 1, signal: null, output: [] }
}),
(() => { const error = new Error("gone") as NodeJS.ErrnoException; error.code = "ESRCH"; throw error }) as typeof process.kill,
)).then((value) => { settled = true; return value })
setTimeout(() => { timerFired = true }, 1)
await delay(5)
assert.equal(timerFired, true)
assert.equal(settled, false)
assert.equal(await enforcement, true)
})
test("failed initial capture retains a bounded spawn-time root identity", async () => {
const module = await import("./process-stop") as typeof import("./process-stop") & {
captureInitialProcessTree(...args: unknown[]): Promise<{ tree?: unknown; rootStartIdentity?: string }>
}
const timeouts: number[] = []
let lookupStarted = false
const result = await module.captureInitialProcessTree(
100,
"win32",
async () => {
assert.equal(lookupStarted, true)
return { status: 1, stdout: "", stderr: "" }
},
async (_pid: number, timeoutMs: number) => { lookupStarted = true; timeouts.push(timeoutMs); return "win32:100" },
Date.now() + 500,
)
assert.equal(result.rootStartIdentity, "win32:100")
assert.ok(timeouts[0]! > 0 && timeouts[0]! <= 500)
assert.deepEqual(mergeCapturedProcessTrees(undefined, {
platform: "win32",
members: [{ pid: 100, startIdentity: "win32:100" }],
}, 100, result.rootStartIdentity), {
platform: "win32",
members: [{ pid: 100, startIdentity: "win32:100" }],
})
})
test("tree enforcement stops launching commands when its deadline is spent", async () => {
const tree = { platform: "win32" as const, members: [
{ pid: 101, startIdentity: "win32:1" },
{ pid: 102, startIdentity: "win32:2" },
{ pid: 103, startIdentity: "win32:3" },
] }
const timeouts: number[] = []
let now = 0
const runTaskkill = ((_command: string, _args: readonly string[], options: { timeout?: number }) => {
timeouts.push(options.timeout ?? 0)
now += options.timeout ?? 0
return { status: 0, stdout: "terminated\n", stderr: "", pid: 1, signal: null, output: [] }
})
assert.equal(await forceCapturedProcessTree(tree, undefined, runTaskkill, process.kill, {
deadlineAt: 2_500,
now: () => now,
}), false)
assert.deepEqual(timeouts, [1_500, 1_000])
})
test("later captures preserve root ownership and add every descendant identity", async () => {
const captured = { platform: "linux" as const, members: [
{ pid: 100, startIdentity: "root" },
{ pid: 200, startIdentity: "old-child" },
] }
const latest = { platform: "linux" as const, members: [
{ pid: 100, startIdentity: "root" },
{ pid: 200, startIdentity: "reused-child" },
{ pid: 300, startIdentity: "new-child" },
] }
const merged = mergeCapturedProcessTrees(captured, latest, 100)!
assert.deepEqual(merged.members, [
{ pid: 100, startIdentity: "root" },
{ pid: 200, startIdentity: "old-child" },
{ pid: 200, startIdentity: "reused-child" },
{ pid: 300, startIdentity: "new-child" },
])
const identities = new Map([[100, "root"], [200, "reused-child"], [300, "new-child"]])
const signals: number[] = []
const kill = ((pid: number, signal?: NodeJS.Signals | number) => {
if (signal === 0) {
if (identities.has(pid)) return true
const error = new Error("gone") as NodeJS.ErrnoException
error.code = "ESRCH"
throw error
}
signals.push(pid)
identities.delete(pid)
return true
}) as typeof process.kill
assert.equal(await forceCapturedProcessTree(merged, (pid) => identities.get(pid), undefined, kill), true)
assert.deepEqual(signals, [300, 200, 100])
const survivingIdentities = new Map([[100, "root"], [200, "reused-child"], [300, "new-child"]])
assert.equal(await forceCapturedProcessTree(
merged,
(pid) => survivingIdentities.get(pid),
undefined,
(() => true) as typeof process.kill,
), false)
const reusedRoot = mergeCapturedProcessTrees(captured, {
platform: "linux",
members: [{ pid: 100, startIdentity: "reused-root" }, { pid: 400, startIdentity: "foreign-child" }],
}, 100)
assert.deepEqual(reusedRoot, captured)
const rootSignals: number[] = []
assert.equal(await forceCapturedProcessTree(
reusedRoot!,
(pid) => pid === 100 ? "reused-root" : undefined,
undefined,
((pid: number, signal?: NodeJS.Signals | number) => {
if (signal === 0) {
const error = new Error("gone") as NodeJS.ErrnoException
error.code = "ESRCH"
throw error
}
rootSignals.push(pid)
return true
}) as typeof process.kill,
), true)
assert.deepEqual(rootSignals, [])
assert.equal(mergeCapturedProcessTrees(undefined, latest, 100), undefined)
})
test("a matching late capture becomes the baseline after the initial capture fails", () => {
const latest = { platform: "linux" as const, members: [
{ pid: 100, startIdentity: "original-root" },
{ pid: 200, startIdentity: "child" },
] }
assert.deepEqual(mergeCapturedProcessTrees(undefined, latest, 100, "original-root"), latest)
assert.equal(mergeCapturedProcessTrees(undefined, latest, 100, "reused-root"), undefined)
})
test("an exited root without a trustworthy capture cannot confirm containment", async () => {
const child = new FakeChild()
child.exited = true
let tree: ReturnType<typeof mergeCapturedProcessTrees>
await assert.rejects(stopManagedChild({
child,
isExited: () => child.exited,
isCleanupComplete: () => false,
forceAttempts: 1,
force: () => {
tree = mergeCapturedProcessTrees(tree, undefined, 100, "original-root")
return tree ? forceCapturedProcessTree(tree) : false
},
}), /termination was not confirmed/)
})
test("captured descendants are forced individually in child-first order", async () => {
const signals: number[] = []
const tree = { platform: "linux" as const, members: [
{ pid: 100, startIdentity: "a" }, { pid: 200, startIdentity: "b" }, { pid: 201, startIdentity: "c" },
] }
const identities = new Map([[100, "a"], [200, "b"], [201, "c"]])
const kill = ((pid: number, signal?: NodeJS.Signals | number) => {
if (signal === 0) {
if (identities.has(pid)) return true
const error = new Error("gone") as NodeJS.ErrnoException
error.code = "ESRCH"
throw error
}
signals.push(pid)
identities.delete(pid)
return true
}) as typeof process.kill
assert.equal(await forceCapturedProcessTree(tree, (pid) => identities.get(pid), undefined, kill), true)
assert.deepEqual(signals, [201, 200, 100])
})
test("signal dispatch is not confirmation while the captured identity remains", async () => {
const tree = { platform: "linux" as const, members: [{ pid: 42, startIdentity: "owned" }] }
const kill = (() => true) as typeof process.kill
assert.equal(await forceCapturedProcessTree(tree, () => "owned", undefined, kill), false)
})
test("incomplete shutdown status remains terminal", async () => {
const hooks = registerHooks({
resolve(specifier, context, nextResolve) {
if (specifier === "electron") {
return { shortCircuit: true, url: "data:text/javascript,export const app={isPackaged:false,getAppPath(){return ''}}" }
}
return nextResolve(specifier, context)
},
})
try {
const { CliProcessManager } = await import("./process-manager")
const manager = new CliProcessManager()
let enforcements = 0
;(manager as EventEmitter).on("shutdownIncomplete", () => { enforcements++ })
;(manager as any).handleStream(
"CODENOMAD_SHUTDOWN_STATUS:incomplete\nCODENOMAD_SHUTDOWN_STATUS:complete\n",
"stdout",
)
assert.equal((manager as any).shutdownStatus, "incomplete")
assert.equal(enforcements, 1)
} finally {
hooks.deregister()
}
})

View file

@ -0,0 +1,361 @@
import { execFile } from "node:child_process"
import { getProcessStartIdentityAsync, type AsyncProcessStartIdentityLookup } from "./client-state-process-identity"
export const CLI_SHUTDOWN_COMMAND = "codenomad:shutdown\n"
export const CLI_STOP_DEADLINE_MS = 30_000
interface ExitTrackedChild {
stdin?: {
destroyed?: boolean
writable?: boolean
end(chunk: string, callback: (error?: Error | null) => void): unknown
} | null
once(event: "exit", listener: () => void): unknown
off?(event: "exit", listener: () => void): unknown
}
interface StopManagedChildOptions {
child: ExitTrackedChild
isExited(): boolean
force(deadlineAt?: number): Promise<boolean> | boolean
isCleanupComplete?(): boolean
deadlineMs?: number
deadlineAt?: number
forceReserveMs?: number
forceRetryMs?: number
forceAttempts?: number
warn?(message: string, error?: unknown): void
}
interface ProcessRow {
pid: number
parentPid: number
startIdentity: string
}
export interface CapturedProcessTree {
platform: NodeJS.Platform
members: Array<{ pid: number; startIdentity: string }>
}
interface AsyncCommandResult {
status: number | null
stdout: string
stderr: string
error?: Error
}
type AsyncCommandRunner = (
command: string,
args: readonly string[],
options: { encoding: "utf8"; timeout: number; windowsHide?: boolean; env?: NodeJS.ProcessEnv },
) => Promise<AsyncCommandResult> | AsyncCommandResult
interface ForceCapturedProcessTreeOptions {
deadlineAt?: number
now?: () => number
revalidateIdentity?: AsyncProcessStartIdentityLookup
}
export function mergeCapturedProcessTrees(
captured: CapturedProcessTree | undefined,
latest: CapturedProcessTree | undefined,
rootPid: number,
expectedRootIdentity?: string,
): CapturedProcessTree | undefined {
if (!latest || (captured && latest.platform !== captured.platform)) return captured
const capturedRoot = captured?.members.find((member) => member.pid === rootPid)
const latestRoot = latest.members.find((member) => member.pid === rootPid)
const rootIdentity = capturedRoot?.startIdentity ?? expectedRootIdentity
if (!rootIdentity || !latestRoot || rootIdentity !== latestRoot.startIdentity) return captured
if (!captured) return latest
const identityKey = (member: { pid: number; startIdentity: string }) => `${member.pid}\0${member.startIdentity}`
const members = new Map(captured.members.map((member) => [identityKey(member), member]))
for (const member of latest.members) {
members.set(identityKey(member), member)
}
return { platform: captured.platform, members: [...members.values()] }
}
function runCommand(
command: string,
args: readonly string[],
options: { encoding: "utf8"; timeout: number; windowsHide?: boolean; env?: NodeJS.ProcessEnv },
): Promise<AsyncCommandResult> {
return new Promise((resolve) => {
execFile(command, args, options, (error, stdout, stderr) => {
resolve({ status: error ? null : 0, stdout, stderr, error: error ?? undefined })
})
})
}
async function captureProcessRows(
platform: NodeJS.Platform,
runList: AsyncCommandRunner,
timeoutMs: number,
): Promise<ProcessRow[] | undefined> {
if (timeoutMs <= 0) return undefined
const result = platform === "win32"
? await runList("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command",
"Get-CimInstance Win32_Process | ForEach-Object { '{0}|{1}|win32:{2}' -f $_.ProcessId, $_.ParentProcessId, ([datetime]$_.CreationDate).ToUniversalTime().Ticks }"],
{ encoding: "utf8", timeout: timeoutMs, windowsHide: true })
: platform === "linux"
? await runList("sh", ["-c", `boot=$(cat /proc/sys/kernel/random/boot_id) || exit 1
for stat in /proc/[0-9]*/stat; do
line=$(cat "$stat" 2>/dev/null) || continue
pid=$(printf '%s\n' "$line" | cut -d' ' -f1); rest=$(printf '%s\n' "$line" | sed 's/^.*) //'); set -- $rest
ppid=$2; shift 19; printf '%s|%s|linux:%s:%s\n' "$pid" "$ppid" "$boot" "$1"
done`], { encoding: "utf8", timeout: timeoutMs })
: await runList("ps", ["-A", "-o", "pid=,ppid=,lstart="], { encoding: "utf8", timeout: timeoutMs,
env: { ...process.env, LC_ALL: "C", LANG: "C" } })
if (result.status !== 0 || result.error) return undefined
const rows: ProcessRow[] = []
for (const line of String(result.stdout ?? "").split(/\r?\n/)) {
if (!line.trim()) continue
if (platform === "darwin" ? /^0(?:\s|$)/.test(line.trim()) : /^0(?:\||$)/.test(line.trim())) continue
const fields = platform === "darwin"
? line.trim().match(/^(\d+)\s+(\d+)\s+(.+)$/)?.slice(1)
: line.trim().split("|")
if (!fields || fields.length !== 3) return undefined
const [pidText, parentPidText, rawIdentity] = fields
const pid = Number(pidText), parentPid = Number(parentPidText)
const startIdentity = platform === "darwin" ? `darwin:${rawIdentity}` : rawIdentity
const validIdentity = platform === "win32"
? /^win32:\d+$/.test(startIdentity)
: platform === "linux"
? /^linux:[^:]+:\d+$/.test(startIdentity)
: Boolean(rawIdentity.trim())
if (!Number.isInteger(pid) || pid <= 0 || !Number.isInteger(parentPid) || !validIdentity) return undefined
rows.push({ pid, parentPid, startIdentity })
}
return rows
}
function processTreeFromRows(rootPid: number, platform: NodeJS.Platform, rows: ProcessRow[]): CapturedProcessTree | undefined {
const descendants = new Set([rootPid])
let changed = true
while (changed) {
changed = false
for (const row of rows) {
if (!descendants.has(row.parentPid) || descendants.has(row.pid)) continue
descendants.add(row.pid)
changed = true
}
}
const members = rows.filter((row) => descendants.has(row.pid))
.map(({ pid, startIdentity }) => ({ pid, startIdentity }))
return members.some((member) => member.pid === rootPid) ? { platform, members } : undefined
}
export async function captureProcessTree(
rootPid: number,
platform: NodeJS.Platform = process.platform,
runList: AsyncCommandRunner = runCommand,
timeoutMs = 1_500,
): Promise<CapturedProcessTree | undefined> {
const rows = await captureProcessRows(platform, runList, timeoutMs)
return rows ? processTreeFromRows(rootPid, platform, rows) : undefined
}
export async function captureInitialProcessTree(
rootPid: number,
platform: NodeJS.Platform = process.platform,
runList: AsyncCommandRunner = runCommand,
lookup: AsyncProcessStartIdentityLookup = (pid, timeoutMs) => getProcessStartIdentityAsync(pid, timeoutMs, platform),
deadlineAt = Date.now() + 3_000,
): Promise<{ tree?: CapturedProcessTree; rootStartIdentity?: string }> {
const fallbackIdentity = Promise.resolve(lookup(rootPid, Math.min(1_500, deadlineAt - Date.now())))
const captured = await captureProcessTree(rootPid, platform, runList, Math.min(1_500, deadlineAt - Date.now()))
const rootStartIdentity = await fallbackIdentity
const tree = rootStartIdentity
? mergeCapturedProcessTrees(undefined, captured, rootPid, rootStartIdentity)
: undefined
return { tree, rootStartIdentity }
}
export async function forceCapturedProcessTree(
tree: CapturedProcessTree,
lookup?: AsyncProcessStartIdentityLookup,
runTerminate: AsyncCommandRunner = runCommand,
kill: typeof process.kill = process.kill,
options: ForceCapturedProcessTreeOptions = {},
): Promise<boolean> {
const now = options.now ?? Date.now
const remainingMs = () => options.deadlineAt === undefined ? 1_500 : options.deadlineAt - now()
const currentIdentity = options.revalidateIdentity ?? lookup
?? ((pid, timeoutMs) => getProcessStartIdentityAsync(pid, timeoutMs, tree.platform))
let confirmed = true
const isGone = (pid: number) => {
try {
kill(pid, 0)
return false
} catch (error) {
return (error as NodeJS.ErrnoException).code === "ESRCH"
}
}
for (const member of [...tree.members].reverse()) {
if (remainingMs() <= 0) return false
if (tree.platform === "win32") {
const expectedTicks = member.startIdentity.match(/^win32:(\d+)$/)?.[1]
if (!expectedTicks) {
confirmed = false
continue
}
const timeout = Math.min(1_500, remainingMs())
if (timeout <= 0) return false
const script = `$source = @'
using System;
using System.Runtime.InteropServices;
public static class CodeNomadProcessHandle {
[StructLayout(LayoutKind.Sequential)] public struct FileTime { public uint Low; public uint High; }
[DllImport("kernel32.dll", SetLastError=true)] public static extern IntPtr OpenProcess(uint access, bool inherit, uint processId);
[DllImport("kernel32.dll", SetLastError=true)] public static extern bool GetProcessTimes(IntPtr process, out FileTime creation, out FileTime exit, out FileTime kernel, out FileTime user);
[DllImport("kernel32.dll", SetLastError=true)] public static extern bool TerminateProcess(IntPtr process, uint exitCode);
[DllImport("kernel32.dll")] public static extern bool CloseHandle(IntPtr handle);
}
'@
Add-Type -TypeDefinition $source
$handle = [CodeNomadProcessHandle]::OpenProcess(0x1001, $false, ${member.pid})
if ($handle -eq [IntPtr]::Zero) { exit 3 }
try {
$creation = [CodeNomadProcessHandle+FileTime]::new()
$exit = [CodeNomadProcessHandle+FileTime]::new()
$kernel = [CodeNomadProcessHandle+FileTime]::new()
$user = [CodeNomadProcessHandle+FileTime]::new()
if (-not [CodeNomadProcessHandle]::GetProcessTimes($handle, [ref]$creation, [ref]$exit, [ref]$kernel, [ref]$user)) { exit 4 }
$fileTime = ([long]$creation.High -shl 32) -bor $creation.Low
$nativeTicks = [DateTime]::FromFileTimeUtc($fileTime).Ticks
$expectedTicks = [long]::Parse('${expectedTicks}')
$nativeTicks -= $nativeTicks % 10
if ($nativeTicks -ne $expectedTicks) { 'mismatch'; exit 0 }
if (-not [CodeNomadProcessHandle]::TerminateProcess($handle, 1)) { exit 5 }
'terminated'
} finally {
[void][CodeNomadProcessHandle]::CloseHandle($handle)
}`
const result = await runTerminate("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], {
encoding: "utf8",
timeout,
windowsHide: true,
})
const outcome = result.status === 0 ? result.stdout.trim() : ""
if (outcome === "mismatch") continue
if (outcome !== "terminated" && !isGone(member.pid)) confirmed = false
continue
}
const identity = await currentIdentity(member.pid, Math.min(1_500, remainingMs()))
if (!identity) {
if (!isGone(member.pid)) confirmed = false
continue
}
if (identity !== member.startIdentity) continue
try {
kill(member.pid, "SIGKILL")
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ESRCH") confirmed = false
}
}
if (remainingMs() <= 0) return false
for (const member of tree.members) {
if (remainingMs() <= 0) return false
const remainingIdentity = await currentIdentity(member.pid, Math.min(1_500, remainingMs()))
if (remainingIdentity === member.startIdentity) confirmed = false
else if (!remainingIdentity && !isGone(member.pid)) confirmed = false
}
return confirmed
}
export function stopManagedChild(options: StopManagedChildOptions): Promise<void> {
return new Promise((resolve, reject) => {
let settled = false
let timer: ReturnType<typeof setTimeout> | undefined
let hardTimer: ReturnType<typeof setTimeout> | undefined
let attempts = 0
const deadlineMs = options.deadlineMs ?? CLI_STOP_DEADLINE_MS
const deadlineAt = options.deadlineAt ?? Date.now() + deadlineMs
const cleanupComplete = options.isCleanupComplete ?? (() => true)
const removeListener = () => options.child.off?.("exit", onExit)
const finish = (error?: Error) => {
if (settled) return
settled = true
if (timer) clearTimeout(timer)
if (hardTimer) clearTimeout(hardTimer)
removeListener()
if (error) reject(error)
else resolve()
}
let forcing = false
const force = () => {
if (timer) clearTimeout(timer)
timer = undefined
if (settled) return
if (forcing) return
if (Date.now() >= deadlineAt) {
finish(new Error("CLI process tree termination exceeded its overall deadline"))
return
}
attempts += 1
forcing = true
void Promise.resolve().then(() => options.force(deadlineAt)).then((confirmed) => {
forcing = false
if (settled) return
if (Date.now() > deadlineAt) {
finish(new Error("CLI process tree termination exceeded its overall deadline"))
return
}
if (confirmed) {
finish()
return
}
retry()
}, (error) => {
forcing = false
options.warn?.("Failed to force CLI process tree termination", error)
retry()
})
}
const retry = () => {
const maxAttempts = options.forceAttempts ?? 3
if (attempts >= maxAttempts) {
finish(new Error(`CLI process tree termination was not confirmed after ${attempts} attempts`))
return
}
options.warn?.("CLI process tree termination was not confirmed; retrying")
const retryMs = options.forceRetryMs ?? 1_000
timer = setTimeout(force, Math.min(retryMs, Math.max(0, deadlineAt - Date.now())))
}
function onExit() {
if (cleanupComplete()) finish()
else force()
}
options.child.once("exit", onExit)
hardTimer = setTimeout(() => {
finish(new Error("CLI process tree termination exceeded its overall deadline"))
}, Math.max(0, deadlineAt - Date.now()))
if (options.isExited()) {
onExit()
return
}
const forceAt = deadlineAt - (options.forceReserveMs ?? Math.min(1_500, deadlineMs / 2))
timer = setTimeout(() => {
options.warn?.("CLI cleanup reached its final enforcement window; forcing process tree termination")
force()
}, Math.max(0, forceAt - Date.now()))
const stdin = options.child.stdin
if (!stdin || stdin.destroyed || stdin.writable === false) {
options.warn?.("CLI stdin is not writable; waiting until the force deadline")
return
}
try {
stdin.end(CLI_SHUTDOWN_COMMAND, (error) => {
if (error) options.warn?.("Failed to send the CLI graceful shutdown command; waiting until the force deadline", error)
})
} catch (error) {
options.warn?.("Failed to send the CLI graceful shutdown command; waiting until the force deadline", error)
}
})
}

View file

@ -0,0 +1,25 @@
import assert from "node:assert/strict"
import test from "node:test"
import { flushRendererClientStateBeforeShutdown, type RendererFlushWindow } from "./renderer-client-state-flush"
const window = (executeJavaScript: (source: string) => Promise<unknown>): RendererFlushWindow => ({
isDestroyed: () => false,
webContents: { isDestroyed: () => false, getURL: () => "http://127.0.0.1:3000/app", executeJavaScript },
})
test("renderer flush enforces primary/trusted access and invokes the registered callback", async () => {
let calls = 0
let source = ""
const target = window(async (value) => { calls++; source = value })
assert.equal(await flushRendererClientStateBeforeShutdown(target, false, () => true), "not-primary")
assert.equal(await flushRendererClientStateBeforeShutdown(target, true, () => false), "untrusted-origin")
assert.equal(calls, 0)
assert.equal(await flushRendererClientStateBeforeShutdown(target, true, () => true), "flushed")
assert.equal(calls, 1)
assert.match(source, /__CODENOMAD_FLUSH_CLIENT_STATE_BEFORE_NATIVE_SHUTDOWN__/)
assert.match(source, /http:\/\/127\.0\.0\.1:3000/)
})
test("renderer flush rejects after its bounded timeout", async () => {
await assert.rejects(flushRendererClientStateBeforeShutdown(window(() => new Promise(() => {})), true, () => true, 10), /timed out after 10ms/)
})

View file

@ -0,0 +1,61 @@
export const RENDERER_CLIENT_STATE_FLUSH_TIMEOUT_MS = 1_000
const RENDERER_CLIENT_STATE_FLUSH_CALLBACK = "__CODENOMAD_FLUSH_CLIENT_STATE_BEFORE_NATIVE_SHUTDOWN__"
interface RendererFlushWebContents {
isDestroyed(): boolean
getURL(): string
executeJavaScript(source: string, userGesture?: boolean): Promise<unknown>
}
export interface RendererFlushWindow {
isDestroyed(): boolean
webContents: RendererFlushWebContents
}
export type RendererFlushResult = "flushed" | "not-primary" | "window-unavailable" | "untrusted-origin"
function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(
() => reject(new Error(`Renderer client-state flush timed out after ${timeoutMs}ms`)),
timeoutMs,
)
promise.then(
(value) => {
clearTimeout(timer)
resolve(value)
},
(error) => {
clearTimeout(timer)
reject(error)
},
)
})
}
export async function flushRendererClientStateBeforeShutdown(
window: RendererFlushWindow | null,
isPrimary: boolean,
isTrustedOrigin: (url: string) => boolean,
timeoutMs = RENDERER_CLIENT_STATE_FLUSH_TIMEOUT_MS,
): Promise<RendererFlushResult> {
if (!isPrimary) return "not-primary"
if (!window || window.isDestroyed() || window.webContents.isDestroyed()) return "window-unavailable"
const currentUrl = window.webContents.getURL()
if (!isTrustedOrigin(currentUrl)) return "untrusted-origin"
const callbackName = JSON.stringify(RENDERER_CLIENT_STATE_FLUSH_CALLBACK)
const expectedOrigin = JSON.stringify(new URL(currentUrl).origin)
await withTimeout(
window.webContents.executeJavaScript(`(() => {
if (window.location.origin !== ${expectedOrigin}) throw new Error("Renderer origin changed before client-state flush");
const flush = window[${callbackName}];
if (typeof flush !== "function") throw new Error("Renderer client-state flush callback is unavailable");
return flush();
})()`),
timeoutMs,
)
return "flushed"
}

View file

@ -0,0 +1,25 @@
import assert from "node:assert/strict"
import test from "node:test"
import { resolveConfiguredRendererOrigins } from "./renderer-origin"
test("packaged renderer origins exclude development server environment URLs", () => {
assert.deepEqual(
resolveConfiguredRendererOrigins(
"https://127.0.0.1:43123/workspace",
true,
["http://localhost:3000/app", "http://127.0.0.1:5173/loading.html"],
),
["https://127.0.0.1:43123"],
)
})
test("development renderer origins include configured development servers", () => {
assert.deepEqual(
resolveConfiguredRendererOrigins(
"http://127.0.0.1:43123/workspace",
false,
["http://localhost:3000/app", "http://localhost:3000/loading.html"],
),
["http://127.0.0.1:43123", "http://localhost:3000"],
)
})

View file

@ -0,0 +1,24 @@
export function isAllowedRendererOrigin(origin: string | undefined | null, allowedOrigins: string[]): boolean {
if (!origin) return false
try {
return allowedOrigins.includes(new URL(origin).origin)
} catch {
return false
}
}
export function resolveConfiguredRendererOrigins(
currentCliUrl: string | null,
isPackaged: boolean,
devCandidates: Array<string | undefined>,
): string[] {
const candidates = isPackaged ? [currentCliUrl] : [currentCliUrl, ...devCandidates]
const origins = new Set<string>()
for (const candidate of candidates) {
if (!candidate) continue
try {
origins.add(new URL(candidate).origin)
} catch {}
}
return [...origins]
}

View file

@ -0,0 +1,38 @@
import assert from "node:assert/strict"
import test from "node:test"
import { SerializedLifecycle } from "./serialized-lifecycle"
test("serializes operations and exposes shutdown before queued work resumes", async () => {
const lifecycle = new SerializedLifecycle()
let release!: () => void
const gate = new Promise<void>((resolve) => { release = resolve })
let active = 0, maximum = 0
const first = lifecycle.enqueue(async () => {
active += 1; maximum = Math.max(maximum, active)
await gate
active -= 1
if (lifecycle.stopped) throw new Error("stopped")
})
const second = lifecycle.enqueue(async () => {
active += 1; maximum = Math.max(maximum, active); active -= 1
})
const shutdown = lifecycle.stop(async () => {})
release()
await assert.rejects(first, /stopped/)
await second
await shutdown
assert.equal(maximum, 1)
})
test("failed shutdown reopens the lifecycle before queued retries run", async () => {
const lifecycle = new SerializedLifecycle()
const shutdown = lifecycle.stop(async () => { throw new Error("cleanup unconfirmed") })
const retry = lifecycle.enqueue(async () => {
assert.equal(lifecycle.stopped, false)
return "restarted"
})
await assert.rejects(shutdown, /cleanup unconfirmed/)
assert.equal(await retry, "restarted")
assert.equal(lifecycle.stopped, false)
})

View file

@ -0,0 +1,22 @@
export class SerializedLifecycle {
private tail: Promise<void> = Promise.resolve()
stopped = false
enqueue<T>(operation: () => Promise<T>): Promise<T> {
const queued = this.tail.catch(() => {}).then(operation)
this.tail = queued.then(() => {}, () => {})
return queued
}
stop<T>(operation: () => Promise<T>): Promise<T> {
this.stopped = true
return this.enqueue(async () => {
try {
return await operation()
} catch (error) {
this.stopped = false
throw error
}
})
}
}

View file

@ -0,0 +1,110 @@
import assert from "node:assert/strict"
import test from "node:test"
import { clampWindowBounds, installWindowZoomInput, normalizeNativeWindowState, normalizeZoomFactor, restoreWindowState, WindowStateTracker } from "./window-state"
import type { BrowserWindow } from "electron"
import type { ClientStateManager } from "./client-state"
const primaryDisplay = { x: 0, y: 0, width: 1920, height: 1080 }
test("normalizes persisted window state", () => {
assert.equal(normalizeNativeWindowState({ bounds: { x: 0, y: 0, width: Number.NaN, height: 900 }, maximized: false, fullscreen: false, zoomFactor: 1 }), undefined)
assert.deepEqual(clampWindowBounds({ x: 4000, y: 2000, width: 1400, height: 900 }, [primaryDisplay]), { x: 520, y: 180, width: 1400, height: 900 })
assert.deepEqual(
clampWindowBounds({ x: -2000, y: 100, width: 3000, height: 300 }, [{ x: -1280, y: 0, width: 1280, height: 1024 }, primaryDisplay]),
{ x: -1280, y: 100, width: 1280, height: 600 },
)
})
test("normalizes unsafe zoom factors", () => {
assert.equal(normalizeZoomFactor(Number.POSITIVE_INFINITY), 1)
assert.equal(normalizeZoomFactor(0.01), 0.25)
assert.equal(normalizeZoomFactor(9), 5)
})
test("restores shared outer position and content size", () => {
const calls: unknown[] = []
const window = {
setPosition: (x: number, y: number) => calls.push(["position", x, y]),
setContentSize: (width: number, height: number) => calls.push(["content", width, height]),
maximize: () => undefined,
setFullScreen: () => undefined,
webContents: { setZoomFactor: () => undefined },
} as unknown as BrowserWindow
const bounds = { x: 10, y: 20, width: 1200, height: 800 }
restoreWindowState(window, { bounds, maximized: false, fullscreen: false, zoomFactor: 1 }, bounds)
assert.deepEqual(calls, [["position", 10, 20], ["content", 1200, 800]])
})
test("flush captures the current native zoom", async () => {
let zoomLevel = -0.5
const window = {
isDestroyed: () => false,
on: () => undefined,
getPosition: () => [0, 0],
getContentSize: () => [1200, 800],
isMaximized: () => false,
isFullScreen: () => false,
webContents: {
isDestroyed: () => false,
on: () => undefined,
setZoomLevel: (level: number) => { zoomLevel = level },
getZoomLevel: () => zoomLevel,
setZoomFactor: (factor: number) => { zoomLevel = Math.log(factor) / Math.log(1.2) },
getZoomFactor: () => 1.2 ** zoomLevel,
},
} as unknown as BrowserWindow
const savedZoomFactors: number[] = []
const manager = {
saveWindowState: async (state: { zoomFactor: number }) => { savedZoomFactors.push(state.zoomFactor); return true },
flush: async () => undefined,
} as unknown as ClientStateManager
const tracker = new WindowStateTracker(window, manager, { bounds: { x: 0, y: 0, width: 1200, height: 800 }, maximized: false, fullscreen: false, zoomFactor: 1 })
await tracker.flush()
assert.ok(Math.abs(savedZoomFactors.at(-1)! - (1.2 ** -0.5)) < 0.000001)
})
test("Electron keyboard and wheel zoom input is applied explicitly", () => {
const events = new Map<string, (...args: any[]) => void>()
let zoomLevel = 0
const prevented: string[] = []
const window = {
webContents: {
on: (name: string, handler: (...args: any[]) => void) => events.set(name, handler),
getZoomLevel: () => zoomLevel,
},
} as unknown as BrowserWindow
installWindowZoomInput(window, (level) => { zoomLevel = level })
events.get("before-input-event")?.({ preventDefault: () => prevented.push("keyboard") }, {
type: "keyDown", control: true, meta: false, alt: false, key: "=",
})
assert.equal(zoomLevel, 0.5)
events.get("zoom-changed")?.({ preventDefault: () => prevented.push("wheel") }, "out")
assert.equal(zoomLevel, 0)
assert.deepEqual(prevented, ["keyboard", "wheel"])
})
test("native menu zoom survives cross-origin navigation", () => {
const events = new Map<string, (...args: any[]) => void>()
let zoomLevel = -0.5
const window = {
isDestroyed: () => false,
on: () => undefined,
webContents: {
isDestroyed: () => false,
on: (name: string, handler: (...args: any[]) => void) => events.set(name, handler),
getZoomFactor: () => 1.2 ** zoomLevel,
setZoomFactor: (factor: number) => { zoomLevel = Math.log(factor) / Math.log(1.2) },
},
} as unknown as BrowserWindow
const manager = { flush: async () => undefined } as unknown as ClientStateManager
new WindowStateTracker(window, manager, {
bounds: { x: 0, y: 0, width: 1200, height: 800 }, maximized: false, fullscreen: false, zoomFactor: 1,
})
events.get("did-start-navigation")?.({}, "http://next.test", false, true)
zoomLevel = 0
events.get("did-finish-load")?.()
assert.ok(Math.abs(zoomLevel - (-0.5)) < 0.000001)
})

View file

@ -0,0 +1,252 @@
import type { BrowserWindow } from "electron"
import type { ClientStateManager, NativeWindowState, WindowBounds } from "./client-state"
export const DEFAULT_WINDOW_WIDTH = 1400
export const DEFAULT_WINDOW_HEIGHT = 900
const MIN_WINDOW_WIDTH = 800
const MIN_WINDOW_HEIGHT = 600
const MIN_ZOOM_FACTOR = 0.25
const MAX_ZOOM_FACTOR = 5
const SAVE_DEBOUNCE_MS = 250
export interface DisplayWorkArea {
x: number
y: number
width: number
height: number
}
function isFiniteNumber(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value)
}
function clamp(value: number, minimum: number, maximum: number): number {
return Math.min(Math.max(value, minimum), maximum)
}
function intersectionArea(bounds: WindowBounds, area: DisplayWorkArea): number {
const width = Math.max(0, Math.min(bounds.x + bounds.width, area.x + area.width) - Math.max(bounds.x, area.x))
const height = Math.max(0, Math.min(bounds.y + bounds.height, area.y + area.height) - Math.max(bounds.y, area.y))
return width * height
}
function centerDistanceSquared(bounds: WindowBounds, area: DisplayWorkArea): number {
const x = bounds.x + bounds.width / 2 - (area.x + area.width / 2)
const y = bounds.y + bounds.height / 2 - (area.y + area.height / 2)
return x * x + y * y
}
export function normalizeZoomFactor(value: unknown): number {
if (!isFiniteNumber(value) || value <= 0) {
return 1
}
return clamp(value, MIN_ZOOM_FACTOR, MAX_ZOOM_FACTOR)
}
export function normalizeNativeWindowState(value: unknown): NativeWindowState | undefined {
if (!value || typeof value !== "object") {
return undefined
}
const candidate = value as Partial<NativeWindowState>
const bounds = candidate.bounds
if (
!bounds ||
!isFiniteNumber(bounds.x) ||
!isFiniteNumber(bounds.y) ||
!isFiniteNumber(bounds.width) ||
!isFiniteNumber(bounds.height) ||
bounds.width <= 0 ||
bounds.height <= 0
) {
return undefined
}
return {
bounds: {
x: Math.round(bounds.x),
y: Math.round(bounds.y),
width: Math.round(bounds.width),
height: Math.round(bounds.height),
},
maximized: candidate.maximized === true,
fullscreen: candidate.fullscreen === true,
zoomFactor: normalizeZoomFactor(candidate.zoomFactor),
}
}
export function clampWindowBounds(bounds: WindowBounds, displays: DisplayWorkArea[]): WindowBounds | undefined {
const normalized = normalizeNativeWindowState({ bounds, maximized: false, fullscreen: false, zoomFactor: 1 })?.bounds
const usableDisplays = displays.filter(
(area) =>
isFiniteNumber(area.x) &&
isFiniteNumber(area.y) &&
isFiniteNumber(area.width) &&
isFiniteNumber(area.height) &&
area.width > 0 &&
area.height > 0,
)
if (!normalized || usableDisplays.length === 0) {
return undefined
}
const display = usableDisplays.reduce((best, area) => {
const bestIntersection = intersectionArea(normalized, best)
const areaIntersection = intersectionArea(normalized, area)
if (areaIntersection !== bestIntersection) {
return areaIntersection > bestIntersection ? area : best
}
return centerDistanceSquared(normalized, area) < centerDistanceSquared(normalized, best) ? area : best
})
const maximumWidth = Math.max(1, Math.floor(display.width))
const maximumHeight = Math.max(1, Math.floor(display.height))
const minimumWidth = Math.min(MIN_WINDOW_WIDTH, maximumWidth)
const minimumHeight = Math.min(MIN_WINDOW_HEIGHT, maximumHeight)
const width = clamp(normalized.width, minimumWidth, maximumWidth)
const height = clamp(normalized.height, minimumHeight, maximumHeight)
const x = clamp(normalized.x, display.x, display.x + maximumWidth - width)
const y = clamp(normalized.y, display.y, display.y + maximumHeight - height)
return { x, y, width, height }
}
export function restoreWindowState(window: BrowserWindow, state: NativeWindowState | undefined, bounds: WindowBounds | undefined) {
if (!state) {
return
}
if (bounds) {
window.setPosition(bounds.x, bounds.y)
window.setContentSize(bounds.width, bounds.height)
}
window.webContents.setZoomFactor(normalizeZoomFactor(state.zoomFactor))
if (state.maximized) {
window.maximize()
}
if (state.fullscreen) {
window.setFullScreen(true)
}
}
export function installWindowZoomInput(window: BrowserWindow, setZoomLevel: (level: number) => void): void {
const changeZoom = (delta: number) => setZoomLevel(window.webContents.getZoomLevel() + delta)
window.webContents.on("before-input-event", (event, input) => {
if (input.type !== "keyDown" || (!input.control && !input.meta) || input.alt) return
if (input.key === "+" || input.key === "=") {
event.preventDefault()
changeZoom(0.5)
} else if (input.key === "-") {
event.preventDefault()
changeZoom(-0.5)
} else if (input.key === "0") {
event.preventDefault()
setZoomLevel(0)
}
})
window.webContents.on("zoom-changed", (event, direction) => {
event.preventDefault()
changeZoom(direction === "in" ? 0.5 : -0.5)
})
}
export class WindowStateTracker {
private saveTimer: ReturnType<typeof setTimeout> | undefined
private desiredZoomFactor: number
private normalBounds: WindowBounds
constructor(
private readonly window: BrowserWindow,
private readonly clientState: ClientStateManager,
initialState?: NativeWindowState,
) {
this.desiredZoomFactor = normalizeZoomFactor(initialState?.zoomFactor)
const [x, y] = typeof window.getPosition === "function" ? window.getPosition() : [0, 0]
const [width, height] = typeof window.getContentSize === "function"
? window.getContentSize()
: [DEFAULT_WINDOW_WIDTH, DEFAULT_WINDOW_HEIGHT]
this.normalBounds = initialState?.bounds ?? { x, y, width, height }
for (const event of ["move", "resize"]) {
window.on(event as "move", () => {
if (!window.isMaximized() && !window.isFullScreen()) this.captureNormalBounds()
this.scheduleSave()
})
}
for (const event of ["maximize", "unmaximize", "enter-full-screen", "leave-full-screen"]) {
window.on(event as "maximize", () => this.scheduleSave())
}
window.webContents.on("zoom-changed", () => this.scheduleSave())
window.webContents.on("did-start-navigation", (_event, _url, _isInPlace, isMainFrame) => {
if (isMainFrame && !window.webContents.isDestroyed()) {
this.desiredZoomFactor = normalizeZoomFactor(window.webContents.getZoomFactor())
}
})
window.webContents.on("did-finish-load", () => {
if (!window.webContents.isDestroyed()) {
window.webContents.setZoomFactor(this.desiredZoomFactor)
}
})
window.on("closed", () => this.clearTimer())
}
async flush(): Promise<void> {
this.clearTimer()
if (!this.window.isDestroyed()) {
await this.captureAndQueue()
}
await this.clientState.flush()
}
setZoomLevel(level: number): void {
if (this.window.isDestroyed() || this.window.webContents.isDestroyed()) return
this.window.webContents.setZoomLevel(level)
this.desiredZoomFactor = normalizeZoomFactor(this.window.webContents.getZoomFactor())
this.scheduleSave()
}
private scheduleSave() {
this.clearTimer()
this.saveTimer = setTimeout(() => {
this.saveTimer = undefined
void this.saveNow()
}, SAVE_DEBOUNCE_MS)
}
private clearTimer() {
if (this.saveTimer) {
clearTimeout(this.saveTimer)
this.saveTimer = undefined
}
}
private async saveNow() {
try {
await this.captureAndQueue()
} catch (error) {
console.warn("[client-state] failed to save window state", error)
}
}
private captureAndQueue(): Promise<boolean> {
if (this.window.isDestroyed() || this.window.webContents.isDestroyed()) {
return Promise.resolve(false)
}
this.desiredZoomFactor = normalizeZoomFactor(this.window.webContents.getZoomFactor())
if (!this.window.isMaximized() && !this.window.isFullScreen()) this.captureNormalBounds()
return this.clientState.saveWindowState({
bounds: this.normalBounds,
maximized: this.window.isMaximized(),
fullscreen: this.window.isFullScreen(),
zoomFactor: this.desiredZoomFactor,
})
}
private captureNormalBounds(): void {
const [x, y] = this.window.getPosition()
const [width, height] = this.window.getContentSize()
this.normalBounds = { x, y, width, height }
}
}

View file

@ -37,6 +37,12 @@ const localElectronAPI = {
setWakeLock: (enabled) => ipcRenderer.invoke("power:setWakeLock", Boolean(enabled)), setWakeLock: (enabled) => ipcRenderer.invoke("power:setWakeLock", Boolean(enabled)),
showNotification: (payload) => ipcRenderer.invoke("notifications:show", payload), showNotification: (payload) => ipcRenderer.invoke("notifications:show", payload),
openRemoteWindow: (payload) => ipcRenderer.invoke("remote:openWindow", payload), openRemoteWindow: (payload) => ipcRenderer.invoke("remote:openWindow", payload),
claimClientStateAccess: (token) => ipcRenderer.invoke("client-state:claimAccess", token),
loadClientState: (token) => ipcRenderer.invoke("client-state:load", token),
saveClientState: (token, snapshot) => ipcRenderer.invoke("client-state:save", token, snapshot),
setClientStateRestoreEnabled: (token, enabled) =>
ipcRenderer.invoke("client-state:setRestoreEnabled", token, Boolean(enabled)),
clearClientState: (token) => ipcRenderer.invoke("client-state:clear", token),
} }
const remoteElectronAPI = { const remoteElectronAPI = {

View file

@ -1,131 +0,0 @@
#!/usr/bin/env node
const { spawn } = require("child_process")
const SHUTDOWN_GRACE_MS = 30_000
let child = null
let shutdownTimer = null
function log(message, error) {
if (error) {
console.error(`[cli-supervisor] ${message}`, error)
return
}
console.log(`[cli-supervisor] ${message}`)
}
function clearShutdownTimer() {
if (shutdownTimer) {
clearTimeout(shutdownTimer)
shutdownTimer = null
}
}
function forwardStream(stream, target) {
if (!stream) return
stream.on("data", (chunk) => {
target.write(chunk)
})
}
function terminateChild(force) {
if (!child || child.exitCode !== null || child.signalCode !== null) {
return
}
try {
child.kill(force ? "SIGKILL" : "SIGTERM")
} catch {
// no-op
}
}
function requestShutdown(force = false) {
if (!child) {
process.exit(force ? 1 : 0)
return
}
terminateChild(force)
if (force) {
process.exit(1)
return
}
clearShutdownTimer()
shutdownTimer = setTimeout(() => {
log(`shutdown timed out after ${SHUTDOWN_GRACE_MS}ms; forcing child termination`)
terminateChild(true)
}, SHUTDOWN_GRACE_MS)
shutdownTimer.unref()
}
function installShutdownHandlers() {
process.on("SIGTERM", () => requestShutdown(false))
process.on("SIGINT", () => requestShutdown(false))
process.on("disconnect", () => requestShutdown(false))
process.on("uncaughtException", (error) => {
log("uncaught exception", error)
requestShutdown(true)
})
process.on("unhandledRejection", (error) => {
log("unhandled rejection", error)
requestShutdown(true)
})
}
function parsePayload() {
const raw = process.argv[2]
if (!raw) {
throw new Error("Supervisor payload is required")
}
const parsed = JSON.parse(raw)
if (!parsed || typeof parsed !== "object") {
throw new Error("Supervisor payload must be an object")
}
if (typeof parsed.command !== "string" || parsed.command.trim().length === 0) {
throw new Error("Supervisor payload command is required")
}
if (!Array.isArray(parsed.args) || !parsed.args.every((value) => typeof value === "string")) {
throw new Error("Supervisor payload args must be a string array")
}
return {
command: parsed.command,
args: parsed.args,
cwd: typeof parsed.cwd === "string" && parsed.cwd.trim().length > 0 ? parsed.cwd : process.cwd(),
}
}
function main() {
installShutdownHandlers()
const payload = parsePayload()
log(`launching shell command: ${payload.command} ${payload.args.join(" ")}`)
child = spawn(payload.command, payload.args, {
cwd: payload.cwd,
env: process.env,
shell: false,
stdio: ["ignore", "pipe", "pipe"],
})
forwardStream(child.stdout, process.stdout)
forwardStream(child.stderr, process.stderr)
child.on("error", (error) => {
log("failed to spawn shell command", error)
process.exit(1)
})
child.on("exit", (code, signal) => {
clearShutdownTimer()
log(`child exited code=${code ?? ""} signal=${signal ?? ""}`)
process.exitCode = typeof code === "number" ? code : signal ? 1 : 0
process.exit()
})
}
main()

View file

@ -24,6 +24,7 @@
"prebuild": "npm run prepare:resources", "prebuild": "npm run prepare:resources",
"build": "electron-vite build", "build": "electron-vite build",
"typecheck": "tsc --noEmit -p tsconfig.json", "typecheck": "tsc --noEmit -p tsconfig.json",
"test:native": "node --import tsx --test electron/main/client-state-cross-host.test.ts electron/main/client-state-process.test.ts electron/main/client-state.test.ts electron/main/client-state-ipc.test.ts electron/main/client-state-navigation.test.ts electron/main/client-state-lifecycle.test.ts electron/main/process-stop.test.ts electron/main/renderer-client-state-flush.test.ts electron/main/renderer-origin.test.ts electron/main/serialized-lifecycle.test.ts electron/main/window-state.test.ts",
"preview": "electron-vite preview", "preview": "electron-vite preview",
"build:binaries": "node scripts/build.js", "build:binaries": "node scripts/build.js",
"build:mac": "node scripts/build.js mac", "build:mac": "node scripts/build.js mac",
@ -131,12 +132,10 @@
"createStartMenuShortcut": true "createStartMenuShortcut": true
}, },
"linux": { "linux": {
"executableName": "CodeNomad",
"target": [ "target": [
{ {
"target": "zip" "target": "tar.gz"
},
{
"target": "AppImage"
} }
], ],
"artifactName": "CodeNomad-Electron-linux-${arch}-${version}.${ext}", "artifactName": "CodeNomad-Electron-linux-${arch}-${version}.${ext}",

View file

@ -217,3 +217,15 @@ When running as a server CodeNomad can also be installed as a PWA from any suppo
- **Config**: `~/.config/codenomad/config.json` - **Config**: `~/.config/codenomad/config.json`
- **Instance Data**: `~/.config/codenomad/instances` (chat history, etc.) - **Instance Data**: `~/.config/codenomad/instances` (chat history, etc.)
### Provider Plan Usage
The Status panel automatically displays quota information for the provider used by the active session. CodeNomad reads existing OpenCode credentials and never returns provider secrets through its API.
Some optional usage integrations require credentials that OpenCode does not expose. They can be enabled without UI configuration through these environment variables:
- Google token refresh: `GOOGLE_OAUTH_CLIENT_ID` and `GOOGLE_OAUTH_CLIENT_SECRET`
- Antigravity token refresh: `ANTIGRAVITY_OAUTH_CLIENT_ID` and `ANTIGRAVITY_OAUTH_CLIENT_SECRET`
- Cursor: `CURSOR_ACCESS_TOKEN` or `CURSOR_TOKEN`, with optional `CURSOR_REFRESH_TOKEN`
- Ollama Cloud: `OLLAMA_CLOUD_COOKIE`
- OpenCode Go: `OPENCODE_GO_WORKSPACE_ID` and `OPENCODE_GO_AUTH_COOKIE`

View file

@ -0,0 +1,26 @@
# Third-Party Notices
The provider usage adapters are derived from OpenChamber:
https://github.com/openchamber/openchamber
MIT License
Copyright (c) 2025 Bohdan Triapitsyn
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -28,6 +28,7 @@
"@fastify/cors": "^8.5.0", "@fastify/cors": "^8.5.0",
"@fastify/reply-from": "^9.8.0", "@fastify/reply-from": "^9.8.0",
"@fastify/static": "^7.0.4", "@fastify/static": "^7.0.4",
"@opencode-ai/sdk": "^1.17.8",
"commander": "^12.1.0", "commander": "^12.1.0",
"fastify": "^4.28.1", "fastify": "^4.28.1",
"fuzzysort": "^2.0.4", "fuzzysort": "^2.0.4",

View file

@ -16,6 +16,8 @@ export type WorkspaceStatus = "starting" | "ready" | "stopped" | "error"
export interface WorkspaceDescriptor { export interface WorkspaceDescriptor {
id: string id: string
/** Correlates creation events with the client request that initiated them. */
requestId?: string
/** Absolute path on the server host. */ /** Absolute path on the server host. */
path: string path: string
name?: string name?: string
@ -38,6 +40,9 @@ export interface WorkspaceDescriptor {
export interface WorkspaceCreateRequest { export interface WorkspaceCreateRequest {
path: string path: string
name?: string name?: string
binaryPath?: string
requestId?: string
forceNew?: boolean
} }
export interface WorkspaceCloneRequest { export interface WorkspaceCloneRequest {
@ -50,7 +55,10 @@ export interface WorkspaceCloneResponse {
path: string path: string
} }
export type WorkspaceCreateResponse = WorkspaceDescriptor export type WorkspaceCreateResponse = WorkspaceDescriptor & {
/** True when an active workspace with the same canonical path was returned. */
reused?: true
}
export type WorkspaceListResponse = WorkspaceDescriptor[] export type WorkspaceListResponse = WorkspaceDescriptor[]
export type WorkspaceDetailResponse = WorkspaceDescriptor export type WorkspaceDetailResponse = WorkspaceDescriptor
@ -59,6 +67,26 @@ export interface WorkspaceDeleteResponse {
status: WorkspaceStatus status: WorkspaceStatus
} }
export interface ProviderUsageWindow {
usedPercent: number | null
remainingPercent: number | null
windowSeconds: number | null
resetAt: number | null
valueLabel?: string
}
export interface ProviderUsageResponse {
requestedProviderId: string
providerId: string | null
providerName: string
modelId?: string
supported: boolean
configured: boolean
ok: boolean
windows: Record<string, ProviderUsageWindow>
fetchedAt: number
}
export type WorktreeKind = "root" | "worktree" export type WorktreeKind = "root" | "worktree"
export interface WorktreeDescriptor { export interface WorktreeDescriptor {
@ -328,6 +356,19 @@ export interface BinaryValidationResult {
error?: string error?: string
} }
export interface OpenCodeUpdateStatus {
currentVersion: string
latestVersion: string | null
updateAvailable: boolean | null
canUpgrade: boolean
checkError?: "update_check_failed"
}
export interface OpenCodeUpdateResponse {
success: boolean
version: string
}
export interface SpeechSegment { export interface SpeechSegment {
startMs: number startMs: number
endMs: number endMs: number
@ -347,6 +388,11 @@ export interface SpeechCapabilitiesResponse {
ttsVoice: string ttsVoice: string
ttsFormats: string[] ttsFormats: string[]
streamingTtsFormats: string[] streamingTtsFormats: string[]
separateProviders?: boolean
sttConfigured?: boolean
ttsConfigured?: boolean
sttBaseUrl?: string
ttsBaseUrl?: string
} }
export interface SpeechTranscriptionResponse { export interface SpeechTranscriptionResponse {
@ -365,6 +411,14 @@ export interface VoiceModeStateResponse {
enabled: boolean enabled: boolean
} }
export interface YoloStateResponse {
enabled: boolean
}
export interface SessionMetadataResponse {
metadata: Record<string, unknown>
}
export interface RemoteServerProfile { export interface RemoteServerProfile {
id: string id: string
name: string name: string
@ -414,12 +468,14 @@ export type WorkspaceEventType =
| "instance.dataChanged" | "instance.dataChanged"
| "instance.event" | "instance.event"
| "instance.eventStatus" | "instance.eventStatus"
| "yolo.stateChanged"
| "yolo.autoAccepted"
export type WorkspaceEventPayload = export type WorkspaceEventPayload =
| { type: "workspace.created"; workspace: WorkspaceDescriptor } | { type: "workspace.created"; workspace: WorkspaceDescriptor }
| { type: "workspace.started"; workspace: WorkspaceDescriptor } | { type: "workspace.started"; workspace: WorkspaceDescriptor }
| { type: "workspace.error"; workspace: WorkspaceDescriptor } | { type: "workspace.error"; workspace: WorkspaceDescriptor }
| { type: "workspace.stopped"; workspaceId: string } | { type: "workspace.stopped"; workspaceId: string; reason?: "deleted" | "stopped" }
| { type: "workspace.log"; entry: WorkspaceLogEntry } | { type: "workspace.log"; entry: WorkspaceLogEntry }
| { type: "sidecar.updated"; sidecar: SideCar } | { type: "sidecar.updated"; sidecar: SideCar }
| { type: "sidecar.removed"; sidecarId: string } | { type: "sidecar.removed"; sidecarId: string }
@ -428,6 +484,8 @@ export type WorkspaceEventPayload =
| { type: "instance.dataChanged"; instanceId: string; data: InstanceData } | { type: "instance.dataChanged"; instanceId: string; data: InstanceData }
| { type: "instance.event"; instanceId: string; event: InstanceStreamEvent } | { type: "instance.event"; instanceId: string; event: InstanceStreamEvent }
| { type: "instance.eventStatus"; instanceId: string; status: InstanceStreamStatus; reason?: string } | { type: "instance.eventStatus"; instanceId: string; status: InstanceStreamStatus; reason?: string }
| { type: "yolo.stateChanged"; instanceId: string; sessionId: string; enabled: boolean }
| { type: "yolo.autoAccepted"; instanceId: string; sessionId: string; permissionId: string }
export interface NetworkAddress { export interface NetworkAddress {
ip: string ip: string

View file

@ -0,0 +1,15 @@
import assert from "node:assert/strict"
import { describe, it } from "node:test"
import { PreferencesSchema } from "./schema"
describe("chat visibility preferences", () => {
it("accepts hidden diagnostics and collapsed usage metrics", () => {
const preferences = PreferencesSchema.parse({
diagnosticsExpansion: "hidden",
usageMetricsExpansion: "collapsed",
})
assert.equal(preferences.diagnosticsExpansion, "hidden")
assert.equal(preferences.usageMetricsExpansion, "collapsed")
})
})

View file

@ -22,8 +22,9 @@ const PreferencesSchema = z
modelThinkingSelections: z.record(z.string(), z.string()).default({}), modelThinkingSelections: z.record(z.string(), z.string()).default({}),
diffViewMode: z.enum(["split", "unified"]).default("split"), diffViewMode: z.enum(["split", "unified"]).default("split"),
toolOutputExpansion: z.enum(["expanded", "collapsed"]).default("expanded"), toolOutputExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
diagnosticsExpansion: z.enum(["expanded", "collapsed"]).default("expanded"), diagnosticsExpansion: z.enum(["hidden", "expanded", "collapsed"]).default("expanded"),
showUsageMetrics: z.boolean().default(true), showUsageMetrics: z.boolean().default(true),
usageMetricsExpansion: z.enum(["expanded", "collapsed"]).default("collapsed"),
autoCleanupBlankSessions: z.boolean().default(true), autoCleanupBlankSessions: z.boolean().default(true),
listeningMode: z.enum(["local", "all"]).default("local"), listeningMode: z.enum(["local", "all"]).default("local"),
logLevel: z.enum(["DEBUG", "INFO", "WARN", "ERROR"]).default("DEBUG"), logLevel: z.enum(["DEBUG", "INFO", "WARN", "ERROR"]).default("DEBUG"),

View file

@ -0,0 +1,45 @@
import assert from "node:assert/strict"
import { describe, it } from "node:test"
import { EventBus } from "./bus"
import type { WorkspaceEventPayload } from "../api-types"
describe("event bus instance status replay", () => {
it("replays the latest instance status to a late subscriber", () => {
const bus = new EventBus()
bus.publish({ type: "instance.eventStatus", instanceId: "workspace-1", status: "connecting" })
bus.publish({ type: "instance.eventStatus", instanceId: "workspace-1", status: "connected" })
const received: WorkspaceEventPayload[] = []
bus.onEvent((event) => received.push(event))
assert.deepEqual(received, [
{ type: "instance.eventStatus", instanceId: "workspace-1", status: "connected" },
])
})
it("delivers terminal disconnects live without replaying stopped workspaces", () => {
const bus = new EventBus()
bus.publish({ type: "instance.eventStatus", instanceId: "workspace-1", status: "connected" })
const live: WorkspaceEventPayload[] = []
bus.onEvent((event) => live.push(event))
live.length = 0
bus.publish({
type: "instance.eventStatus",
instanceId: "workspace-1",
status: "disconnected",
reason: "workspace stopped",
})
assert.deepEqual(live, [{
type: "instance.eventStatus",
instanceId: "workspace-1",
status: "disconnected",
reason: "workspace stopped",
}])
const replayed: WorkspaceEventPayload[] = []
bus.onEvent((event) => replayed.push(event))
assert.deepEqual(replayed, [])
})
})

View file

@ -3,11 +3,22 @@ import { WorkspaceEventPayload } from "../api-types"
import { Logger } from "../logger" import { Logger } from "../logger"
export class EventBus extends EventEmitter { export class EventBus extends EventEmitter {
private readonly instanceStatuses = new Map<string, Extract<WorkspaceEventPayload, { type: "instance.eventStatus" }>>()
constructor(private readonly logger?: Logger) { constructor(private readonly logger?: Logger) {
super() super()
} }
publish(event: WorkspaceEventPayload): boolean { publish(event: WorkspaceEventPayload): boolean {
if (event.type === "instance.eventStatus") {
const terminal = event.status === "disconnected"
&& (event.reason === "workspace stopped" || event.reason === "workspace error")
if (terminal) {
this.instanceStatuses.delete(event.instanceId)
} else {
this.instanceStatuses.set(event.instanceId, event)
}
}
if (event.type !== "instance.event" && event.type !== "instance.eventStatus") { if (event.type !== "instance.event" && event.type !== "instance.eventStatus") {
this.logger?.debug({ type: event.type }, "Publishing workspace event") this.logger?.debug({ type: event.type }, "Publishing workspace event")
if (this.logger?.isLevelEnabled("trace")) { if (this.logger?.isLevelEnabled("trace")) {
@ -31,6 +42,9 @@ export class EventBus extends EventEmitter {
this.on("instance.dataChanged", handler) this.on("instance.dataChanged", handler)
this.on("instance.event", handler) this.on("instance.event", handler)
this.on("instance.eventStatus", handler) this.on("instance.eventStatus", handler)
this.on("yolo.stateChanged", handler)
this.on("yolo.autoAccepted", handler)
for (const status of this.instanceStatuses.values()) listener(status)
return () => { return () => {
this.off("workspace.created", handler) this.off("workspace.created", handler)
this.off("workspace.started", handler) this.off("workspace.started", handler)
@ -44,6 +58,8 @@ export class EventBus extends EventEmitter {
this.off("instance.dataChanged", handler) this.off("instance.dataChanged", handler)
this.off("instance.event", handler) this.off("instance.event", handler)
this.off("instance.eventStatus", handler) this.off("instance.eventStatus", handler)
this.off("yolo.stateChanged", handler)
this.off("yolo.autoAccepted", handler)
} }
} }
} }

View file

@ -17,10 +17,11 @@ describe("workspace search cache", () => {
const workspacePath = "/tmp/workspace" const workspacePath = "/tmp/workspace"
const startTime = 1_000 const startTime = 1_000
refreshWorkspaceCandidates(workspacePath, () => [createEntry("file-a")], startTime) refreshWorkspaceCandidates(workspacePath, "query-a", () => [createEntry("file-a")], startTime)
const beforeExpiry = getWorkspaceCandidates( const beforeExpiry = getWorkspaceCandidates(
workspacePath, workspacePath,
"query-a",
startTime + WORKSPACE_CANDIDATE_CACHE_TTL_MS - 1, startTime + WORKSPACE_CANDIDATE_CACHE_TTL_MS - 1,
) )
assert.ok(beforeExpiry) assert.ok(beforeExpiry)
@ -29,6 +30,7 @@ describe("workspace search cache", () => {
const afterExpiry = getWorkspaceCandidates( const afterExpiry = getWorkspaceCandidates(
workspacePath, workspacePath,
"query-a",
startTime + WORKSPACE_CANDIDATE_CACHE_TTL_MS + 1, startTime + WORKSPACE_CANDIDATE_CACHE_TTL_MS + 1,
) )
assert.equal(afterExpiry, undefined) assert.equal(afterExpiry, undefined)
@ -37,16 +39,32 @@ describe("workspace search cache", () => {
it("replaces cached entries when manually refreshed", () => { it("replaces cached entries when manually refreshed", () => {
const workspacePath = "/tmp/workspace" const workspacePath = "/tmp/workspace"
refreshWorkspaceCandidates(workspacePath, () => [createEntry("file-a")], 5_000) refreshWorkspaceCandidates(workspacePath, "query-a", () => [createEntry("file-a")], 5_000)
const initial = getWorkspaceCandidates(workspacePath, 5_001) const initial = getWorkspaceCandidates(workspacePath, "query-a", 5_001)
assert.ok(initial) assert.ok(initial)
assert.equal(initial[0].name, "file-a") assert.equal(initial[0].name, "file-a")
refreshWorkspaceCandidates(workspacePath, () => [createEntry("file-b")], 6_000) refreshWorkspaceCandidates(workspacePath, "query-a", () => [createEntry("file-b")], 6_000)
const refreshed = getWorkspaceCandidates(workspacePath, 6_001) const refreshed = getWorkspaceCandidates(workspacePath, "query-a", 6_001)
assert.ok(refreshed) assert.ok(refreshed)
assert.equal(refreshed[0].name, "file-b") assert.equal(refreshed[0].name, "file-b")
}) })
it("does not reuse candidates across query scopes", () => {
const workspacePath = "/tmp/workspace"
refreshWorkspaceCandidates(workspacePath, "query-a", () => [createEntry("file-a")], 5_000)
assert.equal(getWorkspaceCandidates(workspacePath, "query-a", 5_001)?.[0].name, "file-a")
assert.equal(getWorkspaceCandidates(workspacePath, "query-b", 5_001), undefined)
refreshWorkspaceCandidates(workspacePath, "query-b", () => [createEntry("file-b")], 5_000)
assert.equal(getWorkspaceCandidates(workspacePath, "query-a", 5_001), undefined)
assert.equal(getWorkspaceCandidates(workspacePath, "query-b", 5_001)?.[0].name, "file-b")
clearWorkspaceSearchCache(workspacePath)
assert.equal(getWorkspaceCandidates(workspacePath, "query-a", 5_001), undefined)
assert.equal(getWorkspaceCandidates(workspacePath, "query-b", 5_001), undefined)
})
}) })
function createEntry(name: string): FileSystemEntry { function createEntry(name: string): FileSystemEntry {

View file

@ -0,0 +1,63 @@
import assert from "node:assert/strict"
import fs from "node:fs"
import os from "node:os"
import path from "node:path"
import { after, test } from "node:test"
import { searchWorkspaceFiles } from "../search"
import { getWorkspaceCandidates } from "../search-cache"
const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-search-"))
after(() => fs.rmSync(workspace, { recursive: true, force: true }))
test("finds a matching file after more than 8000 non-matching entries", () => {
fs.mkdirSync(path.join(workspace, "a"))
fs.mkdirSync(path.join(workspace, "b"))
const [targetDirName, fillerDirName] = fs.readdirSync(workspace)
const fillerDir = path.join(workspace, fillerDirName)
const targetDir = path.join(workspace, targetDirName)
for (let index = 0; index < 8_001; index += 1) {
fs.writeFileSync(path.join(fillerDir, `filler-${index}.txt`), "")
}
fs.writeFileSync(path.join(targetDir, "unique-search-target.txt"), "")
const results = searchWorkspaceFiles(workspace, "unique-search-target", {
type: "file",
refresh: true,
})
assert.equal(results.some((entry) => entry.name === "unique-search-target.txt"), true)
searchWorkspaceFiles(workspace, "filler", { type: "file", refresh: true })
assert.equal(getWorkspaceCandidates(workspace, "file\0filler")?.length, 8_000)
})
test("does not revisit directory links", () => {
const cyclicWorkspace = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-search-cycle-"))
try {
fs.symlinkSync(cyclicWorkspace, path.join(cyclicWorkspace, "cycle"), process.platform === "win32" ? "junction" : "dir")
assert.deepEqual(searchWorkspaceFiles(cyclicWorkspace, "not-present", { refresh: true }), [])
} finally {
fs.rmSync(cyclicWorkspace, { recursive: true, force: true })
}
})
test("indexes both real and linked directory paths", () => {
const linkedWorkspace = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-search-link-"))
try {
const realDirectory = path.join(linkedWorkspace, "b")
fs.mkdirSync(realDirectory)
fs.writeFileSync(path.join(realDirectory, "needle.txt"), "")
fs.symlinkSync(realDirectory, path.join(linkedWorkspace, "a"), process.platform === "win32" ? "junction" : "dir")
const linkedResults = searchWorkspaceFiles(linkedWorkspace, "a/needle", { type: "file", refresh: true })
const realResults = searchWorkspaceFiles(linkedWorkspace, "b/needle", { type: "file", refresh: true })
assert.equal(linkedResults.some((entry) => entry.path === "a/needle.txt"), true)
assert.equal(realResults.some((entry) => entry.path === "b/needle.txt"), true)
} finally {
fs.rmSync(linkedWorkspace, { recursive: true, force: true })
}
})

View file

@ -4,16 +4,17 @@ import type { FileSystemEntry } from "../api-types"
export const WORKSPACE_CANDIDATE_CACHE_TTL_MS = 30_000 export const WORKSPACE_CANDIDATE_CACHE_TTL_MS = 30_000
interface WorkspaceCandidateCacheEntry { interface WorkspaceCandidateCacheEntry {
scope: string
expiresAt: number expiresAt: number
candidates: FileSystemEntry[] candidates: FileSystemEntry[]
} }
const workspaceCandidateCache = new Map<string, WorkspaceCandidateCacheEntry>() const workspaceCandidateCache = new Map<string, WorkspaceCandidateCacheEntry>()
export function getWorkspaceCandidates(rootDir: string, now = Date.now()): FileSystemEntry[] | undefined { export function getWorkspaceCandidates(rootDir: string, scope: string, now = Date.now()): FileSystemEntry[] | undefined {
const key = normalizeKey(rootDir) const key = normalizeKey(rootDir)
const cached = workspaceCandidateCache.get(key) const cached = workspaceCandidateCache.get(key)
if (!cached) { if (!cached || cached.scope !== scope) {
return undefined return undefined
} }
@ -27,19 +28,16 @@ export function getWorkspaceCandidates(rootDir: string, now = Date.now()): FileS
export function refreshWorkspaceCandidates( export function refreshWorkspaceCandidates(
rootDir: string, rootDir: string,
scope: string,
builder: () => FileSystemEntry[], builder: () => FileSystemEntry[],
now = Date.now(), now = Date.now(),
): FileSystemEntry[] { ): FileSystemEntry[] {
const key = normalizeKey(rootDir) const key = normalizeKey(rootDir)
const freshCandidates = builder() const freshCandidates = builder()
if (!freshCandidates || freshCandidates.length === 0) {
workspaceCandidateCache.delete(key)
return []
}
const storedCandidates = cloneEntries(freshCandidates) const storedCandidates = cloneEntries(freshCandidates)
workspaceCandidateCache.set(key, { workspaceCandidateCache.set(key, {
scope,
expiresAt: now + WORKSPACE_CANDIDATE_CACHE_TTL_MS, expiresAt: now + WORKSPACE_CANDIDATE_CACHE_TTL_MS,
candidates: storedCandidates, candidates: storedCandidates,
}) })
@ -53,8 +51,7 @@ export function clearWorkspaceSearchCache(rootDir?: string) {
return return
} }
const key = normalizeKey(rootDir) workspaceCandidateCache.delete(normalizeKey(rootDir))
workspaceCandidateCache.delete(key)
} }
function cloneEntries(entries: FileSystemEntry[]): FileSystemEntry[] { function cloneEntries(entries: FileSystemEntry[]): FileSystemEntry[] {

View file

@ -40,16 +40,19 @@ export function searchWorkspaceFiles(
const limit = normalizeLimit(options.limit) const limit = normalizeLimit(options.limit)
const typeFilter: WorkspaceFileSearchType = options.type ?? "all" const typeFilter: WorkspaceFileSearchType = options.type ?? "all"
const refreshRequested = options.refresh === true const refreshRequested = options.refresh === true
const cacheScope = `${typeFilter}\0${trimmedQuery.toLowerCase()}`
let entries: FileSystemEntry[] | undefined let entries: FileSystemEntry[] | undefined
try { try {
if (!refreshRequested) { if (!refreshRequested) {
entries = getWorkspaceCandidates(normalizedRoot) entries = getWorkspaceCandidates(normalizedRoot, cacheScope)
} }
if (!entries) { if (!entries) {
entries = refreshWorkspaceCandidates(normalizedRoot, () => collectCandidates(normalizedRoot)) entries = refreshWorkspaceCandidates(normalizedRoot, cacheScope, () =>
collectCandidates(normalizedRoot, trimmedQuery, typeFilter),
)
} }
} catch (error) { } catch (error) {
clearWorkspaceSearchCache(normalizedRoot) clearWorkspaceSearchCache(normalizedRoot)
@ -57,7 +60,6 @@ export function searchWorkspaceFiles(
} }
if (!entries || entries.length === 0) { if (!entries || entries.length === 0) {
clearWorkspaceSearchCache(normalizedRoot)
return [] return []
} }
@ -80,16 +82,25 @@ export function searchWorkspaceFiles(
} }
function collectCandidates(rootDir: string): FileSystemEntry[] { function collectCandidates(rootDir: string, query: string, filter: WorkspaceFileSearchType): FileSystemEntry[] {
const queue: string[] = [""] const queue: Array<{ relativeDir: string; ancestors: ReadonlySet<string> }> = [
{ relativeDir: "", ancestors: new Set() },
]
const entries: FileSystemEntry[] = [] const entries: FileSystemEntry[] = []
while (queue.length > 0 && entries.length < MAX_CANDIDATES) { while (queue.length > 0 && entries.length < MAX_CANDIDATES) {
const relativeDir = queue.pop() || "" const queuedDirectory = queue.pop()!
const { relativeDir, ancestors } = queuedDirectory
const absoluteDir = relativeDir ? path.join(rootDir, relativeDir) : rootDir const absoluteDir = relativeDir ? path.join(rootDir, relativeDir) : rootDir
let dirents: fs.Dirent[] let dirents: fs.Dirent[]
let branchAncestors: ReadonlySet<string>
try { try {
const realDir = normalizeDirectoryIdentity(fs.realpathSync.native(absoluteDir))
if (ancestors.has(realDir)) {
continue
}
branchAncestors = new Set([...ancestors, realDir])
dirents = fs.readdirSync(absoluteDir, { withFileTypes: true }) dirents = fs.readdirSync(absoluteDir, { withFileTypes: true })
} catch { } catch {
continue continue
@ -115,9 +126,7 @@ function collectCandidates(rootDir: string): FileSystemEntry[] {
const isDirectory = stats.isDirectory() const isDirectory = stats.isDirectory()
if (isDirectory && !IGNORED_DIRECTORIES.has(lowerName)) { if (isDirectory && !IGNORED_DIRECTORIES.has(lowerName)) {
if (entries.length < MAX_CANDIDATES) { queue.push({ relativeDir: relativePath, ancestors: branchAncestors })
queue.push(relativePath)
}
} }
const entryType: FileSystemEntry["type"] = isDirectory ? "directory" : "file" const entryType: FileSystemEntry["type"] = isDirectory ? "directory" : "file"
@ -131,8 +140,11 @@ function collectCandidates(rootDir: string): FileSystemEntry[] {
modifiedAt: stats.mtime.toISOString(), modifiedAt: stats.mtime.toISOString(),
} }
entries.push(entry) if (!shouldInclude(entry.type, filter) || !fuzzysort.single(query, buildSearchKey(entry))) {
continue
}
entries.push(entry)
if (entries.length >= MAX_CANDIDATES) { if (entries.length >= MAX_CANDIDATES) {
break break
} }
@ -182,3 +194,7 @@ function normalizeRelativeEntryPath(relativePath: string): string {
function buildSearchKey(entry: FileSystemEntry) { function buildSearchKey(entry: FileSystemEntry) {
return entry.path.toLowerCase() return entry.path.toLowerCase()
} }
function normalizeDirectoryIdentity(directoryPath: string) {
return process.platform === "win32" ? directoryPath.toLowerCase() : directoryPath
}

View file

@ -0,0 +1,73 @@
import assert from "node:assert/strict"
import { spawn } from "node:child_process"
import { once } from "node:events"
import { describe, it } from "node:test"
import { installShutdownSignalHandlers, installShutdownStdinHandler, STDIN_SHUTDOWN_COMMAND } from "./index"
import { createServerShutdownHandler } from "./shutdown"
describe("CLI shutdown signal registration", () => {
it("routes a second process signal to forced nonzero escalation", async () => {
const listeners = new Map<string, () => void>()
let finishCleanup!: () => void
const cleanup = new Promise<void>((resolve) => {
finishCleanup = resolve
})
const exits: number[] = []
let handled: Promise<void> | undefined
const shutdown = createServerShutdownHandler({
shutdown: () => cleanup,
logger: { info() {}, warn() {}, error() {} },
forceExit: (code) => exits.push(code),
setExitCode: () => undefined,
reportStatus: () => undefined,
})
installShutdownSignalHandlers(
{ on: (signal, listener) => listeners.set(signal, listener) },
(signal) => (handled = shutdown(signal)),
)
listeners.get("SIGINT")?.()
listeners.get("SIGTERM")?.()
assert.deepEqual(exits, [1])
finishCleanup()
await handled
})
it("coalesces chunked and repeated stdin shutdown commands through the same handler", async () => {
const listeners = new Map<string, (chunk: Buffer | string) => void>()
const triggers: string[] = []
let destroys = 0
const source = {
on: (event: "data", listener: (chunk: Buffer | string) => void) => listeners.set(event, listener),
off: (event: "data", listener: (chunk: Buffer | string) => void) => {
if (listeners.get(event) === listener) listeners.delete(event)
},
destroy: () => { destroys++ },
}
installShutdownStdinHandler(source, async (trigger) => { triggers.push(trigger) })
const listener = listeners.get("data")!
listener(STDIN_SHUTDOWN_COMMAND.slice(0, 8))
listener(`${STDIN_SHUTDOWN_COMMAND.slice(8)}\n${STDIN_SHUTDOWN_COMMAND}\n`)
listener(`${STDIN_SHUTDOWN_COMMAND}\n`)
await Promise.resolve()
assert.deepEqual(triggers, ["stdin"])
assert.equal(destroys, 1)
assert.equal(listeners.has("data"), false)
})
it("allows a real piped process to exit naturally after the shutdown command", { timeout: 5_000 }, async () => {
const moduleUrl = new URL("./index.ts", import.meta.url).href
const child = spawn(process.execPath, ["--import", "tsx", "--input-type=module", "-e", `
import { installShutdownStdinHandler } from ${JSON.stringify(moduleUrl)}
installShutdownStdinHandler(process.stdin, async () => { process.exitCode = 0 })
`], { stdio: ["pipe", "ignore", "inherit"] })
child.stdin.end(`${STDIN_SHUTDOWN_COMMAND}\n`)
const [code] = await once(child, "exit")
assert.equal(code, 0)
})
})

View file

@ -32,6 +32,10 @@ import { ClientConnectionManager } from "./clients/connection-manager"
import { PluginChannelManager } from "./plugins/channel" import { PluginChannelManager } from "./plugins/channel"
import { VoiceModeManager } from "./plugins/voice-mode" import { VoiceModeManager } from "./plugins/voice-mode"
import { runCliUpgrade } from "./cli-upgrade" import { runCliUpgrade } from "./cli-upgrade"
import { createServerShutdownHandler, orchestrateServerShutdown, type ServerShutdownTrigger } from "./shutdown"
import { AutoAcceptManager } from "./permissions/auto-accept-manager"
import { createOpencodePermissionReplier } from "./permissions/opencode-replier"
import { createOpencodeYoloPersistence } from "./permissions/opencode-yolo-metadata"
const require = createRequire(import.meta.url) const require = createRequire(import.meta.url)
@ -73,6 +77,46 @@ const DEFAULT_HOST = "127.0.0.1"
const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json" const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json"
const DEFAULT_HTTPS_PORT = 9898 const DEFAULT_HTTPS_PORT = 9898
const DEFAULT_HTTP_PORT = 9899 const DEFAULT_HTTP_PORT = 9899
export const STDIN_SHUTDOWN_COMMAND = "codenomad:shutdown"
interface ShutdownSignalSource {
on: (signal: "SIGINT" | "SIGTERM", listener: () => void) => unknown
}
export function installShutdownSignalHandlers(
source: ShutdownSignalSource,
shutdown: (signal: ServerShutdownTrigger) => Promise<void>,
): void {
source.on("SIGINT", () => void shutdown("SIGINT"))
source.on("SIGTERM", () => void shutdown("SIGTERM"))
}
interface ShutdownStdinSource {
on(event: "data", listener: (chunk: Buffer | string) => void): unknown
off?(event: "data", listener: (chunk: Buffer | string) => void): unknown
destroy?(): unknown
}
export function installShutdownStdinHandler(
source: ShutdownStdinSource,
shutdown: (signal: ServerShutdownTrigger) => Promise<void>,
): void {
let buffer = ""
let requested = false
const onData = (chunk: Buffer | string) => {
if (requested) return
buffer += chunk.toString()
const lines = buffer.split(/\r?\n/)
buffer = lines.pop() ?? ""
if (!lines.some((line) => line.trim() === STDIN_SHUTDOWN_COMMAND)) return
requested = true
source.off?.("data", onData)
source.destroy?.()
void shutdown("stdin")
}
source.on("data", onData)
}
function parseCliOptions(argv: string[]): CliOptions { function parseCliOptions(argv: string[]): CliOptions {
const program = new Command() const program = new Command()
@ -343,6 +387,15 @@ async function main() {
logger: logger.child({ component: "sidecars" }), logger: logger.child({ component: "sidecars" }),
}) })
const previewManager = new PreviewManager() const previewManager = new PreviewManager()
const yoloLogger = logger.child({ component: "yolo" })
const sessionMetadataPersistence = createOpencodeYoloPersistence(workspaceManager)
const yoloManager = new AutoAcceptManager({
eventBus,
logger: yoloLogger,
replier: createOpencodePermissionReplier({ workspaceManager, logger: yoloLogger }),
persistence: sessionMetadataPersistence,
})
yoloManager.start()
const instanceEventBridge = new InstanceEventBridge({ const instanceEventBridge = new InstanceEventBridge({
workspaceManager, workspaceManager,
eventBus, eventBus,
@ -444,6 +497,8 @@ async function main() {
pluginChannel, pluginChannel,
voiceModeManager, voiceModeManager,
remoteProxySessionManager, remoteProxySessionManager,
yoloManager,
sessionMetadataPersistence,
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR, uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
uiDevServerUrl: uiResolution.uiDevServerUrl, uiDevServerUrl: uiResolution.uiDevServerUrl,
logger, logger,
@ -471,6 +526,8 @@ async function main() {
pluginChannel, pluginChannel,
voiceModeManager, voiceModeManager,
remoteProxySessionManager, remoteProxySessionManager,
yoloManager,
sessionMetadataPersistence,
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR, uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
uiDevServerUrl: undefined, uiDevServerUrl: undefined,
logger, logger,
@ -555,68 +612,46 @@ async function main() {
await launchInBrowser(serverMeta.localUrl, logger.child({ component: "launcher" })) await launchInBrowser(serverMeta.localUrl, logger.child({ component: "launcher" }))
} }
let shuttingDown = false const shutdown = createServerShutdownHandler({
logger,
holdAfterFailure: () => new Promise<void>(() => { setInterval(() => undefined, 60_000) }),
setExitCode: (code) => {
process.stdin.destroy()
process.exitCode = code
},
shutdown: () =>
orchestrateServerShutdown(
{
stopInstanceEventBridge: () => instanceEventBridge.shutdown(),
stopSidecars: () => sidecarManager.shutdown(),
stopClientConnections: () => clientConnectionManager.shutdown(),
stopRemoteProxySessions: () => remoteProxySessionManager.shutdown(),
stopWorkspaces: () => workspaceManager.shutdown(),
stopHttpServers: async () => {
yoloManager.stop()
const results = await Promise.allSettled(servers.map((srv) => srv.stop()))
const failures = results.flatMap((result) => (result.status === "rejected" ? [result.reason] : []))
if (failures.length > 0) {
const error = new Error("One or more HTTP servers failed to stop") as Error & { failures: unknown[] }
error.failures = failures
throw error
}
logger.info("HTTP server(s) stopped")
},
stopReleaseMonitor: () => devReleaseMonitor?.stop(),
},
logger,
),
})
const shutdown = async () => { installShutdownSignalHandlers(process, shutdown)
if (shuttingDown) { installShutdownStdinHandler(process.stdin, shutdown)
logger.info("Shutdown already in progress, ignoring signal")
return
}
shuttingDown = true
logger.info("Received shutdown signal, stopping workspaces and server")
const shutdownWorkspaces = (async () => {
try {
instanceEventBridge.shutdown()
} catch (error) {
logger.warn({ err: error }, "Instance event bridge shutdown failed")
}
try {
await sidecarManager.shutdown()
} catch (error) {
logger.error({ err: error }, "SideCar manager shutdown failed")
}
try {
clientConnectionManager.shutdown()
} catch (error) {
logger.warn({ err: error }, "Client connection manager shutdown failed")
}
try {
await workspaceManager.shutdown()
logger.info("Workspace manager shutdown complete")
} catch (error) {
logger.error({ err: error }, "Workspace manager shutdown failed")
}
})()
const shutdownHttp = (async () => {
try {
await Promise.allSettled(servers.map((srv) => srv.stop()))
logger.info("HTTP server(s) stopped")
} catch (error) {
logger.error({ err: error }, "Failed to stop HTTP server")
}
})()
await Promise.allSettled([shutdownWorkspaces, shutdownHttp])
// no-op: remote UI manifest replaces GitHub release monitor
devReleaseMonitor?.stop()
logger.info("Exiting process")
process.exit(0)
}
process.on("SIGINT", shutdown)
process.on("SIGTERM", shutdown)
} }
main().catch((error) => { if (path.resolve(process.argv[1] ?? "") === __filename) {
const logger = createLogger({ component: "app" }) main().catch((error) => {
logger.error({ err: error }, "CLI server crashed") const logger = createLogger({ component: "app" })
process.exit(1) logger.error({ err: error }, "CLI server crashed")
}) process.exit(1)
})
}

View file

@ -0,0 +1,129 @@
import assert from "node:assert/strict"
import test from "node:test"
import { OpenCodeUpdateError, OpenCodeUpdateService, type OpenCodeUpdateServiceDeps } from "./service"
function createDeps(overrides: Partial<OpenCodeUpdateServiceDeps> = {}): OpenCodeUpdateServiceDeps {
let currentVersion = "1.0.0"
return {
resolveBinary: () => ({ path: "opencode", label: "OpenCode" }),
probeBinary: () => ({ valid: true, version: currentVersion }),
findReadyInstanceId: () => "workspace-1",
fetchLatestVersion: async () => "1.1.0",
upgradeInstance: async (_instanceId, target) => {
currentVersion = target
return { success: true, version: target }
},
...overrides,
}
}
test("reports an available update and caches the latest version", async () => {
let checks = 0
const service = new OpenCodeUpdateService(createDeps({
fetchLatestVersion: async () => {
checks += 1
return "1.1.0"
},
}))
assert.deepEqual(await service.getStatus(), {
currentVersion: "1.0.0",
latestVersion: "1.1.0",
updateAvailable: true,
canUpgrade: true,
})
await service.getStatus()
assert.equal(checks, 1)
})
test("keeps the update visible when no matching instance is ready", async () => {
const service = new OpenCodeUpdateService(createDeps({ findReadyInstanceId: () => undefined }))
assert.deepEqual(await service.getStatus(), {
currentVersion: "1.0.0",
latestVersion: "1.1.0",
updateAvailable: true,
canUpgrade: false,
})
})
test("preserves the installed version when the registry check fails", async () => {
const service = new OpenCodeUpdateService(createDeps({
fetchLatestVersion: async () => {
throw new Error("registry unavailable")
},
}))
assert.deepEqual(await service.getStatus(), {
currentVersion: "1.0.0",
latestVersion: null,
updateAvailable: null,
canUpgrade: false,
checkError: "update_check_failed",
})
})
test("upgrades through the matching OpenCode instance to the advertised version", async () => {
const calls: Array<{ instanceId: string; target: string }> = []
let currentVersion = "1.0.0"
const service = new OpenCodeUpdateService(createDeps({
probeBinary: () => ({ valid: true, version: currentVersion }),
upgradeInstance: async (instanceId, target) => {
calls.push({ instanceId, target })
currentVersion = target
return { success: true, version: target }
},
}))
assert.deepEqual(await service.upgrade(), { success: true, version: "1.1.0" })
assert.deepEqual(calls, [{ instanceId: "workspace-1", target: "1.1.0" }])
})
test("rejects success when the configured binary was not updated", async () => {
const service = new OpenCodeUpdateService(createDeps({
probeBinary: () => ({ valid: true, version: "1.0.0" }),
upgradeInstance: async (_instanceId, target) => ({ success: true, version: target }),
}))
await assert.rejects(
() => service.upgrade(),
(error: unknown) => error instanceof OpenCodeUpdateError && error.code === "upgrade_verification_failed",
)
})
test("joins concurrent upgrades for the same binary", async () => {
let currentVersion = "1.0.0"
let upgrades = 0
let finishUpgrade: (() => void) | undefined
const gate = new Promise<void>((resolve) => {
finishUpgrade = resolve
})
const service = new OpenCodeUpdateService(createDeps({
probeBinary: () => ({ valid: true, version: currentVersion }),
upgradeInstance: async (_instanceId, target) => {
upgrades += 1
await gate
currentVersion = target
return { success: true, version: target }
},
}))
const first = service.upgrade()
const second = service.upgrade()
finishUpgrade?.()
assert.deepEqual(await Promise.all([first, second]), [
{ success: true, version: "1.1.0" },
{ success: true, version: "1.1.0" },
])
assert.equal(upgrades, 1)
})
test("rejects an upgrade when no matching OpenCode instance is running", async () => {
const service = new OpenCodeUpdateService(createDeps({ findReadyInstanceId: () => undefined }))
await assert.rejects(
() => service.upgrade(),
(error: unknown) => error instanceof OpenCodeUpdateError && error.code === "no_ready_instance",
)
})

View file

@ -0,0 +1,194 @@
import { fetch } from "undici"
import type { OpenCodeUpdateResponse, OpenCodeUpdateStatus } from "../api-types"
import type { SettingsService } from "../settings/service"
import { BinaryResolver, type ResolvedBinary } from "../settings/binaries"
import type { WorkspaceManager } from "../workspaces/manager"
import { createInstanceClient } from "../workspaces/instance-client"
import { probeBinaryVersion } from "../workspaces/spawn"
import { compareVersionStrings, stripTagPrefix } from "../releases/release-monitor"
const OPENCODE_LATEST_URL = "https://registry.npmjs.org/opencode-ai/latest"
const LATEST_VERSION_CACHE_MS = 5 * 60_000
const UPGRADE_TIMEOUT_MS = 10 * 60_000
const inFlightUpgrades = new Map<string, Promise<OpenCodeUpdateResponse>>()
type UpgradeResult = { success: true; version: string } | { success: false; error: string }
export interface OpenCodeUpdateServiceDeps {
resolveBinary: () => ResolvedBinary
probeBinary: typeof probeBinaryVersion
findReadyInstanceId: (binaryPath: string) => string | undefined
upgradeInstance: (instanceId: string, target: string) => Promise<UpgradeResult>
fetchLatestVersion: () => Promise<string>
now?: () => number
}
export class OpenCodeUpdateError extends Error {
constructor(
readonly code:
| "binary_unavailable"
| "no_ready_instance"
| "update_check_failed"
| "upgrade_failed"
| "upgrade_verification_failed",
message: string,
) {
super(message)
this.name = "OpenCodeUpdateError"
}
}
export class OpenCodeUpdateService {
private latestVersionCache: { version: string; expiresAt: number } | null = null
constructor(private readonly deps: OpenCodeUpdateServiceDeps) {}
async getStatus(): Promise<OpenCodeUpdateStatus> {
const binary = this.deps.resolveBinary()
const currentVersion = this.readCurrentVersion(binary.path)
let latestVersion: string
try {
latestVersion = await this.readLatestVersion()
} catch (error) {
if (!(error instanceof OpenCodeUpdateError) || error.code !== "update_check_failed") throw error
return {
currentVersion,
latestVersion: null,
updateAvailable: null,
canUpgrade: false,
checkError: "update_check_failed",
}
}
const updateAvailable = compareVersionStrings(latestVersion, currentVersion) > 0
const readyInstanceId = this.deps.findReadyInstanceId(binary.path)
return {
currentVersion,
latestVersion,
updateAvailable,
canUpgrade: updateAvailable && Boolean(readyInstanceId),
}
}
upgrade(): Promise<OpenCodeUpdateResponse> {
const binary = this.deps.resolveBinary()
const existing = inFlightUpgrades.get(binary.path)
if (existing) return existing
const pending = this.performUpgrade(binary).finally(() => {
if (inFlightUpgrades.get(binary.path) === pending) inFlightUpgrades.delete(binary.path)
})
inFlightUpgrades.set(binary.path, pending)
return pending
}
private async performUpgrade(binary: ResolvedBinary): Promise<OpenCodeUpdateResponse> {
const currentVersion = this.readCurrentVersion(binary.path)
const latestVersion = await this.readLatestVersion()
if (compareVersionStrings(latestVersion, currentVersion) <= 0) {
return { success: true, version: currentVersion }
}
const instanceId = this.deps.findReadyInstanceId(binary.path)
if (!instanceId) {
throw new OpenCodeUpdateError(
"no_ready_instance",
"No running OpenCode instance uses the configured binary",
)
}
try {
const result = await this.deps.upgradeInstance(instanceId, latestVersion)
if (!result.success) {
throw new OpenCodeUpdateError("upgrade_failed", result.error)
}
const installedVersion = this.readCurrentVersion(binary.path)
if (compareVersionStrings(installedVersion, latestVersion) !== 0) {
throw new OpenCodeUpdateError(
"upgrade_verification_failed",
`OpenCode reported ${result.version}, but the configured binary is still ${installedVersion}`,
)
}
return { success: true, version: installedVersion }
} catch (error) {
if (error instanceof OpenCodeUpdateError) throw error
throw new OpenCodeUpdateError(
"upgrade_failed",
error instanceof Error ? error.message : "OpenCode upgrade failed",
)
}
}
private readCurrentVersion(binaryPath: string): string {
if (process.platform === "win32" && /["\r\n]/.test(binaryPath)) {
throw new OpenCodeUpdateError("binary_unavailable", "The configured OpenCode binary path is invalid")
}
const result = this.deps.probeBinary(binaryPath)
const version = stripTagPrefix(result.version)
if (!result.valid || !version) {
throw new OpenCodeUpdateError("binary_unavailable", result.error ?? "Unable to read OpenCode version")
}
return version
}
private async readLatestVersion(): Promise<string> {
const now = (this.deps.now ?? Date.now)()
if (this.latestVersionCache && this.latestVersionCache.expiresAt > now) {
return this.latestVersionCache.version
}
try {
const version = stripTagPrefix(await this.deps.fetchLatestVersion())
if (!version || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) {
throw new Error("OpenCode registry returned an invalid version")
}
this.latestVersionCache = { version, expiresAt: now + LATEST_VERSION_CACHE_MS }
return version
} catch (error) {
throw new OpenCodeUpdateError(
"update_check_failed",
error instanceof Error ? error.message : "Unable to check the latest OpenCode version",
)
}
}
}
export async function fetchLatestOpenCodeVersion(): Promise<string> {
const response = await fetch(OPENCODE_LATEST_URL, {
headers: { Accept: "application/json", "User-Agent": "CodeNomad-CLI" },
signal: AbortSignal.timeout(10_000),
})
if (!response.ok) {
throw new Error(`OpenCode registry responded with ${response.status}`)
}
const payload = (await response.json()) as { version?: unknown }
if (typeof payload.version !== "string") {
throw new Error("OpenCode registry response did not include a version")
}
return payload.version
}
export function createOpenCodeUpdateService(
settings: SettingsService,
workspaceManager: WorkspaceManager,
): OpenCodeUpdateService {
const binaryResolver = new BinaryResolver(settings)
return new OpenCodeUpdateService({
resolveBinary: () => {
const binary = binaryResolver.resolveDefault()
return { ...binary, path: workspaceManager.resolveBinaryPath(binary.path) }
},
probeBinary: probeBinaryVersion,
findReadyInstanceId: (binaryPath) => workspaceManager.findReadyInstanceIdByBinary(binaryPath),
fetchLatestVersion: fetchLatestOpenCodeVersion,
upgradeInstance: async (instanceId, target) => {
const client = createInstanceClient(workspaceManager, instanceId, { timeoutMs: UPGRADE_TIMEOUT_MS })
if (!client) {
throw new OpenCodeUpdateError("no_ready_instance", "OpenCode instance is not ready")
}
const { data } = await client.global.upgrade({ target }, { throwOnError: true })
return data
},
})
}

View file

@ -0,0 +1,881 @@
import assert from "node:assert/strict"
import { describe, it } from "node:test"
import { EventBus } from "../events/bus"
import { AutoAcceptManager, type AutoAcceptPersistence, type PermissionReplier, type AutoAcceptReply } from "./auto-accept-manager"
import type { InstanceStreamEvent } from "../api-types"
import type { Logger } from "../logger"
const noopLogger: Logger = {
debug() {},
info() {},
warn() {},
error() {},
trace() {},
isLevelEnabled() {
return false
},
child() {
return noopLogger
},
} as unknown as Logger
function publishInstanceEvent(bus: EventBus, instanceId: string, event: Record<string, unknown>) {
bus.publish({ type: "instance.event", instanceId, event: { ...event } as InstanceStreamEvent })
}
/** Publish a `session.*` event using the real OpenCode shape (`properties.info`). */
function publishSession(
bus: EventBus,
instanceId: string,
eventType: "session.updated" | "session.created" | "session.deleted",
info: Record<string, unknown>,
) {
publishInstanceEvent(bus, instanceId, { type: eventType, properties: { info: { ...info } } })
}
describe("AutoAcceptManager session tree", () => {
it("ingests session.updated to build the parent chain", () => {
const bus = new EventBus(noopLogger)
const replier = makeRecordingReplier()
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier })
manager.start()
publishSession(bus, "inst", "session.updated", { id: "master", parentID: null })
publishSession(bus, "inst", "session.updated", { id: "child", parentID: "master" })
assert.equal(manager.isEnabled("inst", "master"), false)
manager.toggle("inst", "child")
assert.equal(manager.isEnabled("inst", "child"), true)
assert.equal(manager.isEnabled("inst", "master"), true)
manager.stop()
})
it("treats a session with revert as a fork root", () => {
const bus = new EventBus(noopLogger)
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier() })
manager.start()
publishSession(bus, "inst", "session.updated", { id: "master", parentID: null })
publishSession(bus, "inst", "session.updated", {
id: "fork",
parentID: "master",
revert: { messageID: "m", partID: "p" },
})
manager.toggle("inst", "fork")
assert.equal(manager.isEnabled("inst", "fork"), true)
assert.equal(manager.isEnabled("inst", "master"), false)
manager.stop()
})
it("session.deleted removes the tree entry but keeps the toggle", () => {
const bus = new EventBus(noopLogger)
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier() })
manager.start()
publishSession(bus, "inst", "session.updated", { id: "master", parentID: null })
manager.toggle("inst", "master")
publishSession(bus, "inst", "session.deleted", { id: "master" })
// toggle is independent of the tree (survives deletion)
assert.equal(manager.isEnabled("inst", "master"), true)
manager.stop()
})
})
describe("AutoAcceptManager persistence", () => {
it("hydrates a persisted family root before processing queued permissions", async () => {
const bus = new EventBus(noopLogger)
const replier = makeRecordingReplier()
let release!: () => void
const gate = new Promise<void>((resolve) => { release = resolve })
const persistence: AutoAcceptPersistence = {
async loadSessions() {
await gate
return [
{ id: "root", parentId: null, yoloEnabled: true },
{ id: "child", parentId: "root", yoloEnabled: false },
]
},
async persist() {},
}
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier, persistence })
manager.start()
publishSession(bus, "inst", "session.updated", { id: "child", parentID: "root" })
publishInstanceEvent(bus, "inst", {
type: "permission.v2.asked",
properties: { id: "permission", sessionID: "child" },
})
await flushMicrotasks()
assert.equal(replier.calls.length, 0)
release()
await manager.hydrateInstance("inst")
await flushMicrotasks()
assert.equal(manager.isEnabled("inst", "child"), true)
assert.equal(replier.calls.length, 1)
manager.stop()
})
it("persists before enabling memory and emitting feedback", async () => {
const bus = new EventBus(noopLogger)
const changes: Record<string, unknown>[] = []
bus.on("yolo.stateChanged", (event) => changes.push(event))
let release!: () => void
const gate = new Promise<void>((resolve) => { release = resolve })
const writes: unknown[][] = []
const persistence: AutoAcceptPersistence = {
async loadSessions() { return [{ id: "root", parentId: null, yoloEnabled: false }] },
async persist(...args) { writes.push(args); await gate },
}
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier(), persistence })
const toggle = manager.toggle("inst", "root")
await flushMicrotasks()
assert.deepEqual(writes, [["inst", "root", true, undefined]])
assert.equal(manager.isEnabled("inst", "root"), false)
assert.equal(changes.length, 0)
release()
assert.equal(await toggle, true)
assert.equal(manager.isEnabled("inst", "root"), true)
assert.equal(changes.length, 1)
})
it("serializes concurrent toggles", async () => {
const bus = new EventBus(noopLogger)
const writes: boolean[] = []
const persistence: AutoAcceptPersistence = {
async loadSessions() { return [{ id: "root", parentId: null, yoloEnabled: false }] },
async persist(_instanceId, _rootSessionId, enabled) { writes.push(enabled) },
}
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier(), persistence })
assert.deepEqual(await Promise.all([manager.toggle("inst", "root"), manager.toggle("inst", "root")]), [true, false])
assert.deepEqual(writes, [true, false])
assert.equal(manager.isEnabled("inst", "root"), false)
})
it("keeps queued permissions until a failed hydration can retry", async () => {
const bus = new EventBus(noopLogger)
const replier = makeRecordingReplier()
let attempts = 0
const persistence: AutoAcceptPersistence = {
async loadSessions() {
if (++attempts === 1) throw new Error("temporary failure")
return [{ id: "root", parentId: null, yoloEnabled: true }]
},
async persist() {},
}
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier, persistence })
manager.start()
publishInstanceEvent(bus, "inst", {
type: "permission.v2.asked",
properties: { id: "permission", sessionID: "root" },
})
await flushMicrotasks()
assert.equal(replier.calls.length, 0)
await manager.hydrateInstance("inst")
await flushMicrotasks()
assert.equal(replier.calls.length, 1)
manager.stop()
})
it("does not re-enable memory when a persisted toggle finishes after cleanup", async () => {
const bus = new EventBus(noopLogger)
const changes: Record<string, unknown>[] = []
bus.on("yolo.stateChanged", (event) => changes.push(event))
let release!: () => void
const gate = new Promise<void>((resolve) => { release = resolve })
let writes = 0
const persistence: AutoAcceptPersistence = {
async loadSessions() { return [{ id: "root", parentId: null, yoloEnabled: false }] },
async persist() { writes += 1; await gate },
}
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier(), persistence })
const toggle = manager.toggle("inst", "root")
const queued = manager.toggle("inst", "root")
await flushMicrotasks()
manager.clearInstance("inst")
release()
assert.equal(await toggle, false)
assert.equal(await queued, false)
assert.equal(writes, 1)
assert.equal(manager.isEnabled("inst", "root"), false)
assert.equal(changes.length, 0)
})
it("moves persisted Yolo state when late ancestry changes the family root", async () => {
const bus = new EventBus(noopLogger)
const writes: unknown[][] = []
const persistence: AutoAcceptPersistence = {
async loadSessions() {
return [
{ id: "parent", parentId: null, workspaceId: "workspace", yoloEnabled: false },
{ id: "child", parentId: null, workspaceId: "workspace", yoloEnabled: true },
]
},
async persist(...args) { writes.push(args) },
}
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier(), persistence })
manager.start()
await manager.hydrateInstance("inst")
publishSession(bus, "inst", "session.updated", { id: "child", parentID: "parent", workspaceID: "workspace" })
await flushMicrotasks()
assert.equal(manager.isEnabled("inst", "parent"), true)
assert.deepEqual(writes, [
["inst", "parent", true, "workspace"],
["inst", "child", false, "workspace"],
])
manager.stop()
})
it("does not re-enable a family when ancestry repeatedly changes during a disable", async () => {
const bus = new EventBus(noopLogger)
let releaseFirst!: () => void
let releaseSecond!: () => void
const firstGate = new Promise<void>((resolve) => { releaseFirst = resolve })
const secondGate = new Promise<void>((resolve) => { releaseSecond = resolve })
const writes: unknown[][] = []
const persistence: AutoAcceptPersistence = {
async loadSessions() {
return [
{ id: "grandparent", parentId: null, workspaceId: "workspace", yoloEnabled: false },
{ id: "parent", parentId: null, workspaceId: "workspace", yoloEnabled: false },
{ id: "child", parentId: null, workspaceId: "workspace", yoloEnabled: true },
]
},
async persist(...args) {
writes.push(args)
if (writes.length === 1) await firstGate
if (writes.length === 2) await secondGate
},
}
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier(), persistence })
manager.start()
await manager.hydrateInstance("inst")
const toggle = manager.toggle("inst", "child")
await flushMicrotasks()
publishSession(bus, "inst", "session.updated", { id: "child", parentID: "parent", workspaceID: "workspace" })
releaseFirst()
await flushMicrotasks()
publishSession(bus, "inst", "session.updated", { id: "child", parentID: "grandparent", workspaceID: "workspace" })
releaseSecond()
assert.equal(await toggle, false)
await flushMicrotasks()
assert.equal(manager.isEnabled("inst", "grandparent"), false)
assert.equal(writes.some(([, , enabled]) => enabled === true), false, JSON.stringify(writes))
manager.stop()
})
it("allows a queued toggle to continue after an earlier persistence failure", async () => {
const bus = new EventBus(noopLogger)
let attempts = 0
const persistence: AutoAcceptPersistence = {
async loadSessions() { return [{ id: "root", parentId: null, yoloEnabled: false }] },
async persist() { if (++attempts === 1) throw new Error("write failed") },
}
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier(), persistence })
const first = manager.toggle("inst", "root")
const second = manager.toggle("inst", "root")
await assert.rejects(Promise.resolve(first), /write failed/)
assert.equal(await second, true)
assert.equal(manager.isEnabled("inst", "root"), true)
})
it("does not restore a late hydration after workspace cleanup", async () => {
const bus = new EventBus(noopLogger)
let release!: () => void
const gate = new Promise<void>((resolve) => { release = resolve })
const persistence: AutoAcceptPersistence = {
async loadSessions() { await gate; return [{ id: "root", parentId: null, yoloEnabled: true }] },
async persist() {},
}
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier(), persistence })
const hydration = manager.hydrateInstance("inst")
manager.clearInstance("inst")
release()
await hydration
assert.equal(manager.isEnabled("inst", "root"), false)
})
})
describe("AutoAcceptManager permission interception", () => {
it("auto-replies to a v2 permission on an enabled family", async () => {
const bus = new EventBus(noopLogger)
const replier = makeRecordingReplier()
const accepted: Record<string, unknown>[] = []
bus.on("yolo.autoAccepted", (e) => accepted.push(e))
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier })
manager.start()
publishSession(bus, "inst", "session.updated", { id: "master", parentID: null })
publishSession(bus, "inst", "session.updated", { id: "child", parentID: "master" })
manager.toggle("inst", "child") // enable the whole family root
publishInstanceEvent(bus, "inst", {
type: "permission.v2.asked",
properties: { id: "perm-1", sessionID: "child", action: "edit", resources: ["a.ts"] },
})
await flushMicrotasks()
assert.equal(replier.calls.length, 1)
const call = replier.calls[0]
assert.equal(call.instanceId, "inst")
assert.equal(call.permissionId, "perm-1")
assert.equal(call.sessionId, "child")
assert.equal(call.source, "v2")
assert.equal(call.reply, "once")
assert.equal(accepted.length, 1)
assert.equal((accepted[0] as any).permissionId, "perm-1")
manager.stop()
})
it("auto-replies to a legacy permission.asked event", async () => {
const bus = new EventBus(noopLogger)
const replier = makeRecordingReplier()
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier })
manager.start()
publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null })
manager.toggle("inst", "solo")
publishInstanceEvent(bus, "inst", {
type: "permission.asked",
properties: { id: "perm-2", sessionID: "solo", type: "bash" },
})
await flushMicrotasks()
assert.equal(replier.calls.length, 1)
assert.equal(replier.calls[0].source, "legacy")
assert.equal(replier.calls[0].permissionId, "perm-2")
manager.stop()
})
it("does not reply when the family is disabled", async () => {
const bus = new EventBus(noopLogger)
const replier = makeRecordingReplier()
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier })
manager.start()
publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null })
publishInstanceEvent(bus, "inst", {
type: "permission.v2.asked",
properties: { id: "perm-3", sessionID: "solo" },
})
await flushMicrotasks()
assert.equal(replier.calls.length, 0)
manager.stop()
})
it("ignores permission events without an id or sessionID", async () => {
const bus = new EventBus(noopLogger)
const replier = makeRecordingReplier()
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier })
manager.start()
publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null })
manager.toggle("inst", "solo")
publishInstanceEvent(bus, "inst", { type: "permission.v2.asked", properties: { sessionID: "solo" } })
publishInstanceEvent(bus, "inst", { type: "permission.v2.asked", properties: { id: "x" } })
await flushMicrotasks()
assert.equal(replier.calls.length, 0)
manager.stop()
})
it("deduplicates repeated emission of the same permission", async () => {
const bus = new EventBus(noopLogger)
const replier = makeRecordingReplier()
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier })
manager.start()
publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null })
manager.toggle("inst", "solo")
for (let i = 0; i < 3; i++) {
publishInstanceEvent(bus, "inst", {
type: "permission.v2.asked",
properties: { id: "perm-dup", sessionID: "solo" },
})
}
await flushMicrotasks()
assert.equal(replier.calls.length, 1)
manager.stop()
})
it("clears in-flight tracking after the reply resolves so it can retry", async () => {
const bus = new EventBus(noopLogger)
const replier = makeRecordingReplier()
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier })
manager.start()
publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null })
manager.toggle("inst", "solo")
publishInstanceEvent(bus, "inst", {
type: "permission.v2.asked",
properties: { id: "perm-retry", sessionID: "solo" },
})
await flushMicrotasks()
publishInstanceEvent(bus, "inst", {
type: "permission.v2.asked",
properties: { id: "perm-retry", sessionID: "solo" },
})
await flushMicrotasks()
assert.equal(replier.calls.length, 2)
manager.stop()
})
})
describe("AutoAcceptManager state events", () => {
it("publishes yolo.stateChanged with the new enabled value on toggle", () => {
const bus = new EventBus(noopLogger)
const changes: Record<string, unknown>[] = []
bus.on("yolo.stateChanged", (e) => changes.push(e))
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier() })
manager.start()
publishSession(bus, "inst", "session.updated", { id: "master", parentID: null })
manager.toggle("inst", "master")
manager.toggle("inst", "master")
assert.equal(changes.length, 2)
assert.equal((changes[0] as any).enabled, true)
assert.equal((changes[1] as any).enabled, false)
assert.equal((changes[0] as any).sessionId, "master")
assert.equal((changes[0] as any).instanceId, "inst")
manager.stop()
})
})
describe("AutoAcceptManager lifecycle", () => {
it("clearInstance drops tree and enabled state for the instance", () => {
const bus = new EventBus(noopLogger)
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier() })
manager.start()
publishSession(bus, "inst", "session.updated", { id: "master", parentID: null })
manager.toggle("inst", "master")
manager.clearInstance("inst")
assert.equal(manager.isEnabled("inst", "master"), false)
manager.stop()
})
it("clears state when the workspace stops", () => {
const bus = new EventBus(noopLogger)
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier() })
manager.start()
publishSession(bus, "inst", "session.updated", { id: "master", parentID: null })
manager.toggle("inst", "master")
bus.publish({ type: "workspace.stopped", workspaceId: "inst" })
assert.equal(manager.isEnabled("inst", "master"), false)
manager.stop()
})
it("stop() unsubscribes so no further events are processed", async () => {
const bus = new EventBus(noopLogger)
const replier = makeRecordingReplier()
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier })
manager.start()
manager.stop()
publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null })
manager.toggle("inst", "solo")
publishInstanceEvent(bus, "inst", {
type: "permission.v2.asked",
properties: { id: "p", sessionID: "solo" },
})
await flushMicrotasks()
assert.equal(replier.calls.length, 0)
})
})
describe("AutoAcceptManager pending permissions drain", () => {
it("drains a pending permission that arrived before enable", async () => {
const bus = new EventBus(noopLogger)
const replier = makeRecordingReplier()
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier })
manager.start()
publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null })
// permission arrives while yolo is OFF
publishInstanceEvent(bus, "inst", {
type: "permission.v2.asked",
properties: { id: "perm-pending", sessionID: "solo" },
})
await flushMicrotasks()
assert.equal(replier.calls.length, 0)
// enabling yolo should drain the pending permission
manager.toggle("inst", "solo")
await flushMicrotasks()
assert.equal(replier.calls.length, 1)
assert.equal(replier.calls[0].permissionId, "perm-pending")
manager.stop()
})
it("drains pending permissions for the same family only", async () => {
const bus = new EventBus(noopLogger)
const replier = makeRecordingReplier()
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier })
manager.start()
publishSession(bus, "inst", "session.updated", { id: "root-a", parentID: null })
publishSession(bus, "inst", "session.updated", { id: "root-b", parentID: null })
publishInstanceEvent(bus, "inst", {
type: "permission.v2.asked",
properties: { id: "perm-a", sessionID: "root-a" },
})
publishInstanceEvent(bus, "inst", {
type: "permission.v2.asked",
properties: { id: "perm-b", sessionID: "root-b" },
})
await flushMicrotasks()
assert.equal(replier.calls.length, 0)
manager.toggle("inst", "root-a")
await flushMicrotasks()
assert.equal(replier.calls.length, 1)
assert.equal(replier.calls[0].permissionId, "perm-a")
manager.stop()
})
it("does not re-drain already-auto-accepted permissions", async () => {
const bus = new EventBus(noopLogger)
const replier = makeRecordingReplier()
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier })
manager.start()
publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null })
manager.toggle("inst", "solo")
publishInstanceEvent(bus, "inst", {
type: "permission.v2.asked",
properties: { id: "perm-1", sessionID: "solo" },
})
await flushMicrotasks()
assert.equal(replier.calls.length, 1)
// toggling off then on should not re-drain the already-replied permission
manager.toggle("inst", "solo") // off
manager.toggle("inst", "solo") // on — drain runs but pending set is empty
await flushMicrotasks()
assert.equal(replier.calls.length, 1)
manager.stop()
})
it("re-drains pending when late session ancestry joins an enabled family", async () => {
const bus = new EventBus(noopLogger)
const replier = makeRecordingReplier()
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier })
manager.start()
// master exists, yolo enabled on master
publishSession(bus, "inst", "session.updated", { id: "master", parentID: null })
manager.toggle("inst", "master")
// child permission arrives BEFORE child's session ancestry is known.
// At this point "child" is unknown to the tree, so it resolves as its
// own root and the permission is NOT auto-accepted.
publishInstanceEvent(bus, "inst", {
type: "permission.v2.asked",
properties: { id: "perm-late", sessionID: "child" },
})
await flushMicrotasks()
assert.equal(replier.calls.length, 0)
// child session ancestry arrives — child.parentID = "master".
// ingestSession → upsertSession → migrateEnabledRoots makes "child"
// resolve to "master" (enabled). drainPending should fire and accept
// the previously-pending permission.
publishSession(bus, "inst", "session.updated", { id: "child", parentID: "master" })
await flushMicrotasks()
assert.equal(replier.calls.length, 1)
assert.equal(replier.calls[0].permissionId, "perm-late")
assert.equal(replier.calls[0].sessionId, "child")
manager.stop()
})
})
describe("AutoAcceptManager permission replied cleanup", () => {
it("removes a pending permission on permission.v2.replied", async () => {
const bus = new EventBus(noopLogger)
const replier = makeRecordingReplier()
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier })
manager.start()
publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null })
publishInstanceEvent(bus, "inst", {
type: "permission.v2.asked",
properties: { id: "perm-x", sessionID: "solo" },
})
await flushMicrotasks()
// user manually replies → replied event cleans up pending
publishInstanceEvent(bus, "inst", {
type: "permission.v2.replied",
properties: { id: "perm-x" },
})
// enabling yolo now should NOT drain the already-replied permission
manager.toggle("inst", "solo")
await flushMicrotasks()
assert.equal(replier.calls.length, 0)
manager.stop()
})
it("removes a pending permission on legacy permission.replied", async () => {
const bus = new EventBus(noopLogger)
const replier = makeRecordingReplier()
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier })
manager.start()
publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null })
publishInstanceEvent(bus, "inst", {
type: "permission.asked",
properties: { id: "perm-y", sessionID: "solo" },
})
await flushMicrotasks()
publishInstanceEvent(bus, "inst", {
type: "permission.replied",
properties: { requestID: "perm-y" },
})
manager.toggle("inst", "solo")
await flushMicrotasks()
assert.equal(replier.calls.length, 0)
manager.stop()
})
})
describe("AutoAcceptManager clearInstance clears pending", () => {
it("drops pending permissions on clearInstance", async () => {
const bus = new EventBus(noopLogger)
const replier = makeRecordingReplier()
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier })
manager.start()
publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null })
publishInstanceEvent(bus, "inst", {
type: "permission.v2.asked",
properties: { id: "perm-z", sessionID: "solo" },
})
await flushMicrotasks()
manager.clearInstance("inst")
// re-create session and enable — pending set should be empty
publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null })
manager.toggle("inst", "solo")
await flushMicrotasks()
assert.equal(replier.calls.length, 0)
manager.stop()
})
})
describe("AutoAcceptManager permission.updated source inference", () => {
it("preserves the original v2 source when permission.updated arrives", async () => {
const bus = new EventBus(noopLogger)
const replier = makeRecordingReplier()
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier })
manager.start()
publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null })
// yolo is OFF — permission goes to pending with source "v2"
publishInstanceEvent(bus, "inst", {
type: "permission.v2.asked",
properties: { id: "perm-v2", sessionID: "solo" },
})
await flushMicrotasks()
// enable yolo, then send permission.updated — should keep source "v2"
manager.toggle("inst", "solo")
publishInstanceEvent(bus, "inst", {
type: "permission.updated",
properties: { id: "perm-v2", sessionID: "solo" },
})
await flushMicrotasks()
assert.equal(replier.calls.length, 1)
assert.equal(replier.calls[0].source, "v2")
manager.stop()
})
it("skips permission.updated for a permission not in pending", async () => {
const bus = new EventBus(noopLogger)
const replier = makeRecordingReplier()
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier })
manager.start()
publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null })
manager.toggle("inst", "solo")
// permission.updated for a permission that was never asked (not in pending)
publishInstanceEvent(bus, "inst", {
type: "permission.updated",
properties: { id: "perm-unknown", sessionID: "solo" },
})
await flushMicrotasks()
assert.equal(replier.calls.length, 0)
manager.stop()
})
})
describe("AutoAcceptManager replier failure handling", () => {
it("keeps permission pending and does not emit autoAccepted on failure", async () => {
const bus = new EventBus(noopLogger)
const autoAccepted: unknown[] = []
bus.on("yolo.autoAccepted", (e) => autoAccepted.push(e))
const failingReplier: PermissionReplier = async () => {
throw new Error("connection refused")
}
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: failingReplier })
manager.start()
publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null })
manager.toggle("inst", "solo")
publishInstanceEvent(bus, "inst", {
type: "permission.v2.asked",
properties: { id: "perm-fail", sessionID: "solo" },
})
await flushMicrotasks()
assert.equal(autoAccepted.length, 0)
// permission should still be pending (enabling again would try again)
manager.toggle("inst", "solo") // off
manager.toggle("inst", "solo") // on — drain retries
await flushMicrotasks()
assert.equal(autoAccepted.length, 0)
manager.stop()
})
it("stops retrying after MAX_REPLY_ATTEMPTS", async () => {
const bus = new EventBus(noopLogger)
let callCount = 0
const failingReplier: PermissionReplier = async () => {
callCount++
throw new Error("timeout")
}
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: failingReplier })
manager.start()
publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null })
manager.toggle("inst", "solo")
publishInstanceEvent(bus, "inst", {
type: "permission.v2.asked",
properties: { id: "perm-stuck", sessionID: "solo" },
})
await flushMicrotasks()
const attemptsAfterFirst = callCount
// trigger drains via session events
publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null })
await flushMicrotasks()
publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null })
await flushMicrotasks()
publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null })
await flushMicrotasks()
// should have attempted at most MAX_REPLY_ATTEMPTS times total
assert.ok(callCount <= 3, `expected at most 3 attempts, got ${callCount}`)
assert.equal(callCount, attemptsAfterFirst + 2) // 2 more retries (total 3), then stops
manager.stop()
})
})
describe("AutoAcceptManager workspace.error cleanup", () => {
it("clears state when the workspace errors", () => {
const bus = new EventBus(noopLogger)
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier() })
manager.start()
publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null })
manager.toggle("inst", "solo")
assert.equal(manager.isEnabled("inst", "solo"), true)
bus.publish({ type: "workspace.error", workspace: { id: "inst" } as any })
assert.equal(manager.isEnabled("inst", "solo"), false)
manager.stop()
})
})
describe("AutoAcceptManager session.deleted clears pending", () => {
it("removes pending permissions for a deleted session", async () => {
const bus = new EventBus(noopLogger)
const replier = makeRecordingReplier()
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier })
manager.start()
publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null })
// permission arrives while yolo is OFF → goes to pending
publishInstanceEvent(bus, "inst", {
type: "permission.v2.asked",
properties: { id: "perm-doomed", sessionID: "solo" },
})
await flushMicrotasks()
// session deleted → pending should be cleared
publishSession(bus, "inst", "session.deleted", { id: "solo" })
// enabling yolo should not drain the deleted session's permission
manager.toggle("inst", "solo")
await flushMicrotasks()
assert.equal(replier.calls.length, 0)
manager.stop()
})
})
function makeRecordingReplier() {
const calls: AutoAcceptReply[] = []
const replier: PermissionReplier = async (reply) => {
calls.push(reply)
}
return Object.assign(replier, { calls }) as PermissionReplier & { calls: AutoAcceptReply[] }
}
function flushMicrotasks() {
return new Promise<void>((resolve) => setImmediate(resolve))
}

View file

@ -0,0 +1,475 @@
import type { EventBus } from "../events/bus"
import type { Logger } from "../logger"
import { AutoAcceptStore, type AutoAcceptSessionInfo } from "./auto-accept-store"
/**
* Server-side owner of Yolo (permission auto-accept).
*
* Subscribes to the instance SSE stream that the server already consumes
* (`InstanceEventBridge` -> EventBus `instance.event`) and:
* - maintains a per-instance session tree so family-root inheritance can
* be resolved identically to the previous frontend implementation
* - when a permission request arrives for an enabled family, auto-replies
* via the injected {@link PermissionReplier} (same `"once"` semantics the
* UI used to send)
* - emits `yolo.stateChanged` / `yolo.autoAccepted` events on the EventBus
* so the UI stays a pure view
*/
export type PermissionSource = "v2" | "legacy"
export type PermissionReplyValue = "once"
export interface AutoAcceptReply {
instanceId: string
permissionId: string
sessionId: string
source: PermissionSource
reply: PermissionReplyValue
}
export type PermissionReplier = (reply: AutoAcceptReply) => Promise<void>
interface PendingPermission {
permissionId: string
sessionId: string
source: PermissionSource
}
interface AutoAcceptManagerDeps {
eventBus: EventBus
logger: Logger
replier: PermissionReplier
persistence?: AutoAcceptPersistence
}
export interface PersistedAutoAcceptSession extends AutoAcceptSessionInfo {
yoloEnabled: boolean
workspaceId?: string
}
export interface AutoAcceptPersistence {
loadSessions(instanceId: string): Promise<PersistedAutoAcceptSession[]>
persist(instanceId: string, rootSessionId: string, enabled: boolean, workspaceId?: string): Promise<void>
}
const PERMISSION_ASK_TYPES = new Set(["permission.v2.asked", "permission.asked", "permission.updated"])
const PERMISSION_REPLIED_TYPES = new Set(["permission.v2.replied", "permission.replied"])
const SESSION_UPSERT_TYPES = new Set(["session.updated", "session.created"])
const SESSION_REMOVE_TYPES = new Set(["session.deleted"])
export class AutoAcceptManager {
private static readonly MAX_REPLY_ATTEMPTS = 3
private readonly store = new AutoAcceptStore()
/** instanceId:permissionId entries currently being replied, to dedupe re-emissions */
private readonly inFlight = new Set<string>()
/** instanceId -> (permissionId -> pending permission) awaiting a reply */
private readonly pending = new Map<string, Map<string, PendingPermission>>()
/** instanceId:permissionId -> failure count, to stop retrying stuck permissions */
private readonly replyAttempts = new Map<string, number>()
private readonly hydratedInstances = new Set<string>()
private readonly hydration = new Map<string, Promise<void>>()
private readonly queuedEvents = new Map<string, InstanceStreamPayload[]>()
private readonly instanceGeneration = new Map<string, number>()
private readonly sessionWorkspaces = new Map<string, Map<string, string>>()
private readonly mutations = new Map<string, Promise<boolean>>()
private unsubscribe?: () => void
constructor(private readonly deps: AutoAcceptManagerDeps) {}
start(): void {
if (this.unsubscribe) return
const handler = (payload: { instanceId?: string; event?: InstanceStreamPayload }) => {
if (!payload || !payload.instanceId || !payload.event) return
if (this.deps.persistence && !this.hydratedInstances.has(payload.instanceId)) {
const queued = this.queuedEvents.get(payload.instanceId) ?? []
queued.push(payload.event)
this.queuedEvents.set(payload.instanceId, queued)
void this.hydrateInstance(payload.instanceId).catch((error) => {
this.deps.logger.warn({ instanceId: payload.instanceId, err: error }, "Failed to hydrate persisted Yolo state")
})
return
}
this.handleInstanceEvent(payload.instanceId, payload.event)
}
const onStarted = (event: { workspace?: { id?: string } }) => {
const instanceId = event.workspace?.id
if (!instanceId) return
void this.hydrateInstance(instanceId).catch((error) => {
this.deps.logger.warn({ instanceId, err: error }, "Failed to hydrate persisted Yolo state")
})
}
const onStopped = (event: { workspaceId?: string }) => {
if (event?.workspaceId) this.clearInstance(event.workspaceId)
}
const onError = (event: { workspace?: { id?: string } }) => {
if (event?.workspace?.id) this.clearInstance(event.workspace.id)
}
this.deps.eventBus.on("instance.event", handler)
this.deps.eventBus.on("workspace.started", onStarted)
this.deps.eventBus.on("workspace.stopped", onStopped)
this.deps.eventBus.on("workspace.error", onError)
this.unsubscribe = () => {
this.deps.eventBus.off("instance.event", handler)
this.deps.eventBus.off("workspace.started", onStarted)
this.deps.eventBus.off("workspace.stopped", onStopped)
this.deps.eventBus.off("workspace.error", onError)
}
}
stop(): void {
this.unsubscribe?.()
this.unsubscribe = undefined
}
isEnabled(instanceId: string, sessionId: string): boolean {
return this.store.isEnabled(instanceId, sessionId)
}
hydrateInstance(instanceId: string): Promise<void> {
if (!this.deps.persistence || this.hydratedInstances.has(instanceId)) return Promise.resolve()
const existing = this.hydration.get(instanceId)
if (existing) return existing
const generation = this.instanceGeneration.get(instanceId) ?? 0
let hydrated = false
const pending = this.deps.persistence.loadSessions(instanceId).then((sessions) => {
if ((this.instanceGeneration.get(instanceId) ?? 0) !== generation) return
this.store.clearInstance(instanceId)
const workspaces = new Map<string, string>()
for (const session of sessions) {
this.store.upsertSession(instanceId, session)
if (session.workspaceId) workspaces.set(session.id, session.workspaceId)
}
this.sessionWorkspaces.set(instanceId, workspaces)
for (const session of sessions) {
if (!session.yoloEnabled || this.store.familyRoot(instanceId, session.id) !== session.id) continue
this.store.setEnabled(instanceId, session.id, true)
this.deps.eventBus.publish({ type: "yolo.stateChanged", instanceId, sessionId: session.id, enabled: true })
this.drainPending(instanceId, session.id)
}
this.hydratedInstances.add(instanceId)
hydrated = true
}).finally(() => {
if ((this.instanceGeneration.get(instanceId) ?? 0) !== generation) return
this.hydration.delete(instanceId)
if (!hydrated) return
const queued = this.queuedEvents.get(instanceId) ?? []
this.queuedEvents.delete(instanceId)
for (const event of queued) this.handleInstanceEvent(instanceId, event)
})
this.hydration.set(instanceId, pending)
return pending
}
toggle(instanceId: string, sessionId: string): boolean | Promise<boolean> {
if (this.deps.persistence) return this.togglePersisted(instanceId, sessionId)
const enabled = this.store.toggle(instanceId, sessionId)
this.deps.eventBus.publish({ type: "yolo.stateChanged", instanceId, sessionId, enabled })
if (enabled) {
this.drainPending(instanceId, sessionId)
}
return enabled
}
private async togglePersisted(instanceId: string, sessionId: string): Promise<boolean> {
await this.hydrateInstance(instanceId)
const generation = this.instanceGeneration.get(instanceId) ?? 0
const mutation = (this.mutations.get(instanceId) ?? Promise.resolve(false)).catch(() => false).then(async () => {
if ((this.instanceGeneration.get(instanceId) ?? 0) !== generation) {
return this.store.isEnabled(instanceId, sessionId)
}
const rootSessionId = this.store.familyRoot(instanceId, sessionId)
const traversedRootSessionIds = new Set([rootSessionId])
const enabled = !this.store.isEnabled(instanceId, rootSessionId)
await this.deps.persistence!.persist(
instanceId,
rootSessionId,
enabled,
this.sessionWorkspaces.get(instanceId)?.get(rootSessionId),
)
if ((this.instanceGeneration.get(instanceId) ?? 0) !== generation) {
return this.store.isEnabled(instanceId, rootSessionId)
}
let persistedRootSessionId = rootSessionId
let currentRootSessionId = this.store.familyRoot(instanceId, sessionId)
while (currentRootSessionId !== persistedRootSessionId) {
traversedRootSessionIds.add(currentRootSessionId)
await this.deps.persistence!.persist(
instanceId,
currentRootSessionId,
enabled,
this.sessionWorkspaces.get(instanceId)?.get(currentRootSessionId),
)
if (enabled) {
await this.deps.persistence!.persist(
instanceId,
persistedRootSessionId,
false,
this.sessionWorkspaces.get(instanceId)?.get(persistedRootSessionId),
)
}
persistedRootSessionId = currentRootSessionId
currentRootSessionId = this.store.familyRoot(instanceId, sessionId)
}
if ((this.instanceGeneration.get(instanceId) ?? 0) !== generation) {
return this.store.isEnabled(instanceId, currentRootSessionId)
}
if (enabled) {
this.store.setEnabled(instanceId, currentRootSessionId, true)
} else {
for (const traversedRootSessionId of traversedRootSessionIds) {
this.store.setEnabled(instanceId, traversedRootSessionId, false)
}
}
this.deps.eventBus.publish({ type: "yolo.stateChanged", instanceId, sessionId: currentRootSessionId, enabled })
if (enabled) this.drainPending(instanceId, currentRootSessionId)
return enabled
})
const settled = mutation.finally(() => {
if (this.mutations.get(instanceId) === settled) this.mutations.delete(instanceId)
})
this.mutations.set(instanceId, settled)
return settled
}
clearInstance(instanceId: string): void {
this.instanceGeneration.set(instanceId, (this.instanceGeneration.get(instanceId) ?? 0) + 1)
this.hydratedInstances.delete(instanceId)
this.hydration.delete(instanceId)
this.queuedEvents.delete(instanceId)
this.sessionWorkspaces.delete(instanceId)
this.mutations.delete(instanceId)
this.store.clearInstance(instanceId)
this.pending.delete(instanceId)
const prefix = `${instanceId}:`
for (const key of Array.from(this.inFlight.keys())) {
if (key.startsWith(prefix)) this.inFlight.delete(key)
}
for (const key of Array.from(this.replyAttempts.keys())) {
if (key.startsWith(prefix)) this.replyAttempts.delete(key)
}
}
handleInstanceEvent(instanceId: string, event: InstanceStreamPayload): void {
if (!event || typeof event.type !== "string") return
if (SESSION_UPSERT_TYPES.has(event.type)) {
this.ingestSession(instanceId, event.properties)
return
}
if (SESSION_REMOVE_TYPES.has(event.type)) {
const info = (event.properties as { info?: SessionProperties } | undefined)?.info
const id = readString(info?.id) ?? readString(event.properties?.id)
if (id) {
this.store.removeSession(instanceId, id)
this.removePendingForSession(instanceId, id)
}
return
}
if (PERMISSION_REPLIED_TYPES.has(event.type)) {
this.handlePermissionReplied(instanceId, event.properties)
return
}
if (PERMISSION_ASK_TYPES.has(event.type)) {
this.handlePermissionRequest(instanceId, event.type, event.properties)
}
}
private ingestSession(instanceId: string, properties: unknown): void {
// OpenCode wraps session records under `properties.info` for
// session.created/updated/deleted (see SDK EventSessionUpdated). Accept a
// flat fallback only for defensive compatibility.
const info = (properties as { info?: SessionProperties } | SessionProperties | undefined)
const session = (info && typeof info === "object" && "info" in info ? info.info : info) as
| SessionProperties
| undefined
if (!session || typeof session.id !== "string") return
const parentId = session.parentID ?? session.parentId ?? null
const revert = session.revert ?? undefined
const enabledBefore = this.store.enabledRoots(instanceId)
this.store.upsertSession(instanceId, { id: session.id, parentId, revert })
if (typeof session.workspaceID === "string" && session.workspaceID) {
const workspaces = this.sessionWorkspaces.get(instanceId) ?? new Map<string, string>()
workspaces.set(session.id, session.workspaceID)
this.sessionWorkspaces.set(instanceId, workspaces)
}
this.persistRootMigration(instanceId, enabledBefore, this.store.enabledRoots(instanceId))
// Session ancestry may have changed (parent discovered, revert toggled).
// Re-drain pending permissions whose family root may have migrated into
// an enabled family — mirrors the old UI's drainAutoAcceptPermissions-
// ForInstance trigger on session.updated (#497).
this.drainPending(instanceId, session.id)
}
private persistRootMigration(instanceId: string, before: readonly string[], after: readonly string[]): void {
if (!this.deps.persistence) return
const removed = before.filter((id) => !after.includes(id))
const added = after.filter((id) => !before.includes(id))
if (removed.length === 0 && added.length === 0) return
const generation = this.instanceGeneration.get(instanceId) ?? 0
const mutation = (this.mutations.get(instanceId) ?? Promise.resolve(false)).catch(() => false).then(async () => {
if ((this.instanceGeneration.get(instanceId) ?? 0) !== generation) return false
const enabledRoots = new Set(this.store.enabledRoots(instanceId))
for (const rootSessionId of added) {
if (!enabledRoots.has(rootSessionId)) continue
await this.deps.persistence!.persist(
instanceId, rootSessionId, true, this.sessionWorkspaces.get(instanceId)?.get(rootSessionId),
)
}
for (const rootSessionId of removed) {
if (enabledRoots.has(rootSessionId)) continue
if ((this.instanceGeneration.get(instanceId) ?? 0) !== generation) return false
await this.deps.persistence!.persist(
instanceId, rootSessionId, false, this.sessionWorkspaces.get(instanceId)?.get(rootSessionId),
)
}
return false
})
const settled = mutation.finally(() => {
if (this.mutations.get(instanceId) === settled) this.mutations.delete(instanceId)
})
this.mutations.set(instanceId, settled)
void settled.catch((error) => {
this.deps.logger.warn({ instanceId, err: error }, "Failed to migrate persisted Yolo family root")
})
}
private handlePermissionRequest(instanceId: string, eventType: string, permission: unknown): void {
const request = permission as PermissionProperties | undefined
if (!request) return
const permissionId = readString(request.id)
const sessionId = readString(request.sessionID) ?? readString(request.sessionId)
if (!permissionId || !sessionId) return
// Infer source from the event type, but prefer the already-tracked source
// for permission.updated (which may belong to a v2 permission).
const existing = this.pending.get(instanceId)?.get(permissionId)
const source: PermissionSource = eventType === "permission.v2.asked" ? "v2" : (existing?.source ?? "legacy")
// `permission.updated` represents a detail change for a permission that
// is *already* pending. If it is no longer in our pending set it was
// already replied to (by us or the user) — skip to avoid a duplicate reply.
if (eventType === "permission.updated" && !this.pending.get(instanceId)?.has(permissionId)) {
return
}
this.addPending(instanceId, { permissionId, sessionId, source })
if (!this.store.isEnabled(instanceId, sessionId)) return
this.tryAutoAccept(instanceId, permissionId, sessionId, source)
}
private handlePermissionReplied(instanceId: string, properties: unknown): void {
const request = (properties as PermissionRepliedProperties | undefined) ?? {}
const permissionId =
readString(request.id) ??
readString(request.requestID) ??
readString(request.permissionID) ??
readString(request.requestId) ??
readString(request.permissionId)
if (permissionId) this.removePending(instanceId, permissionId)
}
private tryAutoAccept(
instanceId: string,
permissionId: string,
sessionId: string,
source: PermissionSource,
): void {
const key = `${instanceId}:${permissionId}`
if (this.inFlight.has(key)) return
const attempts = this.replyAttempts.get(key) ?? 0
if (attempts >= AutoAcceptManager.MAX_REPLY_ATTEMPTS) return
this.inFlight.add(key)
this.replyAttempts.set(key, attempts + 1)
const reply: AutoAcceptReply = { instanceId, permissionId, sessionId, source, reply: "once" }
void this.deps.replier(reply)
.then(() => {
this.replyAttempts.delete(key)
this.removePending(instanceId, permissionId)
this.deps.eventBus.publish({ type: "yolo.autoAccepted", instanceId, sessionId, permissionId })
})
.catch((error) => {
this.deps.logger.error({ instanceId, permissionId, err: error, attempt: attempts + 1 }, "Yolo auto-accept reply failed")
if (attempts + 1 >= AutoAcceptManager.MAX_REPLY_ATTEMPTS) {
this.removePending(instanceId, permissionId)
}
})
.finally(() => {
this.inFlight.delete(key)
})
}
/** Auto-accept all pending permissions belonging to the same family root. */
private drainPending(instanceId: string, sessionId: string): void {
const instancePending = this.pending.get(instanceId)
if (!instancePending || instancePending.size === 0) return
const root = this.store.familyRoot(instanceId, sessionId)
for (const entry of Array.from(instancePending.values())) {
if (this.store.familyRoot(instanceId, entry.sessionId) === root) {
this.tryAutoAccept(instanceId, entry.permissionId, entry.sessionId, entry.source)
}
}
}
private addPending(instanceId: string, entry: PendingPermission): void {
let instancePending = this.pending.get(instanceId)
if (!instancePending) {
instancePending = new Map()
this.pending.set(instanceId, instancePending)
}
instancePending.set(entry.permissionId, entry)
}
private removePending(instanceId: string, permissionId: string): void {
const instancePending = this.pending.get(instanceId)
if (instancePending?.delete(permissionId)) {
this.replyAttempts.delete(`${instanceId}:${permissionId}`)
if (instancePending.size === 0) this.pending.delete(instanceId)
}
}
private removePendingForSession(instanceId: string, sessionId: string): void {
const instancePending = this.pending.get(instanceId)
if (!instancePending) return
for (const [permId, entry] of Array.from(instancePending)) {
if (entry.sessionId === sessionId) {
instancePending.delete(permId)
this.replyAttempts.delete(`${instanceId}:${permId}`)
}
}
if (instancePending.size === 0) this.pending.delete(instanceId)
}
}
interface InstanceStreamPayload {
type?: string
properties?: Record<string, unknown>
}
interface SessionProperties {
id?: string
parentID?: string | null
parentId?: string | null
revert?: unknown
workspaceID?: string
}
interface PermissionProperties {
id?: string
sessionID?: string
sessionId?: string
}
interface PermissionRepliedProperties {
id?: string
requestID?: string
permissionID?: string
requestId?: string
permissionId?: string
}
function readString(value: unknown): string | undefined {
return typeof value === "string" && value.length > 0 ? value : undefined
}

View file

@ -0,0 +1,182 @@
import assert from "node:assert/strict"
import { describe, it } from "node:test"
import { AutoAcceptStore, resolveFamilyRoot } from "./auto-accept-store"
describe("resolveFamilyRoot", () => {
it("returns the session id itself when no info is known", () => {
assert.equal(resolveFamilyRoot("orphan", () => undefined), "orphan")
})
it("keeps a loaded child as root when its parent is missing", () => {
const root = resolveFamilyRoot("child", (id) =>
id === "child" ? { id: "child", parentId: "parent" } : undefined,
)
assert.equal(root, "child")
})
it("resolves to the master session when the full parent chain is loaded", () => {
const root = resolveFamilyRoot("grandchild", (id) => {
if (id === "grandchild") return { id: "grandchild", parentId: "child" }
if (id === "child") return { id: "child", parentId: "master" }
if (id === "master") return { id: "master", parentId: null }
return undefined
})
assert.equal(root, "master")
})
it("keeps a fork session (with revert) as its own root", () => {
const root = resolveFamilyRoot("fork", (id) => {
if (id === "fork")
return { id: "fork", parentId: "master", revert: { messageID: "msg", partID: "part" } }
if (id === "master") return { id: "master", parentId: null }
return undefined
})
assert.equal(root, "fork")
})
it("terminates on cyclic parent chains without looping forever", () => {
const root = resolveFamilyRoot("a", (id) => {
if (id === "a") return { id: "a", parentId: "b" }
if (id === "b") return { id: "b", parentId: "a" }
return undefined
})
// cycle: last known id before re-entering the cycle is returned
assert.ok(root === "a" || root === "b")
})
})
describe("AutoAcceptStore inheritance", () => {
it("is disabled by default for an unknown session", () => {
const store = new AutoAcceptStore()
assert.equal(store.isEnabled("inst", "s1"), false)
})
it("enabling a parent enables every descendant that resolves to it", () => {
const store = new AutoAcceptStore()
store.upsertSession("inst", { id: "master", parentId: null })
store.upsertSession("inst", { id: "child", parentId: "master" })
store.upsertSession("inst", { id: "grandchild", parentId: "child" })
store.setEnabled("inst", "master", true)
assert.equal(store.isEnabled("inst", "master"), true)
assert.equal(store.isEnabled("inst", "child"), true)
assert.equal(store.isEnabled("inst", "grandchild"), true)
})
it("enabling a child also covers the parent family root and siblings", () => {
const store = new AutoAcceptStore()
store.upsertSession("inst", { id: "master", parentId: null })
store.upsertSession("inst", { id: "child-a", parentId: "master" })
store.upsertSession("inst", { id: "child-b", parentId: "master" })
store.setEnabled("inst", "child-a", true)
assert.equal(store.isEnabled("inst", "child-a"), true)
assert.equal(store.isEnabled("inst", "child-b"), true)
assert.equal(store.isEnabled("inst", "master"), true)
})
it("a fork session is isolated: enabling it does not enable its parent", () => {
const store = new AutoAcceptStore()
store.upsertSession("inst", { id: "master", parentId: null })
store.upsertSession("inst", {
id: "fork",
parentId: "master",
revert: { messageID: "msg", partID: "part" },
})
store.setEnabled("inst", "fork", true)
assert.equal(store.isEnabled("inst", "fork"), true)
assert.equal(store.isEnabled("inst", "master"), false)
})
it("disabling the family root clears the setting for all descendants", () => {
const store = new AutoAcceptStore()
store.upsertSession("inst", { id: "master", parentId: null })
store.upsertSession("inst", { id: "child", parentId: "master" })
store.setEnabled("inst", "child", true)
assert.equal(store.isEnabled("inst", "child"), true)
store.setEnabled("inst", "master", false)
assert.equal(store.isEnabled("inst", "child"), false)
assert.equal(store.isEnabled("inst", "master"), false)
})
it("toggle flips the resolved family-root state and reports the new value", () => {
const store = new AutoAcceptStore()
store.upsertSession("inst", { id: "master", parentId: null })
store.upsertSession("inst", { id: "child", parentId: "master" })
assert.equal(store.toggle("inst", "child"), true)
assert.equal(store.isEnabled("inst", "child"), true)
assert.equal(store.toggle("inst", "master"), false)
assert.equal(store.isEnabled("inst", "child"), false)
})
it("keeps per-instance state independent", () => {
const store = new AutoAcceptStore()
store.upsertSession("inst-a", { id: "root", parentId: null })
store.upsertSession("inst-b", { id: "root", parentId: null })
store.setEnabled("inst-a", "root", true)
assert.equal(store.isEnabled("inst-a", "root"), true)
assert.equal(store.isEnabled("inst-b", "root"), false)
})
})
describe("AutoAcceptStore session tree maintenance", () => {
it("migrates enabled root when a parent is discovered later", () => {
const store = new AutoAcceptStore()
store.upsertSession("inst", { id: "child", parentId: "parent" })
// parent unknown -> child is its own root
store.setEnabled("inst", "child", true)
// later the parent shows up — root should migrate from "child" to "parent"
store.upsertSession("inst", { id: "parent", parentId: null })
assert.equal(store.isEnabled("inst", "parent"), true)
assert.equal(store.isEnabled("inst", "child"), true)
})
it("removing a session does not clear an enabled family root", () => {
const store = new AutoAcceptStore()
store.upsertSession("inst", { id: "master", parentId: null })
store.setEnabled("inst", "master", true)
store.removeSession("inst", "master")
// the toggle is independent of the session tree: it survives session deletion
assert.equal(store.isEnabled("inst", "master"), true)
})
it("clearInstance drops both tree and enabled state", () => {
const store = new AutoAcceptStore()
store.upsertSession("inst", { id: "master", parentId: null })
store.setEnabled("inst", "master", true)
store.clearInstance("inst")
assert.equal(store.isEnabled("inst", "master"), false)
store.upsertSession("inst", { id: "master", parentId: null })
assert.equal(store.isEnabled("inst", "master"), false)
})
it("changing revert status re-roots a session as a fork", () => {
const store = new AutoAcceptStore()
store.upsertSession("inst", { id: "master", parentId: null })
store.upsertSession("inst", { id: "child", parentId: "master" })
store.setEnabled("inst", "child", true)
// parent family enabled
assert.equal(store.isEnabled("inst", "master"), true)
// child becomes a fork
store.upsertSession("inst", {
id: "child",
parentId: "master",
revert: { messageID: "m", partID: "p" },
})
// now child resolves to itself; the family setting was on "master" so still on for master
assert.equal(store.isEnabled("inst", "master"), true)
// child is its own root now, not enabled unless toggled
assert.equal(store.isEnabled("inst", "child"), false)
})
})

View file

@ -0,0 +1,132 @@
/**
* In-memory permission auto-accept (Yolo) state, owned by the server.
*
* This is a faithful port of the previous frontend implementation
* (`packages/ui/src/stores/permission-auto-accept.ts`) so the inheritance
* semantics are preserved exactly:
* - state is keyed by the resolved *family root* session id
* - a session with a `revert` snapshot is treated as its own root (fork)
* - enabling any session enables its whole family root and vice-versa
*
* This store remains in-memory; AutoAcceptManager hydrates and persists it
* through OpenCode session metadata.
*/
export interface AutoAcceptSessionInfo {
id: string
parentId?: string | null
/** Truthy value marks the session as a fork that roots at itself. */
revert?: unknown
}
type SessionLookup = (sessionId: string) => AutoAcceptSessionInfo | undefined
/**
* Resolve the family-root session id for `sessionId` by walking the parent
* chain. Mirrors `resolvePermissionAutoAcceptFamilyRoot` from the UI so
* inheritance behaviour does not change.
*/
export function resolveFamilyRoot(sessionId: string, getSession: SessionLookup): string {
let currentId = sessionId
let lastKnownId = sessionId
const seen = new Set<string>()
while (currentId && !seen.has(currentId)) {
seen.add(currentId)
const session = getSession(currentId)
if (!session) return lastKnownId
lastKnownId = session.id
if (session.revert) return session.id
if (!session.parentId) return session.id
currentId = session.parentId
}
return currentId || sessionId
}
export class AutoAcceptStore {
/** instanceId -> set of enabled family-root session ids */
private readonly enabled = new Map<string, Set<string>>()
/** instanceId -> (sessionId -> info) */
private readonly sessions = new Map<string, Map<string, AutoAcceptSessionInfo>>()
isEnabled(instanceId: string, sessionId: string): boolean {
const root = this.familyRoot(instanceId, sessionId)
return this.enabled.get(instanceId)?.has(root) ?? false
}
setEnabled(instanceId: string, sessionId: string, enabled: boolean): void {
const root = this.familyRoot(instanceId, sessionId)
let roots = this.enabled.get(instanceId)
if (!roots) {
if (!enabled) return
roots = new Set()
this.enabled.set(instanceId, roots)
}
if (enabled) {
roots.add(root)
} else {
roots.delete(root)
if (roots.size === 0) {
this.enabled.delete(instanceId)
}
}
}
toggle(instanceId: string, sessionId: string): boolean {
const next = !this.isEnabled(instanceId, sessionId)
this.setEnabled(instanceId, sessionId, next)
return next
}
upsertSession(instanceId: string, info: AutoAcceptSessionInfo): void {
let tree = this.sessions.get(instanceId)
if (!tree) {
tree = new Map()
this.sessions.set(instanceId, tree)
}
tree.set(info.id, {
id: info.id,
parentId: info.parentId ?? null,
revert: info.revert,
})
this.migrateEnabledRoots(instanceId)
}
removeSession(instanceId: string, sessionId: string): void {
this.sessions.get(instanceId)?.delete(sessionId)
}
clearInstance(instanceId: string): void {
this.sessions.delete(instanceId)
this.enabled.delete(instanceId)
}
/** Resolves the family-root session id for the given session. */
familyRoot(instanceId: string, sessionId: string): string {
const tree = this.sessions.get(instanceId)
return resolveFamilyRoot(sessionId, (id) => tree?.get(id))
}
enabledRoots(instanceId: string): string[] {
return [...(this.enabled.get(instanceId) ?? [])]
}
/**
* Re-resolves every enabled family root for an instance after the session
* tree changes (new session, updated parent/revert). If a root now resolves
* to a different id, the enabled entry is migrated so toggles survive late
* ancestry discovery.
*/
private migrateEnabledRoots(instanceId: string): void {
const roots = this.enabled.get(instanceId)
if (!roots || roots.size === 0) return
for (const oldRoot of Array.from(roots)) {
const newRoot = this.familyRoot(instanceId, oldRoot)
if (newRoot !== oldRoot) {
roots.delete(oldRoot)
roots.add(newRoot)
}
}
}
}

View file

@ -0,0 +1,47 @@
import type { WorkspaceManager } from "../workspaces/manager"
import type { Logger } from "../logger"
import { createInstanceClient } from "../workspaces/instance-client"
import type { AutoAcceptReply, PermissionReplier } from "./auto-accept-manager"
interface OpencodeReplierDeps {
workspaceManager: WorkspaceManager
logger: Logger
}
/**
* Default {@link PermissionReplier} that calls the OpenCode instance via the
* generated SDK client over loopback, using the same `"once"` reply the UI
* previously sent.
*
* Uses `createInstanceClient` so routes and body shapes are always correct
* for the installed SDK version no hand-assembled URLs.
*/
export function createOpencodePermissionReplier(deps: OpencodeReplierDeps): PermissionReplier {
return async (reply: AutoAcceptReply) => {
const client = createInstanceClient(deps.workspaceManager, reply.instanceId)
if (!client) {
throw new Error(`Yolo: instance ${reply.instanceId} has no open port`)
}
const opts = { throwOnError: true } as const
if (reply.source === "v2") {
await client.v2.session.permission.reply(
{
sessionID: reply.sessionId,
requestID: reply.permissionId,
reply: reply.reply,
},
opts,
)
} else {
await client.permission.reply(
{
requestID: reply.permissionId,
reply: reply.reply,
},
opts,
)
}
}
}

View file

@ -0,0 +1,61 @@
import assert from "node:assert/strict"
import { describe, it } from "node:test"
import { createOpencodeYoloPersistence, hasPersistedYolo, mergePersistedYolo } from "./opencode-yolo-metadata"
describe("OpenCode Yolo metadata", () => {
it("preserves unrelated metadata while replacing Yolo state", () => {
assert.deepEqual(
mergePersistedYolo({ thirdParty: { keep: true }, codenomad: { version: 1, worktreeSlug: "feature" } }, "root", true),
{
thirdParty: { keep: true },
codenomad: { version: 1, worktreeSlug: "feature", yolo: { enabled: true, rootSessionId: "root" } },
},
)
})
it("accepts only a marker owned by its session", () => {
const metadata = mergePersistedYolo({}, "root", true)
assert.equal(hasPersistedYolo("root", metadata), true)
assert.equal(hasPersistedYolo("fork", metadata), false)
assert.equal(hasPersistedYolo("root", mergePersistedYolo({}, "root", false)), false)
})
it("uses the session workspace for metadata updates", async () => {
const calls: Array<Record<string, unknown>> = []
const client = {
session: {
async list() { return { data: [{ id: "root", parentID: null, workspaceID: "workspace", metadata: {} }] } },
async get(parameters: Record<string, unknown>) { calls.push(parameters); return { data: { metadata: {} } } },
async update(parameters: Record<string, unknown>) { calls.push(parameters); return { data: {} } },
},
}
const persistence = createOpencodeYoloPersistence({} as never, () => client as never)
const [session] = await persistence.loadSessions("instance")
await persistence.persist("instance", "root", true, session?.workspaceId)
assert.equal(session?.workspaceId, "workspace")
assert.equal(calls[0]?.workspace, "workspace")
assert.equal(calls[1]?.workspace, "workspace")
})
it("serializes Yolo and worktree metadata writes across instances", async () => {
let metadata: Record<string, unknown> = { thirdParty: true }
const client = {
session: {
async get() { return { data: { metadata } } },
async update(parameters: Record<string, unknown>) {
metadata = parameters.metadata as Record<string, unknown>
return { data: { metadata } }
},
},
}
const persistence = createOpencodeYoloPersistence({} as never, () => client as never)
await Promise.all([
persistence.persist("instance-a", "root", true),
persistence.setWorktreeSlug("instance-b", "root", "feature"),
])
assert.deepEqual(metadata, {
thirdParty: true,
codenomad: { version: 1, yolo: { enabled: true, rootSessionId: "root" }, worktreeSlug: "feature" },
})
})
})

View file

@ -0,0 +1,111 @@
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
import type { WorkspaceManager } from "../workspaces/manager"
import { createInstanceClient } from "../workspaces/instance-client"
import type { AutoAcceptPersistence, PersistedAutoAcceptSession } from "./auto-accept-manager"
const CODENOMAD_METADATA_VERSION = 1
const SESSION_LIST_LIMIT = 10_000
type Metadata = Record<string, unknown>
export interface OpencodeYoloPersistence extends AutoAcceptPersistence {
hasProjectSession(instanceId: string, sessionId: string): Promise<boolean>
setWorktreeSlug(instanceId: string, sessionId: string, worktreeSlug: string): Promise<Metadata>
}
function record(value: unknown): Metadata {
return value && typeof value === "object" && !Array.isArray(value) ? { ...(value as Metadata) } : {}
}
export function hasPersistedYolo(sessionId: string, metadata: unknown): boolean {
const codenomad = record(record(metadata).codenomad)
const yolo = record(codenomad.yolo)
return codenomad.version === CODENOMAD_METADATA_VERSION
&& yolo.enabled === true
&& yolo.rootSessionId === sessionId
}
export function mergePersistedYolo(metadata: unknown, rootSessionId: string, enabled: boolean): Metadata {
const current = record(metadata)
const codenomad = record(current.codenomad)
return {
...current,
codenomad: {
...codenomad,
version: CODENOMAD_METADATA_VERSION,
yolo: { enabled, rootSessionId },
},
}
}
export function mergePersistedWorktreeSlug(metadata: unknown, worktreeSlug: string): Metadata {
const current = record(metadata)
const codenomad = record(current.codenomad)
return {
...current,
codenomad: { ...codenomad, version: CODENOMAD_METADATA_VERSION, worktreeSlug },
}
}
export function createOpencodeYoloPersistence(
workspaceManager: WorkspaceManager,
createClient: (manager: WorkspaceManager, instanceId: string) => OpencodeClient | null = createInstanceClient,
): OpencodeYoloPersistence {
const writes = new Map<string, Promise<unknown>>()
const clientFor = (instanceId: string) => {
const client = createClient(workspaceManager, instanceId)
if (!client) throw new Error(`Yolo: instance ${instanceId} has no open port`)
return client
}
const updateMetadata = (
instanceId: string,
sessionId: string,
workspaceId: string | undefined,
update: (metadata: unknown) => Metadata,
): Promise<Metadata> => {
const writeKey = sessionId
const write = (writes.get(writeKey) ?? Promise.resolve()).catch(() => undefined).then(async () => {
const client = clientFor(instanceId)
const scope = { sessionID: sessionId, ...(workspaceId ? { workspace: workspaceId } : {}) }
const { data: session } = await client.session.get(scope, { throwOnError: true })
const metadata = update(session.metadata)
const { data } = await client.session.update({ ...scope, metadata }, { throwOnError: true })
return record(data?.metadata ?? metadata)
})
const settled = write.finally(() => {
if (writes.get(writeKey) === settled) writes.delete(writeKey)
})
writes.set(writeKey, settled)
return settled
}
return {
async loadSessions(instanceId): Promise<PersistedAutoAcceptSession[]> {
const { data } = await clientFor(instanceId).session.list(
{ scope: "project", limit: SESSION_LIST_LIMIT },
{ throwOnError: true },
)
return (data ?? []).map((session) => ({
id: session.id,
parentId: session.parentID ?? null,
revert: session.revert,
workspaceId: session.workspaceID,
yoloEnabled: hasPersistedYolo(session.id, session.metadata),
}))
},
persist(instanceId, rootSessionId, enabled, workspaceId): Promise<void> {
return updateMetadata(instanceId, rootSessionId, workspaceId,
(metadata) => mergePersistedYolo(metadata, rootSessionId, enabled)).then(() => undefined)
},
async hasProjectSession(instanceId, sessionId): Promise<boolean> {
const { data } = await clientFor(instanceId).session.list(
{ scope: "project", limit: SESSION_LIST_LIMIT },
{ throwOnError: true },
)
return (data ?? []).some((session) => session.id === sessionId)
},
setWorktreeSlug(instanceId, sessionId, worktreeSlug): Promise<Metadata> {
return updateMetadata(instanceId, sessionId, undefined,
(metadata) => mergePersistedWorktreeSlug(metadata, worktreeSlug))
},
}
}

View file

@ -13,32 +13,20 @@ import { RemoteProxySessionManager } from "../remote-proxy"
import { resolveHttpsOptions } from "../tls" import { resolveHttpsOptions } from "../tls"
const sharedTempDir = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-remote-proxy-test-")) const sharedTempDir = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-remote-proxy-test-"))
const sharedTls = resolveHttpsOptions({ const sharedTls = resolveHttpsOptions({ enabled: true, configDir: sharedTempDir, host: "127.0.0.1", logger: createStubLogger() })
enabled: true, if (!sharedTls) throw new Error("Failed to generate HTTPS options for remote proxy tests")
configDir: sharedTempDir,
host: "127.0.0.1",
logger: createStubLogger(),
})
if (!sharedTls) {
throw new Error("Failed to generate HTTPS options for remote proxy tests")
}
const sharedHttpsOptions = sharedTls.httpsOptions const sharedHttpsOptions = sharedTls.httpsOptions
const httpsDispatcher = new Agent({ connect: { rejectUnauthorized: false } }) const httpsDispatcher = new Agent({ connect: { rejectUnauthorized: false } })
const managers = new Set<RemoteProxySessionManager>() const managers = new Set<RemoteProxySessionManager>()
afterEach(async () => { afterEach(async () => {
for (const manager of managers) { for (const manager of managers) await manager.shutdown().catch(() => undefined)
await disposeManager(manager)
}
managers.clear() managers.clear()
}) })
after(() => { after(async () => {
fs.rmSync(sharedTempDir, { recursive: true, force: true }) fs.rmSync(sharedTempDir, { recursive: true, force: true })
httpsDispatcher.close().catch(() => {}) await httpsDispatcher.destroy().catch(() => {})
}) })
describe("RemoteProxySessionManager", () => { describe("RemoteProxySessionManager", () => {
@ -47,10 +35,8 @@ describe("RemoteProxySessionManager", () => {
const manager = createSessionManager() const manager = createSessionManager()
const session1 = await createSession(manager, `${upstreamBaseUrl}/base`) const session1 = await createSession(manager, `${upstreamBaseUrl}/base`)
const session2 = await createSession(manager, `${upstreamBaseUrl}/base`) const session2 = await createSession(manager, `${upstreamBaseUrl}/base`)
const blocked = await proxyFetch(`${session1.proxyOrigin}/status`) const blocked = await proxyFetch(`${session1.proxyOrigin}/status`)
assert.equal(blocked.status, 403) assert.equal(blocked.status, 403)
const wrongTokenResponse = await proxyFetch(`${session1.proxyOrigin}/__codenomad/api/auth/token`, { const wrongTokenResponse = await proxyFetch(`${session1.proxyOrigin}/__codenomad/api/auth/token`, {
method: "POST", method: "POST",
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
@ -70,9 +56,7 @@ describe("RemoteProxySessionManager", () => {
await withUpstreamServer(async (upstreamBaseUrl) => { await withUpstreamServer(async (upstreamBaseUrl) => {
const manager = createSessionManager() const manager = createSessionManager()
const session = await createSession(manager, `${upstreamBaseUrl}/base`) const session = await createSession(manager, `${upstreamBaseUrl}/base`)
await activateSession(session) await activateSession(session)
const apiResponse = await proxyFetch(`${session.proxyOrigin}/api/auth/status?foo=bar`) const apiResponse = await proxyFetch(`${session.proxyOrigin}/api/auth/status?foo=bar`)
assert.equal(apiResponse.status, 200) assert.equal(apiResponse.status, 200)
assert.equal(await apiResponse.text(), "/base/api/auth/status?foo=bar") assert.equal(await apiResponse.text(), "/base/api/auth/status?foo=bar")
@ -84,10 +68,8 @@ describe("RemoteProxySessionManager", () => {
const requestUrl = req.url ?? "" const requestUrl = req.url ?? ""
if (requestUrl === "/base/redirect") { if (requestUrl === "/base/redirect") {
res.writeHead(302, { location: "/base/after?ok=1" }) res.writeHead(302, { location: "/base/after?ok=1" })
res.end() return res.end()
return
} }
res.writeHead(200, { "content-type": "text/plain" }) res.writeHead(200, { "content-type": "text/plain" })
res.end(requestUrl) res.end(requestUrl)
}) })
@ -97,21 +79,17 @@ describe("RemoteProxySessionManager", () => {
await withUpstreamServer(async (upstreamBaseUrl) => { await withUpstreamServer(async (upstreamBaseUrl) => {
const manager = createSessionManager() const manager = createSessionManager()
const session = await createSession(manager, `${upstreamBaseUrl}/base`) const session = await createSession(manager, `${upstreamBaseUrl}/base`)
await activateSession(session) await activateSession(session)
const loginResponse = await proxyFetch(`${session.proxyOrigin}/login`) const loginResponse = await proxyFetch(`${session.proxyOrigin}/login`)
assert.equal(loginResponse.status, 200) assert.equal(loginResponse.status, 200)
const setCookie = getSetCookie(loginResponse)[0] const setCookie = getSetCookie(loginResponse)[0]
assert.match(setCookie, /^cnrp_[0-9a-f]+_session=abc123/i) assert.match(setCookie, /^cnrp_[0-9a-f]+_session=abc123/i)
assert.doesNotMatch(setCookie, /domain=/i) assert.doesNotMatch(setCookie, /domain=/i)
const cookieHeader = setCookie.split(";", 1)[0] const cookieHeader = setCookie.split(";", 1)[0]
const whoamiResponse = await proxyFetch(`${session.proxyOrigin}/whoami`, { const whoamiResponse = await proxyFetch(`${session.proxyOrigin}/whoami`, {
headers: { cookie: cookieHeader }, headers: { cookie: cookieHeader },
}) })
assert.equal(await whoamiResponse.text(), "session=abc123") assert.equal(await whoamiResponse.text(), "session=abc123")
}, (req, res) => { }, (req, res) => {
const requestUrl = req.url ?? "" const requestUrl = req.url ?? ""
@ -120,16 +98,12 @@ describe("RemoteProxySessionManager", () => {
"content-type": "text/plain", "content-type": "text/plain",
"set-cookie": "session=abc123; Path=/; Secure; HttpOnly; Domain=127.0.0.1", "set-cookie": "session=abc123; Path=/; Secure; HttpOnly; Domain=127.0.0.1",
}) })
res.end("ok") return res.end("ok")
return
} }
if (requestUrl === "/base/whoami") { if (requestUrl === "/base/whoami") {
res.writeHead(200, { "content-type": "text/plain" }) res.writeHead(200, { "content-type": "text/plain" })
res.end(req.headers.cookie ?? "") return res.end(req.headers.cookie ?? "")
return
} }
res.writeHead(404, { "content-type": "text/plain" }) res.writeHead(404, { "content-type": "text/plain" })
res.end(requestUrl) res.end(requestUrl)
}) })
@ -139,17 +113,13 @@ describe("RemoteProxySessionManager", () => {
await withUpstreamServer(async (upstreamBaseUrl) => { await withUpstreamServer(async (upstreamBaseUrl) => {
const manager = createSessionManager() const manager = createSessionManager()
const session = await createSession(manager, `${upstreamBaseUrl}/base`) const session = await createSession(manager, `${upstreamBaseUrl}/base`)
assert.equal(await manager.deleteSession(session.sessionId), true) assert.equal(await manager.deleteSession(session.sessionId), true)
assert.equal(await manager.deleteSession(session.sessionId), false) assert.equal(await manager.deleteSession(session.sessionId), false)
const session3 = await createSession(manager, `${upstreamBaseUrl}/base`) const session3 = await createSession(manager, `${upstreamBaseUrl}/base`)
const internalSessions = (manager as any).sessions as Map<string, { lastAccessAt: number }> const internalSessions = (manager as any).sessions as Map<string, { lastAccessAt: number }>
const internalCleanup = (manager as any).cleanupExpiredSessions as () => Promise<void> const internalCleanup = (manager as any).cleanupExpiredSessions as () => Promise<void>
internalSessions.get(session3.sessionId)!.lastAccessAt = Date.now() - 31 * 60_000 internalSessions.get(session3.sessionId)!.lastAccessAt = Date.now() - 31 * 60_000
await internalCleanup.call(manager) await internalCleanup.call(manager)
assert.equal(internalSessions.has(session3.sessionId), false) assert.equal(internalSessions.has(session3.sessionId), false)
assert.equal(await manager.deleteSession(session3.sessionId), false) assert.equal(await manager.deleteSession(session3.sessionId), false)
}, (_req, res) => { }, (_req, res) => {
@ -157,20 +127,153 @@ describe("RemoteProxySessionManager", () => {
res.end("ok") res.end("ok")
}) })
}) })
it("closes every session listener during shutdown", async () => {
await withUpstreamServer(async (upstreamBaseUrl) => {
const manager = createSessionManager()
const first = await createSession(manager, `${upstreamBaseUrl}/base`)
await createSession(manager, `${upstreamBaseUrl}/other`)
await manager.shutdown()
assert.equal((manager as any).sessions.size, 0)
await assert.rejects(proxyFetch(`${first.proxyOrigin}/status`))
}, (_req, res) => {
res.writeHead(200).end("ok")
})
})
it("waits for in-flight idle cleanup during shutdown", async () => {
await withUpstreamServer(async (upstreamBaseUrl) => {
const manager = createSessionManager({ disposalTimeoutMs: 1_000 })
const session = await createSession(manager, `${upstreamBaseUrl}/base`)
const internalSession = (manager as any).sessions.get(session.sessionId)
const closeGate = deferred<void>()
const originalClose = internalSession.app.close.bind(internalSession.app)
internalSession.app.close = async () => {
await closeGate.promise
return originalClose()
}
internalSession.lastAccessAt = Date.now() - 31 * 60_000
const cleanup = (manager as any).cleanupExpiredSessions() as Promise<void>
let shutdownSettled = false
const shutdown = manager.shutdown().then(() => {
shutdownSettled = true
})
await new Promise((resolve) => setImmediate(resolve))
assert.equal(shutdownSettled, false)
closeGate.resolve()
await cleanup
await shutdown
assert.equal((manager as any).disposals.size, 0)
}, (_req, res) => {
res.writeHead(200).end("ok")
})
})
it("aborts a stalled event stream during bounded shutdown", async () => {
await withUpstreamServer(async (upstreamBaseUrl) => {
const manager = createSessionManager({ disposalTimeoutMs: 100 })
const session = await createSession(manager, `${upstreamBaseUrl}/base`)
await activateSession(session)
const response = await proxyFetch(`${session.proxyOrigin}/events`)
assert.equal(response.status, 200)
await Promise.race([
manager.shutdown(),
new Promise<never>((_resolve, reject) => setTimeout(() => reject(new Error("shutdown stalled")), 500)),
])
assert.equal((manager as any).sessions.size, 0)
assert.equal((manager as any).disposals.size, 0)
}, (req, res) => {
if (req.url === "/base/events") {
res.writeHead(200, { "content-type": "text/event-stream" })
return void res.write("data: connected\n\n")
}
res.writeHead(200).end("ok")
})
})
it("surfaces listener disposal failures", async () => {
await withUpstreamServer(async (upstreamBaseUrl) => {
const manager = createSessionManager()
const session = await createSession(manager, `${upstreamBaseUrl}/base`)
const internalSession = (manager as any).sessions.get(session.sessionId)
const originalClose = internalSession.app.close.bind(internalSession.app)
let failClose = true
internalSession.app.close = async () => {
await originalClose()
if (failClose) { failClose = false; throw new Error("close failed") }
}
await assert.rejects(manager.deleteSession(session.sessionId), /Remote proxy disposal failed/)
// A completed deletion failure predating shutdown must not poison it.
await manager.shutdown()
assert.equal((manager as any).sessions.size, 0)
}, (_req, res) => {
res.writeHead(200).end("ok")
})
})
it("waits for in-flight creation and rejects sessions that cross shutdown", async () => {
await withUpstreamServer(async (upstreamBaseUrl) => {
const manager = createSessionManager()
const creation = manager.createSession(`${upstreamBaseUrl}/base`, false)
const shutdown = manager.shutdown()
await assert.rejects(creation, /shutting down/)
await shutdown
assert.equal((manager as any).creations.size, 0)
assert.equal((manager as any).sessions.size, 0)
await assert.rejects(manager.createSession(`${upstreamBaseUrl}/base`, false), /shutting down/)
}, (_req, res) => {
res.writeHead(200).end("ok")
})
})
it("coalesces shutdown, retains current disposal failures, and gives every session its own agent", async () => {
await withUpstreamServer(async (upstreamBaseUrl) => {
const manager = createSessionManager()
const verified = await manager.createSession(`${upstreamBaseUrl}/verified`, false)
const insecure = await manager.createSession(`${upstreamBaseUrl}/insecure`, true)
const sessions = (manager as any).sessions as Map<string, any>
assert.ok(sessions.get(verified.sessionId).dispatcher instanceof Agent)
assert.ok(sessions.get(insecure.sessionId).dispatcher instanceof Agent)
assert.notStrictEqual(sessions.get(verified.sessionId).dispatcher, sessions.get(insecure.sessionId).dispatcher)
const closeGate = deferred<void>()
const originalClose = sessions.get(verified.sessionId).app.close.bind(sessions.get(verified.sessionId).app)
let failClose = true
sessions.get(verified.sessionId).app.close = async () => {
await closeGate.promise
await originalClose()
if (failClose) { failClose = false; throw new Error("current close failed") }
}
const disposal = manager.deleteSession(verified.sessionId); const first = manager.shutdown()
const concurrent = manager.shutdown()
assert.strictEqual(first, concurrent)
closeGate.resolve()
await assert.rejects(disposal, /Remote proxy disposal failed/)
await assert.rejects(first, (error: unknown) => error instanceof AggregateError && error.errors.some((cause) =>
cause instanceof AggregateError && cause.errors.some((nested) => /current close failed/.test(String(nested)))))
await manager.shutdown()
assert.equal(sessions.size, 0)
}, (_req, res) => {
res.writeHead(200).end("ok")
})
})
}) })
function createSessionManager() { function createSessionManager(options: { disposalTimeoutMs?: number } = {}) {
const manager = new RemoteProxySessionManager({ const manager = new RemoteProxySessionManager({
authManager: { authManager: { isLoopbackRequest: () => true } as unknown as AuthManager,
isLoopbackRequest: () => true, logger: createStubLogger(), httpsOptions: sharedHttpsOptions, ...options,
} as unknown as AuthManager,
logger: createStubLogger(),
httpsOptions: sharedHttpsOptions,
}) })
managers.add(manager) managers.add(manager)
return manager return manager
} }
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((resolvePromise) => { resolve = resolvePromise })
return { promise, resolve }
}
async function createSession(manager: RemoteProxySessionManager, baseUrl: string) { async function createSession(manager: RemoteProxySessionManager, baseUrl: string) {
const created = await manager.createSession(baseUrl, false) const created = await manager.createSession(baseUrl, false)
const windowUrl = new URL(created.windowUrl) const windowUrl = new URL(created.windowUrl)
@ -188,18 +291,14 @@ async function activateSession(session: { proxyOrigin: string; token: string })
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
body: JSON.stringify({ token: session.token }), body: JSON.stringify({ token: session.token }),
}) })
if (!response.ok) { if (!response.ok) return false
return false
}
const body = (await response.json()) as { ok?: boolean } const body = (await response.json()) as { ok?: boolean }
return body.ok === true return body.ok === true
} }
function getSetCookie(response: Awaited<ReturnType<typeof fetch>>): string[] { function getSetCookie(response: Awaited<ReturnType<typeof fetch>>): string[] {
const values = (response.headers as any).getSetCookie?.() as string[] | undefined const values = (response.headers as any).getSetCookie?.() as string[] | undefined
if (Array.isArray(values) && values.length > 0) { if (Array.isArray(values) && values.length > 0) return values
return values
}
const fallback = response.headers.get("set-cookie") const fallback = response.headers.get("set-cookie")
return fallback ? [fallback] : [] return fallback ? [fallback] : []
} }
@ -208,26 +307,15 @@ async function proxyFetch(url: string, init?: Parameters<typeof fetch>[1]) {
return fetch(url, { dispatcher: httpsDispatcher, ...init }) return fetch(url, { dispatcher: httpsDispatcher, ...init })
} }
async function disposeManager(manager: RemoteProxySessionManager) {
const sessions = Array.from(((manager as any).sessions as Map<string, unknown>).keys())
for (const sessionId of sessions) {
await manager.deleteSession(sessionId)
}
clearInterval((manager as any).cleanupTimer as NodeJS.Timeout)
}
async function withUpstreamServer( async function withUpstreamServer(
callback: (baseUrl: string) => Promise<void>, callback: (baseUrl: string) => Promise<void>,
handler: (req: IncomingMessage, res: ServerResponse<IncomingMessage>) => void, handler: (req: IncomingMessage, res: ServerResponse<IncomingMessage>) => void,
) { ) {
const server = http.createServer(handler) const server = http.createServer(handler)
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", () => resolve())) await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", () => resolve()))
try { try {
const address = server.address() const address = server.address()
if (!address || typeof address === "string") { if (!address || typeof address === "string") throw new Error("Failed to resolve upstream server address")
throw new Error("Failed to resolve upstream server address")
}
await callback(`http://127.0.0.1:${address.port}`) await callback(`http://127.0.0.1:${address.port}`)
} finally { } finally {
await new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))) await new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())))
@ -235,14 +323,6 @@ async function withUpstreamServer(
} }
function createStubLogger(): Logger { function createStubLogger(): Logger {
const logger = { const logger = { info() {}, warn() {}, error() {}, child() { return logger } }
info() {},
warn() {},
error() {},
child() {
return logger
},
}
return logger as unknown as Logger return logger as unknown as Logger
} }

View file

@ -22,15 +22,20 @@ import { registerEventRoutes } from "./routes/events"
import { registerStorageRoutes } from "./routes/storage" import { registerStorageRoutes } from "./routes/storage"
import { registerPluginRoutes } from "./routes/plugin" import { registerPluginRoutes } from "./routes/plugin"
import { registerBackgroundProcessRoutes } from "./routes/background-processes" import { registerBackgroundProcessRoutes } from "./routes/background-processes"
import { registerYoloRoutes } from "./routes/yolo"
import { registerWorktreeRoutes } from "./routes/worktrees" import { registerWorktreeRoutes } from "./routes/worktrees"
import { registerSpeechRoutes } from "./routes/speech" import { registerSpeechRoutes } from "./routes/speech"
import { registerOpenCodeUpdateRoutes } from "./routes/opencode-update"
import { registerRemoteServerRoutes } from "./routes/remote-servers" import { registerRemoteServerRoutes } from "./routes/remote-servers"
import { registerRemoteProxyRoutes } from "./routes/remote-proxy" import { registerRemoteProxyRoutes } from "./routes/remote-proxy"
import { registerSideCarRoutes } from "./routes/sidecars" import { registerSideCarRoutes } from "./routes/sidecars"
import { registerPreviewRoutes } from "./routes/previews" import { registerPreviewRoutes } from "./routes/previews"
import { registerUsageRoutes } from "./routes/usage"
import { ServerMeta } from "../api-types" import { ServerMeta } from "../api-types"
import { InstanceStore } from "../storage/instance-store" import { InstanceStore } from "../storage/instance-store"
import { BackgroundProcessManager } from "../background-processes/manager" import { BackgroundProcessManager } from "../background-processes/manager"
import type { AutoAcceptManager } from "../permissions/auto-accept-manager"
import type { OpencodeYoloPersistence } from "../permissions/opencode-yolo-metadata"
import type { AuthManager } from "../auth/manager" import type { AuthManager } from "../auth/manager"
import { registerAuthRoutes } from "./routes/auth" import { registerAuthRoutes } from "./routes/auth"
import { sendUnauthorized, wantsHtml } from "../auth/http-auth" import { sendUnauthorized, wantsHtml } from "../auth/http-auth"
@ -41,6 +46,7 @@ import { VoiceModeManager } from "../plugins/voice-mode"
import type { SideCarManager } from "../sidecars/manager" import type { SideCarManager } from "../sidecars/manager"
import type { PreviewManager } from "../previews/manager" import type { PreviewManager } from "../previews/manager"
import type { RemoteProxySessionManager } from "./remote-proxy" import type { RemoteProxySessionManager } from "./remote-proxy"
import { createOpenCodeUpdateService } from "../opencode-update/service"
interface HttpServerDeps { interface HttpServerDeps {
bindHost: string bindHost: string
@ -63,6 +69,8 @@ interface HttpServerDeps {
pluginChannel: PluginChannelManager pluginChannel: PluginChannelManager
voiceModeManager: VoiceModeManager voiceModeManager: VoiceModeManager
remoteProxySessionManager: RemoteProxySessionManager remoteProxySessionManager: RemoteProxySessionManager
yoloManager: AutoAcceptManager
sessionMetadataPersistence: OpencodeYoloPersistence
uiStaticDir: string uiStaticDir: string
uiDevServerUrl?: string uiDevServerUrl?: string
logger: Logger logger: Logger
@ -269,6 +277,10 @@ export function createHttpServer(deps: HttpServerDeps) {
registerWorkspaceRoutes(app, { workspaceManager: deps.workspaceManager }) registerWorkspaceRoutes(app, { workspaceManager: deps.workspaceManager })
registerSettingsRoutes(app, { settings: deps.settings, logger: apiLogger }) registerSettingsRoutes(app, { settings: deps.settings, logger: apiLogger })
registerOpenCodeUpdateRoutes(app, {
service: createOpenCodeUpdateService(deps.settings, deps.workspaceManager),
logger: apiLogger,
})
registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser }) registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser })
registerConfigFileRoutes(app) registerConfigFileRoutes(app)
registerMetaRoutes(app, { serverMeta: deps.serverMeta }) registerMetaRoutes(app, { serverMeta: deps.serverMeta })
@ -278,7 +290,10 @@ export function createHttpServer(deps: HttpServerDeps) {
logger: sseLogger, logger: sseLogger,
connectionManager: deps.clientConnectionManager, connectionManager: deps.clientConnectionManager,
}) })
registerWorktreeRoutes(app, { workspaceManager: deps.workspaceManager }) registerWorktreeRoutes(app, {
workspaceManager: deps.workspaceManager,
sessionMetadataPersistence: deps.sessionMetadataPersistence,
})
registerStorageRoutes(app, { registerStorageRoutes(app, {
instanceStore: deps.instanceStore, instanceStore: deps.instanceStore,
eventBus: deps.eventBus, eventBus: deps.eventBus,
@ -289,6 +304,7 @@ export function createHttpServer(deps: HttpServerDeps) {
registerSpeechRoutes(app, { speechService: deps.speechService }) registerSpeechRoutes(app, { speechService: deps.speechService })
registerSideCarRoutes(app, { sidecarManager: deps.sidecarManager }) registerSideCarRoutes(app, { sidecarManager: deps.sidecarManager })
registerPreviewRoutes(app, { previewManager: deps.previewManager }) registerPreviewRoutes(app, { previewManager: deps.previewManager })
registerUsageRoutes(app)
registerSideCarProxyRoutes(app, { sidecarManager: deps.sidecarManager, logger: proxyLogger }) registerSideCarProxyRoutes(app, { sidecarManager: deps.sidecarManager, logger: proxyLogger })
registerPreviewProxyRoutes(app, { previewManager: deps.previewManager, logger: proxyLogger }) registerPreviewProxyRoutes(app, { previewManager: deps.previewManager, logger: proxyLogger })
setupSideCarWebSocketProxy(app, { setupSideCarWebSocketProxy(app, {
@ -309,6 +325,7 @@ export function createHttpServer(deps: HttpServerDeps) {
voiceModeManager: deps.voiceModeManager, voiceModeManager: deps.voiceModeManager,
}) })
registerBackgroundProcessRoutes(app, { backgroundProcessManager }) registerBackgroundProcessRoutes(app, { backgroundProcessManager })
registerYoloRoutes(app, { yoloManager: deps.yoloManager })
registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger }) registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger })

View file

@ -10,20 +10,18 @@ const LOOPBACK_HOST = "127.0.0.1"
const BOOTSTRAP_PAGE_PATH = "/__codenomad/auth/token" const BOOTSTRAP_PAGE_PATH = "/__codenomad/auth/token"
const BOOTSTRAP_EXCHANGE_PATH = "/__codenomad/api/auth/token" const BOOTSTRAP_EXCHANGE_PATH = "/__codenomad/api/auth/token"
const SESSION_IDLE_TTL_MS = 30 * 60_000 const SESSION_IDLE_TTL_MS = 30 * 60_000
const SESSION_DISPOSAL_TIMEOUT_MS = 5_000
interface RemoteProxySession { interface RemoteProxySession {
id: string id: string
bootstrapToken: string bootstrapToken: string
targetBaseUrl: URL targetBaseUrl: URL
skipTlsVerify: boolean
localBaseUrl: URL localBaseUrl: URL
entryUrl: URL
bootstrapUrl: string
activated: boolean activated: boolean
cookiePrefix: string cookiePrefix: string
app: FastifyInstance app: FastifyInstance
dispatcher?: Agent dispatcher?: Agent
createdAt: number abortController: AbortController
lastAccessAt: number lastAccessAt: number
} }
@ -31,6 +29,7 @@ export interface RemoteProxySessionManagerOptions {
authManager: AuthManager authManager: AuthManager
logger: Logger logger: Logger
httpsOptions?: { key: string | Buffer; cert: string | Buffer; ca?: string | Buffer } httpsOptions?: { key: string | Buffer; cert: string | Buffer; ca?: string | Buffer }
disposalTimeoutMs?: number
} }
export interface RemoteProxySessionCreateResult { export interface RemoteProxySessionCreateResult {
@ -40,16 +39,26 @@ export interface RemoteProxySessionCreateResult {
export class RemoteProxySessionManager { export class RemoteProxySessionManager {
private readonly sessions = new Map<string, RemoteProxySession>() private readonly sessions = new Map<string, RemoteProxySession>()
private readonly creations = new Set<Promise<unknown>>()
private readonly disposals = new Set<Promise<unknown>>()
private readonly sessionDisposals = new Map<string, Promise<boolean>>()
private readonly cleanupTimer: NodeJS.Timeout private readonly cleanupTimer: NodeJS.Timeout
private shuttingDown = false
private shutdownPromise?: Promise<void>
constructor(private readonly options: RemoteProxySessionManagerOptions) { constructor(private readonly options: RemoteProxySessionManagerOptions) {
this.cleanupTimer = setInterval(() => { this.cleanupTimer = setInterval(() => void this.cleanupExpiredSessions().catch((error) =>
void this.cleanupExpiredSessions() this.options.logger.error({ err: error }, "Failed to dispose expired remote proxy session")), 60_000)
}, 60_000)
this.cleanupTimer.unref() this.cleanupTimer.unref()
} }
async createSession(baseUrl: string, skipTlsVerify: boolean): Promise<RemoteProxySessionCreateResult> { async createSession(baseUrl: string, skipTlsVerify: boolean): Promise<RemoteProxySessionCreateResult> {
if (this.shuttingDown) throw new Error("Remote proxy session manager is shutting down")
return this.track(this.creations, this.createSessionInternal(baseUrl, skipTlsVerify))
}
private async createSessionInternal(baseUrl: string, skipTlsVerify: boolean): Promise<RemoteProxySessionCreateResult> {
if (!this.options.httpsOptions) { if (!this.options.httpsOptions) {
throw new Error("Local HTTPS is required for remote proxy sessions") throw new Error("Local HTTPS is required for remote proxy sessions")
} }
@ -57,8 +66,9 @@ export class RemoteProxySessionManager {
const targetBaseUrl = normalizeBaseUrl(baseUrl) const targetBaseUrl = normalizeBaseUrl(baseUrl)
const sessionId = randomUUID() const sessionId = randomUUID()
const bootstrapToken = randomBytes(32).toString("base64url") const bootstrapToken = randomBytes(32).toString("base64url")
const dispatcher = skipTlsVerify ? new Agent({ connect: { rejectUnauthorized: false } }) : undefined const dispatcher = new Agent(skipTlsVerify ? { connect: { rejectUnauthorized: false } } : {})
const app = Fastify({ logger: false, https: this.options.httpsOptions }) const abortController = new AbortController()
const app = Fastify({ logger: false, https: this.options.httpsOptions, forceCloseConnections: true })
let session: RemoteProxySession | null = null let session: RemoteProxySession | null = null
app.removeAllContentTypeParsers() app.removeAllContentTypeParsers()
@ -99,7 +109,7 @@ export class RemoteProxySessionManager {
reply.send({ ok: true }) reply.send({ ok: true })
}) })
app.all("/*", async (request, reply) => { const handleProxyRequest = async (request: FastifyRequest, reply: FastifyReply) => {
if (!session) { if (!session) {
reply.code(503).send({ error: "Remote proxy session is unavailable" }) reply.code(503).send({ error: "Remote proxy session is unavailable" })
return return
@ -112,58 +122,72 @@ export class RemoteProxySessionManager {
session.lastAccessAt = Date.now() session.lastAccessAt = Date.now()
await proxyRequest({ request, reply, session, logger: this.options.logger }) await proxyRequest({ request, reply, session, logger: this.options.logger })
}) }
app.all("/*", handleProxyRequest)
app.setNotFoundHandler(async (request, reply) => { app.setNotFoundHandler(handleProxyRequest)
if (!session) {
reply.code(503).send({ error: "Remote proxy session is unavailable" })
return
}
if (!session.activated) {
reply.code(403).send({ error: "Remote proxy session is not activated" })
return
}
session.lastAccessAt = Date.now()
await proxyRequest({ request, reply, session, logger: this.options.logger })
})
const addressInfo = await app.listen({ host: LOOPBACK_HOST, port: 0 }) const addressInfo = await app.listen({ host: LOOPBACK_HOST, port: 0 })
const address = new URL(addressInfo) const address = new URL(addressInfo)
const localBaseUrl = new URL(`https://${LOOPBACK_HOST}:${address.port}`) const localBaseUrl = new URL(`https://${LOOPBACK_HOST}:${address.port}`)
const entryUrl = new URL(targetBaseUrl.pathname || "/", localBaseUrl) const entryUrl = new URL(targetBaseUrl.pathname || "/", localBaseUrl)
const returnTo = buildReturnToTarget(entryUrl) const returnTo = buildReturnToTarget(entryUrl)
const bootstrapUrl = `${localBaseUrl.origin}${BOOTSTRAP_PAGE_PATH}?returnTo=${encodeURIComponent(returnTo)}#${encodeURIComponent(bootstrapToken)}`
session = { session = {
id: sessionId, id: sessionId,
bootstrapToken, bootstrapToken,
targetBaseUrl, targetBaseUrl,
skipTlsVerify,
localBaseUrl, localBaseUrl,
entryUrl,
bootstrapUrl: `${localBaseUrl.origin}${BOOTSTRAP_PAGE_PATH}?returnTo=${encodeURIComponent(returnTo)}#${encodeURIComponent(bootstrapToken)}`,
activated: false, activated: false,
cookiePrefix: `cnrp_${randomBytes(6).toString("hex")}_`, cookiePrefix: `cnrp_${randomBytes(6).toString("hex")}_`,
app, app,
dispatcher, dispatcher,
createdAt: Date.now(), abortController,
lastAccessAt: Date.now(), lastAccessAt: Date.now(),
} }
this.sessions.set(sessionId, session) this.sessions.set(sessionId, session)
if (this.shuttingDown) {
await this.disposeSession(sessionId)
throw new Error("Remote proxy session manager is shutting down")
}
this.options.logger.info( this.options.logger.info(
{ sessionId, targetBaseUrl: targetBaseUrl.toString(), localBaseUrl: localBaseUrl.toString() }, { sessionId, targetBaseUrl: targetBaseUrl.toString(), localBaseUrl: localBaseUrl.toString() },
"Created remote proxy session", "Created remote proxy session",
) )
return { sessionId, windowUrl: session.bootstrapUrl } return { sessionId, windowUrl: bootstrapUrl }
} }
async deleteSession(sessionId: string): Promise<boolean> { async deleteSession(sessionId: string): Promise<boolean> {
return this.disposeSession(sessionId) return this.disposeSession(sessionId)
} }
shutdown(): Promise<void> {
if (this.shutdownPromise) return this.shutdownPromise
this.shuttingDown = true
clearInterval(this.cleanupTimer)
const shutdown = this.drainShutdown()
this.shutdownPromise = shutdown
void shutdown.finally(() => {
if (this.shutdownPromise === shutdown) this.shutdownPromise = undefined
}).catch(() => undefined)
return shutdown
}
private async drainShutdown(): Promise<void> {
const disposals = new Set(this.disposals)
while (this.creations.size > 0) {
await Promise.allSettled([...this.creations])
for (const disposal of this.disposals) disposals.add(disposal)
}
const pendingResults = await Promise.allSettled(disposals)
const results = await Promise.allSettled(Array.from(this.sessions.keys(), (id) => this.disposeSession(id)))
const failures = [...pendingResults, ...results]
.flatMap((result) => result.status === "rejected" ? [result.reason] : [])
if (failures.length) throw new AggregateError(failures, "Remote proxy shutdown failed")
}
private async cleanupExpiredSessions() { private async cleanupExpiredSessions() {
const now = Date.now() const now = Date.now()
for (const session of Array.from(this.sessions.values())) { for (const session of Array.from(this.sessions.values())) {
@ -174,17 +198,47 @@ export class RemoteProxySessionManager {
} }
} }
private async disposeSession(sessionId: string): Promise<boolean> { private disposeSession(sessionId: string): Promise<boolean> {
const pending = this.sessionDisposals.get(sessionId)
if (pending) return pending
const session = this.sessions.get(sessionId) const session = this.sessions.get(sessionId)
if (!session) { if (!session) return Promise.resolve(false)
return false
}
this.sessions.delete(sessionId) session.abortController.abort()
session.dispatcher?.close().catch(() => {}) const disposal = this.trackDisposal(this.disposeResources(session.app, session.dispatcher).then(() => {
await session.app.close().catch(() => {}) if (this.sessions.get(sessionId) === session) this.sessions.delete(sessionId)
this.options.logger.info({ sessionId }, "Disposed remote proxy session") this.options.logger.info({ sessionId }, "Disposed remote proxy session")
return true return true
}))
this.sessionDisposals.set(sessionId, disposal)
void disposal.finally(() => {
if (this.sessionDisposals.get(sessionId) === disposal) this.sessionDisposals.delete(sessionId)
}).catch(() => undefined)
return disposal
}
private async disposeResources(app: FastifyInstance, dispatcher?: Agent): Promise<void> {
app.server.closeAllConnections?.()
const results = await Promise.race([
Promise.allSettled([app.close(), dispatcher?.destroy()]),
new Promise<never>((_resolve, reject) => AbortSignal.timeout(
Math.max(1, this.options.disposalTimeoutMs ?? SESSION_DISPOSAL_TIMEOUT_MS),
).addEventListener(
"abort", () => reject(new Error("Remote proxy disposal timed out")),
)),
])
const failures = results.flatMap((result) => result.status === "rejected" ? [result.reason] : [])
if (failures.length) throw new AggregateError(failures, "Remote proxy disposal failed")
}
private track<T>(operations: Set<Promise<unknown>>, operation: Promise<T>): Promise<T> {
operations.add(operation)
void operation.finally(() => operations.delete(operation)).catch(() => undefined)
return operation
}
private trackDisposal<T>(operation: Promise<T>): Promise<T> {
return this.track(this.disposals, operation)
} }
} }
@ -325,6 +379,7 @@ async function proxyRequest(args: {
method: request.method, method: request.method,
headers, headers,
dispatcher: session.dispatcher, dispatcher: session.dispatcher,
signal: session.abortController.signal,
redirect: "manual", redirect: "manual",
} }

View file

@ -72,10 +72,10 @@
<p>This CodeNomad server is protected. Enter your credentials to continue.</p> <p>This CodeNomad server is protected. Enter your credentials to continue.</p>
<label for="username">Username</label> <label for="username">Username</label>
<input id="username" autocomplete="username" placeholder="{{DEFAULT_USERNAME}}" value="" /> <input id="username" autocomplete="username" autocapitalize="none" autocorrect="off" spellcheck="false" placeholder="{{DEFAULT_USERNAME}}" value="{{DEFAULT_USERNAME}}" />
<label for="password">Password</label> <label for="password">Password</label>
<input id="password" type="password" autocomplete="current-password" value="" /> <input id="password" type="password" autocomplete="current-password" autocapitalize="none" autocorrect="off" spellcheck="false" value="" />
<button id="submit" type="button">Continue</button> <button id="submit" type="button">Continue</button>
<div id="error" class="error" style="display: none"></div> <div id="error" class="error" style="display: none"></div>
@ -95,7 +95,7 @@
async function submit() { async function submit() {
hideError() hideError()
const username = $("username").value.trim() const username = $("username").value
const password = $("password").value const password = $("password").value
if (!username || !password) { if (!username || !password) {
showError("Username and password are required.") showError("Username and password are required.")

View file

@ -0,0 +1,25 @@
import assert from "node:assert/strict"
import { it } from "node:test"
import Fastify from "fastify"
import type { AuthManager } from "../../auth/manager"
import { registerAuthRoutes } from "./auth"
it("renders the escaped default username literally as the login field value", async () => {
const app = Fastify({ logger: false })
const username = " code$&\"nomad "
const authManager = {
getSessionFromRequest: () => null,
getStatus: () => ({ username }),
} as unknown as AuthManager
registerAuthRoutes(app, { authManager })
const response = await app.inject({ method: "GET", url: "/login" })
assert.equal(response.statusCode, 200)
assert.ok(response.body.includes('value=" code$&amp;&quot;nomad "'))
assert.match(response.body, /const username = \$\("username"\)\.value\s*\n/)
assert.equal(response.body.match(/autocapitalize="none"/g)?.length, 2)
assert.equal(response.body.match(/autocorrect="off"/g)?.length, 2)
assert.equal(response.body.match(/spellcheck="false"/g)?.length, 2)
await app.close()
})

View file

@ -39,7 +39,7 @@ function getLoginHtml(defaultUsername: string): string {
} }
const escapedUsername = escapeHtml(defaultUsername) const escapedUsername = escapeHtml(defaultUsername)
return cachedLoginTemplate.replace(/\{\{DEFAULT_USERNAME\}\}/g, escapedUsername) return cachedLoginTemplate.replace(/\{\{DEFAULT_USERNAME\}\}/g, () => escapedUsername)
} }
function getTokenHtml(): string { function getTokenHtml(): string {

View file

@ -0,0 +1,39 @@
import assert from "node:assert/strict"
import test from "node:test"
import Fastify from "fastify"
import type { Logger } from "../../logger"
import type { OpenCodeUpdateService } from "../../opencode-update/service"
import { registerOpenCodeUpdateRoutes } from "./opencode-update"
test("does not pass request-controlled binary paths to the update service", async () => {
const calls: Array<{ method: string; args: unknown[] }> = []
const service = {
getStatus: async (...args: unknown[]) => {
calls.push({ method: "status", args })
return {
currentVersion: "1.0.0",
latestVersion: "1.1.0",
updateAvailable: true,
canUpgrade: true,
}
},
upgrade: async (...args: unknown[]) => {
calls.push({ method: "upgrade", args })
return { success: true, version: "1.1.0" }
},
} as unknown as OpenCodeUpdateService
const app = Fastify()
registerOpenCodeUpdateRoutes(app, {
service,
logger: { warn() {} } as unknown as Logger,
})
await app.inject({ method: "GET", url: "/api/opencode/update?binary=untrusted.cmd" })
await app.inject({ method: "POST", url: "/api/opencode/update", payload: { binary: "untrusted.cmd" } })
assert.deepEqual(calls, [
{ method: "status", args: [] },
{ method: "upgrade", args: [] },
])
await app.close()
})

View file

@ -0,0 +1,43 @@
import type { FastifyInstance } from "fastify"
import type { Logger } from "../../logger"
import { OpenCodeUpdateError, type OpenCodeUpdateService } from "../../opencode-update/service"
interface RouteDeps {
service: OpenCodeUpdateService
logger: Logger
}
function statusCode(error: OpenCodeUpdateError): number {
if (error.code === "no_ready_instance") return 409
if (error.code === "binary_unavailable") return 422
return 502
}
function requestError(error: unknown, fallback: string): { status: number; code: string } {
if (error instanceof OpenCodeUpdateError) return { status: statusCode(error), code: error.code }
return { status: 500, code: fallback }
}
export function registerOpenCodeUpdateRoutes(app: FastifyInstance, deps: RouteDeps) {
app.get("/api/opencode/update", async (_request, reply) => {
try {
return await deps.service.getStatus()
} catch (error) {
deps.logger.warn({ err: error }, "Failed to check OpenCode update status")
const failure = requestError(error, "update_check_failed")
reply.code(failure.status)
return { error: failure.code }
}
})
app.post("/api/opencode/update", async (_request, reply) => {
try {
return await deps.service.upgrade()
} catch (error) {
deps.logger.warn({ err: error }, "Failed to update OpenCode")
const failure = requestError(error, "upgrade_failed")
reply.code(failure.status)
return { success: false, error: failure.code }
}
})
}

View file

@ -0,0 +1,87 @@
import assert from "node:assert/strict"
import { describe, it } from "node:test"
import { enforceSpeechCredentialPairing } from "./settings"
describe("enforceSpeechCredentialPairing", () => {
it("clears shared key when baseUrl differs from stored", () => {
const result = enforceSpeechCredentialPairing(
{ speech: { baseUrl: "https://new.com/v1" } },
{ speech: { baseUrl: "https://old.com/v1", apiKey: "sk-stored" } },
) as any
assert.equal(result.speech.apiKey, null)
})
it("does NOT clear shared key when baseUrl matches stored (old client full patch)", () => {
const result = enforceSpeechCredentialPairing(
{ speech: { baseUrl: "https://api.openai.com/v1", ttsVoice: "alloy", playbackMode: "streaming" } },
{ speech: { baseUrl: "https://api.openai.com/v1", apiKey: "sk-stored" } },
) as any
assert.equal("apiKey" in result.speech, false, "must not clear key when URL unchanged")
})
it("clears directional key when directional baseUrl differs from stored", () => {
const result = enforceSpeechCredentialPairing(
{ speech: { stt: { baseUrl: "https://new.com/v1" } } },
{ speech: { stt: { baseUrl: "https://old.com/v1", apiKey: "sk-stored" } } },
) as any
assert.equal(result.speech.stt.apiKey, null)
})
it("does NOT clear directional key when baseUrl matches stored", () => {
const result = enforceSpeechCredentialPairing(
{ speech: { stt: { baseUrl: "https://same.com/v1", model: "whisper-1" } } },
{ speech: { stt: { baseUrl: "https://same.com/v1", apiKey: "sk-stored" } } },
) as any
assert.equal("apiKey" in result.speech.stt, false, "must not clear directional key when URL unchanged")
})
it("preserves apiKey when both baseUrl and apiKey are in patch", () => {
const result = enforceSpeechCredentialPairing(
{ speech: { baseUrl: "https://new.com/v1", apiKey: "sk-new" } },
{ speech: { baseUrl: "https://old.com/v1", apiKey: "sk-old" } },
) as any
assert.equal(result.speech.apiKey, "sk-new")
})
it("handles both stt and tts independently against stored values", () => {
const result = enforceSpeechCredentialPairing(
{ speech: { stt: { baseUrl: "https://stt-same.com/v1", apiKey: "sk-stt" }, tts: { baseUrl: "https://tts-new.com/v1" } } },
{ speech: { stt: { baseUrl: "https://stt-same.com/v1", apiKey: "sk-stt" }, tts: { baseUrl: "https://tts-old.com/v1", apiKey: "sk-tts" } } },
) as any
assert.equal(result.speech.stt.apiKey, "sk-stt")
assert.equal(result.speech.tts.apiKey, null)
})
it("passes through non-speech patches unchanged", () => {
const body = { logLevel: "INFO" }
const result = enforceSpeechCredentialPairing(body, { speech: {} })
assert.deepEqual(result, body)
})
it("passes through null/undefined body", () => {
assert.equal(enforceSpeechCredentialPairing(null), null)
assert.equal(enforceSpeechCredentialPairing(undefined), undefined)
})
it("does not mutate the original body", () => {
const original = { speech: { baseUrl: "https://new.com/v1" } }
const originalCopy = JSON.parse(JSON.stringify(original))
enforceSpeechCredentialPairing(original, { speech: { baseUrl: "https://old.com/v1" } })
assert.deepEqual(original, originalCopy)
})
it("doc-level payload: clears key when server.speech.baseUrl changes", () => {
const docBody = { server: { speech: { baseUrl: "https://new.com/v1" } } }
const currentServer = { speech: { baseUrl: "https://old.com/v1", apiKey: "sk-stored" } }
const result = enforceSpeechCredentialPairing(docBody.server, currentServer) as any
assert.equal(result.speech.apiKey, null, "doc-level server.speech must have key cleared on URL change")
assert.equal(result.speech.baseUrl, "https://new.com/v1")
})
it("doc-level payload: preserves key when server.speech.baseUrl unchanged", () => {
const docBody = { server: { speech: { baseUrl: "https://same.com/v1", ttsVoice: "alloy" } } }
const currentServer = { speech: { baseUrl: "https://same.com/v1", apiKey: "sk-stored" } }
const result = enforceSpeechCredentialPairing(docBody.server, currentServer) as any
assert.equal("apiKey" in result.speech, false, "key must not be cleared when URL unchanged in doc-level patch")
})
})

View file

@ -19,12 +19,52 @@ function validateBinaryPath(binaryPath: string): { valid: boolean; version?: str
return { valid: result.valid, version: result.version, error: result.error } return { valid: result.valid, version: result.version, error: result.error }
} }
export function enforceSpeechCredentialPairing(body: unknown, currentSpeech?: unknown): unknown {
if (!body || typeof body !== "object") return body
const patch = { ...(body as Record<string, unknown>) }
const speech = patch.speech
if (!speech || typeof speech !== "object") return patch
const speechPatch = { ...(speech as Record<string, unknown>) }
const cur = (currentSpeech && typeof currentSpeech === "object") ? currentSpeech as Record<string, unknown> : {}
const curSpeech = (cur.speech && typeof cur.speech === "object") ? cur.speech as Record<string, unknown> : cur
if ("baseUrl" in speechPatch && !("apiKey" in speechPatch)) {
if ((speechPatch.baseUrl ?? "") !== (curSpeech.baseUrl ?? "")) {
speechPatch.apiKey = null
}
}
for (const dir of ["stt", "tts"] as const) {
if (dir in speechPatch) {
const dirPatch = { ...(speechPatch[dir] as Record<string, unknown>) }
if ("baseUrl" in dirPatch && !("apiKey" in dirPatch)) {
const curDir = (curSpeech[dir] && typeof curSpeech[dir] === "object") ? curSpeech[dir] as Record<string, unknown> : {}
if ((dirPatch.baseUrl ?? "") !== (curDir.baseUrl ?? "")) {
dirPatch.apiKey = null
}
speechPatch[dir] = dirPatch
}
}
}
patch.speech = speechPatch
return patch
}
export function registerSettingsRoutes(app: FastifyInstance, deps: RouteDeps) { export function registerSettingsRoutes(app: FastifyInstance, deps: RouteDeps) {
// Full-document access // Full-document access
app.get("/api/storage/config", async () => sanitizeConfigDoc(deps.settings.getDoc("config"))) app.get("/api/storage/config", async () => sanitizeConfigDoc(deps.settings.getDoc("config")))
app.patch("/api/storage/config", async (request, reply) => { app.patch("/api/storage/config", async (request, reply) => {
try { try {
return sanitizeConfigDoc(deps.settings.mergePatchDoc("config", request.body ?? {})) let body = request.body ?? {}
if (body && typeof body === "object" && "server" in body) {
const bodyObj = { ...(body as Record<string, unknown>) }
const serverPatch = bodyObj.server
if (serverPatch && typeof serverPatch === "object") {
const currentServer = deps.settings.getOwner("config", "server")
bodyObj.server = enforceSpeechCredentialPairing(serverPatch, currentServer)
}
body = bodyObj
}
return sanitizeConfigDoc(deps.settings.mergePatchDoc("config", body))
} catch (error) { } catch (error) {
reply.code(400) reply.code(400)
return { error: error instanceof Error ? error.message : "Invalid patch" } return { error: error instanceof Error ? error.message : "Invalid patch" }
@ -37,9 +77,15 @@ export function registerSettingsRoutes(app: FastifyInstance, deps: RouteDeps) {
app.patch<{ Params: { owner: string } }>("/api/storage/config/:owner", async (request, reply) => { app.patch<{ Params: { owner: string } }>("/api/storage/config/:owner", async (request, reply) => {
try { try {
const currentOwner = request.params.owner === "server"
? deps.settings.getOwner("config", "server")
: undefined
const processed = request.params.owner === "server"
? enforceSpeechCredentialPairing(request.body ?? {}, currentOwner)
: request.body ?? {}
return sanitizeConfigOwner( return sanitizeConfigOwner(
request.params.owner, request.params.owner,
deps.settings.mergePatchOwner("config", request.params.owner, request.body ?? {}), deps.settings.mergePatchOwner("config", request.params.owner, processed),
) )
} catch (error) { } catch (error) {
reply.code(400) reply.code(400)

View file

@ -0,0 +1,28 @@
import assert from "node:assert/strict"
import test from "node:test"
import Fastify from "fastify"
import { registerUsageRoutes } from "./usage"
test("returns a typed unsupported result without exposing server details", async () => {
const app = Fastify()
registerUsageRoutes(app)
try {
const response = await app.inject({ method: "GET", url: "/api/usage/unknown-provider?modelId=test-model" })
assert.equal(response.statusCode, 200)
assert.deepEqual(response.json(), {
requestedProviderId: "unknown-provider",
providerId: null,
providerName: "unknown-provider",
modelId: "test-model",
supported: false,
configured: false,
ok: false,
windows: {},
fetchedAt: response.json().fetchedAt,
})
} finally {
await app.close()
}
})

View file

@ -0,0 +1,24 @@
import type { FastifyInstance } from "fastify"
import { z } from "zod"
import { getProviderUsage } from "../../usage/service"
const UsageParamsSchema = z.object({ providerId: z.string().trim().min(1) })
const UsageQuerySchema = z.object({ modelId: z.string().trim().optional() })
export function registerUsageRoutes(app: FastifyInstance) {
app.get<{ Params: { providerId: string }; Querystring: { modelId?: string } }>(
"/api/usage/:providerId",
async (request, reply) => {
try {
const params = UsageParamsSchema.parse(request.params)
const query = UsageQuerySchema.parse(request.query ?? {})
return await getProviderUsage(params.providerId, { modelId: query.modelId })
} catch (error) {
request.log.error({ err: error }, "Failed to fetch provider usage")
reply.code(400)
return { error: error instanceof Error ? error.message : "Failed to fetch provider usage" }
}
},
)
}

View file

@ -0,0 +1,126 @@
import assert from "node:assert/strict"
import { describe, it } from "node:test"
import Fastify from "fastify"
import type { WorkspaceDescriptor } from "../../api-types"
import type { WorkspaceManager } from "../../workspaces/manager"
import { registerWorkspaceRoutes } from "./workspaces"
describe("workspace routes", () => {
it("forwards a validated explicit binary path when creating a workspace", async () => {
const calls: unknown[][] = []
const app = Fastify({ logger: false })
const descriptor: WorkspaceDescriptor = {
id: "workspace",
path: "C:/work",
status: "ready",
proxyPath: "/workspaces/workspace/instance",
binaryId: "C:/tools/opencode.exe",
binaryLabel: "opencode.exe",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
}
const workspaceManager = {
create: async (...args: unknown[]) => {
calls.push(args)
return { workspace: descriptor, created: true }
},
releaseCreationRequest: (workspaceId: string, requestId: string) =>
workspaceId === descriptor.id && requestId === "restore-request",
cancelCreationRequest: async (requestId: string) => {
calls.push(["cancel", requestId])
},
} as unknown as WorkspaceManager
registerWorkspaceRoutes(app, { workspaceManager })
const response = await app.inject({
method: "POST",
url: "/api/workspaces",
payload: {
path: "C:/work",
name: "Work",
binaryPath: " C:/tools/opencode.exe ",
requestId: " restore-request ",
forceNew: true,
},
})
assert.equal(response.statusCode, 201)
assert.deepEqual(calls, [["C:/work", "Work", {
binaryPath: "C:/tools/opencode.exe",
requestId: "restore-request",
forceNew: true,
}]])
const released = await app.inject({
method: "POST",
url: "/api/workspaces/workspace/creation/release",
payload: { requestId: "restore-request" },
})
assert.equal(released.statusCode, 204)
const wrongRelease = await app.inject({
method: "POST",
url: "/api/workspaces/workspace/creation/release",
payload: { requestId: "other-request" },
})
assert.equal(wrongRelease.statusCode, 404)
const cancelled = await app.inject({
method: "POST",
url: "/api/workspaces/creation/cancel",
payload: { requestId: "restore-request" },
})
assert.equal(cancelled.statusCode, 204)
assert.deepEqual(calls.at(-1), ["cancel", "restore-request"])
const invalid = await app.inject({
method: "POST",
url: "/api/workspaces",
payload: { path: "C:/work", binaryPath: "x".repeat(4097) },
})
assert.equal(invalid.statusCode, 400)
assert.equal(calls.length, 2)
await app.close()
})
it("rejects release after cancellation wins while deletion is still pending", async () => {
const app = Fastify({ logger: false })
let state: "active" | "cancelled" | "released" = "active"
let cancellationStarted!: () => void
let finishDeletion!: () => void
const started = new Promise<void>((resolve) => { cancellationStarted = resolve })
const deletion = new Promise<void>((resolve) => { finishDeletion = resolve })
const workspaceManager = {
cancelCreationRequest: async () => {
state = "cancelled"
cancellationStarted()
await deletion
},
releaseCreationRequest: () => {
if (state === "cancelled") return false
state = "released"
return true
},
} as unknown as WorkspaceManager
registerWorkspaceRoutes(app, { workspaceManager })
const cancellation = app.inject({
method: "POST",
url: "/api/workspaces/creation/cancel",
payload: { requestId: "restore-request" },
})
await started
const release = await app.inject({
method: "POST",
url: "/api/workspaces/workspace/creation/release",
payload: { requestId: "restore-request" },
})
assert.equal(release.statusCode, 404)
assert.equal(release.body, "Workspace creation request not found")
finishDeletion()
assert.equal((await cancellation).statusCode, 204)
await app.close()
})
})

View file

@ -14,6 +14,9 @@ interface RouteDeps {
const WorkspaceCreateSchema = z.object({ const WorkspaceCreateSchema = z.object({
path: z.string(), path: z.string(),
name: z.string().optional(), name: z.string().optional(),
binaryPath: z.string().trim().min(1).max(4096).optional(),
requestId: z.string().trim().min(1).max(128).optional(),
forceNew: z.boolean().optional(),
}) })
const WorkspaceCloneSchema = z.object({ const WorkspaceCloneSchema = z.object({
@ -22,6 +25,10 @@ const WorkspaceCloneSchema = z.object({
cleanup: z.boolean().optional(), cleanup: z.boolean().optional(),
}) })
const WorkspaceCreationReleaseSchema = z.object({
requestId: z.string().trim().min(1).max(128),
})
const WorkspaceFilesQuerySchema = z.object({ const WorkspaceFilesQuerySchema = z.object({
path: z.string().optional(), path: z.string().optional(),
}) })
@ -68,9 +75,13 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) {
app.post("/api/workspaces", async (request, reply) => { app.post("/api/workspaces", async (request, reply) => {
try { try {
const body = WorkspaceCreateSchema.parse(request.body ?? {}) const body = WorkspaceCreateSchema.parse(request.body ?? {})
const workspace = await deps.workspaceManager.create(body.path, body.name) const result = await deps.workspaceManager.create(body.path, body.name, {
binaryPath: body.binaryPath,
requestId: body.requestId,
forceNew: body.forceNew,
})
reply.code(201) reply.code(201)
return workspace return result.created ? result.workspace : { ...result.workspace, reused: true as const }
} catch (error) { } catch (error) {
request.log.error({ err: error }, "Failed to create workspace") request.log.error({ err: error }, "Failed to create workspace")
const message = error instanceof Error ? error.message : "Failed to create workspace" const message = error instanceof Error ? error.message : "Failed to create workspace"
@ -103,6 +114,30 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) {
reply.code(204) reply.code(204)
}) })
app.post("/api/workspaces/creation/cancel", async (request, reply) => {
const parsed = WorkspaceCreationReleaseSchema.safeParse(request.body ?? {})
if (!parsed.success) {
reply.code(400).type("text/plain").send("Invalid workspace creation request")
return
}
await deps.workspaceManager.cancelCreationRequest(parsed.data.requestId)
reply.code(204)
})
app.post<{ Params: { id: string } }>("/api/workspaces/:id/creation/release", async (request, reply) => {
const parsed = WorkspaceCreationReleaseSchema.safeParse(request.body ?? {})
if (!parsed.success) {
reply.code(400).type("text/plain").send("Invalid workspace creation request")
return
}
const body = parsed.data
if (!deps.workspaceManager.releaseCreationRequest(request.params.id, body.requestId)) {
reply.code(404).type("text/plain").send("Workspace creation request not found")
return
}
reply.code(204)
})
app.get<{ app.get<{
Params: { id: string } Params: { id: string }
Querystring: { path?: string } Querystring: { path?: string }

View file

@ -9,10 +9,12 @@ import {
removeWorktree, removeWorktree,
} from "../../workspaces/git-worktrees" } from "../../workspaces/git-worktrees"
import type { WorktreeListResponse, WorktreeMap } from "../../api-types" import type { WorktreeListResponse, WorktreeMap } from "../../api-types"
import type { OpencodeYoloPersistence } from "../../permissions/opencode-yolo-metadata"
import { ensureCodenomadGitExclude, readWorktreeMap, writeWorktreeMap } from "../../workspaces/worktree-map" import { ensureCodenomadGitExclude, readWorktreeMap, writeWorktreeMap } from "../../workspaces/worktree-map"
interface RouteDeps { interface RouteDeps {
workspaceManager: WorkspaceManager workspaceManager: WorkspaceManager
sessionMetadataPersistence: OpencodeYoloPersistence
} }
const WorktreeMapSchema = z.object({ const WorktreeMapSchema = z.object({
@ -26,7 +28,34 @@ const WorktreeCreateSchema = z.object({
branch: z.string().trim().min(1).optional(), branch: z.string().trim().min(1).optional(),
}) })
const WorktreeSessionSchema = z.object({ worktreeSlug: z.string().trim().refine(isValidWorktreeSlug) })
export function registerWorktreeRoutes(app: FastifyInstance, deps: RouteDeps) { export function registerWorktreeRoutes(app: FastifyInstance, deps: RouteDeps) {
app.put<{ Params: { id: string; sessionId: string }; Body: unknown }>(
"/api/workspaces/:id/worktrees/sessions/:sessionId",
async (request, reply) => {
if (!deps.workspaceManager.get(request.params.id)) {
reply.code(404)
return { error: "Workspace not found" }
}
try {
const body = WorktreeSessionSchema.parse(request.body)
if (!await deps.sessionMetadataPersistence.hasProjectSession(request.params.id, request.params.sessionId)) {
reply.code(404)
return { error: "Session not found" }
}
const metadata = await deps.sessionMetadataPersistence.setWorktreeSlug(
request.params.id,
request.params.sessionId,
body.worktreeSlug,
)
return { metadata }
} catch (error) {
return handleError(error, reply)
}
},
)
app.get<{ Params: { id: string } }>("/api/workspaces/:id/worktrees", async (request, reply) => { app.get<{ Params: { id: string } }>("/api/workspaces/:id/worktrees", async (request, reply) => {
const workspace = deps.workspaceManager.get(request.params.id) const workspace = deps.workspaceManager.get(request.params.id)
if (!workspace) { if (!workspace) {

View file

@ -0,0 +1,27 @@
import { FastifyInstance } from "fastify"
import type { AutoAcceptManager } from "../../permissions/auto-accept-manager"
interface RouteDeps {
yoloManager: AutoAcceptManager
}
export function registerYoloRoutes(app: FastifyInstance, deps: RouteDeps) {
app.get<{ Params: { id: string; sessionId: string } }>(
"/workspaces/:id/yolo/sessions/:sessionId",
async (request) => {
const { id, sessionId } = request.params
await deps.yoloManager.hydrateInstance(id)
return { enabled: deps.yoloManager.isEnabled(id, sessionId) }
},
)
app.post<{ Params: { id: string; sessionId: string } }>(
"/workspaces/:id/yolo/sessions/:sessionId/toggle",
async (request, reply) => {
const { id, sessionId } = request.params
const enabled = await deps.yoloManager.toggle(id, sessionId)
reply.code(200)
return { enabled }
},
)
}

View file

@ -0,0 +1,20 @@
import assert from "node:assert/strict"
import { describe, it } from "node:test"
import { BinaryResolver } from "./binaries"
import type { SettingsService } from "./service"
describe("BinaryResolver", () => {
it("uses an explicit workspace binary without changing the configured default", () => {
const settings = {
getOwner(scope: string, owner: string) {
if (scope === "config" && owner === "server") return { opencodeBinary: "default-opencode" }
if (scope === "state" && owner === "ui") return { opencodeBinaries: [{ path: "saved-opencode", label: "Saved", version: "1.2.3" }] }
return {}
},
} as unknown as SettingsService
const resolver = new BinaryResolver(settings)
assert.deepEqual(resolver.resolve("saved-opencode"), { path: "saved-opencode", label: "Saved", version: "1.2.3" })
assert.equal(resolver.resolveDefault().path, "default-opencode")
})
})

View file

@ -40,10 +40,14 @@ export class BinaryResolver {
} }
resolveDefault(): ResolvedBinary { resolveDefault(): ResolvedBinary {
return this.resolve()
}
resolve(explicitPath?: string): ResolvedBinary {
const binaries = this.list() const binaries = this.list()
const configuredDefault = readDefaultBinaryPath(this.settings) const configuredDefault = readDefaultBinaryPath(this.settings)
const fallback = binaries[0]?.path const fallback = binaries[0]?.path
const path = configuredDefault ?? fallback ?? "opencode" const path = explicitPath?.trim() || configuredDefault || fallback || "opencode"
const entry = binaries.find((b) => b.path === path) const entry = binaries.find((b) => b.path === path)
return { return {

View file

@ -0,0 +1,61 @@
import assert from "node:assert/strict"
import { describe, it } from "node:test"
import { sanitizeConfigOwner } from "./public-config"
describe("sanitizeConfigOwner server speech", () => {
it("strips shared apiKey and sets hasApiKey", () => {
const result = sanitizeConfigOwner("server", {
speech: { apiKey: "sk-secret", sttModel: "whisper-1" },
})
assert.equal((result.speech as any).apiKey, undefined)
assert.equal((result.speech as any).hasApiKey, true)
})
it("strips stt.apiKey and sets stt.hasApiKey when separateProviders is true", () => {
const result = sanitizeConfigOwner("server", {
speech: {
separateProviders: true,
apiKey: "sk-shared",
stt: { apiKey: "sk-stt-secret", baseUrl: "https://groq.com" },
tts: { apiKey: "sk-tts-secret", baseUrl: "https://openai.com" },
},
})
assert.equal((result.speech as any).apiKey, undefined)
assert.equal((result.speech as any).hasApiKey, true)
assert.equal((result.speech as any).stt.apiKey, undefined)
assert.equal((result.speech as any).stt.hasApiKey, true)
assert.equal((result.speech as any).tts.apiKey, undefined)
assert.equal((result.speech as any).tts.hasApiKey, true)
})
it("sets hasApiKey=false when per-direction apiKey is absent", () => {
const result = sanitizeConfigOwner("server", {
speech: {
separateProviders: true,
stt: { baseUrl: "https://groq.com" },
tts: { baseUrl: "https://openai.com" },
},
})
assert.equal((result.speech as any).stt.hasApiKey, false)
assert.equal((result.speech as any).tts.hasApiKey, false)
})
it("preserves stt/tts sub-objects that have no apiKey", () => {
const result = sanitizeConfigOwner("server", {
speech: {
separateProviders: true,
stt: { model: "whisper-large-v3" },
tts: { model: "tts-1" },
},
})
assert.equal((result.speech as any).stt.model, "whisper-large-v3")
assert.equal((result.speech as any).stt.hasApiKey, false)
assert.equal((result.speech as any).tts.model, "tts-1")
assert.equal((result.speech as any).tts.hasApiKey, false)
})
it("passes through non-server owners unchanged", () => {
const result = sanitizeConfigOwner("ui", { foo: "bar" })
assert.deepEqual(result, { foo: "bar" })
})
})

View file

@ -20,6 +20,20 @@ function sanitizeServerOwner(value: SettingsDoc): SettingsDoc {
speech.hasApiKey = false speech.hasApiKey = false
} }
for (const dir of ["stt", "tts"] as const) {
if (isPlainObject(speech[dir])) {
const sub = { ...speech[dir] } as SettingsDoc
const dirKey = typeof sub.apiKey === "string" ? sub.apiKey.trim() : ""
if (dirKey) {
delete sub.apiKey
sub.hasApiKey = true
} else if (!("hasApiKey" in sub)) {
sub.hasApiKey = false
}
speech[dir] = sub
}
}
next.speech = speech next.speech = speech
return next return next
} }

View file

@ -0,0 +1,162 @@
import assert from "node:assert/strict"
import { describe, it } from "node:test"
import {
createServerShutdownHandler,
orchestrateServerShutdown,
SERVER_SHUTDOWN_COMPLETE,
SERVER_SHUTDOWN_INCOMPLETE,
type ServerShutdownOperations,
} from "./shutdown"
const logger = { info() {}, warn() {}, error() {} }
const operations = (overrides: Partial<ServerShutdownOperations> = {}): ServerShutdownOperations => ({
stopInstanceEventBridge() {}, stopSidecars() {}, stopClientConnections() {},
stopRemoteProxySessions() {}, stopWorkspaces() {}, stopHttpServers() {}, stopReleaseMonitor() {},
...overrides,
})
describe("server shutdown orchestration", () => {
it("retries workspace cleanup and preserves shutdown order", async () => {
const calls: string[] = []
let attempts = 0
await orchestrateServerShutdown(operations({
stopRemoteProxySessions: () => { calls.push("remote-proxy") },
stopWorkspaces: () => { calls.push(`workspaces-${++attempts}`); if (attempts === 1) throw new Error("still alive") },
stopHttpServers: () => { calls.push("http") },
}), logger)
assert.deepEqual(calls, ["workspaces-1", "remote-proxy", "workspaces-2", "http"])
})
it("closes remaining resources and aggregates the concrete current error", async () => {
const failure = new Error("workspace abc POSIX process group is still alive")
const closed: string[] = []
let attempts = 0
await assert.rejects(orchestrateServerShutdown(operations({
stopWorkspaces: () => { attempts++; throw failure },
stopHttpServers: () => { closed.push("http") },
stopReleaseMonitor: () => { closed.push("release-monitor") },
}), logger), (error: unknown) => {
assert.ok(error instanceof AggregateError)
assert.equal(error.errors.length, 1)
assert.match(error.errors[0].message, /^stopWorkspaces failed:/)
assert.strictEqual(error.errors[0].cause, failure)
return true
})
assert.deepEqual([attempts, closed], [2, ["http", "release-monitor"]])
})
it("starts workspace cleanup without waiting for preliminary shutdown", async () => {
let releasePreliminary!: () => void
const preliminary = new Promise<void>((resolve) => { releasePreliminary = resolve })
let workspaceStarted = false
const shutdown = orchestrateServerShutdown(operations({
stopRemoteProxySessions: () => preliminary,
stopWorkspaces: () => { workspaceStarted = true },
}), logger)
await new Promise<void>((resolve) => setImmediate(resolve))
assert.equal(workspaceStarted, true)
releasePreliminary()
await shutdown
})
})
describe("server shutdown signal boundary", () => {
it("reports incomplete cleanup and holds for final tree enforcement", async () => {
const calls: string[] = []
const exits: number[] = []
const statuses: string[] = []
let releaseHold!: () => void
const hold = new Promise<void>((resolve) => { releaseHold = resolve })
const handler = createServerShutdownHandler({
shutdown: async () => { calls.push("cleanup"); throw new Error("retained child survived") },
logger: { info: () => calls.push("info"), warn() {}, error: () => calls.push("error") },
forceExit: (code) => { calls.push("force-exit"); exits.push(code) },
setExitCode: () => undefined,
reportStatus: (status) => statuses.push(status),
holdAfterFailure: () => hold,
retryAttempts: 0,
})
const pending = handler("SIGTERM")
await new Promise<void>((resolve) => setImmediate(resolve))
assert.deepEqual([exits, statuses, calls], [[], [SERVER_SHUTDOWN_INCOMPLETE], ["info", "cleanup", "error", "error"]])
handler("SIGTERM")
assert.deepEqual(exits, [1])
releaseHold()
await pending
})
it("reports complete cleanup before allowing natural exit", async () => {
const statuses: string[] = [], exitCodes: number[] = []
const handler = createServerShutdownHandler({
shutdown: async () => undefined,
logger,
reportStatus: (status) => statuses.push(status),
setExitCode: (code) => exitCodes.push(code),
})
await handler("stdin")
assert.deepEqual(statuses, [SERVER_SHUTDOWN_COMPLETE])
assert.deepEqual(exitCodes, [0])
})
it("keeps retrying identity-aware cleanup during the final enforcement budget", async () => {
let attempts = 0
const statuses: string[] = [], exitCodes: number[] = []
const handler = createServerShutdownHandler({
shutdown: async () => {
attempts++
if (attempts === 1) throw new Error("detached group still alive")
},
logger,
reportStatus: (status) => statuses.push(status),
setExitCode: (code) => exitCodes.push(code),
retryDelayMs: 0,
})
await handler("stdin")
assert.equal(attempts, 2)
assert.deepEqual(statuses, [SERVER_SHUTDOWN_COMPLETE])
assert.deepEqual(exitCodes, [0])
})
it("preserves containment after the standalone cleanup retry budget", async () => {
let attempts = 0
const statuses: string[] = [], exitCodes: number[] = [], forcedExits: number[] = []
let releaseHold!: () => void
const hold = new Promise<void>((resolve) => { releaseHold = resolve })
const handler = createServerShutdownHandler({
shutdown: async () => { attempts++; throw new Error("process tree remains alive") },
logger,
reportStatus: (status) => statuses.push(status),
setExitCode: (code) => exitCodes.push(code),
forceExit: (code) => forcedExits.push(code),
holdAfterFailure: () => hold,
retryDelayMs: 0,
retryAttempts: 2,
})
const pending = handler("stdin")
while (attempts < 3) await new Promise<void>((resolve) => setTimeout(resolve, 0))
assert.equal(attempts, 3)
assert.deepEqual(statuses, [SERVER_SHUTDOWN_INCOMPLETE])
assert.deepEqual(exitCodes, [1])
assert.deepEqual(forcedExits, [])
handler("SIGTERM")
assert.deepEqual(forcedExits, [1])
releaseHold()
await pending
})
it("escalates a second signal while sharing first-signal cleanup", async () => {
let finish!: () => void
const cleanup = new Promise<void>((resolve) => { finish = resolve })
const exits: number[] = [], exitCodes: number[] = []
const handler = createServerShutdownHandler({ shutdown: () => cleanup, logger,
forceExit: (code) => exits.push(code), setExitCode: (code) => exitCodes.push(code), reportStatus: () => undefined })
const first = handler("SIGINT"), second = handler("SIGTERM")
assert.strictEqual(first, second)
assert.deepEqual(exits, [1])
finish(); await first
assert.deepEqual(exitCodes, [0])
})
})

View file

@ -0,0 +1,101 @@
type ShutdownLogger = Pick<import("./logger").Logger, "info" | "warn" | "error">
type ShutdownOperation = () => void | Promise<void>
export type ServerShutdownTrigger = NodeJS.Signals | "stdin"
export const SERVER_SHUTDOWN_COMPLETE = "CODENOMAD_SHUTDOWN_STATUS:complete"
export const SERVER_SHUTDOWN_INCOMPLETE = "CODENOMAD_SHUTDOWN_STATUS:incomplete"
export type ServerShutdownOperations = Record<
"stopInstanceEventBridge" | "stopSidecars" | "stopClientConnections" | "stopRemoteProxySessions" | "stopWorkspaces" |
"stopHttpServers" | "stopReleaseMonitor",
ShutdownOperation
>
export function createServerShutdownHandler(options: { shutdown: () => Promise<void>; logger: ShutdownLogger;
forceExit?: (code: number) => void; setExitCode?: (code: number) => void;
reportStatus?: (status: string) => void; holdAfterFailure?: () => Promise<void>;
retryDelayMs?: number; retryAttempts?: number }) {
const forceExit = options.forceExit ?? process.exit
const setExitCode = options.setExitCode ?? ((code: number) => { process.exitCode = code })
const reportStatus = options.reportStatus ?? ((status: string) => console.log(status))
let pending: Promise<void> | undefined
return (signal: ServerShutdownTrigger): Promise<void> => {
if (pending) {
options.logger.error({ signal }, "Additional shutdown signal received; forcing nonzero exit")
forceExit(1)
return pending
}
options.logger.info({ signal }, "Received shutdown signal, stopping workspaces and server")
pending = Promise.resolve().then(options.shutdown).then(() => {
options.logger.info({}, "Shutdown complete")
reportStatus(SERVER_SHUTDOWN_COMPLETE)
setExitCode(0)
}, async (error) => {
options.logger.error({ err: error }, "Server shutdown incomplete; retrying cleanup")
const retryAttempts = Math.max(0, Math.floor(options.retryAttempts ?? 3))
for (let attempt = 1; attempt <= retryAttempts; attempt += 1) {
await new Promise<void>((resolve) => setTimeout(resolve, options.retryDelayMs ?? 250))
try {
await options.shutdown()
options.logger.info({}, "Shutdown cleanup retry completed")
reportStatus(SERVER_SHUTDOWN_COMPLETE)
setExitCode(0)
return
} catch (retryError) {
options.logger.warn({ err: retryError, attempt, attempts: retryAttempts }, "Shutdown cleanup retry remains incomplete")
}
}
options.logger.error({ attempts: retryAttempts }, "Shutdown cleanup retries exhausted; preserving process-tree containment")
reportStatus(SERVER_SHUTDOWN_INCOMPLETE)
setExitCode(1)
if (options.holdAfterFailure) await options.holdAfterFailure()
})
return pending
}
}
export async function orchestrateServerShutdown(
operations: ServerShutdownOperations,
logger: ShutdownLogger,
workspaceAttempts = 2,
): Promise<void> {
const errors: unknown[] = []
const namedError = (name: keyof ServerShutdownOperations, cause: unknown) => Object.assign(
new Error(`${name} failed: ${cause instanceof Error ? cause.message : String(cause)}`),
{ cause },
)
const settle = async (pending: Array<[keyof ServerShutdownOperations, ShutdownOperation]>) => {
const results = await Promise.allSettled(pending.map(([, run]) => Promise.resolve().then(run)))
for (let index = 0; index < results.length; index += 1) {
const result = results[index]!
if (result.status !== "rejected") continue
const error = namedError(pending[index]![0], result.reason)
errors.push(error)
logger.error({ err: error }, "Server resource shutdown failed")
}
}
const workspaceShutdown = (async () => {
const attempts = Math.max(1, Math.floor(workspaceAttempts))
for (let attempt = 1; attempt <= attempts; attempt += 1) {
const [result] = await Promise.allSettled([Promise.resolve().then(operations.stopWorkspaces)])
if (result.status === "fulfilled") break
if (attempt < attempts) {
logger.warn({ err: result.reason, attempt, attempts }, "Workspace manager shutdown failed; retrying cleanup")
continue
}
const error = namedError("stopWorkspaces", result.reason)
errors.push(error)
logger.error({ err: error, attempts }, "Workspace manager shutdown failed")
}
})()
await Promise.all([
settle([
["stopInstanceEventBridge", operations.stopInstanceEventBridge], ["stopSidecars", operations.stopSidecars],
["stopClientConnections", operations.stopClientConnections], ["stopRemoteProxySessions", operations.stopRemoteProxySessions],
]),
workspaceShutdown,
])
await settle([["stopHttpServers", operations.stopHttpServers], ["stopReleaseMonitor", operations.stopReleaseMonitor]])
if (errors.length) throw new AggregateError(errors, "Server shutdown failed")
}

View file

@ -0,0 +1,289 @@
import assert from "node:assert/strict"
import { describe, it } from "node:test"
import { SpeechService } from "./service"
import type { SettingsService } from "../settings/service"
import type { Logger } from "../logger"
function createMockSettings(serverConfig: Record<string, unknown>): SettingsService {
return {
getOwner: () => serverConfig,
} as unknown as SettingsService
}
const mockLogger: Logger = {
child: () => mockLogger,
info: () => {},
warn: () => {},
error: () => {},
debug: () => {},
} as unknown as Logger
describe("SpeechService direction resolution", () => {
describe("separateProviders = false (default)", () => {
it("uses shared apiKey for both STT and TTS", () => {
const settings = createMockSettings({
speech: {
apiKey: "sk-shared",
baseUrl: "https://api.openai.com/v1",
sttModel: "whisper-1",
ttsModel: "tts-1",
},
})
const service = new SpeechService(settings, mockLogger)
const caps = service.getCapabilities()
assert.equal(caps.separateProviders, false)
assert.equal(caps.sttConfigured, true)
assert.equal(caps.ttsConfigured, true)
assert.equal(caps.configured, true)
})
it("reports unconfigured when no apiKey is set", () => {
const settings = createMockSettings({
speech: { sttModel: "whisper-1", ttsModel: "tts-1" },
})
const service = new SpeechService(settings, mockLogger)
const caps = service.getCapabilities()
assert.equal(caps.sttConfigured, false)
assert.equal(caps.ttsConfigured, false)
assert.equal(caps.configured, false)
})
it("transcribe throws not-configured when apiKey absent", async () => {
const service = new SpeechService(createMockSettings({ speech: {} }), mockLogger)
await assert.rejects(
() => service.transcribe({ audioBase64: "", mimeType: "audio/webm" }),
/not configured/i,
)
})
})
describe("separateProviders = true", () => {
it("reports per-direction configured status independently", () => {
const settings = createMockSettings({
speech: {
apiKey: "sk-shared",
separateProviders: true,
stt: { apiKey: "sk-groq", baseUrl: "https://api.groq.com/openai/v1", model: "whisper-large-v3" },
tts: { apiKey: "sk-openai", baseUrl: "https://api.openai.com/v1", model: "gpt-4o-mini-tts" },
},
})
const service = new SpeechService(settings, mockLogger)
const caps = service.getCapabilities()
assert.equal(caps.separateProviders, true)
assert.equal(caps.sttConfigured, true)
assert.equal(caps.ttsConfigured, true)
assert.equal(caps.configured, true)
assert.equal(caps.sttBaseUrl, "https://api.groq.com/openai/v1")
assert.equal(caps.ttsBaseUrl, "https://api.openai.com/v1")
})
it("reports partial config when only STT has complete directional pair", () => {
const settings = createMockSettings({
speech: {
separateProviders: true,
stt: { apiKey: "sk-groq", baseUrl: "https://api.groq.com/openai/v1" },
},
})
const service = new SpeechService(settings, mockLogger)
const caps = service.getCapabilities()
assert.equal(caps.sttConfigured, true)
assert.equal(caps.ttsConfigured, false)
assert.equal(caps.configured, false)
})
it("does NOT combine directional key with shared URL (incomplete pair)", () => {
const settings = createMockSettings({
speech: {
apiKey: "sk-shared",
baseUrl: "https://api.openai.com/v1",
separateProviders: true,
stt: { apiKey: "sk-groq" },
tts: { apiKey: "sk-deepseek" },
},
})
const service = new SpeechService(settings, mockLogger)
const caps = service.getCapabilities()
assert.equal(caps.sttConfigured, false, "directional key without directional URL should not be configured")
assert.equal(caps.ttsConfigured, false)
})
it("does NOT combine shared key with foreign directional URL (incomplete pair)", () => {
const settings = createMockSettings({
speech: {
apiKey: "sk-shared",
baseUrl: "https://api.openai.com/v1",
separateProviders: true,
stt: { baseUrl: "https://api.groq.com/openai/v1" },
tts: { baseUrl: "https://api.deepseek.com/v1" },
},
})
const service = new SpeechService(settings, mockLogger)
const caps = service.getCapabilities()
assert.equal(caps.sttConfigured, false)
assert.equal(caps.ttsConfigured, false)
})
it("treats directional baseUrl matching shared as inherited (no directional key)", () => {
const sharedUrl = "https://api.openai.com/v1"
const settings = createMockSettings({
speech: {
apiKey: "sk-shared",
baseUrl: sharedUrl,
separateProviders: true,
stt: { baseUrl: sharedUrl },
tts: { baseUrl: sharedUrl },
},
})
const service = new SpeechService(settings, mockLogger)
const caps = service.getCapabilities()
assert.equal(caps.sttConfigured, true, "directional baseUrl matching shared with no key should inherit shared key")
assert.equal(caps.ttsConfigured, true)
})
it("inherits complete shared pair when neither directional value is set", () => {
const settings = createMockSettings({
speech: {
apiKey: "sk-shared",
baseUrl: "https://api.openai.com/v1",
separateProviders: true,
},
})
const service = new SpeechService(settings, mockLogger)
const caps = service.getCapabilities()
assert.equal(caps.sttConfigured, true)
assert.equal(caps.ttsConfigured, true)
})
it("uses complete directional pair when both key and URL are set", () => {
const settings = createMockSettings({
speech: {
apiKey: "sk-shared",
baseUrl: "https://api.openai.com/v1",
separateProviders: true,
stt: { apiKey: "sk-groq", baseUrl: "https://api.groq.com/openai/v1" },
tts: { apiKey: "sk-openai", baseUrl: "https://api.openai.com/v1" },
},
})
const service = new SpeechService(settings, mockLogger)
const caps = service.getCapabilities()
assert.equal(caps.sttConfigured, true)
assert.equal(caps.ttsConfigured, true)
assert.equal(caps.sttBaseUrl, "https://api.groq.com/openai/v1")
})
it("directional key with matching shared URL forms a complete pair", () => {
const sharedUrl = "https://api.openai.com/v1"
const settings = createMockSettings({
speech: {
apiKey: "sk-shared",
baseUrl: sharedUrl,
separateProviders: true,
stt: { apiKey: "sk-alt-openai", baseUrl: sharedUrl },
},
})
const service = new SpeechService(settings, mockLogger)
const caps = service.getCapabilities()
assert.equal(caps.sttConfigured, true, "directional key with matching shared URL should be a complete pair")
})
it("falls back to shared sttModel/ttsModel when per-direction model is absent", () => {
const settings = createMockSettings({
speech: {
apiKey: "sk-shared",
baseUrl: "https://api.openai.com/v1",
sttModel: "shared-stt-model",
ttsModel: "shared-tts-model",
separateProviders: true,
stt: { apiKey: "sk-stt", baseUrl: "https://api.openai.com/v1" },
tts: { apiKey: "sk-tts", baseUrl: "https://api.openai.com/v1" },
},
})
const service = new SpeechService(settings, mockLogger)
const caps = service.getCapabilities()
assert.equal(caps.sttModel, "shared-stt-model")
assert.equal(caps.ttsModel, "shared-tts-model")
})
it("uses per-direction model when set", () => {
const settings = createMockSettings({
speech: {
apiKey: "sk-shared",
baseUrl: "https://api.openai.com/v1",
sttModel: "shared-stt-model",
ttsModel: "shared-tts-model",
separateProviders: true,
stt: { apiKey: "sk-stt", baseUrl: "https://api.openai.com/v1", model: "whisper-large-v3" },
tts: { apiKey: "sk-tts", baseUrl: "https://api.openai.com/v1", model: "gpt-4o-mini-tts" },
},
})
const service = new SpeechService(settings, mockLogger)
const caps = service.getCapabilities()
assert.equal(caps.sttModel, "whisper-large-v3")
assert.equal(caps.ttsModel, "gpt-4o-mini-tts")
})
it("omits sttBaseUrl/ttsBaseUrl when separateProviders is false", () => {
const settings = createMockSettings({
speech: { apiKey: "sk-shared", baseUrl: "https://api.openai.com/v1" },
})
const service = new SpeechService(settings, mockLogger)
const caps = service.getCapabilities()
assert.equal(caps.sttBaseUrl, undefined)
assert.equal(caps.ttsBaseUrl, undefined)
})
it("transcribe throws not-configured for incomplete pair (key without URL)", async () => {
const settings = createMockSettings({
speech: {
apiKey: "sk-shared",
baseUrl: "https://api.openai.com/v1",
separateProviders: true,
stt: { apiKey: "sk-groq" },
},
})
const service = new SpeechService(settings, mockLogger)
await assert.rejects(
() => service.transcribe({ audioBase64: "", mimeType: "audio/webm" }),
/not configured/i,
)
})
it("configured uses && in separate mode (both required for legacy compat)", () => {
const settings = createMockSettings({
speech: {
separateProviders: true,
stt: { apiKey: "sk-stt", baseUrl: "https://api.stt.com/v1" },
},
})
const service = new SpeechService(settings, mockLogger)
const caps = service.getCapabilities()
assert.equal(caps.sttConfigured, true)
assert.equal(caps.ttsConfigured, false)
assert.equal(caps.configured, false, "configured should be false when only one direction is ready")
})
it("configured uses || in shared mode", () => {
const settings = createMockSettings({
speech: { apiKey: "sk-shared", baseUrl: "https://api.openai.com/v1" },
})
const service = new SpeechService(settings, mockLogger)
const caps = service.getCapabilities()
assert.equal(caps.configured, true)
})
})
})

View file

@ -15,6 +15,21 @@ const ServerSpeechSettingsSchema = z.object({
ttsModel: z.string().optional(), ttsModel: z.string().optional(),
ttsVoice: z.string().optional(), ttsVoice: z.string().optional(),
ttsFormat: z.enum(["mp3", "wav", "opus", "aac"]).optional(), ttsFormat: z.enum(["mp3", "wav", "opus", "aac"]).optional(),
separateProviders: z.boolean().optional(),
stt: z
.object({
apiKey: z.string().nullish(),
baseUrl: z.string().nullish(),
model: z.string().nullish(),
})
.optional(),
tts: z
.object({
apiKey: z.string().nullish(),
baseUrl: z.string().nullish(),
model: z.string().nullish(),
})
.optional(),
}) })
.optional(), .optional(),
}) })
@ -38,7 +53,10 @@ export interface SpeechSynthesisStreamResponse {
} }
export interface SpeechProvider { export interface SpeechProvider {
getCapabilities(): SpeechCapabilitiesResponse getCapabilities(): Omit<
SpeechCapabilitiesResponse,
"separateProviders" | "sttConfigured" | "ttsConfigured" | "sttBaseUrl" | "ttsBaseUrl"
>
transcribe(input: TranscribeAudioInput): Promise<SpeechTranscriptionResponse> transcribe(input: TranscribeAudioInput): Promise<SpeechTranscriptionResponse>
synthesize(input: SynthesizeSpeechInput): Promise<SpeechSynthesisResponse> synthesize(input: SynthesizeSpeechInput): Promise<SpeechSynthesisResponse>
synthesizeStream(input: SynthesizeSpeechInput): Promise<SpeechSynthesisStreamResponse> synthesizeStream(input: SynthesizeSpeechInput): Promise<SpeechSynthesisStreamResponse>
@ -66,33 +84,76 @@ export class SpeechService {
) {} ) {}
getCapabilities(): SpeechCapabilitiesResponse { getCapabilities(): SpeechCapabilitiesResponse {
return this.createProvider().getCapabilities() const parsed = this.parseConfig()
const speech = parsed.speech ?? {}
const separate = speech.separateProviders === true
const sttSettings = this.resolveSttSettingsFrom(speech)
const ttsSettings = this.resolveTtsSettingsFrom(speech)
const sttConfigured = Boolean(sttSettings.apiKey)
const ttsConfigured = Boolean(ttsSettings.apiKey)
return {
available: true,
configured: separate ? (sttConfigured && ttsConfigured) : (sttConfigured || ttsConfigured),
provider: sttSettings.provider,
supportsStt: true,
supportsTts: true,
supportsStreamingTts: true,
baseUrl: separate ? undefined : (speech.baseUrl?.trim() || process.env.OPENAI_BASE_URL || undefined),
sttModel: sttSettings.sttModel,
ttsModel: ttsSettings.ttsModel,
ttsVoice: ttsSettings.ttsVoice,
ttsFormats: ["mp3", "wav", "opus", "aac"],
streamingTtsFormats: ["mp3", "wav", "opus", "aac"],
separateProviders: separate,
sttConfigured,
ttsConfigured,
...(separate ? { sttBaseUrl: sttSettings.baseUrl, ttsBaseUrl: ttsSettings.baseUrl } : {}),
}
} }
async transcribe(input: TranscribeAudioInput): Promise<SpeechTranscriptionResponse> { async transcribe(input: TranscribeAudioInput): Promise<SpeechTranscriptionResponse> {
return this.createProvider().transcribe(input) return this.createSttProvider().transcribe(input)
} }
async synthesize(input: SynthesizeSpeechInput): Promise<SpeechSynthesisResponse> { async synthesize(input: SynthesizeSpeechInput): Promise<SpeechSynthesisResponse> {
return this.createProvider().synthesize(input) return this.createTtsProvider().synthesize(input)
} }
async synthesizeStream(input: SynthesizeSpeechInput): Promise<SpeechSynthesisStreamResponse> { async synthesizeStream(input: SynthesizeSpeechInput): Promise<SpeechSynthesisStreamResponse> {
return this.createProvider().synthesizeStream(input) return this.createTtsProvider().synthesizeStream(input)
} }
private createProvider(): SpeechProvider { private parseConfig() {
const settings = this.resolveSettings() return ServerSpeechSettingsSchema.parse(this.settings.getOwner("config", "server") ?? {})
}
private createSttProvider(): SpeechProvider {
const settings = this.resolveSttSettings()
return new OpenAICompatibleSpeechProvider({ return new OpenAICompatibleSpeechProvider({
settings, settings,
logger: this.logger.child({ provider: settings.provider }), logger: this.logger.child({ provider: settings.provider, direction: "stt" }),
}) })
} }
private resolveSettings(): NormalizedSpeechSettings { private createTtsProvider(): SpeechProvider {
const parsed = ServerSpeechSettingsSchema.parse(this.settings.getOwner("config", "server") ?? {}) const settings = this.resolveTtsSettings()
const speech = parsed.speech ?? {} return new OpenAICompatibleSpeechProvider({
settings,
logger: this.logger.child({ provider: settings.provider, direction: "tts" }),
})
}
private resolveSttSettings(): NormalizedSpeechSettings {
return this.resolveSttSettingsFrom(this.parseConfig().speech ?? {})
}
private resolveTtsSettings(): NormalizedSpeechSettings {
return this.resolveTtsSettingsFrom(this.parseConfig().speech ?? {})
}
private resolveShared(speech: NonNullable<z.infer<typeof ServerSpeechSettingsSchema>["speech"]>): NormalizedSpeechSettings {
return { return {
provider: speech.provider?.trim() || DEFAULT_PROVIDER, provider: speech.provider?.trim() || DEFAULT_PROVIDER,
apiKey: speech.apiKey?.trim() || process.env.OPENAI_API_KEY, apiKey: speech.apiKey?.trim() || process.env.OPENAI_API_KEY,
@ -103,4 +164,64 @@ export class SpeechService {
ttsFormat: speech.ttsFormat ?? DEFAULT_TTS_FORMAT, ttsFormat: speech.ttsFormat ?? DEFAULT_TTS_FORMAT,
} }
} }
private resolveSttSettingsFrom(speech: NonNullable<z.infer<typeof ServerSpeechSettingsSchema>["speech"]>): NormalizedSpeechSettings {
if (speech.separateProviders !== true) {
return this.resolveShared(speech)
}
const stt = speech.stt ?? {}
const sharedBaseUrl = speech.baseUrl?.trim() || undefined
const rawDirBaseUrl = stt.baseUrl?.trim() || undefined
const dirApiKey = stt.apiKey?.trim() || undefined
const dirBaseUrl = dirApiKey
? rawDirBaseUrl
: (rawDirBaseUrl && rawDirBaseUrl !== sharedBaseUrl ? rawDirBaseUrl : undefined)
const hasCompleteDirectional = Boolean(dirApiKey) && Boolean(dirBaseUrl)
const hasNoDirectional = !dirApiKey && !dirBaseUrl
return {
provider: speech.provider?.trim() || DEFAULT_PROVIDER,
apiKey: hasCompleteDirectional
? dirApiKey
: hasNoDirectional
? speech.apiKey?.trim() || process.env.OPENAI_API_KEY
: undefined,
baseUrl: hasCompleteDirectional || hasNoDirectional
? (dirBaseUrl || sharedBaseUrl || process.env.OPENAI_BASE_URL || undefined)
: dirBaseUrl || undefined,
sttModel: stt.model?.trim() || speech.sttModel?.trim() || DEFAULT_STT_MODEL,
ttsModel: speech.ttsModel?.trim() || DEFAULT_TTS_MODEL,
ttsVoice: speech.ttsVoice?.trim() || DEFAULT_TTS_VOICE,
ttsFormat: speech.ttsFormat ?? DEFAULT_TTS_FORMAT,
}
}
private resolveTtsSettingsFrom(speech: NonNullable<z.infer<typeof ServerSpeechSettingsSchema>["speech"]>): NormalizedSpeechSettings {
if (speech.separateProviders !== true) {
return this.resolveShared(speech)
}
const tts = speech.tts ?? {}
const sharedBaseUrl = speech.baseUrl?.trim() || undefined
const rawDirBaseUrl = tts.baseUrl?.trim() || undefined
const dirApiKey = tts.apiKey?.trim() || undefined
const dirBaseUrl = dirApiKey
? rawDirBaseUrl
: (rawDirBaseUrl && rawDirBaseUrl !== sharedBaseUrl ? rawDirBaseUrl : undefined)
const hasCompleteDirectional = Boolean(dirApiKey) && Boolean(dirBaseUrl)
const hasNoDirectional = !dirApiKey && !dirBaseUrl
return {
provider: speech.provider?.trim() || DEFAULT_PROVIDER,
apiKey: hasCompleteDirectional
? dirApiKey
: hasNoDirectional
? speech.apiKey?.trim() || process.env.OPENAI_API_KEY
: undefined,
baseUrl: hasCompleteDirectional || hasNoDirectional
? (dirBaseUrl || sharedBaseUrl || process.env.OPENAI_BASE_URL || undefined)
: dirBaseUrl || undefined,
sttModel: speech.sttModel?.trim() || DEFAULT_STT_MODEL,
ttsModel: tts.model?.trim() || speech.ttsModel?.trim() || DEFAULT_TTS_MODEL,
ttsVoice: speech.ttsVoice?.trim() || DEFAULT_TTS_VOICE,
ttsFormat: speech.ttsFormat ?? DEFAULT_TTS_FORMAT,
}
}
} }

View file

@ -0,0 +1,182 @@
import type { UsageProvider } from "../types"
import {
fetchJson,
getCredential,
notConfigured,
resolveWindowLabel,
safeFetch,
toNumber,
toTimestamp,
toUsageWindow,
} from "../shared"
const kimiAliases = ["kimi-for-coding", "kimi"] as const
const kimi: UsageProvider = {
id: "kimi-for-coding",
name: "Kimi for Coding",
aliases: kimiAliases,
async fetchQuota() {
const key = getCredential(kimiAliases, ["key", "token"])
if (!key) return notConfigured(this.id, this.name)
return safeFetch(this.id, this.name, async () => {
const payload = await fetchJson("https://api.kimi.com/coding/v1/usages", {
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
})
const windows: Record<string, ReturnType<typeof toUsageWindow>> = {}
const usage = payload?.usage
if (usage) {
const limit = toNumber(usage.limit)
const remaining = toNumber(usage.remaining)
windows.weekly = toUsageWindow({
usedPercent: limit && remaining !== null ? 100 - (remaining / limit) * 100 : null,
resetAt: toTimestamp(usage.resetTime),
})
}
for (const item of Array.isArray(payload?.limits) ? payload.limits : []) {
const duration = toNumber(item?.window?.duration)
const unit = item?.window?.timeUnit
const multiplier = unit === "TIME_UNIT_MINUTE" ? 60 : unit === "TIME_UNIT_HOUR" ? 3600 : unit === "TIME_UNIT_DAY" ? 86_400 : null
const seconds = duration !== null && multiplier ? duration * multiplier : null
const limit = toNumber(item?.detail?.limit)
const remaining = toNumber(item?.detail?.remaining)
windows[resolveWindowLabel(seconds)] = toUsageWindow({
usedPercent: limit && remaining !== null ? 100 - (remaining / limit) * 100 : null,
windowSeconds: seconds,
resetAt: toTimestamp(item?.detail?.resetTime),
})
}
return { windows }
})
},
}
const nanoAliases = ["nano-gpt", "nanogpt", "nano_gpt"] as const
const nanoGpt: UsageProvider = {
id: "nano-gpt",
name: "NanoGPT",
aliases: nanoAliases,
async fetchQuota() {
const key = getCredential(nanoAliases, ["key", "token"])
if (!key) return notConfigured(this.id, this.name)
return safeFetch(this.id, this.name, async () => {
const payload = await fetchJson("https://nano-gpt.com/api/subscription/v1/usage", {
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
})
const windows: Record<string, ReturnType<typeof toUsageWindow>> = {}
for (const [label, source] of [["daily", payload?.daily], ["monthly", payload?.monthly]] as const) {
if (!source) continue
const fraction = toNumber(source.percentUsed)
const used = toNumber(source.used)
const limit = toNumber(source.limit ?? source.limits?.[label])
windows[label] = toUsageWindow({
usedPercent: fraction !== null ? fraction * 100 : used !== null && limit ? (used / limit) * 100 : null,
windowSeconds: label === "daily" ? 86_400 : null,
resetAt: toTimestamp(source.resetAt ?? payload?.period?.currentPeriodEnd),
})
}
return { windows }
})
},
}
const openRouterAliases = ["openrouter"] as const
const openRouter: UsageProvider = {
id: "openrouter",
name: "OpenRouter",
aliases: openRouterAliases,
async fetchQuota() {
const key = getCredential(openRouterAliases, ["key", "token"])
if (!key) return notConfigured(this.id, this.name)
return safeFetch(this.id, this.name, async () => {
const payload = await fetchJson("https://openrouter.ai/api/v1/credits", {
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
})
const total = toNumber(payload?.data?.total_credits)
const used = toNumber(payload?.data?.total_usage)
const remaining = total !== null && used !== null ? Math.max(0, total - used) : null
return {
windows: {
credits: toUsageWindow({
usedPercent: total && used !== null ? (used / total) * 100 : null,
valueLabel: remaining !== null && total !== null ? `$${remaining.toFixed(2)} / $${total.toFixed(2)}` : null,
}),
},
}
})
},
}
function createTokenLimitProvider(input: { id: string; name: string; aliases: readonly string[]; url: string }): UsageProvider {
return {
id: input.id,
name: input.name,
aliases: input.aliases,
async fetchQuota() {
const key = getCredential(input.aliases, ["key", "token"])
if (!key) return notConfigured(this.id, this.name)
return safeFetch(this.id, this.name, async () => {
const payload = await fetchJson(input.url, { headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" } })
const windows: Record<string, ReturnType<typeof toUsageWindow>> = {}
for (const limit of Array.isArray(payload?.data?.limits) ? payload.data.limits : []) {
if (limit?.type !== "TOKENS_LIMIT" && limit?.type !== "TIME_LIMIT") continue
const duration = toNumber(limit.number)
const seconds = limit.type === "TIME_LIMIT" ? 30 * 86_400 : limit.unit === 3 && duration ? duration * 3600 : null
const label = limit.type === "TIME_LIMIT" ? "mcp-tools" : resolveWindowLabel(seconds)
windows[label] = toUsageWindow({
usedPercent: toNumber(limit.percentage),
windowSeconds: seconds,
resetAt: toTimestamp(limit.nextResetTime),
})
}
return { windows }
})
},
}
}
const zai = createTokenLimitProvider({
id: "zai-coding-plan",
name: "z.ai",
aliases: ["zai-coding-plan", "zai", "z.ai"],
url: "https://api.z.ai/api/monitor/usage/quota/limit",
})
const zhipu = createTokenLimitProvider({
id: "zhipuai-coding-plan",
name: "Zhipu AI Coding Plan",
aliases: ["zhipuai-coding-plan", "zhipuai", "zhipu"],
url: "https://open.bigmodel.cn/api/monitor/usage/quota/limit",
})
const waferAliases = ["wafer", "wafer-ai", "wafer_ai", "wafer.ai"] as const
const wafer: UsageProvider = {
id: "wafer",
name: "Wafer.ai",
aliases: waferAliases,
async fetchQuota() {
const key = getCredential(waferAliases, ["key", "token"])
if (!key) return notConfigured(this.id, this.name)
return safeFetch(this.id, this.name, async () => {
const payload = await fetchJson("https://pass.wafer.ai/v1/inference/quota", {
headers: { Authorization: `Bearer ${key}`, "Accept-Encoding": "identity" },
})
const remaining = toNumber(payload?.remaining_included_requests)
const limit = toNumber(payload?.included_request_limit)
const startAt = toTimestamp(payload?.window_start)
const resetAt = toTimestamp(payload?.window_end)
const seconds = startAt !== null && resetAt !== null ? Math.round((resetAt - startAt) / 1000) : 5 * 3600
return {
windows: {
[resolveWindowLabel(seconds)]: toUsageWindow({
usedPercent: toNumber(payload?.current_period_used_percent),
windowSeconds: seconds,
resetAt,
valueLabel: remaining !== null && limit !== null ? `${remaining} / ${limit}` : null,
}),
},
}
})
},
}
export const apiKeyProviders: UsageProvider[] = [kimi, nanoGpt, openRouter, zai, zhipu, wafer]

View file

@ -0,0 +1,179 @@
import fs from "fs"
import os from "os"
import path from "path"
import type { UsageProvider } from "../types"
import { asObject, fetchJson, getAuthEntry, getString, notConfigured, result, toNumber, toTimestamp, toUsageWindow } from "../shared"
const GOOGLE_ENDPOINTS = [
"https://daily-cloudcode-pa.sandbox.googleapis.com",
"https://autopush-cloudcode-pa.sandbox.googleapis.com",
"https://cloudcode-pa.googleapis.com",
] as const
const DEFAULT_PROJECT_ID = "rising-fact-p41fc"
interface GoogleSource {
id: "gemini" | "antigravity"
accessToken?: string
refreshToken?: string
projectId?: string
expires?: number | null
}
function parseRefresh(value: unknown): { token?: string; projectId?: string } {
const raw = getString(value)
if (!raw) return {}
const [token, projectId, managedProjectId] = raw.split("|")
return { token: getString(token) ?? undefined, projectId: getString(projectId) ?? getString(managedProjectId) ?? undefined }
}
function readJson(filePath: string): any | null {
try {
return JSON.parse(fs.readFileSync(filePath, "utf8"))
} catch {
return null
}
}
function googleSources(): GoogleSource[] {
const sources: GoogleSource[] = []
const entry = asObject(getAuthEntry(["google", "google.oauth"]))
const oauth = asObject(entry?.oauth) ?? entry
if (oauth) {
const refresh = parseRefresh(oauth.refresh)
const accessToken = getString(oauth.access) ?? getString(oauth.token) ?? undefined
if (accessToken || refresh.token) {
sources.push({
id: "gemini",
accessToken,
refreshToken: refresh.token,
projectId: refresh.projectId,
expires: toTimestamp(oauth.expires),
})
}
}
const home = os.homedir()
for (const candidate of [
path.join(home, ".config", "opencode", "antigravity-accounts.json"),
path.join(home, ".local", "share", "opencode", "antigravity-accounts.json"),
]) {
const data = readJson(candidate)
const accounts = Array.isArray(data?.accounts) ? data.accounts : []
const account = accounts[data?.activeIndex ?? 0] ?? accounts[0]
const refresh = parseRefresh(account?.refreshToken)
const accessToken = getString(account?.accessToken) ?? getString(account?.access_token) ?? undefined
if (accessToken || refresh.token) {
sources.push({
id: "antigravity",
accessToken,
refreshToken: refresh.token,
projectId: getString(account?.projectId) ?? refresh.projectId,
expires: toTimestamp(account?.expiresAt ?? account?.expires),
})
break
}
}
return sources
}
async function refreshAccessToken(source: GoogleSource): Promise<string | null> {
if (!source.refreshToken) return null
const prefix = source.id === "gemini" ? "GOOGLE" : "ANTIGRAVITY"
const clientId = getString(process.env[`${prefix}_OAUTH_CLIENT_ID`])
const clientSecret = getString(process.env[`${prefix}_OAUTH_CLIENT_SECRET`])
if (!clientId || !clientSecret) return null
const payload = await fetchJson("https://oauth2.googleapis.com/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: clientId,
client_secret: clientSecret,
refresh_token: source.refreshToken,
grant_type: "refresh_token",
}),
})
return getString(payload?.access_token)
}
function transformModel(sourceId: string, modelId: string, data: any) {
const fraction = toNumber(data?.quotaInfo?.remainingFraction ?? data?.remainingFraction)
const resetAt = toTimestamp(data?.quotaInfo?.resetTime ?? data?.resetTime)
const label = sourceId === "gemini" || (resetAt !== null && resetAt - Date.now() > 10 * 3600 * 1000) ? "daily" : "5h"
return {
[`${sourceId}/${modelId}`]: {
windows: {
[label]: toUsageWindow({
usedPercent: fraction === null ? null : 100 - fraction * 100,
windowSeconds: label === "daily" ? 86_400 : 5 * 3600,
resetAt,
}),
},
},
}
}
const google: UsageProvider = {
id: "google",
name: "Google",
aliases: ["google", "google.oauth", "gemini", "antigravity"],
async fetchQuota() {
const sources = googleSources()
if (sources.length === 0) return notConfigured(this.id, this.name)
const models: Record<string, { windows: Record<string, ReturnType<typeof toUsageWindow>> }> = {}
try {
for (const source of sources) {
const accessToken = source.accessToken && (!source.expires || source.expires > Date.now())
? source.accessToken
: await refreshAccessToken(source)
if (!accessToken) continue
const projectId = source.projectId ?? DEFAULT_PROJECT_ID
if (source.id === "gemini") {
try {
const quota = await fetchJson(`${GOOGLE_ENDPOINTS[2]}/v1internal:retrieveUserQuota`, {
method: "POST",
headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" },
body: JSON.stringify({ project: projectId }),
})
for (const bucket of Array.isArray(quota?.buckets) ? quota.buckets : []) {
const modelId = getString(bucket?.modelId)
if (modelId) Object.assign(models, transformModel(source.id, modelId, bucket))
}
} catch {
// The model endpoint below often remains available when quota buckets are not.
}
}
for (const endpoint of GOOGLE_ENDPOINTS) {
try {
const payload = await fetchJson(`${endpoint}/v1internal:fetchAvailableModels`, {
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
"User-Agent": "antigravity/1.11.5 windows/amd64",
"X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
},
body: JSON.stringify({ project: projectId }),
})
for (const [modelId, data] of Object.entries(payload?.models ?? {})) {
Object.assign(models, transformModel(source.id, modelId, data))
}
break
} catch {
continue
}
}
}
if (Object.keys(models).length === 0) throw new Error("No model quota data available")
return result(this.id, this.name, { ok: true, configured: true, usage: { windows: {}, models } })
} catch (error) {
return result(this.id, this.name, {
ok: false,
configured: true,
error: error instanceof Error ? error.message : "Request failed",
})
}
},
}
export const googleProviders: UsageProvider[] = [google]

View file

@ -0,0 +1,116 @@
import type { UsageProvider } from "../types"
import { fetchJson, getCredential, notConfigured, result, toNumber, toTimestamp, toUsageWindow } from "../shared"
const INACTIVE_WINDOW_STATUS = 3
function pickChatModel(models: any[]): any | null {
return (
models.find((model) => /^minimax-m/i.test(String(model?.model_name ?? "")) && (toNumber(model?.current_interval_total_count) ?? 0) > 0) ??
models.find((model) => ["general", "chat", "text"].includes(String(model?.model_name ?? "").toLowerCase())) ??
models.find((model) => toNumber(model?.current_interval_remaining_percent) !== null) ??
models[0] ??
null
)
}
function isUsablePayload(payload: any): boolean {
if (payload?.base_resp && payload.base_resp.status_code !== 0) return false
return Array.isArray(payload?.model_remains) && payload.model_remains.length > 0
}
function usedPercent(model: any, prefix: "interval" | "weekly", tokenPlan: boolean): number | null {
const remainingPercent = toNumber(model?.[`current_${prefix}_remaining_percent`])
if (remainingPercent !== null) return 100 - remainingPercent
const total = toNumber(model?.[`current_${prefix}_total_count`])
const rawUsage = toNumber(model?.[`current_${prefix}_usage_count`])
if (!total || rawUsage === null) return null
const used = tokenPlan ? total - rawUsage : rawUsage
return (Math.max(0, used) / total) * 100
}
function windowSeconds(model: any, prefix: "interval" | "weekly"): number | null {
const startAt = toTimestamp(prefix === "interval" ? model?.start_time : model?.weekly_start_time)
const resetAt = toTimestamp(prefix === "interval" ? model?.end_time : model?.weekly_end_time)
if (startAt !== null && resetAt !== null && resetAt > startAt) return Math.floor((resetAt - startAt) / 1000)
const remainsMs = toNumber(prefix === "interval" ? model?.remains_time : model?.weekly_remains_time)
return remainsMs && remainsMs > 0 ? Math.floor(remainsMs / 1000) : null
}
function createMiniMaxProvider(input: {
id: string
name: string
aliases: readonly string[]
tokenPlanUrl: string
codingPlanUrl: string
}): UsageProvider {
return {
id: input.id,
name: input.name,
aliases: input.aliases,
async fetchQuota() {
const key = getCredential(input.aliases, ["key", "token"])
if (!key) return notConfigured(this.id, this.name)
try {
let tokenPlan = true
let payload: any = null
try {
const tokenPayload = await fetchJson(input.tokenPlanUrl, { headers: { Authorization: `Bearer ${key}` } })
if (isUsablePayload(tokenPayload)) payload = tokenPayload
} catch {
// Fall back to the legacy Coding Plan endpoint below.
}
if (!payload) {
tokenPlan = false
const codingPayload = await fetchJson(input.codingPlanUrl, { headers: { Authorization: `Bearer ${key}` } })
if (isUsablePayload(codingPayload)) payload = codingPayload
}
if (!payload) throw new Error("Provider returned no usable quota data")
const model = pickChatModel(Array.isArray(payload?.model_remains) ? payload.model_remains : [])
if (!model) throw new Error("No model quota data available")
const intervalResetAt = toTimestamp(model?.end_time)
const windows: Record<string, ReturnType<typeof toUsageWindow>> = {
"5h": toUsageWindow({
usedPercent: usedPercent(model, "interval", tokenPlan),
windowSeconds: windowSeconds(model, "interval"),
resetAt: intervalResetAt,
}),
}
const weeklyStatus = toNumber(model?.current_weekly_status)
const hasWeekly = weeklyStatus !== INACTIVE_WINDOW_STATUS && (
toNumber(model?.current_weekly_remaining_percent) !== null || (toNumber(model?.current_weekly_total_count) ?? 0) > 0
)
if (hasWeekly) {
windows.weekly = toUsageWindow({
usedPercent: usedPercent(model, "weekly", tokenPlan),
windowSeconds: windowSeconds(model, "weekly"),
resetAt: toTimestamp(model?.weekly_end_time),
})
}
return result(this.id, this.name, { ok: true, configured: true, usage: { windows } })
} catch (error) {
return result(this.id, this.name, {
ok: false,
configured: true,
error: error instanceof Error ? error.message : "Request failed",
})
}
},
}
}
export const miniMaxProviders: UsageProvider[] = [
createMiniMaxProvider({
id: "minimax-coding-plan",
name: "MiniMax Coding Plan",
aliases: ["minimax-coding-plan", "minimax"],
tokenPlanUrl: "https://api.minimax.io/v1/token_plan/remains",
codingPlanUrl: "https://api.minimax.io/v1/api/openplatform/coding_plan/remains",
}),
createMiniMaxProvider({
id: "minimax-cn-coding-plan",
name: "MiniMax Coding Plan CN",
aliases: ["minimax-cn-coding-plan", "minimaxi"],
tokenPlanUrl: "https://api.minimaxi.com/v1/token_plan/remains",
codingPlanUrl: "https://www.minimaxi.com/v1/api/openplatform/coding_plan/remains",
}),
]

View file

@ -0,0 +1,86 @@
import type { UsageProvider } from "../types"
import {
fetchJson,
getAuthEntry,
getCredential,
notConfigured,
resolveWindowLabel,
safeFetch,
toNumber,
toTimestamp,
toUsageWindow,
} from "../shared"
const codexAliases = ["openai", "codex", "chatgpt"] as const
const codex: UsageProvider = {
id: "codex",
name: "Codex",
aliases: codexAliases,
async fetchQuota() {
const entry = getAuthEntry(codexAliases)
const token = entry && (typeof entry.access === "string" ? entry.access : typeof entry.token === "string" ? entry.token : null)
if (!token) return notConfigured(this.id, this.name)
return safeFetch(this.id, this.name, async () => {
const accountId = typeof entry?.accountId === "string" ? entry.accountId : null
const payload = await fetchJson("https://chatgpt.com/backend-api/wham/usage", {
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
...(accountId ? { "ChatGPT-Account-Id": accountId } : {}),
},
})
const windows: Record<string, ReturnType<typeof toUsageWindow>> = {}
for (const source of [payload?.rate_limit?.primary_window, payload?.rate_limit?.secondary_window]) {
if (!source) continue
const seconds = toNumber(source.limit_window_seconds)
windows[resolveWindowLabel(seconds)] = toUsageWindow({
usedPercent: toNumber(source.used_percent),
windowSeconds: seconds,
resetAt: toTimestamp(source.reset_at),
})
}
return { windows }
})
},
}
const copilotAliases = ["github-copilot", "copilot"] as const
const copilot: UsageProvider = {
id: "github-copilot",
name: "GitHub Copilot",
aliases: copilotAliases,
async fetchQuota() {
const token = getCredential(copilotAliases, ["access", "token"])
if (!token) return notConfigured(this.id, this.name)
return safeFetch(this.id, this.name, async () => {
const payload = await fetchJson("https://api.github.com/copilot_internal/user", {
headers: {
Authorization: `token ${token}`,
Accept: "application/json",
"Editor-Version": "vscode/1.96.2",
"X-Github-Api-Version": "2025-04-01",
},
})
const resetAt = toTimestamp(payload?.quota_reset_date)
const windows: Record<string, ReturnType<typeof toUsageWindow>> = {}
for (const [key, snapshot] of Object.entries({
chat: payload?.quota_snapshots?.chat,
completions: payload?.quota_snapshots?.completions,
premium: payload?.quota_snapshots?.premium_interactions,
})) {
if (!snapshot) continue
const source = snapshot as Record<string, unknown>
const entitlement = toNumber(source.entitlement)
const remaining = toNumber(source.remaining)
windows[key] = toUsageWindow({
usedPercent: entitlement && remaining !== null ? 100 - (remaining / entitlement) * 100 : null,
resetAt,
valueLabel: entitlement !== null && remaining !== null ? `${remaining.toFixed(0)} / ${entitlement.toFixed(0)}` : null,
})
}
return { windows }
})
},
}
export const oauthProviders: UsageProvider[] = [codex, copilot]

View file

@ -0,0 +1,145 @@
import type { UsageProvider } from "../types"
import { fetchJson, getString, notConfigured, safeFetch, toNumber, toTimestamp, toUsageWindow } from "../shared"
function decodeJwtExpiration(token: string): number | null {
try {
const payload = token.split(".")[1]
if (!payload) return null
const parsed = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"))
return typeof parsed?.exp === "number" ? parsed.exp * 1000 : null
} catch {
return null
}
}
async function resolveCursorToken(): Promise<string | null> {
const accessToken = getString(process.env.CURSOR_ACCESS_TOKEN) ?? getString(process.env.CURSOR_TOKEN)
const refreshToken = getString(process.env.CURSOR_REFRESH_TOKEN)
const expiresAt = accessToken ? decodeJwtExpiration(accessToken) : null
if (accessToken && (!expiresAt || expiresAt > Date.now() + 5 * 60_000)) return accessToken
if (!refreshToken) return accessToken
const payload = await fetchJson("https://api2.cursor.sh/oauth/token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ grant_type: "refresh_token", client_id: "KbZUR41cY7W6zRSdpSUJ7I7mLYBKOCmB", refresh_token: refreshToken }),
})
return getString(payload?.access_token)
}
async function cursorPost(url: string, token: string): Promise<any> {
return fetchJson(url, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", "Connect-Protocol-Version": "1" },
body: "{}",
})
}
const cursor: UsageProvider = {
id: "cursor",
name: "Cursor",
aliases: ["cursor"],
async fetchQuota() {
const token = await resolveCursorToken().catch(() => null)
if (!token) return notConfigured(this.id, this.name)
return safeFetch(this.id, this.name, async () => {
const usage = await cursorPost("https://api2.cursor.sh/aiserver.v1.DashboardService/GetCurrentPeriodUsage", token)
const plan = await cursorPost("https://api2.cursor.sh/aiserver.v1.DashboardService/GetPlanInfo", token).catch(() => null)
const source = usage?.planUsage ?? {}
const limit = toNumber(source.limit)
const remaining = toNumber(source.remaining)
const explicit = toNumber(source.totalPercentUsed)
const resetAt = toTimestamp(usage?.billingCycleEnd ?? plan?.planInfo?.billingCycleEnd)
return {
windows: {
billing_cycle: toUsageWindow({
usedPercent: explicit ?? (limit && remaining !== null ? ((limit - remaining) / limit) * 100 : null),
resetAt,
valueLabel: toNumber(source.totalSpend) !== null ? `$${(toNumber(source.totalSpend)! / 100).toFixed(2)}` : null,
}),
},
}
})
},
}
function parseOllamaUsage(html: string) {
const windows: Record<string, ReturnType<typeof toUsageWindow>> = {}
for (const [label, pattern] of [
["session", /Session\s+usage[^0-9]*([0-9.]+)%/i],
["weekly", /Weekly\s+usage[^0-9]*([0-9.]+)%/i],
] as const) {
const match = html.match(pattern)
if (match) windows[label] = toUsageWindow({ usedPercent: toNumber(match[1]) })
}
const premium = html.match(/Premium[^0-9]*([0-9]+)\s*\/\s*([0-9]+)/i)
if (premium) {
const used = toNumber(premium[1])
const total = toNumber(premium[2])
windows.premium = toUsageWindow({
usedPercent: total && used !== null ? (used / total) * 100 : null,
valueLabel: used !== null && total !== null ? `${used} / ${total}` : null,
})
}
return windows
}
const ollamaCloud: UsageProvider = {
id: "ollama-cloud",
name: "Ollama Cloud",
aliases: ["ollama-cloud", "ollamacloud"],
async fetchQuota() {
const cookie = getString(process.env.OLLAMA_CLOUD_COOKIE)
if (!cookie) return notConfigured(this.id, this.name)
return safeFetch(this.id, this.name, async () => {
const response = await fetch("https://ollama.com/settings", {
headers: { Cookie: cookie, "User-Agent": "CodeNomad usage provider" },
redirect: "manual",
signal: AbortSignal.timeout(15_000),
})
if (!response.ok) throw new Error(`Provider returned HTTP ${response.status}`)
const windows = parseOllamaUsage(await response.text())
if (Object.keys(windows).length === 0) throw new Error("No usage data available")
return { windows }
})
},
}
function parseOpenCodeGoUsage(html: string, now = Date.now()) {
const normalized = html.replace(/&quot;|&#34;|\\u0022|\\"/g, '"')
const windows: Record<string, ReturnType<typeof toUsageWindow>> = {}
for (const [label, field] of Object.entries({ "5h": "rollingUsage", weekly: "weeklyUsage", monthly: "monthlyUsage" })) {
const match = normalized.match(new RegExp(`["']?${field}["']?\\s*:\\s*(?:\\$R\\[\\d+\\]\\s*=\\s*)?\\{([^{}]*)\\}`, "s"))
if (!match) continue
const capture = (name: string) => toNumber(match[1]?.match(new RegExp(`["']?${name}["']?\\s*:\\s*["']?(-?\\d+(?:\\.\\d+)?)`))?.[1])
const usedPercent = capture("usagePercent")
const resetInSec = capture("resetInSec")
if (usedPercent !== null && resetInSec !== null) {
windows[label] = toUsageWindow({ usedPercent, resetAt: now + Math.max(0, resetInSec) * 1000 })
}
}
return windows
}
const openCodeGo: UsageProvider = {
id: "opencode-go",
name: "OpenCode Go",
aliases: ["opencode-go", "opencode"],
async fetchQuota() {
const workspaceId = getString(process.env.OPENCODE_GO_WORKSPACE_ID)
const authCookie = getString(process.env.OPENCODE_GO_AUTH_COOKIE)
if (!workspaceId || !authCookie) return notConfigured(this.id, this.name)
return safeFetch(this.id, this.name, async () => {
const response = await fetch(`https://opencode.ai/workspace/${encodeURIComponent(workspaceId)}/go`, {
headers: { Cookie: `auth=${authCookie.replace(/^auth=/, "")}`, "User-Agent": "CodeNomad usage provider" },
redirect: "manual",
signal: AbortSignal.timeout(15_000),
})
if (!response.ok) throw new Error(`Provider returned HTTP ${response.status}`)
const windows = parseOpenCodeGoUsage(await response.text())
if (Object.keys(windows).length === 0) throw new Error("No usage data available")
return { windows }
})
},
}
export const specialProviders: UsageProvider[] = [cursor, ollamaCloud, openCodeGo]

View file

@ -0,0 +1,70 @@
import assert from "node:assert/strict"
import test from "node:test"
import { getProviderUsage, resolveUsageProvider, selectModelWindows } from "./service"
import type { ProviderResult } from "./types"
const providerIds = [
"codex",
"github-copilot",
"google",
"kimi-for-coding",
"nano-gpt",
"openrouter",
"zai-coding-plan",
"zhipuai-coding-plan",
"minimax-coding-plan",
"minimax-cn-coding-plan",
"ollama-cloud",
"wafer",
"opencode-go",
"cursor",
]
test("registers every supported usage provider", () => {
for (const providerId of providerIds) {
assert.equal(resolveUsageProvider(providerId)?.id, providerId)
}
})
test("maps OpenCode provider aliases to their usage provider", () => {
assert.equal(resolveUsageProvider("openai")?.id, "codex")
assert.equal(resolveUsageProvider("copilot")?.id, "github-copilot")
assert.equal(resolveUsageProvider("gemini")?.id, "google")
assert.equal(resolveUsageProvider("opencode")?.id, "opencode-go")
})
test("does not use Claude subscription OAuth credentials", () => {
assert.equal(resolveUsageProvider("anthropic"), null)
assert.equal(resolveUsageProvider("claude"), null)
})
test("selects model-specific windows by normalized model id", () => {
const result: ProviderResult = {
providerId: "google",
providerName: "Google",
ok: true,
configured: true,
fetchedAt: 1,
usage: {
windows: {},
models: {
"gemini/gemini-2.5-flash": {
windows: {
daily: { usedPercent: 25, remainingPercent: 75, windowSeconds: 86_400, resetAt: null },
},
},
},
},
}
assert.equal(selectModelWindows(result, "models/gemini-2.5-flash").daily?.usedPercent, 25)
})
test("returns a stable unsupported response without making a provider request", async () => {
const response = await getProviderUsage("unknown-provider", { modelId: "model" })
assert.equal(response.supported, false)
assert.equal(response.configured, false)
assert.equal(response.providerId, null)
assert.deepEqual(response.windows, {})
})

View file

@ -0,0 +1,91 @@
import type { ProviderUsageResponse, ProviderUsageWindow } from "../api-types"
import { apiKeyProviders } from "./providers/api-key"
import { googleProviders } from "./providers/google"
import { miniMaxProviders } from "./providers/minimax"
import { oauthProviders } from "./providers/oauth"
import { specialProviders } from "./providers/special"
import type { ProviderResult, UsageProvider } from "./types"
const CACHE_TTL_MS = 60_000
const providers = [...oauthProviders, ...apiKeyProviders, ...miniMaxProviders, ...googleProviders, ...specialProviders]
const registry = new Map<string, UsageProvider>()
for (const provider of providers) {
registry.set(provider.id, provider)
for (const alias of provider.aliases) registry.set(alias.toLowerCase(), provider)
}
const cache = new Map<string, { result: ProviderResult; expiresAt: number }>()
const pending = new Map<string, Promise<ProviderResult>>()
export function resolveUsageProvider(providerId: string): UsageProvider | null {
return registry.get(providerId.trim().toLowerCase()) ?? null
}
function normalizeModelId(value: string): string {
return value.toLowerCase().replace(/^models\//, "").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")
}
export function selectModelWindows(result: ProviderResult, modelId?: string): Record<string, ProviderUsageWindow> {
const usage = result.usage
if (!usage) return {}
if (!modelId || !usage.models) return usage.windows
const target = normalizeModelId(modelId)
const entries = Object.entries(usage.models)
const exact = entries.find(([name]) => normalizeModelId(name.split("/").pop() ?? name) === target)
if (exact) return exact[1].windows
const partial = entries.find(([name]) => {
const candidate = normalizeModelId(name.split("/").pop() ?? name)
return candidate.includes(target) || target.includes(candidate)
})
return partial?.[1].windows ?? usage.windows
}
async function fetchProvider(provider: UsageProvider): Promise<ProviderResult> {
const cached = cache.get(provider.id)
if (cached && cached.expiresAt > Date.now()) return cached.result
const inFlight = pending.get(provider.id)
if (inFlight) return inFlight
const request = provider.fetchQuota().then((result) => {
cache.set(provider.id, { result, expiresAt: Date.now() + CACHE_TTL_MS })
return result
}).finally(() => pending.delete(provider.id))
pending.set(provider.id, request)
return request
}
export async function getProviderUsage(
requestedProviderId: string,
options: { modelId?: string } = {},
): Promise<ProviderUsageResponse> {
const provider = resolveUsageProvider(requestedProviderId)
if (!provider) {
return {
requestedProviderId,
providerId: null,
providerName: requestedProviderId,
modelId: options.modelId,
supported: false,
configured: false,
ok: false,
windows: {},
fetchedAt: Date.now(),
}
}
const result = await fetchProvider(provider)
return {
requestedProviderId,
providerId: provider.id,
providerName: result.providerName,
modelId: options.modelId,
supported: true,
configured: result.configured,
ok: result.ok,
windows: selectModelWindows(result, options.modelId),
fetchedAt: result.fetchedAt,
}
}
export function clearProviderUsageCache(): void {
cache.clear()
}

View file

@ -0,0 +1,34 @@
import assert from "node:assert/strict"
import fs from "node:fs"
import os from "node:os"
import path from "node:path"
import test from "node:test"
import { getCredential, readOpenCodeAuth, toTimestamp, toUsageWindow } from "./shared"
test("reads the explicit OpenCode auth file without exposing credentials through the API", () => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-usage-"))
const authFile = path.join(directory, "auth.json")
const previous = process.env.OPENCODE_AUTH_FILE
fs.writeFileSync(authFile, JSON.stringify({ openai: { type: "oauth", access: "secret-token" } }))
process.env.OPENCODE_AUTH_FILE = authFile
try {
assert.equal((readOpenCodeAuth().openai as Record<string, unknown>).access, "secret-token")
assert.equal(getCredential(["openai"], ["access"]), "secret-token")
} finally {
if (previous === undefined) delete process.env.OPENCODE_AUTH_FILE
else process.env.OPENCODE_AUTH_FILE = previous
fs.rmSync(directory, { recursive: true, force: true })
}
})
test("normalizes percentages and timestamps", () => {
assert.deepEqual(toUsageWindow({ usedPercent: 120, resetAt: null }), {
usedPercent: 100,
remainingPercent: 0,
windowSeconds: null,
resetAt: null,
})
assert.equal(toTimestamp(1_700_000_000), 1_700_000_000_000)
})

Some files were not shown because too many files have changed in this diff Show more