Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -579,11 +579,27 @@ An instance is a name. Its machine config and all of its state live under `~/.fo
| `webPort` | no | first free port from 8090, chosen by `add` |
| `minGraphqlPoints` | no | 500 |
| `notify` | no | none (搂7) |
| `webAuth` | no | none: the page is localhost-only |

`workDir` absent means worktrees live at `<repoDir>/.worktrees/<issue>`, so a checkout is
self-contained; add `.worktrees/` to the repository's `.gitignore`. `model` is always passed as
`--model`, so a session never inherits whatever the interactive `claude` default happens to be.

### Reaching the page from outside this Mac

The page listens on `127.0.0.1` and, by default, refuses any request whose `Host` is not
localhost. To reach it through a tunnel, set HTTP Basic credentials:

```json
"webAuth": { "user": "me", "password": "at least eight characters" }
```

With `webAuth` set, every route (the page itself included) demands those credentials, the `Host`
check is dropped, and `foreman stop`/`go`/`model`/`cap` send them too. Then
`ngrok http 8090` (or any tunnel that terminates TLS) exposes the page; the browser prompts once.
The password is a credential like the Slack webhook: it is never logged or served. Restart the
daemon after changing it.

### Which instance a command means

Every command that needs an instance resolves it in this order and stops at the first hit:
Expand Down
1 change: 1 addition & 0 deletions docs/2026-09-18-foreman-extraction-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ Today's schema minus `slackUser`:
| `webPort` | no | first free port from 8090, chosen by `add` |
| `minGraphqlPoints` | no | 500 |
| `notify` | no | none |
| `webAuth` | no | none: `{ user, password }` for HTTP Basic; set, every route demands it and the localhost-only Host check is dropped so a tunnel can reach the page |

`webPort` must be unique per instance on a Mac; `foreman add` refuses a port another instance already has.

Expand Down
2 changes: 2 additions & 0 deletions src/cli/ctl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { loadRepoConfigSafe } from "../repo-config.ts";
import { readSessions } from "../sessions.ts";
import { readState } from "../state-file.ts";
import { describeStatus, formatStatus } from "../status.ts";
import { basicAuthorization } from "../web.ts";
import { launchdLabel } from "./launchd.ts";

export type CtlCommand = "status" | "next" | "stop" | "abort" | "go" | "model" | "cap";
Expand Down Expand Up @@ -108,6 +109,7 @@ export async function runCtl(
async function postToDaemon(path: string, body: unknown, fallback: string): Promise<string> {
const r = await fetch(`http://127.0.0.1:${cfg.webPort}/api/${path}`, {
method: "POST",
headers: cfg.webAuth ? { authorization: basicAuthorization(cfg.webAuth) } : {},
body: JSON.stringify(body),
});
const j = (await r.json()) as { ok: boolean; message?: string; error?: string };
Expand Down
1 change: 1 addition & 0 deletions src/cli/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ export async function runDaemon(
const launchdIsInstalled = await launchdInstalled(launchdLabel(instance.name));
const web = await startWebServer({
port: cfg.webPort,
auth: cfg.webAuth,
status: () =>
describeStatus({
state: store.get(),
Expand Down
18 changes: 18 additions & 0 deletions src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,3 +82,21 @@ describe("expandHome", () => {
it("expands ~/", () => expect(expandHome("~/x")).not.toContain("~"));
it("leaves absolute paths", () => expect(expandHome("/a/b")).toBe("/a/b"));
});

describe("webAuth", () => {
it("is absent by default and accepted when both user and password are set", () => {
expect(parseConfig(JSON.stringify(base)).webAuth).toBeUndefined();
const cfg = parseConfig(
JSON.stringify({ ...base, webAuth: { user: "me", password: "correct horse" } }),
);
expect(cfg.webAuth).toEqual({ user: "me", password: "correct horse" });
});
it("rejects a short password and a missing user", () => {
expect(() =>
parseConfig(JSON.stringify({ ...base, webAuth: { user: "me", password: "short" } })),
).toThrow();
expect(() =>
parseConfig(JSON.stringify({ ...base, webAuth: { password: "correct horse" } })),
).toThrow();
});
});
11 changes: 11 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ import { join } from "node:path";
import { z } from "zod";
import { NotifyConfigSchema } from "./notify.ts";

export const WebAuthSchema = z
.object({ user: z.string().min(1), password: z.string().min(8) })
.strict();
export type WebAuth = z.infer<typeof WebAuthSchema>;

export const ConfigSchema = z.object({
repo: z.string().regex(/^[\w.-]+\/[\w.-]+$/),
/** The board's number. Absent until `foreman init` creates it, which is what fills this in. */
Expand Down Expand Up @@ -32,6 +37,12 @@ export const ConfigSchema = z.object({
* nothing on the page), so keep `foreman.json` out of the repo as it already is.
*/
notify: NotifyConfigSchema.optional(),
/**
* HTTP Basic credentials for the page. Absent, the page answers localhost only; set, it answers
* any Host that presents these, so a tunnel (ngrok and the like) can reach it. The password is
* a credential like `slackWebhookUrl`: never logged, never served.
*/
webAuth: WebAuthSchema.optional(),
});
export type ForemanConfig = Omit<z.infer<typeof ConfigSchema>, "workDir"> & {
owner: string;
Expand Down
108 changes: 107 additions & 1 deletion src/web.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type { FeedEntry } from "./feed.ts";
import type { NextReport } from "./next.ts";
import { CAP_CHOICES, MODEL_CHOICES } from "./state-file.ts";
import type { StatusReport } from "./status.ts";
import { createWebServer, isLocalHost } from "./web.ts";
import { basicAuthorization, createWebServer, isLocalHost } from "./web.ts";

const report: StatusReport = {
host: "mac-a",
Expand Down Expand Up @@ -280,6 +280,112 @@ describe("web server", () => {
});
});

describe("web server with webAuth", () => {
const auth = { user: "me", password: "correct horse" };
const acts: string[] = [];
const server = createWebServer({
port: 0,
auth,
status: () => report,
next: async () => nextReport,
act: async (cmd) => {
acts.push(cmd);
return `did ${cmd}`;
},
feed: () => [],
owner: async () => "",
ownerItems: () => [],
needsYouItems: () => [],
unblock: async () => "",
setModel: async () => "",
setCap: async () => "",
refresh: async () => "",
phaseTasks: () => [],
setTaskModel: async () => "",
modelChoices: () => [],
html: "<title>Foreman</title>",
});
let base = "";
beforeAll(async () => {
await new Promise<void>((r) => server.listen(0, "127.0.0.1", r));
base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
});
afterAll(() => server.close());

/** node:http so the Host header can be set; returns status, body and the challenge header. */
const raw = (path: string, headers: Record<string, string>, method = "GET") =>
new Promise<{ status: number; body: string; challenge: string | undefined }>((resolve) => {
const req = http.request(
{ host: "127.0.0.1", port: (server.address() as AddressInfo).port, path, method, headers },
(res) => {
let body = "";
res.on("data", (c) => {
body += c;
});
res.on("end", () =>
resolve({
status: res.statusCode ?? 0,
body,
challenge: res.headers["www-authenticate"],
}),
);
},
);
req.end();
});

it("challenges a request with no credentials, even for the page itself", async () => {
const page = await fetch(`${base}/`);
expect(page.status).toBe(401);
expect(page.headers.get("www-authenticate")).toBe('Basic realm="foreman"');
expect(await page.json()).toEqual({ ok: false, error: "unauthorized" });
const status = await fetch(`${base}/api/status`);
expect(status.status).toBe(401);
const post = await fetch(`${base}/api/stop`, { method: "POST" });
expect(post.status).toBe(401);
expect(acts).toEqual([]);
});
it("refuses a wrong password, a wrong user and a non-Basic scheme", async () => {
const wrong = Buffer.from("me:wrong password").toString("base64");
expect(
(await fetch(`${base}/api/status`, { headers: { authorization: `Basic ${wrong}` } })).status,
).toBe(401);
const user = Buffer.from("you:correct horse").toString("base64");
expect(
(await fetch(`${base}/api/status`, { headers: { authorization: `Basic ${user}` } })).status,
).toBe(401);
expect(
(await fetch(`${base}/api/status`, { headers: { authorization: "Bearer x" } })).status,
).toBe(401);
});
it("accepts the right credentials from any Host, so a tunnel can reach the page", async () => {
const headers = { host: "abc.ngrok.app", authorization: basicAuthorization(auth) };
const status = await raw("/api/status", headers);
expect(status.status).toBe(200);
expect(JSON.parse(status.body)).toEqual(report);
const page = await raw("/", headers);
expect(page.status).toBe(200);
expect(page.body).toBe("<title>Foreman</title>");
const post = await raw("/api/stop", headers, "POST");
expect(post.status).toBe(200);
expect(acts).toEqual(["stop"]);
});
it("still accepts the right credentials on localhost", async () => {
const r = await fetch(`${base}/api/status`, {
headers: { authorization: basicAuthorization(auth) },
});
expect(r.status).toBe(200);
});
});

describe("basicAuthorization", () => {
it("encodes user:password the way a browser does", () => {
expect(basicAuthorization({ user: "me", password: "correct horse" })).toBe(
`Basic ${Buffer.from("me:correct horse").toString("base64")}`,
);
});
});

describe("isLocalHost", () => {
it("accepts loopback names with the right port only", () => {
expect(isLocalHost("127.0.0.1:8090", 8090)).toBe(true);
Expand Down
32 changes: 30 additions & 2 deletions src/web.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { timingSafeEqual } from "node:crypto";
import { readFileSync } from "node:fs";
import http from "node:http";
import type { AddressInfo } from "node:net";
import { join } from "node:path";
import { z } from "zod";
import type { WebAuth } from "./config.ts";
import { FEED_LIMIT_DEFAULT, type FeedEntry, isSessionId } from "./feed.ts";
import { log } from "./log.ts";
import type { NextReport } from "./next.ts";
Expand All @@ -24,6 +26,11 @@ export type WebCommand = "stop" | "abort" | "go";

export interface WebDeps {
port: number;
/**
* HTTP Basic credentials. Set, every route demands them and the Host check is dropped, so a
* tunnel can reach the page; absent, the page stays localhost-only (spec 搂7).
*/
auth?: WebAuth;
status: () => StatusReport;
next: () => Promise<NextReport>;
act: (cmd: WebCommand) => Promise<string>;
Expand Down Expand Up @@ -57,6 +64,19 @@ export function isLocalHost(hostHeader: string | undefined, port: number): boole
return hostHeader === `127.0.0.1:${port}` || hostHeader === `localhost:${port}`;
}

/** The `Authorization` value a client sends for these credentials; what `ctl` uses too. */
export function basicAuthorization(auth: WebAuth): string {
return `Basic ${Buffer.from(`${auth.user}:${auth.password}`).toString("base64")}`;
}

/** Constant-time on the encoded header, so neither a length nor a prefix leaks by timing. */
function authorized(header: string | undefined, auth: WebAuth): boolean {
if (!header) return false;
const want = Buffer.from(basicAuthorization(auth));
const got = Buffer.from(header);
return want.length === got.length && timingSafeEqual(want, got);
}

const OwnerRequestSchema = z.object({
epic: z.number().int().positive(),
action: OwnerActionSchema,
Expand Down Expand Up @@ -110,9 +130,17 @@ export function createWebServer(d: WebDeps): http.Server {
const u = new URL(req.url ?? "/", "http://localhost");
const url = u.pathname;
try {
// Spec 搂7: every route is localhost-only, not just the POST actions.
if (!isLocalHost(req.headers.host, bound))
if (d.auth) {
// Credentials replace the Host check: a rebinding page never carries them, because the
// browser caches Basic credentials per origin and the attacker's origin is not this one.
if (!authorized(req.headers.authorization, d.auth)) {
res.setHeader("www-authenticate", 'Basic realm="foreman"');
return json(res, 401, { ok: false, error: "unauthorized" });
}
} else if (!isLocalHost(req.headers.host, bound)) {
// Spec 搂7: every route is localhost-only, not just the POST actions.
return json(res, 403, { ok: false, error: "localhost only" });
}
if (req.method === "GET" && url === "/")
return send(res, 200, html, "text/html; charset=utf-8");
if (req.method === "GET" && url === "/api/status") return json(res, 200, d.status());
Expand Down
Loading