diff --git a/server/credential-request.test.ts b/server/credential-request.test.ts new file mode 100644 index 000000000..f45dc2b78 --- /dev/null +++ b/server/credential-request.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; + +import { + CREDENTIAL_TARGETS, + credentialConfigPatch, + credentialIsConfigured, + credentialResumeOutcome, + isReusableCredentialRequest, + isCredentialTargetId, + type CredentialConfig, + type CredentialTargetId, +} from "../shared/credential-request.ts"; + +const MAPPINGS: Array<[CredentialTargetId, CredentialConfig]> = [ + ["xaiApiKey", { xai: { key: "secret" } }], + ["boxToken", { box: { token: "secret" } }], + ["opencodeGoApiKey", { opencodeGo: { apiKey: "secret" } }], + ["ttsKey", { tts: { key: "secret" } }], + ["openaiImageApiKey", { imageGen: { key: "secret" } }], +]; + +describe("credential request allowlist", () => { + it("accepts only declared own ids", () => { + expect(isCredentialTargetId("xaiApiKey")).toBe(true); + expect(isCredentialTargetId("composioApiKey")).toBe(false); + expect(isCredentialTargetId("__proto__")).toBe(false); + expect(isCredentialTargetId({ toString: () => "xaiApiKey" })).toBe(false); + }); + + it("maps each id to a fixed config location", () => { + expect(MAPPINGS.map(([id]) => id).sort()).toEqual(Object.keys(CREDENTIAL_TARGETS).sort()); + for (const [id, patch] of MAPPINGS) { + expect(credentialConfigPatch(id, "secret")).toEqual(patch); + expect(credentialIsConfigured(patch, id)).toBe(true); + expect(credentialIsConfigured({}, id)).toBe(false); + } + }); + + it("checks configured state without exposing values", () => { + expect(credentialIsConfigured({ tts: { key: "secret" } }, "ttsKey")).toBe(true); + expect(credentialIsConfigured({ tts: { key: "" } }, "ttsKey")).toBe(false); + expect(Object.keys(CREDENTIAL_TARGETS)).toHaveLength(5); + }); + + it("reuses open room cards only for the bot that requested them", () => { + const card = { + kind: "secret", + secret: { target: "xaiApiKey" }, + from: { botId: "atlas" }, + }; + expect(isReusableCredentialRequest(card, "xaiApiKey", "atlas", true)).toBe(true); + expect(isReusableCredentialRequest(card, "xaiApiKey", "pixel", true)).toBe(false); + expect(isReusableCredentialRequest(card, "xaiApiKey", "pixel", false)).toBe(true); + expect(isReusableCredentialRequest({ ...card, secret: { ...card.secret, provided: true } }, "xaiApiKey", "atlas", true)).toBe(false); + }); + + it("preserves the original save or decline outcome when retrying", () => { + expect(credentialResumeOutcome({ provided: true })).toBe("provided"); + expect(credentialResumeOutcome({ dismissed: true })).toBe("dismissed"); + expect(credentialResumeOutcome({})).toBeNull(); + expect(credentialResumeOutcome({ provided: true, dismissed: true })).toBeNull(); + }); +}); diff --git a/server/drivers/agents-proxy.test.ts b/server/drivers/agents-proxy.test.ts index 73b87bf19..b686cc754 100644 --- a/server/drivers/agents-proxy.test.ts +++ b/server/drivers/agents-proxy.test.ts @@ -21,6 +21,7 @@ let askResponse: unknown = { botName: "Helper", text: "hi from helper" }; let lastDelegateBody: any = null; let delegateResponse: unknown = { queued: true, message: "Delegation queued." }; let lastCreateBody: any = null; +let lastCredentialBody: any = null; let child: ChildProcess; const pending = new Map void>(); @@ -83,6 +84,16 @@ beforeAll(async () => { }); return; } + if (req.method === "POST" && req.url === "/api/internal/request-credential") { + let data = ""; + req.on("data", (c) => (data += c)); + req.on("end", () => { + lastCredentialBody = JSON.parse(data); + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ messageId: "msg-key", label: "OpenCode API key" })); + }); + return; + } res.writeHead(404, { "content-type": "application/json" }); res.end(JSON.stringify({ error: "unknown" })); }); @@ -121,7 +132,7 @@ afterAll(async () => { }); describe("agents-proxy MCP surface", () => { - it("answers the MCP handshake and lists all four tools", async () => { + it("answers the MCP handshake and lists all five tools", async () => { const init = await rpc("initialize", { protocolVersion: "2024-11-05" }); expect(init.result.serverInfo.name).toContain("agents"); const list = await rpc("tools/list"); @@ -130,6 +141,7 @@ describe("agents-proxy MCP surface", () => { "ask_bot", "delegate_bot", "create_bot", + "request_credential", ]); }); @@ -210,6 +222,29 @@ describe("agents-proxy MCP surface", () => { }); }); + it("requests an allowlisted credential without putting a secret in the request", async () => { + const res = await callTool("request_credential", { + credential_id: "opencodeGoApiKey", + reason: "The selected model needs it.", + }); + expect(res.result.content[0].text).toContain("secure OpenCode API key card"); + expect(res.result.content[0].text).toContain("End this turn"); + expect(lastCredentialBody).toEqual({ + fromBotId: "bot-asker", + fromThreadId: "thread-asker-routine", + credentialId: "opencodeGoApiKey", + reason: "The selected model needs it.", + }); + expect(JSON.stringify(lastCredentialBody)).not.toContain("secret"); + }); + + it("rejects credential ids outside the fixed allowlist locally", async () => { + lastCredentialBody = null; + const res = await callTool("request_credential", { credential_id: "arbitrary.config.path" }); + expect(res.result.isError).toBe(true); + expect(lastCredentialBody).toBeNull(); + }); + it("rejects unknown tools with -32602", async () => { const res = await rpc("tools/call", { name: "made_up", arguments: {} }); expect(res.error.code).toBe(-32602); diff --git a/server/drivers/agents-proxy.ts b/server/drivers/agents-proxy.ts index 4e5ecd4aa..c949db741 100644 --- a/server/drivers/agents-proxy.ts +++ b/server/drivers/agents-proxy.ts @@ -1,5 +1,5 @@ // Agent-to-agent comms MCP proxy — spawned as an MCP server inside a bot's -// agent process (via the "agents" integration). Exposes four tools that +// agent process (via the "agents" integration). Exposes five tools that // let one bot talk to another, routed back through the harness so the // harness stays the single owner of turns, permissions, and recursion // limits: @@ -12,6 +12,7 @@ // the peer's reply as its own turn // create_bot(name, role, instructions) → Chiefs can add a specialist to // their own section +// request_credential(id, reason?) → show a secure, allowlisted key card // // Speaks raw JSON-RPC 2.0 over stdio (no MCP SDK — house style, matches // computer-proxy / permission-proxy). All state comes from env, injected by @@ -22,6 +23,8 @@ // OMB_TURN_DEPTH this turn's comms depth (the harness refuses recursion) import readline from "node:readline"; +import { CREDENTIAL_TARGETS, isCredentialTargetId } from "../../shared/credential-request.ts"; + const HARNESS = process.env.OMB_HARNESS_URL ?? "http://127.0.0.1:8799"; const BOT_ID = process.env.OMB_BOT_ID ?? ""; const THREAD_ID = process.env.OMB_THREAD_ID ?? ""; @@ -78,6 +81,26 @@ const TOOLS = [ required: ["name", "role", "instructions"], }, }, + { + name: "request_credential", + description: + "Ask the user for a supported API key through OpenMausBot's secure credential card. Use this instead of asking them to paste a secret into chat. The secret is saved by the desktop app and is never returned to you. After calling this tool, end the turn; OpenMausBot resumes the task after the user saves or declines.", + inputSchema: { + type: "object", + properties: { + credential_id: { + type: "string", + enum: Object.keys(CREDENTIAL_TARGETS), + description: "The credential the current task requires.", + }, + reason: { + type: "string", + description: "Optional short, non-sensitive explanation of why the task needs it.", + }, + }, + required: ["credential_id"], + }, + }, ]; type Json = Record; @@ -165,6 +188,28 @@ async function callTool(name: string, args: Json): Promise<{ text: string; isErr text: `Created @${r.name ?? botName} in ${r.section ?? "General"} [id: ${r.id}]. Assign work with delegate_bot.`, }; } + if (name === "request_credential") { + const credentialId = args.credential_id; + if (!isCredentialTargetId(credentialId)) { + return { text: "request_credential needs a supported credential_id.", isError: true }; + } + const reason = typeof args.reason === "string" ? args.reason.trim().slice(0, 240) : ""; + const r = await api("/api/internal/request-credential", { + method: "POST", + body: JSON.stringify({ + fromBotId: BOT_ID, + fromThreadId: THREAD_ID, + credentialId, + ...(reason ? { reason } : {}), + }), + }); + if (r.alreadyConfigured) { + return { text: `${r.label ?? CREDENTIAL_TARGETS[credentialId].label} is already configured. Continue the task.` }; + } + return { + text: `A secure ${r.label ?? CREDENTIAL_TARGETS[credentialId].label} card is now visible to the user. End this turn; OpenMausBot will resume the task after they save or decline. Never ask them to paste the key into chat.`, + }; + } return { text: `Unknown tool: ${name}`, isError: true }; } diff --git a/server/index.ts b/server/index.ts index 6d5457bd6..6dc63211b 100644 --- a/server/index.ts +++ b/server/index.ts @@ -9,6 +9,14 @@ import { extname, join } from "node:path"; import { z } from "zod"; import { botAvatarUrlFromStoredPath } from "../shared/bot-avatar.ts"; +import { + CREDENTIAL_TARGETS, + credentialResumeOutcome, + credentialIsConfigured, + isReusableCredentialRequest, + isCredentialTargetId, + type CredentialTargetId, +} from "../shared/credential-request.ts"; import { approvalKey, autoVerdict } from "./auto-approve.ts"; import { appendDecision, readDecisions } from "./decision-log.ts"; @@ -616,6 +624,12 @@ const watchdog = new TurnWatchdog({ stopScreenPoller(currentBot.id); if (activeVpsThreads.get(currentBot.id) === turn.threadId) activeVpsThreads.delete(currentBot.id); store.setActivity(currentBot.id, "idle"); + // The grace fallback replaces a missing turn.completed event. Release + // every kind of work that may have queued behind this bot, including + // connector and credential continuations. + drainQueuedSends(); + drainConnectorResumes(); + drainSecretResumes(); } }, 6_000); release.unref?.(); @@ -1325,10 +1339,10 @@ async function startTurn( automationSource?: RoutineRunTrigger; /** the caller was already running unattended, so this turn is too */ unattended?: boolean; - /** Resume an agent after the user completed an inline connection card. + /** Resume an agent after the user completed an inline connection or credential card. * The prompt is control-plane context: it reaches the provider without * masquerading as another message authored by the user. */ - connectorContinuation?: boolean; + cardContinuation?: boolean; onDispatchError?: (message: string) => void; }, ) { @@ -1339,12 +1353,12 @@ async function startTurn( // 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 && !opts?.connectorContinuation) clearUnattended(bot.id); + else if (opts?.automationSource === undefined && !opts?.commsDepth && !opts?.cardContinuation) 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; // a task takes its name from the first thing you asked it to do - if (text.trim() && !opts?.connectorContinuation) store.titleTaskFromFirstMessage(bot.id, text, threadId); + if (text.trim() && !opts?.cardContinuation) store.titleTaskFromFirstMessage(bot.id, text, threadId); const instance = opts?.runOn === "cloud" ? registry.instances().find((candidate) => candidate.driverKind === "boxAgent") ?? null @@ -1376,8 +1390,8 @@ async function startTurn( // an edit hands us its already-branched user message; a plain send appends let userMessage = opts?.userMessage; if (!userMessage) { - userMessage = opts?.connectorContinuation - ? { id: `connector-${randomUUID()}`, at: Date.now(), role: "user", kind: "text", text } + userMessage = opts?.cardContinuation + ? { id: `card-${randomUUID()}`, at: Date.now(), role: "user", kind: "text", text } : store.appendMessage(threadId, { role: "user", kind: "text", text }); } @@ -1616,8 +1630,8 @@ async function startTurn( computerKind = "local"; } } - // peer-agent comms: give a user-initiated turn the list_bots/ask_bot - // tools. A comms-invoked turn (depth ≥ cap) gets none — hard recursion + // Agent control tools include peer comms and the secure credential + // request card. A comms-invoked turn (depth ≥ cap) gets none — hard recursion // stop, so the user's tokens can't be burned by a bot-to-bot loop. // Only drivers that mount the tools get the integration (and, via the // integrations.agents gate below, the prompt hint) — a bot on a driver @@ -1631,8 +1645,7 @@ async function startTurn( ); if ( commsDepth < MAX_COMMS_DEPTH && - instance.adapter.capabilities.agentsMcp === true && - (bot.chiefOfStaff || sectionPeers.length > 0) + instance.adapter.capabilities.agentsMcp === true ) { integrations.agents = agentsIntegration(bot.id, threadId, commsDepth); } @@ -1647,9 +1660,12 @@ async function startTurn( : []; const coordinationPrompt = bot.chiefOfStaff ? chiefOfStaffSystemPrompt(bot.id, store.bots, Boolean(integrations.agents)) - : integrations.agents + : integrations.agents && sectionPeers.length > 0 ? "You can work with the other bots in your section through the agents tools — list_bots shows who's available, ask_bot sends one of them a message and returns their reply." : ""; + const credentialPrompt = integrations.agents + ? " If a supported API key is missing, use request_credential to show the secure in-app card. Never ask the user to paste credentials into chat." + : ""; // (activeVpsThreads was already claimed above, before the provision or // reuse await, so the backend guards saw this turn the whole time.) @@ -1686,6 +1702,7 @@ async function startTurn( ? " The user's connected apps (Gmail, Calendar, Slack, Notion, and the rest) are reachable through the composio tools — find the right one with COMPOSIO_SEARCH_TOOLS, read its arguments with COMPOSIO_GET_TOOL_SCHEMAS, then run it with COMPOSIO_MULTI_EXECUTE_TOOL. Reach for them before telling the user you have no access to a service." : "") + (coordinationPrompt ? ` ${coordinationPrompt}` : "") + + credentialPrompt + (privateWorkspace ? memorySystemPrompt(bot.id) + skillsSystemPrompt(bot.id) : "") + skillInstructions + (opts?.automationSource === "webhook" @@ -1726,6 +1743,8 @@ async function startTurn( // a dispatch failure never emits turn.completed, so the settle-driven // drain would strand anything queued behind this turn drainQueuedSends(); + drainConnectorResumes(); + drainSecretResumes(); } })(); } @@ -1852,7 +1871,8 @@ async function runGroupMemberTurn( // bots that already spoke for this user message — "@Scout ask @Pixel" // must not run Pixel twice (once chained, once as a direct responder) spoken: Set = new Set(), - connectorContinuation?: string, + cardContinuation?: string, + onDispatchError?: (message: string) => void, ): Promise { const group = store.group(groupId); const bot = store.bot(botId); @@ -1861,12 +1881,14 @@ async function runGroupMemberTurn( const instance = registry.get(bot.modelSelection.instanceId); const userName = cfg.profile?.name?.trim() || "User"; if (!instance) { + const message = `${bot.name}'s model is unavailable`; store.appendMessage(group.threadId, { role: "bot", kind: "activity", from: { botId: bot.id, name: bot.name, color: bot.color }, - tool: { name: `error: ${bot.name}'s model is unavailable`, ok: false }, + tool: { name: `error: ${message}`, ok: false }, }); + onDispatchError?.(message); return true; } // One turn per bot at a time, across BOTH engines. Without this a bot @@ -1874,15 +1896,20 @@ async function runGroupMemberTurn( // processes, interleaved token spend, and an interrupt that only ever // reached one of them. if (bot.busy) { + const message = `${bot.name} is busy in another conversation — skipped this round`; store.appendMessage(group.threadId, { role: "bot", kind: "activity", from: { botId: bot.id, name: bot.name, color: bot.color }, - tool: { name: `${bot.name} is busy in another conversation — skipped this round`, ok: false }, + tool: { name: message, ok: false }, }); + onDispatchError?.(message); return true; } const integrations: NonNullable[0]["integrations"]> = {}; + if (hop < MAX_COMMS_DEPTH && instance.adapter.capabilities.agentsMcp === true) { + integrations.agents = agentsIntegration(bot.id, group.threadId, hop); + } const selectedSkills = selectBundledSkills( serializeRoomContext(group.threadId, userName), instance.adapter.capabilities.phoneMcp === true ? ["phoneMcp"] : [], @@ -1897,12 +1924,14 @@ async function runGroupMemberTurn( if (connection) integrations.composio = connection; } } catch (error) { + const message = `connected apps are unavailable — ${error instanceof Error ? error.message : String(error)}`; store.appendMessage(group.threadId, { role: "bot", kind: "activity", from: { botId: bot.id, name: bot.name, color: bot.color }, - tool: { name: `error: connected apps are unavailable — ${error instanceof Error ? error.message : String(error)}`, ok: false }, + tool: { name: `error: ${message}`, ok: false }, }); + onDispatchError?.(message); return true; } store.setActivity(bot.id, "working"); @@ -1922,12 +1951,14 @@ async function runGroupMemberTurn( `Room members: ${roster}, and ${userName} (the human).`, group.bulletin.trim() && `Room bulletin (shared instructions for everyone):\n${group.bulletin.trim()}`, `Reply as yourself, briefly and conversationally. To bring a teammate in, mention them like @Name — they'll see the conversation and respond.`, + integrations.agents && + "If a supported API key is missing, use request_credential to show the secure in-app card. Never ask the user to paste credentials into chat.", ] .filter(Boolean) .join("\n"); const text = `${serializeRoomContext(group.threadId, userName)}\n\n(Reply to the conversation above as ${bot.name}.)${ - connectorContinuation ? `\n\n${connectorContinuation}` : "" + cardContinuation ? `\n\n${cardContinuation}` : "" }`; // same workspace + memory as a 1:1 turn — the room is a different @@ -1994,12 +2025,14 @@ async function runGroupMemberTurn( ...memberTurnSelection(bot.modelSelection), }) .catch((err) => { + const message = err instanceof Error ? err.message : "turn failed"; store.appendMessage(group.threadId, { role: "bot", kind: "activity", from: { botId: bot.id, name: bot.name, color: bot.color }, - tool: { name: `error: ${err instanceof Error ? err.message.slice(0, 140) : "turn failed"}`, ok: false }, + tool: { name: `error: ${message.slice(0, 140)}`, ok: false }, }); + onDispatchError?.(message); watchdog.settle(group.threadId); finish("dispatch_failed"); }); @@ -2016,6 +2049,13 @@ async function runGroupMemberTurn( store.patchGroup(group.id, { busyBotId: null, unread: true }); if (store.bot(bot.id)?.busy) store.setActivity(bot.id, "idle"); } + if (outcome === "dispatch_failed") { + // No turn.completed follows a rejected room dispatch. Anything that was + // queued while this bot briefly owned the room must be retried now. + drainQueuedSends(); + drainConnectorResumes(); + drainSecretResumes(); + } // chained mentions: a member's reply can summon teammates — one hop only if (hop < MAX_GROUP_HOPS && replyText.trim()) { @@ -2179,7 +2219,7 @@ function dispatchConnectorResume(entry: { botId: string; threadId: string; resum } void startTurn(entry.botId, prompt, { threadId: entry.threadId, - connectorContinuation: true, + cardContinuation: true, onDispatchError: (message) => markConnectorResumeFailed(entry.threadId, entry.resumeKey, message), }).catch((error) => { const message = error instanceof Error ? error.message : String(error); @@ -2208,8 +2248,114 @@ function drainConnectorResumes() { } } +type SecretResumeEntry = { + botId: string; + threadId: string; + messageId: string; + label: string; + outcome: "provided" | "dismissed"; +}; +const pendingSecretResumes = new Map(); + +function secretMessage(botId: string, threadId: string, messageId: string): Message | null { + if (!connectorThread(botId, threadId)) return null; + const message = store.messagesFor(threadId).find((candidate) => candidate.id === messageId); + return message?.kind === "secret" && message.secret ? message : null; +} + +function markSecretResumeFailed(threadId: string, messageId: string, error: string) { + const message = store.messagesFor(threadId).find((candidate) => candidate.id === messageId); + if (!message?.secret) return; + store.patchMessage(threadId, message.id, { + secret: { ...message.secret, resumed: false, error: error.slice(0, 180) }, + }); +} + +function dispatchSecretResume(entry: SecretResumeEntry) { + const owner = connectorThread(entry.botId, entry.threadId); + if (!owner) return; + const prompt = + entry.outcome === "provided" + ? `OpenMausBot credential update: the user securely provided ${entry.label}. Continue the task that paused for it. You do not receive the secret and must not ask them to paste it into chat.` + : `OpenMausBot credential update: the user declined to provide ${entry.label}. Continue without it if possible, or briefly explain the limitation. Do not ask them to paste it into chat.`; + if (owner.bot.busy) { + pendingSecretResumes.set(`${entry.threadId}:${entry.messageId}`, entry); + return; + } + if (owner.group) { + const previous = groupQueues.get(owner.group.id) ?? Promise.resolve(); + const next = previous.then(async () => { + const current = connectorThread(entry.botId, entry.threadId); + if (!current?.group) return; + if (current.bot.busy) { + pendingSecretResumes.set(`${entry.threadId}:${entry.messageId}`, entry); + return; + } + await runGroupMemberTurn( + current.group.id, + entry.botId, + 0, + new Set(), + prompt, + (message) => markSecretResumeFailed(entry.threadId, entry.messageId, message), + ); + }); + groupQueues.set( + owner.group.id, + next.catch((error) => { + markSecretResumeFailed( + entry.threadId, + entry.messageId, + error instanceof Error ? error.message : String(error), + ); + }), + ); + return; + } + void startTurn(entry.botId, prompt, { + threadId: entry.threadId, + cardContinuation: true, + onDispatchError: (message) => markSecretResumeFailed(entry.threadId, entry.messageId, message), + }).catch((error) => { + const message = error instanceof Error ? error.message : String(error); + if (/already working/i.test(message)) { + pendingSecretResumes.set(`${entry.threadId}:${entry.messageId}`, entry); + } else { + markSecretResumeFailed(entry.threadId, entry.messageId, message); + } + }); +} + +function resumeSecretCard(botId: string, threadId: string, messageId: string, outcome: SecretResumeEntry["outcome"]) { + const message = secretMessage(botId, threadId, messageId); + if (!message?.secret) return false; + if (message.secret.resumed) return true; + store.patchMessage(threadId, message.id, { + secret: { + ...message.secret, + provided: outcome === "provided" ? true : message.secret.provided, + dismissed: outcome === "dismissed" ? true : message.secret.dismissed, + resumed: true, + error: undefined, + }, + }); + dispatchSecretResume({ botId, threadId, messageId, label: message.secret.label, outcome }); + return true; +} + +function drainSecretResumes() { + for (const [key, entry] of pendingSecretResumes) { + if (store.bot(entry.botId)?.busy) continue; + pendingSecretResumes.delete(key); + dispatchSecretResume(entry); + } +} + bus.subscribe((event: RuntimeEvent) => { - if (event.type === "turn.completed") drainConnectorResumes(); + if (event.type === "turn.completed") { + drainConnectorResumes(); + drainSecretResumes(); + } }); /** Pre-save probe for a CLI path override: run ` --version` with the @@ -2376,6 +2522,8 @@ async function reloadProviders() { // killed turns settle here without a turn.completed event, so anything // queued behind them drains now — onto the freshly loaded fleet drainQueuedSends(); + drainConnectorResumes(); + drainSecretResumes(); } // Config writes rebuild the whole provider registry. Keep the read-modify-write @@ -2687,6 +2835,44 @@ const server = createServer(async (req, res) => { model: safeBot.modelSelection.model, }); } + if (method === "POST" && path === "/api/internal/request-credential") { + const body = await readBody(req); + const fromBotId = String(body.fromBotId ?? ""); + const from = store.bot(fromBotId); + if (!from) return json(res, 403, { error: "unknown sender" }); + const fromThreadId = String(body.fromThreadId ?? from.threadId); + const owner = connectorThread(from.id, fromThreadId); + if (!owner) return json(res, 403, { error: "source conversation does not belong to sender" }); + if (!isCredentialTargetId(body.credentialId)) { + return json(res, 400, { error: "unsupported credential id" }); + } + const credentialId: CredentialTargetId = body.credentialId; + const target = CREDENTIAL_TARGETS[credentialId]; + if (credentialIsConfigured(cfg, credentialId)) { + return json(res, 200, { alreadyConfigured: true, label: target.label }); + } + const existing = store.messagesFor(fromThreadId).find((message) => + isReusableCredentialRequest(message, credentialId, from.id, Boolean(owner.group)) + ); + if (existing) { + return json(res, 200, { messageId: existing.id, label: target.label }); + } + const reason = typeof body.reason === "string" ? body.reason.trim().slice(0, 240) : ""; + const message = store.appendMessage(fromThreadId, { + role: "bot", + kind: "secret", + ...(owner.group ? { from: { botId: from.id, name: from.name, color: from.color } } : {}), + secret: { + target: credentialId, + label: target.label, + description: reason ? `${target.description} ${reason}` : target.description, + placeholder: target.placeholder, + helpUrl: target.helpUrl, + requestKey: randomUUID(), + }, + }); + return json(res, 201, { messageId: message.id, label: target.label }); + } if (method === "POST" && path === "/api/internal/connectors/mcp") { const body = await readBody(req); const upstream = await composio.relayMcp( @@ -4491,6 +4677,38 @@ const server = createServer(async (req, res) => { m = path.match(/^\/api\/connectors\/([\w-]+)$/); if (m && method === "DELETE") return json(res, 200, await composio.removeService(cfg, m[1])); + // Inline credential cards never receive the credential value. Electron + // saves it through the OS-backed store first; this route only verifies + // configured state, updates card metadata, and resumes the paused turn. + m = path.match(/^\/api\/bots\/([\w-]+)\/secret-cards\/([\w-]+)\/(provided|resume|dismiss)$/); + if (m && method === "POST") { + const body = await readBody(req); + const threadId = String(body.threadId ?? ""); + const message = secretMessage(m[1], threadId, m[2]); + if (!message?.secret) return json(res, 404, { error: "no such credential request" }); + if (m[3] === "provided") { + if (message.secret.dismissed) return json(res, 409, { error: "this credential request was dismissed" }); + if (!credentialIsConfigured(cfg, message.secret.target)) { + return json(res, 409, { error: `${message.secret.label} was not saved yet` }); + } + resumeSecretCard(m[1], threadId, message.id, "provided"); + return json(res, 200, { provided: true, resumed: true }); + } + if (m[3] === "resume") { + const outcome = credentialResumeOutcome(message.secret); + if (!outcome) { + return json(res, 409, { error: "this credential request is not ready to resume" }); + } + if (outcome === "provided" && !credentialIsConfigured(cfg, message.secret.target)) { + return json(res, 409, { error: `${message.secret.label} is no longer configured` }); + } + resumeSecretCard(m[1], threadId, message.id, outcome); + return json(res, 200, { resumed: true }); + } + if (!message.secret.provided) resumeSecretCard(m[1], threadId, message.id, "dismissed"); + return json(res, 200, { dismissed: true, resumed: true }); + } + // Inline connection cards are bound to both the bot and the exact task // or room thread that created them. The browser auth URL is returned // only to this local UI and is never stored in the transcript. diff --git a/server/store.test.ts b/server/store.test.ts index 181c88eab..87f92930c 100644 --- a/server/store.test.ts +++ b/server/store.test.ts @@ -564,7 +564,7 @@ describe("Store redacts bot-authored secrets on write", () => { rmSync(DATA_DIR, { recursive: true, force: true }); }); - it("masks a key in a bot reply, a tool title and a card summary — but never in what the user typed", () => { + it("masks a key in bot text, tools and cards — but never in what the user typed", () => { const store = new Store(selection); const bot = store.createBot(); const key = `sk-ant-api03-${"abcdefghijklmnopqrstuvwxyz0123456789"}`; @@ -579,6 +579,19 @@ describe("Store redacts bot-authored secrets on write", () => { card: { title: "Run this?", summary: `curl -H "Authorization: Bearer ${key}"`, options: [], requestId: "r1", tool: "Bash" } as never, }); expect((card.card as { summary?: string }).summary).not.toContain(key); + const secretCard = store.appendMessage(bot.threadId, { + role: "bot", + kind: "secret", + secret: { + target: "xaiApiKey", + label: "xAI API key", + description: `The agent accidentally included ${key}`, + placeholder: "xai-…", + helpUrl: "https://console.x.ai/", + requestKey: "credential-request", + }, + }); + expect(secretCard.secret?.description).not.toContain(key); // the user's own words are theirs const mine = store.appendMessage(bot.threadId, { role: "user", kind: "text", text: `use ${key} for the api` }); expect(mine.text).toContain(key); diff --git a/server/store.ts b/server/store.ts index 09027cd0e..1c75d6224 100644 --- a/server/store.ts +++ b/server/store.ts @@ -66,13 +66,28 @@ export interface ConnectorCardData { resumed?: boolean; } +export interface SecretRequestCardData { + /** Fixed allowlisted credential id; never an arbitrary config path. */ + target: import("../shared/credential-request.ts").CredentialTargetId; + label: string; + description: string; + placeholder: string; + helpUrl: string; + requestKey: string; + provided?: boolean; + dismissed?: boolean; + resumed?: boolean; + error?: string; +} + export interface Message { id: string; role: "bot" | "user"; - kind: "text" | "options" | "activity" | "screen" | "connector"; + kind: "text" | "options" | "activity" | "screen" | "connector" | "secret"; text?: string; card?: OptionCardData; connector?: ConnectorCardData; + secret?: SecretRequestCardData; /** activity messages: tool name + outcome. `spoken` is the same chip as * a phrase a voice can read ("reading a file") — computed once here so * call mode never has to re-derive it from the raw tool name, and absent @@ -216,6 +231,14 @@ function redactBotAuthored & { at?: number error: out.connector.error ? redactSecretsInText(out.connector.error) : undefined, }; } + if (out.secret) { + out.secret = { + ...out.secret, + label: redactSecretsInText(out.secret.label), + description: redactSecretsInText(out.secret.description), + error: out.secret.error ? redactSecretsInText(out.secret.error) : undefined, + }; + } return out; } diff --git a/shared/credential-request.ts b/shared/credential-request.ts new file mode 100644 index 000000000..f381a7028 --- /dev/null +++ b/shared/credential-request.ts @@ -0,0 +1,109 @@ +/** + * Credentials an agent may ask the person to provide through an inline + * card. The id is the entire authority surface: agents never choose a + * config path, label, URL, or arbitrary field name. + */ +export const CREDENTIAL_TARGETS = { + xaiApiKey: { + label: "xAI API key", + description: "Used by the built-in Grok provider.", + placeholder: "xai-…", + helpUrl: "https://console.x.ai/", + }, + boxToken: { + label: "Box API key", + description: "Gives bots an isolated cloud computer when Box is selected.", + placeholder: "Paste your Box API key", + helpUrl: "https://docs.ascii.dev/box/api-keys", + }, + opencodeGoApiKey: { + label: "OpenCode API key", + description: "Used for OpenCode Go and other key-backed OpenCode providers.", + placeholder: "Paste your OpenCode API key", + helpUrl: "https://opencode.ai/docs/providers/", + }, + ttsKey: { + label: "ElevenLabs API key", + description: "Enables text-to-speech voices in calls.", + placeholder: "Paste your ElevenLabs API key", + helpUrl: "https://elevenlabs.io/app/settings/api-keys", + }, + openaiImageApiKey: { + label: "OpenAI API key", + description: "Used only to generate custom bot avatar images.", + placeholder: "sk-…", + helpUrl: "https://platform.openai.com/api-keys", + }, +} as const; + +export type CredentialTargetId = keyof typeof CREDENTIAL_TARGETS; +export type CredentialConfig = { + xai?: { key?: string }; + box?: { token?: string }; + opencodeGo?: { apiKey?: string }; + tts?: { key?: string }; + imageGen?: { key?: string }; +}; + +export function isCredentialTargetId(value: unknown): value is CredentialTargetId { + return typeof value === "string" && Object.prototype.hasOwnProperty.call(CREDENTIAL_TARGETS, value); +} + +export function credentialConfigPatch(id: CredentialTargetId, value: string): CredentialConfig { + switch (id) { + case "xaiApiKey": + return { xai: { key: value } }; + case "boxToken": + return { box: { token: value } }; + case "opencodeGoApiKey": + return { opencodeGo: { apiKey: value } }; + case "ttsKey": + return { tts: { key: value } }; + case "openaiImageApiKey": + return { imageGen: { key: value } }; + } +} + +export function credentialIsConfigured(config: CredentialConfig, id: CredentialTargetId): boolean { + switch (id) { + case "xaiApiKey": + return Boolean(config.xai?.key); + case "boxToken": + return Boolean(config.box?.token); + case "opencodeGoApiKey": + return Boolean(config.opencodeGo?.apiKey); + case "ttsKey": + return Boolean(config.tts?.key); + case "openaiImageApiKey": + return Boolean(config.imageGen?.key); + } +} + +export function isReusableCredentialRequest( + message: { + kind?: unknown; + secret?: { target?: unknown; provided?: unknown; dismissed?: unknown }; + from?: { botId?: unknown }; + }, + target: CredentialTargetId, + requestingBotId: string, + roomThread: boolean, +): boolean { + return ( + message.kind === "secret" && + message.secret?.target === target && + message.secret.provided !== true && + message.secret.dismissed !== true && + (!roomThread || message.from?.botId === requestingBotId) + ); +} + +export function credentialResumeOutcome(state: { + provided?: unknown; + dismissed?: unknown; +}): "provided" | "dismissed" | null { + const provided = state.provided === true; + const dismissed = state.dismissed === true; + if (provided === dismissed) return null; + return provided ? "provided" : "dismissed"; +} diff --git a/src/components/ChatView.tsx b/src/components/ChatView.tsx index 21b030c14..8ba658bb0 100644 --- a/src/components/ChatView.tsx +++ b/src/components/ChatView.tsx @@ -43,6 +43,7 @@ import { OptionCard, shouldHideOnboardingCard } from "./OptionCard"; import { ApprovalCard } from "./ApprovalCard"; import { Composer } from "./Composer"; import { ConnectorCard } from "./ConnectorCard"; +import { SecretRequestCard } from "./SecretRequestCard"; import { ModelPicker } from "./ModelPicker"; import { RenameTitle } from "./RenameTitle"; import { TaskPicker } from "./TaskPicker"; @@ -672,6 +673,8 @@ const MessagesList = memo(function MessagesList({ const newDay = !prev || new Date(prev.at).toDateString() !== new Date(m.at).toDateString(); const row = (() => { switch (m.kind) { + case "secret": + return m.secret ? : null; case "connector": return m.connector ? : null; case "options": diff --git a/src/components/GroupView.tsx b/src/components/GroupView.tsx index 4ac2953dc..4714598a5 100644 --- a/src/components/GroupView.tsx +++ b/src/components/GroupView.tsx @@ -20,6 +20,7 @@ import { effectiveDefaultResponder, groupResponseHint } from "@/lib/group-routin import { ChatMarkdown } from "./ChatMarkdown"; import { Composer } from "./Composer"; import { ConnectorCard } from "./ConnectorCard"; +import { SecretRequestCard } from "./SecretRequestCard"; import { GroupCallButton, GroupCallOverlay } from "./GroupCallView"; import { ReactionBar, ReactionChips } from "./Reactions"; import { ApprovalCard } from "./ApprovalCard"; @@ -112,7 +113,9 @@ const Transcript = memo(function Transcript({ // `tool` distinguishes a permission from a QUESTION — a question // only accepts an "answer", so routing it here would offer an // Allow the broker rejects - m.kind === "connector" && m.connector && m.from?.botId ? ( + m.kind === "secret" && m.secret && m.from?.botId ? ( + + ) : m.kind === "connector" && m.connector && m.from?.botId ? ( ) : m.kind === "options" && m.card?.requestId && m.card.tool ? (
diff --git a/src/components/SecretRequestCard.tsx b/src/components/SecretRequestCard.tsx new file mode 100644 index 000000000..a34feecf7 --- /dev/null +++ b/src/components/SecretRequestCard.tsx @@ -0,0 +1,196 @@ +import { useState, type FormEvent } from "react"; +import { Check, ExternalLink, KeyRound, Loader2, LockKeyhole, RefreshCw, X } from "lucide-react"; + +import { credentialConfigPatch, credentialResumeOutcome } from "../../shared/credential-request"; +import { cn } from "@/lib/cn"; +import { api, useStore, type ConfigStatus, type Message } from "@/state/store"; + +export function SecretRequestCard({ + botId, + threadId, + message, +}: { + botId: string; + threadId: string; + message: Message; +}) { + const { dispatch } = useStore(); + const secret = message.secret!; + const [value, setValue] = useState(""); + const [saving, setSaving] = useState(false); + const [savedLocally, setSavedLocally] = useState(false); + const [localError, setLocalError] = useState(null); + const endpoint = `/api/bots/${encodeURIComponent(botId)}/secret-cards/${encodeURIComponent(message.id)}`; + const error = localError ?? secret.error; + const outcome = credentialResumeOutcome(secret); + const provided = outcome === "provided"; + const declined = outcome === "dismissed"; + const description = provided + ? secret.resumed + ? "Saved securely. Your bot is continuing the task." + : "Saved securely. Your bot will continue when its current turn settles." + : declined + ? "You chose not to provide this credential. OpenMausBot could not resume the bot yet." + : secret.description; + const footerLabel = declined + ? "Continuing without this credential failed" + : secret.resumed + ? "Bot resumed without seeing the key" + : error + ? "The key is safe; resuming failed" + : "Waiting to resume safely"; + + // A successful decline has no durable card to show. If its continuation + // failed, bring the card back with the same retry affordance as a saved key. + if (declined && (secret.resumed || !error)) return null; + + const notifyProvided = async () => { + await api(`${endpoint}/provided`, { + method: "POST", + body: JSON.stringify({ threadId }), + }); + }; + + const retryResume = async () => { + if (saving) return; + setSaving(true); + setLocalError(null); + try { + await api(`${endpoint}/resume`, { + method: "POST", + body: JSON.stringify({ threadId }), + }); + } catch (error) { + setLocalError(error instanceof Error ? error.message : String(error)); + } finally { + setSaving(false); + } + }; + + const save = async (event?: FormEvent) => { + event?.preventDefault(); + if (saving || (!value.trim() && !savedLocally)) return; + setSaving(true); + setLocalError(null); + try { + if (!savedLocally) { + const next = value.trim(); + const status: ConfigStatus = window.ogb?.setCredential + ? await window.ogb.setCredential(secret.target, next) + : await api("/api/config", { + method: "PUT", + body: JSON.stringify(credentialConfigPatch(secret.target, next)), + }); + dispatch({ type: "configStatus", config: status }); + setValue(""); + setSavedLocally(true); + } + await notifyProvided(); + } catch (error) { + setLocalError(error instanceof Error ? error.message : String(error)); + } finally { + setSaving(false); + } + }; + + const dismiss = () => { + void api(`${endpoint}/dismiss`, { + method: "POST", + body: JSON.stringify({ threadId }), + }).catch(() => {}); + }; + + return ( +
+
+
+
+ +
+
+
+ {secret.label} + {provided && ( + + Saved + + )} +
+

+ {description} +

+ {!provided && !declined && ( +

+ Stored securely by OpenMausBot and never added to chat. +

+ )} + {error &&

{error}

} +
+ {!provided && !declined && ( + + )} +
+ {!provided && !declined && ( +
void save(event)} className="border-t border-hairline/40 bg-panel/40 px-4 py-3"> +
+ setValue(event.target.value)} + placeholder={secret.placeholder} + disabled={saving || savedLocally} + aria-label={secret.label} + className="min-w-0 flex-1 rounded-lg border border-hairline bg-inset px-3 py-2 text-[13px] text-ink outline-none placeholder:text-ink-secondary/60 focus:border-accent disabled:opacity-60" + /> + +
+ + Where to get this key + +
+ )} + {(provided || declined) && ( +
+ + {secret.resumed ? : error ? : } + {footerLabel} + + {!secret.resumed && error && ( + + )} +
+ )} +
+
+ ); +} diff --git a/src/lib/taskTimeline.ts b/src/lib/taskTimeline.ts index c6133a15d..b3296011d 100644 --- a/src/lib/taskTimeline.ts +++ b/src/lib/taskTimeline.ts @@ -3,7 +3,7 @@ export interface TimelineMessage { id: string; role: "bot" | "user"; - kind: "text" | "options" | "activity" | "screen" | "connector"; + kind: "text" | "options" | "activity" | "screen" | "connector" | "secret"; text?: string; tool?: { name: string; ok?: boolean }; png?: string; diff --git a/src/state/store.tsx b/src/state/store.tsx index 2d6716a23..0ac027365 100644 --- a/src/state/store.tsx +++ b/src/state/store.tsx @@ -54,13 +54,27 @@ export interface ConnectorCardData { resumed?: boolean; } +export interface SecretRequestCardData { + target: import("../../shared/credential-request").CredentialTargetId; + label: string; + description: string; + placeholder: string; + helpUrl: string; + requestKey: string; + provided?: boolean; + dismissed?: boolean; + resumed?: boolean; + error?: string; +} + export interface Message { id: string; role: "bot" | "user"; - kind: "text" | "options" | "activity" | "screen" | "connector"; + kind: "text" | "options" | "activity" | "screen" | "connector" | "secret"; text?: string; card?: OptionCardData; connector?: ConnectorCardData; + secret?: SecretRequestCardData; /** activity messages: tool name + outcome. `spoken` is the server's * narration of the same chip ("reading a file"), used by call mode. */ /** `setup` marks an error fixed by installing something, not by retrying. */