diff --git a/.changeset/fair-bears-tell.md b/.changeset/fair-bears-tell.md new file mode 100644 index 000000000..def1bad05 --- /dev/null +++ b/.changeset/fair-bears-tell.md @@ -0,0 +1,5 @@ +--- +"@spencer-kit/coder-studio": patch +--- + +Stabilize the web test suite and fix workspace UI and dev-browser regressions. diff --git a/.coder-studio/agent.md b/.coder-studio/agent.md new file mode 100644 index 000000000..0ac585b7f --- /dev/null +++ b/.coder-studio/agent.md @@ -0,0 +1,82 @@ +# Agent Instructions + +## Project Overview + +Coder Studio is a monorepo that ships a single CLI npm package containing two internal assembled artifacts: a web frontend UI (`packages/web`) and a Node.js backend runtime (`packages/server`). Provider integrations and shared utilities live in dedicated packages. The project uses pnpm and supports Node-based development and production builds. + +## Architecture Map + +### Web (frontend_ui) +- Primary entrypoint for user-facing UI +- UI/workspace actions: `packages/web/src/features/workspace/actions/` → `packages/server/src/ws/dispatch.ts` → `packages/server/src/commands/*.ts` +- Agent instructions generation: `packages/web/src/features/workspace/actions/use-agent-instructions-actions.ts` → `packages/server/src/commands/agent-instructions.ts` → `packages/server/src/agent-instructions/agent-generator.ts` → `packages/server/src/agent-instructions/prompt.ts` → `packages/server/src/workspace/intelligence.ts` +- **Start here** for UI changes, feature hooks, and frontend component work + +### Server (backend_runtime) +- Owns WebSocket dispatch and command execution +- Command routing: `packages/server/src/ws/dispatch.ts` → `packages/server/src/commands/*.ts` +- Agent generator: `packages/server/src/agent-instructions/agent-generator.ts` → `prompt.ts` → `workspace/intelligence.ts` +- **Start here** for server/runtime changes, command handlers, and workspace intelligence + +### Providers (provider_integrations) +- External model/runtime adapters with definition + headless/supervisor builders +- Execution path: `packages/providers/src/*/definition.ts` + provider-specific headless/supervisor builders → `packages/server/src/provider-runtime/command-runner.ts` +- **Start here** for provider changes and runtime adapter work + +### Core (shared_contracts) +- Domain types and provider interfaces shared across all packages +- Key files: `packages/core/src/domain/types.ts`, `packages/core/src/provider/definition.ts` +- **Inspect first** for type/contract changes that cross package boundaries + +### CLI (cli_entrypoint) +- Command-line launcher and entrypoint behavior +- **Start here** for CLI behavior and launcher configuration + +### Utils (shared_utilities) +- Reusable utilities consumed by multiple packages +- Shared helpers and cross-cutting concerns + +## Key Directories + +- `packages/web` — Primary frontend UI package for user-facing behavior +- `packages/server` — Backend runtime that owns WebSocket dispatch and command execution +- `packages/providers` — Provider integration package for external model/runtime adapters +- `packages/core` — Shared contracts and types used across packages +- `packages/cli` — Command-line entrypoint and launcher behavior +- `packages/utils` — Shared utility package reused by multiple packages + +## Development Commands + +- **Dev server**: `pnpm dev` — Start the development environment +- **Build all packages**: `pnpm build` — Run production builds for all packages +- **Lint all packages**: `pnpm lint` — Run linting across the monorepo +- **CI verification**: `pnpm ci:verify` — Repository-level validation before handoff +- **Full test suite**: `pnpm ci:test` — Repository-level test validation before handoff +- **E2E UI tests**: `pnpm e2e-ui` — End-to-end UI validation before handoff + +## Workflow Expectations + +- Keep changes focused on the requested task. +- Do not revert user changes unless explicitly asked. +- Do not use squash merge or delete branches during PR/merge workflows unless the user explicitly asks for squash merging or branch deletion. +- Prefer the project's existing patterns. +- Run the relevant verification command before reporting completion. + +## File Constraints + +- Respect package boundaries and keep changes scoped to the package you are touching unless cross-package edits are required. +- Avoid unrelated refactors across packages while solving a targeted task. +- Keep frontend changes in `packages/web` and backend runtime changes in `packages/server` unless the task explicitly crosses layers. +- Provider logic stays in `packages/providers`. Shared contracts and types live in `packages/core`. CLI entrypoint logic lives in `packages/cli`. +- Use repository-level verification commands before claiming completion. + +## Review Checklist + +- Summarize changed files. +- Report verification commands and results. +- Call out risks, skipped tests, and assumptions. + +## Provider Notes + +- Claude Code: use the project rules above. +- Codex: use the project rules above. diff --git a/docs/superpowers/plans/2026-06-15-session-token-automation.md b/docs/superpowers/plans/2026-06-15-session-token-automation.md new file mode 100644 index 000000000..c06c798c0 --- /dev/null +++ b/docs/superpowers/plans/2026-06-15-session-token-automation.md @@ -0,0 +1,494 @@ +# Session Token Automation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace password re-login for agent automation with a loopback-only session bearer token, restrict websocket automation to a small scoped command set, and fix the broken CLI memory payloads that still send legacy arguments. + +**Architecture:** Server issues a per-session automation token and injects it into the agent environment. CLI automation forwards the token as a websocket bearer header, auth validates that bearer only for local `/ws`, and websocket dispatch derives a token-scoped authorization context that is narrower than browser cookie access. Core automation capability reporting and local CLI identify/capability output must reflect the same scoped permission set. + +**Tech Stack:** TypeScript, Fastify, `@fastify/websocket`, `ws`, Vitest, pnpm monorepo, existing session manager/runtime env injection, existing websocket dispatch/hub, existing CLI `bin.test.ts` coverage. + +--- + +## File Structure + +- Create: `packages/server/src/auth/session-token-repo.ts` + Holds in-memory automation session tokens keyed by bearer value and exposes create/get/revoke helpers. +- Modify: `packages/server/src/session/manager.ts` + Issues automation tokens during session creation, injects `CODER_STUDIO_SESSION_TOKEN`, injects scoped permissions env, and revokes tokens when sessions end. +- Modify: `packages/server/src/server.ts` + Instantiates the session token repo and passes it into auth/session/ws layers. +- Modify: `packages/server/src/app.ts` + Passes the token repo into auth guard wiring and, if needed, ensures websocket route metadata remains available before auth decisions. +- Modify: `packages/server/src/auth/plugin.ts` + Adds loopback-only bearer authentication for `/ws` while preserving cookie auth for browser requests. +- Modify: `packages/server/src/auth/index.ts` + Re-export any new auth types/helpers needed by app wiring. +- Modify: `packages/server/src/ws/hub.ts` + Stores per-client auth metadata so dispatch can distinguish browser-cookie sockets from token-auth sockets. +- Modify: `packages/server/src/ws/dispatch.ts` + Adds token-scoped authorization and separates activation gating from automation command authorization. +- Modify: `packages/server/src/commands/automation.ts` + Allows capability responses to use scoped permissions instead of unconditional defaults. +- Modify: `packages/core/src/domain/automation.ts` + Defines the reduced automation permission set, adds env-aware permission parsing for local CLI identify/capabilities, and removes `workspace.list` from the token-scoped default surface. +- Modify: `packages/core/src/domain/automation.test.ts` + Covers scoped permission reporting and capability filtering. +- Modify: `packages/server/src/__tests__/automation/commands.test.ts` + Covers scoped capability responses on the command path. +- Modify: `packages/server/src/__tests__/activation-commands.test.ts` + Covers token-auth websocket authorization alongside existing activation behavior. +- Modify: `packages/server/src/auth/plugin.test.ts` + Covers loopback bearer accept/reject behavior. +- Modify: `packages/server/src/__tests__/ws-hub.test.ts` + Covers websocket auth metadata propagation if hub metadata shape changes. +- Modify: `packages/server/src/__tests__/session-integration.test.ts` + Verifies session env injection includes token and scoped permissions. +- Create or modify: `packages/server/src/__tests__/session-token-repo.test.ts` + Verifies token repo create/get/revoke behavior if the repo is non-trivial enough to deserve direct tests. +- Modify: `packages/cli/src/automation-command-client.ts` + Sends `Authorization: Bearer ...` when `CODER_STUDIO_SESSION_TOKEN` is present. +- Modify: `packages/cli/src/bin.test.ts` + Covers websocket client header propagation via existing CLI test harness and fixes memory command expectations. +- Modify: `packages/cli/src/automation-client.ts` + Uses env-derived scoped permissions for local `identify` / `capabilities` output instead of unconditional defaults. +- Modify: `packages/cli/src/cli.ts` + Removes legacy memory `tag/tags` payload fields and updates help examples to current memory taxonomy. +- Modify: `packages/cli/src/parse-args.ts` + Optionally rejects or ignores legacy memory tag usage if that is the chosen cleanup path; otherwise it can keep parsing `--tag` while transport no longer forwards it. +- Modify: `packages/server/src/commands/memory.test.ts` + Adds explicit regression tests for rejecting legacy `tag/tags`. + +## Task 1: Core Automation Scope Model + +**Files:** +- Modify: `packages/core/src/domain/automation.ts` +- Modify: `packages/core/src/domain/automation.test.ts` +- Modify: `packages/cli/src/automation-client.ts` + +- [ ] **Step 1: Write failing core automation scope tests** + +Add tests that prove: + +- `buildIdentifyResult()` reads a scoped permission env variable when present. +- `printCapabilities()` and `listAutomationCapabilities()` can produce a reduced set without `workspace.list`. +- the scoped permission set still includes `session.list`, `terminal.read`, `git.status`, `git.diff`, `memory.*`, and UI action capabilities. + +- [ ] **Step 2: Run core tests to verify failure** + +Run: + +```bash +pnpm --filter @coder-studio/core test -- automation.test.ts +``` + +Expected: FAIL because identify/capabilities still hard-code `DEFAULT_AGENT_AUTOMATION_PERMISSIONS`. + +- [ ] **Step 3: Implement scoped permission helpers** + +Add: + +- a token-safe automation permission constant, for example `SCOPED_SESSION_AUTOMATION_PERMISSIONS` +- an env parser such as `parseAutomationPermissionsEnv(value?: string): AutomationPermission[]` +- support in `buildIdentifyResult()` for an env var such as `CODER_STUDIO_AUTOMATION_PERMISSIONS` +- CLI `printCapabilities()` to read the same scoped env instead of always using defaults + +Keep `DEFAULT_AGENT_AUTOMATION_PERMISSIONS` available for broader in-product contexts if other callers still need it. + +- [ ] **Step 4: Run core tests to verify pass** + +Run: + +```bash +pnpm --filter @coder-studio/core test -- automation.test.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add packages/core/src/domain/automation.ts packages/core/src/domain/automation.test.ts packages/cli/src/automation-client.ts +git commit -m "feat(core): add scoped automation permissions" +``` + +## Task 2: Session Token Repository And Session Env Injection + +**Files:** +- Create: `packages/server/src/auth/session-token-repo.ts` +- Create or modify: `packages/server/src/__tests__/session-token-repo.test.ts` +- Modify: `packages/server/src/session/manager.ts` +- Modify: `packages/server/src/__tests__/session-integration.test.ts` +- Modify: `packages/server/src/server.ts` + +- [ ] **Step 1: Write failing token lifecycle tests** + +Add tests that prove: + +- a created session receives `CODER_STUDIO_SESSION_TOKEN` +- the same session env also receives `CODER_STUDIO_AUTOMATION_PERMISSIONS` +- tokens are revoked when the session transitions to `ended` +- hydrated sessions do not regain live automation tokens after restart + +- [ ] **Step 2: Run server session tests to verify failure** + +Run: + +```bash +pnpm --filter @coder-studio/server test -- session-integration.test.ts session-hydrate-restart.test.ts +``` + +Expected: FAIL because session creation does not issue automation tokens or permission env yet. + +- [ ] **Step 3: Implement token repo and session wiring** + +Implement a small in-memory repo: + +```ts +interface SessionAutomationTokenRecord { + token: string; + sessionId: string; + workspaceId: string; + providerId: string; + permissions: readonly AutomationPermission[]; + createdAt: number; +} +``` + +Design rules: + +- use a high-entropy token such as `randomBytes(32).toString("hex")` +- store tokens in memory only +- revoke tokens when sessions end, are deleted, or are stopped as part of workspace teardown +- do not recreate tokens in `hydrate()` because rehydrated sessions are effectively ended when no live terminal exists + +Inject into launched agent env: + +- `CODER_STUDIO_SESSION_TOKEN` +- `CODER_STUDIO_AUTOMATION_PERMISSIONS` + +- [ ] **Step 4: Run server session tests to verify pass** + +Run: + +```bash +pnpm --filter @coder-studio/server test -- session-integration.test.ts session-hydrate-restart.test.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add packages/server/src/auth/session-token-repo.ts packages/server/src/__tests__/session-token-repo.test.ts packages/server/src/session/manager.ts packages/server/src/__tests__/session-integration.test.ts packages/server/src/server.ts +git commit -m "feat(server): issue session automation tokens" +``` + +## Task 3: Loopback `/ws` Bearer Authentication + +**Files:** +- Modify: `packages/server/src/auth/plugin.ts` +- Modify: `packages/server/src/auth/index.ts` +- Modify: `packages/server/src/app.ts` +- Modify: `packages/server/src/auth/plugin.test.ts` +- Modify: `packages/server/src/server.ts` + +- [ ] **Step 1: Write failing auth tests** + +Add tests that prove: + +- `GET /ws` without cookie or bearer still fails with `401` when auth is enabled +- `GET /ws` with a valid bearer token from `127.0.0.1` is accepted +- `GET /ws` with an invalid bearer token is rejected +- non-`/ws` requests cannot use bearer token auth as a cookie substitute +- non-loopback bearer requests are rejected even if the token exists + +- [ ] **Step 2: Run auth tests to verify failure** + +Run: + +```bash +pnpm --filter @coder-studio/server test -- auth/plugin.test.ts +``` + +Expected: FAIL because the auth guard only recognizes cookies. + +- [ ] **Step 3: Implement loopback bearer auth** + +Add helpers in `plugin.ts` to: + +- read `Authorization: Bearer ...` +- verify request path is exactly `/ws` +- verify request IP is loopback (`127.0.0.1`, `::1`, or equivalent forwarded/local form already trusted by Fastify config) +- look up the token in the in-memory session token repo + +Preserve existing behavior: + +- browser cookie auth still works everywhere +- frontend navigation still redirects to `/login` +- bearer auth never mints or refreshes auth cookies + +- [ ] **Step 4: Run auth tests to verify pass** + +Run: + +```bash +pnpm --filter @coder-studio/server test -- auth/plugin.test.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add packages/server/src/auth/plugin.ts packages/server/src/auth/index.ts packages/server/src/app.ts packages/server/src/auth/plugin.test.ts packages/server/src/server.ts +git commit -m "feat(server): allow loopback ws bearer auth" +``` + +## Task 4: WebSocket Auth Metadata And Scoped Dispatch Authorization + +**Files:** +- Modify: `packages/server/src/ws/hub.ts` +- Modify: `packages/server/src/ws/dispatch.ts` +- Modify: `packages/server/src/__tests__/ws-hub.test.ts` +- Modify: `packages/server/src/__tests__/activation-commands.test.ts` +- Modify: `packages/server/src/__tests__/automation/commands.test.ts` +- Modify: `packages/server/src/commands/automation.ts` + +- [ ] **Step 1: Write failing websocket authorization tests** + +Add tests that prove: + +- token-auth websocket clients can run allowed scoped commands without holding the active browser lease +- token-auth websocket clients are denied `workspace.list` +- browser websocket clients keep current activation behavior +- `automation.capabilities` on the command path can return a scoped permission-derived command list + +- [ ] **Step 2: Run websocket/automation tests to verify failure** + +Run: + +```bash +pnpm --filter @coder-studio/server test -- activation-commands.test.ts automation/commands.test.ts ws-hub.test.ts +``` + +Expected: FAIL because websocket dispatch only knows activation allowlisting and does not distinguish token-auth clients. + +- [ ] **Step 3: Implement scoped auth metadata and dispatch rules** + +In `WsHub`: + +- derive per-client auth metadata from the authenticated request, for example: + +```ts +type WsClientAuthContext = + | { mode: "browser" } + | { + mode: "session_token"; + sessionId: string; + workspaceId: string; + providerId: string; + permissions: readonly AutomationPermission[]; + }; +``` + +- expose a `getClientAuthContext(clientId)` method through `Broadcaster` + +In `dispatch.ts`: + +- keep existing activation lease checks for browser websocket clients +- short-circuit token-auth websocket clients into a token permission check +- maintain a command-to-permission map for scoped automation commands +- return a stable authorization error such as: + +```ts +{ code: "forbidden", message: "Command not allowed for this automation session" } +``` + +For command naming, remember the server op is `memory.create` while capability names are still advertised as `memory.add`; map both correctly. + +- [ ] **Step 4: Run websocket/automation tests to verify pass** + +Run: + +```bash +pnpm --filter @coder-studio/server test -- activation-commands.test.ts automation/commands.test.ts ws-hub.test.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add packages/server/src/ws/hub.ts packages/server/src/ws/dispatch.ts packages/server/src/__tests__/ws-hub.test.ts packages/server/src/__tests__/activation-commands.test.ts packages/server/src/__tests__/automation/commands.test.ts packages/server/src/commands/automation.ts +git commit -m "feat(server): scope websocket automation commands" +``` + +## Task 5: CLI WebSocket Bearer Propagation + +**Files:** +- Modify: `packages/cli/src/automation-command-client.ts` +- Modify: `packages/cli/src/bin.test.ts` + +- [ ] **Step 1: Write failing CLI transport tests** + +Add tests in `bin.test.ts` or a focused helper test if needed that prove: + +- when `CODER_STUDIO_SESSION_TOKEN` is set, websocket automation connects with `Authorization: Bearer ` +- when no token is set, the client still connects without auth headers + +- [ ] **Step 2: Run CLI tests to verify failure** + +Run: + +```bash +pnpm --filter @spencer-kit/coder-studio test -- bin.test.ts +``` + +Expected: FAIL because websocket creation does not include headers. + +- [ ] **Step 3: Implement CLI bearer forwarding** + +Update `callCoderStudioCommand()` to read `process.env.CODER_STUDIO_SESSION_TOKEN` and pass: + +```ts +const socket = new WebSocket(wsUrl, { + headers: token ? { Authorization: `Bearer ${token}` } : undefined, +}); +``` + +Do not read saved auth password or attempt login fallback here. + +- [ ] **Step 4: Run CLI tests to verify pass** + +Run: + +```bash +pnpm --filter @spencer-kit/coder-studio test -- bin.test.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add packages/cli/src/automation-command-client.ts packages/cli/src/bin.test.ts +git commit -m "feat(cli): send session token bearer auth" +``` + +## Task 6: CLI Memory Payload Cleanup + +**Files:** +- Modify: `packages/cli/src/cli.ts` +- Modify: `packages/cli/src/parse-args.ts` +- Modify: `packages/cli/src/bin.test.ts` +- Modify: `packages/server/src/commands/memory.test.ts` +- Modify: `packages/core/src/domain/automation.test.ts` + +- [ ] **Step 1: Write failing memory regression tests** + +Add tests that prove: + +- `memory list/search/create/update` no longer send `tag` or `tags` +- help/examples/capabilities use only current memory types: `feature | todo | bugfix | project | note` +- server command tests explicitly reject legacy `tag` / `tags` payloads with a validation error + +- [ ] **Step 2: Run CLI and memory tests to verify failure** + +Run: + +```bash +pnpm --filter @spencer-kit/coder-studio test -- bin.test.ts +pnpm --filter @coder-studio/server test -- memory.test.ts +pnpm --filter @coder-studio/core test -- automation.test.ts +``` + +Expected: FAIL because the CLI still forwards legacy tag payloads and still documents obsolete examples. + +- [ ] **Step 3: Implement memory payload cleanup** + +In `cli.ts`: + +- remove `tag` from `memory.list` and `memory.search` +- remove `tags` from `memory.create` and `memory.update` +- update help examples from `decision` to a valid type such as `project` or `note` + +In `parse-args.ts`, choose one of these minimal paths and implement it consistently: + +- keep parsing `--tag` temporarily but ignore it in transport, or +- reject `--tag` with a clear error for all memory subcommands + +Prefer the second option if it does not create excessive churn, because silent ignore is easy to miss. + +- [ ] **Step 4: Run CLI and memory tests to verify pass** + +Run: + +```bash +pnpm --filter @spencer-kit/coder-studio test -- bin.test.ts +pnpm --filter @coder-studio/server test -- memory.test.ts +pnpm --filter @coder-studio/core test -- automation.test.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add packages/cli/src/cli.ts packages/cli/src/parse-args.ts packages/cli/src/bin.test.ts packages/server/src/commands/memory.test.ts packages/core/src/domain/automation.test.ts +git commit -m "fix(cli): remove legacy memory payload args" +``` + +## Task 7: End-To-End Verification + +**Files:** +- Modify as needed from previous tasks only + +- [ ] **Step 1: Run focused package verification** + +Run: + +```bash +pnpm --filter @coder-studio/core test -- automation.test.ts +pnpm --filter @coder-studio/server test -- auth/plugin.test.ts activation-commands.test.ts automation/commands.test.ts session-integration.test.ts session-hydrate-restart.test.ts memory.test.ts +pnpm --filter @spencer-kit/coder-studio test -- bin.test.ts +``` + +Expected: PASS. + +- [ ] **Step 2: Run repository verification** + +Run: + +```bash +pnpm ci:verify +``` + +Expected: PASS. + +- [ ] **Step 3: Inspect changed files before handoff** + +Run: + +```bash +git status --short +git diff -- packages/core/src/domain/automation.ts packages/server/src/auth/plugin.ts packages/server/src/session/manager.ts packages/server/src/ws/dispatch.ts packages/cli/src/automation-command-client.ts packages/cli/src/cli.ts +``` + +Expected: only the planned files are changed for this feature, aside from unrelated pre-existing user changes that must be left intact. + +- [ ] **Step 4: Commit final integration work if needed** + +```bash +git add packages/core/src/domain/automation.ts packages/core/src/domain/automation.test.ts packages/server/src/auth/session-token-repo.ts packages/server/src/auth/plugin.ts packages/server/src/auth/index.ts packages/server/src/app.ts packages/server/src/server.ts packages/server/src/session/manager.ts packages/server/src/ws/hub.ts packages/server/src/ws/dispatch.ts packages/server/src/commands/automation.ts packages/server/src/auth/plugin.test.ts packages/server/src/__tests__/activation-commands.test.ts packages/server/src/__tests__/automation/commands.test.ts packages/server/src/__tests__/ws-hub.test.ts packages/server/src/__tests__/session-integration.test.ts packages/server/src/__tests__/session-token-repo.test.ts packages/cli/src/automation-command-client.ts packages/cli/src/automation-client.ts packages/cli/src/cli.ts packages/cli/src/parse-args.ts packages/cli/src/bin.test.ts packages/server/src/commands/memory.test.ts +git commit -m "feat(server): add scoped session token automation" +``` + +- [ ] **Step 5: Handoff summary** + +Report: + +- changed files +- focused test commands and results +- `pnpm ci:verify` result +- any residual risks around loopback IP detection or future IPC migration diff --git a/docs/superpowers/plans/2026-06-15-startup-readiness-and-startup-path.md b/docs/superpowers/plans/2026-06-15-startup-readiness-and-startup-path.md new file mode 100644 index 000000000..d7841a2de --- /dev/null +++ b/docs/superpowers/plans/2026-06-15-startup-readiness-and-startup-path.md @@ -0,0 +1,363 @@ +# Startup Readiness And Startup Path Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Eliminate false startup timeout reports from the global/production `coder-studio` CLI and reduce actual time-to-ready by shortening the server's pre-listen critical path. + +**Architecture:** Keep the PM2-managed startup model unchanged. Fix the CLI first so it distinguishes "still starting" from "startup failed", then extend the wait budget to match real production startup. On the server side, move non-critical warmup work off the `await app.listen(...)` path so `runtime.json` can be written earlier without changing steady-state behavior. + +**Tech Stack:** TypeScript, Vitest, PM2-managed CLI startup, Fastify server, runtime.json readiness file. + +--- + +### Task 1: Fix The CLI Readiness Heuristic + +**Files:** +- Modify: `packages/cli/src/pm2-control.ts` +- Modify: `packages/cli/src/pm2-control.test.ts` +- Modify: `packages/cli/src/cli.ts` +- Test: `packages/cli/src/pm2-control.test.ts` + +- [ ] **Step 1: Add failing tests for slow-but-successful startup** + +In `packages/cli/src/pm2-control.test.ts`, add cases that prove the CLI should keep waiting while PM2 is still online and only fail when PM2 has clearly failed: + +```ts +it("keeps waiting while pm2 stays online and runtime.json is still pending", async () => { + start.mockImplementationOnce( + (_config: unknown, callback: (error: Error | null, apps: unknown[]) => void) => { + callback(null, [{ pm_id: 1 }]); + } + ); + + let readinessChecks = 0; + describeProcess.mockImplementation( + (_name: string, callback: (error: Error | null, result: unknown[]) => void) => { + readinessChecks += 1; + callback(null, [{ pid: 424242, pm2_env: { status: "online", restart_time: 0 } }]); + } + ); + + const pendingStart = startManagedServer({ + script: "/cli/dist/esm/server-runner.js", + cwd: "/repo", + waitMs: 50, + }); + + setTimeout(() => { + writeRuntimeConfig({ + host: "127.0.0.1", + port: 4187, + pid: 424242, + token: "test-token", + serverInstanceId: "server-1", + startedAt: Date.now(), + }); + }, 10); + + await expect(pendingStart).resolves.toBeUndefined(); + expect(readinessChecks).toBeGreaterThan(0); +}); + +it("fails startup when pm2 enters errored before runtime.json is written", async () => { + start.mockImplementationOnce( + (_config: unknown, callback: (error: Error | null, apps: unknown[]) => void) => { + callback(null, [{ pm_id: 1 }]); + } + ); + + describeProcess.mockImplementationOnce( + (_name: string, callback: (error: Error | null, result: unknown[]) => void) => + callback(null, [{ pid: 424242, pm2_env: { status: "errored", restart_time: 1 } }]) + ); + + await expect( + startManagedServer({ + script: "/cli/dist/esm/server-runner.js", + cwd: "/repo", + waitMs: 50, + }) + ).rejects.toThrow("the managed process entered the errored state"); +}); +``` + +- [ ] **Step 2: Run the CLI readiness tests and confirm the new slow-start case fails** + +Run: + +```bash +pnpm vitest packages/cli/src/pm2-control.test.ts --run +``` + +Expected: FAIL because the current readiness loop only accepts `runtime.json` and times out even while PM2 remains online. + +- [ ] **Step 3: Refactor readiness into explicit states** + +In `packages/cli/src/pm2-control.ts`, replace the open-coded polling logic with a helper that returns `ready`, `pending`, or `failed`: + +```ts +interface RuntimeReadinessState { + kind: "ready" | "pending" | "failed"; + reason?: string; +} + +async function readRuntimeReadiness(pm2: Pm2Module): Promise { + if (readRuntimeConfig()) { + return { kind: "ready" }; + } + + const processes = await describeManagedServer(pm2); + const process = processes[0]; + if (!process) { + return { + kind: "failed", + reason: "the managed process exited before runtime data was written", + }; + } + + const status = process.pm2_env?.status; + if (status === "errored" || status === "stopped") { + return { + kind: "failed", + reason: `the managed process entered the ${status} state`, + }; + } + + return { kind: "pending" }; +} +``` + +Update `waitForRuntimeReady(...)` to use this helper so: +- `ready` returns immediately +- `failed` throws the existing startup error with log excerpts +- `pending` continues polling until the deadline + +- [ ] **Step 4: Increase the managed-start wait window** + +In `packages/cli/src/cli.ts`, change: + +```ts +const MANAGED_SERVER_WAIT_MS = 15000; +``` + +This keeps the change centralized for both `coder-studio serve` and `coder-studio open`. + +- [ ] **Step 5: Re-run the CLI readiness tests** + +Run: + +```bash +pnpm vitest packages/cli/src/pm2-control.test.ts --run +``` + +Expected: PASS, including the slow-start success case and errored-state failure case. + +- [ ] **Step 6: Commit the CLI readiness fix** + +```bash +git add packages/cli/src/cli.ts packages/cli/src/pm2-control.ts packages/cli/src/pm2-control.test.ts +git commit -m "fix(cli): tolerate slow managed startup" +``` + +### Task 2: Move Non-Critical Warmup Off The Pre-Listen Path + +**Files:** +- Modify: `packages/server/src/server.ts` +- Modify: `packages/server/src/__tests__/server-runtime-config.test.ts` +- Test: `packages/server/src/__tests__/server-runtime-config.test.ts` + +- [ ] **Step 1: Add a failing runtime-config timing test** + +In `packages/server/src/__tests__/server-runtime-config.test.ts`, add a timing-oriented assertion that proves startup writes `runtime.json` promptly enough for the CLI contract. Keep it black-box and avoid adding test-only hooks to `createServer`. + +Use a simple upper bound around server creation: + +```ts +it("writes runtime config during startup", async () => { + const startedAt = Date.now(); + + server = await createRuntimeServer({ + stateDir: join(testHomeDir, "server-state"), + host: "127.0.0.1", + port: 0, + writeRuntimeConfig: true, + }); + + const runtime = readRuntimeConfig(); + expect(runtime).toEqual( + expect.objectContaining({ + host: "127.0.0.1", + pid: process.pid, + }) + ); + expect(runtime?.startedAt).toBeGreaterThanOrEqual(startedAt); +}); +``` + +This test is intentionally narrow: it guards the readiness side effect without introducing unsupported `__testOverrides`. + +- [ ] **Step 2: Run the server runtime-config test** + +Run: + +```bash +pnpm vitest packages/server/src/__tests__/server-runtime-config.test.ts --run +``` + +Expected: PASS before refactor. This is a characterization test that protects the existing contract while the startup order changes. + +- [ ] **Step 3: Defer the clearly non-critical warmup calls** + +In `packages/server/src/server.ts`, move these calls so they no longer block `await app.listen(...)` and `writeRuntimeConfig(...)`: + +```ts +workAnalysisService.startAutoScan(); +workspaceMgr.hydrateWatchers(); +await agentInstructionPublisher.syncAllOpenWorkspaces(); +updateService.start(); +monitoringService.start(); +``` + +Refactor them into a post-listen function with failure isolation: + +```ts +const runPostListenWarmup = async () => { + workAnalysisService.startAutoScan(); + workspaceMgr.hydrateWatchers(); + await agentInstructionPublisher.syncAllOpenWorkspaces(); + updateService.start(); + monitoringService.start(); +}; +``` + +Invoke it only after: + +```ts +await app.listen({ host: config.host, port: config.port }); +writeRuntimeConfig(runtime); +void runPostListenWarmup().catch((error) => { + app.log.warn({ err: error }, "post-listen warmup failed"); +}); +``` + +Keep these on the pre-listen path: +- `buildFastifyApp(...)` +- `await sessionMgr.hydrate()` +- supervisor wiring needed for normal runtime construction +- `await app.listen(...)` +- `writeRuntimeConfig(...)` + +- [ ] **Step 4: Re-run the server runtime-config test** + +Run: + +```bash +pnpm vitest packages/server/src/__tests__/server-runtime-config.test.ts --run +``` + +Expected: PASS. + +- [ ] **Step 5: Commit the startup-path reduction** + +```bash +git add packages/server/src/server.ts packages/server/src/__tests__/server-runtime-config.test.ts +git commit -m "perf(server): defer non-critical startup warmup" +``` + +### Task 3: Add Startup Phase Timing Logs + +**Files:** +- Modify: `packages/server/src/server.ts` +- Test: `packages/server/src/__tests__/server-runtime-config.test.ts` + +- [ ] **Step 1: Add minimal startup timing instrumentation** + +In `packages/server/src/server.ts`, add small, flat timing logs for the highest-value phases: + +```ts +const startupAt = Date.now(); + +function logStartupPhase(app: FastifyInstance | null, label: string, startedAt: number) { + const elapsedMs = Date.now() - startedAt; + if (app) { + app.log.info({ elapsedMs }, `[startup] ${label}`); + return; + } + + console.log(`[startup] ${label}=${elapsedMs}ms`); +} +``` + +Log at least: +- `builtinSkillSync` +- `buildFastifyApp` +- `sessionHydrate` +- `listen` +- `runtimeConfigWritten` +- `postListenWarmupScheduled` + +Keep the output single-line and grep-friendly so future production log checks can identify where startup time is spent. + +- [ ] **Step 2: Add or extend a test only if the repo already has a stable log assertion pattern** + +If there is no stable existing pattern for asserting server startup logs, skip a dedicated log assertion test and rely on the targeted runtime-config test plus manual diff review. Do not add brittle logger mocks just to satisfy instrumentation. + +- [ ] **Step 3: Run the targeted tests** + +Run: + +```bash +pnpm vitest packages/cli/src/pm2-control.test.ts packages/server/src/__tests__/server-runtime-config.test.ts --run +``` + +Expected: PASS. + +- [ ] **Step 4: Commit the timing diagnostics** + +```bash +git add packages/server/src/server.ts packages/server/src/__tests__/server-runtime-config.test.ts +git commit -m "chore(server): log startup phase timings" +``` + +### Task 4: Repository Verification + +**Files:** +- Test: `packages/cli/src/pm2-control.test.ts` +- Test: `packages/server/src/__tests__/server-runtime-config.test.ts` + +- [ ] **Step 1: Run targeted startup tests** + +Run: + +```bash +pnpm vitest packages/cli/src/pm2-control.test.ts packages/server/src/__tests__/server-runtime-config.test.ts --run +``` + +Expected: PASS. + +- [ ] **Step 2: Run repository verification** + +Run: + +```bash +pnpm ci:verify +``` + +Expected: PASS. + +- [ ] **Step 3: Review the scoped diff** + +Run: + +```bash +git diff -- packages/cli/src/cli.ts packages/cli/src/pm2-control.ts packages/cli/src/pm2-control.test.ts packages/server/src/server.ts packages/server/src/__tests__/server-runtime-config.test.ts docs/superpowers/plans/2026-06-15-startup-readiness-and-startup-path.md +``` + +Expected: Only readiness detection, wait budget, deferred warmup, startup timing, and the saved plan should appear. + +- [ ] **Step 4: Commit the verification pass** + +```bash +git add packages/cli/src/cli.ts packages/cli/src/pm2-control.ts packages/cli/src/pm2-control.test.ts packages/server/src/server.ts packages/server/src/__tests__/server-runtime-config.test.ts docs/superpowers/plans/2026-06-15-startup-readiness-and-startup-path.md +git commit -m "test: verify startup readiness changes" +``` diff --git a/docs/superpowers/plans/2026-06-16-dev-browser-query-sw-https-implementation.md b/docs/superpowers/plans/2026-06-16-dev-browser-query-sw-https-implementation.md new file mode 100644 index 000000000..48d6d4ebe --- /dev/null +++ b/docs/superpowers/plans/2026-06-16-dev-browser-query-sw-https-implementation.md @@ -0,0 +1,833 @@ +# Dev Browser Query + SW + HTTPS Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Rebuild the built-in browser so loopback previews keep the real visible pathname, root-scope service-worker interception only applies to bound loopback preview traffic, and non-loopback IP access to Coder Studio automatically uses HTTPS with generated local certificates. + +**Architecture:** Keep loopback preview fetching behind the hidden `/dev-browser/session/:id/proxy` route, but change the browser-visible URL to a query-token form such as `/app/?__cs_sid=dev_1`. The first visible-path request is proxied server-side and receives a lightweight bootstrap that registers a root-scope service worker, binds the iframe client to the session, and removes the hidden query token with `history.replaceState`. The legacy `/dev-browser/session/:id/` shell remains as the fallback entry for insecure or service-worker-ineligible contexts. Auto-HTTPS applies only to the Coder Studio transport layer; preview targets remain loopback `http` in v1. + +**Tech Stack:** TypeScript, Fastify, React, Jotai, Vite, Vitest, Service Worker, `node-forge` + +--- + +### Task 1: Replace The Session URL Model With Visible Query URLs + +**Files:** +- Create: `packages/server/src/dev-browser/browser-url.ts` +- Create: `packages/server/src/dev-browser/browser-url.test.ts` +- Modify: `packages/server/src/routes/dev-browser.ts` +- Modify: `packages/server/src/routes/dev-browser.test.ts` +- Test: `packages/server/src/dev-browser/browser-url.test.ts` +- Test: `packages/server/src/routes/dev-browser.test.ts` + +- [ ] **Step 1: Write the failing URL helper test** + +```ts +import { describe, expect, it } from "vitest"; +import { DEV_BROWSER_SESSION_QUERY_PARAM, buildDevBrowserVisibleUrl } from "./browser-url.js"; + +describe("buildDevBrowserVisibleUrl", () => { + it("preserves the target path and folds the session id into the query string", () => { + expect( + buildDevBrowserVisibleUrl({ + sessionId: "dev_1", + targetPath: "/app/?draft=1", + targetHash: "#top", + }) + ).toBe(`/app/?draft=1&${DEV_BROWSER_SESSION_QUERY_PARAM}=dev_1#top`); + }); +}); +``` + +- [ ] **Step 2: Run the helper test to verify it fails** + +Run: `pnpm --filter @coder-studio/server exec vitest run src/dev-browser/browser-url.test.ts` +Expected: FAIL because `browser-url.ts` does not exist yet. + +- [ ] **Step 3: Write the failing route test for the new session response shape** + +```ts +it("creates visible browser urls and retains the legacy fallback entry", async () => { + const created = await createSession("/app/?draft=1#top"); + + expect(created.browserUrl).toBe(`/app/?draft=1&__cs_sid=${created.id}#top`); + expect(created.fallbackBrowserUrl).toBe(`/dev-browser/session/${created.id}/`); + expect(created.browserProxyBase).toBe(`/dev-browser/session/${created.id}/proxy`); +}); +``` + +- [ ] **Step 4: Run the route test to verify it fails** + +Run: `pnpm --filter @coder-studio/server exec vitest run src/routes/dev-browser.test.ts -t "creates visible browser urls and retains the legacy fallback entry"` +Expected: FAIL because `browserUrl` still points at `/dev-browser/session/:id/` and `fallbackBrowserUrl` is missing. + +- [ ] **Step 5: Implement the visible URL helper and session serialization** + +```ts +export const DEV_BROWSER_SESSION_QUERY_PARAM = "__cs_sid"; + +export function buildDevBrowserVisibleUrl(input: { + sessionId: string; + targetPath: string; + targetHash: string; +}): string { + const parsed = new URL(input.targetPath || "/", "http://coder-studio.local"); + parsed.searchParams.set(DEV_BROWSER_SESSION_QUERY_PARAM, input.sessionId); + return `${parsed.pathname}${parsed.search}${input.targetHash}`; +} + +function fallbackBrowserUrl(id: string): string { + return `/dev-browser/session/${id}/`; +} + +function serializeSession(session: DevBrowserSession) { + return { + id: session.id, + browserUrl: buildDevBrowserVisibleUrl({ + sessionId: session.id, + targetPath: session.targetPath, + targetHash: session.targetHash, + }), + fallbackBrowserUrl: fallbackBrowserUrl(session.id), + browserProxyBase: browserProxyBase(session.id), + displayUrl: session.displayUrl, + targetOrigin: session.targetOrigin, + targetPath: session.targetPath, + targetHash: session.targetHash, + expiresAt: session.expiresAt, + ...(session.preserveStudioPlatformPaths ? { preserveStudioPlatformPaths: true } : {}), + }; +} +``` + +- [ ] **Step 6: Run the focused server tests to verify they pass** + +Run: `pnpm --filter @coder-studio/server exec vitest run src/dev-browser/browser-url.test.ts src/routes/dev-browser.test.ts` +Expected: PASS + +- [ ] **Step 7: Commit** + +```bash +git add packages/server/src/dev-browser/browser-url.ts \ + packages/server/src/dev-browser/browser-url.test.ts \ + packages/server/src/routes/dev-browser.ts \ + packages/server/src/routes/dev-browser.test.ts +git commit -m "feat: add visible dev browser session urls" +``` + +### Task 2: Add Visible-Path Proxying And Root-Scope Service Worker Routing + +**Files:** +- Modify: `packages/server/src/dev-browser/proxy-headers.ts` +- Modify: `packages/server/src/dev-browser/proxy-headers.test.ts` +- Modify: `packages/server/src/routes/dev-browser.ts` +- Modify: `packages/server/src/routes/dev-browser.test.ts` +- Modify: `packages/web/public/dev-browser-sw.js` +- Modify: `packages/web/src/features/dev-browser/dev-browser-sw.test.ts` +- Test: `packages/server/src/dev-browser/proxy-headers.test.ts` +- Test: `packages/server/src/routes/dev-browser.test.ts` +- Test: `packages/web/src/features/dev-browser/dev-browser-sw.test.ts` + +- [ ] **Step 1: Write the failing route test for the first visible-path request** + +```ts +it("serves visible-path bootstrap html for the first query-based request", async () => { + const created = await createSession("/app/?draft=1#top"); + const response = await app.inject({ + method: "GET", + url: created.browserUrl, + }); + + expect(response.statusCode).toBe(200); + expect(response.body).toContain(''); + expect(response.body).toContain('navigator.serviceWorker.register("/dev-browser-sw.js", { scope: "/" })'); + expect(response.body).toContain('searchParams.delete("__cs_sid")'); + expect(response.body).not.toContain(`${created.browserProxyBase}/assets/app.js`); +}); +``` + +- [ ] **Step 2: Run the route test to verify it fails** + +Run: `pnpm --filter @coder-studio/server exec vitest run src/routes/dev-browser.test.ts -t "serves visible-path bootstrap html for the first query-based request"` +Expected: FAIL because `/app/?__cs_sid=...` currently falls through to the wrong route model. + +- [ ] **Step 3: Write the failing redirect and service worker mapper tests** + +```ts +it("rewrites loopback redirects back into visible paths for visible-mode proxy responses", () => { + expect( + rewriteVisibleLocationHeader("http://localhost:8000/dashboard?tab=1", { + port: 8000, + targetOrigin: "http://127.0.0.1:8000", + }) + ).toBe("/dashboard?tab=1"); +}); + +it("maps controlled visible-path requests to the hidden proxy base", () => { + const harness = loadHarness(); + expect( + harness.mapRequest({ + requestUrl: "https://studio.example/assets/app.js", + clientSessionId: "dev_1", + sessions: { + dev_1: { + id: "dev_1", + browserProxyBase: "/dev-browser/session/dev_1/proxy", + targetOrigin: "http://127.0.0.1:8000", + targetPath: "/app/", + }, + }, + }) + ).toEqual({ + mode: "visible", + url: "https://studio.example/dev-browser/session/dev_1/proxy/assets/app.js", + }); +}); + +it("passes non-loopback cross-origin requests through unchanged", () => { + const harness = loadHarness(); + expect( + harness.mapRequest({ + requestUrl: "https://example.com/app.js", + clientSessionId: "dev_1", + sessions: { + dev_1: { + id: "dev_1", + browserProxyBase: "/dev-browser/session/dev_1/proxy", + targetOrigin: "http://127.0.0.1:8000", + targetPath: "/app/", + }, + }, + }) + ).toBeNull(); +}); +``` + +- [ ] **Step 4: Run the mapper and redirect tests to verify they fail** + +Run: `pnpm --filter @coder-studio/server exec vitest run src/dev-browser/proxy-headers.test.ts src/routes/dev-browser.test.ts && pnpm --filter @coder-studio/web exec vitest run src/features/dev-browser/dev-browser-sw.test.ts` +Expected: FAIL because there is no visible-path redirect helper, the service worker still assumes `/dev-browser/` scope, and query-based bootstrap requests are not handled. + +- [ ] **Step 5: Implement visible-mode proxy handling and root-scope service worker routing** + +```ts +export function rewriteVisibleLocationHeader( + location: string, + input: { browserProxyBase?: string; port: number; targetOrigin: string } +): string { + if (location.startsWith("/") && !location.startsWith("//")) { + return location; + } + + let parsed: URL; + try { + parsed = new URL(location, input.targetOrigin); + } catch { + return location; + } + + const proxied = toProxiedLoopbackUrl(parsed, input); + if (!proxied) { + return location; + } + + const proxiedUrl = new URL(`http://coder-studio.local${proxied}`); + return `${proxiedUrl.pathname}${proxiedUrl.search}${proxiedUrl.hash}`; +} + +app.addHook("preHandler", async (request, reply) => { + if (request.method !== "GET" && request.method !== "HEAD") { + return; + } + + const incomingUrl = new URL(request.url, "http://coder-studio.local"); + const sessionId = incomingUrl.searchParams.get(DEV_BROWSER_SESSION_QUERY_PARAM); + if (!sessionId) { + return; + } + + const session = sessions.get(sessionId); + if (!session) { + reply.status(410).send({ error: "dev_browser_session_missing" }); + return reply; + } + + await proxyRequest(request, reply, session, { + mode: "visible", + requestUrl: incomingUrl, + stripSessionQuery: true, + }); + return reply; +}); +``` + +```js +const DEV_BROWSER_PROXY_MODE_HEADER = "x-coder-studio-dev-browser-mode"; + +function mapRequestToProxy(input) { + const requestUrl = new URL(input.requestUrl); + const session = getSessionForRequest(input); + if (!session) { + return null; + } + + if (requestUrl.pathname.startsWith(session.browserProxyBase)) { + return null; + } + + if (requestUrl.origin === self.location.origin) { + if (requestUrl.pathname.startsWith("/dev-browser/")) { + return null; + } + if (shouldBypassStudioPath(requestUrl, session)) { + return null; + } + return { + mode: "visible", + url: `${self.location.origin}${session.browserProxyBase}${requestUrl.pathname}${requestUrl.search}`, + }; + } + + if (!isLoopbackUrlForSession(requestUrl, session)) { + return null; + } + + return { + mode: "visible", + url: `${self.location.origin}${session.browserProxyBase}${requestUrl.pathname}${requestUrl.search}`, + }; +} + +self.addEventListener("fetch", (event) => { + const mapped = mapRequestToProxy({ + requestUrl: event.request.url, + referrer: event.request.referrer, + clientSessionId: event.clientId ? clientSessions.get(event.clientId) : undefined, + sessions: Object.fromEntries(sessions.entries()), + }); + if (!mapped) { + return; + } + + if (event.request.mode === "navigate" && event.resultingClientId && event.clientId) { + const sessionId = clientSessions.get(event.clientId); + if (sessionId) { + clientSessions.set(event.resultingClientId, sessionId); + } + } + + const headers = new Headers(event.request.headers); + headers.set(DEV_BROWSER_PROXY_MODE_HEADER, mapped.mode); + const proxiedRequest = new Request(mapped.url, { + method: event.request.method, + headers, + body: event.request.body, + mode: event.request.mode, + credentials: event.request.credentials, + cache: event.request.cache, + redirect: event.request.redirect, + referrer: event.request.referrer, + referrerPolicy: event.request.referrerPolicy, + integrity: event.request.integrity, + keepalive: event.request.keepalive, + signal: event.request.signal, + }); + event.respondWith(fetch(proxiedRequest)); +}); +``` + +- [ ] **Step 6: Run the focused tests to verify they pass** + +Run: `pnpm --filter @coder-studio/server exec vitest run src/dev-browser/proxy-headers.test.ts src/routes/dev-browser.test.ts && pnpm --filter @coder-studio/web exec vitest run src/features/dev-browser/dev-browser-sw.test.ts` +Expected: PASS + +- [ ] **Step 7: Commit** + +```bash +git add packages/server/src/dev-browser/proxy-headers.ts \ + packages/server/src/dev-browser/proxy-headers.test.ts \ + packages/server/src/routes/dev-browser.ts \ + packages/server/src/routes/dev-browser.test.ts \ + packages/web/public/dev-browser-sw.js \ + packages/web/src/features/dev-browser/dev-browser-sw.test.ts +git commit -m "feat: add visible-path dev browser routing" +``` + +### Task 3: Wire The Web Client And Vite Dev Server To The New URL Model + +**Files:** +- Modify: `packages/web/src/features/dev-browser/api.ts` +- Modify: `packages/web/src/features/dev-browser/api.test.ts` +- Modify: `packages/web/src/features/dev-browser/dev-browser-surface.tsx` +- Modify: `packages/web/src/features/dev-browser/dev-browser-surface.test.tsx` +- Modify: `packages/web/vite.config.ts` +- Modify: `packages/web/src/features/dev-browser/dev-browser-vite-proxy.test.ts` +- Test: `packages/web/src/features/dev-browser/api.test.ts` +- Test: `packages/web/src/features/dev-browser/dev-browser-surface.test.tsx` +- Test: `packages/web/src/features/dev-browser/dev-browser-vite-proxy.test.ts` + +- [ ] **Step 1: Write the failing API and iframe-source tests** + +```ts +it("parses the visible browserUrl and fallbackBrowserUrl from create-session responses", async () => { + const fetchMock = vi.fn(async () => + new Response( + JSON.stringify({ + id: "dev_1", + browserUrl: "/app/?__cs_sid=dev_1", + fallbackBrowserUrl: "/dev-browser/session/dev_1/", + browserProxyBase: "/dev-browser/session/dev_1/proxy", + targetOrigin: "http://127.0.0.1:8000", + }), + { status: 200, headers: { "content-type": "application/json" } } + ) + ); + vi.stubGlobal("fetch", fetchMock); + + const created = await createDevBrowserSession("localhost:8000"); + + expect(created.browserUrl).toBe("/app/?__cs_sid=dev_1"); + expect(created.fallbackBrowserUrl).toBe("/dev-browser/session/dev_1/"); +}); +``` + +```ts +it("uses the visible browser url when service workers are supported", async () => { + enableServiceWorkerSupport(); + const activeTab = browserTab("browser-1", null); + const store = createWorkspaceStore([activeTab], activeTab); + + render(, { + wrapper: wrapperFor(store), + }); + + await submitLocalUrl(userEvent.setup(), "localhost:8000"); + + expect(await screen.findByTitle("Browser")).toHaveAttribute("src", "/app/?__cs_sid=dev_1"); +}); +``` + +- [ ] **Step 2: Run the focused tests to verify they fail** + +Run: `pnpm --filter @coder-studio/web exec vitest run src/features/dev-browser/api.test.ts src/features/dev-browser/dev-browser-surface.test.tsx` +Expected: FAIL because the API type has no `fallbackBrowserUrl` and the surface still expects the old `/dev-browser/session/:id/` source. + +- [ ] **Step 3: Write the failing fallback and Vite bootstrap proxy tests** + +```ts +it("falls back to the legacy browser entry when service workers are unavailable", async () => { + Object.defineProperty(window, "isSecureContext", { configurable: true, value: false }); + Object.defineProperty(window.navigator, "serviceWorker", { + configurable: true, + value: undefined, + }); + const activeTab = browserTab("browser-1", null); + const store = createWorkspaceStore([activeTab], activeTab); + + render(, { + wrapper: wrapperFor(store), + }); + + await submitLocalUrl(userEvent.setup(), "localhost:8000"); + + expect(await screen.findByTitle("Browser")).toHaveAttribute("src", "/dev-browser/session/dev_1/"); +}); +``` + +```ts +it("proxies visible-path bootstrap requests with __cs_sid to the backend", async () => { + const response = await fetch(`http://127.0.0.1:${vitePort}/app/?__cs_sid=test`); + + expect(response.status).toBe(200); + expect(await response.text()).toContain("proxied dev browser session"); +}); +``` + +- [ ] **Step 4: Run the fallback and Vite tests to verify they fail** + +Run: `pnpm --filter @coder-studio/web exec vitest run src/features/dev-browser/dev-browser-surface.test.tsx src/features/dev-browser/dev-browser-vite-proxy.test.ts` +Expected: FAIL because the surface has no explicit visible/fallback split and Vite only proxies `/dev-browser/session`. + +- [ ] **Step 5: Implement the API shape, iframe source selection, and Vite query bootstrap proxy** + +```ts +export interface DevBrowserSessionResponse { + browserProxyBase: string; + browserUrl: string; + fallbackBrowserUrl: string; + displayUrl?: string; + expiresAt?: number; + id: string; + targetOrigin: string; +} +``` + +```tsx +const frameSrc = + serviceWorkerSupported || !session?.fallbackBrowserUrl + ? session?.browserUrl ?? null + : session.fallbackBrowserUrl; + +