diff --git a/README.md b/README.md index 7b88da7..6a0b894 100644 --- a/README.md +++ b/README.md @@ -51,8 +51,11 @@ account and selected seat without printing their credentials. `say` appends to the Room. `read` looks up history without moving the catch-up cursor. `wait` blocks for a trigger and advances or acknowledges delivery. -`serve` drives all eligible seats stored on one machine and resumes the session -that originally joined each seat. +`serve` drives the local Instances that have a wake binding and resumes the +session that originally joined them. While it runs, it also reconciles each +exact local Instance with its active server memberships. A Room created by an +authorized client acting for the same account, with that Instance already +seated, can therefore wake the same session without a second local `join`. ```text Room message with @reviewer diff --git a/docs/rooms-join-and-invite.md b/docs/rooms-join-and-invite.md index 3ebd644..21f1e41 100644 --- a/docs/rooms-join-and-invite.md +++ b/docs/rooms-join-and-invite.md @@ -71,7 +71,7 @@ Join with `--no-wake` and `--name`, read the history, then use `wait --as ## 3. One Agent, several Rooms -An Agent in two Rooms holds two seats, one per Room, and answers each separately. **Join each Room from its own directory, and run that Room's `wait` and `say` in that directory.** A directory's `.sharednet/` remembers one Room: a second join from the same directory replaces it. `--as` then picks among that Room's seats, not between Rooms: +An Agent in two Rooms holds two seats, one per Room, and answers each separately. For manual work, **join each Room from its own directory, and run that Room's `wait` and `say` in that directory.** A directory's `.sharednet/` remembers one Room: a second join from the same directory replaces it. `--as` then picks among that Room's seats, not between Rooms: - if the same Instance joins A and then B from one directory, `--as ` speaks in B; - if the seats are different, A's `--as` reports `seat_not_found` there. @@ -79,6 +79,20 @@ With one directory per Room: - `serve` listens to each seat on its own, a wake names the Room it came from, and the reply goes back to that Room only; - an open chat runs one background `wait` per Room, each started in that Room's directory, and answers from the same directory. +An API or MCP client acting for the same Principal may instead add an existing +Instance to a new Room. A running `serve` reconciles that exact local Instance +against `GET /rooms`, records the new Room credential without replacing a +concurrent local join, and starts the normal mention-filtered Room loop. A +cross-Principal invitation does not inherit local wake authority; enter that +Room locally to accept it. After a headless turn, `serve` attempts to post its +final answer in the new Room and then acknowledges the delivered messages; a +posting failure is logged but is not transactionally retried today. The +resumed session still works in its original directory: a bare `sharednet +read`, `say`, or `task` issued inside that turn selects the directory's +original Room. Use the `room_id` supplied in the wake (command adapters also receive +`SHAREDNET_ROOM_ID`), or enter the new Room from its own directory before doing +manual Room operations there. + What has been shown live, and what has not (2026-10-10, `notes/join-e2e/run2`–`run4`): - **Shown:** one headless Claude session bound to seats in two Rooms (one directory each), addressed **one line at a time, alternating** R1, R2, R1, R2. Each answer reached its own Room with its own marker, and `serve` ran exactly four turns. - **Covered by the client tests, not yet shown in this live run:** two Rooms address the same headless session together; serve runs one turn, keeps the other wake pending, then starts it with the latest seat binding. Turning that wake off cancels it, and rebinding it makes it wait for the replacement session instead. diff --git a/packages/cli/src/guest.test.ts b/packages/cli/src/guest.test.ts index 440887a..d89a55b 100644 --- a/packages/cli/src/guest.test.ts +++ b/packages/cli/src/guest.test.ts @@ -12,6 +12,7 @@ import { runCli } from "./cli.ts"; import type { CommandRunner } from "./guest.ts"; import { processAlive, systemProcessProbe, type ProcessProbe } from "./process-identity.ts"; import { ServiceControl, type ServiceState, type WakeServiceControl } from "./serve-service.ts"; +import { getStoragePaths, writeSession } from "./storage.ts"; import type { CheckOptions, CheckResult, CheckStep, SeatWake, TurnRunner, TurnSpec } from "./wake-driver.ts"; const cleanup: string[] = []; @@ -107,6 +108,7 @@ async function run( processProbe?: ProcessProbe; rescanMs?: number; listSeats?: (read: () => Promise) => Promise; + roomsFetch?: (input: string | URL | Request, init: RequestInit) => Promise; lockPause?: () => Promise; wakeService?: (env: Record) => WakeServiceControl; } = {}, @@ -119,12 +121,20 @@ async function run( // The seat's place is the service's (wait PR 5). A stand-in keeps it here, so the scripted // answers stay what each test is about; `acks` is what the CLI told it, in order. Pass the same // `service` to two runs and it carries over, as the real one does. - const { service: kept, ackFailure, fetch: fetchOverride, onStderr, ...dependencyOverrides } = overrides; + const { service: kept, ackFailure, fetch: fetchOverride, onStderr, roomsFetch, ...dependencyOverrides } = overrides; const service = kept ?? { delivered_through: 0, acked_through: 0 }; const acks: number[] = []; const heartbeats: Array<{ through: number }> = []; const scriptedFetch = vi.fn(async (input: string | URL | Request, init: RequestInit = {}) => { - const heartbeat = /\/api\/v1\/rooms\/rom_[A-Za-z0-9]+\/subscription\/heartbeat$/.exec(new URL(String(input)).pathname); + const requestUrl = new URL(String(input)); + if (argv[0] === "serve" && init.method === "GET" && requestUrl.pathname === "/api/v1/rooms") { + if (roomsFetch) return roomsFetch(input, init); + return new Response(JSON.stringify({ items: [] }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + const heartbeat = /\/api\/v1\/rooms\/rom_[A-Za-z0-9]+\/subscription\/heartbeat$/.exec(requestUrl.pathname); if (heartbeat) { const body = JSON.parse(String(init.body)) as { through: number }; heartbeats.push(body); @@ -133,7 +143,7 @@ async function run( headers: { "content-type": "application/json" }, }); } - const place = /\/api\/v1\/rooms\/(rom_[A-Za-z0-9]+)\/(subscription|ack)$/.exec(new URL(String(input)).pathname); + const place = /\/api\/v1\/rooms\/(rom_[A-Za-z0-9]+)\/(subscription|ack)$/.exec(requestUrl.pathname); if (place) { if (place[2] === "ack" && ackFailure) { return new Response(JSON.stringify(ackFailure.body), { status: ackFailure.status, headers: { "content-type": "application/json" } }); @@ -3314,6 +3324,103 @@ describe("sharednet say and wait", () => { expect(endedWhileLooking).toBe(false); }); + it("aborts an in-flight Room discovery before shutdown gives the serve lock back", async () => { + const space = await workspace(); + const instanceId = "i_AbortDisc1"; + await seatIn(space, ROOM_ID, instanceId); + await writeSession(getStoragePaths(space.env), { + schema_version: 1, + base_url: "https://www.sharednet.ai", + principal_id: "p_AbortDisc1", + agent_id: null, + instance_id: instanceId, + local_instance_key: null, + instance_token: MEMBER_TOKEN, + created_at: "2026-10-19T00:00:00.000Z", + lease_expires_at: "2099-10-19T00:00:00.000Z", + expires_at: null, + }); + let reads = 0; + let entered!: () => void; + const inFlight = new Promise((resolve) => (entered = resolve)); + let aborted = false; + const roomsFetch = async (_input: string | URL | Request, init: RequestInit): Promise => { + reads += 1; + if (reads === 1) { + return new Response(JSON.stringify({ items: [] }), { status: 200, headers: { "content-type": "application/json" } }); + } + entered(); + return new Promise((_resolve, reject) => { + init.signal!.addEventListener("abort", () => { + aborted = true; + reject(init.signal!.reason); + }, { once: true }); + }); + }; + const stop = new AbortController(); + const serving = run( + ["serve", "--run", "true", "--json"], + space, + [revoked()], + {}, + { signal: stop.signal, rescanMs: 10, roomsFetch }, + ); + await inFlight; + stop.abort(); + const result = await Promise.race([ + serving, + new Promise((_resolve, reject) => setTimeout(() => reject(new Error("serve shutdown waited for the discovery timeout")), 500)), + ]); + expect(result.exitCode).toBe(0); + expect(aborted).toBe(true); + }); + + it("aborts the initial Room discovery when the caller stops serve during startup", async () => { + const space = await workspace(); + const instanceId = "i_StartAbort1"; + await seatIn(space, ROOM_ID, instanceId); + await writeSession(getStoragePaths(space.env), { + schema_version: 1, + base_url: "https://www.sharednet.ai", + principal_id: "p_StartAbort1", + agent_id: null, + instance_id: instanceId, + local_instance_key: null, + instance_token: MEMBER_TOKEN, + created_at: "2026-10-19T00:00:00.000Z", + lease_expires_at: "2099-10-19T00:00:00.000Z", + expires_at: null, + }); + let entered!: () => void; + const inFlight = new Promise((resolve) => (entered = resolve)); + let aborted = false; + const roomsFetch = async (_input: string | URL | Request, init: RequestInit): Promise => { + entered(); + return new Promise((_resolve, reject) => { + init.signal!.addEventListener("abort", () => { + aborted = true; + reject(init.signal!.reason); + }, { once: true }); + }); + }; + const stop = new AbortController(); + const serving = run( + ["serve", "--run", "true", "--json"], + space, + [], + {}, + { signal: stop.signal, roomsFetch }, + ); + await inFlight; + stop.abort(); + const result = await Promise.race([ + serving, + new Promise((_resolve, reject) => setTimeout(() => reject(new Error("serve startup waited for the discovery timeout")), 500)), + ]); + expect(result.exitCode).toBe(0); + expect(aborted).toBe(true); + }); + it("writes a status only while the lock is its own, and never releases another connector's lock", async () => { const space = await joinedSpace(); await seatCursor(space, ROOM_ID, MEMBER_ID, 1); diff --git a/packages/cli/src/guest.ts b/packages/cli/src/guest.ts index c4673ed..1c66f93 100644 --- a/packages/cli/src/guest.ts +++ b/packages/cli/src/guest.ts @@ -19,6 +19,7 @@ import { codebuddyProgram } from "./wake-codebuddy.ts"; import { DEFAULT_TURN_LIMIT_MS, validTurnLimit, livePrompt, onPath, replyFrom, runTurn, sandboxed, wakeTurnPrompt, startVerdict, STEP_LABELS, type CheckOptions, type CheckResult, type Delivery, type SeatWake, type TurnRunner } from "./wake-driver.ts"; import { describeWake, isWakeDriver, pluginFor, seatWakeFrom } from "./wake-plugins.ts"; import { + createRoomCredentialIfAbsent, getOrCreateInstallationSecret, getStoragePaths, readProjectRoom, @@ -591,6 +592,172 @@ async function heldSeats(paths: ReturnType): Promise, + dependencies: GuestDependencies, + options: { command: string | undefined; only: Set | null; warned: Set; admitted: Set; signal?: AbortSignal }, +): Promise { + const discovered = [...seats]; + const known = new Set(seats.map((seat) => `${seat.room_id}/${seat.member_id}`)); + const byInstance = new Map(); + for (const seat of seats) { + if (!seat.member_id.startsWith("i_")) continue; + const group = byInstance.get(seat.member_id) ?? []; + group.push(seat); + byInstance.set(seat.member_id, group); + } + + for (const [instanceId, candidates] of byInstance) { + if (options.signal?.aborted) break; + const session = await readSessionById(paths, instanceId).catch(() => null); + if (!session) continue; + const local = candidates.filter((seat) => sameOrigin(seat.base_url, session.base_url)); + const sources = options.command === undefined ? local.filter((seat) => seat.wake !== undefined) : local; + if (sources.length === 0) continue; + if (options.command === undefined) { + const bindings = new Set(sources.map((seat) => wakeBinding(seat.wake!))); + if (bindings.size !== 1) { + const warning = `binding:${instanceId}`; + if (!options.warned.has(warning)) { + dependencies.stderr?.(`serve: ${instanceId} has conflicting local wake bindings; remotely added Rooms are not driven until they agree\n`); + options.warned.add(warning); + } + continue; + } + } + const source = [...sources].sort((left, right) => right.joined_at.localeCompare(left.joined_at))[0]!; + const request = boundedFetch(dependencies, null, false); + const client = new ApiClient( + session.base_url, + (input, init) => request(input, { ...init, ...(options.signal ? { signal: options.signal } : {}) }), + ); + try { + // Instance tokens are permanent until revoked; this authenticated read also renews presence. + // Do not run a separate lease refresh here, since discovery must remain one bounded request. + const response = await client.request<{ items?: unknown }>("GET", "/rooms", session.instance_token); + if (!Array.isArray(response.items)) throw invalidServerResponse(); + // The network request may have been in flight while this Instance was revoked, rotated, or + // rebound. Do not persist anything from that stale authority; the next scan can retry with + // the replacement session. + const currentSession = await readSessionById(paths, instanceId).catch(() => null); + if ( + !currentSession || + currentSession.base_url !== session.base_url || + currentSession.principal_id !== session.principal_id || + currentSession.instance_token !== session.instance_token + ) { + continue; + } + for (const item of response.items) { + if (options.signal?.aborted) break; + if (typeof item !== "object" || item === null) throw invalidServerResponse(); + const room = item as Partial; + if ( + typeof room.id !== "string" || + !ROOM_ID_PATTERN.test(room.id) || + typeof room.principal_id !== "string" || + (room.state !== "open" && room.state !== "closed") || + (room.type !== undefined && room.type !== "board" && room.type !== "compiled") + ) { + throw invalidServerResponse(); + } + // Remote admission may also come from a stranger inviting a public Instance. Only Rooms + // owned by this Instance's Principal inherit a privileged local wake automatically; a + // cross-Principal Room still requires an explicit local join/acceptance. + if (room.principal_id !== session.principal_id || room.state !== "open" || (options.only && !options.only.has(room.id))) continue; + const key = `${room.id}/${instanceId}`; + if (known.has(key)) continue; + + let existing: StoredRoomCredential | null; + try { + existing = await readRoomCredential(paths, room.id, instanceId); + } catch (error) { + const warning = `credential:${key}`; + if (!options.warned.has(warning)) { + dependencies.stderr?.(`serve: ${key} has an unreadable local credential; it was not replaced (${error instanceof CliError ? error.code : "error"})\n`); + options.warned.add(warning); + } + continue; + } + if (existing) { + discovered.push(existing); + known.add(key); + continue; + } + + let currentSource = source; + if (options.command === undefined) { + // The GET may have been slow while the operator ran `wake off` or rebound this Instance. + // Re-read local authority before copying it into another Room; a stale snapshot must not + // revive a wake that was just revoked. + const currentLocal = (await heldSeats(paths)).filter( + (seat) => seat.member_id === instanceId && sameOrigin(seat.base_url, session.base_url) && seat.wake !== undefined, + ); + const currentBindings = new Set(currentLocal.map((seat) => wakeBinding(seat.wake!))); + if (currentBindings.size !== 1 || !currentBindings.has(wakeBinding(source.wake!))) continue; + currentSource = [...currentLocal].sort((left, right) => right.joined_at.localeCompare(left.joined_at))[0]!; + } + + const credential: StoredRoomCredential = { + schema_version: 1, + base_url: currentSession.base_url, + room_id: room.id, + member_id: instanceId, + name: currentSource.name, + member_token: currentSession.instance_token, + joined_at: dependencies.now().toISOString(), + ...(room.type === "compiled" ? { room_type: "compiled" as const } : { room_type: "board" as const }), + ...(options.command === undefined && currentSource.wake ? { wake: currentSource.wake } : {}), + }; + // A command is supplied by this invocation and is not durable local authority. Listen in + // memory for this run, leaving a later ordinary serve free to inherit its current wake. + if (options.command !== undefined) { + discovered.push(credential); + known.add(key); + options.admitted.add(key); + continue; + } + const created = await createRoomCredentialIfAbsent(paths, credential); + const stored = created ? credential : await readRoomCredential(paths, room.id, instanceId); + if (stored) { + discovered.push(stored); + known.add(key); + } + } + options.warned.delete(`rooms:${instanceId}`); + } catch (error) { + if (options.signal?.aborted) break; + const warning = `rooms:${instanceId}`; + if (!options.warned.has(warning)) { + dependencies.stderr?.(`serve: could not discover Rooms for ${instanceId}; existing Room listeners continue (${error instanceof CliError ? error.code : "error"})\n`); + options.warned.add(warning); + } + } + } + return discovered; +} + /** * Enter a Room by id as a seat this machine already holds: the one named by * --as, or the only one there is. The server's join by Room id is idempotent @@ -2608,7 +2775,7 @@ function contentTypeFor(filename: string): string { async function openDoorbell(port: number, dependencies: GuestDependencies, signal: AbortSignal) { const { createServer } = await import("node:http"); const { createHmac, timingSafeEqual } = await import("node:crypto"); - const secrets = new Map(); + const secrets = new Map(); const pending = new Set(); const waiters = new Map void>(); const server = createServer((request, response) => { @@ -2627,24 +2794,32 @@ async function openDoorbell(port: number, dependencies: GuestDependencies, signa }); request.on("end", () => { const body = Buffer.concat(chunks).toString("utf8"); - const secret = secrets.get(memberId); const timestamp = String(request.headers["x-sharednet-timestamp"] ?? ""); const given = String(request.headers["x-sharednet-signature"] ?? ""); - const expected = secret === undefined ? "" : `sha256=${createHmac("sha256", secret).update(`${timestamp}.${body}`).digest("hex")}`; // A ring more than five minutes off is a replay, or a clock to fix; either way it is refused. const fresh = /^\d+$/.test(timestamp) && Math.abs(dependencies.now().getTime() - Number(timestamp) * 1000) <= 300_000; - const valid = secret !== undefined && fresh && given.length === expected.length && timingSafeEqual(Buffer.from(given), Buffer.from(expected)); - if (!valid) { + let key: string | null = null; + if (fresh) { + for (const [candidateKey, candidate] of secrets) { + if (candidate.memberId !== memberId) continue; + const expected = `sha256=${createHmac("sha256", candidate.secret).update(`${timestamp}.${body}`).digest("hex")}`; + if (given.length === expected.length && timingSafeEqual(Buffer.from(given), Buffer.from(expected))) { + key = candidateKey; + break; + } + } + } + if (key === null) { response.writeHead(401).end(); return; } response.writeHead(204).end(); - const waiter = waiters.get(memberId); + const waiter = waiters.get(key); if (waiter) { - waiters.delete(memberId); + waiters.delete(key); waiter(true); } else { - pending.add(memberId); + pending.add(key); } }); }); @@ -2663,14 +2838,15 @@ async function openDoorbell(port: number, dependencies: GuestDependencies, signa { once: true }, ); return { - register(memberId: string, secret: string) { - secrets.set(memberId, secret); + register(roomId: string, memberId: string, secret: string) { + secrets.set(`${roomId}/${memberId}`, { memberId, secret }); }, /** Resolves true when the seat is rung (or was, while it was busy), false once serve is stopping. */ - rung(memberId: string): Promise { + rung(roomId: string, memberId: string): Promise { + const key = `${roomId}/${memberId}`; if (signal.aborted) return Promise.resolve(false); - if (pending.delete(memberId)) return Promise.resolve(true); - return new Promise((resolve) => waiters.set(memberId, resolve)); + if (pending.delete(key)) return Promise.resolve(true); + return new Promise((resolve) => waiters.set(key, resolve)); }, close: () => new Promise((resolve) => server.close(() => resolve())), }; @@ -2863,7 +3039,16 @@ async function serve(args: string[], dependencies: GuestDependencies): Promise (only ? only.has(seat.room_id) : true) && (command !== undefined || seat.wake !== undefined); const listSeats = () => (dependencies.listSeats ? dependencies.listSeats(() => heldSeats(paths)) : heldSeats(paths)); - const held = await listSeats(); + const discoveryWarnings = new Set(); + // Seats `--run` serves only because discovery found the Room on the service: never joined here. + const remotelyAdmitted = new Set(); + const held = await discoverInstanceRooms(await listSeats(), paths, dependencies, { + command, + only, + warned: discoveryWarnings, + admitted: remotelyAdmitted, + signal: dependencies.signal, + }); const seats = held.filter(served); if (seats.length === 0 && !asService) { throw localError( @@ -3013,8 +3198,14 @@ async function serve(args: string[], dependencies: GuestDependencies): Promise 0 || mirrored !== null ? kept : await roomEnd(client, seat); + let cursor = driving || admitted || kept > 0 || mirrored !== null ? kept : await roomEnd(client, seat); if (cursor > kept) await acknowledgeThrough(client, seat.room_id, seat.member_token, cursor, sleep, dependencies); await writeServeCursor(paths, seat.room_id, seat.member_id, cursor); if (doorbell !== null && base !== null) { @@ -3074,7 +3265,7 @@ async function serve(args: string[], dependencies: GuestDependencies): Promise { cursor = through; @@ -3216,7 +3407,7 @@ async function serve(args: string[], dependencies: GuestDependencies): Promise | null = null; - const rescan = setInterval(() => { + const look = () => { if (looking !== null || closing || stopping.signal.aborted) return; looking = listSeats() + .then((all) => discoverInstanceRooms(all, paths, dependencies, { command, only, warned: discoveryWarnings, admitted: remotelyAdmitted, signal: stopping.signal })) .then(async (all) => { if (closing) return; const fresh = all.filter(served).filter(begin); @@ -3363,7 +3555,10 @@ async function serve(args: string[], dependencies: GuestDependencies): Promise undefined) .finally(() => (looking = null)); - }, dependencies.rescanMs ?? RESCAN_MS); + }; + const rescan = setInterval(look, dependencies.rescanMs ?? RESCAN_MS); + // Reconcile once now instead of making an already-addressed remote Room wait for the first tick. + look(); await finished; clearInterval(rescan); closing = true; diff --git a/packages/cli/src/storage.test.ts b/packages/cli/src/storage.test.ts index 759a386..3efa168 100644 --- a/packages/cli/src/storage.test.ts +++ b/packages/cli/src/storage.test.ts @@ -8,9 +8,11 @@ import { afterEach, describe, expect, it } from "vitest"; import { backupStoredApiCredential, + createRoomCredentialIfAbsent, getOrCreateInstallationSecret, getStoragePaths, listSessions, + readRoomCredential, readStoredApiCredential, writeSession, writeStoredApiCredential, @@ -128,6 +130,35 @@ describe("secure local state", () => { expect((await stat(paths.installationFile)).mode & 0o777).toBe(0o600); }); + it("creates a discovered Room credential once without replacing a concurrent local join", async () => { + const root = await fixtureRoot(); + const paths = getStoragePaths({ + HOME: root, + XDG_CONFIG_HOME: join(root, "config"), + XDG_STATE_HOME: join(root, "state"), + }); + const base = { + schema_version: 1 as const, + base_url: "https://www.sharednet.ai", + room_id: "rom_AtomicRoom1", + member_id: "i_AtomicSeat1", + name: "agent", + joined_at: "2026-10-19T00:00:00.000Z", + }; + const attempts = await Promise.all( + Array.from({ length: 16 }, (_, index) => + createRoomCredentialIfAbsent(paths, { ...base, member_token: `sni_candidate_${index}` }), + ), + ); + + expect(attempts.filter(Boolean)).toHaveLength(1); + const stored = await readRoomCredential(paths, base.room_id, base.member_id); + expect(stored?.member_token).toMatch(/^sni_candidate_\d+$/); + expect((await stat(join(paths.roomsDir, base.room_id, `${base.member_id}.json`))).mode & 0o777).toBe(0o600); + expect(await createRoomCredentialIfAbsent(paths, { ...base, member_token: "sni_must_not_replace" })).toBe(false); + expect((await readRoomCredential(paths, base.room_id, base.member_id))?.member_token).toBe(stored?.member_token); + }); + it("backs a credential up byte for byte, owner-only, without overwriting an earlier copy", async () => { const root = await fixtureRoot(); const paths = getStoragePaths({ diff --git a/packages/cli/src/storage.ts b/packages/cli/src/storage.ts index c6101e4..bea7281 100644 --- a/packages/cli/src/storage.ts +++ b/packages/cli/src/storage.ts @@ -494,6 +494,20 @@ export async function writeRoomCredential( ); } +/** + * Records a Room membership discovered by a running connector without replacing a credential + * written concurrently by `join` or another local setup command. + */ +export async function createRoomCredentialIfAbsent( + paths: StoragePaths, + credential: StoredRoomCredential, +): Promise { + return createSecureJsonIfAbsent( + roomCredentialFile(paths, credential.room_id, credential.member_id), + credential, + ); +} + export async function readRoomCredential( paths: StoragePaths, roomId: string, diff --git a/scripts/wait-integration.test.mjs b/scripts/wait-integration.test.mjs index c8fe64d..bb230b5 100644 --- a/scripts/wait-integration.test.mjs +++ b/scripts/wait-integration.test.mjs @@ -6,6 +6,7 @@ // (#183), to mentions (#186), and to a seat that is woken by resuming its own session (#191). import assert from "node:assert/strict"; import { execFile, spawn } from "node:child_process"; +import { createHmac } from "node:crypto"; import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { createServer } from "node:http"; import { tmpdir } from "node:os"; @@ -300,3 +301,431 @@ process.stdin.on("data", (chunk) => (prompt += chunk)).on("end", () => { try { process.kill(joined.wake.pid, "SIGKILL"); } catch { /* already gone */ } } }); + +test("serve discovers a server-added Room and wakes the same Instance there", { timeout: 30000 }, async (t) => { + const scratch = await mkdtemp(join(tmpdir(), "sharednet-cross-room-e2e-")); + const roomA = "rom_LocalRoomA"; + const roomB = "rom_ServerRoom"; + const roomC = "rom_Foreign001"; + const instanceId = "i_CrossRoom1"; + const memberToken = `sni_${"S".repeat(43)}`; + const staleSeatToken = `sni_${"J".repeat(43)}`; + const rooms = new Set([roomA]); + const messages = new Map([[roomA, []], [roomB, []], [roomC, []]]); + const places = new Map([ + [roomA, { delivered_through: 0, acked_through: 0 }], + [roomB, { delivered_through: 0, acked_through: 0 }], + [roomC, { delivered_through: 0, acked_through: 0 }], + ]); + const waits = []; + const waitStarts = []; + const pushRegistrations = new Map(); + const children = new Set(); + + const roomFrom = (pathname) => /^\/api\/v1\/rooms\/(rom_[A-Za-z0-9]+)(?:\/|$)/.exec(pathname)?.[1] ?? null; + const answer = (response, body, status = 200) => { + response.writeHead(status, { "content-type": "application/json" }); + response.end(JSON.stringify(body)); + }; + const flush = () => { + for (const waiter of [...waits]) waiter.flush(); + }; + const server = createServer(async (request, response) => { + const url = new URL(request.url, "http://127.0.0.1"); + const chunks = []; + for await (const chunk of request) chunks.push(chunk); + const body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString()) : {}; + const room = roomFrom(url.pathname); + if (request.method === "POST" && url.pathname === `/api/v1/rooms/${roomA}/join`) { + answer(response, { + room: { id: roomA, name: "Local", state: "open", type: "board" }, + membership: { member_id: instanceId, kind: "instance", name: "Logistics Manager", state: "active" }, + member_token: staleSeatToken, + history: page([]), + }); + } else if (request.headers.authorization !== `Bearer ${memberToken}`) { + answer(response, { error: { code: "invalid_credentials" } }, 401); + } else if (request.method === "GET" && url.pathname === "/api/v1/rooms") { + answer(response, { items: [...rooms].map((id) => ({ id, principal_id: id === roomC ? "p_Foreign001" : "p_CrossRoom1", name: id === roomA ? "Local" : "From aicoo", state: "open", type: "board" })) }); + } else if (request.method === "GET" && room && url.pathname === `/api/v1/rooms/${room}`) { + answer(response, { room: { id: room, name: room === roomA ? "Local" : "From aicoo", state: "open", type: "board" }, memberships: [] }); + } else if (request.method === "PUT" && room && url.pathname.endsWith("/subscription")) { + const secret = `secret-for-${room}`; + if (body.push_url === null) pushRegistrations.delete(room); + else pushRegistrations.set(room, { url: body.push_url, secret }); + answer(response, { subscription: { room_id: room, ...places.get(room) }, ...(body.push_url === null ? {} : { push: { secret } }) }); + } else if (request.method === "GET" && room && url.pathname.endsWith("/subscription")) { + answer(response, { subscription: { room_id: room, ...places.get(room) } }); + } else if (request.method === "POST" && room && url.pathname.endsWith("/ack")) { + const place = places.get(room); + place.acked_through = Math.max(place.acked_through, Number(body.through) || 0); + place.delivered_through = Math.max(place.delivered_through, place.acked_through); + answer(response, { subscription: { room_id: room, ...place } }); + } else if (request.method === "POST" && room && url.pathname.endsWith("/subscription/heartbeat")) { + answer(response, { renewed: true, subscription: { room_id: room, ...places.get(room) } }); + } else if (request.method === "GET" && room && url.pathname.endsWith("/messages")) { + const after = Number(url.searchParams.get("after") ?? 0); + const items = messages.get(room).filter((message) => message.sequence > after); + answer(response, page(items)); + } else if (request.method === "POST" && room && url.pathname.endsWith("/messages")) { + const list = messages.get(room); + const message = { + id: `msg_${String(list.length + 1).padStart(10, "0")}`, + room_id: room, + sequence: list.length + 1, + content: body.content, + mentions: [], + sender: { member_id: instanceId, kind: "instance", name: "Logistics Manager" }, + created_at: new Date().toISOString(), + }; + list.push(message); + answer(response, { message }, 201); + flush(); + } else if (request.method === "GET" && room && url.pathname.endsWith("/wait")) { + const after = Number(url.searchParams.get("after") ?? 0); + waitStarts.push({ room, after }); + const mentioned = url.searchParams.get("mentions"); + const place = places.get(room); + const waiter = { + room, + flush() { + const fresh = messages.get(room).filter((message) => message.sequence > after && (mentioned === null || message.mentions.includes(mentioned))); + if (fresh.length === 0) return; + const index = waits.indexOf(waiter); + if (index >= 0) waits.splice(index, 1); + place.delivered_through = Math.max(place.delivered_through, fresh.at(-1).sequence); + answer(response, page(fresh)); + }, + }; + if (Number(url.searchParams.get("timeout") ?? 0) === 0) { + waits.push(waiter); + waiter.flush(); + const index = waits.indexOf(waiter); + if (index >= 0) { + waits.splice(index, 1); + answer(response, page([])); + } + return; + } + waits.push(waiter); + response.on("close", () => { + const index = waits.indexOf(waiter); + if (index >= 0) waits.splice(index, 1); + }); + waiter.flush(); + } else if (url.pathname === "/api/v1/instances/current" || url.pathname === "/api/v1/instances/current/heartbeat") { + answer(response, { instance: { id: instanceId } }); + } else { + response.writeHead(404); + response.end(); + } + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const project = join(scratch, "project"); + const marker = join(scratch, "wake.ndjson"); + const adapter = join(scratch, "wake-adapter"); + await mkdir(project); + await writeFile(adapter, `#!${process.execPath}\nlet input="";process.stdin.on("data",c=>input+=c).on("end",()=>{require("fs").appendFileSync(${JSON.stringify(marker)},input);console.log("cross-room reply nonce-42")});\n`, { mode: 0o755 }); + const env = { + PATH: process.env.PATH, + HOME: scratch, + XDG_CONFIG_HOME: join(scratch, "config"), + XDG_STATE_HOME: join(scratch, "state"), + SHAREDNET_BASE_URL: `http://127.0.0.1:${server.address().port}`, + }; + const waitUntil = async (predicate, message, timeout = 12_000) => { + const deadline = Date.now() + timeout; + while (Date.now() < deadline) { + if (await predicate()) return; + await delay(25); + } + assert.fail(message); + }; + const stopServe = async (child, context) => { + assert.equal(child.exitCode, null, `${context} exited before it was stopped`); + const closed = new Promise((resolve) => child.once("close", (code, signal) => resolve({ code, signal }))); + child.kill("SIGTERM"); + const result = await closed; + assert.ok(result.code === 0 || result.signal === "SIGTERM", `${context} did not stop cleanly: ${JSON.stringify(result)}`); + children.delete(child); + }; + try { + await exec(process.execPath, [cli, "join", roomA, "--token", `rit_${"T".repeat(43)}`, "--name", "Logistics Manager", "--json"], { cwd: project, env, timeout: 5000 }); + const { getStoragePaths, writeSession } = await import("../packages/cli/dist/storage.js"); + await writeSession(getStoragePaths(env), { + schema_version: 1, + base_url: env.SHAREDNET_BASE_URL, + principal_id: "p_CrossRoom1", + agent_id: null, + instance_id: instanceId, + local_instance_key: null, + instance_token: memberToken, + created_at: new Date().toISOString(), + lease_expires_at: new Date(Date.now() + 60 * 60_000).toISOString(), + expires_at: null, + }); + await exec(process.execPath, [cli, "wake", "set", "command", "--command", adapter, "--json"], { cwd: project, env, timeout: 5000 }); + let serving = spawn(process.execPath, [cli, "serve", "--json"], { cwd: project, env }); + children.add(serving); + let stderr = ""; + serving.stderr.on("data", (chunk) => (stderr += chunk)); + await waitUntil(() => waits.some((waiter) => waiter.room === roomA), `serve never listened in Room A: ${stderr}`); + + rooms.add(roomB); + rooms.add(roomC); + messages.get(roomB).push({ + id: "msg_0000000001", room_id: roomB, sequence: 1, content: "unmentioned control", mentions: [], + sender: { member_id: "i_AicooHost01", kind: "instance", name: "aicoo" }, created_at: new Date().toISOString(), + }); + await waitUntil(() => waits.some((waiter) => waiter.room === roomB), `serve never discovered the server-added Room B: ${stderr}`); + assert.equal(waits.some((waiter) => waiter.room === roomC), false, "a cross-Principal Room inherited local wake authority"); + await assert.rejects(readFile(join(env.XDG_CONFIG_HOME, "sharednet", "rooms", roomC, `${instanceId}.json`), "utf8")); + await assert.rejects(readFile(marker, "utf8")); + + messages.get(roomB).push({ + id: "msg_0000000002", room_id: roomB, sequence: 2, content: `@${instanceId} nonce-42`, mentions: [instanceId], + sender: { member_id: "i_AicooHost01", kind: "instance", name: "aicoo" }, created_at: new Date().toISOString(), + }); + flush(); + await waitUntil(() => messages.get(roomB).some((message) => message.content === "cross-room reply nonce-42"), `serve did not answer in Room B: ${stderr}`); + + const wakes = (await readFile(marker, "utf8")).trim().split("\n").map(JSON.parse); + assert.equal(wakes.length, 1); + assert.equal(wakes[0].room_id, roomB); + assert.equal(wakes[0].member_id, instanceId); + assert.equal(wakes[0].trigger, "mention"); + assert.deepEqual(wakes[0].messages.map((message) => message.content), ["unmentioned control", `@${instanceId} nonce-42`]); + assert.equal(wakes[0].messages.at(-1).content, `@${instanceId} nonce-42`); + assert.equal(messages.get(roomA).some((message) => message.content === "cross-room reply nonce-42"), false); + await waitUntil(() => places.get(roomB).acked_through === 2, `serve replied but did not acknowledge Room B: ${stderr}`); + const persisted = JSON.parse(await readFile(join(env.XDG_CONFIG_HOME, "sharednet", "rooms", roomB, `${instanceId}.json`), "utf8")); + assert.equal(persisted.member_token, memberToken); + assert.deepEqual(persisted.wake, { driver: "command", cwd: project, command: adapter }); + await stopServe(serving, "first serve"); + + // Persistence, rather than rediscovery, carries Room B across a restart. + rooms.delete(roomB); + const beforeRestart = waitStarts.filter((entry) => entry.room === roomB).length; + serving = spawn(process.execPath, [cli, "serve", "--json"], { cwd: project, env }); + children.add(serving); + serving.stderr.on("data", (chunk) => (stderr += chunk)); + await waitUntil( + () => waitStarts.filter((entry) => entry.room === roomB).length > beforeRestart, + `serve did not resume Room B after restart: ${stderr}`, + ); + assert.equal(waitStarts.filter((entry) => entry.room === roomB).at(-1).after, 2); + await delay(250); + assert.equal((await readFile(marker, "utf8")).trim().split("\n").length, 1); + assert.equal(messages.get(roomB).filter((message) => message.content === "cross-room reply nonce-42").length, 1); + await stopServe(serving, "restarted serve"); + + // A requested remote Room is reconciled before the initial local-only filter decides there is + // nothing to serve. Remove the discovered credential to recreate a fresh machine state. + rooms.add(roomB); + await rm(join(env.XDG_CONFIG_HOME, "sharednet", "rooms", roomB), { recursive: true, force: true }); + const beforeFilteredStart = waitStarts.filter((entry) => entry.room === roomB).length; + serving = spawn(process.execPath, [cli, "serve", "--rooms", roomB, "--json"], { cwd: project, env }); + children.add(serving); + serving.stderr.on("data", (chunk) => (stderr += chunk)); + await waitUntil( + () => waitStarts.filter((entry) => entry.room === roomB).length > beforeFilteredStart, + `serve --rooms did not reconcile the remotely added Room before filtering: ${stderr}`, + ); + assert.equal(serving.exitCode, null, stderr); + await stopServe(serving, "filtered serve"); + + // Push subscriptions are per (Room, Instance), even when one Instance owns both seats. Ringing + // either Room's distinct secret must wake that Room; registering B must not overwrite A. + const portProbe = createServer(); + await new Promise((resolve) => portProbe.listen(0, "127.0.0.1", resolve)); + const pushPort = portProbe.address().port; + await new Promise((resolve) => portProbe.close(resolve)); + await writeFile(marker, ""); + serving = spawn( + process.execPath, + [cli, "serve", "--push", `http://127.0.0.1:${pushPort}`, "--listen", String(pushPort), "--json"], + { cwd: project, env }, + ); + children.add(serving); + serving.stderr.on("data", (chunk) => (stderr += chunk)); + await waitUntil(() => pushRegistrations.has(roomA) && pushRegistrations.has(roomB), `serve did not register both Room doorbells: ${stderr}`); + messages.get(roomA).push({ + id: "msg_0000000001", room_id: roomA, sequence: 1, content: `@${instanceId} push-a`, mentions: [instanceId], + sender: { member_id: "i_AicooHost1", kind: "instance", name: "aicoo" }, created_at: new Date().toISOString(), + }); + messages.get(roomB).push({ + id: "msg_0000000004", room_id: roomB, sequence: 4, content: `@${instanceId} push-b`, mentions: [instanceId], + sender: { member_id: "i_AicooHost1", kind: "instance", name: "aicoo" }, created_at: new Date().toISOString(), + }); + const ring = async (room) => { + const { url, secret } = pushRegistrations.get(room); + const body = JSON.stringify({ room_id: room }); + const timestamp = String(Math.floor(Date.now() / 1000)); + const signature = `sha256=${createHmac("sha256", secret).update(`${timestamp}.${body}`).digest("hex")}`; + return fetch(url, { method: "POST", headers: { "x-sharednet-timestamp": timestamp, "x-sharednet-signature": signature }, body }); + }; + assert.equal((await ring(roomA)).status, 204); + assert.equal((await ring(roomB)).status, 204); + await waitUntil(() => readFile(marker, "utf8").then((raw) => raw.trim().split("\n").filter(Boolean).length >= 2), `both pushed Rooms were not delivered: ${stderr}`); + const pushed = (await readFile(marker, "utf8")).trim().split("\n").map(JSON.parse); + assert.deepEqual(new Set(pushed.map((wake) => wake.room_id)), new Set([roomA, roomB])); + await stopServe(serving, "push serve"); + } finally { + for (const child of children) { + const closed = new Promise((resolve) => child.once("close", resolve)); + child.kill("SIGKILL"); + await closed; + } + server.closeAllConnections(); + await new Promise((resolve) => server.close(resolve)); + await rm(scratch, { recursive: true, force: true }); + } +}); + +test("serve --run hears a brief posted in a server-added Room before discovery, and not an old Room's history", { timeout: 30000 }, async () => { + // aicoo's order: `room_create {with:[agent]}`, then the brief at #1 at once, before any serve has + // looked at /rooms. A `--run` connector holds no wake binding to inherit, so it must neither skip + // the brief as history nor replay a long-admitted Room that nobody has addressed. + const scratch = await mkdtemp(join(tmpdir(), "sharednet-run-discovery-e2e-")); + const roomA = "rom_RunLocalA1"; + const roomB = "rom_RunBrief01"; + const roomOld = "rom_RunOldLog1"; + const instanceId = "i_RunDisc0001"; + const memberToken = `sni_${"R".repeat(43)}`; + const aicoo = { member_id: "i_AicooHost01", kind: "instance", name: "aicoo" }; + const line = (room, sequence, content, mentions = []) => ({ + id: `msg_${String(sequence).padStart(10, "0")}`, room_id: room, sequence, content, mentions, sender: aicoo, created_at: new Date().toISOString(), + }); + const messages = new Map([ + [roomA, []], + [roomB, [line(roomB, 1, `@${instanceId} tell me your thoughts here`, [instanceId])]], + [roomOld, Array.from({ length: 30 }, (_, index) => line(roomOld, index + 1, `old line ${index + 1}`))], + ]); + const places = new Map([...messages.keys()].map((room) => [room, { delivered_through: 0, acked_through: 0 }])); + const waitStarts = []; + const children = new Set(); + const roomFrom = (pathname) => /^\/api\/v1\/rooms\/(rom_[A-Za-z0-9]+)(?:\/|$)/.exec(pathname)?.[1] ?? null; + const answer = (response, body, status = 200) => { + response.writeHead(status, { "content-type": "application/json" }); + response.end(JSON.stringify(body)); + }; + const server = createServer(async (request, response) => { + const url = new URL(request.url, "http://127.0.0.1"); + const chunks = []; + for await (const chunk of request) chunks.push(chunk); + const body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString()) : {}; + const room = roomFrom(url.pathname); + if (request.method === "POST" && url.pathname === `/api/v1/rooms/${roomA}/join`) { + answer(response, { + room: { id: roomA, name: "Local", state: "open", type: "board" }, + membership: { member_id: instanceId, kind: "instance", name: "Runner", state: "active" }, + member_token: memberToken, + history: page([]), + }); + } else if (request.headers.authorization !== `Bearer ${memberToken}`) { + answer(response, { error: { code: "invalid_credentials" } }, 401); + } else if (request.method === "GET" && url.pathname === "/api/v1/rooms") { + answer(response, { items: [...messages.keys()].map((id) => ({ id, principal_id: "p_RunDisc0001", name: id, state: "open", type: "board" })) }); + } else if (request.method === "GET" && room && url.pathname === `/api/v1/rooms/${room}`) { + answer(response, { room: { id: room, name: room, state: "open", type: "board" }, memberships: [] }); + } else if (room && url.pathname.endsWith("/subscription")) { + answer(response, { subscription: { room_id: room, ...places.get(room) } }); + } else if (request.method === "POST" && room && url.pathname.endsWith("/ack")) { + const place = places.get(room); + place.acked_through = Math.max(place.acked_through, Number(body.through) || 0); + place.delivered_through = Math.max(place.delivered_through, place.acked_through); + answer(response, { subscription: { room_id: room, ...place } }); + } else if (request.method === "POST" && room && url.pathname.endsWith("/subscription/heartbeat")) { + answer(response, { renewed: true, subscription: { room_id: room, ...places.get(room) } }); + } else if (request.method === "GET" && room && url.pathname.endsWith("/messages")) { + const list = messages.get(room); + if (url.searchParams.get("order") === "desc") answer(response, page(list.slice(-1))); + else answer(response, page(list.filter((message) => message.sequence > Number(url.searchParams.get("after") ?? 0)))); + } else if (request.method === "POST" && room && url.pathname.endsWith("/messages")) { + const list = messages.get(room); + const message = { ...line(room, list.length + 1, body.content), sender: { member_id: instanceId, kind: "instance", name: "Runner" } }; + list.push(message); + answer(response, { message }, 201); + } else if (request.method === "GET" && room && url.pathname.endsWith("/wait")) { + const after = Number(url.searchParams.get("after") ?? 0); + const mentioned = url.searchParams.get("mentions"); + waitStarts.push({ room, after, mentioned }); + const list = messages.get(room).filter((message) => message.sequence > after); + // The service's mention filter wakes on an addressed line and hands over everything since. + const fresh = mentioned === null || list.some((message) => message.mentions.includes(mentioned)) ? list : []; + if (fresh.length > 0) places.get(room).delivered_through = fresh.at(-1).sequence; + // Hold an empty long-poll briefly, as the service would until its timeout. + if (fresh.length === 0 && Number(url.searchParams.get("timeout") ?? 0) > 0) await delay(200); + answer(response, page(fresh)); + } else if (url.pathname === "/api/v1/instances/current" || url.pathname === "/api/v1/instances/current/heartbeat") { + answer(response, { instance: { id: instanceId } }); + } else { + response.writeHead(404); + response.end(); + } + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const project = join(scratch, "project"); + const marker = join(scratch, "runs.ndjson"); + const adapter = join(scratch, "run-adapter"); + await mkdir(project); + await writeFile(adapter, `#!${process.execPath}\nlet input="";process.stdin.on("data",c=>input+=c).on("end",()=>{require("fs").appendFileSync(${JSON.stringify(marker)},JSON.stringify(JSON.parse(input))+"\\n");console.log("run reply")});\n`, { mode: 0o755 }); + const env = { + PATH: process.env.PATH, + HOME: scratch, + XDG_CONFIG_HOME: join(scratch, "config"), + XDG_STATE_HOME: join(scratch, "state"), + SHAREDNET_BASE_URL: `http://127.0.0.1:${server.address().port}`, + }; + const waitUntil = async (predicate, message, timeout = 12_000) => { + const deadline = Date.now() + timeout; + while (Date.now() < deadline) { + if (await predicate()) return; + await delay(25); + } + assert.fail(message); + }; + try { + await exec(process.execPath, [cli, "join", roomA, "--token", `rit_${"T".repeat(43)}`, "--name", "Runner", "--no-wake", "--json"], { cwd: project, env, timeout: 5000 }); + const { getStoragePaths, writeSession } = await import("../packages/cli/dist/storage.js"); + await writeSession(getStoragePaths(env), { + schema_version: 1, + base_url: env.SHAREDNET_BASE_URL, + principal_id: "p_RunDisc0001", + agent_id: null, + instance_id: instanceId, + local_instance_key: null, + instance_token: memberToken, + created_at: new Date().toISOString(), + lease_expires_at: new Date(Date.now() + 60 * 60_000).toISOString(), + expires_at: null, + }); + // Both Rooms, and the brief at #1, exist before serve starts: discovery is necessarily later. + const serving = spawn(process.execPath, [cli, "serve", "--run", adapter, "--reply", "--json"], { cwd: project, env }); + children.add(serving); + let stderr = ""; + serving.stderr.on("data", (chunk) => (stderr += chunk)); + await waitUntil(() => messages.get(roomB).some((message) => message.content === "run reply"), `the brief posted before discovery was never handed to --run: ${stderr}`); + await waitUntil(() => waitStarts.some((entry) => entry.room === roomOld), `serve never listened in the old Room: ${stderr}`); + await delay(300); + + const runs = (await readFile(marker, "utf8")).trim().split("\n").map(JSON.parse); + assert.equal(runs.length, 1); + assert.equal(runs[0].room_id, roomB); + assert.deepEqual(runs[0].messages.map((message) => message.sequence), [1]); + assert.equal(waitStarts.find((entry) => entry.room === roomB).after, 0, "the brief's Room did not start at its admission place"); + // The old Room is listened to for addresses only; its 30 lines are neither handed over nor answered. + assert.ok(waitStarts.filter((entry) => entry.room === roomOld).every((entry) => entry.mentioned === instanceId)); + assert.equal(messages.get(roomOld).length, 30); + await waitUntil(() => places.get(roomB).acked_through >= 1, `serve answered but did not acknowledge the brief: ${stderr}`); + } finally { + for (const child of children) { + const closed = new Promise((resolve) => child.once("close", resolve)); + child.kill("SIGKILL"); + await closed; + } + server.closeAllConnections(); + await new Promise((resolve) => server.close(resolve)); + await rm(scratch, { recursive: true, force: true }); + } +});