feat(conversation): CHAT-02 read-only Pi history reader and two board routes (#1507)
packages/conversation is a library with no server: safe-fs, the Pi session parser, CHAT-01 pages, pinned snapshots, cursors and follow. The control board adds GET /api/conversations and /api/conversation behind the Host and Origin guard. Both are read-only, their queries are validated, and each refusal code maps to a status. Dewey authored it (packet 0cf177b1, revision 2). Filbert reviewed the code: R1 revise (branch ids moving on append, the assumed-link bridge merging branches, one unreadable seat directory turning the catalogue into a 500), then R2 approve (3b14d66c). Darkwing reviewed the routes: R1 approve (07b10ad1), R2 approve (b9d92003). The package lands with the routes, because serve.mjs imports the reader at load. On an index export: the eight suites 24/90/43/17/14/15/63/18, conversation and control-board 153/153, webui 9/9. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -9,6 +9,14 @@
|
||||
// seat's tmux pane through tools/tmux/agent-send.sh (#1505);
|
||||
// answers {delivered, exitCode, stdout, stderr, ...}
|
||||
// GET /healthz {"ok":true}
|
||||
// GET /api/conversations
|
||||
// read-only Pi conversation catalogue (#1507, CHAT-02)
|
||||
// GET /api/conversation?id=<conversation>[&branch=<branch>][&cursor=<cursor>]
|
||||
// one CHAT-01 history page: the first page of a branch (the
|
||||
// default one without branch), or the next page (or a
|
||||
// follow) for a cursor. A cursor call repeats the page's
|
||||
// branch; without it the answer is 400. Refusals carry
|
||||
// {error, refusal: {code, reconcile}}.
|
||||
//
|
||||
// POST requires Content-Type: application/json. A plain form post from another
|
||||
// site in the browser cannot set that header without a CORS preflight, and this
|
||||
@@ -24,6 +32,13 @@
|
||||
//
|
||||
// Every /api/board request rescans, so the page is never staler than its
|
||||
// refresh timer. The scan rewrites the derived board files as a side effect.
|
||||
//
|
||||
// The conversation routes read only: packages/conversation lists the repository
|
||||
// specs' Pi session roots (never fleet or connector ones), opens files
|
||||
// O_NOFOLLOW and writes nothing. Registrations are hints there too. The
|
||||
// conversation id is opaque and no path comes from the request. Cursors live
|
||||
// in this server's memory, bound to the one actor this unauthenticated
|
||||
// loopback route has, local-operator.
|
||||
|
||||
import { createServer as createHttpServer } from "node:http";
|
||||
import { readFileSync } from "node:fs";
|
||||
@@ -31,7 +46,8 @@ import { join, resolve } from "node:path";
|
||||
import { isIP } from "node:net";
|
||||
import { hostname } from "node:os";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { scan, markSeen, seenKey, ConfigError } from "./scan.mjs";
|
||||
import { scan, markSeen, seenKey, ConfigError, loadRegistrations } from "./scan.mjs";
|
||||
import { createReader, rootsFromSpecs } from "../../conversation/src/reader.mjs";
|
||||
|
||||
const MAX_BODY = 4096;
|
||||
|
||||
@@ -149,13 +165,70 @@ export function foreignRequest(req) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// HTTP status for each reader refusal. The body always carries the code. The
|
||||
// tests hold every code the reader can raise to an entry here.
|
||||
export const REFUSAL_STATUS = {
|
||||
"unknown-conversation": 404,
|
||||
"unknown-branch": 404,
|
||||
unavailable: 404,
|
||||
"cursor-unknown": 409,
|
||||
"cursor-expired": 409,
|
||||
"cursor-foreign": 409,
|
||||
"source-replaced": 409,
|
||||
"incomplete-header": 409,
|
||||
"unsafe-path": 403,
|
||||
"foreign-project": 403,
|
||||
unreadable: 403,
|
||||
"unsupported-harness": 422,
|
||||
"not-a-pi-session": 422,
|
||||
"too-large": 422,
|
||||
"unknown-actor": 403,
|
||||
"unsupported-purpose": 422,
|
||||
};
|
||||
const QUERY_VALUE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
|
||||
function sendConversationJson(res, status, body) {
|
||||
res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store", "x-content-type-options": "nosniff" });
|
||||
res.end(JSON.stringify(body) + "\n");
|
||||
}
|
||||
|
||||
// GET /api/conversation: id is required; branch and cursor are optional; any
|
||||
// other, repeated or malformed parameter is a 400.
|
||||
function conversationQuery(url) {
|
||||
const out = {};
|
||||
for (const key of new Set(url.searchParams.keys())) {
|
||||
const values = url.searchParams.getAll(key);
|
||||
if (!["id", "branch", "cursor"].includes(key)) return { error: `unknown parameter: ${key}` };
|
||||
if (values.length !== 1 || !QUERY_VALUE.test(values[0])) return { error: `invalid ${key}` };
|
||||
out[key] = values[0];
|
||||
}
|
||||
if (!out.id) return { error: "id is required" };
|
||||
if (out.cursor && !out.branch) return { error: "a cursor call repeats the page's branch" };
|
||||
return out;
|
||||
}
|
||||
|
||||
export function conversationResponse(reader, url) {
|
||||
if (url.pathname === "/api/conversations") {
|
||||
if ([...url.searchParams.keys()].length) return { status: 400, body: { error: "no parameters are accepted" } };
|
||||
return { status: 200, body: reader.catalogue() };
|
||||
}
|
||||
const query = conversationQuery(url);
|
||||
if (query.error) return { status: 400, body: { error: query.error } };
|
||||
const out = query.cursor
|
||||
? reader.next({ cursor: query.cursor, conversation: query.id, branch: query.branch })
|
||||
: reader.open({ conversation: query.id, branch: query.branch ?? null });
|
||||
if (out.ok) return { status: 200, body: out };
|
||||
return { status: REFUSAL_STATUS[out.refusal.code] ?? 422, body: { error: out.refusal.message, refusal: { code: out.refusal.code, reconcile: out.refusal.reconcile } } };
|
||||
}
|
||||
|
||||
export function loadPage(path = join(import.meta.dirname, "page.html")) {
|
||||
return readFileSync(path, "utf8");
|
||||
}
|
||||
|
||||
// specs: agent specs to scan on each request. boardDir: where scan writes.
|
||||
export function createServer({ specs, boardDir, isAlive, now, seatsDir = null, discordDataRoot = null, page = loadPage(), isPidAlive, agentSend = DEFAULT_AGENT_SEND, exec = spawnSync }) {
|
||||
export function createServer({ specs, boardDir, isAlive, now, seatsDir = null, discordDataRoot = null, page = loadPage(), isPidAlive, agentSend = DEFAULT_AGENT_SEND, exec = spawnSync, conversationReader = null }) {
|
||||
const rescan = () => scan(specs, { boardDir, isAlive, now, seatsDir, isPidAlive, discordDataRoot });
|
||||
const reader = conversationReader ?? createReader({ roots: () => rootsFromSpecs(specs, loadRegistrations(seatsDir).registrations) });
|
||||
return createHttpServer((req, res) => {
|
||||
const refused = foreignRequest(req);
|
||||
if (refused) {
|
||||
@@ -212,6 +285,16 @@ export function createServer({ specs, boardDir, isAlive, now, seatsDir = null, d
|
||||
res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
|
||||
return res.end(JSON.stringify(index) + "\n");
|
||||
}
|
||||
if (url.pathname === "/api/conversations" || url.pathname === "/api/conversation") {
|
||||
let out;
|
||||
try {
|
||||
out = conversationResponse(reader, url);
|
||||
} catch (err) {
|
||||
process.stderr.write(`conversation read failed: ${err.message}\n`);
|
||||
out = { status: 500, body: { error: "conversation read failed" } };
|
||||
}
|
||||
return sendConversationJson(res, out.status, out.body);
|
||||
}
|
||||
if (url.pathname === "/favicon.ico") {
|
||||
res.writeHead(204);
|
||||
return res.end();
|
||||
|
||||
@@ -9,6 +9,9 @@ import {
|
||||
existsSync,
|
||||
chmodSync,
|
||||
statSync,
|
||||
symlinkSync,
|
||||
lstatSync,
|
||||
readdirSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve, basename } from "node:path";
|
||||
@@ -16,8 +19,9 @@ import { spawnSync, spawn } from "node:child_process";
|
||||
import { createServer as createNetServer } from "node:net";
|
||||
import { request as httpRequest } from "node:http";
|
||||
import { ConfigError, markSeen } from "../src/scan.mjs";
|
||||
import { isLoopbackHost, startServer, DEFAULT_AGENT_SEND, REPLY_LIMIT, REPLY_TRAILER } from "../src/serve.mjs";
|
||||
import { isLoopbackHost, startServer, DEFAULT_AGENT_SEND, REPLY_LIMIT, REPLY_TRAILER, REFUSAL_STATUS } from "../src/serve.mjs";
|
||||
import { writeRegistration, makeRegistration } from "../../seat/src/seat.mjs";
|
||||
import { UNSUPPORTED_HARNESS } from "../../conversation/src/reader.mjs";
|
||||
|
||||
const pkgRoot = resolve(import.meta.dirname, "..");
|
||||
const cli = join(pkgRoot, "src", "cli.mjs");
|
||||
@@ -1025,3 +1029,146 @@ test("Host/Origin guard: loopback names on this port are accepted, with or witho
|
||||
await closeServer(server);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 12. Read-only conversation routes (#1507, CHAT-02 D3): GET /api/conversations
|
||||
// and GET /api/conversation, served from packages/conversation. Same Host and
|
||||
// Origin guard as every route (F16), application/json with nosniff and
|
||||
// no-store, refusals mapped to 4xx with their code, and nothing written.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function conversationFixture() {
|
||||
const root = makeRoot();
|
||||
const proj = join(root, "proj");
|
||||
const sessionsDir = join(proj, ".pi", "state", "agent1", "sessions");
|
||||
const header = { type: "session", version: 3, id: "c0ffee00-0000-4000-8000-000000000001", timestamp: "2026-09-26T12:00:00.000Z", cwd: proj };
|
||||
const lines = [header];
|
||||
for (let i = 0; i < 120; i++) {
|
||||
lines.push({ type: "message", id: `e${i}`, parentId: i ? `e${i - 1}` : null, timestamp: new Date(Date.parse("2026-09-26T12:00:01Z") + i * 1000).toISOString(), message: { role: i % 2 ? "assistant" : "user", content: [{ type: "text", text: `m${i}` }] } });
|
||||
}
|
||||
writeSessionFile(sessionsDir, "s.jsonl", lines.map((l) => JSON.stringify(l)));
|
||||
const outside = join(root, "outside.jsonl");
|
||||
writeFile(outside, JSON.stringify({ ...header, cwd: "/elsewhere" }) + "\n");
|
||||
chmodSync(outside, 0o000);
|
||||
symlinkSync(outside, join(sessionsDir, "link.jsonl"));
|
||||
const seatsDir = join(root, "seats");
|
||||
writeRegistration(seatsDir, makeRegistration({
|
||||
resolved: { seat: "rocko", project: "proj", sessionsDir: join(proj, ".pi", "state", "rocko", "sessions"), seatDir: join(root, "rocko"), launchScript: join(root, "rocko", "launch.sh"), layout: "repo", defaultWorkspace: null },
|
||||
harness: "claude-code",
|
||||
}));
|
||||
return { root, proj, sessionsDir, seatsDir, boardDir: join(root, "board"), specs: [{ agent: "agent1", project: "proj", sessionsDir, tmux: {} }] };
|
||||
}
|
||||
|
||||
// Size, sha256, mtime, (dev, ino) and listing of every entry under dir.
|
||||
function treePrint(dir) {
|
||||
const out = {};
|
||||
for (const name of readdirSync(dir).sort()) {
|
||||
const path = join(dir, name);
|
||||
const st = lstatSync(path, { bigint: true });
|
||||
out[name] = [String(st.dev), String(st.ino), String(st.size), String(st.mtimeNs), st.isFile() ? createHashHex(readFileSync(path)) : null];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
function createHashHex(buf) {
|
||||
return spawnSync("sha256sum", { input: buf, encoding: "utf8" }).stdout.split(" ")[0];
|
||||
}
|
||||
|
||||
test("conversation routes (F16): a foreign Host, a wrong port and a cross-origin Origin get 403 before the reader runs, with no CORS headers", async () => {
|
||||
const f = conversationFixture();
|
||||
const calls = [];
|
||||
const spy = { catalogue: () => calls.push("catalogue"), open: () => calls.push("open"), next: () => calls.push("next") };
|
||||
const server = await startServer({ host: "127.0.0.1", port: 0, specs: f.specs, boardDir: f.boardDir, seatsDir: f.seatsDir, isAlive: () => true, page: "<html></html>", conversationReader: spy });
|
||||
const port = server.address().port;
|
||||
const own = `127.0.0.1:${port}`;
|
||||
const cases = [
|
||||
["foreign Host", { host: `rebind.example:${port}` }, "non-local Host refused"],
|
||||
["loopback Host, wrong port", { host: `127.0.0.1:${port + 1}` }, "non-local Host refused"],
|
||||
["cross-origin Origin", { host: own, origin: "http://rebind.example" }, "cross-origin request refused"],
|
||||
["opaque Origin", { host: own, origin: "null" }, "cross-origin request refused"],
|
||||
];
|
||||
try {
|
||||
for (const path of ["/api/conversations", "/api/conversation?id=pi-00000000000000000000000000000000", "/api/conversation?id=x&cursor=c-1"]) {
|
||||
for (const [label, headers, error] of cases) {
|
||||
const r = await rawRequest(port, { path, headers });
|
||||
assert.equal(r.status, 403, `${path}, ${label}`);
|
||||
assert.deepEqual(JSON.parse(r.text), { error }, `${path}, ${label}`);
|
||||
for (const h of Object.keys(r.headers)) assert.ok(!h.startsWith("access-control-"), `${path}, ${label}: ${h}`);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(calls, [], "the reader never ran for a refused request");
|
||||
assert.equal((await rawRequest(port, { path: "/api/conversations", headers: { host: own, origin: `http://${own}` } })).status, 200, "same-origin passes");
|
||||
assert.deepEqual(calls, ["catalogue"]);
|
||||
} finally {
|
||||
await closeServer(server);
|
||||
}
|
||||
});
|
||||
|
||||
test("every refusal code the reader can raise has an HTTP status", () => {
|
||||
const src = ["reader.mjs", "pi.mjs", "safe-fs.mjs"].map((f) => readFileSync(join(pkgRoot, "..", "conversation", "src", f), "utf8")).join("\n");
|
||||
const codes = new Set([...src.matchAll(/new Refusal\(\s*"([a-z-]+)"/g)].map((m) => m[1]));
|
||||
codes.add(UNSUPPORTED_HARNESS); // raised by value, as a root's unsupportedReason
|
||||
assert.ok(codes.size >= 15, [...codes].join(" "));
|
||||
assert.deepEqual([...codes].filter((c) => !(c in REFUSAL_STATUS)), []);
|
||||
assert.deepEqual(Object.keys(REFUSAL_STATUS).filter((c) => !codes.has(c)), [], "no stale entries");
|
||||
});
|
||||
|
||||
test("conversation routes: catalogue, first page, next page and follow over HTTP; refusals map to 4xx with their code; nothing is written", async () => {
|
||||
const f = conversationFixture();
|
||||
const before = treePrint(f.sessionsDir);
|
||||
const server = await startServer({ host: "127.0.0.1", port: 0, specs: f.specs, boardDir: f.boardDir, seatsDir: f.seatsDir, isAlive: () => true, page: "<html></html>" });
|
||||
const base = `http://127.0.0.1:${server.address().port}`;
|
||||
const get = async (path) => {
|
||||
const res = await fetch(base + path);
|
||||
assert.equal(res.headers.get("content-type"), "application/json", path);
|
||||
assert.equal(res.headers.get("x-content-type-options"), "nosniff", path);
|
||||
assert.equal(res.headers.get("cache-control"), "no-store", path);
|
||||
assert.equal(res.headers.get("access-control-allow-origin"), null, path);
|
||||
return { status: res.status, body: await res.json() };
|
||||
};
|
||||
try {
|
||||
const cat = await get("/api/conversations");
|
||||
assert.equal(cat.status, 200);
|
||||
const rows = cat.body.conversations;
|
||||
const pi = rows.find((c) => c.availability === "available");
|
||||
const link = rows.find((c) => c.availability === "denied");
|
||||
const claude = rows.find((c) => c.availability === "unsupported");
|
||||
assert.deepEqual([pi.seat, pi.title, pi.readOnly], ["agent1", "m0", true]);
|
||||
assert.deepEqual([link.refusal, claude.seat, claude.unsupportedReason], ["unsafe-path", "rocko", "unsupported-harness"]);
|
||||
|
||||
const first = await get(`/api/conversation?id=${pi.conversation}`);
|
||||
assert.equal(first.status, 200);
|
||||
assert.deepEqual([first.body.page.kind, first.body.page.entries.length, first.body.page.hasMore, first.body.page.branch], ["page", 100, true, "main"]);
|
||||
const q = (cursor) => `/api/conversation?id=${pi.conversation}&branch=${first.body.page.branch}&cursor=${cursor}`;
|
||||
const second = await get(q(first.body.page.nextCursor));
|
||||
assert.equal(second.status, 200);
|
||||
assert.deepEqual([second.body.page.entries.length, second.body.page.hasMore], [20, false]);
|
||||
assert.equal(second.body.page.entries.at(-1).content[0].text, "m119");
|
||||
const follow = await get(q(second.body.follow.id));
|
||||
assert.deepEqual([follow.status, follow.body.page.entries.length], [200, 0]);
|
||||
const branch = await get(`/api/conversation?id=${pi.conversation}&branch=main`);
|
||||
assert.equal(branch.status, 200);
|
||||
|
||||
const refusals = [
|
||||
[`/api/conversation?id=pi-${"0".repeat(32)}`, 404, "unknown-conversation", true],
|
||||
[`/api/conversation?id=${pi.conversation}&branch=b.e5`, 404, "unknown-branch", true],
|
||||
[q("c-unknown"), 409, "cursor-unknown", true],
|
||||
[`/api/conversation?id=${pi.conversation}&branch=b.e1&cursor=${first.body.page.nextCursor}`, 409, "cursor-foreign", true],
|
||||
[`/api/conversation?id=${link.conversation}`, 403, "unsafe-path", false],
|
||||
[`/api/conversation?id=${claude.conversation}`, 422, "unsupported-harness", false],
|
||||
];
|
||||
for (const [path, status, code, reconcile] of refusals) {
|
||||
const r = await get(path);
|
||||
assert.equal(r.status, status, path);
|
||||
assert.deepEqual(r.body.refusal, { code, reconcile }, path);
|
||||
assert.equal(typeof r.body.error, "string");
|
||||
}
|
||||
for (const path of ["/api/conversation", "/api/conversation?id=a&id=b", "/api/conversation?id=a&path=/etc/passwd", "/api/conversation?id=a&path=x", `/api/conversation?id=${pi.conversation}&cursor=${second.body.follow.id}`, "/api/conversation?id=../x", `/api/conversation?id=${"a".repeat(129)}`, "/api/conversations?x=1"]) {
|
||||
const r = await get(path);
|
||||
assert.equal(r.status, 400, path);
|
||||
}
|
||||
assert.deepEqual(treePrint(f.sessionsDir), before, "the sessions directory is unchanged");
|
||||
assert.equal(existsSync(join(f.proj, ".pi", "state", "rocko")), false, "the Claude seat's directory was never created");
|
||||
} finally {
|
||||
await closeServer(server);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user