From af8bdb6254c38b7e102092cbba2b5b1b2b73f337 Mon Sep 17 00:00:00 2001 From: milind-soni Date: Sun, 16 Aug 2026 17:40:31 +0530 Subject: [PATCH 1/4] A webhook turn does not inherit auto mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auto mode is something a person switches on for turns they are present for. A webhook turn starts with nobody at the keyboard, on a payload somebody else wrote — so inheriting that switch silently converts "I trust this bot while I'm watching" into "I trust this bot at 3am on whatever GitHub posts". Everything else about webhook triggers defends against a FORGED request, and that part is solid: hashed secret, constant-time compare, untrusted data framing, loopback by default, rate limit. This is about an authentic one. The guard standing behind auto mode is a regex list its own comment calls not a security boundary, and it must not stand in for a human. So the rule lives with the other policy in auto-approve.ts rather than as a condition at the call site: autoDecision refuses when the turn is unattended, before any allow-list is consulted, so an "always allow" grant can't widen into it either. The approval still appears in the chat and can be answered if someone is around. The mark goes on the DETACHED task's thread, since a webhook runs in its own task — marking the bot's active thread would gate the wrong conversation — and is cleared when the turn settles. Co-Authored-By: Claude Fable 5 --- server/auto-approve.test.ts | 17 +++++++++++++++++ server/auto-approve.ts | 16 +++++++++++++++- server/index.ts | 16 +++++++++++++++- 3 files changed, 47 insertions(+), 2 deletions(-) diff --git a/server/auto-approve.test.ts b/server/auto-approve.test.ts index a4bdad7a12..0c91da91bf 100644 --- a/server/auto-approve.test.ts +++ b/server/auto-approve.test.ts @@ -105,3 +105,20 @@ describe("autoDecision", () => { expect(autoDecision({ alwaysAllow: ["Bash"] }, "Bash", "sudo rm -rf /var")).toBeNull(); }); }); + +describe("unattended turns", () => { + const bot = { autoApprove: true, alwaysAllow: ["Bash:git"] }; + + it("does not inherit auto mode when nobody started the turn", () => { + expect(autoDecision(bot, "Bash", "git status", { unattended: true })).toBeNull(); + }); + + it("does not inherit an always-allow grant either", () => { + expect(autoDecision(bot, "Bash", "git log", { unattended: true })).toBeNull(); + }); + + it("still auto-approves the same action when a person started the turn", () => { + expect(autoDecision(bot, "Bash", "git status")).toBeTruthy(); + expect(autoDecision(bot, "Bash", "git status", { unattended: false })).toBeTruthy(); + }); +}); diff --git a/server/auto-approve.ts b/server/auto-approve.ts index 1ea340520e..7d29756367 100644 --- a/server/auto-approve.ts +++ b/server/auto-approve.ts @@ -68,7 +68,21 @@ export interface AutoApprover { /** Why this request may be answered without the human, or null to ask. * The returned string becomes the chip in the transcript, so an * auto-approved action is never invisible. */ -export function autoDecision(bot: AutoApprover, tool: string, summary: string): string | null { +export function autoDecision( + bot: AutoApprover, + tool: string, + summary: string, + context?: { + /** the turn was started by an outside event, with nobody at the keyboard */ + unattended?: boolean; + }, +): string | null { + // Auto mode is something a person switched on for turns they are present + // for. A webhook turn begins with nobody watching, on a payload someone + // else wrote, so it does not inherit that decision — the guard below is a + // pattern list its own comment calls "not a security boundary", and it + // must not stand in for a human at 3am. + if (context?.unattended) return null; // the guards come first, so an "always allow" can never widen into them if (looksDestructive(summary) || looksDestructive(tool)) return null; if (looksSensitive(summary)) return null; diff --git a/server/index.ts b/server/index.ts index 3550d62d1d..e35b9f11a9 100644 --- a/server/index.ts +++ b/server/index.ts @@ -290,6 +290,14 @@ function notify(notification: Notification | null) { // Group threads: the fold needs to know WHO is talking — the turn engine // records the active member here before dispatching its turn. const groupSpeakers = new Map(); + +// Threads whose current turn was started by an outside event rather than a +// person. Auto mode is a decision someone made for turns they were present +// for; a webhook means the turn begins with nobody at the keyboard, on a +// payload somebody else wrote. So these turns don't inherit it — the guard +// behind auto mode is a pattern list, not a security boundary, and letting +// it stand in for a human at 3am is not what "approve as you go" meant. +const webhookTurns = new Set(); let routines: RoutineManager | null = null; // The Local VM is intentionally one shared, visible desktop. Two agents // driving it simultaneously would mix clicks, keystrokes and screenshots, @@ -372,7 +380,9 @@ bus.subscribe((event: RuntimeEvent) => { // looks destructive stops even in auto mode. const asker = bot ?? (speaker ? store.bot(speaker.botId) : undefined); const settled = permission && asker && event.requestId - ? autoDecision(asker, event.tool, event.summary) + ? autoDecision(asker, event.tool, event.summary, { + unattended: webhookTurns.has(event.threadId), + }) : null; if (settled && asker && event.requestId) { const instance = event.providerInstanceId @@ -461,6 +471,7 @@ bus.subscribe((event: RuntimeEvent) => { }); break; case "turn.completed": { + webhookTurns.delete(event.threadId); if (activeVmThreadId === event.threadId) activeVmThreadId = null; const reply = lastReply.get(event.threadId) ?? ""; lastReply.delete(event.threadId); @@ -619,6 +630,9 @@ async function startTurn( if (!bot) throw Object.assign(new Error("no such bot"), { status: 404 }); if (bot.busy) throw Object.assign(new Error("the bot is already working — interrupt it first"), { status: 409 }); const threadId = opts?.threadId ?? bot.threadId; + // a webhook turn runs in its own detached task, so mark THAT thread — + // marking the bot's active one would gate the wrong conversation + if (opts?.automationSource === "webhook") webhookTurns.add(threadId); const task = store.taskByThread(bot.id, threadId); if (!task) throw Object.assign(new Error("no such task"), { status: 404 }); const commsDepth = opts?.commsDepth ?? 0; From d7f85c952cc1616bd4ba0c46dbae4066a4656434 Mon Sep 17 00:00:00 2001 From: milind-soni Date: Sun, 16 Aug 2026 17:49:57 +0530 Subject: [PATCH 2/4] Carry the unattended gate across peer-comms hops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review caught that the gate held on the bot that read the webhook payload and released on the bot that acted on it. A webhook turn starts at depth 0, so the agents tools ARE mounted and the bot can hand work to a teammate — whose turn then ran with full auto mode and every always-allow grant, nobody at the keyboard. One hop was all it took, and the depth cap does not help. The mark is now keyed by BOT rather than thread. A bot runs one turn at a time so the identity is exact, and the comms paths know who is asking but not always from which thread — ask_bot had no source thread at all. Both ask_bot and delegate_bot now pass the caller's state to the turn they start. It expires by time instead of being cleared on turn.completed. Bus subscribers fire in registration order and the delegation drain runs AFTER the main fold, so clearing there blanked the flag before the hop that needed to read it — the obvious fix, and wrong. A stale mark only ever means "ask a human", so the failure direction is safe, and the TTL stops the map growing without bound. Two wiring tests, because the existing ones exercise the rule and would all still pass if the mark were never set or never read. Both were confirmed to FAIL with the wiring removed: one deletes the gate in the fold, the other breaks only the hop. Co-Authored-By: Claude Fable 5 --- server/index.ts | 65 +++++++++--- server/unattended.test.ts | 212 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 261 insertions(+), 16 deletions(-) create mode 100644 server/unattended.test.ts diff --git a/server/index.ts b/server/index.ts index e35b9f11a9..373b9ffc5c 100644 --- a/server/index.ts +++ b/server/index.ts @@ -103,7 +103,7 @@ function agentsIntegration(botId: string, threadId: string, depth: number) { /** Run a turn on `targetBotId` and resolve with its assistant text — the * synchronous half of ask_bot. Subscribes to the bus, folds assistant_text * for that thread, resolves on turn.completed (or a 4-min ceiling). */ -function askBotAndWait(targetBotId: string, message: string, depth: number): Promise { +function askBotAndWait(targetBotId: string, message: string, depth: number, fromBotId?: string): Promise { const target = store.bot(targetBotId); if (!target) return Promise.resolve("(no such bot)"); const threadId = target.threadId; @@ -126,7 +126,10 @@ function askBotAndWait(targetBotId: string, message: string, depth: number): Pro } }); const timer = setTimeout(() => finish(text || "(timed out waiting for the bot to reply)"), 4 * 60_000); - startTurn(targetBotId, message, { commsDepth: depth + 1 }).catch((err) => + startTurn(targetBotId, message, { + commsDepth: depth + 1, + unattended: isUnattended(fromBotId), + }).catch((err) => finish(`(couldn't start that bot: ${err instanceof Error ? err.message : String(err)})`), ); }); @@ -291,13 +294,38 @@ function notify(notification: Notification | null) { // records the active member here before dispatching its turn. const groupSpeakers = new Map(); -// Threads whose current turn was started by an outside event rather than a -// person. Auto mode is a decision someone made for turns they were present -// for; a webhook means the turn begins with nobody at the keyboard, on a -// payload somebody else wrote. So these turns don't inherit it — the guard -// behind auto mode is a pattern list, not a security boundary, and letting -// it stand in for a human at 3am is not what "approve as you go" meant. -const webhookTurns = new Set(); +// Bots currently working with nobody at the keyboard — a webhook turn, or a +// turn a webhook-driven bot handed to a teammate. Auto mode is a decision +// someone made for turns they were present for, so these don't inherit it: +// the guard behind auto mode is a pattern list, not a security boundary, and +// it must not stand in for a human at 3am. +// +// Keyed by BOT rather than thread because a bot runs one turn at a time, so +// the identity is exact, and because the peer-comms paths know who is asking +// but not always from which thread. Expired by time rather than cleared on +// turn.completed: bus subscribers fire in registration order, and the +// delegation drain runs AFTER the main fold — clearing there would blank the +// flag before the hop that needs to read it. A stale mark only ever means +// "ask a human", so this fails closed. +const unattendedBots = new Map(); +const UNATTENDED_TTL_MS = 30 * 60_000; + +function markUnattended(botId: string) { + unattendedBots.set(botId, Date.now()); +} +function clearUnattended(botId: string) { + unattendedBots.delete(botId); +} +function isUnattended(botId?: string | null): boolean { + if (!botId) return false; + const at = unattendedBots.get(botId); + if (at === undefined) return false; + if (Date.now() - at > UNATTENDED_TTL_MS) { + unattendedBots.delete(botId); + return false; + } + return true; +} let routines: RoutineManager | null = null; // The Local VM is intentionally one shared, visible desktop. Two agents // driving it simultaneously would mix clicks, keystrokes and screenshots, @@ -381,7 +409,7 @@ bus.subscribe((event: RuntimeEvent) => { const asker = bot ?? (speaker ? store.bot(speaker.botId) : undefined); const settled = permission && asker && event.requestId ? autoDecision(asker, event.tool, event.summary, { - unattended: webhookTurns.has(event.threadId), + unattended: isUnattended(asker.id), }) : null; if (settled && asker && event.requestId) { @@ -471,7 +499,6 @@ bus.subscribe((event: RuntimeEvent) => { }); break; case "turn.completed": { - webhookTurns.delete(event.threadId); if (activeVmThreadId === event.threadId) activeVmThreadId = null; const reply = lastReply.get(event.threadId) ?? ""; lastReply.delete(event.threadId); @@ -514,7 +541,10 @@ bus.subscribe((event: RuntimeEvent) => { // unavailable provider. Unhandled, that rejection is fatal to the // harness (Node's default), which in the packaged app kills the server // child. Every delegation failure has to land as a chip instead. - return startTurn(toBotId, text, { commsDepth }).catch((err) => { + return startTurn(toBotId, text, { + commsDepth, + unattended: isUnattended(store.botByThread(sourceThreadId)?.id), + }).catch((err) => { const bot = store.bot(toBotId); const why = err instanceof Error ? err.message : String(err); const source = store.botByThread(sourceThreadId); @@ -623,6 +653,8 @@ async function startTurn( /** Lets the system prompt put externally supplied payloads behind an * explicit untrusted-data boundary without changing ordinary chat. */ automationSource?: RoutineRunTrigger; + /** the caller was already running unattended, so this turn is too */ + unattended?: boolean; onDispatchError?: (message: string) => void; }, ) { @@ -630,9 +662,10 @@ async function startTurn( if (!bot) throw Object.assign(new Error("no such bot"), { status: 404 }); if (bot.busy) throw Object.assign(new Error("the bot is already working — interrupt it first"), { status: 409 }); const threadId = opts?.threadId ?? bot.threadId; - // a webhook turn runs in its own detached task, so mark THAT thread — - // marking the bot's active one would gate the wrong conversation - if (opts?.automationSource === "webhook") webhookTurns.add(threadId); + // a webhook turn, or one inherited from a bot already running unattended + if (opts?.automationSource === "webhook" || opts?.unattended) markUnattended(bot.id); + // a person typing into this bot ends the unattended window immediately + else if (opts?.automationSource === undefined && !opts?.commsDepth) clearUnattended(bot.id); const task = store.taskByThread(bot.id, threadId); if (!task) throw Object.assign(new Error("no such task"), { status: 404 }); const commsDepth = opts?.commsDepth ?? 0; @@ -1322,7 +1355,7 @@ const server = createServer(async (req, res) => { const channel = getOrCreateChannel(store, currentFrom, currentTarget); mirrorExchange(commsBus, currentFrom, currentTarget, message, channel, fromThreadId); const prefixed = `[Message from @${currentFrom.name}, another bot in this OpenMausBot workspace. Reply to them.]\n\n${message}`; - const reply = await askBotAndWait(toBotId, prefixed, depth); + const reply = await askBotAndWait(toBotId, prefixed, depth, fromBotId); mirrorReply(commsBus, currentTarget, reply, channel); return json(res, 200, { botName: currentTarget.name, text: reply }); } diff --git a/server/unattended.test.ts b/server/unattended.test.ts new file mode 100644 index 0000000000..7143eef7ef --- /dev/null +++ b/server/unattended.test.ts @@ -0,0 +1,212 @@ +// Auto mode must not follow a turn that nobody started. +// +// The unit tests in auto-approve.test.ts pin the RULE; these pin the +// WIRING, which is the part that silently rots. Both of these pass if the +// unattended mark is never set, or set on the wrong key, or never read — +// so they are written to fail in exactly those cases: +// +// 1. a webhook delivery to a bot with auto mode ON must still produce an +// approval card, not a silent auto-approval +// 2. and so must the turn that bot hands to a teammate — the gate has to +// survive the peer-comms hop, or it protects the bot that read the +// payload and releases the one that acts on it +import { spawn, type ChildProcess } from "node:child_process"; +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +const SERVER_DIR = dirname(fileURLToPath(import.meta.url)); +const FAKE_CLI = join(SERVER_DIR, "testing", "fake-acp-cli.ts"); +const PORT = 18800 + Math.floor(Math.random() * 10_000); +const BASE = `http://127.0.0.1:${PORT}`; +const posixOnly = describe.skipIf(process.platform === "win32"); + +let child: ChildProcess; +let home: string; +let stderr = ""; + +const api = async (method: string, path: string, body?: unknown): Promise<{ status: number; body: any }> => { + const res = await fetch(`${BASE}${path}`, { + method, + headers: body ? { "content-type": "application/json" } : undefined, + body: body ? JSON.stringify(body) : undefined, + }); + return { status: res.status, body: await res.json() }; +}; + +/** Poll a THREAD for a live permission card. A webhook runs in its own + * detached task, so the card never appears on the bot's open conversation — + * looking there is how you convince yourself this works when it doesn't. */ +async function waitForCard(threadId: string, ms = 30_000) { + const deadline = Date.now() + ms; + while (Date.now() < deadline) { + const { body } = await api("GET", `/api/threads/${threadId}/messages`); + const card = (body.messages ?? []).find( + (m: { kind: string; card?: { requestId?: string } }) => m.kind === "options" && m.card?.requestId, + ); + if (card) return card; + await new Promise((r) => setTimeout(r, 250)); + } + return null; +} + +/** The detached task a webhook delivery created. */ +async function waitForRunThread(runId: string, ms = 20_000) { + const deadline = Date.now() + ms; + while (Date.now() < deadline) { + const { body } = await api("GET", "/api/routines"); + const run = (body.runs ?? []).find((r: { id: string }) => r.id === runId); + if (run?.threadId) return run.threadId as string; + await new Promise((r) => setTimeout(r, 250)); + } + return null; +} + +posixOnly("unattended turns keep asking", () => { + beforeAll(async () => { + chmodSync(FAKE_CLI, 0o755); + home = mkdtempSync(join(tmpdir(), "omb-unattended-")); + mkdirSync(join(home, ".openmausbot"), { recursive: true }); + writeFileSync( + join(home, ".openmausbot", "config.json"), + JSON.stringify({ + instances: { + // asks the client for permission mid-turn, which is exactly the + // moment auto mode would normally answer on the human's behalf + grok: { + driver: "grokAgent", + environment: { FAKE_ACP_MODE: "permission" }, + config: { cli: FAKE_CLI, fullAuto: false }, + }, + // hands its work to a teammate, so the gate has to cross the hop + delegator: { + driver: "grokAgent", + environment: { FAKE_ACP_MODE: "delegate-peer" }, + config: { cli: FAKE_CLI, fullAuto: false }, + }, + }, + }), + ); + child = spawn(process.execPath, [join(SERVER_DIR, "index.ts")], { + cwd: join(SERVER_DIR, ".."), + env: { + ...(process.env.PATH ? { PATH: process.env.PATH } : {}), + ...(process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {}), + HOME: home, + USERPROFILE: home, + OMB_PORT: String(PORT), + }, + stdio: ["ignore", "pipe", "pipe"], + }); + child.stderr!.on("data", (c) => (stderr += c)); + const deadline = Date.now() + 20_000; + for (;;) { + try { + if ((await fetch(`${BASE}/api/health`)).ok) break; + } catch { + /* not up yet */ + } + if (Date.now() > deadline) throw new Error(`server never came up. stderr:\n${stderr}`); + await new Promise((r) => setTimeout(r, 150)); + } + }, 40_000); + + afterAll(async () => { + child?.kill("SIGTERM"); + await new Promise((resolve) => { + if (!child || child.exitCode !== null) return resolve(); + child.on("close", () => resolve()); + setTimeout(() => (child.kill("SIGKILL"), resolve()), 5000).unref?.(); + }); + rmSync(home, { recursive: true, force: true }); + }); + + it( + "still asks a human when a webhook starts the turn, even with auto mode on", + async () => { + const bots = await api("GET", "/api/bots"); + const bot = bots.body.bots[0]; + // auto mode ON: an attended turn would sail straight through + expect((await api("PATCH", `/api/bots/${bot.id}`, { autoApprove: true })).status).toBe(200); + + const hook = await api("POST", "/api/webhooks", { + name: "Nightly build", + prompt: "Handle the incoming build event", + botId: bot.id, + runOn: "maus", + }); + expect(hook.status).toBe(201); + + const delivered = await fetch(hook.body.credential.url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ status: "failed" }), + }); + expect(delivered.status).toBe(202); + const { runId } = (await delivered.json()) as { runId: string }; + + const threadId = await waitForRunThread(runId); + expect(threadId, "the webhook never started a task").toBeTruthy(); + + // the request must reach a person: a card with a live requestId + const card = await waitForCard(threadId!); + expect(card, "a webhook turn auto-approved instead of asking").not.toBeNull(); + expect(card.card.requestId).toBeTruthy(); + // and it must not already be answered + expect(card.card.answered).toBeUndefined(); + }, + 60_000, + ); + + it( + "keeps asking after the work is handed to a teammate", + async () => { + // A runs the webhook and delegates; B does the acting. Without the + // mark crossing the hop, the gate protects the bot that READ the + // payload and releases the bot that ACTS on it. + const created = await api("POST", "/api/bots"); + const teammate = created.body.bot; + await api("PATCH", `/api/bots/${teammate.id}`, { name: "Teammate", autoApprove: true }); + + const delegator = (await api("POST", "/api/bots")).body.bot; + await api("PATCH", `/api/bots/${delegator.id}`, { + name: "Delegator", + autoApprove: true, + modelSelection: { instanceId: "delegator", model: "fake-model" }, + }); + + const hook = await api("POST", "/api/webhooks", { + name: "Handoff", + prompt: "Ask the Teammate to handle this", + botId: delegator.id, + runOn: "maus", + }); + expect(hook.status).toBe(201); + + const delivered = await fetch(hook.body.credential.url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ event: "handoff" }), + }); + expect(delivered.status).toBe(202); + + // the teammate's turn runs on ITS own thread, unattended by inheritance + const deadline = Date.now() + 40_000; + let card: { card?: { requestId?: string; answered?: string } } | null = null; + while (Date.now() < deadline && !card) { + const { body } = await api("GET", "/api/bots"); + const peer = body.bots.find((b: { id: string }) => b.id === teammate.id); + card = + peer?.messages?.find( + (m: { kind: string; card?: { requestId?: string } }) => m.kind === "options" && m.card?.requestId, + ) ?? null; + if (!card) await new Promise((r) => setTimeout(r, 300)); + } + expect(card, "the delegated turn auto-approved — the gate did not cross the hop").not.toBeNull(); + expect(card!.card!.answered).toBeUndefined(); + }, + 90_000, + ); +}); From 705b84a5700ce78c637103a29e1c4315fbb578c5 Mon Sep 17 00:00:00 2001 From: milind-soni Date: Sun, 16 Aug 2026 17:55:39 +0530 Subject: [PATCH 3/4] Pin the ask_bot hop too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review mutated the other comms path — index.ts:124, the ask_bot one — and both existing tests stayed green. The propagation was written correctly, but nothing held it there, so a refactor could have silently reopened the hole this branch exists to close, with the suite passing. It is also the likelier path in practice: a webhook-triggered bot pulling a teammate in for an answer mid-turn is more ordinary than handing the work off asynchronously. Third test drives FAKE_ACP_MODE=ask-peer. The fake asks whichever peer list_bots returns first, so the other bots are hidden to make the target deterministic. Verified to fail with that one line mutated: "the asked teammate auto-approved — ask_bot did not carry the gate". Co-Authored-By: Claude Fable 5 --- server/unattended.test.ts | 63 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/server/unattended.test.ts b/server/unattended.test.ts index 7143eef7ef..1b24d8a86d 100644 --- a/server/unattended.test.ts +++ b/server/unattended.test.ts @@ -86,6 +86,13 @@ posixOnly("unattended turns keep asking", () => { environment: { FAKE_ACP_MODE: "delegate-peer" }, config: { cli: FAKE_CLI, fullAuto: false }, }, + // asks a teammate synchronously — the other comms path, and the + // likelier one: a webhook bot pulling someone in for an answer + asker: { + driver: "grokAgent", + environment: { FAKE_ACP_MODE: "ask-peer" }, + config: { cli: FAKE_CLI, fullAuto: false }, + }, }, }), ); @@ -209,4 +216,60 @@ posixOnly("unattended turns keep asking", () => { }, 90_000, ); + + it( + "keeps asking when the teammate is pulled in synchronously", + async () => { + // ask_bot rather than delegate_bot. Same hole, different door, and + // this is the ordinary shape: a webhook bot asking someone a question + // mid-turn. The fake asks whichever peer list_bots returns first, so + // everything else is hidden to make the target deterministic. + const existing = await api("GET", "/api/bots"); + for (const b of existing.body.bots) await api("PATCH", `/api/bots/${b.id}`, { hidden: true }); + + const target = (await api("POST", "/api/bots")).body.bot; + await api("PATCH", `/api/bots/${target.id}`, { + name: "Answerer", + autoApprove: true, + modelSelection: { instanceId: "grok", model: "fake-model" }, + }); + + const asker = (await api("POST", "/api/bots")).body.bot; + await api("PATCH", `/api/bots/${asker.id}`, { + name: "Asker", + autoApprove: true, + hidden: true, // keep it out of its own peer list's way + modelSelection: { instanceId: "asker", model: "fake-model" }, + }); + + const hook = await api("POST", "/api/webhooks", { + name: "Ask a teammate", + prompt: "Ask the Answerer what to do about this", + botId: asker.id, + runOn: "maus", + }); + expect(hook.status).toBe(201); + const delivered = await fetch(hook.body.credential.url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ event: "ask" }), + }); + expect(delivered.status).toBe(202); + + const deadline = Date.now() + 40_000; + let card: { card?: { requestId?: string; answered?: string } } | null = null; + while (Date.now() < deadline && !card) { + const { body } = await api("GET", "/api/bots"); + const peer = body.bots.find((b: { id: string }) => b.id === target.id); + card = + peer?.messages?.find( + (m: { kind: string; card?: { requestId?: string } }) => m.kind === "options" && m.card?.requestId, + ) ?? null; + if (!card) await new Promise((r) => setTimeout(r, 300)); + } + expect(card, "the asked teammate auto-approved — ask_bot did not carry the gate").not.toBeNull(); + expect(card!.card!.answered).toBeUndefined(); + }, + 90_000, + ); }); From 9a7097a60de57f389bd1fce8b59cb365335b0d37 Mon Sep 17 00:00:00 2001 From: milind-soni Date: Mon, 17 Aug 2026 01:38:53 +0530 Subject: [PATCH 4/4] Keep unattended approvals guarded --- server/index.ts | 12 ++++++++---- server/unattended.test.ts | 9 ++++++++- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/server/index.ts b/server/index.ts index 373b9ffc5c..6848b7a57e 100644 --- a/server/index.ts +++ b/server/index.ts @@ -302,11 +302,11 @@ const groupSpeakers = new Map(); const UNATTENDED_TTL_MS = 30 * 60_000; @@ -320,10 +320,14 @@ function isUnattended(botId?: string | null): boolean { if (!botId) return false; const at = unattendedBots.get(botId); if (at === undefined) return false; - if (Date.now() - at > UNATTENDED_TTL_MS) { + // A long-running turn is still unattended even if its next approval comes + // more than 30 minutes after the previous one. Only an idle bot may age + // out; every positive read refreshes the inactivity window. + if (Date.now() - at > UNATTENDED_TTL_MS && !store.bot(botId)?.busy) { unattendedBots.delete(botId); return false; } + unattendedBots.set(botId, Date.now()); return true; } let routines: RoutineManager | null = null; diff --git a/server/unattended.test.ts b/server/unattended.test.ts index 1b24d8a86d..824d8fbe5c 100644 --- a/server/unattended.test.ts +++ b/server/unattended.test.ts @@ -136,7 +136,14 @@ posixOnly("unattended turns keep asking", () => { const bots = await api("GET", "/api/bots"); const bot = bots.body.bots[0]; // auto mode ON: an attended turn would sail straight through - expect((await api("PATCH", `/api/bots/${bot.id}`, { autoApprove: true })).status).toBe(200); + expect( + ( + await api("PATCH", `/api/bots/${bot.id}`, { + autoApprove: true, + modelSelection: { instanceId: "grok", model: "fake-model" }, + }) + ).status, + ).toBe(200); const hook = await api("POST", "/api/webhooks", { name: "Nightly build",