Files
oh-my-pi/packages/coding-agent/test/collab/read-only.test.ts
T
can1357 42ffc83b5d fix: re-polled steering after yield and drained queued follow-ups after turns
- Re-polled steering at the loop yield boundary and included it in the pre-stop pending batch so late messages are processed immediately.
- Added session-side draining for stranded queued messages, scheduling an auto-continue when a prompt settles and follow-ups or steers remain.
- Added a regression test for late steering injection at yield and updated mid-turn collab prompt handling to keep steering messages in the pending display queue until consumed.
2026-06-12 17:02:42 +02:00

250 lines
8.6 KiB
TypeScript

/**
* End-to-end contract: a host started with both link variants marks view-link
* guests read-only in `welcome` and refuses their mutating frames, while
* full-link guests keep prompt/abort/agent-cmd capability. Runs over a real
* websocket relay (in-test stand-in speaking the documented relay contract)
* with real sealing — only the TUI context is stubbed.
*/
import { afterEach, describe, expect, it } from "bun:test";
import { importRoomKey } from "@oh-my-pi/pi-coding-agent/collab/crypto";
import { CollabHost } from "@oh-my-pi/pi-coding-agent/collab/host";
import {
COLLAB_PROTO,
type CollabFrame,
parseCollabLink,
rewriteEnvelopePeer,
unpackEnvelope,
} from "@oh-my-pi/pi-coding-agent/collab/protocol";
import { CollabSocket } from "@oh-my-pi/pi-coding-agent/collab/relay-client";
import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types";
interface RelayData {
role: "host" | "guest";
peerId: number;
}
type RelaySocket = Bun.ServerWebSocket<RelayData>;
/** Single-room test relay mirroring the production forwarding contract. */
function startTestRelay(): { url: string; stop(): void } {
let host: RelaySocket | null = null;
const guests = new Map<number, RelaySocket>();
let nextPeerId = 1;
const server = Bun.serve({
port: 0,
fetch(req, srv): Response | undefined {
const role = new URL(req.url).searchParams.get("role") === "host" ? "host" : "guest";
const data: RelayData = { role, peerId: 0 };
if (srv.upgrade(req, { data })) return undefined;
return new Response("upgrade failed", { status: 400 });
},
websocket: {
open(ws: RelaySocket): void {
if (ws.data.role === "host") {
host = ws;
return;
}
ws.data.peerId = nextPeerId++;
guests.set(ws.data.peerId, ws);
host?.send(JSON.stringify({ t: "peer-joined", peer: ws.data.peerId }));
},
message(ws: RelaySocket, message: string | Buffer): void {
if (typeof message === "string") return;
const bytes = new Uint8Array(message);
if (ws.data.role === "host") {
const envelope = unpackEnvelope(bytes);
if (!envelope) return;
if (envelope.peerId === 0) {
for (const guest of guests.values()) guest.send(bytes);
} else {
guests.get(envelope.peerId)?.send(bytes);
}
return;
}
rewriteEnvelopePeer(bytes, ws.data.peerId);
host?.send(bytes);
},
close(ws: RelaySocket): void {
if (ws.data.role === "guest") {
guests.delete(ws.data.peerId);
host?.send(JSON.stringify({ t: "peer-left", peer: ws.data.peerId }));
}
},
},
});
return { url: `ws://localhost:${server.port}`, stop: () => server.stop(true) };
}
interface HostHarness {
ctx: InteractiveModeContext;
prompts: { from?: string }[];
aborts: { count: number };
/** Resolves on the next promptCustomMessage call — no polling. */
nextPrompt(): Promise<{ from?: string }>;
}
/** Minimal InteractiveModeContext double: only the members CollabHost touches. */
function makeHostContext(): HostHarness {
const prompts: { from?: string }[] = [];
const aborts = { count: 0 };
const promptWaiters: ((details: { from?: string }) => void)[] = [];
const ctx = {
settings: { get: () => "" },
sessionManager: {
getSessionId: () => "sess-1",
getCwd: () => "/tmp",
snapshotForReplication: () => ({
header: { type: "session", id: "sess-1", timestamp: new Date().toISOString(), cwd: "/tmp" },
entries: [],
}),
onEntryAppended: undefined,
},
session: {
isStreaming: false,
queuedMessageCount: 0,
sessionName: "test",
model: undefined,
thinkingLevel: undefined,
subscribe: () => () => {},
emitNotice: () => {},
promptCustomMessage: (message: { details?: { from?: string } }) => {
const details = message.details ?? {};
prompts.push(details);
for (const waiter of promptWaiters.splice(0)) waiter(details);
return Promise.resolve();
},
abort: () => {
aborts.count++;
return Promise.resolve();
},
},
eventBus: undefined,
statusLine: {
setCollabStatus: () => {},
invalidate: () => {},
getCachedContextBreakdown: () => ({ usedTokens: 0, contextWindow: 0 }),
},
ui: { requestRender: () => {} },
showStatus: () => {},
collabHost: undefined,
} as unknown as InteractiveModeContext;
const nextPrompt = (): Promise<{ from?: string }> => {
const { promise, resolve } = Promise.withResolvers<{ from?: string }>();
promptWaiters.push(resolve);
return promise;
};
return { ctx, prompts, aborts, nextPrompt };
}
interface TestGuest {
socket: CollabSocket;
nextFrame(): Promise<CollabFrame>;
}
/** Raw guest speaking the wire protocol directly. `writeToken` overrides the link's token (e.g. forged). */
async function joinAsGuest(link: string, name: string, writeTokenOverride?: string): Promise<TestGuest> {
const parsed = parseCollabLink(link);
if ("error" in parsed) throw new Error(parsed.error);
const writeToken =
writeTokenOverride ?? (parsed.writeToken ? Buffer.from(parsed.writeToken).toString("base64url") : undefined);
const key = await importRoomKey(parsed.key);
const socket = new CollabSocket({ wsUrl: parsed.wsUrl, role: "guest", key });
const queue: CollabFrame[] = [];
const waiters: ((frame: CollabFrame) => void)[] = [];
socket.onFrame = frame => {
const waiter = waiters.shift();
if (waiter) waiter(frame);
else queue.push(frame);
};
socket.onOpen = () => socket.send({ t: "hello", proto: COLLAB_PROTO, name, writeToken });
socket.connect();
const nextFrame = (): Promise<CollabFrame> => {
const queued = queue.shift();
if (queued) return Promise.resolve(queued);
const { promise, resolve } = Promise.withResolvers<CollabFrame>();
waiters.push(resolve);
return promise;
};
return { socket, nextFrame };
}
const cleanups: (() => void | Promise<void>)[] = [];
afterEach(async () => {
for (const cleanup of cleanups.splice(0).reverse()) await cleanup();
});
async function setup(): Promise<HostHarness & { host: CollabHost }> {
const relay = startTestRelay();
cleanups.push(relay.stop);
const harness = makeHostContext();
const host = new CollabHost(harness.ctx);
await host.start(relay.url);
cleanups.push(() => host.stop("test done"));
return { ...harness, host };
}
describe("collab read-only links", () => {
it("welcomes view-link guests read-only and refuses their mutating frames", async () => {
const { host, prompts, aborts } = await setup();
expect(host.viewLink).not.toBe(host.link);
const guest = await joinAsGuest(host.viewLink, "viewer");
cleanups.push(() => guest.socket.close());
const welcome = await guest.nextFrame();
if (welcome.t !== "welcome") throw new Error(`expected welcome, got ${welcome.t}`);
expect(welcome.readOnly).toBe(true);
guest.socket.send({ t: "prompt", text: "do something" });
const promptReply = await guest.nextFrame();
if (promptReply.t !== "error") throw new Error(`expected error, got ${promptReply.t}`);
expect(promptReply.message).toContain("read-only");
expect(prompts).toHaveLength(0);
guest.socket.send({ t: "abort" });
const abortReply = await guest.nextFrame();
expect(abortReply.t).toBe("error");
expect(aborts.count).toBe(0);
guest.socket.send({ t: "agent-cmd", cmd: "kill", agentId: "nope" });
const cmdReply = await guest.nextFrame();
expect(cmdReply.t).toBe("error");
expect(host.participants.find(p => p.name === "viewer")?.readOnly).toBe(true);
});
it("keeps full write capability for guests holding the write token", async () => {
const { host, prompts, nextPrompt } = await setup();
const guest = await joinAsGuest(host.link, "writer");
cleanups.push(() => guest.socket.close());
const welcome = await guest.nextFrame();
if (welcome.t !== "welcome") throw new Error(`expected welcome, got ${welcome.t}`);
expect(welcome.readOnly).toBeUndefined();
const prompted = nextPrompt();
guest.socket.send({ t: "prompt", text: "real prompt" });
expect(await prompted).toEqual({ from: "writer" });
expect(prompts).toHaveLength(1);
expect(host.participants.find(p => p.name === "writer")?.readOnly).toBeUndefined();
});
it("treats a forged write token as read-only", async () => {
const { host, prompts } = await setup();
// A viewer knows the room key but not the token; garbage must not escalate.
const forged = Buffer.alloc(16, 0xab).toString("base64url");
const guest = await joinAsGuest(host.viewLink, "forger", forged);
cleanups.push(() => guest.socket.close());
const welcome = await guest.nextFrame();
if (welcome.t !== "welcome") throw new Error(`expected welcome, got ${welcome.t}`);
expect(welcome.readOnly).toBe(true);
guest.socket.send({ t: "prompt", text: "escalation attempt" });
const reply = await guest.nextFrame();
expect(reply.t).toBe("error");
expect(prompts).toHaveLength(0);
});
});