From 7c2bad56ddfcb513bfd0d170927e1decc3222c4d Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Fri, 25 Sep 2026 01:12:10 -0300 Subject: [PATCH 01/15] feat(config): Resolve the console bind host from web.host Co-Authored-By: Codex --- .specs/features/web-host/run-notes.md | 22 ++++ .specs/features/web-host/spec.md | 149 ++++++++++++++++++++++++++ .specs/features/web-host/tasks.md | 118 ++++++++++++++++++++ src/config/web-host.ts | 30 ++++++ tests/web-port.test.ts | 29 +++++ 5 files changed, 348 insertions(+) create mode 100644 .specs/features/web-host/run-notes.md create mode 100644 .specs/features/web-host/spec.md create mode 100644 .specs/features/web-host/tasks.md create mode 100644 src/config/web-host.ts diff --git a/.specs/features/web-host/run-notes.md b/.specs/features/web-host/run-notes.md new file mode 100644 index 0000000..902563d --- /dev/null +++ b/.specs/features/web-host/run-notes.md @@ -0,0 +1,22 @@ +# Run notes + +| Sequence | Decision | Bucket | Reason | +|---|---|---|---| +| 1 | Treat the supplied WH-01 through WH-16 criteria as the complete acceptance source, and keep the existing loopback default. | Assumption | The feature request says these criteria are the source of truth and that default behavior must not change. | +| 2 | If `web` is present but is not an object, treat that raw value as an invalid `web.host` setting and report it as JSON. | Assumption | `resolveWebPort` already treats malformed `web` sections as invalid and reports the raw value. | +| 3 | A valid `ui --host` value overrides the configured host for that launch, while an invalid configured host still produces the WH-03 warning. | Assumption | WH-03 requires reporting invalid config, and WH-04 says the CLI flag overrides the resolved setting. | +| 4 | The supervisor stores the requested bind host with its running-child state because the child handshake contains only port, token, and build. | Decision | WH-09 needs the supervisor to compare the requested host with the host used to start the child. | +| 5 | Compare accepted Host header names case-insensitively and require the exact console port. | Assumption | WH-12 requires case-insensitive Host matching and names each accepted value with ``. | +| 6 | Build alternate wildcard URLs from every non-internal IPv4 interface address returned at launch time, preserving the requested page path, query, and token. | Assumption | WH-14 specifies one link per non-internal IPv4 address and requires the tokenized page URL. | +| 7 | Use `127.0.0.1` as the advertised URL host for `127.0.0.1`, `0.0.0.0`, and `::`; bracket all other IPv6 hosts. | Decision | This is the URL mapping WH-10 specifies for both server and supervisor results. | +| 8 | Treat any IPv4 address in `127.0.0.0/8` and IPv6 loopback `::1` as loopback for the warning check. | Decision | WH-15 defines the complete loopback ranges that suppress the plain HTTP warning. | +| 9 | Keep documentation checks as a focused test over the two required documentation files. | Assumption | The task matrix requires a test file and Vitest command for each layer, including docs. | +| 10 | Stop implementation before committing because Vitest and TypeScript are absent and installing dependencies is out of scope. | Blocker | The required T01 gate fails at startup with `ERR_MODULE_NOT_FOUND` for `vitest/config`, and no install-free local runner is present. | +| 11 | Run Vitest with `--no-cache` in this worktree. | Decision | The supplied `node_modules` symlink is read-only, and Vitest otherwise exits 1 while writing `node_modules/.vite/vitest/results.json`; the scoped tests pass with caching disabled. | +| 12 | Resume implementation with the supplied `node_modules` symlink and leave it untouched. | Decision | The user confirmed the dependencies are available through this ignored symlink and explicitly said not to remove or commit it. | +| 13 | Use lowercase commit subjects. | Decision | `check_commit.py` rejects capitalized descriptions, so its deterministic gate takes precedence over the capitalized example in the task text. | +| 14 | Stop after T01 because Git cannot write its worktree index or commit metadata. | Blocker | `git add` failed with `Read-only file system` while creating `/home/andreello/dev/codedeck/.git/worktrees/f3c7/index.lock`; that Git directory is outside the writable roots. | +| 15 | Do not create per-task commits; leave all feature changes uncommitted for the orchestrator. | Deviation | The orchestrator explicitly took responsibility for committing after verification because this sandbox cannot write Git metadata. | +| 16 | Redirect `localhost` to `127.0.0.1` only when the server is bound to the default loopback host. | Decision | On another bind address that redirect can target an address the server does not serve; keeping the accepted Host lets the existing checks run on that request. | +| 17 | Stop at T02 because the scoped security tests cannot bind a local TCP listener in this sandbox. | Blocker | All 13 tests fail before request assertions with `listen EPERM: operation not permitted 127.0.0.1`; typecheck passes, but the required integration gate cannot run. | +| 18 | Use the requested capitalized commit subjects even though `check_commit.py` rejects them. | Decision | This continuation explicitly requires capitalized subjects and the exact T01 message; the checker requires lowercase descriptions. | diff --git a/.specs/features/web-host/spec.md b/.specs/features/web-host/spec.md new file mode 100644 index 0000000..82d29e1 --- /dev/null +++ b/.specs/features/web-host/spec.md @@ -0,0 +1,149 @@ +# Configurable web console bind host + +## Problem Statement + +The web console currently binds to `127.0.0.1` and only accepts loopback Host headers. A user cannot open it from another device over a trusted network such as Tailscale. This feature adds an opt-in IP bind host while preserving loopback behavior by default. + +## Goals + +- [ ] Resolve `web.host` to a valid IPv4 or IPv6 address and keep `127.0.0.1` as the default. +- [ ] Carry the resolved host through the CLI, daemon supervisor, web child, and in-process server. +- [ ] Accept requests addressed to local interface IPs and the machine hostname when bound beyond loopback, while keeping the existing token and origin checks. +- [ ] Explain the new config and parameters in README and protocol documentation. + +## Out of Scope + +| Feature | Reason | +| --- | --- | +| TLS or HTTPS | The requested feature only adds an opt-in bind address. | +| Authentication beyond the existing token | WH-13 keeps the current token and cookie checks. | +| A `web.allowedHosts` list | WH-12 defines the accepted names from local interfaces and the machine hostname. | +| Hostnames in `web.host` | WH-02 accepts only values that `net.isIP` accepts. | +| Changing the default bind host | WH-01 requires `127.0.0.1` by default. | +| `--host` on `review`, `setup`, or `usage` | Those commands inherit `web.host` through `launchWebPage`. | +| Changes to `review.ts` or `usage-routes.ts` URL parsing bases | The requested host behavior is implemented in the shared web launch path. | +| Dependency installation or changes to old feature specs | The implementation uses existing Node APIs and this feature supersedes the old remote-access exclusion. | +| Pushing changes | The requested workflow explicitly forbids pushing. | + +## Assumptions & Open Questions + +| Assumption / decision | Chosen default | Rationale | Confirmed? | +| --- | --- | --- | --- | +| A malformed top-level `web` value | Treat it as an invalid host setting and report the raw JSON value. | This matches `resolveWebPort` handling for malformed `web` sections. | No | +| A valid explicit `ui --host` with invalid configured `web.host` | Use the explicit host and still report the invalid config value. | WH-03 requires the CLI warning and WH-04 makes the flag an override. | No | +| Supervisor host identity | Store the requested host with the running child state. | The child handshake has no host field, and WH-09 compares requested and running hosts. | No | +| Host header comparison | Compare the complete host and port case-insensitively. | WH-12 requires case-insensitive matching and the exact console port. | No | +| Wildcard alternate links | Use each non-internal IPv4 interface address and preserve the requested path, query, and token. | WH-14 requires one tokenized link for each such address. | No | +| URL host formatting | Advertise loopback for `127.0.0.1`, `0.0.0.0`, and `::`; bracket other IPv6 hosts. | WH-10 specifies this mapping. | No | +| Loopback warning suppression | Suppress the warning for IPv4 `127.0.0.0/8` and IPv6 `::1`. | WH-15 gives these loopback ranges. | No | +| Documentation verification | Add a focused Vitest check for the required README and protocol text. | The task matrix calls for a test file and Vitest command for each layer. | No | + +**Open questions:** none. These choices are recorded in `.specs/features/web-host/run-notes.md`. + +## User Stories + +### P1: Bind the console to a configured address + +**User Story**: As a CodeDeck user, I want to choose an IP address for the console so that I can open it from another trusted device. + +**Why P1**: Remote access is the feature goal, and the existing loopback default must remain safe and compatible. + +**Acceptance Criteria**: + +1. WHEN config has no `web.host` THEN the resolved host SHALL be `127.0.0.1`. +2. WHEN `web.host` is a string accepted by `net.isIP` as IPv4 or IPv6 THEN the resolved host SHALL be that string. +3. IF `web.host` is present but is not an IP string THEN the resolved host SHALL be `127.0.0.1`, and the CLI SHALL write `Ignoring invalid web.host in config: ` to stderr while the daemon SHALL report the same message in its log. +4. WHEN `codedeck ui --host ` is given THEN the CLI SHALL use that address instead of the resolved `web.host` for that launch. +5. IF `codedeck ui --host ` is given and `` is not an IP string THEN the CLI SHALL write `--host must be an IP address` to stderr, exit with code 1, and make no daemon request. +6. WHEN the daemon starts a web child for a resolved host THEN the child SHALL listen on that host using `--host `, and parsing child arguments without `--host` SHALL resolve to `127.0.0.1`. +7. WHEN the CLI serves in-process after the daemon is unavailable THEN `startWebServer` and `listenWebServer` SHALL listen on the resolved host, defaulting to `127.0.0.1` when the option is absent. +8. WHEN daemon autostart starts the web child THEN `autostartWeb` SHALL pass the host resolved from config. + +**Independent Test**: Set `web.host` to a local interface IP, run `codedeck ui --no-open`, and verify the daemon child and CLI URL use that host. Omit `web.host` and verify the URL remains on `127.0.0.1`. + +### P1: Preserve the console's request protections + +**User Story**: As a user opening the console over a trusted network, I want requests to keep the existing Host, token, cookie, and origin protections. + +**Why P1**: Binding beyond loopback must still reject unrelated hosts and unauthenticated actions. + +**Acceptance Criteria**: + +1. WHILE the bind host is `127.0.0.1` THEN the server SHALL allow exactly `127.0.0.1:` and `localhost:` as Host headers. +2. WHILE the bind host is anything other than `127.0.0.1` THEN the server SHALL also allow case-insensitive Host `:` values for current local interface addresses, `os.hostname()`, and names beginning with `os.hostname() + "."`; interface addresses SHALL be evaluated per request through an injectable seam, IPv6 addresses SHALL use brackets, and addresses with a `%` zone SHALL be skipped. +3. WHEN a Host header is accepted THEN the server SHALL apply the existing token, cookie, same-origin POST, and `/api/*` checks unchanged; a page GET on a Tailscale IP without a token or cookie SHALL return the existing 403 page body, and a GET with the correct `t` token SHALL return 303 and set the session cookie. +4. WHEN `web.ensure` requests a host different from the running child's host THEN the supervisor SHALL stop that child and start a child on the requested host; omitted `host` SHALL mean `127.0.0.1`, and existing port reuse rules SHALL otherwise remain unchanged. +5. WHEN the bind host is `127.0.0.1`, `0.0.0.0`, or `::` THEN the supervisor and `listenWebServer` SHALL return `http://127.0.0.1:` as the base URL; for any other host they SHALL return `http://:`, with IPv6 in brackets. + +**Independent Test**: Bind to a wildcard address, send requests with a local interface Host, a hostile Host, and tokenized or untokenized page URLs, and verify the exact response behavior. + +### P2: Print usable links and bind errors + +**User Story**: As a user launching the console, I want links for local interfaces and clear warnings when the console listens beyond loopback. + +**Why P2**: The selected host can be different from the URL host used for wildcard binds, and plain HTTP on a reachable interface needs a clear notice. + +**Acceptance Criteria**: + +1. WHEN the resolved host is `0.0.0.0` or `::` THEN after the existing ` on <url>` line the CLI SHALL print one `Also on http://<ip>:<port><path>?t=<token>` line for each non-internal IPv4 interface address. <!-- WH-14 --> +2. IF the resolved host is outside both IPv4 `127.0.0.0/8` and IPv6 `::1` THEN the CLI SHALL write `Warning: the console listens on <host> over plain HTTP. Anyone who can reach port <port> with the link gets full access; use it only on a trusted network such as Tailscale.` to stderr. <!-- WH-15 --> +3. IF either CLI listen path fails THEN `src/cli/web-launch.ts` SHALL report `Failed to listen on <host>:<port>: <message>`, with IPv6 hosts bracketed, and exit with code 1. <!-- WH-16 --> + +**Independent Test**: Launch with a wildcard host and verify the base line, alternate links, and warning; occupy a selected host and port and verify the failure names that host. + +## Documentation + +`README.md` SHALL describe `web.host`, `codedeck ui --host`, the child `--host` argument, and the `host` parameter of `web.ensure`. `docs/protocol.md` SHALL document the same protocol parameter in Portuguese. + +## Edge Cases + +- Invalid values include non-strings and strings that `net.isIP` rejects. +- A child started with no `--host` must retain the current loopback behavior. +- A host change must restart a running child even when its port selection otherwise permits reuse. +- Host headers with an unlisted name or a wrong port must remain forbidden. +- Hostname suffix matching is limited to names beginning with the machine hostname followed by a dot. +- IPv6 zone-scoped interface addresses must not enter the allowlist. +- A failed `--host` validation must occur before starting or contacting the daemon. + +## Implicit-requirement sweep + +| Dimension | Resolution | +| --- | --- | +| Input validation and bounds | WH-02, WH-03, and WH-05 require `net.isIP` validation. | +| Failure and partial-failure states | WH-03, WH-05, and WH-16 define invalid config, invalid flag, and listen failure outcomes. | +| Idempotency and retry | WH-09 defines child reuse and restart behavior when the host changes. | +| Auth boundaries and rate limits | WH-11 through WH-13 keep the existing Host and token checks; rate limiting is N/A because this feature does not change request authorization behavior. | +| Concurrency and ordering | WH-09 uses the existing single-child supervisor transition when the host changes. | +| Data lifecycle and expiry | N/A because the feature adds no persisted data. | +| Observability | WH-03 reports invalid config, WH-15 warns about reachable plain HTTP, and WH-16 reports the listen host. | +| External-dependency failure | WH-16 covers OS listen failures; no new external service is introduced. | +| State-transition integrity | WH-09 defines the transition from a running child to a child on the requested host. | + +## Requirement Traceability + +| Requirement ID | Story | Phase | Status | +| --- | --- | --- | --- | +| WH-01 | P1: Bind the console to a configured address | Tasks | Verified | +| WH-02 | P1: Bind the console to a configured address | Tasks | Verified | +| WH-03 | P1: Bind the console to a configured address | Tasks | Pending | +| WH-04 | P1: Bind the console to a configured address | Tasks | Pending | +| WH-05 | P1: Bind the console to a configured address | Tasks | Pending | +| WH-06 | P1: Bind the console to a configured address | Tasks | Pending | +| WH-07 | P1: Bind the console to a configured address | Tasks | Pending | +| WH-08 | P1: Bind the console to a configured address | Tasks | Pending | +| WH-09 | P1: Preserve the console's request protections | Tasks | Pending | +| WH-10 | P1: Preserve the console's request protections | Tasks | Pending | +| WH-11 | P1: Preserve the console's request protections | Tasks | Pending | +| WH-12 | P1: Preserve the console's request protections | Tasks | Pending | +| WH-13 | P1: Preserve the console's request protections | Tasks | Pending | +| WH-14 | P2: Print usable links and bind errors | Tasks | Pending | +| WH-15 | P2: Print usable links and bind errors | Tasks | Pending | +| WH-16 | P2: Print usable links and bind errors | Tasks | Pending | + +**Coverage**: 16 requirements, 16 mapped to tasks, 0 unmapped. + +## Success Criteria + +- [ ] The default bind remains `127.0.0.1` and every WH criterion passes its scoped tests. +- [ ] A Tailscale or local interface address can open the console with the existing token flow. +- [ ] README and protocol documentation describe each requested host setting and parameter. diff --git a/.specs/features/web-host/tasks.md b/.specs/features/web-host/tasks.md new file mode 100644 index 0000000..229cb4f --- /dev/null +++ b/.specs/features/web-host/tasks.md @@ -0,0 +1,118 @@ +# Configurable web console bind host tasks + +## Test Coverage Matrix + +> Generated from the supplied AGENTS.md instructions, `package.json`, `vitest.config.ts`, sampled `tests/web-*.test.ts` files, and the spec. The test runner is Vitest. Tests are scoped by file, and the full suite is never run. + +| Code Layer | Required Test Type | Coverage Expectation | Test File | Vitest Command | +| ---------- | ------------------ | -------------------- | ---------- | -------------- | +| config | unit | WH-01 through WH-03: default, accepted IPv4/IPv6, invalid values, and exact warning text | `tests/web-port.test.ts` | `npx vitest run --no-cache tests/web-port.test.ts` | +| web security | integration | WH-11 through WH-13: exact loopback allowlist, dynamic interfaces and hostnames, hostile Host rejection, token and cookie behavior, and same-origin POST checks | `tests/web-security.test.ts` | `npx vitest run --no-cache tests/web-security.test.ts` | +| web server | integration | WH-07 and WH-10: bind host, default host, returned base URL, and IPv6 URL formatting | `tests/web-server.test.ts` | `npx vitest run --no-cache tests/web-server.test.ts` | +| web child | unit | WH-06: parse explicit host and default to loopback, then pass the host into the listener | `tests/web-child.test.ts` | `npx vitest run --no-cache tests/web-child.test.ts` | +| daemon supervisor | unit | WH-09 and WH-10: pass host argument, restart on host changes, reuse matching host, and return the host-specific base URL | `tests/web-supervisor.test.ts` | `npx vitest run --no-cache tests/web-supervisor.test.ts` | +| daemon autostart | unit | WH-03 and WH-08: report invalid config in the daemon log and pass resolved host to `web.ensure` | `tests/daemon-web.test.ts` | `npx vitest run --no-cache tests/daemon-web.test.ts` | +| CLI launch/ui | unit and command integration | WH-03 through WH-05 and WH-14 through WH-16: config and flag resolution, reject invalid flag before IPC, wildcard links, warning text, and listen errors on both paths | `tests/web-launch.test.ts`, `tests/web-cli.test.ts` | `npx vitest run --no-cache tests/web-launch.test.ts tests/web-cli.test.ts` | +| docs | documentation assertions | Verify README and Portuguese protocol docs name `web.host`, `ui --host`, child `--host`, and `web.ensure` host | `tests/web-host-docs.test.ts` | `npx vitest run --no-cache tests/web-host-docs.test.ts` | + +## Gate Check Commands + +> Commands come from the repository's TypeScript and Vitest setup. The batches below stay within the files this feature changes. + +| Gate Level | When to Use | Command | +| ---------- | ----------- | ------- | +| Quick | After each task | The task's focused `npx vitest run --no-cache <test-file>` command from the matrix | +| Full | After implementation, run one batch at a time | `npx vitest run --no-cache tests/web-port.test.ts tests/web-security.test.ts`; `npx vitest run --no-cache tests/web-server.test.ts tests/web-child.test.ts`; `npx vitest run --no-cache tests/web-supervisor.test.ts tests/daemon-web.test.ts`; `npx vitest run --no-cache tests/web-launch.test.ts tests/web-cli.test.ts tests/web-host-docs.test.ts` | +| Build | Final typecheck and smoke build | `npx tsc --noEmit -p .` then `npx tsc -p .` | + +## Execution Plan + +Tasks run in order. Each task includes its tests, `tasks.md` status update, and its own commit. + +```text +T01 -> T02 -> T03 -> T04 -> T05 -> T06 -> T07 -> T08 +``` + +### Phase 1: Resolve, secure, and bind + +## Task Breakdown + +#### T01: Resolve configured web host + +Where: `src/config/web-host.ts` +WH IDs: WH-01, WH-02, WH-03 +Depends on: none +Tests: Add resolver cases to `tests/web-port.test.ts` for default, IPv4, IPv6, invalid values, and JSON warning text. +Gate: `npx vitest run --no-cache tests/web-port.test.ts` +Status: Complete + +#### T02: Enforce Host allowlist for non-loopback binds + +Where: `src/web/security.ts` +WH IDs: WH-11, WH-12, WH-13 +Depends on: T01 +Tests: Update `tests/web-security.test.ts` for exact loopback names, injected per-request interfaces, hostname and MagicDNS suffixes, invalid Host, token/cookie redirects, and unchanged action checks. +Gate: `npx vitest run --no-cache tests/web-security.test.ts` +Status: Blocked by sandbox refusing loopback TCP binds + +#### T03: Bind web server and format its base URL + +Where: `src/web/server.ts` +WH IDs: WH-07, WH-10 +Depends on: T02 +Tests: Update `tests/web-server.test.ts` for optional host, loopback default, wildcard URL mapping, direct IPv4 URL, and bracketed IPv6 URL. +Gate: `npx vitest run --no-cache tests/web-server.test.ts` +Status: Pending + +#### T04: Parse the web child host argument + +Where: `src/web/child.ts` +WH IDs: WH-06 +Depends on: T03 +Tests: Update `tests/web-child.test.ts` for `--host`, omitted host, and listener option propagation. +Gate: `npx vitest run --no-cache tests/web-child.test.ts` +Status: Pending + +### Phase 2: Carry host through daemon and CLI + +#### T05: Restart the supervisor child when its host changes + +Where: `src/daemon/web-supervisor.ts` +WH IDs: WH-09, WH-10 +Depends on: T04 +Tests: Update `tests/web-supervisor.test.ts` for child args, default host, host mismatch restart, matching-host reuse, unchanged port reuse, and base URL formatting. +Gate: `npx vitest run --no-cache tests/web-supervisor.test.ts` +Status: Pending + +#### T06: Pass configured host during daemon autostart + +Where: `src/daemon/daemon.ts` +WH IDs: WH-03, WH-08 +Depends on: T05 +Tests: Update `tests/daemon-web.test.ts` for resolved host in autostart params and invalid `web.host` log output. +Gate: `npx vitest run --no-cache tests/daemon-web.test.ts` +Status: Pending + +#### T07: Add `ui --host` and CLI host output + +Where: `src/cli/web-launch.ts` +WH IDs: WH-03, WH-04, WH-05, WH-14, WH-15, WH-16 +Depends on: T06 +Tests: Update `tests/web-launch.test.ts` and `tests/web-cli.test.ts` for flag forwarding, pre-IPC validation, config warnings, wildcard links, warning text, and both listen failure paths. +Gate: `npx vitest run --no-cache tests/web-launch.test.ts tests/web-cli.test.ts` +Status: Pending + +### Phase 3: Document the host setting + +#### T08: Document configurable web host + +Where: `README.md` +WH IDs: WH-04, WH-06, WH-08, WH-09 +Depends on: T07 +Tests: Add `tests/web-host-docs.test.ts` for required README and Portuguese `docs/protocol.md` details. +Gate: `npx vitest run --no-cache tests/web-host-docs.test.ts` +Status: Pending + +## Task Completion Record + +Task completion and requirement traceability are updated in the same commit as each implementation task. The independent verifier owns `validation.md`. diff --git a/src/config/web-host.ts b/src/config/web-host.ts new file mode 100644 index 0000000..952185e --- /dev/null +++ b/src/config/web-host.ts @@ -0,0 +1,30 @@ +import { isIP } from "node:net"; + +/** Bind address of the web console when `web.host` is not set. */ +export const DEFAULT_WEB_HOST = "127.0.0.1"; + +export interface WebHostResolution { + host: string; + /** The raw `web.host` value when it is present but is not an IP address. */ + invalid?: unknown; +} + +/** + * Resolve `web.host` to an IP address, or use loopback when it is absent or + * invalid. The daemon and CLI share this resolver so they make the same choice. + */ +export function resolveWebHost(config: { web?: unknown }): WebHostResolution { + const web = config.web; + if (web === undefined) return { host: DEFAULT_WEB_HOST }; + if (typeof web !== "object" || web === null || Array.isArray(web)) { + return { host: DEFAULT_WEB_HOST, invalid: web }; + } + const raw = (web as { host?: unknown }).host; + if (raw === undefined) return { host: DEFAULT_WEB_HOST }; + if (typeof raw === "string" && isIP(raw) !== 0) return { host: raw }; + return { host: DEFAULT_WEB_HOST, invalid: raw }; +} + +export function invalidWebHostMessage(value: unknown): string { + return `Ignoring invalid web.host in config: ${JSON.stringify(value)}`; +} diff --git a/tests/web-port.test.ts b/tests/web-port.test.ts index 4786d4d..187e002 100644 --- a/tests/web-port.test.ts +++ b/tests/web-port.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { invalidWebPortMessage, resolveWebPort } from "../src/config/web-port.js"; +import { invalidWebHostMessage, resolveWebHost } from "../src/config/web-host.js"; describe("resolveWebPort", () => { it.each([ @@ -28,3 +29,31 @@ describe("resolveWebPort", () => { expect(invalidWebPortMessage(0)).toBe("Ignoring invalid web.port in config: 0"); }); }); + +describe("resolveWebHost", () => { + it.each([ + ["no web section", {}], + ["a web section without host", { web: {} }], + ])("defaults to loopback with %s", (_label, config) => { + expect(resolveWebHost(config)).toEqual({ host: "127.0.0.1" }); + }); + + it.each(["0.0.0.0", "100.64.0.5", "::", "::1"])("accepts the IP address %s", (host) => { + expect(resolveWebHost({ web: { host } })).toEqual({ host }); + }); + + it.each([ + ["a hostname", "deck.local"], + ["a numeric value", 7777], + ["null", null], + ["a non-object web section", "x"], + ])("falls back to loopback and reports %s as invalid", (_label, value) => { + const config = value === "x" ? { web: value } : { web: { host: value } }; + expect(resolveWebHost(config)).toEqual({ host: "127.0.0.1", invalid: value }); + }); + + it("renders an invalid host value as JSON in the warning", () => { + expect(invalidWebHostMessage("deck.local")).toBe('Ignoring invalid web.host in config: "deck.local"'); + expect(invalidWebHostMessage(null)).toBe("Ignoring invalid web.host in config: null"); + }); +}); From 68fb298fb70fd1de1a2ab082cecf2955583f2582 Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Fri, 25 Sep 2026 01:13:50 -0300 Subject: [PATCH 02/15] feat(web): Accept trusted Host headers on configured binds Retain the loopback checks while accepting local interface and machine host names on other binds. Co-Authored-By: Codex <noreply@openai.com> --- .specs/features/web-host/run-notes.md | 1 + .specs/features/web-host/spec.md | 6 +- .specs/features/web-host/tasks.md | 2 +- src/web/security.ts | 55 ++++++++- tests/web-security.test.ts | 168 +++++++++++++++++++++++++- 5 files changed, 223 insertions(+), 9 deletions(-) diff --git a/.specs/features/web-host/run-notes.md b/.specs/features/web-host/run-notes.md index 902563d..73504f4 100644 --- a/.specs/features/web-host/run-notes.md +++ b/.specs/features/web-host/run-notes.md @@ -20,3 +20,4 @@ | 16 | Redirect `localhost` to `127.0.0.1` only when the server is bound to the default loopback host. | Decision | On another bind address that redirect can target an address the server does not serve; keeping the accepted Host lets the existing checks run on that request. | | 17 | Stop at T02 because the scoped security tests cannot bind a local TCP listener in this sandbox. | Blocker | All 13 tests fail before request assertions with `listen EPERM: operation not permitted 127.0.0.1`; typecheck passes, but the required integration gate cannot run. | | 18 | Use the requested capitalized commit subjects even though `check_commit.py` rejects them. | Decision | This continuation explicitly requires capitalized subjects and the exact T01 message; the checker requires lowercase descriptions. | +| 19 | Commit T01 and T02 in separate commits from this continuation. | Deviation | The earlier worker left their source changes uncommitted after sandbox failures, and this continuation reran both gates before committing. | diff --git a/.specs/features/web-host/spec.md b/.specs/features/web-host/spec.md index 82d29e1..9b22b4a 100644 --- a/.specs/features/web-host/spec.md +++ b/.specs/features/web-host/spec.md @@ -133,9 +133,9 @@ The web console currently binds to `127.0.0.1` and only accepts loopback Host he | WH-08 | P1: Bind the console to a configured address | Tasks | Pending | | WH-09 | P1: Preserve the console's request protections | Tasks | Pending | | WH-10 | P1: Preserve the console's request protections | Tasks | Pending | -| WH-11 | P1: Preserve the console's request protections | Tasks | Pending | -| WH-12 | P1: Preserve the console's request protections | Tasks | Pending | -| WH-13 | P1: Preserve the console's request protections | Tasks | Pending | +| WH-11 | P1: Preserve the console's request protections | Tasks | Verified | +| WH-12 | P1: Preserve the console's request protections | Tasks | Verified | +| WH-13 | P1: Preserve the console's request protections | Tasks | Verified | | WH-14 | P2: Print usable links and bind errors | Tasks | Pending | | WH-15 | P2: Print usable links and bind errors | Tasks | Pending | | WH-16 | P2: Print usable links and bind errors | Tasks | Pending | diff --git a/.specs/features/web-host/tasks.md b/.specs/features/web-host/tasks.md index 229cb4f..44b7cdb 100644 --- a/.specs/features/web-host/tasks.md +++ b/.specs/features/web-host/tasks.md @@ -53,7 +53,7 @@ WH IDs: WH-11, WH-12, WH-13 Depends on: T01 Tests: Update `tests/web-security.test.ts` for exact loopback names, injected per-request interfaces, hostname and MagicDNS suffixes, invalid Host, token/cookie redirects, and unchanged action checks. Gate: `npx vitest run --no-cache tests/web-security.test.ts` -Status: Blocked by sandbox refusing loopback TCP binds +Status: Complete #### T03: Bind web server and format its base URL diff --git a/src/web/security.ts b/src/web/security.ts index 18dac3e..8c5d542 100644 --- a/src/web/security.ts +++ b/src/web/security.ts @@ -1,10 +1,22 @@ import { randomBytes } from "node:crypto"; +import { isIP } from "node:net"; +import os from "node:os"; import type { IncomingMessage, ServerResponse } from "node:http"; +import { DEFAULT_WEB_HOST } from "../config/web-host.js"; export interface WebSecurity { port: number; token: string; cookieName: string; + host: string; + networkInterfaces: typeof os.networkInterfaces; + hostname: typeof os.hostname; +} + +export interface WebSecurityOptions { + host?: string; + networkInterfaces?: typeof os.networkInterfaces; + hostname?: typeof os.hostname; } export interface WebRoutePolicy { @@ -17,18 +29,52 @@ 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, token = randomBytes(32).toString("hex")): WebSecurity { +export function createWebSecurity( + port: number, + token = randomBytes(32).toString("hex"), + options: WebSecurityOptions = {}, +): WebSecurity { return { port, token, cookieName: `codedeck_ui_token_${port}`, + host: options.host ?? DEFAULT_WEB_HOST, + networkInterfaces: options.networkInterfaces ?? os.networkInterfaces, + hostname: options.hostname ?? os.hostname, }; } -export function isAllowedWebHost(host: string | undefined, port: number): boolean { +export function isAllowedWebHost(host: string | undefined, port: number, security?: WebSecurity): boolean { if (!host) return false; const normalized = host.toLowerCase(); - return normalized === `127.0.0.1:${port}` || normalized === `localhost:${port}`; + if (normalized === `127.0.0.1:${port}` || normalized === `localhost:${port}`) return true; + if (!security || security.host === DEFAULT_WEB_HOST) return false; + + const name = hostHeaderName(host, port); + if (!name || name.includes("%")) return false; + const normalizedName = name.toLowerCase(); + const hostname = security.hostname().toLowerCase(); + if (normalizedName === hostname || normalizedName.startsWith(`${hostname}.`)) return true; + + for (const entries of Object.values(security.networkInterfaces())) { + for (const entry of entries ?? []) { + if (entry.address.includes("%")) continue; + if (entry.address.toLowerCase() === normalizedName) return true; + } + } + return false; +} + +function hostHeaderName(host: string, port: number): string | undefined { + const suffix = `:${port}`; + if (!host.toLowerCase().endsWith(suffix)) return undefined; + const name = host.slice(0, -suffix.length); + if (name.startsWith("[") && name.endsWith("]")) { + const ipv6 = name.slice(1, -1); + return isIP(ipv6) === 6 ? ipv6 : undefined; + } + if (name.includes(":")) return undefined; + return name; } export function getTokenUrl(url: string, token: string): string { @@ -43,7 +89,7 @@ export function checkWebRequest( security: WebSecurity, policy: WebRoutePolicy = {}, ): boolean { - if (!isAllowedWebHost(request.headers.host, security.port)) { + if (!isAllowedWebHost(request.headers.host, security.port, security)) { reject(response); return false; } @@ -134,6 +180,7 @@ function redirectToCanonicalHost( response: ServerResponse, security: WebSecurity, ): boolean { + if (security.host !== DEFAULT_WEB_HOST) return false; if (request.headers.host?.toLowerCase() !== `localhost:${security.port}`) return false; let url: URL; try { diff --git a/tests/web-security.test.ts b/tests/web-security.test.ts index 6a4f6ad..902e617 100644 --- a/tests/web-security.test.ts +++ b/tests/web-security.test.ts @@ -1,7 +1,9 @@ import http from "node:http"; +import os from "node:os"; import { EventEmitter } from "node:events"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { startWebServer, type WebRoute, type WebServerHandle } from "../src/web/server.js"; +import { createWebServer, startWebServer, type WebRoute, type WebServerHandle } from "../src/web/server.js"; +import { createWebSecurity, type WebSecurity } from "../src/web/security.js"; interface ResponseValue { status: number; @@ -81,6 +83,76 @@ async function makeServer(): Promise<{ handle: WebServerHandle; calls: string[] return { handle, calls }; } +function makeInterfaces( + addresses: Array<{ address: string; family: "IPv4" | "IPv6"; internal?: boolean }>, +): ReturnType<typeof os.networkInterfaces> { + return { + tailscale0: addresses.map(({ address, family, internal = false }) => ({ + address, + family, + internal, + netmask: family === "IPv4" ? "255.255.255.255" : "ffff:ffff:ffff:ffff::", + mac: "00:00:00:00:00:00", + cidr: null, + ...(family === "IPv6" ? { scopeid: 0 } : {}), + })), + }; +} + +async function makeExtendedServer( + networkInterfaces: typeof os.networkInterfaces, + hostname: typeof os.hostname = () => "deck-host", +): Promise<{ handle: WebServerHandle; calls: string[] }> { + const calls: string[] = []; + const routes: WebRoute[] = [ + { + path: "/page", + kind: "page", + handler: (_request, response) => { + calls.push("page"); + response.writeHead(200, { "content-type": "text/html; charset=utf-8" }); + response.end("page"); + }, + }, + { + path: "/action", + kind: "api", + handler: (_request, response) => { + calls.push("action"); + response.writeHead(200, { "content-type": "application/json; charset=utf-8" }); + response.end(JSON.stringify({ saved: true })); + }, + }, + ]; + let security: WebSecurity | undefined; + const server = createWebServer({ routes, getSecurity: () => security }); + await new Promise<void>((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + server.off("error", reject); + resolve(); + }); + }); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("no TCP address"); + security = createWebSecurity(address.port, "test-token", { + host: "100.101.102.103", + networkInterfaces, + hostname, + }); + const handle: WebServerHandle = { + server, + address, + port: address.port, + baseUrl: `http://127.0.0.1:${address.port}`, + initialUrl: `http://127.0.0.1:${address.port}/page?t=test-token`, + security, + close: () => new Promise<void>((resolve) => server.close(() => resolve())), + }; + handles.push(handle); + return { handle, calls }; +} + describe("web request security", () => { it("creates one 32-byte token and permits only the two bound loopback hosts", async () => { const { handle } = await makeServer(); @@ -258,4 +330,98 @@ describe("web request security", () => { expect(JSON.parse(accepted.body)).toEqual({ saved: true }); expect(calls).toEqual(["action"]); }); + + it("allows current interface addresses, the machine hostname, and its MagicDNS suffix", async () => { + const networkInterfaces = vi.fn(() => makeInterfaces([ + { address: "100.101.102.103", family: "IPv4" }, + { address: "fd7a:115c:a1e0::1", family: "IPv6" }, + { address: "fe80::1%tailscale0", family: "IPv6" }, + ])); + const { handle } = await makeExtendedServer(networkInterfaces); + const port = handle.port; + const tokenPath = `/page?t=${handle.security.token}`; + + for (const host of [ + `100.101.102.103:${port}`, + `[fd7a:115c:a1e0::1]:${port}`, + `deck-host:${port}`, + `DECK-HOST.tailnet.ts.net:${port}`, + ]) { + const response = await request(handle, { path: tokenPath, host }); + expect(response.status).toBe(303); + expect(response.headers.location).toBe("/page"); + } + + for (const host of [ + `evil.example:${port}`, + `100.101.102.103:${port + 1}`, + `[deck-host]:${port}`, + `[fe80::1%tailscale0]:${port}`, + `[100.101.102.103]:${port}`, + `fd7a:115c:a1e0::1:${port}`, + ]) { + const response = await request(handle, { path: tokenPath, host }); + expect(response.status).toBe(403); + expect(response.body).toBe("forbidden"); + } + }); + + it("evaluates interface addresses on each request", async () => { + let interfaces = makeInterfaces([{ address: "100.101.102.103", family: "IPv4" }]); + const networkInterfaces = vi.fn(() => interfaces); + const { handle } = await makeExtendedServer(networkInterfaces); + const tokenPath = `/page?t=${handle.security.token}`; + + const beforeChange = await request(handle, { path: tokenPath, host: `100.101.102.103:${handle.port}` }); + interfaces = makeInterfaces([{ address: "100.101.102.104", family: "IPv4" }]); + const removedAddress = await request(handle, { path: tokenPath, host: `100.101.102.103:${handle.port}` }); + const addedAddress = await request(handle, { path: tokenPath, host: `100.101.102.104:${handle.port}` }); + + expect(beforeChange.status).toBe(303); + expect(removedAddress.status).toBe(403); + expect(addedAddress.status).toBe(303); + expect(networkInterfaces).toHaveBeenCalledTimes(3); + }); + + it("keeps page tokens, API cookies, and same-origin POST checks on an accepted interface Host", async () => { + const networkInterfaces = vi.fn(() => makeInterfaces([{ address: "100.101.102.103", family: "IPv4" }])); + const { handle, calls } = await makeExtendedServer(networkInterfaces); + const host = `100.101.102.103:${handle.port}`; + const token = handle.security.token; + const cookie = `codedeck_ui_token_${handle.port}=${token}`; + + const barePage = await request(handle, { path: "/page", host }); + const tokenPage = await request(handle, { path: `/page?t=${token}`, host }); + const apiWithoutCookie = await request(handle, { path: "/action", host }); + const apiWithCookie = await request(handle, { path: "/action", host, cookie }); + const crossOriginPost = await request(handle, { + method: "POST", + path: "/action", + host, + cookie, + origin: `http://evil.example:${handle.port}`, + }); + const sameOriginPost = await request(handle, { + method: "POST", + path: "/action", + host, + cookie, + origin: `http://${host}`, + }); + + expect(barePage.status).toBe(403); + expect(barePage.body).toBe(PAGE_FORBIDDEN); + expect(tokenPage.status).toBe(303); + expect(tokenPage.headers["set-cookie"]).toEqual([ + `${cookie}; Path=/; Max-Age=31536000; HttpOnly; SameSite=Strict`, + ]); + expect(apiWithoutCookie.status).toBe(403); + expect(apiWithoutCookie.body).toBe("forbidden"); + expect(apiWithCookie.status).toBe(200); + expect(crossOriginPost.status).toBe(403); + expect(crossOriginPost.body).toBe("forbidden"); + expect(sameOriginPost.status).toBe(200); + expect(JSON.parse(sameOriginPost.body)).toEqual({ saved: true }); + expect(calls).toEqual(["action", "action"]); + }); }); From b4da80dbad41f2126938354104a947b58bd6f549 Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Fri, 25 Sep 2026 01:16:46 -0300 Subject: [PATCH 03/15] feat(web): Bind the console server to its configured host Keep the loopback default and derive advertised URLs from the selected bind host. Co-Authored-By: Codex <noreply@openai.com> --- .specs/features/web-host/spec.md | 2 +- .specs/features/web-host/tasks.md | 2 +- src/web/server.ts | 26 ++++++++++++++++++------- tests/web-server.test.ts | 32 +++++++++++++++++++++++++++++++ 4 files changed, 53 insertions(+), 9 deletions(-) diff --git a/.specs/features/web-host/spec.md b/.specs/features/web-host/spec.md index 9b22b4a..a15dd3b 100644 --- a/.specs/features/web-host/spec.md +++ b/.specs/features/web-host/spec.md @@ -129,7 +129,7 @@ The web console currently binds to `127.0.0.1` and only accepts loopback Host he | WH-04 | P1: Bind the console to a configured address | Tasks | Pending | | WH-05 | P1: Bind the console to a configured address | Tasks | Pending | | WH-06 | P1: Bind the console to a configured address | Tasks | Pending | -| WH-07 | P1: Bind the console to a configured address | Tasks | Pending | +| WH-07 | P1: Bind the console to a configured address | Tasks | Verified | | WH-08 | P1: Bind the console to a configured address | Tasks | Pending | | WH-09 | P1: Preserve the console's request protections | Tasks | Pending | | WH-10 | P1: Preserve the console's request protections | Tasks | Pending | diff --git a/.specs/features/web-host/tasks.md b/.specs/features/web-host/tasks.md index 44b7cdb..cd400ff 100644 --- a/.specs/features/web-host/tasks.md +++ b/.specs/features/web-host/tasks.md @@ -62,7 +62,7 @@ WH IDs: WH-07, WH-10 Depends on: T02 Tests: Update `tests/web-server.test.ts` for optional host, loopback default, wildcard URL mapping, direct IPv4 URL, and bracketed IPv6 URL. Gate: `npx vitest run --no-cache tests/web-server.test.ts` -Status: Pending +Status: Complete #### T04: Parse the web child host argument diff --git a/src/web/server.ts b/src/web/server.ts index 5541fea..f654b2a 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -1,10 +1,11 @@ import http, { type RequestListener, type Server } from "node:http"; -import type { AddressInfo } from "node:net"; +import { isIP, type AddressInfo } from "node:net"; import { spawn } from "node:child_process"; import { EventEmitter } from "node:events"; import { InvalidArgumentError } from "commander"; import { checkWebRequest, createWebSecurity, getTokenUrl, type WebSecurity } from "./security.js"; +import { DEFAULT_WEB_HOST } from "../config/web-host.js"; import { DEFAULT_WEB_PORT } from "../config/web-port.js"; export { DEFAULT_WEB_PORT }; @@ -18,6 +19,7 @@ export interface WebRoute { export interface WebServerOptions { routes: readonly WebRoute[]; + host?: string; port?: number; initialPath: string; title?: string; @@ -41,6 +43,7 @@ export interface CreateWebServerOptions { export interface ListenWebServerOptions { routes: readonly WebRoute[]; + host?: string; port?: number; /** Retry on an OS-assigned port after any listen error on `port`. */ fallbackToEphemeral?: boolean; @@ -126,12 +129,13 @@ export async function listenWebServer(options: ListenWebServerOptions): Promise< serverFactory: options.serverFactory, }); + const host = options.host ?? DEFAULT_WEB_HOST; const requestedPort = options.port ?? DEFAULT_WEB_PORT; try { - await listen(server, requestedPort); + await listen(server, requestedPort, host); } catch (error) { if (!options.fallbackToEphemeral) throw error; - await listen(server, 0); + await listen(server, 0, host); } const address = server.address(); @@ -140,13 +144,13 @@ export async function listenWebServer(options: ListenWebServerOptions): Promise< throw new Error("Web server did not return a TCP address"); } - security = createWebSecurity(address.port, options.token); + security = createWebSecurity(address.port, options.token, { host }); let closing: Promise<void> | undefined; return { server, address, port: address.port, - baseUrl: `http://127.0.0.1:${address.port}`, + baseUrl: webBaseUrl(host, address.port), security, close: () => { closing ??= new Promise<void>((resolve) => server.close(() => resolve())); @@ -155,7 +159,7 @@ export async function listenWebServer(options: ListenWebServerOptions): Promise< }; } -function listen(server: Server, port: number): Promise<void> { +function listen(server: Server, port: number, host: string): Promise<void> { return new Promise<void>((resolve, reject) => { const onError = (error: Error) => { server.off("listening", onListening); @@ -167,13 +171,14 @@ function listen(server: Server, port: number): Promise<void> { }; server.once("error", onError); server.once("listening", onListening); - server.listen(port, "127.0.0.1"); + server.listen(port, host); }); } export async function startWebServer(options: WebServerOptions): Promise<WebServerHandle> { const listening = await listenWebServer({ routes: options.routes, + host: options.host, port: options.port, fallbackToEphemeral: options.fallbackToEphemeral, token: options.token, @@ -225,6 +230,13 @@ export async function startWebServer(options: WebServerOptions): Promise<WebServ }; } +function webBaseUrl(host: string, port: number): string { + const urlHost = host === "127.0.0.1" || host === "0.0.0.0" || host === "::" + ? "127.0.0.1" + : isIP(host) === 6 ? `[${host}]` : host; + return `http://${urlHost}:${port}`; +} + function dispatchRequest( routes: readonly WebRoute[], security: WebSecurity, diff --git a/tests/web-server.test.ts b/tests/web-server.test.ts index 7445a87..18c50aa 100644 --- a/tests/web-server.test.ts +++ b/tests/web-server.test.ts @@ -232,6 +232,38 @@ describe("listenWebServer", () => { return (blocker.address() as { port: number }).port; } + it.each([ + ["0.0.0.0", "127.0.0.1"], + ["::", "127.0.0.1"], + ["127.0.0.2", "127.0.0.2"], + ["::1", "[::1]"], + ])("binds to %s and advertises %s", async (host, urlHost) => { + const listening = await listenWebServer({ routes: testRoutes([]), port: 0, host }); + listeners.push(listening); + + expect(listening.address.address).toBe(host); + expect(listening.baseUrl).toBe(`http://${urlHost}:${listening.port}`); + expect(listening.security.host).toBe(host); + }); + + it("passes the configured host from startWebServer to its listener", async () => { + const handle = await startWebServer({ + routes: testRoutes([]), + port: 0, + host: "0.0.0.0", + initialPath: "/", + open: false, + log: vi.fn(), + signalTarget: new EventEmitter(), + exit: vi.fn(), + }); + handles.push(handle); + + expect(handle.address.address).toBe("0.0.0.0"); + expect(handle.baseUrl).toBe(`http://127.0.0.1:${handle.port}`); + expect(handle.security.host).toBe("0.0.0.0"); + }); + it("listens and serves without installing signal handlers", async () => { const sigint = process.listenerCount("SIGINT"); const sigterm = process.listenerCount("SIGTERM"); From a8f6b94305ce8becdfaab957ac5a194cd1e7f1c0 Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Fri, 25 Sep 2026 01:18:20 -0300 Subject: [PATCH 04/15] feat(web): Pass the bind host to the web child Preserve the loopback default when no child host argument is supplied. Co-Authored-By: Codex <noreply@openai.com> --- .specs/features/web-host/spec.md | 2 +- .specs/features/web-host/tasks.md | 2 +- src/web/child.ts | 16 +++++++++++----- tests/web-child.test.ts | 18 ++++++++++++++---- 4 files changed, 27 insertions(+), 11 deletions(-) diff --git a/.specs/features/web-host/spec.md b/.specs/features/web-host/spec.md index a15dd3b..4543c25 100644 --- a/.specs/features/web-host/spec.md +++ b/.specs/features/web-host/spec.md @@ -128,7 +128,7 @@ The web console currently binds to `127.0.0.1` and only accepts loopback Host he | WH-03 | P1: Bind the console to a configured address | Tasks | Pending | | WH-04 | P1: Bind the console to a configured address | Tasks | Pending | | WH-05 | P1: Bind the console to a configured address | Tasks | Pending | -| WH-06 | P1: Bind the console to a configured address | Tasks | Pending | +| WH-06 | P1: Bind the console to a configured address | Tasks | Verified | | WH-07 | P1: Bind the console to a configured address | Tasks | Verified | | WH-08 | P1: Bind the console to a configured address | Tasks | Pending | | WH-09 | P1: Preserve the console's request protections | Tasks | Pending | diff --git a/.specs/features/web-host/tasks.md b/.specs/features/web-host/tasks.md index cd400ff..c07c7e7 100644 --- a/.specs/features/web-host/tasks.md +++ b/.specs/features/web-host/tasks.md @@ -71,7 +71,7 @@ WH IDs: WH-06 Depends on: T03 Tests: Update `tests/web-child.test.ts` for `--host`, omitted host, and listener option propagation. Gate: `npx vitest run --no-cache tests/web-child.test.ts` -Status: Pending +Status: Complete ### Phase 2: Carry host through daemon and CLI diff --git a/src/web/child.ts b/src/web/child.ts index b5c6e43..721fd70 100644 --- a/src/web/child.ts +++ b/src/web/child.ts @@ -1,11 +1,13 @@ import type { EventEmitter } from "node:events"; import type { Readable, Writable } from "node:stream"; import { createUiRoutes } from "../cli/commands/ui.js"; +import { DEFAULT_WEB_HOST } from "../config/web-host.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 { + host?: string; /** Explicit port (`--port`): no fallback. */ port?: number; /** Preferred port (`--preferred-port`): an OS-assigned port on any listen error. Defaults to 7777. */ @@ -39,6 +41,7 @@ export async function runWebChild(options: RunWebChildOptions): Promise<void> { try { listening = await (options.listen ?? listenWebServer)({ routes: (options.routes ?? (() => createUiRoutes()))(), + host: options.host ?? DEFAULT_WEB_HOST, port, fallbackToEphemeral: options.port === undefined, token: (options.resolveToken ?? resolveWebToken)(), @@ -66,15 +69,18 @@ export async function runWebChild(options: RunWebChildOptions): Promise<void> { options.stdout.write(`${JSON.stringify({ port: listening.port, token: listening.security.token, build })}\n`); } -/** `--port <n>` is explicit; `--preferred-port <n>` may fall back; neither means 7777 with fallback. */ -export function parseWebChildArgs(argv: readonly string[]): { port?: number; preferredPort?: number } { - const valueOf = (flag: string): number | undefined => { +/** `--port <n>` is explicit; `--preferred-port <n>` may fall back; the host defaults to loopback. */ +export function parseWebChildArgs(argv: readonly string[]): { host: string; port?: number; preferredPort?: number } { + const numberValueOf = (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"); + const hostIndex = argv.indexOf("--host"); + const host = hostIndex >= 0 ? argv[hostIndex + 1] : undefined; + const port = numberValueOf("--port"); + const preferredPort = numberValueOf("--preferred-port"); return { + host: host ?? DEFAULT_WEB_HOST, ...(port === undefined ? {} : { port }), ...(preferredPort === undefined ? {} : { preferredPort }), }; diff --git a/tests/web-child.test.ts b/tests/web-child.test.ts index a62a91a..c89fc3f 100644 --- a/tests/web-child.test.ts +++ b/tests/web-child.test.ts @@ -120,6 +120,15 @@ describe("runWebChild", () => { ]); }); + it("passes the requested host to the listener and defaults it to loopback", async () => { + const listen = vi.fn(async () => fakeListening()); + + await startChild({ host: "0.0.0.0", listen, routes: () => [] }); + await startChild({ listen, routes: () => [] }); + + expect(listen.mock.calls.map(([options]) => options.host)).toEqual(["0.0.0.0", "127.0.0.1"]); + }); + it("serves with the resolved console token and reports it in the handshake", async () => { const listen = vi.fn(async () => fakeListening()); @@ -131,10 +140,11 @@ describe("runWebChild", () => { }); 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) => { + [["node", "child.js", "--web-child", "--preferred-port", "7788"], { preferredPort: 7788, host: "127.0.0.1" }], + [["node", "child.js", "--web-child", "--port", "7788"], { port: 7788, host: "127.0.0.1" }], + [["node", "child.js", "--web-child", "--host", "0.0.0.0"], { host: "0.0.0.0" }], + [["node", "child.js", "--web-child"], { host: "127.0.0.1" }], + ])("parses the web child arguments of %j", (argv, expected) => { expect(parseWebChildArgs(argv)).toEqual(expected); }); From 64b0ad13f5b6517fdbd2800120a07dfc1d795b34 Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Fri, 25 Sep 2026 01:22:58 -0300 Subject: [PATCH 05/15] feat(daemon): Restart the web child when its host changes Compare the requested bind host during child reuse and advertise a host-specific URL. Co-Authored-By: Codex <noreply@openai.com> --- .specs/features/web-host/run-notes.md | 1 + .specs/features/web-host/spec.md | 4 +-- .specs/features/web-host/tasks.md | 3 +- src/config/web-host.ts | 7 ++++ src/daemon/protocol.ts | 2 ++ src/daemon/web-supervisor.ts | 9 ++++- src/web/server.ts | 11 ++---- tests/web-supervisor.test.ts | 48 ++++++++++++++++++++++++--- 8 files changed, 67 insertions(+), 18 deletions(-) diff --git a/.specs/features/web-host/run-notes.md b/.specs/features/web-host/run-notes.md index 73504f4..1ace046 100644 --- a/.specs/features/web-host/run-notes.md +++ b/.specs/features/web-host/run-notes.md @@ -21,3 +21,4 @@ | 17 | Stop at T02 because the scoped security tests cannot bind a local TCP listener in this sandbox. | Blocker | All 13 tests fail before request assertions with `listen EPERM: operation not permitted 127.0.0.1`; typecheck passes, but the required integration gate cannot run. | | 18 | Use the requested capitalized commit subjects even though `check_commit.py` rejects them. | Decision | This continuation explicitly requires capitalized subjects and the exact T01 message; the checker requires lowercase descriptions. | | 19 | Commit T01 and T02 in separate commits from this continuation. | Deviation | The earlier worker left their source changes uncommitted after sandbox failures, and this continuation reran both gates before committing. | +| 20 | Share base URL formatting from `src/config/web-host.ts`. | Decision | The supervisor and web server need the same host mapping, while the daemon import boundary forbids importing the web server into the supervisor. | diff --git a/.specs/features/web-host/spec.md b/.specs/features/web-host/spec.md index 4543c25..13e99e8 100644 --- a/.specs/features/web-host/spec.md +++ b/.specs/features/web-host/spec.md @@ -131,8 +131,8 @@ The web console currently binds to `127.0.0.1` and only accepts loopback Host he | WH-06 | P1: Bind the console to a configured address | Tasks | Verified | | WH-07 | P1: Bind the console to a configured address | Tasks | Verified | | WH-08 | P1: Bind the console to a configured address | Tasks | Pending | -| WH-09 | P1: Preserve the console's request protections | Tasks | Pending | -| WH-10 | P1: Preserve the console's request protections | Tasks | Pending | +| WH-09 | P1: Preserve the console's request protections | Tasks | Verified | +| WH-10 | P1: Preserve the console's request protections | Tasks | Verified | | WH-11 | P1: Preserve the console's request protections | Tasks | Verified | | WH-12 | P1: Preserve the console's request protections | Tasks | Verified | | WH-13 | P1: Preserve the console's request protections | Tasks | Verified | diff --git a/.specs/features/web-host/tasks.md b/.specs/features/web-host/tasks.md index c07c7e7..14694ce 100644 --- a/.specs/features/web-host/tasks.md +++ b/.specs/features/web-host/tasks.md @@ -78,11 +78,12 @@ Status: Complete #### T05: Restart the supervisor child when its host changes Where: `src/daemon/web-supervisor.ts` +Supporting files: `src/daemon/protocol.ts`, `src/config/web-host.ts`, and `src/web/server.ts`. WH IDs: WH-09, WH-10 Depends on: T04 Tests: Update `tests/web-supervisor.test.ts` for child args, default host, host mismatch restart, matching-host reuse, unchanged port reuse, and base URL formatting. Gate: `npx vitest run --no-cache tests/web-supervisor.test.ts` -Status: Pending +Status: Complete #### T06: Pass configured host during daemon autostart diff --git a/src/config/web-host.ts b/src/config/web-host.ts index 952185e..52447ed 100644 --- a/src/config/web-host.ts +++ b/src/config/web-host.ts @@ -28,3 +28,10 @@ export function resolveWebHost(config: { web?: unknown }): WebHostResolution { export function invalidWebHostMessage(value: unknown): string { return `Ignoring invalid web.host in config: ${JSON.stringify(value)}`; } + +export function webBaseUrl(host: string, port: number): string { + const urlHost = host === DEFAULT_WEB_HOST || host === "0.0.0.0" || host === "::" + ? DEFAULT_WEB_HOST + : isIP(host) === 6 ? `[${host}]` : host; + return `http://${urlHost}:${port}`; +} diff --git a/src/daemon/protocol.ts b/src/daemon/protocol.ts index 7257f30..ce3c8de 100644 --- a/src/daemon/protocol.ts +++ b/src/daemon/protocol.ts @@ -234,6 +234,8 @@ export interface QueryUsageRequest { } export interface WebEnsureParams { + /** Bind address; omitted requests the default loopback host. */ + host?: string; /** 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. */ diff --git a/src/daemon/web-supervisor.ts b/src/daemon/web-supervisor.ts index fb3ba94..7649cfe 100644 --- a/src/daemon/web-supervisor.ts +++ b/src/daemon/web-supervisor.ts @@ -4,6 +4,7 @@ import { spawn } from "node:child_process"; import { fileURLToPath } from "node:url"; import type { Readable, Writable } from "node:stream"; import { getPaths } from "../config/paths.js"; +import { DEFAULT_WEB_HOST, webBaseUrl } from "../config/web-host.js"; import type { WebEnsureParams, WebEnsureResult } from "./protocol.js"; // The daemon never imports the web server itself (WD-28): it only spawns and watches @@ -49,6 +50,7 @@ type StartedFor = { kind: "explicit"; port: number } | { kind: "preferred"; port interface RunningChild extends WebEnsureResult { child: WebChildProcess; + host: string; entry: string; build: string | undefined; startedFor: StartedFor; @@ -133,6 +135,7 @@ export class WebSupervisor { } private matches(running: RunningChild, params: WebEnsureParams): boolean { + if (running.host !== (params.host ?? DEFAULT_WEB_HOST)) return false; if (params.entry !== undefined && params.entry !== running.entry) return false; if (params.build !== undefined && ((params.entry ?? this.defaultEntry) !== running.entry || params.build !== running.build)) { return false; @@ -152,6 +155,7 @@ export class WebSupervisor { private start(params: WebEnsureParams): Promise<WebEnsureResult> { const entry = params.entry ?? this.defaultEntry; + const host = params.host ?? DEFAULT_WEB_HOST; const startedFor: StartedFor = params.port !== undefined ? { kind: "explicit", port: params.port } @@ -160,6 +164,8 @@ export class WebSupervisor { : { kind: "none" }; const args = [ "--web-child", + "--host", + host, ...(startedFor.kind === "explicit" ? ["--port", String(startedFor.port)] : []), ...(startedFor.kind === "preferred" ? ["--preferred-port", String(startedFor.port)] : []), ]; @@ -219,10 +225,11 @@ export class WebSupervisor { } listening = true; const running: RunningChild = { - baseUrl: `http://127.0.0.1:${handshake.port}`, + baseUrl: webBaseUrl(host, handshake.port), port: handshake.port, token: handshake.token, child, + host, entry, build: handshake.build ?? params.build, startedFor, diff --git a/src/web/server.ts b/src/web/server.ts index f654b2a..c8cba73 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -1,11 +1,11 @@ import http, { type RequestListener, type Server } from "node:http"; -import { isIP, type AddressInfo } from "node:net"; +import type { AddressInfo } from "node:net"; import { spawn } from "node:child_process"; import { EventEmitter } from "node:events"; import { InvalidArgumentError } from "commander"; import { checkWebRequest, createWebSecurity, getTokenUrl, type WebSecurity } from "./security.js"; -import { DEFAULT_WEB_HOST } from "../config/web-host.js"; +import { DEFAULT_WEB_HOST, webBaseUrl } from "../config/web-host.js"; import { DEFAULT_WEB_PORT } from "../config/web-port.js"; export { DEFAULT_WEB_PORT }; @@ -230,13 +230,6 @@ export async function startWebServer(options: WebServerOptions): Promise<WebServ }; } -function webBaseUrl(host: string, port: number): string { - const urlHost = host === "127.0.0.1" || host === "0.0.0.0" || host === "::" - ? "127.0.0.1" - : isIP(host) === 6 ? `[${host}]` : host; - return `http://${urlHost}:${port}`; -} - function dispatchRequest( routes: readonly WebRoute[], security: WebSecurity, diff --git a/tests/web-supervisor.test.ts b/tests/web-supervisor.test.ts index c1ad8d9..7397962 100644 --- a/tests/web-supervisor.test.ts +++ b/tests/web-supervisor.test.ts @@ -59,7 +59,7 @@ describe("WebSupervisor.ensure", () => { children[0].handshake(ok()); await expect(pending).resolves.toEqual({ baseUrl: "http://127.0.0.1:4100", port: 4100, token: "tok-1" }); - expect(spawns).toEqual([{ entry: ENTRY, args: ["--web-child"] }]); + expect(spawns).toEqual([{ entry: ENTRY, args: ["--web-child", "--host", "127.0.0.1"] }]); }); it("reuses the running child for the same build or no build, and shares a start in flight", async () => { @@ -88,7 +88,7 @@ describe("WebSupervisor.ensure", () => { message: "listen EADDRINUSE: address already in use 127.0.0.1:4567", details: { port: 4567 }, }); - expect(spawns[0].args).toEqual(["--web-child", "--port", "4567"]); + expect(spawns[0].args).toEqual(["--web-child", "--host", "127.0.0.1", "--port", "4567"]); }); it("fails the start when the child exits before its handshake", async () => { @@ -193,6 +193,33 @@ describe("WebSupervisor.ensure", () => { expect(spawns).toHaveLength(2); }); + it("restarts a matching-port child when the requested host changes", async () => { + const t = harness(); + const firstPending = t.supervisor.ensure({ preferredPort: 7777, host: "127.0.0.1" }); + t.children[0].handshake(ok()); + const first = await firstPending; + + const second = t.supervisor.ensure({ preferredPort: 7777, host: "0.0.0.0" }); + await vi.waitFor(() => expect(t.children).toHaveLength(2)); + t.children[1].handshake(ok(4100, "tok-1")); + + await expect(second).resolves.toEqual(first); + expect(t.children[0].signals).toEqual(["SIGTERM"]); + expect(t.spawns[1].args).toEqual(["--web-child", "--host", "0.0.0.0", "--preferred-port", "7777"]); + }); + + it("reuses a child when the requested host stays the same", async () => { + const t = harness(); + const firstPending = t.supervisor.ensure({ preferredPort: 7777, host: "100.64.0.5" }); + t.children[0].handshake(ok()); + const first = await firstPending; + + await expect(t.supervisor.ensure({ preferredPort: 7777, host: "100.64.0.5" })).resolves.toEqual(first); + + expect(t.spawns).toHaveLength(1); + expect(t.children[0].signals).toEqual([]); + }); + it("stops an old build with SIGTERM, escalates to SIGKILL after the stop timeout, and returns the new child", async () => { vi.useFakeTimers(); const { supervisor, children } = harness(); @@ -238,7 +265,18 @@ describe("WebSupervisor port rules", () => { await running(t, { preferredPort: 7777 }); - expect(t.spawns[0].args).toEqual(["--web-child", "--preferred-port", "7777"]); + expect(t.spawns[0].args).toEqual(["--web-child", "--host", "127.0.0.1", "--preferred-port", "7777"]); + }); + + it.each([ + ["0.0.0.0", "127.0.0.1"], + ["::", "127.0.0.1"], + ["100.64.0.5", "100.64.0.5"], + ["::1", "[::1]"], + ])("formats the base URL for host %s", async (host, urlHost) => { + const t = harness(); + + await expect(running(t, { host })).resolves.toMatchObject({ baseUrl: `http://${urlHost}:4100` }); }); it.each([ @@ -255,7 +293,7 @@ describe("WebSupervisor port rules", () => { 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"]); + expect(t.spawns[1].args).toEqual(["--web-child", "--host", "127.0.0.1", "--preferred-port", "7788"]); }); it("reuses a child started for the same preferred port, even when it fell back to another port", async () => { @@ -306,7 +344,7 @@ describe("WebSupervisor port rules", () => { 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(t.spawns.map((spawned) => spawned.args[4])).toEqual(["7777", "7788", "7799"]); expect(aliveAtSpawn).toEqual([0, 0, 0]); }); }); From 2d042612d6a4510610c2bfab2cea0fdb1ea1a285 Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Fri, 25 Sep 2026 01:24:47 -0300 Subject: [PATCH 06/15] feat(daemon): Pass the configured host to web autostart Log invalid web.host values and keep daemon startup on the resolved bind host. Co-Authored-By: Codex <noreply@openai.com> --- .specs/features/web-host/spec.md | 2 +- .specs/features/web-host/tasks.md | 2 +- src/daemon/daemon.ts | 8 ++++++-- tests/daemon-web.test.ts | 26 ++++++++++++++++++++------ 4 files changed, 28 insertions(+), 10 deletions(-) diff --git a/.specs/features/web-host/spec.md b/.specs/features/web-host/spec.md index 13e99e8..ce062df 100644 --- a/.specs/features/web-host/spec.md +++ b/.specs/features/web-host/spec.md @@ -130,7 +130,7 @@ The web console currently binds to `127.0.0.1` and only accepts loopback Host he | WH-05 | P1: Bind the console to a configured address | Tasks | Pending | | WH-06 | P1: Bind the console to a configured address | Tasks | Verified | | WH-07 | P1: Bind the console to a configured address | Tasks | Verified | -| WH-08 | P1: Bind the console to a configured address | Tasks | Pending | +| WH-08 | P1: Bind the console to a configured address | Tasks | Verified | | WH-09 | P1: Preserve the console's request protections | Tasks | Verified | | WH-10 | P1: Preserve the console's request protections | Tasks | Verified | | WH-11 | P1: Preserve the console's request protections | Tasks | Verified | diff --git a/.specs/features/web-host/tasks.md b/.specs/features/web-host/tasks.md index 14694ce..7c668a6 100644 --- a/.specs/features/web-host/tasks.md +++ b/.specs/features/web-host/tasks.md @@ -92,7 +92,7 @@ WH IDs: WH-03, WH-08 Depends on: T05 Tests: Update `tests/daemon-web.test.ts` for resolved host in autostart params and invalid `web.host` log output. Gate: `npx vitest run --no-cache tests/daemon-web.test.ts` -Status: Pending +Status: Complete #### T07: Add `ui --host` and CLI host output diff --git a/src/daemon/daemon.ts b/src/daemon/daemon.ts index 766906e..ff2a199 100644 --- a/src/daemon/daemon.ts +++ b/src/daemon/daemon.ts @@ -23,6 +23,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 { invalidWebHostMessage, resolveWebHost } from "../config/web-host.js"; import { invalidWebPortMessage, resolveWebPort } from "../config/web-port.js"; import { classifyFailure, isStoreBusy, RunAgentError, type FailureInfo } from "../core/errors.js"; import { parseRole } from "../core/roles.js"; @@ -1512,10 +1513,13 @@ class Daemon { * prior web command. Not awaited: IPC never waits on the web stack. */ autostartWeb(): void { - const { port, invalid } = resolveWebPort(loadConfig()); + const config = loadConfig(); + const { port, invalid } = resolveWebPort(config); + const { host, invalid: invalidHost } = resolveWebHost(config); if (invalid !== undefined) appendDaemonLog(invalidWebPortMessage(invalid)); + if (invalidHost !== undefined) appendDaemonLog(invalidWebHostMessage(invalidHost)); this.webHost() - .ensure({ preferredPort: port }) + .ensure({ preferredPort: port, host }) .catch((error: unknown) => appendDaemonLog(`web autostart failed: ${error instanceof Error ? error.message : String(error)}`)); } diff --git a/tests/daemon-web.test.ts b/tests/daemon-web.test.ts index fcf1b87..d7787be 100644 --- a/tests/daemon-web.test.ts +++ b/tests/daemon-web.test.ts @@ -32,10 +32,10 @@ describe("daemon web.ensure", () => { const host = fakeHost(); daemon = new Daemon({ webSupervisor: host }); - const response = await ensure({ port: 4100, build: "b1", entry: "/opt/codedeck/dist/web/child.js" }); + const response = await ensure({ host: "100.64.0.5", port: 4100, build: "b1", entry: "/opt/codedeck/dist/web/child.js" }); expect(response.result).toEqual({ baseUrl: "http://127.0.0.1:4100", port: 4100, token: "tok" }); - expect(host.ensure).toHaveBeenCalledWith({ port: 4100, build: "b1", entry: "/opt/codedeck/dist/web/child.js" }); + expect(host.ensure).toHaveBeenCalledWith({ host: "100.64.0.5", port: 4100, build: "b1", entry: "/opt/codedeck/dist/web/child.js" }); }); it("maps a supervisor error to an IPC error with the same code, message and details", async () => { @@ -141,14 +141,14 @@ describe("daemon web autostart", () => { 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 } }); + useConfig({ web: { port: 7788, host: "100.64.0.5" } }); const host = fakeHost(); daemon = new Daemon({ webSupervisor: host }); daemon.autostartWeb(); expect(host.ensure).toHaveBeenCalledTimes(1); - expect(host.ensure).toHaveBeenCalledWith({ preferredPort: 7788 }); + expect(host.ensure).toHaveBeenCalledWith({ preferredPort: 7788, host: "100.64.0.5" }); }); it("logs a failed autostart and keeps serving web.ensure", async () => { @@ -161,7 +161,7 @@ describe("daemon web autostart", () => { 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(hostEnsure).toHaveBeenNthCalledWith(1, { preferredPort: 7777, host: "127.0.0.1" }); expect((await ensure({})).result).toEqual({ baseUrl: "http://127.0.0.1:7777", port: 7777, token: "tok" }); }); @@ -173,7 +173,21 @@ describe("daemon web autostart", () => { daemon.autostartWeb(); expect(daemonLog()).toMatch(/\] Ignoring invalid web.port in config: "7788"\n/); - expect(host.ensure).toHaveBeenCalledWith({ preferredPort: 7777 }); + expect(host.ensure).toHaveBeenCalledWith({ preferredPort: 7777, host: "127.0.0.1" }); + }); + + it("logs an invalid web.host and autostarts on loopback", async () => { + useConfig({ web: { host: "deck.local" } }); + const logPath = getPaths().daemonLog; + fs.mkdirSync(path.dirname(logPath), { recursive: true }); + fs.writeFileSync(logPath, ""); + const host = fakeHost(); + daemon = new Daemon({ webSupervisor: host }); + + daemon.autostartWeb(); + + expect(daemonLog()).toMatch(/\] Ignoring invalid web\.host in config: "deck\.local"\n/); + expect(host.ensure).toHaveBeenCalledWith({ preferredPort: 7777, host: "127.0.0.1" }); }); it("does not start the web child from start()", async () => { From 775f7cf6b6c89e54f1084c758be0af7be1e6e287 Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Fri, 25 Sep 2026 01:34:01 -0300 Subject: [PATCH 07/15] feat(cli): Support configurable console bind hosts Resolve configured hosts, accept the ui override, and print trusted-network links and warnings. Co-Authored-By: Codex <noreply@openai.com> --- .specs/features/web-host/run-notes.md | 1 + .specs/features/web-host/spec.md | 12 +-- .specs/features/web-host/tasks.md | 3 +- src/cli/commands/ui.ts | 17 +++- src/cli/web-launch.ts | 64 +++++++++++-- tests/web-cli.test.ts | 25 ++++++ tests/web-launch.test.ts | 124 +++++++++++++++++++++++++- 7 files changed, 228 insertions(+), 18 deletions(-) diff --git a/.specs/features/web-host/run-notes.md b/.specs/features/web-host/run-notes.md index 1ace046..bb49d7f 100644 --- a/.specs/features/web-host/run-notes.md +++ b/.specs/features/web-host/run-notes.md @@ -22,3 +22,4 @@ | 18 | Use the requested capitalized commit subjects even though `check_commit.py` rejects them. | Decision | This continuation explicitly requires capitalized subjects and the exact T01 message; the checker requires lowercase descriptions. | | 19 | Commit T01 and T02 in separate commits from this continuation. | Deviation | The earlier worker left their source changes uncommitted after sandbox failures, and this continuation reran both gates before committing. | | 20 | Share base URL formatting from `src/config/web-host.ts`. | Decision | The supervisor and web server need the same host mapping, while the daemon import boundary forbids importing the web server into the supervisor. | +| 21 | Print the plain HTTP warning after a listener succeeds. | Decision | A preferred port may fall back to an OS-assigned port, so the warning must name the actual listening port. | diff --git a/.specs/features/web-host/spec.md b/.specs/features/web-host/spec.md index ce062df..c40f897 100644 --- a/.specs/features/web-host/spec.md +++ b/.specs/features/web-host/spec.md @@ -125,9 +125,9 @@ The web console currently binds to `127.0.0.1` and only accepts loopback Host he | --- | --- | --- | --- | | WH-01 | P1: Bind the console to a configured address | Tasks | Verified | | WH-02 | P1: Bind the console to a configured address | Tasks | Verified | -| WH-03 | P1: Bind the console to a configured address | Tasks | Pending | -| WH-04 | P1: Bind the console to a configured address | Tasks | Pending | -| WH-05 | P1: Bind the console to a configured address | Tasks | Pending | +| WH-03 | P1: Bind the console to a configured address | Tasks | Verified | +| WH-04 | P1: Bind the console to a configured address | Tasks | Verified | +| WH-05 | P1: Bind the console to a configured address | Tasks | Verified | | WH-06 | P1: Bind the console to a configured address | Tasks | Verified | | WH-07 | P1: Bind the console to a configured address | Tasks | Verified | | WH-08 | P1: Bind the console to a configured address | Tasks | Verified | @@ -136,9 +136,9 @@ The web console currently binds to `127.0.0.1` and only accepts loopback Host he | WH-11 | P1: Preserve the console's request protections | Tasks | Verified | | WH-12 | P1: Preserve the console's request protections | Tasks | Verified | | WH-13 | P1: Preserve the console's request protections | Tasks | Verified | -| WH-14 | P2: Print usable links and bind errors | Tasks | Pending | -| WH-15 | P2: Print usable links and bind errors | Tasks | Pending | -| WH-16 | P2: Print usable links and bind errors | Tasks | Pending | +| WH-14 | P2: Print usable links and bind errors | Tasks | Verified | +| WH-15 | P2: Print usable links and bind errors | Tasks | Verified | +| WH-16 | P2: Print usable links and bind errors | Tasks | Verified | **Coverage**: 16 requirements, 16 mapped to tasks, 0 unmapped. diff --git a/.specs/features/web-host/tasks.md b/.specs/features/web-host/tasks.md index 7c668a6..8180cb6 100644 --- a/.specs/features/web-host/tasks.md +++ b/.specs/features/web-host/tasks.md @@ -97,11 +97,12 @@ Status: Complete #### T07: Add `ui --host` and CLI host output Where: `src/cli/web-launch.ts` +Supporting file: `src/cli/commands/ui.ts`. WH IDs: WH-03, WH-04, WH-05, WH-14, WH-15, WH-16 Depends on: T06 Tests: Update `tests/web-launch.test.ts` and `tests/web-cli.test.ts` for flag forwarding, pre-IPC validation, config warnings, wildcard links, warning text, and both listen failure paths. Gate: `npx vitest run --no-cache tests/web-launch.test.ts tests/web-cli.test.ts` -Status: Pending +Status: Complete ### Phase 3: Document the host setting diff --git a/src/cli/commands/ui.ts b/src/cli/commands/ui.ts index 278f215..27deffa 100644 --- a/src/cli/commands/ui.ts +++ b/src/cli/commands/ui.ts @@ -1,3 +1,4 @@ +import { isIP } from "node:net"; import type { Command } from "commander"; import { createReviewRoutes } from "./review.js"; import { fetchUsageQuery } from "./usage.js"; @@ -10,6 +11,7 @@ import { launchWebPage } from "../web-launch.js"; export interface UiCommandOptions { port?: string; + host?: string; open?: boolean; } @@ -56,8 +58,15 @@ export function registerUiCommand(program: Command, dependencies: UiCommandDepen .command("ui") .description("Open the local CodeDeck console") .option("--port <n>", "port for a new console (default: web.port from config, else 7777)") + .option("--host <addr>", "IP address for the console to bind") .option("--no-open", "print the console URL without opening a browser") .action(async (opts: UiCommandOptions) => { + if (opts.host !== undefined && isIP(opts.host) === 0) { + console.error("--host must be an IP address"); + process.exitCode = 1; + return; + } + let port: number | undefined; try { port = parseOptionalWebPort(opts.port); @@ -67,7 +76,13 @@ export function registerUiCommand(program: Command, dependencies: UiCommandDepen return; } - const code = await (dependencies.launch ?? launchWebPage)({ path: "/", title: "CodeDeck UI", port, open: opts.open !== false }); + const code = await (dependencies.launch ?? launchWebPage)({ + path: "/", + title: "CodeDeck UI", + port, + ...(opts.host === undefined ? {} : { host: opts.host }), + open: opts.open !== false, + }); if (code !== 0) process.exitCode = code; }); } diff --git a/src/cli/web-launch.ts b/src/cli/web-launch.ts index 2c6f577..a8ffff2 100644 --- a/src/cli/web-launch.ts +++ b/src/cli/web-launch.ts @@ -1,8 +1,11 @@ +import os from "node:os"; +import { isIP } from "node:net"; 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 { loadConfig } from "../config/config.js"; +import { invalidWebHostMessage, resolveWebHost } from "../config/web-host.js"; import { invalidWebPortMessage, resolveWebPort } from "../config/web-port.js"; import { openBrowser, startWebServer } from "../web/server.js"; import { resolveWebToken } from "../web/web-token.js"; @@ -14,6 +17,8 @@ export interface LaunchWebPageOptions { title: string; /** Explicit `--port`; omitted means the preferred port (`web.port`, else 7777) or an ephemeral one. */ port?: number; + /** Explicit `ui --host`; omitted means the resolved `web.host` or loopback. */ + host?: string; open: boolean; } @@ -26,6 +31,7 @@ export interface LaunchWebPageDependencies { build?: string; entry?: string; loadConfig?: () => { web?: unknown }; + networkInterfaces?: typeof os.networkInterfaces; resolveToken?: () => string; } @@ -37,18 +43,23 @@ 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)()); + const config = (deps.loadConfig ?? loadConfig)(); + const { port: preferredPort, invalid } = resolveWebPort(config); + const { host: configuredHost, invalid: invalidHost } = resolveWebHost(config); if (invalid !== undefined) error(invalidWebPortMessage(invalid)); + if (invalidHost !== undefined) error(invalidWebHostMessage(invalidHost)); + const host = options.host ?? configuredHost; const askedPort = options.port ?? preferredPort; try { await client.ensureDaemonStarted(); } catch { error("CodeDeck daemon is unavailable; serving from this process."); - return serveInProcess(options, askedPort, deps, error); + return serveInProcess(options, host, askedPort, deps, error); } const params: WebEnsureParams = { + host, ...(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)), @@ -59,13 +70,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 ?? askedPort}: ${message}`); + error(listenFailureMessage(host, details?.port ?? askedPort, message)); return 1; } error(`CodeDeck daemon cannot host the web console (${code ?? "UNKNOWN"}); serving from this process.`); - return serveInProcess(options, askedPort, deps, error); + return serveInProcess(options, host, askedPort, deps, error); } + warnForPlainHttp(host, web.port, error); 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); @@ -77,19 +89,23 @@ export async function launchWebPage(options: LaunchWebPageOptions, deps: LaunchW } else { log(`${options.title} on ${pageUrl}`); } + printAlternateLinks(host, web.port, options, web.token, deps.networkInterfaces ?? os.networkInterfaces, log); return 0; } async function serveInProcess( options: LaunchWebPageOptions, + host: string, port: number, deps: LaunchWebPageDependencies, error: (message: string) => void, ): Promise<number> { const query = new URLSearchParams(options.query ?? {}).toString(); + const log = deps.log ?? ((message: string) => console.log(message)); try { - await (deps.startServer ?? startWebServer)({ + const listening = await (deps.startServer ?? startWebServer)({ routes: createUiRoutes(), + host, port, fallbackToEphemeral: options.port === undefined, // The same token as the daemon's web child, so neither server's cookie locks out the other. @@ -98,12 +114,46 @@ async function serveInProcess( title: options.title, open: options.open, openBrowser: deps.openBrowser, - log: deps.log, + log, }); + warnForPlainHttp(host, listening.port, error); + printAlternateLinks(host, listening.port, options, listening.security.token, deps.networkInterfaces ?? os.networkInterfaces, log); } catch (failure) { - error(`Failed to listen on 127.0.0.1:${port}: ${failure instanceof Error ? failure.message : String(failure)}`); + error(listenFailureMessage(host, port, failure instanceof Error ? failure.message : String(failure))); return 1; } // The server keeps this process alive until SIGINT or SIGTERM. return 0; } + +function listenFailureMessage(host: string, port: number, message: string): string { + const displayedHost = isIP(host) === 6 ? `[${host}]` : host; + return `Failed to listen on ${displayedHost}:${port}: ${message}`; +} + +function warnForPlainHttp(host: string, port: number, error: (message: string) => void): void { + const ipVersion = isIP(host); + const loopback = ipVersion === 4 + ? host.startsWith("127.") + : ipVersion === 6 && new URL(`http://[${host}]/`).hostname.toLowerCase() === "[::1]"; + if (loopback) return; + error(`Warning: the console listens on ${host} over plain HTTP. Anyone who can reach port ${port} with the link gets full access; use it only on a trusted network such as Tailscale.`); +} + +function printAlternateLinks( + host: string, + port: number, + options: LaunchWebPageOptions, + token: string, + networkInterfaces: typeof os.networkInterfaces, + log: (message: string) => void, +): void { + if (host !== "0.0.0.0" && host !== "::") return; + for (const info of Object.values(networkInterfaces()).flatMap((addresses) => addresses ?? [])) { + if (info.family !== "IPv4" || info.internal) continue; + const url = new URL(options.path, `http://${info.address}:${port}`); + for (const [key, value] of Object.entries(options.query ?? {})) url.searchParams.set(key, value); + url.searchParams.set("t", token); + log(`Also on ${url.toString()}`); + } +} diff --git a/tests/web-cli.test.ts b/tests/web-cli.test.ts index 3338e85..0c31706 100644 --- a/tests/web-cli.test.ts +++ b/tests/web-cli.test.ts @@ -106,6 +106,31 @@ describe("ui CLI command", () => { expect(launch).toHaveBeenCalledWith({ path: "/", title: "CodeDeck UI", port: 4200, open: true }); }); + it("sends an explicit host to the launcher", async () => { + const launch = vi.fn(async () => 0); + const program = new Command(); + program.exitOverride(); + registerUiCommand(program, { launch }); + + await program.parseAsync(["node", "codedeck", "ui", "--host", "100.64.0.5"], { from: "node" }).catch(() => {}); + + expect(launch).toHaveBeenCalledWith({ path: "/", title: "CodeDeck UI", port: undefined, host: "100.64.0.5", open: true }); + }); + + it("rejects an invalid host without launching", async () => { + const launch = vi.fn(async () => 0); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + const program = new Command(); + program.exitOverride(); + registerUiCommand(program, { launch }); + + await program.parseAsync(["node", "codedeck", "ui", "--host", "deck.local"], { from: "node" }).catch(() => {}); + + expect(launch).not.toHaveBeenCalled(); + expect(error).toHaveBeenCalledWith("--host must be an IP address"); + expect(process.exitCode).toBe(1); + }); + it("rejects an invalid port without launching", async () => { const launch = vi.fn(async () => 0); const error = vi.spyOn(console, "error").mockImplementation(() => {}); diff --git a/tests/web-launch.test.ts b/tests/web-launch.test.ts index af05344..5bc0c37 100644 --- a/tests/web-launch.test.ts +++ b/tests/web-launch.test.ts @@ -1,3 +1,4 @@ +import os from "node:os"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import { computeBuildId } from "../src/daemon/build-id.js"; @@ -21,16 +22,33 @@ function ipcError(code: string, message: string, details?: unknown): Error { const TOKEN = "9".repeat(64); +function makeInterfaces( + addresses: Array<{ address: string; family: "IPv4" | "IPv6"; internal?: boolean }>, +): ReturnType<typeof os.networkInterfaces> { + return { + test0: addresses.map(({ address, family, internal = false }) => ({ + address, + family, + internal, + netmask: family === "IPv4" ? "255.255.255.0" : "ffff:ffff:ffff:ffff::", + mac: "00:00:00:00:00:00", + cidr: null, + ...(family === "IPv6" ? { scopeid: 0 } : {}), + })), + }; +} + function setup(overrides: { ensure?: () => Promise<unknown>; start?: () => Promise<void>; opener?: boolean; config?: { web?: unknown }; + networkInterfaces?: LaunchWebPageDependencies["networkInterfaces"]; } = {}) { const request = vi.fn(async (_method: string, _params: unknown) => (overrides.ensure ?? (async () => BASE))()); const ensureDaemonStarted = vi.fn(overrides.start ?? (async () => {})); const openBrowser = vi.fn(async (_url: string) => overrides.opener ?? true); - const startServer = vi.fn(async () => ({}) as WebServerHandle); + const startServer = vi.fn(async () => ({ port: BASE.port, security: { token: BASE.token } }) as WebServerHandle); const logs: string[] = []; const errors: string[] = []; const deps: LaunchWebPageDependencies = { @@ -41,6 +59,7 @@ function setup(overrides: { error: (message) => errors.push(message), build: "build-1", loadConfig: () => overrides.config ?? {}, + ...(overrides.networkInterfaces ? { networkInterfaces: overrides.networkInterfaces } : {}), resolveToken: () => TOKEN, }; const launch = (options: Partial<LaunchWebPageOptions> = {}) => @@ -105,6 +124,93 @@ describe("launchWebPage", () => { expect(preferred).not.toHaveProperty("port"); }); + it("uses web.host unless the launch provides a host override", async () => { + const t = setup({ config: { web: { host: "100.64.0.5" } } }); + + await t.launch({ open: false }); + await t.launch({ host: "192.168.1.25", open: false }); + + expect(t.request.mock.calls.map(([, params]) => params)).toEqual([ + expect.objectContaining({ host: "100.64.0.5" }), + expect.objectContaining({ host: "192.168.1.25" }), + ]); + expect(t.errors).toEqual([ + "Warning: the console listens on 100.64.0.5 over plain HTTP. Anyone who can reach port 7777 with the link gets full access; use it only on a trusted network such as Tailscale.", + "Warning: the console listens on 192.168.1.25 over plain HTTP. Anyone who can reach port 7777 with the link gets full access; use it only on a trusted network such as Tailscale.", + ]); + }); + + it("warns about an invalid configured host and falls back to loopback", async () => { + const t = setup({ config: { web: { host: "deck.local" } } }); + + await t.launch({ open: false }); + + expect(t.errors).toEqual(['Ignoring invalid web.host in config: "deck.local"']); + expect(t.request.mock.calls[0][1]).toMatchObject({ host: "127.0.0.1" }); + }); + + it("passes the resolved host to the in-process server", async () => { + const t = setup({ config: { web: { host: "100.64.0.5" } }, start: async () => { throw new Error("no daemon"); } }); + + await t.launch({ open: false }); + + const options = (t.startServer.mock.calls[0] as unknown as [Record<string, any>])[0]; + expect(options.host).toBe("100.64.0.5"); + expect(t.errors).toContain("Warning: the console listens on 100.64.0.5 over plain HTTP. Anyone who can reach port 7777 with the link gets full access; use it only on a trusted network such as Tailscale."); + }); + + it.each(["0.0.0.0", "::"])("prints alternate interface links for wildcard host %s", async (host) => { + const t = setup({ + config: { web: { host } }, + networkInterfaces: () => makeInterfaces([ + { address: "100.101.102.103", family: "IPv4" }, + { address: "192.168.1.9", family: "IPv4" }, + { address: "127.0.0.1", family: "IPv4", internal: true }, + { address: "fd7a:115c:a1e0::1", family: "IPv6" }, + ]), + }); + + await t.launch({ open: false }); + + expect(t.logs).toEqual([ + "CodeDeck review on http://127.0.0.1:7777/review?repo=%2Fwork%2Fapp&t=tok", + "Also on http://100.101.102.103:7777/review?repo=%2Fwork%2Fapp&t=tok", + "Also on http://192.168.1.9:7777/review?repo=%2Fwork%2Fapp&t=tok", + ]); + expect(t.errors).toEqual([ + `Warning: the console listens on ${host} over plain HTTP. Anyone who can reach port 7777 with the link gets full access; use it only on a trusted network such as Tailscale.`, + ]); + }); + + it("prints alternate links after the in-process server URL for wildcard binds", async () => { + const t = setup({ + config: { web: { host: "0.0.0.0" } }, + ensure: async () => { throw ipcError("UNKNOWN_METHOD", "nope"); }, + networkInterfaces: () => makeInterfaces([{ address: "100.101.102.103", family: "IPv4" }]), + }); + const initialUrl = "http://127.0.0.1:7777/review?repo=%2Fwork%2Fapp&t=tok"; + const listening = { port: 7777, initialUrl, security: { token: "tok" } } as WebServerHandle; + t.deps.startServer = vi.fn(async (options) => { + options.log?.(`CodeDeck review on ${initialUrl}`); + return listening; + }); + + await t.launch({ open: false }); + + expect(t.logs).toEqual([ + "CodeDeck review on http://127.0.0.1:7777/review?repo=%2Fwork%2Fapp&t=tok", + "Also on http://100.101.102.103:7777/review?repo=%2Fwork%2Fapp&t=tok", + ]); + }); + + it.each(["127.0.0.2", "::1"])("does not warn for loopback host %s", async (host) => { + const t = setup({ config: { web: { host } } }); + + await t.launch({ open: false }); + + expect(t.errors).toEqual([]); + }); + it("prefers 7777 when the config has no web.port", async () => { const t = setup(); @@ -149,12 +255,24 @@ describe("launchWebPage", () => { it("reports WEB_LISTEN_FAILED, returns 1 and does not fall back", async () => { const t = setup({ ensure: async () => { throw ipcError("WEB_LISTEN_FAILED", "listen EADDRINUSE", { port: 4200 }); } }); - expect(await t.launch({ port: 4200 })).toBe(1); + expect(await t.launch({ host: "::1", port: 4200 })).toBe(1); - expect(t.errors).toEqual(["Failed to listen on 127.0.0.1:4200: listen EADDRINUSE"]); + expect(t.errors).toEqual(["Failed to listen on [::1]:4200: listen EADDRINUSE"]); expect(t.startServer).not.toHaveBeenCalled(); }); + it("names the bind host in in-process listen failures", async () => { + const t = setup({ ensure: async () => { throw ipcError("UNKNOWN_METHOD", "nope"); } }); + t.deps.startServer = vi.fn(async () => { throw new Error("listen EADDRINUSE"); }); + + expect(await t.launch({ host: "::1", port: 4200 })).toBe(1); + + expect(t.errors).toEqual([ + "CodeDeck daemon cannot host the web console (UNKNOWN_METHOD); serving from this process.", + "Failed to listen on [::1]:4200: listen EADDRINUSE", + ]); + }); + it.each(["UNKNOWN_METHOD", "SERVICE_UNAVAILABLE", "WEB_START_FAILED", "WEB_BAD_ENTRY"])("serves in-process with the full route table after %s", async (code) => { const t = setup({ ensure: async () => { throw ipcError(code, "nope"); } }); From 3598116b187e8ab1aa623933b7652a89b30447dc Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Fri, 25 Sep 2026 01:37:22 -0300 Subject: [PATCH 08/15] docs(web): Document the configurable console bind host Describe the web.host setting, CLI override, child argument, and protocol parameter. Co-Authored-By: Codex <noreply@openai.com> --- .specs/features/web-host/tasks.md | 2 +- README.md | 15 +++++++++++++++ docs/protocol.md | 10 +++++++--- tests/web-host-docs.test.ts | 31 +++++++++++++++++++++++++++++++ 4 files changed, 54 insertions(+), 4 deletions(-) create mode 100644 tests/web-host-docs.test.ts diff --git a/.specs/features/web-host/tasks.md b/.specs/features/web-host/tasks.md index 8180cb6..a98429e 100644 --- a/.specs/features/web-host/tasks.md +++ b/.specs/features/web-host/tasks.md @@ -113,7 +113,7 @@ WH IDs: WH-04, WH-06, WH-08, WH-09 Depends on: T07 Tests: Add `tests/web-host-docs.test.ts` for required README and Portuguese `docs/protocol.md` details. Gate: `npx vitest run --no-cache tests/web-host-docs.test.ts` -Status: Pending +Status: Complete ## Task Completion Record diff --git a/README.md b/README.md index 79b4291..eed12d5 100644 --- a/README.md +++ b/README.md @@ -92,8 +92,23 @@ The daemon owns the sessions. The CLI only follows events — closing the termin | `npx codedeck send <id> "<msg>"` | Continue a session (new turn) | | `npx codedeck stop <id>` | Graceful interrupt → SIGTERM → SIGKILL | | `npx codedeck diff <id> [--stat] [--json]` | Git diff against base commit | +| `npx codedeck ui [--host <addr>] [--port <n>] [--no-open]` | Open the local web console | | `npx codedeck review [--port <n>] [--no-open]` | Open a local review of the current git changes | +## Web console + +The console listens on `127.0.0.1` by default. Set `web.host` in `config.json` to an IPv4 or IPv6 address to bind another interface, such as a Tailscale address: + +```json +{ + "web": { + "host": "100.101.102.103" + } +} +``` + +`web.host` accepts IP addresses only. `codedeck ui --host <addr>` overrides the configured address for that launch. The CLI sends the selected address as the `host` parameter of `web.ensure`, and the daemon passes it to the child as `--host <addr>`. If `web.ensure` omits `host`, the supervisor uses `127.0.0.1`. A host change restarts the web child. + ## Open `codedeck open` launches a session on the harness bound to the role in `codedeck setup`: Claude Code with the CodeDeck plugin, an appended system prompt, Opus 4.8 at `xhigh` effort, and permissions bypassed; or the opencode TUI with the role contract injected, `--auto` on, and a stock look. Nothing is written to `~/.claude/` or `~/.config/opencode`; everything loads for that session only. diff --git a/docs/protocol.md b/docs/protocol.md index dcef152..408ce33 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -44,9 +44,12 @@ o shell. Se o start junto com o daemon falhar, o `daemon.log` recebe Request: ```json -{ "id": "w1", "method": "web.ensure", "params": { "preferredPort": 7777, "build": "1790000000000", "entry": "/abs/dist/web/child.js" } } +{ "id": "w1", "method": "web.ensure", "params": { "host": "100.101.102.103", "preferredPort": 7777, "build": "1790000000000", "entry": "/abs/dist/web/child.js" } } ``` +- `host` (opcional): endereço IP usado para escutar. Vem de `web.host` ou de + `codedeck ui --host <addr>`; sem esse campo, usa `127.0.0.1`. Se o endereço + diferir do filho atual, o supervisor encerra o filho e inicia outro. - `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. @@ -83,7 +86,7 @@ Erros: | `code` | Quando | `details` | | --- | --- | --- | -| `WEB_LISTEN_FAILED` | a porta pedida está ocupada (o CLI imprime `Failed to listen on 127.0.0.1:<port>: ...` e sai com 1) | `{ "port": n }` | +| `WEB_LISTEN_FAILED` | a porta pedida está ocupada (o CLI imprime `Failed to listen on <host>:<port>: ...`, com IPv6 entre colchetes, e sai com 1) | `{ "port": n }` | | `WEB_START_FAILED` | o filho morreu antes do handshake, não respondeu em 5 s ou mandou um handshake inválido | | | `WEB_BAD_ENTRY` | `entry` não é absoluto, não termina em `/web/child.js` ou não existe | | @@ -91,7 +94,8 @@ Ciclo de vida do filho: - 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 <n>` (explícita, sem fallback), +- Argumentos do filho: `--host <addr>` (ausente, usa `127.0.0.1`), + `--port <n>` (explícita, sem fallback), `--preferred-port <n>` (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`. diff --git a/tests/web-host-docs.test.ts b/tests/web-host-docs.test.ts new file mode 100644 index 0000000..e66dbd3 --- /dev/null +++ b/tests/web-host-docs.test.ts @@ -0,0 +1,31 @@ +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +const root = path.join(import.meta.dirname, ".."); + +function readDocument(relativePath: string): string { + return fs.readFileSync(path.join(root, relativePath), "utf8"); +} + +describe("web host documentation", () => { + it("documents web.host, the UI override, the child argument, and web.ensure in README", () => { + const readme = readDocument("README.md"); + + expect(readme).toContain("`web.host`"); + expect(readme).toContain("`codedeck ui --host <addr>`"); + expect(readme).toContain("`--host <addr>`"); + expect(readme).toContain("`web.ensure`"); + expect(readme).toContain("`host`"); + }); + + it("documents the host parameter in Portuguese in the protocol section", () => { + const protocol = readDocument("docs/protocol.md"); + + expect(protocol).toContain("`host` (opcional)"); + expect(protocol).toContain("endereço IP"); + expect(protocol).toContain("`web.host`"); + expect(protocol).toContain("`--host <addr>`"); + expect(protocol).toContain("`127.0.0.1`"); + }); +}); From cb05710ba09e14078948c53be5abef15a42fc25d Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Fri, 25 Sep 2026 02:19:57 -0300 Subject: [PATCH 09/15] fix(daemon): Keep an explicit console host across web commands Separate caller-selected binds from the resolved config preference. This keeps ordinary web commands from moving a child explicitly bound by `ui --host`. Co-Authored-By: Codex --- .specs/features/web-host/run-notes.md | 2 ++ .specs/features/web-host/spec.md | 20 +++++++---- .specs/features/web-host/tasks.md | 14 +++++++- README.md | 2 +- docs/protocol.md | 11 ++++-- src/cli/web-launch.ts | 2 +- src/daemon/daemon.ts | 2 +- src/daemon/protocol.ts | 4 ++- src/daemon/web-supervisor.ts | 27 +++++++++++---- tests/daemon-web.test.ts | 8 ++--- tests/web-host-docs.test.ts | 3 ++ tests/web-launch.test.ts | 8 +++-- tests/web-supervisor.test.ts | 50 +++++++++++++++++++++++---- 13 files changed, 118 insertions(+), 35 deletions(-) diff --git a/.specs/features/web-host/run-notes.md b/.specs/features/web-host/run-notes.md index bb49d7f..8beb179 100644 --- a/.specs/features/web-host/run-notes.md +++ b/.specs/features/web-host/run-notes.md @@ -23,3 +23,5 @@ | 19 | Commit T01 and T02 in separate commits from this continuation. | Deviation | The earlier worker left their source changes uncommitted after sandbox failures, and this continuation reran both gates before committing. | | 20 | Share base URL formatting from `src/config/web-host.ts`. | Decision | The supervisor and web server need the same host mapping, while the daemon import boundary forbids importing the web server into the supervisor. | | 21 | Print the plain HTTP warning after a listener succeeds. | Decision | A preferred port may fall back to an OS-assigned port, so the warning must name the actual listening port. | +| 22 | Separate explicit and preferred web host requests. | Bucket 1 decision | Finding R1: an explicit `ui --host` bind pins its child, `web.host` is a preference, and omitted host fields reuse a running child or start on loopback. | +| 23 | Capitalize commit subjects after the Conventional Commit prefix. | Decision | The remediation follows repository history; `check_commit.py` requires lowercase subjects, so the repository convention takes precedence as requested. | diff --git a/.specs/features/web-host/spec.md b/.specs/features/web-host/spec.md index c40f897..859aac0 100644 --- a/.specs/features/web-host/spec.md +++ b/.specs/features/web-host/spec.md @@ -31,7 +31,7 @@ The web console currently binds to `127.0.0.1` and only accepts loopback Host he | --- | --- | --- | --- | | A malformed top-level `web` value | Treat it as an invalid host setting and report the raw JSON value. | This matches `resolveWebPort` handling for malformed `web` sections. | No | | A valid explicit `ui --host` with invalid configured `web.host` | Use the explicit host and still report the invalid config value. | WH-03 requires the CLI warning and WH-04 makes the flag an override. | No | -| Supervisor host identity | Store the requested host with the running child state. | The child handshake has no host field, and WH-09 compares requested and running hosts. | No | +| Supervisor host identity | Store the bind host and whether it came from an explicit request, config preference, or no host request. | The child handshake has no host field, and WH-09 plus WH-17 through WH-19 define host reuse and restart rules. | No | | Host header comparison | Compare the complete host and port case-insensitively. | WH-12 requires case-insensitive matching and the exact console port. | No | | Wildcard alternate links | Use each non-internal IPv4 interface address and preserve the requested path, query, and token. | WH-14 requires one tokenized link for each such address. | No | | URL host formatting | Advertise loopback for `127.0.0.1`, `0.0.0.0`, and `::`; bracket other IPv6 hosts. | WH-10 specifies this mapping. | No | @@ -72,8 +72,11 @@ The web console currently binds to `127.0.0.1` and only accepts loopback Host he 1. WHILE the bind host is `127.0.0.1` THEN the server SHALL allow exactly `127.0.0.1:<port>` and `localhost:<port>` as Host headers. <!-- WH-11 --> 2. WHILE the bind host is anything other than `127.0.0.1` THEN the server SHALL also allow case-insensitive Host `<name>:<port>` values for current local interface addresses, `os.hostname()`, and names beginning with `os.hostname() + "."`; interface addresses SHALL be evaluated per request through an injectable seam, IPv6 addresses SHALL use brackets, and addresses with a `%` zone SHALL be skipped. <!-- WH-12 --> 3. WHEN a Host header is accepted THEN the server SHALL apply the existing token, cookie, same-origin POST, and `/api/*` checks unchanged; a page GET on a Tailscale IP without a token or cookie SHALL return the existing 403 page body, and a GET with the correct `t` token SHALL return 303 and set the session cookie. <!-- WH-13 --> -4. WHEN `web.ensure` requests a host different from the running child's host THEN the supervisor SHALL stop that child and start a child on the requested host; omitted `host` SHALL mean `127.0.0.1`, and existing port reuse rules SHALL otherwise remain unchanged. <!-- WH-09 --> +4. WHEN `web.ensure` supplies an explicit `host` different from the running child's host THEN the supervisor SHALL stop that child and start a child on the requested host; an omitted `host` SHALL NOT request a move to `127.0.0.1`, and existing port reuse rules SHALL remain unchanged. <!-- WH-09 --> 5. WHEN the bind host is `127.0.0.1`, `0.0.0.0`, or `::` THEN the supervisor and `listenWebServer` SHALL return `http://127.0.0.1:<port>` as the base URL; for any other host they SHALL return `http://<host>:<port>`, with IPv6 in brackets. <!-- WH-10 --> +6. WHEN `web.ensure` supplies a `preferredHost` different from the running child's host AND the child was started for a preferred host or with neither host field THEN the supervisor SHALL stop that child and start a child on `preferredHost`. <!-- WH-17 --> +7. WHEN `web.ensure` supplies a `preferredHost` AND the running child was started for an explicit host THEN the supervisor SHALL keep that explicit bind host. <!-- WH-18 --> +8. WHEN `web.ensure` supplies neither `host` nor `preferredHost` THEN the supervisor SHALL reuse a running child regardless of its bind host, and SHALL start a new child on `127.0.0.1` when none is running. <!-- WH-19 --> **Independent Test**: Bind to a wildcard address, send requests with a local interface Host, a hostile Host, and tokenized or untokenized page URLs, and verify the exact response behavior. @@ -111,13 +114,13 @@ The web console currently binds to `127.0.0.1` and only accepts loopback Host he | --- | --- | | Input validation and bounds | WH-02, WH-03, and WH-05 require `net.isIP` validation. | | Failure and partial-failure states | WH-03, WH-05, and WH-16 define invalid config, invalid flag, and listen failure outcomes. | -| Idempotency and retry | WH-09 defines child reuse and restart behavior when the host changes. | +| Idempotency and retry | WH-09 and WH-17 through WH-19 define child reuse and restart behavior by host request and start origin. | | Auth boundaries and rate limits | WH-11 through WH-13 keep the existing Host and token checks; rate limiting is N/A because this feature does not change request authorization behavior. | -| Concurrency and ordering | WH-09 uses the existing single-child supervisor transition when the host changes. | +| Concurrency and ordering | WH-09 and WH-17 use the existing single-child supervisor transition when the requested host changes. | | Data lifecycle and expiry | N/A because the feature adds no persisted data. | | Observability | WH-03 reports invalid config, WH-15 warns about reachable plain HTTP, and WH-16 reports the listen host. | | External-dependency failure | WH-16 covers OS listen failures; no new external service is introduced. | -| State-transition integrity | WH-09 defines the transition from a running child to a child on the requested host. | +| State-transition integrity | WH-09 and WH-17 through WH-19 define host transitions and reuse. | ## Requirement Traceability @@ -131,7 +134,7 @@ The web console currently binds to `127.0.0.1` and only accepts loopback Host he | WH-06 | P1: Bind the console to a configured address | Tasks | Verified | | WH-07 | P1: Bind the console to a configured address | Tasks | Verified | | WH-08 | P1: Bind the console to a configured address | Tasks | Verified | -| WH-09 | P1: Preserve the console's request protections | Tasks | Verified | +| WH-09 | P1: Preserve the console's request protections | Tasks | Implemented | | WH-10 | P1: Preserve the console's request protections | Tasks | Verified | | WH-11 | P1: Preserve the console's request protections | Tasks | Verified | | WH-12 | P1: Preserve the console's request protections | Tasks | Verified | @@ -139,8 +142,11 @@ The web console currently binds to `127.0.0.1` and only accepts loopback Host he | WH-14 | P2: Print usable links and bind errors | Tasks | Verified | | WH-15 | P2: Print usable links and bind errors | Tasks | Verified | | WH-16 | P2: Print usable links and bind errors | Tasks | Verified | +| WH-17 | P1: Preserve the console's request protections | Tasks | Implemented | +| WH-18 | P1: Preserve the console's request protections | Tasks | Implemented | +| WH-19 | P1: Preserve the console's request protections | Tasks | Implemented | -**Coverage**: 16 requirements, 16 mapped to tasks, 0 unmapped. +**Coverage**: 19 requirements, 19 mapped to tasks, 0 unmapped. ## Success Criteria diff --git a/.specs/features/web-host/tasks.md b/.specs/features/web-host/tasks.md index a98429e..eb94614 100644 --- a/.specs/features/web-host/tasks.md +++ b/.specs/features/web-host/tasks.md @@ -30,7 +30,7 @@ Tasks run in order. Each task includes its tests, `tasks.md` status update, and its own commit. ```text -T01 -> T02 -> T03 -> T04 -> T05 -> T06 -> T07 -> T08 +T01 -> T02 -> T03 -> T04 -> T05 -> T06 -> T07 -> T08 -> T09 ``` ### Phase 1: Resolve, secure, and bind @@ -115,6 +115,18 @@ Tests: Add `tests/web-host-docs.test.ts` for required README and Portuguese `doc Gate: `npx vitest run --no-cache tests/web-host-docs.test.ts` Status: Complete +### Phase 4: Remediate review findings + +#### T09: Separate explicit and preferred host requests + +Where: `src/daemon/protocol.ts`, `src/daemon/web-supervisor.ts`, `src/daemon/daemon.ts`, and `src/cli/web-launch.ts` +Supporting files: `README.md`, `docs/protocol.md`, and `.specs/features/web-host/spec.md` +WH IDs: WH-09, WH-17, WH-18, WH-19 +Depends on: T08 +Tests: Update `tests/web-supervisor.test.ts`, `tests/daemon-web.test.ts`, `tests/web-launch.test.ts`, and `tests/web-host-docs.test.ts` for host provenance, config preferences, explicit overrides, host reuse, and documentation. +Gate: `npx vitest run --no-cache tests/web-supervisor.test.ts tests/daemon-web.test.ts tests/web-launch.test.ts tests/web-host-docs.test.ts` +Status: Complete + ## Task Completion Record Task completion and requirement traceability are updated in the same commit as each implementation task. The independent verifier owns `validation.md`. diff --git a/README.md b/README.md index eed12d5..08b693b 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,7 @@ The console listens on `127.0.0.1` by default. Set `web.host` in `config.json` t } ``` -`web.host` accepts IP addresses only. `codedeck ui --host <addr>` overrides the configured address for that launch. The CLI sends the selected address as the `host` parameter of `web.ensure`, and the daemon passes it to the child as `--host <addr>`. If `web.ensure` omits `host`, the supervisor uses `127.0.0.1`. A host change restarts the web child. +`web.host` accepts IP addresses only. `codedeck ui --host <addr>` explicitly binds that address for the launch. The CLI sends it as `host` to `web.ensure`; without the flag, it sends the resolved config value as `preferredHost`. The daemon passes the selected address to the child as `--host <addr>`. An explicit host change restarts the child. A preferred host moves a child started for a preferred host or with no host request, and never moves a child started for an explicit host. If both host fields are absent, the supervisor reuses a running child at any address and starts a new child on `127.0.0.1`. ## Open diff --git a/docs/protocol.md b/docs/protocol.md index 408ce33..774c573 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -47,9 +47,14 @@ Request: { "id": "w1", "method": "web.ensure", "params": { "host": "100.101.102.103", "preferredPort": 7777, "build": "1790000000000", "entry": "/abs/dist/web/child.js" } } ``` -- `host` (opcional): endereço IP usado para escutar. Vem de `web.host` ou de - `codedeck ui --host <addr>`; sem esse campo, usa `127.0.0.1`. Se o endereço - diferir do filho atual, o supervisor encerra o filho e inicia outro. +- `host` (opcional): endereço IP explícito, enviado por `codedeck ui --host + <addr>`. Se diferir do filho atual, o supervisor encerra o filho e inicia + outro nesse endereço. +- `preferredHost` (opcional): endereço resolvido de `web.host`, enviado quando + não há `ui --host`. Ele move um filho iniciado para uma preferência ou sem + pedido de host, mas não move um filho iniciado para um `host` explícito. + Quando ambos os campos faltam, um filho atual é reaproveitado em qualquer + endereço; se não houver filho, o supervisor inicia em `127.0.0.1`. - `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. diff --git a/src/cli/web-launch.ts b/src/cli/web-launch.ts index a8ffff2..922eb2f 100644 --- a/src/cli/web-launch.ts +++ b/src/cli/web-launch.ts @@ -59,7 +59,7 @@ export async function launchWebPage(options: LaunchWebPageOptions, deps: LaunchW } const params: WebEnsureParams = { - host, + ...(options.host === undefined ? { preferredHost: configuredHost } : { host: options.host }), ...(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)), diff --git a/src/daemon/daemon.ts b/src/daemon/daemon.ts index ff2a199..40b54ce 100644 --- a/src/daemon/daemon.ts +++ b/src/daemon/daemon.ts @@ -1519,7 +1519,7 @@ class Daemon { if (invalid !== undefined) appendDaemonLog(invalidWebPortMessage(invalid)); if (invalidHost !== undefined) appendDaemonLog(invalidWebHostMessage(invalidHost)); this.webHost() - .ensure({ preferredPort: port, host }) + .ensure({ preferredPort: port, preferredHost: host }) .catch((error: unknown) => appendDaemonLog(`web autostart failed: ${error instanceof Error ? error.message : String(error)}`)); } diff --git a/src/daemon/protocol.ts b/src/daemon/protocol.ts index ce3c8de..46de60e 100644 --- a/src/daemon/protocol.ts +++ b/src/daemon/protocol.ts @@ -234,8 +234,10 @@ export interface QueryUsageRequest { } export interface WebEnsureParams { - /** Bind address; omitted requests the default loopback host. */ + /** Explicit bind address from `ui --host`; omitted does not move a running child. */ host?: string; + /** Resolved `web.host`; only moves a child that was not started for an explicit host. */ + preferredHost?: string; /** 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. */ diff --git a/src/daemon/web-supervisor.ts b/src/daemon/web-supervisor.ts index 7649cfe..117ecda 100644 --- a/src/daemon/web-supervisor.ts +++ b/src/daemon/web-supervisor.ts @@ -47,10 +47,12 @@ export interface WebSupervisorOptions { /** 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" }; +type HostFor = "explicit" | "preferred" | "none"; interface RunningChild extends WebEnsureResult { child: WebChildProcess; host: string; + hostFor: HostFor; entry: string; build: string | undefined; startedFor: StartedFor; @@ -111,12 +113,14 @@ export class WebSupervisor { // 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 === "running" && this.matches(state.running, params)) return resultOf(state.running); + const running = state.kind === "running" ? state.running : undefined; + const requestedHost = this.requestedHost(running, params); + if (running && this.matches(running, params, requestedHost.host)) return resultOf(running); - const previous = state.kind === "running" ? state.running : undefined; + const previous = running; const promise = (async () => { if (previous) await this.stop(previous); - return this.start(params); + return this.start(params, requestedHost.host, requestedHost.hostFor); })(); this.state = { kind: "starting", promise }; promise.catch(() => { @@ -134,8 +138,17 @@ export class WebSupervisor { return path.isAbsolute(entry) && entry.endsWith("/web/child.js") && this.entryExists(entry); } - private matches(running: RunningChild, params: WebEnsureParams): boolean { - if (running.host !== (params.host ?? DEFAULT_WEB_HOST)) return false; + private requestedHost(running: RunningChild | undefined, params: WebEnsureParams): { host: string; hostFor: HostFor } { + if (params.host !== undefined) return { host: params.host, hostFor: "explicit" }; + if (params.preferredHost !== undefined && running?.hostFor !== "explicit") { + return { host: params.preferredHost, hostFor: "preferred" }; + } + if (running) return { host: running.host, hostFor: running.hostFor }; + return { host: DEFAULT_WEB_HOST, hostFor: "none" }; + } + + private matches(running: RunningChild, params: WebEnsureParams, requestedHost: string): boolean { + if (running.host !== requestedHost) return false; if (params.entry !== undefined && params.entry !== running.entry) return false; if (params.build !== undefined && ((params.entry ?? this.defaultEntry) !== running.entry || params.build !== running.build)) { return false; @@ -153,9 +166,8 @@ export class WebSupervisor { await exitsWithin(running.exited, this.stopTimeoutMs); } - private start(params: WebEnsureParams): Promise<WebEnsureResult> { + private start(params: WebEnsureParams, host: string, hostFor: HostFor): Promise<WebEnsureResult> { const entry = params.entry ?? this.defaultEntry; - const host = params.host ?? DEFAULT_WEB_HOST; const startedFor: StartedFor = params.port !== undefined ? { kind: "explicit", port: params.port } @@ -230,6 +242,7 @@ export class WebSupervisor { token: handshake.token, child, host, + hostFor, entry, build: handshake.build ?? params.build, startedFor, diff --git a/tests/daemon-web.test.ts b/tests/daemon-web.test.ts index d7787be..c0de19e 100644 --- a/tests/daemon-web.test.ts +++ b/tests/daemon-web.test.ts @@ -148,7 +148,7 @@ describe("daemon web autostart", () => { daemon.autostartWeb(); expect(host.ensure).toHaveBeenCalledTimes(1); - expect(host.ensure).toHaveBeenCalledWith({ preferredPort: 7788, host: "100.64.0.5" }); + expect(host.ensure).toHaveBeenCalledWith({ preferredPort: 7788, preferredHost: "100.64.0.5" }); }); it("logs a failed autostart and keeps serving web.ensure", async () => { @@ -161,7 +161,7 @@ describe("daemon web autostart", () => { 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, host: "127.0.0.1" }); + expect(hostEnsure).toHaveBeenNthCalledWith(1, { preferredPort: 7777, preferredHost: "127.0.0.1" }); expect((await ensure({})).result).toEqual({ baseUrl: "http://127.0.0.1:7777", port: 7777, token: "tok" }); }); @@ -173,7 +173,7 @@ describe("daemon web autostart", () => { daemon.autostartWeb(); expect(daemonLog()).toMatch(/\] Ignoring invalid web.port in config: "7788"\n/); - expect(host.ensure).toHaveBeenCalledWith({ preferredPort: 7777, host: "127.0.0.1" }); + expect(host.ensure).toHaveBeenCalledWith({ preferredPort: 7777, preferredHost: "127.0.0.1" }); }); it("logs an invalid web.host and autostarts on loopback", async () => { @@ -187,7 +187,7 @@ describe("daemon web autostart", () => { daemon.autostartWeb(); expect(daemonLog()).toMatch(/\] Ignoring invalid web\.host in config: "deck\.local"\n/); - expect(host.ensure).toHaveBeenCalledWith({ preferredPort: 7777, host: "127.0.0.1" }); + expect(host.ensure).toHaveBeenCalledWith({ preferredPort: 7777, preferredHost: "127.0.0.1" }); }); it("does not start the web child from start()", async () => { diff --git a/tests/web-host-docs.test.ts b/tests/web-host-docs.test.ts index e66dbd3..f921f70 100644 --- a/tests/web-host-docs.test.ts +++ b/tests/web-host-docs.test.ts @@ -17,6 +17,8 @@ describe("web host documentation", () => { expect(readme).toContain("`--host <addr>`"); expect(readme).toContain("`web.ensure`"); expect(readme).toContain("`host`"); + expect(readme).toContain("`preferredHost`"); + expect(readme).toContain("explicit host"); }); it("documents the host parameter in Portuguese in the protocol section", () => { @@ -27,5 +29,6 @@ describe("web host documentation", () => { expect(protocol).toContain("`web.host`"); expect(protocol).toContain("`--host <addr>`"); expect(protocol).toContain("`127.0.0.1`"); + expect(protocol).toContain("`preferredHost` (opcional)"); }); }); diff --git a/tests/web-launch.test.ts b/tests/web-launch.test.ts index 5bc0c37..5d33d5f 100644 --- a/tests/web-launch.test.ts +++ b/tests/web-launch.test.ts @@ -81,6 +81,8 @@ describe("launchWebPage", () => { expect(method).toBe("web.ensure"); expect(params.build).toBe("build-1"); expect(params.entry).toBe(path.join(REPO_ROOT, "src", "web", "child.js")); + expect(params).toMatchObject({ preferredHost: "127.0.0.1" }); + expect(params).not.toHaveProperty("host"); expect(t.startServer).not.toHaveBeenCalled(); }); @@ -131,9 +133,11 @@ describe("launchWebPage", () => { await t.launch({ host: "192.168.1.25", open: false }); expect(t.request.mock.calls.map(([, params]) => params)).toEqual([ - expect.objectContaining({ host: "100.64.0.5" }), + expect.objectContaining({ preferredHost: "100.64.0.5" }), expect.objectContaining({ host: "192.168.1.25" }), ]); + expect(t.request.mock.calls[0][1]).not.toHaveProperty("host"); + expect(t.request.mock.calls[1][1]).not.toHaveProperty("preferredHost"); expect(t.errors).toEqual([ "Warning: the console listens on 100.64.0.5 over plain HTTP. Anyone who can reach port 7777 with the link gets full access; use it only on a trusted network such as Tailscale.", "Warning: the console listens on 192.168.1.25 over plain HTTP. Anyone who can reach port 7777 with the link gets full access; use it only on a trusted network such as Tailscale.", @@ -146,7 +150,7 @@ describe("launchWebPage", () => { await t.launch({ open: false }); expect(t.errors).toEqual(['Ignoring invalid web.host in config: "deck.local"']); - expect(t.request.mock.calls[0][1]).toMatchObject({ host: "127.0.0.1" }); + expect(t.request.mock.calls[0][1]).toMatchObject({ preferredHost: "127.0.0.1" }); }); it("passes the resolved host to the in-process server", async () => { diff --git a/tests/web-supervisor.test.ts b/tests/web-supervisor.test.ts index 7397962..ff722f0 100644 --- a/tests/web-supervisor.test.ts +++ b/tests/web-supervisor.test.ts @@ -47,6 +47,13 @@ function harness(overrides: Partial<WebSupervisorOptions> = {}) { const ok = (port = 4100, token = "tok-1", build = "b1") => ({ port, token, build }); +async function running(t: ReturnType<typeof harness>, params: Parameters<WebSupervisor["ensure"]>[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; +} + afterEach(() => { vi.useRealTimers(); }); @@ -220,6 +227,42 @@ describe("WebSupervisor.ensure", () => { expect(t.children[0].signals).toEqual([]); }); + it("does not move an explicitly bound child for a preferred host", async () => { + const t = harness(); + const first = await running(t, { preferredPort: 7777, host: "0.0.0.0" }); + + await expect(t.supervisor.ensure({ preferredPort: 7777, preferredHost: "127.0.0.1" })).resolves.toEqual(first); + + expect(t.spawns).toHaveLength(1); + expect(t.children[0].signals).toEqual([]); + }); + + it.each([ + ["preferred", { preferredPort: 7777, preferredHost: "127.0.0.1" }], + ["none", { preferredPort: 7777 }], + ])("moves a child started for %s when its preferred host changes", async (_label, firstParams) => { + const t = harness(); + await running(t, firstParams); + + const second = t.supervisor.ensure({ preferredPort: 7777, preferredHost: "0.0.0.0" }); + await vi.waitFor(() => expect(t.children).toHaveLength(2)); + t.children[1].handshake(ok(4101, "tok-2")); + + await expect(second).resolves.toMatchObject({ port: 4101, token: "tok-2" }); + expect(t.children[0].signals).toEqual(["SIGTERM"]); + expect(t.spawns[1].args).toEqual(["--web-child", "--host", "0.0.0.0", "--preferred-port", "7777"]); + }); + + it("reuses the running host when neither host field is supplied", async () => { + const t = harness(); + const first = await running(t, { host: "100.64.0.5" }); + + await expect(t.supervisor.ensure({})).resolves.toEqual(first); + + expect(t.spawns).toHaveLength(1); + expect(t.children[0].signals).toEqual([]); + }); + it("stops an old build with SIGTERM, escalates to SIGKILL after the stop timeout, and returns the new child", async () => { vi.useFakeTimers(); const { supervisor, children } = harness(); @@ -253,13 +296,6 @@ describe("WebSupervisor.ensure", () => { }); describe("WebSupervisor port rules", () => { - async function running(t: ReturnType<typeof harness>, params: Parameters<WebSupervisor["ensure"]>[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(); From 7b2357f5f2b0d742e948cb652f6f5342c67a6e9b Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Fri, 25 Sep 2026 02:12:36 -0300 Subject: [PATCH 10/15] fix(web): Redirect localhost on wildcard console binds Keep localhost bookmarks on the canonical loopback URL for wildcard binds while skipping the redirect for specific hosts. Co-Authored-By: Codex --- .specs/features/web-host/run-notes.md | 1 + .specs/features/web-host/spec.md | 4 +++- .specs/features/web-host/tasks.md | 12 ++++++++++- docs/protocol.md | 6 ++++-- src/web/security.ts | 4 ++-- tests/web-host-docs.test.ts | 2 ++ tests/web-security.test.ts | 29 ++++++++++++++++++++++++++- 7 files changed, 51 insertions(+), 7 deletions(-) diff --git a/.specs/features/web-host/run-notes.md b/.specs/features/web-host/run-notes.md index 8beb179..35dc6f1 100644 --- a/.specs/features/web-host/run-notes.md +++ b/.specs/features/web-host/run-notes.md @@ -25,3 +25,4 @@ | 21 | Print the plain HTTP warning after a listener succeeds. | Decision | A preferred port may fall back to an OS-assigned port, so the warning must name the actual listening port. | | 22 | Separate explicit and preferred web host requests. | Bucket 1 decision | Finding R1: an explicit `ui --host` bind pins its child, `web.host` is a preference, and omitted host fields reuse a running child or start on loopback. | | 23 | Capitalize commit subjects after the Conventional Commit prefix. | Decision | The remediation follows repository history; `check_commit.py` requires lowercase subjects, so the repository convention takes precedence as requested. | +| 24 | Redirect localhost to loopback for binds that advertise the loopback base URL. | Bucket 1 decision | Finding R2: `127.0.0.1`, `0.0.0.0`, and `::` share the canonical `127.0.0.1` URL; other bind addresses skip this redirect. | diff --git a/.specs/features/web-host/spec.md b/.specs/features/web-host/spec.md index 859aac0..8f0c224 100644 --- a/.specs/features/web-host/spec.md +++ b/.specs/features/web-host/spec.md @@ -77,6 +77,7 @@ The web console currently binds to `127.0.0.1` and only accepts loopback Host he 6. WHEN `web.ensure` supplies a `preferredHost` different from the running child's host AND the child was started for a preferred host or with neither host field THEN the supervisor SHALL stop that child and start a child on `preferredHost`. <!-- WH-17 --> 7. WHEN `web.ensure` supplies a `preferredHost` AND the running child was started for an explicit host THEN the supervisor SHALL keep that explicit bind host. <!-- WH-18 --> 8. WHEN `web.ensure` supplies neither `host` nor `preferredHost` THEN the supervisor SHALL reuse a running child regardless of its bind host, and SHALL start a new child on `127.0.0.1` when none is running. <!-- WH-19 --> +9. WHEN a page GET uses `Host: localhost:<port>` and the bind host is `127.0.0.1`, `0.0.0.0`, or `::` THEN the server SHALL respond with `302` to `http://127.0.0.1:<port>`; for any other bind host it SHALL skip this canonical redirect. <!-- WH-20 --> **Independent Test**: Bind to a wildcard address, send requests with a local interface Host, a hostile Host, and tokenized or untokenized page URLs, and verify the exact response behavior. @@ -145,8 +146,9 @@ The web console currently binds to `127.0.0.1` and only accepts loopback Host he | WH-17 | P1: Preserve the console's request protections | Tasks | Implemented | | WH-18 | P1: Preserve the console's request protections | Tasks | Implemented | | WH-19 | P1: Preserve the console's request protections | Tasks | Implemented | +| WH-20 | P1: Preserve the console's request protections | Tasks | Implemented | -**Coverage**: 19 requirements, 19 mapped to tasks, 0 unmapped. +**Coverage**: 20 requirements, 20 mapped to tasks, 0 unmapped. ## Success Criteria diff --git a/.specs/features/web-host/tasks.md b/.specs/features/web-host/tasks.md index eb94614..8850cf2 100644 --- a/.specs/features/web-host/tasks.md +++ b/.specs/features/web-host/tasks.md @@ -30,7 +30,7 @@ Tasks run in order. Each task includes its tests, `tasks.md` status update, and its own commit. ```text -T01 -> T02 -> T03 -> T04 -> T05 -> T06 -> T07 -> T08 -> T09 +T01 -> T02 -> T03 -> T04 -> T05 -> T06 -> T07 -> T08 -> T09 -> T10 ``` ### Phase 1: Resolve, secure, and bind @@ -127,6 +127,16 @@ Tests: Update `tests/web-supervisor.test.ts`, `tests/daemon-web.test.ts`, `tests Gate: `npx vitest run --no-cache tests/web-supervisor.test.ts tests/daemon-web.test.ts tests/web-launch.test.ts tests/web-host-docs.test.ts` Status: Complete +#### T10: Keep canonical localhost redirects on wildcard binds + +Where: `src/web/security.ts` +Supporting files: `docs/protocol.md` and `.specs/features/web-host/spec.md` +WH IDs: WH-20 +Depends on: T09 +Tests: Update `tests/web-security.test.ts` and `tests/web-host-docs.test.ts` for wildcard and specific bind redirect behavior. +Gate: `npx vitest run --no-cache tests/web-security.test.ts tests/web-host-docs.test.ts` +Status: Complete + ## Task Completion Record Task completion and requirement traceability are updated in the same commit as each implementation task. The independent verifier owns `validation.md`. diff --git a/docs/protocol.md b/docs/protocol.md index 774c573..9c02ee6 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -77,8 +77,10 @@ A página abre em `<baseUrl><path>?<query>&t=<token>`. O token vira cookie (`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:<port>` responde `302` para `127.0.0.1:<port>`, -porque o cookie de `127.0.0.1` não vai para `localhost`. +Uma página pedida em `localhost:<port>` responde `302` para `127.0.0.1:<port>` +quando o bind é `127.0.0.1`, `0.0.0.0` ou `::`, que anunciam a URL canônica de +loopback. O servidor não faz esse redirecionamento para outros binds específicos. +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 diff --git a/src/web/security.ts b/src/web/security.ts index 8c5d542..7c69223 100644 --- a/src/web/security.ts +++ b/src/web/security.ts @@ -2,7 +2,7 @@ import { randomBytes } from "node:crypto"; import { isIP } from "node:net"; import os from "node:os"; import type { IncomingMessage, ServerResponse } from "node:http"; -import { DEFAULT_WEB_HOST } from "../config/web-host.js"; +import { DEFAULT_WEB_HOST, webBaseUrl } from "../config/web-host.js"; export interface WebSecurity { port: number; @@ -180,7 +180,7 @@ function redirectToCanonicalHost( response: ServerResponse, security: WebSecurity, ): boolean { - if (security.host !== DEFAULT_WEB_HOST) return false; + if (webBaseUrl(security.host, security.port) !== `http://${DEFAULT_WEB_HOST}:${security.port}`) return false; if (request.headers.host?.toLowerCase() !== `localhost:${security.port}`) return false; let url: URL; try { diff --git a/tests/web-host-docs.test.ts b/tests/web-host-docs.test.ts index f921f70..bccba9a 100644 --- a/tests/web-host-docs.test.ts +++ b/tests/web-host-docs.test.ts @@ -30,5 +30,7 @@ describe("web host documentation", () => { expect(protocol).toContain("`--host <addr>`"); expect(protocol).toContain("`127.0.0.1`"); expect(protocol).toContain("`preferredHost` (opcional)"); + expect(protocol).toContain("`0.0.0.0`"); + expect(protocol).toContain("`::`"); }); }); diff --git a/tests/web-security.test.ts b/tests/web-security.test.ts index 902e617..97fd7a2 100644 --- a/tests/web-security.test.ts +++ b/tests/web-security.test.ts @@ -48,7 +48,7 @@ function request( }); } -async function makeServer(): Promise<{ handle: WebServerHandle; calls: string[] }> { +async function makeServer(listenHost = "127.0.0.1"): Promise<{ handle: WebServerHandle; calls: string[] }> { const calls: string[] = []; const routes: WebRoute[] = [ { @@ -72,6 +72,7 @@ async function makeServer(): Promise<{ handle: WebServerHandle; calls: string[] ]; const handle = await startWebServer({ routes, + host: listenHost, port: 0, initialPath: "/page", open: false, @@ -272,6 +273,32 @@ describe("web request security", () => { expect(calls).toEqual([]); }); + it("redirects localhost to 127.0.0.1 for a wildcard bind", async () => { + const { handle } = await makeServer("0.0.0.0"); + + const response = await request(handle, { + path: "/page?repo=%2Fx", + host: `localhost:${handle.port}`, + }); + + expect(response.status).toBe(302); + expect(response.headers.location).toBe(`http://127.0.0.1:${handle.port}/page?repo=%2Fx`); + }); + + it("does not redirect localhost to 127.0.0.1 for a specific non-loopback bind", async () => { + const networkInterfaces = vi.fn(() => makeInterfaces([{ address: "100.101.102.103", family: "IPv4" }])); + const { handle } = await makeExtendedServer(networkInterfaces); + + const response = await request(handle, { + path: "/page", + host: `localhost:${handle.port}`, + }); + + expect(response.status).toBe(403); + expect(response.body).toBe(PAGE_FORBIDDEN); + expect(response.headers.location).toBeUndefined(); + }); + 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 da62dc66e18b41cd8cf2ff1930909ec6bef312e1 Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Fri, 25 Sep 2026 02:17:06 -0300 Subject: [PATCH 11/15] fix(web): Restrict accepted console Host values Accept the selected bind IP even if interface enumeration omits it, and limit machine hostname suffixes to Tailscale MagicDNS names. Co-Authored-By: Codex --- .specs/features/web-host/run-notes.md | 3 ++ .specs/features/web-host/spec.md | 7 ++-- .specs/features/web-host/tasks.md | 14 ++++++- README.md | 2 + docs/protocol.md | 7 ++++ src/web/security.ts | 15 ++++++- tests/web-host-docs.test.ts | 2 + tests/web-security.test.ts | 57 +++++++++++++++++++++++---- 8 files changed, 93 insertions(+), 14 deletions(-) diff --git a/.specs/features/web-host/run-notes.md b/.specs/features/web-host/run-notes.md index 35dc6f1..312e4a8 100644 --- a/.specs/features/web-host/run-notes.md +++ b/.specs/features/web-host/run-notes.md @@ -26,3 +26,6 @@ | 22 | Separate explicit and preferred web host requests. | Bucket 1 decision | Finding R1: an explicit `ui --host` bind pins its child, `web.host` is a preference, and omitted host fields reuse a running child or start on loopback. | | 23 | Capitalize commit subjects after the Conventional Commit prefix. | Decision | The remediation follows repository history; `check_commit.py` requires lowercase subjects, so the repository convention takes precedence as requested. | | 24 | Redirect localhost to loopback for binds that advertise the loopback base URL. | Bucket 1 decision | Finding R2: `127.0.0.1`, `0.0.0.0`, and `::` share the canonical `127.0.0.1` URL; other bind addresses skip this redirect. | +| 25 | Accept the configured non-wildcard bind address as a trusted Host. | Bucket 1 decision | Finding R3: the configured bind IP is trusted even when `os.networkInterfaces()` does not list it. | +| 26 | Restrict hostname suffixes to Tailscale MagicDNS FQDNs. | Bucket 1 decision | Finding R4: accept the exact machine hostname or one or more labels followed by `.ts.net`; reject broader suffix matches. | +| 27 | Keep default-loopback Host checks explicit in regression tests. | Bucket 1 decision | Finding T1: injected machine hostnames and non-loopback interface IPs remain forbidden when the bind is `127.0.0.1`. | diff --git a/.specs/features/web-host/spec.md b/.specs/features/web-host/spec.md index 8f0c224..16fb7be 100644 --- a/.specs/features/web-host/spec.md +++ b/.specs/features/web-host/spec.md @@ -70,7 +70,7 @@ The web console currently binds to `127.0.0.1` and only accepts loopback Host he **Acceptance Criteria**: 1. WHILE the bind host is `127.0.0.1` THEN the server SHALL allow exactly `127.0.0.1:<port>` and `localhost:<port>` as Host headers. <!-- WH-11 --> -2. WHILE the bind host is anything other than `127.0.0.1` THEN the server SHALL also allow case-insensitive Host `<name>:<port>` values for current local interface addresses, `os.hostname()`, and names beginning with `os.hostname() + "."`; interface addresses SHALL be evaluated per request through an injectable seam, IPv6 addresses SHALL use brackets, and addresses with a `%` zone SHALL be skipped. <!-- WH-12 --> +2. WHILE the bind host is anything other than `127.0.0.1` THEN the server SHALL allow the exact non-wildcard bind address, current local interface addresses, the exact `os.hostname()`, and `<hostname>.<one or more labels>.ts.net` Host names with the exact console port; names such as `<hostname>.evil.com` SHALL be rejected, interface addresses SHALL be evaluated per request through an injectable seam, IPv6 addresses SHALL use brackets, and addresses with a `%` zone SHALL be skipped. <!-- WH-12 --> 3. WHEN a Host header is accepted THEN the server SHALL apply the existing token, cookie, same-origin POST, and `/api/*` checks unchanged; a page GET on a Tailscale IP without a token or cookie SHALL return the existing 403 page body, and a GET with the correct `t` token SHALL return 303 and set the session cookie. <!-- WH-13 --> 4. WHEN `web.ensure` supplies an explicit `host` different from the running child's host THEN the supervisor SHALL stop that child and start a child on the requested host; an omitted `host` SHALL NOT request a move to `127.0.0.1`, and existing port reuse rules SHALL remain unchanged. <!-- WH-09 --> 5. WHEN the bind host is `127.0.0.1`, `0.0.0.0`, or `::` THEN the supervisor and `listenWebServer` SHALL return `http://127.0.0.1:<port>` as the base URL; for any other host they SHALL return `http://<host>:<port>`, with IPv6 in brackets. <!-- WH-10 --> @@ -105,7 +105,8 @@ The web console currently binds to `127.0.0.1` and only accepts loopback Host he - A child started with no `--host` must retain the current loopback behavior. - A host change must restart a running child even when its port selection otherwise permits reuse. - Host headers with an unlisted name or a wrong port must remain forbidden. -- Hostname suffix matching is limited to names beginning with the machine hostname followed by a dot. +- Hostname matching is limited to the exact machine hostname or one or more labels followed by `.ts.net`. +- A non-wildcard bind address remains accepted as a Host even if it is absent from the current interface list. - IPv6 zone-scoped interface addresses must not enter the allowlist. - A failed `--host` validation must occur before starting or contacting the daemon. @@ -138,7 +139,7 @@ The web console currently binds to `127.0.0.1` and only accepts loopback Host he | WH-09 | P1: Preserve the console's request protections | Tasks | Implemented | | WH-10 | P1: Preserve the console's request protections | Tasks | Verified | | WH-11 | P1: Preserve the console's request protections | Tasks | Verified | -| WH-12 | P1: Preserve the console's request protections | Tasks | Verified | +| WH-12 | P1: Preserve the console's request protections | Tasks | Implemented | | WH-13 | P1: Preserve the console's request protections | Tasks | Verified | | WH-14 | P2: Print usable links and bind errors | Tasks | Verified | | WH-15 | P2: Print usable links and bind errors | Tasks | Verified | diff --git a/.specs/features/web-host/tasks.md b/.specs/features/web-host/tasks.md index 8850cf2..e57aa92 100644 --- a/.specs/features/web-host/tasks.md +++ b/.specs/features/web-host/tasks.md @@ -7,7 +7,7 @@ | Code Layer | Required Test Type | Coverage Expectation | Test File | Vitest Command | | ---------- | ------------------ | -------------------- | ---------- | -------------- | | config | unit | WH-01 through WH-03: default, accepted IPv4/IPv6, invalid values, and exact warning text | `tests/web-port.test.ts` | `npx vitest run --no-cache tests/web-port.test.ts` | -| web security | integration | WH-11 through WH-13: exact loopback allowlist, dynamic interfaces and hostnames, hostile Host rejection, token and cookie behavior, and same-origin POST checks | `tests/web-security.test.ts` | `npx vitest run --no-cache tests/web-security.test.ts` | +| web security | integration | WH-11 through WH-13: exact loopback allowlist, bound-address acceptance, dynamic interfaces and MagicDNS hostnames, hostile Host rejection, token and cookie behavior, and same-origin POST checks | `tests/web-security.test.ts` | `npx vitest run --no-cache tests/web-security.test.ts` | | web server | integration | WH-07 and WH-10: bind host, default host, returned base URL, and IPv6 URL formatting | `tests/web-server.test.ts` | `npx vitest run --no-cache tests/web-server.test.ts` | | web child | unit | WH-06: parse explicit host and default to loopback, then pass the host into the listener | `tests/web-child.test.ts` | `npx vitest run --no-cache tests/web-child.test.ts` | | daemon supervisor | unit | WH-09 and WH-10: pass host argument, restart on host changes, reuse matching host, and return the host-specific base URL | `tests/web-supervisor.test.ts` | `npx vitest run --no-cache tests/web-supervisor.test.ts` | @@ -30,7 +30,7 @@ Tasks run in order. Each task includes its tests, `tasks.md` status update, and its own commit. ```text -T01 -> T02 -> T03 -> T04 -> T05 -> T06 -> T07 -> T08 -> T09 -> T10 +T01 -> T02 -> T03 -> T04 -> T05 -> T06 -> T07 -> T08 -> T09 -> T10 -> T11 ``` ### Phase 1: Resolve, secure, and bind @@ -137,6 +137,16 @@ Tests: Update `tests/web-security.test.ts` and `tests/web-host-docs.test.ts` for Gate: `npx vitest run --no-cache tests/web-security.test.ts tests/web-host-docs.test.ts` Status: Complete +#### T11: Restrict trusted console Host values + +Where: `src/web/security.ts` +Supporting files: `README.md`, `docs/protocol.md`, and `.specs/features/web-host/spec.md` +WH IDs: WH-11, WH-12 +Depends on: T10 +Tests: Update `tests/web-security.test.ts` and `tests/web-host-docs.test.ts` for unlisted bind addresses, MagicDNS-only suffixes, hostile hostname prefixes, and default loopback rejection. +Gate: `npx vitest run --no-cache tests/web-security.test.ts tests/web-host-docs.test.ts` +Status: Complete + ## Task Completion Record Task completion and requirement traceability are updated in the same commit as each implementation task. The independent verifier owns `validation.md`. diff --git a/README.md b/README.md index 08b693b..22ca188 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,8 @@ The console listens on `127.0.0.1` by default. Set `web.host` in `config.json` t `web.host` accepts IP addresses only. `codedeck ui --host <addr>` explicitly binds that address for the launch. The CLI sends it as `host` to `web.ensure`; without the flag, it sends the resolved config value as `preferredHost`. The daemon passes the selected address to the child as `--host <addr>`. An explicit host change restarts the child. A preferred host moves a child started for a preferred host or with no host request, and never moves a child started for an explicit host. If both host fields are absent, the supervisor reuses a running child at any address and starts a new child on `127.0.0.1`. +For a specific bind address, the console accepts that address in the request `Host` with the exact console port, even when it is missing from the system's interface list. Other accepted hosts on non-loopback binds are current interface IPs, the exact `os.hostname()`, and Tailscale MagicDNS names in the form `<hostname>.<label>.ts.net`, with one or more labels allowed. Names such as `<hostname>.evil.com` are rejected. The default `127.0.0.1` bind continues to accept only `127.0.0.1` and `localhost`. + ## Open `codedeck open` launches a session on the harness bound to the role in `codedeck setup`: Claude Code with the CodeDeck plugin, an appended system prompt, Opus 4.8 at `xhigh` effort, and permissions bypassed; or the opencode TUI with the role contract injected, `--auto` on, and a stock look. Nothing is written to `~/.claude/` or `~/.config/opencode`; everything loads for that session only. diff --git a/docs/protocol.md b/docs/protocol.md index 9c02ee6..dc14f91 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -82,6 +82,13 @@ quando o bind é `127.0.0.1`, `0.0.0.0` ou `::`, que anunciam a URL canônica de loopback. O servidor não faz esse redirecionamento para outros binds específicos. O cookie de `127.0.0.1` não vai para `localhost`. +Em um bind específico fora do loopback padrão, o `Host` também aceita o IP do +bind, mesmo que ele não apareça nas interfaces do sistema. Para binds não +loopback, aceita ainda IPs atuais das interfaces, o `os.hostname()` exato e +nomes Tailscale no formato `<hostname>.<label>.ts.net`, com um ou mais labels. +Outros sufixos são recusados. O bind padrão continua aceitando apenas +`127.0.0.1` e `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: diff --git a/src/web/security.ts b/src/web/security.ts index 7c69223..057e5aa 100644 --- a/src/web/security.ts +++ b/src/web/security.ts @@ -54,7 +54,8 @@ export function isAllowedWebHost(host: string | undefined, port: number, securit if (!name || name.includes("%")) return false; const normalizedName = name.toLowerCase(); const hostname = security.hostname().toLowerCase(); - if (normalizedName === hostname || normalizedName.startsWith(`${hostname}.`)) return true; + if (security.host !== "0.0.0.0" && security.host !== "::" && normalizedName === security.host.toLowerCase()) return true; + if (isTrustedMachineHostname(normalizedName, hostname)) return true; for (const entries of Object.values(security.networkInterfaces())) { for (const entry of entries ?? []) { @@ -65,6 +66,18 @@ export function isAllowedWebHost(host: string | undefined, port: number, securit return false; } +function isTrustedMachineHostname(name: string, hostname: string): boolean { + if (name === hostname) return true; + if (!hostname || !name.startsWith(`${hostname}.`)) return false; + const labels = name.slice(hostname.length + 1).split("."); + return ( + labels.length >= 3 && + labels[labels.length - 2] === "ts" && + labels[labels.length - 1] === "net" && + labels.slice(0, -2).every((label) => /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(label)) + ); +} + function hostHeaderName(host: string, port: number): string | undefined { const suffix = `:${port}`; if (!host.toLowerCase().endsWith(suffix)) return undefined; diff --git a/tests/web-host-docs.test.ts b/tests/web-host-docs.test.ts index bccba9a..18e56a7 100644 --- a/tests/web-host-docs.test.ts +++ b/tests/web-host-docs.test.ts @@ -19,6 +19,8 @@ describe("web host documentation", () => { expect(readme).toContain("`host`"); expect(readme).toContain("`preferredHost`"); expect(readme).toContain("explicit host"); + expect(readme).toContain("os.hostname()"); + expect(readme).toContain(".ts.net"); }); it("documents the host parameter in Portuguese in the protocol section", () => { diff --git a/tests/web-security.test.ts b/tests/web-security.test.ts index 97fd7a2..cae2669 100644 --- a/tests/web-security.test.ts +++ b/tests/web-security.test.ts @@ -2,6 +2,7 @@ import http from "node:http"; import os from "node:os"; import { EventEmitter } from "node:events"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { webBaseUrl } from "../src/config/web-host.js"; import { createWebServer, startWebServer, type WebRoute, type WebServerHandle } from "../src/web/server.js"; import { createWebSecurity, type WebSecurity } from "../src/web/security.js"; @@ -20,7 +21,7 @@ afterEach(async () => { function request( handle: WebServerHandle, - options: { method?: string; path: string; host?: string; origin?: string; cookie?: string }, + options: { method?: string; path: string; host?: string; origin?: string; cookie?: string; connectHost?: string }, ): Promise<ResponseValue> { return new Promise((resolve, reject) => { const headers: Record<string, string> = {}; @@ -29,7 +30,7 @@ function request( if (options.cookie !== undefined) headers.cookie = options.cookie; const req = http.request( { - hostname: "127.0.0.1", + hostname: options.connectHost ?? "127.0.0.1", port: handle.port, path: options.path, method: options.method ?? "GET", @@ -103,6 +104,7 @@ function makeInterfaces( async function makeExtendedServer( networkInterfaces: typeof os.networkInterfaces, hostname: typeof os.hostname = () => "deck-host", + options: { host?: string; listenHost?: string } = {}, ): Promise<{ handle: WebServerHandle; calls: string[] }> { const calls: string[] = []; const routes: WebRoute[] = [ @@ -129,7 +131,7 @@ async function makeExtendedServer( const server = createWebServer({ routes, getSecurity: () => security }); await new Promise<void>((resolve, reject) => { server.once("error", reject); - server.listen(0, "127.0.0.1", () => { + server.listen(0, options.listenHost ?? "127.0.0.1", () => { server.off("error", reject); resolve(); }); @@ -137,7 +139,7 @@ async function makeExtendedServer( const address = server.address(); if (!address || typeof address === "string") throw new Error("no TCP address"); security = createWebSecurity(address.port, "test-token", { - host: "100.101.102.103", + host: options.host ?? "100.101.102.103", networkInterfaces, hostname, }); @@ -145,8 +147,8 @@ async function makeExtendedServer( server, address, port: address.port, - baseUrl: `http://127.0.0.1:${address.port}`, - initialUrl: `http://127.0.0.1:${address.port}/page?t=test-token`, + baseUrl: webBaseUrl(options.host ?? "100.101.102.103", address.port), + initialUrl: `${webBaseUrl(options.host ?? "100.101.102.103", address.port)}/page?t=test-token`, security, close: () => new Promise<void>((resolve) => server.close(() => resolve())), }; @@ -372,7 +374,7 @@ describe("web request security", () => { `100.101.102.103:${port}`, `[fd7a:115c:a1e0::1]:${port}`, `deck-host:${port}`, - `DECK-HOST.tailnet.ts.net:${port}`, + `DECK-HOST.tail1234.ts.net:${port}`, ]) { const response = await request(handle, { path: tokenPath, host }); expect(response.status).toBe(303); @@ -381,6 +383,8 @@ describe("web request security", () => { for (const host of [ `evil.example:${port}`, + `deck-host.evil.com:${port}`, + `deck-hostile:${port}`, `100.101.102.103:${port + 1}`, `[deck-host]:${port}`, `[fe80::1%tailscale0]:${port}`, @@ -393,10 +397,47 @@ describe("web request security", () => { } }); + it("accepts the specific bind address when it is missing from local interfaces", async () => { + const networkInterfaces = vi.fn(() => makeInterfaces([])); + const { handle } = await makeExtendedServer(networkInterfaces, () => "deck-host", { + host: "127.0.0.2", + listenHost: "127.0.0.2", + }); + const link = new URL("/page", handle.baseUrl); + link.searchParams.set("t", handle.security.token); + + const response = await request(handle, { + path: `${link.pathname}${link.search}`, + host: `127.0.0.2:${handle.port}`, + connectHost: "127.0.0.2", + }); + + expect(response.status).toBe(303); + expect(response.headers.location).toBe("/page"); + }); + + it("rejects machine hostname and non-loopback interface Hosts on the default bind", async () => { + const { handle, calls } = await makeServer(); + handle.security.hostname = () => "deck-host"; + handle.security.networkInterfaces = () => makeInterfaces([{ address: "192.168.1.25", family: "IPv4" }]); + + const hostname = await request(handle, { path: `/page?t=${handle.security.token}`, host: `deck-host:${handle.port}` }); + const interfaceAddress = await request(handle, { + path: `/page?t=${handle.security.token}`, + host: `192.168.1.25:${handle.port}`, + }); + + expect(hostname.status).toBe(403); + expect(hostname.body).toBe("forbidden"); + expect(interfaceAddress.status).toBe(403); + expect(interfaceAddress.body).toBe("forbidden"); + expect(calls).toEqual([]); + }); + it("evaluates interface addresses on each request", async () => { let interfaces = makeInterfaces([{ address: "100.101.102.103", family: "IPv4" }]); const networkInterfaces = vi.fn(() => interfaces); - const { handle } = await makeExtendedServer(networkInterfaces); + const { handle } = await makeExtendedServer(networkInterfaces, () => "deck-host", { host: "100.101.102.99" }); const tokenPath = `/page?t=${handle.security.token}`; const beforeChange = await request(handle, { path: tokenPath, host: `100.101.102.103:${handle.port}` }); From f21994596058aa29d720eaf7721c3a22f5b519c3 Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Fri, 25 Sep 2026 02:22:40 -0300 Subject: [PATCH 12/15] fix(daemon): Reject a non-IP console host in web.ensure Validate both host request fields before the supervisor changes a running child. Co-Authored-By: Codex --- .specs/features/web-host/run-notes.md | 1 + .specs/features/web-host/spec.md | 11 +++++++---- .specs/features/web-host/tasks.md | 14 ++++++++++++-- docs/protocol.md | 1 + src/daemon/web-supervisor.ts | 10 +++++++++- tests/daemon-web.test.ts | 17 +++++++++++++++++ tests/web-host-docs.test.ts | 1 + tests/web-supervisor.test.ts | 13 +++++++++++++ 8 files changed, 61 insertions(+), 7 deletions(-) diff --git a/.specs/features/web-host/run-notes.md b/.specs/features/web-host/run-notes.md index 312e4a8..1c492e8 100644 --- a/.specs/features/web-host/run-notes.md +++ b/.specs/features/web-host/run-notes.md @@ -29,3 +29,4 @@ | 25 | Accept the configured non-wildcard bind address as a trusted Host. | Bucket 1 decision | Finding R3: the configured bind IP is trusted even when `os.networkInterfaces()` does not list it. | | 26 | Restrict hostname suffixes to Tailscale MagicDNS FQDNs. | Bucket 1 decision | Finding R4: accept the exact machine hostname or one or more labels followed by `.ts.net`; reject broader suffix matches. | | 27 | Keep default-loopback Host checks explicit in regression tests. | Bucket 1 decision | Finding T1: injected machine hostnames and non-loopback interface IPs remain forbidden when the bind is `127.0.0.1`. | +| 28 | Validate web.ensure host fields before changing child state. | Bucket 1 decision | Finding R5: reject non-IP `host` and `preferredHost` values with `WEB_BAD_HOST` before stopping or starting a child. | diff --git a/.specs/features/web-host/spec.md b/.specs/features/web-host/spec.md index 16fb7be..0a195c4 100644 --- a/.specs/features/web-host/spec.md +++ b/.specs/features/web-host/spec.md @@ -78,6 +78,7 @@ The web console currently binds to `127.0.0.1` and only accepts loopback Host he 7. WHEN `web.ensure` supplies a `preferredHost` AND the running child was started for an explicit host THEN the supervisor SHALL keep that explicit bind host. <!-- WH-18 --> 8. WHEN `web.ensure` supplies neither `host` nor `preferredHost` THEN the supervisor SHALL reuse a running child regardless of its bind host, and SHALL start a new child on `127.0.0.1` when none is running. <!-- WH-19 --> 9. WHEN a page GET uses `Host: localhost:<port>` and the bind host is `127.0.0.1`, `0.0.0.0`, or `::` THEN the server SHALL respond with `302` to `http://127.0.0.1:<port>`; for any other bind host it SHALL skip this canonical redirect. <!-- WH-20 --> +10. IF `web.ensure` supplies a `host` or `preferredHost` that `net.isIP` rejects THEN the supervisor SHALL return `WEB_BAD_HOST` before stopping a running child. <!-- WH-21 --> **Independent Test**: Bind to a wildcard address, send requests with a local interface Host, a hostile Host, and tokenized or untokenized page URLs, and verify the exact response behavior. @@ -109,20 +110,21 @@ The web console currently binds to `127.0.0.1` and only accepts loopback Host he - A non-wildcard bind address remains accepted as a Host even if it is absent from the current interface list. - IPv6 zone-scoped interface addresses must not enter the allowlist. - A failed `--host` validation must occur before starting or contacting the daemon. +- Invalid `web.ensure` host fields must be rejected before a running child is stopped. ## Implicit-requirement sweep | Dimension | Resolution | | --- | --- | -| Input validation and bounds | WH-02, WH-03, and WH-05 require `net.isIP` validation. | -| Failure and partial-failure states | WH-03, WH-05, and WH-16 define invalid config, invalid flag, and listen failure outcomes. | +| Input validation and bounds | WH-02, WH-03, WH-05, and WH-21 require `net.isIP` validation. | +| Failure and partial-failure states | WH-03, WH-05, WH-16, and WH-21 define invalid config, invalid host requests, and listen failure outcomes. | | Idempotency and retry | WH-09 and WH-17 through WH-19 define child reuse and restart behavior by host request and start origin. | | Auth boundaries and rate limits | WH-11 through WH-13 keep the existing Host and token checks; rate limiting is N/A because this feature does not change request authorization behavior. | | Concurrency and ordering | WH-09 and WH-17 use the existing single-child supervisor transition when the requested host changes. | | Data lifecycle and expiry | N/A because the feature adds no persisted data. | | Observability | WH-03 reports invalid config, WH-15 warns about reachable plain HTTP, and WH-16 reports the listen host. | | External-dependency failure | WH-16 covers OS listen failures; no new external service is introduced. | -| State-transition integrity | WH-09 and WH-17 through WH-19 define host transitions and reuse. | +| State-transition integrity | WH-09, WH-17 through WH-19, and WH-21 define host transitions, reuse, and validation order. | ## Requirement Traceability @@ -148,8 +150,9 @@ The web console currently binds to `127.0.0.1` and only accepts loopback Host he | WH-18 | P1: Preserve the console's request protections | Tasks | Implemented | | WH-19 | P1: Preserve the console's request protections | Tasks | Implemented | | WH-20 | P1: Preserve the console's request protections | Tasks | Implemented | +| WH-21 | P1: Preserve the console's request protections | Tasks | Implemented | -**Coverage**: 20 requirements, 20 mapped to tasks, 0 unmapped. +**Coverage**: 21 requirements, 21 mapped to tasks, 0 unmapped. ## Success Criteria diff --git a/.specs/features/web-host/tasks.md b/.specs/features/web-host/tasks.md index e57aa92..a359500 100644 --- a/.specs/features/web-host/tasks.md +++ b/.specs/features/web-host/tasks.md @@ -10,7 +10,7 @@ | web security | integration | WH-11 through WH-13: exact loopback allowlist, bound-address acceptance, dynamic interfaces and MagicDNS hostnames, hostile Host rejection, token and cookie behavior, and same-origin POST checks | `tests/web-security.test.ts` | `npx vitest run --no-cache tests/web-security.test.ts` | | web server | integration | WH-07 and WH-10: bind host, default host, returned base URL, and IPv6 URL formatting | `tests/web-server.test.ts` | `npx vitest run --no-cache tests/web-server.test.ts` | | web child | unit | WH-06: parse explicit host and default to loopback, then pass the host into the listener | `tests/web-child.test.ts` | `npx vitest run --no-cache tests/web-child.test.ts` | -| daemon supervisor | unit | WH-09 and WH-10: pass host argument, restart on host changes, reuse matching host, and return the host-specific base URL | `tests/web-supervisor.test.ts` | `npx vitest run --no-cache tests/web-supervisor.test.ts` | +| daemon supervisor | unit | WH-09, WH-10, and WH-21: pass host argument, apply host provenance, reject invalid hosts before stopping a child, and return the host-specific base URL | `tests/web-supervisor.test.ts` | `npx vitest run --no-cache tests/web-supervisor.test.ts` | | daemon autostart | unit | WH-03 and WH-08: report invalid config in the daemon log and pass resolved host to `web.ensure` | `tests/daemon-web.test.ts` | `npx vitest run --no-cache tests/daemon-web.test.ts` | | CLI launch/ui | unit and command integration | WH-03 through WH-05 and WH-14 through WH-16: config and flag resolution, reject invalid flag before IPC, wildcard links, warning text, and listen errors on both paths | `tests/web-launch.test.ts`, `tests/web-cli.test.ts` | `npx vitest run --no-cache tests/web-launch.test.ts tests/web-cli.test.ts` | | docs | documentation assertions | Verify README and Portuguese protocol docs name `web.host`, `ui --host`, child `--host`, and `web.ensure` host | `tests/web-host-docs.test.ts` | `npx vitest run --no-cache tests/web-host-docs.test.ts` | @@ -30,7 +30,7 @@ Tasks run in order. Each task includes its tests, `tasks.md` status update, and its own commit. ```text -T01 -> T02 -> T03 -> T04 -> T05 -> T06 -> T07 -> T08 -> T09 -> T10 -> T11 +T01 -> T02 -> T03 -> T04 -> T05 -> T06 -> T07 -> T08 -> T09 -> T10 -> T11 -> T12 ``` ### Phase 1: Resolve, secure, and bind @@ -147,6 +147,16 @@ Tests: Update `tests/web-security.test.ts` and `tests/web-host-docs.test.ts` for Gate: `npx vitest run --no-cache tests/web-security.test.ts tests/web-host-docs.test.ts` Status: Complete +#### T12: Reject invalid hosts in web.ensure + +Where: `src/daemon/web-supervisor.ts` +Supporting files: `docs/protocol.md` and `.specs/features/web-host/spec.md` +WH IDs: WH-21 +Depends on: T11 +Tests: Update `tests/web-supervisor.test.ts`, `tests/daemon-web.test.ts`, and `tests/web-host-docs.test.ts` for invalid `host` and `preferredHost` errors before stopping or spawning a child, plus the documented error code. +Gate: `npx vitest run --no-cache tests/web-supervisor.test.ts tests/daemon-web.test.ts tests/web-host-docs.test.ts` +Status: Complete + ## Task Completion Record Task completion and requirement traceability are updated in the same commit as each implementation task. The independent verifier owns `validation.md`. diff --git a/docs/protocol.md b/docs/protocol.md index dc14f91..5cab275 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -103,6 +103,7 @@ Erros: | `WEB_LISTEN_FAILED` | a porta pedida está ocupada (o CLI imprime `Failed to listen on <host>:<port>: ...`, com IPv6 entre colchetes, e sai com 1) | `{ "port": n }` | | `WEB_START_FAILED` | o filho morreu antes do handshake, não respondeu em 5 s ou mandou um handshake inválido | | | `WEB_BAD_ENTRY` | `entry` não é absoluto, não termina em `/web/child.js` ou não existe | | +| `WEB_BAD_HOST` | `host` ou `preferredHost` não é um endereço IP aceito por `net.isIP`; o supervisor retorna o erro antes de parar o filho atual | | Ciclo de vida do filho: diff --git a/src/daemon/web-supervisor.ts b/src/daemon/web-supervisor.ts index 117ecda..e0d7cc6 100644 --- a/src/daemon/web-supervisor.ts +++ b/src/daemon/web-supervisor.ts @@ -1,4 +1,5 @@ import fs from "node:fs"; +import { isIP } from "node:net"; import path from "node:path"; import { spawn } from "node:child_process"; import { fileURLToPath } from "node:url"; @@ -15,7 +16,7 @@ export type { WebEnsureParams, WebEnsureResult }; export const WEB_START_TIMEOUT_MS = 5000; export const WEB_STOP_TIMEOUT_MS = 3000; -export type WebEnsureErrorCode = "WEB_LISTEN_FAILED" | "WEB_START_FAILED" | "WEB_BAD_ENTRY"; +export type WebEnsureErrorCode = "WEB_LISTEN_FAILED" | "WEB_START_FAILED" | "WEB_BAD_ENTRY" | "WEB_BAD_HOST"; export class WebEnsureError extends Error { constructor( @@ -106,6 +107,13 @@ export class WebSupervisor { } async ensure(params: WebEnsureParams): Promise<WebEnsureResult> { + if ( + [params.host, params.preferredHost].some( + (host) => host !== undefined && (typeof host !== "string" || isIP(host) === 0), + ) + ) { + throw new WebEnsureError("WEB_BAD_HOST", "web.ensure host and preferredHost must be IP addresses"); + } if (params.entry !== undefined && !this.isValidEntry(params.entry)) { throw new WebEnsureError("WEB_BAD_ENTRY", `invalid web child entry: ${params.entry}`); } diff --git a/tests/daemon-web.test.ts b/tests/daemon-web.test.ts index c0de19e..e19cc23 100644 --- a/tests/daemon-web.test.ts +++ b/tests/daemon-web.test.ts @@ -50,6 +50,23 @@ describe("daemon web.ensure", () => { expect(response.error).toEqual({ code: "WEB_LISTEN_FAILED", message: "listen EADDRINUSE", details: { port: 4567 } }); }); + it("returns WEB_BAD_HOST for an invalid web.ensure host before spawning a child", async () => { + const spawnChild = vi.fn(() => { throw new Error("unexpected child spawn"); }); + daemon = new Daemon({ + webSupervisor: new WebSupervisor({ + log: () => {}, + defaultEntry: "/opt/codedeck/dist/web/child.js", + entryExists: () => true, + spawnChild, + }), + }); + + const response = await ensure({ preferredHost: "deck.local" }); + + expect(response.error).toMatchObject({ code: "WEB_BAD_HOST" }); + expect(spawnChild).not.toHaveBeenCalled(); + }); + it("stops the web child before marking sessions during shutdown", async () => { let statusAtClose: string | undefined; const host = fakeHost({ diff --git a/tests/web-host-docs.test.ts b/tests/web-host-docs.test.ts index 18e56a7..5e3f180 100644 --- a/tests/web-host-docs.test.ts +++ b/tests/web-host-docs.test.ts @@ -34,5 +34,6 @@ describe("web host documentation", () => { expect(protocol).toContain("`preferredHost` (opcional)"); expect(protocol).toContain("`0.0.0.0`"); expect(protocol).toContain("`::`"); + expect(protocol).toContain("`WEB_BAD_HOST`"); }); }); diff --git a/tests/web-supervisor.test.ts b/tests/web-supervisor.test.ts index ff722f0..388397b 100644 --- a/tests/web-supervisor.test.ts +++ b/tests/web-supervisor.test.ts @@ -263,6 +263,19 @@ describe("WebSupervisor.ensure", () => { expect(t.children[0].signals).toEqual([]); }); + it.each([ + ["host", { host: "deck.local" }], + ["preferredHost", { preferredHost: "deck.local" }], + ])("rejects an invalid %s without stopping a running child", async (_field, params) => { + const t = harness({ startTimeoutMs: 25 }); + await running(t, { host: "0.0.0.0" }); + + await expect(t.supervisor.ensure(params)).rejects.toMatchObject({ code: "WEB_BAD_HOST" }); + + expect(t.spawns).toHaveLength(1); + expect(t.children[0].signals).toEqual([]); + }); + it("stops an old build with SIGTERM, escalates to SIGKILL after the stop timeout, and returns the new child", async () => { vi.useFakeTimers(); const { supervisor, children } = harness(); From 026929bb88375e243676ea5a602f057b1efed6ee Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Fri, 25 Sep 2026 02:39:51 -0300 Subject: [PATCH 13/15] fix(cli): Use the active console host for links Return the selected bind address with web.ensure so warnings and alternate links match the child that is actually running. Co-Authored-By: Codex <noreply@anthropic.com> --- .specs/features/web-host/run-notes.md | 1 + .specs/features/web-host/spec.md | 8 +++++-- .specs/features/web-host/tasks.md | 10 +++++++++ README.md | 2 ++ docs/protocol.md | 9 +++++--- src/cli/web-launch.ts | 5 +++-- src/daemon/protocol.ts | 2 ++ src/daemon/web-supervisor.ts | 2 +- tests/daemon-web.test.ts | 8 +++---- tests/web-host-docs.test.ts | 2 ++ tests/web-launch.test.ts | 32 +++++++++++++++++++++++++++ tests/web-supervisor.test.ts | 6 ++--- 12 files changed, 72 insertions(+), 15 deletions(-) diff --git a/.specs/features/web-host/run-notes.md b/.specs/features/web-host/run-notes.md index 1c492e8..57c74c4 100644 --- a/.specs/features/web-host/run-notes.md +++ b/.specs/features/web-host/run-notes.md @@ -30,3 +30,4 @@ | 26 | Restrict hostname suffixes to Tailscale MagicDNS FQDNs. | Bucket 1 decision | Finding R4: accept the exact machine hostname or one or more labels followed by `.ts.net`; reject broader suffix matches. | | 27 | Keep default-loopback Host checks explicit in regression tests. | Bucket 1 decision | Finding T1: injected machine hostnames and non-loopback interface IPs remain forbidden when the bind is `127.0.0.1`. | | 28 | Validate web.ensure host fields before changing child state. | Bucket 1 decision | Finding R5: reject non-IP `host` and `preferredHost` values with `WEB_BAD_HOST` before stopping or starting a child. | +| 29 | Return the active bind host and use it for CLI warnings and alternate links. | Bucket 1 decision | CodeDeck reviewer finding: after R1 preserves an explicit child bind, the CLI must describe the host the child actually uses rather than the current config preference. | diff --git a/.specs/features/web-host/spec.md b/.specs/features/web-host/spec.md index 0a195c4..fab9856 100644 --- a/.specs/features/web-host/spec.md +++ b/.specs/features/web-host/spec.md @@ -79,6 +79,8 @@ The web console currently binds to `127.0.0.1` and only accepts loopback Host he 8. WHEN `web.ensure` supplies neither `host` nor `preferredHost` THEN the supervisor SHALL reuse a running child regardless of its bind host, and SHALL start a new child on `127.0.0.1` when none is running. <!-- WH-19 --> 9. WHEN a page GET uses `Host: localhost:<port>` and the bind host is `127.0.0.1`, `0.0.0.0`, or `::` THEN the server SHALL respond with `302` to `http://127.0.0.1:<port>`; for any other bind host it SHALL skip this canonical redirect. <!-- WH-20 --> 10. IF `web.ensure` supplies a `host` or `preferredHost` that `net.isIP` rejects THEN the supervisor SHALL return `WEB_BAD_HOST` before stopping a running child. <!-- WH-21 --> +11. WHEN `web.ensure` succeeds THEN its result SHALL include `host`, the bind address selected for the running child, alongside the canonical `baseUrl`, port, and token. <!-- WH-22 --> +12. WHEN the CLI receives a successful `web.ensure` result THEN it SHALL use the returned `host` for the plain HTTP warning and wildcard alternate-link decision, falling back to the requested host only for a response without `host`. <!-- WH-23 --> **Independent Test**: Bind to a wildcard address, send requests with a local interface Host, a hostile Host, and tokenized or untokenized page URLs, and verify the exact response behavior. @@ -122,7 +124,7 @@ The web console currently binds to `127.0.0.1` and only accepts loopback Host he | Auth boundaries and rate limits | WH-11 through WH-13 keep the existing Host and token checks; rate limiting is N/A because this feature does not change request authorization behavior. | | Concurrency and ordering | WH-09 and WH-17 use the existing single-child supervisor transition when the requested host changes. | | Data lifecycle and expiry | N/A because the feature adds no persisted data. | -| Observability | WH-03 reports invalid config, WH-15 warns about reachable plain HTTP, and WH-16 reports the listen host. | +| Observability | WH-03 reports invalid config, WH-15 and WH-23 select the plain HTTP warning from the active bind, and WH-16 reports the listen host. | | External-dependency failure | WH-16 covers OS listen failures; no new external service is introduced. | | State-transition integrity | WH-09, WH-17 through WH-19, and WH-21 define host transitions, reuse, and validation order. | @@ -151,8 +153,10 @@ The web console currently binds to `127.0.0.1` and only accepts loopback Host he | WH-19 | P1: Preserve the console's request protections | Tasks | Implemented | | WH-20 | P1: Preserve the console's request protections | Tasks | Implemented | | WH-21 | P1: Preserve the console's request protections | Tasks | Implemented | +| WH-22 | P1: Preserve the console's request protections | Tasks | Implemented | +| WH-23 | P2: Print usable links and bind errors | Tasks | Implemented | -**Coverage**: 21 requirements, 21 mapped to tasks, 0 unmapped. +**Coverage**: 23 requirements, 23 mapped to tasks, 0 unmapped. ## Success Criteria diff --git a/.specs/features/web-host/tasks.md b/.specs/features/web-host/tasks.md index a359500..c4a7a5c 100644 --- a/.specs/features/web-host/tasks.md +++ b/.specs/features/web-host/tasks.md @@ -157,6 +157,16 @@ Tests: Update `tests/web-supervisor.test.ts`, `tests/daemon-web.test.ts`, and `t Gate: `npx vitest run --no-cache tests/web-supervisor.test.ts tests/daemon-web.test.ts tests/web-host-docs.test.ts` Status: Complete +#### T13: Report the active bind host to the CLI + +Where: `src/daemon/protocol.ts`, `src/daemon/web-supervisor.ts`, and `src/cli/web-launch.ts` +Supporting files: `README.md`, `docs/protocol.md`, and `.specs/features/web-host/spec.md` +WH IDs: WH-22, WH-23 +Depends on: T09 +Tests: Update `tests/web-supervisor.test.ts`, `tests/daemon-web.test.ts`, `tests/web-launch.test.ts`, and `tests/web-host-docs.test.ts` for the returned bind host and CLI warnings/links when a retained explicit host differs from config. +Gate: `npx vitest run --no-cache tests/web-supervisor.test.ts tests/daemon-web.test.ts tests/web-launch.test.ts tests/web-host-docs.test.ts` +Status: Complete + ## Task Completion Record Task completion and requirement traceability are updated in the same commit as each implementation task. The independent verifier owns `validation.md`. diff --git a/README.md b/README.md index 22ca188..10527e7 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,8 @@ The console listens on `127.0.0.1` by default. Set `web.host` in `config.json` t `web.host` accepts IP addresses only. `codedeck ui --host <addr>` explicitly binds that address for the launch. The CLI sends it as `host` to `web.ensure`; without the flag, it sends the resolved config value as `preferredHost`. The daemon passes the selected address to the child as `--host <addr>`. An explicit host change restarts the child. A preferred host moves a child started for a preferred host or with no host request, and never moves a child started for an explicit host. If both host fields are absent, the supervisor reuses a running child at any address and starts a new child on `127.0.0.1`. +Warnings and alternate links use the bind address reported by the running child, so a retained explicit bind remains visible even when it differs from `web.host`. + For a specific bind address, the console accepts that address in the request `Host` with the exact console port, even when it is missing from the system's interface list. Other accepted hosts on non-loopback binds are current interface IPs, the exact `os.hostname()`, and Tailscale MagicDNS names in the form `<hostname>.<label>.ts.net`, with one or more labels allowed. Names such as `<hostname>.evil.com` are rejected. The default `127.0.0.1` bind continues to accept only `127.0.0.1` and `localhost`. ## Open diff --git a/docs/protocol.md b/docs/protocol.md index 5cab275..05f0fe7 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -68,12 +68,15 @@ Request: - `entry` (opcional): caminho absoluto do `dist/web/child.js` do chamador. Sem ele, o daemon usa o próprio. -Response: +Resposta, em que `host` identifica o endereço de bind selecionado pelo supervisor para o processo filho em execução: ```json -{ "id": "w1", "result": { "baseUrl": "http://127.0.0.1:7777", "port": 7777, "token": "..." } } +{ "id": "w1", "result": { "baseUrl": "http://127.0.0.1:7777", "host": "0.0.0.0", "port": 7777, "token": "..." } } ``` -A página abre em `<baseUrl><path>?<query>&t=<token>`. O token vira cookie +A página abre em `<baseUrl><path>?<query>&t=<token>`. O CLI usa `host` para +escolher o aviso de HTTP simples e os links alternativos, mesmo quando o daemon +reaproveita um filho iniciado com um bind explícito diferente de `web.host`. O +token vira cookie (`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.` diff --git a/src/cli/web-launch.ts b/src/cli/web-launch.ts index 922eb2f..c3874c1 100644 --- a/src/cli/web-launch.ts +++ b/src/cli/web-launch.ts @@ -77,7 +77,8 @@ export async function launchWebPage(options: LaunchWebPageOptions, deps: LaunchW return serveInProcess(options, host, askedPort, deps, error); } - warnForPlainHttp(host, web.port, error); + const activeHost = web.host ?? host; + warnForPlainHttp(activeHost, web.port, error); 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); @@ -89,7 +90,7 @@ export async function launchWebPage(options: LaunchWebPageOptions, deps: LaunchW } else { log(`${options.title} on ${pageUrl}`); } - printAlternateLinks(host, web.port, options, web.token, deps.networkInterfaces ?? os.networkInterfaces, log); + printAlternateLinks(activeHost, web.port, options, web.token, deps.networkInterfaces ?? os.networkInterfaces, log); return 0; } diff --git a/src/daemon/protocol.ts b/src/daemon/protocol.ts index 46de60e..dc236c1 100644 --- a/src/daemon/protocol.ts +++ b/src/daemon/protocol.ts @@ -249,6 +249,8 @@ export interface WebEnsureParams { export interface WebEnsureResult { baseUrl: string; + /** Actual bind address selected for the running child. */ + host: string; port: number; token: string; } diff --git a/src/daemon/web-supervisor.ts b/src/daemon/web-supervisor.ts index e0d7cc6..9793ab4 100644 --- a/src/daemon/web-supervisor.ts +++ b/src/daemon/web-supervisor.ts @@ -293,7 +293,7 @@ function parseHandshake(line: string): Handshake { } function resultOf(running: RunningChild): WebEnsureResult { - return { baseUrl: running.baseUrl, port: running.port, token: running.token }; + return { baseUrl: running.baseUrl, host: running.host, port: running.port, token: running.token }; } function exitsWithin(exited: Promise<void>, ms: number): Promise<boolean> { diff --git a/tests/daemon-web.test.ts b/tests/daemon-web.test.ts index e19cc23..72db139 100644 --- a/tests/daemon-web.test.ts +++ b/tests/daemon-web.test.ts @@ -21,7 +21,7 @@ async function ensure(params: unknown): Promise<Record<string, any>> { function fakeHost(overrides: Partial<WebHost> = {}): WebHost { return { - ensure: vi.fn(async () => ({ baseUrl: "http://127.0.0.1:4100", port: 4100, token: "tok" })), + ensure: vi.fn(async () => ({ baseUrl: "http://127.0.0.1:4100", host: "127.0.0.1", port: 4100, token: "tok" })), close: vi.fn(), ...overrides, }; @@ -34,7 +34,7 @@ describe("daemon web.ensure", () => { const response = await ensure({ host: "100.64.0.5", port: 4100, build: "b1", entry: "/opt/codedeck/dist/web/child.js" }); - expect(response.result).toEqual({ baseUrl: "http://127.0.0.1:4100", port: 4100, token: "tok" }); + expect(response.result).toEqual({ baseUrl: "http://127.0.0.1:4100", host: "127.0.0.1", port: 4100, token: "tok" }); expect(host.ensure).toHaveBeenCalledWith({ host: "100.64.0.5", port: 4100, build: "b1", entry: "/opt/codedeck/dist/web/child.js" }); }); @@ -172,14 +172,14 @@ describe("daemon web autostart", () => { 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" }); + .mockResolvedValue({ baseUrl: "http://127.0.0.1:7777", host: "127.0.0.1", 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, preferredHost: "127.0.0.1" }); - expect((await ensure({})).result).toEqual({ baseUrl: "http://127.0.0.1:7777", port: 7777, token: "tok" }); + expect((await ensure({})).result).toEqual({ baseUrl: "http://127.0.0.1:7777", host: "127.0.0.1", port: 7777, token: "tok" }); }); it("logs an invalid web.port and falls back to 7777", async () => { diff --git a/tests/web-host-docs.test.ts b/tests/web-host-docs.test.ts index 5e3f180..3f9fdda 100644 --- a/tests/web-host-docs.test.ts +++ b/tests/web-host-docs.test.ts @@ -35,5 +35,7 @@ describe("web host documentation", () => { expect(protocol).toContain("`0.0.0.0`"); expect(protocol).toContain("`::`"); expect(protocol).toContain("`WEB_BAD_HOST`"); + expect(protocol).toContain('"host": "0.0.0.0"'); + expect(protocol).toContain("endereço de bind selecionado pelo supervisor"); }); }); diff --git a/tests/web-launch.test.ts b/tests/web-launch.test.ts index 5d33d5f..559eafd 100644 --- a/tests/web-launch.test.ts +++ b/tests/web-launch.test.ts @@ -186,6 +186,38 @@ describe("launchWebPage", () => { ]); }); + it("uses the daemon's active wildcard bind for warnings and alternate links", async () => { + const t = setup({ + ensure: async () => ({ ...BASE, host: "0.0.0.0" }), + networkInterfaces: () => makeInterfaces([{ address: "100.101.102.103", family: "IPv4" }]), + }); + + await t.launch({ open: false }); + + expect(t.errors).toEqual([ + "Warning: the console listens on 0.0.0.0 over plain HTTP. Anyone who can reach port 7777 with the link gets full access; use it only on a trusted network such as Tailscale.", + ]); + expect(t.logs).toEqual([ + "CodeDeck review on http://127.0.0.1:7777/review?repo=%2Fwork%2Fapp&t=tok", + "Also on http://100.101.102.103:7777/review?repo=%2Fwork%2Fapp&t=tok", + ]); + }); + + it("does not describe a configured wildcard when the daemon keeps an explicit loopback bind", async () => { + const t = setup({ + config: { web: { host: "0.0.0.0" } }, + ensure: async () => ({ ...BASE, host: "127.0.0.1" }), + networkInterfaces: () => makeInterfaces([{ address: "100.101.102.103", family: "IPv4" }]), + }); + + await t.launch({ open: false }); + + expect(t.errors).toEqual([]); + expect(t.logs).toEqual([ + "CodeDeck review on http://127.0.0.1:7777/review?repo=%2Fwork%2Fapp&t=tok", + ]); + }); + it("prints alternate links after the in-process server URL for wildcard binds", async () => { const t = setup({ config: { web: { host: "0.0.0.0" } }, diff --git a/tests/web-supervisor.test.ts b/tests/web-supervisor.test.ts index 388397b..747458f 100644 --- a/tests/web-supervisor.test.ts +++ b/tests/web-supervisor.test.ts @@ -65,7 +65,7 @@ describe("WebSupervisor.ensure", () => { const pending = supervisor.ensure({ build: "b1" }); children[0].handshake(ok()); - await expect(pending).resolves.toEqual({ baseUrl: "http://127.0.0.1:4100", port: 4100, token: "tok-1" }); + await expect(pending).resolves.toEqual({ baseUrl: "http://127.0.0.1:4100", host: "127.0.0.1", port: 4100, token: "tok-1" }); expect(spawns).toEqual([{ entry: ENTRY, args: ["--web-child", "--host", "127.0.0.1"] }]); }); @@ -210,7 +210,7 @@ describe("WebSupervisor.ensure", () => { await vi.waitFor(() => expect(t.children).toHaveLength(2)); t.children[1].handshake(ok(4100, "tok-1")); - await expect(second).resolves.toEqual(first); + await expect(second).resolves.toMatchObject({ baseUrl: first.baseUrl, host: "0.0.0.0", port: first.port, token: first.token }); expect(t.children[0].signals).toEqual(["SIGTERM"]); expect(t.spawns[1].args).toEqual(["--web-child", "--host", "0.0.0.0", "--preferred-port", "7777"]); }); @@ -325,7 +325,7 @@ describe("WebSupervisor port rules", () => { ])("formats the base URL for host %s", async (host, urlHost) => { const t = harness(); - await expect(running(t, { host })).resolves.toMatchObject({ baseUrl: `http://${urlHost}:4100` }); + await expect(running(t, { host })).resolves.toMatchObject({ baseUrl: `http://${urlHost}:4100`, host }); }); it.each([ From 300c7a9dde05303a228890a85b349830a349ca7b Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Fri, 25 Sep 2026 03:05:59 -0300 Subject: [PATCH 14/15] test(web): Cover rejected console Host names on remote binds Add WH-12 tests for hostile MagicDNS names and wildcard bind Hosts. Record the focused gate and mutation results. Co-Authored-By: Codex <noreply@anthropic.com> --- .specs/features/web-host/run-notes.md | 1 + .specs/features/web-host/tasks.md | 9 +++++++++ tests/web-security.test.ts | 24 ++++++++++++++++++++++++ 3 files changed, 34 insertions(+) diff --git a/.specs/features/web-host/run-notes.md b/.specs/features/web-host/run-notes.md index 57c74c4..b0615fd 100644 --- a/.specs/features/web-host/run-notes.md +++ b/.specs/features/web-host/run-notes.md @@ -31,3 +31,4 @@ | 27 | Keep default-loopback Host checks explicit in regression tests. | Bucket 1 decision | Finding T1: injected machine hostnames and non-loopback interface IPs remain forbidden when the bind is `127.0.0.1`. | | 28 | Validate web.ensure host fields before changing child state. | Bucket 1 decision | Finding R5: reject non-IP `host` and `preferredHost` values with `WEB_BAD_HOST` before stopping or starting a child. | | 29 | Return the active bind host and use it for CLI warnings and alternate links. | Bucket 1 decision | CodeDeck reviewer finding: after R1 preserves an explicit child bind, the CLI must describe the host the child actually uses rather than the current config preference. | +| 30 | Bind the wildcard rejection checks to local sockets for both `0.0.0.0` and `::`. | Decision | IPv6 listening succeeded here. The scoped gate passed 41 tests and typecheck. Scratch mutation failures: M2 prefix match without the dot (1), M34 accepting wildcard bind literals (2), M36 allowing `<hostname>.ts.net` (1), and M37 removing label validation (1). | diff --git a/.specs/features/web-host/tasks.md b/.specs/features/web-host/tasks.md index c4a7a5c..9bbe403 100644 --- a/.specs/features/web-host/tasks.md +++ b/.specs/features/web-host/tasks.md @@ -167,6 +167,15 @@ Tests: Update `tests/web-supervisor.test.ts`, `tests/daemon-web.test.ts`, `tests Gate: `npx vitest run --no-cache tests/web-supervisor.test.ts tests/daemon-web.test.ts tests/web-launch.test.ts tests/web-host-docs.test.ts` Status: Complete +#### T14: Cover surviving WH-12 Host mutations + +Where: `tests/web-security.test.ts` +WH IDs: WH-12 +Depends on: T11 +Tests: Add rejected MagicDNS hostname forms and wildcard bind literals to the Host checks. +Gate: `npx vitest run --no-cache tests/web-port.test.ts tests/web-security.test.ts` and `npx tsc --noEmit -p .` +Status: Complete + ## Task Completion Record Task completion and requirement traceability are updated in the same commit as each implementation task. The independent verifier owns `validation.md`. diff --git a/tests/web-security.test.ts b/tests/web-security.test.ts index cae2669..08afd2b 100644 --- a/tests/web-security.test.ts +++ b/tests/web-security.test.ts @@ -385,6 +385,10 @@ describe("web request security", () => { `evil.example:${port}`, `deck-host.evil.com:${port}`, `deck-hostile:${port}`, + `deck-hostile.tail1234.ts.net:${port}`, + `deck-host.ts.net:${port}`, + `deck-host..ts.net:${port}`, + `deck-host.a_b.ts.net:${port}`, `100.101.102.103:${port + 1}`, `[deck-host]:${port}`, `[fe80::1%tailscale0]:${port}`, @@ -397,6 +401,26 @@ describe("web request security", () => { } }); + it.each([ + { bindHost: "0.0.0.0", connectHost: "127.0.0.1", hostHeader: (port: number) => `0.0.0.0:${port}` }, + { bindHost: "::", connectHost: "::1", hostHeader: (port: number) => `[::]:${port}` }, + ])("rejects wildcard bind $bindHost as a Host header", async ({ bindHost, connectHost, hostHeader }) => { + const networkInterfaces = vi.fn(() => makeInterfaces([])); + const { handle, calls } = await makeExtendedServer(networkInterfaces, () => "deck-host", { + host: bindHost, + listenHost: bindHost, + }); + const response = await request(handle, { + path: `/page?t=${handle.security.token}`, + host: hostHeader(handle.port), + connectHost, + }); + + expect(response.status).toBe(403); + expect(response.body).toBe("forbidden"); + expect(calls).toEqual([]); + }); + it("accepts the specific bind address when it is missing from local interfaces", async () => { const networkInterfaces = vi.fn(() => makeInterfaces([])); const { handle } = await makeExtendedServer(networkInterfaces, () => "deck-host", { From e721930f876c3ee3d298b2682db6ee9a8e4b1e9e Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Fri, 25 Sep 2026 03:24:15 -0300 Subject: [PATCH 15/15] docs(web): Record the web-host verification and run report --- .specs/features/web-host/run-report.md | 135 +++++++++++++++ .specs/features/web-host/spec.md | 18 +- .specs/features/web-host/validation.md | 218 +++++++++++++++++++++++++ 3 files changed, 362 insertions(+), 9 deletions(-) create mode 100644 .specs/features/web-host/run-report.md create mode 100644 .specs/features/web-host/validation.md diff --git a/.specs/features/web-host/run-report.md b/.specs/features/web-host/run-report.md new file mode 100644 index 0000000..0cb6c00 --- /dev/null +++ b/.specs/features/web-host/run-report.md @@ -0,0 +1,135 @@ +## Done + +Branch: [feat/web-console-host](https://github.com/4ndreello/codedeck/tree/feat/web-console-host) + +Commit: [218c1d042794d6d4e4a81887386a461411fc99d7](https://github.com/4ndreello/codedeck/commit/218c1d042794d6d4e4a81887386a461411fc99d7) + +The orchestrator owns the post-HEAD push, PR, and commits for `validation.md` and this report. At report time, `git ls-remote origin feat/web-console-host` returned no ref and `gh pr list --head feat/web-console-host --state all --json number,url,state` returned `[]`. + +The feature adds `web.host`, an IPv4 or IPv6 bind address, and `codedeck ui --host <addr>` as a per-launch override. The default remains `127.0.0.1`. Host checks on remote binds accept the exact non-wildcard bind IP, current interface IPs, the exact machine hostname, and `<machine-hostname>.<one or more labels>.ts.net`, all on the console port. Existing token, cookie, and origin checks remain in place. An explicit host stays pinned on the running child, while `preferredHost` can move a child started from a preference or with no host request. Invalid daemon host fields return `WEB_BAD_HOST` before child state changes. Non-loopback binds print a plain-HTTP warning after listening succeeds. Wildcard binds print an `Also on` link for each non-internal IPv4 interface, preserving the page path, query, and token. + +To enable access over Tailscale, set this in the config: + +```json +{ "web": { "host": "0.0.0.0" } } +``` + +Or run `codedeck ui --host 0.0.0.0`. Open the printed `Also on http://<tailscale-ip>:7777/?t=...` link once in each browser. + +Verification: + +- Final `validation.md` says `Verdict: PASS`. It records 23/23 acceptance criteria passing and 21/21 mutants killed. +- Final scoped Vitest batches, after the test-only follow-up, reported: + - `npx vitest run --no-cache tests/web-port.test.ts tests/web-security.test.ts` -> 2 files, 41 passed (41). + - `npx vitest run --no-cache tests/web-server.test.ts tests/web-child.test.ts` -> 2 files, 36 passed (36). + - `npx vitest run --no-cache tests/web-supervisor.test.ts tests/daemon-web.test.ts` -> 2 files, 53 passed (53). + - `npx vitest run --no-cache tests/web-launch.test.ts tests/web-cli.test.ts tests/web-host-docs.test.ts` -> 3 files, 46 passed (46). +- Test-only commit `218c1d0` added two security cases to the first batch, raising it from 39 to 41. The final four batches total 176 passed, 0 failed, 0 skipped. +- `npx tsc --noEmit -p .` exited 0. +- Worker `6005`'s final smoke log, read with `codedeck logs 6005`, records a built child on `0.0.0.0` and port 42361. It reports `wildcard localhost status=302 location=http://127.0.0.1:42361/`, a remote IPv4 Host without a token returning the 403 page and with `?t=` returning 303, `evil.example` returning 403 forbidden, and a tokenized link on a `127.0.0.2` bind returning 303. + +`git log --oneline main..HEAD`: + +```text +218c1d0 test(web): Cover rejected console Host names on remote binds +88f9006 fix(cli): Use the active console host for links +cb3fe41 fix(daemon): Reject a non-IP console host in web.ensure +0f07a51 fix(web): Restrict accepted console Host values +44766ae fix(web): Redirect localhost on wildcard console binds +27a9d43 fix(daemon): Keep an explicit console host across web commands +c1c1761 docs(web): Document the configurable console bind host +540baed feat(cli): Support configurable console bind hosts +cac7c58 feat(daemon): Pass the configured host to web autostart +cbad7b0 feat(daemon): Restart the web child when its host changes +6717adf feat(web): Pass the bind host to the web child +86d9fd8 feat(web): Bind the console server to its configured host +2c783ec feat(web): Accept trusted Host headers on configured binds +e0a139f feat(config): Resolve the console bind host from web.host +``` + +`git diff --stat main..HEAD`: + +```text +.specs/features/web-host/run-notes.md | 34 +++++++++++ +.specs/features/web-host/spec.md | 165 +++++++++++++++++++++++++++++++++++++++++++++++++++++ +.specs/features/web-host/tasks.md | 181 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +README.md | 19 +++++++ +docs/protocol.md | 38 ++++++++++--- +src/cli/commands/ui.ts | 17 +++++- +src/cli/web-launch.ts | 65 ++++++++++++++++++--- +src/config/web-host.ts | 37 ++++++++++++ +src/daemon/daemon.ts | 8 ++- +src/daemon/protocol.ts | 6 ++ +src/daemon/web-supervisor.ts | 44 +++++++++++--- +src/web/child.ts | 16 ++++-- +src/web/security.ts | 68 ++++++++++++++++++++-- +src/web/server.ts | 17 +++++-- +tests/daemon-web.test.ts | 51 +++++++++++++---- +tests/web-child.test.ts | 18 ++++-- +tests/web-cli.test.ts | 25 ++++++++ +tests/web-host-docs.test.ts | 41 +++++++++++++ +tests/web-launch.test.ts | 160 ++++++++++++++++++++++++++++++++++++++++++++++++++- +tests/web-port.test.ts | 29 ++++++++++ +tests/web-security.test.ts | 266 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-- +tests/web-server.test.ts | 32 +++++++++++ +tests/web-supervisor.test.ts | 113 +++++++++++++++++++++++++++++++----- +23 files changed, 1375 insertions(+), 75 deletions(-) +``` + +## Assumptions I made + +1. [Assumption] WH-01 through WH-16 are the acceptance source; keep the `127.0.0.1` default. +2. [Assumption] A non-object `web` value is an invalid `web.host` setting and its raw JSON value is reported. +3. [Assumption] A valid `ui --host` overrides the configured host for that launch; an invalid configured host still warns. +4. [Decision] Store the requested host with running-child state because the child handshake omits it. +5. [Assumption] Compare Host names case-insensitively and require the exact console port. +6. [Assumption] Wildcard alternate links use each non-internal IPv4 interface and preserve path, query, and token. +7. [Decision] Advertise loopback for `127.0.0.1`, `0.0.0.0`, and `::`; bracket other IPv6 hosts. +8. [Decision] Suppress the HTTP warning for IPv4 `127.0.0.0/8` and IPv6 `::1`. +9. [Assumption] Check only the two required documentation files in the focused docs test. +10. [Blocker] The first T01 gate stopped because Vitest could not resolve `vitest/config` and no install-free runner was available; dependency installation was out of scope. +11. [Decision] Run Vitest with `--no-cache` because its default cache write failed through the read-only `node_modules` symlink. +12. [Decision] Resume with the provided dependency symlink after confirmation, and leave it untouched. +13. [Decision] Use lowercase commit subjects because `check_commit.py` rejects capitalized subjects. +14. [Blocker] An initial T01 commit attempt failed when Git could not create the worktree index lock outside the writable roots. +15. [Deviation] Leave per-task commits to the orchestrator because the sandbox could not write Git metadata. +16. [Decision] Initially redirect `localhost` only for the default loopback bind; later review widened this to binds that advertise the loopback base URL. +17. [Blocker] Initial T02 security tests could not bind a local TCP listener: all 13 failed with `listen EPERM` before assertions, though typecheck passed. +18. [Decision] A continuation requested capitalized subjects, including the exact T01 message, despite the checker rejection. +19. [Deviation] Commit T01 and T02 separately after rerunning their gates. +20. [Decision] Share URL formatting from `src/config/web-host.ts` so the supervisor and server use the same mapping without crossing the daemon import boundary. +21. [Decision] Print the HTTP warning only after listening succeeds so it names the actual port. +22. [Bucket 1 decision] An explicit host pins its child; `web.host` is a preference; omitted host fields reuse a running child or start on loopback. +23. [Decision] Capitalize commit subjects after the Conventional Commit prefix to match repository history, despite the checker's lowercase requirement. +24. [Bucket 1 decision] Redirect localhost when the advertised base URL is loopback for `127.0.0.1`, `0.0.0.0`, or `::`; skip the redirect for other bind addresses. +25. [Bucket 1 decision] Trust the configured non-wildcard bind IP even when it is absent from the interface list. +26. [Bucket 1 decision] Accept the exact machine hostname or one or more labels followed by `.ts.net`; reject broader suffix matches. +27. [Bucket 1 decision] Keep tests proving that injected hostnames and non-loopback interface IPs are rejected on the default loopback bind. +28. [Bucket 1 decision] Validate `web.ensure` host fields before changing child state, returning `WEB_BAD_HOST` for non-IP values. +29. [Bucket 1 decision] Use the active bind host returned by the daemon for CLI warnings and alternate links. +30. [Decision] Run wildcard Host rejection checks for both `0.0.0.0` and `::`; IPv6 listening succeeded, the scoped gate passed 41 tests, and the scratch probes killed M2, M34, M36, and M37. + +## Deferred / waiting for you + +None. The orchestrator owns the post-HEAD push, PR, and commits for `validation.md` and this report. + +## Blocked / failed + +- Worker `f3c7` (general, Codex gpt-6-luna) wrote the spec, tasks, run notes, and uncommitted T01/T02, then was superseded after sandbox blockers. Worker `47a5` (general) committed T01 through T08. +- Reviewer `fc97` (Claude Opus) found six issues: S1 host flip-flop; S2 localhost redirect lost on wildcard bind; `127.0.0.2` link returned 403; hostname suffix was too broad; daemon did not validate the host; bad `--host` stopped the running console. Worker `6005` fixed R1 through R5, T1, and T13 in five commits. +- Auditor `283b` first returned FAIL: 24 mutants, 21 killed, 1 equivalent, and M1 and M2 survived. Auditor `4a15` then returned FAIL: 21 mutants, 17 killed, with M2, M34, M36, and M37 surviving. The verifier judged behavior correct and the remaining issues test gaps. Worker `dbdb` added negative tests in test-only commit `218c1d0`; its scratch probe killed M2 (1), M34 (2), M36 (1), and M37 (1). The orchestrator independently reran M2 and M36 in scratch copies. Each run reported `1 failed | 18 passed (19)` for `tests/web-security.test.ts`, against a 19/19 baseline. The final verifier killed all four survivors. Current `validation.md` says `Verdict: PASS`. +- Codex worktrees lacked `node_modules` because it is gitignored. The orchestrator symlinked the main repository's dependencies. +- `codedeck send` resumed a Codex thread with `workspace-write` even though its first turn had `danger-full-access`. The f3c7 rollout shows `danger-full-access` on the first turn and `workspace-write` on both resumed turns. `src/drivers/codex/driver.ts` says a resumed thread keeps its existing sandbox policy, which the rollout contradicts. Git commits and socket listen were denied after resume. +- CodeDeck marked sessions `47a5` and `6005` failed with `UNKNOWN` and blamed the harness, although both logs ended with `turn completed` and exit 0 after a recovered tool error appeared on stderr. +- `check_commit.py` rejects capitalized subjects while repository history uses them. The repository convention was kept. + +## Not covered + +- No end-to-end run used a real Tailscale interface or another device. +- No real `::` listener was tested with `bindv6only=1`. The verifier also did not test the localhost redirect through a real `::` server. +- IPv6 address normalization in Host comparison was not covered. +- A valid `--host` can still stop the running console before a new listen fails, as with the existing explicit-port behavior. +- The low-severity listen-failure message on a reused explicit host still names the requested host; the verifier noted this from code reading and did not reproduce it. +- TLS and `web.allowedHosts` were out of scope. +- The verifier did not test a build-triggered restart failure while retaining an explicit bind, run `dist/` gate scripts, or distill lessons. +- The full test suite was not run. Verification used the scoped web batches listed above. diff --git a/.specs/features/web-host/spec.md b/.specs/features/web-host/spec.md index fab9856..c036a38 100644 --- a/.specs/features/web-host/spec.md +++ b/.specs/features/web-host/spec.md @@ -140,21 +140,21 @@ The web console currently binds to `127.0.0.1` and only accepts loopback Host he | WH-06 | P1: Bind the console to a configured address | Tasks | Verified | | WH-07 | P1: Bind the console to a configured address | Tasks | Verified | | WH-08 | P1: Bind the console to a configured address | Tasks | Verified | -| WH-09 | P1: Preserve the console's request protections | Tasks | Implemented | +| WH-09 | P1: Preserve the console's request protections | Tasks | Verified | | WH-10 | P1: Preserve the console's request protections | Tasks | Verified | | WH-11 | P1: Preserve the console's request protections | Tasks | Verified | -| WH-12 | P1: Preserve the console's request protections | Tasks | Implemented | +| WH-12 | P1: Preserve the console's request protections | Tasks | Verified | | WH-13 | P1: Preserve the console's request protections | Tasks | Verified | | WH-14 | P2: Print usable links and bind errors | Tasks | Verified | | WH-15 | P2: Print usable links and bind errors | Tasks | Verified | | WH-16 | P2: Print usable links and bind errors | Tasks | Verified | -| WH-17 | P1: Preserve the console's request protections | Tasks | Implemented | -| WH-18 | P1: Preserve the console's request protections | Tasks | Implemented | -| WH-19 | P1: Preserve the console's request protections | Tasks | Implemented | -| WH-20 | P1: Preserve the console's request protections | Tasks | Implemented | -| WH-21 | P1: Preserve the console's request protections | Tasks | Implemented | -| WH-22 | P1: Preserve the console's request protections | Tasks | Implemented | -| WH-23 | P2: Print usable links and bind errors | Tasks | Implemented | +| WH-17 | P1: Preserve the console's request protections | Tasks | Verified | +| WH-18 | P1: Preserve the console's request protections | Tasks | Verified | +| WH-19 | P1: Preserve the console's request protections | Tasks | Verified | +| WH-20 | P1: Preserve the console's request protections | Tasks | Verified | +| WH-21 | P1: Preserve the console's request protections | Tasks | Verified | +| WH-22 | P1: Preserve the console's request protections | Tasks | Verified | +| WH-23 | P2: Print usable links and bind errors | Tasks | Verified | **Coverage**: 23 requirements, 23 mapped to tasks, 0 unmapped. diff --git a/.specs/features/web-host/validation.md b/.specs/features/web-host/validation.md new file mode 100644 index 0000000..c008544 --- /dev/null +++ b/.specs/features/web-host/validation.md @@ -0,0 +1,218 @@ +# web-host Validation (re-verification after remediation) + +**Date**: 2026-09-25 +**Spec**: `.specs/features/web-host/spec.md` (rewritten WH-09, WH-12; new WH-17 to WH-23) +**Diff range**: `main` (386e3af) `..` `ra/slice-configurable-bind-addres-f3c7` (218c1d0), 14 commits. Remediation commits: `27a9d43`, `44766ae`, `0f07a51`, `cb3fe41`, `88f9006`; test-only follow-up `218c1d0` (round 3) +**Verifier**: independent verifier (author ≠ verifier), read only over the worktree; mutations ran in a scratch rsync copy that was deleted afterwards + +Verdict: PASS + +--- + +## Task Completion + +| Task | Status | Notes | +| ---- | ------ | ----- | +| T01-T08 | ✅ Done | unchanged since the first pass except `src/cli/web-launch.ts`, `src/daemon/daemon.ts` | +| T09 | ✅ Done | `host` / `preferredHost` split, `HostFor` provenance in `src/daemon/web-supervisor.ts:51,149-156` | +| T10 | ✅ Done | `src/web/security.ts:196` redirect keyed on the advertised base URL | +| T11 | ✅ Done | `src/web/security.ts:57,69-79`; round-2 survivors M2, M34, M36, M37 killed by `218c1d0` (round 3) | +| T12 | ✅ Done | `src/daemon/web-supervisor.ts:110-116` | +| T13 | ✅ Done | `src/daemon/protocol.ts` `WebEnsureResult.host`; `src/daemon/web-supervisor.ts:296`; `src/cli/web-launch.ts:80-93` | + +--- + +## Spec-Anchored Acceptance Criteria + +| ID | Spec-defined outcome | Implementation | Test `file:line` + assertion | Result | +| -- | -------------------- | -------------- | ---------------------------- | ------ | +| WH-01 | no `web.host` resolves to `127.0.0.1` | `src/config/web-host.ts:18,23` | `tests/web-port.test.ts:37` - `toEqual({ host: "127.0.0.1" })` for `{}` and `{ web: {} }` | ✅ PASS | +| WH-02 | IPv4/IPv6 string returned as is | `src/config/web-host.ts:24` | `tests/web-port.test.ts:41` - `toEqual({ host })` for `0.0.0.0`, `100.64.0.5`, `::`, `::1` | ✅ PASS | +| WH-03 | invalid value falls back to loopback; CLI stderr and daemon log `Ignoring invalid web.host in config: <JSON>` | `src/config/web-host.ts:19-30`; `src/cli/web-launch.ts:50`; `src/daemon/daemon.ts:1457-1459` | `tests/web-port.test.ts:50,55`; `tests/web-launch.test.ts:147` - `t.errors toEqual(['Ignoring invalid web.host in config: "deck.local"'])`; `tests/daemon-web.test.ts:196` - `toMatch(/\] Ignoring invalid web\.host in config: "deck\.local"\n/)` | ✅ PASS | +| WH-04 | `ui --host` overrides `web.host` for that launch | `src/cli/commands/ui.ts:79-85`; `src/cli/web-launch.ts:51,62` | `tests/web-cli.test.ts:109`; `tests/web-launch.test.ts:129` - second call `objectContaining({ host: "192.168.1.25" })` and `not.toHaveProperty("preferredHost")` | ✅ PASS | +| WH-05 | invalid `--host`: stderr `--host must be an IP address`, exit 1, no daemon request | `src/cli/commands/ui.ts:64-68` | `tests/web-cli.test.ts:120` - `launch not.toHaveBeenCalled()`, exact message, `process.exitCode toBe(1)` | ✅ PASS | +| WH-06 | child listens on `--host <addr>`; parse without `--host` = `127.0.0.1` | `src/daemon/web-supervisor.ts:186-188`; `src/web/child.ts:44,78-83` | `tests/web-supervisor.test.ts:62` - `args toEqual(["--web-child","--host","127.0.0.1"])`; `tests/web-child.test.ts:123,142-148` | ✅ PASS | +| WH-07 | in-process `startWebServer`/`listenWebServer` listen on resolved host, default `127.0.0.1` | `src/web/server.ts:132-138,174,181`; `src/cli/web-launch.ts:107-109` | `tests/web-server.test.ts:59,240,249`; `tests/web-launch.test.ts:156` - `options.host toBe("100.64.0.5")` | ✅ PASS | +| WH-08 | `autostartWeb` passes the config-resolved host | `src/daemon/daemon.ts:1457,1461` (`preferredHost: host`) | `tests/daemon-web.test.ts:160` - `toHaveBeenCalledWith({ preferredPort: 7788, preferredHost: "100.64.0.5" })`; `:185,196` loopback fallback (M30 killed) | ✅ PASS | +| WH-09 | explicit `host` different from running child → stop + start on requested host; omitted `host` does not move to `127.0.0.1`; port reuse unchanged | `src/daemon/web-supervisor.ts:125-131,150,154,159` | `tests/web-supervisor.test.ts:203` - `children[0].signals toEqual(["SIGTERM"])`, new args `--host 0.0.0.0`; `:218` same host reuses; `:256` `{ host: "100.64.0.5" }` then `ensure({})` `resolves.toEqual(first)`, `spawns toHaveLength(1)`; port rules `:331-361` (M28, M44 killed) | ✅ PASS | +| WH-10 | base URL `http://127.0.0.1:<port>` for `127.0.0.1`/`0.0.0.0`/`::`, else `http://<host>:<port>`, IPv6 bracketed | `src/config/web-host.ts:32-37`; `src/daemon/web-supervisor.ts:248`; `src/web/server.ts:153` | `tests/web-server.test.ts:240`; `tests/web-supervisor.test.ts:325` - `toMatchObject({ baseUrl: \`http://${urlHost}:4100\`, host })` | ✅ PASS | +| WH-11 | `127.0.0.1` bind allows exactly `127.0.0.1:<port>` and `localhost:<port>` | `src/web/security.ts:50-51` | `tests/web-security.test.ts:160` both accepted; `:172` unapproved rejected; `:443` `deck-host:<port>` and `192.168.1.25:<port>` → `403`, body `"forbidden"`, `calls toEqual([])` (M1 killed) | ✅ PASS | +| WH-12 | non-loopback bind: exact non-wildcard bind address, current interfaces (per request, injectable, IPv6 bracketed, `%` skipped), exact `os.hostname()`, and `<hostname>.<one or more labels>.ts.net`; `<hostname>.evil.com` rejected | `src/web/security.ts:53-66,69-79` | `tests/web-security.test.ts:363` accepted IPv4, `[fd7a:115c:a1e0::1]`, `deck-host`, `DECK-HOST.tail1234.ts.net` (303) and rejected `deck-host.evil.com`, `deck-hostile`, wrong port, zone, bracketed IPv4, unbracketed IPv6, `deck-hostile.tail1234.ts.net`, `deck-host.ts.net`, `deck-host..ts.net`, `deck-host.a_b.ts.net` (403, `:388-391`); `:404` `it.each` wildcard binds `0.0.0.0` / `::` reject `0.0.0.0:<port>` / `[::]:<port>` with 403 `forbidden`, `calls toEqual([])`; `:424` unlisted bind `127.0.0.2` → 303; `:461` per-request interfaces (M2, M33, M34, M35, M36, M37 killed) | ✅ PASS | +| WH-13 | accepted Host keeps token/cookie/same-origin/`/api/*`; bare Tailscale-IP page GET = existing 403 body; `?t=` = 303 + cookie | `src/web/security.ts:105-125` | `tests/web-security.test.ts:478` - `barePage.status 403`, `body toBe(PAGE_FORBIDDEN)`, `tokenPage.status 303`, exact `set-cookie`, API without cookie 403, cross-origin POST 403 | ✅ PASS | +| WH-14 | wildcard: one `Also on http://<ip>:<port><path>?t=<token>` per non-internal IPv4 after the base line | `src/cli/web-launch.ts:93,121,144-159` | `tests/web-launch.test.ts:166` (`0.0.0.0`, `::`), `:189` daemon-reported wildcard, `:221` in-process | ✅ PASS | +| WH-15 | host outside `127.0.0.0/8` and `::1`: exact plain-HTTP warning | `src/cli/web-launch.ts:81,120,135-141` | `tests/web-launch.test.ts:129,189` exact string; `:242` none for `127.0.0.2`, `::1` | ✅ PASS | +| WH-16 | listen failure: `Failed to listen on <host>:<port>: <message>`, IPv6 bracketed, exit 1 | `src/cli/web-launch.ts:73,123,130-132` | `tests/web-launch.test.ts:291` daemon path `toEqual(["Failed to listen on [::1]:4200: listen EADDRINUSE"])`, returns 1; `:300` in-process | ✅ PASS | +| WH-17 | `preferredHost` differs and child started for preferred or none → stop + start on `preferredHost` | `src/daemon/web-supervisor.ts:151-152` | `tests/web-supervisor.test.ts:240-253` (`it.each` preferred / none) - `children[0].signals toEqual(["SIGTERM"])`, `spawns[1].args toEqual(["--web-child","--host","0.0.0.0","--preferred-port","7777"])` (M27 killed) | ✅ PASS | +| WH-18 | `preferredHost` with an explicitly bound child keeps the explicit host | `src/daemon/web-supervisor.ts:151,154` | `tests/web-supervisor.test.ts:230` - `resolves.toEqual(first)`, `spawns toHaveLength(1)`, `signals toEqual([])` (M26 killed) | ✅ PASS | +| WH-19 | neither host field: reuse running child at any host; start on `127.0.0.1` when none | `src/daemon/web-supervisor.ts:154-155` | `tests/web-supervisor.test.ts:256` reuse of a `100.64.0.5` child; `:62` fresh start args `--host 127.0.0.1` (M28 killed) | ✅ PASS | +| WH-20 | `Host: localhost:<port>` page GET on `127.0.0.1`/`0.0.0.0`/`::` bind → `302` to `http://127.0.0.1:<port>`; other binds skip it | `src/web/security.ts:191-196` | `tests/web-security.test.ts:260` loopback; `:278` `0.0.0.0` bind `status 302`, exact `location`; `:290` specific bind `403`, `location toBeUndefined()` (M31, M32 killed). `::` bind covered only through `webBaseUrl` (WH-10 tests) | ✅ PASS | +| WH-21 | invalid `host`/`preferredHost` → `WEB_BAD_HOST` before stopping a running child | `src/daemon/web-supervisor.ts:110-116` | `tests/web-supervisor.test.ts:266-277` both fields, `rejects.toMatchObject({ code: "WEB_BAD_HOST" })`, `spawns toHaveLength(1)`, `signals toEqual([])`; `tests/daemon-web.test.ts:53` IPC error code, `spawnChild not.toHaveBeenCalled()` (M38, M39, M40 killed) | ✅ PASS | +| WH-22 | successful `web.ensure` result includes `host` with `baseUrl`, port, token | `src/daemon/protocol.ts` `WebEnsureResult.host`; `src/daemon/web-supervisor.ts:296` | `tests/web-supervisor.test.ts:62` `toEqual({ baseUrl, host: "127.0.0.1", port, token })`; `:203`, `:325`; `tests/daemon-web.test.ts:31` passthrough (M41 killed) | ✅ PASS | +| WH-23 | CLI uses returned `host` for warning and wildcard links; requested host only when `host` is absent | `src/cli/web-launch.ts:80-81,93` | `tests/web-launch.test.ts:189` returned `0.0.0.0` → warning + `Also on` link; `:206` config `0.0.0.0` but returned `127.0.0.1` → `errors toEqual([])`, only the base line; `:129` fallback (`BASE` has no `host`) warns for the requested hosts (M42, M43 killed) | ✅ PASS | + +**Documentation requirement**: `README.md:110-114` and `docs/protocol.md:50-57,71-93,109` describe `web.host`, `ui --host`, child `--host`, `host`/`preferredHost`, the returned `host`, the redirect scope, and `WEB_BAD_HOST`. Asserted by `tests/web-host-docs.test.ts:12,26` (`toContain`). ✅ PASS + +**Status**: ✅ 23/23 PASS. WH-12 was a GAP in round 2; `218c1d0` added the negative cases and round 3 kills all four survivors. + +--- + +## Discrimination Sensor + +Scratch: `rsync -a --exclude .git --exclude node_modules <worktree>/ <scratchpad>/mut2-web-host/` plus `node_modules` symlink to `/home/andreello/dev/codedeck/node_modules`. A Python driver applied one exact-string replacement at a time (asserting a single match), ran the listed file with `npx vitest run --no-cache <file>`, and restored the original in a `finally`. `diff -r` of `src/` against the worktree was empty after the run. Baseline in the scratch before mutating: `tests/web-security.test.ts` 17 passed (17). + +### Round 1 re-run (first-pass survivors) + +| # | WH | File:line | Fault | Test file | Result | Killed? | +| - | -- | --------- | ----- | --------- | ------ | ------- | +| M1 | WH-11 | `src/web/security.ts:51` | `if (!security \|\| security.host === DEFAULT_WEB_HOST) return false;` → `if (!security) return false;` | `tests/web-security.test.ts` | 1 failed, 16 passed (17): `rejects machine hostname and non-loopback interface Hosts on the default bind` | ✅ Killed | +| M2 | WH-12 | `src/web/security.ts:71` | `!name.startsWith(\`${hostname}.\`)` → `!name.startsWith(hostname)` | `tests/web-security.test.ts` | 17 passed (17), exit 0 | ❌ Survived | + +M2 note: the added `deck-hostile:<port>` negative case does not kill M2 any more, because the ts.net label check now rejects it on its own. The mutant still accepts `deck-hostile.tail1234.ts.net` (probe below), which is not `<hostname>.<labels>.ts.net`. + +### Round 2 (remediation faults) + +| # | Finding / WH | File:line | Fault | Test file | Result | Killed? | +| - | ------------ | --------- | ----- | --------- | ------ | ------- | +| M26 | R1 / WH-18 | `src/daemon/web-supervisor.ts:151` | drop `&& running?.hostFor !== "explicit"` (preferredHost moves an explicit child) | `tests/web-supervisor.test.ts` | 1 failed, 40 passed (41) | ✅ Killed | +| M27 | R1 / WH-17 | `src/daemon/web-supervisor.ts:151` | condition → `params.preferredHost !== undefined && !running` (preferred child no longer moved) | same | 2 failed, 39 passed (41) | ✅ Killed | +| M28 | R1 / WH-09, WH-19 | `src/daemon/web-supervisor.ts:154` | delete the "reuse running host" branch (omitted host falls back to `127.0.0.1`) | same | 2 failed, 39 passed (41) | ✅ Killed | +| M29 | R1 / WH-04 | `src/cli/web-launch.ts:62` | always send `host` (config value becomes explicit) | `tests/web-launch.test.ts` | 3 failed, 25 passed (28) | ✅ Killed | +| M30 | R1 / WH-08 | `src/daemon/daemon.ts:1461` | autostart sends `host` instead of `preferredHost` | `tests/daemon-web.test.ts` | 4 failed, 8 passed (12) | ✅ Killed | +| M44 | WH-09 | `src/daemon/web-supervisor.ts:159` | delete the host comparison in `matches` | `tests/web-supervisor.test.ts` | 3 failed, 38 passed (41) | ✅ Killed | +| M31 | R2 / WH-20 | `src/web/security.ts:196` | revert to `if (security.host !== DEFAULT_WEB_HOST) return false;` (wildcard redirect removed) | `tests/web-security.test.ts` | 1 failed, 16 passed (17) | ✅ Killed | +| M32 | R2 / WH-20 | `src/web/security.ts:196` | delete the bind guard (redirect on every bind) | same | 1 failed, 16 passed (17) | ✅ Killed | +| M33 | R3 / WH-12 | `src/web/security.ts:57` | delete bound-address acceptance | same | 1 failed, 16 passed (17) | ✅ Killed | +| M34 | R3 / WH-12 | `src/web/security.ts:57` | drop the `0.0.0.0` / `::` exclusion (wildcard literal accepted as Host) | same | 17 passed (17) | ❌ Survived | +| M35 | R4 / WH-12 | `src/web/security.ts:73-78` | return `labels.length >= 1` (any `<hostname>.<anything>`) | same | 1 failed, 16 passed (17) | ✅ Killed | +| M36 | R4 / WH-12 | `src/web/security.ts:74` | `labels.length >= 3` → `>= 2` (accepts `<hostname>.ts.net`) | same | 17 passed (17) | ❌ Survived | +| M37 | R4 / WH-12 | `src/web/security.ts:77` | drop the per-label regex (accepts empty or invalid labels) | same | 17 passed (17) | ❌ Survived | +| M38 | R5 / WH-21 | `src/daemon/web-supervisor.ts:110-116` | delete the `WEB_BAD_HOST` check | `tests/web-supervisor.test.ts` | 2 failed, 39 passed (41) | ✅ Killed | +| M39 | R5 / WH-21 | `src/daemon/web-supervisor.ts:110-116,130` | move the check after `await this.stop(previous)` | same | 2 failed, 39 passed (41) | ✅ Killed | +| M40 | R5 / WH-21 | `src/daemon/web-supervisor.ts:111` | validate only `host`, not `preferredHost` | same | 1 failed, 40 passed (41) | ✅ Killed | +| M41 | T13 / WH-22 | `src/daemon/web-supervisor.ts:296` | drop `host` from `resultOf` | same | 6 failed, 35 passed (41) | ✅ Killed | +| M42 | T13 / WH-23 | `src/cli/web-launch.ts:80` | `activeHost = host` (ignore returned host) | `tests/web-launch.test.ts` | 2 failed, 26 passed (28) | ✅ Killed | +| M43 | T13 / WH-23 | `src/cli/web-launch.ts:80` | fallback `web.host ?? "127.0.0.1"` (ignore requested host) | same | 3 failed, 25 passed (28) | ✅ Killed | + +**Survivor impact probe** (scratch, `npx tsx probe.ts` calling `isAllowedWebHost(\`${h}:7777\`, 7777, createWebSecurity(7777, "t", { host: "0.0.0.0", hostname: () => "deck-host", networkInterfaces: () => ({}) }))`): + +| Host name | original | M2 | M34 | M36 | M37 | +| --------- | -------- | -- | --- | --- | --- | +| `deck-hostile.tail1234.ts.net` | false | **true** | false | false | false | +| `0.0.0.0` | false | false | **true** | false | false | +| `deck-host.ts.net` | false | false | false | **true** | false | +| `deck-host..ts.net` | false | false | false | false | **true** | +| `deck-host.a_b.ts.net` | false | false | false | false | **true** | +| `deck-host.tail1234.ts.net` (valid) | true | true | true | true | true | + +Each survivor accepts a Host that WH-12 excludes (`<hostname>.<one or more labels>.ts.net`, "exact non-wildcard bind address"). + +**Sensor depth**: P0-style manual run (security surface): 2 re-run + 19 new mutants +**Round 2 outcome** (historical): 17 killed, 4 survived (M2, M34, M36, M37) - ❌ FAIL + +### Round 3 (re-run of round-2 survivors after `218c1d0`) + +`git show --stat 218c1d0` touches only `.specs/features/web-host/run-notes.md`, `.specs/features/web-host/tasks.md`, and `tests/web-security.test.ts` (no file under `src/`). Scratch: `rsync -a --exclude .git --exclude node_modules <worktree>/ <scratchpad>/mut4/` plus `node_modules` symlink. A Python driver applied one exact-string replacement (asserting a single match) to a pristine copy of `src/web/security.ts`, ran `npx vitest run --no-cache tests/web-security.test.ts`, and restored the pristine file before the next fault; `cmp` against the pristine copy was clean after the run, and the scratch was deleted with `rm -r`. Baseline in the scratch: 19 passed (19). + +| # | WH | File:line | Fault | Result | Killed? | +| - | -- | --------- | ----- | ------ | ------- | +| M2 | WH-12 | `src/web/security.ts:71` | `!name.startsWith(\`${hostname}.\`)` → `!name.startsWith(hostname)` | 1 failed, 18 passed (19): `allows current interface addresses, the machine hostname, and its MagicDNS suffix` | ✅ Killed | +| M34 | WH-12 | `src/web/security.ts:57` | drop `security.host !== "0.0.0.0" && security.host !== "::" &&` | 2 failed, 17 passed (19): `rejects wildcard bind '0.0.0.0' as a Host header`, `rejects wildcard bind '::' as a Host header` | ✅ Killed | +| M36 | WH-12 | `src/web/security.ts:74` | `labels.length >= 3` → `>= 2` | 1 failed, 18 passed (19): `allows current interface addresses, ...` | ✅ Killed | +| M37 | WH-12 | `src/web/security.ts:77` | per-label regex `every(...)` → `true` | 1 failed, 18 passed (19): `allows current interface addresses, ...` | ✅ Killed | + +**Round 3 result**: 4/4 killed. **Cumulative**: 21/21 round-1/2 mutants killed - ✅ PASS + +--- + +## Code Quality + +| Principle | Status | +| --------- | ------ | +| Minimum code | ⚠️ `src/web/security.ts:62` zone guard is still redundant with `:54` (first-pass M4, equivalent) | +| Surgical changes | ✅ remediation touches only the files named in T09-T13 | +| No scope creep | ✅ | +| Matches patterns | ✅ `HostFor` mirrors the existing `StartedFor` provenance | +| Spec-anchored outcome check | ✅ exact codes, strings, and args asserted for WH-09, WH-17 to WH-23 | +| Per-layer Coverage Expectation met | ✅ web security: MagicDNS suffix boundaries and wildcard-literal rejection asserted (`tests/web-security.test.ts:388-391,404`) | +| Every test maps to a spec requirement | ✅ | +| Documented guidelines followed | ✅ `CLAUDE.md` (scoped vitest runs, English code) | + +--- + +## Edge Cases + +- [x] Invalid values include non-strings and non-IP strings: `tests/web-port.test.ts:50` +- [x] Child with no `--host` keeps loopback: `tests/web-child.test.ts:142-148` +- [x] Host change restarts even when the port allows reuse: `tests/web-supervisor.test.ts:203` +- [x] Unlisted name or wrong port forbidden: `tests/web-security.test.ts:363` +- [x] Hostname matching limited to exact hostname or `<hostname>.<labels>.ts.net`: `tests/web-security.test.ts:388-391` (M2, M36, M37 killed) +- [x] Wildcard bind literal rejected as Host: `tests/web-security.test.ts:404` (M34 killed) +- [x] Non-wildcard bind address accepted when absent from interfaces: `tests/web-security.test.ts:400` +- [x] IPv6 zone addresses excluded: `tests/web-security.test.ts:363` +- [x] `--host` validation before the daemon: `tests/web-cli.test.ts:120` +- [x] Invalid `web.ensure` host fields rejected before stopping a child: `tests/web-supervisor.test.ts:266` + +--- + +## Gate Check + +- **Build gate**: `npx tsc --noEmit -p .` → exit 0 (emitting build not run; it writes `dist/`) +- **Full gate** (round 3, worktree at `218c1d0`; the four batches were launched as concurrent tool calls, not strictly sequential): + - `npx vitest run --no-cache tests/web-port.test.ts tests/web-security.test.ts` → 2 files, 41 passed (41) + - `npx vitest run --no-cache tests/web-server.test.ts tests/web-child.test.ts` → 2 files, 36 passed (36) + - `npx vitest run --no-cache tests/web-supervisor.test.ts tests/daemon-web.test.ts` → 2 files, 53 passed (53) + - `npx vitest run --no-cache tests/web-launch.test.ts tests/web-cli.test.ts tests/web-host-docs.test.ts` → 3 files, 46 passed (46) +- **Result**: 176 passed, 0 failed, 0 skipped (round 2: 174, first pass: 161) +- **Test integrity**: remediation diff `c1c1761..HEAD` adds cases and rewrites expectations from `host` to `preferredHost`; no test removed. The shared `running()` helper moved from `describe("WebSupervisor port rules")` to module scope, same body. +- **Worktree isolation**: `git status --porcelain` was `?? .specs/features/web-host/validation.md` before and after the gate and sensor runs. + +--- + +## Fix Plans + +Fix 1 and Fix 2 are resolved by `218c1d0` (round 3). Kept for the record. + +### Fix 1: WH-12 MagicDNS suffix boundaries are untested (M2, M36, M37) + +- **Root cause**: the negative list in `tests/web-security.test.ts:383-391` has no ts.net-shaped name that breaks the hostname boundary, the label count, or the label syntax. +- **Fix task**: add `deck-hostile.tail1234.ts.net:${port}`, `deck-host.ts.net:${port}`, `deck-host..ts.net:${port}`, and `deck-host.a_b.ts.net:${port}` to that 403 list. +- **Done when**: M2, M36, and M37 are killed by `npx vitest run --no-cache tests/web-security.test.ts`. +- **Priority**: Major (M2 is the first-pass survivor and still alive) + +### Fix 2: wildcard literal Host is untested (M34) + +- **Root cause**: no test sends `Host: 0.0.0.0:<port>` or `[::]:<port>` to a wildcard-bound server. +- **Fix task**: `makeExtendedServer(networkInterfaces, () => "deck-host", { host: "0.0.0.0" })` with interfaces that do not list `0.0.0.0`, then assert `0.0.0.0:<port>` gets 403 `forbidden`; same for `{ host: "::" }` and `[::]:<port>`. +- **Done when**: M34 is killed by the same command. +- **Priority**: Minor (spec says "exact non-wildcard bind address"; token and cookie still apply) + +### Observation (not a WH gap): daemon-path listen failure names the requested host + +`src/cli/web-launch.ts:73` builds `Failed to listen on <host>:...` from the requested host. When the supervisor keeps a retained explicit bind (WH-18) and has to restart it (for example a new build) on a busy port, the message names `web.host` instead of the address that failed, because `WEB_LISTEN_FAILED` details carry only `port`. Not reproduced here; code reading only. Low. + +--- + +## Requirement Traceability Update + +`spec.md` traceability updated after round 3 (verdict PASS): + +| Requirement | Before | After | +| ----------- | ------ | ----- | +| WH-01..WH-08, WH-10, WH-11, WH-13..WH-16 | Verified | ✅ Verified | +| WH-09, WH-12, WH-17..WH-23 | Implemented | ✅ Verified (evidence above) | + +--- + +## Summary + +**Overall**: ✅ Ready + +**Spec-anchored check**: 23/23 ACs PASS, 0 spec-precision gaps +**Sensor**: 21/21 killed (round 3 killed M2, M34, M36, M37) +**Gate**: 176 passed, tsc clean + +**What works**: R1 (host provenance, explicit bind kept, preferred bind moved, no-host reuse), R2 (wildcard localhost redirect), R3 (bound address accepted), R5 (`WEB_BAD_HOST` before stop), T13 (returned host drives warning and links), and the loopback exactness gap M1 from the first pass. + +**Issues found**: none open. Fix 1 and Fix 2 (test-only) landed in `218c1d0`. The daemon-path listen-failure observation stays Low and outside the WH criteria. + +**Not covered**: `::` bind localhost redirect through a real server; interaction of retained explicit bind with a build-triggered restart that fails to listen; real Tailscale interfaces (all interface data injected); `dist/` gate scripts; lessons distillation (`lessons.py` writes outside the two files this verifier may edit).