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
42 changes: 42 additions & 0 deletions server/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,48 @@ describe("harness HTTP API", () => {
expect(array.body.error).toContain("opencodeGo");
});

it("never hands a client the provider session cursors", async () => {
// resumeCursors is the harness's own bookkeeping. It reached clients for
// a long time as harmless noise; once a phone is a client it is provider
// session state leaving the machine, so nothing carrying a bot may have it.
const listed = await api("GET", "/api/bots");
for (const bot of listed.body.bots) {
expect(bot).not.toHaveProperty("resumeCursors");
for (const task of bot.tasks ?? []) expect(task).not.toHaveProperty("resumeCursors");
}

const created = await api("POST", "/api/bots");
const botId = created.body.bot.id;
try {
expect(created.body.bot).not.toHaveProperty("resumeCursors");
const patched = await api("PATCH", `/api/bots/${botId}`, { name: "Cursorless" });
expect(patched.body.bot).not.toHaveProperty("resumeCursors");

const task = await api("POST", `/api/bots/${botId}/tasks`, {});
expect(task.body.bot).not.toHaveProperty("resumeCursors");
for (const t of task.body.bot.tasks ?? []) expect(t).not.toHaveProperty("resumeCursors");
// the task alone, not just the bot it came attached to
expect(task.body.task).not.toHaveProperty("resumeCursors");
const renamed = await api("PATCH", `/api/bots/${botId}/tasks/${task.body.task.threadId}`, {
title: "Cursorless task",
});
expect(renamed.body.task).not.toHaveProperty("resumeCursors");

// and the same on the wire, not just in the HTTP responses
const stream = await openSse(`${BASE}/api/events`);
try {
await api("PATCH", `/api/bots/${botId}`, { unread: true });
const frame = await stream.until((f) => f.kind === "bot");
expect(frame.bot).not.toHaveProperty("resumeCursors");
expect(JSON.stringify(frame)).not.toContain("resumeCursors");
} finally {
stream.close();
}
} finally {
await api("DELETE", `/api/bots/${botId}`);
}
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

it("404s unknown routes with the route in the error", async () => {
const res = await api("GET", "/api/definitely-not-a-route");
expect(res.status).toBe(404);
Expand Down
49 changes: 35 additions & 14 deletions server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,14 @@ import { discardDelegations, drainDelegations, queueDelegation, type QueueResult
import { EventBus } from "./harness/bus.ts";
import { ProviderRegistry } from "./harness/registry.ts";
import { cancelPeerApprovalsFor, dismissStalePeerCards, requestPeerApproval, resolvePeerComms, type ApprovalBus } from "./peer-approval.ts";
import { mentionedBots, roomResponders, Store, type GroupDefaultResponder, type Message } from "./store.ts";
import {
mentionedBots,
roomResponders,
Store,
type GroupDefaultResponder,
type Message,
type TaskRecord,
} from "./store.ts";
import * as tts from "./tts/index.ts";
import { narrateTool, toUtterances } from "./tts/speech-text.ts";
import { readCuaConnection } from "./local-computer.ts";
Expand Down Expand Up @@ -139,11 +146,25 @@ const store = new Store(() => bootSelection);
bootSelection = await defaultSelection();
store.seedIfEmpty();

/** A bot as a client may see it: no provider session cursors.
*
* `resumeCursors` is the harness's own bookkeeping — the native session id
* to resume, per instance, per task. No client has ever used it, and a
* paired phone has even less business holding provider session identifiers
* than the desktop window did. Stripped here rather than at each call site
* so a new broadcast cannot forget. */
const wireTask = ({ resumeCursors, ...task }: TaskRecord) => task;

const wireBot = (bot: NonNullable<ReturnType<typeof store.bot>>) => {
const { resumeCursors, tasks, ...rest } = bot;
return { ...rest, ...(tasks ? { tasks: tasks.map(wireTask) } : {}) };
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const publicBot = (bot: NonNullable<ReturnType<typeof store.bot>>) => ({
...bot,
...wireBot(bot),
messages: store.messagesFor(bot.threadId),
activeLeafId: store.activeLeaf(bot.threadId),
tasks: store.tasks(bot.id).map(({ resumeCursors, ...task }) => task),
tasks: store.tasks(bot.id).map(wireTask),
});

// ── message pages ──────────────────────────────────────────────────────
Expand Down Expand Up @@ -442,7 +463,7 @@ bus.subscribe((event: RuntimeEvent) => {
lastReply.delete(event.threadId);
if (bot) {
store.patchBot(bot.id, { busy: false, unread: true });
broadcast({ kind: "bot", bot: store.bot(bot.id) });
broadcast({ kind: "bot", bot: wireBot(store.bot(bot.id)!) });
notify(buildNotification("done", bot, event.threadId, reply));
if (screenPollers.has(bot.id)) {
// the last live frame becomes a settled inline screen message —
Expand Down Expand Up @@ -661,7 +682,7 @@ async function startTurn(
// in the background — box provisioning can take ~90s and must never
// hang the HTTP request
store.patchBot(bot.id, { busy: true, unread: false });
broadcast({ kind: "bot", bot: store.bot(bot.id) });
broadcast({ kind: "bot", bot: wireBot(store.bot(bot.id)!) });

void (async () => {
try {
Expand Down Expand Up @@ -827,7 +848,7 @@ async function startTurn(
});
broadcast({ kind: "message", threadId, message: failure });
store.patchBot(bot.id, { busy: false });
broadcast({ kind: "bot", bot: store.bot(bot.id) });
broadcast({ kind: "bot", bot: wireBot(store.bot(bot.id)!) });
opts?.onDispatchError?.(message);
}
})();
Expand Down Expand Up @@ -1063,7 +1084,7 @@ async function reloadProviders() {
});
broadcast({ kind: "message", threadId: b.threadId, message: note });
store.patchBot(b.id, { busy: false });
broadcast({ kind: "bot", bot: store.bot(b.id) });
broadcast({ kind: "bot", bot: wireBot(store.bot(b.id)!) });
}
}

Expand Down Expand Up @@ -1593,7 +1614,7 @@ const server = createServer(async (req, res) => {
store.patchBot(bot.id, { modelSelection: await defaultSelection() });
return json(res, 201, {
bot: {
...store.bot(bot.id)!,
...wireBot(store.bot(bot.id)!),
messages: store.messagesFor(bot.threadId),
activeLeafId: store.activeLeaf(bot.threadId),
},
Expand Down Expand Up @@ -1649,8 +1670,8 @@ const server = createServer(async (req, res) => {
if (chiefChanges === null) return json(res, 404, { error: "no such bot" });
const changed = new Map([[bot.id, store.bot(bot.id)!]]);
for (const changedBot of chiefChanges) changed.set(changedBot.id, changedBot);
for (const changedBot of changed.values()) broadcast({ kind: "bot", bot: changedBot });
return json(res, 200, { bot });
for (const changedBot of changed.values()) broadcast({ kind: "bot", bot: wireBot(changedBot) });
return json(res, 200, { bot: wireBot(bot) });
}
m = path.match(/^\/api\/bots\/([\w-]+)$/);
if (m && method === "DELETE") {
Expand Down Expand Up @@ -1810,10 +1831,10 @@ const server = createServer(async (req, res) => {
// changes which transcript is live, and a partial patch would leave
// the client showing the previous task's conversation.
const botWithThread = (bot: NonNullable<ReturnType<typeof store.bot>>) => ({
...bot,
...wireBot(bot),
messages: store.messagesFor(bot.threadId),
activeLeafId: store.activeLeaf(bot.threadId),
tasks: store.tasks(bot.id).map(({ resumeCursors, ...t }) => t),
tasks: store.tasks(bot.id).map(wireTask),
});

m = path.match(/^\/api\/bots\/([\w-]+)\/tasks$/);
Expand All @@ -1826,7 +1847,7 @@ const server = createServer(async (req, res) => {
if (!task) return json(res, 500, { error: "couldn't create that task" });
const fresh = botWithThread(store.bot(bot.id)!);
broadcast({ kind: "bot", bot: fresh });
return json(res, 201, { bot: fresh, task });
return json(res, 201, { bot: fresh, task: wireTask(task) });
}
m = path.match(/^\/api\/bots\/([\w-]+)\/tasks\/([\w-]+)$/);
if (m && method === "POST") {
Expand All @@ -1842,7 +1863,7 @@ const server = createServer(async (req, res) => {
if (!task) return json(res, 404, { error: "no such task" });
const fresh = botWithThread(store.bot(m[1])!);
broadcast({ kind: "bot", bot: fresh });
return json(res, 200, { task });
return json(res, 200, { task: wireTask(task) });
}
if (m && method === "DELETE") {
const bot = store.bot(m[1]);
Expand Down
Loading