From c2123649a97a76817c5b055e519f970f34db2a3b Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Thu, 24 Sep 2026 22:17:51 -0300 Subject: [PATCH 01/17] docs(specs): Specify stable web console access --- .specs/features/web-access/spec.md | 181 +++++++++++++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 .specs/features/web-access/spec.md diff --git a/.specs/features/web-access/spec.md b/.specs/features/web-access/spec.md new file mode 100644 index 0000000..a38907d --- /dev/null +++ b/.specs/features/web-access/spec.md @@ -0,0 +1,181 @@ +# Web access specification + +## Problem Statement + +The web console cannot be bookmarked. Opening `http://localhost:3100/setup` directly answers 403 `open this page with codedeck ui` (observed 2026-09-25, screenshot from the user). Three things cause it: the token is random on every web child start, the cookie that carries it dies with the browser session, and the child only exists after a CLI command asks the daemon for it. On top of that, port 3100 sits in the range dev servers use. The user wants a fixed address they can always open while the daemon runs. + +## Goals + +- [ ] After running any web command once on a machine, `http://127.0.0.1:7777/` and `http://localhost:7777/` open the page from a bookmark, across browser restarts, web child restarts, and daemon restarts. +- [ ] While the daemon runs, the console answers on its port without a prior web command. +- [ ] The port is configurable in `config.json`, and a web command says so when the console runs on a port other than the one configured. +- [ ] Every protection that exists today (Host check, Origin check on POST, cookie on `/api/*`) keeps holding. + +## Current state (verified) + +- `createWebSecurity(port)` draws a fresh 32-byte hex token per server (src/web/security.ts:18-25). The web child reports it in its handshake (src/web/child.ts:63). +- A valid `?t=` on a page GET sets `codedeck_ui_token_=; Path=/; HttpOnly; SameSite=Strict` with no `Max-Age` and answers 303 without `t` (src/web/security.ts:113-122). A page GET with no valid cookie answers 403 `open this page with codedeck ui` (src/web/security.ts:16, 64-67). +- `isAllowedWebHost` accepts `127.0.0.1:` and `localhost:` (src/web/security.ts:27-31). The URL the CLI opens uses `127.0.0.1` (src/daemon/web-supervisor.ts:201). +- `DEFAULT_WEB_PORT` is 3100 (src/web/server.ts:8). The child falls back to an ephemeral port only when no port was given (src/web/child.ts:37). The four web commands print `(default: 3100)` in `--port` help (src/cli/commands/setup.ts:1304, review.ts:96, usage.ts:154, ui.ts:58). +- The daemon creates the `WebSupervisor` lazily on the first `web.ensure` (src/daemon/daemon.ts:1377). `start()` never touches the web child (src/daemon/daemon.ts:328-375), and the `--daemon` entry only calls `start()` (src/daemon/daemon.ts:2003-2009). +- `launchWebPage` prints `CodeDeck web is already running on port ` only when `--port` was given and differs (src/cli/web-launch.ts:61-63). The in-process fallback builds its own server and token through `startWebServer` (src/cli/web-launch.ts:83). +- `loadConfig` spreads the parsed file over the defaults without validating unknown keys (src/config/config.ts:432-442). Setup saves keep keys they do not manage (src/config/setup.ts:340-343). +- No vitest setup sets `RUN_AGENT_DIR` globally (vitest.config.ts), so any code that writes under `getPaths().base` by default must be kept out of unit tests. + +## External Dependencies + +| Resource | Identifier | System | Verified | Evidence | +| --- | --- | --- | --- | --- | +| cookies are not isolated by port | RFC 6265 §8.5 | IETF RFC 6265 | yes | fetched rfc-editor.org/rfc/rfc6265.html 2026-09-25: "Cookies do not provide isolation by port." | +| a cookie set for `127.0.0.1` is not sent to `localhost` | RFC 6265 §5.1.3 | IETF RFC 6265 | yes | fetched rfc-editor.org/rfc/rfc6265.html 2026-09-25: domain matching requires identical strings or a host-name suffix, and an IP address never suffix-matches | +| `Max-Age` is the cookie lifetime in seconds | RFC 6265 §4.1.2.2 | IETF RFC 6265 | yes | fetched rfc-editor.org/rfc/rfc6265.html 2026-09-25 | + +## Out of Scope + +| Feature | Reason | +| --- | --- | +| Starting the daemon at login (systemd user unit) | "Always" here means while the daemon runs. After a reboot the first `codedeck` command starts the daemon, and with it the console. A login unit is a separate install concern. | +| Respawning the web child after a crash without a request | A crash is logged; the next web command respawns it (existing behavior). Crash-loop handling would need backoff rules this feature does not need. | +| Removing the token | The token is what keeps other local users and non-browser processes off the setup API, which rewrites the harness config. | +| A command to rotate the token | Deleting `~/.run-agent/web-token` rotates it on the next web child start; documented in `docs/protocol.md`. | +| Remote or LAN access | The server stays on 127.0.0.1. | +| New pages or page visuals | This feature changes access only. | + +--- + +## Assumptions & Open Questions + +| Assumption / decision | Chosen default | Rationale | Confirmed? | +| --- | --- | --- | --- | +| Default port | 7777 replaces 3100 as `DEFAULT_WEB_PORT`. | The user asked for 7777; 3000-3999 collides with common dev servers. | y | +| Port config key | `web.port` in `config.json`, an integer 1-65535. Precedence: `--port`, then `web.port`, then 7777. | Matches the existing per-feature objects in the config (`autocompact`, `orchestrator`). | n | +| Configured port busy | Same as the default today: the child falls back to an OS-assigned port. The CLI notices the mismatch (WA-12). | A foreign process on 7777 must not break the console; the notice tells the user the bookmark will not work this time. | n | +| Explicit `--port` busy | Unchanged: report the listen error and exit 1. | An explicit port is a request, not a hint. | y | +| Invalid `web.port` (not an integer in 1-65535) | Treated as absent: 7777 is used and nothing fails. | Matches how `loadConfig` tolerates a broken file (src/config/config.ts:439-441); `doctor` reporting is not part of this feature. | n | +| Token storage | `~/.run-agent/web-token` (`getPaths().base`), 64 lowercase hex characters plus an optional trailing newline, created with mode 0600. | Same directory as the daemon's other state; 0600 keeps other users out. | n | +| Token file missing or malformed | Generate a new token and write it. Creation uses exclusive create, and a process that loses the race reads the winner's token. | The web child and an in-process fallback can start at the same moment; both must end up with the same token. | n | +| Who uses the stored token | The web child and the CLI in-process fallback. Unit tests keep a per-server random token unless they inject one. | Both servers must accept the same cookie, or the fallback would overwrite it (same cookie name, same host, see RFC 6265 §8.5). | n | +| Cookie lifetime | `Max-Age=31536000` (365 days), renewed on every page GET that already carries a valid cookie. | A bookmark keeps working for a year after the last visit; 365 days is below the caps browsers put on `Max-Age`. | n | +| Canonical host | `127.0.0.1`. A page GET whose Host is `localhost:` answers 302 to the same path and query on `127.0.0.1:`. | RFC 6265 §5.1.3: a cookie set for `127.0.0.1` is never sent to `localhost`, so without the redirect a `localhost` bookmark would get 403 forever. | n | +| When the daemon starts the child | Once, right after the daemon's IPC socket listens, from its own `dist/web/child.js`, with no explicit port. | Makes the address answer without a prior command. The existing entry/build rules still replace the child when a CLI from another tree asks. | n | +| Eager start failure | Logged to `daemon.log` as `web autostart failed: `; the daemon keeps running and `web.ensure` retries later. | The web console must never keep the daemon from starting. | n | +| 403 page text | `Run "codedeck ui" once in a terminal to open CodeDeck in this browser.` | The old text did not say the step is one-time. | n | + +**Open questions:** none - all resolved or logged above. + +--- + +## User Stories + +### P1: Bookmark survives restarts ⭐ MVP + +**User Story**: As a developer, I want a console URL I can bookmark so that I open setup, usage, or review without going through the terminal each time. + +**Why P1**: This is the reported pain. + +**Acceptance Criteria**: + +1. WHEN the web child starts and `~/.run-agent/web-token` holds a valid token THEN the child SHALL serve with that token and report it in its handshake. +2. IF `~/.run-agent/web-token` is missing or malformed THEN the web child SHALL write a new 64-character lowercase hex token to it with mode 0600 and serve with that token. +3. WHEN two processes create the token file at the same time THEN both SHALL end up serving with the token that is in the file. +4. WHEN the CLI serves in-process because the daemon cannot host the console THEN it SHALL use the token from `~/.run-agent/web-token` under the same rules as the web child. +5. WHEN a page GET carries a valid `?t=` THEN the server SHALL answer 303 with `Set-Cookie: codedeck_ui_token_=; Path=/; Max-Age=31536000; HttpOnly; SameSite=Strict`. +6. WHEN a page GET carries a valid cookie and no `t` THEN the server SHALL serve the page with the same `Set-Cookie` header as WA-05. +7. WHEN a page GET arrives with Host `localhost:` THEN the server SHALL answer 302 with `Location: http://127.0.0.1:` and SHALL NOT check the token or cookie first. +8. IF a page GET on `127.0.0.1:` has no valid `t` and no valid cookie THEN the server SHALL answer 403 with the body `Run "codedeck ui" once in a terminal to open CodeDeck in this browser.` +9. The server SHALL keep rejecting requests with any Host other than `127.0.0.1:` or `localhost:`, POSTs without a valid cookie and same-origin `Origin`, and `/api/*` requests without a valid cookie, each with 403 `forbidden`. + +**Independent Test**: open `codedeck ui` once, close the browser, kill the web child, run `codedeck setup --no-open`, then open `http://localhost:7777/setup` from a fresh browser window: the page loads without a token. + +--- + +### P1: Console answers while the daemon runs ⭐ MVP + +**User Story**: As a developer, I want the console to be up whenever the daemon is so that the bookmark works without first running a web command. + +**Why P1**: A stable token does not help if nothing listens on the port. + +**Acceptance Criteria**: + +1. WHEN the daemon's IPC socket starts listening THEN the daemon SHALL start the web child once, from its own `dist/web/child.js`, without an explicit port. +2. IF the eager start fails THEN the daemon SHALL append `web autostart failed: ` to `daemon.log`, keep serving IPC, and start the child on the next `web.ensure`. + +**Independent Test**: stop the daemon, run `codedeck ps` (which starts the daemon), then `curl -sI http://127.0.0.1:7777/` answers 403 instead of refusing the connection. + +--- + +### P2: Fixed, configurable port + +**User Story**: As a developer, I want the console on a port I choose so that the bookmark does not collide with my dev servers. + +**Why P2**: 7777 alone serves most users; the config key covers collisions. + +**Acceptance Criteria**: + +1. The web server SHALL use port 7777 when neither `--port` nor a valid `web.port` is given. +2. WHERE `config.json` holds a valid `web.port` the web child SHALL listen on that port when no `--port` is given. +3. IF the configured or default port is busy THEN the web child SHALL listen on an OS-assigned port. +4. WHEN a web command gets a console URL whose port differs from the preferred port (`--port`, else `web.port`, else 7777) THEN it SHALL print `CodeDeck web is running on port instead of ` before the page line. +5. IF `web.port` is not an integer in 1-65535 THEN the web child and the CLI SHALL treat it as absent. +6. The `--port` help of `review`, `setup`, `usage`, and `ui` SHALL read `port to listen on (default: 7777, or web.port from config)`. + +**Independent Test**: set `"web": { "port": 7788 }`, restart the daemon, and `curl -sI http://127.0.0.1:7788/` answers 403; occupy 7788 with `nc -l 7788`, restart the daemon, run `codedeck ui --no-open`, and the notice names the fallback port. + +--- + +## Edge Cases + +- IF the token file exists with other content (a truncated write, a hand edit) THEN the child SHALL replace it per WA-02. +- WHEN the web child restarts on the same port (build change, crash plus a new command) THEN a browser with the cookie SHALL load pages without a new `?t=` (follows from WA-01 and WA-06). +- WHEN the daemon already serves on the preferred port and a web command passes no `--port` THEN the command SHALL print no port notice. +- IF a `localhost` page GET carries `?t=` THEN the 302 SHALL keep `t` in the query so the `127.0.0.1` request can set the cookie (follows from WA-07). + +--- + +## Implicit-requirement sweep + +| Dimension | Resolution | +| --- | --- | +| Input validation & bounds | WA-16 (`web.port`), WA-02 (token format). | +| Failure / partial-failure states | WA-11 (eager start), WA-14 (busy port), WA-02 (malformed token). | +| Idempotency / retry / duplicate handling | WA-03 (concurrent token creation); WA-10 starts the child once. | +| Auth boundaries & rate limits | WA-09 keeps the existing checks; rate limits N/A because the server listens on loopback only. | +| Concurrency / ordering | WA-03; supervisor start sharing is unchanged from web-daemon. | +| Data lifecycle / expiry | WA-05/WA-06 (365-day sliding cookie); token rotation by file deletion is out of scope. | +| Observability | WA-11 log line, WA-15 CLI notice. | +| External-dependency failure | N/A because the only external behavior is browser cookie handling, fixed by RFC 6265. | +| State-transition integrity | N/A because this feature adds no new states; the supervisor's lifecycle is unchanged. | + +--- + +## Requirement Traceability + +| Requirement ID | Story | Phase | Status | +| --- | --- | --- | --- | +| WA-01 | P1: Bookmark survives restarts | Tasks | Pending | +| WA-02 | P1: Bookmark survives restarts | Tasks | Pending | +| WA-03 | P1: Bookmark survives restarts | Tasks | Pending | +| WA-04 | P1: Bookmark survives restarts | Tasks | Pending | +| WA-05 | P1: Bookmark survives restarts | Tasks | Pending | +| WA-06 | P1: Bookmark survives restarts | Tasks | Pending | +| WA-07 | P1: Bookmark survives restarts | Tasks | Pending | +| WA-08 | P1: Bookmark survives restarts | Tasks | Pending | +| WA-09 | P1: Bookmark survives restarts | Tasks | Pending | +| WA-10 | P1: Console answers while the daemon runs | Tasks | Pending | +| WA-11 | P1: Console answers while the daemon runs | Tasks | Pending | +| WA-12 | P2: Fixed, configurable port | Tasks | Pending | +| WA-13 | P2: Fixed, configurable port | Tasks | Pending | +| WA-14 | P2: Fixed, configurable port | Tasks | Pending | +| WA-15 | P2: Fixed, configurable port | Tasks | Pending | +| WA-16 | P2: Fixed, configurable port | Tasks | Pending | +| WA-17 | P2: Fixed, configurable port | Tasks | Pending | + +**Coverage:** 17 total, 0 mapped to tasks, 17 unmapped ⚠️ + +--- + +## Success Criteria + +- [ ] A `localhost:7777` bookmark opens setup after a browser restart and after a daemon restart, with no terminal step. +- [ ] `curl -sI http://127.0.0.1:7777/` answers 403 within 5 s of the daemon starting. +- [ ] No existing 403 case in `tests/web-server.test.ts` starts passing a request it rejected before. From 2480e7a087831bef016ebe2715374ad58fed7546 Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Thu, 24 Sep 2026 22:29:51 -0300 Subject: [PATCH 02/17] docs(specs): Address the web-access spec review --- .specs/features/web-access/spec.md | 162 ++++++++++++++++++----------- 1 file changed, 99 insertions(+), 63 deletions(-) diff --git a/.specs/features/web-access/spec.md b/.specs/features/web-access/spec.md index a38907d..124b8aa 100644 --- a/.specs/features/web-access/spec.md +++ b/.specs/features/web-access/spec.md @@ -2,42 +2,60 @@ ## Problem Statement -The web console cannot be bookmarked. Opening `http://localhost:3100/setup` directly answers 403 `open this page with codedeck ui` (observed 2026-09-25, screenshot from the user). Three things cause it: the token is random on every web child start, the cookie that carries it dies with the browser session, and the child only exists after a CLI command asks the daemon for it. On top of that, port 3100 sits in the range dev servers use. The user wants a fixed address they can always open while the daemon runs. +The web console cannot be bookmarked. Opening `http://localhost:3100/setup` directly answers 403 `open this page with codedeck ui` (observed 2026-09-24, screenshot from the user). Three things cause it: the token is random on every web child start, the cookie that carries it dies with the browser session, and the child only exists after a CLI command asks the daemon for it. On top of that, port 3100 sits in the range dev servers use. The user wants a fixed address they can always open while the daemon runs. ## Goals - [ ] After running any web command once on a machine, `http://127.0.0.1:7777/` and `http://localhost:7777/` open the page from a bookmark, across browser restarts, web child restarts, and daemon restarts. - [ ] While the daemon runs, the console answers on its port without a prior web command. -- [ ] The port is configurable in `config.json`, and a web command says so when the console runs on a port other than the one configured. +- [ ] The port is configurable in `config.json`, a config change takes effect on the next web command without restarting the daemon, and a web command says so when the console runs on a port other than the one asked for. - [ ] Every protection that exists today (Host check, Origin check on POST, cookie on `/api/*`) keeps holding. ## Current state (verified) -- `createWebSecurity(port)` draws a fresh 32-byte hex token per server (src/web/security.ts:18-25). The web child reports it in its handshake (src/web/child.ts:63). -- A valid `?t=` on a page GET sets `codedeck_ui_token_=; Path=/; HttpOnly; SameSite=Strict` with no `Max-Age` and answers 303 without `t` (src/web/security.ts:113-122). A page GET with no valid cookie answers 403 `open this page with codedeck ui` (src/web/security.ts:16, 64-67). +- `createWebSecurity(port)` draws a fresh 32-byte hex token per server (src/web/security.ts:18-25). The web child reports it in its handshake (src/web/child.ts:59). +- A valid `?t=` on a page GET sets `codedeck_ui_token_=; Path=/; HttpOnly; SameSite=Strict` with no `Max-Age` and answers 303 without `t` (src/web/security.ts:113-122). A page GET with no valid cookie answers 403 `open this page with codedeck ui` (src/web/security.ts:16, 64-67). A page GET with an invalid `t` and a valid cookie serves the page (src/web/security.ts:113-114, 64). - `isAllowedWebHost` accepts `127.0.0.1:` and `localhost:` (src/web/security.ts:27-31). The URL the CLI opens uses `127.0.0.1` (src/daemon/web-supervisor.ts:201). -- `DEFAULT_WEB_PORT` is 3100 (src/web/server.ts:8). The child falls back to an ephemeral port only when no port was given (src/web/child.ts:37). The four web commands print `(default: 3100)` in `--port` help (src/cli/commands/setup.ts:1304, review.ts:96, usage.ts:154, ui.ts:58). -- The daemon creates the `WebSupervisor` lazily on the first `web.ensure` (src/daemon/daemon.ts:1377). `start()` never touches the web child (src/daemon/daemon.ts:328-375), and the `--daemon` entry only calls `start()` (src/daemon/daemon.ts:2003-2009). -- `launchWebPage` prints `CodeDeck web is already running on port ` only when `--port` was given and differs (src/cli/web-launch.ts:61-63). The in-process fallback builds its own server and token through `startWebServer` (src/cli/web-launch.ts:83). -- `loadConfig` spreads the parsed file over the defaults without validating unknown keys (src/config/config.ts:432-442). Setup saves keep keys they do not manage (src/config/setup.ts:340-343). +- `DEFAULT_WEB_PORT` is 3100 (src/web/server.ts:8). `listenWebServer` falls back to an ephemeral port only on `EADDRINUSE` and only with `fallbackToEphemeral` (src/web/server.ts:122-126). The child sets it only when no port was given (src/web/child.ts:37). The four web commands print `(default: 3100)` in `--port` help (src/cli/commands/setup.ts:1304, review.ts:96, usage.ts:154, ui.ts:58). +- The supervisor reuses a running child whatever port is asked (`matches`, src/daemon/web-supervisor.ts:129-133), and a request that arrives while a start is in flight gets that start's promise, ignoring its own params (src/daemon/web-supervisor.ts:105). +- The daemon creates the `WebSupervisor` lazily on the first `web.ensure` (src/daemon/daemon.ts:1377). `start()` never touches the web child (src/daemon/daemon.ts:328-385), and the `--daemon` entry only calls `start()` (src/daemon/daemon.ts:2003-2009). Tests call `start()` directly (tests/orchestrator-usage-daemon.test.ts:449, 485, 499). +- `scripts/pty-gate.sh` runs a real daemon under a temp `RUN_AGENT_DIR`, and its EXIT trap only deletes directories (scripts/pty-gate.sh:21), so the daemon outlives the gate. +- `launchWebPage` prints `CodeDeck web is already running on port ` only when `--port` was given and differs (src/cli/web-launch.ts:61-63). The in-process fallback listens on `options.port ?? DEFAULT_WEB_PORT` with its own random token (src/cli/web-launch.ts:83-85). +- `loadConfig` spreads the parsed file over the defaults without validating unknown keys (src/config/config.ts:432-442). Setup saves keep keys they do not manage (src/config/setup.ts:340-343). The daemon already imports `loadConfig` (src/daemon/daemon.ts:24). - No vitest setup sets `RUN_AGENT_DIR` globally (vitest.config.ts), so any code that writes under `getPaths().base` by default must be kept out of unit tests. ## External Dependencies | Resource | Identifier | System | Verified | Evidence | | --- | --- | --- | --- | --- | -| cookies are not isolated by port | RFC 6265 §8.5 | IETF RFC 6265 | yes | fetched rfc-editor.org/rfc/rfc6265.html 2026-09-25: "Cookies do not provide isolation by port." | -| a cookie set for `127.0.0.1` is not sent to `localhost` | RFC 6265 §5.1.3 | IETF RFC 6265 | yes | fetched rfc-editor.org/rfc/rfc6265.html 2026-09-25: domain matching requires identical strings or a host-name suffix, and an IP address never suffix-matches | -| `Max-Age` is the cookie lifetime in seconds | RFC 6265 §4.1.2.2 | IETF RFC 6265 | yes | fetched rfc-editor.org/rfc/rfc6265.html 2026-09-25 | +| cookies are not isolated by port | RFC 6265 §8.5 | IETF RFC 6265 | yes | fetched rfc-editor.org/rfc/rfc6265.html 2026-09-24: "Cookies do not provide isolation by port." | +| a cookie set for `127.0.0.1` is not sent to `localhost` | RFC 6265 §5.1.3 | IETF RFC 6265 | yes | fetched rfc-editor.org/rfc/rfc6265.html 2026-09-24: domain matching requires identical strings or a host-name suffix, and an IP address never suffix-matches | +| `Max-Age` is the cookie lifetime in seconds | RFC 6265 §4.1.2.2 | IETF RFC 6265 | yes | fetched rfc-editor.org/rfc/rfc6265.html 2026-09-24 | +| web port constant | DEFAULT_WEB_PORT | repo | yes | src/web/server.ts:8 | +| test state override | RUN_AGENT_DIR | repo | yes | src/config/paths.ts:15 | + +## Supersedes (web-daemon) + +| web-daemon item | Replaced by | +| --- | --- | +| Assumption "When the child starts: lazily" | WA-10 | +| Assumption "Preferred port 3100" and WD-04 | WA-12, WA-13 | +| Out of Scope "Persisting the web token across web child restarts" | WA-01 to WA-04 | +| WD-21 notice text `CodeDeck web is already running on port ` | WA-17 | +| WD-30 page 403 text `open this page with codedeck ui` | WA-08 | +| `web.ensure` params `{port?, build?, entry?}` | adds `preferredPort` (WA-14) | + +Tests and docs that pin the old values change with this feature: tests/web-security.test.ts:140, 150; tests/web-launch.test.ts:98, 126; tests/web-server.test.ts:44-46; tests/review-command.test.ts:11-12; docs/protocol.md:46-63. ## Out of Scope | Feature | Reason | | --- | --- | -| Starting the daemon at login (systemd user unit) | "Always" here means while the daemon runs. After a reboot the first `codedeck` command starts the daemon, and with it the console. A login unit is a separate install concern. | -| Respawning the web child after a crash without a request | A crash is logged; the next web command respawns it (existing behavior). Crash-loop handling would need backoff rules this feature does not need. | -| Removing the token | The token is what keeps other local users and non-browser processes off the setup API, which rewrites the harness config. | -| A command to rotate the token | Deleting `~/.run-agent/web-token` rotates it on the next web child start; documented in `docs/protocol.md`. | +| Starting the daemon at login (systemd user unit) | "Always" here means while the daemon runs. After a reboot the first `codedeck` command starts the daemon, and with it the console. | +| Respawning the web child after a crash without a request | A crash is logged; the next web command respawns it (existing behavior). | +| Removing the token | The token keeps other local users and non-browser processes off the setup API, which rewrites the harness config. | +| A command to rotate the token | Deleting `~/.run-agent/web-token` rotates it at the next web child start; documented in `docs/protocol.md`. | +| Moving a running console to an explicit `--port` | `--port` applies only when a new child starts (WA-15). Changing the console's port for good is what `web.port` is for. | | Remote or LAN access | The server stays on 127.0.0.1. | | New pages or page visuals | This feature changes access only. | @@ -48,17 +66,21 @@ The web console cannot be bookmarked. Opening `http://localhost:3100/setup` dire | Assumption / decision | Chosen default | Rationale | Confirmed? | | --- | --- | --- | --- | | Default port | 7777 replaces 3100 as `DEFAULT_WEB_PORT`. | The user asked for 7777; 3000-3999 collides with common dev servers. | y | -| Port config key | `web.port` in `config.json`, an integer 1-65535. Precedence: `--port`, then `web.port`, then 7777. | Matches the existing per-feature objects in the config (`autocompact`, `orchestrator`). | n | -| Configured port busy | Same as the default today: the child falls back to an OS-assigned port. The CLI notices the mismatch (WA-12). | A foreign process on 7777 must not break the console; the notice tells the user the bookmark will not work this time. | n | -| Explicit `--port` busy | Unchanged: report the listen error and exit 1. | An explicit port is a request, not a hint. | y | -| Invalid `web.port` (not an integer in 1-65535) | Treated as absent: 7777 is used and nothing fails. | Matches how `loadConfig` tolerates a broken file (src/config/config.ts:439-441); `doctor` reporting is not part of this feature. | n | -| Token storage | `~/.run-agent/web-token` (`getPaths().base`), 64 lowercase hex characters plus an optional trailing newline, created with mode 0600. | Same directory as the daemon's other state; 0600 keeps other users out. | n | -| Token file missing or malformed | Generate a new token and write it. Creation uses exclusive create, and a process that loses the race reads the winner's token. | The web child and an in-process fallback can start at the same moment; both must end up with the same token. | n | -| Who uses the stored token | The web child and the CLI in-process fallback. Unit tests keep a per-server random token unless they inject one. | Both servers must accept the same cookie, or the fallback would overwrite it (same cookie name, same host, see RFC 6265 §8.5). | n | -| Cookie lifetime | `Max-Age=31536000` (365 days), renewed on every page GET that already carries a valid cookie. | A bookmark keeps working for a year after the last visit; 365 days is below the caps browsers put on `Max-Age`. | n | -| Canonical host | `127.0.0.1`. A page GET whose Host is `localhost:` answers 302 to the same path and query on `127.0.0.1:`. | RFC 6265 §5.1.3: a cookie set for `127.0.0.1` is never sent to `localhost`, so without the redirect a `localhost` bookmark would get 403 forever. | n | -| When the daemon starts the child | Once, right after the daemon's IPC socket listens, from its own `dist/web/child.js`, with no explicit port. | Makes the address answer without a prior command. The existing entry/build rules still replace the child when a CLI from another tree asks. | n | -| Eager start failure | Logged to `daemon.log` as `web autostart failed: `; the daemon keeps running and `web.ensure` retries later. | The web console must never keep the daemon from starting. | n | +| Port config key | `web.port` in `config.json`, an integer 1-65535. The preferred port is `web.port` when valid, else 7777. | Matches the existing per-feature objects in the config (`autocompact`, `orchestrator`). | n | +| Who reads `web.port` | The CLI and the daemon, through one shared resolver in `src/config`. The web child reads no config: it gets its port from its arguments. | One resolution point; the child cannot disagree with the process that asked for it. | n | +| Explicit `--port` vs preferred port | `--port` is explicit: no fallback, a listen error exits 1. The preferred port falls back to an OS-assigned port on any listen error (`EADDRINUSE`, `EACCES`, ...). | An explicit port is a request, not a hint; a bad config value must not break every web command. | n | +| `--port` while a console runs | Reused as today, with the WA-17 notice. `--port` only picks the port of a child that has to start anyway. | Restarting on every `--port` would move open tabs; `web.port` is the durable knob. | n | +| Config change while a console runs | The supervisor remembers the preferred port a child was started for and restarts the child when a later request brings a different preferred port. A child started for an explicit port is not restarted for a preferred port. | Editing `web.port` then running any web command applies it without restarting the daemon, which would interrupt sessions. | n | +| Invalid `web.port` | Treated as absent (7777). The CLI prints `Ignoring invalid web.port in config: ` to stderr; the daemon appends the same line to `daemon.log` on its eager start. | Visible, but never fatal, like `loadConfig`'s tolerance (src/config/config.ts:439-441). | n | +| Token storage | `~/.run-agent/web-token` (`getPaths().base`), 64 lowercase hex characters plus an optional trailing newline, mode 0600. | Same directory as the daemon's other state; 0600 keeps other users out. | n | +| Token creation | Write a random token to a 0600 temp file in the same directory, then `link(tmp, web-token)`. On `EEXIST`, read the existing file; if it is malformed, `rename(tmp, web-token)` and read the file again. Remove the temp file in every case. | `link` publishes a complete file atomically, so a concurrent reader never sees a partial token, and every process ends up with the token in the file. | n | +| Loose token file permissions | If the existing file is readable or writable by group or others, `chmod 0600` before using it. | A hand-copied file must not stay world-readable. | n | +| Who uses the stored token | The web child and the CLI in-process fallback. Unit tests keep a per-server random token unless they inject one. | Both servers must accept the same cookie, or the fallback would overwrite it (same cookie name, same host, RFC 6265 §8.5). | n | +| Cookie lifetime | `Max-Age=31536000` (365 days), renewed on every page GET served with a valid cookie. | A bookmark keeps working for a year after the last visit. | n | +| Accepted risk of a persistent token | The token no longer dies with the child: the `?t=` URL a command prints and the cookie (sent by the browser to any 127.0.0.1 port, RFC 6265 §8.5) stay valid until the file is deleted. Accepted for a single-user loopback tool; rotation by deleting the file is documented. | The alternative (a new link per restart) is the pain this feature removes. | n | +| Canonical host | `127.0.0.1`. A page GET whose Host is `localhost:` answers 302 to `127.0.0.1:` with the path and query of `new URL(request.url, "http://127.0.0.1:")`. | RFC 6265 §5.1.3: a cookie set for `127.0.0.1` is never sent to `localhost`; building from a parsed URL keeps an absolute-form request target out of `Location`. | n | +| Where the eager start runs | In the `--daemon` entry, after `start()` resolves, not awaited. `Daemon.start()` itself does not start the web child. | Keeps tests that call `start()` from spawning a real child, and IPC never waits on the web stack. | n | +| Stray gate daemons | `scripts/pty-gate.sh` stops the daemon it started (from `$RUN_AGENT_DIR/daemon.pid`) in its EXIT trap. | With the eager start, an orphaned gate daemon would hold 7777 with another token and break the real bookmark. | n | | 403 page text | `Run "codedeck ui" once in a terminal to open CodeDeck in this browser.` | The old text did not say the step is one-time. | n | **Open questions:** none - all resolved or logged above. @@ -76,16 +98,17 @@ The web console cannot be bookmarked. Opening `http://localhost:3100/setup` dire **Acceptance Criteria**: 1. WHEN the web child starts and `~/.run-agent/web-token` holds a valid token THEN the child SHALL serve with that token and report it in its handshake. -2. IF `~/.run-agent/web-token` is missing or malformed THEN the web child SHALL write a new 64-character lowercase hex token to it with mode 0600 and serve with that token. -3. WHEN two processes create the token file at the same time THEN both SHALL end up serving with the token that is in the file. -4. WHEN the CLI serves in-process because the daemon cannot host the console THEN it SHALL use the token from `~/.run-agent/web-token` under the same rules as the web child. -5. WHEN a page GET carries a valid `?t=` THEN the server SHALL answer 303 with `Set-Cookie: codedeck_ui_token_=; Path=/; Max-Age=31536000; HttpOnly; SameSite=Strict`. -6. WHEN a page GET carries a valid cookie and no `t` THEN the server SHALL serve the page with the same `Set-Cookie` header as WA-05. -7. WHEN a page GET arrives with Host `localhost:` THEN the server SHALL answer 302 with `Location: http://127.0.0.1:` and SHALL NOT check the token or cookie first. +2. IF `~/.run-agent/web-token` is missing or malformed THEN the web child SHALL publish a new 64-character lowercase hex token there with mode 0600 through the temp-file-and-link procedure and serve with the token the file holds afterwards. +3. WHEN two processes resolve the token at the same time with no file present THEN both SHALL serve with the same token, equal to the file's content. +4. IF the token file is readable or writable by group or others THEN the web child SHALL set its mode to 0600 before serving. +5. WHEN the CLI serves in-process because the daemon cannot host the console THEN it SHALL resolve the token with the same procedure as the web child. +6. WHEN a page GET carries a valid `?t=` THEN the server SHALL answer 303 with `Set-Cookie: codedeck_ui_token_=; Path=/; Max-Age=31536000; HttpOnly; SameSite=Strict`. +7. WHEN a page GET carries a valid cookie and no valid `t` THEN the server SHALL serve the page with the same `Set-Cookie` header as WA-06. 8. IF a page GET on `127.0.0.1:` has no valid `t` and no valid cookie THEN the server SHALL answer 403 with the body `Run "codedeck ui" once in a terminal to open CodeDeck in this browser.` -9. The server SHALL keep rejecting requests with any Host other than `127.0.0.1:` or `localhost:`, POSTs without a valid cookie and same-origin `Origin`, and `/api/*` requests without a valid cookie, each with 403 `forbidden`. +9. WHEN a page GET arrives with Host `localhost:` THEN the server SHALL answer 302 with `Location: http://127.0.0.1:` taken from `new URL(request.url, "http://127.0.0.1:")`, before any token or cookie check. +10. The server SHALL keep answering 403 `forbidden` to a request with any Host other than `127.0.0.1:` or `localhost:`, to a POST without a valid cookie and same-origin `Origin`, and to an `/api/*` request without a valid cookie. -**Independent Test**: open `codedeck ui` once, close the browser, kill the web child, run `codedeck setup --no-open`, then open `http://localhost:7777/setup` from a fresh browser window: the page loads without a token. +**Independent Test**: run `codedeck ui` once, close the browser, kill the web child, run `codedeck setup --no-open`, then open `http://localhost:7777/setup` in a fresh browser window: the page loads without a token. --- @@ -97,10 +120,12 @@ The web console cannot be bookmarked. Opening `http://localhost:3100/setup` dire **Acceptance Criteria**: -1. WHEN the daemon's IPC socket starts listening THEN the daemon SHALL start the web child once, from its own `dist/web/child.js`, without an explicit port. -2. IF the eager start fails THEN the daemon SHALL append `web autostart failed: ` to `daemon.log`, keep serving IPC, and start the child on the next `web.ensure`. +1. WHEN the `--daemon` entry's `start()` resolves THEN the daemon SHALL call `web.ensure` once with its own entry, no explicit port, and the resolved preferred port, without awaiting it. +2. IF the eager start fails THEN the daemon SHALL append `web autostart failed: ` to `daemon.log` and keep serving IPC. +3. WHEN a `web.ensure` arrives while another start is in flight THEN the supervisor SHALL wait for that start to settle and then apply its reuse and restart rules to the new request's own params. +4. The `Daemon.start()` method SHALL NOT start a web child. -**Independent Test**: stop the daemon, run `codedeck ps` (which starts the daemon), then `curl -sI http://127.0.0.1:7777/` answers 403 instead of refusing the connection. +**Independent Test**: stop the daemon, run `codedeck ps` (which starts the daemon), then `curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:7777/` prints 403 instead of refusing the connection. --- @@ -112,23 +137,27 @@ The web console cannot be bookmarked. Opening `http://localhost:3100/setup` dire **Acceptance Criteria**: -1. The web server SHALL use port 7777 when neither `--port` nor a valid `web.port` is given. -2. WHERE `config.json` holds a valid `web.port` the web child SHALL listen on that port when no `--port` is given. -3. IF the configured or default port is busy THEN the web child SHALL listen on an OS-assigned port. -4. WHEN a web command gets a console URL whose port differs from the preferred port (`--port`, else `web.port`, else 7777) THEN it SHALL print `CodeDeck web is running on port instead of ` before the page line. -5. IF `web.port` is not an integer in 1-65535 THEN the web child and the CLI SHALL treat it as absent. -6. The `--port` help of `review`, `setup`, `usage`, and `ui` SHALL read `port to listen on (default: 7777, or web.port from config)`. +1. The preferred port SHALL be `web.port` from `config.json` when it is an integer in 1-65535, and 7777 otherwise. +2. WHEN a web command runs without `--port` THEN it SHALL send the preferred port as `preferredPort` in `web.ensure` and no `port`. +3. WHEN a web command gets a console URL whose port differs from its `--port`, or from the preferred port when `--port` is absent, THEN it SHALL print `CodeDeck web is running on port instead of ` before the page line. +4. WHEN the supervisor starts a child for a `preferredPort` THEN the child SHALL listen on that port and, on any listen error, on an OS-assigned port. +5. WHEN a `web.ensure` without `port` brings a `preferredPort` different from the one the running child was started for THEN the supervisor SHALL restart the child for the new preferred port. +6. WHILE the running child was started for an explicit `port` the supervisor SHALL reuse it for any request whose entry and build match. +7. WHEN the CLI serves in-process without `--port` THEN it SHALL listen on the preferred port and fall back to an OS-assigned port on any listen error. +8. IF `web.port` is present and not an integer in 1-65535 THEN the CLI SHALL print `Ignoring invalid web.port in config: ` to stderr and the daemon SHALL append the same line to `daemon.log` on its eager start. +9. The `--port` help of `review`, `setup`, `usage`, and `ui` SHALL read `port for a new console (default: web.port from config, else 7777)`. -**Independent Test**: set `"web": { "port": 7788 }`, restart the daemon, and `curl -sI http://127.0.0.1:7788/` answers 403; occupy 7788 with `nc -l 7788`, restart the daemon, run `codedeck ui --no-open`, and the notice names the fallback port. +**Independent Test**: with the daemon running on 7777, set `"web": { "port": 7788 }` and run `codedeck ui --no-open`: the printed URL uses 7788 and `curl` on 7777 is refused. Occupy 7799 with `nc -l 7799`, set `web.port` to 7799, run `codedeck ui --no-open`, and the notice names the fallback port. --- ## Edge Cases -- IF the token file exists with other content (a truncated write, a hand edit) THEN the child SHALL replace it per WA-02. -- WHEN the web child restarts on the same port (build change, crash plus a new command) THEN a browser with the cookie SHALL load pages without a new `?t=` (follows from WA-01 and WA-06). -- WHEN the daemon already serves on the preferred port and a web command passes no `--port` THEN the command SHALL print no port notice. -- IF a `localhost` page GET carries `?t=` THEN the 302 SHALL keep `t` in the query so the `127.0.0.1` request can set the cookie (follows from WA-07). +- IF the token file holds anything other than 64 lowercase hex characters and an optional newline THEN the child SHALL replace it per WA-02. +- WHEN the web child restarts on the same port (build change, crash plus a new command) THEN a browser with the cookie SHALL load pages without a new `?t=` (follows from WA-01 and WA-07). +- WHEN the console already runs on the preferred port and a web command passes no `--port` THEN the command SHALL print no port notice. +- IF a `localhost` page GET carries `?t=` THEN the 302 SHALL keep `t` in the query so the `127.0.0.1` request sets the cookie (follows from WA-09). +- IF `web.port` is a privileged port the user cannot bind THEN the child SHALL fall back per WA-18 and the command SHALL print the WA-17 notice. --- @@ -136,15 +165,15 @@ The web console cannot be bookmarked. Opening `http://localhost:3100/setup` dire | Dimension | Resolution | | --- | --- | -| Input validation & bounds | WA-16 (`web.port`), WA-02 (token format). | -| Failure / partial-failure states | WA-11 (eager start), WA-14 (busy port), WA-02 (malformed token). | -| Idempotency / retry / duplicate handling | WA-03 (concurrent token creation); WA-10 starts the child once. | -| Auth boundaries & rate limits | WA-09 keeps the existing checks; rate limits N/A because the server listens on loopback only. | -| Concurrency / ordering | WA-03; supervisor start sharing is unchanged from web-daemon. | -| Data lifecycle / expiry | WA-05/WA-06 (365-day sliding cookie); token rotation by file deletion is out of scope. | -| Observability | WA-11 log line, WA-15 CLI notice. | +| Input validation & bounds | WA-15, WA-22 (`web.port`); WA-02 (token format). | +| Failure / partial-failure states | WA-12 (eager start), WA-18, WA-21 (listen errors), WA-02 (malformed token). | +| Idempotency / retry / duplicate handling | WA-03 (concurrent token creation), WA-11 (one eager call), WA-13 (requests during a start). | +| Auth boundaries & rate limits | WA-10 keeps the existing checks; WA-04 file mode; rate limits N/A because the server listens on loopback only. | +| Concurrency / ordering | WA-03, WA-13. | +| Data lifecycle / expiry | WA-06, WA-07 (365-day sliding cookie); persistent-token risk accepted in Assumptions. | +| Observability | WA-12, WA-22 log lines; WA-17 CLI notice. | | External-dependency failure | N/A because the only external behavior is browser cookie handling, fixed by RFC 6265. | -| State-transition integrity | N/A because this feature adds no new states; the supervisor's lifecycle is unchanged. | +| State-transition integrity | WA-13, WA-19, WA-20 define when the supervisor reuses or restarts a child. | --- @@ -161,21 +190,28 @@ The web console cannot be bookmarked. Opening `http://localhost:3100/setup` dire | WA-07 | P1: Bookmark survives restarts | Tasks | Pending | | WA-08 | P1: Bookmark survives restarts | Tasks | Pending | | WA-09 | P1: Bookmark survives restarts | Tasks | Pending | -| WA-10 | P1: Console answers while the daemon runs | Tasks | Pending | +| WA-10 | P1: Bookmark survives restarts | Tasks | Pending | | WA-11 | P1: Console answers while the daemon runs | Tasks | Pending | -| WA-12 | P2: Fixed, configurable port | Tasks | Pending | -| WA-13 | P2: Fixed, configurable port | Tasks | Pending | -| WA-14 | P2: Fixed, configurable port | Tasks | Pending | +| WA-12 | P1: Console answers while the daemon runs | Tasks | Pending | +| WA-13 | P1: Console answers while the daemon runs | Tasks | Pending | +| WA-14 | P1: Console answers while the daemon runs | Tasks | Pending | | WA-15 | P2: Fixed, configurable port | Tasks | Pending | | WA-16 | P2: Fixed, configurable port | Tasks | Pending | | WA-17 | P2: Fixed, configurable port | Tasks | Pending | +| WA-18 | P2: Fixed, configurable port | Tasks | Pending | +| WA-19 | P2: Fixed, configurable port | Tasks | Pending | +| WA-20 | P2: Fixed, configurable port | Tasks | Pending | +| WA-21 | P2: Fixed, configurable port | Tasks | Pending | +| WA-22 | P2: Fixed, configurable port | Tasks | Pending | +| WA-23 | P2: Fixed, configurable port | Tasks | Pending | -**Coverage:** 17 total, 0 mapped to tasks, 17 unmapped ⚠️ +**Coverage:** 23 total, 0 mapped to tasks, 23 unmapped ⚠️ --- ## Success Criteria - [ ] A `localhost:7777` bookmark opens setup after a browser restart and after a daemon restart, with no terminal step. -- [ ] `curl -sI http://127.0.0.1:7777/` answers 403 within 5 s of the daemon starting. -- [ ] No existing 403 case in `tests/web-server.test.ts` starts passing a request it rejected before. +- [ ] `curl` on `http://127.0.0.1:7777/` answers 403 within 5 s of the daemon starting. +- [ ] Editing `web.port` and running one web command moves the console, with no daemon restart. +- [ ] No existing 403 case in `tests/web-server.test.ts` or `tests/web-security.test.ts` starts accepting a request it rejected before. From 5e4e68a799fadb0d06b603e27e81fe9743b32209 Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Thu, 24 Sep 2026 22:31:30 -0300 Subject: [PATCH 03/17] docs(specs): Settle the web-access supervisor port rules --- .specs/features/web-access/spec.md | 33 ++++++++++++++++++++---------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/.specs/features/web-access/spec.md b/.specs/features/web-access/spec.md index 124b8aa..9aa5027 100644 --- a/.specs/features/web-access/spec.md +++ b/.specs/features/web-access/spec.md @@ -43,7 +43,11 @@ The web console cannot be bookmarked. Opening `http://localhost:3100/setup` dire | Out of Scope "Persisting the web token across web child restarts" | WA-01 to WA-04 | | WD-21 notice text `CodeDeck web is already running on port ` | WA-17 | | WD-30 page 403 text `open this page with codedeck ui` | WA-08 | -| `web.ensure` params `{port?, build?, entry?}` | adds `preferredPort` (WA-14) | +| `web.ensure` params `{port?, build?, entry?}` | adds `preferredPort` (WA-16) | +| WD-02 (same entry and build reuse the running child) | WA-19, WA-20: reuse also requires the port rule to hold | +| WD-42 (a request without `build` reuses the running child) | WA-19, WA-20: the port rule applies first | +| WD-03 (requests during a start share one result) | WA-13: each waiting request re-evaluates with its own params | +| WD-44 (ephemeral fallback only on `EADDRINUSE` when `--port` is absent) | WA-18, WA-21: any listen error | Tests and docs that pin the old values change with this feature: tests/web-security.test.ts:140, 150; tests/web-launch.test.ts:98, 126; tests/web-server.test.ts:44-46; tests/review-command.test.ts:11-12; docs/protocol.md:46-63. @@ -69,18 +73,20 @@ Tests and docs that pin the old values change with this feature: tests/web-secur | Port config key | `web.port` in `config.json`, an integer 1-65535. The preferred port is `web.port` when valid, else 7777. | Matches the existing per-feature objects in the config (`autocompact`, `orchestrator`). | n | | Who reads `web.port` | The CLI and the daemon, through one shared resolver in `src/config`. The web child reads no config: it gets its port from its arguments. | One resolution point; the child cannot disagree with the process that asked for it. | n | | Explicit `--port` vs preferred port | `--port` is explicit: no fallback, a listen error exits 1. The preferred port falls back to an OS-assigned port on any listen error (`EADDRINUSE`, `EACCES`, ...). | An explicit port is a request, not a hint; a bad config value must not break every web command. | n | -| `--port` while a console runs | Reused as today, with the WA-17 notice. `--port` only picks the port of a child that has to start anyway. | Restarting on every `--port` would move open tabs; `web.port` is the durable knob. | n | -| Config change while a console runs | The supervisor remembers the preferred port a child was started for and restarts the child when a later request brings a different preferred port. A child started for an explicit port is not restarted for a preferred port. | Editing `web.port` then running any web command applies it without restarting the daemon, which would interrupt sessions. | n | +| `--port` while a console runs | Reused as today, with the WA-17 notice. `--port` only picks the port of a child that has to start anyway, and that child lasts until the next request without `--port`, which moves the console back to the preferred port (WA-19). | Restarting on every `--port` would move open tabs; a request without `--port` restoring the preferred port keeps the bookmark from being hijacked by one `--port` run. | n | +| Config change while a console runs | The supervisor remembers what each child was started for: an explicit port, a preferred port, or nothing. A request without `port` but with a `preferredPort` restarts the child unless it was started for that same preferred port (WA-19). | Editing `web.port` then running any web command applies it without restarting the daemon, which would interrupt sessions. | n | +| Request with neither `port` nor `preferredPort` (a CLI from an older tree) | Reuses a running child whose entry and build match; otherwise starts a child with no port argument (WA-25). | Absent means "no opinion", not "different". | n | +| Child port arguments | `--port `: explicit, no fallback (unchanged). `--preferred-port `: that port, OS-assigned on any listen error. Neither: 7777 with the same fallback. | Keeps today's meaning of `--port`; a child started by an older daemon still gets a sensible port. | n | | Invalid `web.port` | Treated as absent (7777). The CLI prints `Ignoring invalid web.port in config: ` to stderr; the daemon appends the same line to `daemon.log` on its eager start. | Visible, but never fatal, like `loadConfig`'s tolerance (src/config/config.ts:439-441). | n | | Token storage | `~/.run-agent/web-token` (`getPaths().base`), 64 lowercase hex characters plus an optional trailing newline, mode 0600. | Same directory as the daemon's other state; 0600 keeps other users out. | n | -| Token creation | Write a random token to a 0600 temp file in the same directory, then `link(tmp, web-token)`. On `EEXIST`, read the existing file; if it is malformed, `rename(tmp, web-token)` and read the file again. Remove the temp file in every case. | `link` publishes a complete file atomically, so a concurrent reader never sees a partial token, and every process ends up with the token in the file. | n | +| Token creation | Write a random token to a 0600 temp file in the same directory, then `link(tmp, web-token)`. On `EEXIST`, read the existing file; if it is malformed, `rename(tmp, web-token)` and read the file again. Remove the temp file in every case. | `link` publishes a complete file atomically, so a concurrent reader never sees a partial token and processes racing on a missing file agree (WA-03). Two processes replacing the same malformed file at once can serve different tokens until the next child start; accepted, since it needs a corrupted file plus a start race. | n | | Loose token file permissions | If the existing file is readable or writable by group or others, `chmod 0600` before using it. | A hand-copied file must not stay world-readable. | n | | Who uses the stored token | The web child and the CLI in-process fallback. Unit tests keep a per-server random token unless they inject one. | Both servers must accept the same cookie, or the fallback would overwrite it (same cookie name, same host, RFC 6265 §8.5). | n | | Cookie lifetime | `Max-Age=31536000` (365 days), renewed on every page GET served with a valid cookie. | A bookmark keeps working for a year after the last visit. | n | | Accepted risk of a persistent token | The token no longer dies with the child: the `?t=` URL a command prints and the cookie (sent by the browser to any 127.0.0.1 port, RFC 6265 §8.5) stay valid until the file is deleted. Accepted for a single-user loopback tool; rotation by deleting the file is documented. | The alternative (a new link per restart) is the pain this feature removes. | n | | Canonical host | `127.0.0.1`. A page GET whose Host is `localhost:` answers 302 to `127.0.0.1:` with the path and query of `new URL(request.url, "http://127.0.0.1:")`. | RFC 6265 §5.1.3: a cookie set for `127.0.0.1` is never sent to `localhost`; building from a parsed URL keeps an absolute-form request target out of `Location`. | n | | Where the eager start runs | In the `--daemon` entry, after `start()` resolves, not awaited. `Daemon.start()` itself does not start the web child. | Keeps tests that call `start()` from spawning a real child, and IPC never waits on the web stack. | n | -| Stray gate daemons | `scripts/pty-gate.sh` stops the daemon it started (from `$RUN_AGENT_DIR/daemon.pid`) in its EXIT trap. | With the eager start, an orphaned gate daemon would hold 7777 with another token and break the real bookmark. | n | +| Stray gate daemons | `scripts/pty-gate.sh` and `scripts/rename-gate.sh` stop the daemon they started (from `$RUN_AGENT_DIR/daemon.pid`) in their EXIT trap. | With the eager start, an orphaned gate daemon would hold 7777 with another token and break the real bookmark. | n | | 403 page text | `Run "codedeck ui" once in a terminal to open CodeDeck in this browser.` | The old text did not say the step is one-time. | n | **Open questions:** none - all resolved or logged above. @@ -122,7 +128,7 @@ Tests and docs that pin the old values change with this feature: tests/web-secur 1. WHEN the `--daemon` entry's `start()` resolves THEN the daemon SHALL call `web.ensure` once with its own entry, no explicit port, and the resolved preferred port, without awaiting it. 2. IF the eager start fails THEN the daemon SHALL append `web autostart failed: ` to `daemon.log` and keep serving IPC. -3. WHEN a `web.ensure` arrives while another start is in flight THEN the supervisor SHALL wait for that start to settle and then apply its reuse and restart rules to the new request's own params. +3. WHEN a `web.ensure` arrives while another start is in flight THEN the supervisor SHALL wait for that start to settle and then apply its reuse and restart rules to the new request's own params, repeating the wait whenever it finds another start in flight, so that at most one start runs at a time. 4. The `Daemon.start()` method SHALL NOT start a web child. **Independent Test**: stop the daemon, run `codedeck ps` (which starts the daemon), then `curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:7777/` prints 403 instead of refusing the connection. @@ -140,12 +146,14 @@ Tests and docs that pin the old values change with this feature: tests/web-secur 1. The preferred port SHALL be `web.port` from `config.json` when it is an integer in 1-65535, and 7777 otherwise. 2. WHEN a web command runs without `--port` THEN it SHALL send the preferred port as `preferredPort` in `web.ensure` and no `port`. 3. WHEN a web command gets a console URL whose port differs from its `--port`, or from the preferred port when `--port` is absent, THEN it SHALL print `CodeDeck web is running on port instead of ` before the page line. -4. WHEN the supervisor starts a child for a `preferredPort` THEN the child SHALL listen on that port and, on any listen error, on an OS-assigned port. -5. WHEN a `web.ensure` without `port` brings a `preferredPort` different from the one the running child was started for THEN the supervisor SHALL restart the child for the new preferred port. -6. WHILE the running child was started for an explicit `port` the supervisor SHALL reuse it for any request whose entry and build match. +4. WHEN the supervisor starts a child for a `preferredPort` THEN it SHALL pass `--preferred-port `, and the child SHALL listen on that port and, on any listen error, on an OS-assigned port. +5. WHEN a `web.ensure` without `port` and with a `preferredPort` finds a running child that was not started for that same preferred port (started for an explicit port, for another preferred port, or with no port argument) THEN the supervisor SHALL restart the child for the requested preferred port. +6. WHEN a `web.ensure` with `port` finds a running child whose entry and build match THEN the supervisor SHALL reuse it, whatever the child was started for. 7. WHEN the CLI serves in-process without `--port` THEN it SHALL listen on the preferred port and fall back to an OS-assigned port on any listen error. 8. IF `web.port` is present and not an integer in 1-65535 THEN the CLI SHALL print `Ignoring invalid web.port in config: ` to stderr and the daemon SHALL append the same line to `daemon.log` on its eager start. 9. The `--port` help of `review`, `setup`, `usage`, and `ui` SHALL read `port for a new console (default: web.port from config, else 7777)`. +10. WHEN the web child starts with neither `--port` nor `--preferred-port` THEN it SHALL listen on 7777 and, on any listen error, on an OS-assigned port. +11. WHEN a `web.ensure` with neither `port` nor `preferredPort` finds a running child whose entry and build match THEN the supervisor SHALL reuse it. **Independent Test**: with the daemon running on 7777, set `"web": { "port": 7788 }` and run `codedeck ui --no-open`: the printed URL uses 7788 and `curl` on 7777 is refused. Occupy 7799 with `nc -l 7799`, set `web.port` to 7799, run `codedeck ui --no-open`, and the notice names the fallback port. @@ -157,6 +165,7 @@ Tests and docs that pin the old values change with this feature: tests/web-secur - WHEN the web child restarts on the same port (build change, crash plus a new command) THEN a browser with the cookie SHALL load pages without a new `?t=` (follows from WA-01 and WA-07). - WHEN the console already runs on the preferred port and a web command passes no `--port` THEN the command SHALL print no port notice. - IF a `localhost` page GET carries `?t=` THEN the 302 SHALL keep `t` in the query so the `127.0.0.1` request sets the cookie (follows from WA-09). +- WHEN `codedeck ui --port 8000` started the running child and a later web command passes no `--port` THEN the supervisor SHALL restart the child on the preferred port (follows from WA-19). - IF `web.port` is a privileged port the user cannot bind THEN the child SHALL fall back per WA-18 and the command SHALL print the WA-17 notice. --- @@ -173,7 +182,7 @@ Tests and docs that pin the old values change with this feature: tests/web-secur | Data lifecycle / expiry | WA-06, WA-07 (365-day sliding cookie); persistent-token risk accepted in Assumptions. | | Observability | WA-12, WA-22 log lines; WA-17 CLI notice. | | External-dependency failure | N/A because the only external behavior is browser cookie handling, fixed by RFC 6265. | -| State-transition integrity | WA-13, WA-19, WA-20 define when the supervisor reuses or restarts a child. | +| State-transition integrity | WA-13, WA-19, WA-20, WA-25 define when the supervisor reuses or restarts a child; entry and build mismatches still restart as in web-daemon. | --- @@ -204,8 +213,10 @@ Tests and docs that pin the old values change with this feature: tests/web-secur | WA-21 | P2: Fixed, configurable port | Tasks | Pending | | WA-22 | P2: Fixed, configurable port | Tasks | Pending | | WA-23 | P2: Fixed, configurable port | Tasks | Pending | +| WA-24 | P2: Fixed, configurable port | Tasks | Pending | +| WA-25 | P2: Fixed, configurable port | Tasks | Pending | -**Coverage:** 23 total, 0 mapped to tasks, 23 unmapped ⚠️ +**Coverage:** 25 total, 0 mapped to tasks, 25 unmapped ⚠️ --- From dfd554de846248de58bcf5081b1c8809a647b690 Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Thu, 24 Sep 2026 22:32:55 -0300 Subject: [PATCH 04/17] docs(specs): Break web-access into tasks --- .specs/features/web-access/tasks.md | 334 ++++++++++++++++++++++++++++ 1 file changed, 334 insertions(+) create mode 100644 .specs/features/web-access/tasks.md diff --git a/.specs/features/web-access/tasks.md b/.specs/features/web-access/tasks.md new file mode 100644 index 0000000..7ab7591 --- /dev/null +++ b/.specs/features/web-access/tasks.md @@ -0,0 +1,334 @@ +# Web access tasks + +## Execution Protocol (MANDATORY -- do not skip) + +Implement these tasks with the `tlc-spec-driven` skill: **activate it by name and follow its Execute flow and Critical Rules.** Do not search for skill files by filesystem path. The skill is the source of truth for the full flow (per-task cycle, sub-agent delegation, adequacy review, Verifier, discrimination sensor). + +**If the skill cannot be activated, STOP and tell the user - do not proceed without it.** + +--- + +**Design**: inline (no design.md; the spec's Assumptions table fixes every mechanism) +**Status**: Draft + +--- + +## Test Coverage Matrix + +> Generated from codebase, project guidelines, and spec. Guidelines found: `CLAUDE.md` (scoped vitest runs, never the full suite), `vitest.config.ts`. + +| Code Layer | Required Test Type | Coverage Expectation | Location Pattern | Run Command | +| --- | --- | --- | --- | --- | +| Token store (`src/web/web-token.ts`) | unit over a temp directory, fs seams for races | All branches; 1:1 to WA ACs | `tests/web-token.test.ts` | `npx vitest run tests/` | +| Web server / security (`src/web/server.ts`, `src/web/security.ts`) | integration (real loopback listener on port 0) | Every WA AC in the task, happy + error paths | `tests/web-security.test.ts`, `tests/web-server.test.ts` | `npx vitest run tests/` | +| Config resolver (`src/config/web-port.ts`) | unit | All branches | `tests/web-port.test.ts` | `npx vitest run tests/` | +| Web child and supervisor | unit with injected streams / fake child | All branches; 1:1 to WA ACs | `tests/web-child.test.ts`, `tests/web-supervisor.test.ts` | `npx vitest run tests/` | +| Daemon entry (`src/daemon/daemon.ts`) | unit through `tests/helpers/daemon-seam.ts` | 1:1 to WA ACs | `tests/daemon-web.test.ts` | `npx vitest run tests/` | +| CLI launcher and commands | unit with injected client / fakes | 1:1 to WA ACs | `tests/web-launch.test.ts`, `tests/web-cli.test.ts`, `tests/review-command.test.ts` | `npx vitest run tests/` | +| Gate scripts, docs | none | build gate only | - | - | + +## Gate Check Commands + +| Gate Level | When to Use | Command | +| --- | --- | --- | +| Quick | Every task | `npx vitest run ` (one run per file) + `npx tsc --noEmit` | +| Full | Last task of each phase | Quick gate over every test file touched in the phase, one file per run | +| Build | Last task | `npm run build`, `scripts/pty-gate.sh`, then a manual smoke run against `dist/` under a temp `RUN_AGENT_DIR` | + +--- + +## Execution Plan + +### Phase 1: Token and security + +``` +T1 → T2 → T3 +``` + +### Phase 2: Ports and supervision + +``` +T4 → T5 → T6 → T7 +``` + +### Phase 3: CLI and delivery + +``` +T8 → T9 → T10 +``` + +--- + +## Task Breakdown + +### T1: Add the persistent web token store + +**What**: `resolveWebToken(options?)` in a new module: reads `/web-token`, validates 64 lowercase hex plus optional newline, tightens loose modes to 0600, and publishes a new token through temp file + `link`, falling back to read on `EEXIST` and to `rename` when the existing file is malformed. +**Where**: `src/web/web-token.ts` (new) +**Depends on**: None +**Reuses**: `getPaths().base` from `src/config/paths.ts` +**Requirement**: WA-01, WA-02, WA-03, WA-04 + +**Done when**: + +- [ ] Valid file → returns its token unchanged (with and without trailing newline) +- [ ] Missing file → creates a 64-hex token with mode 0600 and returns it; no temp file left behind +- [ ] Malformed file (short, uppercase, extra text) → replaced; the returned token equals the file content +- [ ] Race seam: a file appears between the temp write and `link` → `EEXIST` path returns the other token, which equals the file content +- [ ] Mode 0644 file → mode becomes 0600, token kept +- [ ] Gate check passes: `npx vitest run tests/web-token.test.ts`; `npx tsc --noEmit` + +**Tests**: unit +**Gate**: quick + +**Commit**: `feat(web): Persist the web console token` + +--- + +### T2: Keep the cookie for a year and send localhost to 127.0.0.1 + +**What**: In `checkWebRequest`: `Max-Age=31536000` on the bootstrap cookie, the same `Set-Cookie` on page GETs served with a valid cookie, a 302 from `localhost:` page GETs to `127.0.0.1:` built from a parsed URL, and the new 403 page text. +**Where**: `src/web/security.ts` +**Depends on**: T1 +**Reuses**: existing `redirectWithSessionCookie` and `hasSessionCookie` +**Requirement**: WA-06, WA-07, WA-08, WA-09, WA-10 + +**Done when**: + +- [ ] Valid `?t=` → 303 with the exact WA-06 `Set-Cookie` +- [ ] Valid cookie, no `t` and invalid `t` → page served with the same `Set-Cookie` +- [ ] `localhost` Host page GET with and without `t` → 302 to the `127.0.0.1` URL keeping the query; an absolute-form request target does not leak its host into `Location` +- [ ] No cookie, no `t` on `127.0.0.1` → 403 with the WA-08 body +- [ ] Existing rejections (foreign Host, POST without cookie/Origin, `/api/*` without cookie) still answer 403 `forbidden` +- [ ] Gate check passes: `npx vitest run tests/web-security.test.ts`; `npx vitest run tests/web-server.test.ts`; `npx tsc --noEmit` + +**Tests**: integration +**Gate**: quick + +**Commit**: `feat(web): Keep the console cookie and canonicalize the host` + +--- + +### T3: Accept an injected token and fall back on any listen error + +**What**: `listenWebServer` / `startWebServer` take an optional `token`; `createWebSecurity(port, token?)` keeps a random default. With `fallbackToEphemeral`, any listen error retries on port 0. `DEFAULT_WEB_PORT` becomes 7777. +**Where**: `src/web/server.ts`, `src/web/security.ts` +**Depends on**: T2 +**Reuses**: existing `listen` helper +**Requirement**: WA-01, WA-18, WA-21 + +**Done when**: + +- [ ] Injected token → served and required by the cookie check; omitted → random 64-hex token +- [ ] `fallbackToEphemeral` + `EADDRINUSE` and + a non-`EADDRINUSE` listen error (injected server factory) → listens on an OS port +- [ ] No fallback → the listen error propagates +- [ ] `DEFAULT_WEB_PORT` is 7777 +- [ ] Gate check passes: `npx vitest run tests/web-server.test.ts`; `npx vitest run tests/web-security.test.ts`; `npx tsc --noEmit` + +**Tests**: integration +**Gate**: full + +**Commit**: `feat(web): Serve on 7777 with an injectable token` + +--- + +### T4: Resolve the preferred web port from config + +**What**: `resolveWebPort(config)` returns `{ port, invalid? }`: `web.port` when an integer in 1-65535, else 7777, with `invalid` holding the raw value when `web.port` is present but bad; plus the `Ignoring invalid web.port in config: ` formatter. Adds `web?: { port?: number }` to `RunAgentConfig`. +**Where**: `src/config/web-port.ts` (new), `src/config/config.ts` +**Depends on**: None (previous phase) +**Reuses**: `isJsonObject` style guards in `src/config/config.ts` +**Requirement**: WA-15, WA-22 + +**Done when**: + +- [ ] Absent `web` / absent `web.port` → 7777, no `invalid` +- [ ] `web.port` 7788 → 7788; 1 and 65535 accepted +- [ ] `"7788"`, 0, 65536, 7.5, `web: "x"` → 7777 with `invalid` set; message renders the JSON value +- [ ] Gate check passes: `npx vitest run tests/web-port.test.ts`; `npx tsc --noEmit` + +**Tests**: unit +**Gate**: quick + +**Commit**: `feat(config): Add the web.port setting` + +--- + +### T5: Serve the child with the stored token and preferred port + +**What**: The child parses `--port` (explicit) and `--preferred-port` (fallback); with neither it uses 7777 with fallback. It passes `resolveWebToken()` as the server token (injectable for tests). +**Where**: `src/web/child.ts` +**Depends on**: T4 +**Reuses**: `runWebChild` options seams +**Requirement**: WA-01, WA-18, WA-24 + +**Done when**: + +- [ ] `--preferred-port 7788` → listen called with port 7788 and fallback on +- [ ] `--port 7788` → port 7788, fallback off (unchanged) +- [ ] Neither → port 7777, fallback on +- [ ] The handshake token equals the injected token resolver's value, and the server was given that token +- [ ] Gate check passes: `npx vitest run tests/web-child.test.ts`; `npx tsc --noEmit` + +**Tests**: unit +**Gate**: quick + +**Commit**: `feat(web): Start the web child on the preferred port` + +--- + +### T6: Apply the port rules in the supervisor + +**What**: `preferredPort` in `WebEnsureParams`; the supervisor records what each child was started for (explicit, preferred n, none), passes `--preferred-port`, restarts per WA-19, reuses per WA-20 and WA-25, and serializes requests that arrive during a start (WA-13). +**Where**: `src/daemon/web-supervisor.ts`, `src/daemon/protocol.ts` +**Depends on**: T5 +**Reuses**: existing `stop`, `start`, `matches` +**Requirement**: WA-13, WA-16, WA-18, WA-19, WA-20, WA-25 + +**Done when**: + +- [ ] `preferredPort` only → spawn args `--web-child --preferred-port ` +- [ ] Running for preferred 7777, request preferred 7788 → SIGTERM + new child for 7788 +- [ ] Running for explicit 8000, request preferred 7777 → restart for 7777 +- [ ] Running with no port argument, request preferred 7777 → restart for 7777 +- [ ] Running for preferred 7777, request `port` 8000 with matching entry/build → reuse, no spawn +- [ ] Request with neither → reuse a matching child +- [ ] Entry or build mismatch still restarts (existing tests keep passing) +- [ ] Two requests during a start that each need a different child → starts run one after the other, never two children alive at once, each request resolves with a child matching its own params +- [ ] Gate check passes: `npx vitest run tests/web-supervisor.test.ts`; `npx tsc --noEmit` + +**Tests**: unit +**Gate**: quick + +**Commit**: `feat(daemon): Restart the web child when the preferred port changes` + +--- + +### T7: Start the web child with the daemon + +**What**: `Daemon.autostartWeb()` resolves the preferred port from `loadConfig()`, logs the WA-22 line when invalid, calls the supervisor without awaiting, and logs `web autostart failed: ` on rejection. The `--daemon` entry calls it after `start()` resolves; `start()` does not. +**Where**: `src/daemon/daemon.ts` +**Depends on**: T6 +**Reuses**: `appendDaemonLog`, `DaemonOptions.webSupervisor` +**Requirement**: WA-11, WA-12, WA-14, WA-22 + +**Done when**: + +- [ ] `autostartWeb()` calls `ensure` once with `{ preferredPort }` and no `port`/`entry`/`build` +- [ ] Rejection → `daemon.log` gets `web autostart failed: `; later `web.ensure` requests still work +- [ ] Invalid `web.port` in the test config → the WA-22 line in `daemon.log` +- [ ] `start()` alone never calls the supervisor (seam test) +- [ ] The `--daemon` entry calls `autostartWeb()` after `start()` (static check of the entry block) +- [ ] Gate check passes: `npx vitest run tests/daemon-web.test.ts`; `npx vitest run tests/web-supervisor.test.ts`; `npx tsc --noEmit` + +**Tests**: unit +**Gate**: full + +**Commit**: `feat(daemon): Start the web console with the daemon` + +--- + +### T8: Send the preferred port from the CLI and share the token in the fallback + +**What**: `launchWebPage` resolves the preferred port (injectable config loader), prints the WA-22 warning when invalid, sends `preferredPort` without `--port`, prints `CodeDeck web is running on port instead of ` when the URL port differs, and serves the fallback on the preferred port with fallback on and the stored token. +**Where**: `src/cli/web-launch.ts` +**Depends on**: None (previous phase) +**Reuses**: `resolveWebPort`, `resolveWebToken` +**Requirement**: WA-05, WA-16, WA-17, WA-21, WA-22 + +**Done when**: + +- [ ] No `--port` → params `{ preferredPort: , build, entry }` without `port`; with `--port` → `port` sent, no `preferredPort` +- [ ] URL port ≠ asked port → notice line before the page line; equal → no notice (both with and without `--port`) +- [ ] Invalid `web.port` → the WA-22 line on stderr, once +- [ ] Fallback without `--port` → `startServer` gets the preferred port, `fallbackToEphemeral: true`, and the resolver's token; with `--port` → that port, no fallback +- [ ] Gate check passes: `npx vitest run tests/web-launch.test.ts`; `npx tsc --noEmit` + +**Tests**: unit +**Gate**: quick + +**Commit**: `feat(cli): Ask for the preferred web port` + +--- + +### T9: Update the --port help of the web commands + +**What**: The four commands read `port for a new console (default: web.port from config, else 7777)`; tests that pin 3100 move to 7777. +**Where**: `src/cli/commands/review.ts`, `src/cli/commands/setup.ts`, `src/cli/commands/usage.ts`, `src/cli/commands/ui.ts` +**Depends on**: T8 +**Reuses**: none +**Requirement**: WA-23 + +**Done when**: + +- [ ] Each command's help contains the WA-23 text +- [ ] `parseReviewPort(undefined)` is 7777 +- [ ] Gate check passes: `npx vitest run tests/web-cli.test.ts`; `npx vitest run tests/review-command.test.ts`; `npx tsc --noEmit` + +**Tests**: unit +**Gate**: quick + +**Commit**: `feat(cli): Describe the console port in --port help` + +--- + +### T10: Stop gate daemons, document, and smoke-test the build + +**What**: `scripts/pty-gate.sh` and `scripts/rename-gate.sh` kill the daemon from `$RUN_AGENT_DIR/daemon.pid` in their EXIT trap; `docs/protocol.md` documents `preferredPort`, the child arguments, the token file and its rotation. Run the build gate and a smoke run. +**Where**: `scripts/pty-gate.sh`, `scripts/rename-gate.sh`, `docs/protocol.md` +**Depends on**: T9 +**Reuses**: none +**Requirement**: WA-11, WA-12 + +**Done when**: + +- [ ] `npm run build` exits 0; `scripts/pty-gate.sh` prints its ok line and leaves no daemon with its `RUN_AGENT_DIR` +- [ ] Smoke under a temp `RUN_AGENT_DIR` and config: daemon start → 403 on the preferred port within 5 s; `ui --no-open` URL → cookie with `Max-Age`; restarting the daemon keeps the token; `localhost` page GET → 302; `web.port` change + one command moves the port +- [ ] Gate check passes: build gate + +**Tests**: none +**Gate**: build + +**Commit**: `docs(web): Document stable console access and stop gate daemons` + +--- + +## Phase Execution Map + +``` +Phase 1 → Phase 2 → Phase 3 + +Phase 1: T1 → T2 → T3 +Phase 2: T4 → T5 → T6 → T7 +Phase 3: T8 → T9 → T10 +``` + +## Diagram-Definition Cross-Check + +| Task | Depends On (task body) | Diagram Shows | Status | +| --- | --- | --- | --- | +| T1 | None | start | ✅ | +| T2 | T1 | T1 → T2 | ✅ | +| T3 | T2 | T2 → T3 | ✅ | +| T4 | None (previous phase) | phase start | ✅ | +| T5 | T4 | T4 → T5 | ✅ | +| T6 | T5 | T5 → T6 | ✅ | +| T7 | T6 | T6 → T7 | ✅ | +| T8 | None (previous phase) | phase start | ✅ | +| T9 | T8 | T8 → T9 | ✅ | +| T10 | T9 | T9 → T10 | ✅ | + +## Test Co-location Validation + +| Task | Code Layer Created/Modified | Matrix Requires | Task Says | Status | +| --- | --- | --- | --- | --- | +| T1 | Token store | unit | unit | ✅ | +| T2 | Web security | integration | integration | ✅ | +| T3 | Web server | integration | integration | ✅ | +| T4 | Config resolver | unit | unit | ✅ | +| T5 | Web child | unit | unit | ✅ | +| T6 | Supervisor | unit | unit | ✅ | +| T7 | Daemon entry | unit | unit | ✅ | +| T8 | CLI launcher | unit | unit | ✅ | +| T9 | CLI commands | unit | unit | ✅ | +| T10 | Gate scripts, docs | none | none | ✅ | From 576957fb977f29e4cc36b40aff8321285c7f0253 Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Thu, 24 Sep 2026 22:33:25 -0300 Subject: [PATCH 05/17] feat(web): Persist the web console token --- .specs/features/web-access/spec.md | 8 +-- .specs/features/web-access/tasks.md | 13 +++-- src/web/web-token.ts | 56 +++++++++++++++++++ tests/web-token.test.ts | 87 +++++++++++++++++++++++++++++ 4 files changed, 154 insertions(+), 10 deletions(-) create mode 100644 src/web/web-token.ts create mode 100644 tests/web-token.test.ts diff --git a/.specs/features/web-access/spec.md b/.specs/features/web-access/spec.md index 9aa5027..e353083 100644 --- a/.specs/features/web-access/spec.md +++ b/.specs/features/web-access/spec.md @@ -190,10 +190,10 @@ Tests and docs that pin the old values change with this feature: tests/web-secur | Requirement ID | Story | Phase | Status | | --- | --- | --- | --- | -| WA-01 | P1: Bookmark survives restarts | Tasks | Pending | -| WA-02 | P1: Bookmark survives restarts | Tasks | Pending | -| WA-03 | P1: Bookmark survives restarts | Tasks | Pending | -| WA-04 | P1: Bookmark survives restarts | Tasks | Pending | +| WA-01 | P1: Bookmark survives restarts | Tasks | Verified | +| WA-02 | P1: Bookmark survives restarts | Tasks | Verified | +| WA-03 | P1: Bookmark survives restarts | Tasks | Verified | +| WA-04 | P1: Bookmark survives restarts | Tasks | Verified | | WA-05 | P1: Bookmark survives restarts | Tasks | Pending | | WA-06 | P1: Bookmark survives restarts | Tasks | Pending | | WA-07 | P1: Bookmark survives restarts | Tasks | Pending | diff --git a/.specs/features/web-access/tasks.md b/.specs/features/web-access/tasks.md index 7ab7591..c72d680 100644 --- a/.specs/features/web-access/tasks.md +++ b/.specs/features/web-access/tasks.md @@ -71,15 +71,16 @@ T8 → T9 → T10 **Done when**: -- [ ] Valid file → returns its token unchanged (with and without trailing newline) -- [ ] Missing file → creates a 64-hex token with mode 0600 and returns it; no temp file left behind -- [ ] Malformed file (short, uppercase, extra text) → replaced; the returned token equals the file content -- [ ] Race seam: a file appears between the temp write and `link` → `EEXIST` path returns the other token, which equals the file content -- [ ] Mode 0644 file → mode becomes 0600, token kept -- [ ] Gate check passes: `npx vitest run tests/web-token.test.ts`; `npx tsc --noEmit` +- [x] Valid file → returns its token unchanged (with and without trailing newline) +- [x] Missing file → creates a 64-hex token with mode 0600 and returns it; no temp file left behind +- [x] Malformed file (short, uppercase, extra text) → replaced; the returned token equals the file content +- [x] Race seam: a file appears between the temp write and `link` → `EEXIST` path returns the other token, which equals the file content +- [x] Mode 0644 file → mode becomes 0600, token kept +- [x] Gate check passes: `npx vitest run tests/web-token.test.ts`; `npx tsc --noEmit` **Tests**: unit **Gate**: quick +**Status**: ✅ Done **Commit**: `feat(web): Persist the web console token` diff --git a/src/web/web-token.ts b/src/web/web-token.ts new file mode 100644 index 0000000..3043821 --- /dev/null +++ b/src/web/web-token.ts @@ -0,0 +1,56 @@ +import { randomBytes } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { getPaths } from "../config/paths.js"; + +const TOKEN_PATTERN = /^[0-9a-f]{64}\n?$/; + +export interface ResolveWebTokenOptions { + /** Directory holding `web-token`; defaults to the run-agent base directory. */ + dir?: string; + /** Seam for the publishing `link`, so a test can lose the creation race on purpose. */ + link?: (existing: string, target: string) => void; +} + +/** + * The console token every web server on this machine shares, so a browser + * cookie survives web child and daemon restarts. A new token is written to a + * temp file and published with `link`, which never exposes a partial file. + */ +export function resolveWebToken(options: ResolveWebTokenOptions = {}): string { + const file = path.join(options.dir ?? getPaths().base, "web-token"); + const stored = readToken(file); + if (stored) return stored; + + fs.mkdirSync(path.dirname(file), { recursive: true }); + const tmp = `${file}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`; + fs.writeFileSync(tmp, `${randomBytes(32).toString("hex")}\n`, { mode: 0o600, flag: "wx" }); + try { + try { + (options.link ?? fs.linkSync)(tmp, file); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + const winner = readToken(file); + if (winner) return winner; + fs.renameSync(tmp, file); + } + const token = readToken(file); + if (!token) throw new Error(`could not store the web token in ${file}`); + return token; + } finally { + fs.rmSync(tmp, { force: true }); + } +} + +function readToken(file: string): string | undefined { + let raw: string; + try { + raw = fs.readFileSync(file, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw error; + } + if (!TOKEN_PATTERN.test(raw)) return undefined; + if ((fs.statSync(file).mode & 0o077) !== 0) fs.chmodSync(file, 0o600); + return raw.slice(0, 64); +} diff --git a/tests/web-token.test.ts b/tests/web-token.test.ts new file mode 100644 index 0000000..6668fe3 --- /dev/null +++ b/tests/web-token.test.ts @@ -0,0 +1,87 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { resolveWebToken } from "../src/web/web-token.js"; + +const TOKEN_A = "a".repeat(64); +const TOKEN_B = "0123456789abcdef".repeat(4); +const dirs: string[] = []; + +function tempDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "codedeck-web-token-")); + dirs.push(dir); + return dir; +} + +const tokenFile = (dir: string) => path.join(dir, "web-token"); +const modeOf = (file: string) => fs.statSync(file).mode & 0o777; + +afterEach(() => { + for (const dir of dirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); +}); + +describe("resolveWebToken", () => { + it.each([ + ["without a trailing newline", TOKEN_A], + ["with a trailing newline", `${TOKEN_A}\n`], + ])("returns a valid stored token %s unchanged", (_label, content) => { + const dir = tempDir(); + fs.writeFileSync(tokenFile(dir), content, { mode: 0o600 }); + + expect(resolveWebToken({ dir })).toBe(TOKEN_A); + expect(fs.readFileSync(tokenFile(dir), "utf8")).toBe(content); + }); + + it("creates a 64-character hex token with mode 0600 when the file is missing, leaving no temp file", () => { + const dir = tempDir(); + + const token = resolveWebToken({ dir }); + + expect(token).toMatch(/^[0-9a-f]{64}$/); + expect(fs.readFileSync(tokenFile(dir), "utf8").trim()).toBe(token); + expect(modeOf(tokenFile(dir))).toBe(0o600); + expect(fs.readdirSync(dir)).toEqual(["web-token"]); + expect(resolveWebToken({ dir })).toBe(token); + }); + + it.each([ + ["too short", "abc123"], + ["uppercase", "A".repeat(64)], + ["extra text", `${TOKEN_A} extra`], + ["empty", ""], + ])("replaces a malformed file (%s) and returns the token the file holds", (_label, content) => { + const dir = tempDir(); + fs.writeFileSync(tokenFile(dir), content, { mode: 0o600 }); + + const token = resolveWebToken({ dir }); + + expect(token).toMatch(/^[0-9a-f]{64}$/); + expect(fs.readFileSync(tokenFile(dir), "utf8").trim()).toBe(token); + expect(modeOf(tokenFile(dir))).toBe(0o600); + expect(fs.readdirSync(dir)).toEqual(["web-token"]); + }); + + it("serves the token another process published between its temp write and its link", () => { + const dir = tempDir(); + const link = (existing: string, target: string) => { + fs.writeFileSync(target, `${TOKEN_B}\n`, { mode: 0o600 }); + fs.linkSync(existing, target); + }; + + const token = resolveWebToken({ dir, link }); + + expect(token).toBe(TOKEN_B); + expect(fs.readFileSync(tokenFile(dir), "utf8").trim()).toBe(TOKEN_B); + expect(fs.readdirSync(dir)).toEqual(["web-token"]); + }); + + it("tightens a group- or world-readable token file to 0600 and keeps the token", () => { + const dir = tempDir(); + fs.writeFileSync(tokenFile(dir), TOKEN_A); + fs.chmodSync(tokenFile(dir), 0o644); + + expect(resolveWebToken({ dir })).toBe(TOKEN_A); + expect(modeOf(tokenFile(dir))).toBe(0o600); + }); +}); From 8360af7801edcddc726b01b77c9d678d8cba661b Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Thu, 24 Sep 2026 22:34:10 -0300 Subject: [PATCH 06/17] feat(web): Keep the console cookie and canonicalize the host --- .specs/features/web-access/spec.md | 10 +++---- .specs/features/web-access/tasks.md | 13 ++++----- src/web/security.ts | 34 ++++++++++++++++++++---- tests/web-security.test.ts | 41 ++++++++++++++++++++++++++--- 4 files changed, 78 insertions(+), 20 deletions(-) diff --git a/.specs/features/web-access/spec.md b/.specs/features/web-access/spec.md index e353083..0c68e0f 100644 --- a/.specs/features/web-access/spec.md +++ b/.specs/features/web-access/spec.md @@ -195,11 +195,11 @@ Tests and docs that pin the old values change with this feature: tests/web-secur | WA-03 | P1: Bookmark survives restarts | Tasks | Verified | | WA-04 | P1: Bookmark survives restarts | Tasks | Verified | | WA-05 | P1: Bookmark survives restarts | Tasks | Pending | -| WA-06 | P1: Bookmark survives restarts | Tasks | Pending | -| WA-07 | P1: Bookmark survives restarts | Tasks | Pending | -| WA-08 | P1: Bookmark survives restarts | Tasks | Pending | -| WA-09 | P1: Bookmark survives restarts | Tasks | Pending | -| WA-10 | P1: Bookmark survives restarts | Tasks | Pending | +| WA-06 | P1: Bookmark survives restarts | Tasks | Verified | +| WA-07 | P1: Bookmark survives restarts | Tasks | Verified | +| WA-08 | P1: Bookmark survives restarts | Tasks | Verified | +| WA-09 | P1: Bookmark survives restarts | Tasks | Verified | +| WA-10 | P1: Bookmark survives restarts | Tasks | Verified | | WA-11 | P1: Console answers while the daemon runs | Tasks | Pending | | WA-12 | P1: Console answers while the daemon runs | Tasks | Pending | | WA-13 | P1: Console answers while the daemon runs | Tasks | Pending | diff --git a/.specs/features/web-access/tasks.md b/.specs/features/web-access/tasks.md index c72d680..c0efb1d 100644 --- a/.specs/features/web-access/tasks.md +++ b/.specs/features/web-access/tasks.md @@ -96,15 +96,16 @@ T8 → T9 → T10 **Done when**: -- [ ] Valid `?t=` → 303 with the exact WA-06 `Set-Cookie` -- [ ] Valid cookie, no `t` and invalid `t` → page served with the same `Set-Cookie` -- [ ] `localhost` Host page GET with and without `t` → 302 to the `127.0.0.1` URL keeping the query; an absolute-form request target does not leak its host into `Location` -- [ ] No cookie, no `t` on `127.0.0.1` → 403 with the WA-08 body -- [ ] Existing rejections (foreign Host, POST without cookie/Origin, `/api/*` without cookie) still answer 403 `forbidden` -- [ ] Gate check passes: `npx vitest run tests/web-security.test.ts`; `npx vitest run tests/web-server.test.ts`; `npx tsc --noEmit` +- [x] Valid `?t=` → 303 with the exact WA-06 `Set-Cookie` +- [x] Valid cookie, no `t` and invalid `t` → page served with the same `Set-Cookie` +- [x] `localhost` Host page GET with and without `t` → 302 to the `127.0.0.1` URL keeping the query; an absolute-form request target does not leak its host into `Location` +- [x] No cookie, no `t` on `127.0.0.1` → 403 with the WA-08 body +- [x] Existing rejections (foreign Host, POST without cookie/Origin, `/api/*` without cookie) still answer 403 `forbidden` +- [x] Gate check passes: `npx vitest run tests/web-security.test.ts`; `npx vitest run tests/web-server.test.ts`; `npx tsc --noEmit` **Tests**: integration **Gate**: quick +**Status**: ✅ Done **Commit**: `feat(web): Keep the console cookie and canonicalize the host` diff --git a/src/web/security.ts b/src/web/security.ts index e075764..f280aaa 100644 --- a/src/web/security.ts +++ b/src/web/security.ts @@ -13,7 +13,9 @@ export interface WebRoutePolicy { } export const WEB_FORBIDDEN_MESSAGE = "forbidden"; -export const WEB_PAGE_FORBIDDEN_MESSAGE = "open this page with codedeck ui"; +export const WEB_PAGE_FORBIDDEN_MESSAGE = 'Run "codedeck ui" once in a terminal to open CodeDeck in this browser.'; +/** One year: a bookmark keeps working as long as the page is opened at least once a year. */ +export const WEB_COOKIE_MAX_AGE_SECONDS = 31_536_000; export function createWebSecurity(port: number): WebSecurity { const token = randomBytes(32).toString("hex"); @@ -60,11 +62,13 @@ export function checkWebRequest( if (policy.htmlPage) { response.setHeader("Content-Security-Policy", "frame-ancestors 'none'"); if (request.method === "GET") { + if (redirectToCanonicalHost(request, response, security)) return false; if (redirectWithSessionCookie(request, response, security)) return false; if (!hasSessionCookie(request, security)) { reject(response, WEB_PAGE_FORBIDDEN_MESSAGE); return false; } + response.setHeader("Set-Cookie", sessionCookie(security)); } } @@ -114,15 +118,35 @@ function redirectWithSessionCookie( if (tokens.length !== 1 || tokens[0] !== security.token) return false; url.searchParams.delete("t"); - response.setHeader( - "Set-Cookie", - `${security.cookieName}=${security.token}; Path=/; HttpOnly; SameSite=Strict`, - ); + response.setHeader("Set-Cookie", sessionCookie(security)); response.writeHead(303, { Location: `${url.pathname}${url.search}` }); response.end(); return true; } +function sessionCookie(security: WebSecurity): string { + return `${security.cookieName}=${security.token}; Path=/; Max-Age=${WEB_COOKIE_MAX_AGE_SECONDS}; HttpOnly; SameSite=Strict`; +} + +// A cookie set for 127.0.0.1 is never sent to localhost (RFC 6265 5.1.3), so +// pages live on 127.0.0.1 only and a localhost bookmark is sent there. +function redirectToCanonicalHost( + request: IncomingMessage, + response: ServerResponse, + security: WebSecurity, +): boolean { + if (request.headers.host?.toLowerCase() !== `localhost:${security.port}`) return false; + let url: URL; + try { + url = new URL(request.url || "/", `http://127.0.0.1:${security.port}`); + } catch { + return false; + } + response.writeHead(302, { Location: `http://127.0.0.1:${security.port}${url.pathname}${url.search}` }); + response.end(); + return true; +} + function reject(response: ServerResponse, message = WEB_FORBIDDEN_MESSAGE): void { response.writeHead(403, { "content-type": "text/plain; charset=utf-8" }); response.end(message); diff --git a/tests/web-security.test.ts b/tests/web-security.test.ts index 3749eda..66ff492 100644 --- a/tests/web-security.test.ts +++ b/tests/web-security.test.ts @@ -10,6 +10,7 @@ interface ResponseValue { } const handles: WebServerHandle[] = []; +const PAGE_FORBIDDEN = 'Run "codedeck ui" once in a terminal to open CodeDeck in this browser.'; afterEach(async () => { await Promise.all(handles.splice(0).map((handle) => handle.close())); @@ -86,7 +87,7 @@ describe("web request security", () => { expect(handle.security.token).toMatch(/^[0-9a-f]{64}$/); const cookie = `codedeck_ui_token_${handle.port}=${handle.security.token}`; - const accepted = await request(handle, { path: "/page", host: `LOCALHOST:${handle.port}`, cookie }); + const accepted = await request(handle, { path: "/action", host: `LOCALHOST:${handle.port}`, cookie }); expect(accepted.status).toBe(200); const acceptedIp = await request(handle, { path: "/page", host: `127.0.0.1:${handle.port}`, cookie }); @@ -115,7 +116,7 @@ describe("web request security", () => { expect(response.status).toBe(303); expect(response.headers.location).toBe("/page?keep=yes"); expect(response.headers["set-cookie"]).toEqual([ - `codedeck_ui_token_${handle.port}=${handle.security.token}; Path=/; HttpOnly; SameSite=Strict`, + `codedeck_ui_token_${handle.port}=${handle.security.token}; Path=/; Max-Age=31536000; HttpOnly; SameSite=Strict`, ]); }); @@ -137,7 +138,7 @@ describe("web request security", () => { for (const path of ["/page", "/page?t=stale-token"]) { const response = await request(handle, { path, host: `127.0.0.1:${handle.port}` }); expect(response.status).toBe(403); - expect(response.body).toBe("open this page with codedeck ui"); + expect(response.body).toBe(PAGE_FORBIDDEN); expect(response.headers["set-cookie"]).toBeUndefined(); expect(response.headers["content-security-policy"]).toBe("frame-ancestors 'none'"); } @@ -147,7 +148,7 @@ describe("web request security", () => { cookie: `codedeck_ui_token_${handle.port}=stale`, }); expect(stale.status).toBe(403); - expect(stale.body).toBe("open this page with codedeck ui"); + expect(stale.body).toBe(PAGE_FORBIDDEN); expect(calls).toEqual([]); }); @@ -165,6 +166,38 @@ describe("web request security", () => { expect(calls).toEqual(["page"]); }); + it("renews the year-long cookie on a page GET served with a valid cookie, with or without an invalid t", async () => { + const { handle, calls } = await makeServer(); + const cookie = `codedeck_ui_token_${handle.port}=${handle.security.token}`; + + for (const path of ["/page", "/page?t=stale-token"]) { + const response = await request(handle, { path, host: `127.0.0.1:${handle.port}`, cookie }); + expect(response.status).toBe(200); + expect(response.headers["set-cookie"]).toEqual([ + `codedeck_ui_token_${handle.port}=${handle.security.token}; Path=/; Max-Age=31536000; HttpOnly; SameSite=Strict`, + ]); + } + expect(calls).toEqual(["page", "page"]); + }); + + it("sends a localhost page GET to 127.0.0.1 with its path and query before any credential check", async () => { + const { handle, calls } = await makeServer(); + const base = `http://127.0.0.1:${handle.port}`; + + const bare = await request(handle, { path: "/page?repo=%2Fx", host: `LocalHost:${handle.port}` }); + const withToken = await request(handle, { path: `/page?t=${handle.security.token}`, host: `localhost:${handle.port}` }); + const absolute = await request(handle, { path: "http://evil.test/page?keep=1", host: `localhost:${handle.port}` }); + + expect(bare.status).toBe(302); + expect(bare.headers.location).toBe(`${base}/page?repo=%2Fx`); + expect(bare.headers["set-cookie"]).toBeUndefined(); + expect(withToken.status).toBe(302); + expect(withToken.headers.location).toBe(`${base}/page?t=${handle.security.token}`); + expect(absolute.status).toBe(302); + expect(absolute.headers.location).toBe(`${base}/page?keep=1`); + expect(calls).toEqual([]); + }); + it("rejects API GETs without the current cookie before dispatch", async () => { const { handle, calls } = await makeServer(); const host = `127.0.0.1:${handle.port}`; From 8878c6186bb4dc65b652d323b033f3cef3105dcc Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Thu, 24 Sep 2026 22:35:04 -0300 Subject: [PATCH 07/17] feat(web): Serve on 7777 with an injectable token --- .specs/features/web-access/tasks.md | 14 +++---- src/web/security.ts | 3 +- src/web/server.ts | 12 ++++-- tests/review-command.test.ts | 4 +- tests/web-launch.test.ts | 2 +- tests/web-server.test.ts | 58 +++++++++++++++++++++++++++-- 6 files changed, 75 insertions(+), 18 deletions(-) diff --git a/.specs/features/web-access/tasks.md b/.specs/features/web-access/tasks.md index c0efb1d..5658623 100644 --- a/.specs/features/web-access/tasks.md +++ b/.specs/features/web-access/tasks.md @@ -114,21 +114,22 @@ T8 → T9 → T10 ### T3: Accept an injected token and fall back on any listen error **What**: `listenWebServer` / `startWebServer` take an optional `token`; `createWebSecurity(port, token?)` keeps a random default. With `fallbackToEphemeral`, any listen error retries on port 0. `DEFAULT_WEB_PORT` becomes 7777. -**Where**: `src/web/server.ts`, `src/web/security.ts` +**Where**: `src/web/server.ts`, `src/web/security.ts`; the 3100 pins in `tests/review-command.test.ts` and `tests/web-launch.test.ts` move with the constant **Depends on**: T2 **Reuses**: existing `listen` helper **Requirement**: WA-01, WA-18, WA-21 **Done when**: -- [ ] Injected token → served and required by the cookie check; omitted → random 64-hex token -- [ ] `fallbackToEphemeral` + `EADDRINUSE` and + a non-`EADDRINUSE` listen error (injected server factory) → listens on an OS port -- [ ] No fallback → the listen error propagates -- [ ] `DEFAULT_WEB_PORT` is 7777 -- [ ] Gate check passes: `npx vitest run tests/web-server.test.ts`; `npx vitest run tests/web-security.test.ts`; `npx tsc --noEmit` +- [x] Injected token → served and required by the cookie check; omitted → random 64-hex token +- [x] `fallbackToEphemeral` + `EADDRINUSE` and + a non-`EADDRINUSE` listen error (injected server factory) → listens on an OS port +- [x] No fallback → the listen error propagates +- [x] `DEFAULT_WEB_PORT` is 7777 +- [x] Gate check passes: `npx vitest run tests/web-server.test.ts`; `npx vitest run tests/web-security.test.ts`; `npx tsc --noEmit` **Tests**: integration **Gate**: full +**Status**: ✅ Done **Commit**: `feat(web): Serve on 7777 with an injectable token` @@ -264,7 +265,6 @@ T8 → T9 → T10 **Done when**: - [ ] Each command's help contains the WA-23 text -- [ ] `parseReviewPort(undefined)` is 7777 - [ ] Gate check passes: `npx vitest run tests/web-cli.test.ts`; `npx vitest run tests/review-command.test.ts`; `npx tsc --noEmit` **Tests**: unit diff --git a/src/web/security.ts b/src/web/security.ts index f280aaa..18dac3e 100644 --- a/src/web/security.ts +++ b/src/web/security.ts @@ -17,8 +17,7 @@ export const WEB_PAGE_FORBIDDEN_MESSAGE = 'Run "codedeck ui" once in a terminal /** One year: a bookmark keeps working as long as the page is opened at least once a year. */ export const WEB_COOKIE_MAX_AGE_SECONDS = 31_536_000; -export function createWebSecurity(port: number): WebSecurity { - const token = randomBytes(32).toString("hex"); +export function createWebSecurity(port: number, token = randomBytes(32).toString("hex")): WebSecurity { return { port, token, diff --git a/src/web/server.ts b/src/web/server.ts index e6c8cb3..f75b5f4 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -5,7 +5,7 @@ import { EventEmitter } from "node:events"; import { InvalidArgumentError } from "commander"; import { checkWebRequest, createWebSecurity, getTokenUrl, type WebSecurity } from "./security.js"; -export const DEFAULT_WEB_PORT = 3100; +export const DEFAULT_WEB_PORT = 7777; export interface WebRoute { path: string; @@ -24,6 +24,8 @@ export interface WebServerOptions { log?: (message: string) => void; serverFactory?: (handler: RequestListener) => Server; fallbackToEphemeral?: boolean; + /** Token the server accepts; a fresh random one when omitted. */ + token?: string; signalTarget?: EventEmitter; closeServer?: () => Promise | void; exit?: (code: number) => void; @@ -38,7 +40,10 @@ export interface CreateWebServerOptions { export interface ListenWebServerOptions { routes: readonly WebRoute[]; port?: number; + /** Retry on an OS-assigned port after any listen error on `port`. */ fallbackToEphemeral?: boolean; + /** Token the server accepts; a fresh random one when omitted. */ + token?: string; serverFactory?: (handler: RequestListener) => Server; } @@ -123,7 +128,7 @@ export async function listenWebServer(options: ListenWebServerOptions): Promise< try { await listen(server, requestedPort); } catch (error) { - if (!options.fallbackToEphemeral || (error as NodeJS.ErrnoException).code !== "EADDRINUSE") throw error; + if (!options.fallbackToEphemeral) throw error; await listen(server, 0); } @@ -133,7 +138,7 @@ export async function listenWebServer(options: ListenWebServerOptions): Promise< throw new Error("Web server did not return a TCP address"); } - security = createWebSecurity(address.port); + security = createWebSecurity(address.port, options.token); let closing: Promise | undefined; return { server, @@ -169,6 +174,7 @@ export async function startWebServer(options: WebServerOptions): Promise vi.restoreAllMocks()); describe("parseReviewPort", () => { - it("defaults to 3100", () => { - expect(parseReviewPort(undefined)).toBe(3100); + it("defaults to 7777", () => { + expect(parseReviewPort(undefined)).toBe(7777); }); it("accepts a valid port", () => { diff --git a/tests/web-launch.test.ts b/tests/web-launch.test.ts index 9a9d564..ecc5588 100644 --- a/tests/web-launch.test.ts +++ b/tests/web-launch.test.ts @@ -123,7 +123,7 @@ describe("launchWebPage", () => { expect(initial.pathname).toBe("/review"); expect(initial.searchParams.get("repo")).toBe("/work/my app&co"); expect(options.fallbackToEphemeral).toBe(true); - expect(options.port).toBe(3100); + expect(options.port).toBe(7777); }); it("keeps an explicit port in the fallback and adds no ? for an empty query", async () => { diff --git a/tests/web-server.test.ts b/tests/web-server.test.ts index d04f982..7445a87 100644 --- a/tests/web-server.test.ts +++ b/tests/web-server.test.ts @@ -41,9 +41,9 @@ function testRoutes(seen: string[]): WebRoute[] { } describe("parseWebPort", () => { - it("defaults to 3100 and accepts integer ports in range", () => { - expect(DEFAULT_WEB_PORT).toBe(3100); - expect(parseWebPort(undefined)).toBe(3100); + it("defaults to 7777 and accepts integer ports in range", () => { + expect(DEFAULT_WEB_PORT).toBe(7777); + expect(parseWebPort(undefined)).toBe(7777); expect(parseWebPort("8080")).toBe(8080); expect(parseWebPort("65535")).toBe(65535); }); @@ -259,6 +259,58 @@ describe("listenWebServer", () => { expect(listening.port).toBeGreaterThan(0); }); + it("falls back to another port after a listen error other than EADDRINUSE when fallback is on", async () => { + const serverFactory = (handler: http.RequestListener) => { + const server = http.createServer({ requireHostHeader: false }, handler); + const realListen = server.listen.bind(server) as (...args: unknown[]) => http.Server; + let refused = false; + server.listen = ((...args: unknown[]) => { + if (refused) return realListen(...args); + refused = true; + process.nextTick(() => server.emit("error", Object.assign(new Error("listen EACCES: permission denied"), { code: "EACCES" }))); + return server; + }) as typeof server.listen; + return server; + }; + + const listening = await listenWebServer({ routes: testRoutes([]), port: 80, fallbackToEphemeral: true, serverFactory }); + listeners.push(listening); + + expect(listening.port).not.toBe(80); + expect(listening.port).toBeGreaterThan(0); + }); + + it("serves with an injected token and draws a random one otherwise", async () => { + const token = "c".repeat(64); + const injected = await listenWebServer({ routes: testRoutes([]), port: 0, token }); + const drawn = await listenWebServer({ routes: testRoutes([]), port: 0 }); + listeners.push(injected, drawn); + + expect(injected.security.token).toBe(token); + expect(drawn.security.token).toMatch(/^[0-9a-f]{64}$/); + expect(drawn.security.token).not.toBe(token); + const accepted = await fetch(`${injected.baseUrl}/api/test`, { headers: { cookie: `codedeck_ui_token_${injected.port}=${token}` } }); + expect(accepted.status).toBe(200); + }); + + it("passes an injected token through startWebServer", async () => { + const token = "d".repeat(64); + const handle = await startWebServer({ + routes: testRoutes([]), + port: 0, + token, + initialPath: "/", + open: false, + log: vi.fn(), + signalTarget: new EventEmitter(), + exit: vi.fn(), + }); + handles.push(handle); + + expect(handle.security.token).toBe(token); + expect(new URL(handle.initialUrl).searchParams.get("t")).toBe(token); + }); + it("rejects with the listen error when the port is busy and fallback is off", async () => { const port = await busyPort(); From 82c94680fd9f6022be19908c775309293354a3ac Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Thu, 24 Sep 2026 22:35:31 -0300 Subject: [PATCH 08/17] feat(config): Add the web.port setting --- .specs/features/web-access/spec.md | 2 +- .specs/features/web-access/tasks.md | 11 ++++++----- src/config/config.ts | 2 ++ src/config/web-port.ts | 27 ++++++++++++++++++++++++++ src/web/server.ts | 4 +++- tests/web-port.test.ts | 30 +++++++++++++++++++++++++++++ 6 files changed, 69 insertions(+), 7 deletions(-) create mode 100644 src/config/web-port.ts create mode 100644 tests/web-port.test.ts diff --git a/.specs/features/web-access/spec.md b/.specs/features/web-access/spec.md index 0c68e0f..23d20ae 100644 --- a/.specs/features/web-access/spec.md +++ b/.specs/features/web-access/spec.md @@ -204,7 +204,7 @@ Tests and docs that pin the old values change with this feature: tests/web-secur | WA-12 | P1: Console answers while the daemon runs | Tasks | Pending | | WA-13 | P1: Console answers while the daemon runs | Tasks | Pending | | WA-14 | P1: Console answers while the daemon runs | Tasks | Pending | -| WA-15 | P2: Fixed, configurable port | Tasks | Pending | +| WA-15 | P2: Fixed, configurable port | Tasks | Verified | | WA-16 | P2: Fixed, configurable port | Tasks | Pending | | WA-17 | P2: Fixed, configurable port | Tasks | Pending | | WA-18 | P2: Fixed, configurable port | Tasks | Pending | diff --git a/.specs/features/web-access/tasks.md b/.specs/features/web-access/tasks.md index 5658623..6538c90 100644 --- a/.specs/features/web-access/tasks.md +++ b/.specs/features/web-access/tasks.md @@ -138,20 +138,21 @@ T8 → T9 → T10 ### T4: Resolve the preferred web port from config **What**: `resolveWebPort(config)` returns `{ port, invalid? }`: `web.port` when an integer in 1-65535, else 7777, with `invalid` holding the raw value when `web.port` is present but bad; plus the `Ignoring invalid web.port in config: ` formatter. Adds `web?: { port?: number }` to `RunAgentConfig`. -**Where**: `src/config/web-port.ts` (new), `src/config/config.ts` +**Where**: `src/config/web-port.ts` (new), `src/config/config.ts`; `src/web/server.ts` re-exports `DEFAULT_WEB_PORT` from it so daemon code can resolve the port without reaching `src/web` **Depends on**: None (previous phase) **Reuses**: `isJsonObject` style guards in `src/config/config.ts` **Requirement**: WA-15, WA-22 **Done when**: -- [ ] Absent `web` / absent `web.port` → 7777, no `invalid` -- [ ] `web.port` 7788 → 7788; 1 and 65535 accepted -- [ ] `"7788"`, 0, 65536, 7.5, `web: "x"` → 7777 with `invalid` set; message renders the JSON value -- [ ] Gate check passes: `npx vitest run tests/web-port.test.ts`; `npx tsc --noEmit` +- [x] Absent `web` / absent `web.port` → 7777, no `invalid` +- [x] `web.port` 7788 → 7788; 1 and 65535 accepted +- [x] `"7788"`, 0, 65536, 7.5, `web: "x"` → 7777 with `invalid` set; message renders the JSON value +- [x] Gate check passes: `npx vitest run tests/web-port.test.ts`; `npx tsc --noEmit` **Tests**: unit **Gate**: quick +**Status**: ✅ Done **Commit**: `feat(config): Add the web.port setting` diff --git a/src/config/config.ts b/src/config/config.ts index 057149f..e8d4d87 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -59,6 +59,8 @@ export interface RunAgentConfig { models?: Partial>; agents?: Partial>; orchestrator?: OrchestratorMode; + /** Web console settings; `port` is the preferred console port (default 7777). */ + web?: { port?: number }; } /** diff --git a/src/config/web-port.ts b/src/config/web-port.ts new file mode 100644 index 0000000..aad8eea --- /dev/null +++ b/src/config/web-port.ts @@ -0,0 +1,27 @@ +/** Port of the web console when `web.port` does not name another. */ +export const DEFAULT_WEB_PORT = 7777; + +export interface WebPortResolution { + port: number; + /** The raw `web.port` value when it is present but not a usable port. */ + invalid?: unknown; +} + +/** + * The preferred console port: `web.port` when it is an integer in 1-65535, + * else 7777. The daemon and the CLI both resolve it here; the web child only + * receives the result as an argument. + */ +export function resolveWebPort(config: { web?: unknown }): WebPortResolution { + const web = config.web; + if (web === undefined) return { port: DEFAULT_WEB_PORT }; + if (typeof web !== "object" || web === null || Array.isArray(web)) return { port: DEFAULT_WEB_PORT, invalid: web }; + const raw = (web as { port?: unknown }).port; + if (raw === undefined) return { port: DEFAULT_WEB_PORT }; + if (typeof raw === "number" && Number.isInteger(raw) && raw >= 1 && raw <= 65535) return { port: raw }; + return { port: DEFAULT_WEB_PORT, invalid: raw }; +} + +export function invalidWebPortMessage(value: unknown): string { + return `Ignoring invalid web.port in config: ${JSON.stringify(value)}`; +} diff --git a/src/web/server.ts b/src/web/server.ts index f75b5f4..5541fea 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -5,7 +5,9 @@ import { EventEmitter } from "node:events"; import { InvalidArgumentError } from "commander"; import { checkWebRequest, createWebSecurity, getTokenUrl, type WebSecurity } from "./security.js"; -export const DEFAULT_WEB_PORT = 7777; +import { DEFAULT_WEB_PORT } from "../config/web-port.js"; + +export { DEFAULT_WEB_PORT }; export interface WebRoute { path: string; diff --git a/tests/web-port.test.ts b/tests/web-port.test.ts new file mode 100644 index 0000000..4786d4d --- /dev/null +++ b/tests/web-port.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { invalidWebPortMessage, resolveWebPort } from "../src/config/web-port.js"; + +describe("resolveWebPort", () => { + it.each([ + ["no web section", {}], + ["a web section without port", { web: {} }], + ])("prefers 7777 with %s", (_label, config) => { + expect(resolveWebPort(config)).toEqual({ port: 7777 }); + }); + + it.each([7788, 1, 65535])("prefers a valid web.port %s", (port) => { + expect(resolveWebPort({ web: { port } })).toEqual({ port }); + }); + + it.each([ + ["a numeric string", { web: { port: "7788" } }, "7788"], + ["zero", { web: { port: 0 } }, 0], + ["65536", { web: { port: 65536 } }, 65536], + ["a fraction", { web: { port: 7.5 } }, 7.5], + ["a non-object web section", { web: "x" }, "x"], + ])("falls back to 7777 and reports %s as invalid", (_label, config, invalid) => { + expect(resolveWebPort(config)).toEqual({ port: 7777, invalid }); + }); + + it("renders the invalid value as JSON in the warning", () => { + expect(invalidWebPortMessage("7788")).toBe('Ignoring invalid web.port in config: "7788"'); + expect(invalidWebPortMessage(0)).toBe("Ignoring invalid web.port in config: 0"); + }); +}); From d4055aaaaef73e2c4134220e148680b45f3f1db9 Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Thu, 24 Sep 2026 22:36:09 -0300 Subject: [PATCH 09/17] feat(web): Start the web child on the preferred port --- .specs/features/web-access/spec.md | 2 +- .specs/features/web-access/tasks.md | 11 +++++---- src/web/child.ts | 31 ++++++++++++++++++----- tests/web-child.test.ts | 38 +++++++++++++++++++++++------ 4 files changed, 62 insertions(+), 20 deletions(-) diff --git a/.specs/features/web-access/spec.md b/.specs/features/web-access/spec.md index 23d20ae..a78933d 100644 --- a/.specs/features/web-access/spec.md +++ b/.specs/features/web-access/spec.md @@ -213,7 +213,7 @@ Tests and docs that pin the old values change with this feature: tests/web-secur | WA-21 | P2: Fixed, configurable port | Tasks | Pending | | WA-22 | P2: Fixed, configurable port | Tasks | Pending | | WA-23 | P2: Fixed, configurable port | Tasks | Pending | -| WA-24 | P2: Fixed, configurable port | Tasks | Pending | +| WA-24 | P2: Fixed, configurable port | Tasks | Verified | | WA-25 | P2: Fixed, configurable port | Tasks | Pending | **Coverage:** 25 total, 0 mapped to tasks, 25 unmapped ⚠️ diff --git a/.specs/features/web-access/tasks.md b/.specs/features/web-access/tasks.md index 6538c90..c638ddf 100644 --- a/.specs/features/web-access/tasks.md +++ b/.specs/features/web-access/tasks.md @@ -168,14 +168,15 @@ T8 → T9 → T10 **Done when**: -- [ ] `--preferred-port 7788` → listen called with port 7788 and fallback on -- [ ] `--port 7788` → port 7788, fallback off (unchanged) -- [ ] Neither → port 7777, fallback on -- [ ] The handshake token equals the injected token resolver's value, and the server was given that token -- [ ] Gate check passes: `npx vitest run tests/web-child.test.ts`; `npx tsc --noEmit` +- [x] `--preferred-port 7788` → listen called with port 7788 and fallback on +- [x] `--port 7788` → port 7788, fallback off (unchanged) +- [x] Neither → port 7777, fallback on +- [x] The handshake token equals the injected token resolver's value, and the server was given that token +- [x] Gate check passes: `npx vitest run tests/web-child.test.ts`; `npx tsc --noEmit` **Tests**: unit **Gate**: quick +**Status**: ✅ Done **Commit**: `feat(web): Start the web child on the preferred port` diff --git a/src/web/child.ts b/src/web/child.ts index 8544046..b5c6e43 100644 --- a/src/web/child.ts +++ b/src/web/child.ts @@ -3,10 +3,15 @@ import type { Readable, Writable } from "node:stream"; import { createUiRoutes } from "../cli/commands/ui.js"; import { computeBuildId, distRootFor } from "../daemon/build-id.js"; import { DEFAULT_WEB_PORT, listenWebServer, type WebRoute } from "./server.js"; +import { resolveWebToken } from "./web-token.js"; export interface RunWebChildOptions { - /** Explicit port; when omitted the child tries the default port and falls back to an ephemeral one. */ + /** Explicit port (`--port`): no fallback. */ port?: number; + /** Preferred port (`--preferred-port`): an OS-assigned port on any listen error. Defaults to 7777. */ + preferredPort?: number; + /** Seam for the shared console token; defaults to `~/.run-agent/web-token`. */ + resolveToken?: () => string; stdin: Readable; stdout: Writable; listen?: typeof listenWebServer; @@ -28,17 +33,19 @@ export async function runWebChild(options: RunWebChildOptions): Promise { const build = options.build ?? computeBuildId(options.distRoot ?? distRootFor(import.meta.url)); // The daemon may close the pipe after the handshake; a failed write must not crash the child. options.stdout.on("error", () => {}); + const port = options.port ?? options.preferredPort ?? DEFAULT_WEB_PORT; let listening: Awaited>; try { listening = await (options.listen ?? listenWebServer)({ routes: (options.routes ?? (() => createUiRoutes()))(), - port: options.port, + port, fallbackToEphemeral: options.port === undefined, + token: (options.resolveToken ?? resolveWebToken)(), }); } catch (error) { const message = error instanceof Error ? error.message : String(error); - const line = JSON.stringify({ error: { message, port: options.port ?? DEFAULT_WEB_PORT } }); + const line = JSON.stringify({ error: { message, port } }); options.stdout.write(`${line}\n`, () => exit(1)); return; } @@ -59,8 +66,20 @@ export async function runWebChild(options: RunWebChildOptions): Promise { options.stdout.write(`${JSON.stringify({ port: listening.port, token: listening.security.token, build })}\n`); } +/** `--port ` is explicit; `--preferred-port ` may fall back; neither means 7777 with fallback. */ +export function parseWebChildArgs(argv: readonly string[]): { port?: number; preferredPort?: number } { + const valueOf = (flag: string): number | undefined => { + const index = argv.indexOf(flag); + return index >= 0 ? Number(argv[index + 1]) : undefined; + }; + const port = valueOf("--port"); + const preferredPort = valueOf("--preferred-port"); + return { + ...(port === undefined ? {} : { port }), + ...(preferredPort === undefined ? {} : { preferredPort }), + }; +} + if (process.argv.includes("--web-child")) { - const portIndex = process.argv.indexOf("--port"); - const port = portIndex >= 0 ? Number(process.argv[portIndex + 1]) : undefined; - void runWebChild({ port, stdin: process.stdin, stdout: process.stdout }); + void runWebChild({ ...parseWebChildArgs(process.argv), stdin: process.stdin, stdout: process.stdout }); } diff --git a/tests/web-child.test.ts b/tests/web-child.test.ts index 16d7833..6e3148a 100644 --- a/tests/web-child.test.ts +++ b/tests/web-child.test.ts @@ -6,7 +6,7 @@ import { EventEmitter } from "node:events"; import { PassThrough, Writable } from "node:stream"; import { afterEach, describe, expect, it, vi } from "vitest"; import { computeBuildId } from "../src/daemon/build-id.js"; -import { runWebChild, type RunWebChildOptions } from "../src/web/child.js"; +import { parseWebChildArgs, runWebChild, type RunWebChildOptions } from "../src/web/child.js"; vi.mock("../src/daemon/build-id.js", async (importOriginal) => { const actual = await importOriginal(); @@ -17,6 +17,8 @@ import type { ListeningWebServer, WebRoute } from "../src/web/server.js"; interface Handshake { port: number; token: string; build: string } const cleanups: (() => void)[] = []; +const TOKEN = "e".repeat(64); +const resolveToken = () => TOKEN; afterEach(() => { for (const cleanup of cleanups.splice(0)) cleanup(); @@ -39,7 +41,7 @@ async function startChild(overrides: Partial = {}) { const signalTarget = new EventEmitter(); const exit = vi.fn(); const line = firstLine(stdout); - await runWebChild({ port: 0, stdin, stdout, exit, signalTarget, build: "build-1", ...overrides }); + await runWebChild({ port: 0, stdin, stdout, exit, signalTarget, build: "build-1", resolveToken, ...overrides }); const handshake = JSON.parse(await line) as Record; cleanups.push(() => stdin.end()); return { stdin, stdout, signalTarget, exit, handshake }; @@ -104,25 +106,45 @@ describe("runWebChild", () => { expect(handshake.build).toBe("own-tree"); }); - it("asks for the ephemeral fallback only when no port was given", async () => { + it("listens on the preferred port with fallback, on an explicit port without, and on 7777 with fallback by default", async () => { const listen = vi.fn(async () => fakeListening()); + await startChild({ port: undefined, preferredPort: 7788, listen, routes: () => [] }); + await startChild({ port: 7788, listen, routes: () => [] }); await startChild({ port: undefined, listen, routes: () => [] }); - await startChild({ port: 4567, listen, routes: () => [] }); expect(listen.mock.calls.map(([options]) => [options.port, options.fallbackToEphemeral])).toEqual([ - [undefined, true], - [4567, false], + [7788, true], + [7788, false], + [7777, true], ]); }); + it("serves with the resolved console token and reports it in the handshake", async () => { + const listen = vi.fn(async () => fakeListening()); + + const { handshake: served } = await startChild({ resolveToken: () => "f".repeat(64), routes: () => [] }); + await startChild({ listen, resolveToken: () => "f".repeat(64), routes: () => [] }); + + expect(served.token).toBe("f".repeat(64)); + expect(listen.mock.calls[0][0].token).toBe("f".repeat(64)); + }); + + it.each([ + [["node", "child.js", "--web-child", "--preferred-port", "7788"], { preferredPort: 7788 }], + [["node", "child.js", "--web-child", "--port", "7788"], { port: 7788 }], + [["node", "child.js", "--web-child"], {}], + ])("parses the port arguments of %j", (argv, expected) => { + expect(parseWebChildArgs(argv)).toEqual(expected); + }); + it("prints an error handshake and exits 1 when listening fails", async () => { const listen = vi.fn(async () => { throw new Error("listen EADDRINUSE: address already in use 127.0.0.1:4567"); }); const stdout = new PassThrough(); const exit = vi.fn(); const line = firstLine(stdout); - await runWebChild({ port: 4567, stdin: new PassThrough(), stdout, exit, listen, routes: () => [], build: "b" }); + await runWebChild({ port: 4567, stdin: new PassThrough(), stdout, exit, listen, routes: () => [], build: "b", resolveToken }); expect(JSON.parse(await line)).toEqual({ error: { message: "listen EADDRINUSE: address already in use 127.0.0.1:4567", port: 4567 }, @@ -163,7 +185,7 @@ describe("runWebChild", () => { const stdin = new PassThrough(); const exit = vi.fn(); - await runWebChild({ port: 0, stdin, stdout, exit, signalTarget: new EventEmitter(), build: "b", routes: () => [] }); + await runWebChild({ port: 0, stdin, stdout, exit, signalTarget: new EventEmitter(), build: "b", routes: () => [], resolveToken }); await new Promise((resolve) => setImmediate(resolve)); stdin.end(); From ef6d2d9662d7e6bb6fa53341c1e842cdc0fb7d49 Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Thu, 24 Sep 2026 22:37:08 -0300 Subject: [PATCH 10/17] feat(daemon): Restart the web child when the preferred port changes --- .specs/features/web-access/spec.md | 10 ++-- .specs/features/web-access/tasks.md | 19 ++++--- src/daemon/protocol.ts | 3 + src/daemon/web-supervisor.ts | 36 +++++++++--- tests/web-supervisor.test.ts | 86 +++++++++++++++++++++++++++++ 5 files changed, 133 insertions(+), 21 deletions(-) diff --git a/.specs/features/web-access/spec.md b/.specs/features/web-access/spec.md index a78933d..72e0f89 100644 --- a/.specs/features/web-access/spec.md +++ b/.specs/features/web-access/spec.md @@ -202,19 +202,19 @@ Tests and docs that pin the old values change with this feature: tests/web-secur | WA-10 | P1: Bookmark survives restarts | Tasks | Verified | | WA-11 | P1: Console answers while the daemon runs | Tasks | Pending | | WA-12 | P1: Console answers while the daemon runs | Tasks | Pending | -| WA-13 | P1: Console answers while the daemon runs | Tasks | Pending | +| WA-13 | P1: Console answers while the daemon runs | Tasks | Verified | | WA-14 | P1: Console answers while the daemon runs | Tasks | Pending | | WA-15 | P2: Fixed, configurable port | Tasks | Verified | | WA-16 | P2: Fixed, configurable port | Tasks | Pending | | WA-17 | P2: Fixed, configurable port | Tasks | Pending | -| WA-18 | P2: Fixed, configurable port | Tasks | Pending | -| WA-19 | P2: Fixed, configurable port | Tasks | Pending | -| WA-20 | P2: Fixed, configurable port | Tasks | Pending | +| WA-18 | P2: Fixed, configurable port | Tasks | Verified | +| WA-19 | P2: Fixed, configurable port | Tasks | Verified | +| WA-20 | P2: Fixed, configurable port | Tasks | Verified | | WA-21 | P2: Fixed, configurable port | Tasks | Pending | | WA-22 | P2: Fixed, configurable port | Tasks | Pending | | WA-23 | P2: Fixed, configurable port | Tasks | Pending | | WA-24 | P2: Fixed, configurable port | Tasks | Verified | -| WA-25 | P2: Fixed, configurable port | Tasks | Pending | +| WA-25 | P2: Fixed, configurable port | Tasks | Verified | **Coverage:** 25 total, 0 mapped to tasks, 25 unmapped ⚠️ diff --git a/.specs/features/web-access/tasks.md b/.specs/features/web-access/tasks.md index c638ddf..30ffeb6 100644 --- a/.specs/features/web-access/tasks.md +++ b/.specs/features/web-access/tasks.md @@ -192,18 +192,19 @@ T8 → T9 → T10 **Done when**: -- [ ] `preferredPort` only → spawn args `--web-child --preferred-port ` -- [ ] Running for preferred 7777, request preferred 7788 → SIGTERM + new child for 7788 -- [ ] Running for explicit 8000, request preferred 7777 → restart for 7777 -- [ ] Running with no port argument, request preferred 7777 → restart for 7777 -- [ ] Running for preferred 7777, request `port` 8000 with matching entry/build → reuse, no spawn -- [ ] Request with neither → reuse a matching child -- [ ] Entry or build mismatch still restarts (existing tests keep passing) -- [ ] Two requests during a start that each need a different child → starts run one after the other, never two children alive at once, each request resolves with a child matching its own params -- [ ] Gate check passes: `npx vitest run tests/web-supervisor.test.ts`; `npx tsc --noEmit` +- [x] `preferredPort` only → spawn args `--web-child --preferred-port ` +- [x] Running for preferred 7777, request preferred 7788 → SIGTERM + new child for 7788 +- [x] Running for explicit 8000, request preferred 7777 → restart for 7777 +- [x] Running with no port argument, request preferred 7777 → restart for 7777 +- [x] Running for preferred 7777, request `port` 8000 with matching entry/build → reuse, no spawn +- [x] Request with neither → reuse a matching child +- [x] Entry or build mismatch still restarts (existing tests keep passing) +- [x] Two requests during a start that each need a different child → starts run one after the other, never two children alive at once, each request resolves with a child matching its own params +- [x] Gate check passes: `npx vitest run tests/web-supervisor.test.ts`; `npx tsc --noEmit` **Tests**: unit **Gate**: quick +**Status**: ✅ Done **Commit**: `feat(daemon): Restart the web child when the preferred port changes` diff --git a/src/daemon/protocol.ts b/src/daemon/protocol.ts index 9bee358..95951c5 100644 --- a/src/daemon/protocol.ts +++ b/src/daemon/protocol.ts @@ -232,7 +232,10 @@ export interface QueryUsageRequest { } export interface WebEnsureParams { + /** Explicit port (`--port`): used only when a child has to start, never a fallback. */ port?: number; + /** The caller's resolved preferred port (`web.port` or 7777), sent when there is no explicit port. */ + preferredPort?: number; build?: string; /** Absolute path of the caller's `dist/web/child.js`; the daemon spawns it as the web child. */ entry?: string; diff --git a/src/daemon/web-supervisor.ts b/src/daemon/web-supervisor.ts index f2c6534..fb3ba94 100644 --- a/src/daemon/web-supervisor.ts +++ b/src/daemon/web-supervisor.ts @@ -44,10 +44,14 @@ export interface WebSupervisorOptions { stopTimeoutMs?: number; } +/** What a child was started for; decides whether a later request may reuse it. */ +type StartedFor = { kind: "explicit"; port: number } | { kind: "preferred"; port: number } | { kind: "none" }; + interface RunningChild extends WebEnsureResult { child: WebChildProcess; entry: string; build: string | undefined; + startedFor: StartedFor; exited: Promise; } @@ -97,13 +101,15 @@ export class WebSupervisor { this.stopTimeoutMs = options.stopTimeoutMs ?? WEB_STOP_TIMEOUT_MS; } - ensure(params: WebEnsureParams): Promise { + async ensure(params: WebEnsureParams): Promise { if (params.entry !== undefined && !this.isValidEntry(params.entry)) { - return Promise.reject(new WebEnsureError("WEB_BAD_ENTRY", `invalid web child entry: ${params.entry}`)); + throw new WebEnsureError("WEB_BAD_ENTRY", `invalid web child entry: ${params.entry}`); } + // One start at a time: a request that finds a start in flight waits for it, + // then judges the outcome by its own params. + while (this.state.kind === "starting") await this.state.promise.catch(() => {}); const state = this.state; - if (state.kind === "starting") return state.promise; - if (state.kind === "running" && this.matches(state.running, params)) return Promise.resolve(resultOf(state.running)); + if (state.kind === "running" && this.matches(state.running, params)) return resultOf(state.running); const previous = state.kind === "running" ? state.running : undefined; const promise = (async () => { @@ -128,8 +134,13 @@ export class WebSupervisor { private matches(running: RunningChild, params: WebEnsureParams): boolean { if (params.entry !== undefined && params.entry !== running.entry) return false; - if (params.build === undefined) return true; - return (params.entry ?? this.defaultEntry) === running.entry && params.build === running.build; + if (params.build !== undefined && ((params.entry ?? this.defaultEntry) !== running.entry || params.build !== running.build)) { + return false; + } + // An explicit port never moves a running console. A preferred port does, + // unless the child was started for that same preferred port. + if (params.port !== undefined || params.preferredPort === undefined) return true; + return running.startedFor.kind === "preferred" && running.startedFor.port === params.preferredPort; } private async stop(running: RunningChild): Promise { @@ -141,7 +152,17 @@ export class WebSupervisor { private start(params: WebEnsureParams): Promise { const entry = params.entry ?? this.defaultEntry; - const args = ["--web-child", ...(params.port === undefined ? [] : ["--port", String(params.port)])]; + const startedFor: StartedFor = + params.port !== undefined + ? { kind: "explicit", port: params.port } + : params.preferredPort !== undefined + ? { kind: "preferred", port: params.preferredPort } + : { kind: "none" }; + const args = [ + "--web-child", + ...(startedFor.kind === "explicit" ? ["--port", String(startedFor.port)] : []), + ...(startedFor.kind === "preferred" ? ["--preferred-port", String(startedFor.port)] : []), + ]; let child: WebChildProcess; try { child = this.spawnChild(entry, args); @@ -204,6 +225,7 @@ export class WebSupervisor { child, entry, build: handshake.build ?? params.build, + startedFor, exited, }; this.state = { kind: "running", running }; diff --git a/tests/web-supervisor.test.ts b/tests/web-supervisor.test.ts index 9253a68..c1ad8d9 100644 --- a/tests/web-supervisor.test.ts +++ b/tests/web-supervisor.test.ts @@ -225,6 +225,92 @@ describe("WebSupervisor.ensure", () => { }); }); +describe("WebSupervisor port rules", () => { + async function running(t: ReturnType, params: Parameters[0], port = 4100) { + const pending = t.supervisor.ensure(params); + await vi.waitFor(() => expect(t.children.length).toBeGreaterThan(0)); + t.children[t.children.length - 1].handshake(ok(port, `tok-${port}`)); + return pending; + } + + it("passes a preferred port as --preferred-port", async () => { + const t = harness(); + + await running(t, { preferredPort: 7777 }); + + expect(t.spawns[0].args).toEqual(["--web-child", "--preferred-port", "7777"]); + }); + + it.each([ + ["another preferred port", { preferredPort: 7777 }], + ["an explicit port", { port: 8000 }], + ["no port argument", {}], + ])("restarts a child started for %s when a request brings preferred port 7788", async (_label, first) => { + const t = harness(); + await running(t, first); + + const second = t.supervisor.ensure({ preferredPort: 7788 }); + await vi.waitFor(() => expect(t.children).toHaveLength(2)); + t.children[1].handshake(ok(7788, "tok-2")); + + await expect(second).resolves.toMatchObject({ port: 7788, token: "tok-2" }); + expect(t.children[0].signals).toEqual(["SIGTERM"]); + expect(t.spawns[1].args).toEqual(["--web-child", "--preferred-port", "7788"]); + }); + + it("reuses a child started for the same preferred port, even when it fell back to another port", async () => { + const t = harness(); + const first = await running(t, { preferredPort: 7777 }, 40123); + + await expect(t.supervisor.ensure({ preferredPort: 7777 })).resolves.toEqual(first); + + expect(t.spawns).toHaveLength(1); + expect(t.children[0].signals).toEqual([]); + }); + + it.each([ + ["an explicit port", { port: 8000 }], + ["neither port field", {}], + ])("reuses a child started for a preferred port when a request brings %s", async (_label, request) => { + const t = harness(); + const first = await running(t, { preferredPort: 7777 }); + + await expect(t.supervisor.ensure(request)).resolves.toEqual(first); + + expect(t.spawns).toHaveLength(1); + }); + + it("runs the starts that waiting requests need one after the other, never two children at once", async () => { + const alive = new Set(); + const aliveAtSpawn: number[] = []; + const t = harness({ + spawnChild: (entry, args) => { + aliveAtSpawn.push(alive.size); + t.spawns.push({ entry, args }); + const child = new FakeChild(); + alive.add(child); + child.once("exit", () => alive.delete(child)); + t.children.push(child); + return child as unknown as WebChildProcess; + }, + }); + + const a = t.supervisor.ensure({ preferredPort: 7777 }); + const b = t.supervisor.ensure({ preferredPort: 7788 }); + const c = t.supervisor.ensure({ preferredPort: 7799 }); + for (const [index, port] of [7777, 7788, 7799].entries()) { + await vi.waitFor(() => expect(t.children).toHaveLength(index + 1)); + t.children[index].handshake(ok(port, `tok-${port}`)); + } + + await expect(a).resolves.toMatchObject({ port: 7777 }); + await expect(b).resolves.toMatchObject({ port: 7788 }); + await expect(c).resolves.toMatchObject({ port: 7799 }); + expect(t.spawns.map((spawned) => spawned.args[2])).toEqual(["7777", "7788", "7799"]); + expect(aliveAtSpawn).toEqual([0, 0, 0]); + }); +}); + describe("WebSupervisor default entry", () => { it("spawns the web child next to the supervisor module when no entry is given", async () => { const spawns: string[] = []; From f7d076d7c99c08f8c0d6cb24def553d390181c39 Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Thu, 24 Sep 2026 22:38:09 -0300 Subject: [PATCH 11/17] feat(daemon): Start the web console with the daemon --- .specs/features/web-access/spec.md | 6 +-- .specs/features/web-access/tasks.md | 13 ++--- src/daemon/daemon.ts | 32 +++++++++--- tests/daemon-web.test.ts | 76 ++++++++++++++++++++++++++++- 4 files changed, 111 insertions(+), 16 deletions(-) diff --git a/.specs/features/web-access/spec.md b/.specs/features/web-access/spec.md index 72e0f89..3dc81c8 100644 --- a/.specs/features/web-access/spec.md +++ b/.specs/features/web-access/spec.md @@ -200,10 +200,10 @@ Tests and docs that pin the old values change with this feature: tests/web-secur | WA-08 | P1: Bookmark survives restarts | Tasks | Verified | | WA-09 | P1: Bookmark survives restarts | Tasks | Verified | | WA-10 | P1: Bookmark survives restarts | Tasks | Verified | -| WA-11 | P1: Console answers while the daemon runs | Tasks | Pending | -| WA-12 | P1: Console answers while the daemon runs | Tasks | Pending | +| WA-11 | P1: Console answers while the daemon runs | Tasks | Verified | +| WA-12 | P1: Console answers while the daemon runs | Tasks | Verified | | WA-13 | P1: Console answers while the daemon runs | Tasks | Verified | -| WA-14 | P1: Console answers while the daemon runs | Tasks | Pending | +| WA-14 | P1: Console answers while the daemon runs | Tasks | Verified | | WA-15 | P2: Fixed, configurable port | Tasks | Verified | | WA-16 | P2: Fixed, configurable port | Tasks | Pending | | WA-17 | P2: Fixed, configurable port | Tasks | Pending | diff --git a/.specs/features/web-access/tasks.md b/.specs/features/web-access/tasks.md index 30ffeb6..436c140 100644 --- a/.specs/features/web-access/tasks.md +++ b/.specs/features/web-access/tasks.md @@ -220,15 +220,16 @@ T8 → T9 → T10 **Done when**: -- [ ] `autostartWeb()` calls `ensure` once with `{ preferredPort }` and no `port`/`entry`/`build` -- [ ] Rejection → `daemon.log` gets `web autostart failed: `; later `web.ensure` requests still work -- [ ] Invalid `web.port` in the test config → the WA-22 line in `daemon.log` -- [ ] `start()` alone never calls the supervisor (seam test) -- [ ] The `--daemon` entry calls `autostartWeb()` after `start()` (static check of the entry block) -- [ ] Gate check passes: `npx vitest run tests/daemon-web.test.ts`; `npx vitest run tests/web-supervisor.test.ts`; `npx tsc --noEmit` +- [x] `autostartWeb()` calls `ensure` once with `{ preferredPort }` and no `port`/`entry`/`build` +- [x] Rejection → `daemon.log` gets `web autostart failed: `; later `web.ensure` requests still work +- [x] Invalid `web.port` in the test config → the WA-22 line in `daemon.log` +- [x] `start()` alone never calls the supervisor (seam test) +- [x] The `--daemon` entry calls `autostartWeb()` after `start()` (static check of the entry block) +- [x] Gate check passes: `npx vitest run tests/daemon-web.test.ts`; `npx vitest run tests/web-supervisor.test.ts`; `npx tsc --noEmit` **Tests**: unit **Gate**: full +**Status**: ✅ Done **Commit**: `feat(daemon): Start the web console with the daemon` diff --git a/src/daemon/daemon.ts b/src/daemon/daemon.ts index 43e3084..6045cbf 100644 --- a/src/daemon/daemon.ts +++ b/src/daemon/daemon.ts @@ -22,6 +22,7 @@ import { killTree, processAlive, processStartTime, resolveInhibitBin, sleep } fr import { readSessionProcessMetadata } from "../drivers/session-runtime.js"; import type { AgentEvent } from "../core/events.js"; import { loadConfig, resolveDefaultSandbox } from "../config/config.js"; +import { invalidWebPortMessage, resolveWebPort } from "../config/web-port.js"; import { classifyFailure, RunAgentError, type FailureInfo } from "../core/errors.js"; import { parseRole } from "../core/roles.js"; import { getCachedOrDiscoverModels, type HarnessModels } from "../core/models.js"; @@ -1383,9 +1384,8 @@ class Daemon { case "web.ensure": { const p = (params || {}) as WebEnsureParams; - this.web ??= new WebSupervisor({ log: appendDaemonLog, spawnChild: this.spawnWebChild }); try { - send({ result: await this.web.ensure(p) }); + send({ result: await this.webHost().ensure(p) }); } catch (error) { if (error instanceof WebEnsureError) { send({ error: { code: error.code, message: error.message, ...(error.details ? { details: error.details } : {}) } }); @@ -1435,6 +1435,23 @@ class Daemon { } } + private webHost(): WebHost { + this.web ??= new WebSupervisor({ log: appendDaemonLog, spawnChild: this.spawnWebChild }); + return this.web; + } + + /** + * Start the web console with the daemon so its address answers without a + * prior web command. Not awaited: IPC never waits on the web stack. + */ + autostartWeb(): void { + const { port, invalid } = resolveWebPort(loadConfig()); + if (invalid !== undefined) appendDaemonLog(invalidWebPortMessage(invalid)); + this.webHost() + .ensure({ preferredPort: port }) + .catch((error: unknown) => appendDaemonLog(`web autostart failed: ${error instanceof Error ? error.message : String(error)}`)); + } + private async startDriverForSession(sessionId: string, prompt: string, model?: string): Promise { if (this.shuttingDown) return; const session = this.sessions.get(sessionId); @@ -2011,10 +2028,13 @@ class Daemon { // Entry if (process.argv.includes("--daemon")) { const d = new Daemon(); - d.start().catch((e) => { - console.error("[daemon] failed to start", e); - process.exit(1); - }); + d.start().then( + () => d.autostartWeb(), + (e) => { + console.error("[daemon] failed to start", e); + process.exit(1); + }, + ); } export { Daemon }; diff --git a/tests/daemon-web.test.ts b/tests/daemon-web.test.ts index 7147c81..fcf1b87 100644 --- a/tests/daemon-web.test.ts +++ b/tests/daemon-web.test.ts @@ -1,7 +1,9 @@ import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { EventEmitter } from "node:events"; import { PassThrough } from "node:stream"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { Daemon, type WebHost } from "../src/daemon/daemon.js"; import { getPaths } from "../src/config/paths.js"; import { WebEnsureError, WebSupervisor, type WebChildProcess } from "../src/daemon/web-supervisor.js"; @@ -119,3 +121,75 @@ describe("daemon web.ensure", () => { expect(seam(daemon).sessions.get("s1")).toEqual(before); }); }); + +describe("daemon web autostart", () => { + const originalConfigDir = process.env.RUN_AGENT_CONFIG_DIR; + const configDirs: string[] = []; + afterEach(() => { + if (originalConfigDir === undefined) delete process.env.RUN_AGENT_CONFIG_DIR; + else process.env.RUN_AGENT_CONFIG_DIR = originalConfigDir; + for (const dir of configDirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); + }); + + function useConfig(config: unknown): void { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "codedeck-web-autostart-")); + configDirs.push(dir); + fs.writeFileSync(path.join(dir, "config.json"), JSON.stringify(config)); + process.env.RUN_AGENT_CONFIG_DIR = dir; + } + + const daemonLog = () => fs.readFileSync(getPaths().daemonLog, "utf8"); + + it("asks the supervisor once for the preferred port from config, with no port, entry or build", async () => { + useConfig({ web: { port: 7788 } }); + const host = fakeHost(); + daemon = new Daemon({ webSupervisor: host }); + + daemon.autostartWeb(); + + expect(host.ensure).toHaveBeenCalledTimes(1); + expect(host.ensure).toHaveBeenCalledWith({ preferredPort: 7788 }); + }); + + it("logs a failed autostart and keeps serving web.ensure", async () => { + useConfig({}); + const hostEnsure = vi.fn() + .mockRejectedValueOnce(new WebEnsureError("WEB_START_FAILED", "web child exited before its handshake (code=1)")) + .mockResolvedValue({ baseUrl: "http://127.0.0.1:7777", port: 7777, token: "tok" }); + daemon = new Daemon({ webSupervisor: fakeHost({ ensure: hostEnsure }) }); + + daemon.autostartWeb(); + + await vi.waitFor(() => expect(daemonLog()).toMatch(/\] web autostart failed: web child exited before its handshake \(code=1\)\n/)); + expect(hostEnsure).toHaveBeenNthCalledWith(1, { preferredPort: 7777 }); + expect((await ensure({})).result).toEqual({ baseUrl: "http://127.0.0.1:7777", port: 7777, token: "tok" }); + }); + + it("logs an invalid web.port and falls back to 7777", async () => { + useConfig({ web: { port: "7788" } }); + const host = fakeHost(); + daemon = new Daemon({ webSupervisor: host }); + + daemon.autostartWeb(); + + expect(daemonLog()).toMatch(/\] Ignoring invalid web.port in config: "7788"\n/); + expect(host.ensure).toHaveBeenCalledWith({ preferredPort: 7777 }); + }); + + it("does not start the web child from start()", async () => { + const host = fakeHost(); + daemon = new Daemon({ webSupervisor: host }); + (daemon as unknown as { maybeSpawnInhibit: () => void }).maybeSpawnInhibit = () => {}; + + await daemon.start(); + + expect(host.ensure).not.toHaveBeenCalled(); + }); + + it("autostarts the web console from the --daemon entry after start() resolves", () => { + const source = fs.readFileSync(path.join(import.meta.dirname, "..", "src", "daemon", "daemon.ts"), "utf8"); + const entry = source.slice(source.indexOf('if (process.argv.includes("--daemon"))')); + + expect(entry).toMatch(/d\.start\(\)\.then\(\s*\(\) => d\.autostartWeb\(\),/); + }); +}); From fe5b29341642428a6e0a31167744dc028e456d52 Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Thu, 24 Sep 2026 22:39:09 -0300 Subject: [PATCH 12/17] feat(cli): Ask for the preferred web port WA-17 now names daemon URLs only: the in-process fallback prints its own page line inside startWebServer, so a notice could not precede it. --- .specs/features/web-access/spec.md | 14 +++--- .specs/features/web-access/tasks.md | 11 +++-- src/cli/web-launch.ts | 28 +++++++---- tests/web-launch.test.ts | 77 +++++++++++++++++++++++++---- 4 files changed, 100 insertions(+), 30 deletions(-) diff --git a/.specs/features/web-access/spec.md b/.specs/features/web-access/spec.md index 3dc81c8..753f38b 100644 --- a/.specs/features/web-access/spec.md +++ b/.specs/features/web-access/spec.md @@ -89,6 +89,8 @@ Tests and docs that pin the old values change with this feature: tests/web-secur | Stray gate daemons | `scripts/pty-gate.sh` and `scripts/rename-gate.sh` stop the daemon they started (from `$RUN_AGENT_DIR/daemon.pid`) in their EXIT trap. | With the eager start, an orphaned gate daemon would hold 7777 with another token and break the real bookmark. | n | | 403 page text | `Run "codedeck ui" once in a terminal to open CodeDeck in this browser.` | The old text did not say the step is one-time. | n | +| Port notice in the in-process fallback | None: WA-17 covers URLs the daemon returns. The fallback already prints `serving from this process` and its own URL line. | `startWebServer` prints the page line itself, so a notice could not come before it without reshaping that function. | n | + **Open questions:** none - all resolved or logged above. --- @@ -145,7 +147,7 @@ Tests and docs that pin the old values change with this feature: tests/web-secur 1. The preferred port SHALL be `web.port` from `config.json` when it is an integer in 1-65535, and 7777 otherwise. 2. WHEN a web command runs without `--port` THEN it SHALL send the preferred port as `preferredPort` in `web.ensure` and no `port`. -3. WHEN a web command gets a console URL whose port differs from its `--port`, or from the preferred port when `--port` is absent, THEN it SHALL print `CodeDeck web is running on port instead of ` before the page line. +3. WHEN a web command gets a console URL from the daemon whose port differs from its `--port`, or from the preferred port when `--port` is absent, THEN it SHALL print `CodeDeck web is running on port instead of ` before the page line. 4. WHEN the supervisor starts a child for a `preferredPort` THEN it SHALL pass `--preferred-port `, and the child SHALL listen on that port and, on any listen error, on an OS-assigned port. 5. WHEN a `web.ensure` without `port` and with a `preferredPort` finds a running child that was not started for that same preferred port (started for an explicit port, for another preferred port, or with no port argument) THEN the supervisor SHALL restart the child for the requested preferred port. 6. WHEN a `web.ensure` with `port` finds a running child whose entry and build match THEN the supervisor SHALL reuse it, whatever the child was started for. @@ -194,7 +196,7 @@ Tests and docs that pin the old values change with this feature: tests/web-secur | WA-02 | P1: Bookmark survives restarts | Tasks | Verified | | WA-03 | P1: Bookmark survives restarts | Tasks | Verified | | WA-04 | P1: Bookmark survives restarts | Tasks | Verified | -| WA-05 | P1: Bookmark survives restarts | Tasks | Pending | +| WA-05 | P1: Bookmark survives restarts | Tasks | Verified | | WA-06 | P1: Bookmark survives restarts | Tasks | Verified | | WA-07 | P1: Bookmark survives restarts | Tasks | Verified | | WA-08 | P1: Bookmark survives restarts | Tasks | Verified | @@ -205,13 +207,13 @@ Tests and docs that pin the old values change with this feature: tests/web-secur | WA-13 | P1: Console answers while the daemon runs | Tasks | Verified | | WA-14 | P1: Console answers while the daemon runs | Tasks | Verified | | WA-15 | P2: Fixed, configurable port | Tasks | Verified | -| WA-16 | P2: Fixed, configurable port | Tasks | Pending | -| WA-17 | P2: Fixed, configurable port | Tasks | Pending | +| WA-16 | P2: Fixed, configurable port | Tasks | Verified | +| WA-17 | P2: Fixed, configurable port | Tasks | Verified | | WA-18 | P2: Fixed, configurable port | Tasks | Verified | | WA-19 | P2: Fixed, configurable port | Tasks | Verified | | WA-20 | P2: Fixed, configurable port | Tasks | Verified | -| WA-21 | P2: Fixed, configurable port | Tasks | Pending | -| WA-22 | P2: Fixed, configurable port | Tasks | Pending | +| WA-21 | P2: Fixed, configurable port | Tasks | Verified | +| WA-22 | P2: Fixed, configurable port | Tasks | Verified | | WA-23 | P2: Fixed, configurable port | Tasks | Pending | | WA-24 | P2: Fixed, configurable port | Tasks | Verified | | WA-25 | P2: Fixed, configurable port | Tasks | Verified | diff --git a/.specs/features/web-access/tasks.md b/.specs/features/web-access/tasks.md index 436c140..3182240 100644 --- a/.specs/features/web-access/tasks.md +++ b/.specs/features/web-access/tasks.md @@ -245,14 +245,15 @@ T8 → T9 → T10 **Done when**: -- [ ] No `--port` → params `{ preferredPort: , build, entry }` without `port`; with `--port` → `port` sent, no `preferredPort` -- [ ] URL port ≠ asked port → notice line before the page line; equal → no notice (both with and without `--port`) -- [ ] Invalid `web.port` → the WA-22 line on stderr, once -- [ ] Fallback without `--port` → `startServer` gets the preferred port, `fallbackToEphemeral: true`, and the resolver's token; with `--port` → that port, no fallback -- [ ] Gate check passes: `npx vitest run tests/web-launch.test.ts`; `npx tsc --noEmit` +- [x] No `--port` → params `{ preferredPort: , build, entry }` without `port`; with `--port` → `port` sent, no `preferredPort` +- [x] URL port ≠ asked port → notice line before the page line; equal → no notice (both with and without `--port`) +- [x] Invalid `web.port` → the WA-22 line on stderr, once +- [x] Fallback without `--port` → `startServer` gets the preferred port, `fallbackToEphemeral: true`, and the resolver's token; with `--port` → that port, no fallback +- [x] Gate check passes: `npx vitest run tests/web-launch.test.ts`; `npx tsc --noEmit` **Tests**: unit **Gate**: quick +**Status**: ✅ Done **Commit**: `feat(cli): Ask for the preferred web port` diff --git a/src/cli/web-launch.ts b/src/cli/web-launch.ts index c9a6ac9..2c6f577 100644 --- a/src/cli/web-launch.ts +++ b/src/cli/web-launch.ts @@ -2,14 +2,17 @@ import { fileURLToPath } from "node:url"; import { IpcClient } from "../daemon/ipc.js"; import { computeBuildId, distRootFor } from "../daemon/build-id.js"; import type { WebEnsureParams, WebEnsureResult } from "../daemon/protocol.js"; -import { DEFAULT_WEB_PORT, openBrowser, startWebServer } from "../web/server.js"; +import { loadConfig } from "../config/config.js"; +import { invalidWebPortMessage, resolveWebPort } from "../config/web-port.js"; +import { openBrowser, startWebServer } from "../web/server.js"; +import { resolveWebToken } from "../web/web-token.js"; import { createUiRoutes } from "./commands/ui.js"; export interface LaunchWebPageOptions { path: string; query?: Record; title: string; - /** Explicit `--port`; omitted means the daemon picks 3100 or an ephemeral port. */ + /** Explicit `--port`; omitted means the preferred port (`web.port`, else 7777) or an ephemeral one. */ port?: number; open: boolean; } @@ -22,6 +25,8 @@ export interface LaunchWebPageDependencies { startServer?: typeof startWebServer; build?: string; entry?: string; + loadConfig?: () => { web?: unknown }; + resolveToken?: () => string; } /** @@ -32,16 +37,19 @@ export async function launchWebPage(options: LaunchWebPageOptions, deps: LaunchW const client = deps.client ?? new IpcClient(); const log = deps.log ?? ((message: string) => console.log(message)); const error = deps.error ?? ((message: string) => console.error(message)); + const { port: preferredPort, invalid } = resolveWebPort((deps.loadConfig ?? loadConfig)()); + if (invalid !== undefined) error(invalidWebPortMessage(invalid)); + const askedPort = options.port ?? preferredPort; try { await client.ensureDaemonStarted(); } catch { error("CodeDeck daemon is unavailable; serving from this process."); - return serveInProcess(options, deps, error); + return serveInProcess(options, askedPort, deps, error); } const params: WebEnsureParams = { - ...(options.port === undefined ? {} : { port: options.port }), + ...(options.port === undefined ? { preferredPort } : { port: options.port }), build: deps.build ?? computeBuildId(distRootFor(import.meta.url)), entry: deps.entry ?? fileURLToPath(new URL("../web/child.js", import.meta.url)), }; @@ -51,16 +59,14 @@ export async function launchWebPage(options: LaunchWebPageOptions, deps: LaunchW } catch (failure) { const { code, message, details } = failure as Error & { code?: string; details?: { port?: number } }; if (code === "WEB_LISTEN_FAILED") { - error(`Failed to listen on 127.0.0.1:${details?.port ?? options.port ?? DEFAULT_WEB_PORT}: ${message}`); + error(`Failed to listen on 127.0.0.1:${details?.port ?? askedPort}: ${message}`); return 1; } error(`CodeDeck daemon cannot host the web console (${code ?? "UNKNOWN"}); serving from this process.`); - return serveInProcess(options, deps, error); + return serveInProcess(options, askedPort, deps, error); } - if (options.port !== undefined && web.port !== options.port) { - log(`CodeDeck web is already running on port ${web.port}`); - } + if (web.port !== askedPort) log(`CodeDeck web is running on port ${web.port} instead of ${askedPort}`); const url = new URL(options.path, web.baseUrl); for (const [key, value] of Object.entries(options.query ?? {})) url.searchParams.set(key, value); url.searchParams.set("t", web.token); @@ -76,16 +82,18 @@ export async function launchWebPage(options: LaunchWebPageOptions, deps: LaunchW async function serveInProcess( options: LaunchWebPageOptions, + port: number, deps: LaunchWebPageDependencies, error: (message: string) => void, ): Promise { const query = new URLSearchParams(options.query ?? {}).toString(); - const port = options.port ?? DEFAULT_WEB_PORT; try { await (deps.startServer ?? startWebServer)({ routes: createUiRoutes(), port, fallbackToEphemeral: options.port === undefined, + // The same token as the daemon's web child, so neither server's cookie locks out the other. + token: (deps.resolveToken ?? resolveWebToken)(), initialPath: query ? `${options.path}?${query}` : options.path, title: options.title, open: options.open, diff --git a/tests/web-launch.test.ts b/tests/web-launch.test.ts index ecc5588..af05344 100644 --- a/tests/web-launch.test.ts +++ b/tests/web-launch.test.ts @@ -13,16 +13,19 @@ vi.mock("../src/daemon/build-id.js", async (importOriginal) => { const REPO_ROOT = path.join(import.meta.dirname, ".."); -const BASE = { baseUrl: "http://127.0.0.1:3100", port: 3100, token: "tok" }; +const BASE = { baseUrl: "http://127.0.0.1:7777", port: 7777, token: "tok" }; function ipcError(code: string, message: string, details?: unknown): Error { return Object.assign(new Error(message), { code, details }); } +const TOKEN = "9".repeat(64); + function setup(overrides: { ensure?: () => Promise; start?: () => Promise; opener?: boolean; + config?: { web?: unknown }; } = {}) { const request = vi.fn(async (_method: string, _params: unknown) => (overrides.ensure ?? (async () => BASE))()); const ensureDaemonStarted = vi.fn(overrides.start ?? (async () => {})); @@ -37,6 +40,8 @@ function setup(overrides: { log: (message) => logs.push(message), error: (message) => errors.push(message), build: "build-1", + loadConfig: () => overrides.config ?? {}, + resolveToken: () => TOKEN, }; const launch = (options: Partial = {}) => launchWebPage({ path: "/review", query: { repo: "/work/app" }, title: "CodeDeck review", open: true, ...options }, deps); @@ -49,7 +54,7 @@ describe("launchWebPage", () => { const code = await t.launch(); - const url = "http://127.0.0.1:3100/review?repo=%2Fwork%2Fapp&t=tok"; + const url = "http://127.0.0.1:7777/review?repo=%2Fwork%2Fapp&t=tok"; expect(code).toBe(0); expect(t.openBrowser).toHaveBeenCalledWith(url); expect(t.logs).toEqual([`CodeDeck review on ${url}`]); @@ -76,7 +81,7 @@ describe("launchWebPage", () => { expect(await t.launch({ open: false })).toBe(0); expect(t.openBrowser).not.toHaveBeenCalled(); - expect(t.logs).toEqual(["CodeDeck review on http://127.0.0.1:3100/review?repo=%2Fwork%2Fapp&t=tok"]); + expect(t.logs).toEqual(["CodeDeck review on http://127.0.0.1:7777/review?repo=%2Fwork%2Fapp&t=tok"]); }); it("prints the manual-visit line when the browser cannot open", async () => { @@ -84,19 +89,61 @@ describe("launchWebPage", () => { expect(await t.launch()).toBe(0); - expect(t.logs).toEqual(["Could not open a browser, visit http://127.0.0.1:3100/review?repo=%2Fwork%2Fapp&t=tok manually."]); + expect(t.logs).toEqual(["Could not open a browser, visit http://127.0.0.1:7777/review?repo=%2Fwork%2Fapp&t=tok manually."]); + }); + + it("sends an explicit port without preferredPort, and the preferred port without port otherwise", async () => { + const t = setup({ config: { web: { port: 7788 } } }); + + await t.launch({ port: 4200, open: false }); + await t.launch({ open: false }); + + const [explicit, preferred] = t.request.mock.calls.map(([, params]) => params); + expect(explicit).toMatchObject({ port: 4200 }); + expect(explicit).not.toHaveProperty("preferredPort"); + expect(preferred).toMatchObject({ preferredPort: 7788, build: "build-1" }); + expect(preferred).not.toHaveProperty("port"); }); - it("sends an explicit port, omits a missing one, and notices a different running port", async () => { + it("prefers 7777 when the config has no web.port", async () => { const t = setup(); + await t.launch({ open: false }); + + expect(t.request.mock.calls[0][1]).toMatchObject({ preferredPort: 7777 }); + }); + + it("notices a console on another port than the one asked for, before the page line", async () => { + const t = setup({ config: { web: { port: 7788 } } }); + await t.launch({ port: 4200, open: false }); await t.launch({ open: false }); - expect(t.request.mock.calls[0][1]).toMatchObject({ port: 4200 }); - expect(t.request.mock.calls[1][1]).not.toHaveProperty("port"); - expect(t.logs[0]).toBe("CodeDeck web is already running on port 3100"); - expect(t.logs.filter((line) => line.startsWith("CodeDeck web is already running"))).toHaveLength(1); + const url = "http://127.0.0.1:7777/review?repo=%2Fwork%2Fapp&t=tok"; + expect(t.logs).toEqual([ + "CodeDeck web is running on port 7777 instead of 4200", + `CodeDeck review on ${url}`, + "CodeDeck web is running on port 7777 instead of 7788", + `CodeDeck review on ${url}`, + ]); + }); + + it("prints no port notice when the console runs on the asked port", async () => { + const t = setup(); + + await t.launch({ open: false }); + await t.launch({ port: 7777, open: false }); + + expect(t.logs.filter((line) => line.startsWith("CodeDeck web is running on port"))).toEqual([]); + }); + + it("warns once about an invalid web.port and asks for 7777", async () => { + const t = setup({ config: { web: { port: "7788" } } }); + + await t.launch({ open: false }); + + expect(t.errors).toEqual(['Ignoring invalid web.port in config: "7788"']); + expect(t.request.mock.calls[0][1]).toMatchObject({ preferredPort: 7777 }); }); it("reports WEB_LISTEN_FAILED, returns 1 and does not fall back", async () => { @@ -126,6 +173,17 @@ describe("launchWebPage", () => { expect(options.port).toBe(7777); }); + it("serves the fallback on the preferred port with ephemeral fallback and the shared token", async () => { + const t = setup({ config: { web: { port: 7788 } }, ensure: async () => { throw ipcError("UNKNOWN_METHOD", "nope"); } }); + + await t.launch(); + + const options = (t.startServer.mock.calls[0] as unknown as [Record])[0]; + expect(options.port).toBe(7788); + expect(options.fallbackToEphemeral).toBe(true); + expect(options.token).toBe(TOKEN); + }); + it("keeps an explicit port in the fallback and adds no ? for an empty query", async () => { const t = setup({ ensure: async () => { throw ipcError("UNKNOWN_METHOD", "nope"); } }); @@ -135,6 +193,7 @@ describe("launchWebPage", () => { expect(options.initialPath).toBe("/setup"); expect(options.port).toBe(4200); expect(options.fallbackToEphemeral).toBe(false); + expect(options.token).toBe(TOKEN); }); it("serves in-process when the daemon cannot start", async () => { From 02c677bb9e9621bfceba7b0fc0ae7d7110cedafb Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Thu, 24 Sep 2026 22:39:13 -0300 Subject: [PATCH 13/17] docs(specs): Join the fallback notice row to the assumptions table --- .specs/features/web-access/spec.md | 1 - 1 file changed, 1 deletion(-) diff --git a/.specs/features/web-access/spec.md b/.specs/features/web-access/spec.md index 753f38b..3d83e6c 100644 --- a/.specs/features/web-access/spec.md +++ b/.specs/features/web-access/spec.md @@ -88,7 +88,6 @@ Tests and docs that pin the old values change with this feature: tests/web-secur | Where the eager start runs | In the `--daemon` entry, after `start()` resolves, not awaited. `Daemon.start()` itself does not start the web child. | Keeps tests that call `start()` from spawning a real child, and IPC never waits on the web stack. | n | | Stray gate daemons | `scripts/pty-gate.sh` and `scripts/rename-gate.sh` stop the daemon they started (from `$RUN_AGENT_DIR/daemon.pid`) in their EXIT trap. | With the eager start, an orphaned gate daemon would hold 7777 with another token and break the real bookmark. | n | | 403 page text | `Run "codedeck ui" once in a terminal to open CodeDeck in this browser.` | The old text did not say the step is one-time. | n | - | Port notice in the in-process fallback | None: WA-17 covers URLs the daemon returns. The fallback already prints `serving from this process` and its own URL line. | `startWebServer` prints the page line itself, so a notice could not come before it without reshaping that function. | n | **Open questions:** none - all resolved or logged above. From 8bba82aa707e186baa6d82c24767be76562079af Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Thu, 24 Sep 2026 22:39:35 -0300 Subject: [PATCH 14/17] feat(cli): Describe the console port in --port help --- .specs/features/web-access/spec.md | 2 +- .specs/features/web-access/tasks.md | 5 +++-- src/cli/commands/review.ts | 2 +- src/cli/commands/setup.ts | 2 +- src/cli/commands/ui.ts | 2 +- src/cli/commands/usage.ts | 2 +- tests/web-cli.test.ts | 18 ++++++++++++++++++ 7 files changed, 26 insertions(+), 7 deletions(-) diff --git a/.specs/features/web-access/spec.md b/.specs/features/web-access/spec.md index 3d83e6c..a2fa4fd 100644 --- a/.specs/features/web-access/spec.md +++ b/.specs/features/web-access/spec.md @@ -213,7 +213,7 @@ Tests and docs that pin the old values change with this feature: tests/web-secur | WA-20 | P2: Fixed, configurable port | Tasks | Verified | | WA-21 | P2: Fixed, configurable port | Tasks | Verified | | WA-22 | P2: Fixed, configurable port | Tasks | Verified | -| WA-23 | P2: Fixed, configurable port | Tasks | Pending | +| WA-23 | P2: Fixed, configurable port | Tasks | Verified | | WA-24 | P2: Fixed, configurable port | Tasks | Verified | | WA-25 | P2: Fixed, configurable port | Tasks | Verified | diff --git a/.specs/features/web-access/tasks.md b/.specs/features/web-access/tasks.md index 3182240..ad85405 100644 --- a/.specs/features/web-access/tasks.md +++ b/.specs/features/web-access/tasks.md @@ -269,11 +269,12 @@ T8 → T9 → T10 **Done when**: -- [ ] Each command's help contains the WA-23 text -- [ ] Gate check passes: `npx vitest run tests/web-cli.test.ts`; `npx vitest run tests/review-command.test.ts`; `npx tsc --noEmit` +- [x] Each command's help contains the WA-23 text +- [x] Gate check passes: `npx vitest run tests/web-cli.test.ts`; `npx vitest run tests/review-command.test.ts`; `npx tsc --noEmit` **Tests**: unit **Gate**: quick +**Status**: ✅ Done **Commit**: `feat(cli): Describe the console port in --port help` diff --git a/src/cli/commands/review.ts b/src/cli/commands/review.ts index fc8e918..ac0f000 100644 --- a/src/cli/commands/review.ts +++ b/src/cli/commands/review.ts @@ -93,7 +93,7 @@ export function registerReviewCommand(program: Command, dependencies: ReviewComm program .command("review") .description("Open a local review of the current git changes") - .option("--port ", "port to listen on (default: 3100)") + .option("--port ", "port for a new console (default: web.port from config, else 7777)") .option("--no-open", "print the review URL without opening a browser") .action(async (opts: ReviewCommandOptions) => { let port: number | undefined; diff --git a/src/cli/commands/setup.ts b/src/cli/commands/setup.ts index ada4b19..49d0a29 100644 --- a/src/cli/commands/setup.ts +++ b/src/cli/commands/setup.ts @@ -1301,7 +1301,7 @@ export function registerSetupCommand(program: Command, dependencies: SetupComman .allowExcessArguments(true) .option("--refresh", "ignore the cached catalog and rediscover") .option("--tui", "use the frozen terminal setup wizard") - .option("--port ", "port to listen on (default: 3100)") + .option("--port ", "port for a new console (default: web.port from config, else 7777)") .option("--no-open", "print the setup URL without opening a browser") .option("--non-interactive", "run setup without the picker") .option("--json", "output one machine-readable envelope") diff --git a/src/cli/commands/ui.ts b/src/cli/commands/ui.ts index ad775a1..278f215 100644 --- a/src/cli/commands/ui.ts +++ b/src/cli/commands/ui.ts @@ -55,7 +55,7 @@ export function registerUiCommand(program: Command, dependencies: UiCommandDepen program .command("ui") .description("Open the local CodeDeck console") - .option("--port ", "port to listen on (default: 3100)") + .option("--port ", "port for a new console (default: web.port from config, else 7777)") .option("--no-open", "print the console URL without opening a browser") .action(async (opts: UiCommandOptions) => { let port: number | undefined; diff --git a/src/cli/commands/usage.ts b/src/cli/commands/usage.ts index 8f7fa49..54c675d 100644 --- a/src/cli/commands/usage.ts +++ b/src/cli/commands/usage.ts @@ -151,7 +151,7 @@ export function registerUsageCommand(program: Command, dependencies: UsageComman .option("--transcript ", "report live orchestrator transcript tokens") .option("--backfill", "import historical orchestrator usage") .option("--web", "open aggregate usage in the browser") - .option("--port ", "port to listen on (default: 3100)") + .option("--port ", "port for a new console (default: web.port from config, else 7777)") .option("--no-open", "print the usage URL without opening a browser") .option("-i, --tui", "open interactive full-screen TUI dashboard") .option("-w, --watch", "watch usage in real time with live updates") diff --git a/tests/web-cli.test.ts b/tests/web-cli.test.ts index 42764c3..3338e85 100644 --- a/tests/web-cli.test.ts +++ b/tests/web-cli.test.ts @@ -6,6 +6,7 @@ import { createCliProgram } from "../src/cli/index.js"; import { createUiRoutes, registerUiCommand } from "../src/cli/commands/ui.js"; import { registerSetupCommand } from "../src/cli/commands/setup.js"; import { registerUsageCommand } from "../src/cli/commands/usage.js"; +import { registerReviewCommand } from "../src/cli/commands/review.js"; import { DEFAULT_CONFIG, serializeConfig, type SetupConfigRead } from "../src/config/config.js"; import type { BatchModelsOptions, BatchModelsResult } from "../src/core/models.js"; import type { UsageQueryResult } from "../src/daemon/protocol.js"; @@ -294,3 +295,20 @@ describe("setup and usage web commands", () => { expect(launch).not.toHaveBeenCalled(); }); }); + +describe("web command --port help", () => { + it.each([ + ["review", registerReviewCommand], + ["setup", registerSetupCommand], + ["usage", registerUsageCommand], + ["ui", registerUiCommand], + ] as const)("%s describes the console port", (name, register) => { + const program = new Command(); + register(program); + const command = program.commands.find((candidate) => candidate.name() === name)!; + + const option = command.options.find((candidate) => candidate.long === "--port")!; + + expect(option.description).toBe("port for a new console (default: web.port from config, else 7777)"); + }); +}); From bcf3ed200089185e109ecfded49046e3ee32ed34 Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Thu, 24 Sep 2026 22:46:45 -0300 Subject: [PATCH 15/17] docs(web): Document stable console access and stop gate daemons Describe the daemon-owned console on web.port, the stable token file, the year-long cookie and the localhost redirect in the protocol doc. The pty and rename gates now kill their isolated daemon on exit instead of leaving it behind. --- .specs/features/web-access/tasks.md | 7 +++--- docs/protocol.md | 36 +++++++++++++++++++++-------- scripts/pty-gate.sh | 4 +++- scripts/rename-gate.sh | 4 +++- 4 files changed, 37 insertions(+), 14 deletions(-) diff --git a/.specs/features/web-access/tasks.md b/.specs/features/web-access/tasks.md index ad85405..39264f8 100644 --- a/.specs/features/web-access/tasks.md +++ b/.specs/features/web-access/tasks.md @@ -290,12 +290,13 @@ T8 → T9 → T10 **Done when**: -- [ ] `npm run build` exits 0; `scripts/pty-gate.sh` prints its ok line and leaves no daemon with its `RUN_AGENT_DIR` -- [ ] Smoke under a temp `RUN_AGENT_DIR` and config: daemon start → 403 on the preferred port within 5 s; `ui --no-open` URL → cookie with `Max-Age`; restarting the daemon keeps the token; `localhost` page GET → 302; `web.port` change + one command moves the port -- [ ] Gate check passes: build gate +- [x] `npm run build` exits 0; `scripts/pty-gate.sh` prints its ok line and leaves no daemon with its `RUN_AGENT_DIR` +- [x] Smoke under a temp `RUN_AGENT_DIR` and config: daemon start → 403 on the preferred port within 5 s; `ui --no-open` URL → cookie with `Max-Age`; restarting the daemon keeps the token; `localhost` page GET → 302; `web.port` change + one command moves the port +- [x] Gate check passes: build gate **Tests**: none **Gate**: build +**Status**: ✅ Done **Commit**: `docs(web): Document stable console access and stop gate daemons` diff --git a/docs/protocol.md b/docs/protocol.md index 94e9497..dcef152 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -35,19 +35,26 @@ Persistência: `sessions` + `events(seq)` monotônico, `raw_payload` preservado. ## Console web (`web.ensure`) -O daemon não abre porta HTTP. Ele supervisiona um processo filho +O daemon não abre porta HTTP. Ele sobe, logo depois de iniciar, um processo filho (`dist/web/child.js --web-child`) que serve o console inteiro: `/`, `/review`, `/setup`, `/usage` e as rotas `/api/*`. `codedeck ui`, `review`, `setup` e `usage --web` pedem esse filho ao daemon, abrem ou imprimem a URL e voltam para -o shell. +o shell. Se o start junto com o daemon falhar, o `daemon.log` recebe +`web autostart failed: ` e o próximo `web.ensure` tenta de novo. Request: ```json -{ "id": "w1", "method": "web.ensure", "params": { "port": 3100, "build": "1790000000000", "entry": "/abs/dist/web/child.js" } } +{ "id": "w1", "method": "web.ensure", "params": { "preferredPort": 7777, "build": "1790000000000", "entry": "/abs/dist/web/child.js" } } ``` -- `port` (opcional): porta explícita (`--port`). Sem ela, o filho tenta 3100 e - cai numa porta efêmera se 3100 estiver ocupada. +- `port` (opcional): porta explícita (`--port`). Só vale quando um filho + precisa subir, e nunca cai para outra porta. Um filho já rodando é + reaproveitado em qualquer porta. +- `preferredPort` (opcional): a porta preferida de quem chamou, `web.port` do + `config.json` ou 7777. O filho escuta nela e cai numa porta efêmera com + qualquer erro de listen. Um pedido com `preferredPort` e sem `port` reinicia + o filho que não subiu para essa mesma porta preferida. Sem nenhum dos dois, o + filho atual é reaproveitado. - `build` (opcional): identidade do build do chamador, o maior `mtime` dos `.js` na árvore `dist/` dele. - `entry` (opcional): caminho absoluto do `dist/web/child.js` do chamador. Sem @@ -55,12 +62,20 @@ Request: Response: ```json -{ "id": "w1", "result": { "baseUrl": "http://127.0.0.1:3100", "port": 3100, "token": "..." } } +{ "id": "w1", "result": { "baseUrl": "http://127.0.0.1:7777", "port": 7777, "token": "..." } } ``` A página abre em `?&t=`. O token vira cookie -(`303` sem `t`), e toda rota `/api/*` exige esse cookie. Uma página aberta sem -token nem cookie responde `403 open this page with codedeck ui`. O review +(`303` sem `t`, `Max-Age` de 365 dias, renovado a cada página servida), e toda +rota `/api/*` exige esse cookie. Uma página aberta sem token nem cookie responde +`403 Run "codedeck ui" once in a terminal to open CodeDeck in this browser.` +Uma página pedida em `localhost:` responde `302` para `127.0.0.1:`, +porque o cookie de `127.0.0.1` não vai para `localhost`. + +O token fica em `~/.run-agent/web-token` (modo 0600) e vale para todo servidor +do console, inclusive o fallback no próprio processo, então um bookmark +sobrevive a restarts do filho e do daemon. Para trocar o token, apague o arquivo: +o próximo filho que subir grava um novo. O review recebe o repositório em `?repo=`, porque o filho não roda no cwd de quem chamou. @@ -74,7 +89,10 @@ Erros: Ciclo de vida do filho: -- Existe no máximo um filho. Pedidos simultâneos compartilham o mesmo start. +- Existe no máximo um filho e no máximo um start por vez. Um pedido que chega + durante um start espera ele terminar e decide pelos próprios parâmetros. +- Argumentos do filho: `--port ` (explícita, sem fallback), + `--preferred-port ` (fallback efêmero) ou nenhum (7777 com fallback). - Handshake: a primeira linha do stdout do filho é `{ port, token, build }` ou `{ error: { message, port } }`. O stderr vai para `~/.run-agent/logs/web-child.log`. - Se `build` ou `entry` do pedido diferem do filho atual, o daemon manda diff --git a/scripts/pty-gate.sh b/scripts/pty-gate.sh index 166b56a..c995f35 100755 --- a/scripts/pty-gate.sh +++ b/scripts/pty-gate.sh @@ -18,7 +18,9 @@ WORK="$(mktemp -d)" CONFIG_DIR="$(mktemp -d)" STATE_DIR="$(mktemp -d)" CAPTURE="$WORK/capture" -trap 'rm -rf "$WORK" "$CONFIG_DIR" "$STATE_DIR"' EXIT +# The daemon `open` starts outlives this script and would keep holding the +# console port with a token nobody has, so stop it before removing its state. +trap 'kill "$(cat "$STATE_DIR/daemon.pid" 2>/dev/null)" 2>/dev/null || true; rm -rf "$WORK" "$CONFIG_DIR" "$STATE_DIR"' EXIT ROWS="${PTY_GATE_ROWS:-41}" COLS="${PTY_GATE_COLS:-137}" diff --git a/scripts/rename-gate.sh b/scripts/rename-gate.sh index b2b0690..bc84993 100755 --- a/scripts/rename-gate.sh +++ b/scripts/rename-gate.sh @@ -16,7 +16,9 @@ HERE="$(cd "$(dirname "$0")/.." && pwd)" CAPTURE="$(mktemp)" CONFIG_DIR="$(mktemp -d)" STATE_DIR="$(mktemp -d)" -trap 'rm -rf "$CAPTURE" "$CONFIG_DIR" "$STATE_DIR"' EXIT +# Stop the daemon this run started before removing its state; left running, it +# would keep holding the console port with a token nobody has. +trap 'kill "$(cat "$STATE_DIR/daemon.pid" 2>/dev/null)" 2>/dev/null || true; rm -rf "$CAPTURE" "$CONFIG_DIR" "$STATE_DIR"' EXIT # Same reason as the theme gate: an empty `models` key means the wizard never # opens, so the session paints instead of waiting on a question. From 2ea5e6e582ff348109546505fcdc1b4061f4ec06 Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Thu, 24 Sep 2026 22:55:36 -0300 Subject: [PATCH 16/17] test(web): Pin the forbidden body and the web child entry wiring Close the Verifier's surviving mutants: the Host and POST rejection tests now assert the spec's forbidden body, and a source check pins that the --web-child entry feeds parseWebChildArgs into runWebChild. Also fix the spec coverage line and the tasks status header. --- .specs/features/web-access/spec.md | 2 +- .specs/features/web-access/tasks.md | 2 +- tests/web-child.test.ts | 7 +++++++ tests/web-security.test.ts | 3 +++ 4 files changed, 12 insertions(+), 2 deletions(-) diff --git a/.specs/features/web-access/spec.md b/.specs/features/web-access/spec.md index a2fa4fd..8941d53 100644 --- a/.specs/features/web-access/spec.md +++ b/.specs/features/web-access/spec.md @@ -217,7 +217,7 @@ Tests and docs that pin the old values change with this feature: tests/web-secur | WA-24 | P2: Fixed, configurable port | Tasks | Verified | | WA-25 | P2: Fixed, configurable port | Tasks | Verified | -**Coverage:** 25 total, 0 mapped to tasks, 25 unmapped ⚠️ +**Coverage:** 25 total, 25 mapped to tasks (see tasks.md), 0 unmapped. --- diff --git a/.specs/features/web-access/tasks.md b/.specs/features/web-access/tasks.md index 39264f8..0dc523b 100644 --- a/.specs/features/web-access/tasks.md +++ b/.specs/features/web-access/tasks.md @@ -9,7 +9,7 @@ Implement these tasks with the `tlc-spec-driven` skill: **activate it by name an --- **Design**: inline (no design.md; the spec's Assumptions table fixes every mechanism) -**Status**: Draft +**Status**: Done --- diff --git a/tests/web-child.test.ts b/tests/web-child.test.ts index 6e3148a..a62a91a 100644 --- a/tests/web-child.test.ts +++ b/tests/web-child.test.ts @@ -138,6 +138,13 @@ describe("runWebChild", () => { expect(parseWebChildArgs(argv)).toEqual(expected); }); + it("feeds the parsed argv into runWebChild from the --web-child entry", () => { + const source = fs.readFileSync(path.join(import.meta.dirname, "..", "src", "web", "child.ts"), "utf8"); + const entry = source.slice(source.indexOf('if (process.argv.includes("--web-child"))')); + + expect(entry).toMatch(/runWebChild\(\{\s*\.\.\.parseWebChildArgs\(process\.argv\)/); + }); + it("prints an error handshake and exits 1 when listening fails", async () => { const listen = vi.fn(async () => { throw new Error("listen EADDRINUSE: address already in use 127.0.0.1:4567"); }); const stdout = new PassThrough(); diff --git a/tests/web-security.test.ts b/tests/web-security.test.ts index 66ff492..6a4f6ad 100644 --- a/tests/web-security.test.ts +++ b/tests/web-security.test.ts @@ -101,7 +101,9 @@ describe("web request security", () => { const foreign = await request(handle, { path: "/page", host: `example.test:${handle.port}` }); expect(missing.status).toBe(403); + expect(missing.body).toBe("forbidden"); expect(foreign.status).toBe(403); + expect(foreign.body).toBe("forbidden"); expect(calls).toEqual([]); }); @@ -240,6 +242,7 @@ describe("web request security", () => { origin: value.origin, }); expect(response.status).toBe(403); + expect(response.body).toBe("forbidden"); } expect(calls).toEqual([]); From 2a16948985c762f6f887c62d1b73ddfefb684bac Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Thu, 24 Sep 2026 22:57:07 -0300 Subject: [PATCH 17/17] docs(web): Record the web-access validation report The Verifier passes all 25 acceptance criteria and kills 28 of 28 mutants after one fix round. Two candidate lessons come from the mutants that survived the first pass. --- .specs/LESSONS.md | 12 ++ .specs/features/web-access/validation.md | 226 +++++++++++++++++++++++ .specs/lessons.json | 38 +++- 3 files changed, 275 insertions(+), 1 deletion(-) create mode 100644 .specs/features/web-access/validation.md diff --git a/.specs/LESSONS.md b/.specs/LESSONS.md index 4a5eb34..477935d 100644 --- a/.specs/LESSONS.md +++ b/.specs/LESSONS.md @@ -44,6 +44,18 @@ Seen once or not yet corroborated. Tracked, not trusted. - evidence: validation.md round 2 M27 (tests/web-supervisor.test.ts:287) (import-boundary) - last seen: 2026-09-25T00:53:08Z +### L-006 - Assert the exact response body, not only the status code, on every rejection path whose message the spec pins +- signal: `surviving_mutant` · recurrence: 1 feature(s) · scope: `web-security` · harmful: 0 +- features: web-access +- evidence: validation.md M9, M28 (src/web/security.ts:47,52; tests/web-security.test.ts:103,242) (web-security) +- last seen: 2026-09-25T01:54:46Z + +### L-007 - Test the process entry block that wires argv into the main function, not only the argv parser and the main function separately +- signal: `surviving_mutant` · recurrence: 1 feature(s) · scope: `entrypoints` · harmful: 0 +- features: web-access +- evidence: validation.md M12 (src/web/child.ts:84) (entrypoints) +- last seen: 2026-09-25T01:54:46Z + ## Quarantined (failed when applied - ignore) A confirmed lesson that recurred alongside failure. Kept for the maintainer to review. diff --git a/.specs/features/web-access/validation.md b/.specs/features/web-access/validation.md new file mode 100644 index 0000000..b3a48ec --- /dev/null +++ b/.specs/features/web-access/validation.md @@ -0,0 +1,226 @@ +# Web access Validation + +**Date**: 2026-09-24 +**Spec**: `.specs/features/web-access/spec.md` +**Diff range**: `0498d4d..ff8b930` (branch `feat/web-access`, 12 commits) +**Iteration**: re-verification 1 of 3, after the fix commit `ff8b930` (`test(web): Pin the forbidden body and the web child entry wiring`) +**Verifier**: independent sub-agent (author ≠ verifier) + +**Verdict**: PASS + +All 25 ACs are implemented and each has a `file:line` test citation. All 28 sensor mutants are killed. The first pass (against `dd7b7d9`) was FAIL because M9, M12 and M28 survived. The fix commit `ff8b930` changes only tests and spec/tasks text; `git diff dd7b7d9..ff8b930 -- src` is empty, so the implementation evidence below still holds. + +--- + +## Task Completion + +| Task | Status | Notes | +| ---- | ------ | ----- | +| T1 | ✅ Done | `src/web/web-token.ts` new, `tests/web-token.test.ts` 9 tests | +| T2 | ✅ Done | `src/web/security.ts:18,64,70,126-146` | +| T3 | ✅ Done | `src/web/server.ts:133,143`; `DEFAULT_WEB_PORT` lives in `src/config/web-port.ts:2` and is re-exported | +| T4 | ✅ Done | `src/config/web-port.ts`, `src/config/config.ts` `web?: { port?: number }` | +| T5 | ✅ Done | `src/web/child.ts:36,43-44,70-84` | +| T6 | ✅ Done | Supervisor rules covered; child entry wiring covered by `tests/web-child.test.ts:141-146` since `ff8b930` | +| T7 | ✅ Done | `src/daemon/daemon.ts:1429-1444,2022-2028` | +| T8 | ✅ Done | `src/cli/web-launch.ts:40-42,52,69,94-96` | +| T9 | ✅ Done | `review.ts:96`, `setup.ts:1304`, `usage.ts:154`, `ui.ts:58` | +| T10 | ✅ Done | `scripts/pty-gate.sh:23`, `scripts/rename-gate.sh:21`, `docs/protocol.md` | + +--- + +## Spec-Anchored Acceptance Criteria + +### P1: Bookmark survives restarts + +| AC | Spec-defined outcome | Implementation | Test `file:line` + assertion | Result | +| --- | --- | --- | --- | --- | +| WA-01 | child serves with the stored token and reports it in the handshake | `src/web/web-token.ts:22-23`, `src/web/child.ts:44`, `src/web/server.ts:143` | `tests/web-token.test.ts:25-34` `expect(resolveWebToken({ dir })).toBe(TOKEN_A)`; `tests/web-child.test.ts:123-131` `expect(served.token).toBe("f".repeat(64))` and `expect(listen.mock.calls[0][0].token).toBe(...)`; `tests/web-server.test.ts:283-294` injected token served and required by cookie (`accepted.status` 200) | ✅ PASS | +| WA-02 | missing/malformed file → new 64 lowercase hex token, mode 0600, temp+link, serve the file's token | `web-token.ts:25-43,6` | `tests/web-token.test.ts:36-46` `toMatch(/^[0-9a-f]{64}$/)`, `modeOf(...)).toBe(0o600)`, `readdirSync(dir)).toEqual(["web-token"])`; `:48-63` malformed (short, uppercase, extra text, empty) → replaced, file content equals returned token | ✅ PASS | +| WA-03 | concurrent resolvers agree on the file's token | `web-token.ts:30-34` | `tests/web-token.test.ts:65-77` link seam loses the race: `expect(token).toBe(TOKEN_B)` and the file holds `TOKEN_B` | ✅ PASS (seam, not two real processes) | +| WA-04 | loose mode → 0600 before serving | `web-token.ts:54` | `tests/web-token.test.ts:79-86` 0644 → `modeOf(...)).toBe(0o600)`, token kept | ✅ PASS | +| WA-05 | in-process fallback resolves the token the same way | `src/cli/web-launch.ts:96` | `tests/web-launch.test.ts:176-185` `expect(options.token).toBe(TOKEN)`; `:187-197` same with `--port` | ✅ PASS (the default `?? resolveWebToken` is only visible by reading the code, see Observations) | +| WA-06 | 303 + `Set-Cookie: codedeck_ui_token_=; Path=/; Max-Age=31536000; HttpOnly; SameSite=Strict` | `security.ts:18,120,127` | `tests/web-security.test.ts:110-123` `status 303`, exact `set-cookie` array | ✅ PASS | +| WA-07 | valid cookie, no valid `t` → page served with the same `Set-Cookie` | `security.ts:70` | `tests/web-security.test.ts:171-183` for `/page` and `/page?t=stale-token`: `status 200` and exact `set-cookie` | ✅ PASS | +| WA-08 | 403 with body `Run "codedeck ui" once in a terminal to open CodeDeck in this browser.` | `security.ts:16,67` | `tests/web-security.test.ts:137-155` `expect(response.body).toBe(PAGE_FORBIDDEN)` (`:13` holds the exact text) | ✅ PASS | +| WA-09 | localhost page GET → 302 `Location: http://127.0.0.1:` from a parsed URL, before token/cookie checks | `security.ts:64,132-146` | `tests/web-security.test.ts:185-201` bare, with `t` (302 instead of 303 proves the ordering) and an absolute-form target: exact `location`, `set-cookie` undefined, `calls` empty | ✅ PASS | +| WA-10 | foreign Host, bad POST and `/api/*` without cookie → 403 `forbidden` | `security.ts:46-59,149` | API: `tests/web-security.test.ts:203-221` `status 403` + `:210` `body "forbidden"`. Host: `:97-108` status 403 + `:104,106` `expect(missing.body).toBe("forbidden")`, `expect(foreign.body).toBe("forbidden")`. POST: `:223-260` status 403 + `:245` `expect(response.body).toBe("forbidden")` for all 6 cases | ✅ PASS (fixed in `ff8b930`; M9, M28 now killed) | + +### P1: Console answers while the daemon runs + +| AC | Spec-defined outcome | Implementation | Test `file:line` + assertion | Result | +| --- | --- | --- | --- | --- | +| WA-11 | after `start()` resolves, one unawaited `web.ensure` with the resolved preferred port and no port | `daemon.ts:1438-1444,2022-2023` | `tests/daemon-web.test.ts:143-152` `toHaveBeenCalledTimes(1)`, `toHaveBeenCalledWith({ preferredPort: 7788 })`; `:189-194` static regex on the `--daemon` entry: `d.start().then(() => d.autostartWeb(), ...)` | ✅ PASS | +| WA-12 | failure → `web autostart failed: ` in `daemon.log`, IPC keeps serving | `daemon.ts:1443` | `tests/daemon-web.test.ts:154-166` log line regex with the exact message; a later `ensure({})` returns the host result | ✅ PASS | +| WA-13 | requests during a start wait, re-check with their own params, repeat while a start is in flight; at most one start | `web-supervisor.ts:110-123` | `tests/web-supervisor.test.ts:283-313` three concurrent requests: each resolves to its own port, spawn args in order, `aliveAtSpawn` `[0,0,0]` | ✅ PASS | +| WA-14 | `Daemon.start()` starts no web child | `daemon.ts` (`start()` untouched) | `tests/daemon-web.test.ts:179-187` `expect(host.ensure).not.toHaveBeenCalled()` after `await daemon.start()` | ✅ PASS | + +### P2: Fixed, configurable port + +| AC | Spec-defined outcome | Implementation | Test `file:line` + assertion | Result | +| --- | --- | --- | --- | --- | +| WA-15 | `web.port` when an integer in 1-65535, else 7777 | `src/config/web-port.ts:16-23` | `tests/web-port.test.ts:5-10` absent → 7777; `:12-14` 7788, 1, 65535; `:16-24` `"7788"`, 0, 65536, 7.5 → `{ port: 7777, invalid }` | ✅ PASS | +| WA-16 | no `--port` → `preferredPort` sent, no `port` | `web-launch.ts:52` | `tests/web-launch.test.ts:95-106` `toMatchObject({ preferredPort: 7788 })`, `not.toHaveProperty("port")`; explicit case has no `preferredPort`; `:108-114` default 7777 | ✅ PASS | +| WA-17 | `CodeDeck web is running on port instead of ` before the page line | `web-launch.ts:42,69` | `tests/web-launch.test.ts:116-129` exact `logs` array, notice first, for `--port` and preferred; `:131-138` no notice when equal | ✅ PASS | +| WA-18 | supervisor passes `--preferred-port `; child listens there, OS port on any listen error | `web-supervisor.ts:161-165`; `child.ts:36,43`; `server.ts:133` | `tests/web-supervisor.test.ts:236-242` args `["--web-child","--preferred-port","7777"]`; `tests/web-child.test.ts:109-121` `[7788, true]`; `tests/web-child.test.ts:133-139` `parseWebChildArgs`; `tests/web-child.test.ts:141-146` entry block matches `/runWebChild\(\{\s*\.\.\.parseWebChildArgs\(process\.argv\)/`; `tests/web-server.test.ts:262-281` EACCES → other port | ✅ PASS (entry check added in `ff8b930`; M12 now killed; runtime check below) | +| WA-19 | `preferredPort` without `port` restarts a child not started for that preferred port | `web-supervisor.ts:142-143,155-160` | `tests/web-supervisor.test.ts:244-259` for another preferred port, an explicit port and no port: SIGTERM, new child, args `--preferred-port 7788`; `:261-269` same preferred port → reuse, even after fallback | ✅ PASS | +| WA-20 | `port` + matching entry/build → reuse whatever the child was started for | `web-supervisor.ts:142` | `tests/web-supervisor.test.ts:271-281` (`{ port: 8000 }`) `resolves.toEqual(first)`, `spawns` length 1 | ✅ PASS | +| WA-21 | in-process without `--port` → preferred port, OS port on any listen error | `web-launch.ts:94`, `server.ts:133` | `tests/web-launch.test.ts:176-185` `options.port` 7788, `fallbackToEphemeral` true; `tests/web-server.test.ts:262-281` for any-error fallback | ✅ PASS | +| WA-22 | CLI prints `Ignoring invalid web.port in config: ` to stderr; daemon appends it to `daemon.log` | `web-launch.ts:41`, `daemon.ts:1440`, `web-port.ts:25-27` | `tests/web-launch.test.ts:140-147` `errors` equals the exact line once; `tests/daemon-web.test.ts:168-177` log regex with `"7788"`; `tests/web-port.test.ts:26-29` JSON rendering | ✅ PASS | +| WA-23 | `--port` help reads `port for a new console (default: web.port from config, else 7777)` | the four command files | `tests/web-cli.test.ts:300-313` exact `option.description` for review, setup, usage, ui | ✅ PASS | +| WA-24 | child with neither flag → 7777, OS port on any listen error | `child.ts:36,43` | `tests/web-child.test.ts:109-121` `[7777, true]` | ✅ PASS | +| WA-25 | neither `port` nor `preferredPort` + matching entry/build → reuse | `web-supervisor.ts:142` | `tests/web-supervisor.test.ts:271-281` (`{}`) reuse, 1 spawn; `:65-78` reuse on `{}` | ✅ PASS | + +**Status**: ✅ All ACs covered. No spec-precision gaps: every AC fixes an exact value. + +--- + +## Edge Cases + +- [x] Token file with anything other than 64 lowercase hex plus optional newline is replaced: `tests/web-token.test.ts:48-63`. +- [x] A child restart keeps the cookie valid: follows from WA-01 + WA-07. The author's smoke saw a 200 with the old cookie after a daemon restart. My runtime check below saw two children share one token. +- [x] No notice when the console is on the preferred port: `tests/web-launch.test.ts:131-138`. +- [x] `?t=` survives the localhost 302: `tests/web-security.test.ts:190,197`. +- [x] A `--port 8000` child is restarted by a later request without `--port`: `tests/web-supervisor.test.ts:244-259` ("an explicit port" row). +- [x] Unbindable preferred port falls back and prints the notice: `tests/web-server.test.ts:262-281` (EACCES) + `tests/web-launch.test.ts:116-129`. + +--- + +## Supersedes Check (web-daemon) + +| web-daemon item | Replacement verified | +| --- | --- | +| lazy child start | WA-11: `daemon.ts:2022-2023`, lazy `webHost()` kept for `web.ensure` (`daemon.ts:1429-1432`) | +| preferred port 3100, WD-04 | `DEFAULT_WEB_PORT` 7777 (`web-port.ts:2`); `grep 3100 src docs/protocol.md` finds nothing | +| token not persisted | WA-01..04 | +| WD-21 `already running on port` | gone from `src`; WA-17 text at `web-launch.ts:69` | +| WD-30 `open this page with codedeck ui` | gone from `src` and `docs`; WA-08 text at `security.ts:16` | +| `web.ensure` params | `preferredPort` added in `src/daemon/protocol.ts` and `docs/protocol.md` | +| WD-02, WD-42 reuse | port rule applied after entry/build (`web-supervisor.ts:136-143`); the old reuse tests still pass (`tests/web-supervisor.test.ts:65-78`) | +| WD-03 shared start | replaced by the serialized wait (`web-supervisor.ts:110`); same-params requests still get one spawn (`tests/web-supervisor.test.ts:65-72`) | +| WD-44 EADDRINUSE-only fallback | any error (`server.ts:133`); the `EADDRINUSE` literal is gone from `src` | + +The tests the spec listed as pinning old values were all updated: `web-security.test.ts:141,151`, `web-launch.test.ts` 3100→7777, `web-server.test.ts:45-46`, `review-command.test.ts:11-12`, `docs/protocol.md:35-95`. + +--- + +## Discrimination Sensor + +Scratch: `git worktree add --detach /verify-wt HEAD`, `node_modules` symlinked. Each mutant was applied with an exact single-match replacement, its test file run alone, and the file restored. The scratch was removed with `git worktree remove --force`. + +| # | File:line | Mutation | Result | +| --- | --- | --- | --- | +| M1 | `src/web/web-token.ts:54` | drop the chmod of a loose file | ✅ Killed by `web-token.test.ts:79` | +| M2 | `src/web/web-token.ts:34` | ignore the race winner, always rename | ✅ Killed by `web-token.test.ts:65` | +| M3 | `src/web/web-token.ts:6` | accept uppercase hex | ✅ Killed by `web-token.test.ts:48` (uppercase) | +| M4 | `src/web/web-token.ts:41` | leave the temp file behind | ✅ Killed by `web-token.test.ts:36,65` | +| M5 | `src/web/security.ts:70` | no cookie renewal on a page served with the cookie | ✅ Killed by `web-security.test.ts:171` (re-run on `ff8b930`) | +| M6 | `src/web/security.ts:18` | Max-Age 86400 | ✅ Killed by `web-security.test.ts:110,171` (re-run) | +| M7 | `src/web/security.ts:64-65` | canonical redirect after the token check | ✅ Killed by `web-security.test.ts:185` (re-run) | +| M8 | `src/web/security.ts:144` | `Location` built from the raw request target | ✅ Killed by `web-security.test.ts:185` (re-run) | +| M9 | `src/web/security.ts:47` | foreign Host answers with the page text instead of `forbidden` | ✅ Killed by `web-security.test.ts:97` (survived on `dd7b7d9`) | +| M10 | `src/web/server.ts:133` | fallback only on `EADDRINUSE` | ✅ Killed by `web-server.test.ts:262` | +| M11 | `src/web/child.ts:36` | child ignores `preferredPort` | ✅ Killed by `web-child.test.ts:109` (re-run) | +| M12 | `src/web/child.ts:84` | process entry passes only `port`, drops `--preferred-port` | ✅ Killed by `web-child.test.ts:141` (survived on `dd7b7d9`) | +| M13 | `src/daemon/web-supervisor.ts:143` | preferred port never restarts (`return true`) | ✅ Killed by `web-supervisor.test.ts:244,283` | +| M14 | `src/daemon/web-supervisor.ts:142` | explicit port no longer reuses | ✅ Killed by `web-supervisor.test.ts:271` | +| M15 | `src/daemon/web-supervisor.ts:110` | revert to sharing the in-flight promise (old WD-03) | ✅ Killed by `web-supervisor.test.ts:283` | +| M16 | `src/daemon/web-supervisor.ts:110` | `while` → `if` (no repeated wait) | ✅ Killed by `web-supervisor.test.ts:283` | +| M17 | `src/daemon/web-supervisor.ts:164` | no `--preferred-port` arg | ✅ Killed by `web-supervisor.test.ts:236,244` | +| M18 | `src/daemon/web-supervisor.ts:142` | a request with neither field reuses only a portless child | ✅ Killed by `web-supervisor.test.ts:271` | +| M19 | `src/daemon/daemon.ts:1443` | failed autostart swallowed without a log | ✅ Killed by `daemon-web.test.ts:154` | +| M20 | `src/daemon/daemon.ts:1440` | no invalid-`web.port` log line | ✅ Killed by `daemon-web.test.ts:168` | +| M21 | `src/daemon/daemon.ts:1442` | autostart sends `{ port }` | ✅ Killed by `daemon-web.test.ts:143,154,168` | +| M22 | `src/daemon/daemon.ts:2022` | autostart before `start()` resolves | ✅ Killed by `daemon-web.test.ts:189` | +| M23 | `src/cli/web-launch.ts:69` | notice only with `--port` | ✅ Killed by `web-launch.test.ts:116` | +| M24 | `src/cli/web-launch.ts:96` | fallback without the shared token | ✅ Killed by `web-launch.test.ts:176` | +| M25 | `src/cli/web-launch.ts:52` | preferred port sent as `port` | ✅ Killed by `web-launch.test.ts:95` | +| M26 | `src/cli/web-launch.ts:41` | no invalid-`web.port` warning | ✅ Killed by `web-launch.test.ts:140` | +| M27 | `src/config/web-port.ts:21` | accept port 0 | ✅ Killed by `web-port.test.ts:16` (zero) | +| M28 | `src/web/security.ts:52` | POST without credentials answers with the page text instead of `forbidden` | ✅ Killed by `web-security.test.ts:223` (survived on `dd7b7d9`) | + +**Sensor depth**: expanded (auth boundary + concurrency), 28 manual mutants. +**Result**: 28/28 killed. PASS ✅ + +Re-run on `ff8b930` in a fresh scratch worktree: M5-M9, M11, M12, M28 (every mutant whose covering test file changed). The other 20 target source that did not change and test files that did not change (`git diff dd7b7d9..ff8b930 -- src` is empty; only `web-security.test.ts` and `web-child.test.ts` changed), so their first-pass kills carry over. + +**Isolation**: first pass, `git status --porcelain` was empty before and after. Re-verification: the baseline was ` M .specs/LESSONS.md`, ` M .specs/lessons.json`, `?? .specs/features/web-access/validation.md` (the verifier's own outputs), and it was identical afterwards (`cmp` matched). Both scratch worktrees are gone from `git worktree list`. + +--- + +## Runtime checks (verifier) + +- `node dist/web/child.js --web-child --preferred-port 7793` twice under a temp `RUN_AGENT_DIR` (dist newer than every `src/*.ts`): the first child printed `{"port":7793,...}` and the second fell back to `{"port":36771,...}`. Both used the same token, equal to `web-token`, mode `600`. So the real entry does wire `--preferred-port` (the behavior M12 targets), and WA-01/WA-02/WA-18 hold at runtime. +- `scripts/pty-gate.sh` (worktree `dist/`): exit 0, `pty path ok: tty, 137x41, /rename corrigir-auth-do-login typed, keys still flowing`. Afterwards no `daemon.js` or `web/child.js` process from the worktree's `dist/` was left. Every running daemon belongs to `/home/andreello/dev/codedeck/dist`. +- The author's 8-step smoke against `dist/` is cited from the brief and was not re-run. + +--- + +## Gate Check + +- **Commands** (one file per run, from the worktree): `npx vitest run tests/.test.ts` for each of the 10 files; `npx tsc --noEmit` (exit 0); `scripts/pty-gate.sh` (exit 0). `npm run build` was not re-run: the author saw exit 0, and `find src -newer dist/daemon/daemon.js -name '*.ts'` is empty. +- **Re-verification on `ff8b930`**: `npx vitest run tests/web-security.test.ts` passed 10/10 and `npx vitest run tests/web-child.test.ts` passed 13/13. The other 8 files did not change and were not re-run. +- **Result**: 136 passed, 0 failed, 0 skipped. web-token 9, web-security 10, web-server 16, web-port 11, web-child 13, web-supervisor 29, daemon-web 10, web-launch 17, web-cli 14, review-command 7. +- **Test count before feature** (same 8 pre-existing files at `0498d4d`, run in scratch): 84. security 8, server 13, child 8, supervisor 21, daemon-web 5, launch 12, cli 10, review 7. +- **Test count after feature**: 136 (116 in those 8 files + 20 in the 2 new files). +- **Delta**: +52. No file lost tests. One pre-existing assertion was re-targeted: `web-security.test.ts:90` now sends the LOCALHOST cookie case to `/action` instead of `/page`, because page GETs on localhost now redirect. The Host acceptance it checks is still asserted. + +--- + +## Code Quality + +| Principle | Status | +| --- | --- | +| Minimum code | ✅ | +| Surgical changes | ✅ (diff limited to the files named in tasks.md) | +| No scope creep | ✅ | +| Matches patterns | ✅ (DI seams like the existing `runWebChild`/`launchWebPage` options; static entry check like other daemon-entry tests) | +| Spec-anchored outcome check | ✅ (WA-10 body pinned on all three paths since `ff8b930`) | +| Per-layer coverage expectation | ✅ (child entry covered by `web-child.test.ts:141-146`) | +| Every test maps to a spec requirement | ✅ | +| Documented guidelines followed: `CLAUDE.md` (scoped vitest, seams keep tests off `~/.run-agent`) | ✅ every new default-path writer (`resolveWebToken`) is injected in unit tests (`web-child.test.ts:44,147,188`, `web-launch.test.ts:43-44`); the daemon tests use a temp `RUN_AGENT_DIR` (`tests/helpers/daemon-seam.ts:87`) and a temp `RUN_AGENT_CONFIG_DIR` | + +--- + +## Fix Plans + +Both fix plans from the first pass are done in `ff8b930` and verified above: + +- Fix 1 (WA-10 `forbidden` body on the Host and POST rejections): `tests/web-security.test.ts:104,106,245`. M9 and M28 are now killed. +- Fix 2 (web child entry wiring, WA-18): `tests/web-child.test.ts:141-146`. M12 is now killed. + +--- + +## Observations (not gaps) + +- The default seams `(deps.resolveToken ?? resolveWebToken)` (`web-launch.ts:96`) and `(options.resolveToken ?? resolveWebToken)` (`child.ts:44`) are always injected in tests. The child default is confirmed by the runtime check. The CLI fallback default is confirmed only by reading the code; it was not run as a mutant. +- WA-11 says "with its own entry". `autostartWeb` sends no `entry`, and the supervisor falls back to `defaultEntry`, the daemon's own `dist/web/child.js` (`web-supervisor.ts:98`). This matches the spec's intent and the test asserts it (`daemon-web.test.ts:150`). +- `ff8b930` fixed the `spec.md` coverage line (now `25 mapped`) and set the `tasks.md` Status to `Done`. + +--- + +## Requirement Traceability Update + +| Requirement | Previous Status | New Status | +| --- | --- | --- | +| WA-01..WA-09 | Verified (set by author) | ✅ Verified | +| WA-10 | Needs Fix (first pass) | ✅ Verified (`ff8b930`) | +| WA-11..WA-17 | Verified (set by author) | ✅ Verified | +| WA-18 | Needs Fix (first pass) | ✅ Verified (`ff8b930`) | +| WA-19..WA-25 | Verified (set by author) | ✅ Verified | + +--- + +## Summary + +**Overall**: ✅ Ready + +**Spec-anchored check**: 25/25 ACs matched to the spec outcome. 0 spec-precision gaps. +**Sensor**: 28/28 mutations killed (M9, M12 and M28 survived on `dd7b7d9` and are killed on `ff8b930`). +**Gate**: 136 passed, 0 failed; `tsc --noEmit` exit 0 and `pty-gate.sh` exit 0 (first pass; `src` has not changed since). + +**What works**: persistent 0600 token with a race-safe publish, year-long renewed cookie, localhost→127.0.0.1 302, 7777 default with fallback on any listen error, supervisor port rules and serialized starts, eager autostart after `start()`, CLI `preferredPort`/notice/warning, help text, gate daemon cleanup. + +**Issues found**: none open. The two first-pass gaps were closed in iteration 1. + +**Next steps**: none from the verifier. The feature is ready to merge. diff --git a/.specs/lessons.json b/.specs/lessons.json index 2d80c17..a11df23 100644 --- a/.specs/lessons.json +++ b/.specs/lessons.json @@ -3,7 +3,7 @@ "promote_threshold": 2, "window_days": 45, "quarantine_threshold": 2, - "next_id": 6, + "next_id": 8, "lessons": [ { "id": "L-001", @@ -95,6 +95,42 @@ ], "created": "2026-09-25T00:53:08Z", "last_seen": "2026-09-25T00:53:08Z" + }, + { + "id": "L-006", + "key": "surviving_mutant::assert the exact response body not only the status code on every rejection path whose message the spec pins", + "text": "Assert the exact response body, not only the status code, on every rejection path whose message the spec pins", + "signal": "surviving_mutant", + "scope": "web-security", + "status": "candidate", + "features": [ + "web-access" + ], + "recurrence": 1, + "harmful": 0, + "evidence": [ + "validation.md M9, M28 (src/web/security.ts:47,52; tests/web-security.test.ts:103,242) (web-security)" + ], + "created": "2026-09-25T01:54:46Z", + "last_seen": "2026-09-25T01:54:46Z" + }, + { + "id": "L-007", + "key": "surviving_mutant::test the process entry block that wires argv into the main function not only the argv parser and the main function separately", + "text": "Test the process entry block that wires argv into the main function, not only the argv parser and the main function separately", + "signal": "surviving_mutant", + "scope": "entrypoints", + "status": "candidate", + "features": [ + "web-access" + ], + "recurrence": 1, + "harmful": 0, + "evidence": [ + "validation.md M12 (src/web/child.ts:84) (entrypoints)" + ], + "created": "2026-09-25T01:54:46Z", + "last_seen": "2026-09-25T01:54:46Z" } ] }