diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3a797f0da31..00c4f695760 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: 1.1.38 + bun-version: 1.3.9 - name: Cache Bun and Turbo uses: actions/cache@v4 @@ -40,3 +40,6 @@ jobs: - name: Test run: bun run test + + - name: Smoke Test (t3 runtime) + run: bun run --cwd apps/t3 build && bun run smoke-test diff --git a/.gitignore b/.gitignore index 701ad4089f5..3ac5a88fdfa 100644 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,6 @@ node_modules *.log *.tsbuildinfo apps/*/dist -apps/*/dist-electron packages/*/dist .env .env.local diff --git a/.oxfmtrc.json b/.oxfmtrc.json index 8bb9df72413..561b8b83f45 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -3,7 +3,6 @@ "ignorePatterns": [ ".plans", "dist", - "dist-electron", "node_modules", "bun.lock", "*.tsbuildinfo" diff --git a/.oxlintrc.json b/.oxlintrc.json index d45179a4c46..d112b92ce8f 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -1,6 +1,6 @@ { "$schema": "./node_modules/oxlint/configuration_schema.json", - "ignorePatterns": ["dist", "dist-electron", "node_modules", "bun.lock", "*.tsbuildinfo"], + "ignorePatterns": ["dist", "node_modules", "bun.lock", "*.tsbuildinfo"], "plugins": ["eslint", "oxc", "react", "unicorn", "typescript"], "categories": { "correctness": "warn", diff --git a/.plans/01-shared-model-normalization.md b/.plans/01-shared-model-normalization.md index e1e5f77db48..05423c3eb6e 100644 --- a/.plans/01-shared-model-normalization.md +++ b/.plans/01-shared-model-normalization.md @@ -5,7 +5,7 @@ Move model alias/default normalization into `packages/contracts` so desktop and ## Motivation - Removes duplicated logic between: - - `apps/desktop/src/codexAppServerManager.ts` + - `packages/runtime-core/src/codexAppServerManager.ts` - `apps/renderer/src/model-logic.ts` - Prevents behavior drift when model aliases/defaults are updated. @@ -22,7 +22,7 @@ Move model alias/default normalization into `packages/contracts` so desktop and - `resolveModelSlug` - `DEFAULT_MODEL` 2. Export model utilities from `packages/contracts/src/index.ts`. -3. Update `apps/desktop/src/codexAppServerManager.ts` to replace local alias map/helper. +3. Update `packages/runtime-core/src/codexAppServerManager.ts` to replace local alias map/helper. 4. Update `apps/renderer/src/model-logic.ts` to wrap or re-export shared functions. 5. Update tests: - Move/duplicate normalization tests to contracts. @@ -38,5 +38,5 @@ Move model alias/default normalization into `packages/contracts` so desktop and - Manual check that model selection and session start still send expected model slug. ## Done Criteria -- No duplicated alias/default map in desktop and renderer. +- No duplicated alias/default map in runtime-core and renderer. - Shared model utilities are contract-tested. diff --git a/.plans/02-typed-ipc-boundaries.md b/.plans/02-typed-ipc-boundaries.md index 4e910043561..498a673e91f 100644 --- a/.plans/02-typed-ipc-boundaries.md +++ b/.plans/02-typed-ipc-boundaries.md @@ -1,37 +1,37 @@ -# Plan: Strengthen Typed IPC Boundaries in Main Process +# Plan: Strengthen Typed WS RPC Boundaries in Runtime Server ## Summary -Replace loose payload casting in IPC handlers with strict schema parsing and typed helper wrappers. +Replace loose payload casting in runtime RPC handlers with strict schema parsing and typed helper wrappers. ## Motivation -- `apps/desktop/src/main.ts` currently uses casts like `payload as Parameters<...>`. +- `apps/t3/src/runtimeApiServer.ts` should avoid payload casts and parse all unknown input at the RPC boundary. - Casts can hide contract breakages until runtime. ## Scope -- Desktop main process IPC registration. -- Optional shared helper for handler registration. +- Runtime WebSocket RPC registration and method dispatch. +- Optional shared helper for method-level parse/dispatch registration. ## Proposed Changes -1. Add IPC helper utility (e.g. `apps/desktop/src/ipcHelpers.ts`) to: +1. Add RPC helper utility (e.g. `apps/t3/src/rpcHelpers.ts`) to: - Parse payload(s) with Zod schemas - Standardize typed handler signatures -2. Refactor provider IPC handlers in `apps/desktop/src/main.ts` to use: +2. Refactor provider RPC handlers in `apps/t3/src/runtimeApiServer.ts` to use: - `providerSessionStartInputSchema.parse` - `providerSendTurnInputSchema.parse` - `providerInterruptTurnInputSchema.parse` - `providerStopSessionInputSchema.parse` -3. Apply same pattern to agent/terminal handlers where possible. -4. Add tests for handler parsing failure paths (invalid payloads). +3. Apply same pattern to app/todo/agent/terminal/shell handlers where possible. +4. Add tests for parsing failure paths (invalid payloads). ## Risks -- Refactor can subtly change IPC error shape/messages. +- Refactor can subtly change RPC error shape/messages. - Helper abstraction should stay simple and not obscure control flow. ## Validation - `bun run test` - `bun run typecheck` -- Manual invalid payload check from renderer/devtools to confirm fast failure. +- Manual invalid payload check from websocket client/devtools to confirm fast failure. ## Done Criteria -- No provider handler uses `payload as Parameters<...>`. -- All IPC entrypoints parse unknown payloads at boundary. +- No runtime handler uses `payload as Parameters<...>`. +- All websocket RPC entrypoints parse unknown payloads at boundary. diff --git a/.plans/03-split-codex-app-server-manager.md b/.plans/03-split-codex-app-server-manager.md index e425a6ae7d2..d759f53c8d7 100644 --- a/.plans/03-split-codex-app-server-manager.md +++ b/.plans/03-split-codex-app-server-manager.md @@ -4,7 +4,7 @@ Split `CodexAppServerManager` into smaller modules with clear responsibilities. ## Motivation -- `apps/desktop/src/codexAppServerManager.ts` is large and mixes: +- `packages/runtime-core/src/codexAppServerManager.ts` is large and mixes: - Process lifecycle - JSON-RPC parsing/routing - Session state transitions @@ -12,7 +12,7 @@ Split `CodexAppServerManager` into smaller modules with clear responsibilities. - This increases regression risk and slows changes. ## Scope -- Desktop provider internals only. +- Runtime-core provider internals only. - Keep external behavior/API stable. ## Proposed Changes diff --git a/.plans/06-provider-logstream-lifecycle.md b/.plans/06-provider-logstream-lifecycle.md index 71d1cbd95bd..585b9836bb4 100644 --- a/.plans/06-provider-logstream-lifecycle.md +++ b/.plans/06-provider-logstream-lifecycle.md @@ -4,18 +4,18 @@ Ensure `ProviderManager` logging stream is initialized, rotated/structured, and closed safely. ## Motivation -- `apps/desktop/src/providerManager.ts` opens a write stream in constructor. +- `packages/runtime-core/src/providerManager.ts` opens a write stream in constructor. - Stream lifecycle is not explicit on shutdown. ## Scope -- Desktop provider logging behavior. -- App shutdown integration. +- Runtime-core provider logging behavior. +- Runtime server shutdown integration. ## Proposed Changes 1. Add explicit `dispose()` on `ProviderManager`: - Remove event listeners - End/close log stream -2. Call `providerManager.dispose()` from app shutdown path in `apps/desktop/src/main.ts`. +2. Call `providerManager.dispose()` from app shutdown path in `apps/t3/src/runtimeApiServer.ts` close flow. 3. Optional: change log format to JSON lines with stable fields. 4. Optional: per-session log files under `.logs/providers/`. diff --git a/.plans/10-unify-process-session-abstraction.md b/.plans/10-unify-process-session-abstraction.md index c3feec020f2..cff212fecb5 100644 --- a/.plans/10-unify-process-session-abstraction.md +++ b/.plans/10-unify-process-session-abstraction.md @@ -4,11 +4,11 @@ Refactor `ProcessManager` to use a single runtime-session interface for child-process and PTY modes. ## Motivation -- `apps/desktop/src/processManager.ts` maintains parallel maps and branch-heavy logic. +- `packages/runtime-core/src/processManager.ts` maintains parallel maps and branch-heavy logic. - New execution backends/providers will multiply complexity. ## Scope -- Desktop process execution internals. +- Runtime-core process execution internals. - Preserve public `ProcessManager` API. ## Proposed Changes diff --git a/AGENTS.md b/AGENTS.md index 7c84e207ce5..75413f36079 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,11 +1,13 @@ # AGENTS.md ## Project Snapshot + CodeThing is a minimal GUI for using code agents like Codex and Claude Code (coming soon). This repository is a VERY EARLY WIP. Proposing sweeping changes that improve long-term maintainability is encouraged. ## Core Priorities + 1. Performance first. 2. Reliability first. 3. Keep behavior predictable under load and during failures (session restarts, reconnects, partial streams). @@ -13,22 +15,30 @@ This repository is a VERY EARLY WIP. Proposing sweeping changes that improve lon If a tradeoff is required, choose correctness and robustness over short-term convenience. ## Package Roles -- `apps/desktop`: Electron main/preload runtime. Owns provider orchestration, process/session lifecycle, and native IPC boundaries. + +- `apps/t3`: Node CLI/runtime. Owns WebSocket server lifecycle, browser launch, and RPC routing. - `apps/renderer`: React/Vite UI. Owns session UX, conversation/event rendering, and client-side state. -- `packages/contracts`: Shared Zod schemas and TypeScript contracts for provider events, IPC payloads, and model/session types. +- `packages/contracts`: Shared Zod schemas and TypeScript contracts for provider events, WebSocket payloads, and model/session types. +- `packages/runtime-core`: Shared Node runtime services (`ProcessManager`, `TodoStore`, `ProviderManager`, `CodexAppServerManager`) used by `apps/t3`. +- Legacy desktop runtime wrapper package has been removed; `runtime-core` is the canonical runtime service source. +- Legacy desktop runtime wrapper package has been removed; `runtime-core` is the canonical runtime service source. ## Codex App Server (Important) -CodeThing is currently Codex-first. The desktop app starts `codex app-server` (JSON-RPC over stdio) per provider session, then streams structured events into the renderer through the provider APIs. + +CodeThing is currently Codex-first. The `t3` runtime starts `codex app-server` (JSON-RPC over stdio) per provider session, then streams structured events into the renderer through the provider APIs. How we use it in this codebase: -- Session startup/resume and turn lifecycle are brokered in `apps/desktop/src/codexAppServerManager.ts`. -- Provider dispatch and thread event logging are coordinated in `apps/desktop/src/providerManager.ts`. + +- Session startup/resume and turn lifecycle are brokered in `packages/runtime-core/src/codexAppServerManager.ts`. +- Provider dispatch and thread event logging are coordinated in `packages/runtime-core/src/providerManager.ts`. - Renderer consumes provider event streams via `nativeApi.providers.onEvent`. Docs: + - Codex App Server docs: https://developers.openai.com/codex/sdk/#app-server ## Reference Repos + - Open-source Codex repo: https://github.com/openai/codex - Codex-Monitor (Tauri, feature-complete, strong reference implementation): https://github.com/Dimillian/CodexMonitor diff --git a/README.md b/README.md index 567c2659a0b..e67c96d09e6 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,122 @@ -# CodeThing (Electron + Vite + Bun) +# CodeThing (`t3`: Node + WebSocket + Browser) -CodeThing is a desktop shell for coding agents. This first implementation is: +CodeThing now runs as a local Node.js runtime that serves a browser UI and exposes a local WebSocket API. + +Current implementation is: 1. Codex-first: connects to `codex app-server` and streams turn/item events. 2. Provider-ready: renderer speaks a provider abstraction so Claude Code can plug in later. -3. Typed end-to-end: contracts validate payloads at preload/main boundaries. +3. Typed end-to-end: contracts validate payloads across the WebSocket boundary. + +## Quickstart + +```bash +npx t3 +``` + +On launch, `t3`: + +1. starts a local WebSocket runtime (`127.0.0.1`), +2. serves the web UI in your browser, +3. auto-connects to your current working directory as the default project. + +CLI flags: + +- `--no-open[=bool]` — disable browser auto-open (supports `true/false`, `1/0`, `yes/no`, `on/off` in equals form). +- `-o, --open[=bool]` — force browser auto-open (supports `true/false`, `1/0`, `yes/no`, `on/off` in equals form; overrides `T3_NO_OPEN`). +- `--backend-port ` — set WebSocket runtime port. +- `--web-port ` — set web UI port. +- `--cwd ` — choose launch project directory (defaults to current directory). +- `` — shorthand positional argument equivalent to `--cwd `. +- `-- ` — treat following argument as path even if it starts with `-`. +- `--version` — print CLI version. +- `--help` — print CLI usage. + +`--cwd` (or positional path) must be a non-empty value pointing to an existing directory; startup fails fast with a clear error otherwise. +Port values must be decimal integers in the range `1..65535`. + +If default ports are busy, `t3` will automatically retry with the next available port pair unless ports are explicitly pinned via CLI flags or environment variables. + +Optional environment variables: + +- `T3_NO_OPEN=1` — start runtime without auto-opening a browser window (parses `true/false`, `1/0`, `yes/no`, `on/off`). +- `T3_BACKEND_PORT` — override local WebSocket runtime port (default `4317`). +- `T3_WEB_PORT` — override local web UI port (default `4318`). + +Runtime command semantics: + +- `terminal.run` executes in the launch directory when `cwd` is omitted. +- Relative `cwd` values for `terminal.run` and `shell.openInEditor` resolve from the launch directory. +- `terminal.run` and `shell.openInEditor` reject empty/whitespace, missing, or non-directory cwd targets with structured request errors. ## Workspace layout -- `/apps/desktop`: Electron main + preload process, includes provider and Codex session managers. +- `/apps/t3`: CLI launcher + local WebSocket runtime server. - `/apps/renderer`: React + Vite UI for session control, conversation, and protocol event stream. -- `/packages/contracts`: shared Zod schemas + TypeScript types for IPC and provider events. +- `/packages/contracts`: shared Zod schemas + TypeScript types for WS protocol, provider events, and API contracts. +- `/packages/runtime-core`: shared Node runtime services (`ProcessManager`, `TodoStore`, `ProviderManager`, `CodexAppServerManager`) consumed by `apps/t3`. +- Legacy desktop runtime wrapper package has been removed; `runtime-core` is now the sole source of runtime service implementations. ## Codex prerequisites - Install Codex CLI so `codex` is on your PATH. - Authenticate Codex before running CodeThing (for example via API key or ChatGPT auth supported by Codex). - CodeThing starts the server via `codex app-server` per session. - -## Security and boundary model - -- `nodeIntegration: false` -- `contextIsolation: true` -- `sandbox: true` -- Renderer talks only to `window.nativeApi` exposed by preload. -- Preload and main both validate inputs using shared Zod schemas. - -`sandbox: true` above is Electron renderer sandboxing. It is separate from Codex execution sandbox policy (`read-only`, `workspace-write`, `danger-full-access`) used when starting provider sessions. +- Note: test suites use an in-process fake `codex app-server` shim, so CI/local tests do not require the real Codex CLI. + +## Runtime boundary model + +- `t3` starts a localhost-only WebSocket server. +- Launch URLs include an ephemeral WebSocket token so only the opened browser session can attach. +- Connections missing the token (or using a wrong token) are rejected by the runtime. +- Connections with duplicate token query parameters are also rejected (even when duplicate values match) to avoid ambiguous auth parsing. +- Runtime auth token lookup is strict on the lowercase `token` query key; alternate key casing is rejected. +- Connections with unexpected query parameters are rejected (auth mode requires only `token`; no-auth mode allows no query params). +- WebSocket control connections are accepted only on the root runtime path (`/`) to keep the API surface narrow. +- Unauthorized websocket attempts close with code `4001` and reason `unauthorized`. +- Browser renderer talks through a typed `NativeApi` adapter over that WebSocket. +- Runtime currently enforces a single active browser client (new client replaces old one). +- When replaced by a newer connection, the previous active websocket is closed with code `4000`. +- Replacement closes use reason string `replaced-by-new-client` for deterministic client handling. +- Renderer websocket connection errors now preserve close metadata (`code`/`reason`) to improve reconnect diagnostics. +- Renderer normalizes close metadata before diagnostics (e.g. trims close reasons and ignores malformed close codes) for cleaner error messages. +- Renderer decodes websocket message payloads from strings, `ArrayBuffer`, `ArrayBufferView` (e.g. `Uint8Array`/`DataView`), and `Blob`. +- Renderer validates provider/agent event payload shapes before dispatch, ignoring malformed event payloads to keep client state stable. +- Renderer validates critical RPC success payload shapes (`app.health`, `app.bootstrap`, `providers.listSessions`, `dialogs.pickFolder`, plus terminal/todo/provider/agent control flows) and fails fast on malformed responses. +- In-flight renderer requests now surface unauthorized/replacement disconnect causes explicitly. +- Renderer also fails in-flight requests immediately on websocket `error` events (without waiting for close) for faster feedback. +- After reconnect, renderer ignores stale events from prior sockets (including provider and agent streams) to avoid cross-connection state corruption. +- Initial websocket connect failures now include detailed diagnostics from socket/open and constructor failures (including nested/string/cause-chain payload messages, with bounded-depth fallback for malformed/cyclic payloads). +- Subsequent requests automatically reconnect after close/error disconnects, including idle (no pending request) error scenarios. +- Renderer now proactively resets the websocket after request send failures, rejects any in-flight requests, and reconnects cleanly on later requests. +- Send-failure request errors now include non-Error payload details when available (for clearer diagnostics). +- Runtime validates request payloads with shared Zod contracts. +- Websocket RPC method names are centralized in shared contracts (`WS_NATIVE_API_METHODS`), and runtime dispatch validates method strings against that shared list. +- Runtime tests now assert that every declared websocket method is wired through the server dispatch path (preventing contract/runtime drift). +- Runtime and renderer both validate critical success payloads with shared contracts to prevent malformed envelope data from entering app state. +- Core transport schemas (provider/agent/todo/terminal/app payloads) are strict objects, so unexpected fields are rejected at the contract boundary. +- Runtime successful responses always include `result` (using `null` for void methods) to keep websocket envelopes schema-safe. +- Websocket event envelopes are channel-typed (`provider:event`, `agent:output`, `agent:exit`) and validated against shared payload schemas. +- Websocket protocol envelopes are strict (unexpected top-level fields are rejected) to keep RPC/event parsing deterministic. +- Websocket request envelope guards also cap request id/method lengths (currently 256 chars each); over-limit requests are treated as malformed and ignored. +- Runtime error envelopes are bounded (`code`/`message` max lengths) and oversized error text is normalized before sending to avoid protocol-invalid responses. +- Blank/whitespace-only websocket ids, methods, and error fields are rejected at schema boundaries. +- Codex execution sandbox policy (`read-only`, `workspace-write`, `danger-full-access`) is still selected per session startup options. +- Static HTML responses are served with `Cache-Control: no-store`; built `/assets/*` files are served with long-lived immutable cache headers. +- Static file success responses include `Accept-Ranges: bytes`, `Vary: Range`, deterministic `ETag` and `Last-Modified` validators, plus hardened browser headers (`X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`, `Cross-Origin-Resource-Policy`, `Cross-Origin-Opener-Policy`). +- Static files support single-range byte requests (`Range: bytes=...`) with standards-compliant `206` / `416` behavior. +- Static range parsing tolerates optional separator whitespace (e.g. `Range: bytes = 0 - 1023`). +- Oversized suffix ranges are normalized to full-file spans (for example `bytes=-999999` on small assets). +- Static non-range requests support `If-None-Match` and `If-Modified-Since` conditional caching via `304 Not Modified`. +- Static range requests support `If-Range` semantics (matched validator keeps `206`; mismatched validator falls back to full `200` response). +- `If-None-Match` / `If-Modified-Since` preconditions are evaluated before range handling, so satisfied validators return `304` even when a `Range` header is present. +- Static precondition headers `If-Match` and `If-Unmodified-Since` are enforced with `412 Precondition Failed` semantics. +- Failed `If-Match` checks take precedence over cache validators (e.g. matching `If-None-Match` still results in `412` if `If-Match` fails). +- Failed `If-Unmodified-Since` checks also return `412` before cache-based `304` handling. +- Wildcard validators are supported where applicable (`If-None-Match: *`, `If-Match: *`). +- Strong/weak validator semantics follow HTTP rules: weak ETags participate in `If-None-Match` weak comparison, but are rejected for strong-only checks (`If-Match`, `If-Range`). +- `412` static precondition responses include validator headers (`ETag`, `Last-Modified`) and range capability metadata (`Accept-Ranges`, `Vary: Range`). +- `416` unsatisfiable-range responses include both range metadata and validators (`Content-Range`, `Accept-Ranges`, `Vary: Range`, `ETag`, `Last-Modified`). ## Runtime modes @@ -39,22 +129,16 @@ Mode changes apply across all threads. Existing live sessions are restarted so o ## Scripts -- `bun run dev`: starts contract build/watch, renderer dev server, and Electron process. -- `bun run build`: builds contracts, renderer, and desktop bundles through Turbo. +- `bun run dev`: builds contracts, starts `t3` runtime, opens browser UI. +- `bun run build`: builds contracts, renderer static assets, and `t3` CLI bundle. - `bun run typecheck`: strict TypeScript checks for all packages. - `bun run test`: runs workspace tests. - -## CI quality gates - -- `.github/workflows/ci.yml` runs `bun run lint`, `bun run typecheck`, and `bun run test` on pull requests and pushes to `main`. - -Optional: - -- `ELECTRON_RENDERER_PORT=5180 bun run dev` if `5173` is already in use. +- `bun run smoke-test`: runs end-to-end CLI/WebSocket smoke coverage (against the fake Codex app-server shim). +- `bun run --cwd apps/t3 dev`: run the CLI directly in dev mode. ## Provider architecture -The renderer now depends on `nativeApi.providers.*`: +The renderer depends on `nativeApi.providers.*`: 1. `startSession` 2. `sendTurn` @@ -64,4 +148,9 @@ The renderer now depends on `nativeApi.providers.*`: 6. `listSessions` 7. `onEvent` -Codex is the only implemented provider right now. `claudeCode` is reserved in contracts/UI but returns a not-implemented error in main-process dispatch. +Codex is the only implemented provider right now. `claudeCode` is reserved in contracts/UI but currently returns a not-implemented runtime error. + +Runtime app utilities exposed via `nativeApi.app.*`: + +1. `bootstrap` +2. `health` diff --git a/apps/desktop/package.json b/apps/desktop/package.json deleted file mode 100644 index b86b9c007f8..00000000000 --- a/apps/desktop/package.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "name": "@acme/desktop", - "version": "0.0.0", - "private": true, - "main": "dist-electron/main.js", - "scripts": { - "dev": "concurrently -k -n BUNDLE,ELECTRON \"bun run dev:bundle\" \"bun run dev:electron\"", - "dev:bundle": "tsup --watch", - "dev:electron": "bun run scripts/dev-electron.mjs", - "build": "tsup", - "start": "electron dist-electron/main.js", - "postinstall": "electron-rebuild", - "typecheck": "tsc --noEmit", - "test": "vitest run", - "smoke-test": "node scripts/smoke-test.mjs" - }, - "dependencies": { - "@acme/contracts": "workspace:*", - "electron": "33.4.11", - "node-pty": "^1.0.0" - }, - "devDependencies": { - "concurrently": "^9.1.2", - "@electron/rebuild": "^3.7.0", - "@types/node": "^22.10.2", - "electronmon": "^2.0.2", - "tsup": "^8.3.5", - "typescript": "^5.7.3", - "wait-on": "^8.0.2" - } -} diff --git a/apps/desktop/scripts/dev-electron.mjs b/apps/desktop/scripts/dev-electron.mjs deleted file mode 100644 index d0522bb4e7b..00000000000 --- a/apps/desktop/scripts/dev-electron.mjs +++ /dev/null @@ -1,28 +0,0 @@ -import { spawn } from "node:child_process"; - -import waitOn from "wait-on"; - -const port = Number(process.env.ELECTRON_RENDERER_PORT ?? 5173); -const devServerUrl = `http://localhost:${port}`; - -await waitOn({ - resources: [ - `tcp:${port}`, - "file:dist-electron/main.js", - "file:dist-electron/preload.js", - ], -}); - -const command = - process.platform === "win32" ? "electronmon.cmd" : "electronmon"; -const child = spawn(command, ["dist-electron/main.js"], { - stdio: "inherit", - env: { - ...process.env, - VITE_DEV_SERVER_URL: devServerUrl, - }, -}); - -child.on("exit", (code) => { - process.exit(code ?? 0); -}); diff --git a/apps/desktop/scripts/smoke-test.mjs b/apps/desktop/scripts/smoke-test.mjs deleted file mode 100644 index 62884bcde3e..00000000000 --- a/apps/desktop/scripts/smoke-test.mjs +++ /dev/null @@ -1,84 +0,0 @@ -/** - * Smoke test: builds desktop + renderer, launches Electron against the - * production bundle, waits for the renderer to confirm it loaded, then exits. - * - * Catches the two categories of regression we've hit: - * 1. Module resolution failures (preload can't find @acme/contracts, etc.) - * 2. CSP / script blocking (React fails to mount) - * - * The test works by injecting a tiny check via ELECTRON_ENABLE_LOGGING — - * Electron forwards renderer console.log to the main process stdout when - * that env var is set. We look for React's "Download the React DevTools" - * message as proof that React successfully mounted (it only fires after - * the first render). For production mode (no React DevTools message), we - * instead check that no fatal errors appeared. - */ -import { spawn, execSync } from "node:child_process"; -import { dirname, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const root = resolve(__dirname, "../../.."); -const desktopDir = resolve(__dirname, ".."); -const electronBin = resolve(desktopDir, "node_modules/.bin/electron"); -const mainJs = resolve(desktopDir, "dist-electron/main.js"); - -// ── Build first ────────────────────────────────────────────────────── -console.log("Building contracts + renderer + desktop..."); -execSync("bun run build", { cwd: root, stdio: "inherit" }); - -// ── Launch Electron (production mode — no VITE_DEV_SERVER_URL) ────── -console.log("\nLaunching Electron (production mode)..."); - -const child = spawn(electronBin, [mainJs], { - stdio: ["pipe", "pipe", "pipe"], - env: { - ...process.env, - VITE_DEV_SERVER_URL: "", // ensure production path - ELECTRON_ENABLE_LOGGING: "1", - }, -}); - -let output = ""; - -child.stdout.on("data", (d) => { - output += d.toString(); -}); -child.stderr.on("data", (d) => { - output += d.toString(); -}); - -const TIMEOUT_MS = 8_000; - -const timer = setTimeout(() => { - child.kill(); -}, TIMEOUT_MS); - -child.on("exit", () => { - clearTimeout(timer); - - // Fatal patterns that indicate broken builds - const fatalPatterns = [ - "Cannot find module", - "MODULE_NOT_FOUND", - "Refused to execute", // CSP blocking scripts - "can't detect preamble", // @vitejs/plugin-react failure - "Uncaught Error", - "Uncaught TypeError", - "Uncaught ReferenceError", - ]; - - const failures = fatalPatterns.filter((p) => output.includes(p)); - - if (failures.length > 0) { - console.error("\n❌ Smoke test FAILED. Matched fatal patterns:"); - for (const f of failures) { - console.error(` • ${f}`); - } - console.error("\nFull output:\n" + output); - process.exit(1); - } - - console.log("✅ Smoke test passed — no fatal errors detected"); - process.exit(0); -}); diff --git a/apps/desktop/src/fixPath.ts b/apps/desktop/src/fixPath.ts deleted file mode 100644 index be7ea9447c4..00000000000 --- a/apps/desktop/src/fixPath.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { execFileSync } from "node:child_process"; - -export function fixPath(): void { - if (process.platform !== "darwin") return; - - try { - const shell = process.env.SHELL ?? "/bin/zsh"; - const result = execFileSync(shell, ["-ilc", "echo -n $PATH"], { - encoding: "utf8", - timeout: 5000, - }); - if (result) { - process.env.PATH = result; - } - } catch { - // Silently ignore — keep default PATH - } -} diff --git a/apps/desktop/src/ipcHelpers.test.ts b/apps/desktop/src/ipcHelpers.test.ts deleted file mode 100644 index ce3318f0619..00000000000 --- a/apps/desktop/src/ipcHelpers.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -import { withParsedArgs, withParsedPayload } from "./ipcHelpers"; - -describe("withParsedPayload", () => { - it("parses payload and passes typed input to handler", async () => { - const handler = vi.fn(async (_event: unknown, payload: { value: string }) => - payload.value.toUpperCase(), - ); - const wrapped = withParsedPayload( - { - parse(payload: unknown): { value: string } { - if ( - !payload || - typeof payload !== "object" || - typeof (payload as { value?: unknown }).value !== "string" - ) { - throw new Error("Invalid payload"); - } - - return { value: (payload as { value: string }).value }; - }, - }, - handler, - ); - - const result = await wrapped({}, { value: "hello" }); - expect(result).toBe("HELLO"); - expect(handler).toHaveBeenCalledWith({}, { value: "hello" }); - }); - - it("throws and does not call handler on invalid payload", async () => { - const handler = vi.fn(async () => "ok"); - const wrapped = withParsedPayload( - { - parse(payload: unknown): { value: string } { - if ( - !payload || - typeof payload !== "object" || - typeof (payload as { value?: unknown }).value !== "string" - ) { - throw new Error("Invalid payload"); - } - - return { value: (payload as { value: string }).value }; - }, - }, - handler, - ); - - expect(() => wrapped({}, { value: 123 })).toThrow(); - expect(handler).not.toHaveBeenCalled(); - }); -}); - -describe("withParsedArgs", () => { - it("parses tuple arguments before invoking handler", () => { - const handler = vi.fn((_event: unknown, sessionId: string, data: string) => { - return `${sessionId}:${data}`; - }); - const wrapped = withParsedArgs( - { - parse(args: unknown[]): [string, string] { - const [sessionId, data] = args; - if (typeof sessionId !== "string" || sessionId.length === 0) { - throw new Error("Invalid sessionId"); - } - if (typeof data !== "string") { - throw new Error("Invalid data"); - } - - return [sessionId, data]; - }, - }, - handler, - ); - - expect(wrapped({}, "abc", "input")).toBe("abc:input"); - expect(handler).toHaveBeenCalledWith({}, "abc", "input"); - }); - - it("throws and does not call handler when args are invalid", () => { - const handler = vi.fn(); - const wrapped = withParsedArgs( - { - parse(args: unknown[]): [string, string] { - const [sessionId, data] = args; - if (typeof sessionId !== "string" || sessionId.length === 0) { - throw new Error("Invalid sessionId"); - } - if (typeof data !== "string") { - throw new Error("Invalid data"); - } - - return [sessionId, data]; - }, - }, - handler, - ); - - expect(() => wrapped({}, 123, "input")).toThrow(); - expect(handler).not.toHaveBeenCalled(); - }); -}); diff --git a/apps/desktop/src/ipcHelpers.ts b/apps/desktop/src/ipcHelpers.ts deleted file mode 100644 index 6383cbfef24..00000000000 --- a/apps/desktop/src/ipcHelpers.ts +++ /dev/null @@ -1,24 +0,0 @@ -type MaybePromise = T | Promise; -type Parser = { - parse: (value: unknown) => T; -}; -type ArgsParser = { - parse: (value: unknown[]) => T; -}; - -export function withParsedPayload( - schema: Parser, - handler: (event: unknown, payload: TPayload) => MaybePromise, -): (event: unknown, payload: unknown) => MaybePromise { - return (event, payload) => handler(event, schema.parse(payload)); -} - -export function withParsedArgs( - schema: ArgsParser, - handler: (event: unknown, ...args: TArgs) => MaybePromise, -): (event: unknown, ...args: unknown[]) => MaybePromise { - return (event, ...args) => { - const parsedArgs = schema.parse(args); - return handler(event, ...parsedArgs); - }; -} diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts deleted file mode 100644 index 454cbe65f17..00000000000 --- a/apps/desktop/src/main.ts +++ /dev/null @@ -1,349 +0,0 @@ -import { fixPath } from "./fixPath"; -fixPath(); - -import { spawn } from "node:child_process"; -import path from "node:path"; -import { BrowserWindow, app, dialog, ipcMain, session, shell } from "electron"; - -import { - EDITORS, - IPC_CHANNELS, - type TerminalCommandInput, - type TerminalCommandResult, - agentConfigSchema, - agentSessionIdSchema, - newTodoInputSchema, - providerInterruptTurnInputSchema, - providerRespondToRequestInputSchema, - providerSendTurnInputSchema, - providerSessionStartInputSchema, - providerStopSessionInputSchema, - terminalCommandInputSchema, - todoIdSchema, -} from "@acme/contracts"; -import { withParsedArgs, withParsedPayload } from "./ipcHelpers"; -import { ProcessManager } from "./processManager"; -import { ProviderManager } from "./providerManager"; -import { TodoStore } from "./todoStore"; - -const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL); - -let todoStore: TodoStore; -const processManager = new ProcessManager(); -const providerManager = new ProviderManager(); -const agentWriteArgsParser = { - parse(args: unknown[]): [string, string] { - const [sessionId, data] = args; - if (typeof data !== "string") { - throw new Error("agent:write data must be a string"); - } - - return [agentSessionIdSchema.parse(sessionId), data]; - }, -}; - -function createWindow(): BrowserWindow { - const window = new BrowserWindow({ - width: 1100, - height: 780, - minWidth: 840, - minHeight: 620, - show: false, - autoHideMenuBar: true, - titleBarStyle: "hiddenInset", - trafficLightPosition: { x: 16, y: 18 }, - webPreferences: { - preload: path.join(__dirname, "preload.js"), - contextIsolation: true, - nodeIntegration: false, - sandbox: true, - }, - }); - - window.webContents.setWindowOpenHandler(() => ({ action: "deny" })); - - window.once("ready-to-show", () => { - window.show(); - }); - - setupEventForwarding(window); - - if (isDevelopment) { - void window.loadURL(process.env.VITE_DEV_SERVER_URL as string); - return window; - } - - void window.loadFile(path.join(__dirname, "../../renderer/dist/index.html")); - return window; -} - -function registerIpcHandlers(): void { - // Todo handlers - ipcMain.handle(IPC_CHANNELS.todosList, async () => { - return todoStore.list(); - }); - - ipcMain.handle( - IPC_CHANNELS.todosAdd, - withParsedPayload(newTodoInputSchema, async (_event, payload) => { - return todoStore.add(payload); - }), - ); - - ipcMain.handle( - IPC_CHANNELS.todosToggle, - withParsedPayload(todoIdSchema, async (_event, id) => { - return todoStore.toggle(id); - }), - ); - - ipcMain.handle( - IPC_CHANNELS.todosRemove, - withParsedPayload(todoIdSchema, async (_event, id) => { - return todoStore.remove(id); - }), - ); - - ipcMain.handle(IPC_CHANNELS.dialogPickFolder, async () => { - const owner = BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0]; - const result = owner - ? await dialog.showOpenDialog(owner, { - properties: ["openDirectory", "createDirectory"], - }) - : await dialog.showOpenDialog({ - properties: ["openDirectory", "createDirectory"], - }); - - if (result.canceled) return null; - return result.filePaths[0] ?? null; - }); - - // Terminal handlers - ipcMain.handle( - IPC_CHANNELS.terminalRun, - withParsedPayload(terminalCommandInputSchema, async (_event, payload) => { - return runTerminalCommand(payload); - }), - ); - - // Shell handlers - ipcMain.handle( - IPC_CHANNELS.shellOpenInEditor, - async (_event, cwd: string, editor: string) => { - if (!cwd) throw new Error("cwd is required"); - const editorDef = EDITORS.find((e) => e.id === editor); - if (!editorDef) throw new Error(`Unknown editor: ${editor}`); - if (!editorDef.command) { - const error = await shell.openPath(cwd); - if (error) throw new Error(error); - return; - } - const child = spawn(editorDef.command, [cwd], { - detached: true, - stdio: "ignore", - }); - child.on("error", () => { - /* ignore spawn failures for detached editors */ - }); - child.unref(); - }, - ); - - // Agent handlers - ipcMain.handle( - IPC_CHANNELS.agentSpawn, - withParsedPayload(agentConfigSchema, async (_event, config) => { - return processManager.spawn(config); - }), - ); - - ipcMain.handle( - IPC_CHANNELS.agentKill, - withParsedPayload(agentSessionIdSchema, async (_event, sessionId) => { - processManager.kill(sessionId); - }), - ); - - ipcMain.handle( - IPC_CHANNELS.agentWrite, - withParsedArgs(agentWriteArgsParser, async (_event, sessionId, data) => { - processManager.write(sessionId, data); - }), - ); - - // Provider handlers - ipcMain.handle( - IPC_CHANNELS.providerSessionStart, - withParsedPayload(providerSessionStartInputSchema, async (_event, payload) => { - return providerManager.startSession(payload); - }), - ); - - ipcMain.handle( - IPC_CHANNELS.providerTurnStart, - withParsedPayload(providerSendTurnInputSchema, async (_event, payload) => { - return providerManager.sendTurn(payload); - }), - ); - - ipcMain.handle( - IPC_CHANNELS.providerTurnInterrupt, - withParsedPayload(providerInterruptTurnInputSchema, async (_event, payload) => { - await providerManager.interruptTurn(payload); - }), - ); - - ipcMain.handle( - IPC_CHANNELS.providerRequestRespond, - withParsedPayload( - providerRespondToRequestInputSchema, - async (_event, payload) => { - await providerManager.respondToRequest(payload); - }, - ), - ); - - ipcMain.handle( - IPC_CHANNELS.providerSessionStop, - withParsedPayload(providerStopSessionInputSchema, async (_event, payload) => { - providerManager.stopSession(payload); - }), - ); - - ipcMain.handle(IPC_CHANNELS.providerSessionList, async () => { - return providerManager.listSessions(); - }); -} - -async function runTerminalCommand(input: TerminalCommandInput): Promise { - const shellPath = - process.platform === "win32" - ? (process.env.ComSpec ?? "cmd.exe") - : (process.env.SHELL ?? "/bin/sh"); - - const args = - process.platform === "win32" ? ["/d", "/s", "/c", input.command] : ["-lc", input.command]; - - return new Promise((resolve, reject) => { - const child = spawn(shellPath, args, { - cwd: input.cwd, - env: process.env, - stdio: ["ignore", "pipe", "pipe"], - }); - - let stdout = ""; - let stderr = ""; - let timedOut = false; - - const timeout = setTimeout(() => { - timedOut = true; - child.kill("SIGTERM"); - setTimeout(() => { - if (!child.killed) { - child.kill("SIGKILL"); - } - }, 1_000).unref(); - }, input.timeoutMs ?? 30_000); - - child.stdout?.on("data", (chunk: Buffer) => { - stdout += chunk.toString(); - }); - - child.stderr?.on("data", (chunk: Buffer) => { - stderr += chunk.toString(); - }); - - child.on("error", (error) => { - clearTimeout(timeout); - reject(error); - }); - - child.on("close", (code, signal) => { - clearTimeout(timeout); - resolve({ - stdout, - stderr, - code: code ?? null, - signal: signal ?? null, - timedOut, - }); - }); - }); -} - -function setupEventForwarding(window: BrowserWindow): void { - const onOutput = (chunk: unknown) => { - if (!window.isDestroyed()) { - window.webContents.send(IPC_CHANNELS.agentOutput, chunk); - } - }; - - const onExit = (exit: unknown) => { - if (!window.isDestroyed()) { - window.webContents.send(IPC_CHANNELS.agentExit, exit); - } - }; - - const onProviderEvent = (event: unknown) => { - if (!window.isDestroyed()) { - window.webContents.send(IPC_CHANNELS.providerEvent, event); - } - }; - - processManager.on("output", onOutput); - processManager.on("exit", onExit); - providerManager.on("event", onProviderEvent); - - window.on("closed", () => { - processManager.off("output", onOutput); - processManager.off("exit", onExit); - providerManager.off("event", onProviderEvent); - }); -} - -function setupCSP(): void { - session.defaultSession.webRequest.onHeadersReceived((details, callback) => { - const csp = isDevelopment - ? "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; connect-src 'self' ws://localhost:* http://localhost:*" - : "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'"; - - callback({ - responseHeaders: { - ...details.responseHeaders, - "Content-Security-Policy": [csp], - }, - }); - }); -} - -async function bootstrap(): Promise { - setupCSP(); - - todoStore = new TodoStore(path.join(app.getPath("userData"), "todos.json")); - await todoStore.init(); - - registerIpcHandlers(); - createWindow(); - - app.on("activate", () => { - if (BrowserWindow.getAllWindows().length === 0) { - createWindow(); - } - }); -} - -app.on("before-quit", () => { - processManager.killAll(); - providerManager.stopAll(); - providerManager.dispose(); -}); - -app.whenReady().then(() => { - void bootstrap(); -}); - -app.on("window-all-closed", () => { - if (process.platform !== "darwin") { - app.quit(); - } -}); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts deleted file mode 100644 index 11fdfe4e77f..00000000000 --- a/apps/desktop/src/preload.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { contextBridge, ipcRenderer } from "electron"; - -import { IPC_CHANNELS, type NativeApi } from "@acme/contracts"; - -const nativeApi: NativeApi = { - todos: { - list: () => ipcRenderer.invoke(IPC_CHANNELS.todosList), - add: (input) => ipcRenderer.invoke(IPC_CHANNELS.todosAdd, input), - toggle: (id) => ipcRenderer.invoke(IPC_CHANNELS.todosToggle, id), - remove: (id) => ipcRenderer.invoke(IPC_CHANNELS.todosRemove, id), - }, - dialogs: { - pickFolder: () => ipcRenderer.invoke(IPC_CHANNELS.dialogPickFolder), - }, - terminal: { - run: (input) => ipcRenderer.invoke(IPC_CHANNELS.terminalRun, input), - }, - agent: { - spawn: (config) => ipcRenderer.invoke(IPC_CHANNELS.agentSpawn, config), - kill: (sessionId) => ipcRenderer.invoke(IPC_CHANNELS.agentKill, sessionId), - write: (sessionId, data) => ipcRenderer.invoke(IPC_CHANNELS.agentWrite, sessionId, data), - onOutput: (callback) => { - const listener = (_event: Electron.IpcRendererEvent, chunk: unknown) => - callback(chunk as Parameters[0]); - ipcRenderer.on(IPC_CHANNELS.agentOutput, listener); - return () => ipcRenderer.removeListener(IPC_CHANNELS.agentOutput, listener); - }, - onExit: (callback) => { - const listener = (_event: Electron.IpcRendererEvent, exit: unknown) => - callback(exit as Parameters[0]); - ipcRenderer.on(IPC_CHANNELS.agentExit, listener); - return () => ipcRenderer.removeListener(IPC_CHANNELS.agentExit, listener); - }, - }, - providers: { - startSession: (input) => ipcRenderer.invoke(IPC_CHANNELS.providerSessionStart, input), - sendTurn: (input) => ipcRenderer.invoke(IPC_CHANNELS.providerTurnStart, input), - interruptTurn: (input) => ipcRenderer.invoke(IPC_CHANNELS.providerTurnInterrupt, input), - respondToRequest: (input) => ipcRenderer.invoke(IPC_CHANNELS.providerRequestRespond, input), - stopSession: (input) => ipcRenderer.invoke(IPC_CHANNELS.providerSessionStop, input), - listSessions: () => ipcRenderer.invoke(IPC_CHANNELS.providerSessionList), - onEvent: (callback) => { - const listener = (_event: Electron.IpcRendererEvent, payload: unknown) => - callback(payload as Parameters[0]); - ipcRenderer.on(IPC_CHANNELS.providerEvent, listener); - return () => ipcRenderer.removeListener(IPC_CHANNELS.providerEvent, listener); - }, - }, - shell: { - openInEditor: (cwd: string, editor: string) => - ipcRenderer.invoke(IPC_CHANNELS.shellOpenInEditor, cwd, editor), - }, -}; - -contextBridge.exposeInMainWorld("nativeApi", nativeApi); diff --git a/apps/desktop/tsup.config.ts b/apps/desktop/tsup.config.ts deleted file mode 100644 index 0d630c28055..00000000000 --- a/apps/desktop/tsup.config.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { defineConfig } from "tsup"; - -export default defineConfig({ - entry: ["src/main.ts", "src/preload.ts"], - format: "cjs", - outDir: "dist-electron", - sourcemap: true, - clean: true, - noExternal: ["@acme/contracts"], -}); diff --git a/apps/renderer/index.html b/apps/renderer/index.html index c2c8793a7c0..d8fd8a14ea6 100644 --- a/apps/renderer/index.html +++ b/apps/renderer/index.html @@ -3,7 +3,7 @@ - Electron Todo + CodeThing
diff --git a/apps/renderer/src/App.tsx b/apps/renderer/src/App.tsx index a525c7caebe..eb14eb15f9e 100644 --- a/apps/renderer/src/App.tsx +++ b/apps/renderer/src/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef } from "react"; +import { useEffect, useRef } from "react"; import ChatView from "./components/ChatView"; import DiffPanel from "./components/DiffPanel"; @@ -7,7 +7,7 @@ import { readNativeApi } from "./session-logic"; import { StoreProvider, useStore } from "./store"; function EventRouter() { - const api = useMemo(() => readNativeApi(), []); + const api = readNativeApi(); const { dispatch } = useStore(); const activeAssistantItemRef = useRef(null); @@ -25,8 +25,82 @@ function EventRouter() { return null; } +function BootstrapRouter() { + const api = readNativeApi(); + const { dispatch } = useStore(); + + useEffect(() => { + if (!api) return; + let cancelled = false; + void api.app + .bootstrap() + .then((bootstrap) => { + if (cancelled) return; + dispatch({ + type: "BOOTSTRAP_FROM_SERVER", + bootstrap, + }); + }) + .catch((error) => { + if (cancelled) return; + dispatch({ + type: "SET_RUNTIME_ERROR", + error: + error instanceof Error + ? error.message + : "Could not connect to the local t3 runtime.", + }); + }); + + return () => { + cancelled = true; + }; + }, [api, dispatch]); + + return null; +} + +function RuntimeHealthRouter() { + const api = readNativeApi(); + const { dispatch } = useStore(); + + useEffect(() => { + if (!api) return; + let cancelled = false; + + const checkHealth = async () => { + try { + await api.app.health(); + if (cancelled) return; + dispatch({ type: "SET_RUNTIME_ERROR", error: null }); + } catch (error) { + if (cancelled) return; + dispatch({ + type: "SET_RUNTIME_ERROR", + error: + error instanceof Error + ? error.message + : "Could not connect to the local t3 runtime.", + }); + } + }; + + void checkHealth(); + const interval = window.setInterval(() => { + void checkHealth(); + }, 8_000); + + return () => { + cancelled = true; + window.clearInterval(interval); + }; + }, [api, dispatch]); + + return null; +} + function Layout() { - const api = useMemo(() => readNativeApi(), []); + const api = readNativeApi(); const { state } = useStore(); if (!api) { @@ -34,9 +108,7 @@ function Layout() {
-

- Native bridge unavailable. Launch through Electron. -

+

Local t3 runtime unavailable.

); @@ -44,7 +116,14 @@ function Layout() { return (
+ + + {state.runtimeError && state.projects.length === 0 && ( +
+ Runtime connection failed: {state.runtimeError} +
+ )} {state.diffOpen && } diff --git a/apps/renderer/src/components/ChatView.tsx b/apps/renderer/src/components/ChatView.tsx index 3463a6bd73f..eb5360305e1 100644 --- a/apps/renderer/src/components/ChatView.tsx +++ b/apps/renderer/src/components/ChatView.tsx @@ -8,7 +8,6 @@ import { Fragment, type KeyboardEvent, useEffect, - useMemo, useRef, useState, } from "react"; @@ -89,23 +88,17 @@ function approvalDetail(event: ProviderEvent): string | undefined { return asString(payload?.reason); } -function derivePendingApprovals( - events: ProviderEvent[], -): PendingApprovalCard[] { +function derivePendingApprovals(events: ProviderEvent[]): PendingApprovalCard[] { const pending = new Map(); - const ordered = [...events].reverse(); + const ordered = events.toReversed(); for (const event of ordered) { - if ( - event.method === "session/closed" || - event.method === "session/exited" - ) { + if (event.method === "session/closed" || event.method === "session/exited") { pending.clear(); continue; } - const requestId = - event.requestId ?? asString(asRecord(event.payload)?.requestId); + const requestId = event.requestId ?? asString(asRecord(event.payload)?.requestId); if (!requestId) continue; if ( @@ -132,7 +125,7 @@ function derivePendingApprovals( export default function ChatView() { const { state, dispatch } = useStore(); - const api = useMemo(() => readNativeApi(), []); + const api = readNativeApi(); const [prompt, setPrompt] = useState(""); const [isSending, setIsSending] = useState(false); const [isConnecting, setIsConnecting] = useState(false); @@ -140,16 +133,11 @@ export default function ChatView() { const [isEditorMenuOpen, setIsEditorMenuOpen] = useState(false); const [lastEditor, setLastEditor] = useState(() => { const stored = localStorage.getItem(LAST_EDITOR_KEY); - return EDITORS.some((e) => e.id === stored) - ? (stored as EditorId) - : EDITORS[0].id; + return EDITORS.some((e) => e.id === stored) ? (stored as EditorId) : EDITORS[0].id; }); - const [selectedEffort, setSelectedEffort] = - useState(DEFAULT_REASONING); + const [selectedEffort, setSelectedEffort] = useState(DEFAULT_REASONING); const [isSwitchingRuntimeMode, setIsSwitchingRuntimeMode] = useState(false); - const [respondingRequestIds, setRespondingRequestIds] = useState( - [], - ); + const [respondingRequestIds, setRespondingRequestIds] = useState([]); const [nowTick, setNowTick] = useState(() => Date.now()); const messagesEndRef = useRef(null); const textareaRef = useRef(null); @@ -165,15 +153,9 @@ export default function ChatView() { const isWorking = phase === "running" || isSending || isConnecting; const nowIso = new Date(nowTick).toISOString(); const modelOptions = MODEL_OPTIONS; - const workLogEntries = useMemo( - () => deriveWorkLogEntries(activeThread?.events ?? [], undefined), - [activeThread?.events], - ); - const pendingApprovals = useMemo( - () => derivePendingApprovals(activeThread?.events ?? []), - [activeThread?.events], - ); - const assistantCompletionByItemId = useMemo(() => { + const workLogEntries = deriveWorkLogEntries(activeThread?.events ?? [], undefined); + const pendingApprovals = derivePendingApprovals(activeThread?.events ?? []); + const assistantCompletionByItemId = (() => { const map = new Map(); const ordered = [...(activeThread?.events ?? [])].toReversed(); for (const event of ordered) { @@ -182,12 +164,9 @@ export default function ChatView() { map.set(event.itemId, event.createdAt); } return map; - }, [activeThread?.events]); - const timelineEntries = useMemo( - () => deriveTimelineEntries(activeThread?.messages ?? [], workLogEntries), - [activeThread?.messages, workLogEntries], - ); - const completionSummary = useMemo(() => { + })(); + const timelineEntries = deriveTimelineEntries(activeThread?.messages ?? [], workLogEntries); + const completionSummary = (() => { if (!activeThread?.latestTurnStartedAt) return null; if (!activeThread.latestTurnCompletedAt) return null; if (workLogEntries.length === 0) return null; @@ -205,13 +184,8 @@ export default function ChatView() { activeThread.latestTurnCompletedAt, ); return elapsed ? `Worked for ${elapsed}` : null; - }, [ - activeThread?.latestTurnStartedAt, - activeThread?.latestTurnCompletedAt, - activeThread?.latestTurnDurationMs, - workLogEntries.length, - ]); - const completionDividerBeforeEntryId = useMemo(() => { + })(); + const completionDividerBeforeEntryId = (() => { if (!activeThread?.latestTurnStartedAt) return null; if (!activeThread.latestTurnCompletedAt) return null; if (workLogEntries.length === 0) return null; @@ -226,12 +200,7 @@ export default function ChatView() { return !Number.isNaN(messageAt) && messageAt >= turnStartedAt; }); return entry?.id ?? null; - }, [ - activeThread?.latestTurnStartedAt, - activeThread?.latestTurnCompletedAt, - timelineEntries, - workLogEntries.length, - ]); + })(); const runtimeSessionConfig = state.runtimeMode === "full-access" ? ({ @@ -243,18 +212,14 @@ export default function ChatView() { sandboxMode: "workspace-write", } as const); - const handleRuntimeModeChange = async ( - mode: "approval-required" | "full-access", - ) => { + const handleRuntimeModeChange = async (mode: "approval-required" | "full-access") => { if (mode === state.runtimeMode) return; dispatch({ type: "SET_RUNTIME_MODE", mode }); if (!api) return; const sessionIds = state.threads .map((t) => t.session) - .filter( - (s): s is NonNullable => s !== null && s.status !== "closed", - ) + .filter((s): s is NonNullable => s !== null && s.status !== "closed") .map((s) => s.sessionId); if (sessionIds.length === 0) return; @@ -262,9 +227,7 @@ export default function ChatView() { setIsSwitchingRuntimeMode(true); try { await Promise.all( - sessionIds.map((id) => - api.providers.stopSession({ sessionId: id }).catch(() => undefined), - ), + sessionIds.map((id) => api.providers.stopSession({ sessionId: id }).catch(() => undefined)), ); } finally { setIsSwitchingRuntimeMode(false); @@ -321,10 +284,7 @@ export default function ChatView() { const handleClickOutside = (event: MouseEvent) => { if (!editorMenuRef.current) return; - if ( - event.target instanceof Node && - !editorMenuRef.current.contains(event.target) - ) { + if (event.target instanceof Node && !editorMenuRef.current.contains(event.target)) { setIsEditorMenuOpen(false); } }; @@ -359,7 +319,11 @@ export default function ChatView() { const ensureSession = async (): Promise => { if (!api || !activeThread || !activeProject) return null; - if (activeThread.session && activeThread.session.status !== "closed") { + if ( + activeThread.session && + activeThread.session.status !== "closed" && + activeThread.session.status !== "error" + ) { const sessionThreadId = activeThread.session.threadId ?? null; const continuityState: SessionContinuityState = activeThread.codexThreadId === null @@ -451,14 +415,9 @@ export default function ChatView() { try { const shouldBootstrap = previousMessages.length > 0 && - (sessionInfo.continuityState === "new" || - sessionInfo.continuityState === "fallback_new"); + (sessionInfo.continuityState === "new" || sessionInfo.continuityState === "fallback_new"); const input = shouldBootstrap - ? buildBootstrapInput( - previousMessages, - trimmed, - PROVIDER_SEND_TURN_MAX_INPUT_CHARS, - ).text + ? buildBootstrapInput(previousMessages, trimmed, PROVIDER_SEND_TURN_MAX_INPUT_CHARS).text : trimmed; await api.providers.sendTurn({ sessionId: sessionInfo.sessionId, @@ -485,10 +444,7 @@ export default function ChatView() { }); }; - const onRespondToApproval = async ( - requestId: string, - decision: ProviderApprovalDecision, - ) => { + const onRespondToApproval = async (requestId: string, decision: ProviderApprovalDecision) => { if (!api || !activeThread?.session) return; setRespondingRequestIds((existing) => @@ -504,15 +460,10 @@ export default function ChatView() { dispatch({ type: "SET_ERROR", threadId: activeThread.id, - error: - err instanceof Error - ? err.message - : "Failed to submit approval decision.", + error: err instanceof Error ? err.message : "Failed to submit approval decision.", }); } finally { - setRespondingRequestIds((existing) => - existing.filter((id) => id !== requestId), - ); + setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId)); } }; @@ -552,9 +503,7 @@ export default function ChatView() { {/* Top bar */}
-

- {activeThread.title} -

+

{activeThread.title}

{/* Open in editor */} @@ -579,9 +528,7 @@ export default function ChatView() { {editorLabel(editor)} {editor.id === lastEditor && ( - {navigator.platform.includes("Mac") - ? "\u2318O" - : "Ctrl+O"} + {navigator.platform.includes("Mac") ? "\u2318O" : "Ctrl+O"} )} @@ -615,9 +562,7 @@ export default function ChatView() { {pendingApprovals.length > 0 && (
{pendingApprovals.map((approval) => { - const isResponding = respondingRequestIds.includes( - approval.requestId, - ); + const isResponding = respondingRequestIds.includes(approval.requestId); return (
- void onRespondToApproval(approval.requestId, "accept") - } + onClick={() => void onRespondToApproval(approval.requestId, "accept")} > Approve once @@ -651,12 +594,7 @@ export default function ChatView() { type="button" className="rounded-md border border-sky-300/30 bg-sky-500/[0.15] px-2 py-1 text-[11px] text-sky-100 transition-colors duration-150 hover:bg-sky-500/[0.22] disabled:cursor-not-allowed disabled:opacity-50" disabled={isResponding} - onClick={() => - void onRespondToApproval( - approval.requestId, - "acceptForSession", - ) - } + onClick={() => void onRespondToApproval(approval.requestId, "acceptForSession")} > Always allow this session @@ -664,9 +602,7 @@ export default function ChatView() { type="button" className="rounded-md border border-border px-2 py-1 text-[11px] text-foreground/90 transition-colors duration-150 hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50" disabled={isResponding} - onClick={() => - void onRespondToApproval(approval.requestId, "decline") - } + onClick={() => void onRespondToApproval(approval.requestId, "decline")} > Decline @@ -674,9 +610,7 @@ export default function ChatView() { type="button" className="rounded-md border border-rose-300/30 bg-rose-500/[0.12] px-2 py-1 text-[11px] text-rose-100 transition-colors duration-150 hover:bg-rose-500/[0.2] disabled:cursor-not-allowed disabled:opacity-50" disabled={isResponding} - onClick={() => - void onRespondToApproval(approval.requestId, "cancel") - } + onClick={() => void onRespondToApproval(approval.requestId, "cancel")} > Cancel turn @@ -691,7 +625,9 @@ export default function ChatView() {
{activeThread.messages.length === 0 && !isWorking ? (
-

Send a message to start the conversation.

+

+ Send a message to start the conversation. +

) : (
@@ -704,9 +640,7 @@ export default function ChatView() {
- {completionSummary - ? `Response • ${completionSummary}` - : "Response"} + {completionSummary ? `Response • ${completionSummary}` : "Response"}
@@ -748,9 +682,7 @@ export default function ChatView() { {timelineEntry.message.streaming && ( @@ -769,15 +701,10 @@ export default function ChatView() { {formatMessageMeta( timelineEntry.message.createdAt, timelineEntry.message.streaming - ? formatElapsed( - timelineEntry.message.createdAt, - nowIso, - ) + ? formatElapsed(timelineEntry.message.createdAt, nowIso) : formatElapsed( timelineEntry.message.createdAt, - assistantCompletionByItemId.get( - timelineEntry.message.id, - ), + assistantCompletionByItemId.get(timelineEntry.message.id), ), )}

@@ -933,9 +860,7 @@ export default function ChatView() { disabled={isSwitchingRuntimeMode} onClick={() => void handleRuntimeModeChange( - state.runtimeMode === "full-access" - ? "approval-required" - : "full-access", + state.runtimeMode === "full-access" ? "approval-required" : "full-access", ) } title={ @@ -945,13 +870,7 @@ export default function ChatView() { } > {state.runtimeMode === "full-access" ? ( - - {state.runtimeMode === "full-access" - ? "Full access" - : "Supervised"} - + {state.runtimeMode === "full-access" ? "Full access" : "Supervised"}
diff --git a/apps/renderer/src/components/Sidebar.tsx b/apps/renderer/src/components/Sidebar.tsx index 85f82788adf..f119a455186 100644 --- a/apps/renderer/src/components/Sidebar.tsx +++ b/apps/renderer/src/components/Sidebar.tsx @@ -1,5 +1,5 @@ import { MonitorIcon, MoonIcon, SunIcon } from "lucide-react"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useEffect, useState } from "react"; import { useTheme } from "../hooks/useTheme"; import { DEFAULT_MODEL } from "../model-logic"; import { readNativeApi } from "../session-logic"; @@ -27,7 +27,7 @@ function threadStatusLabel( export default function Sidebar() { const { state, dispatch } = useStore(); - const api = useMemo(() => readNativeApi(), []); + const api = readNativeApi(); const { theme, setTheme } = useTheme(); const [addingProject, setAddingProject] = useState(false); const [newCwd, setNewCwd] = useState(""); @@ -36,7 +36,7 @@ export default function Sidebar() { const handleAddProject = () => { const cwd = newCwd.trim(); if (!cwd) return; - const name = cwd.split("/").filter(Boolean).pop() ?? "project"; + const name = cwd.split("/").findLast((segment) => segment.length > 0) ?? "project"; const project: Project = { id: crypto.randomUUID(), name, @@ -49,28 +49,23 @@ export default function Sidebar() { setAddingProject(false); }; - const handleNewThread = useCallback( - (projectId: string) => { - dispatch({ - type: "ADD_THREAD", - thread: { - id: crypto.randomUUID(), - codexThreadId: null, - projectId, - title: "New thread", - model: - state.projects.find((p) => p.id === projectId)?.model ?? - DEFAULT_MODEL, - session: null, - messages: [], - events: [], - error: null, - createdAt: new Date().toISOString(), - }, - }); - }, - [dispatch, state.projects], - ); + const handleNewThread = (projectId: string) => { + dispatch({ + type: "ADD_THREAD", + thread: { + id: crypto.randomUUID(), + codexThreadId: null, + projectId, + title: "New thread", + model: state.projects.find((p) => p.id === projectId)?.model ?? DEFAULT_MODEL, + session: null, + messages: [], + events: [], + error: null, + createdAt: new Date().toISOString(), + }, + }); + }; const handlePickFolder = async () => { if (!api || isPickingFolder) return; @@ -107,21 +102,33 @@ export default function Sidebar() { } } - const activeThread = state.threads.find( - (t) => t.id === state.activeThreadId, - ); + const activeThread = state.threads.find((t) => t.id === state.activeThreadId); const projectId = activeThread?.projectId ?? state.projects[0]?.id; if (!projectId) return; event.preventDefault(); - handleNewThread(projectId); + dispatch({ + type: "ADD_THREAD", + thread: { + id: crypto.randomUUID(), + codexThreadId: null, + projectId, + title: "New thread", + model: state.projects.find((project) => project.id === projectId)?.model ?? DEFAULT_MODEL, + session: null, + messages: [], + events: [], + error: null, + createdAt: new Date().toISOString(), + }, + }); }; window.addEventListener("keydown", onWindowKeyDown); return () => { window.removeEventListener("keydown", onWindowKeyDown); }; - }, [handleNewThread, state.activeThreadId, state.projects, state.threads]); + }, [dispatch, state.activeThreadId, state.projects, state.threads]); return (
{formatRelativeTime(thread.createdAt)} diff --git a/apps/renderer/src/historyBootstrap.ts b/apps/renderer/src/historyBootstrap.ts index 6d0f504d33f..97d7892ca43 100644 --- a/apps/renderer/src/historyBootstrap.ts +++ b/apps/renderer/src/historyBootstrap.ts @@ -36,13 +36,8 @@ export function buildBootstrapInput( latestPrompt: string, maxChars: number, ): BootstrapInputResult { - const budget = Number.isFinite(maxChars) - ? Math.max(1, Math.floor(maxChars)) - : 1; - const promptOnly = - latestPrompt.length <= budget - ? latestPrompt - : latestPrompt.slice(0, budget); + const budget = Number.isFinite(maxChars) ? Math.max(1, Math.floor(maxChars)) : 1; + const promptOnly = latestPrompt.length <= budget ? latestPrompt : latestPrompt.slice(0, budget); if (previousMessages.length === 0) { return { @@ -73,7 +68,7 @@ export function buildBootstrapInput( let includedNewestFirst: string[] = []; for (const block of newestFirstBlocks) { const nextNewestFirst = [...includedNewestFirst, block]; - const nextChronological = [...nextNewestFirst].reverse(); + const nextChronological = nextNewestFirst.toReversed(); const omittedCount = newestFirstBlocks.length - nextChronological.length; const transcriptBody = omittedCount > 0 @@ -85,10 +80,9 @@ export function buildBootstrapInput( includedNewestFirst = nextNewestFirst; } - let includedChronological = [...includedNewestFirst].reverse(); + let includedChronological = includedNewestFirst.toReversed(); while (true) { - const omittedCount = - newestFirstBlocks.length - includedChronological.length; + const omittedCount = newestFirstBlocks.length - includedChronological.length; const transcriptBody = omittedCount > 0 ? includedChronological.length > 0 @@ -101,8 +95,7 @@ export function buildBootstrapInput( text: finalized, includedCount: includedChronological.length, omittedCount, - truncated: - omittedCount > 0 || latestPrompt.length !== promptOnly.length, + truncated: omittedCount > 0 || latestPrompt.length !== promptOnly.length, }; } diff --git a/apps/renderer/src/hooks/useTheme.ts b/apps/renderer/src/hooks/useTheme.ts index dd3e3c24fde..bf458896d01 100644 --- a/apps/renderer/src/hooks/useTheme.ts +++ b/apps/renderer/src/hooks/useTheme.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useSyncExternalStore } from "react"; +import { useEffect, useSyncExternalStore } from "react"; type Theme = "light" | "dark" | "system"; @@ -28,7 +28,7 @@ function applyTheme(theme: Theme, suppressTransitions = false) { document.documentElement.classList.toggle("dark", isDark); if (suppressTransitions) { // Force a reflow so the no-transitions class takes effect before removal - document.documentElement.offsetHeight; + void document.documentElement.offsetHeight; requestAnimationFrame(() => { document.documentElement.classList.remove("no-transitions"); }); @@ -75,11 +75,11 @@ export function useTheme() { const resolvedTheme: "light" | "dark" = theme === "system" ? (getSystemDark() ? "dark" : "light") : theme; - const setTheme = useCallback((next: Theme) => { + const setTheme = (next: Theme) => { localStorage.setItem(STORAGE_KEY, next); applyTheme(next, true); emitChange(); - }, []); + }; // Keep DOM in sync on mount/change useEffect(() => { diff --git a/apps/renderer/src/index.css b/apps/renderer/src/index.css index 4c62dcaf61e..6ff0b052a20 100644 --- a/apps/renderer/src/index.css +++ b/apps/renderer/src/index.css @@ -88,11 +88,7 @@ --secondary: --alpha(var(--color-black) / 4%); --secondary-foreground: var(--color-neutral-800); --muted: --alpha(var(--color-black) / 4%); - --muted-foreground: color-mix( - in srgb, - var(--color-neutral-500) 90%, - var(--color-black) - ); + --muted-foreground: color-mix(in srgb, var(--color-neutral-500) 90%, var(--color-black)); --accent: --alpha(var(--color-black) / 4%); --accent-foreground: var(--color-neutral-800); --destructive: var(--color-red-500); @@ -122,11 +118,7 @@ @variant dark { color-scheme: dark; - --background: color-mix( - in srgb, - var(--color-neutral-950) 95%, - var(--color-white) - ); + --background: color-mix(in srgb, var(--color-neutral-950) 95%, var(--color-white)); --foreground: var(--color-neutral-100); --card: color-mix(in srgb, var(--background) 98%, var(--color-white)); --card-foreground: var(--color-neutral-100); @@ -137,18 +129,10 @@ --secondary: --alpha(var(--color-white) / 4%); --secondary-foreground: var(--color-neutral-100); --muted: --alpha(var(--color-white) / 4%); - --muted-foreground: color-mix( - in srgb, - var(--color-neutral-500) 90%, - var(--color-white) - ); + --muted-foreground: color-mix(in srgb, var(--color-neutral-500) 90%, var(--color-white)); --accent: --alpha(var(--color-white) / 4%); --accent-foreground: var(--color-neutral-100); - --destructive: color-mix( - in srgb, - var(--color-red-500) 90%, - var(--color-white) - ); + --destructive: color-mix(in srgb, var(--color-red-500) 90%, var(--color-white)); --border: --alpha(var(--color-white) / 6%); --input: --alpha(var(--color-white) / 8%); --ring: var(--color-neutral-500); @@ -287,11 +271,7 @@ label:has(> select#reasoning-effort) select { .chat-markdown a { color: var(--info-foreground); text-decoration: underline; - text-decoration-color: color-mix( - in srgb, - var(--info-foreground) 40%, - transparent - ); + text-decoration-color: color-mix(in srgb, var(--info-foreground) 40%, transparent); } .chat-markdown a:hover { diff --git a/apps/renderer/src/persistenceSchema.ts b/apps/renderer/src/persistenceSchema.ts index e6b4983a213..4f5f41cedc4 100644 --- a/apps/renderer/src/persistenceSchema.ts +++ b/apps/renderer/src/persistenceSchema.ts @@ -1,12 +1,7 @@ import { z } from "zod"; import { DEFAULT_MODEL, resolveModelSlug } from "./model-logic"; -import { - DEFAULT_RUNTIME_MODE, - type Project, - type RuntimeMode, - type Thread, -} from "./types"; +import { DEFAULT_RUNTIME_MODE, type Project, type RuntimeMode, type Thread } from "./types"; const LEGACY_DEFAULT_MODEL = "gpt-5.2-codex"; @@ -146,13 +141,9 @@ export function hydratePersistedState( return { projects, threads, - activeThreadId: hasActiveThread - ? parsedState.data.activeThreadId - : (threads[0]?.id ?? null), + activeThreadId: hasActiveThread ? parsedState.data.activeThreadId : (threads[0]?.id ?? null), runtimeMode: - "runtimeMode" in parsedState.data - ? parsedState.data.runtimeMode - : DEFAULT_RUNTIME_MODE, + "runtimeMode" in parsedState.data ? parsedState.data.runtimeMode : DEFAULT_RUNTIME_MODE, }; } diff --git a/apps/renderer/src/session-logic.ts b/apps/renderer/src/session-logic.ts index 3329f8b1963..81af255ad8e 100644 --- a/apps/renderer/src/session-logic.ts +++ b/apps/renderer/src/session-logic.ts @@ -1,5 +1,6 @@ import type { ProviderEvent, ProviderKind, ProviderSession } from "@acme/contracts"; import type { ChatMessage, SessionPhase } from "./types"; +import { getOrCreateWsNativeApi } from "./wsNativeApi"; export const PROVIDER_OPTIONS: Array<{ value: ProviderKind; @@ -12,7 +13,7 @@ export const PROVIDER_OPTIONS: Array<{ export function readNativeApi() { if (typeof window === "undefined") return undefined; - return window.nativeApi; + return window.nativeApi ?? getOrCreateWsNativeApi(); } export function asObject(value: unknown): Record | undefined { @@ -96,11 +97,7 @@ function normalizeItemType(raw: string | undefined): string { } function shouldDropItemType(type: string): boolean { - if ( - type.includes("preamble") || - type.includes("reasoning") || - type.includes("thought") - ) { + if (type.includes("preamble") || type.includes("reasoning") || type.includes("thought")) { return true; } @@ -108,11 +105,7 @@ function shouldDropItemType(type: string): boolean { } function shouldShowItemLifecycle(type: string): boolean { - return ( - type.includes("tool") || - type.includes("command") || - type.includes("file change") - ); + return type.includes("tool") || type.includes("command") || type.includes("file change"); } function shouldDropMethod(method: string): boolean { @@ -224,10 +217,7 @@ function entryFromRequest(event: ProviderEvent): WorkLogEntry | null { }; } -function entryFromNotification( - event: ProviderEvent, - turnStartedAt: number, -): WorkLogEntry | null { +function entryFromNotification(event: ProviderEvent, turnStartedAt: number): WorkLogEntry | null { if (event.kind !== "notification") return null; if (shouldDropMethod(event.method)) return null; if (event.method === "item/agentMessage/delta") return null; @@ -245,9 +235,7 @@ function entryFromNotification( const turnErrorDetail = normalizeDetail(turnErrorMessage); const eventAt = Date.parse(event.createdAt); const durationMs = - Number.isNaN(turnStartedAt) || - Number.isNaN(eventAt) || - eventAt < turnStartedAt + Number.isNaN(turnStartedAt) || Number.isNaN(eventAt) || eventAt < turnStartedAt ? undefined : eventAt - turnStartedAt; diff --git a/apps/renderer/src/store.test.ts b/apps/renderer/src/store.test.ts index 666f11c70fb..c4674fe7bd7 100644 --- a/apps/renderer/src/store.test.ts +++ b/apps/renderer/src/store.test.ts @@ -4,9 +4,7 @@ import { describe, expect, it } from "vitest"; import { type AppState, reducer } from "./store"; import type { Thread } from "./types"; -function makeSession( - overrides: Partial = {}, -): ProviderSession { +function makeSession(overrides: Partial = {}): ProviderSession { return { sessionId: "sess-1", provider: "codex", @@ -60,10 +58,82 @@ function makeState(thread: Thread): AppState { activeThreadId: thread.id, runtimeMode: "full-access", diffOpen: false, + runtimeError: null, }; } describe("store reducer thread continuity", () => { + it("bootstraps project and active thread from runtime response", () => { + const state: AppState = { + projects: [], + threads: [], + activeThreadId: null, + runtimeMode: "full-access", + diffOpen: false, + runtimeError: null, + }; + const session = makeSession({ + sessionId: "sess-bootstrap", + threadId: "thr-bootstrap", + }); + + const next = reducer(state, { + type: "BOOTSTRAP_FROM_SERVER", + bootstrap: { + launchCwd: "/workspace", + projectName: "workspace", + provider: "codex", + model: "gpt-5.3-codex", + session, + }, + }); + + expect(next.projects).toHaveLength(1); + expect(next.projects[0]?.cwd).toBe("/workspace"); + expect(next.threads).toHaveLength(1); + expect(next.threads[0]?.session?.sessionId).toBe("sess-bootstrap"); + expect(next.activeThreadId).toBe(next.threads[0]?.id ?? null); + }); + + it("surfaces bootstrap errors on the active thread", () => { + const state: AppState = { + projects: [], + threads: [], + activeThreadId: null, + runtimeMode: "full-access", + diffOpen: false, + runtimeError: null, + }; + + const next = reducer(state, { + type: "BOOTSTRAP_FROM_SERVER", + bootstrap: { + launchCwd: "/workspace", + projectName: "workspace", + provider: "codex", + model: "gpt-5.3-codex", + session: makeSession({ + sessionId: "sess-bootstrap-error", + status: "error", + }), + bootstrapError: "Timed out waiting for initialize.", + }, + }); + + expect(next.threads[0]?.error).toBe("Timed out waiting for initialize."); + }); + + it("stores runtime connection errors separately from thread errors", () => { + const state = makeState(makeThread()); + const next = reducer(state, { + type: "SET_RUNTIME_ERROR", + error: "Connection refused", + }); + + expect(next.runtimeError).toBe("Connection refused"); + expect(next.threads[0]?.error).toBeNull(); + }); + it("stores codexThreadId from UPDATE_SESSION", () => { const state = makeState( makeThread({ diff --git a/apps/renderer/src/store.ts b/apps/renderer/src/store.ts index 1363a9a6242..56d11e11c7e 100644 --- a/apps/renderer/src/store.ts +++ b/apps/renderer/src/store.ts @@ -8,21 +8,11 @@ import { useReducer, } from "react"; -import type { ProviderEvent, ProviderSession } from "@acme/contracts"; +import type { AppBootstrapResult, ProviderEvent, ProviderSession } from "@acme/contracts"; import { resolveModelSlug } from "./model-logic"; import { hydratePersistedState, toPersistedState } from "./persistenceSchema"; -import { - applyEventToMessages, - asObject, - asString, - evolveSession, -} from "./session-logic"; -import { - DEFAULT_RUNTIME_MODE, - type Project, - type RuntimeMode, - type Thread, -} from "./types"; +import { applyEventToMessages, asObject, asString, evolveSession } from "./session-logic"; +import { DEFAULT_RUNTIME_MODE, type Project, type RuntimeMode, type Thread } from "./types"; // ── Actions ────────────────────────────────────────────────────────── @@ -42,7 +32,9 @@ type Action = | { type: "SET_ERROR"; threadId: string; error: string | null } | { type: "SET_THREAD_TITLE"; threadId: string; title: string } | { type: "SET_THREAD_MODEL"; threadId: string; model: string } - | { type: "SET_RUNTIME_MODE"; mode: RuntimeMode }; + | { type: "SET_RUNTIME_MODE"; mode: RuntimeMode } + | { type: "SET_RUNTIME_ERROR"; error: string | null } + | { type: "BOOTSTRAP_FROM_SERVER"; bootstrap: AppBootstrapResult }; // ── State ──────────────────────────────────────────────────────────── @@ -52,6 +44,7 @@ export interface AppState { activeThreadId: string | null; runtimeMode: RuntimeMode; diffOpen: boolean; + runtimeError: string | null; } const PERSISTED_STATE_KEY = "codething:renderer-state:v4"; @@ -67,6 +60,7 @@ const initialState: AppState = { activeThreadId: null, runtimeMode: DEFAULT_RUNTIME_MODE, diffOpen: false, + runtimeError: null, }; // ── Helpers ────────────────────────────────────────────────────────── @@ -88,7 +82,7 @@ function readPersistedState(): AppState { ); if (!hydrated) return initialState; - return { ...hydrated, diffOpen: false }; + return { ...hydrated, diffOpen: false, runtimeError: null }; } catch { return initialState; } @@ -98,10 +92,7 @@ function persistState(state: AppState): void { if (typeof window === "undefined") return; try { - window.localStorage.setItem( - PERSISTED_STATE_KEY, - JSON.stringify(toPersistedState(state)), - ); + window.localStorage.setItem(PERSISTED_STATE_KEY, JSON.stringify(toPersistedState(state))); for (const legacyKey of LEGACY_PERSISTED_STATE_KEYS) { window.localStorage.removeItem(legacyKey); } @@ -145,10 +136,7 @@ function durationMs(startIso: string, endIso: string): number | undefined { return end - start; } -function updateTurnFields( - thread: Thread, - event: ProviderEvent, -): Partial { +function updateTurnFields(thread: Thread, event: ProviderEvent): Partial { if (event.method === "turn/started") { return { latestTurnId: getEventTurnId(event) ?? thread.latestTurnId, @@ -165,9 +153,7 @@ function updateTurnFields( ? thread.latestTurnStartedAt : undefined; const elapsed = - startedAt && startedAt.length > 0 - ? durationMs(startedAt, event.createdAt) - : undefined; + startedAt && startedAt.length > 0 ? durationMs(startedAt, event.createdAt) : undefined; return { latestTurnId: completedTurnId ?? thread.latestTurnId, @@ -244,17 +230,11 @@ export function reducer(state: AppState, action: Action): AppState { codexThreadId: t.codexThreadId ?? eventThreadId ?? null, error: threadMismatchError ?? - (event.kind === "error" && event.message - ? event.message - : t.error), + (event.kind === "error" && event.message ? event.message : t.error), }; })(), session: t.session ? evolveSession(t.session, event) : t.session, - messages: applyEventToMessages( - t.messages, - event, - activeAssistantItemRef, - ), + messages: applyEventToMessages(t.messages, event, activeAssistantItemRef), events: [event, ...t.events], ...updateTurnFields(t, event), })), @@ -328,6 +308,81 @@ export function reducer(state: AppState, action: Action): AppState { runtimeMode: action.mode, }; + case "SET_RUNTIME_ERROR": + return { + ...state, + runtimeError: action.error, + }; + + case "BOOTSTRAP_FROM_SERVER": { + const { bootstrap } = action; + const existingProject = state.projects.find((project) => project.cwd === bootstrap.launchCwd); + const projectId = existingProject?.id ?? crypto.randomUUID(); + const project = + existingProject ?? + ({ + id: projectId, + name: bootstrap.projectName, + cwd: bootstrap.launchCwd, + model: resolveModelSlug(bootstrap.model), + expanded: true, + } satisfies Project); + + const projectThreads = state.threads.filter((thread) => thread.projectId === projectId); + const existingThread = + state.threads.find((thread) => thread.session?.sessionId === bootstrap.session.sessionId) ?? + projectThreads.find( + (thread) => + bootstrap.session.threadId !== undefined && + thread.codexThreadId === bootstrap.session.threadId, + ) ?? + projectThreads[0]; + + const activeThreadId = existingThread?.id ?? crypto.randomUUID(); + const thread = + existingThread ?? + ({ + id: activeThreadId, + codexThreadId: bootstrap.session.threadId ?? null, + projectId, + title: "New thread", + model: resolveModelSlug(bootstrap.model), + session: bootstrap.session, + messages: [], + events: [], + error: bootstrap.bootstrapError ?? null, + createdAt: new Date().toISOString(), + } satisfies Thread); + + return { + ...state, + projects: existingProject + ? state.projects.map((entry) => + entry.id === existingProject.id + ? { + ...entry, + model: resolveModelSlug(bootstrap.model), + } + : entry, + ) + : [project, ...state.projects], + threads: state.threads + .map((entry) => + entry.id === thread.id + ? { + ...entry, + session: bootstrap.session, + codexThreadId: bootstrap.session.threadId ?? entry.codexThreadId, + error: bootstrap.bootstrapError ?? entry.error, + } + : entry, + ) + .concat(existingThread ? [] : [thread]), + activeThreadId, + runtimeError: null, + }; + } + default: return state; } diff --git a/apps/renderer/src/wsNativeApi.test.ts b/apps/renderer/src/wsNativeApi.test.ts new file mode 100644 index 00000000000..d8f41b59000 --- /dev/null +++ b/apps/renderer/src/wsNativeApi.test.ts @@ -0,0 +1,3709 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + WS_CLOSE_CODES, + WS_CLOSE_REASONS, + WS_ERROR_MESSAGE_MAX_CHARS, + WS_REQUEST_ID_MAX_CHARS, +} from "@acme/contracts"; + +type Listener = (event: unknown) => void; + +class MockWebSocket { + static OPEN = 1; + static instances: MockWebSocket[] = []; + static failSend = false; + static failSendError: unknown = new Error("mock send failure"); + static failOpen = false; + static failOpenEvent: unknown = { + message: "mock open failure", + }; + static failConstruct = false; + static failConstructError: unknown = new Error("mock constructor failure"); + static failCloseBeforeOpen = false; + static failCloseBeforeOpenEvent: { code?: number; reason?: string } = { + code: WS_CLOSE_CODES.unauthorized, + reason: WS_CLOSE_REASONS.unauthorized, + }; + + readyState = 0; + binaryType = "blob"; + sentMessages: string[] = []; + private listeners: Record = {}; + + constructor(readonly url: string) { + if (MockWebSocket.failConstruct) { + throw MockWebSocket.failConstructError; + } + MockWebSocket.instances.push(this); + queueMicrotask(() => { + if (MockWebSocket.failCloseBeforeOpen) { + this.readyState = 3; + this.emit("close", MockWebSocket.failCloseBeforeOpenEvent); + return; + } + if (MockWebSocket.failOpen) { + this.emit("error", MockWebSocket.failOpenEvent); + return; + } + this.readyState = MockWebSocket.OPEN; + this.emit("open", {}); + }); + } + + addEventListener(type: string, listener: Listener) { + const next = this.listeners[type] ?? []; + next.push(listener); + this.listeners[type] = next; + } + + send(data: string) { + if (MockWebSocket.failSend) { + throw MockWebSocket.failSendError; + } + + this.sentMessages.push(String(data)); + } + + close() { + this.readyState = 3; + this.emit("close", { code: 1000 }); + } + + closeWith(event: { code?: number; reason?: string }) { + this.readyState = 3; + this.emit("close", event); + } + + emitError(message = "mock socket error") { + this.emit("error", { message }); + } + + emitErrorEvent(event: unknown) { + this.emit("error", event); + } + + emitMessage(data: unknown) { + this.emit("message", { data }); + } + + private emit(type: string, event: unknown) { + const listeners = this.listeners[type] ?? []; + for (const listener of listeners) { + listener(event); + } + } +} + +function setWindowSearch(search: string) { + vi.stubGlobal("window", { + location: { + search, + }, + }); +} + +function waitForCondition(check: () => boolean, timeoutMs = 1_000) { + return new Promise((resolve, reject) => { + const startedAt = Date.now(); + const timer = setInterval(() => { + if (check()) { + clearInterval(timer); + resolve(); + return; + } + + if (Date.now() - startedAt >= timeoutMs) { + clearInterval(timer); + reject(new Error("Timed out waiting for test condition.")); + } + }, 10); + }); +} + +async function waitForSocket() { + await waitForCondition(() => MockWebSocket.instances.length > 0); + const socket = MockWebSocket.instances[0]; + if (!socket) { + throw new Error("Expected mock websocket instance."); + } + return socket; +} + +function nestedErrorPayload(depth: number, terminalMessage: string) { + let current: unknown = { message: terminalMessage }; + for (let index = 0; index < depth; index += 1) { + current = { error: current }; + } + return current; +} + +function nestedCausePayload(depth: number, terminalMessage: string) { + let current: unknown = { message: terminalMessage }; + for (let index = 0; index < depth; index += 1) { + current = { cause: current }; + } + return current; +} + +describe("wsNativeApi", () => { + beforeEach(() => { + vi.resetModules(); + MockWebSocket.instances = []; + MockWebSocket.failSend = false; + MockWebSocket.failSendError = new Error("mock send failure"); + MockWebSocket.failOpen = false; + MockWebSocket.failOpenEvent = { message: "mock open failure" }; + MockWebSocket.failConstruct = false; + MockWebSocket.failConstructError = new Error("mock constructor failure"); + MockWebSocket.failCloseBeforeOpen = false; + MockWebSocket.failCloseBeforeOpenEvent = { + code: WS_CLOSE_CODES.unauthorized, + reason: WS_CLOSE_REASONS.unauthorized, + }; + vi.stubGlobal("WebSocket", MockWebSocket as unknown as typeof WebSocket); + }); + + it("connects using ws query parameter and resolves responses", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4400%3Ftoken%3Dabc"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + const request = api.todos.list(); + + const socket = MockWebSocket.instances[0]; + await waitForCondition(() => (socket?.sentMessages.length ?? 0) > 0); + expect(socket?.url).toBe("ws://127.0.0.1:4400?token=abc"); + const requestEnvelope = JSON.parse(socket?.sentMessages[0] ?? "{}") as { + type: string; + id: string; + method: string; + }; + expect(requestEnvelope.type).toBe("request"); + expect(requestEnvelope.method).toBe("todos.list"); + + socket?.emitMessage( + JSON.stringify({ + type: "hello", + version: 1, + launchCwd: "/workspace", + }), + ); + socket?.emitMessage( + JSON.stringify({ + type: "response", + id: requestEnvelope.id, + ok: true, + result: [], + }), + ); + + await expect(request).resolves.toEqual([]); + }); + + it("rejects todos.list responses with invalid payload shape", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4525"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + const request = api.todos.list(); + const socket = MockWebSocket.instances[0]; + await waitForCondition(() => (socket?.sentMessages.length ?? 0) > 0); + const requestEnvelope = JSON.parse(socket?.sentMessages[0] ?? "{}") as { id: string }; + socket?.emitMessage( + JSON.stringify({ + type: "response", + id: requestEnvelope.id, + ok: true, + result: [ + { + id: "", + }, + ], + }), + ); + + await expect(request).rejects.toThrow( + "Runtime method 'todos.list' returned invalid response payload.", + ); + }); + + it("rejects todos.list responses with unexpected todo fields", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4537"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + const request = api.todos.list(); + const socket = MockWebSocket.instances[0]; + await waitForCondition(() => (socket?.sentMessages.length ?? 0) > 0); + const requestEnvelope = JSON.parse(socket?.sentMessages[0] ?? "{}") as { id: string }; + socket?.emitMessage( + JSON.stringify({ + type: "response", + id: requestEnvelope.id, + ok: true, + result: [ + { + id: "todo-1", + title: "Write tests", + completed: false, + createdAt: "2026-02-01T00:00:00.000Z", + unexpected: true, + }, + ], + }), + ); + + await expect(request).rejects.toThrow( + "Runtime method 'todos.list' returned invalid response payload.", + ); + }); + + it("configures websocket binaryType to arraybuffer", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4430"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + const request = api.todos.list(); + const socket = await waitForSocket(); + expect(socket.binaryType).toBe("arraybuffer"); + await waitForCondition(() => socket.sentMessages.length > 0); + const requestEnvelope = JSON.parse(socket.sentMessages[0] ?? "{}") as { + id: string; + }; + socket.emitMessage( + JSON.stringify({ + type: "response", + id: requestEnvelope.id, + ok: true, + result: [], + }), + ); + await expect(request).resolves.toEqual([]); + }); + + it("rejects immediately when websocket send throws", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4401"); + MockWebSocket.failSend = true; + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + await expect(api.todos.list()).rejects.toThrow( + "Failed to send runtime request 'todos.list': mock send failure", + ); + }); + + it("surfaces string send failure details when websocket send throws", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4502"); + MockWebSocket.failSend = true; + MockWebSocket.failSendError = "string-send-failure"; + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + await expect(api.todos.list()).rejects.toThrow( + "Failed to send runtime request 'todos.list': string-send-failure", + ); + }); + + it("surfaces nested send failure details when websocket send throws", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4518"); + MockWebSocket.failSend = true; + MockWebSocket.failSendError = { + error: { + message: "nested-send-failure", + }, + }; + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + await expect(api.todos.list()).rejects.toThrow( + "Failed to send runtime request 'todos.list': nested-send-failure", + ); + }); + + it("surfaces send failure details from cause chains when websocket send throws", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4525"); + MockWebSocket.failSend = true; + MockWebSocket.failSendError = { + cause: { + message: "cause-send-failure", + }, + }; + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + await expect(api.todos.list()).rejects.toThrow( + "Failed to send runtime request 'todos.list': cause-send-failure", + ); + }); + + it("falls back when send failure cause chain exceeds extraction depth", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4529"); + MockWebSocket.failSend = true; + MockWebSocket.failSendError = nestedCausePayload(12, "too-deep-cause-send-failure"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + await expect(api.todos.list()).rejects.toThrow( + "Failed to send runtime request 'todos.list': unknown websocket failure", + ); + }); + + it("falls back to unknown websocket failure when send throw has no message", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4519"); + MockWebSocket.failSend = true; + MockWebSocket.failSendError = { error: {} }; + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + await expect(api.todos.list()).rejects.toThrow( + "Failed to send runtime request 'todos.list': unknown websocket failure", + ); + }); + + it("falls back safely when send throw payload contains cyclic error references", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4520"); + MockWebSocket.failSend = true; + const cyclicError: { error?: unknown } = {}; + cyclicError.error = cyclicError; + MockWebSocket.failSendError = cyclicError; + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + await expect(api.todos.list()).rejects.toThrow( + "Failed to send runtime request 'todos.list': unknown websocket failure", + ); + }); + + it("recovers after a transient websocket send failure", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4480"); + MockWebSocket.failSend = true; + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + await expect(api.todos.list()).rejects.toThrow( + "Failed to send runtime request 'todos.list': mock send failure", + ); + + MockWebSocket.failSend = false; + const secondRequest = api.todos.list(); + await waitForCondition(() => MockWebSocket.instances.some((socket) => socket.sentMessages.length > 0)); + const socketWithMessage = [...MockWebSocket.instances] + .toReversed() + .find((socket) => socket.sentMessages.length > 0); + const requestEnvelope = JSON.parse(socketWithMessage?.sentMessages[0] ?? "{}") as { + id: string; + }; + socketWithMessage?.emitMessage( + JSON.stringify({ + type: "response", + id: requestEnvelope.id, + ok: true, + result: [], + }), + ); + + await expect(secondRequest).resolves.toEqual([]); + }); + + it("rejects existing pending requests when a later websocket send fails", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4490"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + const firstPending = api.todos.list(); + const socket = await waitForSocket(); + await waitForCondition(() => socket.sentMessages.length === 1); + + MockWebSocket.failSend = true; + const secondPending = api.app.health(); + await expect(secondPending).rejects.toThrow( + "Failed to send runtime request 'app.health': mock send failure", + ); + await expect(firstPending).rejects.toThrow("websocket errored (mock send failure)"); + + MockWebSocket.failSend = false; + const recoveryRequest = api.todos.list(); + await waitForCondition(() => MockWebSocket.instances.length >= 2); + const recoverySocket = MockWebSocket.instances[1]; + await waitForCondition(() => (recoverySocket?.sentMessages.length ?? 0) > 0); + const requestEnvelope = JSON.parse(recoverySocket?.sentMessages[0] ?? "{}") as { + id: string; + }; + recoverySocket?.emitMessage( + JSON.stringify({ + type: "response", + id: requestEnvelope.id, + ok: true, + result: [], + }), + ); + await expect(recoveryRequest).resolves.toEqual([]); + }); + + it("propagates string send-failure details to existing pending requests", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4506"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + const firstPending = api.todos.list(); + const socket = await waitForSocket(); + await waitForCondition(() => socket.sentMessages.length === 1); + + MockWebSocket.failSend = true; + MockWebSocket.failSendError = "later-string-send-failure"; + const secondPending = api.app.health(); + await expect(secondPending).rejects.toThrow( + "Failed to send runtime request 'app.health': later-string-send-failure", + ); + await expect(firstPending).rejects.toThrow("websocket errored (later-string-send-failure)"); + }); + + it("sends app.health requests to runtime", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4411"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + const request = api.app.health(); + const socket = MockWebSocket.instances[0]; + await waitForCondition(() => (socket?.sentMessages.length ?? 0) > 0); + const requestEnvelope = JSON.parse(socket?.sentMessages[0] ?? "{}") as { + id: string; + method: string; + }; + expect(requestEnvelope.method).toBe("app.health"); + + socket?.emitMessage( + JSON.stringify({ + type: "response", + id: requestEnvelope.id, + ok: true, + result: { + status: "ok", + launchCwd: "/workspace", + sessionCount: 0, + activeClientConnected: true, + }, + }), + ); + + await expect(request).resolves.toEqual({ + status: "ok", + launchCwd: "/workspace", + sessionCount: 0, + activeClientConnected: true, + }); + }); + + it("rejects app.health responses with invalid payload shape", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4521"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + const request = api.app.health(); + const socket = MockWebSocket.instances[0]; + await waitForCondition(() => (socket?.sentMessages.length ?? 0) > 0); + const requestEnvelope = JSON.parse(socket?.sentMessages[0] ?? "{}") as { id: string }; + socket?.emitMessage( + JSON.stringify({ + type: "response", + id: requestEnvelope.id, + ok: true, + result: { + status: "ok", + launchCwd: "/workspace", + sessionCount: -1, + activeClientConnected: true, + }, + }), + ); + + await expect(request).rejects.toThrow( + "Runtime method 'app.health' returned invalid response payload.", + ); + }); + + it("rejects app.health responses with unexpected payload fields", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4535"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + const request = api.app.health(); + const socket = MockWebSocket.instances[0]; + await waitForCondition(() => (socket?.sentMessages.length ?? 0) > 0); + const requestEnvelope = JSON.parse(socket?.sentMessages[0] ?? "{}") as { id: string }; + socket?.emitMessage( + JSON.stringify({ + type: "response", + id: requestEnvelope.id, + ok: true, + result: { + status: "ok", + launchCwd: "/workspace", + sessionCount: 0, + activeClientConnected: true, + unexpected: true, + }, + }), + ); + + await expect(request).rejects.toThrow( + "Runtime method 'app.health' returned invalid response payload.", + ); + }); + + it("sends app.bootstrap requests and returns payload", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4412"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + const request = api.app.bootstrap(); + const socket = MockWebSocket.instances[0]; + await waitForCondition(() => (socket?.sentMessages.length ?? 0) > 0); + const requestEnvelope = JSON.parse(socket?.sentMessages[0] ?? "{}") as { + id: string; + method: string; + }; + expect(requestEnvelope.method).toBe("app.bootstrap"); + + socket?.emitMessage( + JSON.stringify({ + type: "response", + id: requestEnvelope.id, + ok: true, + result: { + launchCwd: "/workspace", + projectName: "workspace", + provider: "codex", + model: "gpt-5-codex", + session: { + sessionId: "sess-1", + provider: "codex", + status: "ready", + cwd: "/workspace", + model: "gpt-5-codex", + createdAt: "2026-02-01T00:00:00.000Z", + updatedAt: "2026-02-01T00:00:00.000Z", + }, + }, + }), + ); + + await expect(request).resolves.toMatchObject({ + launchCwd: "/workspace", + provider: "codex", + session: { + sessionId: "sess-1", + }, + }); + }); + + it("preserves bootstrapError field in bootstrap payloads", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4420"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + const request = api.app.bootstrap(); + const socket = MockWebSocket.instances[0]; + await waitForCondition(() => (socket?.sentMessages.length ?? 0) > 0); + const requestEnvelope = JSON.parse(socket?.sentMessages[0] ?? "{}") as { + id: string; + }; + + socket?.emitMessage( + JSON.stringify({ + type: "response", + id: requestEnvelope.id, + ok: true, + result: { + launchCwd: "/workspace", + projectName: "workspace", + provider: "codex", + model: "gpt-5-codex", + session: { + sessionId: "bootstrap-error", + provider: "codex", + status: "error", + cwd: "/workspace", + model: "gpt-5-codex", + createdAt: "2026-02-01T00:00:00.000Z", + updatedAt: "2026-02-01T00:00:00.000Z", + lastError: "Timed out waiting for initialize.", + }, + bootstrapError: "Timed out waiting for initialize.", + }, + }), + ); + + await expect(request).resolves.toMatchObject({ + bootstrapError: "Timed out waiting for initialize.", + session: { + status: "error", + }, + }); + }); + + it("rejects app.bootstrap responses with unexpected payload fields", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4536"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + const request = api.app.bootstrap(); + const socket = MockWebSocket.instances[0]; + await waitForCondition(() => (socket?.sentMessages.length ?? 0) > 0); + const requestEnvelope = JSON.parse(socket?.sentMessages[0] ?? "{}") as { id: string }; + + socket?.emitMessage( + JSON.stringify({ + type: "response", + id: requestEnvelope.id, + ok: true, + result: { + launchCwd: "/workspace", + projectName: "workspace", + provider: "codex", + model: "gpt-5-codex", + session: { + sessionId: "sess-1", + provider: "codex", + status: "ready", + createdAt: "2026-02-01T00:00:00.000Z", + updatedAt: "2026-02-01T00:00:00.000Z", + }, + unexpected: true, + }, + }), + ); + + await expect(request).rejects.toThrow( + "Runtime method 'app.bootstrap' returned invalid response payload.", + ); + }); + + it("falls back to default local runtime URL when ws query is missing", async () => { + setWindowSearch(""); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + const request = api.todos.list(); + const socket = MockWebSocket.instances[0]; + await waitForCondition(() => (socket?.sentMessages.length ?? 0) > 0); + expect(socket?.url).toBe("ws://127.0.0.1:4317"); + + const requestEnvelope = JSON.parse(socket?.sentMessages[0] ?? "{}") as { + id: string; + }; + socket?.emitMessage( + JSON.stringify({ + type: "response", + id: requestEnvelope.id, + ok: true, + result: [], + }), + ); + + await expect(request).resolves.toEqual([]); + }); + + it("rejects request when runtime responds with structured error", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4402"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + const request = api.todos.list(); + const socket = MockWebSocket.instances[0]; + await waitForCondition(() => (socket?.sentMessages.length ?? 0) > 0); + const requestEnvelope = JSON.parse(socket?.sentMessages[0] ?? "{}") as { + id: string; + }; + + socket?.emitMessage( + JSON.stringify({ + type: "response", + id: requestEnvelope.id, + ok: false, + error: { + code: "request_failed", + message: "boom", + }, + }), + ); + + await expect(request).rejects.toThrow("boom"); + }); + + it("ignores malformed error responses and still resolves matching response", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4431"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + const request = api.todos.list(); + const socket = MockWebSocket.instances[0]; + await waitForCondition(() => (socket?.sentMessages.length ?? 0) > 0); + const requestEnvelope = JSON.parse(socket?.sentMessages[0] ?? "{}") as { + id: string; + }; + + socket?.emitMessage( + JSON.stringify({ + type: "response", + id: requestEnvelope.id, + ok: false, + error: { + code: "request_failed", + }, + }), + ); + socket?.emitMessage( + JSON.stringify({ + type: "response", + id: requestEnvelope.id, + ok: true, + result: [], + }), + ); + + await expect(request).resolves.toEqual([]); + }); + + it("rejects pending requests when websocket disconnects", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4403"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + const request = api.todos.list(); + const socket = MockWebSocket.instances[0]; + await waitForCondition(() => (socket?.sentMessages.length ?? 0) > 0); + socket?.close(); + + await expect(request).rejects.toThrow("websocket disconnected (code 1000)"); + }); + + it("rejects pending requests when websocket errors after opening", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4469"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + const request = api.todos.list(); + const socket = MockWebSocket.instances[0]; + await waitForCondition(() => (socket?.sentMessages.length ?? 0) > 0); + socket?.emitError("forced-socket-error"); + + await expect(request).rejects.toThrow("websocket errored (forced-socket-error)"); + }); + + it("uses nested websocket error message when present for pending requests", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4488"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + const request = api.todos.list(); + const socket = MockWebSocket.instances[0]; + await waitForCondition(() => (socket?.sentMessages.length ?? 0) > 0); + socket?.emitErrorEvent({ + error: { + message: "nested-socket-error", + }, + }); + + await expect(request).rejects.toThrow("websocket errored (nested-socket-error)"); + }); + + it("uses string websocket error payload when present for pending requests", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4499"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + const request = api.todos.list(); + const socket = MockWebSocket.instances[0]; + await waitForCondition(() => (socket?.sentMessages.length ?? 0) > 0); + socket?.emitErrorEvent("string-socket-error"); + + await expect(request).rejects.toThrow("websocket errored (string-socket-error)"); + }); + + it("uses nested string websocket error payload for pending requests", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4510"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + const request = api.todos.list(); + const socket = MockWebSocket.instances[0]; + await waitForCondition(() => (socket?.sentMessages.length ?? 0) > 0); + socket?.emitErrorEvent({ + error: "nested-string-socket-error", + }); + + await expect(request).rejects.toThrow("websocket errored (nested-string-socket-error)"); + }); + + it("uses deeply nested websocket error payload for pending requests", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4515"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + const request = api.todos.list(); + const socket = MockWebSocket.instances[0]; + await waitForCondition(() => (socket?.sentMessages.length ?? 0) > 0); + socket?.emitErrorEvent({ + error: { + error: { + message: "deep-socket-error", + }, + }, + }); + + await expect(request).rejects.toThrow("websocket errored (deep-socket-error)"); + }); + + it("uses websocket error payload messages from cause chains for pending requests", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4526"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + const request = api.todos.list(); + const socket = MockWebSocket.instances[0]; + await waitForCondition(() => (socket?.sentMessages.length ?? 0) > 0); + socket?.emitErrorEvent({ + cause: { + message: "cause-socket-error", + }, + }); + + await expect(request).rejects.toThrow("websocket errored (cause-socket-error)"); + }); + + it("falls back when websocket error cause chain exceeds extraction depth", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4530"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + const request = api.todos.list(); + const socket = MockWebSocket.instances[0]; + await waitForCondition(() => (socket?.sentMessages.length ?? 0) > 0); + socket?.emitErrorEvent(nestedCausePayload(12, "too-deep-cause-socket-error")); + + await expect(request).rejects.toThrow("websocket errored."); + }); + + it("extracts websocket error payload messages nested beyond five levels", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4522"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + const request = api.todos.list(); + const socket = MockWebSocket.instances[0]; + await waitForCondition(() => (socket?.sentMessages.length ?? 0) > 0); + socket?.emitErrorEvent(nestedErrorPayload(6, "very-deep-socket-error")); + + await expect(request).rejects.toThrow("websocket errored (very-deep-socket-error)"); + }); + + it("falls back when websocket error payload message exceeds extraction depth", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4523"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + const request = api.todos.list(); + const socket = MockWebSocket.instances[0]; + await waitForCondition(() => (socket?.sentMessages.length ?? 0) > 0); + socket?.emitErrorEvent(nestedErrorPayload(12, "too-deep-socket-error")); + + await expect(request).rejects.toThrow("websocket errored."); + }); + + it("rejects all concurrent pending requests on websocket error and then reconnects", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4475"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + const firstPending = api.todos.list(); + const secondPending = api.app.health(); + const socket = MockWebSocket.instances[0]; + await waitForCondition(() => (socket?.sentMessages.length ?? 0) >= 2); + socket?.emitError("forced-concurrent-socket-error"); + + await expect(firstPending).rejects.toThrow("websocket errored (forced-concurrent-socket-error)"); + await expect(secondPending).rejects.toThrow("websocket errored (forced-concurrent-socket-error)"); + + const recoveryRequest = api.todos.list(); + await waitForCondition(() => MockWebSocket.instances.length >= 2); + const recoverySocket = MockWebSocket.instances[1]; + await waitForCondition(() => (recoverySocket?.sentMessages.length ?? 0) > 0); + const requestEnvelope = JSON.parse(recoverySocket?.sentMessages[0] ?? "{}") as { + id: string; + }; + recoverySocket?.emitMessage( + JSON.stringify({ + type: "response", + id: requestEnvelope.id, + ok: true, + result: [], + }), + ); + await expect(recoveryRequest).resolves.toEqual([]); + }); + + it("falls back to generic message when websocket errors without message", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4473"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + const request = api.todos.list(); + const socket = MockWebSocket.instances[0]; + await waitForCondition(() => (socket?.sentMessages.length ?? 0) > 0); + socket?.emitErrorEvent({}); + + await expect(request).rejects.toThrow("websocket errored."); + }); + + it("falls back to generic message when websocket errors with whitespace-only message", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4491"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + const request = api.todos.list(); + const socket = MockWebSocket.instances[0]; + await waitForCondition(() => (socket?.sentMessages.length ?? 0) > 0); + socket?.emitErrorEvent({ message: " " }); + + await expect(request).rejects.toThrow("websocket errored."); + }); + + it("includes close reason details when pending request disconnects", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4450"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + const request = api.todos.list(); + const socket = MockWebSocket.instances[0]; + await waitForCondition(() => (socket?.sentMessages.length ?? 0) > 0); + socket?.closeWith({ + code: WS_CLOSE_CODES.unauthorized, + reason: WS_CLOSE_REASONS.unauthorized, + }); + + await expect(request).rejects.toThrow("websocket disconnected (unauthorized)"); + }); + + it("includes replacement details when pending request disconnects", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4453"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + const request = api.todos.list(); + const socket = MockWebSocket.instances[0]; + await waitForCondition(() => (socket?.sentMessages.length ?? 0) > 0); + socket?.closeWith({ + code: WS_CLOSE_CODES.replacedByNewClient, + reason: WS_CLOSE_REASONS.replacedByNewClient, + }); + + await expect(request).rejects.toThrow("websocket disconnected (replaced-by-new-client)"); + }); + + it("includes generic close code and reason details when pending request disconnects", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4454"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + const request = api.todos.list(); + const socket = MockWebSocket.instances[0]; + await waitForCondition(() => (socket?.sentMessages.length ?? 0) > 0); + socket?.closeWith({ + code: 4200, + reason: "custom-close", + }); + + await expect(request).rejects.toThrow("websocket disconnected (code 4200: custom-close)"); + }); + + it("includes generic close code details when reason is missing on disconnect", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4457"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + const request = api.todos.list(); + const socket = MockWebSocket.instances[0]; + await waitForCondition(() => (socket?.sentMessages.length ?? 0) > 0); + socket?.closeWith({ code: 4201 }); + + await expect(request).rejects.toThrow("websocket disconnected (code 4201)"); + }); + + it("ignores non-integer close code values on disconnect diagnostics", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4503"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + const request = api.todos.list(); + const socket = MockWebSocket.instances[0]; + await waitForCondition(() => (socket?.sentMessages.length ?? 0) > 0); + socket?.closeWith({ code: 4200.5, reason: "float-close-code" }); + + await expect(request).rejects.toThrow("websocket disconnected (reason: float-close-code)"); + }); + + it("includes generic close reason details when code is missing on disconnect", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4459"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + const request = api.todos.list(); + const socket = MockWebSocket.instances[0]; + await waitForCondition(() => (socket?.sentMessages.length ?? 0) > 0); + socket?.closeWith({ reason: "custom-reason-only" }); + + await expect(request).rejects.toThrow("websocket disconnected (reason: custom-reason-only)"); + }); + + it("trims generic close reason details when code is missing on disconnect", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4513"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + const request = api.todos.list(); + const socket = MockWebSocket.instances[0]; + await waitForCondition(() => (socket?.sentMessages.length ?? 0) > 0); + socket?.closeWith({ reason: " custom-reason-only " }); + + await expect(request).rejects.toThrow("websocket disconnected (reason: custom-reason-only)"); + }); + + it("falls back to generic disconnect message when close reason is whitespace-only", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4495"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + const request = api.todos.list(); + const socket = MockWebSocket.instances[0]; + await waitForCondition(() => (socket?.sentMessages.length ?? 0) > 0); + socket?.closeWith({ reason: " " }); + + await expect(request).rejects.toThrow("websocket disconnected."); + }); + + it("uses trimmed close reason for semantic unauthorized disconnect mapping", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4496"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + const request = api.todos.list(); + const socket = MockWebSocket.instances[0]; + await waitForCondition(() => (socket?.sentMessages.length ?? 0) > 0); + socket?.closeWith({ reason: ` ${WS_CLOSE_REASONS.unauthorized} ` }); + + await expect(request).rejects.toThrow("websocket disconnected (unauthorized)"); + }); + + it("maps unauthorized reason-only disconnects to explicit unauthorized errors", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4461"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + const request = api.todos.list(); + const socket = MockWebSocket.instances[0]; + await waitForCondition(() => (socket?.sentMessages.length ?? 0) > 0); + socket?.closeWith({ reason: WS_CLOSE_REASONS.unauthorized }); + + await expect(request).rejects.toThrow("websocket disconnected (unauthorized)"); + }); + + it("prioritizes unauthorized reason over non-auth close code on disconnect", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4482"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + const request = api.todos.list(); + const socket = MockWebSocket.instances[0]; + await waitForCondition(() => (socket?.sentMessages.length ?? 0) > 0); + socket?.closeWith({ code: 1000, reason: WS_CLOSE_REASONS.unauthorized }); + + await expect(request).rejects.toThrow("websocket disconnected (unauthorized)"); + }); + + it("prioritizes unauthorized code over non-auth reason on disconnect", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4486"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + const request = api.todos.list(); + const socket = MockWebSocket.instances[0]; + await waitForCondition(() => (socket?.sentMessages.length ?? 0) > 0); + socket?.closeWith({ code: WS_CLOSE_CODES.unauthorized, reason: "not-unauthorized-reason" }); + + await expect(request).rejects.toThrow("websocket disconnected (unauthorized)"); + }); + + it("maps replacement reason-only disconnects to explicit replacement errors", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4462"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + const request = api.todos.list(); + const socket = MockWebSocket.instances[0]; + await waitForCondition(() => (socket?.sentMessages.length ?? 0) > 0); + socket?.closeWith({ reason: WS_CLOSE_REASONS.replacedByNewClient }); + + await expect(request).rejects.toThrow("websocket disconnected (replaced-by-new-client)"); + }); + + it("prioritizes replacement code over non-replacement reason on disconnect", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4487"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + const request = api.todos.list(); + const socket = MockWebSocket.instances[0]; + await waitForCondition(() => (socket?.sentMessages.length ?? 0) > 0); + socket?.closeWith({ + code: WS_CLOSE_CODES.replacedByNewClient, + reason: "not-replacement-reason", + }); + + await expect(request).rejects.toThrow("websocket disconnected (replaced-by-new-client)"); + }); + + it("reconnects after pending request is rejected by unauthorized disconnect", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4467"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + const rejectedRequest = api.todos.list(); + const firstSocket = MockWebSocket.instances[0]; + await waitForCondition(() => (firstSocket?.sentMessages.length ?? 0) > 0); + firstSocket?.closeWith({ + code: WS_CLOSE_CODES.unauthorized, + reason: WS_CLOSE_REASONS.unauthorized, + }); + await expect(rejectedRequest).rejects.toThrow("websocket disconnected (unauthorized)"); + + const secondRequest = api.todos.list(); + await waitForCondition(() => MockWebSocket.instances.length >= 2); + const secondSocket = MockWebSocket.instances[1]; + await waitForCondition(() => (secondSocket?.sentMessages.length ?? 0) > 0); + const secondEnvelope = JSON.parse(secondSocket?.sentMessages[0] ?? "{}") as { + id: string; + }; + secondSocket?.emitMessage( + JSON.stringify({ + type: "response", + id: secondEnvelope.id, + ok: true, + result: [], + }), + ); + + await expect(secondRequest).resolves.toEqual([]); + }); + + it("rejects all concurrent pending requests on unauthorized disconnect and then reconnects", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4468"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + const firstPending = api.todos.list(); + const secondPending = api.app.health(); + const socket = MockWebSocket.instances[0]; + await waitForCondition(() => (socket?.sentMessages.length ?? 0) >= 2); + socket?.closeWith({ + code: WS_CLOSE_CODES.unauthorized, + reason: WS_CLOSE_REASONS.unauthorized, + }); + + await expect(firstPending).rejects.toThrow("websocket disconnected (unauthorized)"); + await expect(secondPending).rejects.toThrow("websocket disconnected (unauthorized)"); + + const recoveryRequest = api.todos.list(); + await waitForCondition(() => MockWebSocket.instances.length >= 2); + const recoverySocket = MockWebSocket.instances[1]; + await waitForCondition(() => (recoverySocket?.sentMessages.length ?? 0) > 0); + const requestEnvelope = JSON.parse(recoverySocket?.sentMessages[0] ?? "{}") as { + id: string; + }; + recoverySocket?.emitMessage( + JSON.stringify({ + type: "response", + id: requestEnvelope.id, + ok: true, + result: [], + }), + ); + await expect(recoveryRequest).resolves.toEqual([]); + }); + + it("falls back to generic disconnect message when close code is missing", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4455"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + const request = api.todos.list(); + const socket = MockWebSocket.instances[0]; + await waitForCondition(() => (socket?.sentMessages.length ?? 0) > 0); + socket?.closeWith({}); + + await expect(request).rejects.toThrow("websocket disconnected."); + }); + + it("reconnects on subsequent requests after websocket close", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4428"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + const firstRequest = api.todos.list(); + const firstSocket = await waitForSocket(); + await waitForCondition(() => firstSocket.sentMessages.length > 0); + const firstEnvelope = JSON.parse(firstSocket.sentMessages[0] ?? "{}") as { + id: string; + }; + firstSocket.emitMessage( + JSON.stringify({ + type: "response", + id: firstEnvelope.id, + ok: true, + result: [], + }), + ); + await expect(firstRequest).resolves.toEqual([]); + + firstSocket.close(); + await waitForCondition(() => MockWebSocket.instances.length >= 1); + + const secondRequest = api.todos.list(); + await waitForCondition(() => MockWebSocket.instances.length >= 2); + const secondSocket = MockWebSocket.instances[1]; + await waitForCondition(() => (secondSocket?.sentMessages.length ?? 0) > 0); + const secondEnvelope = JSON.parse(secondSocket?.sentMessages[0] ?? "{}") as { + id: string; + }; + secondSocket?.emitMessage( + JSON.stringify({ + type: "response", + id: secondEnvelope.id, + ok: true, + result: [], + }), + ); + + await expect(secondRequest).resolves.toEqual([]); + }); + + it("reconnects on subsequent requests after websocket error", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4470"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + const firstRequest = api.todos.list(); + const firstSocket = await waitForSocket(); + await waitForCondition(() => firstSocket.sentMessages.length > 0); + firstSocket.emitError("forced-socket-error"); + await expect(firstRequest).rejects.toThrow("websocket errored (forced-socket-error)"); + + const secondRequest = api.todos.list(); + await waitForCondition(() => MockWebSocket.instances.length >= 2); + const secondSocket = MockWebSocket.instances[1]; + await waitForCondition(() => (secondSocket?.sentMessages.length ?? 0) > 0); + const secondEnvelope = JSON.parse(secondSocket?.sentMessages[0] ?? "{}") as { + id: string; + }; + secondSocket?.emitMessage( + JSON.stringify({ + type: "response", + id: secondEnvelope.id, + ok: true, + result: [], + }), + ); + + await expect(secondRequest).resolves.toEqual([]); + }); + + it("reconnects after websocket error even when no requests are pending", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4476"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + const firstRequest = api.todos.list(); + const firstSocket = await waitForSocket(); + await waitForCondition(() => firstSocket.sentMessages.length > 0); + const firstEnvelope = JSON.parse(firstSocket.sentMessages[0] ?? "{}") as { + id: string; + }; + firstSocket.emitMessage( + JSON.stringify({ + type: "response", + id: firstEnvelope.id, + ok: true, + result: [], + }), + ); + await expect(firstRequest).resolves.toEqual([]); + + firstSocket.emitError("post-idle-socket-error"); + + const secondRequest = api.todos.list(); + await waitForCondition(() => MockWebSocket.instances.length >= 2); + const secondSocket = MockWebSocket.instances[1]; + await waitForCondition(() => (secondSocket?.sentMessages.length ?? 0) > 0); + const secondEnvelope = JSON.parse(secondSocket?.sentMessages[0] ?? "{}") as { + id: string; + }; + secondSocket?.emitMessage( + JSON.stringify({ + type: "response", + id: secondEnvelope.id, + ok: true, + result: [], + }), + ); + + await expect(secondRequest).resolves.toEqual([]); + }); + + it("reconnects on subsequent requests after unauthorized websocket close", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4465"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + const firstRequest = api.todos.list(); + const firstSocket = await waitForSocket(); + await waitForCondition(() => firstSocket.sentMessages.length > 0); + const firstEnvelope = JSON.parse(firstSocket.sentMessages[0] ?? "{}") as { + id: string; + }; + firstSocket.emitMessage( + JSON.stringify({ + type: "response", + id: firstEnvelope.id, + ok: true, + result: [], + }), + ); + await expect(firstRequest).resolves.toEqual([]); + + firstSocket.closeWith({ + code: WS_CLOSE_CODES.unauthorized, + reason: WS_CLOSE_REASONS.unauthorized, + }); + + const secondRequest = api.todos.list(); + await waitForCondition(() => MockWebSocket.instances.length >= 2); + const secondSocket = MockWebSocket.instances[1]; + await waitForCondition(() => (secondSocket?.sentMessages.length ?? 0) > 0); + const secondEnvelope = JSON.parse(secondSocket?.sentMessages[0] ?? "{}") as { + id: string; + }; + secondSocket?.emitMessage( + JSON.stringify({ + type: "response", + id: secondEnvelope.id, + ok: true, + result: [], + }), + ); + + await expect(secondRequest).resolves.toEqual([]); + }); + + it("reconnects on subsequent requests after replacement websocket close", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4466"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + const firstRequest = api.todos.list(); + const firstSocket = await waitForSocket(); + await waitForCondition(() => firstSocket.sentMessages.length > 0); + const firstEnvelope = JSON.parse(firstSocket.sentMessages[0] ?? "{}") as { + id: string; + }; + firstSocket.emitMessage( + JSON.stringify({ + type: "response", + id: firstEnvelope.id, + ok: true, + result: [], + }), + ); + await expect(firstRequest).resolves.toEqual([]); + + firstSocket.closeWith({ + code: WS_CLOSE_CODES.replacedByNewClient, + reason: WS_CLOSE_REASONS.replacedByNewClient, + }); + + const secondRequest = api.todos.list(); + await waitForCondition(() => MockWebSocket.instances.length >= 2); + const secondSocket = MockWebSocket.instances[1]; + await waitForCondition(() => (secondSocket?.sentMessages.length ?? 0) > 0); + const secondEnvelope = JSON.parse(secondSocket?.sentMessages[0] ?? "{}") as { + id: string; + }; + secondSocket?.emitMessage( + JSON.stringify({ + type: "response", + id: secondEnvelope.id, + ok: true, + result: [], + }), + ); + + await expect(secondRequest).resolves.toEqual([]); + }); + + it("ignores stale close events from prior sockets after reconnect", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4471"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + const firstRequest = api.todos.list(); + const firstSocket = await waitForSocket(); + await waitForCondition(() => firstSocket.sentMessages.length > 0); + const firstEnvelope = JSON.parse(firstSocket.sentMessages[0] ?? "{}") as { + id: string; + }; + firstSocket.emitMessage( + JSON.stringify({ + type: "response", + id: firstEnvelope.id, + ok: true, + result: [], + }), + ); + await expect(firstRequest).resolves.toEqual([]); + + firstSocket.closeWith({ + code: WS_CLOSE_CODES.unauthorized, + reason: WS_CLOSE_REASONS.unauthorized, + }); + + const secondRequest = api.todos.list(); + await waitForCondition(() => MockWebSocket.instances.length >= 2); + const secondSocket = MockWebSocket.instances[1]; + await waitForCondition(() => (secondSocket?.sentMessages.length ?? 0) > 0); + const secondEnvelope = JSON.parse(secondSocket?.sentMessages[0] ?? "{}") as { + id: string; + }; + + firstSocket.closeWith({ code: 1000 }); + + secondSocket?.emitMessage( + JSON.stringify({ + type: "response", + id: secondEnvelope.id, + ok: true, + result: [], + }), + ); + + await expect(secondRequest).resolves.toEqual([]); + }); + + it("ignores stale error events from prior sockets after reconnect", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4474"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + const firstRequest = api.todos.list(); + const firstSocket = await waitForSocket(); + await waitForCondition(() => firstSocket.sentMessages.length > 0); + const firstEnvelope = JSON.parse(firstSocket.sentMessages[0] ?? "{}") as { + id: string; + }; + firstSocket.emitMessage( + JSON.stringify({ + type: "response", + id: firstEnvelope.id, + ok: true, + result: [], + }), + ); + await expect(firstRequest).resolves.toEqual([]); + + firstSocket.closeWith({ + code: WS_CLOSE_CODES.unauthorized, + reason: WS_CLOSE_REASONS.unauthorized, + }); + + const secondRequest = api.todos.list(); + await waitForCondition(() => MockWebSocket.instances.length >= 2); + const secondSocket = MockWebSocket.instances[1]; + await waitForCondition(() => (secondSocket?.sentMessages.length ?? 0) > 0); + const secondEnvelope = JSON.parse(secondSocket?.sentMessages[0] ?? "{}") as { + id: string; + }; + + firstSocket.emitError("stale-socket-error"); + + secondSocket?.emitMessage( + JSON.stringify({ + type: "response", + id: secondEnvelope.id, + ok: true, + result: [], + }), + ); + + await expect(secondRequest).resolves.toEqual([]); + }); + + it("ignores stale message events from prior sockets after reconnect", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4477"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + const firstRequest = api.todos.list(); + const firstSocket = await waitForSocket(); + await waitForCondition(() => firstSocket.sentMessages.length > 0); + const firstEnvelope = JSON.parse(firstSocket.sentMessages[0] ?? "{}") as { + id: string; + }; + firstSocket.emitMessage( + JSON.stringify({ + type: "response", + id: firstEnvelope.id, + ok: true, + result: [], + }), + ); + await expect(firstRequest).resolves.toEqual([]); + + firstSocket.closeWith({ + code: WS_CLOSE_CODES.unauthorized, + reason: WS_CLOSE_REASONS.unauthorized, + }); + + const secondRequest = api.todos.list(); + await waitForCondition(() => MockWebSocket.instances.length >= 2); + const secondSocket = MockWebSocket.instances[1]; + await waitForCondition(() => (secondSocket?.sentMessages.length ?? 0) > 0); + const secondEnvelope = JSON.parse(secondSocket?.sentMessages[0] ?? "{}") as { + id: string; + }; + + firstSocket.emitMessage( + JSON.stringify({ + type: "response", + id: secondEnvelope.id, + ok: true, + result: ["stale-result"], + }), + ); + + secondSocket?.emitMessage( + JSON.stringify({ + type: "response", + id: secondEnvelope.id, + ok: true, + result: [], + }), + ); + + await expect(secondRequest).resolves.toEqual([]); + }); + + it("rejects requests when runtime does not respond before timeout", async () => { + vi.useFakeTimers(); + try { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4423"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + const request = api.todos.list(); + await Promise.resolve(); + await Promise.resolve(); + + vi.advanceTimersByTime(30_001); + await expect(request).rejects.toThrow("Request timed out for method 'todos.list'."); + } finally { + vi.useRealTimers(); + } + }); + + it("continues processing new requests after a timeout", async () => { + vi.useFakeTimers(); + try { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4424"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + const firstRequest = api.todos.list(); + await Promise.resolve(); + await Promise.resolve(); + + vi.advanceTimersByTime(30_001); + await expect(firstRequest).rejects.toThrow("Request timed out for method 'todos.list'."); + + const socket = MockWebSocket.instances[0]; + const secondRequest = api.todos.list(); + await Promise.resolve(); + await Promise.resolve(); + const secondEnvelope = JSON.parse(socket?.sentMessages.at(-1) ?? "{}") as { + id: string; + }; + socket?.emitMessage( + JSON.stringify({ + type: "response", + id: secondEnvelope.id, + ok: true, + result: [], + }), + ); + + await expect(secondRequest).resolves.toEqual([]); + } finally { + vi.useRealTimers(); + } + }); + + it("returns a stable cached native API instance", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4404"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + + const first = getOrCreateWsNativeApi(); + const second = getOrCreateWsNativeApi(); + + expect(second).toBe(first); + }); + + it("sends shell.openInEditor requests with expected payload", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4413"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + const request = api.shell.openInEditor("/workspace", "cursor"); + const socket = MockWebSocket.instances[0]; + await waitForCondition(() => (socket?.sentMessages.length ?? 0) > 0); + const requestEnvelope = JSON.parse(socket?.sentMessages[0] ?? "{}") as { + id: string; + method: string; + params: { cwd: string; editor: string }; + }; + expect(requestEnvelope.method).toBe("shell.openInEditor"); + expect(requestEnvelope.params).toEqual({ cwd: "/workspace", editor: "cursor" }); + + socket?.emitMessage( + JSON.stringify({ + type: "response", + id: requestEnvelope.id, + ok: true, + result: null, + }), + ); + await expect(request).resolves.toBeUndefined(); + }); + + it("rejects shell.openInEditor responses with invalid payload shape", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4532"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + const request = api.shell.openInEditor("/workspace", "cursor"); + const socket = MockWebSocket.instances[0]; + await waitForCondition(() => (socket?.sentMessages.length ?? 0) > 0); + const requestEnvelope = JSON.parse(socket?.sentMessages[0] ?? "{}") as { id: string }; + socket?.emitMessage( + JSON.stringify({ + type: "response", + id: requestEnvelope.id, + ok: true, + result: { + unexpected: true, + }, + }), + ); + + await expect(request).rejects.toThrow( + "Runtime method 'shell.openInEditor' returned invalid response payload.", + ); + }); + + it("sends terminal.run requests with expected payload", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4414"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + const request = api.terminal.run({ + command: "pwd", + cwd: "/workspace", + timeoutMs: 5_000, + }); + const socket = MockWebSocket.instances[0]; + await waitForCondition(() => (socket?.sentMessages.length ?? 0) > 0); + const requestEnvelope = JSON.parse(socket?.sentMessages[0] ?? "{}") as { + id: string; + method: string; + params: { command: string; cwd: string; timeoutMs: number }; + }; + expect(requestEnvelope.method).toBe("terminal.run"); + expect(requestEnvelope.params).toEqual({ + command: "pwd", + cwd: "/workspace", + timeoutMs: 5_000, + }); + + socket?.emitMessage( + JSON.stringify({ + type: "response", + id: requestEnvelope.id, + ok: true, + result: { + stdout: "/workspace\n", + stderr: "", + code: 0, + signal: null, + timedOut: false, + }, + }), + ); + + await expect(request).resolves.toMatchObject({ + stdout: "/workspace\n", + code: 0, + timedOut: false, + }); + }); + + it("rejects terminal.run responses with invalid payload shape", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4526"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + const request = api.terminal.run({ + command: "pwd", + cwd: "/workspace", + }); + const socket = MockWebSocket.instances[0]; + await waitForCondition(() => (socket?.sentMessages.length ?? 0) > 0); + const requestEnvelope = JSON.parse(socket?.sentMessages[0] ?? "{}") as { id: string }; + socket?.emitMessage( + JSON.stringify({ + type: "response", + id: requestEnvelope.id, + ok: true, + result: { + stdout: "/workspace\n", + stderr: "", + code: "0", + signal: null, + timedOut: false, + }, + }), + ); + + await expect(request).rejects.toThrow( + "Runtime method 'terminal.run' returned invalid response payload.", + ); + }); + + it("rejects terminal.run responses with unexpected result fields", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4538"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + const request = api.terminal.run({ + command: "pwd", + cwd: "/workspace", + }); + const socket = MockWebSocket.instances[0]; + await waitForCondition(() => (socket?.sentMessages.length ?? 0) > 0); + const requestEnvelope = JSON.parse(socket?.sentMessages[0] ?? "{}") as { id: string }; + socket?.emitMessage( + JSON.stringify({ + type: "response", + id: requestEnvelope.id, + ok: true, + result: { + stdout: "/workspace\n", + stderr: "", + code: 0, + signal: null, + timedOut: false, + unexpected: true, + }, + }), + ); + + await expect(request).rejects.toThrow( + "Runtime method 'terminal.run' returned invalid response payload.", + ); + }); + + it("sends dialogs.pickFolder requests and resolves value", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4417"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + const request = api.dialogs.pickFolder(); + const socket = MockWebSocket.instances[0]; + await waitForCondition(() => (socket?.sentMessages.length ?? 0) > 0); + const requestEnvelope = JSON.parse(socket?.sentMessages[0] ?? "{}") as { + id: string; + method: string; + }; + expect(requestEnvelope.method).toBe("dialogs.pickFolder"); + + socket?.emitMessage( + JSON.stringify({ + type: "response", + id: requestEnvelope.id, + ok: true, + result: "/workspace", + }), + ); + await expect(request).resolves.toBe("/workspace"); + }); + + it("rejects dialogs.pickFolder responses with invalid payload shape", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4522"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + const request = api.dialogs.pickFolder(); + const socket = MockWebSocket.instances[0]; + await waitForCondition(() => (socket?.sentMessages.length ?? 0) > 0); + const requestEnvelope = JSON.parse(socket?.sentMessages[0] ?? "{}") as { id: string }; + socket?.emitMessage( + JSON.stringify({ + type: "response", + id: requestEnvelope.id, + ok: true, + result: 123, + }), + ); + + await expect(request).rejects.toThrow( + "Runtime method 'dialogs.pickFolder' returned invalid response payload.", + ); + }); + + it("sends providers.listSessions requests and resolves payload", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4415"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + const request = api.providers.listSessions(); + const socket = MockWebSocket.instances[0]; + await waitForCondition(() => (socket?.sentMessages.length ?? 0) > 0); + const requestEnvelope = JSON.parse(socket?.sentMessages[0] ?? "{}") as { + id: string; + method: string; + }; + expect(requestEnvelope.method).toBe("providers.listSessions"); + + socket?.emitMessage( + JSON.stringify({ + type: "response", + id: requestEnvelope.id, + ok: true, + result: [ + { + sessionId: "sess-1", + provider: "codex", + status: "ready", + cwd: "/workspace", + model: "gpt-5-codex", + createdAt: "2026-02-01T00:00:00.000Z", + updatedAt: "2026-02-01T00:00:00.000Z", + }, + ], + }), + ); + + await expect(request).resolves.toMatchObject([ + { + sessionId: "sess-1", + provider: "codex", + }, + ]); + }); + + it("rejects providers.listSessions responses with invalid payload shape", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4523"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + const request = api.providers.listSessions(); + const socket = MockWebSocket.instances[0]; + await waitForCondition(() => (socket?.sentMessages.length ?? 0) > 0); + const requestEnvelope = JSON.parse(socket?.sentMessages[0] ?? "{}") as { id: string }; + socket?.emitMessage( + JSON.stringify({ + type: "response", + id: requestEnvelope.id, + ok: true, + result: [ + { + sessionId: "", + }, + ], + }), + ); + + await expect(request).rejects.toThrow( + "Runtime method 'providers.listSessions' returned invalid response payload.", + ); + }); + + it("rejects providers.listSessions responses with unexpected session fields", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4539"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + const request = api.providers.listSessions(); + const socket = MockWebSocket.instances[0]; + await waitForCondition(() => (socket?.sentMessages.length ?? 0) > 0); + const requestEnvelope = JSON.parse(socket?.sentMessages[0] ?? "{}") as { id: string }; + socket?.emitMessage( + JSON.stringify({ + type: "response", + id: requestEnvelope.id, + ok: true, + result: [ + { + sessionId: "sess-1", + provider: "codex", + status: "ready", + cwd: "/workspace", + model: "gpt-5-codex", + createdAt: "2026-02-01T00:00:00.000Z", + updatedAt: "2026-02-01T00:00:00.000Z", + unexpected: true, + }, + ], + }), + ); + + await expect(request).rejects.toThrow( + "Runtime method 'providers.listSessions' returned invalid response payload.", + ); + }); + + it("sends provider turn-control requests with expected payloads", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4419"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + const startRequest = api.providers.startSession({ + provider: "codex", + cwd: "/workspace", + model: "gpt-5-codex", + approvalPolicy: "never", + sandboxMode: "danger-full-access", + }); + const socket = await waitForSocket(); + await waitForCondition(() => (socket?.sentMessages.length ?? 0) >= 1); + const startEnvelope = JSON.parse(socket?.sentMessages[0] ?? "{}") as { + id: string; + method: string; + params: { provider: string; cwd: string; model: string }; + }; + expect(startEnvelope.method).toBe("providers.startSession"); + expect(startEnvelope.params).toMatchObject({ + provider: "codex", + cwd: "/workspace", + model: "gpt-5-codex", + }); + socket?.emitMessage( + JSON.stringify({ + type: "response", + id: startEnvelope.id, + ok: true, + result: { + sessionId: "sess-1", + provider: "codex", + status: "ready", + cwd: "/workspace", + model: "gpt-5-codex", + createdAt: "2026-02-01T00:00:00.000Z", + updatedAt: "2026-02-01T00:00:00.000Z", + }, + }), + ); + await expect(startRequest).resolves.toMatchObject({ sessionId: "sess-1" }); + + const sendTurnRequest = api.providers.sendTurn({ + sessionId: "sess-1", + input: "hello", + }); + await waitForCondition(() => (socket?.sentMessages.length ?? 0) >= 2); + const sendTurnEnvelope = JSON.parse(socket?.sentMessages[1] ?? "{}") as { + id: string; + method: string; + params: { sessionId: string; input: string }; + }; + expect(sendTurnEnvelope.method).toBe("providers.sendTurn"); + expect(sendTurnEnvelope.params).toEqual({ sessionId: "sess-1", input: "hello" }); + socket?.emitMessage( + JSON.stringify({ + type: "response", + id: sendTurnEnvelope.id, + ok: true, + result: { + threadId: "thread-1", + turnId: "turn-1", + }, + }), + ); + await expect(sendTurnRequest).resolves.toMatchObject({ turnId: "turn-1" }); + + const interruptRequest = api.providers.interruptTurn({ + sessionId: "sess-1", + turnId: "turn-1", + }); + await waitForCondition(() => (socket?.sentMessages.length ?? 0) >= 3); + const interruptEnvelope = JSON.parse(socket?.sentMessages[2] ?? "{}") as { + id: string; + method: string; + params: { sessionId: string; turnId: string }; + }; + expect(interruptEnvelope.method).toBe("providers.interruptTurn"); + expect(interruptEnvelope.params).toEqual({ sessionId: "sess-1", turnId: "turn-1" }); + socket?.emitMessage( + JSON.stringify({ + type: "response", + id: interruptEnvelope.id, + ok: true, + result: null, + }), + ); + await expect(interruptRequest).resolves.toBeUndefined(); + + const respondRequest = api.providers.respondToRequest({ + sessionId: "sess-1", + requestId: "req-1", + decision: "accept", + }); + await waitForCondition(() => (socket?.sentMessages.length ?? 0) >= 4); + const respondEnvelope = JSON.parse(socket?.sentMessages[3] ?? "{}") as { + id: string; + method: string; + params: { sessionId: string; requestId: string; decision: string }; + }; + expect(respondEnvelope.method).toBe("providers.respondToRequest"); + expect(respondEnvelope.params).toEqual({ + sessionId: "sess-1", + requestId: "req-1", + decision: "accept", + }); + socket?.emitMessage( + JSON.stringify({ + type: "response", + id: respondEnvelope.id, + ok: true, + result: null, + }), + ); + await expect(respondRequest).resolves.toBeUndefined(); + + const stopRequest = api.providers.stopSession({ + sessionId: "sess-1", + }); + await waitForCondition(() => (socket?.sentMessages.length ?? 0) >= 5); + const stopEnvelope = JSON.parse(socket?.sentMessages[4] ?? "{}") as { + id: string; + method: string; + params: { sessionId: string }; + }; + expect(stopEnvelope.method).toBe("providers.stopSession"); + expect(stopEnvelope.params).toEqual({ sessionId: "sess-1" }); + socket?.emitMessage( + JSON.stringify({ + type: "response", + id: stopEnvelope.id, + ok: true, + result: null, + }), + ); + await expect(stopRequest).resolves.toBeUndefined(); + }); + + it("rejects provider control requests on structured runtime errors", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4421"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + const request = api.providers.stopSession({ + sessionId: "sess-1", + }); + const socket = await waitForSocket(); + await waitForCondition(() => (socket?.sentMessages.length ?? 0) >= 1); + const requestEnvelope = JSON.parse(socket?.sentMessages[0] ?? "{}") as { + id: string; + method: string; + }; + expect(requestEnvelope.method).toBe("providers.stopSession"); + socket?.emitMessage( + JSON.stringify({ + type: "response", + id: requestEnvelope.id, + ok: false, + error: { + code: "request_failed", + message: "provider stop failed", + }, + }), + ); + + await expect(request).rejects.toThrow("provider stop failed"); + }); + + it("rejects provider void responses that do not return null", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4534"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + const request = api.providers.stopSession({ + sessionId: "sess-1", + }); + const socket = await waitForSocket(); + await waitForCondition(() => (socket?.sentMessages.length ?? 0) > 0); + const requestEnvelope = JSON.parse(socket?.sentMessages[0] ?? "{}") as { id: string }; + socket?.emitMessage( + JSON.stringify({ + type: "response", + id: requestEnvelope.id, + ok: true, + result: { + unexpected: true, + }, + }), + ); + + await expect(request).rejects.toThrow( + "Runtime method 'providers.stopSession' returned invalid response payload.", + ); + }); + + it("rejects providers.startSession responses with invalid payload shape", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4528"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + const request = api.providers.startSession({ + provider: "codex", + cwd: "/workspace", + model: "gpt-5-codex", + }); + const socket = await waitForSocket(); + await waitForCondition(() => (socket?.sentMessages.length ?? 0) > 0); + const requestEnvelope = JSON.parse(socket?.sentMessages[0] ?? "{}") as { id: string }; + socket?.emitMessage( + JSON.stringify({ + type: "response", + id: requestEnvelope.id, + ok: true, + result: { + sessionId: "", + provider: "codex", + status: "ready", + createdAt: "2026-02-01T00:00:00.000Z", + updatedAt: "2026-02-01T00:00:00.000Z", + }, + }), + ); + + await expect(request).rejects.toThrow( + "Runtime method 'providers.startSession' returned invalid response payload.", + ); + }); + + it("rejects providers.sendTurn responses with invalid payload shape", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4530"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + const request = api.providers.sendTurn({ + sessionId: "sess-1", + input: "hello", + }); + const socket = await waitForSocket(); + await waitForCondition(() => (socket?.sentMessages.length ?? 0) > 0); + const requestEnvelope = JSON.parse(socket?.sentMessages[0] ?? "{}") as { id: string }; + socket?.emitMessage( + JSON.stringify({ + type: "response", + id: requestEnvelope.id, + ok: true, + result: { + threadId: "thread-1", + }, + }), + ); + + await expect(request).rejects.toThrow( + "Runtime method 'providers.sendTurn' returned invalid response payload.", + ); + }); + + it("sends todo mutation requests with expected payloads", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4416"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + const addRequest = api.todos.add({ + title: "Write tests", + }); + const socket = await waitForSocket(); + await waitForCondition(() => (socket?.sentMessages.length ?? 0) >= 1, 5_000); + const addEnvelope = JSON.parse(socket?.sentMessages[0] ?? "{}") as { + id: string; + method: string; + params: { title: string }; + }; + expect(addEnvelope.method).toBe("todos.add"); + expect(addEnvelope.params).toEqual({ title: "Write tests" }); + socket?.emitMessage( + JSON.stringify({ + type: "response", + id: addEnvelope.id, + ok: true, + result: [ + { + id: "todo-1", + title: "Write tests", + completed: false, + createdAt: "2026-02-01T00:00:00.000Z", + }, + ], + }), + ); + await expect(addRequest).resolves.toMatchObject([ + { + id: "todo-1", + completed: false, + }, + ]); + + const toggleRequest = api.todos.toggle("todo-1"); + await waitForCondition(() => (socket?.sentMessages.length ?? 0) >= 2, 5_000); + const toggleEnvelope = JSON.parse(socket?.sentMessages[1] ?? "{}") as { + id: string; + method: string; + params: string; + }; + expect(toggleEnvelope.method).toBe("todos.toggle"); + expect(toggleEnvelope.params).toBe("todo-1"); + socket?.emitMessage( + JSON.stringify({ + type: "response", + id: toggleEnvelope.id, + ok: true, + result: [ + { + id: "todo-1", + title: "Write tests", + completed: true, + createdAt: "2026-02-01T00:00:00.000Z", + }, + ], + }), + ); + await expect(toggleRequest).resolves.toMatchObject([ + { + id: "todo-1", + completed: true, + }, + ]); + + const removeRequest = api.todos.remove("todo-1"); + await waitForCondition(() => (socket?.sentMessages.length ?? 0) >= 3, 5_000); + const removeEnvelope = JSON.parse(socket?.sentMessages[2] ?? "{}") as { + id: string; + method: string; + params: string; + }; + expect(removeEnvelope.method).toBe("todos.remove"); + expect(removeEnvelope.params).toBe("todo-1"); + socket?.emitMessage( + JSON.stringify({ + type: "response", + id: removeEnvelope.id, + ok: true, + result: [], + }), + ); + await expect(removeRequest).resolves.toEqual([]); + }); + + it("rejects todos.add responses with invalid payload shape", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4533"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + const request = api.todos.add({ + title: "Write tests", + }); + const socket = await waitForSocket(); + await waitForCondition(() => (socket?.sentMessages.length ?? 0) > 0); + const requestEnvelope = JSON.parse(socket?.sentMessages[0] ?? "{}") as { id: string }; + socket?.emitMessage( + JSON.stringify({ + type: "response", + id: requestEnvelope.id, + ok: true, + result: [ + { + title: "Write tests", + completed: false, + }, + ], + }), + ); + + await expect(request).rejects.toThrow( + "Runtime method 'todos.add' returned invalid response payload.", + ); + }); + + it("sends agent spawn/write/kill requests with expected payloads", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4418"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + const spawnRequest = api.agent.spawn({ + command: "bash", + args: ["-lc", "echo hi"], + cwd: "/workspace", + }); + const socket = await waitForSocket(); + await waitForCondition(() => (socket?.sentMessages.length ?? 0) >= 1); + const spawnEnvelope = JSON.parse(socket?.sentMessages[0] ?? "{}") as { + id: string; + method: string; + params: { command: string; args: string[]; cwd: string }; + }; + expect(spawnEnvelope.method).toBe("agent.spawn"); + expect(spawnEnvelope.params).toEqual({ + command: "bash", + args: ["-lc", "echo hi"], + cwd: "/workspace", + }); + socket?.emitMessage( + JSON.stringify({ + type: "response", + id: spawnEnvelope.id, + ok: true, + result: "agent-session-1", + }), + ); + await expect(spawnRequest).resolves.toBe("agent-session-1"); + + const writeRequest = api.agent.write("agent-session-1", "input"); + await waitForCondition(() => (socket?.sentMessages.length ?? 0) >= 2); + const writeEnvelope = JSON.parse(socket?.sentMessages[1] ?? "{}") as { + id: string; + method: string; + params: { sessionId: string; data: string }; + }; + expect(writeEnvelope.method).toBe("agent.write"); + expect(writeEnvelope.params).toEqual({ sessionId: "agent-session-1", data: "input" }); + socket?.emitMessage( + JSON.stringify({ + type: "response", + id: writeEnvelope.id, + ok: true, + result: null, + }), + ); + await expect(writeRequest).resolves.toBeUndefined(); + + const killRequest = api.agent.kill("agent-session-1"); + await waitForCondition(() => (socket?.sentMessages.length ?? 0) >= 3); + const killEnvelope = JSON.parse(socket?.sentMessages[2] ?? "{}") as { + id: string; + method: string; + params: string; + }; + expect(killEnvelope.method).toBe("agent.kill"); + expect(killEnvelope.params).toBe("agent-session-1"); + socket?.emitMessage( + JSON.stringify({ + type: "response", + id: killEnvelope.id, + ok: true, + result: null, + }), + ); + await expect(killRequest).resolves.toBeUndefined(); + }); + + it("rejects void method responses that do not return null", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4524"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + const request = api.agent.kill("agent-session-1"); + const socket = await waitForSocket(); + await waitForCondition(() => (socket?.sentMessages.length ?? 0) > 0); + const requestEnvelope = JSON.parse(socket?.sentMessages[0] ?? "{}") as { id: string }; + socket?.emitMessage( + JSON.stringify({ + type: "response", + id: requestEnvelope.id, + ok: true, + result: { + unexpected: true, + }, + }), + ); + + await expect(request).rejects.toThrow( + "Runtime method 'agent.kill' returned invalid response payload.", + ); + }); + + it("rejects agent.spawn responses with invalid payload shape", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4529"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + const request = api.agent.spawn({ + command: "bash", + args: ["-lc", "echo hi"], + cwd: "/workspace", + }); + const socket = await waitForSocket(); + await waitForCondition(() => (socket?.sentMessages.length ?? 0) > 0); + const requestEnvelope = JSON.parse(socket?.sentMessages[0] ?? "{}") as { id: string }; + socket?.emitMessage( + JSON.stringify({ + type: "response", + id: requestEnvelope.id, + ok: true, + result: "", + }), + ); + + await expect(request).rejects.toThrow( + "Runtime method 'agent.spawn' returned invalid response payload.", + ); + }); + + it("rejects requests when websocket connection fails", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4405"); + MockWebSocket.failOpen = true; + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + await expect(api.todos.list()).rejects.toThrow( + "Failed to connect to local t3 runtime: websocket error (mock open failure).", + ); + }); + + it("uses nested websocket open error message when direct message is missing", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4489"); + MockWebSocket.failOpen = true; + MockWebSocket.failOpenEvent = { + error: { + message: "nested-open-error", + }, + }; + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + await expect(api.todos.list()).rejects.toThrow( + "Failed to connect to local t3 runtime: websocket error (nested-open-error).", + ); + }); + + it("uses string websocket open error payload when available", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4500"); + MockWebSocket.failOpen = true; + MockWebSocket.failOpenEvent = "string-open-error"; + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + await expect(api.todos.list()).rejects.toThrow( + "Failed to connect to local t3 runtime: websocket error (string-open-error).", + ); + }); + + it("uses nested string websocket open error payload when available", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4511"); + MockWebSocket.failOpen = true; + MockWebSocket.failOpenEvent = { + error: "nested-string-open-error", + }; + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + await expect(api.todos.list()).rejects.toThrow( + "Failed to connect to local t3 runtime: websocket error (nested-string-open-error).", + ); + }); + + it("uses deeply nested websocket open error payload when available", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4516"); + MockWebSocket.failOpen = true; + MockWebSocket.failOpenEvent = { + error: { + error: { + message: "deep-open-error", + }, + }, + }; + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + await expect(api.todos.list()).rejects.toThrow( + "Failed to connect to local t3 runtime: websocket error (deep-open-error).", + ); + }); + + it("uses websocket open error payload messages from cause chains when available", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4527"); + MockWebSocket.failOpen = true; + MockWebSocket.failOpenEvent = { + cause: { + message: "cause-open-error", + }, + }; + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + await expect(api.todos.list()).rejects.toThrow( + "Failed to connect to local t3 runtime: websocket error (cause-open-error).", + ); + }); + + it("falls back when websocket open error cause chain exceeds extraction depth", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4531"); + MockWebSocket.failOpen = true; + MockWebSocket.failOpenEvent = nestedCausePayload(12, "too-deep-cause-open-error"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + await expect(api.todos.list()).rejects.toThrow("Failed to connect to local t3 runtime."); + }); + + it("falls back when websocket open error payload message exceeds extraction depth", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4524"); + MockWebSocket.failOpen = true; + MockWebSocket.failOpenEvent = nestedErrorPayload(12, "too-deep-open-error"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + await expect(api.todos.list()).rejects.toThrow("Failed to connect to local t3 runtime."); + }); + + it("uses trimmed nested websocket open error message when direct message is whitespace", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4492"); + MockWebSocket.failOpen = true; + MockWebSocket.failOpenEvent = { + message: " ", + error: { + message: " nested-open-error-trimmed ", + }, + }; + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + await expect(api.todos.list()).rejects.toThrow( + "Failed to connect to local t3 runtime: websocket error (nested-open-error-trimmed).", + ); + }); + + it("falls back to generic connect error when websocket open error has no message", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4472"); + MockWebSocket.failOpen = true; + MockWebSocket.failOpenEvent = {}; + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + await expect(api.todos.list()).rejects.toThrow("Failed to connect to local t3 runtime."); + }); + + it("rejects requests when websocket closes before opening", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4432"); + MockWebSocket.failCloseBeforeOpen = true; + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + await expect(api.todos.list()).rejects.toThrow( + "Failed to connect to local t3 runtime: unauthorized websocket connection.", + ); + }); + + it("reports replacement details when websocket closes before opening", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4451"); + MockWebSocket.failCloseBeforeOpen = true; + MockWebSocket.failCloseBeforeOpenEvent = { + code: WS_CLOSE_CODES.replacedByNewClient, + reason: WS_CLOSE_REASONS.replacedByNewClient, + }; + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + await expect(api.todos.list()).rejects.toThrow( + "Failed to connect to local t3 runtime: replaced by a newer websocket client.", + ); + }); + + it("reports generic close code and reason when websocket closes before opening", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4452"); + MockWebSocket.failCloseBeforeOpen = true; + MockWebSocket.failCloseBeforeOpenEvent = { + code: 4200, + reason: "custom-close", + }; + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + await expect(api.todos.list()).rejects.toThrow( + "Failed to connect to local t3 runtime (close code 4200: custom-close).", + ); + }); + + it("reports generic close code when websocket closes before opening without reason", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4458"); + MockWebSocket.failCloseBeforeOpen = true; + MockWebSocket.failCloseBeforeOpenEvent = { + code: 4201, + }; + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + await expect(api.todos.list()).rejects.toThrow( + "Failed to connect to local t3 runtime (close code 4201).", + ); + }); + + it("ignores non-integer close code values before opening", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4504"); + MockWebSocket.failCloseBeforeOpen = true; + MockWebSocket.failCloseBeforeOpenEvent = { + code: 4200.5, + reason: "float-close-code", + }; + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + await expect(api.todos.list()).rejects.toThrow( + "Failed to connect to local t3 runtime (close reason: float-close-code).", + ); + }); + + it("falls back to generic connect failure when pre-open close code is NaN", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4505"); + MockWebSocket.failCloseBeforeOpen = true; + MockWebSocket.failCloseBeforeOpenEvent = { + code: Number.NaN, + }; + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + await expect(api.todos.list()).rejects.toThrow("Failed to connect to local t3 runtime."); + }); + + it("reports generic close reason when websocket closes before opening without code", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4460"); + MockWebSocket.failCloseBeforeOpen = true; + MockWebSocket.failCloseBeforeOpenEvent = { + reason: "custom-reason-only", + }; + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + await expect(api.todos.list()).rejects.toThrow( + "Failed to connect to local t3 runtime (close reason: custom-reason-only).", + ); + }); + + it("trims generic close reason when websocket closes before opening without code", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4514"); + MockWebSocket.failCloseBeforeOpen = true; + MockWebSocket.failCloseBeforeOpenEvent = { + reason: " custom-reason-only ", + }; + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + await expect(api.todos.list()).rejects.toThrow( + "Failed to connect to local t3 runtime (close reason: custom-reason-only).", + ); + }); + + it("falls back to generic connect failure when pre-open close reason is whitespace-only", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4497"); + MockWebSocket.failCloseBeforeOpen = true; + MockWebSocket.failCloseBeforeOpenEvent = { + reason: " ", + }; + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + await expect(api.todos.list()).rejects.toThrow("Failed to connect to local t3 runtime."); + }); + + it("uses trimmed reason for semantic replacement pre-open close mapping", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4498"); + MockWebSocket.failCloseBeforeOpen = true; + MockWebSocket.failCloseBeforeOpenEvent = { + reason: ` ${WS_CLOSE_REASONS.replacedByNewClient} `, + }; + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + await expect(api.todos.list()).rejects.toThrow( + "Failed to connect to local t3 runtime: replaced by a newer websocket client.", + ); + }); + + it("maps unauthorized reason-only pre-open closes to explicit unauthorized errors", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4463"); + MockWebSocket.failCloseBeforeOpen = true; + MockWebSocket.failCloseBeforeOpenEvent = { + reason: WS_CLOSE_REASONS.unauthorized, + }; + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + await expect(api.todos.list()).rejects.toThrow( + "Failed to connect to local t3 runtime: unauthorized websocket connection.", + ); + }); + + it("prioritizes unauthorized code over non-auth reason on pre-open close", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4484"); + MockWebSocket.failCloseBeforeOpen = true; + MockWebSocket.failCloseBeforeOpenEvent = { + code: WS_CLOSE_CODES.unauthorized, + reason: "not-unauthorized-reason", + }; + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + await expect(api.todos.list()).rejects.toThrow( + "Failed to connect to local t3 runtime: unauthorized websocket connection.", + ); + }); + + it("maps replacement reason-only pre-open closes to explicit replacement errors", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4464"); + MockWebSocket.failCloseBeforeOpen = true; + MockWebSocket.failCloseBeforeOpenEvent = { + reason: WS_CLOSE_REASONS.replacedByNewClient, + }; + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + await expect(api.todos.list()).rejects.toThrow( + "Failed to connect to local t3 runtime: replaced by a newer websocket client.", + ); + }); + + it("prioritizes replacement code over non-replacement reason on pre-open close", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4485"); + MockWebSocket.failCloseBeforeOpen = true; + MockWebSocket.failCloseBeforeOpenEvent = { + code: WS_CLOSE_CODES.replacedByNewClient, + reason: "not-replacement-reason", + }; + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + await expect(api.todos.list()).rejects.toThrow( + "Failed to connect to local t3 runtime: replaced by a newer websocket client.", + ); + }); + + it("prioritizes replacement reason over non-replacement pre-open close code", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4483"); + MockWebSocket.failCloseBeforeOpen = true; + MockWebSocket.failCloseBeforeOpenEvent = { + code: 1000, + reason: WS_CLOSE_REASONS.replacedByNewClient, + }; + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + await expect(api.todos.list()).rejects.toThrow( + "Failed to connect to local t3 runtime: replaced by a newer websocket client.", + ); + }); + + it("falls back to generic connect failure when close metadata is missing", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4456"); + MockWebSocket.failCloseBeforeOpen = true; + MockWebSocket.failCloseBeforeOpenEvent = {}; + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + await expect(api.todos.list()).rejects.toThrow("Failed to connect to local t3 runtime."); + }); + + it("recovers after websocket pre-open close on a later request", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4433"); + MockWebSocket.failCloseBeforeOpen = true; + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + await expect(api.todos.list()).rejects.toThrow( + "Failed to connect to local t3 runtime: unauthorized websocket connection.", + ); + + MockWebSocket.failCloseBeforeOpen = false; + const secondRequest = api.todos.list(); + await waitForCondition(() => MockWebSocket.instances.length >= 2); + const socket = MockWebSocket.instances[1]; + await waitForCondition(() => (socket?.sentMessages.length ?? 0) > 0); + const requestEnvelope = JSON.parse(socket?.sentMessages[0] ?? "{}") as { + id: string; + }; + socket?.emitMessage( + JSON.stringify({ + type: "response", + id: requestEnvelope.id, + ok: true, + result: [], + }), + ); + + await expect(secondRequest).resolves.toEqual([]); + }); + + it("recovers after websocket open failure on a later request", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4429"); + MockWebSocket.failOpen = true; + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + await expect(api.todos.list()).rejects.toThrow( + "Failed to connect to local t3 runtime: websocket error (mock open failure).", + ); + + MockWebSocket.failOpen = false; + const secondRequest = api.todos.list(); + await waitForCondition(() => MockWebSocket.instances.length >= 2); + const socket = MockWebSocket.instances[1]; + await waitForCondition(() => (socket?.sentMessages.length ?? 0) > 0); + const requestEnvelope = JSON.parse(socket?.sentMessages[0] ?? "{}") as { + id: string; + }; + socket?.emitMessage( + JSON.stringify({ + type: "response", + id: requestEnvelope.id, + ok: true, + result: [], + }), + ); + + await expect(secondRequest).resolves.toEqual([]); + }); + + it("rejects requests when websocket construction throws", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4426"); + MockWebSocket.failConstruct = true; + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + await expect(api.todos.list()).rejects.toThrow( + "Failed to connect to local t3 runtime: websocket error (mock constructor failure).", + ); + }); + + it("uses string constructor throw payload for connect diagnostics", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4501"); + MockWebSocket.failConstruct = true; + MockWebSocket.failConstructError = "string-constructor-failure"; + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + await expect(api.todos.list()).rejects.toThrow( + "Failed to connect to local t3 runtime: websocket error (string-constructor-failure).", + ); + }); + + it("uses nested string constructor throw payload for connect diagnostics", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4512"); + MockWebSocket.failConstruct = true; + MockWebSocket.failConstructError = { error: "nested-string-constructor-failure" }; + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + await expect(api.todos.list()).rejects.toThrow( + "Failed to connect to local t3 runtime: websocket error (nested-string-constructor-failure).", + ); + }); + + it("uses deeply nested constructor throw payload for connect diagnostics", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4517"); + MockWebSocket.failConstruct = true; + MockWebSocket.failConstructError = { + error: { + error: { + message: "deep-constructor-failure", + }, + }, + }; + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + await expect(api.todos.list()).rejects.toThrow( + "Failed to connect to local t3 runtime: websocket error (deep-constructor-failure).", + ); + }); + + it("uses constructor throw payload messages from cause chains for connect diagnostics", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4528"); + MockWebSocket.failConstruct = true; + MockWebSocket.failConstructError = { + cause: { + message: "cause-constructor-failure", + }, + }; + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + await expect(api.todos.list()).rejects.toThrow( + "Failed to connect to local t3 runtime: websocket error (cause-constructor-failure).", + ); + }); + + it("falls back when constructor cause chain exceeds extraction depth", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4532"); + MockWebSocket.failConstruct = true; + MockWebSocket.failConstructError = nestedCausePayload(12, "too-deep-cause-constructor"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + await expect(api.todos.list()).rejects.toThrow("Failed to connect to local t3 runtime."); + }); + + it("uses non-Error constructor message when websocket construction throws", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4493"); + MockWebSocket.failConstruct = true; + MockWebSocket.failConstructError = { message: "object-constructor-failure" }; + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + await expect(api.todos.list()).rejects.toThrow( + "Failed to connect to local t3 runtime: websocket error (object-constructor-failure).", + ); + }); + + it("falls back to generic connect error when constructor message is whitespace", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4494"); + MockWebSocket.failConstruct = true; + MockWebSocket.failConstructError = { message: " " }; + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + await expect(api.todos.list()).rejects.toThrow("Failed to connect to local t3 runtime."); + }); + + it("falls back safely when constructor throw payload contains cyclic error references", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4521"); + MockWebSocket.failConstruct = true; + const cyclicError: { error?: unknown } = {}; + cyclicError.error = cyclicError; + MockWebSocket.failConstructError = cyclicError; + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + await expect(api.todos.list()).rejects.toThrow("Failed to connect to local t3 runtime."); + }); + + it("recovers after websocket construction failure on next request", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4427"); + MockWebSocket.failConstruct = true; + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + await expect(api.todos.list()).rejects.toThrow( + "Failed to connect to local t3 runtime: websocket error (mock constructor failure).", + ); + + MockWebSocket.failConstruct = false; + const secondRequest = api.todos.list(); + const socket = await waitForSocket(); + await waitForCondition(() => socket.sentMessages.length > 0); + const requestEnvelope = JSON.parse(socket.sentMessages[0] ?? "{}") as { + id: string; + }; + + socket.emitMessage( + JSON.stringify({ + type: "response", + id: requestEnvelope.id, + ok: true, + result: [], + }), + ); + + await expect(secondRequest).resolves.toEqual([]); + }); + + it("accepts arraybuffer server messages", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4406"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + const request = api.todos.list(); + const socket = MockWebSocket.instances[0]; + await waitForCondition(() => (socket?.sentMessages.length ?? 0) > 0); + const requestEnvelope = JSON.parse(socket?.sentMessages[0] ?? "{}") as { + id: string; + }; + + const encoded = new TextEncoder().encode( + JSON.stringify({ + type: "response", + id: requestEnvelope.id, + ok: true, + result: [], + }), + ); + socket?.emitMessage(encoded.buffer); + + await expect(request).resolves.toEqual([]); + }); + + it("accepts Uint8Array server messages", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4507"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + const request = api.todos.list(); + const socket = MockWebSocket.instances[0]; + await waitForCondition(() => (socket?.sentMessages.length ?? 0) > 0); + const requestEnvelope = JSON.parse(socket?.sentMessages[0] ?? "{}") as { + id: string; + }; + const payload = new TextEncoder().encode( + JSON.stringify({ + type: "response", + id: requestEnvelope.id, + ok: true, + result: [], + }), + ); + socket?.emitMessage(payload); + + await expect(request).resolves.toEqual([]); + }); + + it("accepts DataView server messages", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4508"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + const request = api.todos.list(); + const socket = MockWebSocket.instances[0]; + await waitForCondition(() => (socket?.sentMessages.length ?? 0) > 0); + const requestEnvelope = JSON.parse(socket?.sentMessages[0] ?? "{}") as { + id: string; + }; + const payload = new TextEncoder().encode( + JSON.stringify({ + type: "response", + id: requestEnvelope.id, + ok: true, + result: [], + }), + ); + socket?.emitMessage(new DataView(payload.buffer)); + + await expect(request).resolves.toEqual([]); + }); + + it("accepts sliced Uint8Array server messages", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4509"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + const request = api.todos.list(); + const socket = MockWebSocket.instances[0]; + await waitForCondition(() => (socket?.sentMessages.length ?? 0) > 0); + const requestEnvelope = JSON.parse(socket?.sentMessages[0] ?? "{}") as { + id: string; + }; + const jsonPayload = JSON.stringify({ + type: "response", + id: requestEnvelope.id, + ok: true, + result: [], + }); + const encodedPayload = new TextEncoder().encode(jsonPayload); + const paddedPayload = new Uint8Array(encodedPayload.length + 6); + paddedPayload.fill(32); + paddedPayload.set(encodedPayload, 3); + const slicedPayload = paddedPayload.subarray(3, 3 + encodedPayload.length); + socket?.emitMessage(slicedPayload); + + await expect(request).resolves.toEqual([]); + }); + + it("accepts blob server messages", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4407"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + const request = api.todos.list(); + const socket = MockWebSocket.instances[0]; + await waitForCondition(() => (socket?.sentMessages.length ?? 0) > 0); + const requestEnvelope = JSON.parse(socket?.sentMessages[0] ?? "{}") as { + id: string; + }; + + socket?.emitMessage( + new Blob([ + JSON.stringify({ + type: "response", + id: requestEnvelope.id, + ok: true, + result: [], + }), + ]), + ); + + await expect(request).resolves.toEqual([]); + }); + + it("ignores blob decode failures and continues processing", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4422"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + const request = api.todos.list(); + const socket = MockWebSocket.instances[0]; + await waitForCondition(() => (socket?.sentMessages.length ?? 0) > 0); + const requestEnvelope = JSON.parse(socket?.sentMessages[0] ?? "{}") as { + id: string; + }; + + const originalBlobText = Blob.prototype.text; + Blob.prototype.text = () => Promise.reject(new Error("decode failure")); + try { + socket?.emitMessage(new Blob(["invalid-json"])); + } finally { + Blob.prototype.text = originalBlobText; + } + + socket?.emitMessage( + JSON.stringify({ + type: "response", + id: requestEnvelope.id, + ok: true, + result: [], + }), + ); + + await expect(request).resolves.toEqual([]); + }); + + it("ignores invalid server messages and still resolves on valid response", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4408"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + const request = api.todos.list(); + const socket = MockWebSocket.instances[0]; + await waitForCondition(() => (socket?.sentMessages.length ?? 0) > 0); + const requestEnvelope = JSON.parse(socket?.sentMessages[0] ?? "{}") as { + id: string; + }; + + socket?.emitMessage("not json"); + socket?.emitMessage(JSON.stringify({ type: "event", channel: "unknown", payload: null })); + socket?.emitMessage( + JSON.stringify({ + type: "hello", + version: 1, + launchCwd: "/workspace", + unexpected: true, + }), + ); + socket?.emitMessage( + JSON.stringify({ + type: "event", + channel: "provider:event", + payload: { + id: "evt-1", + kind: "notification", + provider: "codex", + sessionId: "sess-1", + createdAt: "2026-02-01T00:00:00.000Z", + method: "turn/started", + }, + unexpected: true, + }), + ); + socket?.emitMessage( + JSON.stringify({ + type: "response", + id: requestEnvelope.id, + ok: true, + }), + ); + socket?.emitMessage( + JSON.stringify({ + type: "response", + id: requestEnvelope.id, + ok: false, + result: { invalid: true }, + error: { + code: "request_failed", + message: "invalid-error-shape", + }, + }), + ); + socket?.emitMessage( + JSON.stringify({ + type: "response", + id: " ", + ok: true, + result: [], + }), + ); + socket?.emitMessage( + JSON.stringify({ + type: "response", + id: requestEnvelope.id, + ok: true, + result: [], + unexpected: true, + }), + ); + socket?.emitMessage( + JSON.stringify({ + type: "response", + id: requestEnvelope.id, + ok: false, + error: { + code: " ", + message: "m".repeat(WS_ERROR_MESSAGE_MAX_CHARS + 1), + }, + }), + ); + socket?.emitMessage( + JSON.stringify({ + type: "response", + id: "x".repeat(WS_REQUEST_ID_MAX_CHARS + 1), + ok: true, + result: [], + }), + ); + socket?.emitMessage( + JSON.stringify({ + type: "response", + id: requestEnvelope.id, + ok: true, + result: [], + }), + ); + + await expect(request).resolves.toEqual([]); + }); + + it("ignores responses for unknown request ids", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4425"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + const request = api.todos.list(); + const socket = MockWebSocket.instances[0]; + await waitForCondition(() => (socket?.sentMessages.length ?? 0) > 0); + const requestEnvelope = JSON.parse(socket?.sentMessages[0] ?? "{}") as { + id: string; + }; + + socket?.emitMessage( + JSON.stringify({ + type: "response", + id: "unknown-request-id", + ok: true, + result: ["ignored"], + }), + ); + socket?.emitMessage( + JSON.stringify({ + type: "response", + id: requestEnvelope.id, + ok: true, + result: [], + }), + ); + + await expect(request).resolves.toEqual([]); + }); + + it("dispatches provider events to subscribers and supports unsubscribe", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4409"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + const received: unknown[] = []; + const unsubscribe = api.providers.onEvent((event) => { + received.push(event); + }); + + const request = api.todos.list(); + const socket = MockWebSocket.instances[0]; + await waitForCondition(() => (socket?.sentMessages.length ?? 0) > 0); + const requestEnvelope = JSON.parse(socket?.sentMessages[0] ?? "{}") as { + id: string; + }; + socket?.emitMessage( + JSON.stringify({ + type: "response", + id: requestEnvelope.id, + ok: true, + result: [], + }), + ); + await expect(request).resolves.toEqual([]); + + const payload = { + id: "evt-1", + kind: "notification", + provider: "codex", + sessionId: "sess-1", + createdAt: "2026-02-01T00:00:00.000Z", + method: "turn/started", + }; + socket?.emitMessage( + JSON.stringify({ + type: "event", + channel: "provider:event", + payload: { + sessionId: "sess-1", + }, + }), + ); + await new Promise((resolve) => { + setTimeout(resolve, 25); + }); + expect(received).toHaveLength(0); + + socket?.emitMessage( + JSON.stringify({ + type: "event", + channel: "provider:event", + payload, + }), + ); + await waitForCondition(() => received.length === 1); + + unsubscribe(); + socket?.emitMessage( + JSON.stringify({ + type: "event", + channel: "provider:event", + payload: { ...payload, id: "evt-2" }, + }), + ); + await new Promise((resolve) => { + setTimeout(resolve, 25); + }); + expect(received).toHaveLength(1); + }); + + it("ignores provider events from stale sockets after reconnect", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4478"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + const received: unknown[] = []; + const unsubscribe = api.providers.onEvent((event) => { + received.push(event); + }); + + const firstRequest = api.todos.list(); + const firstSocket = MockWebSocket.instances[0]; + await waitForCondition(() => (firstSocket?.sentMessages.length ?? 0) > 0); + const firstEnvelope = JSON.parse(firstSocket?.sentMessages[0] ?? "{}") as { + id: string; + }; + firstSocket?.emitMessage( + JSON.stringify({ + type: "response", + id: firstEnvelope.id, + ok: true, + result: [], + }), + ); + await expect(firstRequest).resolves.toEqual([]); + + firstSocket?.closeWith({ + code: WS_CLOSE_CODES.unauthorized, + reason: WS_CLOSE_REASONS.unauthorized, + }); + + const secondRequest = api.todos.list(); + await waitForCondition(() => MockWebSocket.instances.length >= 2); + const secondSocket = MockWebSocket.instances[1]; + await waitForCondition(() => (secondSocket?.sentMessages.length ?? 0) > 0); + const secondEnvelope = JSON.parse(secondSocket?.sentMessages[0] ?? "{}") as { + id: string; + }; + secondSocket?.emitMessage( + JSON.stringify({ + type: "response", + id: secondEnvelope.id, + ok: true, + result: [], + }), + ); + await expect(secondRequest).resolves.toEqual([]); + + firstSocket?.emitMessage( + JSON.stringify({ + type: "event", + channel: "provider:event", + payload: { + id: "stale-provider-event", + kind: "notification", + provider: "codex", + sessionId: "sess-1", + createdAt: "2026-02-01T00:00:00.000Z", + method: "turn/started", + }, + }), + ); + await new Promise((resolve) => { + setTimeout(resolve, 25); + }); + expect(received).toHaveLength(0); + + secondSocket?.emitMessage( + JSON.stringify({ + type: "event", + channel: "provider:event", + payload: { + id: "active-provider-event", + kind: "notification", + provider: "codex", + sessionId: "sess-1", + createdAt: "2026-02-01T00:00:01.000Z", + method: "turn/started", + }, + }), + ); + await waitForCondition(() => received.length === 1); + + unsubscribe(); + }); + + it("dispatches agent output and exit events to subscribers", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4410"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + const outputEvents: unknown[] = []; + const exitEvents: unknown[] = []; + const unsubscribeOutput = api.agent.onOutput((event) => { + outputEvents.push(event); + }); + const unsubscribeExit = api.agent.onExit((event) => { + exitEvents.push(event); + }); + + const request = api.todos.list(); + const socket = MockWebSocket.instances[0]; + await waitForCondition(() => (socket?.sentMessages.length ?? 0) > 0); + const requestEnvelope = JSON.parse(socket?.sentMessages[0] ?? "{}") as { + id: string; + }; + socket?.emitMessage( + JSON.stringify({ + type: "response", + id: requestEnvelope.id, + ok: true, + result: [], + }), + ); + await expect(request).resolves.toEqual([]); + + socket?.emitMessage( + JSON.stringify({ + type: "event", + channel: "agent:output", + payload: { + sessionId: "agent-session-1", + stream: "invalid-stream", + data: "ignored-invalid", + }, + }), + ); + socket?.emitMessage( + JSON.stringify({ + type: "event", + channel: "agent:exit", + payload: { + sessionId: "agent-session-1", + code: "0", + signal: null, + }, + }), + ); + await new Promise((resolve) => { + setTimeout(resolve, 25); + }); + expect(outputEvents).toHaveLength(0); + expect(exitEvents).toHaveLength(0); + + socket?.emitMessage( + JSON.stringify({ + type: "event", + channel: "agent:output", + payload: { + sessionId: "agent-session-1", + stream: "stdout", + data: "hello", + }, + }), + ); + socket?.emitMessage( + JSON.stringify({ + type: "event", + channel: "agent:exit", + payload: { + sessionId: "agent-session-1", + code: 0, + signal: null, + }, + }), + ); + + await waitForCondition(() => outputEvents.length === 1 && exitEvents.length === 1); + + unsubscribeOutput(); + unsubscribeExit(); + socket?.emitMessage( + JSON.stringify({ + type: "event", + channel: "agent:output", + payload: { + sessionId: "agent-session-1", + stream: "stdout", + data: "ignored", + }, + }), + ); + socket?.emitMessage( + JSON.stringify({ + type: "event", + channel: "agent:exit", + payload: { + sessionId: "agent-session-1", + code: 1, + signal: null, + }, + }), + ); + await new Promise((resolve) => { + setTimeout(resolve, 25); + }); + expect(outputEvents).toHaveLength(1); + expect(exitEvents).toHaveLength(1); + }); + + it("ignores stale agent events from prior sockets after reconnect", async () => { + setWindowSearch("?ws=ws%3A%2F%2F127.0.0.1%3A4479"); + const { getOrCreateWsNativeApi } = await import("./wsNativeApi"); + const api = getOrCreateWsNativeApi(); + + const outputEvents: unknown[] = []; + const exitEvents: unknown[] = []; + const unsubscribeOutput = api.agent.onOutput((event) => { + outputEvents.push(event); + }); + const unsubscribeExit = api.agent.onExit((event) => { + exitEvents.push(event); + }); + + const firstRequest = api.todos.list(); + const firstSocket = MockWebSocket.instances[0]; + await waitForCondition(() => (firstSocket?.sentMessages.length ?? 0) > 0); + const firstEnvelope = JSON.parse(firstSocket?.sentMessages[0] ?? "{}") as { + id: string; + }; + firstSocket?.emitMessage( + JSON.stringify({ + type: "response", + id: firstEnvelope.id, + ok: true, + result: [], + }), + ); + await expect(firstRequest).resolves.toEqual([]); + + firstSocket?.closeWith({ + code: WS_CLOSE_CODES.unauthorized, + reason: WS_CLOSE_REASONS.unauthorized, + }); + + const secondRequest = api.todos.list(); + await waitForCondition(() => MockWebSocket.instances.length >= 2); + const secondSocket = MockWebSocket.instances[1]; + await waitForCondition(() => (secondSocket?.sentMessages.length ?? 0) > 0); + const secondEnvelope = JSON.parse(secondSocket?.sentMessages[0] ?? "{}") as { + id: string; + }; + secondSocket?.emitMessage( + JSON.stringify({ + type: "response", + id: secondEnvelope.id, + ok: true, + result: [], + }), + ); + await expect(secondRequest).resolves.toEqual([]); + + firstSocket?.emitMessage( + JSON.stringify({ + type: "event", + channel: "agent:output", + payload: { + sessionId: "agent-session-1", + stream: "stdout", + data: "stale-output", + }, + }), + ); + firstSocket?.emitMessage( + JSON.stringify({ + type: "event", + channel: "agent:exit", + payload: { + sessionId: "agent-session-1", + code: 0, + signal: null, + }, + }), + ); + await new Promise((resolve) => { + setTimeout(resolve, 25); + }); + expect(outputEvents).toHaveLength(0); + expect(exitEvents).toHaveLength(0); + + secondSocket?.emitMessage( + JSON.stringify({ + type: "event", + channel: "agent:output", + payload: { + sessionId: "agent-session-1", + stream: "stdout", + data: "active-output", + }, + }), + ); + secondSocket?.emitMessage( + JSON.stringify({ + type: "event", + channel: "agent:exit", + payload: { + sessionId: "agent-session-1", + code: 0, + signal: null, + }, + }), + ); + await waitForCondition(() => outputEvents.length === 1 && exitEvents.length === 1); + + unsubscribeOutput(); + unsubscribeExit(); + }); +}); diff --git a/apps/renderer/src/wsNativeApi.ts b/apps/renderer/src/wsNativeApi.ts new file mode 100644 index 00000000000..e5ef553ab7c --- /dev/null +++ b/apps/renderer/src/wsNativeApi.ts @@ -0,0 +1,525 @@ +import type { + AgentExit, + NativeApi, + OutputChunk, + ProviderEvent, + WsNativeApiMethod, + WsClientMessage, + WsEventMessage, + WsResponseMessage, +} from "@acme/contracts"; +import { + WS_CLOSE_CODES, + WS_CLOSE_REASONS, + WS_EVENT_CHANNELS, + agentSessionIdSchema, + appBootstrapResultSchema, + appHealthResultSchema, + dialogsPickFolderResultSchema, + providerSessionListSchema, + providerSessionSchema, + providerTurnStartResultSchema, + terminalCommandResultSchema, + todoListSchema, + wsServerMessageSchema, +} from "@acme/contracts"; + +type PendingRequest = { + resolve: (value: unknown) => void; + reject: (error: Error) => void; + timeout: ReturnType; +}; + +type SubscriptionSet = Set<(value: TValue) => void>; +type SafeParseResult = { success: true; data: TValue } | { success: false }; +type SchemaLike = { + safeParse: (value: unknown) => SafeParseResult; +}; +const REQUEST_TIMEOUT_MS = 30_000; +const MAX_NESTED_ERROR_EXTRACTION_DEPTH = 8; +const textDecoder = new TextDecoder(); + +function closeDetailsFromEvent(event: unknown) { + const code = (event as { code?: unknown } | null)?.code; + const reason = (event as { reason?: unknown } | null)?.reason; + return { + code: normalizeCloseCode(code), + reason: normalizeNonEmptyString(reason), + }; +} + +function runtimeConnectErrorFromClose(event: unknown) { + const { code, reason } = closeDetailsFromEvent(event); + if (code === WS_CLOSE_CODES.unauthorized || reason === WS_CLOSE_REASONS.unauthorized) { + return new Error("Failed to connect to local t3 runtime: unauthorized websocket connection."); + } + if ( + code === WS_CLOSE_CODES.replacedByNewClient || + reason === WS_CLOSE_REASONS.replacedByNewClient + ) { + return new Error( + "Failed to connect to local t3 runtime: replaced by a newer websocket client.", + ); + } + if (code === null && (!reason || reason.length === 0)) { + return new Error("Failed to connect to local t3 runtime."); + } + if (code === null) { + return new Error(`Failed to connect to local t3 runtime (close reason: ${reason}).`); + } + if (!reason || reason.length === 0) { + return new Error(`Failed to connect to local t3 runtime (close code ${code}).`); + } + return new Error(`Failed to connect to local t3 runtime (close code ${code}: ${reason}).`); +} + +function requestDisconnectError(id: string, event: unknown) { + const { code, reason } = closeDetailsFromEvent(event); + if (code === WS_CLOSE_CODES.unauthorized || reason === WS_CLOSE_REASONS.unauthorized) { + return new Error(`Request ${id} failed: websocket disconnected (unauthorized).`); + } + if ( + code === WS_CLOSE_CODES.replacedByNewClient || + reason === WS_CLOSE_REASONS.replacedByNewClient + ) { + return new Error(`Request ${id} failed: websocket disconnected (replaced-by-new-client).`); + } + if (code === null && (!reason || reason.length === 0)) { + return new Error(`Request ${id} failed: websocket disconnected.`); + } + if (code === null) { + return new Error(`Request ${id} failed: websocket disconnected (reason: ${reason}).`); + } + if (!reason || reason.length === 0) { + return new Error(`Request ${id} failed: websocket disconnected (code ${code}).`); + } + return new Error(`Request ${id} failed: websocket disconnected (code ${code}: ${reason}).`); +} + +function requestSocketError(id: string, event: unknown) { + const message = socketErrorMessage(event); + if (typeof message === "string" && message.length > 0) { + return new Error(`Request ${id} failed: websocket errored (${message}).`); + } + return new Error(`Request ${id} failed: websocket errored.`); +} + +function runtimeConnectErrorFromSocketError(event: unknown) { + const message = socketErrorMessage(event); + if (typeof message === "string" && message.length > 0) { + return new Error(`Failed to connect to local t3 runtime: websocket error (${message}).`); + } + return new Error("Failed to connect to local t3 runtime."); +} + +function runtimeConnectErrorFromConstructionError(error: unknown) { + const message = messageFromUnknown(error); + if (message) { + return new Error(`Failed to connect to local t3 runtime: websocket error (${message}).`); + } + return new Error("Failed to connect to local t3 runtime."); +} + +function socketErrorMessage(event: unknown) { + return messageFromUnknown(event); +} + +function messageFromUnknown(value: unknown, depth = 0): string | null { + if (depth > MAX_NESTED_ERROR_EXTRACTION_DEPTH) { + return null; + } + + const direct = normalizeNonEmptyString(value); + if (direct) { + return direct; + } + + const message = normalizeNonEmptyString((value as { message?: unknown } | null)?.message); + if (message) { + return message; + } + + const nestedErrorMessage = messageFromUnknown( + (value as { error?: unknown } | null)?.error, + depth + 1, + ); + if (nestedErrorMessage) { + return nestedErrorMessage; + } + + return messageFromUnknown((value as { cause?: unknown } | null)?.cause, depth + 1); +} + +function normalizeNonEmptyString(value: unknown) { + if (typeof value !== "string") { + return null; + } + + const normalized = value.trim(); + if (normalized.length === 0) { + return null; + } + + return normalized; +} + +function normalizeCloseCode(value: unknown) { + if (typeof value !== "number" || !Number.isInteger(value)) { + return null; + } + + return value; +} + +class WsNativeApiClient { + private socket: WebSocket | null = null; + private connectPromise: Promise | null = null; + private nextRequestId = 1; + private pending = new Map(); + private providerEventListeners: SubscriptionSet = new Set(); + private agentOutputListeners: SubscriptionSet = new Set(); + private agentExitListeners: SubscriptionSet = new Set(); + + constructor(private readonly wsUrl: string) {} + + private rejectPendingRequests(errorForRequest: (id: string) => Error) { + for (const [id, pending] of this.pending.entries()) { + clearTimeout(pending.timeout); + pending.reject(errorForRequest(id)); + } + this.pending.clear(); + } + + private connect() { + if (this.socket && this.socket.readyState === WebSocket.OPEN) { + return Promise.resolve(this.socket); + } + + if (this.connectPromise) { + return this.connectPromise; + } + + const connectAttempt = new Promise((resolve, reject) => { + let socket: WebSocket; + try { + socket = new WebSocket(this.wsUrl); + } catch (error) { + reject(runtimeConnectErrorFromConstructionError(error)); + return; + } + socket.binaryType = "arraybuffer"; + this.socket = socket; + let hasOpened = false; + let connectionSettled = false; + const rejectConnection = (error?: Error) => { + if (connectionSettled) { + return; + } + connectionSettled = true; + this.connectPromise = null; + reject(error ?? new Error("Failed to connect to local t3 runtime.")); + }; + const resolveConnection = () => { + if (connectionSettled) { + return; + } + connectionSettled = true; + this.connectPromise = null; + resolve(socket); + }; + + socket.addEventListener("open", () => { + hasOpened = true; + resolveConnection(); + }); + + socket.addEventListener("error", (event) => { + if (this.socket !== socket) { + if (!hasOpened) { + rejectConnection(runtimeConnectErrorFromSocketError(event)); + } + return; + } + + if (!hasOpened) { + rejectConnection(runtimeConnectErrorFromSocketError(event)); + return; + } + + this.socket = null; + this.rejectPendingRequests((id) => requestSocketError(id, event)); + try { + socket.close(); + } catch { + // best-effort close after error + } + }); + + socket.addEventListener("message", (event) => { + if (this.socket !== socket) { + return; + } + void this.handleMessage(event.data); + }); + + socket.addEventListener("close", (event) => { + if (this.socket !== socket) { + if (!hasOpened) { + rejectConnection(runtimeConnectErrorFromClose(event)); + } + return; + } + + this.socket = null; + if (!hasOpened) { + rejectConnection(runtimeConnectErrorFromClose(event)); + return; + } + this.rejectPendingRequests((id) => requestDisconnectError(id, event)); + }); + }); + + this.connectPromise = connectAttempt; + connectAttempt.catch(() => { + if (this.connectPromise === connectAttempt) { + this.connectPromise = null; + } + }); + + return connectAttempt; + } + + private async request(method: WsNativeApiMethod, params?: unknown) { + const socket = await this.connect(); + const id = String(this.nextRequestId); + this.nextRequestId += 1; + + const requestMessage: WsClientMessage = { + type: "request", + id, + method, + params, + }; + + const requestPromise = new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`Request timed out for method '${method}'.`)); + }, REQUEST_TIMEOUT_MS); + this.pending.set(id, { resolve, reject, timeout }); + }); + + try { + socket.send(JSON.stringify(requestMessage)); + } catch (error) { + const pending = this.pending.get(id); + if (pending) { + clearTimeout(pending.timeout); + this.pending.delete(id); + const sendErrorMessage = messageFromUnknown(error); + pending.reject( + new Error( + `Failed to send runtime request '${method}': ${sendErrorMessage ?? "unknown websocket failure"}`, + ), + ); + } + this.rejectPendingRequests((requestId) => requestSocketError(requestId, error)); + if (this.socket === socket) { + this.socket = null; + } + try { + socket.close(); + } catch { + // best-effort close after send failure + } + } + + return requestPromise; + } + + private async requestParsed( + method: WsNativeApiMethod, + schema: SchemaLike, + params?: unknown, + ): Promise { + const value = await this.request(method, params); + const parsed = schema.safeParse(value); + if (!parsed.success) { + throw new Error(`Runtime method '${method}' returned invalid response payload.`); + } + return parsed.data; + } + + private async requestNullResult(method: WsNativeApiMethod, params?: unknown): Promise { + const value = await this.request(method, params); + if (value !== null) { + throw new Error(`Runtime method '${method}' returned invalid response payload.`); + } + } + + private handleResponse(message: WsResponseMessage) { + const pending = this.pending.get(message.id); + if (!pending) { + return; + } + + this.pending.delete(message.id); + clearTimeout(pending.timeout); + if (message.ok) { + pending.resolve(message.result); + return; + } + + pending.reject(new Error(message.error?.message ?? "Unknown runtime request failure.")); + } + + private handleEvent(message: WsEventMessage) { + if (message.channel === WS_EVENT_CHANNELS.providerEvent) { + for (const listener of this.providerEventListeners) { + listener(message.payload as ProviderEvent); + } + return; + } + + if (message.channel === WS_EVENT_CHANNELS.agentOutput) { + for (const listener of this.agentOutputListeners) { + listener(message.payload as OutputChunk); + } + return; + } + + if (message.channel === WS_EVENT_CHANNELS.agentExit) { + for (const listener of this.agentExitListeners) { + listener(message.payload as AgentExit); + } + } + } + + private async decodeIncomingMessage(raw: unknown): Promise { + if (typeof raw === "string") { + return raw; + } + + if (ArrayBuffer.isView(raw)) { + return textDecoder.decode(raw); + } + + if (raw instanceof ArrayBuffer) { + return textDecoder.decode(raw); + } + + if (raw instanceof Blob) { + return raw.text(); + } + + return null; + } + + private async handleMessage(raw: unknown) { + let decoded: string | null; + try { + decoded = await this.decodeIncomingMessage(raw); + } catch { + return; + } + if (!decoded) { + return; + } + + let parsedRaw: unknown; + try { + parsedRaw = JSON.parse(decoded); + } catch { + return; + } + + const parsed = wsServerMessageSchema.safeParse(parsedRaw); + if (!parsed.success) { + return; + } + + if (parsed.data.type === "response") { + this.handleResponse(parsed.data); + return; + } + + if (parsed.data.type === "event") { + this.handleEvent(parsed.data); + } + } + + asNativeApi(): NativeApi { + return { + app: { + bootstrap: async () => this.requestParsed("app.bootstrap", appBootstrapResultSchema), + health: async () => this.requestParsed("app.health", appHealthResultSchema), + }, + todos: { + list: async () => this.requestParsed("todos.list", todoListSchema), + add: async (input) => this.requestParsed("todos.add", todoListSchema, input), + toggle: async (id) => this.requestParsed("todos.toggle", todoListSchema, id), + remove: async (id) => this.requestParsed("todos.remove", todoListSchema, id), + }, + dialogs: { + pickFolder: async () => + this.requestParsed("dialogs.pickFolder", dialogsPickFolderResultSchema), + }, + terminal: { + run: async (input) => this.requestParsed("terminal.run", terminalCommandResultSchema, input), + }, + agent: { + spawn: async (config) => this.requestParsed("agent.spawn", agentSessionIdSchema, config), + kill: async (sessionId) => this.requestNullResult("agent.kill", sessionId), + write: async (sessionId, data) => this.requestNullResult("agent.write", { sessionId, data }), + onOutput: (callback) => { + this.agentOutputListeners.add(callback); + return () => { + this.agentOutputListeners.delete(callback); + }; + }, + onExit: (callback) => { + this.agentExitListeners.add(callback); + return () => { + this.agentExitListeners.delete(callback); + }; + }, + }, + providers: { + startSession: async (input) => + this.requestParsed("providers.startSession", providerSessionSchema, input), + sendTurn: async (input) => + this.requestParsed("providers.sendTurn", providerTurnStartResultSchema, input), + interruptTurn: async (input) => this.requestNullResult("providers.interruptTurn", input), + respondToRequest: async (input) => this.requestNullResult("providers.respondToRequest", input), + stopSession: async (input) => this.requestNullResult("providers.stopSession", input), + listSessions: async () => + this.requestParsed("providers.listSessions", providerSessionListSchema), + onEvent: (callback) => { + this.providerEventListeners.add(callback); + return () => { + this.providerEventListeners.delete(callback); + }; + }, + }, + shell: { + openInEditor: async (cwd, editor) => this.requestNullResult("shell.openInEditor", { cwd, editor }), + }, + }; + } +} + +function resolveWsUrl() { + const params = new URLSearchParams(window.location.search); + return params.get("ws") ?? "ws://127.0.0.1:4317"; +} + +let cachedApi: NativeApi | undefined; + +export function getOrCreateWsNativeApi() { + if (cachedApi) { + return cachedApi; + } + + cachedApi = new WsNativeApiClient(resolveWsUrl()).asNativeApi(); + return cachedApi; +} diff --git a/apps/renderer/vite.config.ts b/apps/renderer/vite.config.ts index e57fcd856ae..d4875ae61ee 100644 --- a/apps/renderer/vite.config.ts +++ b/apps/renderer/vite.config.ts @@ -12,7 +12,7 @@ export default defineConfig({ tailwindcss(), ], server: { - port: Number(process.env.ELECTRON_RENDERER_PORT ?? 5173), + port: Number(process.env.T3_WEB_PORT ?? 5173), strictPort: true, }, build: { diff --git a/apps/t3/package.json b/apps/t3/package.json new file mode 100644 index 00000000000..64710cc59dc --- /dev/null +++ b/apps/t3/package.json @@ -0,0 +1,28 @@ +{ + "name": "@acme/t3-runtime", + "version": "0.0.0", + "private": true, + "bin": { + "t3": "dist/cli.js" + }, + "type": "module", + "scripts": { + "dev": "tsx src/cli.ts", + "build": "tsup --config tsup.config.ts", + "typecheck": "tsc --noEmit", + "test": "vitest run --passWithNoTests", + "smoke-test": "node scripts/smoke-test.mjs" + }, + "dependencies": { + "@acme/contracts": "workspace:*", + "@acme/runtime-core": "workspace:*", + "ws": "^8.18.0" + }, + "devDependencies": { + "@types/node": "^22.10.2", + "@types/ws": "^8.5.13", + "tsup": "^8.3.5", + "tsx": "^4.20.3", + "typescript": "^5.7.3" + } +} diff --git a/apps/t3/scripts/smoke-test.mjs b/apps/t3/scripts/smoke-test.mjs new file mode 100644 index 00000000000..9b165803816 --- /dev/null +++ b/apps/t3/scripts/smoke-test.mjs @@ -0,0 +1,3135 @@ +import { spawn } from "node:child_process"; +import fs from "node:fs"; +import { createServer } from "node:net"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { createFakeCodexAppServerBinary } from "../../../test-support/fakeCodexAppServer.mjs"; + +const WS_CLOSE_CODES = { + replacedByNewClient: 4000, + unauthorized: 4001, +}; + +const WS_CLOSE_REASONS = { + replacedByNewClient: "replaced-by-new-client", + unauthorized: "unauthorized", +}; + +const WS_EVENT_CHANNELS = { + providerEvent: "provider:event", + agentOutput: "agent:output", + agentExit: "agent:exit", +}; +const WS_REQUEST_ID_MAX_CHARS = 256; +const WS_METHOD_MAX_CHARS = 256; +const WS_ERROR_CODE_MAX_CHARS = 128; +const WS_ERROR_MESSAGE_MAX_CHARS = 8192; + +function isRecord(value) { + return typeof value === "object" && value !== null; +} + +function hasOwn(value, key) { + return Object.prototype.hasOwnProperty.call(value, key); +} + +function hasOnlyKeys(value, allowedKeys) { + if (!isRecord(value)) { + return false; + } + + const allowed = new Set(allowedKeys); + for (const key of Object.keys(value)) { + if (!allowed.has(key)) { + return false; + } + } + + return true; +} + +function parseWsMessage(raw) { + let parsed; + try { + parsed = JSON.parse(String(raw)); + } catch { + return null; + } + + if (!isRecord(parsed) || typeof parsed.type !== "string") { + return null; + } + + if (parsed.type === "hello") { + if (!hasOnlyKeys(parsed, ["type", "version", "launchCwd"])) { + return null; + } + if (parsed.version !== 1) { + return null; + } + if (typeof parsed.launchCwd !== "string") { + return null; + } + return parsed; + } + + if (parsed.type === "event") { + return parseWsEventMessage(parsed); + } + + if (parsed.type !== "response") { + return null; + } + + if ( + typeof parsed.id !== "string" || + parsed.id.trim().length === 0 || + parsed.id.length > WS_REQUEST_ID_MAX_CHARS || + typeof parsed.ok !== "boolean" + ) { + return null; + } + + if (parsed.ok) { + if (!hasOnlyKeys(parsed, ["type", "id", "ok", "result"])) { + return null; + } + if (!hasOwn(parsed, "result") || hasOwn(parsed, "error")) { + return null; + } + return parsed; + } + + if (!hasOnlyKeys(parsed, ["type", "id", "ok", "error"])) { + return null; + } + + if (hasOwn(parsed, "result")) { + return null; + } + + if ( + !isRecord(parsed.error) || + !hasOnlyKeys(parsed.error, ["code", "message"]) || + typeof parsed.error.code !== "string" || + parsed.error.code.trim().length === 0 || + parsed.error.code.length > WS_ERROR_CODE_MAX_CHARS || + typeof parsed.error.message !== "string" || + parsed.error.message.trim().length === 0 || + parsed.error.message.length > WS_ERROR_MESSAGE_MAX_CHARS + ) { + return null; + } + + return parsed; +} + +function parseWsEventMessage(parsed) { + if (!hasOnlyKeys(parsed, ["type", "channel", "payload"])) { + return null; + } + + if (!hasOwn(parsed, "payload")) { + return null; + } + + if (parsed.channel === WS_EVENT_CHANNELS.providerEvent) { + if (!isValidProviderEventPayload(parsed.payload)) { + return null; + } + return parsed; + } + + if (parsed.channel === WS_EVENT_CHANNELS.agentOutput) { + if (!isValidAgentOutputPayload(parsed.payload)) { + return null; + } + return parsed; + } + + if (parsed.channel === WS_EVENT_CHANNELS.agentExit) { + if (!isValidAgentExitPayload(parsed.payload)) { + return null; + } + return parsed; + } + + return null; +} + +function isValidProviderEventPayload(payload) { + if (!isRecord(payload)) { + return false; + } + + return ( + typeof payload.id === "string" && + payload.id.length > 0 && + typeof payload.kind === "string" && + typeof payload.provider === "string" && + typeof payload.sessionId === "string" && + payload.sessionId.length > 0 && + typeof payload.createdAt === "string" && + payload.createdAt.length > 0 && + typeof payload.method === "string" && + payload.method.length > 0 + ); +} + +function isValidAgentOutputPayload(payload) { + if (!isRecord(payload)) { + return false; + } + + return ( + typeof payload.sessionId === "string" && + payload.sessionId.length > 0 && + (payload.stream === "stdout" || payload.stream === "stderr") && + typeof payload.data === "string" + ); +} + +function isValidAgentExitPayload(payload) { + if (!isRecord(payload)) { + return false; + } + + const validCode = payload.code === null || Number.isInteger(payload.code); + const validSignal = payload.signal === null || typeof payload.signal === "string"; + return typeof payload.sessionId === "string" && payload.sessionId.length > 0 && validCode && validSignal; +} + +function getFreePort() { + return new Promise((resolve, reject) => { + const server = createServer(); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address !== "object") { + reject(new Error("Could not resolve free port.")); + return; + } + const { port } = address; + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(port); + }); + }); + server.on("error", (error) => reject(error)); + }); +} + +function waitForProcessExit(processRef) { + return new Promise((resolve) => { + processRef.once("exit", (code) => resolve(code)); + }); +} + +async function terminateProcess(processRef, timeoutMs = 5_000) { + if (processRef.exitCode !== null || processRef.signalCode !== null) { + return; + } + + processRef.kill("SIGTERM"); + const exited = await Promise.race([ + waitForProcessExit(processRef).then(() => true), + new Promise((resolve) => { + setTimeout(() => resolve(false), timeoutMs); + }), + ]); + + if (exited) { + return; + } + + processRef.kill("SIGKILL"); + await waitForProcessExit(processRef); +} + +function waitForUnauthorizedCloseWithoutMessages(socket, label, timeoutMs = 10_000) { + return new Promise((resolve, reject) => { + let messageCount = 0; + const timer = setTimeout(() => { + reject(new Error(`Smoke test failed: ${label} websocket close event timed out.`)); + }, timeoutMs); + + socket.addEventListener("message", () => { + messageCount += 1; + }); + socket.addEventListener("close", (event) => { + clearTimeout(timer); + if (event.code !== WS_CLOSE_CODES.unauthorized) { + reject( + new Error( + `Smoke test failed: expected unauthorized close code ${WS_CLOSE_CODES.unauthorized} for ${label}, received ${event.code}.`, + ), + ); + return; + } + if (event.reason !== WS_CLOSE_REASONS.unauthorized) { + reject( + new Error( + `Smoke test failed: expected unauthorized close reason for ${label}, received ${JSON.stringify( + event.reason, + )}.`, + ), + ); + return; + } + if (messageCount > 0) { + reject( + new Error( + `Smoke test failed: unauthorized websocket ${label} received ${messageCount} message(s) before close.`, + ), + ); + return; + } + resolve(); + }); + socket.addEventListener("error", () => { + clearTimeout(timer); + reject(new Error(`Smoke test failed: ${label} websocket client error before close.`)); + }); + }); +} + +function waitForCloseCode(socket, expectedCode, label, timeoutMs = 10_000) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error(`Smoke test failed: ${label} websocket close event timed out.`)); + }, timeoutMs); + + socket.addEventListener("close", (event) => { + clearTimeout(timer); + if (event.code !== expectedCode) { + reject( + new Error( + `Smoke test failed: expected ${label} close code ${expectedCode}, received ${event.code}.`, + ), + ); + return; + } + if ( + label === "replaced-client" && + event.reason !== WS_CLOSE_REASONS.replacedByNewClient + ) { + reject( + new Error( + `Smoke test failed: expected replaced-client close reason "${WS_CLOSE_REASONS.replacedByNewClient}", received ${JSON.stringify( + event.reason, + )}.`, + ), + ); + return; + } + resolve(); + }); + socket.addEventListener("error", () => { + clearTimeout(timer); + reject(new Error(`Smoke test failed: ${label} websocket client error before close.`)); + }); + }); +} + +function sendWsRequest(socket, request, timeoutMs = 20_000) { + return new Promise((resolve, reject) => { + const cleanup = () => { + clearTimeout(timer); + socket.removeEventListener("message", onMessage); + socket.removeEventListener("error", onError); + socket.removeEventListener("close", onClose); + }; + + const timer = setTimeout(() => { + cleanup(); + reject( + new Error( + `Smoke test failed: websocket request ${request.id} (${request.method}) timed out.`, + ), + ); + }, timeoutMs); + + const onMessage = (event) => { + const message = parseWsMessage(event.data); + if (!message) { + return; + } + + if (message.type !== "response" || message.id !== request.id) { + return; + } + + cleanup(); + resolve(message); + }; + + const onError = () => { + cleanup(); + reject( + new Error( + `Smoke test failed: websocket request ${request.id} (${request.method}) errored before response.`, + ), + ); + }; + + const onClose = (event) => { + cleanup(); + reject( + new Error( + `Smoke test failed: websocket request ${request.id} (${request.method}) closed before response (code ${event.code}).`, + ), + ); + }; + + socket.addEventListener("message", onMessage); + socket.addEventListener("error", onError); + socket.addEventListener("close", onClose); + socket.send( + JSON.stringify({ + type: "request", + id: request.id, + method: request.method, + ...(request.params === undefined ? {} : { params: request.params }), + }), + ); + }); +} + +function waitForWsEvent(socket, matcher, label, timeoutMs = 20_000) { + return new Promise((resolve, reject) => { + const cleanup = () => { + clearTimeout(timer); + socket.removeEventListener("message", onMessage); + socket.removeEventListener("error", onError); + socket.removeEventListener("close", onClose); + }; + + const timer = setTimeout(() => { + cleanup(); + reject(new Error(`Smoke test failed: ${label} websocket event timed out.`)); + }, timeoutMs); + + const onMessage = (event) => { + const message = parseWsMessage(event.data); + if (!message) { + return; + } + + if (message.type !== "event") { + return; + } + if (!matcher(message)) { + return; + } + + cleanup(); + resolve(message); + }; + + const onError = () => { + cleanup(); + reject(new Error(`Smoke test failed: ${label} websocket errored before matching event.`)); + }; + + const onClose = (event) => { + cleanup(); + reject( + new Error( + `Smoke test failed: ${label} websocket closed before matching event (code ${event.code}).`, + ), + ); + }; + + socket.addEventListener("message", onMessage); + socket.addEventListener("error", onError); + socket.addEventListener("close", onClose); + }); +} + +function waitForStartupUrl(readOutput, processRef, timeoutMs = 20_000) { + return new Promise((resolve, reject) => { + const finish = (callback, value) => { + clearInterval(timer); + processRef.off("exit", onExit); + callback(value); + }; + const onExit = (code) => { + finish( + reject, + new Error( + `Smoke test failed: CLI exited before startup URL was printed (exit code ${String(code)}).`, + ), + ); + }; + processRef.once("exit", onExit); + + const startedAt = Date.now(); + const timer = setInterval(() => { + const output = readOutput(); + const match = output.match(/CodeThing is running at (http:\/\/[^\s]+)/); + if (match?.[1]) { + finish(resolve, match[1]); + return; + } + + if (Date.now() - startedAt >= timeoutMs) { + finish(reject, new Error("Smoke test failed: did not observe startup URL in CLI output.")); + } + }, 100); + }); +} + +async function main() { + const [backendPort, webPort] = await Promise.all([getFreePort(), getFreePort()]); + const scriptDir = path.dirname(fileURLToPath(import.meta.url)); + const appRoot = path.resolve(scriptDir, ".."); + const fakeCodex = createFakeCodexAppServerBinary("t3-smoke-fake-codex-"); + const distCli = path.join(appRoot, "dist", "cli.js"); + if (!fs.existsSync(distCli)) { + throw new Error("Missing dist/cli.js. Run `bun run --cwd apps/t3 build` first."); + } + + const runtimeEnv = { + ...process.env, + PATH: `${fakeCodex.tempDir}${path.delimiter}${process.env.PATH ?? ""}`, + }; + + const child = spawn( + process.execPath, + [ + distCli, + "-o=0", + "--backend-port", + String(backendPort), + "--web-port", + String(webPort), + ], + { + cwd: appRoot, + env: runtimeEnv, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + + let output = ""; + child.stdout.on("data", (chunk) => { + output += chunk.toString(); + }); + child.stderr.on("data", (chunk) => { + output += chunk.toString(); + }); + + try { + const appUrl = await waitForStartupUrl(() => output, child); + const parsedAppUrl = new URL(appUrl); + + const page = await fetch(parsedAppUrl); + if (page.status !== 200) { + throw new Error(`Smoke test failed: expected web status 200, received ${page.status}.`); + } + if (page.headers.get("x-content-type-options") !== "nosniff") { + throw new Error("Smoke test failed: expected x-content-type-options=nosniff."); + } + if ((page.headers.get("x-frame-options") ?? "").toUpperCase() !== "DENY") { + throw new Error("Smoke test failed: expected x-frame-options=DENY."); + } + if ((page.headers.get("referrer-policy") ?? "").toLowerCase() !== "no-referrer") { + throw new Error("Smoke test failed: expected referrer-policy=no-referrer."); + } + if ((page.headers.get("cross-origin-resource-policy") ?? "").toLowerCase() !== "same-origin") { + throw new Error("Smoke test failed: expected CORP header to be same-origin."); + } + if ((page.headers.get("cross-origin-opener-policy") ?? "").toLowerCase() !== "same-origin") { + throw new Error("Smoke test failed: expected COOP header to be same-origin."); + } + if ((page.headers.get("cache-control") ?? "").toLowerCase() !== "no-store") { + throw new Error( + `Smoke test failed: expected cache-control=no-store, got ${String( + page.headers.get("cache-control"), + )}.`, + ); + } + if ((page.headers.get("accept-ranges") ?? "").toLowerCase() !== "bytes") { + throw new Error("Smoke test failed: expected accept-ranges=bytes on HTML response."); + } + if ((page.headers.get("vary") ?? "").toLowerCase() !== "range") { + throw new Error("Smoke test failed: expected vary=range on HTML response."); + } + const html = await page.text(); + const assetMatch = html.match(/(?:src|href)="(\/assets\/[^"]+)"/); + if (!assetMatch?.[1]) { + throw new Error("Smoke test failed: could not locate built asset path in HTML."); + } + const assetUrl = new URL(assetMatch[1], parsedAppUrl); + const assetResponse = await fetch(assetUrl); + if (assetResponse.status !== 200) { + throw new Error( + `Smoke test failed: expected built asset status 200, received ${assetResponse.status}.`, + ); + } + const assetCacheControl = (assetResponse.headers.get("cache-control") ?? "").toLowerCase(); + if (!assetCacheControl.includes("immutable")) { + throw new Error( + `Smoke test failed: expected immutable cache-control on built asset, got ${String( + assetResponse.headers.get("cache-control"), + )}.`, + ); + } + if (!assetCacheControl.includes("max-age=31536000")) { + throw new Error( + `Smoke test failed: expected max-age=31536000 on built asset, got ${String( + assetResponse.headers.get("cache-control"), + )}.`, + ); + } + if ((assetResponse.headers.get("x-content-type-options") ?? "").toLowerCase() !== "nosniff") { + throw new Error("Smoke test failed: expected nosniff on built asset response."); + } + if ((assetResponse.headers.get("x-frame-options") ?? "").toUpperCase() !== "DENY") { + throw new Error("Smoke test failed: expected x-frame-options=DENY on built asset response."); + } + if ((assetResponse.headers.get("referrer-policy") ?? "").toLowerCase() !== "no-referrer") { + throw new Error("Smoke test failed: expected referrer-policy=no-referrer on built asset response."); + } + if ((assetResponse.headers.get("cross-origin-resource-policy") ?? "").toLowerCase() !== "same-origin") { + throw new Error("Smoke test failed: expected CORP header on built asset response."); + } + if ((assetResponse.headers.get("cross-origin-opener-policy") ?? "").toLowerCase() !== "same-origin") { + throw new Error("Smoke test failed: expected COOP header on built asset response."); + } + if ((assetResponse.headers.get("accept-ranges") ?? "").toLowerCase() !== "bytes") { + throw new Error("Smoke test failed: expected accept-ranges=bytes on built asset response."); + } + if ((assetResponse.headers.get("vary") ?? "").toLowerCase() !== "range") { + throw new Error("Smoke test failed: expected vary=range on built asset response."); + } + const assetEtag = assetResponse.headers.get("etag"); + if (!assetEtag || assetEtag.length === 0) { + throw new Error("Smoke test failed: expected ETag on built asset response."); + } + const assetContentType = assetResponse.headers.get("content-type"); + if (!assetContentType || assetContentType.length === 0) { + throw new Error("Smoke test failed: expected content-type on built asset response."); + } + const assetLastModified = assetResponse.headers.get("last-modified"); + if (!assetLastModified || assetLastModified.length === 0) { + throw new Error("Smoke test failed: expected Last-Modified on built asset response."); + } + const parsedLastModifiedMs = Date.parse(assetLastModified); + if (!Number.isFinite(parsedLastModifiedMs)) { + throw new Error( + `Smoke test failed: expected parseable last-modified date, got ${assetLastModified}.`, + ); + } + const assetContentLength = Number(assetResponse.headers.get("content-length") ?? "0"); + if (!Number.isFinite(assetContentLength) || assetContentLength <= 0) { + throw new Error( + `Smoke test failed: expected positive content-length on built asset response, got ${String( + assetResponse.headers.get("content-length"), + )}.`, + ); + } + const conditionalAsset = await fetch(assetUrl, { + headers: { + "If-None-Match": assetEtag, + }, + }); + if (conditionalAsset.status !== 304) { + throw new Error( + `Smoke test failed: expected conditional asset status 304, received ${conditionalAsset.status}.`, + ); + } + if (conditionalAsset.headers.get("etag") !== assetEtag) { + throw new Error( + `Smoke test failed: expected conditional asset ETag ${assetEtag}, got ${String( + conditionalAsset.headers.get("etag"), + )}.`, + ); + } + if (conditionalAsset.headers.get("content-type") !== assetContentType) { + throw new Error( + `Smoke test failed: expected conditional asset content-type ${assetContentType}, got ${String( + conditionalAsset.headers.get("content-type"), + )}.`, + ); + } + if ((conditionalAsset.headers.get("cache-control") ?? "").toLowerCase() !== assetCacheControl) { + throw new Error("Smoke test failed: expected cache-control preserved on conditional asset response."); + } + if ((conditionalAsset.headers.get("x-content-type-options") ?? "").toLowerCase() !== "nosniff") { + throw new Error("Smoke test failed: expected nosniff on conditional asset response."); + } + if ((conditionalAsset.headers.get("x-frame-options") ?? "").toUpperCase() !== "DENY") { + throw new Error("Smoke test failed: expected x-frame-options=DENY on conditional asset response."); + } + if ((conditionalAsset.headers.get("referrer-policy") ?? "").toLowerCase() !== "no-referrer") { + throw new Error("Smoke test failed: expected referrer-policy=no-referrer on conditional asset response."); + } + if ((conditionalAsset.headers.get("accept-ranges") ?? "").toLowerCase() !== "bytes") { + throw new Error("Smoke test failed: expected accept-ranges=bytes on conditional asset response."); + } + if ((conditionalAsset.headers.get("vary") ?? "").toLowerCase() !== "range") { + throw new Error("Smoke test failed: expected vary=range on conditional asset response."); + } + if (conditionalAsset.headers.get("last-modified") !== assetLastModified) { + throw new Error( + `Smoke test failed: expected conditional asset last-modified ${assetLastModified}, got ${String( + conditionalAsset.headers.get("last-modified"), + )}.`, + ); + } + const conditionalAssetContentLength = conditionalAsset.headers.get("content-length"); + if (conditionalAssetContentLength !== null && conditionalAssetContentLength !== "0") { + throw new Error( + `Smoke test failed: expected no content-length (or 0) on conditional asset response, got ${conditionalAssetContentLength}.`, + ); + } + const weakConditionalAsset = await fetch(assetUrl, { + headers: { + "If-None-Match": `W/${assetEtag}`, + }, + }); + if (weakConditionalAsset.status !== 304) { + throw new Error( + `Smoke test failed: expected weak conditional asset status 304, received ${weakConditionalAsset.status}.`, + ); + } + const lowercaseWeakConditionalAsset = await fetch(assetUrl, { + headers: { + "If-None-Match": `w/${assetEtag}`, + }, + }); + if (lowercaseWeakConditionalAsset.status !== 304) { + throw new Error( + `Smoke test failed: expected lowercase weak conditional asset status 304, received ${lowercaseWeakConditionalAsset.status}.`, + ); + } + const wildcardConditionalAsset = await fetch(assetUrl, { + headers: { + "If-None-Match": "*", + }, + }); + if (wildcardConditionalAsset.status !== 304) { + throw new Error( + `Smoke test failed: expected wildcard conditional asset status 304, received ${wildcardConditionalAsset.status}.`, + ); + } + const modifiedSinceAsset = await fetch(assetUrl, { + headers: { + "If-Modified-Since": assetLastModified, + }, + }); + if (modifiedSinceAsset.status !== 304) { + throw new Error( + `Smoke test failed: expected If-Modified-Since asset status 304, received ${modifiedSinceAsset.status}.`, + ); + } + const precedenceAsset = await fetch(assetUrl, { + headers: { + "If-Modified-Since": assetLastModified, + "If-None-Match": "\"definitely-different-etag\"", + }, + }); + if (precedenceAsset.status !== 200) { + throw new Error( + `Smoke test failed: expected If-None-Match precedence status 200, received ${precedenceAsset.status}.`, + ); + } + const ifMatchMismatchAsset = await fetch(assetUrl, { + headers: { + "If-Match": "\"definitely-different-etag\"", + }, + }); + if (ifMatchMismatchAsset.status !== 412) { + throw new Error( + `Smoke test failed: expected If-Match mismatch status 412, received ${ifMatchMismatchAsset.status}.`, + ); + } + if ((ifMatchMismatchAsset.headers.get("content-type") ?? "").toLowerCase() !== "text/plain; charset=utf-8") { + throw new Error( + `Smoke test failed: expected plain-text content-type on If-Match mismatch response, got ${String( + ifMatchMismatchAsset.headers.get("content-type"), + )}.`, + ); + } + const ifMatchMismatchContentLength = Number(ifMatchMismatchAsset.headers.get("content-length") ?? "0"); + if (!Number.isFinite(ifMatchMismatchContentLength) || ifMatchMismatchContentLength <= 0) { + throw new Error( + `Smoke test failed: expected positive content-length on If-Match mismatch response, got ${String( + ifMatchMismatchAsset.headers.get("content-length"), + )}.`, + ); + } + if ((ifMatchMismatchAsset.headers.get("cache-control") ?? "").toLowerCase() !== "no-store") { + throw new Error("Smoke test failed: expected cache-control=no-store on If-Match mismatch response."); + } + if ((ifMatchMismatchAsset.headers.get("x-content-type-options") ?? "").toLowerCase() !== "nosniff") { + throw new Error("Smoke test failed: expected nosniff on If-Match mismatch response."); + } + if ((ifMatchMismatchAsset.headers.get("accept-ranges") ?? "").toLowerCase() !== "bytes") { + throw new Error("Smoke test failed: expected accept-ranges=bytes on If-Match mismatch response."); + } + if ((ifMatchMismatchAsset.headers.get("vary") ?? "").toLowerCase() !== "range") { + throw new Error("Smoke test failed: expected vary=range on If-Match mismatch response."); + } + const ifMatchLowercaseWeakAsset = await fetch(assetUrl, { + headers: { + "If-Match": `w/${assetEtag}`, + }, + }); + if (ifMatchLowercaseWeakAsset.status !== 412) { + throw new Error( + `Smoke test failed: expected lowercase weak If-Match status 412, received ${ifMatchLowercaseWeakAsset.status}.`, + ); + } + const ifMatchWildcardAsset = await fetch(assetUrl, { + headers: { + "If-Match": "*", + }, + }); + if (ifMatchWildcardAsset.status !== 200) { + throw new Error( + `Smoke test failed: expected wildcard If-Match status 200, received ${ifMatchWildcardAsset.status}.`, + ); + } + const ifMatchWithNoneMatchAsset = await fetch(assetUrl, { + headers: { + "If-Match": assetEtag, + "If-None-Match": assetEtag, + }, + }); + if (ifMatchWithNoneMatchAsset.status !== 304) { + throw new Error( + `Smoke test failed: expected If-Match + If-None-Match status 304, received ${ifMatchWithNoneMatchAsset.status}.`, + ); + } + const ifMatchWildcardWithNoneMatchAsset = await fetch(assetUrl, { + headers: { + "If-Match": "*", + "If-None-Match": assetEtag, + }, + }); + if (ifMatchWildcardWithNoneMatchAsset.status !== 304) { + throw new Error( + `Smoke test failed: expected wildcard If-Match + If-None-Match status 304, received ${ifMatchWildcardWithNoneMatchAsset.status}.`, + ); + } + const ifMatchWildcardWithNoneMatchHeadAsset = await fetch(assetUrl, { + method: "HEAD", + headers: { + "If-Match": "*", + "If-None-Match": assetEtag, + }, + }); + if (ifMatchWildcardWithNoneMatchHeadAsset.status !== 304) { + throw new Error( + `Smoke test failed: expected HEAD wildcard If-Match + If-None-Match status 304, received ${ifMatchWildcardWithNoneMatchHeadAsset.status}.`, + ); + } + const ifMatchMismatchWithNoneMatchAsset = await fetch(assetUrl, { + headers: { + "If-Match": "\"definitely-different-etag\"", + "If-None-Match": assetEtag, + }, + }); + if (ifMatchMismatchWithNoneMatchAsset.status !== 412) { + throw new Error( + `Smoke test failed: expected mismatched If-Match + If-None-Match status 412, received ${ifMatchMismatchWithNoneMatchAsset.status}.`, + ); + } + const ifMatchWildcardRangedAsset = await fetch(assetUrl, { + headers: { + Range: "bytes=0-15", + "If-Match": "*", + }, + }); + if (ifMatchWildcardRangedAsset.status !== 206) { + throw new Error( + `Smoke test failed: expected wildcard If-Match ranged status 206, received ${ifMatchWildcardRangedAsset.status}.`, + ); + } + if (ifMatchMismatchAsset.headers.get("etag") !== assetEtag) { + throw new Error("Smoke test failed: expected ETag on If-Match mismatch response."); + } + if (ifMatchMismatchAsset.headers.get("last-modified") !== assetLastModified) { + throw new Error("Smoke test failed: expected Last-Modified on If-Match mismatch response."); + } + const ifMatchRangeMismatchAsset = await fetch(assetUrl, { + headers: { + Range: "bytes=0-15", + "If-Match": "\"definitely-different-etag\"", + }, + }); + if (ifMatchRangeMismatchAsset.status !== 412) { + throw new Error( + `Smoke test failed: expected ranged If-Match mismatch status 412, received ${ifMatchRangeMismatchAsset.status}.`, + ); + } + if (ifMatchRangeMismatchAsset.headers.get("content-range") !== null) { + throw new Error( + "Smoke test failed: expected no content-range on ranged If-Match mismatch response.", + ); + } + const staleUnmodifiedSince = new Date(parsedLastModifiedMs - 1_000).toUTCString(); + const staleUnmodifiedWithNoneMatchAsset = await fetch(assetUrl, { + headers: { + "If-Unmodified-Since": staleUnmodifiedSince, + "If-None-Match": assetEtag, + }, + }); + if (staleUnmodifiedWithNoneMatchAsset.status !== 412) { + throw new Error( + `Smoke test failed: expected stale If-Unmodified-Since + If-None-Match status 412, received ${staleUnmodifiedWithNoneMatchAsset.status}.`, + ); + } + const ifUnmodifiedSinceStaleAsset = await fetch(assetUrl, { + headers: { + "If-Unmodified-Since": staleUnmodifiedSince, + }, + }); + if (ifUnmodifiedSinceStaleAsset.status !== 412) { + throw new Error( + `Smoke test failed: expected stale If-Unmodified-Since status 412, received ${ifUnmodifiedSinceStaleAsset.status}.`, + ); + } + if ((ifUnmodifiedSinceStaleAsset.headers.get("cache-control") ?? "").toLowerCase() !== "no-store") { + throw new Error( + "Smoke test failed: expected cache-control=no-store on stale If-Unmodified-Since response.", + ); + } + if ((ifUnmodifiedSinceStaleAsset.headers.get("accept-ranges") ?? "").toLowerCase() !== "bytes") { + throw new Error( + "Smoke test failed: expected accept-ranges=bytes on stale If-Unmodified-Since response.", + ); + } + if (ifUnmodifiedSinceStaleAsset.headers.get("etag") !== assetEtag) { + throw new Error("Smoke test failed: expected ETag on stale If-Unmodified-Since response."); + } + const ifUnmodifiedSinceRangeStaleAsset = await fetch(assetUrl, { + headers: { + Range: "bytes=0-15", + "If-Unmodified-Since": staleUnmodifiedSince, + }, + }); + if (ifUnmodifiedSinceRangeStaleAsset.status !== 412) { + throw new Error( + `Smoke test failed: expected ranged stale If-Unmodified-Since status 412, received ${ifUnmodifiedSinceRangeStaleAsset.status}.`, + ); + } + const ifUnmodifiedSinceCurrentAsset = await fetch(assetUrl, { + headers: { + "If-Unmodified-Since": assetLastModified, + }, + }); + if (ifUnmodifiedSinceCurrentAsset.status !== 200) { + throw new Error( + `Smoke test failed: expected current If-Unmodified-Since status 200, received ${ifUnmodifiedSinceCurrentAsset.status}.`, + ); + } + const ifUnmodifiedSinceCurrentWithNoneMatch = await fetch(assetUrl, { + headers: { + "If-Unmodified-Since": assetLastModified, + "If-None-Match": assetEtag, + }, + }); + if (ifUnmodifiedSinceCurrentWithNoneMatch.status !== 304) { + throw new Error( + `Smoke test failed: expected current If-Unmodified-Since + If-None-Match status 304, received ${ifUnmodifiedSinceCurrentWithNoneMatch.status}.`, + ); + } + const ifUnmodifiedSinceCurrentWithNoneMatchRange = await fetch(assetUrl, { + headers: { + Range: "bytes=0-15", + "If-Unmodified-Since": assetLastModified, + "If-None-Match": assetEtag, + }, + }); + if (ifUnmodifiedSinceCurrentWithNoneMatchRange.status !== 304) { + throw new Error( + `Smoke test failed: expected ranged current If-Unmodified-Since + If-None-Match status 304, received ${ifUnmodifiedSinceCurrentWithNoneMatchRange.status}.`, + ); + } + const ifMatchPrecedenceAsset = await fetch(assetUrl, { + headers: { + "If-Match": assetEtag, + "If-Unmodified-Since": staleUnmodifiedSince, + }, + }); + if (ifMatchPrecedenceAsset.status !== 200) { + throw new Error( + `Smoke test failed: expected If-Match precedence status 200, received ${ifMatchPrecedenceAsset.status}.`, + ); + } + const conditionalHeadAsset = await fetch(assetUrl, { + method: "HEAD", + headers: { + "If-None-Match": assetEtag, + }, + }); + if (conditionalHeadAsset.status !== 304) { + throw new Error( + `Smoke test failed: expected conditional HEAD asset status 304, received ${conditionalHeadAsset.status}.`, + ); + } + if (conditionalHeadAsset.headers.get("etag") !== assetEtag) { + throw new Error( + `Smoke test failed: expected conditional HEAD asset ETag ${assetEtag}, got ${String( + conditionalHeadAsset.headers.get("etag"), + )}.`, + ); + } + if (conditionalHeadAsset.headers.get("content-type") !== assetContentType) { + throw new Error( + `Smoke test failed: expected conditional HEAD asset content-type ${assetContentType}, got ${String( + conditionalHeadAsset.headers.get("content-type"), + )}.`, + ); + } + if ((conditionalHeadAsset.headers.get("cache-control") ?? "").toLowerCase() !== assetCacheControl) { + throw new Error( + "Smoke test failed: expected cache-control preserved on conditional HEAD asset response.", + ); + } + if ((conditionalHeadAsset.headers.get("x-content-type-options") ?? "").toLowerCase() !== "nosniff") { + throw new Error("Smoke test failed: expected nosniff on conditional HEAD asset response."); + } + if ((conditionalHeadAsset.headers.get("x-frame-options") ?? "").toUpperCase() !== "DENY") { + throw new Error( + "Smoke test failed: expected x-frame-options=DENY on conditional HEAD asset response.", + ); + } + if ((conditionalHeadAsset.headers.get("referrer-policy") ?? "").toLowerCase() !== "no-referrer") { + throw new Error( + "Smoke test failed: expected referrer-policy=no-referrer on conditional HEAD asset response.", + ); + } + if ((conditionalHeadAsset.headers.get("accept-ranges") ?? "").toLowerCase() !== "bytes") { + throw new Error( + "Smoke test failed: expected accept-ranges=bytes on conditional HEAD asset response.", + ); + } + if ((conditionalHeadAsset.headers.get("vary") ?? "").toLowerCase() !== "range") { + throw new Error("Smoke test failed: expected vary=range on conditional HEAD asset response."); + } + if (conditionalHeadAsset.headers.get("last-modified") !== assetLastModified) { + throw new Error( + `Smoke test failed: expected conditional HEAD asset last-modified ${assetLastModified}, got ${String( + conditionalHeadAsset.headers.get("last-modified"), + )}.`, + ); + } + const conditionalHeadAssetContentLength = conditionalHeadAsset.headers.get("content-length"); + if ( + conditionalHeadAssetContentLength !== null && + conditionalHeadAssetContentLength !== "0" + ) { + throw new Error( + `Smoke test failed: expected no content-length (or 0) on conditional HEAD asset response, got ${conditionalHeadAssetContentLength}.`, + ); + } + const modifiedSinceHeadAsset = await fetch(assetUrl, { + method: "HEAD", + headers: { + "If-Modified-Since": assetLastModified, + }, + }); + if (modifiedSinceHeadAsset.status !== 304) { + throw new Error( + `Smoke test failed: expected If-Modified-Since HEAD asset status 304, received ${modifiedSinceHeadAsset.status}.`, + ); + } + const ifMatchMismatchHeadAsset = await fetch(assetUrl, { + method: "HEAD", + headers: { + "If-Match": "\"definitely-different-etag\"", + }, + }); + if (ifMatchMismatchHeadAsset.status !== 412) { + throw new Error( + `Smoke test failed: expected HEAD If-Match mismatch status 412, received ${ifMatchMismatchHeadAsset.status}.`, + ); + } + if ( + (ifMatchMismatchHeadAsset.headers.get("content-type") ?? "").toLowerCase() !== + "text/plain; charset=utf-8" + ) { + throw new Error( + `Smoke test failed: expected plain-text content-type on HEAD If-Match mismatch response, got ${String( + ifMatchMismatchHeadAsset.headers.get("content-type"), + )}.`, + ); + } + const ifMatchMismatchHeadContentLength = Number( + ifMatchMismatchHeadAsset.headers.get("content-length") ?? "0", + ); + if (!Number.isFinite(ifMatchMismatchHeadContentLength) || ifMatchMismatchHeadContentLength <= 0) { + throw new Error( + `Smoke test failed: expected positive content-length on HEAD If-Match mismatch response, got ${String( + ifMatchMismatchHeadAsset.headers.get("content-length"), + )}.`, + ); + } + if ((ifMatchMismatchHeadAsset.headers.get("cache-control") ?? "").toLowerCase() !== "no-store") { + throw new Error( + "Smoke test failed: expected cache-control=no-store on HEAD If-Match mismatch response.", + ); + } + if ((ifMatchMismatchHeadAsset.headers.get("accept-ranges") ?? "").toLowerCase() !== "bytes") { + throw new Error( + "Smoke test failed: expected accept-ranges=bytes on HEAD If-Match mismatch response.", + ); + } + if ((ifMatchMismatchHeadAsset.headers.get("vary") ?? "").toLowerCase() !== "range") { + throw new Error("Smoke test failed: expected vary=range on HEAD If-Match mismatch response."); + } + if (ifMatchMismatchHeadAsset.headers.get("etag") !== assetEtag) { + throw new Error("Smoke test failed: expected ETag on HEAD If-Match mismatch response."); + } + if (ifMatchMismatchHeadAsset.headers.get("last-modified") !== assetLastModified) { + throw new Error("Smoke test failed: expected Last-Modified on HEAD If-Match mismatch response."); + } + const ifUnmodifiedSinceStaleHeadAsset = await fetch(assetUrl, { + method: "HEAD", + headers: { + "If-Unmodified-Since": staleUnmodifiedSince, + }, + }); + if (ifUnmodifiedSinceStaleHeadAsset.status !== 412) { + throw new Error( + `Smoke test failed: expected HEAD stale If-Unmodified-Since status 412, received ${ifUnmodifiedSinceStaleHeadAsset.status}.`, + ); + } + if ((ifUnmodifiedSinceStaleHeadAsset.headers.get("cache-control") ?? "").toLowerCase() !== "no-store") { + throw new Error( + "Smoke test failed: expected cache-control=no-store on HEAD stale If-Unmodified-Since response.", + ); + } + if (ifUnmodifiedSinceStaleHeadAsset.headers.get("etag") !== assetEtag) { + throw new Error( + "Smoke test failed: expected ETag on HEAD stale If-Unmodified-Since response.", + ); + } + if (ifUnmodifiedSinceStaleHeadAsset.headers.get("last-modified") !== assetLastModified) { + throw new Error( + "Smoke test failed: expected Last-Modified on HEAD stale If-Unmodified-Since response.", + ); + } + const ifMatchRangeMismatchHeadAsset = await fetch(assetUrl, { + method: "HEAD", + headers: { + Range: "bytes=0-15", + "If-Match": "\"definitely-different-etag\"", + }, + }); + if (ifMatchRangeMismatchHeadAsset.status !== 412) { + throw new Error( + `Smoke test failed: expected HEAD ranged If-Match mismatch status 412, received ${ifMatchRangeMismatchHeadAsset.status}.`, + ); + } + if (ifMatchRangeMismatchHeadAsset.headers.get("content-range") !== null) { + throw new Error( + "Smoke test failed: expected no content-range on HEAD ranged If-Match mismatch response.", + ); + } + const rangeEnd = Math.min(15, assetContentLength - 1); + const rangedAsset = await fetch(assetUrl, { + headers: { + Range: `bytes=0-${rangeEnd}`, + }, + }); + if (rangedAsset.status !== 206) { + throw new Error( + `Smoke test failed: expected ranged asset status 206, received ${rangedAsset.status}.`, + ); + } + const expectedContentRange = `bytes 0-${rangeEnd}/${assetContentLength}`; + if (rangedAsset.headers.get("content-range") !== expectedContentRange) { + throw new Error( + `Smoke test failed: expected content-range ${expectedContentRange}, got ${String( + rangedAsset.headers.get("content-range"), + )}.`, + ); + } + const rangedContentLength = Number(rangedAsset.headers.get("content-length") ?? "0"); + if (!Number.isFinite(rangedContentLength) || rangedContentLength !== rangeEnd + 1) { + throw new Error( + `Smoke test failed: expected ranged content-length ${String( + rangeEnd + 1, + )}, got ${String(rangedAsset.headers.get("content-length"))}.`, + ); + } + if ((rangedAsset.headers.get("accept-ranges") ?? "").toLowerCase() !== "bytes") { + throw new Error("Smoke test failed: expected accept-ranges=bytes on ranged asset response."); + } + if ((rangedAsset.headers.get("vary") ?? "").toLowerCase() !== "range") { + throw new Error("Smoke test failed: expected vary=range on ranged asset response."); + } + const oversizedSuffixRangedAsset = await fetch(assetUrl, { + headers: { + Range: "bytes=-999999", + }, + }); + if (oversizedSuffixRangedAsset.status !== 206) { + throw new Error( + `Smoke test failed: expected oversized suffix range status 206, received ${oversizedSuffixRangedAsset.status}.`, + ); + } + if (oversizedSuffixRangedAsset.headers.get("content-range") !== `bytes 0-${assetContentLength - 1}/${assetContentLength}`) { + throw new Error( + `Smoke test failed: expected oversized suffix content-range bytes 0-${String( + assetContentLength - 1, + )}/${String(assetContentLength)}, got ${String(oversizedSuffixRangedAsset.headers.get("content-range"))}.`, + ); + } + const spacedRangedAsset = await fetch(assetUrl, { + headers: { + Range: `bytes = 0 - ${rangeEnd}`, + }, + }); + if (spacedRangedAsset.status !== 206) { + throw new Error( + `Smoke test failed: expected spaced range asset status 206, received ${spacedRangedAsset.status}.`, + ); + } + const tabSpacedRangedAsset = await fetch(assetUrl, { + headers: { + Range: `bytes\t=\t0\t-\t${rangeEnd}`, + }, + }); + if (tabSpacedRangedAsset.status !== 206) { + throw new Error( + `Smoke test failed: expected tab-spaced range asset status 206, received ${tabSpacedRangedAsset.status}.`, + ); + } + const conditionalRangedAsset = await fetch(assetUrl, { + headers: { + Range: `bytes=0-${rangeEnd}`, + "If-None-Match": assetEtag, + }, + }); + if (conditionalRangedAsset.status !== 304) { + throw new Error( + `Smoke test failed: expected conditional ranged asset status 304, received ${conditionalRangedAsset.status}.`, + ); + } + if (conditionalRangedAsset.headers.get("content-range") !== null) { + throw new Error( + "Smoke test failed: expected no content-range on conditional ranged If-None-Match response.", + ); + } + const wildcardConditionalRangedAsset = await fetch(assetUrl, { + headers: { + Range: `bytes=0-${rangeEnd}`, + "If-None-Match": "*", + }, + }); + if (wildcardConditionalRangedAsset.status !== 304) { + throw new Error( + `Smoke test failed: expected wildcard conditional ranged asset status 304, received ${wildcardConditionalRangedAsset.status}.`, + ); + } + if (wildcardConditionalRangedAsset.headers.get("content-range") !== null) { + throw new Error( + "Smoke test failed: expected no content-range on wildcard conditional ranged response.", + ); + } + const mismatchConditionalRangedAsset = await fetch(assetUrl, { + headers: { + Range: `bytes=0-${rangeEnd}`, + "If-None-Match": "\"definitely-different-etag\"", + }, + }); + if (mismatchConditionalRangedAsset.status !== 206) { + throw new Error( + `Smoke test failed: expected mismatched conditional ranged asset status 206, received ${mismatchConditionalRangedAsset.status}.`, + ); + } + const ifRangeEtagAsset = await fetch(assetUrl, { + headers: { + Range: `bytes=0-${rangeEnd}`, + "If-Range": assetEtag, + }, + }); + if (ifRangeEtagAsset.status !== 206) { + throw new Error( + `Smoke test failed: expected If-Range(etag) asset status 206, received ${ifRangeEtagAsset.status}.`, + ); + } + const ifRangeDateAsset = await fetch(assetUrl, { + headers: { + Range: `bytes=0-${rangeEnd}`, + "If-Range": assetLastModified, + }, + }); + if (ifRangeDateAsset.status !== 206) { + throw new Error( + `Smoke test failed: expected If-Range(date) asset status 206, received ${ifRangeDateAsset.status}.`, + ); + } + const staleIfRangeDate = new Date(parsedLastModifiedMs - 1_000).toUTCString(); + const ifRangeStaleDateAsset = await fetch(assetUrl, { + headers: { + Range: `bytes=0-${rangeEnd}`, + "If-Range": staleIfRangeDate, + }, + }); + if (ifRangeStaleDateAsset.status !== 200) { + throw new Error( + `Smoke test failed: expected stale If-Range(date) asset status 200, received ${ifRangeStaleDateAsset.status}.`, + ); + } + const ifRangeInvalidDateAsset = await fetch(assetUrl, { + headers: { + Range: `bytes=0-${rangeEnd}`, + "If-Range": "not-a-date", + }, + }); + if (ifRangeInvalidDateAsset.status !== 200) { + throw new Error( + `Smoke test failed: expected invalid If-Range(date) asset status 200, received ${ifRangeInvalidDateAsset.status}.`, + ); + } + const rangedModifiedSinceAsset = await fetch(assetUrl, { + headers: { + Range: `bytes=0-${rangeEnd}`, + "If-Modified-Since": assetLastModified, + }, + }); + if (rangedModifiedSinceAsset.status !== 304) { + throw new Error( + `Smoke test failed: expected ranged If-Modified-Since asset status 304, received ${rangedModifiedSinceAsset.status}.`, + ); + } + if (rangedModifiedSinceAsset.headers.get("content-range") !== null) { + throw new Error( + "Smoke test failed: expected no content-range on ranged If-Modified-Since response.", + ); + } + const staleModifiedSince = new Date(parsedLastModifiedMs - 1_000).toUTCString(); + const staleRangedModifiedSinceAsset = await fetch(assetUrl, { + headers: { + Range: `bytes=0-${rangeEnd}`, + "If-Modified-Since": staleModifiedSince, + }, + }); + if (staleRangedModifiedSinceAsset.status !== 206) { + throw new Error( + `Smoke test failed: expected stale ranged If-Modified-Since status 206, received ${staleRangedModifiedSinceAsset.status}.`, + ); + } + const ifRangeMismatchAsset = await fetch(assetUrl, { + headers: { + Range: `bytes=0-${rangeEnd}`, + "If-Range": "\"definitely-different-etag\"", + }, + }); + if (ifRangeMismatchAsset.status !== 200) { + throw new Error( + `Smoke test failed: expected If-Range mismatch asset status 200, received ${ifRangeMismatchAsset.status}.`, + ); + } + if (ifRangeMismatchAsset.headers.get("content-range") !== null) { + throw new Error("Smoke test failed: expected no content-range on If-Range mismatch response."); + } + const ifRangeMismatchLength = Number(ifRangeMismatchAsset.headers.get("content-length") ?? "0"); + if (!Number.isFinite(ifRangeMismatchLength) || ifRangeMismatchLength !== assetContentLength) { + throw new Error( + `Smoke test failed: expected full content-length ${String( + assetContentLength, + )} on If-Range mismatch response, got ${String(ifRangeMismatchAsset.headers.get("content-length"))}.`, + ); + } + const ifRangeWeakAsset = await fetch(assetUrl, { + headers: { + Range: `bytes=0-${rangeEnd}`, + "If-Range": `W/${assetEtag}`, + }, + }); + if (ifRangeWeakAsset.status !== 200) { + throw new Error( + `Smoke test failed: expected If-Range weak-etag asset status 200, received ${ifRangeWeakAsset.status}.`, + ); + } + const ifRangeLowercaseWeakAsset = await fetch(assetUrl, { + headers: { + Range: `bytes=0-${rangeEnd}`, + "If-Range": `w/${assetEtag}`, + }, + }); + if (ifRangeLowercaseWeakAsset.status !== 200) { + throw new Error( + `Smoke test failed: expected If-Range lowercase weak-etag asset status 200, received ${ifRangeLowercaseWeakAsset.status}.`, + ); + } + if (ifRangeWeakAsset.headers.get("content-range") !== null) { + throw new Error("Smoke test failed: expected no content-range on If-Range weak-etag response."); + } + const unsatisfiableRange = await fetch(assetUrl, { + headers: { + Range: `bytes=${assetContentLength}-${assetContentLength + 10}`, + }, + }); + if (unsatisfiableRange.status !== 416) { + throw new Error( + `Smoke test failed: expected unsatisfiable range status 416, received ${unsatisfiableRange.status}.`, + ); + } + if (unsatisfiableRange.headers.get("content-range") !== `bytes */${assetContentLength}`) { + throw new Error( + `Smoke test failed: expected unsatisfiable content-range bytes */${String( + assetContentLength, + )}, got ${String(unsatisfiableRange.headers.get("content-range"))}.`, + ); + } + if (unsatisfiableRange.headers.get("etag") !== assetEtag) { + throw new Error("Smoke test failed: expected ETag on unsatisfiable range response."); + } + if (unsatisfiableRange.headers.get("last-modified") !== assetLastModified) { + throw new Error("Smoke test failed: expected Last-Modified on unsatisfiable range response."); + } + if ((unsatisfiableRange.headers.get("accept-ranges") ?? "").toLowerCase() !== "bytes") { + throw new Error( + "Smoke test failed: expected accept-ranges=bytes on unsatisfiable range response.", + ); + } + if ((unsatisfiableRange.headers.get("vary") ?? "").toLowerCase() !== "range") { + throw new Error("Smoke test failed: expected vary=range on unsatisfiable range response."); + } + if ((unsatisfiableRange.headers.get("cache-control") ?? "").toLowerCase() !== "no-store") { + throw new Error( + "Smoke test failed: expected cache-control=no-store on unsatisfiable range response.", + ); + } + const headAssetResponse = await fetch(assetUrl, { method: "HEAD" }); + if (headAssetResponse.status !== 200) { + throw new Error( + `Smoke test failed: expected HEAD asset status 200, received ${headAssetResponse.status}.`, + ); + } + const headAssetContentLength = Number(headAssetResponse.headers.get("content-length") ?? "0"); + if (!Number.isFinite(headAssetContentLength) || headAssetContentLength <= 0) { + throw new Error( + `Smoke test failed: expected positive content-length on HEAD asset response, got ${String( + headAssetResponse.headers.get("content-length"), + )}.`, + ); + } + const headAssetCacheControl = (headAssetResponse.headers.get("cache-control") ?? "").toLowerCase(); + if (!headAssetCacheControl.includes("immutable")) { + throw new Error( + `Smoke test failed: expected immutable cache-control on HEAD asset response, got ${String( + headAssetResponse.headers.get("cache-control"), + )}.`, + ); + } + if ((headAssetResponse.headers.get("x-content-type-options") ?? "").toLowerCase() !== "nosniff") { + throw new Error("Smoke test failed: expected nosniff on HEAD asset response."); + } + if ((headAssetResponse.headers.get("x-frame-options") ?? "").toUpperCase() !== "DENY") { + throw new Error("Smoke test failed: expected x-frame-options=DENY on HEAD asset response."); + } + if ((headAssetResponse.headers.get("referrer-policy") ?? "").toLowerCase() !== "no-referrer") { + throw new Error("Smoke test failed: expected referrer-policy=no-referrer on HEAD asset response."); + } + if ( + (headAssetResponse.headers.get("cross-origin-resource-policy") ?? "").toLowerCase() !== + "same-origin" + ) { + throw new Error("Smoke test failed: expected CORP on HEAD asset response."); + } + if ( + (headAssetResponse.headers.get("cross-origin-opener-policy") ?? "").toLowerCase() !== + "same-origin" + ) { + throw new Error("Smoke test failed: expected COOP on HEAD asset response."); + } + if ((headAssetResponse.headers.get("accept-ranges") ?? "").toLowerCase() !== "bytes") { + throw new Error("Smoke test failed: expected accept-ranges=bytes on HEAD asset response."); + } + if ((headAssetResponse.headers.get("vary") ?? "").toLowerCase() !== "range") { + throw new Error("Smoke test failed: expected vary=range on HEAD asset response."); + } + if (headAssetResponse.headers.get("etag") !== assetEtag) { + throw new Error( + `Smoke test failed: expected HEAD asset ETag ${assetEtag}, got ${String( + headAssetResponse.headers.get("etag"), + )}.`, + ); + } + if (!headAssetResponse.headers.get("last-modified")) { + throw new Error("Smoke test failed: expected last-modified on HEAD asset response."); + } + const headRangedAsset = await fetch(assetUrl, { + method: "HEAD", + headers: { + Range: `bytes=0-${rangeEnd}`, + }, + }); + if (headRangedAsset.status !== 206) { + throw new Error( + `Smoke test failed: expected HEAD ranged asset status 206, received ${headRangedAsset.status}.`, + ); + } + if (headRangedAsset.headers.get("content-range") !== expectedContentRange) { + throw new Error( + `Smoke test failed: expected HEAD ranged content-range ${expectedContentRange}, got ${String( + headRangedAsset.headers.get("content-range"), + )}.`, + ); + } + const headRangedContentLength = Number(headRangedAsset.headers.get("content-length") ?? "0"); + if (!Number.isFinite(headRangedContentLength) || headRangedContentLength !== rangeEnd + 1) { + throw new Error( + `Smoke test failed: expected HEAD ranged content-length ${String( + rangeEnd + 1, + )}, got ${String(headRangedAsset.headers.get("content-length"))}.`, + ); + } + if ((headRangedAsset.headers.get("accept-ranges") ?? "").toLowerCase() !== "bytes") { + throw new Error( + "Smoke test failed: expected accept-ranges=bytes on HEAD ranged asset response.", + ); + } + if ((headRangedAsset.headers.get("vary") ?? "").toLowerCase() !== "range") { + throw new Error("Smoke test failed: expected vary=range on HEAD ranged asset response."); + } + const headIfRangeMismatch = await fetch(assetUrl, { + method: "HEAD", + headers: { + Range: `bytes=0-${rangeEnd}`, + "If-Range": "\"definitely-different-etag\"", + }, + }); + if (headIfRangeMismatch.status !== 200) { + throw new Error( + `Smoke test failed: expected HEAD If-Range mismatch status 200, received ${headIfRangeMismatch.status}.`, + ); + } + if (headIfRangeMismatch.headers.get("content-range") !== null) { + throw new Error("Smoke test failed: expected no content-range on HEAD If-Range mismatch response."); + } + const headUnsatisfiableRange = await fetch(assetUrl, { + method: "HEAD", + headers: { + Range: `bytes=${assetContentLength}-${assetContentLength + 1}`, + }, + }); + if (headUnsatisfiableRange.status !== 416) { + throw new Error( + `Smoke test failed: expected HEAD unsatisfiable range status 416, received ${headUnsatisfiableRange.status}.`, + ); + } + if (headUnsatisfiableRange.headers.get("content-range") !== `bytes */${assetContentLength}`) { + throw new Error( + `Smoke test failed: expected HEAD unsatisfiable content-range bytes */${String( + assetContentLength, + )}, got ${String(headUnsatisfiableRange.headers.get("content-range"))}.`, + ); + } + if (headUnsatisfiableRange.headers.get("etag") !== assetEtag) { + throw new Error("Smoke test failed: expected ETag on HEAD unsatisfiable range response."); + } + if (headUnsatisfiableRange.headers.get("last-modified") !== assetLastModified) { + throw new Error("Smoke test failed: expected Last-Modified on HEAD unsatisfiable range response."); + } + if ((headUnsatisfiableRange.headers.get("accept-ranges") ?? "").toLowerCase() !== "bytes") { + throw new Error( + "Smoke test failed: expected accept-ranges=bytes on HEAD unsatisfiable range response.", + ); + } + if ((headUnsatisfiableRange.headers.get("vary") ?? "").toLowerCase() !== "range") { + throw new Error( + "Smoke test failed: expected vary=range on HEAD unsatisfiable range response.", + ); + } + if ((headUnsatisfiableRange.headers.get("cache-control") ?? "").toLowerCase() !== "no-store") { + throw new Error( + "Smoke test failed: expected cache-control=no-store on HEAD unsatisfiable range response.", + ); + } + const missingAssetUrl = new URL("/assets/missing-bundle.js", parsedAppUrl); + const missingAsset = await fetch(missingAssetUrl); + if (missingAsset.status !== 404) { + throw new Error( + `Smoke test failed: expected missing asset status 404, received ${missingAsset.status}.`, + ); + } + if ((missingAsset.headers.get("x-content-type-options") ?? "").toLowerCase() !== "nosniff") { + throw new Error("Smoke test failed: expected nosniff on missing asset response."); + } + if ((missingAsset.headers.get("x-frame-options") ?? "").toUpperCase() !== "DENY") { + throw new Error("Smoke test failed: expected x-frame-options=DENY on missing asset response."); + } + if ((missingAsset.headers.get("referrer-policy") ?? "").toLowerCase() !== "no-referrer") { + throw new Error("Smoke test failed: expected referrer-policy=no-referrer on missing asset."); + } + if ( + (missingAsset.headers.get("cross-origin-resource-policy") ?? "").toLowerCase() !== + "same-origin" + ) { + throw new Error("Smoke test failed: expected CORP header on missing asset response."); + } + if ( + (missingAsset.headers.get("cross-origin-opener-policy") ?? "").toLowerCase() !== + "same-origin" + ) { + throw new Error("Smoke test failed: expected COOP header on missing asset response."); + } + if ((missingAsset.headers.get("cache-control") ?? "").toLowerCase() !== "no-store") { + throw new Error("Smoke test failed: expected cache-control=no-store on missing asset."); + } + const headMissingAsset = await fetch(missingAssetUrl, { method: "HEAD" }); + if (headMissingAsset.status !== 404) { + throw new Error( + `Smoke test failed: expected HEAD missing asset status 404, received ${headMissingAsset.status}.`, + ); + } + if ((headMissingAsset.headers.get("cache-control") ?? "").toLowerCase() !== "no-store") { + throw new Error( + "Smoke test failed: expected cache-control=no-store on HEAD missing asset response.", + ); + } + const headMissingAssetContentLength = Number( + headMissingAsset.headers.get("content-length") ?? "0", + ); + if (!Number.isFinite(headMissingAssetContentLength) || headMissingAssetContentLength <= 0) { + throw new Error( + `Smoke test failed: expected positive content-length on HEAD missing asset response, got ${String( + headMissingAsset.headers.get("content-length"), + )}.`, + ); + } + if ((headMissingAsset.headers.get("x-content-type-options") ?? "").toLowerCase() !== "nosniff") { + throw new Error("Smoke test failed: expected nosniff on HEAD missing asset response."); + } + if ((headMissingAsset.headers.get("x-frame-options") ?? "").toUpperCase() !== "DENY") { + throw new Error( + "Smoke test failed: expected x-frame-options=DENY on HEAD missing asset response.", + ); + } + if ((headMissingAsset.headers.get("referrer-policy") ?? "").toLowerCase() !== "no-referrer") { + throw new Error( + "Smoke test failed: expected referrer-policy=no-referrer on HEAD missing asset response.", + ); + } + if ( + (headMissingAsset.headers.get("cross-origin-resource-policy") ?? "").toLowerCase() !== + "same-origin" + ) { + throw new Error("Smoke test failed: expected CORP on HEAD missing asset response."); + } + if ( + (headMissingAsset.headers.get("cross-origin-opener-policy") ?? "").toLowerCase() !== + "same-origin" + ) { + throw new Error("Smoke test failed: expected COOP on HEAD missing asset response."); + } + const postPage = await fetch(parsedAppUrl, { + method: "POST", + body: "noop", + }); + if (postPage.status !== 405) { + throw new Error(`Smoke test failed: expected POST status 405, received ${postPage.status}.`); + } + if ((postPage.headers.get("allow") ?? "").toLowerCase() !== "get, head") { + throw new Error( + `Smoke test failed: expected Allow header 'GET, HEAD', got ${String( + postPage.headers.get("allow"), + )}.`, + ); + } + if ((postPage.headers.get("content-type") ?? "").toLowerCase() !== "text/plain; charset=utf-8") { + throw new Error( + `Smoke test failed: expected plain-text POST error content-type, got ${String( + postPage.headers.get("content-type"), + )}.`, + ); + } + const postContentLength = Number(postPage.headers.get("content-length") ?? "0"); + if (!Number.isFinite(postContentLength) || postContentLength <= 0) { + throw new Error( + `Smoke test failed: expected positive content-length on POST response, got ${String( + postPage.headers.get("content-length"), + )}.`, + ); + } + if ((postPage.headers.get("cache-control") ?? "").toLowerCase() !== "no-store") { + throw new Error("Smoke test failed: expected cache-control=no-store on POST response."); + } + if ((postPage.headers.get("x-content-type-options") ?? "").toLowerCase() !== "nosniff") { + throw new Error("Smoke test failed: expected nosniff on POST response."); + } + if ((postPage.headers.get("x-frame-options") ?? "").toUpperCase() !== "DENY") { + throw new Error("Smoke test failed: expected x-frame-options=DENY on POST response."); + } + if ((postPage.headers.get("referrer-policy") ?? "").toLowerCase() !== "no-referrer") { + throw new Error("Smoke test failed: expected referrer-policy=no-referrer on POST response."); + } + if ((postPage.headers.get("cross-origin-resource-policy") ?? "").toLowerCase() !== "same-origin") { + throw new Error("Smoke test failed: expected CORP header on POST response."); + } + if ((postPage.headers.get("cross-origin-opener-policy") ?? "").toLowerCase() !== "same-origin") { + throw new Error("Smoke test failed: expected COOP header on POST response."); + } + const headPage = await fetch(parsedAppUrl, { method: "HEAD" }); + if (headPage.status !== 200) { + throw new Error( + `Smoke test failed: expected HEAD web status 200, received ${headPage.status}.`, + ); + } + if ((headPage.headers.get("cache-control") ?? "").toLowerCase() !== "no-store") { + throw new Error( + `Smoke test failed: expected HEAD cache-control=no-store, got ${String( + headPage.headers.get("cache-control"), + )}.`, + ); + } + if ((headPage.headers.get("x-content-type-options") ?? "").toLowerCase() !== "nosniff") { + throw new Error("Smoke test failed: expected nosniff on HEAD app response."); + } + if ((headPage.headers.get("x-frame-options") ?? "").toUpperCase() !== "DENY") { + throw new Error("Smoke test failed: expected x-frame-options=DENY on HEAD app response."); + } + if ((headPage.headers.get("referrer-policy") ?? "").toLowerCase() !== "no-referrer") { + throw new Error("Smoke test failed: expected referrer-policy=no-referrer on HEAD app response."); + } + if ((headPage.headers.get("cross-origin-resource-policy") ?? "").toLowerCase() !== "same-origin") { + throw new Error("Smoke test failed: expected CORP header on HEAD app response."); + } + if ((headPage.headers.get("cross-origin-opener-policy") ?? "").toLowerCase() !== "same-origin") { + throw new Error("Smoke test failed: expected COOP header on HEAD app response."); + } + if ((headPage.headers.get("accept-ranges") ?? "").toLowerCase() !== "bytes") { + throw new Error("Smoke test failed: expected accept-ranges=bytes on HEAD app response."); + } + if ((headPage.headers.get("vary") ?? "").toLowerCase() !== "range") { + throw new Error("Smoke test failed: expected vary=range on HEAD app response."); + } + const headContentLength = Number(headPage.headers.get("content-length") ?? "0"); + if (!Number.isFinite(headContentLength) || headContentLength <= 0) { + throw new Error( + `Smoke test failed: expected positive content-length for HEAD response, got ${String( + headPage.headers.get("content-length"), + )}.`, + ); + } + + const wsUrl = parsedAppUrl.searchParams.get("ws"); + if (!wsUrl) { + throw new Error("Smoke test failed: launch URL did not include ws runtime parameter."); + } + const parsedWsUrl = new URL(wsUrl); + if (parsedWsUrl.port !== String(backendPort)) { + throw new Error( + `Smoke test failed: expected backend port ${backendPort}, got ${parsedWsUrl.port}.`, + ); + } + if (!parsedWsUrl.searchParams.get("token")) { + throw new Error("Smoke test failed: websocket URL is missing runtime auth token."); + } + const runtimeAuthToken = parsedWsUrl.searchParams.get("token") ?? ""; + const runtimeAuthTokenParam = encodeURIComponent(runtimeAuthToken); + + const unauthorizedWsUrl = `${parsedWsUrl.origin}${parsedWsUrl.pathname}`; + const unauthorizedWs = new WebSocket(unauthorizedWsUrl); + await waitForUnauthorizedCloseWithoutMessages(unauthorizedWs, "missing-token"); + + const missingTokenWithExtraQueryWs = new WebSocket( + `${parsedWsUrl.origin}${parsedWsUrl.pathname}?debug=1`, + ); + await waitForUnauthorizedCloseWithoutMessages( + missingTokenWithExtraQueryWs, + "missing-token-with-extra-query", + ); + + const wrongTokenKeyWs = new WebSocket( + `${parsedWsUrl.origin}${parsedWsUrl.pathname}?Token=${runtimeAuthTokenParam}`, + ); + await waitForUnauthorizedCloseWithoutMessages(wrongTokenKeyWs, "wrong-token-key"); + + const wrongTokenWs = new WebSocket( + `${parsedWsUrl.origin}${parsedWsUrl.pathname}?token=wrong-token`, + ); + await waitForUnauthorizedCloseWithoutMessages(wrongTokenWs, "wrong-token"); + + const emptyTokenWs = new WebSocket(`${parsedWsUrl.origin}${parsedWsUrl.pathname}?token=`); + await waitForUnauthorizedCloseWithoutMessages(emptyTokenWs, "empty-token"); + + const whitespaceTokenWs = new WebSocket( + `${parsedWsUrl.origin}${parsedWsUrl.pathname}?token=%20%20`, + ); + await waitForUnauthorizedCloseWithoutMessages(whitespaceTokenWs, "whitespace-token"); + + const duplicateTokenWs = new WebSocket( + `${parsedWsUrl.origin}${parsedWsUrl.pathname}?token=${runtimeAuthTokenParam}&token=wrong-token`, + ); + await waitForUnauthorizedCloseWithoutMessages(duplicateTokenWs, "duplicate-token"); + + const duplicateSameTokenWs = new WebSocket( + `${parsedWsUrl.origin}${parsedWsUrl.pathname}?token=${runtimeAuthTokenParam}&token=${runtimeAuthTokenParam}`, + ); + await waitForUnauthorizedCloseWithoutMessages(duplicateSameTokenWs, "duplicate-same-token"); + + const extraParamTokenWs = new WebSocket( + `${parsedWsUrl.origin}${parsedWsUrl.pathname}?token=${runtimeAuthTokenParam}&debug=1`, + ); + await waitForUnauthorizedCloseWithoutMessages(extraParamTokenWs, "extra-param-token"); + + const wrongPathTokenWs = new WebSocket( + `${parsedWsUrl.origin}/unexpected?token=${runtimeAuthTokenParam}`, + ); + await waitForUnauthorizedCloseWithoutMessages(wrongPathTokenWs, "wrong-path-token"); + + const ws = new WebSocket(wsUrl); + await new Promise((resolve, reject) => { + let sawHello = false; + let sawHealthResponse = false; + const tryResolve = () => { + if (sawHello && sawHealthResponse) { + clearTimeout(timer); + resolve(); + } + }; + const timer = setTimeout( + () => reject(new Error("Smoke test failed: websocket did not respond in time.")), + 20_000, + ); + ws.addEventListener("open", () => { + ws.send( + JSON.stringify({ + type: "request", + id: "smoke", + method: "app.health", + }), + ); + }); + ws.addEventListener("message", (event) => { + const message = parseWsMessage(event.data); + if (!message) { + return; + } + if (message.type === "hello") { + if (message.version !== 1) { + clearTimeout(timer); + reject( + new Error( + `Smoke test failed: expected hello version 1, got ${String(message.version)}.`, + ), + ); + return; + } + if (message.launchCwd !== appRoot) { + clearTimeout(timer); + reject( + new Error( + `Smoke test failed: expected hello launch cwd ${appRoot}, got ${String( + message.launchCwd, + )}.`, + ), + ); + return; + } + sawHello = true; + tryResolve(); + return; + } + if (message.type !== "response" || message.id !== "smoke" || message.ok !== true) { + return; + } + if (message.result?.status !== "ok") { + return; + } + if (message.result?.launchCwd !== appRoot) { + clearTimeout(timer); + reject( + new Error( + `Smoke test failed: expected launch cwd ${appRoot}, got ${String( + message.result?.launchCwd, + )}.`, + ), + ); + return; + } + if (message.result?.activeClientConnected !== true) { + clearTimeout(timer); + reject( + new Error( + "Smoke test failed: app.health did not report active websocket client connectivity.", + ), + ); + return; + } + if (!Number.isInteger(message.result?.sessionCount) || message.result.sessionCount < 0) { + clearTimeout(timer); + reject( + new Error( + `Smoke test failed: expected non-negative integer sessionCount, got ${String( + message.result?.sessionCount, + )}.`, + ), + ); + return; + } + + sawHealthResponse = true; + tryResolve(); + }); + ws.addEventListener("error", () => { + clearTimeout(timer); + reject(new Error("Smoke test failed: websocket client error.")); + }); + }); + + await new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error("Smoke test failed: binary websocket app.health request timed out.")), + 20_000, + ); + const onMessage = (event) => { + const message = parseWsMessage(event.data); + if (!message) { + return; + } + + if (message.type !== "response" || message.id !== "smoke-binary-health") { + return; + } + if ( + message.ok !== true || + message.result?.status !== "ok" || + message.result?.launchCwd !== appRoot + ) { + clearTimeout(timer); + ws.removeEventListener("message", onMessage); + reject(new Error("Smoke test failed: binary websocket app.health response mismatch.")); + return; + } + + clearTimeout(timer); + ws.removeEventListener("message", onMessage); + resolve(); + }; + + ws.addEventListener("message", onMessage); + const encodedBinaryRequest = new TextEncoder().encode( + JSON.stringify({ + type: "request", + id: "smoke-binary-health", + method: "app.health", + }), + ); + ws.send(encodedBinaryRequest); + }); + + await new Promise((resolve, reject) => { + const timer = setTimeout( + () => + reject( + new Error("Smoke test failed: arraybuffer websocket app.health request timed out."), + ), + 20_000, + ); + const onMessage = (event) => { + const message = parseWsMessage(event.data); + if (!message) { + return; + } + + if (message.type !== "response" || message.id !== "smoke-arraybuffer-health") { + return; + } + if ( + message.ok !== true || + message.result?.status !== "ok" || + message.result?.launchCwd !== appRoot + ) { + clearTimeout(timer); + ws.removeEventListener("message", onMessage); + reject( + new Error("Smoke test failed: arraybuffer websocket app.health response mismatch."), + ); + return; + } + + clearTimeout(timer); + ws.removeEventListener("message", onMessage); + resolve(); + }; + + ws.addEventListener("message", onMessage); + const encodedArrayBufferRequest = new TextEncoder().encode( + JSON.stringify({ + type: "request", + id: "smoke-arraybuffer-health", + method: "app.health", + }), + ); + ws.send(encodedArrayBufferRequest.buffer); + }); + + await new Promise((resolve, reject) => { + const timer = setTimeout( + () => + reject(new Error("Smoke test failed: dataview websocket app.health request timed out.")), + 20_000, + ); + const onMessage = (event) => { + const message = parseWsMessage(event.data); + if (!message) { + return; + } + + if (message.type !== "response" || message.id !== "smoke-dataview-health") { + return; + } + if ( + message.ok !== true || + message.result?.status !== "ok" || + message.result?.launchCwd !== appRoot + ) { + clearTimeout(timer); + ws.removeEventListener("message", onMessage); + reject(new Error("Smoke test failed: dataview websocket app.health response mismatch.")); + return; + } + + clearTimeout(timer); + ws.removeEventListener("message", onMessage); + resolve(); + }; + + ws.addEventListener("message", onMessage); + const encodedDataViewRequest = new TextEncoder().encode( + JSON.stringify({ + type: "request", + id: "smoke-dataview-health", + method: "app.health", + }), + ); + ws.send(new DataView(encodedDataViewRequest.buffer)); + }); + + await new Promise((resolve, reject) => { + const timer = setTimeout( + () => + reject(new Error("Smoke test failed: sliced-uint8 websocket app.health request timed out.")), + 20_000, + ); + const onMessage = (event) => { + const message = parseWsMessage(event.data); + if (!message) { + return; + } + + if (message.type !== "response" || message.id !== "smoke-sliced-uint8-health") { + return; + } + if ( + message.ok !== true || + message.result?.status !== "ok" || + message.result?.launchCwd !== appRoot + ) { + clearTimeout(timer); + ws.removeEventListener("message", onMessage); + reject( + new Error("Smoke test failed: sliced-uint8 websocket app.health response mismatch."), + ); + return; + } + + clearTimeout(timer); + ws.removeEventListener("message", onMessage); + resolve(); + }; + + ws.addEventListener("message", onMessage); + const encodedSlicedUint8Request = new TextEncoder().encode( + JSON.stringify({ + type: "request", + id: "smoke-sliced-uint8-health", + method: "app.health", + }), + ); + const padded = new Uint8Array(encodedSlicedUint8Request.length + 10); + padded.fill(32); + padded.set(encodedSlicedUint8Request, 5); + ws.send(padded.subarray(5, 5 + encodedSlicedUint8Request.length)); + }); + + await new Promise((resolve, reject) => { + const timer = setTimeout( + () => + reject( + new Error("Smoke test failed: sliced-dataview websocket app.health request timed out."), + ), + 20_000, + ); + const onMessage = (event) => { + const message = parseWsMessage(event.data); + if (!message) { + return; + } + + if (message.type !== "response" || message.id !== "smoke-sliced-dataview-health") { + return; + } + if ( + message.ok !== true || + message.result?.status !== "ok" || + message.result?.launchCwd !== appRoot + ) { + clearTimeout(timer); + ws.removeEventListener("message", onMessage); + reject( + new Error( + "Smoke test failed: sliced-dataview websocket app.health response mismatch.", + ), + ); + return; + } + + clearTimeout(timer); + ws.removeEventListener("message", onMessage); + resolve(); + }; + + ws.addEventListener("message", onMessage); + const encodedSlicedDataViewRequest = new TextEncoder().encode( + JSON.stringify({ + type: "request", + id: "smoke-sliced-dataview-health", + method: "app.health", + }), + ); + const padded = new Uint8Array(encodedSlicedDataViewRequest.length + 14); + padded.fill(32); + padded.set(encodedSlicedDataViewRequest, 7); + ws.send(new DataView(padded.buffer, 7, encodedSlicedDataViewRequest.length)); + }); + + await new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error("Smoke test failed: buffer websocket app.health request timed out.")), + 20_000, + ); + const onMessage = (event) => { + const message = parseWsMessage(event.data); + if (!message) { + return; + } + + if (message.type !== "response" || message.id !== "smoke-buffer-health") { + return; + } + if ( + message.ok !== true || + message.result?.status !== "ok" || + message.result?.launchCwd !== appRoot + ) { + clearTimeout(timer); + ws.removeEventListener("message", onMessage); + reject(new Error("Smoke test failed: buffer websocket app.health response mismatch.")); + return; + } + + clearTimeout(timer); + ws.removeEventListener("message", onMessage); + resolve(); + }; + + ws.addEventListener("message", onMessage); + const encodedBufferRequest = Buffer.from( + JSON.stringify({ + type: "request", + id: "smoke-buffer-health", + method: "app.health", + }), + ); + ws.send(encodedBufferRequest); + }); + + ws.send("not-json"); + ws.send(JSON.stringify({ foo: "bar" })); + ws.send( + JSON.stringify({ + type: "request", + id: "smoke-malformed-extra-field", + method: "app.health", + unexpected: true, + }), + ); + ws.send( + JSON.stringify({ + type: "request", + id: "x".repeat(WS_REQUEST_ID_MAX_CHARS + 1), + method: "app.health", + }), + ); + ws.send( + JSON.stringify({ + type: "request", + id: "smoke-malformed-long-method", + method: "m".repeat(WS_METHOD_MAX_CHARS + 1), + }), + ); + const postMalformedHealthResponse = await sendWsRequest(ws, { + id: "smoke-health-after-malformed", + method: "app.health", + }); + if ( + postMalformedHealthResponse.ok !== true || + postMalformedHealthResponse.result?.status !== "ok" || + postMalformedHealthResponse.result?.launchCwd !== appRoot + ) { + throw new Error( + `Smoke test failed: expected healthy response after malformed websocket messages, got ${JSON.stringify( + postMalformedHealthResponse, + )}.`, + ); + } + + const maxLengthHealthResponse = await sendWsRequest(ws, { + id: "r".repeat(WS_REQUEST_ID_MAX_CHARS), + method: "app.health", + }); + if ( + maxLengthHealthResponse.ok !== true || + maxLengthHealthResponse.result?.status !== "ok" || + maxLengthHealthResponse.result?.launchCwd !== appRoot + ) { + throw new Error( + `Smoke test failed: expected healthy response for max-length request id, got ${JSON.stringify( + maxLengthHealthResponse, + )}.`, + ); + } + + const bootstrapResponse = await sendWsRequest(ws, { + id: "smoke-bootstrap", + method: "app.bootstrap", + }); + if ( + bootstrapResponse.ok !== true || + bootstrapResponse.result?.launchCwd !== appRoot || + typeof bootstrapResponse.result?.projectName !== "string" || + bootstrapResponse.result.projectName.length === 0 || + bootstrapResponse.result?.provider !== "codex" || + typeof bootstrapResponse.result?.model !== "string" || + bootstrapResponse.result.model.length === 0 || + bootstrapResponse.result?.bootstrapError !== undefined || + typeof bootstrapResponse.result?.session?.sessionId !== "string" || + bootstrapResponse.result.session.sessionId.length === 0 || + bootstrapResponse.result.session.status !== "ready" || + bootstrapResponse.result.session.threadId !== "thread-fake" + ) { + throw new Error("Smoke test failed: app.bootstrap response payload mismatch."); + } + const bootstrapSessionId = bootstrapResponse.result.session.sessionId; + + const listedSessionsResponse = await sendWsRequest(ws, { + id: "smoke-providers-list-sessions", + method: "providers.listSessions", + }); + if (listedSessionsResponse.ok !== true || !Array.isArray(listedSessionsResponse.result)) { + throw new Error("Smoke test failed: expected providers.listSessions array response."); + } + for (const session of listedSessionsResponse.result) { + if (typeof session?.sessionId !== "string" || session.sessionId.length === 0) { + throw new Error("Smoke test failed: providers.listSessions entry missing sessionId."); + } + } + const listedIncludesBootstrap = listedSessionsResponse.result.some( + (session) => session?.sessionId === bootstrapSessionId, + ); + if (!listedIncludesBootstrap) { + throw new Error( + `Smoke test failed: providers.listSessions did not include bootstrap session ${bootstrapSessionId}.`, + ); + } + + const providerTurnResponse = await sendWsRequest(ws, { + id: "smoke-providers-send-turn-bootstrap", + method: "providers.sendTurn", + params: { + sessionId: bootstrapSessionId, + input: "smoke provider turn", + }, + }); + if ( + providerTurnResponse.ok !== true || + typeof providerTurnResponse.result?.threadId !== "string" || + providerTurnResponse.result.threadId.length === 0 || + typeof providerTurnResponse.result?.turnId !== "string" || + providerTurnResponse.result.turnId.length === 0 + ) { + throw new Error( + `Smoke test failed: expected successful providers.sendTurn payload, got ${JSON.stringify( + providerTurnResponse, + )}.`, + ); + } + + const providerApprovalRequestEvent = await waitForWsEvent( + ws, + (message) => + message.channel === "provider:event" && + message.payload?.kind === "request" && + message.payload?.method === "item/commandExecution/requestApproval" && + message.payload?.sessionId === bootstrapSessionId && + typeof message.payload?.requestId === "string" && + message.payload.requestId.length > 0, + "provider-approval-request", + 20_000, + ); + if ( + providerApprovalRequestEvent.payload?.requestKind !== "command" || + typeof providerApprovalRequestEvent.payload?.requestId !== "string" || + providerApprovalRequestEvent.payload.requestId.length === 0 + ) { + throw new Error( + `Smoke test failed: provider approval request event payload mismatch: ${JSON.stringify( + providerApprovalRequestEvent, + )}.`, + ); + } + + const providerRespondResponse = await sendWsRequest(ws, { + id: "smoke-providers-respond-bootstrap", + method: "providers.respondToRequest", + params: { + sessionId: bootstrapSessionId, + requestId: providerApprovalRequestEvent.payload.requestId, + decision: "accept", + }, + }); + if (providerRespondResponse.ok !== true || providerRespondResponse.result !== null) { + throw new Error( + `Smoke test failed: expected successful providers.respondToRequest payload, got ${JSON.stringify( + providerRespondResponse, + )}.`, + ); + } + + const providerInterruptResponse = await sendWsRequest(ws, { + id: "smoke-providers-interrupt-bootstrap", + method: "providers.interruptTurn", + params: { + sessionId: bootstrapSessionId, + turnId: providerTurnResponse.result.turnId, + }, + }); + if (providerInterruptResponse.ok !== true || providerInterruptResponse.result !== null) { + throw new Error( + `Smoke test failed: expected successful providers.interruptTurn payload, got ${JSON.stringify( + providerInterruptResponse, + )}.`, + ); + } + + const providerStartSessionResponse = await sendWsRequest(ws, { + id: "smoke-providers-start-session", + method: "providers.startSession", + params: { + provider: "codex", + }, + }); + if ( + providerStartSessionResponse.ok !== true || + typeof providerStartSessionResponse.result?.sessionId !== "string" || + providerStartSessionResponse.result.sessionId.length === 0 || + providerStartSessionResponse.result?.provider !== "codex" || + providerStartSessionResponse.result?.status !== "ready" + ) { + throw new Error( + `Smoke test failed: expected successful providers.startSession payload, got ${JSON.stringify( + providerStartSessionResponse, + )}.`, + ); + } + const smokeStartedSessionId = providerStartSessionResponse.result.sessionId; + + const providerStopSessionResponse = await sendWsRequest(ws, { + id: "smoke-providers-stop-started-session", + method: "providers.stopSession", + params: { + sessionId: smokeStartedSessionId, + }, + }); + if (providerStopSessionResponse.ok !== true || providerStopSessionResponse.result !== null) { + throw new Error( + `Smoke test failed: expected successful providers.stopSession payload, got ${JSON.stringify( + providerStopSessionResponse, + )}.`, + ); + } + + const listedSessionsAfterProviderStopResponse = await sendWsRequest(ws, { + id: "smoke-providers-list-sessions-after-stop", + method: "providers.listSessions", + }); + if ( + listedSessionsAfterProviderStopResponse.ok !== true || + !Array.isArray(listedSessionsAfterProviderStopResponse.result) + ) { + throw new Error( + "Smoke test failed: expected providers.listSessions array response after stop.", + ); + } + if ( + listedSessionsAfterProviderStopResponse.result.some( + (session) => session?.sessionId === smokeStartedSessionId, + ) + ) { + throw new Error( + `Smoke test failed: providers.stopSession session ${smokeStartedSessionId} still present.`, + ); + } + + const todoTitle = `Smoke todo ${String(backendPort)}-${String(webPort)}-${Date.now()}`; + const addedTodosResponse = await sendWsRequest(ws, { + id: "smoke-todos-add", + method: "todos.add", + params: { title: todoTitle }, + }); + if (addedTodosResponse.ok !== true || !Array.isArray(addedTodosResponse.result)) { + throw new Error("Smoke test failed: expected successful todos.add response."); + } + + const addedTodo = addedTodosResponse.result.find((todo) => { + return ( + typeof todo?.id === "string" && + todo.id.length > 0 && + todo.title === todoTitle && + todo.completed === false + ); + }); + if (!addedTodo) { + throw new Error("Smoke test failed: could not locate newly-added todo entry."); + } + + const toggledTodosResponse = await sendWsRequest(ws, { + id: "smoke-todos-toggle", + method: "todos.toggle", + params: addedTodo.id, + }); + if (toggledTodosResponse.ok !== true || !Array.isArray(toggledTodosResponse.result)) { + throw new Error("Smoke test failed: expected successful todos.toggle response."); + } + const toggledTodo = toggledTodosResponse.result.find((todo) => todo?.id === addedTodo.id); + if (!toggledTodo || toggledTodo.completed !== true) { + throw new Error("Smoke test failed: expected toggled todo to be completed."); + } + + const removedTodosResponse = await sendWsRequest(ws, { + id: "smoke-todos-remove", + method: "todos.remove", + params: addedTodo.id, + }); + if (removedTodosResponse.ok !== true || !Array.isArray(removedTodosResponse.result)) { + throw new Error("Smoke test failed: expected successful todos.remove response."); + } + if (removedTodosResponse.result.some((todo) => todo?.id === addedTodo.id)) { + throw new Error("Smoke test failed: removed todo is still present in todos list."); + } + + const terminalRunResponse = await sendWsRequest(ws, { + id: "smoke-terminal-run", + method: "terminal.run", + params: { + command: "echo smoke-terminal-ok", + cwd: appRoot, + timeoutMs: 5_000, + }, + }); + if ( + terminalRunResponse.ok !== true || + typeof terminalRunResponse.result?.stdout !== "string" || + !terminalRunResponse.result.stdout.toLowerCase().includes("smoke-terminal-ok") || + terminalRunResponse.result?.stderr !== "" || + terminalRunResponse.result?.timedOut !== false || + terminalRunResponse.result?.code !== 0 + ) { + throw new Error("Smoke test failed: terminal.run response payload mismatch."); + } + + const timedOutTerminalRunResponse = await sendWsRequest(ws, { + id: "smoke-terminal-run-timeout", + method: "terminal.run", + params: { + command: `${JSON.stringify(process.execPath)} -e "setTimeout(() => {}, 2000)"`, + cwd: appRoot, + timeoutMs: 200, + }, + }); + if ( + timedOutTerminalRunResponse.ok !== true || + timedOutTerminalRunResponse.result?.timedOut !== true || + typeof timedOutTerminalRunResponse.result?.stdout !== "string" || + typeof timedOutTerminalRunResponse.result?.stderr !== "string" || + (timedOutTerminalRunResponse.result?.code !== null && + typeof timedOutTerminalRunResponse.result?.code !== "number") + ) { + throw new Error("Smoke test failed: expected terminal.run timeout result payload."); + } + + const spawnedAgentResponse = await sendWsRequest(ws, { + id: "smoke-agent-spawn", + method: "agent.spawn", + params: { + command: process.execPath, + args: [ + "-e", + "setTimeout(() => { process.stdout.write('smoke-agent-output\\n'); }, 300); setTimeout(() => { process.exit(0); }, 700);", + ], + cwd: appRoot, + }, + }); + if (spawnedAgentResponse.ok !== true || typeof spawnedAgentResponse.result !== "string") { + throw new Error("Smoke test failed: expected successful agent.spawn response."); + } + const spawnedAgentSessionId = spawnedAgentResponse.result; + if (spawnedAgentSessionId.length === 0) { + throw new Error("Smoke test failed: agent.spawn returned empty session id."); + } + + const agentOutputEvent = await waitForWsEvent( + ws, + (message) => + message.channel === "agent:output" && + message.payload?.sessionId === spawnedAgentSessionId && + message.payload?.stream === "stdout" && + typeof message.payload?.data === "string" && + message.payload.data.includes("smoke-agent-output"), + "agent-output", + 20_000, + ); + if ( + agentOutputEvent.payload?.sessionId !== spawnedAgentSessionId || + agentOutputEvent.payload?.stream !== "stdout" + ) { + throw new Error("Smoke test failed: unexpected agent output event payload."); + } + + const agentExitEvent = await waitForWsEvent( + ws, + (message) => + message.channel === "agent:exit" && + message.payload?.sessionId === spawnedAgentSessionId && + message.payload?.code === 0, + "agent-exit", + 20_000, + ); + if ( + agentExitEvent.payload?.sessionId !== spawnedAgentSessionId || + agentExitEvent.payload?.code !== 0 + ) { + throw new Error("Smoke test failed: unexpected agent exit event payload."); + } + + const killableAgentResponse = await sendWsRequest(ws, { + id: "smoke-agent-spawn-killable", + method: "agent.spawn", + params: { + command: process.execPath, + args: ["-e", "setInterval(() => {}, 1_000);"], + cwd: appRoot, + }, + }); + if (killableAgentResponse.ok !== true || typeof killableAgentResponse.result !== "string") { + throw new Error("Smoke test failed: expected successful killable agent.spawn response."); + } + const killableAgentSessionId = killableAgentResponse.result; + if (killableAgentSessionId.length === 0) { + throw new Error("Smoke test failed: killable agent session id is empty."); + } + + const killableExitPromise = waitForWsEvent( + ws, + (message) => + message.channel === "agent:exit" && message.payload?.sessionId === killableAgentSessionId, + "agent-kill-exit", + 20_000, + ); + const killResponse = await sendWsRequest(ws, { + id: "smoke-agent-kill", + method: "agent.kill", + params: killableAgentSessionId, + }); + if (killResponse.ok !== true || killResponse.result !== null) { + throw new Error("Smoke test failed: expected successful agent.kill response."); + } + const killableExitEvent = await killableExitPromise; + if ( + killableExitEvent.payload?.sessionId !== killableAgentSessionId || + (killableExitEvent.payload?.code === 0 && killableExitEvent.payload?.signal === null) + ) { + throw new Error("Smoke test failed: expected killed agent exit event payload."); + } + + const writableAgentResponse = await sendWsRequest(ws, { + id: "smoke-agent-spawn-writable", + method: "agent.spawn", + params: { + command: process.execPath, + args: [ + "-e", + "process.stdin.setEncoding('utf8'); process.stdin.once('data', (data) => { process.stdout.write('smoke-agent-write:' + data); process.exit(0); });", + ], + cwd: appRoot, + }, + }); + if (writableAgentResponse.ok !== true || typeof writableAgentResponse.result !== "string") { + throw new Error("Smoke test failed: expected successful writable agent.spawn response."); + } + const writableAgentSessionId = writableAgentResponse.result; + if (writableAgentSessionId.length === 0) { + throw new Error("Smoke test failed: writable agent session id is empty."); + } + + const writableOutputPromise = waitForWsEvent( + ws, + (message) => + message.channel === "agent:output" && + message.payload?.sessionId === writableAgentSessionId && + message.payload?.stream === "stdout" && + typeof message.payload?.data === "string" && + message.payload.data.includes("smoke-agent-write:ping"), + "agent-write-output", + 20_000, + ); + const writableExitPromise = waitForWsEvent( + ws, + (message) => + message.channel === "agent:exit" && + message.payload?.sessionId === writableAgentSessionId && + message.payload?.code === 0, + "agent-write-exit", + 20_000, + ); + const writeResponse = await sendWsRequest(ws, { + id: "smoke-agent-write", + method: "agent.write", + params: { + sessionId: writableAgentSessionId, + data: "ping\n", + }, + }); + if (writeResponse.ok !== true || writeResponse.result !== null) { + throw new Error("Smoke test failed: expected successful agent.write response."); + } + const writableOutputEvent = await writableOutputPromise; + if ( + writableOutputEvent.payload?.sessionId !== writableAgentSessionId || + writableOutputEvent.payload?.stream !== "stdout" + ) { + throw new Error("Smoke test failed: unexpected writable agent output payload."); + } + const writableExitEvent = await writableExitPromise; + if ( + writableExitEvent.payload?.sessionId !== writableAgentSessionId || + writableExitEvent.payload?.code !== 0 + ) { + throw new Error("Smoke test failed: unexpected writable agent exit payload."); + } + + const unknownAgentWriteResponse = await sendWsRequest(ws, { + id: "smoke-agent-write-unknown-session", + method: "agent.write", + params: { + sessionId: "missing-agent-session", + data: "ping\n", + }, + }); + if ( + unknownAgentWriteResponse.ok !== false || + unknownAgentWriteResponse.error?.code !== "request_failed" || + typeof unknownAgentWriteResponse.error?.message !== "string" || + !unknownAgentWriteResponse.error.message.includes("No session") + ) { + throw new Error( + `Smoke test failed: expected structured unknown-session agent.write error, got ${JSON.stringify( + unknownAgentWriteResponse, + )}.`, + ); + } + + const unknownAgentKillResponse = await sendWsRequest(ws, { + id: "smoke-agent-kill-unknown-session", + method: "agent.kill", + params: "missing-agent-session", + }); + if (unknownAgentKillResponse.ok !== true || unknownAgentKillResponse.result !== null) { + throw new Error( + `Smoke test failed: expected successful unknown-session agent.kill no-op, got ${JSON.stringify( + unknownAgentKillResponse, + )}.`, + ); + } + + const duplicateTokenWhileConnectedWs = new WebSocket( + `${parsedWsUrl.origin}${parsedWsUrl.pathname}?token=${runtimeAuthTokenParam}&token=wrong-token`, + ); + await waitForUnauthorizedCloseWithoutMessages( + duplicateTokenWhileConnectedWs, + "duplicate-token-while-connected", + ); + + const duplicateSameTokenWhileConnectedWs = new WebSocket( + `${parsedWsUrl.origin}${parsedWsUrl.pathname}?token=${runtimeAuthTokenParam}&token=${runtimeAuthTokenParam}`, + ); + await waitForUnauthorizedCloseWithoutMessages( + duplicateSameTokenWhileConnectedWs, + "duplicate-same-token-while-connected", + ); + + const extraParamWhileConnectedWs = new WebSocket( + `${parsedWsUrl.origin}${parsedWsUrl.pathname}?token=${runtimeAuthTokenParam}&debug=1`, + ); + await waitForUnauthorizedCloseWithoutMessages( + extraParamWhileConnectedWs, + "extra-param-while-connected", + ); + + const wrongTokenKeyWhileConnectedWs = new WebSocket( + `${parsedWsUrl.origin}${parsedWsUrl.pathname}?Token=${runtimeAuthTokenParam}`, + ); + await waitForUnauthorizedCloseWithoutMessages( + wrongTokenKeyWhileConnectedWs, + "wrong-token-key-while-connected", + ); + + const wrongPathWhileConnectedWs = new WebSocket( + `${parsedWsUrl.origin}/unexpected?token=${runtimeAuthTokenParam}`, + ); + await waitForUnauthorizedCloseWithoutMessages( + wrongPathWhileConnectedWs, + "wrong-path-while-connected", + ); + + await new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error("Smoke test failed: post-unauthorized websocket health request timed out.")), + 20_000, + ); + const onMessage = (event) => { + const message = parseWsMessage(event.data); + if (!message) { + return; + } + + if (message.type !== "response" || message.id !== "smoke-after-unauth") { + return; + } + if (message.ok !== true || message.result?.status !== "ok") { + clearTimeout(timer); + ws.removeEventListener("message", onMessage); + reject(new Error("Smoke test failed: expected successful post-unauthorized health response.")); + return; + } + + clearTimeout(timer); + ws.removeEventListener("message", onMessage); + resolve(); + }; + + ws.addEventListener("message", onMessage); + ws.send( + JSON.stringify({ + type: "request", + id: "smoke-after-unauth", + method: "app.health", + }), + ); + }); + + await new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error("Smoke test failed: unknown-method websocket request timed out.")), + 20_000, + ); + const onMessage = (event) => { + const message = parseWsMessage(event.data); + if (!message) { + return; + } + + if (message.type !== "response" || message.id !== "smoke-unknown-method") { + return; + } + + ws.removeEventListener("message", onMessage); + clearTimeout(timer); + + if ( + message.ok !== false || + message.error?.code !== "request_failed" || + typeof message.error?.message !== "string" || + !message.error.message.includes("Unknown API method") + ) { + reject( + new Error( + `Smoke test failed: expected structured unknown-method error response, got ${JSON.stringify( + message, + )}.`, + ), + ); + return; + } + + resolve(); + }; + + ws.addEventListener("message", onMessage); + ws.send( + JSON.stringify({ + type: "request", + id: "smoke-unknown-method", + method: "unknown.method", + }), + ); + }); + + await new Promise((resolve, reject) => { + const timer = setTimeout( + () => + reject( + new Error("Smoke test failed: max-length unknown-method websocket request timed out."), + ), + 20_000, + ); + const onMessage = (event) => { + const message = parseWsMessage(event.data); + if (!message) { + return; + } + + if (message.type !== "response" || message.id !== "smoke-unknown-method-max-length") { + return; + } + + ws.removeEventListener("message", onMessage); + clearTimeout(timer); + + if ( + message.ok !== false || + message.error?.code !== "request_failed" || + typeof message.error?.message !== "string" || + !message.error.message.includes("Unknown API method") + ) { + reject( + new Error( + `Smoke test failed: expected structured max-length unknown-method error response, got ${JSON.stringify( + message, + )}.`, + ), + ); + return; + } + + resolve(); + }; + + ws.addEventListener("message", onMessage); + ws.send( + JSON.stringify({ + type: "request", + id: "smoke-unknown-method-max-length", + method: "m".repeat(WS_METHOD_MAX_CHARS), + }), + ); + }); + + const invalidShellEditorResponse = await sendWsRequest(ws, { + id: "smoke-shell-invalid-editor", + method: "shell.openInEditor", + params: { + cwd: appRoot, + editor: "unknown-editor", + }, + }); + if ( + invalidShellEditorResponse.ok !== false || + invalidShellEditorResponse.error?.code !== "request_failed" || + typeof invalidShellEditorResponse.error?.message !== "string" || + (!invalidShellEditorResponse.error.message.includes("Unknown editor") && + !invalidShellEditorResponse.error.message.includes("Invalid enum value")) + ) { + throw new Error( + `Smoke test failed: expected structured invalid-editor error response, got ${JSON.stringify( + invalidShellEditorResponse, + )}.`, + ); + } + + const invalidTerminalCwdResponse = await sendWsRequest(ws, { + id: "smoke-terminal-invalid-cwd", + method: "terminal.run", + params: { + command: "echo should-not-run", + cwd: path.join(appRoot, "__missing_smoke_dir__"), + }, + }); + if ( + invalidTerminalCwdResponse.ok !== false || + invalidTerminalCwdResponse.error?.code !== "request_failed" || + typeof invalidTerminalCwdResponse.error?.message !== "string" || + !invalidTerminalCwdResponse.error.message.includes("Working directory does not exist") + ) { + throw new Error("Smoke test failed: expected structured invalid-cwd terminal.run error."); + } + + const invalidShellCwdResponse = await sendWsRequest(ws, { + id: "smoke-shell-invalid-cwd", + method: "shell.openInEditor", + params: { + cwd: path.join(appRoot, "__missing_shell_dir__"), + editor: "cursor", + }, + }); + if ( + invalidShellCwdResponse.ok !== false || + invalidShellCwdResponse.error?.code !== "request_failed" || + typeof invalidShellCwdResponse.error?.message !== "string" || + !invalidShellCwdResponse.error.message.includes("Editor target does not exist") + ) { + throw new Error( + `Smoke test failed: expected structured invalid-cwd shell.openInEditor error, got ${JSON.stringify( + invalidShellCwdResponse, + )}.`, + ); + } + + const invalidTodoAddResponse = await sendWsRequest(ws, { + id: "smoke-todo-add-invalid", + method: "todos.add", + params: { + title: "", + }, + }); + if ( + invalidTodoAddResponse.ok !== false || + invalidTodoAddResponse.error?.code !== "request_failed" || + typeof invalidTodoAddResponse.error?.message !== "string" || + invalidTodoAddResponse.error.message.length === 0 + ) { + throw new Error( + `Smoke test failed: expected structured invalid todos.add error, got ${JSON.stringify( + invalidTodoAddResponse, + )}.`, + ); + } + + const invalidTodoToggleResponse = await sendWsRequest(ws, { + id: "smoke-todo-toggle-invalid", + method: "todos.toggle", + params: "", + }); + if ( + invalidTodoToggleResponse.ok !== false || + invalidTodoToggleResponse.error?.code !== "request_failed" || + typeof invalidTodoToggleResponse.error?.message !== "string" || + invalidTodoToggleResponse.error.message.length === 0 + ) { + throw new Error( + `Smoke test failed: expected structured invalid todos.toggle error, got ${JSON.stringify( + invalidTodoToggleResponse, + )}.`, + ); + } + + const invalidProviderRespondResponse = await sendWsRequest(ws, { + id: "smoke-provider-respond-invalid", + method: "providers.respondToRequest", + params: { + sessionId: "sess-1", + requestId: "req-1", + decision: "invalid-decision", + }, + }); + if ( + invalidProviderRespondResponse.ok !== false || + invalidProviderRespondResponse.error?.code !== "request_failed" || + typeof invalidProviderRespondResponse.error?.message !== "string" || + invalidProviderRespondResponse.error.message.length === 0 + ) { + throw new Error( + `Smoke test failed: expected structured invalid providers.respondToRequest error, got ${JSON.stringify( + invalidProviderRespondResponse, + )}.`, + ); + } + + const invalidProviderSendTurnResponse = await sendWsRequest(ws, { + id: "smoke-provider-send-turn-invalid", + method: "providers.sendTurn", + params: { + sessionId: "sess-1", + input: "", + }, + }); + if ( + invalidProviderSendTurnResponse.ok !== false || + invalidProviderSendTurnResponse.error?.code !== "request_failed" || + typeof invalidProviderSendTurnResponse.error?.message !== "string" || + invalidProviderSendTurnResponse.error.message.length === 0 + ) { + throw new Error( + `Smoke test failed: expected structured invalid providers.sendTurn error, got ${JSON.stringify( + invalidProviderSendTurnResponse, + )}.`, + ); + } + + const invalidProviderStartSessionResponse = await sendWsRequest(ws, { + id: "smoke-provider-start-session-invalid", + method: "providers.startSession", + params: { + provider: "unknown-provider", + }, + }); + if ( + invalidProviderStartSessionResponse.ok !== false || + invalidProviderStartSessionResponse.error?.code !== "request_failed" || + typeof invalidProviderStartSessionResponse.error?.message !== "string" || + invalidProviderStartSessionResponse.error.message.length === 0 + ) { + throw new Error( + `Smoke test failed: expected structured invalid providers.startSession error, got ${JSON.stringify( + invalidProviderStartSessionResponse, + )}.`, + ); + } + + const invalidProviderInterruptResponse = await sendWsRequest(ws, { + id: "smoke-provider-interrupt-invalid", + method: "providers.interruptTurn", + params: { + sessionId: "", + turnId: "turn-1", + }, + }); + if ( + invalidProviderInterruptResponse.ok !== false || + invalidProviderInterruptResponse.error?.code !== "request_failed" || + typeof invalidProviderInterruptResponse.error?.message !== "string" || + invalidProviderInterruptResponse.error.message.length === 0 + ) { + throw new Error( + `Smoke test failed: expected structured invalid providers.interruptTurn error, got ${JSON.stringify( + invalidProviderInterruptResponse, + )}.`, + ); + } + + const invalidProviderStopResponse = await sendWsRequest(ws, { + id: "smoke-provider-stop-invalid", + method: "providers.stopSession", + params: { + sessionId: "", + }, + }); + if ( + invalidProviderStopResponse.ok !== false || + invalidProviderStopResponse.error?.code !== "request_failed" || + typeof invalidProviderStopResponse.error?.message !== "string" || + invalidProviderStopResponse.error.message.length === 0 + ) { + throw new Error( + `Smoke test failed: expected structured invalid providers.stopSession error, got ${JSON.stringify( + invalidProviderStopResponse, + )}.`, + ); + } + + const replacedClientClosed = waitForCloseCode( + ws, + WS_CLOSE_CODES.replacedByNewClient, + "replaced-client", + ); + const replacementWs = new WebSocket(wsUrl); + await new Promise((resolve, reject) => { + let sawHello = false; + let sawHealthResponse = false; + const tryResolve = () => { + if (!sawHello || !sawHealthResponse) { + return; + } + clearTimeout(timer); + replacementWs.removeEventListener("message", onMessage); + resolve(); + }; + const timer = setTimeout( + () => reject(new Error("Smoke test failed: replacement websocket did not respond in time.")), + 20_000, + ); + const onMessage = (event) => { + const message = parseWsMessage(event.data); + if (!message) { + return; + } + + if (message.type === "hello") { + if (message.version !== 1 || message.launchCwd !== appRoot) { + clearTimeout(timer); + replacementWs.removeEventListener("message", onMessage); + reject(new Error("Smoke test failed: replacement websocket hello payload mismatch.")); + return; + } + sawHello = true; + tryResolve(); + return; + } + + if (message.type !== "response" || message.id !== "smoke-replacement-health") { + return; + } + if ( + message.ok !== true || + message.result?.status !== "ok" || + message.result?.launchCwd !== appRoot || + message.result?.activeClientConnected !== true || + !Number.isInteger(message.result?.sessionCount) || + message.result.sessionCount < 0 + ) { + clearTimeout(timer); + replacementWs.removeEventListener("message", onMessage); + reject(new Error("Smoke test failed: replacement websocket health payload mismatch.")); + return; + } + + sawHealthResponse = true; + tryResolve(); + }; + + replacementWs.addEventListener("open", () => { + replacementWs.send( + JSON.stringify({ + type: "request", + id: "smoke-replacement-health", + method: "app.health", + }), + ); + }); + replacementWs.addEventListener("message", onMessage); + replacementWs.addEventListener("error", () => { + clearTimeout(timer); + replacementWs.removeEventListener("message", onMessage); + reject(new Error("Smoke test failed: replacement websocket client error.")); + }); + }); + + await replacedClientClosed; + replacementWs.close(); + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : "Smoke test failed."}\n`); + process.stderr.write(output); + process.exitCode = 1; + } finally { + await terminateProcess(child); + fs.rmSync(fakeCodex.tempDir, { recursive: true, force: true }); + } +} + +await main(); diff --git a/apps/t3/src/cli.test.ts b/apps/t3/src/cli.test.ts new file mode 100644 index 00000000000..39a23ba5136 --- /dev/null +++ b/apps/t3/src/cli.test.ts @@ -0,0 +1,1361 @@ +import { mkdtempSync, mkdirSync, symlinkSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +import { + formatStartupError, + ifMatchSatisfied, + ifModifiedSinceSatisfied, + ifNoneMatchSatisfied, + ifRangeSatisfied, + ifUnmodifiedSinceSatisfied, + parseByteRangeHeader, + parseCliOptions, + readCliVersion, + resolveStaticAssetReadTarget, + resolveStaticAssetPath, + validateLaunchDirectory, +} from "./cli"; + +describe("parseCliOptions", () => { + it("reads defaults from environment variables", () => { + const options = parseCliOptions( + [], + { + T3_BACKEND_PORT: "5001", + T3_WEB_PORT: "5002", + T3_NO_OPEN: "1", + }, + "/workspace", + ); + + expect(options.backendPort).toBe(5001); + expect(options.webPort).toBe(5002); + expect(options.noOpen).toBe(true); + expect(options.launchCwd).toBe("/workspace"); + expect(options.backendPortLocked).toBe(true); + expect(options.webPortLocked).toBe(true); + }); + + it("trims environment variable port values before parsing", () => { + const options = parseCliOptions( + [], + { + T3_BACKEND_PORT: " 5001 ", + T3_WEB_PORT: " 5002 ", + }, + "/workspace", + ); + + expect(options.backendPort).toBe(5001); + expect(options.webPort).toBe(5002); + expect(options.backendPortLocked).toBe(true); + expect(options.webPortLocked).toBe(true); + }); + + it("trims tabbed environment variable port values before parsing", () => { + const options = parseCliOptions( + [], + { + T3_BACKEND_PORT: "\t5001\t", + T3_WEB_PORT: "\t5002\t", + }, + "/workspace", + ); + + expect(options.backendPort).toBe(5001); + expect(options.webPort).toBe(5002); + expect(options.backendPortLocked).toBe(true); + expect(options.webPortLocked).toBe(true); + }); + + it("accepts flexible truthy values for T3_NO_OPEN", () => { + const options = parseCliOptions( + [], + { + T3_NO_OPEN: "true", + }, + "/workspace", + ); + expect(options.noOpen).toBe(true); + }); + + it("treats falsey T3_NO_OPEN values as disabled", () => { + const options = parseCliOptions( + [], + { + T3_NO_OPEN: "off", + }, + "/workspace", + ); + expect(options.noOpen).toBe(false); + }); + + it("treats unknown T3_NO_OPEN values as disabled", () => { + const options = parseCliOptions( + [], + { + T3_NO_OPEN: "sometimes", + }, + "/workspace", + ); + expect(options.noOpen).toBe(false); + }); + + it("accepts trimmed uppercase truthy values for T3_NO_OPEN", () => { + const options = parseCliOptions( + [], + { + T3_NO_OPEN: "\tON\t", + }, + "/workspace", + ); + expect(options.noOpen).toBe(true); + }); + + it("treats trimmed uppercase falsey values for T3_NO_OPEN as disabled", () => { + const options = parseCliOptions( + [], + { + T3_NO_OPEN: "\tOFF\t", + }, + "/workspace", + ); + expect(options.noOpen).toBe(false); + }); + + it("supports explicit equals-style --no-open boolean overrides", () => { + const options = parseCliOptions( + ["--no-open=false"], + { + T3_NO_OPEN: "true", + }, + "/workspace", + ); + expect(options.noOpen).toBe(false); + }); + + it("supports --open to override truthy no-open environment defaults", () => { + const options = parseCliOptions( + ["--open"], + { + T3_NO_OPEN: "true", + }, + "/workspace", + ); + expect(options.noOpen).toBe(false); + }); + + it("supports -o as a short alias for --open", () => { + const options = parseCliOptions( + ["-o"], + { + T3_NO_OPEN: "true", + }, + "/workspace", + ); + expect(options.noOpen).toBe(false); + }); + + it("supports equals-style -o boolean values", () => { + const enabled = parseCliOptions(["-o=true"], { T3_NO_OPEN: "true" }, "/workspace"); + expect(enabled.noOpen).toBe(false); + + const disabled = parseCliOptions(["-o=0"], { T3_NO_OPEN: "false" }, "/workspace"); + expect(disabled.noOpen).toBe(true); + }); + + it("supports equals-style -o off values", () => { + const options = parseCliOptions(["-o=off"], { T3_NO_OPEN: "true" }, "/workspace"); + expect(options.noOpen).toBe(true); + }); + + it("trims equals-style -o values before parsing", () => { + const options = parseCliOptions(["-o= ON "], { T3_NO_OPEN: "true" }, "/workspace"); + expect(options.noOpen).toBe(false); + }); + + it("lets -o=true override truthy no-open environment defaults", () => { + const options = parseCliOptions(["-o=true"], { T3_NO_OPEN: "yes" }, "/workspace"); + expect(options.noOpen).toBe(false); + }); + + it("supports explicit equals-style --open boolean overrides", () => { + const options = parseCliOptions( + ["--open=false"], + { + T3_NO_OPEN: "false", + }, + "/workspace", + ); + expect(options.noOpen).toBe(true); + }); + + it("supports equals-style --open true values", () => { + const options = parseCliOptions( + ["--open=true"], + { + T3_NO_OPEN: "true", + }, + "/workspace", + ); + expect(options.noOpen).toBe(false); + }); + + it("supports numeric equals-style --open values", () => { + const enabled = parseCliOptions(["--open=1"], { T3_NO_OPEN: "true" }, "/workspace"); + expect(enabled.noOpen).toBe(false); + + const disabled = parseCliOptions(["--open=0"], { T3_NO_OPEN: "false" }, "/workspace"); + expect(disabled.noOpen).toBe(true); + }); + + it("supports equals-style --open off values", () => { + const options = parseCliOptions(["--open=off"], {}, "/workspace"); + expect(options.noOpen).toBe(true); + }); + + it("trims equals-style --open values before parsing", () => { + const options = parseCliOptions(["--open= true "], { T3_NO_OPEN: "true" }, "/workspace"); + expect(options.noOpen).toBe(false); + }); + + it("respects last flag when combining --open and --no-open", () => { + const openThenNoOpen = parseCliOptions(["--open", "--no-open"], {}, "/workspace"); + expect(openThenNoOpen.noOpen).toBe(true); + + const noOpenThenOpen = parseCliOptions(["--no-open", "--open"], {}, "/workspace"); + expect(noOpenThenOpen.noOpen).toBe(false); + }); + + it("respects last equals-style open/no-open override", () => { + const noOpenThenOpenFalse = parseCliOptions( + ["--no-open=1", "--open=0"], + {}, + "/workspace", + ); + expect(noOpenThenOpenFalse.noOpen).toBe(true); + + const openFalseThenNoOpenFalse = parseCliOptions( + ["--open=0", "--no-open=0"], + {}, + "/workspace", + ); + expect(openFalseThenNoOpenFalse.noOpen).toBe(false); + }); + + it("throws for invalid equals-style --open values", () => { + expect(() => parseCliOptions(["--open=maybe"], {}, "/workspace")).toThrow( + "Invalid value for --open", + ); + }); + + it("throws for invalid equals-style -o values", () => { + expect(() => parseCliOptions(["-o=maybe"], {}, "/workspace")).toThrow( + "Invalid value for -o", + ); + }); + + it("throws for empty equals-style -o values", () => { + expect(() => parseCliOptions(["-o="], {}, "/workspace")).toThrow("Invalid value for -o"); + }); + + it("throws for empty equals-style --open values", () => { + expect(() => parseCliOptions(["--open="], {}, "/workspace")).toThrow( + "Invalid value for --open", + ); + }); + + it("supports falsey equals-style --no-open values", () => { + const options = parseCliOptions(["--no-open=0"], {}, "/workspace"); + expect(options.noOpen).toBe(false); + }); + + it("throws for invalid equals-style --no-open values", () => { + expect(() => parseCliOptions(["--no-open=maybe"], {}, "/workspace")).toThrow( + "Invalid value for --no-open", + ); + }); + + it("throws for empty equals-style --no-open values", () => { + expect(() => parseCliOptions(["--no-open="], {}, "/workspace")).toThrow( + "Invalid value for --no-open", + ); + }); + + it("parses case-insensitive equals-style --no-open values", () => { + const options = parseCliOptions(["--no-open=ON"], {}, "/workspace"); + expect(options.noOpen).toBe(true); + }); + + it("trims equals-style --no-open values before parsing", () => { + const options = parseCliOptions(["--no-open= no "], { T3_NO_OPEN: "true" }, "/workspace"); + expect(options.noOpen).toBe(false); + }); + + it("supports off as equals-style --no-open false value", () => { + const options = parseCliOptions(["--no-open=off"], { T3_NO_OPEN: "true" }, "/workspace"); + expect(options.noOpen).toBe(false); + }); + + it("supports yes as equals-style --no-open true value", () => { + const options = parseCliOptions(["--no-open=yes"], {}, "/workspace"); + expect(options.noOpen).toBe(true); + }); + + it("parses case-insensitive and trimmed T3_NO_OPEN truthy values", () => { + const options = parseCliOptions( + [], + { + T3_NO_OPEN: " YeS ", + }, + "/workspace", + ); + expect(options.noOpen).toBe(true); + }); + + it("accepts 'on' as T3_NO_OPEN truthy value", () => { + const options = parseCliOptions( + [], + { + T3_NO_OPEN: "on", + }, + "/workspace", + ); + expect(options.noOpen).toBe(true); + }); + + it("accepts 'yes' as T3_NO_OPEN truthy value", () => { + const options = parseCliOptions( + [], + { + T3_NO_OPEN: "yes", + }, + "/workspace", + ); + expect(options.noOpen).toBe(true); + }); + + it("treats non-truthy T3_NO_OPEN values as disabled", () => { + const options = parseCliOptions( + [], + { + T3_NO_OPEN: "0", + }, + "/workspace", + ); + expect(options.noOpen).toBe(false); + }); + + it("treats false as disabled T3_NO_OPEN value", () => { + const options = parseCliOptions( + [], + { + T3_NO_OPEN: "false", + }, + "/workspace", + ); + expect(options.noOpen).toBe(false); + }); + + it("treats unknown T3_NO_OPEN values as disabled", () => { + const options = parseCliOptions( + [], + { + T3_NO_OPEN: "definitely-not-boolean", + }, + "/workspace", + ); + expect(options.noOpen).toBe(false); + }); + + it("treats off as disabled T3_NO_OPEN value", () => { + const options = parseCliOptions( + [], + { + T3_NO_OPEN: "off", + }, + "/workspace", + ); + expect(options.noOpen).toBe(false); + }); + + it("allows command line arguments to override defaults", () => { + const options = parseCliOptions( + [ + "--backend-port", + "7001", + "--web-port=7002", + "--cwd", + "apps/t3", + "--no-open", + ], + {}, + "/workspace", + ); + + expect(options.backendPort).toBe(7001); + expect(options.webPort).toBe(7002); + expect(options.noOpen).toBe(true); + expect(options.launchCwd).toBe(path.resolve("/workspace", "apps/t3")); + expect(options.backendPortLocked).toBe(true); + expect(options.webPortLocked).toBe(true); + }); + + it("accepts a positional cwd argument", () => { + const options = parseCliOptions(["apps/renderer"], {}, "/workspace"); + expect(options.launchCwd).toBe(path.resolve("/workspace", "apps/renderer")); + }); + + it("trims positional cwd arguments before resolving path", () => { + const options = parseCliOptions([" apps/renderer "], {}, "/workspace"); + expect(options.launchCwd).toBe(path.resolve("/workspace", "apps/renderer")); + }); + + it("resolves relative cwd arguments against provided parser cwd", () => { + const options = parseCliOptions(["--cwd", "project"], {}, "/tmp/t3-root"); + expect(options.launchCwd).toBe(path.resolve("/tmp/t3-root", "project")); + }); + + it("rejects whitespace-only positional cwd arguments", () => { + expect(() => parseCliOptions([" "], {}, "/workspace")).toThrow("Invalid value for [path]"); + }); + + it("rejects multiple positional cwd arguments", () => { + expect(() => parseCliOptions(["apps/renderer", "apps/t3"], {}, "/workspace")).toThrow( + "Unexpected positional argument: apps/t3", + ); + }); + + it("supports end-of-options marker for positional cwd values", () => { + const options = parseCliOptions(["--", "-project"], {}, "/workspace"); + expect(options.launchCwd).toBe(path.resolve("/workspace", "-project")); + }); + + it("treats known flag tokens as positional values after end-of-options marker", () => { + const options = parseCliOptions(["--", "--help"], {}, "/workspace"); + expect(options.launchCwd).toBe(path.resolve("/workspace", "--help")); + }); + + it("throws when end-of-options marker has no positional value", () => { + expect(() => parseCliOptions(["--"], {}, "/workspace")).toThrow("Missing value for [path]"); + }); + + it("throws when end-of-options marker has multiple positional values", () => { + expect(() => parseCliOptions(["--", "apps/renderer", "apps/t3"], {}, "/workspace")).toThrow( + "Unexpected positional argument: apps/t3", + ); + }); + + it("throws when end-of-options marker has extra values after flag-like path", () => { + expect(() => parseCliOptions(["--", "--help", "apps/t3"], {}, "/workspace")).toThrow( + "Unexpected positional argument: apps/t3", + ); + }); + + it("throws when end-of-options marker appears after positional cwd is already set", () => { + expect(() => parseCliOptions(["apps/renderer", "--", "apps/t3"], {}, "/workspace")).toThrow( + "Unexpected positional argument: apps/t3", + ); + }); + + it("keeps ports unlocked when using defaults", () => { + const options = parseCliOptions([], {}, "/workspace"); + expect(options.backendPortLocked).toBe(false); + expect(options.webPortLocked).toBe(false); + }); + + it("normalizes the parser cwd for default launch path", () => { + const options = parseCliOptions([], {}, "apps/t3"); + expect(options.launchCwd).toBe(path.resolve("apps/t3")); + }); + + it("supports help flag", () => { + const options = parseCliOptions(["--help"], {}, "/workspace"); + expect(options.showHelp).toBe(true); + }); + + it("supports short help flag alias", () => { + const options = parseCliOptions(["-h"], {}, "/workspace"); + expect(options.showHelp).toBe(true); + }); + + it("supports version flag", () => { + const options = parseCliOptions(["--version"], {}, "/workspace"); + expect(options.showVersion).toBe(true); + }); + + it("supports short version flag alias", () => { + const options = parseCliOptions(["-v"], {}, "/workspace"); + expect(options.showVersion).toBe(true); + }); + + it("throws for invalid explicit port values", () => { + expect(() => parseCliOptions(["--web-port", "nope"], {}, "/workspace")).toThrow( + "Invalid value for --web-port", + ); + }); + + it("rejects non-decimal explicit port values", () => { + expect(() => parseCliOptions(["--backend-port", "0x10"], {}, "/workspace")).toThrow( + "Invalid value for --backend-port", + ); + }); + + it("rejects plus-prefixed explicit port values", () => { + expect(() => parseCliOptions(["--web-port", "+4318"], {}, "/workspace")).toThrow( + "Invalid value for --web-port", + ); + }); + + it("trims whitespace in explicit port values", () => { + const options = parseCliOptions( + ["--backend-port", " 7001 ", "--web-port= 7002 "], + {}, + "/workspace", + ); + expect(options.backendPort).toBe(7001); + expect(options.webPort).toBe(7002); + }); + + it("throws for out-of-range explicit port values", () => { + expect(() => parseCliOptions(["--backend-port", "65536"], {}, "/workspace")).toThrow( + "Invalid value for --backend-port", + ); + }); + + it("throws for zero backend explicit port values", () => { + expect(() => parseCliOptions(["--backend-port", "0"], {}, "/workspace")).toThrow( + "Invalid value for --backend-port", + ); + }); + + it("throws for empty equals-style backend port values", () => { + expect(() => parseCliOptions(["--backend-port="], {}, "/workspace")).toThrow( + "Invalid value for --backend-port", + ); + }); + + it("throws for empty equals-style web port values", () => { + expect(() => parseCliOptions(["--web-port="], {}, "/workspace")).toThrow( + "Invalid value for --web-port", + ); + }); + + it("throws when backend port value is missing", () => { + expect(() => parseCliOptions(["--backend-port"], {}, "/workspace")).toThrow( + "Missing value for --backend-port", + ); + }); + + it("rejects negative backend port values provided as separate args", () => { + expect(() => parseCliOptions(["--backend-port", "-1"], {}, "/workspace")).toThrow( + "Invalid value for --backend-port", + ); + }); + + it("treats known flags after --backend-port as missing values", () => { + expect(() => parseCliOptions(["--backend-port", "--web-port"], {}, "/workspace")).toThrow( + "Missing value for --backend-port", + ); + }); + + it("treats --open after --backend-port as missing value", () => { + expect(() => parseCliOptions(["--backend-port", "--open"], {}, "/workspace")).toThrow( + "Missing value for --backend-port", + ); + }); + + it("treats -o after --backend-port as missing value", () => { + expect(() => parseCliOptions(["--backend-port", "-o"], {}, "/workspace")).toThrow( + "Missing value for --backend-port", + ); + }); + + it("treats -o=bool after --backend-port as missing value", () => { + expect(() => parseCliOptions(["--backend-port", "-o=true"], {}, "/workspace")).toThrow( + "Missing value for --backend-port", + ); + }); + + it("treats equals-style flag tokens after --backend-port as missing values", () => { + expect(() => + parseCliOptions(["--backend-port", "--web-port=7000"], {}, "/workspace"), + ).toThrow("Missing value for --backend-port"); + }); + + it("treats --open equals-style tokens after --backend-port as missing values", () => { + expect(() => + parseCliOptions(["--backend-port", "--open=false"], {}, "/workspace"), + ).toThrow("Missing value for --backend-port"); + }); + + it("treats end-of-options marker after --backend-port as missing value", () => { + expect(() => parseCliOptions(["--backend-port", "--"], {}, "/workspace")).toThrow( + "Missing value for --backend-port", + ); + }); + + it("throws when web port value is missing", () => { + expect(() => parseCliOptions(["--web-port"], {}, "/workspace")).toThrow( + "Missing value for --web-port", + ); + }); + + it("rejects negative web port values provided as separate args", () => { + expect(() => parseCliOptions(["--web-port", "-1"], {}, "/workspace")).toThrow( + "Invalid value for --web-port", + ); + }); + + it("rejects zero web port values provided as separate args", () => { + expect(() => parseCliOptions(["--web-port", "0"], {}, "/workspace")).toThrow( + "Invalid value for --web-port", + ); + }); + + it("treats known flags after --web-port as missing values", () => { + expect(() => parseCliOptions(["--web-port", "--cwd"], {}, "/workspace")).toThrow( + "Missing value for --web-port", + ); + }); + + it("treats --open after --web-port as missing value", () => { + expect(() => parseCliOptions(["--web-port", "--open"], {}, "/workspace")).toThrow( + "Missing value for --web-port", + ); + }); + + it("treats -o after --web-port as missing value", () => { + expect(() => parseCliOptions(["--web-port", "-o"], {}, "/workspace")).toThrow( + "Missing value for --web-port", + ); + }); + + it("treats -o=bool after --web-port as missing value", () => { + expect(() => parseCliOptions(["--web-port", "-o=0"], {}, "/workspace")).toThrow( + "Missing value for --web-port", + ); + }); + + it("treats equals-style flag tokens after --web-port as missing values", () => { + expect(() => + parseCliOptions(["--web-port", "--backend-port=7000"], {}, "/workspace"), + ).toThrow("Missing value for --web-port"); + }); + + it("treats --open equals-style tokens after --web-port as missing values", () => { + expect(() => parseCliOptions(["--web-port", "--open=true"], {}, "/workspace")).toThrow( + "Missing value for --web-port", + ); + }); + + it("treats end-of-options marker after --web-port as missing value", () => { + expect(() => parseCliOptions(["--web-port", "--"], {}, "/workspace")).toThrow( + "Missing value for --web-port", + ); + }); + + it("throws for invalid environment port values", () => { + expect(() => parseCliOptions([], { T3_WEB_PORT: "nope" }, "/workspace")).toThrow( + "Invalid value for T3_WEB_PORT", + ); + }); + + it("rejects non-decimal environment port values", () => { + expect(() => parseCliOptions([], { T3_WEB_PORT: "1e3" }, "/workspace")).toThrow( + "Invalid value for T3_WEB_PORT", + ); + }); + + it("rejects plus-prefixed environment port values", () => { + expect(() => parseCliOptions([], { T3_BACKEND_PORT: "+4317" }, "/workspace")).toThrow( + "Invalid value for T3_BACKEND_PORT", + ); + }); + + it("throws for empty environment port values", () => { + expect(() => parseCliOptions([], { T3_WEB_PORT: "" }, "/workspace")).toThrow( + "Invalid value for T3_WEB_PORT", + ); + }); + + it("throws for out-of-range environment port values", () => { + expect(() => parseCliOptions([], { T3_WEB_PORT: "65536" }, "/workspace")).toThrow( + "Invalid value for T3_WEB_PORT", + ); + }); + + it("throws for zero web environment port values", () => { + expect(() => parseCliOptions([], { T3_WEB_PORT: "0" }, "/workspace")).toThrow( + "Invalid value for T3_WEB_PORT", + ); + }); + + it("throws for out-of-range backend environment port values", () => { + expect(() => parseCliOptions([], { T3_BACKEND_PORT: "65536" }, "/workspace")).toThrow( + "Invalid value for T3_BACKEND_PORT", + ); + }); + + it("throws for zero backend environment port values", () => { + expect(() => parseCliOptions([], { T3_BACKEND_PORT: "0" }, "/workspace")).toThrow( + "Invalid value for T3_BACKEND_PORT", + ); + }); + + it("throws for whitespace-only backend environment port values", () => { + expect(() => parseCliOptions([], { T3_BACKEND_PORT: " " }, "/workspace")).toThrow( + "Invalid value for T3_BACKEND_PORT", + ); + }); + + it("throws for invalid backend environment port values", () => { + expect(() => parseCliOptions([], { T3_BACKEND_PORT: "nope" }, "/workspace")).toThrow( + "Invalid value for T3_BACKEND_PORT", + ); + }); + + it("throws for empty cwd flag values", () => { + expect(() => parseCliOptions(["--cwd="], {}, "/workspace")).toThrow( + "Invalid value for --cwd", + ); + }); + + it("throws for whitespace-only equals-style cwd values", () => { + expect(() => parseCliOptions(["--cwd= "], {}, "/workspace")).toThrow( + "Invalid value for --cwd", + ); + }); + + it("throws when cwd flag value is missing", () => { + expect(() => parseCliOptions(["--cwd"], {}, "/workspace")).toThrow( + "Missing value for --cwd", + ); + }); + + it("throws for whitespace-only cwd flag values", () => { + expect(() => parseCliOptions(["--cwd", " "], {}, "/workspace")).toThrow( + "Invalid value for --cwd", + ); + }); + + it("trims cwd flag values before resolving path", () => { + const options = parseCliOptions(["--cwd", " apps/renderer "], {}, "/workspace"); + expect(options.launchCwd).toBe(path.resolve("/workspace", "apps/renderer")); + }); + + it("accepts dash-prefixed cwd values with separate --cwd argument", () => { + const options = parseCliOptions(["--cwd", "-project"], {}, "/workspace"); + expect(options.launchCwd).toBe(path.resolve("/workspace", "-project")); + }); + + it("accepts dash-prefixed cwd values with equals-style --cwd argument", () => { + const options = parseCliOptions(["--cwd=-project"], {}, "/workspace"); + expect(options.launchCwd).toBe(path.resolve("/workspace", "-project")); + }); + + it("treats known flags after --cwd as missing values", () => { + expect(() => parseCliOptions(["--cwd", "--help"], {}, "/workspace")).toThrow( + "Missing value for --cwd", + ); + }); + + it("treats equals-style flag tokens after --cwd as missing values", () => { + expect(() => parseCliOptions(["--cwd", "--open=false"], {}, "/workspace")).toThrow( + "Missing value for --cwd", + ); + }); + + it("treats --open after --cwd as missing value", () => { + expect(() => parseCliOptions(["--cwd", "--open"], {}, "/workspace")).toThrow( + "Missing value for --cwd", + ); + }); + + it("treats -o after --cwd as missing value", () => { + expect(() => parseCliOptions(["--cwd", "-o"], {}, "/workspace")).toThrow( + "Missing value for --cwd", + ); + }); + + it("treats -o=bool after --cwd as missing value", () => { + expect(() => parseCliOptions(["--cwd", "-o=false"], {}, "/workspace")).toThrow( + "Missing value for --cwd", + ); + }); + + it("treats end-of-options marker after --cwd as missing value", () => { + expect(() => parseCliOptions(["--cwd", "--"], {}, "/workspace")).toThrow( + "Missing value for --cwd", + ); + }); + + it("throws for unknown arguments", () => { + expect(() => parseCliOptions(["--wat"], {}, "/workspace")).toThrow( + "Unknown argument: --wat", + ); + }); + + it("throws for unknown equals-style arguments", () => { + expect(() => parseCliOptions(["--wat=value"], {}, "/workspace")).toThrow( + "Unknown argument: --wat=value", + ); + }); + + it("throws for unknown short arguments", () => { + expect(() => parseCliOptions(["-x"], {}, "/workspace")).toThrow( + "Unknown argument: -x", + ); + }); +}); + +describe("readCliVersion", () => { + it("prefers npm_package_version from environment", () => { + const value = readCliVersion("/tmp/does-not-matter.json", { + npm_package_version: "9.9.9", + }); + expect(value).toBe("9.9.9"); + }); + + it("trims npm_package_version from environment", () => { + const value = readCliVersion("/tmp/does-not-matter.json", { + npm_package_version: " 9.9.9 ", + }); + expect(value).toBe("9.9.9"); + }); + + it("falls back when npm_package_version is whitespace-only", () => { + const tempDir = mkdtempSync(path.join(os.tmpdir(), "t3-version-env-fallback-")); + const packageJsonPath = path.join(tempDir, "package.json"); + writeFileSync(packageJsonPath, JSON.stringify({ version: "1.2.3" }), "utf8"); + const value = readCliVersion(packageJsonPath, { + npm_package_version: " ", + }); + expect(value).toBe("1.2.3"); + }); + + it("falls back to package json version when env is missing", () => { + const tempDir = mkdtempSync(path.join(os.tmpdir(), "t3-version-test-")); + const packageJsonPath = path.join(tempDir, "package.json"); + writeFileSync(packageJsonPath, JSON.stringify({ version: "1.2.3" }), "utf8"); + const value = readCliVersion(packageJsonPath, {}); + expect(value).toBe("1.2.3"); + }); + + it("returns default when env and package file are unavailable", () => { + const value = readCliVersion("/tmp/no-such-package.json", {}); + expect(value).toBe("0.1.0"); + }); + + it("trims package json version before returning", () => { + const tempDir = mkdtempSync(path.join(os.tmpdir(), "t3-version-trim-test-")); + const packageJsonPath = path.join(tempDir, "package.json"); + writeFileSync(packageJsonPath, JSON.stringify({ version: " 1.2.3 " }), "utf8"); + const value = readCliVersion(packageJsonPath, {}); + expect(value).toBe("1.2.3"); + }); +}); + +describe("formatStartupError", () => { + const options = parseCliOptions([], {}, "/workspace"); + + it("returns helpful guidance for port conflicts", () => { + const message = formatStartupError({ code: "EADDRINUSE" }, options); + expect(message).toContain("Port already in use"); + expect(message).toContain("--backend-port"); + }); + + it("returns error message when available", () => { + const message = formatStartupError(new Error("boom"), options); + expect(message).toBe("boom"); + }); + + it("falls back to generic startup error text", () => { + const message = formatStartupError({}, options); + expect(message).toBe("Failed to start t3 runtime."); + }); +}); + +describe("validateLaunchDirectory", () => { + it("returns resolved path for existing directories", () => { + const tempDir = mkdtempSync(path.join(os.tmpdir(), "t3-launch-dir-")); + expect(validateLaunchDirectory(tempDir)).toBe(path.resolve(tempDir)); + }); + + it("resolves relative directory paths against process cwd", () => { + expect(validateLaunchDirectory(".")).toBe(path.resolve(".")); + }); + + it("throws for missing launch directories", () => { + const missing = path.join(os.tmpdir(), `t3-missing-dir-${Date.now()}`); + expect(() => validateLaunchDirectory(missing)).toThrow("Launch directory does not exist"); + }); + + it("throws when launch path points to a file", () => { + const tempDir = mkdtempSync(path.join(os.tmpdir(), "t3-launch-file-")); + const filePath = path.join(tempDir, "not-a-dir.txt"); + writeFileSync(filePath, "content", "utf8"); + expect(() => validateLaunchDirectory(filePath)).toThrow("Launch path is not a directory"); + }); +}); + +describe("resolveStaticAssetPath", () => { + const distRoot = "/workspace/apps/renderer/dist"; + + it("maps root path to index.html", () => { + const result = resolveStaticAssetPath("/", distRoot); + expect(result).toEqual({ + kind: "file", + filePath: path.join(distRoot, "index.html"), + }); + }); + + it("maps request paths without query strings", () => { + const result = resolveStaticAssetPath("/assets/main.js?v=123", distRoot); + expect(result).toEqual({ + kind: "file", + filePath: path.join(distRoot, "assets", "main.js"), + }); + }); + + it("strips hash fragments when resolving request paths", () => { + const result = resolveStaticAssetPath("/assets/main.js#chunk", distRoot); + expect(result).toEqual({ + kind: "file", + filePath: path.join(distRoot, "assets", "main.js"), + }); + }); + + it("strips query strings before hash fragments when resolving paths", () => { + const result = resolveStaticAssetPath("/assets/main.js?v=123#chunk", distRoot); + expect(result).toEqual({ + kind: "file", + filePath: path.join(distRoot, "assets", "main.js"), + }); + }); + + it("supports absolute-form request targets by using URL pathname", () => { + const result = resolveStaticAssetPath("http://127.0.0.1/assets/main.js?x=1", distRoot); + expect(result).toEqual({ + kind: "file", + filePath: path.join(distRoot, "assets", "main.js"), + }); + }); + + it("supports uppercase absolute-form request target schemes", () => { + const result = resolveStaticAssetPath("HTTP://127.0.0.1/assets/main.js?x=1", distRoot); + expect(result).toEqual({ + kind: "file", + filePath: path.join(distRoot, "assets", "main.js"), + }); + }); + + it("rejects malformed absolute-form request targets", () => { + const result = resolveStaticAssetPath("http://%zz", distRoot); + expect(result).toEqual({ + kind: "bad_request", + }); + }); + + it("rejects traversal attempts with decoded dot-dot segments", () => { + const result = resolveStaticAssetPath("/../package.json", distRoot); + expect(result).toEqual({ + kind: "forbidden", + }); + }); + + it("rejects traversal attempts with encoded dot-dot segments", () => { + const result = resolveStaticAssetPath("/%2e%2e/%2e%2e/package.json", distRoot); + expect(result).toEqual({ + kind: "forbidden", + }); + }); + + it("rejects malformed encoded paths", () => { + const result = resolveStaticAssetPath("/%E0%A4%A", distRoot); + expect(result).toEqual({ + kind: "bad_request", + }); + }); + + it("rejects null-byte encoded paths", () => { + const result = resolveStaticAssetPath("/index.html%00", distRoot); + expect(result).toEqual({ + kind: "bad_request", + }); + }); +}); + +describe("parseByteRangeHeader", () => { + it("returns null when range header is missing", () => { + expect(parseByteRangeHeader(undefined, 100)).toBeNull(); + }); + + it("rejects non-integer and negative file sizes", () => { + expect(parseByteRangeHeader("bytes=0-1", -1)).toBe("invalid"); + expect(parseByteRangeHeader("bytes=0-1", 1.5)).toBe("invalid"); + }); + + it("parses explicit start/end ranges", () => { + expect(parseByteRangeHeader("bytes=0-9", 100)).toEqual({ + start: 0, + end: 9, + }); + }); + + it("trims surrounding whitespace before parsing", () => { + expect(parseByteRangeHeader(" bytes=0-9 ", 100)).toEqual({ + start: 0, + end: 9, + }); + }); + + it("tolerates optional whitespace around separators", () => { + expect(parseByteRangeHeader("bytes = 0 - 9", 100)).toEqual({ + start: 0, + end: 9, + }); + }); + + it("tolerates tab whitespace around separators", () => { + expect(parseByteRangeHeader("bytes\t=\t0\t-\t9", 100)).toEqual({ + start: 0, + end: 9, + }); + }); + + it("parses case-insensitive byte-unit prefixes", () => { + expect(parseByteRangeHeader("ByTeS=0-9", 100)).toEqual({ + start: 0, + end: 9, + }); + }); + + it("parses open-ended ranges", () => { + expect(parseByteRangeHeader("bytes=10-", 100)).toEqual({ + start: 10, + end: 99, + }); + }); + + it("parses whitespace-padded open-ended ranges", () => { + expect(parseByteRangeHeader("bytes = 10 - ", 100)).toEqual({ + start: 10, + end: 99, + }); + }); + + it("parses suffix ranges", () => { + expect(parseByteRangeHeader("bytes=-5", 100)).toEqual({ + start: 95, + end: 99, + }); + }); + + it("parses whitespace-padded suffix ranges", () => { + expect(parseByteRangeHeader("bytes = - 5", 100)).toEqual({ + start: 95, + end: 99, + }); + }); + + it("parses mixed-case whitespace-padded suffix ranges", () => { + expect(parseByteRangeHeader("ByTeS = - 5", 100)).toEqual({ + start: 95, + end: 99, + }); + }); + + it("clamps oversized suffix ranges to full file", () => { + expect(parseByteRangeHeader("bytes=-500", 100)).toEqual({ + start: 0, + end: 99, + }); + }); + + it("clamps explicit range ends to file size", () => { + expect(parseByteRangeHeader("bytes=90-1000", 100)).toEqual({ + start: 90, + end: 99, + }); + }); + + it("rejects malformed and unsatisfiable ranges", () => { + expect(parseByteRangeHeader("items=0-1", 100)).toBe("invalid"); + expect(parseByteRangeHeader("bytes=0-1,2-3", 100)).toBe("invalid"); + expect(parseByteRangeHeader("bytes=-", 100)).toBe("invalid"); + expect(parseByteRangeHeader("bytes = - ", 100)).toBe("invalid"); + expect(parseByteRangeHeader("bytes=10-9", 100)).toBe("invalid"); + expect(parseByteRangeHeader("bytes=100-101", 100)).toBe("invalid"); + expect(parseByteRangeHeader("bytes=-0", 100)).toBe("invalid"); + expect(parseByteRangeHeader("bytes=0-0", 0)).toBe("invalid"); + expect(parseByteRangeHeader("bytes=-1", 0)).toBe("invalid"); + }); +}); + +describe("ifNoneMatchSatisfied", () => { + it("returns false when header is missing or empty", () => { + expect(ifNoneMatchSatisfied(undefined, "\"abc\"")).toBe(false); + expect(ifNoneMatchSatisfied(" ", "\"abc\"")).toBe(false); + }); + + it("supports wildcard in array-valued headers", () => { + expect(ifNoneMatchSatisfied(["\"foo\"", "*"], "\"abc\"")).toBe(true); + }); + + it("matches wildcard headers", () => { + expect(ifNoneMatchSatisfied("*", "\"abc\"")).toBe(true); + }); + + it("matches exact etags from comma-separated lists", () => { + expect(ifNoneMatchSatisfied("\"foo\", \"bar\", \"abc\"", "\"abc\"")).toBe(true); + }); + + it("matches weak and strong forms using weak comparison semantics", () => { + expect(ifNoneMatchSatisfied("W/\"abc\"", "\"abc\"")).toBe(true); + expect(ifNoneMatchSatisfied("\"abc\"", "W/\"abc\"")).toBe(true); + expect(ifNoneMatchSatisfied("w/\"abc\"", "\"abc\"")).toBe(true); + expect(ifNoneMatchSatisfied(" W/\"abc\" ", "\"abc\"")).toBe(true); + }); + + it("does not match non-identical etags", () => { + expect(ifNoneMatchSatisfied("\"foo\", \"bar\"", "\"abc\"")).toBe(false); + }); + + it("supports array-valued header representations", () => { + expect(ifNoneMatchSatisfied(["\"foo\"", "\"abc\""], "\"abc\"")).toBe(true); + }); +}); + +describe("ifMatchSatisfied", () => { + it("defaults to true when header is missing", () => { + expect(ifMatchSatisfied(undefined, "\"abc\"")).toBe(true); + }); + + it("treats empty header values as non-restrictive", () => { + expect(ifMatchSatisfied(" ", "\"abc\"")).toBe(true); + }); + + it("supports wildcard and exact strong matches", () => { + expect(ifMatchSatisfied("*", "\"abc\"")).toBe(true); + expect(ifMatchSatisfied("\"abc\"", "\"abc\"")).toBe(true); + }); + + it("supports comma-separated header values", () => { + expect(ifMatchSatisfied("\"foo\", \"abc\"", "\"abc\"")).toBe(true); + expect(ifMatchSatisfied("\"foo\", *", "\"abc\"")).toBe(true); + }); + + it("trims comma-separated header tokens before comparison", () => { + expect(ifMatchSatisfied(" \"foo\" , \"abc\" ", "\"abc\"")).toBe(true); + }); + + it("rejects weak validators and mismatches", () => { + expect(ifMatchSatisfied("W/\"abc\"", "\"abc\"")).toBe(false); + expect(ifMatchSatisfied("w/\"abc\"", "\"abc\"")).toBe(false); + expect(ifMatchSatisfied("\"xyz\"", "\"abc\"")).toBe(false); + }); + + it("rejects weak current etags for strong If-Match comparisons", () => { + expect(ifMatchSatisfied("\"abc\"", "W/\"abc\"")).toBe(false); + }); + + it("supports array-valued headers", () => { + expect(ifMatchSatisfied(["\"foo\"", "\"abc\""], "\"abc\"")).toBe(true); + }); + + it("supports wildcard in array-valued headers", () => { + expect(ifMatchSatisfied(["\"foo\"", "*"], "\"abc\"")).toBe(true); + }); +}); + +describe("ifModifiedSinceSatisfied", () => { + it("returns false for missing or invalid dates", () => { + expect(ifModifiedSinceSatisfied(undefined, Date.now())).toBe(false); + expect(ifModifiedSinceSatisfied("not-a-date", Date.now())).toBe(false); + }); + + it("returns true when resource has not changed since provided timestamp", () => { + const modifiedAt = Date.parse("2026-01-01T12:00:00.100Z"); + expect(ifModifiedSinceSatisfied("Thu, 01 Jan 2026 12:00:00 GMT", modifiedAt)).toBe(true); + }); + + it("returns false when resource changed after provided timestamp", () => { + const modifiedAt = Date.parse("2026-01-01T12:00:01.000Z"); + expect(ifModifiedSinceSatisfied("Thu, 01 Jan 2026 12:00:00 GMT", modifiedAt)).toBe(false); + }); + + it("supports array-valued header representations", () => { + const modifiedAt = Date.parse("2026-01-01T12:00:00.000Z"); + expect(ifModifiedSinceSatisfied(["Thu, 01 Jan 2026 12:00:00 GMT"], modifiedAt)).toBe(true); + }); + + it("uses first array value when multiple are provided", () => { + const modifiedAt = Date.parse("2026-01-01T12:00:01.000Z"); + expect( + ifModifiedSinceSatisfied(["Thu, 01 Jan 2026 12:00:01 GMT", "Thu, 01 Jan 2026 12:00:00 GMT"], modifiedAt), + ).toBe(true); + }); +}); + +describe("ifUnmodifiedSinceSatisfied", () => { + it("defaults to true for missing and invalid values", () => { + expect(ifUnmodifiedSinceSatisfied(undefined, Date.now())).toBe(true); + expect(ifUnmodifiedSinceSatisfied("not-a-date", Date.now())).toBe(true); + }); + + it("returns true when resource has not changed since timestamp", () => { + const modifiedAt = Date.parse("2026-01-01T12:00:00.100Z"); + expect(ifUnmodifiedSinceSatisfied("Thu, 01 Jan 2026 12:00:00 GMT", modifiedAt)).toBe(true); + }); + + it("returns false when resource changed after timestamp", () => { + const modifiedAt = Date.parse("2026-01-01T12:00:01.000Z"); + expect(ifUnmodifiedSinceSatisfied("Thu, 01 Jan 2026 12:00:00 GMT", modifiedAt)).toBe(false); + }); + + it("uses first value when header is provided as an array", () => { + const modifiedAt = Date.parse("2026-01-01T12:00:00.000Z"); + expect( + ifUnmodifiedSinceSatisfied(["Thu, 01 Jan 2026 12:00:00 GMT", "Wed, 31 Dec 2025 12:00:00 GMT"], modifiedAt), + ).toBe(true); + }); + + it("uses first array value even when later values differ", () => { + const modifiedAt = Date.parse("2026-01-01T12:00:01.000Z"); + expect( + ifUnmodifiedSinceSatisfied(["Thu, 01 Jan 2026 12:00:00 GMT", "Thu, 01 Jan 2026 12:00:01 GMT"], modifiedAt), + ).toBe(false); + }); + + it("treats invalid first array value as non-restrictive", () => { + const modifiedAt = Date.parse("2026-01-01T12:00:01.000Z"); + expect( + ifUnmodifiedSinceSatisfied(["not-a-date", "Thu, 01 Jan 2026 12:00:00 GMT"], modifiedAt), + ).toBe(true); + }); +}); + +describe("ifRangeSatisfied", () => { + it("returns true when header is missing", () => { + expect(ifRangeSatisfied(undefined, "\"abc\"", Date.now())).toBe(true); + }); + + it("requires exact strong etag match", () => { + expect(ifRangeSatisfied("\"abc\"", "\"abc\"", Date.now())).toBe(true); + expect(ifRangeSatisfied("\"abc\"", "\"xyz\"", Date.now())).toBe(false); + expect(ifRangeSatisfied("W/\"abc\"", "\"abc\"", Date.now())).toBe(false); + expect(ifRangeSatisfied("w/\"abc\"", "\"abc\"", Date.now())).toBe(false); + }); + + it("rejects wildcard etag values", () => { + expect(ifRangeSatisfied("*", "\"abc\"", Date.now())).toBe(false); + }); + + it("supports HTTP-date if-range values", () => { + const modifiedAt = Date.parse("2026-01-01T12:00:00.100Z"); + expect(ifRangeSatisfied("Thu, 01 Jan 2026 12:00:00 GMT", "\"etag\"", modifiedAt)).toBe(true); + expect( + ifRangeSatisfied("Thu, 01 Jan 2026 11:59:59 GMT", "\"etag\"", Date.parse("2026-01-01T12:00:01.000Z")), + ).toBe(false); + }); + + it("supports array-valued headers and rejects invalid date forms", () => { + const modifiedAt = Date.parse("2026-01-01T12:00:00.000Z"); + expect(ifRangeSatisfied(["\"etag\""], "\"etag\"", modifiedAt)).toBe(true); + expect(ifRangeSatisfied(["\"etag\"", "\"other\""], "\"etag\"", modifiedAt)).toBe(true); + expect(ifRangeSatisfied("not-a-date", "\"etag\"", modifiedAt)).toBe(false); + }); +}); + +describe("resolveStaticAssetReadTarget", () => { + it("falls back to index for unknown routes", () => { + const tempDir = mkdtempSync(path.join(os.tmpdir(), "t3-static-route-")); + writeFileSync(path.join(tempDir, "index.html"), "ok", "utf8"); + + const result = resolveStaticAssetReadTarget("/unknown/route", tempDir); + expect(result).toEqual({ + kind: "file", + filePath: path.join(tempDir, "index.html"), + }); + }); + + it("returns concrete file paths for existing assets", () => { + const tempDir = mkdtempSync(path.join(os.tmpdir(), "t3-static-asset-")); + const assetsDir = path.join(tempDir, "assets"); + mkdirSync(assetsDir, { recursive: true }); + writeFileSync(path.join(tempDir, "index.html"), "ok", "utf8"); + writeFileSync(path.join(assetsDir, "main.js"), "console.log('ok')", "utf8"); + + const result = resolveStaticAssetReadTarget("/assets/main.js", tempDir); + expect(result).toEqual({ + kind: "file", + filePath: path.join(assetsDir, "main.js"), + }); + }); + + it("resolves absolute-form request targets to in-dist assets", () => { + const tempDir = mkdtempSync(path.join(os.tmpdir(), "t3-static-absolute-target-")); + const assetsDir = path.join(tempDir, "assets"); + mkdirSync(assetsDir, { recursive: true }); + writeFileSync(path.join(tempDir, "index.html"), "ok", "utf8"); + writeFileSync(path.join(assetsDir, "main.js"), "console.log('ok')", "utf8"); + + const result = resolveStaticAssetReadTarget( + "http://127.0.0.1/assets/main.js?cache=1", + tempDir, + ); + expect(result).toEqual({ + kind: "file", + filePath: path.join(assetsDir, "main.js"), + }); + }); + + it("returns not_found for missing asset files with extensions", () => { + const tempDir = mkdtempSync(path.join(os.tmpdir(), "t3-static-missing-asset-")); + writeFileSync(path.join(tempDir, "index.html"), "ok", "utf8"); + + const result = resolveStaticAssetReadTarget("/assets/missing.js", tempDir); + expect(result).toEqual({ + kind: "not_found", + }); + }); + + it("rejects symlinked files that escape the dist directory", () => { + const tempDir = mkdtempSync(path.join(os.tmpdir(), "t3-static-symlink-")); + writeFileSync(path.join(tempDir, "index.html"), "ok", "utf8"); + const outsideFile = path.join(os.tmpdir(), `t3-outside-${Date.now()}.txt`); + writeFileSync(outsideFile, "outside", "utf8"); + symlinkSync(outsideFile, path.join(tempDir, "outside.txt")); + + const result = resolveStaticAssetReadTarget("/outside.txt", tempDir); + expect(result).toEqual({ + kind: "forbidden", + }); + }); + + it("allows symlinked files that resolve inside dist directory", () => { + const tempDir = mkdtempSync(path.join(os.tmpdir(), "t3-static-symlink-inside-")); + const assetsDir = path.join(tempDir, "assets"); + mkdirSync(assetsDir, { recursive: true }); + writeFileSync(path.join(tempDir, "index.html"), "ok", "utf8"); + writeFileSync(path.join(assetsDir, "main.js"), "console.log('ok')", "utf8"); + symlinkSync(path.join(assetsDir, "main.js"), path.join(tempDir, "linked-main.js")); + + const result = resolveStaticAssetReadTarget("/linked-main.js", tempDir); + expect(result).toEqual({ + kind: "file", + filePath: path.join(assetsDir, "main.js"), + }); + }); + + it("returns not_found when spa fallback file is missing", () => { + const tempDir = mkdtempSync(path.join(os.tmpdir(), "t3-static-missing-index-")); + const result = resolveStaticAssetReadTarget("/missing.js", tempDir); + expect(result).toEqual({ + kind: "not_found", + }); + }); +}); diff --git a/apps/t3/src/cli.ts b/apps/t3/src/cli.ts new file mode 100644 index 00000000000..afea0dd6619 --- /dev/null +++ b/apps/t3/src/cli.ts @@ -0,0 +1,1175 @@ +#!/usr/bin/env node +import { spawn, spawnSync } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import fs from "node:fs"; +import { createServer, type ServerResponse } from "node:http"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { startRuntimeApiServer } from "./runtimeApiServer"; + +const DEFAULT_BACKEND_PORT = 4317; +const DEFAULT_WEB_PORT = 4318; +const DEFAULT_CLI_VERSION = "0.1.0"; +const TRUTHY_BOOLEAN_VALUES = new Set(["1", "true", "yes", "on"]); +const FALSY_BOOLEAN_VALUES = new Set(["0", "false", "no", "off"]); + +function parseExplicitPort(value: string, key: string): number { + const normalized = value.trim(); + if (!/^\d+$/.test(normalized)) { + throw new Error(`Invalid value for ${key}: '${value}'. Expected an integer between 1 and 65535.`); + } + + const parsed = Number.parseInt(normalized, 10); + if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 65_535) { + throw new Error(`Invalid value for ${key}: '${value}'. Expected an integer between 1 and 65535.`); + } + + return parsed; +} + +function parseEnvPort( + value: string | undefined, + key: string, + fallback: number, +): { port: number; locked: boolean } { + if (value === undefined) { + return { port: fallback, locked: false }; + } + + const trimmed = value.trim(); + if (trimmed.length === 0) { + throw new Error(`Invalid value for ${key}: expected a non-empty port.`); + } + + return { + port: parseExplicitPort(trimmed, key), + locked: true, + }; +} + +function parseExplicitPath(value: string, key: string, cwd: string): string { + const trimmed = value.trim(); + if (trimmed.length === 0) { + throw new Error(`Invalid value for ${key}: expected a non-empty path.`); + } + + return path.resolve(cwd, trimmed); +} + +function parseBooleanEnvFlag(value: string | undefined): boolean { + if (!value) { + return false; + } + + const normalized = value.trim().toLowerCase(); + return TRUTHY_BOOLEAN_VALUES.has(normalized); +} + +function parseBooleanCliValue(value: string, key: string): boolean { + const normalized = value.trim().toLowerCase(); + if (TRUTHY_BOOLEAN_VALUES.has(normalized)) { + return true; + } + if (FALSY_BOOLEAN_VALUES.has(normalized)) { + return false; + } + + throw new Error( + `Invalid value for ${key}: '${value}'. Expected one of true/false, 1/0, yes/no, on/off.`, + ); +} + +interface CliOptions { + backendPort: number; + webPort: number; + launchCwd: string; + noOpen: boolean; + showHelp: boolean; + showVersion: boolean; + backendPortLocked: boolean; + webPortLocked: boolean; +} + +interface StartupErrorShape { + code?: unknown; + message?: unknown; +} + +const KNOWN_CLI_FLAGS = new Set([ + "--help", + "-h", + "--version", + "-v", + "--open", + "-o", + "--no-open", + "--backend-port", + "--web-port", + "--cwd", + "--", +]); + +function isKnownFlagToken(value: string): boolean { + if (KNOWN_CLI_FLAGS.has(value)) { + return true; + } + + for (const flag of KNOWN_CLI_FLAGS) { + if (flag === "--") { + continue; + } + if (value.startsWith(`${flag}=`)) { + return true; + } + } + + return false; +} + +function readArgValue( + args: string[], + index: number, + key: string, + options?: { + allowDashPrefixed?: boolean; + }, +): string { + const value = args[index + 1]; + if (!value) { + throw new Error(`Missing value for ${key}.`); + } + + const dashPrefixed = value.startsWith("-"); + if (!options?.allowDashPrefixed && dashPrefixed) { + throw new Error(`Missing value for ${key}.`); + } + if (options?.allowDashPrefixed && dashPrefixed && isKnownFlagToken(value)) { + throw new Error(`Missing value for ${key}.`); + } + + return value; +} + +export function formatStartupError(error: unknown, options: CliOptions): string { + const candidate = error as StartupErrorShape; + const code = typeof candidate?.code === "string" ? candidate.code : undefined; + + if (code === "EADDRINUSE") { + return `Port already in use. Try --backend-port ${options.backendPort + 1} --web-port ${options.webPort + 1} or stop the conflicting process.`; + } + + if (error instanceof Error && error.message.length > 0) { + return error.message; + } + + return "Failed to start t3 runtime."; +} + +function isPortInUseError(error: unknown): boolean { + const candidate = error as StartupErrorShape; + return candidate?.code === "EADDRINUSE"; +} + +export function parseCliOptions( + argv: string[], + env: NodeJS.ProcessEnv, + cwd: string, +): CliOptions { + const parserCwd = path.resolve(cwd); + const backendPortFromEnv = parseEnvPort( + env.T3_BACKEND_PORT, + "T3_BACKEND_PORT", + DEFAULT_BACKEND_PORT, + ); + const webPortFromEnv = parseEnvPort(env.T3_WEB_PORT, "T3_WEB_PORT", DEFAULT_WEB_PORT); + + let backendPort = backendPortFromEnv.port; + let webPort = webPortFromEnv.port; + let backendPortLocked = backendPortFromEnv.locked; + let webPortLocked = webPortFromEnv.locked; + let launchCwd = parserCwd; + let usedPositionalCwd = false; + let noOpen = parseBooleanEnvFlag(env.T3_NO_OPEN); + let showHelp = false; + let showVersion = false; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (!arg) continue; + + if (arg === "--") { + const positionalArgs = argv.slice(index + 1); + const [first, ...rest] = positionalArgs; + if (!first) { + throw new Error("Missing value for [path]."); + } + if (usedPositionalCwd) { + throw new Error(`Unexpected positional argument: ${first}`); + } + if (rest.length > 0) { + throw new Error(`Unexpected positional argument: ${rest[0]}`); + } + launchCwd = parseExplicitPath(first, "[path]", parserCwd); + usedPositionalCwd = true; + break; + } + + if (arg === "--help" || arg === "-h") { + showHelp = true; + continue; + } + + if (arg === "--version" || arg === "-v") { + showVersion = true; + continue; + } + + if (arg === "--no-open") { + noOpen = true; + continue; + } + + if (arg === "--open" || arg === "-o") { + noOpen = false; + continue; + } + + if (arg.startsWith("-o=")) { + noOpen = !parseBooleanCliValue(arg.split("=")[1] ?? "", "-o"); + continue; + } + + if (arg.startsWith("--open=")) { + noOpen = !parseBooleanCliValue(arg.split("=")[1] ?? "", "--open"); + continue; + } + + if (arg.startsWith("--no-open=")) { + noOpen = parseBooleanCliValue(arg.split("=")[1] ?? "", "--no-open"); + continue; + } + + if (arg.startsWith("--backend-port=")) { + backendPort = parseExplicitPort(arg.split("=")[1] ?? "", "--backend-port"); + backendPortLocked = true; + continue; + } + + if (arg === "--backend-port") { + backendPort = parseExplicitPort( + readArgValue(argv, index, "--backend-port", { allowDashPrefixed: true }), + "--backend-port", + ); + backendPortLocked = true; + index += 1; + continue; + } + + if (arg.startsWith("--web-port=")) { + webPort = parseExplicitPort(arg.split("=")[1] ?? "", "--web-port"); + webPortLocked = true; + continue; + } + + if (arg === "--web-port") { + webPort = parseExplicitPort( + readArgValue(argv, index, "--web-port", { allowDashPrefixed: true }), + "--web-port", + ); + webPortLocked = true; + index += 1; + continue; + } + + if (arg.startsWith("--cwd=")) { + launchCwd = parseExplicitPath(arg.split("=")[1] ?? "", "--cwd", parserCwd); + continue; + } + + if (arg === "--cwd") { + launchCwd = parseExplicitPath( + readArgValue(argv, index, "--cwd", { allowDashPrefixed: true }), + "--cwd", + parserCwd, + ); + index += 1; + continue; + } + + if (!arg.startsWith("-")) { + if (usedPositionalCwd) { + throw new Error(`Unexpected positional argument: ${arg}`); + } + launchCwd = parseExplicitPath(arg, "[path]", parserCwd); + usedPositionalCwd = true; + continue; + } + + throw new Error(`Unknown argument: ${arg}`); + } + + return { + backendPort, + webPort, + launchCwd, + noOpen, + showHelp, + showVersion, + backendPortLocked, + webPortLocked, + }; +} + +function printHelp(): void { + process.stdout.write( + [ + "Usage: t3 [options]", + "", + "Options:", + " --no-open[=bool] Start runtime without opening browser (or explicitly set bool)", + " -o, --open[=bool] Force browser auto-open (or explicitly set bool)", + " --backend-port Override WebSocket API port (default: 4317)", + " --web-port Override web UI port (default: 4318)", + " --cwd Launch project directory (default: current directory)", + " [path] Positional shorthand for --cwd ", + " -- End options (useful for paths beginning with '-')", + " -v, --version Print CLI version", + " -h, --help Show this help message", + "", + "Environment variables:", + " T3_NO_OPEN=1|true|yes|on Disable browser auto-open", + " T3_BACKEND_PORT= Default backend port", + " T3_WEB_PORT= Default web UI port", + "", + ].join("\n"), + ); +} + +function openBrowser(url: string, noOpen: boolean): void { + if (noOpen) { + return; + } + + const command = + process.platform === "win32" ? "cmd" : process.platform === "darwin" ? "open" : "xdg-open"; + const args = process.platform === "win32" ? ["/c", "start", "", url] : [url]; + + const child = spawn(command, args, { + detached: true, + stdio: "ignore", + }); + child.on("error", () => { + // Best-effort browser launch; keep runtime alive even when opener is unavailable. + }); + child.unref(); +} + +export function readCliVersion( + packageJsonPath = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", + "..", + "..", + "package.json", + ), + env = process.env, +): string { + const envVersion = env.npm_package_version; + if (typeof envVersion === "string") { + const trimmed = envVersion.trim(); + if (trimmed.length > 0) { + return trimmed; + } + } + + try { + const raw = fs.readFileSync(packageJsonPath, "utf8"); + const parsed = JSON.parse(raw) as { version?: unknown }; + if (typeof parsed.version === "string") { + const trimmed = parsed.version.trim(); + if (trimmed.length > 0) { + return trimmed; + } + } + } catch { + // Ignore read/parse failures and return fallback. + } + + return DEFAULT_CLI_VERSION; +} + +export function validateLaunchDirectory(launchCwd: string): string { + const resolved = path.resolve(launchCwd); + let stats: fs.Stats; + try { + stats = fs.statSync(resolved); + } catch (error) { + const code = (error as NodeJS.ErrnoException | undefined)?.code; + if (code && code !== "ENOENT") { + throw new Error(`Failed to access launch directory: ${resolved} (${code})`, { + cause: error, + }); + } + throw new Error(`Launch directory does not exist: ${resolved}`, { + cause: error, + }); + } + + if (!stats.isDirectory()) { + throw new Error(`Launch path is not a directory: ${resolved}`); + } + + return resolved; +} + +async function runCli(options: CliOptions): Promise { + const launchCwd = validateLaunchDirectory(options.launchCwd); + const authToken = randomUUID(); + const runtimeServer = await startRuntimeApiServer({ + port: options.backendPort, + launchCwd, + authToken, + }); + + const __dirname = path.dirname(fileURLToPath(import.meta.url)); + const rendererRoot = path.resolve(__dirname, "../../renderer"); + ensureRendererBuild(rendererRoot); + + let staticServer: + | { + close: () => Promise; + } + | undefined; + try { + staticServer = await startStaticWebServer(path.join(rendererRoot, "dist"), options.webPort); + } catch (error) { + await runtimeServer.close(); + throw error; + } + + const wsParam = encodeURIComponent(runtimeServer.wsUrl); + const appUrl = `http://127.0.0.1:${options.webPort}?ws=${wsParam}`; + openBrowser(appUrl, options.noOpen); + + process.stdout.write(`CodeThing is running at ${appUrl}\n`); + + let shutdownStarted = false; + const shutdown = async () => { + if (shutdownStarted) { + return; + } + shutdownStarted = true; + await Promise.all([staticServer.close(), runtimeServer.close()]); + process.exit(0); + }; + + process.on("SIGINT", () => { + void shutdown(); + }); + process.on("SIGTERM", () => { + void shutdown(); + }); +} + +function ensureRendererBuild(rendererRoot: string): void { + const distPath = path.join(rendererRoot, "dist", "index.html"); + if (fs.existsSync(distPath)) { + return; + } + + const bunPath = process.env.BUN_BIN ?? "bun"; + const build = spawnSync(bunPath, ["run", "--cwd", rendererRoot, "build"], { + stdio: "inherit", + env: { + ...process.env, + PATH: `${process.env.HOME}/.bun/bin:${process.env.PATH}`, + }, + }); + if (build.status !== 0) { + throw new Error("Failed to build renderer assets."); + } +} + +function contentTypeFor(filePath: string): string { + if (filePath.endsWith(".html")) return "text/html; charset=utf-8"; + if (filePath.endsWith(".js")) return "application/javascript; charset=utf-8"; + if (filePath.endsWith(".css")) return "text/css; charset=utf-8"; + if (filePath.endsWith(".json")) return "application/json; charset=utf-8"; + if (filePath.endsWith(".svg")) return "image/svg+xml"; + if (filePath.endsWith(".png")) return "image/png"; + if (filePath.endsWith(".jpg") || filePath.endsWith(".jpeg")) return "image/jpeg"; + if (filePath.endsWith(".woff2")) return "font/woff2"; + if (filePath.endsWith(".woff")) return "font/woff"; + return "application/octet-stream"; +} + +function cacheControlFor(filePath: string): string { + if (filePath.endsWith(".html")) { + return "no-store"; + } + + return "public, max-age=31536000, immutable"; +} + +function staticEtagFor(stats: fs.Stats): string { + return `"${stats.size.toString(16)}-${Math.trunc(stats.mtimeMs).toString(16)}"`; +} + +function isWeakEtag(value: string): boolean { + return /^w\//i.test(value.trim()); +} + +function normalizeWeakEtag(etag: string): string { + const trimmed = etag.trim(); + return isWeakEtag(trimmed) ? trimmed.slice(2).trim() : trimmed; +} + +function normalizeEtagHeaderValue(value: string | string[] | undefined): string[] { + if (!value) { + return []; + } + + const rawHeaderValue = Array.isArray(value) ? value.join(",") : value; + return rawHeaderValue + .split(",") + .map((token) => token.trim()) + .filter((token) => token.length > 0); +} + +function firstHeaderValue(value: string | string[] | undefined): string | undefined { + if (Array.isArray(value)) { + return value[0]; + } + + return value; +} + +export function ifNoneMatchSatisfied( + ifNoneMatchHeader: string | string[] | undefined, + etag: string, +): boolean { + const candidates = normalizeEtagHeaderValue(ifNoneMatchHeader); + if (candidates.length === 0) { + return false; + } + + if (candidates.includes("*")) { + return true; + } + + const normalizedEtag = normalizeWeakEtag(etag); + return candidates.some((candidate) => normalizeWeakEtag(candidate) === normalizedEtag); +} + +export function ifMatchSatisfied(ifMatchHeader: string | string[] | undefined, etag: string): boolean { + const candidates = normalizeEtagHeaderValue(ifMatchHeader); + if (candidates.length === 0) { + return true; + } + + if (candidates.includes("*")) { + return true; + } + + const normalizedCurrentTag = etag.trim(); + if (isWeakEtag(normalizedCurrentTag)) { + return false; + } + + return candidates.some((candidate) => { + const normalizedCandidate = candidate.trim(); + if (isWeakEtag(normalizedCandidate)) { + return false; + } + + return normalizedCandidate === normalizedCurrentTag; + }); +} + +export function ifModifiedSinceSatisfied( + ifModifiedSinceHeader: string | string[] | undefined, + modifiedAtMs: number, +): boolean { + if (!ifModifiedSinceHeader) { + return false; + } + + const rawHeaderValue = firstHeaderValue(ifModifiedSinceHeader); + if (!rawHeaderValue) { + return false; + } + + const parsedTimestamp = Date.parse(rawHeaderValue); + if (!Number.isFinite(parsedTimestamp)) { + return false; + } + + return Math.floor(modifiedAtMs / 1_000) <= Math.floor(parsedTimestamp / 1_000); +} + +export function ifUnmodifiedSinceSatisfied( + ifUnmodifiedSinceHeader: string | string[] | undefined, + modifiedAtMs: number, +): boolean { + if (!ifUnmodifiedSinceHeader) { + return true; + } + + const rawHeaderValue = firstHeaderValue(ifUnmodifiedSinceHeader); + if (!rawHeaderValue) { + return true; + } + + const parsedTimestamp = Date.parse(rawHeaderValue); + if (!Number.isFinite(parsedTimestamp)) { + return true; + } + + return Math.floor(modifiedAtMs / 1_000) <= Math.floor(parsedTimestamp / 1_000); +} + +export function ifRangeSatisfied( + ifRangeHeader: string | string[] | undefined, + etag: string, + modifiedAtMs: number, +): boolean { + if (!ifRangeHeader) { + return true; + } + + const rawHeaderValue = firstHeaderValue(ifRangeHeader); + if (!rawHeaderValue) { + return true; + } + + const trimmed = rawHeaderValue.trim(); + if (trimmed.startsWith("\"")) { + return trimmed === etag; + } + if (isWeakEtag(trimmed)) { + return false; + } + + return ifModifiedSinceSatisfied(trimmed, modifiedAtMs); +} + +export function parseByteRangeHeader( + rangeHeaderValue: string | undefined, + fileSize: number, +): { start: number; end: number } | null | "invalid" { + if (!rangeHeaderValue) { + return null; + } + if (!Number.isInteger(fileSize) || fileSize < 0) { + return "invalid"; + } + + const match = rangeHeaderValue.trim().match(/^bytes\s*=\s*(\d*)\s*-\s*(\d*)$/i); + if (!match) { + return "invalid"; + } + + const startRaw = match[1] ?? ""; + const endRaw = match[2] ?? ""; + if (startRaw.length === 0 && endRaw.length === 0) { + return "invalid"; + } + + if (startRaw.length === 0) { + const suffixLength = Number.parseInt(endRaw, 10); + if (!Number.isInteger(suffixLength) || suffixLength <= 0 || fileSize === 0) { + return "invalid"; + } + const start = Math.max(fileSize - suffixLength, 0); + return { + start, + end: fileSize - 1, + }; + } + + const start = Number.parseInt(startRaw, 10); + if (!Number.isInteger(start) || start < 0 || start >= fileSize) { + return "invalid"; + } + + const end = + endRaw.length === 0 + ? fileSize - 1 + : (() => { + const parsed = Number.parseInt(endRaw, 10); + if (!Number.isInteger(parsed) || parsed < 0) { + return null; + } + return Math.min(parsed, fileSize - 1); + })(); + if (end === null || start > end) { + return "invalid"; + } + + return { start, end }; +} + +function isPathInside(parent: string, child: string): boolean { + const relative = path.relative(parent, child); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +} + +export function resolveStaticAssetPath( + requestUrl: string | undefined, + distRoot: string, +): { kind: "file"; filePath: string } | { kind: "forbidden" } | { kind: "bad_request" } { + const rawPath = (() => { + if (!requestUrl) { + return "/"; + } + + if (/^https?:\/\//i.test(requestUrl)) { + try { + return new URL(requestUrl).pathname; + } catch { + return null; + } + } + + return requestUrl.split(/[?#]/, 1)[0] ?? "/"; + })(); + if (!rawPath) { + return { kind: "bad_request" }; + } + let decodedPath: string; + try { + decodedPath = decodeURIComponent(rawPath); + } catch { + return { kind: "bad_request" }; + } + if (decodedPath.includes("\0")) { + return { kind: "bad_request" }; + } + + const normalizedPath = + decodedPath === "/" ? "index.html" : decodedPath.replace(/^\/+/, ""); + const candidateFilePath = path.resolve(distRoot, normalizedPath); + if (!isPathInside(distRoot, candidateFilePath)) { + return { kind: "forbidden" }; + } + + return { kind: "file", filePath: candidateFilePath }; +} + +function resolveSafeFilePathInDist( + filePath: string, + realDistRoot: string, +): { kind: "file"; filePath: string } | { kind: "missing" } | { kind: "forbidden" } { + let stats: fs.Stats; + try { + stats = fs.lstatSync(filePath); + } catch { + return { kind: "missing" }; + } + + if (stats.isDirectory()) { + return { kind: "missing" }; + } + + if (!stats.isFile() && !stats.isSymbolicLink()) { + return { kind: "missing" }; + } + + let realFilePath: string; + try { + realFilePath = fs.realpathSync(filePath); + } catch { + return { kind: "missing" }; + } + + if (!isPathInside(realDistRoot, realFilePath)) { + return { kind: "forbidden" }; + } + + try { + if (!fs.statSync(realFilePath).isFile()) { + return { kind: "missing" }; + } + } catch { + return { kind: "missing" }; + } + + return { kind: "file", filePath: realFilePath }; +} + +export function resolveStaticAssetReadTarget( + requestUrl: string | undefined, + distRoot: string, + resolvedRealDistRoot?: string, +): + | { kind: "file"; filePath: string } + | { kind: "forbidden" } + | { kind: "bad_request" } + | { kind: "not_found" } { + const normalizedDistRoot = path.resolve(distRoot); + const realDistRoot = + resolvedRealDistRoot ?? + (() => { + try { + return fs.realpathSync(normalizedDistRoot); + } catch { + return normalizedDistRoot; + } + })(); + + const requestedPath = resolveStaticAssetPath(requestUrl, normalizedDistRoot); + if (requestedPath.kind === "bad_request" || requestedPath.kind === "forbidden") { + return requestedPath; + } + + const requestedFile = resolveSafeFilePathInDist(requestedPath.filePath, realDistRoot); + if (requestedFile.kind === "forbidden") { + return { kind: "forbidden" }; + } + if (requestedFile.kind === "file") { + return requestedFile; + } + if (path.extname(requestedPath.filePath).length > 0) { + return { kind: "not_found" }; + } + + const indexPath = path.join(normalizedDistRoot, "index.html"); + const indexFile = resolveSafeFilePathInDist(indexPath, realDistRoot); + if (indexFile.kind !== "file") { + return indexFile.kind === "forbidden" ? { kind: "forbidden" } : { kind: "not_found" }; + } + + return indexFile; +} + +function applyStaticSecurityHeaders( + response: ServerResponse, + options: { + cacheControl: string; + }, +): void { + response.setHeader("X-Content-Type-Options", "nosniff"); + response.setHeader("X-Frame-Options", "DENY"); + response.setHeader("Referrer-Policy", "no-referrer"); + response.setHeader("Cross-Origin-Opener-Policy", "same-origin"); + response.setHeader("Cross-Origin-Resource-Policy", "same-origin"); + response.setHeader("Cache-Control", options.cacheControl); +} + +function applyRangeCapabilityHeaders(response: ServerResponse): void { + response.setHeader("Accept-Ranges", "bytes"); + response.setHeader("Vary", "Range"); +} + +function applyStaticValidatorHeaders( + response: ServerResponse, + validators: { + etag: string; + lastModified: string; + }, +): void { + response.setHeader("ETag", validators.etag); + response.setHeader("Last-Modified", validators.lastModified); +} + +function startStaticWebServer(distRoot: string, port: number) { + const normalizedDistRoot = path.resolve(distRoot); + const resolvedRealDistRoot = (() => { + try { + return fs.realpathSync(normalizedDistRoot); + } catch { + return normalizedDistRoot; + } + })(); + + const server = createServer((request, response) => { + const requestMethod = (request.method ?? "GET").toUpperCase(); + const respondText = ( + statusCode: number, + message: string, + extraHeaders: Record = {}, + ) => { + const body = Buffer.from(message, "utf8"); + response.statusCode = statusCode; + response.setHeader("Content-Type", "text/plain; charset=utf-8"); + response.setHeader("Content-Length", String(body.byteLength)); + applyStaticSecurityHeaders(response, { + cacheControl: "no-store", + }); + for (const [key, value] of Object.entries(extraHeaders)) { + response.setHeader(key, value); + } + if (requestMethod === "HEAD") { + response.end(); + return; + } + response.end(body); + }; + + const respondPreconditionFailed = (validators: { etag: string; lastModified: string }) => { + const body = Buffer.from("Precondition Failed", "utf8"); + response.statusCode = 412; + response.setHeader("Content-Type", "text/plain; charset=utf-8"); + response.setHeader("Content-Length", String(body.byteLength)); + applyStaticValidatorHeaders(response, validators); + applyRangeCapabilityHeaders(response); + applyStaticSecurityHeaders(response, { + cacheControl: "no-store", + }); + if (requestMethod === "HEAD") { + response.end(); + return; + } + response.end(body); + }; + + const respondWithStaticFile = (targetPath: string, requestMethod: "GET" | "HEAD") => { + fs.stat(targetPath, (error, stats) => { + if (error || !stats.isFile()) { + respondText(404, "Not found"); + return; + } + + const etag = staticEtagFor(stats); + const lastModified = stats.mtime.toUTCString(); + const hasIfMatchHeader = request.headers["if-match"] !== undefined; + const ifMatchMatches = ifMatchSatisfied(request.headers["if-match"], etag); + const ifUnmodifiedSinceMatches = hasIfMatchHeader + ? true + : ifUnmodifiedSinceSatisfied(request.headers["if-unmodified-since"], stats.mtimeMs); + const hasIfNoneMatchHeader = request.headers["if-none-match"] !== undefined; + const ifNoneMatchMatches = ifNoneMatchSatisfied(request.headers["if-none-match"], etag); + const ifModifiedSinceMatches = hasIfNoneMatchHeader + ? false + : ifModifiedSinceSatisfied(request.headers["if-modified-since"], stats.mtimeMs); + if (!ifMatchMatches || !ifUnmodifiedSinceMatches) { + respondPreconditionFailed({ + etag, + lastModified, + }); + return; + } + const shouldReturnNotModified = ifNoneMatchMatches || ifModifiedSinceMatches; + + if (shouldReturnNotModified) { + response.statusCode = 304; + response.setHeader("Content-Type", contentTypeFor(targetPath)); + applyStaticValidatorHeaders(response, { + etag, + lastModified, + }); + applyRangeCapabilityHeaders(response); + applyStaticSecurityHeaders(response, { + cacheControl: cacheControlFor(targetPath), + }); + response.end(); + return; + } + + const resolvedRange = parseByteRangeHeader(request.headers.range, stats.size); + if (resolvedRange === "invalid") { + respondText(416, "Range Not Satisfiable", { + "Content-Range": `bytes */${stats.size}`, + ETag: etag, + "Last-Modified": lastModified, + "Accept-Ranges": "bytes", + Vary: "Range", + }); + return; + } + const effectiveRange = + resolvedRange && ifRangeSatisfied(request.headers["if-range"], etag, stats.mtimeMs) + ? resolvedRange + : null; + + response.statusCode = effectiveRange ? 206 : 200; + response.setHeader("Content-Type", contentTypeFor(targetPath)); + applyStaticValidatorHeaders(response, { + etag, + lastModified, + }); + response.setHeader( + "Content-Length", + String(effectiveRange ? effectiveRange.end - effectiveRange.start + 1 : stats.size), + ); + applyRangeCapabilityHeaders(response); + if (effectiveRange) { + response.setHeader("Content-Range", `bytes ${effectiveRange.start}-${effectiveRange.end}/${stats.size}`); + } + applyStaticSecurityHeaders(response, { + cacheControl: cacheControlFor(targetPath), + }); + + if (requestMethod === "HEAD") { + response.end(); + return; + } + + const stream = fs.createReadStream( + targetPath, + effectiveRange + ? { + start: effectiveRange.start, + end: effectiveRange.end, + } + : undefined, + ); + response.on("close", () => { + stream.destroy(); + }); + stream.on("error", () => { + if (!response.headersSent) { + respondText(404, "Not found"); + return; + } + response.destroy(); + }); + stream.pipe(response); + }); + }; + + if (requestMethod !== "GET" && requestMethod !== "HEAD") { + respondText(405, "Method Not Allowed", { + Allow: "GET, HEAD", + }); + return; + } + + const resolvedPath = resolveStaticAssetReadTarget( + request.url, + normalizedDistRoot, + resolvedRealDistRoot, + ); + if (resolvedPath.kind === "bad_request") { + respondText(400, "Invalid request path"); + return; + } + + if (resolvedPath.kind === "forbidden") { + respondText(403, "Forbidden"); + return; + } + if (resolvedPath.kind === "not_found") { + respondText(404, "Not found"); + return; + } + + respondWithStaticFile(resolvedPath.filePath, requestMethod); + }); + + return new Promise<{ + close: () => Promise; + }>((resolve, reject) => { + const onError = (error: Error) => { + reject(error); + }; + server.once("error", onError); + server.listen(port, "127.0.0.1", () => { + server.off("error", onError); + let closePromise: Promise | null = null; + resolve({ + close: () => { + if (closePromise) { + return closePromise; + } + + closePromise = new Promise((closeResolve) => { + server.close(() => closeResolve()); + }); + + return closePromise; + }, + }); + }); + }); +} + +async function main() { + let options: CliOptions; + try { + options = parseCliOptions(process.argv.slice(2), process.env, process.cwd()); + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : "Invalid arguments."}\n\n`); + printHelp(); + process.exit(1); + return; + } + + if (options.showHelp) { + printHelp(); + process.exit(0); + return; + } + + if (options.showVersion) { + process.stdout.write(`${readCliVersion()}\n`); + process.exit(0); + return; + } + + const maxPortRetryAttempts = 10; + const runWithRetry = async ( + currentOptions: CliOptions, + attempt: number, + ): Promise => { + try { + await runCli(currentOptions); + return; + } catch (error) { + const canRetryWithNextPorts = + isPortInUseError(error) && + !currentOptions.backendPortLocked && + !currentOptions.webPortLocked && + attempt < maxPortRetryAttempts - 1; + if (canRetryWithNextPorts) { + const nextBackendPort = currentOptions.backendPort + 1; + const nextWebPort = currentOptions.webPort + 1; + process.stderr.write( + `Ports ${currentOptions.backendPort}/${currentOptions.webPort} busy; retrying with ${nextBackendPort}/${nextWebPort}.\n`, + ); + return runWithRetry( + { + ...currentOptions, + backendPort: nextBackendPort, + webPort: nextWebPort, + }, + attempt + 1, + ); + } + + const wrappedError = new Error("CLI startup failed."); + (wrappedError as Error & { cause?: unknown }).cause = { + originalError: error, + options: currentOptions, + }; + throw wrappedError; + } + }; + + try { + await runWithRetry(options, 0); + } catch (error) { + const wrappedCause = (error as Error & { cause?: unknown }).cause as + | { + originalError?: unknown; + options?: CliOptions; + } + | undefined; + const wrapped = wrappedCause ?? { + originalError: error, + options, + }; + const failedOptions = wrapped.options ?? options; + process.stderr.write( + `${formatStartupError(wrapped.originalError ?? error, failedOptions)}\n`, + ); + process.exit(1); + } +} + +const entrypoint = process.argv[1]; +const currentFilePath = fileURLToPath(import.meta.url); +if (entrypoint && path.resolve(entrypoint) === currentFilePath) { + void main(); +} diff --git a/apps/t3/src/runtimeApiServer.test.ts b/apps/t3/src/runtimeApiServer.test.ts new file mode 100644 index 00000000000..a89ab68d9d2 --- /dev/null +++ b/apps/t3/src/runtimeApiServer.test.ts @@ -0,0 +1,3327 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { createServer as createNetServer } from "node:net"; +import { afterEach, describe, expect, it } from "vitest"; +import { WebSocket } from "ws"; + +import { + WS_NATIVE_API_METHODS, + WS_METHOD_MAX_CHARS, + WS_REQUEST_ID_MAX_CHARS, + WS_ERROR_MESSAGE_MAX_CHARS, + WS_CLOSE_CODES, + WS_CLOSE_REASONS, + WS_EVENT_CHANNELS, + type WsResponseMessage, + type WsServerMessage, + wsServerMessageSchema, +} from "@acme/contracts"; +import { createFakeCodexAppServerBinary } from "../../../test-support/fakeCodexAppServer"; +import { startRuntimeApiServer } from "./runtimeApiServer"; + +function withTimeout(promise: Promise, timeoutMs = 5_000): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error("Timed out waiting for websocket message.")); + }, timeoutMs); + promise.then( + (value) => { + clearTimeout(timeout); + resolve(value); + }, + (error) => { + clearTimeout(timeout); + reject(error); + }, + ); + }); +} + +async function connectClient(url: string) { + const queuedMessages: WsServerMessage[] = []; + const pendingResolvers: Array<(message: WsServerMessage) => void> = []; + const socket = new WebSocket(url); + socket.on("message", (raw) => { + let parsedRaw: unknown; + try { + parsedRaw = JSON.parse(raw.toString()) as unknown; + } catch { + return; + } + + const parsedResult = wsServerMessageSchema.safeParse(parsedRaw); + if (!parsedResult.success) { + return; + } + const parsed = parsedResult.data; + + const pending = pendingResolvers.shift(); + if (pending) { + pending(parsed); + return; + } + + queuedMessages.push(parsed); + }); + await new Promise((resolve, reject) => { + socket.once("open", () => resolve()); + socket.once("error", (error) => reject(error)); + }); + + const nextMessage = async () => { + const queued = queuedMessages.shift(); + if (queued) { + return queued; + } + + return withTimeout( + new Promise((resolve) => { + pendingResolvers.push(resolve); + }), + ); + }; + + return { + socket, + nextMessage, + }; +} + +async function sendRequest( + socket: WebSocket, + nextMessage: () => Promise, + id: string, + method: string, + params?: unknown, +): Promise { + socket.send( + JSON.stringify({ + type: "request", + id, + method, + params, + }), + ); + + const waitForMatchingResponse = async (): Promise => { + const message = await nextMessage(); + if (message.type !== "response" || message.id !== id) { + return waitForMatchingResponse(); + } + return message; + }; + + return waitForMatchingResponse(); +} + +type WsNativeApiMethod = (typeof WS_NATIVE_API_METHODS)[number]; + +const METHOD_COVERAGE_PARAMS: Record = { + "app.bootstrap": undefined, + "app.health": undefined, + "todos.list": undefined, + "todos.add": { text: "coverage todo" }, + "todos.toggle": "missing-todo-id", + "todos.remove": "missing-todo-id", + "dialogs.pickFolder": undefined, + "terminal.run": { command: "echo runtime-method-coverage" }, + "agent.spawn": { + command: process.execPath, + args: ["-e", "process.exit(0)"], + }, + "agent.kill": "missing-session-id", + "agent.write": { + sessionId: "missing-session-id", + data: "coverage", + }, + "providers.startSession": null, + "providers.sendTurn": null, + "providers.interruptTurn": null, + "providers.respondToRequest": null, + "providers.stopSession": null, + "providers.listSessions": undefined, + "shell.openInEditor": { + cwd: process.cwd(), + editor: "file-manager", + }, +}; + +async function waitForAgentEvent( + nextMessage: () => Promise, + channel: string, + sessionId: string, +) { + const message = await nextMessage(); + if (message.type !== "event" || message.channel !== channel) { + return waitForAgentEvent(nextMessage, channel, sessionId); + } + const payload = message.payload as { + sessionId?: string; + }; + if (payload.sessionId !== sessionId) { + return waitForAgentEvent(nextMessage, channel, sessionId); + } + + return message; +} + +async function waitForProviderEvent( + nextMessage: () => Promise, + matcher: (payload: { + id: string; + kind: string; + provider: string; + sessionId: string; + createdAt: string; + method: string; + requestId?: string; + requestKind?: string; + }) => boolean, +) { + const message = await nextMessage(); + if (message.type !== "event" || message.channel !== WS_EVENT_CHANNELS.providerEvent) { + return waitForProviderEvent(nextMessage, matcher); + } + + const payload = message.payload as { + id: string; + kind: string; + provider: string; + sessionId: string; + createdAt: string; + method: string; + requestId?: string; + requestKind?: string; + }; + if (!matcher(payload)) { + return waitForProviderEvent(nextMessage, matcher); + } + + return payload; +} + +const servers: Array<{ close: () => Promise }> = []; + +afterEach(async () => { + const snapshot = [...servers]; + servers.length = 0; + await Promise.all(snapshot.map((server) => server.close())); +}); + +describe("runtimeApiServer", () => { + it("fails startup when websocket port is already in use", async () => { + const blockingServer = createNetServer(); + await new Promise((resolve, reject) => { + blockingServer.listen(0, "127.0.0.1", () => resolve()); + blockingServer.once("error", (error) => reject(error)); + }); + const address = blockingServer.address(); + if (!address || typeof address !== "object") { + throw new Error("Expected blocking server to have an address."); + } + + try { + await expect( + startRuntimeApiServer({ + port: address.port, + launchCwd: process.cwd(), + }), + ).rejects.toMatchObject({ + code: "EADDRINUSE", + }); + } finally { + await new Promise((resolve, reject) => { + blockingServer.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + } + }); + + it("rejects startup when runtime port is negative", async () => { + await expect( + startRuntimeApiServer({ + port: -1, + launchCwd: process.cwd(), + }), + ).rejects.toThrow("Invalid runtime port"); + }); + + it("rejects startup when runtime port exceeds max range", async () => { + await expect( + startRuntimeApiServer({ + port: 70_000, + launchCwd: process.cwd(), + }), + ).rejects.toThrow("Invalid runtime port"); + }); + + it("rejects startup when runtime port is non-integer", async () => { + await expect( + startRuntimeApiServer({ + port: 4317.5, + launchCwd: process.cwd(), + }), + ).rejects.toThrow("Invalid runtime port"); + }); + + it("rejects startup when runtime port is NaN", async () => { + await expect( + startRuntimeApiServer({ + port: Number.NaN, + launchCwd: process.cwd(), + }), + ).rejects.toThrow("Invalid runtime port"); + }); + + it("rejects startup when runtime port is not a number", async () => { + await expect( + startRuntimeApiServer({ + port: "4317" as unknown as number, + launchCwd: process.cwd(), + }), + ).rejects.toThrow("Invalid runtime port"); + }); + + it("rejects startup when runtime port is null", async () => { + await expect( + startRuntimeApiServer({ + port: null as unknown as number, + launchCwd: process.cwd(), + }), + ).rejects.toThrow("Invalid runtime port"); + }); + + it("rejects startup when runtime port is infinite", async () => { + await expect( + startRuntimeApiServer({ + port: Number.POSITIVE_INFINITY, + launchCwd: process.cwd(), + }), + ).rejects.toThrow("Invalid runtime port"); + }); + + it("allows runtime server close to be called multiple times", async () => { + const server = await startRuntimeApiServer({ + port: 0, + launchCwd: process.cwd(), + }); + + await expect(server.close()).resolves.toBeUndefined(); + await expect(server.close()).resolves.toBeUndefined(); + }); + + it("allows concurrent runtime server close calls", async () => { + const server = await startRuntimeApiServer({ + port: 0, + launchCwd: process.cwd(), + }); + + await expect(Promise.all([server.close(), server.close(), server.close()])).resolves.toEqual([ + undefined, + undefined, + undefined, + ]); + }); + + it("terminates connected websocket clients when runtime closes", async () => { + const server = await startRuntimeApiServer({ + port: 0, + launchCwd: process.cwd(), + }); + servers.push(server); + + const client = await connectClient(server.wsUrl); + await client.nextMessage(); + + const closed = withTimeout( + new Promise<{ code: number }>((resolve) => { + client.socket.once("close", (code) => resolve({ code })); + }), + ); + + await server.close(); + const closeEvent = await closed; + expect([1000, 1001, 1005, 1006]).toContain(closeEvent.code); + }); + + it("rejects startup when launchCwd does not exist", async () => { + const missingPath = path.join(process.cwd(), `missing-launch-cwd-${Date.now()}`); + await expect( + startRuntimeApiServer({ + port: 0, + launchCwd: missingPath, + }), + ).rejects.toThrow("Invalid launchCwd does not exist"); + }); + + it("rejects startup when launchCwd is empty", async () => { + await expect( + startRuntimeApiServer({ + port: 0, + launchCwd: "", + }), + ).rejects.toThrow("Invalid launchCwd must be a non-empty path"); + }); + + it("rejects startup when launchCwd is only whitespace", async () => { + await expect( + startRuntimeApiServer({ + port: 0, + launchCwd: " ", + }), + ).rejects.toThrow("Invalid launchCwd must be a non-empty path"); + }); + + it("rejects startup when launchCwd is not a string", async () => { + await expect( + startRuntimeApiServer({ + port: 0, + launchCwd: 123 as unknown as string, + }), + ).rejects.toThrow("Invalid launchCwd must be a non-empty path"); + }); + + it("rejects startup when launchCwd is null", async () => { + await expect( + startRuntimeApiServer({ + port: 0, + launchCwd: null as unknown as string, + }), + ).rejects.toThrow("Invalid launchCwd must be a non-empty path"); + }); + + it("rejects startup when launchCwd is not a directory", async () => { + const tempDir = mkdtempSync(path.join(os.tmpdir(), "t3-launch-cwd-file-")); + const filePath = path.join(tempDir, "launch.txt"); + writeFileSync(filePath, "not-a-directory", "utf8"); + await expect( + startRuntimeApiServer({ + port: 0, + launchCwd: filePath, + }), + ).rejects.toThrow("Invalid launchCwd is not a directory"); + }); + + it("rejects non-positive bootstrap timeout configuration", async () => { + await expect( + startRuntimeApiServer({ + port: 0, + launchCwd: process.cwd(), + bootstrapSessionTimeoutMs: 0, + }), + ).rejects.toThrow("Invalid bootstrapSessionTimeoutMs"); + }); + + it("rejects non-integer bootstrap timeout configuration", async () => { + await expect( + startRuntimeApiServer({ + port: 0, + launchCwd: process.cwd(), + bootstrapSessionTimeoutMs: 1.5, + }), + ).rejects.toThrow("Invalid bootstrapSessionTimeoutMs"); + }); + + it("rejects NaN bootstrap timeout configuration", async () => { + await expect( + startRuntimeApiServer({ + port: 0, + launchCwd: process.cwd(), + bootstrapSessionTimeoutMs: Number.NaN, + }), + ).rejects.toThrow("Invalid bootstrapSessionTimeoutMs"); + }); + + it("rejects non-number bootstrap timeout configuration", async () => { + await expect( + startRuntimeApiServer({ + port: 0, + launchCwd: process.cwd(), + bootstrapSessionTimeoutMs: "1000" as unknown as number, + }), + ).rejects.toThrow("Invalid bootstrapSessionTimeoutMs"); + }); + + it("rejects infinite bootstrap timeout configuration", async () => { + await expect( + startRuntimeApiServer({ + port: 0, + launchCwd: process.cwd(), + bootstrapSessionTimeoutMs: Number.POSITIVE_INFINITY, + }), + ).rejects.toThrow("Invalid bootstrapSessionTimeoutMs"); + }); + + it("rejects overly large bootstrap timeout configuration", async () => { + await expect( + startRuntimeApiServer({ + port: 0, + launchCwd: process.cwd(), + bootstrapSessionTimeoutMs: 120_001, + }), + ).rejects.toThrow("Invalid bootstrapSessionTimeoutMs"); + }); + + it("accepts bootstrap timeout at configured upper bound", async () => { + const server = await startRuntimeApiServer({ + port: 0, + launchCwd: process.cwd(), + bootstrapSessionTimeoutMs: 120_000, + }); + servers.push(server); + }); + + it("accepts bootstrap timeout at configured lower bound", async () => { + const server = await startRuntimeApiServer({ + port: 0, + launchCwd: process.cwd(), + bootstrapSessionTimeoutMs: 1, + }); + servers.push(server); + }); + + it("rejects empty auth token configuration", async () => { + await expect( + startRuntimeApiServer({ + port: 0, + launchCwd: process.cwd(), + authToken: " ", + }), + ).rejects.toThrow("Invalid runtime auth token"); + }); + + it("rejects non-string auth token configuration", async () => { + await expect( + startRuntimeApiServer({ + port: 0, + launchCwd: process.cwd(), + authToken: 123 as unknown as string, + }), + ).rejects.toThrow("Invalid runtime auth token"); + }); + + it("rejects null auth token configuration", async () => { + await expect( + startRuntimeApiServer({ + port: 0, + launchCwd: process.cwd(), + authToken: null as unknown as string, + }), + ).rejects.toThrow("Invalid runtime auth token"); + }); + + it("accepts websocket connections without token when auth is disabled", async () => { + const server = await startRuntimeApiServer({ + port: 0, + launchCwd: process.cwd(), + }); + servers.push(server); + + const client = await connectClient(server.wsUrl); + const hello = await client.nextMessage(); + expect(hello.type).toBe("hello"); + if (hello.type !== "hello") { + throw new Error("Expected hello message."); + } + expect(hello.version).toBe(1); + expect(hello.launchCwd).toBe(process.cwd()); + + const response = await sendRequest( + client.socket, + client.nextMessage, + "todos-no-auth-1", + "todos.list", + ); + expect(response.ok).toBe(true); + client.socket.close(); + }); + + it("trims configured auth token before validating connections", async () => { + const server = await startRuntimeApiServer({ + port: 0, + launchCwd: process.cwd(), + authToken: " secret-token ", + }); + servers.push(server); + + const wsUrl = new URL(server.wsUrl); + expect(wsUrl.searchParams.get("token")).toBe("secret-token"); + + const client = await connectClient(server.wsUrl); + const hello = await client.nextMessage(); + expect(hello.type).toBe("hello"); + client.socket.close(); + }); + + it("trims non-space surrounding whitespace from auth tokens", async () => { + const server = await startRuntimeApiServer({ + port: 0, + launchCwd: process.cwd(), + authToken: "\n\tsecret-token\t\n", + }); + servers.push(server); + + const wsUrl = new URL(server.wsUrl); + expect(wsUrl.searchParams.get("token")).toBe("secret-token"); + + const client = await connectClient(server.wsUrl); + const hello = await client.nextMessage(); + expect(hello.type).toBe("hello"); + client.socket.close(); + }); + + it("encodes auth token query parameter in runtime websocket URL", async () => { + const server = await startRuntimeApiServer({ + port: 0, + launchCwd: process.cwd(), + authToken: "token with spaces/?&", + }); + servers.push(server); + + const wsUrl = new URL(server.wsUrl); + expect(wsUrl.searchParams.get("token")).toBe("token with spaces/?&"); + + const client = await connectClient(server.wsUrl); + const hello = await client.nextMessage(); + expect(hello.type).toBe("hello"); + client.socket.close(); + }); + + it("normalizes relative launch cwd in app.health response", async () => { + const server = await startRuntimeApiServer({ + port: 0, + launchCwd: ".", + }); + servers.push(server); + + const client = await connectClient(server.wsUrl); + await client.nextMessage(); + + const response = await sendRequest( + client.socket, + client.nextMessage, + "health-relative-cwd", + "app.health", + ); + expect(response.ok).toBe(true); + if (!response.ok) { + throw new Error("Expected health response to succeed."); + } + + const payload = response.result as { + status: string; + launchCwd: string; + sessionCount: number; + activeClientConnected: boolean; + }; + expect(payload.status).toBe("ok"); + expect(payload.launchCwd).toBe(process.cwd()); + expect(payload.sessionCount).toBeGreaterThanOrEqual(0); + expect(payload.activeClientConnected).toBe(true); + client.socket.close(); + }); + + it("responds to todos.list over websocket RPC", async () => { + const server = await startRuntimeApiServer({ + port: 0, + launchCwd: process.cwd(), + }); + servers.push(server); + + const client = await connectClient(server.wsUrl); + const hello = await client.nextMessage(); + expect(hello.type).toBe("hello"); + + const response = await sendRequest( + client.socket, + client.nextMessage, + "todos-1", + "todos.list", + ); + expect(response.ok).toBe(true); + expect(Array.isArray(response.result)).toBe(true); + + client.socket.close(); + }); + + it("accepts request ids at max websocket length", async () => { + const server = await startRuntimeApiServer({ + port: 0, + launchCwd: process.cwd(), + }); + servers.push(server); + + const client = await connectClient(server.wsUrl); + await client.nextMessage(); + + const response = await sendRequest( + client.socket, + client.nextMessage, + "r".repeat(WS_REQUEST_ID_MAX_CHARS), + "todos.list", + ); + expect(response.ok).toBe(true); + if (!response.ok) { + throw new Error("Expected max-length request id response to succeed."); + } + expect(response.id).toHaveLength(WS_REQUEST_ID_MAX_CHARS); + + client.socket.close(); + }); + + it("responds to providers.listSessions over websocket RPC", async () => { + const server = await startRuntimeApiServer({ + port: 0, + launchCwd: process.cwd(), + }); + servers.push(server); + + const client = await connectClient(server.wsUrl); + await client.nextMessage(); + + const response = await sendRequest( + client.socket, + client.nextMessage, + "providers-list-1", + "providers.listSessions", + ); + expect(response.ok).toBe(true); + if (!response.ok) { + throw new Error("Expected providers.listSessions response to succeed."); + } + expect(Array.isArray(response.result)).toBe(true); + + client.socket.close(); + }); + + it( + "supports provider lifecycle methods with a fake codex app-server", + async () => { + const fakeCodex = createFakeCodexAppServerBinary("t3-runtime-fake-codex-"); + const originalPath = process.env.PATH; + process.env.PATH = `${fakeCodex.tempDir}${path.delimiter}${originalPath ?? ""}`; + + try { + const server = await startRuntimeApiServer({ + port: 0, + launchCwd: process.cwd(), + }); + servers.push(server); + + const client = await connectClient(server.wsUrl); + await client.nextMessage(); + + const startResponse = await sendRequest( + client.socket, + client.nextMessage, + "providers-start-fake-1", + "providers.startSession", + { + provider: "codex", + }, + ); + expect(startResponse.ok).toBe(true); + if (!startResponse.ok) { + throw new Error("Expected fake providers.startSession response to succeed."); + } + + const session = startResponse.result as { + sessionId: string; + provider: string; + status: string; + threadId?: string; + }; + expect(session.provider).toBe("codex"); + expect(session.status).toBe("ready"); + expect(session.threadId).toBe("thread-fake"); + expect(session.sessionId.length).toBeGreaterThan(0); + + const sendTurnResponse = await sendRequest( + client.socket, + client.nextMessage, + "providers-turn-fake-1", + "providers.sendTurn", + { + sessionId: session.sessionId, + input: "hello fake codex", + }, + ); + expect(sendTurnResponse.ok).toBe(true); + if (!sendTurnResponse.ok) { + throw new Error("Expected fake providers.sendTurn response to succeed."); + } + const turnResult = sendTurnResponse.result as { + threadId: string; + turnId: string; + }; + expect(turnResult.threadId).toBe("thread-fake"); + expect(turnResult.turnId).toBe("turn-1"); + + const approvalEvent = await waitForProviderEvent( + client.nextMessage, + (payload) => + payload.kind === "request" && + payload.method === "item/commandExecution/requestApproval" && + payload.sessionId === session.sessionId && + typeof payload.requestId === "string" && + payload.requestId.length > 0, + ); + expect(approvalEvent.requestKind).toBe("command"); + expect(approvalEvent.requestId).toBeTruthy(); + const approvalRequestId = approvalEvent.requestId; + if (!approvalRequestId) { + throw new Error("Expected provider approval event requestId."); + } + + const respondToRequestResponse = await sendRequest( + client.socket, + client.nextMessage, + "providers-respond-fake-1", + "providers.respondToRequest", + { + sessionId: session.sessionId, + requestId: approvalRequestId, + decision: "accept", + }, + ); + expect(respondToRequestResponse.ok).toBe(true); + if (!respondToRequestResponse.ok) { + throw new Error("Expected fake providers.respondToRequest response to succeed."); + } + expect(respondToRequestResponse.result).toBeNull(); + + const interruptResponse = await sendRequest( + client.socket, + client.nextMessage, + "providers-interrupt-fake-1", + "providers.interruptTurn", + { + sessionId: session.sessionId, + }, + ); + expect(interruptResponse.ok).toBe(true); + if (!interruptResponse.ok) { + throw new Error("Expected fake providers.interruptTurn response to succeed."); + } + expect(interruptResponse.result).toBeNull(); + + const beforeStopListResponse = await sendRequest( + client.socket, + client.nextMessage, + "providers-list-before-stop-fake-1", + "providers.listSessions", + ); + expect(beforeStopListResponse.ok).toBe(true); + if (!beforeStopListResponse.ok) { + throw new Error("Expected pre-stop providers.listSessions response to succeed."); + } + const sessionsBeforeStop = beforeStopListResponse.result as Array<{ sessionId: string }>; + expect(sessionsBeforeStop.some((entry) => entry.sessionId === session.sessionId)).toBe( + true, + ); + + const stopResponse = await sendRequest( + client.socket, + client.nextMessage, + "providers-stop-fake-1", + "providers.stopSession", + { + sessionId: session.sessionId, + }, + ); + expect(stopResponse.ok).toBe(true); + if (!stopResponse.ok) { + throw new Error("Expected fake providers.stopSession response to succeed."); + } + expect(stopResponse.result).toBeNull(); + + const afterStopListResponse = await sendRequest( + client.socket, + client.nextMessage, + "providers-list-after-stop-fake-1", + "providers.listSessions", + ); + expect(afterStopListResponse.ok).toBe(true); + if (!afterStopListResponse.ok) { + throw new Error("Expected post-stop providers.listSessions response to succeed."); + } + const sessionsAfterStop = afterStopListResponse.result as Array<{ sessionId: string }>; + expect(sessionsAfterStop.some((entry) => entry.sessionId === session.sessionId)).toBe( + false, + ); + + client.socket.close(); + } finally { + process.env.PATH = originalPath; + rmSync(fakeCodex.tempDir, { recursive: true, force: true }); + } + }, + 20_000, + ); + + it("handles shell.openInEditor file-manager requests", async () => { + const server = await startRuntimeApiServer({ + port: 0, + launchCwd: process.cwd(), + }); + servers.push(server); + + const client = await connectClient(server.wsUrl); + await client.nextMessage(); + + const response = await sendRequest( + client.socket, + client.nextMessage, + "shell-open-fm-1", + "shell.openInEditor", + { + cwd: process.cwd(), + editor: "file-manager", + }, + ); + expect(response.ok).toBe(true); + expect(response.result).toBeNull(); + + client.socket.close(); + }); + + it("handles shell.openInEditor cursor requests", async () => { + const server = await startRuntimeApiServer({ + port: 0, + launchCwd: process.cwd(), + }); + servers.push(server); + + const client = await connectClient(server.wsUrl); + await client.nextMessage(); + + const response = await sendRequest( + client.socket, + client.nextMessage, + "shell-open-cursor-1", + "shell.openInEditor", + { + cwd: process.cwd(), + editor: "cursor", + }, + ); + expect(response.ok).toBe(true); + expect(response.result).toBeNull(); + + client.socket.close(); + }); + + it("accepts relative shell.openInEditor cwd paths", async () => { + const server = await startRuntimeApiServer({ + port: 0, + launchCwd: process.cwd(), + }); + servers.push(server); + + const client = await connectClient(server.wsUrl); + await client.nextMessage(); + + const response = await sendRequest( + client.socket, + client.nextMessage, + "shell-open-relative-1", + "shell.openInEditor", + { + cwd: ".", + editor: "file-manager", + }, + ); + expect(response.ok).toBe(true); + expect(response.result).toBeNull(); + + client.socket.close(); + }); + + it("resolves relative shell.openInEditor cwd paths from launch cwd", async () => { + const launchCwd = mkdtempSync(path.join(os.tmpdir(), "t3-shell-launch-cwd-")); + const nestedDir = path.join(launchCwd, "nested"); + mkdirSync(nestedDir, { recursive: true }); + + const server = await startRuntimeApiServer({ + port: 0, + launchCwd, + }); + servers.push(server); + + const client = await connectClient(server.wsUrl); + await client.nextMessage(); + + const response = await sendRequest( + client.socket, + client.nextMessage, + "shell-open-relative-launch-cwd-1", + "shell.openInEditor", + { + cwd: "nested", + editor: "file-manager", + }, + ); + expect(response.ok).toBe(true); + expect(response.result).toBeNull(); + + client.socket.close(); + }); + + it("responds to dialogs.pickFolder requests", async () => { + const server = await startRuntimeApiServer({ + port: 0, + launchCwd: process.cwd(), + }); + servers.push(server); + + const client = await connectClient(server.wsUrl); + await client.nextMessage(); + + const response = await sendRequest( + client.socket, + client.nextMessage, + "dialog-pick-folder-1", + "dialogs.pickFolder", + ); + expect(response.ok).toBe(true); + if (!response.ok) { + throw new Error("Expected dialogs.pickFolder response to succeed."); + } + expect(response.result === null || typeof response.result === "string").toBe(true); + + client.socket.close(); + }); + + it("ignores malformed client messages and continues processing", async () => { + const server = await startRuntimeApiServer({ + port: 0, + launchCwd: process.cwd(), + }); + servers.push(server); + + const client = await connectClient(server.wsUrl); + await client.nextMessage(); + + client.socket.send("not-json"); + client.socket.send(JSON.stringify({ type: "request", id: "", method: "" })); + client.socket.send(JSON.stringify({ type: "request", id: " ", method: "todos.list" })); + client.socket.send(JSON.stringify({ type: "request", id: "malformed-space-method", method: " " })); + client.socket.send( + JSON.stringify({ + type: "request", + id: "malformed-extra-field", + method: "todos.list", + unexpected: true, + }), + ); + client.socket.send( + JSON.stringify({ + type: "request", + id: "x".repeat(WS_REQUEST_ID_MAX_CHARS + 1), + method: "todos.list", + }), + ); + client.socket.send( + JSON.stringify({ + type: "request", + id: "malformed-long-method", + method: "m".repeat(WS_METHOD_MAX_CHARS + 1), + }), + ); + + const response = await sendRequest( + client.socket, + client.nextMessage, + "todos-after-malformed", + "todos.list", + ); + expect(response.ok).toBe(true); + + client.socket.close(); + }); + + it("closes oversized websocket payloads and remains available", async () => { + const server = await startRuntimeApiServer({ + port: 0, + launchCwd: process.cwd(), + }); + servers.push(server); + + const firstClient = await connectClient(server.wsUrl); + await firstClient.nextMessage(); + + const firstClose = withTimeout( + new Promise((resolve) => { + firstClient.socket.once("close", (code) => resolve(code)); + }), + 10_000, + ); + firstClient.socket.send("x".repeat(6 * 1024 * 1024)); + const closeCode = await firstClose; + expect([1006, 1009]).toContain(closeCode); + + const secondClient = await connectClient(server.wsUrl); + await secondClient.nextMessage(); + + const response = await sendRequest( + secondClient.socket, + secondClient.nextMessage, + "todos-after-large-payload", + "todos.list", + ); + expect(response.ok).toBe(true); + secondClient.socket.close(); + }); + + it("accepts buffer-encoded websocket request payloads", async () => { + const server = await startRuntimeApiServer({ + port: 0, + launchCwd: process.cwd(), + }); + servers.push(server); + + const client = await connectClient(server.wsUrl); + await client.nextMessage(); + + client.socket.send( + Buffer.from( + JSON.stringify({ + type: "request", + id: "buffer-todos-1", + method: "todos.list", + }), + ), + ); + + const response = await withTimeout( + (async (): Promise => { + const message = await client.nextMessage(); + if (message.type === "response" && message.id === "buffer-todos-1") { + return message; + } + return Promise.reject(new Error("Expected matching todos response.")); + })(), + ); + expect(response.ok).toBe(true); + expect(Array.isArray(response.result)).toBe(true); + + client.socket.close(); + }); + + it("accepts arraybuffer-encoded websocket request payloads", async () => { + const server = await startRuntimeApiServer({ + port: 0, + launchCwd: process.cwd(), + }); + servers.push(server); + + const client = await connectClient(server.wsUrl); + await client.nextMessage(); + + const encoded = new TextEncoder().encode( + JSON.stringify({ + type: "request", + id: "arraybuffer-todos-1", + method: "todos.list", + }), + ); + client.socket.send(encoded.buffer); + + const response = await withTimeout( + (async (): Promise => { + const message = await client.nextMessage(); + if (message.type === "response" && message.id === "arraybuffer-todos-1") { + return message; + } + return Promise.reject(new Error("Expected matching todos response.")); + })(), + ); + expect(response.ok).toBe(true); + expect(Array.isArray(response.result)).toBe(true); + + client.socket.close(); + }); + + it("accepts uint8array-encoded websocket request payloads", async () => { + const server = await startRuntimeApiServer({ + port: 0, + launchCwd: process.cwd(), + }); + servers.push(server); + + const client = await connectClient(server.wsUrl); + await client.nextMessage(); + + const encoded = new TextEncoder().encode( + JSON.stringify({ + type: "request", + id: "uint8array-todos-1", + method: "todos.list", + }), + ); + client.socket.send(encoded); + + const response = await withTimeout( + (async (): Promise => { + const message = await client.nextMessage(); + if (message.type === "response" && message.id === "uint8array-todos-1") { + return message; + } + return Promise.reject(new Error("Expected matching todos response.")); + })(), + ); + expect(response.ok).toBe(true); + expect(Array.isArray(response.result)).toBe(true); + + client.socket.close(); + }); + + it("accepts dataview-encoded websocket request payloads", async () => { + const server = await startRuntimeApiServer({ + port: 0, + launchCwd: process.cwd(), + }); + servers.push(server); + + const client = await connectClient(server.wsUrl); + await client.nextMessage(); + + const encoded = new TextEncoder().encode( + JSON.stringify({ + type: "request", + id: "dataview-todos-1", + method: "todos.list", + }), + ); + client.socket.send(new DataView(encoded.buffer)); + + const response = await withTimeout( + (async (): Promise => { + const message = await client.nextMessage(); + if (message.type === "response" && message.id === "dataview-todos-1") { + return message; + } + return Promise.reject(new Error("Expected matching todos response.")); + })(), + ); + expect(response.ok).toBe(true); + expect(Array.isArray(response.result)).toBe(true); + + client.socket.close(); + }); + + it("accepts sliced uint8array-encoded websocket request payloads", async () => { + const server = await startRuntimeApiServer({ + port: 0, + launchCwd: process.cwd(), + }); + servers.push(server); + + const client = await connectClient(server.wsUrl); + await client.nextMessage(); + + const encoded = new TextEncoder().encode( + JSON.stringify({ + type: "request", + id: "uint8array-sliced-todos-1", + method: "todos.list", + }), + ); + const padded = new Uint8Array(encoded.length + 8); + padded.fill(32); + padded.set(encoded, 4); + client.socket.send(padded.subarray(4, 4 + encoded.length)); + + const response = await withTimeout( + (async (): Promise => { + const message = await client.nextMessage(); + if (message.type === "response" && message.id === "uint8array-sliced-todos-1") { + return message; + } + return Promise.reject(new Error("Expected matching todos response.")); + })(), + ); + expect(response.ok).toBe(true); + expect(Array.isArray(response.result)).toBe(true); + + client.socket.close(); + }); + + it("accepts sliced dataview-encoded websocket request payloads", async () => { + const server = await startRuntimeApiServer({ + port: 0, + launchCwd: process.cwd(), + }); + servers.push(server); + + const client = await connectClient(server.wsUrl); + await client.nextMessage(); + + const encoded = new TextEncoder().encode( + JSON.stringify({ + type: "request", + id: "dataview-sliced-todos-1", + method: "todos.list", + }), + ); + const padded = new Uint8Array(encoded.length + 12); + padded.fill(32); + padded.set(encoded, 6); + const slicedDataView = new DataView(padded.buffer, 6, encoded.length); + client.socket.send(slicedDataView); + + const response = await withTimeout( + (async (): Promise => { + const message = await client.nextMessage(); + if (message.type === "response" && message.id === "dataview-sliced-todos-1") { + return message; + } + return Promise.reject(new Error("Expected matching todos response.")); + })(), + ); + expect(response.ok).toBe(true); + expect(Array.isArray(response.result)).toBe(true); + + client.socket.close(); + }); + + it("replaces an existing websocket client with a new one", async () => { + const server = await startRuntimeApiServer({ + port: 0, + launchCwd: process.cwd(), + }); + servers.push(server); + + const firstClient = await connectClient(server.wsUrl); + await firstClient.nextMessage(); + + const firstClose = new Promise<{ code: number; reason: string }>((resolve) => { + firstClient.socket.once("close", (code, reason) => + resolve({ code, reason: reason.toString() }), + ); + }); + + const secondClient = await connectClient(server.wsUrl); + await secondClient.nextMessage(); + + const closed = await withTimeout(firstClose); + expect(closed.code).toBe(WS_CLOSE_CODES.replacedByNewClient); + expect(closed.reason).toBe(WS_CLOSE_REASONS.replacedByNewClient); + + const response = await sendRequest( + secondClient.socket, + secondClient.nextMessage, + "todos-2", + "todos.list", + ); + expect(response.ok).toBe(true); + + secondClient.socket.close(); + }); + + it("replaces active authorized client when another authorized client connects", async () => { + const server = await startRuntimeApiServer({ + port: 0, + launchCwd: process.cwd(), + authToken: "secret-token", + }); + servers.push(server); + + const firstClient = await connectClient(server.wsUrl); + await firstClient.nextMessage(); + + const firstClose = new Promise<{ code: number; reason: string }>((resolve) => { + firstClient.socket.once("close", (code, reason) => + resolve({ code, reason: reason.toString() }), + ); + }); + + const secondClient = await connectClient(server.wsUrl); + await secondClient.nextMessage(); + + const closed = await withTimeout(firstClose); + expect(closed.code).toBe(WS_CLOSE_CODES.replacedByNewClient); + expect(closed.reason).toBe(WS_CLOSE_REASONS.replacedByNewClient); + + const response = await sendRequest( + secondClient.socket, + secondClient.nextMessage, + "todos-auth-replace-1", + "todos.list", + ); + expect(response.ok).toBe(true); + + secondClient.socket.close(); + }); + + it("requires auth token when runtime is configured with one", async () => { + const server = await startRuntimeApiServer({ + port: 0, + launchCwd: process.cwd(), + authToken: "secret-token", + }); + servers.push(server); + + const authorizedUrl = new URL(server.wsUrl); + expect(authorizedUrl.searchParams.get("token")).toBe("secret-token"); + const unauthorizedUrl = `${authorizedUrl.origin}${authorizedUrl.pathname}`; + const unauthorizedClient = new WebSocket(unauthorizedUrl); + let unauthorizedMessageCount = 0; + unauthorizedClient.on("message", () => { + unauthorizedMessageCount += 1; + }); + const unauthorizedClose = await withTimeout( + new Promise<{ code: number; reason: string }>((resolve, reject) => { + unauthorizedClient.once("close", (code, reason) => + resolve({ code, reason: reason.toString() }), + ); + unauthorizedClient.once("error", (error) => reject(error)); + }), + ); + expect(unauthorizedClose.code).toBe(WS_CLOSE_CODES.unauthorized); + expect(unauthorizedClose.reason).toBe(WS_CLOSE_REASONS.unauthorized); + expect(unauthorizedMessageCount).toBe(0); + + const missingTokenWithExtraQueryUrl = `${authorizedUrl.origin}${authorizedUrl.pathname}?debug=1`; + const missingTokenWithExtraQueryClient = new WebSocket(missingTokenWithExtraQueryUrl); + let missingTokenWithExtraQueryMessageCount = 0; + missingTokenWithExtraQueryClient.on("message", () => { + missingTokenWithExtraQueryMessageCount += 1; + }); + const missingTokenWithExtraQueryClose = await withTimeout( + new Promise<{ code: number; reason: string }>((resolve, reject) => { + missingTokenWithExtraQueryClient.once("close", (code, reason) => + resolve({ code, reason: reason.toString() }), + ); + missingTokenWithExtraQueryClient.once("error", (error) => reject(error)); + }), + ); + expect(missingTokenWithExtraQueryClose.code).toBe(WS_CLOSE_CODES.unauthorized); + expect(missingTokenWithExtraQueryClose.reason).toBe(WS_CLOSE_REASONS.unauthorized); + expect(missingTokenWithExtraQueryMessageCount).toBe(0); + + const wrongTokenKeyUrl = `${authorizedUrl.origin}${authorizedUrl.pathname}?Token=secret-token`; + const wrongTokenKeyClient = new WebSocket(wrongTokenKeyUrl); + let wrongTokenKeyMessageCount = 0; + wrongTokenKeyClient.on("message", () => { + wrongTokenKeyMessageCount += 1; + }); + const wrongTokenKeyClose = await withTimeout( + new Promise<{ code: number; reason: string }>((resolve, reject) => { + wrongTokenKeyClient.once("close", (code, reason) => + resolve({ code, reason: reason.toString() }), + ); + wrongTokenKeyClient.once("error", (error) => reject(error)); + }), + ); + expect(wrongTokenKeyClose.code).toBe(WS_CLOSE_CODES.unauthorized); + expect(wrongTokenKeyClose.reason).toBe(WS_CLOSE_REASONS.unauthorized); + expect(wrongTokenKeyMessageCount).toBe(0); + + const wrongTokenUrl = `${authorizedUrl.origin}${authorizedUrl.pathname}?token=wrong-token`; + const wrongTokenClient = new WebSocket(wrongTokenUrl); + let wrongTokenMessageCount = 0; + wrongTokenClient.on("message", () => { + wrongTokenMessageCount += 1; + }); + const wrongTokenClose = await withTimeout( + new Promise<{ code: number; reason: string }>((resolve, reject) => { + wrongTokenClient.once("close", (code, reason) => + resolve({ code, reason: reason.toString() }), + ); + wrongTokenClient.once("error", (error) => reject(error)); + }), + ); + expect(wrongTokenClose.code).toBe(WS_CLOSE_CODES.unauthorized); + expect(wrongTokenClose.reason).toBe(WS_CLOSE_REASONS.unauthorized); + expect(wrongTokenMessageCount).toBe(0); + + const emptyTokenUrl = `${authorizedUrl.origin}${authorizedUrl.pathname}?token=`; + const emptyTokenClient = new WebSocket(emptyTokenUrl); + let emptyTokenMessageCount = 0; + emptyTokenClient.on("message", () => { + emptyTokenMessageCount += 1; + }); + const emptyTokenClose = await withTimeout( + new Promise<{ code: number; reason: string }>((resolve, reject) => { + emptyTokenClient.once("close", (code, reason) => + resolve({ code, reason: reason.toString() }), + ); + emptyTokenClient.once("error", (error) => reject(error)); + }), + ); + expect(emptyTokenClose.code).toBe(WS_CLOSE_CODES.unauthorized); + expect(emptyTokenClose.reason).toBe(WS_CLOSE_REASONS.unauthorized); + expect(emptyTokenMessageCount).toBe(0); + + const whitespaceTokenUrl = `${authorizedUrl.origin}${authorizedUrl.pathname}?token=%20%20`; + const whitespaceTokenClient = new WebSocket(whitespaceTokenUrl); + let whitespaceTokenMessageCount = 0; + whitespaceTokenClient.on("message", () => { + whitespaceTokenMessageCount += 1; + }); + const whitespaceTokenClose = await withTimeout( + new Promise<{ code: number; reason: string }>((resolve, reject) => { + whitespaceTokenClient.once("close", (code, reason) => + resolve({ code, reason: reason.toString() }), + ); + whitespaceTokenClient.once("error", (error) => reject(error)); + }), + ); + expect(whitespaceTokenClose.code).toBe(WS_CLOSE_CODES.unauthorized); + expect(whitespaceTokenClose.reason).toBe(WS_CLOSE_REASONS.unauthorized); + expect(whitespaceTokenMessageCount).toBe(0); + + const duplicateTokenUrl = `${authorizedUrl.origin}${authorizedUrl.pathname}?token=secret-token&token=wrong-token`; + const duplicateTokenClient = new WebSocket(duplicateTokenUrl); + let duplicateTokenMessageCount = 0; + duplicateTokenClient.on("message", () => { + duplicateTokenMessageCount += 1; + }); + const duplicateTokenClose = await withTimeout( + new Promise<{ code: number; reason: string }>((resolve, reject) => { + duplicateTokenClient.once("close", (code, reason) => + resolve({ code, reason: reason.toString() }), + ); + duplicateTokenClient.once("error", (error) => reject(error)); + }), + ); + expect(duplicateTokenClose.code).toBe(WS_CLOSE_CODES.unauthorized); + expect(duplicateTokenClose.reason).toBe(WS_CLOSE_REASONS.unauthorized); + expect(duplicateTokenMessageCount).toBe(0); + + const duplicateSameTokenUrl = `${authorizedUrl.origin}${authorizedUrl.pathname}?token=secret-token&token=secret-token`; + const duplicateSameTokenClient = new WebSocket(duplicateSameTokenUrl); + let duplicateSameTokenMessageCount = 0; + duplicateSameTokenClient.on("message", () => { + duplicateSameTokenMessageCount += 1; + }); + const duplicateSameTokenClose = await withTimeout( + new Promise<{ code: number; reason: string }>((resolve, reject) => { + duplicateSameTokenClient.once("close", (code, reason) => + resolve({ code, reason: reason.toString() }), + ); + duplicateSameTokenClient.once("error", (error) => reject(error)); + }), + ); + expect(duplicateSameTokenClose.code).toBe(WS_CLOSE_CODES.unauthorized); + expect(duplicateSameTokenClose.reason).toBe(WS_CLOSE_REASONS.unauthorized); + expect(duplicateSameTokenMessageCount).toBe(0); + + const extraParamTokenUrl = `${authorizedUrl.origin}${authorizedUrl.pathname}?token=secret-token&debug=1`; + const extraParamTokenClient = new WebSocket(extraParamTokenUrl); + let extraParamTokenMessageCount = 0; + extraParamTokenClient.on("message", () => { + extraParamTokenMessageCount += 1; + }); + const extraParamTokenClose = await withTimeout( + new Promise<{ code: number; reason: string }>((resolve, reject) => { + extraParamTokenClient.once("close", (code, reason) => + resolve({ code, reason: reason.toString() }), + ); + extraParamTokenClient.once("error", (error) => reject(error)); + }), + ); + expect(extraParamTokenClose.code).toBe(WS_CLOSE_CODES.unauthorized); + expect(extraParamTokenClose.reason).toBe(WS_CLOSE_REASONS.unauthorized); + expect(extraParamTokenMessageCount).toBe(0); + + const wrongPathTokenUrl = `${authorizedUrl.origin}/unexpected?token=secret-token`; + const wrongPathTokenClient = new WebSocket(wrongPathTokenUrl); + let wrongPathTokenMessageCount = 0; + wrongPathTokenClient.on("message", () => { + wrongPathTokenMessageCount += 1; + }); + const wrongPathTokenClose = await withTimeout( + new Promise<{ code: number; reason: string }>((resolve, reject) => { + wrongPathTokenClient.once("close", (code, reason) => + resolve({ code, reason: reason.toString() }), + ); + wrongPathTokenClient.once("error", (error) => reject(error)); + }), + ); + expect(wrongPathTokenClose.code).toBe(WS_CLOSE_CODES.unauthorized); + expect(wrongPathTokenClose.reason).toBe(WS_CLOSE_REASONS.unauthorized); + expect(wrongPathTokenMessageCount).toBe(0); + + const authorizedClient = await connectClient(server.wsUrl); + const hello = await authorizedClient.nextMessage(); + expect(hello.type).toBe("hello"); + authorizedClient.socket.close(); + }); + + it("rejects websocket connections on non-root paths without auth", async () => { + const server = await startRuntimeApiServer({ + port: 0, + launchCwd: process.cwd(), + }); + servers.push(server); + + const wrongPathClient = new WebSocket(`${server.wsUrl}/unexpected`); + let wrongPathMessageCount = 0; + wrongPathClient.on("message", () => { + wrongPathMessageCount += 1; + }); + const wrongPathClose = await withTimeout( + new Promise<{ code: number; reason: string }>((resolve, reject) => { + wrongPathClient.once("close", (code, reason) => + resolve({ code, reason: reason.toString() }), + ); + wrongPathClient.once("error", (error) => reject(error)); + }), + ); + expect(wrongPathClose.code).toBe(WS_CLOSE_CODES.unauthorized); + expect(wrongPathClose.reason).toBe(WS_CLOSE_REASONS.unauthorized); + expect(wrongPathMessageCount).toBe(0); + + const unexpectedQueryClient = new WebSocket(`${server.wsUrl}?debug=1`); + let unexpectedQueryMessageCount = 0; + unexpectedQueryClient.on("message", () => { + unexpectedQueryMessageCount += 1; + }); + const unexpectedQueryClose = await withTimeout( + new Promise<{ code: number; reason: string }>((resolve, reject) => { + unexpectedQueryClient.once("close", (code, reason) => + resolve({ code, reason: reason.toString() }), + ); + unexpectedQueryClient.once("error", (error) => reject(error)); + }), + ); + expect(unexpectedQueryClose.code).toBe(WS_CLOSE_CODES.unauthorized); + expect(unexpectedQueryClose.reason).toBe(WS_CLOSE_REASONS.unauthorized); + expect(unexpectedQueryMessageCount).toBe(0); + + const unexpectedTokenClient = new WebSocket(`${server.wsUrl}?token=legacy-token`); + let unexpectedTokenMessageCount = 0; + unexpectedTokenClient.on("message", () => { + unexpectedTokenMessageCount += 1; + }); + const unexpectedTokenClose = await withTimeout( + new Promise<{ code: number; reason: string }>((resolve, reject) => { + unexpectedTokenClient.once("close", (code, reason) => + resolve({ code, reason: reason.toString() }), + ); + unexpectedTokenClient.once("error", (error) => reject(error)); + }), + ); + expect(unexpectedTokenClose.code).toBe(WS_CLOSE_CODES.unauthorized); + expect(unexpectedTokenClose.reason).toBe(WS_CLOSE_REASONS.unauthorized); + expect(unexpectedTokenMessageCount).toBe(0); + + const authorizedClient = await connectClient(server.wsUrl); + const hello = await authorizedClient.nextMessage(); + expect(hello.type).toBe("hello"); + authorizedClient.socket.close(); + }); + + it("does not evict active client when unexpected-query client connects without auth", async () => { + const server = await startRuntimeApiServer({ + port: 0, + launchCwd: process.cwd(), + }); + servers.push(server); + + const activeClient = await connectClient(server.wsUrl); + await activeClient.nextMessage(); + + const activeClose = new Promise<{ code: number }>((resolve) => { + activeClient.socket.once("close", (code) => resolve({ code })); + }); + + const unauthorizedUnexpectedQueryClient = new WebSocket(`${server.wsUrl}?debug=1`); + const unauthorizedUnexpectedQueryClose = await withTimeout( + new Promise<{ code: number; reason: string }>((resolve, reject) => { + unauthorizedUnexpectedQueryClient.once("close", (code, reason) => + resolve({ code, reason: reason.toString() }), + ); + unauthorizedUnexpectedQueryClient.once("error", (error) => reject(error)); + }), + ); + expect(unauthorizedUnexpectedQueryClose.code).toBe(WS_CLOSE_CODES.unauthorized); + expect(unauthorizedUnexpectedQueryClose.reason).toBe(WS_CLOSE_REASONS.unauthorized); + + const unauthorizedWrongTokenKeyClient = new WebSocket(`${server.wsUrl}?Token=legacy-token`); + const unauthorizedWrongTokenKeyClose = await withTimeout( + new Promise<{ code: number; reason: string }>((resolve, reject) => { + unauthorizedWrongTokenKeyClient.once("close", (code, reason) => + resolve({ code, reason: reason.toString() }), + ); + unauthorizedWrongTokenKeyClient.once("error", (error) => reject(error)); + }), + ); + expect(unauthorizedWrongTokenKeyClose.code).toBe(WS_CLOSE_CODES.unauthorized); + expect(unauthorizedWrongTokenKeyClose.reason).toBe(WS_CLOSE_REASONS.unauthorized); + + const baseUrl = new URL(server.wsUrl); + const unauthorizedWrongPathClient = new WebSocket(`${baseUrl.origin}/unexpected`); + const unauthorizedWrongPathClose = await withTimeout( + new Promise<{ code: number; reason: string }>((resolve, reject) => { + unauthorizedWrongPathClient.once("close", (code, reason) => + resolve({ code, reason: reason.toString() }), + ); + unauthorizedWrongPathClient.once("error", (error) => reject(error)); + }), + ); + expect(unauthorizedWrongPathClose.code).toBe(WS_CLOSE_CODES.unauthorized); + expect(unauthorizedWrongPathClose.reason).toBe(WS_CLOSE_REASONS.unauthorized); + + const response = await sendRequest( + activeClient.socket, + activeClient.nextMessage, + "todos-authless-unexpected-query-1", + "todos.list", + ); + expect(response.ok).toBe(true); + + activeClient.socket.close(); + const closed = await withTimeout(activeClose); + expect([1000, 1005]).toContain(closed.code); + }); + + it("does not evict authorized client when unauthorized client connects", async () => { + const server = await startRuntimeApiServer({ + port: 0, + launchCwd: process.cwd(), + authToken: "secret-token", + }); + servers.push(server); + + const authorizedClient = await connectClient(server.wsUrl); + await authorizedClient.nextMessage(); + + const authorizedClose = new Promise<{ code: number }>((resolve) => { + authorizedClient.socket.once("close", (code) => resolve({ code })); + }); + + const authorizedUrl = new URL(server.wsUrl); + const unauthorizedUrl = `${authorizedUrl.origin}${authorizedUrl.pathname}`; + const unauthorizedClient = new WebSocket(unauthorizedUrl); + const unauthorizedClose = await withTimeout( + new Promise<{ code: number; reason: string }>((resolve, reject) => { + unauthorizedClient.once("close", (code, reason) => + resolve({ code, reason: reason.toString() }), + ); + unauthorizedClient.once("error", (error) => reject(error)); + }), + ); + expect(unauthorizedClose.code).toBe(WS_CLOSE_CODES.unauthorized); + expect(unauthorizedClose.reason).toBe(WS_CLOSE_REASONS.unauthorized); + + const response = await sendRequest( + authorizedClient.socket, + authorizedClient.nextMessage, + "todos-auth-1", + "todos.list", + ); + expect(response.ok).toBe(true); + + authorizedClient.socket.close(); + const closed = await withTimeout(authorizedClose); + expect([1000, 1005]).toContain(closed.code); + }); + + it("does not evict authorized client when duplicate-token client connects", async () => { + const server = await startRuntimeApiServer({ + port: 0, + launchCwd: process.cwd(), + authToken: "secret-token", + }); + servers.push(server); + + const authorizedClient = await connectClient(server.wsUrl); + await authorizedClient.nextMessage(); + + const authorizedClose = new Promise<{ code: number }>((resolve) => { + authorizedClient.socket.once("close", (code) => resolve({ code })); + }); + + const authorizedUrl = new URL(server.wsUrl); + const duplicateTokenClient = new WebSocket( + `${authorizedUrl.origin}${authorizedUrl.pathname}?token=secret-token&token=wrong-token`, + ); + const duplicateTokenClose = await withTimeout( + new Promise<{ code: number; reason: string }>((resolve, reject) => { + duplicateTokenClient.once("close", (code, reason) => + resolve({ code, reason: reason.toString() }), + ); + duplicateTokenClient.once("error", (error) => reject(error)); + }), + ); + expect(duplicateTokenClose.code).toBe(WS_CLOSE_CODES.unauthorized); + expect(duplicateTokenClose.reason).toBe(WS_CLOSE_REASONS.unauthorized); + + const response = await sendRequest( + authorizedClient.socket, + authorizedClient.nextMessage, + "todos-auth-duplicate-token-1", + "todos.list", + ); + expect(response.ok).toBe(true); + + authorizedClient.socket.close(); + const closed = await withTimeout(authorizedClose); + expect([1000, 1005]).toContain(closed.code); + }); + + it("does not evict authorized client when duplicate-identical-token client connects", async () => { + const server = await startRuntimeApiServer({ + port: 0, + launchCwd: process.cwd(), + authToken: "secret-token", + }); + servers.push(server); + + const authorizedClient = await connectClient(server.wsUrl); + await authorizedClient.nextMessage(); + + const authorizedClose = new Promise<{ code: number }>((resolve) => { + authorizedClient.socket.once("close", (code) => resolve({ code })); + }); + + const authorizedUrl = new URL(server.wsUrl); + const duplicateIdenticalTokenClient = new WebSocket( + `${authorizedUrl.origin}${authorizedUrl.pathname}?token=secret-token&token=secret-token`, + ); + const duplicateIdenticalTokenClose = await withTimeout( + new Promise<{ code: number; reason: string }>((resolve, reject) => { + duplicateIdenticalTokenClient.once("close", (code, reason) => + resolve({ code, reason: reason.toString() }), + ); + duplicateIdenticalTokenClient.once("error", (error) => reject(error)); + }), + ); + expect(duplicateIdenticalTokenClose.code).toBe(WS_CLOSE_CODES.unauthorized); + expect(duplicateIdenticalTokenClose.reason).toBe(WS_CLOSE_REASONS.unauthorized); + + const response = await sendRequest( + authorizedClient.socket, + authorizedClient.nextMessage, + "todos-auth-duplicate-identical-token-1", + "todos.list", + ); + expect(response.ok).toBe(true); + + authorizedClient.socket.close(); + const closed = await withTimeout(authorizedClose); + expect([1000, 1005]).toContain(closed.code); + }); + + it("does not evict authorized client when extra-query client connects", async () => { + const server = await startRuntimeApiServer({ + port: 0, + launchCwd: process.cwd(), + authToken: "secret-token", + }); + servers.push(server); + + const authorizedClient = await connectClient(server.wsUrl); + await authorizedClient.nextMessage(); + + const authorizedClose = new Promise<{ code: number }>((resolve) => { + authorizedClient.socket.once("close", (code) => resolve({ code })); + }); + + const authorizedUrl = new URL(server.wsUrl); + const extraQueryClient = new WebSocket( + `${authorizedUrl.origin}${authorizedUrl.pathname}?token=secret-token&debug=1`, + ); + const extraQueryClose = await withTimeout( + new Promise<{ code: number; reason: string }>((resolve, reject) => { + extraQueryClient.once("close", (code, reason) => + resolve({ code, reason: reason.toString() }), + ); + extraQueryClient.once("error", (error) => reject(error)); + }), + ); + expect(extraQueryClose.code).toBe(WS_CLOSE_CODES.unauthorized); + expect(extraQueryClose.reason).toBe(WS_CLOSE_REASONS.unauthorized); + + const response = await sendRequest( + authorizedClient.socket, + authorizedClient.nextMessage, + "todos-auth-extra-query-1", + "todos.list", + ); + expect(response.ok).toBe(true); + + authorizedClient.socket.close(); + const closed = await withTimeout(authorizedClose); + expect([1000, 1005]).toContain(closed.code); + }); + + it("does not evict authorized client when wrong-token-key client connects", async () => { + const server = await startRuntimeApiServer({ + port: 0, + launchCwd: process.cwd(), + authToken: "secret-token", + }); + servers.push(server); + + const authorizedClient = await connectClient(server.wsUrl); + await authorizedClient.nextMessage(); + + const authorizedClose = new Promise<{ code: number }>((resolve) => { + authorizedClient.socket.once("close", (code) => resolve({ code })); + }); + + const authorizedUrl = new URL(server.wsUrl); + const wrongTokenKeyClient = new WebSocket( + `${authorizedUrl.origin}${authorizedUrl.pathname}?Token=secret-token`, + ); + const wrongTokenKeyClose = await withTimeout( + new Promise<{ code: number; reason: string }>((resolve, reject) => { + wrongTokenKeyClient.once("close", (code, reason) => + resolve({ code, reason: reason.toString() }), + ); + wrongTokenKeyClient.once("error", (error) => reject(error)); + }), + ); + expect(wrongTokenKeyClose.code).toBe(WS_CLOSE_CODES.unauthorized); + expect(wrongTokenKeyClose.reason).toBe(WS_CLOSE_REASONS.unauthorized); + + const response = await sendRequest( + authorizedClient.socket, + authorizedClient.nextMessage, + "todos-auth-wrong-token-key-1", + "todos.list", + ); + expect(response.ok).toBe(true); + + authorizedClient.socket.close(); + const closed = await withTimeout(authorizedClose); + expect([1000, 1005]).toContain(closed.code); + }); + + it("does not evict authorized client when wrong-path client connects", async () => { + const server = await startRuntimeApiServer({ + port: 0, + launchCwd: process.cwd(), + authToken: "secret-token", + }); + servers.push(server); + + const authorizedClient = await connectClient(server.wsUrl); + await authorizedClient.nextMessage(); + + const authorizedClose = new Promise<{ code: number }>((resolve) => { + authorizedClient.socket.once("close", (code) => resolve({ code })); + }); + + const authorizedUrl = new URL(server.wsUrl); + const wrongPathClient = new WebSocket( + `${authorizedUrl.origin}/unexpected?token=secret-token`, + ); + const wrongPathClose = await withTimeout( + new Promise<{ code: number; reason: string }>((resolve, reject) => { + wrongPathClient.once("close", (code, reason) => + resolve({ code, reason: reason.toString() }), + ); + wrongPathClient.once("error", (error) => reject(error)); + }), + ); + expect(wrongPathClose.code).toBe(WS_CLOSE_CODES.unauthorized); + expect(wrongPathClose.reason).toBe(WS_CLOSE_REASONS.unauthorized); + + const response = await sendRequest( + authorizedClient.socket, + authorizedClient.nextMessage, + "todos-auth-wrong-path-1", + "todos.list", + ); + expect(response.ok).toBe(true); + + authorizedClient.socket.close(); + const closed = await withTimeout(authorizedClose); + expect([1000, 1005]).toContain(closed.code); + }); + + it("returns a bootstrap payload even when codex cannot initialize", async () => { + const originalPath = process.env.PATH; + process.env.PATH = ""; + try { + const server = await startRuntimeApiServer({ + port: 0, + launchCwd: process.cwd(), + bootstrapSessionTimeoutMs: 100, + }); + servers.push(server); + + const client = await connectClient(server.wsUrl); + await client.nextMessage(); + + const response = await sendRequest( + client.socket, + client.nextMessage, + "bootstrap-1", + "app.bootstrap", + ); + expect(response.ok).toBe(true); + if (!response.ok) { + throw new Error("Expected successful bootstrap response payload."); + } + + const payload = response.result as { + launchCwd: string; + projectName: string; + session: { status: string }; + bootstrapError?: string; + }; + expect(payload.launchCwd).toBe(process.cwd()); + expect(payload.projectName).toBe(path.basename(process.cwd()) || process.cwd()); + expect(payload.session.status).toBe("error"); + expect(typeof payload.bootstrapError).toBe("string"); + expect((payload.bootstrapError ?? "").length).toBeGreaterThan(0); + + client.socket.close(); + } finally { + process.env.PATH = originalPath; + } + }); + + it("handles repeated bootstrap requests under failure conditions", async () => { + const originalPath = process.env.PATH; + process.env.PATH = ""; + try { + const server = await startRuntimeApiServer({ + port: 0, + launchCwd: process.cwd(), + bootstrapSessionTimeoutMs: 100, + }); + servers.push(server); + + const client = await connectClient(server.wsUrl); + await client.nextMessage(); + + const first = await sendRequest( + client.socket, + client.nextMessage, + "bootstrap-repeat-1", + "app.bootstrap", + ); + const second = await sendRequest( + client.socket, + client.nextMessage, + "bootstrap-repeat-2", + "app.bootstrap", + ); + + expect(first.ok).toBe(true); + expect(second.ok).toBe(true); + if (!first.ok || !second.ok) { + throw new Error("Expected both bootstrap responses to succeed."); + } + + const firstSession = first.result as { + projectName: string; + session: { sessionId: string; status: string }; + }; + const secondSession = second.result as { + projectName: string; + session: { sessionId: string; status: string }; + }; + expect(firstSession.projectName).toBe(path.basename(process.cwd()) || process.cwd()); + expect(secondSession.projectName).toBe(path.basename(process.cwd()) || process.cwd()); + expect(firstSession.session.status).toBe("error"); + expect(firstSession.session.sessionId.length).toBeGreaterThan(0); + expect(secondSession.session.sessionId.length).toBeGreaterThan(0); + expect(secondSession.session.status).not.toBe("closed"); + + client.socket.close(); + } finally { + process.env.PATH = originalPath; + } + }); + + it( + "returns a ready bootstrap payload when codex app-server is available", + async () => { + const fakeCodex = createFakeCodexAppServerBinary("t3-bootstrap-fake-codex-"); + const originalPath = process.env.PATH; + process.env.PATH = `${fakeCodex.tempDir}${path.delimiter}${originalPath ?? ""}`; + + try { + const server = await startRuntimeApiServer({ + port: 0, + launchCwd: process.cwd(), + }); + servers.push(server); + + const client = await connectClient(server.wsUrl); + await client.nextMessage(); + + const bootstrapResponse = await sendRequest( + client.socket, + client.nextMessage, + "bootstrap-fake-success-1", + "app.bootstrap", + ); + expect(bootstrapResponse.ok).toBe(true); + if (!bootstrapResponse.ok) { + throw new Error("Expected bootstrap response to succeed with fake codex."); + } + + const payload = bootstrapResponse.result as { + launchCwd: string; + projectName: string; + provider: string; + model: string; + session: { + sessionId: string; + status: string; + threadId?: string; + }; + bootstrapError?: string; + }; + expect(payload.launchCwd).toBe(process.cwd()); + expect(payload.projectName).toBe(path.basename(process.cwd()) || process.cwd()); + expect(payload.provider).toBe("codex"); + expect(payload.model.length).toBeGreaterThan(0); + expect(payload.session.status).toBe("ready"); + expect(payload.session.threadId).toBe("thread-fake"); + expect(payload.session.sessionId.length).toBeGreaterThan(0); + expect(payload.bootstrapError).toBeUndefined(); + + const sessionsResponse = await sendRequest( + client.socket, + client.nextMessage, + "bootstrap-fake-sessions-1", + "providers.listSessions", + ); + expect(sessionsResponse.ok).toBe(true); + if (!sessionsResponse.ok) { + throw new Error("Expected providers.listSessions to succeed after bootstrap."); + } + const sessions = sessionsResponse.result as Array<{ + sessionId: string; + status: string; + threadId?: string; + }>; + const bootstrappedSession = sessions.find( + (entry) => entry.sessionId === payload.session.sessionId, + ); + expect(bootstrappedSession).toBeDefined(); + expect(bootstrappedSession?.status).toBe("ready"); + expect(bootstrappedSession?.threadId).toBe("thread-fake"); + + client.socket.close(); + } finally { + process.env.PATH = originalPath; + rmSync(fakeCodex.tempDir, { recursive: true, force: true }); + } + }, + 20_000, + ); + + it("returns structured errors for unknown methods", async () => { + const server = await startRuntimeApiServer({ + port: 0, + launchCwd: process.cwd(), + }); + servers.push(server); + + const client = await connectClient(server.wsUrl); + await client.nextMessage(); + + const response = await sendRequest( + client.socket, + client.nextMessage, + "unknown-1", + "unknown.method", + ); + expect(response.ok).toBe(false); + if (response.ok) { + throw new Error("Expected unknown method to fail."); + } + expect(response.error?.code).toBe("request_failed"); + expect(response.error?.message).toContain("Unknown API method"); + + client.socket.close(); + }); + + it( + "supports every websocket native API method declared in contracts", + async () => { + const server = await startRuntimeApiServer({ + port: 0, + launchCwd: process.cwd(), + }); + servers.push(server); + + const client = await connectClient(server.wsUrl); + await client.nextMessage(); + + const responses: WsResponseMessage[] = []; + await WS_NATIVE_API_METHODS.reduce( + (chain, method, index) => + chain.then(async () => { + const response = await sendRequest( + client.socket, + client.nextMessage, + `method-coverage-${index}`, + method, + METHOD_COVERAGE_PARAMS[method], + ); + responses.push(response); + }), + Promise.resolve(), + ); + + for (const response of responses) { + if (response.ok) { + continue; + } + + expect(response.error?.code).toBe("request_failed"); + expect(response.error?.message).not.toContain("Unknown API method"); + } + + client.socket.close(); + }, + 20_000, + ); + + it("returns structured errors for unknown methods at max method length", async () => { + const server = await startRuntimeApiServer({ + port: 0, + launchCwd: process.cwd(), + }); + servers.push(server); + + const client = await connectClient(server.wsUrl); + await client.nextMessage(); + + const maxLengthUnknownMethod = "m".repeat(WS_METHOD_MAX_CHARS); + const response = await sendRequest( + client.socket, + client.nextMessage, + "unknown-max-method-1", + maxLengthUnknownMethod, + ); + expect(response.ok).toBe(false); + if (response.ok) { + throw new Error("Expected max-length unknown method to fail."); + } + expect(response.error?.code).toBe("request_failed"); + expect(response.error?.message).toContain("Unknown API method"); + + client.socket.close(); + }); + + it("returns structured errors for invalid shell.openInEditor payloads", async () => { + const server = await startRuntimeApiServer({ + port: 0, + launchCwd: process.cwd(), + }); + servers.push(server); + + const client = await connectClient(server.wsUrl); + await client.nextMessage(); + + const response = await sendRequest( + client.socket, + client.nextMessage, + "shell-invalid-1", + "shell.openInEditor", + { + cwd: "/workspace", + editor: "unknown-editor", + }, + ); + expect(response.ok).toBe(false); + if (response.ok) { + throw new Error("Expected invalid shell payload to fail."); + } + expect(response.error?.code).toBe("request_failed"); + + client.socket.close(); + }); + + it("returns structured errors when shell.openInEditor target is missing", async () => { + const server = await startRuntimeApiServer({ + port: 0, + launchCwd: process.cwd(), + }); + servers.push(server); + + const client = await connectClient(server.wsUrl); + await client.nextMessage(); + + const missingPath = path.join(process.cwd(), `missing-editor-target-${Date.now()}`); + const response = await sendRequest( + client.socket, + client.nextMessage, + "shell-missing-path-1", + "shell.openInEditor", + { + cwd: missingPath, + editor: "file-manager", + }, + ); + expect(response.ok).toBe(false); + if (response.ok) { + throw new Error("Expected missing shell path request to fail."); + } + expect(response.error?.code).toBe("request_failed"); + expect(response.error?.message).toContain("Editor target does not exist"); + + client.socket.close(); + }); + + it("returns launch-cwd-relative errors for missing shell.openInEditor targets", async () => { + const launchCwd = mkdtempSync(path.join(os.tmpdir(), "t3-shell-missing-relative-")); + const server = await startRuntimeApiServer({ + port: 0, + launchCwd, + }); + servers.push(server); + + const client = await connectClient(server.wsUrl); + await client.nextMessage(); + + const response = await sendRequest( + client.socket, + client.nextMessage, + "shell-missing-relative-1", + "shell.openInEditor", + { + cwd: "missing-dir", + editor: "file-manager", + }, + ); + expect(response.ok).toBe(false); + if (response.ok) { + throw new Error("Expected relative missing shell target request to fail."); + } + expect(response.error?.code).toBe("request_failed"); + expect(response.error?.message).toContain(path.join(launchCwd, "missing-dir")); + + client.socket.close(); + }); + + it("returns structured errors when shell.openInEditor target is not a directory", async () => { + const server = await startRuntimeApiServer({ + port: 0, + launchCwd: process.cwd(), + }); + servers.push(server); + + const client = await connectClient(server.wsUrl); + await client.nextMessage(); + + const tempDir = mkdtempSync(path.join(os.tmpdir(), "t3-shell-target-file-")); + const filePath = path.join(tempDir, "target.txt"); + writeFileSync(filePath, "not-a-dir", "utf8"); + const response = await sendRequest( + client.socket, + client.nextMessage, + "shell-file-path-1", + "shell.openInEditor", + { + cwd: filePath, + editor: "cursor", + }, + ); + expect(response.ok).toBe(false); + if (response.ok) { + throw new Error("Expected file-path shell target request to fail."); + } + expect(response.error?.code).toBe("request_failed"); + expect(response.error?.message).toContain("Editor target is not a directory"); + + client.socket.close(); + }); + + it("returns structured errors when shell.openInEditor target is whitespace", async () => { + const server = await startRuntimeApiServer({ + port: 0, + launchCwd: process.cwd(), + }); + servers.push(server); + + const client = await connectClient(server.wsUrl); + await client.nextMessage(); + + const response = await sendRequest( + client.socket, + client.nextMessage, + "shell-whitespace-path-1", + "shell.openInEditor", + { + cwd: " ", + editor: "cursor", + }, + ); + expect(response.ok).toBe(false); + if (response.ok) { + throw new Error("Expected whitespace shell target request to fail."); + } + expect(response.error?.code).toBe("request_failed"); + expect(response.error?.message).toContain("Editor target must be a non-empty path"); + + client.socket.close(); + }); + + it("truncates overlong runtime error messages to websocket protocol limits", async () => { + const server = await startRuntimeApiServer({ + port: 0, + launchCwd: process.cwd(), + }); + servers.push(server); + + const client = await connectClient(server.wsUrl); + await client.nextMessage(); + + const response = await sendRequest( + client.socket, + client.nextMessage, + "shell-overlong-error-1", + "shell.openInEditor", + { + cwd: `/${"x".repeat(WS_ERROR_MESSAGE_MAX_CHARS + 500)}`, + editor: "cursor", + }, + ); + expect(response.ok).toBe(false); + if (response.ok) { + throw new Error("Expected overlong shell target request to fail."); + } + expect(response.error?.code).toBe("request_failed"); + expect(typeof response.error?.message).toBe("string"); + expect(response.error?.message?.toLowerCase()).toContain("editor target"); + expect((response.error?.message ?? "").length).toBeLessThanOrEqual(WS_ERROR_MESSAGE_MAX_CHARS); + expect(response.error?.message).toContain("…"); + + client.socket.close(); + }); + + it("returns structured errors for invalid terminal.run payloads", async () => { + const server = await startRuntimeApiServer({ + port: 0, + launchCwd: process.cwd(), + }); + servers.push(server); + + const client = await connectClient(server.wsUrl); + await client.nextMessage(); + + const response = await sendRequest( + client.socket, + client.nextMessage, + "terminal-invalid-1", + "terminal.run", + { + command: "", + }, + ); + expect(response.ok).toBe(false); + if (response.ok) { + throw new Error("Expected invalid terminal payload to fail."); + } + expect(response.error?.code).toBe("request_failed"); + + client.socket.close(); + }); + + it("returns structured errors for invalid todos.toggle payloads", async () => { + const server = await startRuntimeApiServer({ + port: 0, + launchCwd: process.cwd(), + }); + servers.push(server); + + const client = await connectClient(server.wsUrl); + await client.nextMessage(); + + const response = await sendRequest( + client.socket, + client.nextMessage, + "todo-invalid-1", + "todos.toggle", + "", + ); + expect(response.ok).toBe(false); + if (response.ok) { + throw new Error("Expected invalid todo id payload to fail."); + } + expect(response.error?.code).toBe("request_failed"); + + client.socket.close(); + }); + + it("returns structured errors for invalid agent.spawn payloads", async () => { + const server = await startRuntimeApiServer({ + port: 0, + launchCwd: process.cwd(), + }); + servers.push(server); + + const client = await connectClient(server.wsUrl); + await client.nextMessage(); + + const response = await sendRequest( + client.socket, + client.nextMessage, + "agent-invalid-1", + "agent.spawn", + { + command: "", + }, + ); + expect(response.ok).toBe(false); + if (response.ok) { + throw new Error("Expected invalid agent payload to fail."); + } + expect(response.error?.code).toBe("request_failed"); + + client.socket.close(); + }); + + it("returns structured errors for invalid providers.respondToRequest payloads", async () => { + const server = await startRuntimeApiServer({ + port: 0, + launchCwd: process.cwd(), + }); + servers.push(server); + + const client = await connectClient(server.wsUrl); + await client.nextMessage(); + + const response = await sendRequest( + client.socket, + client.nextMessage, + "provider-respond-invalid-1", + "providers.respondToRequest", + { + sessionId: "sess-1", + requestId: "req-1", + decision: "invalid-decision", + }, + ); + expect(response.ok).toBe(false); + if (response.ok) { + throw new Error("Expected invalid provider decision payload to fail."); + } + expect(response.error?.code).toBe("request_failed"); + + client.socket.close(); + }); + + it("returns structured errors for invalid providers.sendTurn payloads", async () => { + const server = await startRuntimeApiServer({ + port: 0, + launchCwd: process.cwd(), + }); + servers.push(server); + + const client = await connectClient(server.wsUrl); + await client.nextMessage(); + + const response = await sendRequest( + client.socket, + client.nextMessage, + "provider-send-invalid-1", + "providers.sendTurn", + { + sessionId: "sess-1", + input: "", + }, + ); + expect(response.ok).toBe(false); + if (response.ok) { + throw new Error("Expected invalid provider sendTurn payload to fail."); + } + expect(response.error?.code).toBe("request_failed"); + + client.socket.close(); + }); + + it("returns structured errors for invalid providers.startSession payloads", async () => { + const server = await startRuntimeApiServer({ + port: 0, + launchCwd: process.cwd(), + }); + servers.push(server); + + const client = await connectClient(server.wsUrl); + await client.nextMessage(); + + const response = await sendRequest( + client.socket, + client.nextMessage, + "provider-start-invalid-1", + "providers.startSession", + { + provider: "unknown-provider", + }, + ); + expect(response.ok).toBe(false); + if (response.ok) { + throw new Error("Expected invalid provider start payload to fail."); + } + expect(response.error?.code).toBe("request_failed"); + + client.socket.close(); + }); + + it("returns structured errors for invalid providers.interruptTurn payloads", async () => { + const server = await startRuntimeApiServer({ + port: 0, + launchCwd: process.cwd(), + }); + servers.push(server); + + const client = await connectClient(server.wsUrl); + await client.nextMessage(); + + const response = await sendRequest( + client.socket, + client.nextMessage, + "provider-interrupt-invalid-1", + "providers.interruptTurn", + { + sessionId: "", + turnId: "turn-1", + }, + ); + expect(response.ok).toBe(false); + if (response.ok) { + throw new Error("Expected invalid provider interrupt payload to fail."); + } + expect(response.error?.code).toBe("request_failed"); + + client.socket.close(); + }); + + it("returns structured errors for invalid providers.stopSession payloads", async () => { + const server = await startRuntimeApiServer({ + port: 0, + launchCwd: process.cwd(), + }); + servers.push(server); + + const client = await connectClient(server.wsUrl); + await client.nextMessage(); + + const response = await sendRequest( + client.socket, + client.nextMessage, + "provider-stop-invalid-1", + "providers.stopSession", + { + sessionId: "", + }, + ); + expect(response.ok).toBe(false); + if (response.ok) { + throw new Error("Expected invalid provider stop payload to fail."); + } + expect(response.error?.code).toBe("request_failed"); + + client.socket.close(); + }); + + it("runs terminal commands through terminal.run endpoint", async () => { + const server = await startRuntimeApiServer({ + port: 0, + launchCwd: process.cwd(), + }); + servers.push(server); + + const client = await connectClient(server.wsUrl); + await client.nextMessage(); + + const response = await sendRequest( + client.socket, + client.nextMessage, + "terminal-run-1", + "terminal.run", + { + command: "echo hello", + cwd: process.cwd(), + timeoutMs: 5_000, + }, + ); + expect(response.ok).toBe(true); + if (!response.ok) { + throw new Error("Expected terminal.run response to succeed."); + } + const payload = response.result as { + stdout: string; + stderr: string; + code: number | null; + timedOut: boolean; + }; + expect(payload.stdout.toLowerCase()).toContain("hello"); + expect(payload.stderr).toBe(""); + expect(payload.code).toBe(0); + expect(payload.timedOut).toBe(false); + + client.socket.close(); + }); + + it("marks long-running terminal commands as timed out", async () => { + const server = await startRuntimeApiServer({ + port: 0, + launchCwd: process.cwd(), + }); + servers.push(server); + + const client = await connectClient(server.wsUrl); + await client.nextMessage(); + + const response = await sendRequest( + client.socket, + client.nextMessage, + "terminal-timeout-1", + "terminal.run", + { + command: "sleep 2", + cwd: process.cwd(), + timeoutMs: 100, + }, + ); + expect(response.ok).toBe(true); + if (!response.ok) { + throw new Error("Expected terminal.run timeout response to succeed."); + } + const payload = response.result as { + timedOut: boolean; + stdout: string; + stderr: string; + code: number | null; + }; + expect(payload.timedOut).toBe(true); + expect(payload.stdout).toBe(""); + expect(payload.stderr).toBe(""); + expect(payload.code === null || payload.code > 0).toBe(true); + + client.socket.close(); + }); + + it("force-kills commands that ignore SIGTERM after timeout", async () => { + if (process.platform === "win32") { + return; + } + + const server = await startRuntimeApiServer({ + port: 0, + launchCwd: process.cwd(), + }); + servers.push(server); + + const client = await connectClient(server.wsUrl); + await client.nextMessage(); + + const startedAt = Date.now(); + const response = await sendRequest( + client.socket, + client.nextMessage, + "terminal-timeout-sigkill-1", + "terminal.run", + { + command: "trap '' TERM; while true; do sleep 1; done", + cwd: process.cwd(), + timeoutMs: 100, + }, + ); + const elapsedMs = Date.now() - startedAt; + expect(response.ok).toBe(true); + if (!response.ok) { + throw new Error("Expected SIGTERM-resistant command response to succeed."); + } + const payload = response.result as { + timedOut: boolean; + }; + expect(payload.timedOut).toBe(true); + expect(elapsedMs).toBeLessThan(5_000); + + client.socket.close(); + }); + + it("runs terminal commands in the requested cwd", async () => { + const server = await startRuntimeApiServer({ + port: 0, + launchCwd: process.cwd(), + }); + servers.push(server); + + const client = await connectClient(server.wsUrl); + await client.nextMessage(); + + const response = await sendRequest( + client.socket, + client.nextMessage, + "terminal-cwd-1", + "terminal.run", + { + command: "pwd", + cwd: process.cwd(), + }, + ); + expect(response.ok).toBe(true); + if (!response.ok) { + throw new Error("Expected terminal cwd response to succeed."); + } + + const payload = response.result as { + stdout: string; + }; + expect(payload.stdout.trim()).toBe(process.cwd()); + + client.socket.close(); + }); + + it("defaults terminal commands to launch cwd when cwd is omitted", async () => { + const launchCwd = path.dirname(process.cwd()); + const server = await startRuntimeApiServer({ + port: 0, + launchCwd, + }); + servers.push(server); + + const client = await connectClient(server.wsUrl); + await client.nextMessage(); + + const response = await sendRequest( + client.socket, + client.nextMessage, + "terminal-default-cwd-1", + "terminal.run", + { + command: "pwd", + }, + ); + expect(response.ok).toBe(true); + if (!response.ok) { + throw new Error("Expected terminal default cwd response to succeed."); + } + + const payload = response.result as { + stdout: string; + }; + expect(payload.stdout.trim()).toBe(launchCwd); + + client.socket.close(); + }); + + it("resolves relative terminal cwd paths against runtime working directory", async () => { + const server = await startRuntimeApiServer({ + port: 0, + launchCwd: process.cwd(), + }); + servers.push(server); + + const client = await connectClient(server.wsUrl); + await client.nextMessage(); + + const response = await sendRequest( + client.socket, + client.nextMessage, + "terminal-cwd-relative-1", + "terminal.run", + { + command: "pwd", + cwd: ".", + }, + ); + expect(response.ok).toBe(true); + if (!response.ok) { + throw new Error("Expected relative terminal cwd response to succeed."); + } + + const payload = response.result as { + stdout: string; + }; + expect(payload.stdout.trim()).toBe(process.cwd()); + + client.socket.close(); + }); + + it("resolves relative terminal cwd paths from launch cwd", async () => { + const launchCwd = mkdtempSync(path.join(os.tmpdir(), "t3-terminal-launch-cwd-")); + const nestedDir = path.join(launchCwd, "nested"); + mkdirSync(nestedDir, { recursive: true }); + + const server = await startRuntimeApiServer({ + port: 0, + launchCwd, + }); + servers.push(server); + + const client = await connectClient(server.wsUrl); + await client.nextMessage(); + + const response = await sendRequest( + client.socket, + client.nextMessage, + "terminal-cwd-relative-launch-1", + "terminal.run", + { + command: "pwd", + cwd: "nested", + }, + ); + expect(response.ok).toBe(true); + if (!response.ok) { + throw new Error("Expected relative launch cwd terminal response to succeed."); + } + + const payload = response.result as { + stdout: string; + }; + expect(payload.stdout.trim()).toBe(nestedDir); + + client.socket.close(); + }); + + it("returns structured errors when terminal cwd does not exist", async () => { + const server = await startRuntimeApiServer({ + port: 0, + launchCwd: process.cwd(), + }); + servers.push(server); + + const client = await connectClient(server.wsUrl); + await client.nextMessage(); + + const missingCwd = path.join(process.cwd(), `missing-cwd-${Date.now()}`); + const response = await sendRequest( + client.socket, + client.nextMessage, + "terminal-missing-cwd-1", + "terminal.run", + { + command: "pwd", + cwd: missingCwd, + }, + ); + expect(response.ok).toBe(false); + if (response.ok) { + throw new Error("Expected terminal missing cwd response to fail."); + } + expect(response.error?.code).toBe("request_failed"); + expect(response.error?.message).toContain("Working directory does not exist"); + + client.socket.close(); + }); + + it("returns launch-cwd-relative errors when terminal cwd is missing", async () => { + const launchCwd = mkdtempSync(path.join(os.tmpdir(), "t3-terminal-missing-relative-")); + const server = await startRuntimeApiServer({ + port: 0, + launchCwd, + }); + servers.push(server); + + const client = await connectClient(server.wsUrl); + await client.nextMessage(); + + const response = await sendRequest( + client.socket, + client.nextMessage, + "terminal-missing-relative-1", + "terminal.run", + { + command: "pwd", + cwd: "missing-subdir", + }, + ); + expect(response.ok).toBe(false); + if (response.ok) { + throw new Error("Expected relative missing terminal cwd response to fail."); + } + expect(response.error?.code).toBe("request_failed"); + expect(response.error?.message).toContain(path.join(launchCwd, "missing-subdir")); + + client.socket.close(); + }); + + it("returns structured errors when terminal cwd is not a directory", async () => { + const server = await startRuntimeApiServer({ + port: 0, + launchCwd: process.cwd(), + }); + servers.push(server); + + const client = await connectClient(server.wsUrl); + await client.nextMessage(); + + const tempDir = mkdtempSync(path.join(os.tmpdir(), "t3-terminal-file-cwd-")); + const fileCwd = path.join(tempDir, "file.txt"); + writeFileSync(fileCwd, "not-a-dir", "utf8"); + + const response = await sendRequest( + client.socket, + client.nextMessage, + "terminal-file-cwd-1", + "terminal.run", + { + command: "pwd", + cwd: fileCwd, + }, + ); + expect(response.ok).toBe(false); + if (response.ok) { + throw new Error("Expected terminal file cwd response to fail."); + } + expect(response.error?.code).toBe("request_failed"); + expect(response.error?.message).toContain("Working directory is not a directory"); + + client.socket.close(); + }); + + it("returns structured errors when terminal cwd is whitespace", async () => { + const server = await startRuntimeApiServer({ + port: 0, + launchCwd: process.cwd(), + }); + servers.push(server); + + const client = await connectClient(server.wsUrl); + await client.nextMessage(); + + const response = await sendRequest( + client.socket, + client.nextMessage, + "terminal-whitespace-cwd-1", + "terminal.run", + { + command: "pwd", + cwd: " ", + }, + ); + expect(response.ok).toBe(false); + if (response.ok) { + throw new Error("Expected terminal whitespace cwd response to fail."); + } + expect(response.error?.code).toBe("request_failed"); + expect(response.error?.message).toContain("Working directory must be a non-empty path"); + + client.socket.close(); + }); + + it("returns non-zero terminal exit codes without request failure", async () => { + const server = await startRuntimeApiServer({ + port: 0, + launchCwd: process.cwd(), + }); + servers.push(server); + + const client = await connectClient(server.wsUrl); + await client.nextMessage(); + + const response = await sendRequest( + client.socket, + client.nextMessage, + "terminal-exit-code-1", + "terminal.run", + { + command: "exit 3", + cwd: process.cwd(), + }, + ); + expect(response.ok).toBe(true); + if (!response.ok) { + throw new Error("Expected terminal non-zero exit response to succeed."); + } + + const payload = response.result as { + code: number | null; + timedOut: boolean; + }; + expect(payload.code).toBe(3); + expect(payload.timedOut).toBe(false); + + client.socket.close(); + }); + + it("supports todo mutation lifecycle over websocket RPC", async () => { + const server = await startRuntimeApiServer({ + port: 0, + launchCwd: process.cwd(), + }); + servers.push(server); + + const client = await connectClient(server.wsUrl); + await client.nextMessage(); + + const addResponse = await sendRequest( + client.socket, + client.nextMessage, + "todo-add-1", + "todos.add", + { title: "test todo" }, + ); + expect(addResponse.ok).toBe(true); + if (!addResponse.ok) { + throw new Error("Expected todos.add response to succeed."); + } + + const afterAdd = addResponse.result as Array<{ + id: string; + title: string; + completed: boolean; + }>; + expect(afterAdd.length).toBeGreaterThan(0); + const createdTodo = afterAdd[0]; + expect(createdTodo?.title).toBe("test todo"); + expect(createdTodo?.completed).toBe(false); + if (!createdTodo?.id) { + throw new Error("Expected created todo id."); + } + + const toggleResponse = await sendRequest( + client.socket, + client.nextMessage, + "todo-toggle-1", + "todos.toggle", + createdTodo.id, + ); + expect(toggleResponse.ok).toBe(true); + if (!toggleResponse.ok) { + throw new Error("Expected todos.toggle response to succeed."); + } + const afterToggle = toggleResponse.result as Array<{ + id: string; + completed: boolean; + }>; + const toggled = afterToggle.find((todo) => todo.id === createdTodo.id); + expect(toggled?.completed).toBe(true); + + const removeResponse = await sendRequest( + client.socket, + client.nextMessage, + "todo-remove-1", + "todos.remove", + createdTodo.id, + ); + expect(removeResponse.ok).toBe(true); + if (!removeResponse.ok) { + throw new Error("Expected todos.remove response to succeed."); + } + const afterRemove = removeResponse.result as Array<{ id: string }>; + expect(afterRemove.some((todo) => todo.id === createdTodo.id)).toBe(false); + + client.socket.close(); + }); + + it("streams agent output and exit events for spawned commands", async () => { + const server = await startRuntimeApiServer({ + port: 0, + launchCwd: process.cwd(), + }); + servers.push(server); + + const client = await connectClient(server.wsUrl); + await client.nextMessage(); + + const spawnResponse = await sendRequest( + client.socket, + client.nextMessage, + "agent-spawn-1", + "agent.spawn", + { + command: "bash", + args: ["-lc", "printf runtime-agent-test"], + cwd: process.cwd(), + }, + ); + expect(spawnResponse.ok).toBe(true); + if (!spawnResponse.ok) { + throw new Error("Expected agent.spawn response to succeed."); + } + const sessionId = String(spawnResponse.result); + + const outputEvent = await waitForAgentEvent( + client.nextMessage, + WS_EVENT_CHANNELS.agentOutput, + sessionId, + ); + const outputPayload = outputEvent.payload as { + stream: string; + data: string; + }; + expect(outputPayload.stream).toBe("stdout"); + expect(outputPayload.data).toContain("runtime-agent-test"); + + const exitEvent = await waitForAgentEvent( + client.nextMessage, + WS_EVENT_CHANNELS.agentExit, + sessionId, + ); + const exitPayload = exitEvent.payload as { + code: number | null; + }; + expect(exitPayload.code).toBe(0); + + client.socket.close(); + }); + + it("supports agent.write and agent.kill lifecycle", async () => { + const server = await startRuntimeApiServer({ + port: 0, + launchCwd: process.cwd(), + }); + servers.push(server); + + const client = await connectClient(server.wsUrl); + await client.nextMessage(); + + const spawnResponse = await sendRequest( + client.socket, + client.nextMessage, + "agent-spawn-2", + "agent.spawn", + { + command: "bash", + args: ["-lc", "cat"], + cwd: process.cwd(), + }, + ); + expect(spawnResponse.ok).toBe(true); + if (!spawnResponse.ok) { + throw new Error("Expected agent.spawn response to succeed."); + } + const sessionId = String(spawnResponse.result); + + const writeResponse = await sendRequest( + client.socket, + client.nextMessage, + "agent-write-1", + "agent.write", + { + sessionId, + data: "runtime-write-test\n", + }, + ); + expect(writeResponse.ok).toBe(true); + expect(writeResponse.result).toBeNull(); + + const outputEvent = await waitForAgentEvent( + client.nextMessage, + WS_EVENT_CHANNELS.agentOutput, + sessionId, + ); + const outputPayload = outputEvent.payload as { + data: string; + }; + expect(outputPayload.data).toContain("runtime-write-test"); + + const killResponse = await sendRequest( + client.socket, + client.nextMessage, + "agent-kill-1", + "agent.kill", + sessionId, + ); + expect(killResponse.ok).toBe(true); + expect(killResponse.result).toBeNull(); + + const exitEvent = await waitForAgentEvent( + client.nextMessage, + WS_EVENT_CHANNELS.agentExit, + sessionId, + ); + const exitPayload = exitEvent.payload as { + code: number | null; + signal: string | null; + }; + expect(exitPayload.code === null || typeof exitPayload.code === "number").toBe(true); + expect(exitPayload.signal === null || typeof exitPayload.signal === "string").toBe(true); + + client.socket.close(); + }); + + it("returns structured errors when writing to unknown agent session", async () => { + const server = await startRuntimeApiServer({ + port: 0, + launchCwd: process.cwd(), + }); + servers.push(server); + + const client = await connectClient(server.wsUrl); + await client.nextMessage(); + + const response = await sendRequest( + client.socket, + client.nextMessage, + "agent-write-unknown-1", + "agent.write", + { + sessionId: "missing-session-id", + data: "hello", + }, + ); + expect(response.ok).toBe(false); + if (response.ok) { + throw new Error("Expected unknown agent.write session to fail."); + } + expect(response.error?.code).toBe("request_failed"); + + client.socket.close(); + }); + + it("treats agent.kill for unknown session as successful no-op", async () => { + const server = await startRuntimeApiServer({ + port: 0, + launchCwd: process.cwd(), + }); + servers.push(server); + + const client = await connectClient(server.wsUrl); + await client.nextMessage(); + + const response = await sendRequest( + client.socket, + client.nextMessage, + "agent-kill-unknown-1", + "agent.kill", + "missing-session-id", + ); + expect(response.ok).toBe(true); + expect(response.result).toBeNull(); + + client.socket.close(); + }); + + it("reports runtime health metadata", async () => { + const server = await startRuntimeApiServer({ + port: 0, + launchCwd: process.cwd(), + }); + servers.push(server); + + const client = await connectClient(server.wsUrl); + await client.nextMessage(); + + const response = await sendRequest( + client.socket, + client.nextMessage, + "health-1", + "app.health", + ); + expect(response.ok).toBe(true); + if (!response.ok) { + throw new Error("Expected health response to succeed."); + } + + const payload = response.result as { + status: string; + launchCwd: string; + sessionCount: number; + activeClientConnected: boolean; + }; + expect(payload.status).toBe("ok"); + expect(payload.launchCwd).toBe(process.cwd()); + expect(typeof payload.sessionCount).toBe("number"); + expect(payload.activeClientConnected).toBe(true); + + client.socket.close(); + }); +}); diff --git a/apps/t3/src/runtimeApiServer.ts b/apps/t3/src/runtimeApiServer.ts new file mode 100644 index 00000000000..e55bdb7f692 --- /dev/null +++ b/apps/t3/src/runtimeApiServer.ts @@ -0,0 +1,818 @@ +import { spawn } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { WebSocket, WebSocketServer, type RawData } from "ws"; + +import { + EDITORS, + DEFAULT_MODEL, + appBootstrapResultSchema, + appHealthResultSchema, + dialogsPickFolderResultSchema, + type ProviderSession, + type WsClientMessage, + type WsResponseMessage, + type WsServerMessage, + WS_EVENT_CHANNELS, + WS_ERROR_CODE_MAX_CHARS, + WS_ERROR_MESSAGE_MAX_CHARS, + WS_CLOSE_CODES, + WS_CLOSE_REASONS, + agentConfigSchema, + agentSessionIdSchema, + agentWriteInputSchema, + newTodoInputSchema, + todoIdSchema, + providerInterruptTurnInputSchema, + providerRespondToRequestInputSchema, + providerSendTurnInputSchema, + providerSessionListSchema, + providerSessionStartInputSchema, + providerSessionSchema, + providerStopSessionInputSchema, + providerTurnStartResultSchema, + type TerminalCommandInput, + shellOpenInEditorInputSchema, + terminalCommandResultSchema, + terminalCommandInputSchema, + todoListSchema, + wsClientMessageSchema, + wsNativeApiMethodSchema, + wsServerMessageSchema, +} from "@acme/contracts"; +import { ProcessManager, ProviderManager, TodoStore } from "@acme/runtime-core"; + +interface RuntimeApiServerOptions { + port: number; + launchCwd: string; + bootstrapSessionTimeoutMs?: number; + authToken?: string; +} + +interface RuntimeApiServer { + wsUrl: string; + close: () => Promise; +} + +interface JsonRpcErrorResult { + code: string; + message: string; +} + +const BOOTSTRAP_SESSION_TIMEOUT_MS = 3_000; +const MAX_BOOTSTRAP_SESSION_TIMEOUT_MS = 120_000; +const MAX_WS_CLIENT_PAYLOAD_BYTES = 5 * 1024 * 1024; +const MAX_ERROR_PATH_CHARS = 2_048; + +interface BootstrapSessionResult { + session: ProviderSession; + bootstrapError: string | undefined; +} + +type WsServerEventMessage = Extract; + +function raceWithTimeout( + promise: Promise, + timeoutMs: number, + timeoutMessage: string, +): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error(timeoutMessage)); + }, timeoutMs); + timeout.unref(); + + promise.then( + (value) => { + clearTimeout(timeout); + resolve(value); + }, + (error) => { + clearTimeout(timeout); + reject(error); + }, + ); + }); +} + +function responseSuccess(id: string, result: unknown): WsResponseMessage { + return { + type: "response", + id, + ok: true, + result: result === undefined ? null : result, + }; +} + +function responseError(id: string, error: JsonRpcErrorResult): WsResponseMessage { + const normalizedCode = normalizeErrorField( + error.code, + "request_failed", + WS_ERROR_CODE_MAX_CHARS, + ); + const normalizedMessage = normalizeErrorField( + error.message, + "Request failed", + WS_ERROR_MESSAGE_MAX_CHARS, + ); + return { + type: "response", + id, + ok: false, + error: { + code: normalizedCode, + message: normalizedMessage, + }, + }; +} + +function normalizeErrorField(value: string, fallback: string, maxChars: number): string { + const trimmed = value.trim(); + const safeValue = trimmed.length > 0 ? trimmed : fallback; + if (safeValue.length <= maxChars) { + return safeValue; + } + + if (maxChars <= 1) { + return safeValue.slice(0, maxChars); + } + + return `${safeValue.slice(0, maxChars - 1)}…`; +} + +function summarizePathForError(candidatePath: string): string { + if (candidatePath.length <= MAX_ERROR_PATH_CHARS) { + return candidatePath; + } + + const prefixLength = Math.floor((MAX_ERROR_PATH_CHARS - 1) / 2); + const suffixLength = MAX_ERROR_PATH_CHARS - 1 - prefixLength; + return `${candidatePath.slice(0, prefixLength)}…${candidatePath.slice(-suffixLength)}`; +} + +function sendMessage(socket: WebSocket, message: unknown): void { + if (socket.readyState !== WebSocket.OPEN) { + return; + } + + const parsedMessage = wsServerMessageSchema.safeParse(message); + if (!parsedMessage.success) { + return; + } + + try { + socket.send(JSON.stringify(parsedMessage.data)); + } catch { + // Best-effort event delivery. Socket may have transitioned states. + } +} + +function decodeClientMessage(raw: RawData): string | null { + if (typeof raw === "string") { + return raw; + } + + if (raw instanceof Buffer) { + return raw.toString("utf8"); + } + + if (raw instanceof ArrayBuffer) { + return Buffer.from(raw).toString("utf8"); + } + + if (Array.isArray(raw)) { + return Buffer.concat(raw).toString("utf8"); + } + + return null; +} + +function openPathInFileManager(targetPath: string): void { + const command = + process.platform === "win32" ? "explorer" : process.platform === "darwin" ? "open" : "xdg-open"; + + const child = spawn(command, [targetPath], { + detached: true, + stdio: "ignore", + }); + child.on("error", () => { + // Best-effort shell handoff. + }); + child.unref(); +} + +async function tryCommand(command: string, args: string[]): Promise { + return new Promise((resolve) => { + const child = spawn(command, args, { + stdio: ["ignore", "pipe", "ignore"], + }); + + let output = ""; + child.stdout.on("data", (chunk: Buffer) => { + output += chunk.toString(); + }); + + child.on("error", () => { + resolve(null); + }); + + child.on("close", (code) => { + if (code !== 0) { + resolve(null); + return; + } + + const trimmed = output.trim(); + resolve(trimmed.length > 0 ? trimmed : null); + }); + }); +} + +async function pickFolder(): Promise { + if (process.platform === "darwin") { + const script = + 'try\nset selectedFolder to POSIX path of (choose folder with prompt "Choose a project folder")\nreturn selectedFolder\non error\nreturn ""\nend try'; + const result = await tryCommand("osascript", ["-e", script]); + return result && result.length > 0 ? result : null; + } + + if (process.platform === "win32") { + const powershellScript = [ + "Add-Type -AssemblyName System.Windows.Forms", + "$dialog = New-Object System.Windows.Forms.FolderBrowserDialog", + '$dialog.Description = "Choose a project folder"', + "if ($dialog.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {", + " Write-Output $dialog.SelectedPath", + "}", + ].join("; "); + const result = await tryCommand("powershell", ["-NoProfile", "-Command", powershellScript]); + return result && result.length > 0 ? result : null; + } + + const zenity = await tryCommand("zenity", ["--file-selection", "--directory"]); + if (zenity) { + return zenity; + } + + const kdialog = await tryCommand("kdialog", ["--getexistingdirectory"]); + if (kdialog) { + return kdialog; + } + + return null; +} + +async function runTerminalCommand( + parsed: TerminalCommandInput, + defaultCwd: string, +) { + const providedCwd = parsed.cwd ?? defaultCwd; + const resolvedCwd = resolveExistingDirectoryFromBase( + providedCwd, + "Working directory", + defaultCwd, + ); + + const shellPath = + process.platform === "win32" + ? (process.env.ComSpec ?? "cmd.exe") + : (process.env.SHELL ?? "/bin/sh"); + const args = + process.platform === "win32" ? ["/d", "/s", "/c", parsed.command] : ["-lc", parsed.command]; + + return new Promise<{ + stdout: string; + stderr: string; + code: number | null; + signal: string | null; + timedOut: boolean; + }>((resolve, reject) => { + const child = spawn(shellPath, args, { + cwd: resolvedCwd, + env: process.env, + stdio: ["ignore", "pipe", "pipe"], + }); + + let stdout = ""; + let stderr = ""; + let timedOut = false; + + const timeout = setTimeout(() => { + timedOut = true; + child.kill("SIGTERM"); + setTimeout(() => { + if (child.exitCode === null && child.signalCode === null) { + child.kill("SIGKILL"); + } + }, 1_000).unref(); + }, parsed.timeoutMs ?? 30_000); + timeout.unref(); + + child.stdout?.on("data", (chunk: Buffer) => { + stdout += chunk.toString(); + }); + child.stderr?.on("data", (chunk: Buffer) => { + stderr += chunk.toString(); + }); + child.on("error", (error) => { + clearTimeout(timeout); + reject(error); + }); + child.on("close", (code, signal) => { + clearTimeout(timeout); + resolve({ + stdout, + stderr, + code: code ?? null, + signal: signal ?? null, + timedOut, + }); + }); + }); +} + +function resolveExistingDirectory(targetPath: string, label: string): string { + if (typeof targetPath !== "string" || targetPath.trim().length === 0) { + throw new Error(`${label} must be a non-empty path.`); + } + + const candidate = path.resolve(targetPath); + let stats: fs.Stats; + try { + stats = fs.statSync(candidate); + } catch (error) { + const code = (error as NodeJS.ErrnoException | undefined)?.code; + const safeCandidate = summarizePathForError(candidate); + if (code && code !== "ENOENT") { + throw new Error(`Failed to access ${label.toLowerCase()}: ${safeCandidate} (${code})`, { + cause: error, + }); + } + throw new Error(`${label} does not exist: ${safeCandidate}`, { + cause: error, + }); + } + + if (!stats.isDirectory()) { + throw new Error(`${label} is not a directory: ${summarizePathForError(candidate)}`); + } + + return candidate; +} + +function resolveExistingDirectoryFromBase( + targetPath: string, + label: string, + baseDirectory: string, +): string { + if (typeof targetPath !== "string" || targetPath.trim().length === 0) { + throw new Error(`${label} must be a non-empty path.`); + } + + const candidate = path.isAbsolute(targetPath) + ? path.resolve(targetPath) + : path.resolve(baseDirectory, targetPath); + return resolveExistingDirectory(candidate, label); +} + +export async function startRuntimeApiServer( + options: RuntimeApiServerOptions, +): Promise { + if (!Number.isInteger(options.port) || options.port < 0 || options.port > 65_535) { + throw new Error("Invalid runtime port: expected integer between 0 and 65535."); + } + const launchCwd = resolveExistingDirectory(options.launchCwd, "Invalid launchCwd"); + if (options.authToken !== undefined && typeof options.authToken !== "string") { + throw new Error("Invalid runtime auth token: expected non-empty string."); + } + const authToken = options.authToken?.trim(); + if (options.authToken !== undefined && !authToken) { + throw new Error("Invalid runtime auth token: expected non-empty string."); + } + const bootstrapSessionTimeoutMs = + options.bootstrapSessionTimeoutMs ?? BOOTSTRAP_SESSION_TIMEOUT_MS; + if ( + !Number.isInteger(bootstrapSessionTimeoutMs) || + bootstrapSessionTimeoutMs <= 0 || + bootstrapSessionTimeoutMs > MAX_BOOTSTRAP_SESSION_TIMEOUT_MS + ) { + throw new Error( + `Invalid bootstrapSessionTimeoutMs: expected integer between 1 and ${MAX_BOOTSTRAP_SESSION_TIMEOUT_MS}.`, + ); + } + const providerManager = new ProviderManager(); + const processManager = new ProcessManager(); + const todoStore = new TodoStore(path.join(os.homedir(), ".t3", "todos.json")); + await todoStore.init(); + + let activeClient: WebSocket | null = null; + let launchSessionPromise: Promise | null = null; + let bootstrapFallbackSession: ProviderSession | null = null; + + const emitEvent = ( + channel: TChannel, + payload: Extract["payload"], + ) => { + if (!activeClient) { + return; + } + + sendMessage(activeClient, { + type: "event", + channel, + payload, + }); + }; + + processManager.on("output", (chunk) => { + emitEvent(WS_EVENT_CHANNELS.agentOutput, chunk); + }); + processManager.on("exit", (payload) => { + emitEvent(WS_EVENT_CHANNELS.agentExit, payload); + }); + providerManager.on("event", (payload) => { + emitEvent(WS_EVENT_CHANNELS.providerEvent, payload); + }); + + const createBootstrapErrorSession = (message: string): ProviderSession => { + const timestamp = new Date().toISOString(); + return { + sessionId: `bootstrap-error-${Date.now()}`, + provider: "codex", + status: "error", + cwd: launchCwd, + model: DEFAULT_MODEL, + createdAt: timestamp, + updatedAt: timestamp, + lastError: message, + }; + }; + + const ensureLaunchSession = async (): Promise => { + const isLaunchProjectSession = (session: ProviderSession) => { + if (!session.cwd) { + return false; + } + + return path.resolve(session.cwd) === launchCwd; + }; + + const existingSession = providerManager + .listSessions() + .find((session) => isLaunchProjectSession(session) && session.status !== "closed"); + if (existingSession) { + bootstrapFallbackSession = null; + return { + session: existingSession, + bootstrapError: undefined, + }; + } + + if (bootstrapFallbackSession) { + return { + session: bootstrapFallbackSession, + bootstrapError: bootstrapFallbackSession.lastError, + }; + } + + if (launchSessionPromise) { + return launchSessionPromise; + } + + launchSessionPromise = (async () => { + try { + const startedSession = await raceWithTimeout( + providerManager.startSession({ + provider: "codex", + cwd: launchCwd, + model: DEFAULT_MODEL, + approvalPolicy: "never", + sandboxMode: "danger-full-access", + }), + bootstrapSessionTimeoutMs, + "Timed out starting launch session.", + ); + bootstrapFallbackSession = null; + return { + session: startedSession, + bootstrapError: undefined, + }; + } catch (error) { + const message = + error instanceof Error + ? error.message + : "Failed to initialize Codex launch session."; + const fallbackSession = (() => { + if (!bootstrapFallbackSession) { + return createBootstrapErrorSession(message); + } + + return Object.assign({}, bootstrapFallbackSession, { + lastError: message, + updatedAt: new Date().toISOString(), + }) as ProviderSession; + })(); + bootstrapFallbackSession = fallbackSession; + return { + session: fallbackSession, + bootstrapError: message, + }; + } finally { + launchSessionPromise = null; + } + })(); + + const pendingLaunchSessionPromise = launchSessionPromise; + if (!pendingLaunchSessionPromise) { + throw new Error("Could not initialize launch session promise."); + } + return pendingLaunchSessionPromise; + }; + + const wss = new WebSocketServer({ + host: "127.0.0.1", + port: options.port, + maxPayload: MAX_WS_CLIENT_PAYLOAD_BYTES, + perMessageDeflate: false, + }); + + const isAuthorizedConnection = (requestUrl: string | undefined) => { + if (!requestUrl) { + return authToken === null; + } + + try { + const request = new URL(requestUrl, "ws://127.0.0.1"); + if (request.pathname !== "/") { + return false; + } + + if (!authToken) { + return request.searchParams.size === 0; + } + + const tokens = request.searchParams.getAll("token"); + return ( + request.searchParams.size === 1 && + tokens.length === 1 && + tokens[0] === authToken + ); + } catch { + return false; + } + }; + + const resolveMethod = async (method: string, params: unknown) => { + const parsedMethod = wsNativeApiMethodSchema.safeParse(method); + if (!parsedMethod.success) { + throw new Error(`Unknown API method: ${method}`); + } + + const apiMethod = parsedMethod.data; + switch (apiMethod) { + case "app.bootstrap": { + const bootstrap = await ensureLaunchSession(); + const payload = { + launchCwd, + projectName: path.basename(launchCwd) || launchCwd, + provider: "codex", + model: bootstrap.session.model ?? DEFAULT_MODEL, + session: bootstrap.session, + ...(bootstrap.bootstrapError + ? { bootstrapError: bootstrap.bootstrapError } + : {}), + }; + return appBootstrapResultSchema.parse(payload); + } + + case "app.health": { + const payload = { + status: "ok", + launchCwd, + sessionCount: providerManager.listSessions().length, + activeClientConnected: activeClient !== null, + }; + return appHealthResultSchema.parse(payload); + } + + case "todos.list": + return todoListSchema.parse(await todoStore.list()); + case "todos.add": + return todoListSchema.parse(await todoStore.add(newTodoInputSchema.parse(params))); + case "todos.toggle": + return todoListSchema.parse(await todoStore.toggle(todoIdSchema.parse(params))); + case "todos.remove": + return todoListSchema.parse(await todoStore.remove(todoIdSchema.parse(params))); + + case "dialogs.pickFolder": + return dialogsPickFolderResultSchema.parse(await pickFolder()); + case "terminal.run": + return terminalCommandResultSchema.parse( + await runTerminalCommand(terminalCommandInputSchema.parse(params), launchCwd), + ); + + case "agent.spawn": + return agentSessionIdSchema.parse(processManager.spawn(agentConfigSchema.parse(params))); + case "agent.kill": + processManager.kill(agentSessionIdSchema.parse(params)); + return null; + case "agent.write": { + const parsed = agentWriteInputSchema.parse(params); + processManager.write(parsed.sessionId, parsed.data); + return null; + } + + case "providers.startSession": { + const session = await providerManager.startSession( + providerSessionStartInputSchema.parse(params), + ); + bootstrapFallbackSession = null; + return providerSessionSchema.parse(session); + } + case "providers.sendTurn": + return providerTurnStartResultSchema.parse( + await providerManager.sendTurn(providerSendTurnInputSchema.parse(params)), + ); + case "providers.interruptTurn": + await providerManager.interruptTurn(providerInterruptTurnInputSchema.parse(params)); + return null; + case "providers.respondToRequest": + await providerManager.respondToRequest(providerRespondToRequestInputSchema.parse(params)); + return null; + case "providers.stopSession": + providerManager.stopSession(providerStopSessionInputSchema.parse(params)); + return null; + case "providers.listSessions": + return providerSessionListSchema.parse(providerManager.listSessions()); + + case "shell.openInEditor": { + const parsed = shellOpenInEditorInputSchema.parse(params); + const targetPath = resolveExistingDirectoryFromBase(parsed.cwd, "Editor target", launchCwd); + + const editor = EDITORS.find((entry) => entry.id === parsed.editor); + if (!editor) { + throw new Error(`Unknown editor: ${parsed.editor}`); + } + + if (!editor.command) { + openPathInFileManager(targetPath); + return null; + } + + const child = spawn(editor.command, [targetPath], { + detached: true, + stdio: "ignore", + }); + child.on("error", () => { + // Best-effort editor launch. + }); + child.unref(); + return null; + } + + default: { + const unreachableMethod: never = apiMethod; + throw new Error(`Unknown API method: ${unreachableMethod}`); + } + } + }; + + wss.on("connection", (socket, request) => { + if (!isAuthorizedConnection(request.url)) { + socket.close(WS_CLOSE_CODES.unauthorized, WS_CLOSE_REASONS.unauthorized); + return; + } + + if (activeClient && activeClient !== socket) { + activeClient.close( + WS_CLOSE_CODES.replacedByNewClient, + WS_CLOSE_REASONS.replacedByNewClient, + ); + } + + activeClient = socket; + sendMessage(socket, { + type: "hello", + version: 1, + launchCwd, + }); + + socket.on("message", async (raw) => { + const decoded = decodeClientMessage(raw); + if (!decoded) { + return; + } + + const maybeParsed = (() => { + try { + return JSON.parse(decoded) as unknown; + } catch { + return null; + } + })(); + + if (!maybeParsed) { + return; + } + + const parsed = wsClientMessageSchema.safeParse(maybeParsed); + if (!parsed.success) { + return; + } + + const message = parsed.data as WsClientMessage; + try { + const result = await resolveMethod(message.method, message.params); + sendMessage(socket, responseSuccess(message.id, result)); + } catch (error) { + sendMessage( + socket, + responseError(message.id, { + code: "request_failed", + message: error instanceof Error ? error.message : "Request failed", + }), + ); + } + }); + + socket.on("close", () => { + if (activeClient === socket) { + activeClient = null; + } + }); + socket.on("error", () => { + // Connection-level protocol/socket errors are expected occasionally + // (for example oversized client payloads). Keep server process alive. + if (activeClient === socket && socket.readyState !== WebSocket.OPEN) { + activeClient = null; + } + }); + }); + + try { + await new Promise((resolve, reject) => { + if (wss.address()) { + resolve(); + return; + } + + const onListening = () => { + wss.off("error", onError); + resolve(); + }; + const onError = (error: Error) => { + wss.off("listening", onListening); + reject(error); + }; + + wss.once("listening", onListening); + wss.once("error", onError); + }); + } catch (error) { + processManager.killAll(); + providerManager.stopAll(); + providerManager.dispose(); + await new Promise((resolve) => { + try { + wss.close(() => resolve()); + } catch { + resolve(); + } + }); + throw error; + } + + const address = wss.address(); + const resolvedPort = + typeof address === "object" && address !== null ? address.port : options.port; + const wsBaseUrl = `ws://127.0.0.1:${resolvedPort}`; + const wsUrl = authToken + ? `${wsBaseUrl}?token=${encodeURIComponent(authToken)}` + : wsBaseUrl; + let closePromise: Promise | null = null; + + return { + wsUrl, + close() { + if (closePromise) { + return closePromise; + } + + closePromise = (async () => { + processManager.killAll(); + providerManager.stopAll(); + providerManager.dispose(); + activeClient = null; + for (const client of wss.clients) { + client.terminate(); + } + await new Promise((resolve) => { + wss.close(() => resolve()); + }); + })(); + + return closePromise; + }, + }; +} diff --git a/apps/t3/src/runtimeArchitecture.test.ts b/apps/t3/src/runtimeArchitecture.test.ts new file mode 100644 index 00000000000..20a7a58d8b2 --- /dev/null +++ b/apps/t3/src/runtimeArchitecture.test.ts @@ -0,0 +1,56 @@ +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +const RUNTIME_SOURCE_FILES = ["cli.ts", "runtimeApiServer.ts"] as const; + +describe("runtime architecture boundaries", () => { + it("keeps runtime API server wired through runtime-core services", () => { + const sourcePath = path.resolve(import.meta.dirname, "runtimeApiServer.ts"); + const source = fs.readFileSync(sourcePath, "utf8"); + + expect(source).toContain("from \"@acme/runtime-core\""); + expect(source).toContain("ProcessManager"); + expect(source).toContain("ProviderManager"); + expect(source).toContain("TodoStore"); + }); + + for (const fileName of RUNTIME_SOURCE_FILES) { + it(`avoids direct desktop service imports in ${fileName}`, () => { + const sourcePath = path.resolve(import.meta.dirname, fileName); + const source = fs.readFileSync(sourcePath, "utf8"); + + expect(source).not.toContain("../../desktop/src/"); + }); + } + + it("does not rely on a legacy desktop workspace package", () => { + const legacyDesktopPackagePath = path.resolve( + import.meta.dirname, + "..", + "..", + "desktop", + "package.json", + ); + + expect(fs.existsSync(legacyDesktopPackagePath)).toBe(false); + }); + + it("keeps runtime-core bundled into published t3 runtime output", () => { + const tsupConfigPath = path.resolve(import.meta.dirname, "..", "tsup.config.ts"); + const tsupConfigSource = fs.readFileSync(tsupConfigPath, "utf8"); + + expect(tsupConfigSource).toContain("\"@acme/runtime-core\""); + expect(tsupConfigSource).toContain("noExternal"); + }); + + it("keeps smoke tests independent from real codex installation", () => { + const smokeScriptPath = path.resolve(import.meta.dirname, "..", "scripts", "smoke-test.mjs"); + const smokeScriptSource = fs.readFileSync(smokeScriptPath, "utf8"); + + expect(smokeScriptSource).toContain("../../../test-support/fakeCodexAppServer.mjs"); + expect(smokeScriptSource).toContain("createFakeCodexAppServerBinary"); + expect(smokeScriptSource).toContain("PATH: `${fakeCodex.tempDir}${path.delimiter}"); + expect(smokeScriptSource).toContain("fs.rmSync(fakeCodex.tempDir"); + }); +}); diff --git a/apps/desktop/tsconfig.json b/apps/t3/tsconfig.json similarity index 56% rename from apps/desktop/tsconfig.json rename to apps/t3/tsconfig.json index 893a95a4193..037bd16c406 100644 --- a/apps/desktop/tsconfig.json +++ b/apps/t3/tsconfig.json @@ -1,9 +1,7 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "composite": true, - "types": ["node", "electron"], - "lib": ["ES2023", "DOM"] + "types": ["node"] }, "include": ["src", "tsup.config.ts"] } diff --git a/apps/t3/tsup.config.ts b/apps/t3/tsup.config.ts new file mode 100644 index 00000000000..115949c306f --- /dev/null +++ b/apps/t3/tsup.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["src/cli.ts"], + format: ["esm"], + dts: true, + clean: true, + noExternal: ["@acme/contracts", "@acme/runtime-core"], + outDir: "dist", +}); diff --git a/bun.lock b/bun.lock index 9febbb12982..84b6729567c 100644 --- a/bun.lock +++ b/bun.lock @@ -4,6 +4,10 @@ "workspaces": { "": { "name": "ct-round-5", + "dependencies": { + "ws": "^8.18.0", + "zod": "^3.24.1", + }, "devDependencies": { "oxfmt": "^0.28.0", "oxlint": "^1.43.0", @@ -12,24 +16,6 @@ "vitest": "^3.0.0", }, }, - "apps/desktop": { - "name": "@acme/desktop", - "version": "0.0.0", - "dependencies": { - "@acme/contracts": "workspace:*", - "electron": "33.4.11", - "node-pty": "^1.0.0", - }, - "devDependencies": { - "@electron/rebuild": "^3.7.0", - "@types/node": "^22.10.2", - "concurrently": "^9.1.2", - "electronmon": "^2.0.2", - "tsup": "^8.3.5", - "typescript": "^5.7.3", - "wait-on": "^8.0.2", - }, - }, "apps/renderer": { "name": "@acme/renderer", "version": "0.0.0", @@ -55,6 +41,25 @@ "vite": "^6.0.5", }, }, + "apps/t3": { + "name": "@acme/t3-runtime", + "version": "0.0.0", + "bin": { + "t3": "dist/cli.js", + }, + "dependencies": { + "@acme/contracts": "workspace:*", + "@acme/runtime-core": "workspace:*", + "ws": "^8.18.0", + }, + "devDependencies": { + "@types/node": "^22.10.2", + "@types/ws": "^8.5.13", + "tsup": "^8.3.5", + "tsx": "^4.20.3", + "typescript": "^5.7.3", + }, + }, "packages/contracts": { "name": "@acme/contracts", "version": "0.0.0", @@ -66,14 +71,28 @@ "typescript": "^5.7.3", }, }, + "packages/runtime-core": { + "name": "@acme/runtime-core", + "version": "0.0.0", + "dependencies": { + "@acme/contracts": "workspace:*", + "node-pty": "^1.0.0", + }, + "devDependencies": { + "@types/node": "^22.10.2", + "typescript": "^5.7.3", + }, + }, }, "packages": { "@acme/contracts": ["@acme/contracts@workspace:packages/contracts"], - "@acme/desktop": ["@acme/desktop@workspace:apps/desktop"], - "@acme/renderer": ["@acme/renderer@workspace:apps/renderer"], + "@acme/runtime-core": ["@acme/runtime-core@workspace:packages/runtime-core"], + + "@acme/t3-runtime": ["@acme/t3-runtime@workspace:apps/t3"], + "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], "@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="], @@ -112,12 +131,6 @@ "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], - "@electron/get": ["@electron/get@2.0.3", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ=="], - - "@electron/node-gyp": ["@electron/node-gyp@github:electron/node-gyp#06b29aa", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "glob": "^8.1.0", "graceful-fs": "^4.2.6", "make-fetch-happen": "^10.2.1", "nopt": "^6.0.0", "proc-log": "^2.0.1", "semver": "^7.3.5", "tar": "^6.2.1", "which": "^2.0.2" }, "bin": "./bin/node-gyp.js" }, "electron-node-gyp-06b29aa"], - - "@electron/rebuild": ["@electron/rebuild@3.7.2", "", { "dependencies": { "@electron/node-gyp": "git+https://github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2", "@malept/cross-spawn-promise": "^2.0.0", "chalk": "^4.0.0", "debug": "^4.1.1", "detect-libc": "^2.0.1", "fs-extra": "^10.0.0", "got": "^11.7.0", "node-abi": "^3.45.0", "node-api-version": "^0.2.0", "ora": "^5.1.0", "read-binary-file-arch": "^1.0.6", "semver": "^7.3.5", "tar": "^6.0.5", "yargs": "^17.0.1" }, "bin": { "electron-rebuild": "lib/cli.js" } }, "sha512-19/KbIR/DAxbsCkiaGMXIdPnMCJLkcf8AvGnduJtWBs/CBwiAjY1apCqOLVxrXg+rtXFCngbXhBanWjxLUt1Mg=="], - "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="], @@ -170,20 +183,6 @@ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="], - "@gar/promisify": ["@gar/promisify@1.1.3", "", {}, "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw=="], - - "@hapi/address": ["@hapi/address@5.1.1", "", { "dependencies": { "@hapi/hoek": "^11.0.2" } }, "sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA=="], - - "@hapi/formula": ["@hapi/formula@3.0.2", "", {}, "sha512-hY5YPNXzw1He7s0iqkRQi+uMGh383CGdyyIGYtB+W5N3KHPXoqychklvHhKCC9M3Xtv0OCs/IHw+r4dcHtBYWw=="], - - "@hapi/hoek": ["@hapi/hoek@11.0.7", "", {}, "sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ=="], - - "@hapi/pinpoint": ["@hapi/pinpoint@2.0.1", "", {}, "sha512-EKQmr16tM8s16vTT3cA5L0kZZcTMU5DUOZTuvpnY738m+jyP3JIUj+Mm1xc1rsLkGBQ/gVnfKYPwOmPg1tUR4Q=="], - - "@hapi/tlds": ["@hapi/tlds@1.1.4", "", {}, "sha512-Fq+20dxsxLaUn5jSSWrdtSRcIUba2JquuorF9UW1wIJS5cSUwxIsO2GIhaWynPRflvxSzFN+gxKte2HEW1OuoA=="], - - "@hapi/topo": ["@hapi/topo@6.0.2", "", { "dependencies": { "@hapi/hoek": "^11.0.2" } }, "sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg=="], - "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], @@ -194,12 +193,6 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], - "@malept/cross-spawn-promise": ["@malept/cross-spawn-promise@2.0.0", "", { "dependencies": { "cross-spawn": "^7.0.1" } }, "sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg=="], - - "@npmcli/fs": ["@npmcli/fs@2.1.2", "", { "dependencies": { "@gar/promisify": "^1.1.3", "semver": "^7.3.5" } }, "sha512-yOJKRvohFOaLqipNtwYB9WugyZKhC/DZC4VYPmpaCzDBrA8YpK3qHZ8/HGscMnE4GqbkLNuVcCnxkeQEdGt6LQ=="], - - "@npmcli/move-file": ["@npmcli/move-file@2.0.1", "", { "dependencies": { "mkdirp": "^1.0.4", "rimraf": "^3.0.2" } }, "sha512-mJd2Z5TjYWq/ttPLLGqArdtnC74J6bOzg4rMDnN+p1xTacZ2yPRCk2y0oSWQtygLR9YVQXgOcONrwtnk3JupxQ=="], - "@oxfmt/darwin-arm64": ["@oxfmt/darwin-arm64@0.28.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-jmUfF7cNJPw57bEK7sMIqrYRgn4LH428tSgtgLTCtjuGuu1ShREyrkeB7y8HtkXRfhBs4lVY+HMLhqElJvZ6ww=="], "@oxfmt/darwin-x64": ["@oxfmt/darwin-x64@0.28.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-S6vlV8S7jbjzJOSjfVg2CimUC0r7/aHDLdUm/3+/B/SU/s1jV7ivqWkMv1/8EB43d1BBwT9JQ60ZMTkBqeXSFA=="], @@ -284,12 +277,6 @@ "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.57.1", "", { "os": "win32", "cpu": "x64" }, "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA=="], - "@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="], - - "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - - "@szmarczak/http-timer": ["@szmarczak/http-timer@4.0.6", "", { "dependencies": { "defer-to-connect": "^2.0.0" } }, "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w=="], - "@tailwindcss/node": ["@tailwindcss/node@4.1.18", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="], "@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.18", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.18", "@tailwindcss/oxide-darwin-arm64": "4.1.18", "@tailwindcss/oxide-darwin-x64": "4.1.18", "@tailwindcss/oxide-freebsd-x64": "4.1.18", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", "@tailwindcss/oxide-linux-x64-musl": "4.1.18", "@tailwindcss/oxide-wasm32-wasi": "4.1.18", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" } }, "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A=="], @@ -320,8 +307,6 @@ "@tailwindcss/vite": ["@tailwindcss/vite@4.1.18", "", { "dependencies": { "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "tailwindcss": "4.1.18" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA=="], - "@tootallnate/once": ["@tootallnate/once@2.0.0", "", {}, "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A=="], - "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], @@ -330,8 +315,6 @@ "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], - "@types/cacheable-request": ["@types/cacheable-request@6.0.3", "", { "dependencies": { "@types/http-cache-semantics": "*", "@types/keyv": "^3.1.4", "@types/node": "*", "@types/responselike": "^1.0.0" } }, "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw=="], - "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], "@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="], @@ -344,10 +327,6 @@ "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="], - "@types/http-cache-semantics": ["@types/http-cache-semantics@4.2.0", "", {}, "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q=="], - - "@types/keyv": ["@types/keyv@3.1.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg=="], - "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], @@ -358,11 +337,9 @@ "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], - "@types/responselike": ["@types/responselike@1.0.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw=="], - "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], - "@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="], + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], @@ -382,72 +359,30 @@ "@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="], - "abbrev": ["abbrev@1.1.1", "", {}, "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q=="], - "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], - "agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], - - "agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="], - - "aggregate-error": ["aggregate-error@3.1.0", "", { "dependencies": { "clean-stack": "^2.0.0", "indent-string": "^4.0.0" } }, "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA=="], - - "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - - "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - "any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="], "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], - "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], - - "axios": ["axios@1.13.4", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, "sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg=="], - "babel-plugin-react-compiler": ["babel-plugin-react-compiler@19.0.0-beta-ebf51a3-20250411", "", { "dependencies": { "@babel/types": "^7.26.0" } }, "sha512-q84bNR9JG1crykAlJUt5Ud0/5BUyMFuQww/mrwIQDFBaxsikqBDj3f/FNDsVd2iR26A1HvXKWPEIfgJDv8/V2g=="], "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], - "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], - - "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], - "baseline-browser-mapping": ["baseline-browser-mapping@2.9.19", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg=="], - "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], - - "boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="], - - "brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], - - "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], - "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="], - "buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], - - "buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="], - "bundle-require": ["bundle-require@5.1.0", "", { "dependencies": { "load-tsconfig": "^0.2.3" }, "peerDependencies": { "esbuild": ">=0.18" } }, "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA=="], "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], - "cacache": ["cacache@16.1.3", "", { "dependencies": { "@npmcli/fs": "^2.1.0", "@npmcli/move-file": "^2.0.0", "chownr": "^2.0.0", "fs-minipass": "^2.1.0", "glob": "^8.0.1", "infer-owner": "^1.0.4", "lru-cache": "^7.7.1", "minipass": "^3.1.6", "minipass-collect": "^1.0.2", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "mkdirp": "^1.0.4", "p-map": "^4.0.0", "promise-inflight": "^1.0.1", "rimraf": "^3.0.2", "ssri": "^9.0.0", "tar": "^6.1.11", "unique-filename": "^2.0.0" } }, "sha512-/+Emcj9DAXxX4cwlLmRI9c166RuL3w30zp4R7Joiv2cQTtTtA+jeuCAjH3ZlGnYS3tKENSrKhAzVVP9GVyzeYQ=="], - - "cacheable-lookup": ["cacheable-lookup@5.0.4", "", {}, "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA=="], - - "cacheable-request": ["cacheable-request@7.0.4", "", { "dependencies": { "clone-response": "^1.0.2", "get-stream": "^5.1.0", "http-cache-semantics": "^4.0.0", "keyv": "^4.0.0", "lowercase-keys": "^2.0.0", "normalize-url": "^6.0.1", "responselike": "^2.0.0" } }, "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg=="], - - "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], - "caniuse-lite": ["caniuse-lite@1.0.30001769", "", {}, "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg=="], "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], "chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="], - "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], - "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], "character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="], @@ -460,107 +395,41 @@ "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], - "chownr": ["chownr@2.0.0", "", {}, "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ=="], - - "clean-stack": ["clean-stack@2.2.0", "", {}, "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A=="], - - "cli-cursor": ["cli-cursor@3.1.0", "", { "dependencies": { "restore-cursor": "^3.1.0" } }, "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw=="], - - "cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="], - - "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], - - "clone": ["clone@1.0.4", "", {}, "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg=="], - - "clone-response": ["clone-response@1.0.3", "", { "dependencies": { "mimic-response": "^1.0.0" } }, "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA=="], - - "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], - - "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], - - "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], - "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], "commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], - "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], - - "concurrently": ["concurrently@9.2.1", "", { "dependencies": { "chalk": "4.1.2", "rxjs": "7.8.2", "shell-quote": "1.8.3", "supports-color": "8.1.1", "tree-kill": "1.2.2", "yargs": "17.7.2" }, "bin": { "conc": "dist/bin/concurrently.js", "concurrently": "dist/bin/concurrently.js" } }, "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng=="], - "confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="], "consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="], "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], - "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], - "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="], - "decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="], - "deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="], - "defaults": ["defaults@1.0.4", "", { "dependencies": { "clone": "^1.0.2" } }, "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A=="], - - "defer-to-connect": ["defer-to-connect@2.0.1", "", {}, "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg=="], - - "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="], - - "define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="], - - "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], - "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], - "detect-node": ["detect-node@2.1.0", "", {}, "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g=="], - "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], - "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], - - "electron": ["electron@33.4.11", "", { "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^20.9.0", "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js" } }, "sha512-xmdAs5QWRkInC7TpXGNvzo/7exojubk+72jn1oJL7keNeIlw7xNglf8TGtJtkR4rWC5FJq0oXiIXPS9BcK2Irg=="], - "electron-to-chromium": ["electron-to-chromium@1.5.286", "", {}, "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A=="], - "electronmon": ["electronmon@2.0.4", "", { "dependencies": { "chalk": "^3.0.0", "import-from": "^3.0.0", "runtime-required": "^1.1.0", "watchboy": "^0.4.3" }, "bin": { "electronmon": "bin/cli.js" } }, "sha512-u6eDrvUbqa+wsnMrhG2vHmo5neL1owLg2e5i1avGWcOb4rHsUf9lSfbs0FvfPsBNpLxxlPO98nrMhAGV+zw/fQ=="], - - "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], - - "encoding": ["encoding@0.1.13", "", { "dependencies": { "iconv-lite": "^0.6.2" } }, "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A=="], - - "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], - "enhanced-resolve": ["enhanced-resolve@5.19.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg=="], - "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], - - "err-code": ["err-code@2.0.3", "", {}, "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA=="], - - "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], - - "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], - "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], - "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], - - "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], - - "es6-error": ["es6-error@4.1.1", "", {}, "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg=="], - "esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], - "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + "escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], "estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="], @@ -568,66 +437,20 @@ "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], - "exponential-backoff": ["exponential-backoff@3.1.3", "", {}, "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="], - "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], - "extract-zip": ["extract-zip@2.0.1", "", { "dependencies": { "debug": "^4.1.1", "get-stream": "^5.1.0", "yauzl": "^2.10.0" }, "optionalDependencies": { "@types/yauzl": "^2.9.1" }, "bin": { "extract-zip": "cli.js" } }, "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg=="], - - "fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="], - "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], - "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], - "fix-dts-default-cjs-exports": ["fix-dts-default-cjs-exports@1.0.1", "", { "dependencies": { "magic-string": "^0.30.17", "mlly": "^1.7.4", "rollup": "^4.34.8" } }, "sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg=="], - "follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="], - - "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], - - "fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="], - - "fs-minipass": ["fs-minipass@2.1.0", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg=="], - - "fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="], - "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], - "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], - "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], - "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], - - "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], - - "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], - - "get-stream": ["get-stream@5.2.0", "", { "dependencies": { "pump": "^3.0.0" } }, "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA=="], - - "glob": ["glob@8.1.0", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^5.0.1", "once": "^1.3.0" } }, "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ=="], - - "global-agent": ["global-agent@3.0.0", "", { "dependencies": { "boolean": "^3.0.1", "es6-error": "^4.1.1", "matcher": "^3.0.0", "roarr": "^2.15.3", "semver": "^7.3.2", "serialize-error": "^7.0.1" } }, "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q=="], - - "globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="], - - "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], - - "got": ["got@11.8.6", "", { "dependencies": { "@sindresorhus/is": "^4.0.0", "@szmarczak/http-timer": "^4.0.5", "@types/cacheable-request": "^6.0.1", "@types/responselike": "^1.0.0", "cacheable-lookup": "^5.0.3", "cacheable-request": "^7.0.2", "decompress-response": "^6.0.0", "http2-wrapper": "^1.0.0-beta.5.2", "lowercase-keys": "^2.0.0", "p-cancelable": "^2.0.0", "responselike": "^2.0.0" } }, "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g=="], + "get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="], "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], - "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], - - "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], - - "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], - - "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], - - "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], - "hast-util-is-element": ["hast-util-is-element@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g=="], "hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="], @@ -640,78 +463,28 @@ "html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="], - "http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="], - - "http-proxy-agent": ["http-proxy-agent@5.0.0", "", { "dependencies": { "@tootallnate/once": "2", "agent-base": "6", "debug": "4" } }, "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w=="], - - "http2-wrapper": ["http2-wrapper@1.0.3", "", { "dependencies": { "quick-lru": "^5.1.1", "resolve-alpn": "^1.0.0" } }, "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg=="], - - "https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], - - "humanize-ms": ["humanize-ms@1.2.1", "", { "dependencies": { "ms": "^2.0.0" } }, "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ=="], - - "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], - - "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], - - "import-from": ["import-from@3.0.0", "", { "dependencies": { "resolve-from": "^5.0.0" } }, "sha512-CiuXOFFSzkU5x/CR0+z7T91Iht4CXgfCxVOFRhh2Zyhg5wOpWvvDLQUsWl+gcN+QscYBjez8hDCt85O7RLDttQ=="], - - "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], - - "indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="], - - "infer-owner": ["infer-owner@1.0.4", "", {}, "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A=="], - - "inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="], - - "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], - "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], - "ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="], - "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], "is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="], "is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="], - "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], - "is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="], - "is-interactive": ["is-interactive@1.0.0", "", {}, "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w=="], - - "is-lambda": ["is-lambda@1.0.1", "", {}, "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ=="], - - "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], - "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], - "is-unicode-supported": ["is-unicode-supported@0.1.0", "", {}, "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw=="], - - "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], - "jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="], - "joi": ["joi@18.0.2", "", { "dependencies": { "@hapi/address": "^5.1.1", "@hapi/formula": "^3.0.2", "@hapi/hoek": "^11.0.7", "@hapi/pinpoint": "^2.0.1", "@hapi/tlds": "^1.1.1", "@hapi/topo": "^6.0.2", "@standard-schema/spec": "^1.0.0" } }, "sha512-RuCOQMIt78LWnktPoeBL0GErkNaJPTBGcYuyaBvUOQSpcpcLfWrHPPihYdOGbV5pam9VTWbeoF7TsGiHugcjGA=="], - "joycon": ["joycon@3.1.1", "", {}, "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw=="], "js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="], "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], - "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], - - "json-stringify-safe": ["json-stringify-safe@5.0.1", "", {}, "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="], - "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], - "jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="], - - "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], - "lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="], "lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="], @@ -742,34 +515,20 @@ "load-tsconfig": ["load-tsconfig@0.2.5", "", {}, "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg=="], - "lodash": ["lodash@4.17.23", "", {}, "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="], - - "lodash.difference": ["lodash.difference@4.5.0", "", {}, "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA=="], - - "log-symbols": ["log-symbols@4.1.0", "", { "dependencies": { "chalk": "^4.1.0", "is-unicode-supported": "^0.1.0" } }, "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg=="], - "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], "loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="], - "lowercase-keys": ["lowercase-keys@2.0.0", "", {}, "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA=="], - "lowlight": ["lowlight@3.3.0", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.0.0", "highlight.js": "~11.11.0" } }, "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ=="], - "lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="], + "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], "lucide-react": ["lucide-react@0.563.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-8dXPB2GI4dI8jV4MgUDGBeLdGk8ekfqVZ0BdLcrRzocGgG75ltNEmWS+gE7uokKF/0oSUuczNDT+g9hFJ23FkA=="], "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], - "make-fetch-happen": ["make-fetch-happen@10.2.1", "", { "dependencies": { "agentkeepalive": "^4.2.1", "cacache": "^16.1.0", "http-cache-semantics": "^4.1.0", "http-proxy-agent": "^5.0.0", "https-proxy-agent": "^5.0.0", "is-lambda": "^1.0.1", "lru-cache": "^7.7.1", "minipass": "^3.1.6", "minipass-collect": "^1.0.2", "minipass-fetch": "^2.0.3", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "negotiator": "^0.6.3", "promise-retry": "^2.0.1", "socks-proxy-agent": "^7.0.0", "ssri": "^9.0.0" } }, "sha512-NgOPbRiaQM10DYXvN3/hhGVI2M5MtITFryzBGxHM5p4wnFxsVCbxkrBrDsk+EZ5OB4jEOT7AjDxtdF+KVEFT7w=="], - "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], - "matcher": ["matcher@3.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng=="], - - "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], - "mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="], "mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA=="], @@ -856,36 +615,6 @@ "micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="], - "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], - - "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], - - "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], - - "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], - - "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="], - - "minimatch": ["minimatch@5.1.6", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g=="], - - "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], - - "minipass": ["minipass@5.0.0", "", {}, "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ=="], - - "minipass-collect": ["minipass-collect@1.0.2", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA=="], - - "minipass-fetch": ["minipass-fetch@2.1.2", "", { "dependencies": { "minipass": "^3.1.6", "minipass-sized": "^1.0.3", "minizlib": "^2.1.2" }, "optionalDependencies": { "encoding": "^0.1.13" } }, "sha512-LT49Zi2/WMROHYoqGgdlQIZh8mLPZmOrN2NdJjMXxYe4nkN6FUyuPuOAOedNJDrx0IRGg9+4guZewtp8hE6TxA=="], - - "minipass-flush": ["minipass-flush@1.0.5", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw=="], - - "minipass-pipeline": ["minipass-pipeline@1.2.4", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A=="], - - "minipass-sized": ["minipass-sized@1.0.3", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g=="], - - "minizlib": ["minizlib@2.1.2", "", { "dependencies": { "minipass": "^3.0.0", "yallist": "^4.0.0" } }, "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg=="], - - "mkdirp": ["mkdirp@1.0.4", "", { "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="], - "mlly": ["mlly@1.8.0", "", { "dependencies": { "acorn": "^8.15.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.1" } }, "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], @@ -894,60 +623,28 @@ "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], - "negotiator": ["negotiator@0.6.4", "", {}, "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w=="], - - "node-abi": ["node-abi@3.87.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ=="], - "node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="], - "node-api-version": ["node-api-version@0.2.1", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q=="], - "node-pty": ["node-pty@1.1.0", "", { "dependencies": { "node-addon-api": "^7.1.0" } }, "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg=="], "node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="], - "nopt": ["nopt@6.0.0", "", { "dependencies": { "abbrev": "^1.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-ZwLpbTgdhuZUnZzjd7nb1ZV+4DoiC6/sfiVKok72ym/4Tlf+DFdlHYmT2JPmcNNWV6Pi3SDf1kT+A4r9RTuT9g=="], - - "normalize-path": ["normalize-path@2.1.1", "", { "dependencies": { "remove-trailing-separator": "^1.0.1" } }, "sha512-3pKJwH184Xo/lnH6oyP1q2pMd7HcypqqmRs91/6/i2CGtWwIKGCkOOMTm/zXbgTEWHw1uNpNi/igc3ePOYHb6w=="], - - "normalize-url": ["normalize-url@6.1.0", "", {}, "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A=="], - "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], - "object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="], - - "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], - - "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], - - "ora": ["ora@5.4.1", "", { "dependencies": { "bl": "^4.1.0", "chalk": "^4.1.0", "cli-cursor": "^3.1.0", "cli-spinners": "^2.5.0", "is-interactive": "^1.0.0", "is-unicode-supported": "^0.1.0", "log-symbols": "^4.1.0", "strip-ansi": "^6.0.0", "wcwidth": "^1.0.1" } }, "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ=="], - "oxfmt": ["oxfmt@0.28.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/darwin-arm64": "0.28.0", "@oxfmt/darwin-x64": "0.28.0", "@oxfmt/linux-arm64-gnu": "0.28.0", "@oxfmt/linux-arm64-musl": "0.28.0", "@oxfmt/linux-x64-gnu": "0.28.0", "@oxfmt/linux-x64-musl": "0.28.0", "@oxfmt/win32-arm64": "0.28.0", "@oxfmt/win32-x64": "0.28.0" }, "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-3+hhBqPE6Kp22KfJmnstrZbl+KdOVSEu1V0ABaFIg1rYLtrMgrupx9znnHgHLqKxAVHebjTdiCJDk30CXOt6cw=="], "oxlint": ["oxlint@1.43.0", "", { "optionalDependencies": { "@oxlint/darwin-arm64": "1.43.0", "@oxlint/darwin-x64": "1.43.0", "@oxlint/linux-arm64-gnu": "1.43.0", "@oxlint/linux-arm64-musl": "1.43.0", "@oxlint/linux-x64-gnu": "1.43.0", "@oxlint/linux-x64-musl": "1.43.0", "@oxlint/win32-arm64": "1.43.0", "@oxlint/win32-x64": "1.43.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.11.2" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-xiqTCsKZch+R61DPCjyqUVP2MhkQlRRYxLRBeBDi+dtQJ90MOgdcjIktvDCgXz0bgtx94EQzHEndsizZjMX2OA=="], - "p-cancelable": ["p-cancelable@2.1.1", "", {}, "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg=="], - - "p-map": ["p-map@4.0.0", "", { "dependencies": { "aggregate-error": "^3.0.0" } }, "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ=="], - "parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="], - "path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="], - - "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], - "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], "pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="], - "pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="], - "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], - "pify": ["pify@4.0.1", "", {}, "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g=="], - "pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="], "pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="], @@ -956,22 +653,8 @@ "postcss-load-config": ["postcss-load-config@6.0.1", "", { "dependencies": { "lilconfig": "^3.1.1" }, "peerDependencies": { "jiti": ">=1.21.0", "postcss": ">=8.0.9", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["jiti", "postcss", "tsx", "yaml"] }, "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g=="], - "proc-log": ["proc-log@2.0.1", "", {}, "sha512-Kcmo2FhfDTXdcbfDH76N7uBYHINxc/8GW7UAVuVP9I+Va3uHSerrnKV6dLooga/gh7GlgzuCCr/eoldnL1muGw=="], - - "progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="], - - "promise-inflight": ["promise-inflight@1.0.1", "", {}, "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g=="], - - "promise-retry": ["promise-retry@2.0.1", "", { "dependencies": { "err-code": "^2.0.2", "retry": "^0.12.0" } }, "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g=="], - "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], - "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], - - "pump": ["pump@3.0.3", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="], - - "quick-lru": ["quick-lru@5.1.1", "", {}, "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA=="], - "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], @@ -980,10 +663,6 @@ "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], - "read-binary-file-arch": ["read-binary-file-arch@1.0.6", "", { "dependencies": { "debug": "^4.3.4" }, "bin": { "read-binary-file-arch": "cli.js" } }, "sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg=="], - - "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], - "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], "rehype-highlight": ["rehype-highlight@7.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-to-text": "^4.0.0", "lowlight": "^3.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-k158pK7wdC2qL3M5NcZROZ2tR/l7zOzjxXd5VGdcfIyoijjQqpHd3JKtYSBDpDZ38UI2WJWuFAtkMDxmx5kstA=="], @@ -996,80 +675,30 @@ "remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="], - "remove-trailing-separator": ["remove-trailing-separator@1.1.0", "", {}, "sha512-/hS+Y0u3aOfIETiaiirUFwDBDzmXPvO+jAfKTitUngIPzdKc6Z0LoFjM/CK5PL4C+eKwHohlHAb6H0VFfmmUsw=="], - - "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], - - "resolve-alpn": ["resolve-alpn@1.2.1", "", {}, "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g=="], - "resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="], - "responselike": ["responselike@2.0.1", "", { "dependencies": { "lowercase-keys": "^2.0.0" } }, "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw=="], - - "restore-cursor": ["restore-cursor@3.1.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA=="], - - "retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="], - - "rimraf": ["rimraf@3.0.2", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "bin.js" } }, "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA=="], - - "roarr": ["roarr@2.15.4", "", { "dependencies": { "boolean": "^3.0.1", "detect-node": "^2.0.4", "globalthis": "^1.0.1", "json-stringify-safe": "^5.0.1", "semver-compare": "^1.0.0", "sprintf-js": "^1.1.2" } }, "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A=="], + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], "rollup": ["rollup@4.57.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.57.1", "@rollup/rollup-android-arm64": "4.57.1", "@rollup/rollup-darwin-arm64": "4.57.1", "@rollup/rollup-darwin-x64": "4.57.1", "@rollup/rollup-freebsd-arm64": "4.57.1", "@rollup/rollup-freebsd-x64": "4.57.1", "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", "@rollup/rollup-linux-arm-musleabihf": "4.57.1", "@rollup/rollup-linux-arm64-gnu": "4.57.1", "@rollup/rollup-linux-arm64-musl": "4.57.1", "@rollup/rollup-linux-loong64-gnu": "4.57.1", "@rollup/rollup-linux-loong64-musl": "4.57.1", "@rollup/rollup-linux-ppc64-gnu": "4.57.1", "@rollup/rollup-linux-ppc64-musl": "4.57.1", "@rollup/rollup-linux-riscv64-gnu": "4.57.1", "@rollup/rollup-linux-riscv64-musl": "4.57.1", "@rollup/rollup-linux-s390x-gnu": "4.57.1", "@rollup/rollup-linux-x64-gnu": "4.57.1", "@rollup/rollup-linux-x64-musl": "4.57.1", "@rollup/rollup-openbsd-x64": "4.57.1", "@rollup/rollup-openharmony-arm64": "4.57.1", "@rollup/rollup-win32-arm64-msvc": "4.57.1", "@rollup/rollup-win32-ia32-msvc": "4.57.1", "@rollup/rollup-win32-x64-gnu": "4.57.1", "@rollup/rollup-win32-x64-msvc": "4.57.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A=="], - "runtime-required": ["runtime-required@1.1.0", "", {}, "sha512-yX97f5E0WfNpcQnfVjap6vzQcvErkYYCx6eTK4siqGEdC8lglwypUFgZVTX7ShvIlgfkC4XGFl9O1KTYcff0pw=="], - - "rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="], - - "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], - - "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], - "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], - "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], - - "semver-compare": ["semver-compare@1.0.0", "", {}, "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow=="], - - "serialize-error": ["serialize-error@7.0.1", "", { "dependencies": { "type-fest": "^0.13.1" } }, "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw=="], - - "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], - - "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], - - "shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="], + "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], - "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], - - "smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="], - - "socks": ["socks@2.8.7", "", { "dependencies": { "ip-address": "^10.0.1", "smart-buffer": "^4.2.0" } }, "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A=="], - - "socks-proxy-agent": ["socks-proxy-agent@7.0.0", "", { "dependencies": { "agent-base": "^6.0.2", "debug": "^4.3.3", "socks": "^2.6.2" } }, "sha512-Fgl0YPZ902wEsAyiQ+idGd1A7rSFx/ayC1CQVMw5P+EQx2V0SgpGtf6OKFhVjPflPUl9YMmEOnmfjCdMUsygww=="], - "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], - "sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="], - - "ssri": ["ssri@9.0.1", "", { "dependencies": { "minipass": "^3.1.1" } }, "sha512-o57Wcn66jMQvfHG1FlYbWeZWW/dHZhJXjpIcTfXldXEk5nz5lStPo3mK0OJQfGR3RbZUlbISexbljkJzuEj/8Q=="], - "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], - "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], - - "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], - "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], - "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - "strip-literal": ["strip-literal@3.1.0", "", { "dependencies": { "js-tokens": "^9.0.1" } }, "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg=="], "style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="], @@ -1078,16 +707,10 @@ "sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="], - "sumchecker": ["sumchecker@3.0.1", "", { "dependencies": { "debug": "^4.1.0" } }, "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg=="], - - "supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], - "tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="], "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], - "tar": ["tar@6.2.1", "", { "dependencies": { "chownr": "^2.0.0", "fs-minipass": "^2.0.0", "minipass": "^5.0.0", "minizlib": "^2.1.1", "mkdirp": "^1.0.3", "yallist": "^4.0.0" } }, "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A=="], - "thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="], "thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="], @@ -1104,8 +727,6 @@ "tinyspy": ["tinyspy@4.0.4", "", {}, "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q=="], - "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], - "tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="], "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], @@ -1114,10 +735,10 @@ "ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="], - "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "tsup": ["tsup@8.5.1", "", { "dependencies": { "bundle-require": "^5.1.0", "cac": "^6.7.14", "chokidar": "^4.0.3", "consola": "^3.4.0", "debug": "^4.4.0", "esbuild": "^0.27.0", "fix-dts-default-cjs-exports": "^1.0.0", "joycon": "^3.1.1", "picocolors": "^1.1.1", "postcss-load-config": "^6.0.1", "resolve-from": "^5.0.0", "rollup": "^4.34.8", "source-map": "^0.7.6", "sucrase": "^3.35.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.11", "tree-kill": "^1.2.2" }, "peerDependencies": { "@microsoft/api-extractor": "^7.36.0", "@swc/core": "^1", "postcss": "^8.4.12", "typescript": ">=4.5.0" }, "optionalPeers": ["@microsoft/api-extractor", "@swc/core", "postcss", "typescript"], "bin": { "tsup": "dist/cli-default.js", "tsup-node": "dist/cli-node.js" } }, "sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing=="], + "tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="], + "turbo": ["turbo@2.8.3", "", { "optionalDependencies": { "turbo-darwin-64": "2.8.3", "turbo-darwin-arm64": "2.8.3", "turbo-linux-64": "2.8.3", "turbo-linux-arm64": "2.8.3", "turbo-windows-64": "2.8.3", "turbo-windows-arm64": "2.8.3" }, "bin": { "turbo": "bin/turbo" } }, "sha512-8Osxz5Tu/Dw2kb31EAY+nhq/YZ3wzmQSmYa1nIArqxgCAldxv9TPlrAiaBUDVnKA4aiPn0OFBD1ACcpc5VFOAQ=="], "turbo-darwin-64": ["turbo-darwin-64@2.8.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-4kXRLfcygLOeNcP6JquqRLmGB/ATjjfehiojL2dJkL7GFm3SPSXbq7oNj8UbD8XriYQ5hPaSuz59iF1ijPHkTw=="], @@ -1132,8 +753,6 @@ "turbo-windows-arm64": ["turbo-windows-arm64@2.8.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-afTUGKBRmOJU1smQSBnFGcbq0iabAPwh1uXu2BVk7BREg30/1gMnJh9DFEQTah+UD3n3ru8V55J83RQNFfqoyw=="], - "type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="], - "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "ufo": ["ufo@1.6.3", "", {}, "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q=="], @@ -1142,10 +761,6 @@ "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="], - "unique-filename": ["unique-filename@2.0.1", "", { "dependencies": { "unique-slug": "^3.0.0" } }, "sha512-ODWHtkkdx3IAR+veKxFV+VBkUMcN+FaqzUUd7IZzt+0zhDZFPFxhlqwPF3YQvMHx1TD0tdgYl+kuPnJ8E6ql7A=="], - - "unique-slug": ["unique-slug@3.0.0", "", { "dependencies": { "imurmurhash": "^0.1.4" } }, "sha512-8EyMynh679x/0gqE9fT9oilG+qEt+ibFyqjuVTsZn1+CMxH+XLlpvr2UZx4nVcCwTpx81nICr2JQFkM+HPLq4w=="], - "unist-util-find-after": ["unist-util-find-after@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ=="], "unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="], @@ -1158,14 +773,8 @@ "unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="], - "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], - - "unixify": ["unixify@1.0.0", "", { "dependencies": { "normalize-path": "^2.1.1" } }, "sha512-6bc58dPYhCMHHuwxldQxO3RRNZ4eCogZ/st++0+fcC1nr0jiGUtAdBJ2qzmLQWSxbtz42pWt4QQMiZ9HvZf5cg=="], - "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], - "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], - "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], @@ -1176,29 +785,11 @@ "vitest": ["vitest@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", "@vitest/mocker": "3.2.4", "@vitest/pretty-format": "^3.2.4", "@vitest/runner": "3.2.4", "@vitest/snapshot": "3.2.4", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", "magic-string": "^0.30.17", "pathe": "^2.0.3", "picomatch": "^4.0.2", "std-env": "^3.9.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.14", "tinypool": "^1.1.1", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", "vite-node": "3.2.4", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@vitest/browser": "3.2.4", "@vitest/ui": "3.2.4", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A=="], - "wait-on": ["wait-on@8.0.5", "", { "dependencies": { "axios": "^1.12.1", "joi": "^18.0.1", "lodash": "^4.17.21", "minimist": "^1.2.8", "rxjs": "^7.8.2" }, "bin": { "wait-on": "bin/wait-on" } }, "sha512-J3WlS0txVHkhLRb2FsmRg3dkMTCV1+M6Xra3Ho7HzZDHpE7DCOnoSoCJsZotrmW3uRMhvIJGSKUKrh/MeF4iag=="], - - "watchboy": ["watchboy@0.4.3", "", { "dependencies": { "lodash.difference": "^4.5.0", "micromatch": "^4.0.2", "pify": "^4.0.1", "unixify": "^1.0.0" } }, "sha512-GHs1HxwvxSMBsqd/WfTOZhj5gBdMqf5HQpfgtKxDfZRxrlYPDdVLRB61LCeRzJaWANmvSIMlfmRVDwVmJFgAKA=="], - - "wcwidth": ["wcwidth@1.0.1", "", { "dependencies": { "defaults": "^1.0.3" } }, "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg=="], - - "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], - "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], - "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], - - "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], - - "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], - - "yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + "ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="], - "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], - - "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], - - "yauzl": ["yauzl@2.10.0", "", { "dependencies": { "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" } }, "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g=="], + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], @@ -1206,16 +797,6 @@ "@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], - "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - - "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], - - "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - - "@electron/get/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], - - "@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "@tailwindcss/node/jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], @@ -1230,56 +811,12 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "cacache/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], - - "chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - - "clone-response/mimic-response": ["mimic-response@1.0.1", "", {}, "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ=="], - - "electron/@types/node": ["@types/node@20.19.33", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw=="], - - "electronmon/chalk": ["chalk@3.0.0", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg=="], - - "fs-minipass/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], - - "make-fetch-happen/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], - - "mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], - - "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], - - "minipass-collect/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], - - "minipass-fetch/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], - - "minipass-flush/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], - - "minipass-pipeline/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], - - "minipass-sized/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], - - "minizlib/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], - "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], - "rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], - - "ssri/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], - "vite/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], "vitest/tinypool": ["tinypool@1.1.1", "", {}, "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg=="], - "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], - - "@electron/get/fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="], - - "@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], - - "electronmon/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - - "rimraf/glob/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], - "vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], "vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], @@ -1331,7 +868,5 @@ "vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], "vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], - - "rimraf/glob/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], } } diff --git a/package.json b/package.json index 65342be64e8..c3299409ace 100644 --- a/package.json +++ b/package.json @@ -1,20 +1,34 @@ { - "name": "ct-round-5", - "private": true, + "name": "t3", + "version": "0.1.0", + "private": false, + "bin": { + "t3": "apps/t3/dist/cli.js" + }, + "files": [ + "apps/t3/dist/**", + "apps/renderer/dist/**", + "README.md" + ], "workspaces": [ "apps/*", "packages/*" ], "scripts": { - "dev": "bun run build:contracts && turbo run dev --parallel", - "build": "turbo run build", + "dev": "bun run build:contracts && bun run --cwd apps/t3 dev", + "build": "bun run build:contracts && bun run --cwd apps/renderer build && bun run --cwd apps/t3 build", "typecheck": "turbo run typecheck", "lint": "oxlint --report-unused-disable-directives", "test": "turbo run test", + "smoke-test": "bun run --cwd apps/t3 smoke-test", "fmt": "oxfmt", - "smoke-test": "turbo run smoke-test", + "prepack": "node scripts/prepack.mjs", "build:contracts": "bun run --cwd packages/contracts build" }, + "dependencies": { + "ws": "^8.18.0", + "zod": "^3.24.1" + }, "devDependencies": { "oxfmt": "^0.28.0", "oxlint": "^1.43.0", diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 0c4d86a52c0..dfd89d7bc7b 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -12,7 +12,7 @@ "exports": { ".": { "types": "./src/index.ts", - "import": "./dist/index.js", + "import": "./src/index.ts", "require": "./dist/index.cjs" } }, diff --git a/packages/contracts/src/agent.test.ts b/packages/contracts/src/agent.test.ts new file mode 100644 index 00000000000..592208e95f8 --- /dev/null +++ b/packages/contracts/src/agent.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vitest"; + +import { agentConfigSchema, agentExitSchema, agentWriteInputSchema, outputChunkSchema } from "./agent"; + +describe("agentConfigSchema", () => { + it("accepts valid agent configs", () => { + const parsed = agentConfigSchema.parse({ + command: "bash", + args: ["-lc", "echo hi"], + cwd: "/workspace", + usePty: false, + }); + + expect(parsed.command).toBe("bash"); + }); + + it("rejects unexpected config properties", () => { + expect(() => + agentConfigSchema.parse({ + command: "bash", + unexpected: true, + }), + ).toThrow(); + }); +}); + +describe("outputChunkSchema", () => { + it("accepts stdout/stderr output chunks", () => { + expect( + outputChunkSchema.parse({ + sessionId: "agent-1", + stream: "stdout", + data: "hello", + }).stream, + ).toBe("stdout"); + }); + + it("rejects unexpected output properties", () => { + expect(() => + outputChunkSchema.parse({ + sessionId: "agent-1", + stream: "stdout", + data: "hello", + unexpected: true, + }), + ).toThrow(); + }); + + it("rejects empty output session ids", () => { + expect(() => + outputChunkSchema.parse({ + sessionId: "", + stream: "stdout", + data: "hello", + }), + ).toThrow(); + }); +}); + +describe("agentWriteInputSchema", () => { + it("accepts valid write payloads", () => { + const parsed = agentWriteInputSchema.parse({ + sessionId: "agent-1", + data: "ping\n", + }); + + expect(parsed.sessionId).toBe("agent-1"); + }); + + it("rejects invalid write payloads", () => { + expect(() => + agentWriteInputSchema.parse({ + sessionId: "", + data: "ping\n", + }), + ).toThrow(); + + expect(() => + agentWriteInputSchema.parse({ + sessionId: "agent-1", + data: "ping\n", + unexpected: true, + }), + ).toThrow(); + }); +}); + +describe("agentExitSchema", () => { + it("accepts exit payloads", () => { + const parsed = agentExitSchema.parse({ + sessionId: "agent-1", + code: 0, + signal: null, + }); + + expect(parsed.code).toBe(0); + }); + + it("rejects unexpected exit properties", () => { + expect(() => + agentExitSchema.parse({ + sessionId: "agent-1", + code: 0, + signal: null, + unexpected: true, + }), + ).toThrow(); + }); + + it("rejects empty exit session ids", () => { + expect(() => + agentExitSchema.parse({ + sessionId: "", + code: 0, + signal: null, + }), + ).toThrow(); + }); +}); diff --git a/packages/contracts/src/agent.ts b/packages/contracts/src/agent.ts index 2231f2e0025..d2151626ce8 100644 --- a/packages/contracts/src/agent.ts +++ b/packages/contracts/src/agent.ts @@ -8,20 +8,28 @@ export const agentConfigSchema = z.object({ cwd: z.string().optional(), env: z.record(z.string()).optional(), usePty: z.boolean().optional(), -}); +}).strict(); + +export const agentWriteInputSchema = z + .object({ + sessionId: z.string().min(1), + data: z.string(), + }) + .strict(); export const outputChunkSchema = z.object({ - sessionId: z.string(), + sessionId: agentSessionIdSchema, stream: z.enum(["stdout", "stderr"]), data: z.string(), -}); +}).strict(); export const agentExitSchema = z.object({ - sessionId: z.string(), + sessionId: agentSessionIdSchema, code: z.number().nullable(), signal: z.string().nullable(), -}); +}).strict(); export type AgentConfig = z.infer; +export type AgentWriteInput = z.infer; export type OutputChunk = z.infer; export type AgentExit = z.infer; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 6861cff7c5e..85451764c2a 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -4,3 +4,4 @@ export * from "./agent"; export * from "./terminal"; export * from "./provider"; export * from "./model"; +export * from "./ws"; diff --git a/packages/contracts/src/ipc.test.ts b/packages/contracts/src/ipc.test.ts new file mode 100644 index 00000000000..5a514edae41 --- /dev/null +++ b/packages/contracts/src/ipc.test.ts @@ -0,0 +1,208 @@ +import { describe, expect, it } from "vitest"; + +import { + appBootstrapResultSchema, + appHealthResultSchema, + dialogsPickFolderResultSchema, + editorIdSchema, + shellOpenInEditorInputSchema, + WS_NATIVE_API_METHODS, + wsNativeApiMethodSchema, +} from "./ipc"; + +describe("appBootstrapResultSchema", () => { + it("accepts valid bootstrap payloads", () => { + const parsed = appBootstrapResultSchema.parse({ + launchCwd: "/workspace", + projectName: "workspace", + provider: "codex", + model: "gpt-5-codex", + session: { + sessionId: "sess-1", + provider: "codex", + status: "ready", + cwd: "/workspace", + model: "gpt-5-codex", + createdAt: "2026-02-01T00:00:00.000Z", + updatedAt: "2026-02-01T00:00:00.000Z", + }, + }); + + expect(parsed.provider).toBe("codex"); + }); + + it("rejects invalid bootstrap payloads", () => { + expect(() => + appBootstrapResultSchema.parse({ + launchCwd: "/workspace", + projectName: "", + provider: "codex", + model: "gpt-5-codex", + session: { + sessionId: "sess-1", + provider: "codex", + status: "ready", + createdAt: "2026-02-01T00:00:00.000Z", + updatedAt: "2026-02-01T00:00:00.000Z", + }, + }), + ).toThrow(); + }); + + it("rejects empty bootstrap errors", () => { + expect(() => + appBootstrapResultSchema.parse({ + launchCwd: "/workspace", + projectName: "workspace", + provider: "codex", + model: "gpt-5-codex", + session: { + sessionId: "sess-1", + provider: "codex", + status: "ready", + createdAt: "2026-02-01T00:00:00.000Z", + updatedAt: "2026-02-01T00:00:00.000Z", + }, + bootstrapError: "", + }), + ).toThrow(); + }); + + it("rejects unexpected bootstrap payload properties", () => { + expect(() => + appBootstrapResultSchema.parse({ + launchCwd: "/workspace", + projectName: "workspace", + provider: "codex", + model: "gpt-5-codex", + session: { + sessionId: "sess-1", + provider: "codex", + status: "ready", + createdAt: "2026-02-01T00:00:00.000Z", + updatedAt: "2026-02-01T00:00:00.000Z", + }, + unexpected: true, + }), + ).toThrow(); + }); +}); + +describe("appHealthResultSchema", () => { + it("accepts valid health payloads", () => { + const parsed = appHealthResultSchema.parse({ + status: "ok", + launchCwd: "/workspace", + sessionCount: 2, + activeClientConnected: true, + }); + + expect(parsed.status).toBe("ok"); + }); + + it("rejects invalid health payloads", () => { + expect(() => + appHealthResultSchema.parse({ + status: "ok", + launchCwd: "/workspace", + sessionCount: -1, + activeClientConnected: true, + }), + ).toThrow(); + }); + + it("rejects non-integer session counts", () => { + expect(() => + appHealthResultSchema.parse({ + status: "ok", + launchCwd: "/workspace", + sessionCount: 1.5, + activeClientConnected: true, + }), + ).toThrow(); + }); + + it("rejects unexpected health payload properties", () => { + expect(() => + appHealthResultSchema.parse({ + status: "ok", + launchCwd: "/workspace", + sessionCount: 1, + activeClientConnected: true, + unexpected: true, + }), + ).toThrow(); + }); +}); + +describe("dialogsPickFolderResultSchema", () => { + it("accepts null and string folder selections", () => { + expect(dialogsPickFolderResultSchema.parse(null)).toBeNull(); + expect(dialogsPickFolderResultSchema.parse("/workspace")).toBe("/workspace"); + }); + + it("rejects non-string non-null selections", () => { + expect(() => dialogsPickFolderResultSchema.parse(123)).toThrow(); + }); +}); + +describe("editor and shell-open schemas", () => { + it("accepts known editor ids", () => { + expect(editorIdSchema.parse("cursor")).toBe("cursor"); + expect(editorIdSchema.parse("file-manager")).toBe("file-manager"); + }); + + it("rejects unknown editor ids", () => { + expect(() => editorIdSchema.parse("vscode")).toThrow(); + }); + + it("accepts valid shell.openInEditor payloads", () => { + const parsed = shellOpenInEditorInputSchema.parse({ + cwd: "/workspace", + editor: "cursor", + }); + + expect(parsed.cwd).toBe("/workspace"); + expect(parsed.editor).toBe("cursor"); + }); + + it("rejects invalid shell.openInEditor payloads", () => { + expect(() => + shellOpenInEditorInputSchema.parse({ + cwd: "", + editor: "cursor", + }), + ).toThrow(); + + expect(() => + shellOpenInEditorInputSchema.parse({ + cwd: "/workspace", + editor: "vscode", + }), + ).toThrow(); + + expect(() => + shellOpenInEditorInputSchema.parse({ + cwd: "/workspace", + editor: "cursor", + unexpected: true, + }), + ).toThrow(); + }); +}); + +describe("wsNativeApiMethodSchema", () => { + it("accepts every declared websocket native API method", () => { + for (const method of WS_NATIVE_API_METHODS) { + expect(wsNativeApiMethodSchema.parse(method)).toBe(method); + } + }); + + it("rejects unknown websocket native API methods", () => { + expect(() => wsNativeApiMethodSchema.parse("providers.abortTurn")).toThrow(); + }); + + it("keeps declared websocket native API methods unique", () => { + expect(new Set(WS_NATIVE_API_METHODS).size).toBe(WS_NATIVE_API_METHODS.length); + }); +}); diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 59166a0161c..9e0e2226cdc 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1,4 +1,6 @@ +import { z } from "zod"; import type { AgentConfig, AgentExit, OutputChunk } from "./agent"; +import { providerKindSchema, providerSessionSchema } from "./provider"; import type { ProviderEvent, ProviderInterruptTurnInput, @@ -17,31 +19,64 @@ export const EDITORS = [ { id: "file-manager", label: "File Manager", command: null }, ] as const; +export const WS_NATIVE_API_METHODS = [ + "app.bootstrap", + "app.health", + "todos.list", + "todos.add", + "todos.toggle", + "todos.remove", + "dialogs.pickFolder", + "terminal.run", + "agent.spawn", + "agent.kill", + "agent.write", + "providers.startSession", + "providers.sendTurn", + "providers.interruptTurn", + "providers.respondToRequest", + "providers.stopSession", + "providers.listSessions", + "shell.openInEditor", +] as const; + +export type WsNativeApiMethod = (typeof WS_NATIVE_API_METHODS)[number]; +export const wsNativeApiMethodSchema = z.enum(WS_NATIVE_API_METHODS); + export type EditorId = (typeof EDITORS)[number]["id"]; +export const editorIdSchema = z.enum(EDITORS.map((entry) => entry.id) as [EditorId, ...EditorId[]]); -export const IPC_CHANNELS = { - todosList: "todos:list", - todosAdd: "todos:add", - todosToggle: "todos:toggle", - todosRemove: "todos:remove", - dialogPickFolder: "dialog:pick-folder", - terminalRun: "terminal:run", - agentSpawn: "agent:spawn", - agentKill: "agent:kill", - agentWrite: "agent:write", - agentOutput: "agent:output", - agentExit: "agent:exit", - providerSessionStart: "provider:session:start", - providerTurnStart: "provider:turn:start", - providerTurnInterrupt: "provider:turn:interrupt", - providerSessionStop: "provider:session:stop", - providerSessionList: "provider:session:list", - providerRequestRespond: "provider:request:respond", - providerEvent: "provider:event", - shellOpenInEditor: "shell:open-in-editor", -} as const; +export const appBootstrapResultSchema = z.object({ + launchCwd: z.string().min(1), + projectName: z.string().min(1), + provider: providerKindSchema, + model: z.string().min(1), + session: providerSessionSchema, + bootstrapError: z.string().min(1).optional(), +}).strict(); + +export const appHealthResultSchema = z.object({ + status: z.literal("ok"), + launchCwd: z.string().min(1), + sessionCount: z.number().int().min(0), + activeClientConnected: z.boolean(), +}).strict(); +export const dialogsPickFolderResultSchema = z.string().nullable(); +export const shellOpenInEditorInputSchema = z + .object({ + cwd: z.string().min(1), + editor: editorIdSchema, + }) + .strict(); + +export type AppBootstrapResult = z.infer; +export type AppHealthResult = z.infer; export interface NativeApi { + app: { + bootstrap: () => Promise; + health: () => Promise; + }; todos: { list: () => Promise; add: (input: NewTodoInput) => Promise; diff --git a/packages/contracts/src/provider.test.ts b/packages/contracts/src/provider.test.ts index 50b1f556239..826a5520154 100644 --- a/packages/contracts/src/provider.test.ts +++ b/packages/contracts/src/provider.test.ts @@ -29,6 +29,15 @@ describe("providerSessionStartInputSchema", () => { }), ).toThrow(); }); + + it("rejects unexpected startSession input properties", () => { + expect(() => + providerSessionStartInputSchema.parse({ + provider: "codex", + unexpected: true, + }), + ).toThrow(); + }); }); describe("providerSendTurnInputSchema", () => { @@ -43,6 +52,16 @@ describe("providerSendTurnInputSchema", () => { expect(parsed.model).toBe("gpt-5.2-codex"); expect(parsed.effort).toBe("high"); }); + + it("rejects unexpected sendTurn properties", () => { + expect(() => + providerSendTurnInputSchema.parse({ + sessionId: "sess_1", + input: "hello", + unexpected: true, + }), + ).toThrow(); + }); }); describe("providerEventSchema", () => { @@ -76,6 +95,22 @@ describe("providerEventSchema", () => { expect(parsed.requestId).toBe("req_123"); expect(parsed.requestKind).toBe("command"); }); + + it("rejects unexpected event properties", () => { + expect(() => + providerEventSchema.parse({ + id: "evt_2", + kind: "request", + provider: "codex", + sessionId: "sess_1", + createdAt: "2026-01-01T00:00:00.000Z", + method: "item/commandExecution/requestApproval", + requestId: "req_123", + requestKind: "command", + unexpected: true, + }), + ).toThrow(); + }); }); describe("providerRespondToRequestInputSchema", () => { @@ -97,4 +132,15 @@ describe("providerRespondToRequestInputSchema", () => { }), ).toThrow(); }); + + it("rejects unexpected respondToRequest properties", () => { + expect(() => + providerRespondToRequestInputSchema.parse({ + sessionId: "sess_1", + requestId: "req_1", + decision: "accept", + unexpected: true, + }), + ).toThrow(); + }); }); diff --git a/packages/contracts/src/provider.ts b/packages/contracts/src/provider.ts index 0d0c30a1a85..c2f2af97559 100644 --- a/packages/contracts/src/provider.ts +++ b/packages/contracts/src/provider.ts @@ -43,7 +43,9 @@ export const providerSessionSchema = z.object({ createdAt: z.string().datetime(), updatedAt: z.string().datetime(), lastError: z.string().min(1).optional(), -}); +}).strict(); + +export const providerSessionListSchema = z.array(providerSessionSchema); export const providerSessionStartInputSchema = z.object({ provider: providerKindSchema.default("codex"), @@ -52,7 +54,7 @@ export const providerSessionStartInputSchema = z.object({ resumeThreadId: z.string().trim().min(1).optional(), approvalPolicy: providerApprovalPolicySchema.default("never"), sandboxMode: providerSandboxModeSchema.default("workspace-write"), -}); +}).strict(); export const PROVIDER_SEND_TURN_MAX_INPUT_CHARS = 120_000; @@ -61,34 +63,29 @@ export const providerSendTurnInputSchema = z.object({ input: z.string().trim().min(1).max(PROVIDER_SEND_TURN_MAX_INPUT_CHARS), model: z.string().trim().min(1).optional(), effort: z.string().trim().min(1).optional(), -}); +}).strict(); export const providerTurnStartResultSchema = z.object({ threadId: z.string().min(1), turnId: z.string().min(1), -}); +}).strict(); export const providerInterruptTurnInputSchema = z.object({ sessionId: z.string().min(1), turnId: z.string().min(1).optional(), -}); +}).strict(); export const providerStopSessionInputSchema = z.object({ sessionId: z.string().min(1), -}); +}).strict(); export const providerRespondToRequestInputSchema = z.object({ sessionId: z.string().min(1), requestId: z.string().min(1), decision: providerApprovalDecisionSchema, -}); +}).strict(); -export const providerEventKindSchema = z.enum([ - "session", - "notification", - "request", - "error", -]); +export const providerEventKindSchema = z.enum(["session", "notification", "request", "error"]); export const providerEventSchema = z.object({ id: z.string().min(1), @@ -105,30 +102,20 @@ export const providerEventSchema = z.object({ requestKind: providerRequestKindSchema.optional(), textDelta: z.string().optional(), payload: z.unknown().optional(), -}); +}).strict(); export type ProviderKind = z.infer; export type ProviderApprovalPolicy = z.infer; export type ProviderSandboxMode = z.infer; export type ProviderRequestKind = z.infer; -export type ProviderApprovalDecision = z.infer< - typeof providerApprovalDecisionSchema ->; +export type ProviderApprovalDecision = z.infer; export type ProviderSessionStatus = z.infer; export type ProviderSession = z.infer; export type ProviderSessionStartInput = z.input; export type ProviderSendTurnInput = z.input; -export type ProviderTurnStartResult = z.infer< - typeof providerTurnStartResultSchema ->; -export type ProviderInterruptTurnInput = z.input< - typeof providerInterruptTurnInputSchema ->; -export type ProviderStopSessionInput = z.input< - typeof providerStopSessionInputSchema ->; -export type ProviderRespondToRequestInput = z.input< - typeof providerRespondToRequestInputSchema ->; +export type ProviderTurnStartResult = z.infer; +export type ProviderInterruptTurnInput = z.input; +export type ProviderStopSessionInput = z.input; +export type ProviderRespondToRequestInput = z.input; export type ProviderEventKind = z.infer; export type ProviderEvent = z.infer; diff --git a/packages/contracts/src/terminal.test.ts b/packages/contracts/src/terminal.test.ts index 7e5b9aab837..28fcb1b3b7d 100644 --- a/packages/contracts/src/terminal.test.ts +++ b/packages/contracts/src/terminal.test.ts @@ -21,4 +21,12 @@ describe("terminalCommandInputSchema", () => { const result = terminalCommandInputSchema.safeParse({ command: " " }); expect(result.success).toBe(false); }); + + it("rejects unexpected command properties", () => { + const result = terminalCommandInputSchema.safeParse({ + command: "echo hello", + unexpected: true, + }); + expect(result.success).toBe(false); + }); }); diff --git a/packages/contracts/src/terminal.ts b/packages/contracts/src/terminal.ts index 33126ec96a3..9b8ea6529ed 100644 --- a/packages/contracts/src/terminal.ts +++ b/packages/contracts/src/terminal.ts @@ -4,7 +4,7 @@ export const terminalCommandInputSchema = z.object({ command: z.string().trim().min(1).max(4000), cwd: z.string().min(1).optional(), timeoutMs: z.number().int().positive().max(120_000).optional(), -}); +}).strict(); export const terminalCommandResultSchema = z.object({ stdout: z.string(), @@ -12,7 +12,7 @@ export const terminalCommandResultSchema = z.object({ code: z.number().nullable(), signal: z.string().nullable(), timedOut: z.boolean(), -}); +}).strict(); export type TerminalCommandInput = z.infer; export type TerminalCommandResult = z.infer; diff --git a/packages/contracts/src/todo.test.ts b/packages/contracts/src/todo.test.ts index 181edb151fd..dbb4a8c4ab6 100644 --- a/packages/contracts/src/todo.test.ts +++ b/packages/contracts/src/todo.test.ts @@ -32,6 +32,17 @@ describe("todoSchema", () => { }); expect(result.success).toBe(false); }); + + it("rejects todos with unexpected properties", () => { + const result = todoSchema.safeParse({ + id: "abc-123", + title: "Buy milk", + completed: false, + createdAt: "2025-01-01T00:00:00.000Z", + unexpected: true, + }); + expect(result.success).toBe(false); + }); }); describe("newTodoInputSchema", () => { @@ -49,4 +60,12 @@ describe("newTodoInputSchema", () => { const result = newTodoInputSchema.safeParse({ title: " " }); expect(result.success).toBe(false); }); + + it("rejects unexpected input properties", () => { + const result = newTodoInputSchema.safeParse({ + title: "Buy milk", + unexpected: true, + }); + expect(result.success).toBe(false); + }); }); diff --git a/packages/contracts/src/todo.ts b/packages/contracts/src/todo.ts index 9157ca93c80..46c8a91d5fe 100644 --- a/packages/contracts/src/todo.ts +++ b/packages/contracts/src/todo.ts @@ -5,13 +5,13 @@ export const todoSchema = z.object({ title: z.string().min(1).max(280), completed: z.boolean(), createdAt: z.string().datetime(), -}); +}).strict(); export const todoListSchema = z.array(todoSchema); export const newTodoInputSchema = z.object({ title: z.string().trim().min(1).max(280), -}); +}).strict(); export const todoIdSchema = z.string().min(1); diff --git a/packages/contracts/src/ws.test.ts b/packages/contracts/src/ws.test.ts new file mode 100644 index 00000000000..e94204c5f48 --- /dev/null +++ b/packages/contracts/src/ws.test.ts @@ -0,0 +1,496 @@ +import { describe, expect, it } from "vitest"; + +import { + WS_CLOSE_CODES, + WS_CLOSE_REASONS, + WS_ERROR_CODE_MAX_CHARS, + WS_ERROR_MESSAGE_MAX_CHARS, + WS_EVENT_CHANNELS, + WS_METHOD_MAX_CHARS, + WS_REQUEST_ID_MAX_CHARS, + wsClientMessageSchema, + wsServerMessageSchema, +} from "./ws"; + +describe("wsClientMessageSchema", () => { + it("accepts request messages", () => { + const parsed = wsClientMessageSchema.parse({ + type: "request", + id: "req-1", + method: "providers.startSession", + params: { provider: "codex" }, + }); + + expect(parsed.method).toBe("providers.startSession"); + }); + + it("rejects empty request ids", () => { + expect(() => + wsClientMessageSchema.parse({ + type: "request", + id: "", + method: "providers.startSession", + }), + ).toThrow(); + }); + + it("rejects whitespace-only request ids", () => { + expect(() => + wsClientMessageSchema.parse({ + type: "request", + id: " ", + method: "providers.startSession", + }), + ).toThrow(); + }); + + it("rejects empty request methods", () => { + expect(() => + wsClientMessageSchema.parse({ + type: "request", + id: "req-1", + method: "", + }), + ).toThrow(); + }); + + it("rejects whitespace-only request methods", () => { + expect(() => + wsClientMessageSchema.parse({ + type: "request", + id: "req-1", + method: " ", + }), + ).toThrow(); + }); + + it("rejects overly long request ids", () => { + expect(() => + wsClientMessageSchema.parse({ + type: "request", + id: "r".repeat(WS_REQUEST_ID_MAX_CHARS + 1), + method: "providers.startSession", + }), + ).toThrow(); + }); + + it("accepts request ids at max length", () => { + const parsed = wsClientMessageSchema.parse({ + type: "request", + id: "r".repeat(WS_REQUEST_ID_MAX_CHARS), + method: "providers.startSession", + }); + + expect(parsed.id).toHaveLength(WS_REQUEST_ID_MAX_CHARS); + }); + + it("rejects overly long request methods", () => { + expect(() => + wsClientMessageSchema.parse({ + type: "request", + id: "req-1", + method: "m".repeat(WS_METHOD_MAX_CHARS + 1), + }), + ).toThrow(); + }); + + it("accepts request methods at max length", () => { + const parsed = wsClientMessageSchema.parse({ + type: "request", + id: "req-1", + method: "m".repeat(WS_METHOD_MAX_CHARS), + }); + + expect(parsed.method).toHaveLength(WS_METHOD_MAX_CHARS); + }); + + it("rejects unexpected request properties", () => { + expect(() => + wsClientMessageSchema.parse({ + type: "request", + id: "req-1", + method: "providers.listSessions", + unexpected: true, + }), + ).toThrow(); + }); +}); + +describe("wsServerMessageSchema", () => { + it("accepts successful response messages", () => { + const parsed = wsServerMessageSchema.parse({ + type: "response", + id: "req-1", + ok: true, + result: { sessionId: "sess-1" }, + }); + + expect(parsed.type).toBe("response"); + }); + + it("requires errors for failed responses", () => { + expect(() => + wsServerMessageSchema.parse({ + type: "response", + id: "req-1", + ok: false, + }), + ).toThrow(); + }); + + it("accepts response errors at max field lengths", () => { + const parsed = wsServerMessageSchema.parse({ + type: "response", + id: "req-1", + ok: false, + error: { + code: "c".repeat(WS_ERROR_CODE_MAX_CHARS), + message: "m".repeat(WS_ERROR_MESSAGE_MAX_CHARS), + }, + }); + + expect(parsed.type).toBe("response"); + }); + + it("rejects overlong response error fields", () => { + expect(() => + wsServerMessageSchema.parse({ + type: "response", + id: "req-1", + ok: false, + error: { + code: "c".repeat(WS_ERROR_CODE_MAX_CHARS + 1), + message: "valid", + }, + }), + ).toThrow(); + + expect(() => + wsServerMessageSchema.parse({ + type: "response", + id: "req-1", + ok: false, + error: { + code: "request_failed", + message: "m".repeat(WS_ERROR_MESSAGE_MAX_CHARS + 1), + }, + }), + ).toThrow(); + }); + + it("requires result for successful responses", () => { + expect(() => + wsServerMessageSchema.parse({ + type: "response", + id: "req-1", + ok: true, + }), + ).toThrow(); + }); + + it("rejects errors for successful responses", () => { + expect(() => + wsServerMessageSchema.parse({ + type: "response", + id: "req-1", + ok: true, + result: { status: "ok" }, + error: { + code: "unexpected", + message: "should-not-be-present", + }, + }), + ).toThrow(); + }); + + it("rejects result payloads for failed responses", () => { + expect(() => + wsServerMessageSchema.parse({ + type: "response", + id: "req-1", + ok: false, + result: { status: "unexpected" }, + error: { + code: "request_failed", + message: "expected-failure", + }, + }), + ).toThrow(); + }); + + it("rejects unexpected response properties", () => { + expect(() => + wsServerMessageSchema.parse({ + type: "response", + id: "req-1", + ok: true, + result: { status: "ok" }, + unexpected: true, + }), + ).toThrow(); + }); + + it("rejects overly long response ids", () => { + expect(() => + wsServerMessageSchema.parse({ + type: "response", + id: "r".repeat(WS_REQUEST_ID_MAX_CHARS + 1), + ok: true, + result: {}, + }), + ).toThrow(); + }); + + it("accepts response ids at max length", () => { + const parsed = wsServerMessageSchema.parse({ + type: "response", + id: "r".repeat(WS_REQUEST_ID_MAX_CHARS), + ok: true, + result: {}, + }); + + expect(parsed.type).toBe("response"); + }); + + it("rejects whitespace-only response ids", () => { + expect(() => + wsServerMessageSchema.parse({ + type: "response", + id: " ", + ok: true, + result: {}, + }), + ).toThrow(); + }); + + it("rejects whitespace-only response error fields", () => { + expect(() => + wsServerMessageSchema.parse({ + type: "response", + id: "req-1", + ok: false, + error: { + code: " ", + message: "valid", + }, + }), + ).toThrow(); + + expect(() => + wsServerMessageSchema.parse({ + type: "response", + id: "req-1", + ok: false, + error: { + code: "request_failed", + message: " ", + }, + }), + ).toThrow(); + }); + + it("accepts typed event channels", () => { + const parsed = wsServerMessageSchema.parse({ + type: "event", + channel: WS_EVENT_CHANNELS.providerEvent, + payload: { + id: "evt-1", + kind: "notification", + provider: "codex", + sessionId: "sess-1", + createdAt: "2026-02-01T00:00:00.000Z", + method: "turn/started", + }, + }); + + expect(parsed.type).toBe("event"); + }); + + it("accepts typed agent output and exit events", () => { + const output = wsServerMessageSchema.parse({ + type: "event", + channel: WS_EVENT_CHANNELS.agentOutput, + payload: { + sessionId: "agent-1", + stream: "stdout", + data: "hello", + }, + }); + const exit = wsServerMessageSchema.parse({ + type: "event", + channel: WS_EVENT_CHANNELS.agentExit, + payload: { + sessionId: "agent-1", + code: 0, + signal: null, + }, + }); + + expect(output.type).toBe("event"); + expect(exit.type).toBe("event"); + }); + + it("rejects unknown event channels", () => { + expect(() => + wsServerMessageSchema.parse({ + type: "event", + channel: "provider:unknown", + payload: { + id: "evt-1", + kind: "notification", + provider: "codex", + sessionId: "sess-1", + createdAt: "2026-02-01T00:00:00.000Z", + method: "turn/started", + }, + }), + ).toThrow(); + }); + + it("rejects malformed payloads for typed channels", () => { + expect(() => + wsServerMessageSchema.parse({ + type: "event", + channel: WS_EVENT_CHANNELS.providerEvent, + payload: { + sessionId: "sess-1", + }, + }), + ).toThrow(); + + expect(() => + wsServerMessageSchema.parse({ + type: "event", + channel: WS_EVENT_CHANNELS.agentOutput, + payload: { + sessionId: "agent-1", + stream: "invalid-stream", + data: "oops", + }, + }), + ).toThrow(); + + expect(() => + wsServerMessageSchema.parse({ + type: "event", + channel: WS_EVENT_CHANNELS.agentExit, + payload: { + sessionId: "agent-1", + code: "0", + signal: null, + }, + }), + ).toThrow(); + }); + + it("rejects agent events with empty session ids", () => { + expect(() => + wsServerMessageSchema.parse({ + type: "event", + channel: WS_EVENT_CHANNELS.agentOutput, + payload: { + sessionId: "", + stream: "stdout", + data: "hello", + }, + }), + ).toThrow(); + + expect(() => + wsServerMessageSchema.parse({ + type: "event", + channel: WS_EVENT_CHANNELS.agentExit, + payload: { + sessionId: "", + code: 0, + signal: null, + }, + }), + ).toThrow(); + }); + + it("rejects unexpected event properties", () => { + expect(() => + wsServerMessageSchema.parse({ + type: "event", + channel: WS_EVENT_CHANNELS.providerEvent, + payload: { + id: "evt-1", + kind: "notification", + provider: "codex", + sessionId: "sess-1", + createdAt: "2026-02-01T00:00:00.000Z", + method: "turn/started", + }, + unexpected: true, + }), + ).toThrow(); + }); + + it("rejects unexpected response error properties", () => { + expect(() => + wsServerMessageSchema.parse({ + type: "response", + id: "req-1", + ok: false, + error: { + code: "request_failed", + message: "boom", + unexpected: true, + }, + }), + ).toThrow(); + }); + + it("accepts hello server messages", () => { + const parsed = wsServerMessageSchema.parse({ + type: "hello", + version: 1, + launchCwd: "/workspace", + }); + + expect(parsed.type).toBe("hello"); + }); + + it("rejects hello messages with unsupported versions", () => { + expect(() => + wsServerMessageSchema.parse({ + type: "hello", + version: 2, + launchCwd: "/workspace", + }), + ).toThrow(); + }); + + it("rejects unexpected hello properties", () => { + expect(() => + wsServerMessageSchema.parse({ + type: "hello", + version: 1, + launchCwd: "/workspace", + unexpected: true, + }), + ).toThrow(); + }); +}); + +describe("ws close metadata", () => { + it("exposes stable unauthorized close semantics", () => { + expect(WS_CLOSE_CODES.unauthorized).toBe(4001); + expect(WS_CLOSE_REASONS.unauthorized).toBe("unauthorized"); + }); + + it("exposes stable replacement close semantics", () => { + expect(WS_CLOSE_CODES.replacedByNewClient).toBe(4000); + expect(WS_CLOSE_REASONS.replacedByNewClient).toBe("replaced-by-new-client"); + }); + + it("keeps close codes and reasons unique", () => { + expect( + new Set([WS_CLOSE_CODES.unauthorized, WS_CLOSE_CODES.replacedByNewClient]).size, + ).toBe(2); + expect( + new Set([WS_CLOSE_REASONS.unauthorized, WS_CLOSE_REASONS.replacedByNewClient]).size, + ).toBe(2); + }); +}); diff --git a/packages/contracts/src/ws.ts b/packages/contracts/src/ws.ts new file mode 100644 index 00000000000..22e51969b2c --- /dev/null +++ b/packages/contracts/src/ws.ts @@ -0,0 +1,139 @@ +import { z } from "zod"; +import { agentExitSchema, outputChunkSchema } from "./agent"; +import { providerEventSchema } from "./provider"; + +export const WS_EVENT_CHANNELS = { + providerEvent: "provider:event", + agentOutput: "agent:output", + agentExit: "agent:exit", +} as const; + +export const WS_CLOSE_CODES = { + replacedByNewClient: 4000, + unauthorized: 4001, +} as const; + +export const WS_CLOSE_REASONS = { + replacedByNewClient: "replaced-by-new-client", + unauthorized: "unauthorized", +} as const; + +export const WS_REQUEST_ID_MAX_CHARS = 256; +export const WS_METHOD_MAX_CHARS = 256; +export const WS_ERROR_CODE_MAX_CHARS = 128; +export const WS_ERROR_MESSAGE_MAX_CHARS = 8_192; + +const wsRequestIdSchema = z + .string() + .min(1) + .max(WS_REQUEST_ID_MAX_CHARS) + .refine((value) => value.trim().length > 0, { + message: "request.id must not be blank", + }); +const wsMethodSchema = z + .string() + .min(1) + .max(WS_METHOD_MAX_CHARS) + .refine((value) => value.trim().length > 0, { + message: "request.method must not be blank", + }); +const wsErrorCodeSchema = z + .string() + .min(1) + .max(WS_ERROR_CODE_MAX_CHARS) + .refine((value) => value.trim().length > 0, { + message: "response.error.code must not be blank", + }); +const wsErrorMessageSchema = z + .string() + .min(1) + .max(WS_ERROR_MESSAGE_MAX_CHARS) + .refine((value) => value.trim().length > 0, { + message: "response.error.message must not be blank", + }); + +const wsRequestSchema = z.object({ + type: z.literal("request"), + id: wsRequestIdSchema, + method: wsMethodSchema, + params: z.unknown().optional(), +}).strict(); + +const wsResponseErrorSchema = z.object({ + code: wsErrorCodeSchema, + message: wsErrorMessageSchema, +}).strict(); + +const wsResponseSchema = z + .object({ + type: z.literal("response"), + id: wsRequestIdSchema, + ok: z.boolean(), + result: z.unknown().optional(), + error: wsResponseErrorSchema.optional(), + }) + .strict() + .superRefine((value, ctx) => { + if (value.ok && value.error) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "response.error must be undefined when ok=true", + }); + } + + if (value.ok && value.result === undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "response.result is required when ok=true", + }); + } + + if (!value.ok && !value.error) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "response.error is required when ok=false", + }); + } + + if (!value.ok && value.result !== undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "response.result must be undefined when ok=false", + }); + } + }); + +const wsEventSchema = z.union([ + z.object({ + type: z.literal("event"), + channel: z.literal(WS_EVENT_CHANNELS.providerEvent), + payload: providerEventSchema, + }).strict(), + z.object({ + type: z.literal("event"), + channel: z.literal(WS_EVENT_CHANNELS.agentOutput), + payload: outputChunkSchema, + }).strict(), + z.object({ + type: z.literal("event"), + channel: z.literal(WS_EVENT_CHANNELS.agentExit), + payload: agentExitSchema, + }).strict(), +]); + +const wsHelloSchema = z.object({ + type: z.literal("hello"), + version: z.literal(1), + launchCwd: z.string().min(1), +}).strict(); + +export const wsClientMessageSchema = wsRequestSchema; +export const wsServerMessageSchema = z.union([wsResponseSchema, wsEventSchema, wsHelloSchema]); + +export type WsEventChannel = z.infer["channel"]; +export type WsRequestMessage = z.infer; +export type WsResponseMessage = z.infer; +export type WsEventMessage = z.infer; +export type WsHelloMessage = z.infer; +export type WsClientMessage = z.infer; +export type WsServerMessage = z.infer; diff --git a/packages/runtime-core/package.json b/packages/runtime-core/package.json new file mode 100644 index 00000000000..a153aca4efd --- /dev/null +++ b/packages/runtime-core/package.json @@ -0,0 +1,27 @@ +{ + "name": "@acme/runtime-core", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "./src/index.ts", + "module": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + } + }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "dependencies": { + "@acme/contracts": "workspace:*", + "node-pty": "^1.0.0" + }, + "devDependencies": { + "@types/node": "^22.10.2", + "typescript": "^5.7.3" + } +} diff --git a/apps/desktop/src/codexAppServerManager.test.ts b/packages/runtime-core/src/codexAppServerManager.test.ts similarity index 91% rename from apps/desktop/src/codexAppServerManager.test.ts rename to packages/runtime-core/src/codexAppServerManager.test.ts index b611e4d4de4..7c87e4442cc 100644 --- a/apps/desktop/src/codexAppServerManager.test.ts +++ b/packages/runtime-core/src/codexAppServerManager.test.ts @@ -57,17 +57,13 @@ describe("normalizeCodexModelSlug", () => { describe("isRecoverableThreadResumeError", () => { it("matches not-found resume errors", () => { expect( - isRecoverableThreadResumeError( - new Error("thread/resume failed: thread not found"), - ), + isRecoverableThreadResumeError(new Error("thread/resume failed: thread not found")), ).toBe(true); }); it("ignores non-resume errors", () => { expect( - isRecoverableThreadResumeError( - new Error("thread/start failed: permission denied"), - ), + isRecoverableThreadResumeError(new Error("thread/start failed: permission denied")), ).toBe(false); }); diff --git a/apps/desktop/src/codexAppServerManager.ts b/packages/runtime-core/src/codexAppServerManager.ts similarity index 95% rename from apps/desktop/src/codexAppServerManager.ts rename to packages/runtime-core/src/codexAppServerManager.ts index 1564883e651..84605920f89 100644 --- a/apps/desktop/src/codexAppServerManager.ts +++ b/packages/runtime-core/src/codexAppServerManager.ts @@ -26,9 +26,7 @@ interface PendingRequest { interface PendingApprovalRequest { requestId: string; jsonRpcId: string | number; - method: - | "item/commandExecution/requestApproval" - | "item/fileChange/requestApproval"; + method: "item/commandExecution/requestApproval" | "item/fileChange/requestApproval"; requestKind: ProviderRequestKind; threadId?: string; turnId?: string; @@ -122,16 +120,12 @@ export function classifyCodexStderrLine(rawLine: string): { message: string } | } export function isRecoverableThreadResumeError(error: unknown): boolean { - const message = ( - error instanceof Error ? error.message : String(error) - ).toLowerCase(); + const message = (error instanceof Error ? error.message : String(error)).toLowerCase(); if (!message.includes("thread/resume")) { return false; } - return RECOVERABLE_THREAD_RESUME_ERROR_SNIPPETS.some((snippet) => - message.includes(snippet), - ); + return RECOVERABLE_THREAD_RESUME_ERROR_SNIPPETS.some((snippet) => message.includes(snippet)); } export interface CodexAppServerManagerEvents { @@ -209,14 +203,10 @@ export class CodexAppServerManager extends EventEmitter boolean, + timeoutMs = 5_000, +) { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + clearInterval(poll); + reject(new Error("Timed out waiting for provider event.")); + }, timeoutMs); + + const poll = setInterval(() => { + const match = events.find((event) => matcher(event)); + if (!match) { + return; + } + + clearTimeout(timeout); + clearInterval(poll); + resolve(match); + }, 10); + }); +} + +describe("ProviderManager integration with fake codex app-server", () => { + it( + "supports start/send/respond/interrupt/stop lifecycle", + async () => { + const fakeCodex = createFakeCodexAppServerBinary("runtime-core-fake-codex-"); + const originalPath = process.env.PATH; + process.env.PATH = `${fakeCodex.tempDir}${path.delimiter}${originalPath ?? ""}`; + + const manager = new ProviderManager(); + const events: ProviderEvent[] = []; + manager.on("event", (event) => { + events.push(event); + }); + + try { + const session = await manager.startSession({ + provider: "codex", + }); + expect(session.provider).toBe("codex"); + expect(session.status).toBe("ready"); + expect(session.threadId).toBe("thread-fake"); + expect(session.sessionId.length).toBeGreaterThan(0); + + const turn = await manager.sendTurn({ + sessionId: session.sessionId, + input: "hello fake codex", + }); + expect(turn.threadId).toBe("thread-fake"); + expect(turn.turnId).toBe("turn-1"); + + const approvalEvent = await waitForProviderEvent( + events, + (event) => + event.kind === "request" && + event.method === "item/commandExecution/requestApproval" && + event.sessionId === session.sessionId && + event.requestKind === "command" && + typeof event.requestId === "string" && + event.requestId.length > 0, + ); + const requestId = approvalEvent.requestId; + if (!requestId) { + throw new Error("Expected command approval request id."); + } + + await manager.respondToRequest({ + sessionId: session.sessionId, + requestId, + decision: "accept", + }); + + await manager.interruptTurn({ + sessionId: session.sessionId, + turnId: turn.turnId, + }); + + expect(manager.listSessions().some((entry) => entry.sessionId === session.sessionId)).toBe( + true, + ); + + manager.stopSession({ + sessionId: session.sessionId, + }); + expect(manager.listSessions().some((entry) => entry.sessionId === session.sessionId)).toBe( + false, + ); + } finally { + manager.dispose(); + process.env.PATH = originalPath; + rmSync(fakeCodex.tempDir, { recursive: true, force: true }); + } + }, + 20_000, + ); +}); diff --git a/apps/desktop/src/providerManager.test.ts b/packages/runtime-core/src/providerManager.test.ts similarity index 94% rename from apps/desktop/src/providerManager.test.ts rename to packages/runtime-core/src/providerManager.test.ts index c159bb296cf..424cfce16c0 100644 --- a/apps/desktop/src/providerManager.test.ts +++ b/packages/runtime-core/src/providerManager.test.ts @@ -16,10 +16,7 @@ describe("ProviderManager", () => { method: string; threadId: string; }) => void; - threadLogStreams: Map< - string, - { writableEnded: boolean; destroyed: boolean } - >; + threadLogStreams: Map; }; internals.onCodexEvent({ id: "evt-1", diff --git a/apps/desktop/src/providerManager.ts b/packages/runtime-core/src/providerManager.ts similarity index 96% rename from apps/desktop/src/providerManager.ts rename to packages/runtime-core/src/providerManager.ts index ebba31b20dd..affe501dcaf 100644 --- a/apps/desktop/src/providerManager.ts +++ b/packages/runtime-core/src/providerManager.ts @@ -87,11 +87,7 @@ export class ProviderManager extends EventEmitter { throw new Error(`Unknown provider session: ${input.sessionId}`); } - await this.codex.respondToRequest( - input.sessionId, - input.requestId, - input.decision, - ); + await this.codex.respondToRequest(input.sessionId, input.requestId, input.decision); } stopSession(raw: ProviderStopSessionInput): void { @@ -151,10 +147,7 @@ export class ProviderManager extends EventEmitter { private resolveThreadId(event: ProviderEvent): string | undefined { const fromPayload = this.readThreadIdFromPayload(event.payload); - const threadId = - event.threadId ?? - fromPayload ?? - this.sessionThreadIds.get(event.sessionId); + const threadId = event.threadId ?? fromPayload ?? this.sessionThreadIds.get(event.sessionId); if (threadId) { this.sessionThreadIds.set(event.sessionId, threadId); diff --git a/packages/runtime-core/src/runtimeCoreArchitecture.test.ts b/packages/runtime-core/src/runtimeCoreArchitecture.test.ts new file mode 100644 index 00000000000..e7945b1e464 --- /dev/null +++ b/packages/runtime-core/src/runtimeCoreArchitecture.test.ts @@ -0,0 +1,24 @@ +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +const SOURCE_FILES = [ + "index.ts", + "processManager.ts", + "todoStore.ts", + "providerManager.ts", + "codexAppServerManager.ts", +] as const; + +describe("runtime-core architecture boundaries", () => { + for (const fileName of SOURCE_FILES) { + it(`${fileName} does not reach into app-layer paths`, () => { + const filePath = path.resolve(import.meta.dirname, fileName); + const source = fs.readFileSync(filePath, "utf8"); + + expect(source).not.toContain("../apps/"); + expect(source).not.toContain("../../apps/"); + expect(source).not.toContain("../../../apps/"); + }); + } +}); diff --git a/packages/runtime-core/src/todoStore.test.ts b/packages/runtime-core/src/todoStore.test.ts new file mode 100644 index 00000000000..6ac6a658241 --- /dev/null +++ b/packages/runtime-core/src/todoStore.test.ts @@ -0,0 +1,60 @@ +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import { TodoStore } from "./todoStore"; + +const tempDirs: string[] = []; + +afterEach(async () => { + await Promise.all( + tempDirs.splice(0, tempDirs.length).map(async (dir) => { + await rm(dir, { recursive: true, force: true }); + }), + ); +}); + +async function createStore() { + const dir = await mkdtemp(path.join(os.tmpdir(), "runtime-core-todos-")); + tempDirs.push(dir); + const filePath = path.join(dir, "todos.json"); + const store = new TodoStore(filePath); + await store.init(); + return { store, filePath }; +} + +describe("TodoStore", () => { + it("initializes an empty todo file", async () => { + const { store, filePath } = await createStore(); + + expect(await store.list()).toEqual([]); + + const raw = await readFile(filePath, "utf8"); + expect(JSON.parse(raw)).toEqual([]); + }); + + it("adds, toggles, removes, and persists todos", async () => { + const { store, filePath } = await createStore(); + + const afterAdd = await store.add({ title: "ship runtime core" }); + expect(afterAdd).toHaveLength(1); + const added = afterAdd[0]; + expect(added?.title).toBe("ship runtime core"); + expect(added?.completed).toBe(false); + + if (!added) { + throw new Error("Expected todo to be created."); + } + + const afterToggle = await store.toggle(added.id); + expect(afterToggle[0]?.completed).toBe(true); + + const reloaded = new TodoStore(filePath); + await reloaded.init(); + expect((await reloaded.list())[0]?.completed).toBe(true); + + const afterRemove = await reloaded.remove(added.id); + expect(afterRemove).toEqual([]); + }); +}); diff --git a/apps/desktop/src/todoStore.ts b/packages/runtime-core/src/todoStore.ts similarity index 100% rename from apps/desktop/src/todoStore.ts rename to packages/runtime-core/src/todoStore.ts diff --git a/packages/runtime-core/tsconfig.json b/packages/runtime-core/tsconfig.json new file mode 100644 index 00000000000..374bac55202 --- /dev/null +++ b/packages/runtime-core/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["src"] +} diff --git a/scripts/prepack.mjs b/scripts/prepack.mjs new file mode 100644 index 00000000000..68baa2f0faa --- /dev/null +++ b/scripts/prepack.mjs @@ -0,0 +1,48 @@ +import { spawnSync } from "node:child_process"; +import os from "node:os"; +import path from "node:path"; + +function canExecute(command) { + const result = spawnSync(command, ["--version"], { + stdio: "ignore", + shell: false, + }); + return result.status === 0; +} + +function resolveBunPath() { + const candidates = [ + process.env.BUN_BIN, + "bun", + path.join(os.homedir(), ".bun", "bin", "bun"), + path.join(os.homedir(), ".bun", "bin", "bun.exe"), + ].filter(Boolean); + + for (const candidate of candidates) { + if (canExecute(candidate)) { + return candidate; + } + } + + throw new Error( + "Bun is required for prepack. Install from https://bun.sh and ensure bun is available.", + ); +} + +const bunPath = resolveBunPath(); +const buildSteps = [ + ["run", "--cwd", "packages/contracts", "build"], + ["run", "--cwd", "apps/renderer", "build"], + ["run", "--cwd", "apps/t3", "build"], +]; + +for (const step of buildSteps) { + const result = spawnSync(bunPath, step, { + stdio: "inherit", + shell: false, + env: process.env, + }); + if (result.status !== 0) { + process.exit(result.status ?? 1); + } +} diff --git a/test-support/fakeCodexAppServer.mjs b/test-support/fakeCodexAppServer.mjs new file mode 100644 index 00000000000..cce0cc7c345 --- /dev/null +++ b/test-support/fakeCodexAppServer.mjs @@ -0,0 +1,88 @@ +import { mkdtempSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +export function createFakeCodexAppServerBinary(prefix) { + const tempDir = mkdtempSync(path.join(os.tmpdir(), prefix)); + const binaryPath = path.join(tempDir, "codex"); + const script = `#!/usr/bin/env node +const readline = require("node:readline"); +const rl = readline.createInterface({ input: process.stdin }); +let turnCount = 0; +const send = (message) => process.stdout.write(\`\${JSON.stringify(message)}\\n\`); + +rl.on("line", (line) => { + let parsed; + try { + parsed = JSON.parse(line); + } catch { + return; + } + + if (!parsed || typeof parsed !== "object") { + return; + } + + if (!("id" in parsed) || typeof parsed.method !== "string") { + return; + } + + if (parsed.method === "initialize") { + send({ id: parsed.id, result: {} }); + return; + } + + if (parsed.method === "thread/start") { + send({ id: parsed.id, result: { thread: { id: "thread-fake" } } }); + return; + } + + if (parsed.method === "thread/resume") { + const threadId = + parsed.params && + typeof parsed.params === "object" && + typeof parsed.params.threadId === "string" + ? parsed.params.threadId + : "thread-fake"; + send({ id: parsed.id, result: { thread: { id: threadId } } }); + return; + } + + if (parsed.method === "turn/start") { + turnCount += 1; + send({ id: parsed.id, result: { turn: { id: \`turn-\${turnCount}\` } } }); + setTimeout(() => { + send({ + id: \`approval-\${turnCount}\`, + method: "item/commandExecution/requestApproval", + params: { + threadId: "thread-fake", + turnId: \`turn-\${turnCount}\`, + itemId: \`item-\${turnCount}\`, + }, + }); + }, 25); + return; + } + + if (parsed.method === "turn/interrupt") { + send({ id: parsed.id, result: {} }); + return; + } + + send({ + id: parsed.id, + error: { + code: -32601, + message: \`Unsupported fake codex method: \${parsed.method}\`, + }, + }); +}); +`; + writeFileSync(binaryPath, script, { encoding: "utf8", mode: 0o755 }); + + return { + tempDir, + binaryPath, + }; +} diff --git a/test-support/fakeCodexAppServer.ts b/test-support/fakeCodexAppServer.ts new file mode 100644 index 00000000000..dce7282539b --- /dev/null +++ b/test-support/fakeCodexAppServer.ts @@ -0,0 +1,88 @@ +import { mkdtempSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +export function createFakeCodexAppServerBinary(prefix: string) { + const tempDir = mkdtempSync(path.join(os.tmpdir(), prefix)); + const binaryPath = path.join(tempDir, "codex"); + const script = `#!/usr/bin/env node +const readline = require("node:readline"); +const rl = readline.createInterface({ input: process.stdin }); +let turnCount = 0; +const send = (message) => process.stdout.write(\`\${JSON.stringify(message)}\\n\`); + +rl.on("line", (line) => { + let parsed; + try { + parsed = JSON.parse(line); + } catch { + return; + } + + if (!parsed || typeof parsed !== "object") { + return; + } + + if (!("id" in parsed) || typeof parsed.method !== "string") { + return; + } + + if (parsed.method === "initialize") { + send({ id: parsed.id, result: {} }); + return; + } + + if (parsed.method === "thread/start") { + send({ id: parsed.id, result: { thread: { id: "thread-fake" } } }); + return; + } + + if (parsed.method === "thread/resume") { + const threadId = + parsed.params && + typeof parsed.params === "object" && + typeof parsed.params.threadId === "string" + ? parsed.params.threadId + : "thread-fake"; + send({ id: parsed.id, result: { thread: { id: threadId } } }); + return; + } + + if (parsed.method === "turn/start") { + turnCount += 1; + send({ id: parsed.id, result: { turn: { id: \`turn-\${turnCount}\` } } }); + setTimeout(() => { + send({ + id: \`approval-\${turnCount}\`, + method: "item/commandExecution/requestApproval", + params: { + threadId: "thread-fake", + turnId: \`turn-\${turnCount}\`, + itemId: \`item-\${turnCount}\`, + }, + }); + }, 25); + return; + } + + if (parsed.method === "turn/interrupt") { + send({ id: parsed.id, result: {} }); + return; + } + + send({ + id: parsed.id, + error: { + code: -32601, + message: \`Unsupported fake codex method: \${parsed.method}\`, + }, + }); +}); +`; + writeFileSync(binaryPath, script, { encoding: "utf8", mode: 0o755 }); + + return { + tempDir, + binaryPath, + }; +} diff --git a/turbo.json b/turbo.json index 89fbd7b9690..44828ecc948 100644 --- a/turbo.json +++ b/turbo.json @@ -1,10 +1,10 @@ { "$schema": "https://turbo.build/schema.json", - "globalEnv": ["ELECTRON_RENDERER_PORT"], + "globalEnv": ["T3_BACKEND_PORT", "T3_WEB_PORT"], "tasks": { "build": { "dependsOn": ["^build"], - "outputs": ["dist/**", "dist-electron/**"] + "outputs": ["dist/**"] }, "dev": { "cache": false, @@ -17,11 +17,6 @@ "test": { "dependsOn": ["^build"], "outputs": [] - }, - "smoke-test": { - "dependsOn": ["build"], - "cache": false, - "outputs": [] } } }