Require the daemon secret and loopback Host/Origin on the design and iOS QA daemons (#4117)

* Require the daemon secret and loopback Host/Origin on the design and iOS QA daemons (GST-1747)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Name the changelog fragment after the branch (GST-1747)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pre-landing review fixes

Review-Finding: p1/red-team#2 reader=red-team
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: refuse cross-site browser requests on the iOS QA loopback, show the expired page for a stale board secret, refuse requests without a Host (GST-1747)

Review-Finding: v1/outside-voice#1 reader=outside-voice
Review-Finding: v1/adversarial-native#2 reader=adversarial-native
Review-Finding: v1/adversarial-native#5 reader=adversarial-native
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs: show the secret segment in the documented design board URL (GST-1747)

Review-Finding: v1/adversarial-native#1 reader=adversarial-native
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test: keep the daemon token fixtures on one line so the credential scan reads them as code (GST-1747)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs: cover the Sec-Fetch-Site refusal, the expired board page and the no-Host 403 in the changelog fragment (GST-1747)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-11-07 07:39:51 +00:00
1 parent de5cb247e0
commit 6cf00aee9c
21 files changed
+413 -89

No files matched your search

@@ -0,0 +1,4 @@
---
important: security
---
- **The design board daemon and the iOS QA daemon now serve only local clients that address them directly.** The design daemon keeps a per-daemon secret in its private (0600) state file and serves every route except `/health` under that secret, so board URLs from `$D compare --serve` now look like `http://127.0.0.1:N/<secret>/boards/<id>/`. The board page, feedback, progress polling and `BOARD_URL`-based reloads keep working unchanged, and `$D daemon stop` reads the secret from the state file. Both daemons answer 403 to requests whose Host is not a loopback address for their port, to requests with no Host header, and to requests whose Origin is another site. The iOS QA loopback also answers 403 to browser requests that `Sec-Fetch-Site` marks as cross-site or same-site; local agent clients send no such header and work as before. A board link opened from before a design daemon restart now shows a "Board expired" page instead of a bare 401, and the board page's progress polling stops at the first error response instead of reading it as progress. The iOS QA tailnet listener is unchanged. (GST-1747)
@@ -355,7 +355,7 @@ Write this round's board images (printed paths that passed checks, in order) as
$D compare --images-file "$_DESIGN_DIR/board-images.json" --output "$_DESIGN_DIR/design-board.html" --serve
```
This publishes to a persistent daemon, opens the board and exits. Read captured stderr for the startup marker; a PID is not readiness. Exit 0 with `BOARD_URL` means the daemon is serving. Save its full `http://127.0.0.1:N/boards/<id>/` URL. Only legacy `--no-daemon` needs a host background task; `SERVE_STARTED: port=N` gives root URL `http://127.0.0.1:N/`.
This publishes to a persistent daemon, opens the board and exits. Read captured stderr for the startup marker; a PID is not readiness. Exit 0 with `BOARD_URL` means the daemon is serving. Save its full `http://127.0.0.1:N/<secret>/boards/<id>/` URL. Only legacy `--no-daemon` needs a host background task; `SERVE_STARTED: port=N` gives root URL `http://127.0.0.1:N/`.
**Wait with AskUserQuestion:** "Review <BOARD_URL>, Submit or request new variants, then tell me; or paste preferences here." The board chooses; the question waits. Do not poll.
+1 -1
View File
@@ -500,7 +500,7 @@ $D compare --images-file "$_DESIGN_DIR/board-images.json" --output "$_DESIGN_DIR
Creates HTML and opens the board. **Run it in the background** (host task, or `&` redirecting stdout/stderr to private files in `$_DESIGN_DIR`). Read captured stderr for the startup marker; a PID is not readiness. Missing marker: use the failure fallback below.
Default stderr: `BOARD_URL: http://127.0.0.1:N/boards/<id>/`. Use that full per-board URL for AskUserQuestion and as the reload base. Only explicit legacy `--no-daemon` emits `SERVE_STARTED: port=XXXXX`, serving one board at `/` with reload at `/api/reload`.
Default stderr: `BOARD_URL: http://127.0.0.1:N/<secret>/boards/<id>/`. Use that exact URL for AskUserQuestion and as the reload base. Only explicit legacy `--no-daemon` emits `SERVE_STARTED: port=XXXXX`, serving one board at `/` with reload at `/api/reload`.
**PRIMARY WAIT: AskUserQuestion with board URL**
+2 -1
View File
@@ -375,7 +375,7 @@ async function main(): Promise<void> {
* Stderr lines (in order):
* - "DAEMON_STARTED port=N version=V" (or "DAEMON_ATTACHED port=N ..."
* if a daemon was already running)
* - "BOARD_PUBLISHED: http://127.0.0.1:N/boards/<id>/"
* - "BOARD_PUBLISHED: http://127.0.0.1:N/<secret>/boards/<id>/"
* - "BOARD_URL: <same url>" (alias for grep-friendliness)
* - "SERVE_STARTED: port=N html=<path>" (legacy back-compat alias for
* any external script that scraped the pre-daemon output — note the
@@ -395,6 +395,7 @@ async function publishToDaemon(opts: { html: string; title?: string }): Promise<
);
const result = await publishBoard({
port: ensured.port,
token: ensured.token,
html: opts.html,
title: opts.title,
});
+4 -1
View File
@@ -535,7 +535,10 @@ export function generateCompareHtml(images: string[]): string {
return;
}
fetch('./api/progress')
.then(function(r) { return r.json(); })
.then(function(r) {
if (!r.ok) throw new Error('progress ' + r.status);
return r.json();
})
.then(function(data) {
if (data.status === 'serving') {
clearInterval(pollInterval);
+29 -17
View File
@@ -33,6 +33,7 @@ import { setTimeout as delay } from "timers/promises";
import {
acquireLock,
CMDLINE_MARKER,
type DaemonState,
healthCheck,
isProcessAlive,
readStateFile,
@@ -70,6 +71,7 @@ export interface EnsureDaemonOptions {
export interface EnsureDaemonResult {
port: number;
token: string;
version: string;
spawned: boolean;
}
@@ -97,14 +99,19 @@ export async function ensureDaemon(
if (existing) {
const health = await healthCheck(existing.port);
if (health) {
if (health.version === expectedVersion) {
if (health.version === expectedVersion && existing.token) {
log(verbose, `attached to existing daemon pid=${existing.pid} port=${existing.port}`);
return { port: existing.port, version: health.version, spawned: false };
return { port: existing.port, token: existing.token, version: health.version, spawned: false };
}
// Version mismatch: refuse if active boards exist (Codex finding).
// Version mismatch, or a daemon from before per-daemon secrets: refuse if
// active boards exist (Codex finding).
if (health.activeBoards > 0) {
const mismatch =
health.version === expectedVersion
? `existing daemon (gstack ${health.version}) predates per-daemon secrets`
: `existing daemon is gstack ${health.version}; this CLI is ${expectedVersion}`;
process.stderr.write(
`[design-daemon] WARNING: existing daemon is gstack ${health.version}; this CLI is ${expectedVersion}.\n` +
`[design-daemon] WARNING: ${mismatch}.\n` +
`[design-daemon] ${health.activeBoards} active board(s) detected. Refusing to auto-kill.\n` +
`[design-daemon] Submit or close the open boards, then re-run.\n` +
`[design-daemon] Or force restart: $D daemon stop (will drop in-memory history).\n`,
@@ -113,7 +120,7 @@ export async function ensureDaemon(
}
// No active boards — safe to graceful-shutdown and respawn.
log(verbose, `daemon version mismatch (${health.version} vs ${expectedVersion}); shutting down`);
await gracefulShutdownExistingDaemon(existing.port);
await gracefulShutdownExistingDaemon(existing.port, existing.token);
await killByPidWithIdentity(existing.pid, existing.cmdlineMarker, verbose);
} else {
// State file points at an unresponsive port. Either the daemon
@@ -136,7 +143,7 @@ export async function ensureDaemon(
const fresh = readStateFile(stateFile);
if (fresh) {
const h = await healthCheck(fresh.port);
if (h) return { port: fresh.port, version: h.version, spawned: false };
if (h && fresh.token) return { port: fresh.port, token: fresh.token, version: h.version, spawned: false };
}
await delay(POLL_INTERVAL_MS);
}
@@ -149,20 +156,20 @@ export async function ensureDaemon(
const fresh = readStateFile(stateFile);
if (fresh) {
const h = await healthCheck(fresh.port);
if (h && h.version === expectedVersion) {
if (h && h.version === expectedVersion && fresh.token) {
log(verbose, `another CLI won the lock; attaching pid=${fresh.pid} port=${fresh.port}`);
return { port: fresh.port, version: h.version, spawned: false };
return { port: fresh.port, token: fresh.token, version: h.version, spawned: false };
}
}
log(verbose, "spawning new daemon");
const port = await spawnDaemon({
const spawned = await spawnDaemon({
script: opts.daemonScript,
env: { ...opts.daemonEnv, DESIGN_DAEMON_STATE_FILE: stateFile },
stateFile,
expectedVersion,
});
return { port, version: expectedVersion, spawned: true };
return { port: spawned.port, token: spawned.token, version: expectedVersion, spawned: true };
} finally {
release();
}
@@ -174,6 +181,8 @@ export async function ensureDaemon(
*/
export interface PublishBoardOptions {
port: number;
/** The daemon's secret from its state file (EnsureDaemonResult.token). */
token: string;
html: string;
title?: string;
publisherPid?: number;
@@ -191,7 +200,7 @@ export async function publishBoard(opts: PublishBoardOptions): Promise<PublishBo
publisherPid: opts.publisherPid ?? process.pid,
};
if (opts.title) body.title = opts.title;
const resp = await fetch(`http://127.0.0.1:${opts.port}/api/boards`, {
const resp = await fetch(`http://127.0.0.1:${opts.port}/${opts.token}/api/boards`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
@@ -265,7 +274,7 @@ interface SpawnDaemonOpts {
expectedVersion: string;
}
async function spawnDaemon(opts: SpawnDaemonOpts): Promise<number> {
async function spawnDaemon(opts: SpawnDaemonOpts): Promise<DaemonState> {
const logPath = resolveStartupLogPath();
fs.mkdirSync(path.dirname(logPath), { recursive: true });
// Truncate the startup log on each spawn so a later read finds only THIS
@@ -291,9 +300,9 @@ async function spawnDaemon(opts: SpawnDaemonOpts): Promise<number> {
const deadline = Date.now() + MAX_START_WAIT_MS;
while (Date.now() < deadline) {
const fresh = readStateFile(opts.stateFile);
if (fresh) {
if (fresh?.token) {
const h = await healthCheck(fresh.port);
if (h) return fresh.port;
if (h) return fresh;
}
await delay(POLL_INTERVAL_MS);
}
@@ -312,9 +321,12 @@ async function spawnDaemon(opts: SpawnDaemonOpts): Promise<number> {
);
}
async function gracefulShutdownExistingDaemon(port: number): Promise<void> {
async function gracefulShutdownExistingDaemon(port: number, token: string | undefined): Promise<void> {
// A daemon from before per-daemon secrets has no token; the caller's
// identity-checked SIGTERM stops it instead.
if (!token) return;
try {
await fetch(`http://127.0.0.1:${port}/shutdown`, {
await fetch(`http://127.0.0.1:${port}/${token}/shutdown`, {
method: "POST",
signal: AbortSignal.timeout(2000),
});
@@ -390,7 +402,7 @@ export async function shutdownDaemon(opts: { force?: boolean } = {}): Promise<{
activeBoards: health.activeBoards,
};
}
await gracefulShutdownExistingDaemon(existing.port);
await gracefulShutdownExistingDaemon(existing.port, existing.token);
// Best-effort: SIGTERM if /shutdown didn't take effect
if (isProcessAlive(existing.pid)) {
await killByPidWithIdentity(existing.pid, existing.cmdlineMarker, true);
+1
View File
@@ -20,6 +20,7 @@ export interface DaemonState {
version: string;
serverPath: string;
cmdlineMarker: string;
token: string; // per-daemon secret; every route but /health lives under /<token>
}
// String we grep for in the spawned daemon's cmdline to confirm a pid is
+54 -23
View File
@@ -7,16 +7,20 @@
* serve.ts as the default for `$D compare --serve`; serve.ts is kept as
* the --no-daemon legacy/test path.
*
* Endpoints (see plan docs/designs path for full table):
* GET / index of boards
* GET /health liveness + version (unauth)
* POST /api/boards publish a new board
* POST /shutdown graceful exit (refused if active)
* GET /boards/<id> 301 → /boards/<id>/
* GET /boards/<id>/ render board HTML
* GET /boards/<id>/api/progress state machine status
* POST /boards/<id>/api/feedback submit/regenerate
* POST /boards/<id>/api/reload swap board HTML
* Endpoints (see plan docs/designs path for full table). Every route but
* /health lives under /<secret>, the per-daemon token kept in the 0600 state
* file; published board URLs carry it, so board JS and the agent reach the
* board through the URL alone. Requests with a non-loopback Host or a
* cross-origin Origin are refused before routing.
* GET /health liveness + version (no secret)
* GET /<secret>/ index of boards
* POST /<secret>/api/boards publish a new board
* POST /<secret>/shutdown graceful exit (refused if active)
* GET /<secret>/boards/<id> 301 → /<secret>/boards/<id>/
* GET /<secret>/boards/<id>/ render board HTML
* GET /<secret>/boards/<id>/api/progress state machine status
* POST /<secret>/boards/<id>/api/feedback submit/regenerate
* POST /<secret>/boards/<id>/api/reload swap board HTML
*
* Lifecycle:
* start → bind 127.0.0.1:N → write state file → serve until 24h idle or
@@ -27,9 +31,12 @@
* Both are Codex-flagged guards against silent loss of in-memory history.
*/
import { randomBytes, timingSafeEqual } from "crypto";
import fs from "fs";
import path from "path";
import { loopbackRequestRefusal } from "../../lib/loopback-request";
import {
CMDLINE_MARKER,
DaemonState,
@@ -58,6 +65,8 @@ const IDLE_CHECK_INTERVAL_MS = parseInt(
const MAX_BOARDS = parseInt(process.env.DESIGN_DAEMON_MAX_BOARDS || "50", 10);
const VERSION = readVersionString();
const DAEMON_TOKEN = randomBytes(32).toString("hex");
const ROUTE_PREFIX = `/${DAEMON_TOKEN}`;
// ─── Per-board state ─────────────────────────────────────────────
@@ -252,7 +261,7 @@ function handleIndex(): Response {
.map((b) => {
const ts = new Date(b.publishedAt).toISOString();
const titleSuffix = b.title ? ` — ${escapeHtml(b.title)}` : "";
return `<li><a href="/boards/${b.id}/">${b.id}</a> <span class="state state-${b.state}">${b.state}</span> <time>${ts}</time>${titleSuffix}</li>`;
return `<li><a href="${ROUTE_PREFIX}/boards/${b.id}/">${b.id}</a> <span class="state state-${b.state}">${b.state}</span> <time>${ts}</time>${titleSuffix}</li>`;
})
.join("\n");
const empty = `<p class="empty">No boards yet. Run <code>$D compare --serve</code> to publish one.</p>`;
@@ -280,7 +289,7 @@ ${list}
return new Response(html, { headers: { "Content-Type": "text/html; charset=utf-8" } });
}
async function handlePublish(req: Request, origin: string): Promise<Response> {
async function handlePublish(req: Request, baseUrl: string): Promise<Response> {
let body: any;
try {
body = await req.json();
@@ -319,7 +328,7 @@ async function handlePublish(req: Request, origin: string): Promise<Response> {
error: "Source directory already in use by an active board",
existing: {
id: existing.id,
url: `${origin}/boards/${existing.id}/`,
url: `${baseUrl}/boards/${existing.id}/`,
state: existing.state,
},
},
@@ -355,7 +364,7 @@ async function handlePublish(req: Request, origin: string): Promise<Response> {
dlog(`published board ${id} sourceDir=${sourceDir} pid=${board.publisherPid}`);
return Response.json({
id,
url: `${origin}/boards/${id}/`,
url: `${baseUrl}/boards/${id}/`,
sourceDir,
});
}
@@ -458,14 +467,14 @@ async function handleBoardReload(board: Board, req: Request): Promise<Response>
return Response.json({ reloaded: true });
}
function boardExpiredHtml(id: string): string {
function boardExpiredHtml(id: string, indexLink = true): string {
return `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><title>Board expired — gstack</title>
<style>body{font:14px/1.5 -apple-system,system-ui,sans-serif;max-width:600px;margin:80px auto;padding:0 20px;color:#1a1a1a;text-align:center}
h1{font-size:20px}.id{font-family:ui-monospace,monospace;color:#888;font-size:13px}
a{color:#0070f3;text-decoration:none}a:hover{text-decoration:underline}</style></head><body>
<h1>Board expired</h1>
<p>Board <span class="id">${escapeHtml(id)}</span> is no longer hosted by this daemon (evicted or the daemon restarted).</p>
<p><a href="/">← see active boards</a></p>
${indexLink ? `<p><a href="${ROUTE_PREFIX}/">← see active boards</a></p>` : ""}
</body></html>`;
}
@@ -474,14 +483,34 @@ a{color:#0070f3;text-decoration:none}a:hover{text-decoration:underline}</style><
const BOARD_RE = /^\/boards\/([A-Za-z0-9_-]+)(\/.*)?$/;
export async function fetchHandler(req: Request): Promise<Response> {
// Without a Host header Bun's req.url is a bare path; refuse it before parsing.
if (!URL.canParse(req.url)) return Response.json({ error: "host_not_loopback" }, { status: 403 });
const url = new URL(req.url);
const origin = url.origin;
const refusal = loopbackRequestRefusal(url.host, req.headers.get("origin"), serverRef?.port ?? Number(url.port));
if (refusal) return Response.json({ error: refusal }, { status: 403 });
if (req.method === "GET" && url.pathname === "/health") return handleHealth();
if (req.method === "GET" && url.pathname === "/") return handleIndex();
if (req.method === "POST" && url.pathname === "/api/boards") return handlePublish(req, origin);
if (req.method === "POST" && url.pathname === "/shutdown") {
const [, secret = "", ...rest] = url.pathname.split("/");
const presented = Buffer.from(secret);
const expected = Buffer.from(DAEMON_TOKEN);
if (presented.length !== expected.length || !timingSafeEqual(presented, expected)) {
// A board page opened from before a daemon restart carries the old
// secret: show the expired page, minus the link that would carry this one.
const stale = `/${rest.join("/")}`.match(BOARD_RE);
if (req.method === "GET" && stale && (stale[2] ?? "/") === "/") {
return new Response(boardExpiredHtml(stale[1]!, false), {
status: 401,
headers: { "Content-Type": "text/html; charset=utf-8" },
});
}
return Response.json({ error: "unauthorized" }, { status: 401 });
}
const pathname = `/${rest.join("/")}`;
if (req.method === "GET" && pathname === "/") return handleIndex();
if (req.method === "POST" && pathname === "/api/boards") return handlePublish(req, url.origin + ROUTE_PREFIX);
if (req.method === "POST" && pathname === "/shutdown") {
if (hasActiveBoards()) {
return Response.json(
{
@@ -495,7 +524,7 @@ export async function fetchHandler(req: Request): Promise<Response> {
return Response.json({ shuttingDown: true });
}
const m = url.pathname.match(BOARD_RE);
const m = pathname.match(BOARD_RE);
if (m) {
const id = m[1]!;
const subpath = m[2] || "";
@@ -511,7 +540,7 @@ export async function fetchHandler(req: Request): Promise<Response> {
if (req.method === "GET" && subpath === "") {
return new Response(null, {
status: 301,
headers: { Location: `/boards/${id}/` },
headers: { Location: `${ROUTE_PREFIX}/boards/${id}/` },
});
}
if (req.method === "GET" && subpath === "/") return handleBoardGet(board);
@@ -546,6 +575,7 @@ export function start(): { port: number } {
version: VERSION,
serverPath: process.argv[1] || "",
cmdlineMarker: CMDLINE_MARKER,
token: DAEMON_TOKEN,
};
writeStateFile(state);
dlog(`DAEMON_STARTED port=${actualPort} pid=${process.pid} version=${VERSION}`);
@@ -574,6 +604,7 @@ if (import.meta.main) {
// Exported for tests. Keep this small and stable.
export const __testInternals__ = {
token: DAEMON_TOKEN,
boards,
fetchHandler,
idleCheckTick,
+129
View File
@@ -0,0 +1,129 @@
/**
* Request checks for the design daemon: every route except /health needs
* the per-daemon secret from the private state file, and only loopback
* Host and Origin values are served. Spawns a real daemon so Host comes
* from the wire, the way Bun.serve sees it in production.
*/
import { afterEach, beforeEach, expect, test } from "bun:test";
import fs from "fs";
import path from "path";
import { publishBoard } from "../src/daemon-client";
import { makeBoardHtml, makeTmpDir, spawnDaemonForTest, type SpawnedDaemon } from "./daemon-tests-fixtures";
let workDir: string;
let daemon: SpawnedDaemon;
beforeEach(async () => {
workDir = makeTmpDir("daemon-access");
daemon = await spawnDaemonForTest({ stateFile: path.join(workDir, "design.json") });
});
afterEach(async () => {
await daemon.stop();
fs.rmSync(workDir, { recursive: true, force: true });
});
const base = () => `http://127.0.0.1:${daemon.port}`;
function boardDir(name: string): string {
const dir = path.join(workDir, name);
fs.mkdirSync(dir);
return dir;
}
test("the state file carries a private per-daemon secret", () => {
expect(daemon.token).toMatch(/^[0-9a-f]{64}$/);
if (process.platform !== "win32") expect(fs.statSync(daemon.stateFile).mode & 0o077).toBe(0);
});
test("routes other than /health refuse requests without the daemon secret", async () => {
const published = await publishBoard({ port: daemon.port, token: daemon.token, html: makeBoardHtml(boardDir("a")) });
const other = makeBoardHtml(boardDir("b"));
const attempts: Array<[string, string, unknown?]> = [
["GET", "/"],
["POST", "/api/boards", { html: other }],
["POST", "/shutdown"],
["GET", `/boards/${published.id}/`],
["GET", `/boards/${published.id}/api/progress`],
["POST", `/boards/${published.id}/api/feedback`, { regenerated: true }],
["POST", `/boards/${published.id}/api/reload`, { html: other }],
["GET", `/${"0".repeat(64)}/`],
];
for (const [method, route, body] of attempts) {
const res = await fetch(base() + route, {
method,
headers: { "Content-Type": "text/plain" },
body: body === undefined ? undefined : JSON.stringify(body),
});
expect({ route, status: res.status }).toEqual({ route, status: 401 });
}
expect(fs.existsSync(path.join(published.sourceDir, "feedback-pending.json"))).toBe(false);
expect((await (await fetch(base() + "/health")).json()) as object).toMatchObject({ ok: true, boards: 1 });
});
test("a non-loopback Host is refused, /health included", async () => {
for (const route of ["/health", `/${daemon.token}/`, `/${daemon.token}/api/boards`]) {
const res = await fetch(base() + route, {
method: route.endsWith("/api/boards") ? "POST" : "GET",
headers: { Host: `board.example:${daemon.port}`, "Content-Type": "text/plain" },
body: route.endsWith("/api/boards") ? JSON.stringify({ html: makeBoardHtml(boardDir("host")) }) : undefined,
});
expect({ route, status: res.status }).toEqual({ route, status: 403 });
}
const health = (await (await fetch(base() + "/health")).json()) as { boards: number };
expect(health.boards).toBe(0);
});
test("a cross-origin Origin is refused even with the daemon secret", async () => {
const published = await publishBoard({ port: daemon.port, token: daemon.token, html: makeBoardHtml(boardDir("origin")) });
for (const origin of ["http://board.example", "null", `http://127.0.0.1:${daemon.port + 1}`]) {
const res = await fetch(`${published.url}api/feedback`, {
method: "POST",
headers: { Origin: origin, "Content-Type": "text/plain" },
body: JSON.stringify({ regenerated: false }),
});
expect({ origin, status: res.status }).toEqual({ origin, status: 403 });
}
expect(fs.existsSync(path.join(published.sourceDir, "feedback.json"))).toBe(false);
});
test("the published board URL carries the secret and its same-origin page requests work", async () => {
const published = await publishBoard({ port: daemon.port, token: daemon.token, html: makeBoardHtml(boardDir("ok"), "<p>local board</p>") });
expect(published.url).toBe(`${base()}/${daemon.token}/boards/${published.id}/`);
expect(await (await fetch(published.url)).text()).toContain("local board");
const submit = await fetch(`${published.url}api/feedback`, {
method: "POST",
headers: { Origin: base(), "Content-Type": "application/json" },
body: JSON.stringify({ preferred: "A", regenerated: false }),
});
expect(submit.status).toBe(200);
expect(JSON.parse(fs.readFileSync(path.join(published.sourceDir, "feedback.json"), "utf-8")).boardId).toBe(published.id);
const index = await fetch(`${base()}/${daemon.token}/`, { headers: { Host: `localhost:${daemon.port}` } });
expect(index.status).toBe(200);
expect(await index.text()).toContain(`/${daemon.token}/boards/${published.id}/`);
});
test("a request without a Host header is refused, not answered with a server error", async () => {
const { connect } = await import("net");
const statusLine = await new Promise<string>((resolve, reject) => {
const socket = connect(daemon.port, "127.0.0.1", () => socket.write(`GET /${daemon.token}/ HTTP/1.0\r\n\r\n`));
let data = "";
socket.on("data", (chunk) => (data += chunk));
socket.on("end", () => resolve(data.split("\r\n")[0] ?? ""));
socket.on("error", reject);
});
expect(statusLine).toContain(" 403");
});
test("a board page requested with a stale secret shows the expired page without the current secret", async () => {
const published = await publishBoard({ port: daemon.port, token: daemon.token, html: makeBoardHtml(boardDir("stale")) });
const res = await fetch(`${base()}/${"0".repeat(64)}/boards/${published.id}/`);
expect(res.status).toBe(401);
const body = await res.text();
expect(body).toContain("Board expired");
expect(body).not.toContain(daemon.token);
const progress = await fetch(`${base()}/${"0".repeat(64)}/boards/${published.id}/api/progress`);
expect(progress.status).toBe(401);
});
+14 -17
View File
@@ -136,7 +136,7 @@ describe("ensureDaemon", () => {
// Track for cleanup
activeDaemons.push({
proc: { pid: state!.pid } as any,
port: state!.port,
port: state!.port, token: state!.token,
stateFile,
stop: async () => {
try { process.kill(state!.pid, "SIGTERM"); } catch {}
@@ -181,7 +181,7 @@ describe("ensureDaemon", () => {
activeDaemons.push({
proc: { pid: fresh!.pid } as any,
port: fresh!.port,
port: fresh!.port, token: fresh!.token,
stateFile,
stop: async () => { try { process.kill(fresh!.pid, "SIGTERM"); } catch {} },
});
@@ -216,7 +216,7 @@ describe("ensureDaemon", () => {
expect(fresh!.pid).not.toBe(process.pid);
activeDaemons.push({
proc: { pid: fresh!.pid } as any,
port: fresh!.port,
port: fresh!.port, token: fresh!.token,
stateFile,
stop: async () => { try { process.kill(fresh!.pid, "SIGTERM"); } catch {} },
});
@@ -245,7 +245,7 @@ describe("ensureDaemon", () => {
expect(fresh!.pid).not.toBe(existing.proc.pid);
activeDaemons.push({
proc: { pid: fresh!.pid } as any,
port: fresh!.port,
port: fresh!.port, token: fresh!.token,
stateFile,
stop: async () => { try { process.kill(fresh!.pid, "SIGTERM"); } catch {} },
});
@@ -258,7 +258,7 @@ describe("ensureDaemon", () => {
// Publish a board so activeBoards > 0
const html = makeBoardHtml(workDir);
await publishBoard({ port: existing.port, html });
await publishBoard({ port: existing.port, token: existing.token, html });
// Sanity: status should reflect the active board
const statusResp = await fetch(`http://127.0.0.1:${existing.port}/health`);
@@ -309,9 +309,9 @@ describe("publishBoard", () => {
test("publishes a board through the real HTTP path and returns id+url+sourceDir", async () => {
const d = await spawn1();
const htmlPath = makeBoardHtml(workDir, "<p>via-client</p>");
const result = await publishBoard({ port: d.port, html: htmlPath });
const result = await publishBoard({ port: d.port, token: d.token, html: htmlPath });
expect(result.id).toMatch(/^b-/);
expect(result.url).toBe(`http://127.0.0.1:${d.port}/boards/${result.id}/`);
expect(result.url).toBe(`http://127.0.0.1:${d.port}/${d.token}/boards/${result.id}/`);
expect(result.sourceDir).toBe(fs.realpathSync(workDir));
// Confirm the board is actually fetchable at the returned URL
@@ -324,9 +324,9 @@ describe("publishBoard", () => {
test("409 surfaces existing board's id+url (returned object, no throw)", async () => {
const d = await spawn1();
const htmlPath = makeBoardHtml(workDir);
const first = await publishBoard({ port: d.port, html: htmlPath });
const first = await publishBoard({ port: d.port, token: d.token, html: htmlPath });
const htmlPath2 = makeBoardHtml(workDir, "<p>second</p>");
const second = await publishBoard({ port: d.port, html: htmlPath2 });
const second = await publishBoard({ port: d.port, token: d.token, html: htmlPath2 });
// Same sourceDir → 409 with `existing` field; publishBoard returns it
// so the caller can attach to the existing board.
expect(second.id).toBe(first.id);
@@ -366,7 +366,7 @@ describe("shutdownDaemon + daemonStatus", () => {
test("shutdownDaemon refuses (without force) when active boards present", async () => {
const d = await spawn1();
await publishBoard({ port: d.port, html: makeBoardHtml(workDir) });
await publishBoard({ port: d.port, token: d.token, html: makeBoardHtml(workDir) });
const r = await shutdownDaemon();
expect(r.stopped).toBe(false);
expect(r.reason).toContain("active");
@@ -377,7 +377,7 @@ describe("shutdownDaemon + daemonStatus", () => {
test("shutdownDaemon with force=true ignores active boards", async () => {
const d = await spawn1();
await publishBoard({ port: d.port, html: makeBoardHtml(workDir) });
await publishBoard({ port: d.port, token: d.token, html: makeBoardHtml(workDir) });
const r = await shutdownDaemon({ force: true });
expect(r.stopped).toBe(true);
});
@@ -438,10 +438,7 @@ describe("daemon idle-shutdown behavior (real process)", () => {
let pollCount = 0;
const boardDir = makeTmpDir("idle-poll");
try {
const board = await publishBoard({
port: d.port,
html: makeBoardHtml(boardDir),
});
const board = await publishBoard({ port: d.port, token: d.token, html: makeBoardHtml(boardDir) });
// Submit so the board becomes `done` — non-done would trigger the
// 1h extension path and keep the daemon alive past IDLE_MS.
await fetch(`${board.url}api/feedback`, {
@@ -498,7 +495,7 @@ describe("daemon idle-shutdown behavior (real process)", () => {
});
const boardDir = makeTmpDir("idle-active");
try {
await publishBoard({ port: d.port, html: makeBoardHtml(boardDir) });
await publishBoard({ port: d.port, token: d.token, html: makeBoardHtml(boardDir) });
// Daemon has 1 non-done board. After IDLE_MS, idleCheckTick should
// extend rather than shut down. So at IDLE_MS + small margin, it's
// still alive.
@@ -553,7 +550,7 @@ describe("concurrent ensureDaemon race", () => {
// Track for cleanup
activeDaemons.push({
proc: { pid: state!.pid } as any,
port: state!.port,
port: state!.port, token: state!.token,
stateFile,
stop: async () => {
try { process.kill(state!.pid, "SIGTERM"); } catch {}
+9 -2
View File
@@ -17,6 +17,7 @@ import os from "os";
import path from "path";
import { __testInternals__ } from "../src/daemon";
import { readStateFile } from "../src/daemon-state";
export const DAEMON_SCRIPT = path.join(import.meta.dir, "..", "src", "daemon.ts");
@@ -38,19 +39,24 @@ export function resetDaemon(): void {
__testInternals__.resetForTest();
}
/** Build a Request for the in-process fetchHandler tests. */
/**
* Build a Request for the in-process fetchHandler tests. Every route but
* /health is addressed under the daemon's secret prefix.
*/
export function req(method: string, urlPath: string, body?: unknown): Request {
const init: RequestInit = { method };
if (body !== undefined) {
init.body = typeof body === "string" ? body : JSON.stringify(body);
init.headers = { "Content-Type": "application/json" };
}
return new Request(`http://127.0.0.1:1234${urlPath}`, init);
const prefix = urlPath === "/health" ? "" : `/${__testInternals__.token}`;
return new Request(`http://127.0.0.1:1234${prefix}${urlPath}`, init);
}
export interface SpawnedDaemon {
proc: ChildProcess;
port: number;
token: string;
stateFile: string;
stop: () => Promise<void>;
}
@@ -114,6 +120,7 @@ export async function spawnDaemonForTest(
return {
proc,
port,
token: readStateFile(stateFile)?.token ?? "",
stateFile,
stop: async () => {
if (proc.exitCode !== null || proc.signalCode !== null) return;
+5 -5
View File
@@ -154,7 +154,7 @@ describe("daemon /boards/<id> trailing-slash redirect", () => {
const board = await publishTestBoard();
const r = await fetchHandler(req("GET", `/boards/${board.id}`));
expect(r.status).toBe(301);
expect(r.headers.get("Location")).toBe(`/boards/${board.id}/`);
expect(r.headers.get("Location")).toBe(`/${__testInternals__.token}/boards/${board.id}/`);
});
test("GET /boards/<id>/ renders the board's HTML", async () => {
@@ -247,7 +247,7 @@ describe("daemon /boards/<id>/api/feedback", () => {
test("rejects malformed JSON body", async () => {
const board = await publishTestBoard();
const bad = new Request(`http://127.0.0.1/boards/${board.id}/api/feedback`, {
const bad = new Request(`http://127.0.0.1:1234/${__testInternals__.token}/boards/${board.id}/api/feedback`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "{not json",
@@ -477,7 +477,7 @@ describe("daemon idle + activity tracking (smoke)", () => {
describe("daemon malformed body handling", () => {
test("POST /api/boards rejects invalid JSON body with 400", async () => {
const bad = new Request("http://127.0.0.1:1234/api/boards", {
const bad = new Request(`http://127.0.0.1:1234/${__testInternals__.token}/api/boards`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "{not json",
@@ -493,7 +493,7 @@ describe("daemon malformed body handling", () => {
// !body || typeof body !== "object" guard and fail at the missing-html
// check below. The "Expected JSON object" path only fires for genuinely
// non-object values like null, numbers, strings.
const bad = new Request("http://127.0.0.1:1234/api/boards", {
const bad = new Request(`http://127.0.0.1:1234/${__testInternals__.token}/api/boards`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "null",
@@ -517,7 +517,7 @@ describe("daemon malformed body handling", () => {
test("POST /boards/<id>/api/reload rejects invalid JSON body with 400", async () => {
const board = await publishTestBoard();
const bad = new Request(
`http://127.0.0.1:1234/boards/${board.id}/api/reload`,
`http://127.0.0.1:1234/${__testInternals__.token}/boards/${board.id}/api/reload`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -68,8 +68,8 @@ describe("daemon round-trip: publish → submit → feedback.json", () => {
const boardDir = makeTmpDir("board-submit");
try {
const htmlPath = makeBoardHtml(boardDir, "<p>round-trip board</p>");
const board = await publishBoard({ port: d.port, html: htmlPath });
expect(board.url).toBe(`http://127.0.0.1:${d.port}/boards/${board.id}/`);
const board = await publishBoard({ port: d.port, token: d.token, html: htmlPath });
expect(board.url).toBe(`http://127.0.0.1:${d.port}/${d.token}/boards/${board.id}/`);
expect(board.sourceDir).toBe(fs.realpathSync(boardDir));
// GET the board URL — same path the browser would hit
@@ -124,14 +124,15 @@ describe("daemon round-trip: publish → submit → feedback.json", () => {
try {
const board = await publishBoard({
port: d.port,
token: d.token,
html: makeBoardHtml(boardDir),
});
// Use redirect: 'manual' so we observe the 301 response itself
const res = await fetch(`http://127.0.0.1:${d.port}/boards/${board.id}`, {
const res = await fetch(`http://127.0.0.1:${d.port}/${d.token}/boards/${board.id}`, {
redirect: "manual",
});
expect(res.status).toBe(301);
expect(res.headers.get("Location")).toBe(`/boards/${board.id}/`);
expect(res.headers.get("Location")).toBe(`/${d.token}/boards/${board.id}/`);
} finally {
try { fs.rmSync(boardDir, { recursive: true, force: true }); } catch {}
}
@@ -146,7 +147,7 @@ describe("daemon round-trip: publish → regenerate → reload → submit round
const boardDir = makeTmpDir("board-regen");
try {
const r1Path = makeBoardHtml(boardDir, "<p>round 1 variants</p>");
const board = await publishBoard({ port: d.port, html: r1Path });
const board = await publishBoard({ port: d.port, token: d.token, html: r1Path });
// Skill issues a regenerate via the board JS path
const regen = await fetch(`${board.url}api/feedback`, {
@@ -219,8 +220,8 @@ describe("daemon round-trip: two concurrent publishes share one daemon", () => {
const dirA = makeTmpDir("two-a");
const dirB = makeTmpDir("two-b");
try {
const a = await publishBoard({ port: d.port, html: makeBoardHtml(dirA) });
const b = await publishBoard({ port: d.port, html: makeBoardHtml(dirB) });
const a = await publishBoard({ port: d.port, token: d.token, html: makeBoardHtml(dirA) });
const b = await publishBoard({ port: d.port, token: d.token, html: makeBoardHtml(dirB) });
// Same daemon process — state file pid is stable
const state = readStateFile(stateFile);
@@ -245,7 +246,7 @@ describe("daemon round-trip: two concurrent publishes share one daemon", () => {
expect(fs.existsSync(path.join(b.sourceDir, "feedback.json"))).toBe(false);
// Index page lists both
const idx = await fetch(`http://127.0.0.1:${d.port}/`);
const idx = await fetch(`http://127.0.0.1:${d.port}/${d.token}/`);
const idxHtml = await idx.text();
expect(idxHtml).toContain(a.id);
expect(idxHtml).toContain(b.id);
+18 -2
View File
@@ -25,6 +25,7 @@ import { bootstrapTunnel, recoverTunnel, type TunnelRecovery } from './tunnel-bo
import { startTunnelKeepalive, type SpawnImpl, type ResolveImpl } from './devicectl';
import { clearSession, defaultSessionPath, resumeSession, writeSession } from './session-cache';
import type { Capability } from './types';
import { loopbackRequestRefusal } from '../../../lib/loopback-request';
interface DaemonOptions {
loopbackPort: number;
@@ -389,8 +390,10 @@ function sendJson(res: ServerResponse, status: number, body: unknown): void {
}
/**
* Loopback handler — full surface for the spawning agent. No auth (the
* loopback bind itself is the boundary).
* Loopback handler — full surface for the spawning agent. No bearer: the
* loopback bind plus the Host, Origin and Sec-Fetch-Site checks are the
* boundary, so only local clients that address the bound port directly are
* served.
*/
async function handleLoopback(ctx: HandlerCtx): Promise<void> {
const { req, res, tokenStore, getTunnel, invalidateTunnel, refreshTunnel } = ctx;
@@ -399,6 +402,19 @@ async function handleLoopback(ctx: HandlerCtx): Promise<void> {
const method = req.method ?? 'GET';
try {
const refusal = loopbackRequestRefusal(req.headers.host, req.headers.origin, req.socket.localPort ?? 0);
if (refusal) {
sendJson(res, 403, { error: refusal });
return;
}
// Browsers omit Origin on no-cors GETs (an <img> tag) but always send
// Sec-Fetch-Site; local agent clients send neither.
const fetchSite = req.headers['sec-fetch-site'];
if (fetchSite === 'cross-site' || fetchSite === 'same-site') {
sendJson(res, 403, { error: 'cross_site' });
return;
}
// /healthz — public on loopback.
if (method === 'GET' && path === '/healthz') {
sendJson(res, 200, { version: '1.0.0', mode: 'loopback' });
@@ -0,0 +1,95 @@
// Loopback listener request checks: only a loopback Host for the bound
// port is served, and a request carrying a cross-origin Origin is refused
// before anything reaches the device.
import { afterAll, beforeAll, expect, test } from 'bun:test';
import { createServer, type Server } from 'http';
import { mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { startDaemon, type RunningDaemon } from '../src/index';
let workDir: string;
let device: Server;
let daemon: RunningDaemon;
const deviceRequests: string[] = [];
beforeAll(async () => {
workDir = mkdtempSync(join(tmpdir(), 'ios-qa-loopback-request-'));
device = createServer((req, res) => {
deviceRequests.push(`${req.method} ${req.url}`);
req.resume();
req.on('end', () => {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
});
});
await new Promise<void>((resolve) => device.listen(0, '127.0.0.1', () => resolve()));
const devicePort = (device.address() as { port: number }).port;
const d = await startDaemon({
loopbackPort: 0,
tailnetEnabled: false,
pidfilePath: join(workDir, 'daemon.pid'),
tunnelProvider: async () => ({ udid: 'STUB-UDID', ipv6Addr: '127.0.0.1', port: devicePort, bootTokenRotated: 'stub-device-token' }),
});
if ('error' in d) throw new Error(d.error);
daemon = d;
});
afterAll(async () => {
await daemon?.close();
device.close();
rmSync(workDir, { recursive: true, force: true });
});
const base = () => `http://127.0.0.1:${daemon.loopbackPort}`;
test('a non-loopback Host is refused before the device is contacted', async () => {
deviceRequests.length = 0;
for (const host of [`device.example:${daemon.loopbackPort}`, 'device.example', `127.0.0.1:${daemon.loopbackPort + 1}`, `127.0.0.2:${daemon.loopbackPort}`]) {
for (const [method, route] of [['GET', '/healthz'], ['GET', '/screenshot'], ['POST', '/type'], ['GET', '/auth/sessions']]) {
const res = await fetch(base() + route, {
method,
headers: { Host: host, 'content-type': 'text/plain' },
body: method === 'POST' ? '{}' : undefined,
});
expect({ host, route, status: res.status }).toEqual({ host, route, status: 403 });
}
}
expect(deviceRequests).toEqual([]);
});
test('a cross-origin Origin is refused before the device is contacted', async () => {
deviceRequests.length = 0;
for (const origin of ['http://device.example', 'null', `http://localhost:${daemon.loopbackPort + 1}`, `https://127.0.0.1:${daemon.loopbackPort}`]) {
const res = await fetch(base() + '/session/acquire', {
method: 'POST',
headers: { Origin: origin, 'content-type': 'text/plain' },
body: '{}',
});
expect({ origin, status: res.status }).toEqual({ origin, status: 403 });
}
expect(deviceRequests).toEqual([]);
});
test('local clients naming the loopback address still reach the device', async () => {
deviceRequests.length = 0;
for (const host of [`127.0.0.1:${daemon.loopbackPort}`, `localhost:${daemon.loopbackPort}`, `[::1]:${daemon.loopbackPort}`]) {
const res = await fetch(base() + '/screenshot', { headers: { Host: host } });
expect({ host, status: res.status }).toEqual({ host, status: 200 });
}
const sameOrigin = await fetch(base() + '/healthz', { headers: { Origin: base() } });
expect(sameOrigin.status).toBe(200);
expect(deviceRequests).toEqual(['GET /screenshot', 'GET /screenshot', 'GET /screenshot']);
});
test('a cross-site or same-site browser request without an Origin is refused before the device is contacted', async () => {
deviceRequests.length = 0;
for (const site of ['cross-site', 'same-site']) {
for (const route of ['/screenshot', '/healthz']) {
const res = await fetch(base() + route, { headers: { 'Sec-Fetch-Site': site, 'Sec-Fetch-Mode': 'no-cors' } });
expect({ site, route, status: res.status }).toEqual({ site, route, status: 403 });
}
}
expect(deviceRequests).toEqual([]);
});
+27
View File
@@ -0,0 +1,27 @@
/**
* Request checks for daemons bound to a loopback port. Binding 127.0.0.1
* keeps other machines out but not other origins on this machine: a browser
* page can send requests to the port, and a page served under a hostname
* that resolves to 127.0.0.1 arrives with that hostname as Host. Serving
* only loopback Host values and same-origin (or absent) Origin values keeps
* the port to local clients that address it directly.
*/
const LOOPBACK_HOSTNAMES = ['127.0.0.1', 'localhost', '[::1]'];
function isLoopbackAuthority(authority: string, port: number): boolean {
const normalized = authority.toLowerCase();
return LOOPBACK_HOSTNAMES.some((hostname) => normalized === `${hostname}:${port}`);
}
/** Why a request to a loopback daemon on `port` must be refused, or null to serve it. */
export function loopbackRequestRefusal(
host: string | null | undefined,
origin: string | null | undefined,
port: number,
): 'host_not_loopback' | 'cross_origin' | null {
if (!host || !isLoopbackAuthority(host, port)) return 'host_not_loopback';
if (origin === null || origin === undefined) return null;
if (!origin.startsWith('http://') || !isLoopbackAuthority(origin.slice('http://'.length), port)) return 'cross_origin';
return null;
}
+2 -2
View File
@@ -85,7 +85,7 @@ $D compare --images-file "$_DESIGN_DIR/board-images.json" --output "$_DESIGN_DIR
This publishes the board to the design daemon, opens it in the user's default
browser, and exits; it does not wait for feedback. Read captured stderr for the
`BOARD_URL: http://127.0.0.1:N/boards/<id>/` line, then wait with AskUserQuestion:
`BOARD_URL: http://127.0.0.1:N/<secret>/boards/<id>/` line, then wait with AskUserQuestion:
"Review <BOARD_URL>, click Submit or Regenerate, then tell me (or paste your
preferences here)." After the answer, read `feedback.json` or
`feedback-pending.json` next to the board HTML.
@@ -100,7 +100,7 @@ If the JSON contains `"regenerated": true`:
2. Generate new variants with `$D iterate` or `$D variants` using the updated brief, rewritten into the brief file (capture and round accounting as in Step 3)
3. Rebuild the board with Step 4's board block, without `--serve`
4. POST the new HTML to the running board. Parse the board URL from stderr
(`BOARD_URL: http://127.0.0.1:N/boards/<id>/` — the daemon path) or fall
(`BOARD_URL: http://127.0.0.1:N/<secret>/boards/<id>/` — the daemon path) or fall
back to the legacy port (`SERVE_STARTED: port=N` — only emitted under
`--no-daemon`, hits `/api/reload` root). Daemon path:
`jq -nc --arg html "$_DESIGN_DIR/design-board.html" '{html: $html}' | curl -sS -X POST "${BOARD_URL}api/reload" -H 'Content-Type: application/json' --data-binary @-`
+1 -1
View File
@@ -488,7 +488,7 @@ $D compare --images-file "$_DESIGN_DIR/board-images.json" --output "$_DESIGN_DIR
Creates HTML and opens the board. **Run it in the background** (host task, or `&` redirecting stdout/stderr to private files in `$_DESIGN_DIR`). Read captured stderr for the startup marker; a PID is not readiness. Missing marker: use the failure fallback below.
Default stderr: `BOARD_URL: http://127.0.0.1:N/boards/<id>/`. Use that full per-board URL for AskUserQuestion and as the reload base. Only explicit legacy `--no-daemon` emits `SERVE_STARTED: port=XXXXX`, serving one board at `/` with reload at `/api/reload`.
Default stderr: `BOARD_URL: http://127.0.0.1:N/<secret>/boards/<id>/`. Use that exact URL for AskUserQuestion and as the reload base. Only explicit legacy `--no-daemon` emits `SERVE_STARTED: port=XXXXX`, serving one board at `/` with reload at `/api/reload`.
**PRIMARY WAIT: AskUserQuestion with board URL**
+4 -4
View File
@@ -1227,7 +1227,7 @@ ${designBoardBlock(true)}
This publishes the board to the design daemon, opens it in the user's default
browser, and exits; it does not wait for feedback. Read captured stderr for the
\`BOARD_URL: http://127.0.0.1:N/boards/<id>/\` line, then wait with AskUserQuestion:
\`BOARD_URL: http://127.0.0.1:N/<secret>/boards/<id>/\` line, then wait with AskUserQuestion:
"Review <BOARD_URL>, click Submit or Regenerate, then tell me (or paste your
preferences here)." After the answer, read \`feedback.json\` or
\`feedback-pending.json\` next to the board HTML.
@@ -1242,7 +1242,7 @@ If the JSON contains \`"regenerated": true\`:
2. Generate new variants with \`$D iterate\` or \`$D variants\` using the updated brief, rewritten into the brief file (capture and round accounting as in Step 3)
3. Rebuild the board with Step 4's board block, without \`--serve\`
4. POST the new HTML to the running board. Parse the board URL from stderr
(\`BOARD_URL: http://127.0.0.1:N/boards/<id>/\` — the daemon path) or fall
(\`BOARD_URL: http://127.0.0.1:N/<secret>/boards/<id>/\` — the daemon path) or fall
back to the legacy port (\`SERVE_STARTED: port=N\` — only emitted under
\`--no-daemon\`, hits \`/api/reload\` root). Daemon path:
\`jq -nc --arg html "$_DESIGN_DIR/design-board.html" '{html: $html}' | curl -sS -X POST "\${BOARD_URL}api/reload" -H 'Content-Type: application/json' --data-binary @-\`
@@ -1352,7 +1352,7 @@ export function generateDesignShotgunLoop(ctx: TemplateContext): string {
${designBoardBlock(true)}
This publishes to a persistent daemon, opens the board and exits. Read captured stderr for the startup marker; a PID is not readiness. Exit 0 with \`BOARD_URL\` means the daemon is serving. Save its full \`http://127.0.0.1:N/boards/<id>/\` URL. Only legacy \`--no-daemon\` needs a host background task; \`SERVE_STARTED: port=N\` gives root URL \`http://127.0.0.1:N/\`.
This publishes to a persistent daemon, opens the board and exits. Read captured stderr for the startup marker; a PID is not readiness. Exit 0 with \`BOARD_URL\` means the daemon is serving. Save its full \`http://127.0.0.1:N/<secret>/boards/<id>/\` URL. Only legacy \`--no-daemon\` needs a host background task; \`SERVE_STARTED: port=N\` gives root URL \`http://127.0.0.1:N/\`.
**Wait with AskUserQuestion:** "Review <BOARD_URL>, Submit or request new variants, then tell me; or paste preferences here." The board chooses; the question waits. Do not poll.
@@ -1392,7 +1392,7 @@ ${designBoardBlock(true)}
Creates HTML and opens the board. **Run it in the background** (host task, or \`&\` redirecting stdout/stderr to private files in \`$_DESIGN_DIR\`). Read captured stderr for the startup marker; a PID is not readiness. Missing marker: use the failure fallback below.
Default stderr: \`BOARD_URL: http://127.0.0.1:N/boards/<id>/\`. Use that full per-board URL for AskUserQuestion and as the reload base. Only explicit legacy \`--no-daemon\` emits \`SERVE_STARTED: port=XXXXX\`, serving one board at \`/\` with reload at \`/api/reload\`.
Default stderr: \`BOARD_URL: http://127.0.0.1:N/<secret>/boards/<id>/\`. Use that exact URL for AskUserQuestion and as the reload base. Only explicit legacy \`--no-daemon\` emits \`SERVE_STARTED: port=XXXXX\`, serving one board at \`/\` with reload at \`/api/reload\`.
**PRIMARY WAIT: AskUserQuestion with board URL**
+1 -1
View File
@@ -148,7 +148,7 @@ export function createDesignReviewPicker({ cwd, deadlineAt }: { cwd: string; dea
throw new Error('Design board submission has no unambiguous offered action');
}
const urls = [...new Set(question.question.match(/https?:\/\/[^\s<>\[\]()]+/g) ?? [])];
if (urls.length !== 1 || !/^http:\/\/127\.0\.0\.1:[1-9][0-9]*\/boards\/[A-Za-z0-9_-]+\/$/.test(urls[0]!)) {
if (urls.length !== 1 || !/^http:\/\/127\.0\.0\.1:[1-9][0-9]*\/[0-9a-f]{64}\/boards\/[A-Za-z0-9_-]+\/$/.test(urls[0]!)) {
throw new Error('Design board submission requires one exact owned board URL');
}
const url = urls[0]!;
+3 -3
View File
@@ -39,10 +39,10 @@ afterEach(async () => {
}
});
async function board(port = daemon.port): Promise<PublishBoardResult> {
async function board(owner: SpawnedDaemon = daemon): Promise<PublishBoardResult> {
const directory = path.join(cwd, `board-${++boardNumber}`);
fs.mkdirSync(directory);
return publishBoard({ port, html: makeBoardHtml(directory) });
return publishBoard({ port: owner.port, token: owner.token, html: makeBoardHtml(directory) });
}
// These are the declared actor records, not relabeled historical questions.
@@ -365,7 +365,7 @@ test('a real foreign daemon port is refused without changing either board', asyn
fs.mkdirSync(path.dirname(otherState), { recursive: true });
const other = await spawnDaemonForTest({ stateFile: otherState });
daemons.push(other);
const foreign = await board(other.port);
const foreign = await board(other);
expect(() => picker()(question(foreign.url))).toThrow();
await expectUnsubmitted(published);
await expectUnsubmitted(foreign);