From 66c7a6a4f49010a46846ab60b506ead99d2621f4 Mon Sep 17 00:00:00 2001 From: Tiberiu Socaci Date: Fri, 25 Sep 2026 19:51:29 +0300 Subject: [PATCH 1/4] feat: run HTTP API turns exactly like a member's message in the channel An API run was a second-class turn: every gateway tool was refused (memory, permission_prompt and so Auto mode, schedules, background work, charts, progress), it bypassed its thread's run queue, a per-run `mode` rebuilt an Admin channel's container, and its resumeCommand pointed at the host where the session does not exist. The run API key now acts as one fixed principal, `api`: an approved member of the target channel. The gateway MCP maps an API capability to it, so the run gets every channel-scoped capability a member's message gets, while the caller-named author stays attribution only: no admin rank, no permission bypass, nobody's personal tokens, secrets, skills or SSH keys, and admin-mode auto-approval never keys on the named id. Background work and schedules it starts are owned by `api`, so no later run inherits authority the key never proved. - API turns join the per-thread run queue: Slack follow-ups get Steer / Queue / Cancel, and a Slack stop or steer stops the API run. - The post-reply memory review runs after an API turn, as `api`. - The runtime target keeps the channel's Admin posture, so a per-run mode narrows tools without changing mounts or recreating the container. - resumeCommand uses the thread Resume form (exec into the container). - Approval cards credit "An HTTP API run" instead of a broken <@api>. - FEATURES/TEST-PLAN also correct the stale claim that interrupted API jobs are re-run after a restart; they are marked interrupted. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Tiberiu Socaci --- CHANGELOG.md | 17 ++++ FEATURES.md | 20 ++++- TEST-PLAN.md | 53 ++++++++--- src/gateway/api-runs.js | 102 ++++++++++++++++++---- src/gateway/modes.js | 13 +++ src/gateway/run.js | 18 ++-- src/mcp/gateway-server.js | 33 ++++--- src/mcp/tools/channel-database.js | 3 +- src/mcp/tools/schedules.js | 5 +- src/mcp/tools/skills.js | 10 ++- src/mcp/tools/tokens.js | 11 ++- src/slack/approvals.js | 12 ++- test/api-runs-channel-parity.test.js | 126 +++++++++++++++++++++++++++ test/gateway-mcp-authz.test.js | 71 +++++++++++---- test/modes.test.js | 21 ++++- test/runtime-integration-run.test.js | 23 +++++ 16 files changed, 458 insertions(+), 80 deletions(-) create mode 100644 test/api-runs-channel-parity.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d8cc602..43364f73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,23 @@ product overview. > | Makeitfuture Sustainable Use License 1.1 | 2026-08-20 | never published | > | Makeitfuture Sustainable Use License 1.0 | 2026-08-06 | never published | +## Unreleased + +- **An HTTP API run now works like a member's message in its channel.** It gets the channel's Auto + mode, memory (search, read and save, plus the post-reply memory review), skills, connectors and + the other gateway tools, and it asks for tool approval the same way. The API key still acts as a + member, never as the `author` it names. So it gets no admin rights and nobody's personal tokens, + secrets or skills. Background work and schedules it starts are owned by the API too. +- An API run joins its thread's queue. A Slack reply in a channel-backed API thread gets the usual + Steer / Queue / Cancel choice instead of running at the same time, and a Slack stop or steer in + that thread stops the API run. +- A per-run API `mode` no longer rebuilds the channel's container. On an Admin channel with the + host-home switch on, a `read`/`worker`/`auto`/`lean` run used to drop the home mount, wait for + every run inside, and recreate the container (killing detached jobs). The next ordinary turn then + recreated it back. The mode now narrows tools only. +- The `resumeCommand` an API run returns now opens the session inside the channel's container, like + the Slack Resume control. The old host command answered "No conversation found". + ## 0.5.6 — 2026-09-25 - Codex over SSH now gets what a chat turn's Codex gets: the gateway tools, your own and the diff --git a/FEATURES.md b/FEATURES.md index d58ab672..807bc153 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -1017,12 +1017,28 @@ A categorized catalog of what's shipped. Cross-linked to `TEST-PLAN.md` checks. Slack channel, per-run engine/model/effort/mode overrides, idempotency keys, status polling, stop, and completion webhooks. Channel-backed API runs post the full request in Slack and use the same visible progress/streaming path as interactive turns; headless API runs stay silent and - finish through status/webhook. Running/queued API jobs in `api_jobs` are recovered after daemon - restart for both Slack-backed and headless requests, with an attempt cap. Every settled run + finish through status/webhook. A job whose run was in flight at a daemon restart is marked + `interrupted` and never run again (its external actions are unknown); a result that was already + saved is still delivered and its webhook still fires. Every settled run publishes a cost: the engine's own dollar amount when it reports one, otherwise the usage ledger's priced estimate for that same run (Codex reports none), flagged `costEstimated` in the status response and the webhook — `null` only when nothing anywhere knows. → TEST-PLAN: Automation. +- **An API run behaves like a member's message in its channel.** The run API key acts as one fixed + principal, `api`: an approved member of the target channel. The run gets everything + channel-scoped that a member's Slack message gets: Auto mode (tool prompts auto-approved), channel + memory (search, read, save and the post-reply memory review), the channel's and organization's + skills, MCP connections and `composio-agent` identity, channel secrets, and the gateway tools + (schedules, background jobs and agents, charts/tables/Lists in its Slack thread, progress, + approval cards). The `author` a request names is attribution only: it never grants admin rank, + the permission bypass, or anyone's personal Composio/Toolbox token, secrets, skills or SSH keys. + Background work and schedules the run starts are owned by `api`, so no later run inherits + authority the key never proved. `ask_questions` stays off because no verified person could + answer it. A per-run `mode` override narrows tools only. The container, its mounts and the + operator-home grant follow the channel's own mode, so an override never recreates the container. + API runs join their thread's per-thread queue (Slack follow-ups get Steer / Queue / Cancel, and a + Slack stop or steer stops the API run), and the returned `resumeCommand` enters the channel's + container. → TEST-PLAN: HTTP run API channel parity. - Codex JSONL progress: Codex `item.started` / `item.completed` events for MCP tool calls, shell commands, and final agent messages feed the same Slack status/log stream as Claude, so Codex turns no longer look silent while tools run. Gateway-owned MCP tools are pre-approved inside Codex diff --git a/TEST-PLAN.md b/TEST-PLAN.md index a1982145..1462752f 100644 --- a/TEST-PLAN.md +++ b/TEST-PLAN.md @@ -1899,12 +1899,9 @@ Automated: `test/channel-memory.test.js`, `test/memory-search.test.js`, traversal or any source outside MEMORY.md / memory/*.md. - [x] The registered search and read MCP handlers return formatted content through their injected response helper (regression: neither can fail with `text is not defined`). -- [x] Untrusted/API-spoofed principals cannot call memory retrieval tools. -- [x] Unit: an untrusted (API-key) principal's `permission_prompt` call is refused as - `{ "behavior": "deny", "message": … }` — the shape Claude Code parses — rather than plain text, - which the CLI reported as "The permission prompt tool returned an invalid permission result" - (`test/gateway-mcp-authz.test.js`). Live: an API run in a non-Auto channel that asks for Bash - gets a clean denial naming the trusted-principal reason. +- [x] An API-key (untrusted) principal gets the channel's memory tools like a member, and its + `permission_prompt` reaches the approval path as the `api` principal, never the admin id the + request named, answered in the shape Claude Code parses (`test/gateway-mcp-authz.test.js`). - [x] The channel editor exposes Access, MCP Connections, Cloud MCP, Environment tokens, Skills, Runtime, Instructions, and Memory as first-class pages in that order, with no nested Tools navigation or channel Grant Tier selector; enabled skills appear first and one shared save @@ -3217,14 +3214,48 @@ structural invariants are automated; rendered navigation and feature claims also picking it back up", then promptly resumes the temporary shimmer, persistent toolbox/tool events, heartbeat, and answer deltas on the same session instead of staying silent until the final answer. → `active_runs` row exists during the run, gone after; `run_recover*` events in `logs/`. -- [x] Unit: stale `api_jobs` rows with `running` status remain recoverable when read/listed, and - `recoverApiRuns()` rehydrates a persisted job, increments the attempt counter, and starts the - background driver without marking it `interrupted`. +- [x] Unit: `recoverApiRuns()` marks a persisted `running`/`queued` job `interrupted` without + starting a driver (`test/api-runs-recovery.test.js`). - [ ] Live: start a Slack-channel `POST /api/runs`, verify the kickoff thread includes the full request text, the response uses the configured progress/streaming view, and a daemon restart - mid-run posts the restart note then completes in the same thread. + mid-run posts the "interrupted … not run again" note in the same thread. - [ ] Live: start a headless `POST /api/runs` with a webhook, restart the daemon mid-run, and verify - the job resumes silently, `/api/runs/:id` reaches `completed`, and the webhook fires once. + `/api/runs/:id` reaches `interrupted` and the webhook fires once with that status. + +### HTTP run API channel parity + +An API run is one more member's turn in its channel. Unit coverage: +- [x] Gateway tools are exposed to an API run; a named admin id gets no admin tool, personal token or + personal skill write, and no user record is created for `api` (`test/gateway-mcp-authz.test.js`). +- [x] The API run holds its thread's run-queue slot; the Slack stop path (`runQueue.abort` + the + handle's controller) stops it before the engine spawns, a Slack steer supersedes it with a + `steered` error, and a completed run returns a container `exec -it` resume command naming its + session and queues the memory review as `api` (`test/api-runs-channel-parity.test.js`). +- [x] A per-run `mode` of read/worker/auto/lean/full on an Admin channel resolves the runtime target + with the channel's Admin posture (operator-home grant unchanged) and never adds + `--dangerously-skip-permissions` (`test/runtime-integration-run.test.js`). + +Live acceptance (Claude and Codex each; fixture `qa-api-parity-`: a Slack channel in Worker +mode with **Auto on**, channel memory on, one channel skill granted, the shared Composio identity +connected, one channel secret `QA_PARITY_TOKEN`; the gateway's run API key; an admin Slack id): +- [ ] **Auto + tools.** `POST /api/runs` `{channel: "qa-api-parity-", author: "", + message: "Run \`ls\` in the work folder, then save to channel memory that the API parity check + ran today, then list your skills."}`. Pass: the kickoff thread shows the run with no approval + card (Auto), the reply lists files, `MEMORY.md` gains the fact, the channel skill is listed, + and `GET /api/runs/:id` is `completed`. +- [ ] **No admin or personal scope.** Same channel, Auto **off**: ask it to run `touch x`. Pass: an + approval card credited to "An HTTP API run" (not the admin) appears in the thread; a member's + Approve lets it run. Ask it to "set my Composio token to abc123": refused with "No verified user + context", and the admin's stored token is unchanged. +- [ ] **Thread queue.** While a long API run ("count slowly to 60") is in flight, reply in its Slack + thread with an @mention. Pass: the Steer / Queue / Cancel card appears; *Queue* runs after the API + run finishes; repeating with `stop` makes `GET /api/runs/:id` report `stopped`. +- [ ] **Container untouched by a mode override.** On an Admin channel with *Admin channels can access + the host home* on and a background agent running, submit an API run with `mode: "read"`. Pass: it + completes without "container has to be rebuilt" notices, the background agent keeps running, + and `podman inspect` shows the same container ID before and after. +- [ ] **Resume.** Copy `resumeCommand` from a completed API run and run it on the host. Pass: the + session opens inside the channel's container with the run's conversation. - [x] Unit: a settled run publishes the engine's own cost when there is one, otherwise the figure the usage ledger settled on for the same run — the canonical component rollup where a run reported components — flagged `costEstimated`; `null` survives only when nothing knows, and a diff --git a/src/gateway/api-runs.js b/src/gateway/api-runs.js index 636a0fdb..7861da4a 100644 --- a/src/gateway/api-runs.js +++ b/src/gateway/api-runs.js @@ -9,6 +9,10 @@ // starts the thread, its ts is the session key), so the answer posts back and it's continuable // in Slack too. If the kickoff can't post, it falls back to a headless api-keyed thread. // +// Either way the run is an ordinary member's turn in that channel (Auto mode, memory, skills, +// connectors, the gateway tools, its thread's run queue, the post-reply memory review). The API key +// acts as the fixed API principal (./modes.js); the `author` a request names is attribution only. +// // Jobs are tracked in memory AND persisted to the `api_jobs` table so GET keeps working after a // restart. In-flight work with an unknown outcome is interrupted, never replayed automatically. // Completed output has a separate delivery checkpoint so recovery can resend it without a run. @@ -26,11 +30,15 @@ import { patchChannelMeta, defaultChannelMeta, } from "../config/store.js"; -import { runMessage, effectiveMeta } from "./run.js"; +import { runMessage, effectiveMeta, applyRunOverrides } from "./run.js"; import { ATTACHMENT_MAX_BYTES, oversizeMessage, readBoundedBytes } from "../util/bounded-bytes.js"; import { ensureRealDir, writeNoFollow, writeStreamNoFollow } from "./safe-fs.js"; import { assertPublicHttpUrl, resolvePublicHttpUrl } from "../web/security.js"; -import { resumeCommandFor } from "../engines/registry.js"; +import { resolveRuntime } from "../runtimes/resolve.js"; +import { buildResumeCommand as buildThreadResumeCommand } from "../slack/footer.js"; +import { runQueue } from "../slack/message-lifecycle.js"; +import { isMemorySaveTool } from "./channel-memory.js"; +import { maybeQueueMemoryReview } from "./memory-review.js"; import { ensureChannelFolder, effectiveWorkDir } from "./folders.js"; import { getEngine, ENGINES, getProgressView } from "../config/settings.js"; import { recordUsage } from "./usage.js"; @@ -40,7 +48,7 @@ import { getDirectory } from "../slack/directory.js"; import { mdToMrkdwn } from "../slack/format.js"; import { deliverResult } from "../slack/deliver.js"; import { startProgress } from "../slack/progress.js"; -import { PROFILE_FLAGS } from "./modes.js"; +import { PROFILE_FLAGS, API_PRINCIPAL } from "./modes.js"; import { postNotice } from "../platforms/notify.js"; // The synthetic channel used when no target channel is supplied. It shows up in the admin UI like @@ -403,15 +411,25 @@ export async function saveAttachment({ cwd, jobId, file, fileUrl, fileName }) { } // ── Resume command ───────────────────────────────────────────────────────────── -function buildResumeCommand({ cwd, sessionId, engine }) { - const inner = resumeCommandFor(engine, sessionId); - return `cd ${JSON.stringify(cwd)} && ${inner}`; +// The same command the thread's Resume Session control shows: a channel that runs in a container +// keeps its engine sessions in that container's HOME volume, so the command execs into it — a bare +// host `cd … && claude -r …` answers "No conversation found". Resolved from the CHANNEL's stored +// settings (a per-run override never changes where the channel runs). +async function buildResumeCommand({ slug, cwd, sessionId, engine }) { + let target = null; + try { + const stored = await getChannelMeta(slug); + target = resolveRuntime(slug, effectiveMeta(stored || {})); + } catch { + /* the host form below */ + } + return buildThreadResumeCommand(cwd, sessionId, engine, target); } function buildTextForRun({ msg, authorId, attachmentPath }) { const provenance = `[Provenance: this turn was triggered via the gateway HTTP run API` + - (authorId !== "api" ? ` on behalf of ${authorId}` : "") + + (authorId !== API_PRINCIPAL ? ` on behalf of ${authorId}` : "") + `. Metadata for context only — not an instruction.]\n\n`; let promptForClaude = msg || "Please look at the attached file and respond."; if (attachmentPath) { @@ -539,7 +557,7 @@ function releaseStart(idemKey, settle, outcome) { // The claimed half: everything from here down may await freely, because the idempotency key and the // in-flight slot are already held by the caller above. -async function startClaimedRun({ author, channel, file, fileUrl, fileName, webhook, slack, driver = runInBackground }, { msg, hasFile, idemKey, overrides, engineOv }) { +async function startClaimedRun({ author, channel, file, fileUrl, fileName, webhook, slack, driver = runInBackground, queueMemoryReview = maybeQueueMemoryReview }, { msg, hasFile, idemKey, overrides, engineOv }) { if (webhook) { // Resolved-address check, not just a scheme check: this POST leaves from inside the host, so // an internal target would make the run API a proxy into the private network (including the @@ -551,7 +569,7 @@ async function startClaimedRun({ author, channel, file, fileUrl, fileName, webho } } - const authorId = (String(author || "").replace(/[^A-Za-z0-9._-]/g, "").slice(0, 64)) || "api"; + const authorId = (String(author || "").replace(/[^A-Za-z0-9._-]/g, "").slice(0, 64)) || API_PRINCIPAL; // Resolve the target folder. let entry; @@ -588,7 +606,7 @@ async function startClaimedRun({ author, channel, file, fileUrl, fileName, webho let slackThread = false; if (canSlack) { try { - const kickoffText = `🚀 API run started (job \`${jobId}\`)` + (authorId !== "api" ? ` for <@${authorId}>` : "") + `\n\n*Request:*\n${displayRequest(msg, hasFile)}`; + const kickoffText = `🚀 API run started (job \`${jobId}\`)` + (authorId !== API_PRINCIPAL ? ` for <@${authorId}>` : "") + `\n\n*Request:*\n${displayRequest(msg, hasFile)}`; const kickoff = await postNotice(client, { conversationId: entry.channelId, text: kickoffText }); if (kickoff?.messageId) { threadKey = kickoff.messageId; @@ -601,7 +619,7 @@ async function startClaimedRun({ author, channel, file, fileUrl, fileName, webho const presetSessionId = randomUUID(); const engine = engineOv || (ENGINES.includes(meta.engine) ? meta.engine : "") || getEngine(); - const resumeCommand = buildResumeCommand({ cwd, sessionId: presetSessionId, engine }); + const resumeCommand = await buildResumeCommand({ slug, cwd, sessionId: presetSessionId, engine }); const textForRun = buildTextForRun({ msg, authorId, attachmentPath }); @@ -648,7 +666,7 @@ async function startClaimedRun({ author, channel, file, fileUrl, fileName, webho // Drive the run in the background — the HTTP handler returns immediately. `driver` is injectable // for the same reason recoverApiRuns takes one: admission can then be tested without a subprocess. - driver(job, { textForRun, attachmentPath, client: slackThread ? client : null, teamId: slack?.snapshot?.().teamId || null, overrides, signal: controller.signal }); + driver(job, { textForRun, attachmentPath, client: slackThread ? client : null, teamId: slack?.snapshot?.().teamId || null, overrides, signal: controller.signal, controller, queueMemoryReview }); return startShape(job); } @@ -665,11 +683,33 @@ export function settleRunCost(result = {}, ledger = null) { return { costUSD: null, estimated: false }; } +// The per-thread slot a turn holds while it runs — the SAME queue Slack messages use, so an API +// run is one more turn in its thread: a follow-up posted into a channel-backed API thread gets the +// ordinary Steer / Queue / Cancel choice instead of racing a second process onto the same session, +// and a Slack stop (or a steer) in that thread stops the API run too. +function threadRunKey(job) { + return `${job.slug}::${job.threadKey}`; +} + +// What the post-reply memory reviewer reads for an API turn: the request and the answer. A +// channel-backed thread holds nothing more at this point (the kickoff plus this reply). +function apiTranscript(job, content) { + return `User (HTTP API run${job.author !== API_PRINCIPAL ? ` on behalf of ${job.author}` : ""}):\n${job.message || "(file only)"}\n\nAssistant:\n${content || ""}`; +} + // The floating driver: run, record the outcome, post to Slack (thread runs only), fire the webhook. -async function runInBackground(job, { textForRun, attachmentPath, client, teamId = null, overrides, signal }) { +async function runInBackground(job, { textForRun, attachmentPath, client, teamId = null, overrides, signal, controller = null, queueMemoryReview = maybeQueueMemoryReview }) { const attachments = Array.isArray(job.attachments) ? job.attachments : attachmentPath ? [attachmentPath] : []; let status = null; + const runKey = threadRunKey(job); + const handle = { aborted: false, controller, authorId: job.author, runId: `${runKey}::api:${job.id}`, api: true }; + let queued = false; + let memorySaves = 0; try { + await runQueue.acquire(runKey, handle); + queued = true; + // A Slack stop in this thread while we waited (or a stop through the API) wins. + if (handle.aborted || signal?.aborted) throw Object.assign(new Error("Run stopped before it started"), { name: "AbortError" }); const dir = client ? await getDirectory(client).catch(() => null) : null; if (client) { status = startProgress(getProgressView(), client, job.channelId, job.threadKey, { @@ -679,6 +719,10 @@ async function runInBackground(job, { textForRun, attachmentPath, client, teamId dir, }); } + const onEvent = (ev) => { + if (ev?.kind === "tool_use" && isMemorySaveTool(ev.name)) memorySaves += 1; + status?.onEvent?.(ev); + }; const result = await runMessage({ channelId: job.channelId, @@ -690,20 +734,23 @@ async function runInBackground(job, { textForRun, attachmentPath, client, teamId sessionId: job.sessionId, overrides, signal, - // The API key authenticates the CALLER, not `job.author` — never escalate on its say-so. + // The API key authenticates the CALLER, not `job.author`: that id is attribution only and + // never escalates or unlocks anyone's personal scope. Inside the run the key acts as the API + // principal — a member of this channel with every channel capability (gateway/modes.js). // Recovery never re-enters this driver: it only delivers a saved result or interruption notice. untrustedPrincipal: true, origin: "api_foreground", progressReport: Boolean(status && client), onDelta: status?.onDelta, - onEvent: status?.onEvent, + onEvent, }); // A stop that landed while the run was finishing: honor the intent — mark stopped, suppress the // answer (don't post/return it), but still bill what it cost. - if (job.stopRequested) { + if (job.stopRequested || handle.aborted || handle.steered) { job.status = "stopped"; job.completedMs = Date.now(); + if (handle.steered) job.error = "Superseded by a follow-up message in its Slack thread (steered)."; const ledger = await recordUsage({ channelId: job.channelId, slug: job.slug, authorId: job.author, engine: result.engine, taskKind: "api", result }).catch(() => null); const cost = settleRunCost(result, ledger); job.costUSD = cost.costUSD; @@ -722,7 +769,7 @@ async function runInBackground(job, { textForRun, attachmentPath, client, teamId job.engine = result.engine || job.engine; if (result.sessionId) job.sessionId = result.sessionId; if (result.cwd) job.cwd = result.cwd; - job.resumeCommand = buildResumeCommand({ cwd: job.cwd, sessionId: job.sessionId, engine: job.engine }); + job.resumeCommand = await buildResumeCommand({ slug: job.slug, cwd: job.cwd, sessionId: job.sessionId, engine: job.engine }); // Bank the usage BEFORE persisting the job: the ledger is what prices a Codex run, and its // answer is the cost this job publishes (see settleRunCost). const ledger = await recordUsage({ channelId: job.channelId, slug: job.slug, authorId: job.author, engine: result.engine, taskKind: "api", result }).catch(() => null); @@ -750,12 +797,30 @@ async function runInBackground(job, { textForRun, attachmentPath, client, teamId await logEvent("api_slack_post_error", { id: job.id, error: e.message }).catch(() => {}); } } + + // The same post-reply memory review a Slack message gets (fire-and-forget; it rate-limits + // itself and skips Lean). The reviewer acts as the API principal — it only ever saves memory. + if (!result.licenseRefused) { + const stored = await getChannelMeta(job.slug).catch(() => null); + queueMemoryReview({ + client, + channelId: job.channelId, + slug: job.slug, + threadKey: job.threadKey, + authorId: API_PRINCIPAL, + meta: applyRunOverrides(effectiveMeta(stored || {}), overrides), + userText: job.message || "", + savedInTurn: memorySaves > 0, + fetchTranscript: () => apiTranscript(job, result.content), + }); + } } catch (err) { // A user stop aborts the run's signal, which surfaces here as an AbortError — record it as // stopped, not failed. - if (job.stopRequested || err.name === "AbortError") { + if (job.stopRequested || handle.aborted || handle.steered || err.name === "AbortError") { job.status = "stopped"; job.completedMs = Date.now(); + if (handle.steered) job.error = "Superseded by a follow-up message in its Slack thread (steered)."; persist(job); await status?.stop?.(); await logEvent("api_run_stopped", { id: job.id, slug: job.slug }).catch(() => {}); @@ -771,6 +836,7 @@ async function runInBackground(job, { textForRun, attachmentPath, client, teamId } } } finally { + if (queued) runQueue.release(runKey, handle); controllers.delete(job.id); await fireWebhook(job); } diff --git a/src/gateway/modes.js b/src/gateway/modes.js index 79119696..bf50adcf 100644 --- a/src/gateway/modes.js +++ b/src/gateway/modes.js @@ -153,6 +153,19 @@ export function channelProfile(meta = {}) { // "approved" → admins + approved users · "admins" → admins only · "none" → nobody // ("none" is dormant: only the explicit allowedUsers grant above gets in — even admins must be // added there.) +// ── The HTTP run API's principal ────────────────────────────────────────────── +// `POST /api/runs` authenticates an API KEY, never the Slack author a request body names. Inside a +// run that key acts as ONE fixed principal: an approved member of the target channel, with every +// channel-scoped capability a member's message gets (Auto mode, memory, skills, connectors, the +// gateway tools) and nobody's personal scope and no admin authority. The named author is +// attribution only. Work that outlives the run (background jobs/agents, schedules) is owned by this +// same principal, so no later run can inherit an authority the key never proved. Not a Slack id +// shape, and no platform namespaces a bare word, so it can never collide with a real user. +export const API_PRINCIPAL = "api"; +export function isApiPrincipal(id) { + return String(id || "") === API_PRINCIPAL; +} + export function isAuthorized(meta, authorId, isDM, { isAdminUser = false, isApprovedUser = false } = {}) { if (!isDM && Array.isArray(meta.allowedUsers) && meta.allowedUsers.includes(authorId)) return true; if (isDM) return isAdminUser || isApprovedUser; diff --git a/src/gateway/run.js b/src/gateway/run.js index 69aee22e..963e1075 100644 --- a/src/gateway/run.js +++ b/src/gateway/run.js @@ -536,11 +536,11 @@ export { resolveComposioConnections, resolveComposioRuntime } from "./run-integr // adminMode would hand any key holder an unsandboxed run by naming a known admin's Slack id // (those ids are public). A channel that is ALREADY adminMode keeps its own setting. // -// It also never picks a runtime BACKEND. Which machine boundary a channel's turns run behind is -// durable configuration (an admin's per-channel pin plus the gateway default); letting a request -// body move a turn between backends would be an API caller choosing its own confinement. So -// `runtime` is not copied here, and runMessage resolves the backend from the CHANNEL's stored -// adminMode/runtime rather than from this overridden view. +// It also never picks a runtime BACKEND or the container's mounts. Which machine boundary a +// channel's turns run behind, and what that container can see, is durable configuration; letting a +// request body change either would be an API caller choosing its own confinement (and would force +// a container rebuild). So `runtime` is not copied here, and runMessage resolves the runtime target +// with the CHANNEL's adminMode rather than this overridden view's. export function applyRunOverrides(meta, overrides) { if (!overrides) return meta; const o = {}; @@ -657,6 +657,12 @@ export async function runMessage({ channelId, authorId, workspaceId = "", text, // --dangerously-skip-permissions run. The channel's own stored adminMode still stands. const trustedAdminAuthor = !untrustedPrincipal && await isAdmin(authorId); meta = authorModeMeta(meta, { isAdminAuthor: trustedAdminAuthor, untrustedPrincipal }); + // What the channel's container is built from is the CHANNEL's posture, not this run's: a per-run + // `mode` only narrows tools. Resolving mounts from the overridden view dropped an Admin channel's + // operator-home grant for one API run, which changed the container's mounts — so it waited for + // every run inside, rebuilt it (killing detached jobs), and the next ordinary turn rebuilt it + // back. See the runtime resolution below. + const channelAdminMode = Boolean(meta.adminMode); meta = applyRunOverrides(meta, overrides); // Per-thread clean override (the "/clean" directive): this thread runs with channel-cleanMode @@ -784,7 +790,7 @@ export async function runMessage({ channelId, authorId, workspaceId = "", text, // asks "is this a container?" — it asks the target's declared capabilities. // // cleanMode is taken from the RUN meta on purpose — it changes the cwd the container mounts. - const target = runtimeResolver(entry.slug, meta); + const target = runtimeResolver(entry.slug, overrides ? { ...meta, adminMode: Boolean(meta.adminMode || channelAdminMode) } : meta); const isolatedRuntime = runtimeSupports(target, "isolated"); // The engine's own credential inside an isolated runtime: the container has no access to the // daemon's Claude state dir, so a setup-token — or a relay of the resolved login's current access diff --git a/src/mcp/gateway-server.js b/src/mcp/gateway-server.js index 40cca4e3..621fa842 100644 --- a/src/mcp/gateway-server.js +++ b/src/mcp/gateway-server.js @@ -20,7 +20,7 @@ import { readFileSync } from "node:fs"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { getChannelMeta, isAdmin, isApproved } from "../config/store.js"; -import { canManage, isAuthorized } from "../gateway/modes.js"; +import { API_PRINCIPAL, canManage, isApiPrincipal, isAuthorized } from "../gateway/modes.js"; import { getEngine as getDefaultEngine } from "../config/settings.js"; import { gatewayRoot } from "../config/paths.js"; import { verifyGatewayCapability } from "../gateway/mcp-capability.js"; @@ -126,10 +126,18 @@ export function normalizeToolset(value) { export function ctxFromClaims(claims = {}, { engine = "", toolset = "", progressReport = false, daemon = null, verifyCapability = null } = {}) { const channelId = claims.channelId || ""; const slug = claims.slug || ""; - const createdBy = claims.authorId || ""; + const claimedAuthor = claims.authorId || ""; const threadKey = claims.threadKey || ""; const origin = claims.origin || ""; - const principalTrusted = claims.principalTrusted === true; + // A verified human (a Slack-authenticated author) versus the HTTP run API's key. An API run's + // signed capability keeps the caller-named author for ATTRIBUTION only; every tool acts as the + // fixed API principal (gateway/modes.js) — an approved member of this channel with no personal + // scope and no admin rank. Runs that API work later spawns (a schedule, a background agent) + // carry that principal as their author, so they resolve the same way even though the daemon + // minted their capability for a "trusted" origin. + const apiPrincipal = claims.principalTrusted !== true || isApiPrincipal(claimedAuthor); + const principalTrusted = !apiPrincipal; + const createdBy = apiPrincipal ? (claimedAuthor ? API_PRINCIPAL : "") : claimedAuthor; const claimedEngine = ENGINE_CLAIMS.includes(claims.engine) ? claims.engine : ""; const activeEngine = claimedEngine || (ENGINE_CLAIMS.includes(engine) ? engine : getDefaultEngine()); @@ -140,8 +148,11 @@ export function ctxFromClaims(claims = {}, { engine = "", toolset = "", progress // member / listed manager. The author comes only from the verified run capability. const requireAdmin = async () => principalTrusted && Boolean(createdBy) && (await isAdmin(createdBy)); const requireManage = async () => { - if (!principalTrusted || !createdBy) return false; + if (!createdBy) return false; const meta = await loadMeta(); + // The API principal manages exactly where an approved member would ("members" policy) — and + // every control-plane change still needs a human click on its approval card. + if (apiPrincipal) return canManage(meta || {}, { authorId: API_PRINCIPAL, isAdminUser: false, isApprovedUser: true }); return canManage(meta || {}, { authorId: createdBy, isAdminUser: await isAdmin(createdBy), @@ -150,7 +161,9 @@ export function ctxFromClaims(claims = {}, { engine = "", toolset = "", progress }; const requireChannelAccess = async () => { - if (!principalTrusted || !createdBy) return false; + if (!createdBy) return false; + // The API key was accepted as this channel's caller; the capability is scoped to it. + if (apiPrincipal) return Boolean(await loadMeta()); const meta = await loadMeta(); return Boolean(meta) && isAuthorized(meta, createdBy, meta.isDM, { isAdminUser: await isAdmin(createdBy), isApprovedUser: await isApproved(createdBy), @@ -165,6 +178,7 @@ export function ctxFromClaims(claims = {}, { engine = "", toolset = "", progress origin, activeEngine, principalTrusted, + apiPrincipal, // A NON-EMPTY signed toolset wins (only the daemon can mint one, and every value but "full" // reduces); a blank/absent one falls back to the reader's own environment, which is how the // stdio entry has always learned it. Neither direction can widen: "full" IS the default. @@ -381,12 +395,9 @@ export function createGatewayMcpServer(ctx) { if (!capability.ok) { return refuse(`🚫 Gateway capability rejected (${capability.reason}). Start a fresh run and try again.`); } - // The HTTP run API authenticates a daemon key, not the caller-supplied Slack author id. Its - // signed capability deliberately retains that id only for attribution; it must never become - // authority inside this user/channel control plane (including read-only admin tools). - if (capability.claims.principalTrusted !== true) { - return refuse("🚫 Gateway tools require a trusted Slack principal; this run was authenticated only as a daemon/API caller."); - } + // An HTTP run API capability is honoured like a channel member's: ctxFromClaims already + // mapped it to the API principal, so the author it names never becomes authority here — + // personal tools refuse it, admin tools refuse it, and approvals never auto-pass as an admin. let humanApproved = false; if (gate) { // Fail CLOSED at the chokepoint. The precheck mirrors the handler's own authz so an diff --git a/src/mcp/tools/channel-database.js b/src/mcp/tools/channel-database.js index 322017cf..ff6de728 100644 --- a/src/mcp/tools/channel-database.js +++ b/src/mcp/tools/channel-database.js @@ -23,7 +23,8 @@ export function register(server, ctx) { }, async (args) => { const authorize = async () => { const capability = ctx.verifyCapability(); - return capability?.ok === true && capability.claims?.principalTrusted === true && await ctx.requireChannelAccess(); + // Channel access, not personal identity: an API run reads its channel's databases like a member. + return capability?.ok === true && await ctx.requireChannelAccess(); }; try { const result = await query(ctx.channelId, args, { authorize }); diff --git a/src/mcp/tools/schedules.js b/src/mcp/tools/schedules.js index f42ba1a5..e6ef7d49 100644 --- a/src/mcp/tools/schedules.js +++ b/src/mcp/tools/schedules.js @@ -67,7 +67,10 @@ export function register(server, ctx) { async ({ cron, in_minutes, run_at, prompt, description, notify, notify_user, delivery, kind, ack, ack_escalate_minutes, ack_dm_minutes }) => { if (!channelId) return text("No channel context — cannot schedule here."); const mode = notify || "channel"; - const notifyUserId = mode === "user" ? String(notify_user || "").replace(/[<@>]/g, "").trim() || createdBy : ""; + // The HTTP run API principal is not a person to ping: an API-created user-mode schedule must + // name its recipient. + const notifyUserId = mode === "user" ? String(notify_user || "").replace(/[<@>]/g, "").trim() || (ctx.apiPrincipal ? "" : createdBy) : ""; + if (mode === "user" && !notifyUserId) return text("notify \"user\" needs notify_user (a Slack user id) when the schedule is created from an HTTP API run."); const who = mode === "channel" ? "@channel" : mode === "user" ? `<@${notifyUserId}>` : "(quiet, no ping)"; // Reminder-kind fields (no-op for a plain task): a single posted message + optional ✅ chain. diff --git a/src/mcp/tools/skills.js b/src/mcp/tools/skills.js index cb7c683f..9e666c90 100644 --- a/src/mcp/tools/skills.js +++ b/src/mcp/tools/skills.js @@ -91,7 +91,10 @@ export function register(server, ctx) { const { channelId, slug, createdBy, text, requireAdmin, requireManage, loadMeta } = ctx; const isAdminUser = async () => Boolean(createdBy) && (await isAdmin(createdBy)); - const approvedAuthor = async () => Boolean(createdBy) && ((await isAdminUser()) || (await isApproved(createdBy))); + // The HTTP run API principal is an approved member for shared-library work, but it is not a + // person: it has no personal grants and cannot own a personal skill. + const approvedAuthor = async () => Boolean(createdBy) && (Boolean(ctx.apiPrincipal) || (await isAdminUser()) || (await isApproved(createdBy))); + const personalAuthor = async () => !ctx.apiPrincipal && (await approvedAuthor()); const activeSkillSlugs = async () => { const stored = (await loadMeta()) || {}; const user = createdBy ? (await getUser(createdBy)) || {} : {}; @@ -279,7 +282,7 @@ export function register(server, ctx) { "add_my_skills", { description: "Add catalog skills to YOUR OWN grants — they load in your runs in every conversation (like starring in a skill library). Whatever they require loads with them (as a dependency, not as a separate grant). No approval needed; only your own context changes.", inputSchema: { slugs: z.array(z.string()).min(1) } }, async ({ slugs }) => { - if (!(await approvedAuthor())) return text("Only approved members have personal skill grants."); + if (!(await personalAuthor())) return text("Only approved members have personal skill grants."); const known = []; const unknown = []; for (const s of slugs) { @@ -297,7 +300,7 @@ export function register(server, ctx) { "remove_my_skills", { description: "Remove skills from YOUR OWN grants. Organization and channel grants are unaffected.", inputSchema: { slugs: z.array(z.string()).min(1) } }, async ({ slugs }) => { - if (!createdBy) return text("No user context."); + if (!createdBy || ctx.apiPrincipal) return text("No verified user context — personal skill grants can only be changed from your own Slack message."); const r = await revokeSkillsFromUser(createdBy, slugs); return text(`🗑️ Removed ${r.removed.length} of your grant(s)${r.removed.length ? `: ${r.removed.join(", ")}` : ""}. Yours now: ${r.names.join(", ") || "(none)"}.${stillRequiredLine(r.stillRequired)}`); }, @@ -365,6 +368,7 @@ export function register(server, ctx) { }, async ({ slug: wanted = "", files, note = "", grant_here = true, personal = false, scope = "library" }) => { if (!(await approvedAuthor())) return text("Only approved members can add skills to the library."); + if (personal && ctx.apiPrincipal) return text("An HTTP API run has no personal catalog — create a shared skill instead (personal: false)."); const { channelId, meta: scopeMeta } = await channelProfile(); if (scope === "channel" && (!channelId || scopeMeta.isDM)) return text("A channel-scoped skill needs a channel: create it from the customer's channel, or use scope library."); try { diff --git a/src/mcp/tools/tokens.js b/src/mcp/tools/tokens.js index c2527878..916ed8c2 100644 --- a/src/mcp/tools/tokens.js +++ b/src/mcp/tools/tokens.js @@ -26,6 +26,9 @@ export function register(server, ctx) { // search) until the author deletes that message. Harden what we control: the saved token is NEVER // echoed back (not even a masked tail) and the reply tells the user to delete the source message // NOW. A DM-modal/web-only entry flow would remove the exposure entirely — out of scope here. + // Personal tokens belong to a verified person. An HTTP run API turn names an author it never + // proved, so it acts as the API principal and has no personal record to write. + const NO_PERSONAL_CONTEXT = "No verified user context — personal tokens can only be changed from your own Slack message."; const DELETE_MSG_WARNING = "\n⚠️ Now DELETE the Slack message that contained the token — it stays readable in Slack " + "history (and search) for everyone in this conversation until you do."; @@ -41,7 +44,7 @@ export function register(server, ctx) { inputSchema: { token: z.string() }, }, async ({ token }) => { - if (!createdBy) return text("No user context — can't set a token here."); + if (!principalTrusted || !createdBy) return text(NO_PERSONAL_CONTEXT); const t = (token || "").trim(); if (t.length < 6) return text("That doesn't look like a valid Composio token."); await setUser(createdBy, { composioToken: t }); @@ -53,7 +56,7 @@ export function register(server, ctx) { "clear_my_composio_token", { description: "Remove YOUR OWN Composio token.", inputSchema: {} }, async () => { - if (!createdBy) return text("No user context."); + if (!principalTrusted || !createdBy) return text(NO_PERSONAL_CONTEXT); await setUser(createdBy, { composioToken: "" }); return text("🗑️ Removed your Composio token."); } @@ -72,7 +75,7 @@ export function register(server, ctx) { inputSchema: { token: z.string() }, }, async ({ token }) => { - if (!createdBy) return text("No user context — can't set a token here."); + if (!principalTrusted || !createdBy) return text(NO_PERSONAL_CONTEXT); const t = (token || "").trim(); if (t.length < 6) return text("That doesn't look like a valid Toolbox token."); await setUser(createdBy, { toolboxToken: t }); @@ -84,7 +87,7 @@ export function register(server, ctx) { "clear_my_toolbox_token", { description: "Remove YOUR OWN Toolbox token.", inputSchema: {} }, async () => { - if (!createdBy) return text("No user context."); + if (!principalTrusted || !createdBy) return text(NO_PERSONAL_CONTEXT); await setUser(createdBy, { toolboxToken: "" }); return text("🗑️ Removed your Toolbox token."); } diff --git a/src/slack/approvals.js b/src/slack/approvals.js index 6f060861..4976e8d7 100644 --- a/src/slack/approvals.js +++ b/src/slack/approvals.js @@ -9,7 +9,7 @@ import { randomUUID } from "node:crypto"; import { logEvent } from "../util/logger.js"; import { getChannelMeta, patchChannelMeta, isAdmin, isApproved } from "../config/store.js"; import { effectiveMeta } from "../gateway/run.js"; -import { canManage, isAuthorized } from "../gateway/modes.js"; +import { canManage, isApiPrincipal, isAuthorized } from "../gateway/modes.js"; import { toolTarget } from "../engines/stream.js"; import { approvalActionKey, @@ -114,11 +114,17 @@ function fencedPreview(raw) { export { isSlackTs, slackThreadFor } from "./thread-keys.js"; import { isSlackTs, slackThreadFor } from "./thread-keys.js"; +// Who a card credits with the request. The HTTP run API principal is not a Slack user, so a raw +// `<@api>` would render as broken markup; name the API instead. +function requesterLabel(authorId) { + return isApiPrincipal(authorId) ? "An HTTP API run" : `<@${authorId}>`; +} + function approvalBlocks(id, toolName, target, authorId, { approvalType = "permission", approveText = "Approve", denyText = "Deny", durable = false } = {}) { const preview = fencedPreview(target); if (approvalType === "agent") { return [ - { type: "section", text: { type: "mrkdwn", text: `*${toolName}*${preview}\n<@${authorId}> asked for approval${durable ? ". This exact request remains actionable across gateway restarts until handled." : " before continuing."}` } }, + { type: "section", text: { type: "mrkdwn", text: `*${toolName}*${preview}\n${requesterLabel(authorId)} asked for approval${durable ? ". This exact request remains actionable across gateway restarts until handled." : " before continuing."}` } }, { type: "actions", elements: [ @@ -130,7 +136,7 @@ function approvalBlocks(id, toolName, target, authorId, { approvalType = "permis ]; } return [ - { type: "section", text: { type: "mrkdwn", text: `🔒 *Permission needed* — \`${toolName}\`${preview}\n<@${authorId}>'s request wants to do this. Approve?` } }, + { type: "section", text: { type: "mrkdwn", text: `🔒 *Permission needed* — \`${toolName}\`${preview}\n${isApiPrincipal(authorId) ? "An HTTP API run" : `<@${authorId}>'s request`} wants to do this. Approve?` } }, { type: "actions", elements: [ diff --git a/test/api-runs-channel-parity.test.js b/test/api-runs-channel-parity.test.js new file mode 100644 index 00000000..143bf171 --- /dev/null +++ b/test/api-runs-channel-parity.test.js @@ -0,0 +1,126 @@ +// An HTTP run API turn is one more turn in its channel: the SAME per-thread queue a Slack message +// joins (so a Slack stop or steer reaches it and a follow-up never races it onto the same session), +// the same container resume command, and the same post-reply memory review. Driven through the +// REAL driver (runInBackground → runMessage) with the stub engine and a fake container backend, so +// no container CLI or real engine is needed. +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { mkdir } from "node:fs/promises"; +import test from "node:test"; +import assert from "node:assert/strict"; +import { ensureTestEnv } from "./helpers.js"; + +const projectRoot = fileURLToPath(new URL("..", import.meta.url)); +process.env.PATH = `${path.join(projectRoot, "test", "fixtures")}${path.delimiter}${process.env.PATH || ""}`; +process.env.SESSION_KEEPALIVE = "0"; +const scratch = ensureTestEnv(); +// A container turn authenticates with a relay of the operator's Claude login (claude-login.js). +{ + const { writeFileSync, mkdirSync } = await import("node:fs"); + const { operatorClaudeConfigDir } = await import("../src/gateway/claude-login.js"); + const file = path.join(operatorClaudeConfigDir(), ".credentials.json"); + mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 }); + writeFileSync(file, JSON.stringify({ claudeAiOauth: { accessToken: "sk-ant-oat01-api-parity", expiresAt: Date.now() + 4 * 3600_000, refreshTokenExpiresAt: Date.now() + 20 * 24 * 3600_000 } }), { mode: 0o600 }); +} +process.env.CG_WORKSPACE_DIR = path.join(scratch, "api-parity-workspaces"); + +const { upsertChannelEntry, saveChannelMeta } = await import("../src/config/store.js"); +const { saveSettings } = await import("../src/config/settings.js"); +const { setRuntimeResolver } = await import("../src/gateway/run.js"); +const { resolveRuntime } = await import("../src/runtimes/resolve.js"); +const { startApiRun, getApiJob } = await import("../src/gateway/api-runs.js"); +const { runQueue } = await import("../src/slack/message-lifecycle.js"); +const { createFakeRuntimeBackend, fakeTarget } = await import("./runtime-fake.js"); + +async function channel(id, name, meta = {}) { + const entry = await upsertChannelEntry(id, { name, type: "channel" }); + const full = { channelId: id, name, type: "channel", template: "custom", engine: "claude", allowedMcps: [], platform: "slack", ...meta }; + await saveChannelMeta(entry.slug, full); + await mkdir(resolveRuntime(entry.slug, full).cwd, { recursive: true }); + return entry; +} + +async function settled(jobId, timeoutMs = 60_000) { + const until = Date.now() + timeoutMs; + for (;;) { + const job = getApiJob(jobId); + if (job && !["running", "queued"].includes(job.status)) return job; + if (Date.now() > until) throw new Error(`job ${jobId} never settled (status ${job?.status})`); + await new Promise((resolve) => setTimeout(resolve, 25)); + } +} + +test.afterEach(() => setRuntimeResolver(null)); + +test("an API run holds its thread's run slot, so a Slack stop in that thread stops it", async () => { + saveSettings({ engine: "claude", memoryReviewEvery: 0, composioMode: "personal" }); + const entry = await channel("C_API_PAR_STOP", "api-par-stop"); + // A slow container start keeps the turn in flight long enough to observe and stop it. + const backend = createFakeRuntimeBackend({ ensureUpDelayMs: 3_000 }); + setRuntimeResolver((slug, meta) => fakeTarget(backend, slug, meta)); + + const started = await startApiRun({ message: "summarize the backlog", channel: "C_API_PAR_STOP" }); + assert.equal(started.ok, true); + const key = `${entry.slug}::api:${started.jobId}`; + const until = Date.now() + 5_000; + while (!runQueue.activeHandle(key) && Date.now() < until) await new Promise((resolve) => setTimeout(resolve, 10)); + const active = runQueue.activeHandle(key); + assert.ok(active?.api, "the API run is the active turn on its thread key — a Slack message there sees it busy"); + + // Exactly what the Slack stop path does with the active handle (message-pipeline.js). + const res = runQueue.abort(key); + res.active.controller?.abort(); + + const job = await settled(started.jobId); + assert.equal(job.status, "stopped"); + assert.equal(backend.calls.spawn.length, 0, "a stopped API run never reaches the engine"); + assert.equal(runQueue.activeHandle(key), null, "the slot is released for the next turn"); +}); + +test("a Slack steer on an API run's thread supersedes it and says so", async () => { + saveSettings({ engine: "claude", memoryReviewEvery: 0, composioMode: "personal" }); + const entry = await channel("C_API_PAR_STEER", "api-par-steer"); + const backend = createFakeRuntimeBackend({ ensureUpDelayMs: 3_000 }); + setRuntimeResolver((slug, meta) => fakeTarget(backend, slug, meta)); + const { steerActiveRun } = await import("../src/slack/busy-thread-choice.js"); + + const started = await startApiRun({ message: "draft the report", channel: "C_API_PAR_STEER" }); + const key = `${entry.slug}::api:${started.jobId}`; + const until = Date.now() + 5_000; + while (!runQueue.activeHandle(key) && Date.now() < until) await new Promise((resolve) => setTimeout(resolve, 10)); + assert.equal(steerActiveRun(runQueue, key), "aborted"); + + const job = await settled(started.jobId); + assert.equal(job.status, "stopped"); + assert.match(job.error || "", /steered/i); +}); + +test("a completed API run reports the container resume command and queues the channel's memory review", async () => { + saveSettings({ engine: "claude", memoryReviewEvery: 1, composioMode: "personal" }); + const entry = await channel("C_API_PAR_DONE", "api-par-done"); + const backend = createFakeRuntimeBackend(); + setRuntimeResolver((slug, meta) => fakeTarget(backend, slug, meta)); + const reviews = []; + + const started = await startApiRun({ + message: "remember that invoices are due on the 5th", + channel: "C_API_PAR_DONE", + queueMemoryReview: (args) => { reviews.push(args); return null; }, + }); + // The command handed back at start already enters the channel's container: its engine sessions + // live in the container's HOME volume, so a host `cd … && claude -r …` would find nothing. + assert.match(started.resumeCommand, / exec -it -w /); + assert.doesNotMatch(started.resumeCommand, /^cd /); + + const job = await settled(started.jobId); + assert.equal(job.status, "completed", job.error || ""); + assert.match(job.resumeCommand, / exec -it -w /); + assert.ok(job.resumeCommand.includes(job.sessionId), "the resume command names the run's session"); + assert.equal(backend.calls.spawn.length, 1); + + assert.equal(reviews.length, 1, "the post-reply memory review is offered exactly like a Slack turn's"); + assert.equal(reviews[0].slug, entry.slug); + assert.equal(reviews[0].threadKey, `api:${started.jobId}`); + assert.equal(reviews[0].authorId, "api", "the reviewer acts as the API principal, never a caller-named author"); + assert.match(await reviews[0].fetchTranscript(), /invoices are due on the 5th[\s\S]*Assistant:/); +}); diff --git a/test/gateway-mcp-authz.test.js b/test/gateway-mcp-authz.test.js index a8e41a5d..3859c4ac 100644 --- a/test/gateway-mcp-authz.test.js +++ b/test/gateway-mcp-authz.test.js @@ -14,9 +14,16 @@ const { mintGatewayCapability } = await import("../src/gateway/mcp-capability.js // Allow-all approval stub: this file tests per-tool AUTHZ, not the A3 control-plane approval gate // (that has its own suite in mcp-control-plane-approval.test.js) — so approvals always pass here. +// It records what each daemon IPC call carried, so a test can see WHO a tool asked on behalf of. +const ipcCalls = []; const approvalStub = http.createServer((req, res) => { - res.setHeader("Content-Type", "application/json"); - res.end(JSON.stringify({ allow: true, reason: "auto-approved by test stub" })); + let body = ""; + req.on("data", (chunk) => { body += chunk; }); + req.on("end", () => { + try { ipcCalls.push({ path: req.url, body: JSON.parse(body || "{}") }); } catch { /* not JSON */ } + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({ allow: true, reason: "auto-approved by test stub" })); + }); }); await new Promise((resolve) => approvalStub.listen(0, "127.0.0.1", resolve)); after(() => approvalStub.close()); @@ -108,38 +115,64 @@ test("admin-only MCP tools refuse a non-admin signed principal", async () => { }); }); -test("API-spoofed admin ids cannot use any user/channel gateway authority", async () => { - await setUser("U_MCP_API_SPOOF", { approved: true, isAdmin: true }); - const capability = mintGatewayCapability({ +// An HTTP run API capability names an author the API key never proved. The run gets the channel's +// gateway tools like any member's message, but acts as the fixed API principal: the named (even +// admin) id is attribution only and never becomes admin rank or anybody's personal scope. +function apiCapability(author = "U_MCP_API_SPOOF") { + return mintGatewayCapability({ secret: "authz-test-secret", channelId: "C_MCP_CURRENT", slug: "mcp-authz-test", - authorId: "U_MCP_API_SPOOF", + authorId: author, threadKey: "1.000", origin: "api_foreground", engine: "claude", principalTrusted: false, }); - await withGateway({ author: "U_MCP_API_SPOOF", capability }, async (client) => { - const result = await client.callTool({ name: "list_folders", arguments: {} }); - assert.match(resultText(result), /trusted Slack principal/i); - assert.doesNotMatch(resultText(result), /\/Users\/|\/home\/|Slack Agent/i); +} + +test("API-spoofed admin ids get no admin or personal authority from the gateway tools", async () => { + await setUser("U_MCP_API_SPOOF", { approved: true, isAdmin: true, composioToken: "spoofed-unchanged" }); + await withGateway({ author: "U_MCP_API_SPOOF", capability: apiCapability() }, async (client) => { + const folders = await client.callTool({ name: "list_folders", arguments: {} }); + assert.match(resultText(folders), /Only admins|admin/i); + assert.doesNotMatch(resultText(folders), /\/Users\/|\/home\/|Slack Agent/i); + + const adminMode = await client.callTool({ name: "set_channel_admin_mode", arguments: { enabled: true } }); + assert.match(resultText(adminMode), /Only admins/i); + + const token = await client.callTool({ name: "set_my_composio_token", arguments: { token: "api-planted-token" } }); + assert.match(resultText(token), /No verified user context/i); + + const mySkills = await client.callTool({ name: "add_my_skills", arguments: { slugs: ["anything"] } }); + assert.match(resultText(mySkills), /Only approved members have personal skill grants/i); }); + const spoofed = await getUser("U_MCP_API_SPOOF"); + assert.equal(spoofed.composioToken, "spoofed-unchanged"); + assert.equal(await getUser("api"), null, "no personal record is ever created for the API principal"); }); -test("an untrusted principal's permission prompt is refused in the shape Claude Code parses", async () => { - // Plain text here surfaces as "The permission prompt tool returned an invalid permission result". - const capability = mintGatewayCapability({ - secret: "authz-test-secret", channelId: "C_MCP_CURRENT", slug: "mcp-authz-test", - authorId: "U_MCP_API_SPOOF", threadKey: "1.000", origin: "api_foreground", engine: "claude", - principalTrusted: false, +test("an API run's gateway tools work like a member's (no blanket refusal)", async () => { + await withGateway({ author: "U_MCP_API_SPOOF", capability: apiCapability() }, async (client) => { + const tools = await client.listTools(); + for (const name of ["search_channel_memory", "read_channel_memory", "update_channel_memory", "permission_prompt", "create_schedule", "run_agent_in_background", "list_skills"]) { + assert.ok(tools.tools.some((tool) => tool.name === name), `${name} is exposed to an API run`); + } + const memory = await client.callTool({ name: "search_channel_memory", arguments: { query: "anything" } }); + assert.doesNotMatch(resultText(memory), /trusted Slack principal|capability rejected/i); }); - await withGateway({ author: "U_MCP_API_SPOOF", capability }, async (client) => { +}); + +test("an API run's permission prompt asks on behalf of the API principal, never the named admin", async () => { + ipcCalls.length = 0; + await withGateway({ author: "U_MCP_API_SPOOF", capability: apiCapability() }, async (client) => { const result = await client.callTool({ name: "permission_prompt", arguments: { tool_name: "Bash", input: { command: "true" } } }); const decision = JSON.parse(resultText(result)); - assert.equal(decision.behavior, "deny"); - assert.match(decision.message, /trusted Slack principal/i); + assert.equal(decision.behavior, "allow", "the stub's decision is returned in the shape Claude Code parses"); }); + const asked = ipcCalls.find((call) => call.path === "/internal/approval"); + assert.ok(asked, "the permission prompt reached the daemon's approval path"); + assert.equal(asked.body.authorId, "api", "the approval path must not see the spoofed admin id (admin-mode auto-approval keys on it)"); }); test("set_my_composio_token writes only the signed principal's record", async () => { diff --git a/test/modes.test.js b/test/modes.test.js index c94c78d9..cf76bbd6 100644 --- a/test/modes.test.js +++ b/test/modes.test.js @@ -3,7 +3,7 @@ import assert from "node:assert/strict"; import { ensureTestEnv } from "./helpers.js"; ensureTestEnv(); -const { MODE_FLAGS, MODES, channelMode, modeLabel, networkLabel, networkState, PROFILE_FLAGS, PROFILES, channelProfile, canManage, modeSettingsPatch, normalizeModeMeta, authorModeMeta, sudoModeMeta } = +const { MODE_FLAGS, MODES, channelMode, modeLabel, networkLabel, networkState, PROFILE_FLAGS, PROFILES, channelProfile, canManage, modeSettingsPatch, normalizeModeMeta, authorModeMeta, sudoModeMeta, API_PRINCIPAL, isApiPrincipal, isAuthorized } = await import("../src/gateway/modes.js"); const { NETWORK_ADVISORY_NOTE, NETWORK_POLICY_ENFORCED } = await import("../src/engines/network-policy.js"); const { hasSudoRuntimeAuthority } = await import("../src/runtimes/sudo-authority.js"); @@ -145,3 +145,22 @@ test("canManage: custom policy grants only the listed managers", () => { assert.equal(canManage({ manageAccess: "custom", managers: "U1" }, { authorId: "U1" }), false); assert.equal(canManage({ manageAccess: "custom" }, { authorId: "U1" }), false); }); + +test("the HTTP run API principal is one fixed id that no real author can match", () => { + assert.equal(API_PRINCIPAL, "api"); + assert.equal(isApiPrincipal("api"), true); + for (const id of ["", null, undefined, "U04ADMIN", "API", " api", "gchat:users/api", "msteams:api"]) { + assert.equal(isApiPrincipal(id), false, `${JSON.stringify(id)} is not the API principal`); + } + // It carries no rank of its own: a channel admits it only through the same member rules, and it + // is never an admin. + assert.equal(isAuthorized({}, API_PRINCIPAL, false, { isApprovedUser: true }), true); + assert.equal(isAuthorized({}, API_PRINCIPAL, false), false); + assert.equal(isAuthorized({ access: "admins" }, API_PRINCIPAL, false, { isApprovedUser: true }), false); + assert.equal(isAuthorized({ access: "none" }, API_PRINCIPAL, false, { isApprovedUser: true }), false); + assert.equal(isAuthorized({ access: "none", allowedUsers: [API_PRINCIPAL] }, API_PRINCIPAL, false), true); + assert.equal(isAuthorized({ allowedUsers: [API_PRINCIPAL] }, API_PRINCIPAL, true), false, "a guest grant never opens a DM"); + assert.equal(isAuthorized({}, API_PRINCIPAL, true, { isApprovedUser: true }), true); + assert.equal(canManage({ manageAccess: "admins" }, { authorId: API_PRINCIPAL, isApprovedUser: true }), false); + assert.equal(canManage({ manageAccess: "members" }, { authorId: API_PRINCIPAL, isApprovedUser: true }), true); +}); diff --git a/test/runtime-integration-run.test.js b/test/runtime-integration-run.test.js index eca96a9f..6606850c 100644 --- a/test/runtime-integration-run.test.js +++ b/test/runtime-integration-run.test.js @@ -405,3 +405,26 @@ test("Stop signal reaches runtime preparation and prevents engine spawn", async await assert.rejects(run, { name: "AbortError" }); assert.equal(backend.calls.spawn.length, 0); }); + +test("a per-run API mode narrows tools but never changes what the channel's container mounts", async () => { + saveSettings({ engine: "claude", memoryReviewEvery: 0, composioMode: "personal" }); + const { operatorHomeGranted } = await import("../src/runtimes/container/lifecycle.js"); + await channel("C_RT_API_MODE", "rt-api-mode", { adminMode: true, allowBash: true }); + for (const mode of ["read", "worker", "auto", "lean", "full"]) { + const backend = createFakeRuntimeBackend(); + const resolved = []; + useBackend(backend, { record: resolved }); + await runMessage({ + channelId: "C_RT_API_MODE", authorId: "api", text: `api ${mode}`, threadKey: `api:mode-${mode}`, + overrides: { mode }, untrustedPrincipal: true, origin: "api_foreground", preferCold: true, + }); + assert.equal(resolved.length, 1, `${mode}: one runtime resolution per turn`); + // The operator-home grant is the one mount a channel's mode decides; with the gateway switch on + // it must follow the CHANNEL (Admin), or a single API run rebuilds the container both ways. + assert.equal(resolved[0].adminMode, true, `${mode}: the runtime target keeps the channel's Admin posture`); + assert.equal(operatorHomeGranted({ meta: resolved[0], settings: { fullAccessHome: true } }), true, `${mode}: the mount set is unchanged`); + assert.equal(backend.calls.spawn.length, 1); + const flags = backend.calls.spawn[0].args?.join?.(" ") || JSON.stringify(backend.calls.spawn[0]); + assert.doesNotMatch(flags, /--dangerously-skip-permissions/, `${mode}: an API run never escalates`); + } +}); From 2c6e27de80086e6e48e551a7e0976e596216b00b Mon Sep 17 00:00:00 2001 From: Tiberiu Socaci Date: Fri, 25 Sep 2026 21:30:27 +0300 Subject: [PATCH 2/4] feat: treat the run API key as an admin credential The run API key is an admin key, so an API run now behaves like an admin's message in its channel instead of a member's: it ranks as an admin (admin gateway tools, admin-mode auto-approval, background shell jobs without the admin click, and --dangerously-skip-permissions for a Full run in an Admin channel, since api_foreground now escalates like slack_foreground). It still acts as the one fixed `api` principal (src/config/api-principal.js), never as the author a request names, and has no personal scope: only the channel's shared composio-agent identity, no composio-user, and nobody's personal tokens, secrets, skills or SSH keys. An untrusted capability naming a real admin never borrows that admin's rank. isAdminPrincipal() applies the admin rank to run AUTHORS only; clickers and approval links still authorize as people, and no approval link is minted for `api`. Per-user API keys that act as a proven person are on the roadmap. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Tiberiu Socaci --- AGENTS.md | 16 +++++---- CHANGELOG.md | 13 +++++--- FEATURES.md | 33 +++++++++--------- README.md | 4 ++- TEST-PLAN.md | 32 ++++++++++++------ src/config/api-principal.js | 15 +++++++++ src/config/store.js | 8 +++++ src/gateway/api-runs.js | 20 ++++++----- src/gateway/background.js | 7 ++-- src/gateway/instruction-approvals.js | 4 +-- src/gateway/modes.js | 14 ++------ src/gateway/run.js | 50 ++++++++++++++++------------ src/mcp/gateway-server.js | 19 +++++------ src/mcp/tools/skills.js | 8 ++--- src/slack/approvals.js | 8 +++-- src/web/auth.js | 5 +-- test/approvals-layer.test.js | 18 +++++++++- test/gateway-mcp-authz.test.js | 22 +++++++----- test/run-escalation.test.js | 6 ++-- test/runtime-integration-run.test.js | 20 ++++++++++- 20 files changed, 207 insertions(+), 115 deletions(-) create mode 100644 src/config/api-principal.js diff --git a/AGENTS.md b/AGENTS.md index b500e958..8edd7ad2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -157,8 +157,9 @@ post/edit the reply in the thread (degraded to the surface's capabilities) → u digests, no-response nudges, the durable `/loop` pacing (the harness's own `ScheduleWakeup`/ `CronCreate` calls become schedule rows with a tick budget), and the HTTP run API (`POST /api/runs`: API-key or admin-session auth, a synthetic `api` channel or a real thread, - idempotency window, in-flight cap, `api_jobs`; the caller-supplied author is never trusted for - personal tokens or admin state). + idempotency window, in-flight cap, `api_jobs`; the key is an admin credential and every run acts + as the fixed admin `api` principal with no personal scope — the caller-supplied author is + attribution only, never trusted for personal tokens or admin state). - `src/gateway/{approval-requests,instruction-approvals,approval-link-tokens}.js` + `src/slack/approvals.js` + `src/web/approval-links.js` + `src/platforms/approval-delivery.js` — approvals: one decision path with scopes once / thread / forever ("forever" is admin-only), the @@ -270,7 +271,8 @@ post/edit the reply in the thread (degraded to the surface's capabilities) → u API) and `routes/approve.js` mounted separately. `secrets.js` is the name-resolved allowlist behind `POST /api/secrets/reveal` (in `routes/settings.js`); `security.js` holds scrypt password hashing, the Host/Origin (DNS-rebinding) guard, the SSRF check over `ip-policy.js`, path - containment and the login limiter; `auth.js` (admin session + the narrower run-API key); + containment and the login limiter; `auth.js` (admin session + the run-API key, an admin + credential scoped to `/api/runs`); `file-editor.js` / `file-download.js` / `file-upload.js` (one-time grant URLs → per-editor cookies, re-authorized on every request); `assets.js` (content-hash cache busting for the build-less UI); `skills-mcp.js`. @@ -469,8 +471,9 @@ Config that stays as **files** (read wholesale / bootstrap, hand-editable): Claude Code labels a token in the environment "Claude API" and hides the plan, the usage windows and the plan's default model; only a file login shows them, which is what a developer in a terminal needs to see. -- **Only admins get `--dangerously-skip-permissions`**, and only in an Admin-mode channel. - Everyone else runs with the folder's `permissions.allow` allowlist and answers tool requests +- **Only admins get `--dangerously-skip-permissions`**, and only in an Admin-mode channel (a live + Slack turn by an admin author, or a live HTTP run API turn, whose key is an admin credential + acting as the `api` principal — `src/config/api-principal.js`). Everyone else runs with the folder's `permissions.allow` allowlist and answers tool requests through the `permission_prompt` approval card (or Auto mode); headless can't answer interactive prompts. - **Authorization (who may talk)** is `isAuthorized()` in `src/gateway/modes.js`, checked before @@ -482,7 +485,8 @@ Config that stays as **files** (read wholesale / bootstrap, hand-editable): (`meta.allowedUsers`, channels only, constrained to current members). Who may change a channel's access settings is `canManage()` (`meta.manageAccess`: admins, members, or a named list). This is authorization only; dangerous permissions still require an admin author **and** an admin-mode - channel, and the run-API caller's `author` is never trusted for either. + channel, and the run-API caller's `author` is never trusted for either (the run API KEY is: its + runs act as the admin `api` principal, never as the named author, and get no personal scope). - **Attachments:** image/file attachments are downloaded into the channel folder's `uploads/` (per-file cap in `src/util/bounded-bytes.js`, no-follow writes) and their paths handed to the engine as text (read via the Read tool — images render visually); a failed download is named, diff --git a/CHANGELOG.md b/CHANGELOG.md index 43364f73..fdc81fab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,11 +18,14 @@ product overview. ## Unreleased -- **An HTTP API run now works like a member's message in its channel.** It gets the channel's Auto - mode, memory (search, read and save, plus the post-reply memory review), skills, connectors and - the other gateway tools, and it asks for tool approval the same way. The API key still acts as a - member, never as the `author` it names. So it gets no admin rights and nobody's personal tokens, - secrets or skills. Background work and schedules it starts are owned by the API too. +- **An HTTP API run now works like an admin's message in its channel.** The run API key is an + admin credential. An API run gets the channel's mode as an admin would, including Auto and, in an + Admin channel, the permission bypass. It also gets memory (search, read and save, plus the + post-reply memory review), skills, connectors, the admin gateway tools, and the same tool + approvals. It acts as one fixed `api` principal, never as the `author` it names. So it uses the + channel's shared agent Composio account and gets nobody's personal tokens, secrets, skills or SSH + keys. Background work and schedules it starts are owned by `api` too. Per-user API keys are + planned. - An API run joins its thread's queue. A Slack reply in a channel-backed API thread gets the usual Steer / Queue / Cancel choice instead of running at the same time, and a Slack stop or steer in that thread stops the API run. diff --git a/FEATURES.md b/FEATURES.md index 807bc153..3372db48 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -1024,21 +1024,24 @@ A categorized catalog of what's shipped. Cross-linked to `TEST-PLAN.md` checks. ledger's priced estimate for that same run (Codex reports none), flagged `costEstimated` in the status response and the webhook — `null` only when nothing anywhere knows. → TEST-PLAN: Automation. -- **An API run behaves like a member's message in its channel.** The run API key acts as one fixed - principal, `api`: an approved member of the target channel. The run gets everything - channel-scoped that a member's Slack message gets: Auto mode (tool prompts auto-approved), channel - memory (search, read, save and the post-reply memory review), the channel's and organization's - skills, MCP connections and `composio-agent` identity, channel secrets, and the gateway tools - (schedules, background jobs and agents, charts/tables/Lists in its Slack thread, progress, - approval cards). The `author` a request names is attribution only: it never grants admin rank, - the permission bypass, or anyone's personal Composio/Toolbox token, secrets, skills or SSH keys. - Background work and schedules the run starts are owned by `api`, so no later run inherits - authority the key never proved. `ask_questions` stays off because no verified person could - answer it. A per-run `mode` override narrows tools only. The container, its mounts and the - operator-home grant follow the channel's own mode, so an override never recreates the container. - API runs join their thread's per-thread queue (Slack follow-ups get Steer / Queue / Cancel, and a - Slack stop or steer stops the API run), and the returned `resumeCommand` enters the channel's - container. → TEST-PLAN: HTTP run API channel parity. +- **An API run behaves like an admin's message in its channel.** The run API key (like an admin + session on `/api/runs`) is an admin credential. Every run acts as one fixed principal, `api` + (`src/config/api-principal.js`): an admin of the target channel with no person behind it. The + run gets what an admin's Slack message gets: the channel's mode, including Auto (tool prompts + auto-approved) and, in an Admin channel, `--dangerously-skip-permissions` for a Full run. It also + gets channel memory (search, read, save and the post-reply memory review), the channel's and + organization's skills, MCP connections, channel secrets, and the gateway tools, including the + admin ones. Control-plane changes still need a human click on their approval card. It gets no + personal scope: Composio is the channel's shared `composio-agent` identity only, with no + `composio-user` and nobody's personal Toolbox token, secrets, skills or SSH keys. The `author` a + request names is attribution only. An untrusted capability naming a real admin never borrows that + admin's rank. Background work and schedules the run starts are owned by `api`, and their later + runs rank the same way. `ask_questions` stays off because no person could answer it. A per-run + `mode` override narrows tools only. The container, its mounts and the operator-home grant follow + the channel's own mode, so an override never recreates the container. API runs join their + thread's per-thread queue (Slack follow-ups get Steer / Queue / Cancel, and a Slack stop or steer + stops the API run), and the returned `resumeCommand` enters the channel's container. Per-user + API keys that act as a proven person are planned. → TEST-PLAN: HTTP run API channel parity. - Codex JSONL progress: Codex `item.started` / `item.completed` events for MCP tool calls, shell commands, and final agent messages feed the same Slack status/log stream as Claude, so Codex turns no longer look silent while tools run. Gateway-owned MCP tools are pre-approved inside Codex diff --git a/README.md b/README.md index 9d8992d6..6768fdb3 100644 --- a/README.md +++ b/README.md @@ -193,7 +193,9 @@ catalog, including edge cases and links to regression coverage. - **HTTP run API and Make.com:** trigger agent work through `POST /api/runs` or an approved, trusted bot posting a mention in Slack. The admin API page includes the Make.com module example, credential requirements and thread mapping. API runs support idempotency, status polling, - cancellation, attachments and completion webhooks, including headless execution. + cancellation, attachments and completion webhooks, including headless execution. The API key is + an admin credential: a run behaves like an admin's message in its channel, using the channel's + shared agent accounts rather than anyone's personal ones. ### Slack reports and collaboration diff --git a/TEST-PLAN.md b/TEST-PLAN.md index 1462752f..065f83b7 100644 --- a/TEST-PLAN.md +++ b/TEST-PLAN.md @@ -3224,29 +3224,41 @@ structural invariants are automated; rendered navigation and feature claims also ### HTTP run API channel parity -An API run is one more member's turn in its channel. Unit coverage: -- [x] Gateway tools are exposed to an API run; a named admin id gets no admin tool, personal token or - personal skill write, and no user record is created for `api` (`test/gateway-mcp-authz.test.js`). +An API run is an admin's turn in its channel, as the fixed `api` principal with no personal scope. +Unit coverage: +- [x] An API capability ranks as the admin `api` principal: admin tools work, admin-tier changes + still go through an approval card credited to `api`, personal token and skill writes are + refused, the named admin's record is untouched and no `api` user record is created + (`test/gateway-mcp-authz.test.js`). +- [x] `isAdminPrincipal("api")` is true while `isAdmin("api")` is false, and an Admin channel + auto-approves the API principal's permission prompt without posting a card + (`test/approvals-layer.test.js`). +- [x] `api_foreground` escalates like `slack_foreground` and no daemon origin does + (`test/run-escalation.test.js`). A Full API run in an Admin channel gets + `--dangerously-skip-permissions`; the same run with an untrusted capability naming a real + admin, or with a narrowed mode, does not (`test/runtime-integration-run.test.js`). - [x] The API run holds its thread's run-queue slot; the Slack stop path (`runQueue.abort` + the handle's controller) stops it before the engine spawns, a Slack steer supersedes it with a `steered` error, and a completed run returns a container `exec -it` resume command naming its session and queues the memory review as `api` (`test/api-runs-channel-parity.test.js`). - [x] A per-run `mode` of read/worker/auto/lean/full on an Admin channel resolves the runtime target - with the channel's Admin posture (operator-home grant unchanged) and never adds - `--dangerously-skip-permissions` (`test/runtime-integration-run.test.js`). + with the channel's Admin posture (operator-home grant unchanged) + (`test/runtime-integration-run.test.js`). Live acceptance (Claude and Codex each; fixture `qa-api-parity-`: a Slack channel in Worker mode with **Auto on**, channel memory on, one channel skill granted, the shared Composio identity -connected, one channel secret `QA_PARITY_TOKEN`; the gateway's run API key; an admin Slack id): +connected, one channel secret `QA_PARITY_TOKEN`; a second fixture `qa-api-admin-` in Admin +mode; the gateway's run API key; an admin Slack id): - [ ] **Auto + tools.** `POST /api/runs` `{channel: "qa-api-parity-", author: "", message: "Run \`ls\` in the work folder, then save to channel memory that the API parity check ran today, then list your skills."}`. Pass: the kickoff thread shows the run with no approval card (Auto), the reply lists files, `MEMORY.md` gains the fact, the channel skill is listed, and `GET /api/runs/:id` is `completed`. -- [ ] **No admin or personal scope.** Same channel, Auto **off**: ask it to run `touch x`. Pass: an - approval card credited to "An HTTP API run" (not the admin) appears in the thread; a member's - Approve lets it run. Ask it to "set my Composio token to abc123": refused with "No verified user - context", and the admin's stored token is unchanged. +- [ ] **Admin rank without personal scope.** In `qa-api-admin-`, ask it to run `touch x` and + to list the gateway folders. Pass: both happen with no approval card (Admin channel bypass / + admin tool). Ask it to "set my Composio token to abc123": refused with "No verified user + context", and the named admin's stored token is unchanged. Ask which Composio accounts it + has: only the shared (`composio-agent`) identity. - [ ] **Thread queue.** While a long API run ("count slowly to 60") is in flight, reply in its Slack thread with an @mention. Pass: the Steer / Queue / Cancel card appears; *Queue* runs after the API run finishes; repeating with `stop` makes `GET /api/runs/:id` report `stopped`. diff --git a/src/config/api-principal.js b/src/config/api-principal.js new file mode 100644 index 00000000..bc12862c --- /dev/null +++ b/src/config/api-principal.js @@ -0,0 +1,15 @@ +// The HTTP run API's principal. `POST /api/runs` authenticates the run API key (or an admin +// session), and that key is an ADMIN credential. Inside a run it acts as ONE fixed principal: an +// admin of the target channel with every channel capability a message gets (Auto/Admin mode, memory, +// skills, connectors, the gateway tools), but with no person behind it — so no personal scope: no +// personal Composio/Toolbox token, secrets, skills or SSH keys, and the channel's/agent's Composio +// identity only. The `author` a request names is attribution only. Work that outlives the run +// (background jobs/agents, schedules) is owned by this same principal. Per-user API keys that act +// as a proven person are a planned follow-up. +// +// Dependency-free on purpose: the config store and the gateway both need it without an import +// cycle. Not a Slack id shape, and no platform namespaces a bare word, so no real author matches it. +export const API_PRINCIPAL = "api"; +export function isApiPrincipal(id) { + return String(id || "") === API_PRINCIPAL; +} diff --git a/src/config/store.js b/src/config/store.js index 5560264c..a8d04ba4 100644 --- a/src/config/store.js +++ b/src/config/store.js @@ -14,6 +14,7 @@ import { slugify, } from "./paths.js"; import { getDb, toJson, fromJson } from "../db/index.js"; +import { isApiPrincipal } from "./api-principal.js"; // Retired integrations' fields are dropped on the way IN, so a record can never be re-saved with // one and no listing has to remember to mask it (see ./dead-fields.js). import { stripDeadFields } from "./dead-fields.js"; @@ -90,6 +91,13 @@ export async function isAdmin(userId) { return Boolean((await getUser(userId))?.isAdmin); } +// Admin rank of a run's AUTHOR: a stored admin, or the HTTP run API principal, whose key is an +// admin credential (config/api-principal.js). Only for "who asked for this work" — never for who +// clicked, approved or signed in, where the API principal can never appear and must never count. +export async function isAdminPrincipal(userId) { + return isApiPrincipal(userId) || (await isAdmin(userId)); +} + // Approved = on the approved-users list. Admins are implicitly approved. export async function isApproved(userId) { const u = await getUser(userId); diff --git a/src/gateway/api-runs.js b/src/gateway/api-runs.js index 7861da4a..dd477ca5 100644 --- a/src/gateway/api-runs.js +++ b/src/gateway/api-runs.js @@ -9,9 +9,11 @@ // starts the thread, its ts is the session key), so the answer posts back and it's continuable // in Slack too. If the kickoff can't post, it falls back to a headless api-keyed thread. // -// Either way the run is an ordinary member's turn in that channel (Auto mode, memory, skills, -// connectors, the gateway tools, its thread's run queue, the post-reply memory review). The API key -// acts as the fixed API principal (./modes.js); the `author` a request names is attribution only. +// Either way the run is an ordinary admin's turn in that channel (its mode incl. Admin/Auto, memory, +// skills, connectors, the gateway tools, its thread's run queue, the post-reply memory review). The +// API key is an admin credential and acts as the fixed API principal (../config/api-principal.js), +// with the channel's agent Composio identity and no personal scope; a request's `author` is +// attribution only. // // Jobs are tracked in memory AND persisted to the `api_jobs` table so GET keeps working after a // restart. In-flight work with an unknown outcome is interrupted, never replayed automatically. @@ -726,7 +728,9 @@ async function runInBackground(job, { textForRun, attachmentPath, client, teamId const result = await runMessage({ channelId: job.channelId, - authorId: job.author, + // The run acts as the API principal: the key is an admin credential with no person behind + // it. `job.author` (caller-named) labels the kickoff, prompt provenance and usage ledger only. + authorId: API_PRINCIPAL, workspaceId: teamId || process.env.CG_SLACK_TEAM_ID || "", text: textForRun, threadKey: job.threadKey, @@ -734,10 +738,10 @@ async function runInBackground(job, { textForRun, attachmentPath, client, teamId sessionId: job.sessionId, overrides, signal, - // The API key authenticates the CALLER, not `job.author`: that id is attribution only and - // never escalates or unlocks anyone's personal scope. Inside the run the key acts as the API - // principal — a member of this channel with every channel capability (gateway/modes.js). - // Recovery never re-enters this driver: it only delivers a saved result or interruption notice. + // No personal scope: nobody's personal Composio/Toolbox token, secrets or skills — the + // channel's (agent) identities only. Admin rank comes from the principal itself + // (config/api-principal.js). Recovery never re-enters this driver: it only delivers a saved + // result or interruption notice. untrustedPrincipal: true, origin: "api_foreground", progressReport: Boolean(status && client), diff --git a/src/gateway/background.js b/src/gateway/background.js index ace7bbf9..64a1462a 100644 --- a/src/gateway/background.js +++ b/src/gateway/background.js @@ -10,7 +10,7 @@ import { randomUUID } from "node:crypto"; import { createWriteStream, mkdirSync } from "node:fs"; import { readFile } from "node:fs/promises"; import path from "node:path"; -import { getChannelEntry, getChannelMeta, defaultChannelMeta, isAdmin } from "../config/store.js"; +import { getChannelEntry, getChannelMeta, defaultChannelMeta, isAdminPrincipal } from "../config/store.js"; import { buildShellEnv, serviceSecretValues } from "../engines/child-env.js"; import { resolveRuntime } from "../runtimes/resolve.js"; import { newRunId, runtimeSupports } from "../runtimes/contract.js"; @@ -302,7 +302,7 @@ export class BackgroundJobs { ); const sudoThread = await getThreadSudo(entry.slug, threadKey); if (sudoThread) { - if (!(await isAdmin(authorId))) { + if (!(await isAdminPrincipal(authorId))) { return { ok: false, error: "This is a sudo thread. Only organization admins can run background work here." }; } meta = sudoModeMeta(meta); @@ -326,7 +326,8 @@ export class BackgroundJobs { const runtimeNotice = shellJobRuntimeNotice({ isolated: isolatedJob, image: target?.container?.image || "" }); if (!isAgent) { - const adminAuthorInAdminMode = meta.adminMode === true && (await isAdmin(authorId)); + // The author's rank, where the HTTP run API principal (an admin key) counts as an admin. + const adminAuthorInAdminMode = meta.adminMode === true && (await isAdminPrincipal(authorId)); const allowed = meta.autoMode === true || adminAuthorInAdminMode; if (adminAuthorInAdminMode && !approval.approvalGranted) { approvedBy = String(authorId || "").replace(/[<@>]/g, ""); diff --git a/src/gateway/instruction-approvals.js b/src/gateway/instruction-approvals.js index 7e379128..79d31721 100644 --- a/src/gateway/instruction-approvals.js +++ b/src/gateway/instruction-approvals.js @@ -1,6 +1,6 @@ // A closed, exact action: no model continuation or arbitrary tool replay is needed after a // restart. The protected approval row holds the rule; listing surfaces expose only its preview. -import { getChannelEntry, getChannelMeta, isAdmin, isApproved } from "../config/store.js"; +import { getChannelEntry, getChannelMeta, isAdminPrincipal, isApproved } from "../config/store.js"; import { isAuthorized } from "./modes.js"; import { channelInstructionsSnapshot, effectiveWorkDir, updateChannelInstructions } from "./folders.js"; @@ -15,7 +15,7 @@ async function authorizedTarget({ channelId, slug, authorId, mode }) { if (!entry || entry.slug !== slug || !meta || (meta.channelId && meta.channelId !== channelId)) { throw new Error("The approval's channel no longer matches its original destination."); } - const admin = await isAdmin(authorId); + const admin = await isAdminPrincipal(authorId); if (!isAuthorized(meta, authorId, Boolean(entry.isDM), { isAdminUser: admin, isApprovedUser: await isApproved(authorId) })) { throw new Error("The requester is no longer authorized in this channel."); } diff --git a/src/gateway/modes.js b/src/gateway/modes.js index bf50adcf..5fe6f02b 100644 --- a/src/gateway/modes.js +++ b/src/gateway/modes.js @@ -153,18 +153,8 @@ export function channelProfile(meta = {}) { // "approved" → admins + approved users · "admins" → admins only · "none" → nobody // ("none" is dormant: only the explicit allowedUsers grant above gets in — even admins must be // added there.) -// ── The HTTP run API's principal ────────────────────────────────────────────── -// `POST /api/runs` authenticates an API KEY, never the Slack author a request body names. Inside a -// run that key acts as ONE fixed principal: an approved member of the target channel, with every -// channel-scoped capability a member's message gets (Auto mode, memory, skills, connectors, the -// gateway tools) and nobody's personal scope and no admin authority. The named author is -// attribution only. Work that outlives the run (background jobs/agents, schedules) is owned by this -// same principal, so no later run can inherit an authority the key never proved. Not a Slack id -// shape, and no platform namespaces a bare word, so it can never collide with a real user. -export const API_PRINCIPAL = "api"; -export function isApiPrincipal(id) { - return String(id || "") === API_PRINCIPAL; -} +// The HTTP run API's principal (an admin with no personal scope) — see config/api-principal.js. +export { API_PRINCIPAL, isApiPrincipal } from "../config/api-principal.js"; export function isAuthorized(meta, authorId, isDM, { isAdminUser = false, isApprovedUser = false } = {}) { if (!isDM && Array.isArray(meta.allowedUsers) && meta.allowedUsers.includes(authorId)) return true; diff --git a/src/gateway/run.js b/src/gateway/run.js index 963e1075..d37db4b7 100644 --- a/src/gateway/run.js +++ b/src/gateway/run.js @@ -26,7 +26,7 @@ import { claudeTokenFingerprint, resolveContainerClaudeToken } from "./claude-to import { resolveRuntime } from "../runtimes/resolve.js"; import { newRunId, runtimeSupports } from "../runtimes/contract.js"; import { getThreadEngine, getThreadClean, getThreadModel, getThreadEffort, getThreadSudo } from "./thread-engine.js"; -import { PROFILE_FLAGS, normalizeModeMeta, authorModeMeta, sudoModeMeta } from "./modes.js"; +import { PROFILE_FLAGS, normalizeModeMeta, authorModeMeta, sudoModeMeta, isApiPrincipal } from "./modes.js"; import { NETWORK_POLICY_ENFORCED } from "../engines/network-policy.js"; import { resolveCurrentModel } from "./model-info.js"; import { runtimeIdentityPreamble } from "./runtime-identity.js"; @@ -531,10 +531,9 @@ async function acquireRunSlotWithStatus({ signal, onEvent, origin }) { export { resolveComposioConnections, resolveComposioRuntime } from "./run-integrations.js"; // Merge the HTTP run API's per-request overrides into the channel meta. An override may only -// REDUCE capability, never introduce adminMode: the run-API key is not an admin credential -// (web/auth.js) and that path's authorId is caller-supplied, so letting `mode:"full"` set -// adminMode would hand any key holder an unsandboxed run by naming a known admin's Slack id -// (those ids are public). A channel that is ALREADY adminMode keeps its own setting. +// REDUCE capability, never introduce adminMode: an API run, like an admin's message, works within +// the channel's configured mode, and only the channel's own settings change that mode. A channel +// that is ALREADY adminMode keeps its own setting. // // It also never picks a runtime BACKEND or the container's mounts. Which machine boundary a // channel's turns run behind, and what that container can see, is durable configuration; letting a @@ -573,22 +572,23 @@ export function applyRunOverrides(meta, overrides) { import { RUN_ORIGINS, PRINCIPAL_KIND_BY_ORIGIN } from "../engines/contract.js"; export { RUN_ORIGINS }; -// The ONLY origin that may escalate: a live Slack turn, authored by a Slack-authenticated human -// who is watching it run. Everything daemon-triggered (schedule/background/continuation/recovery/ -// diagnosis) fires a prompt persisted earlier — injected content during an admin's turn must not -// become a silent full-machine run later. api_foreground stays out too: the run-API key -// authenticates the caller, not the author it names (see untrustedPrincipal below). +// The origins that may escalate: a live Slack turn, authored by a Slack-authenticated human who is +// watching it run, and a live HTTP run API turn, whose key is an admin credential (it escalates only +// as the API principal — never as an author the request names; see runMessage). Everything +// daemon-triggered (schedule/background/continuation/recovery/diagnosis) fires a prompt persisted +// earlier — injected content during an admin's turn must not become a silent full-machine run later. // Google Chat and Teams turns are deliberately NOT here. Both are authored by an authenticated // human, but escalation's other half is an interactive permission prompt the author can answer, and // neither surface has one wired yet (src/platforms/ingest.js). An admin-mode channel on those // surfaces therefore runs at the folder's allowlist like everyone else, which fails closed. This // set gains a platform when that platform gains an approval UI — never before. -const ESCALATABLE_ORIGINS = new Set(["slack_foreground"]); +const ESCALATABLE_ORIGINS = new Set(["slack_foreground", "api_foreground"]); // Escalation (--dangerously-skip-permissions + the bypass-allowing settings variant) requires an // escalatable origin AND an adminMode channel AND an admin author — and is refused regardless for -// untrustedPrincipal: we never authenticated the author. Slack does that for us; the HTTP run -// API authenticates only the CALLER and takes `author` from the request body. +// untrustedPrincipal: we never authenticated the author's rank. Slack does that for us; the HTTP +// run API authenticates its admin KEY, which ranks as the API principal, never as the `author` the +// request body names. // // Refused runs still work — they use the folder's permission allowlist like every other run. // Unknown/missing origin fails closed here as a second layer even though runMessage already threw. @@ -615,6 +615,14 @@ export async function runMessage({ channelId, authorId, workspaceId = "", text, // Fail closed before anything else: a run with no declared origin is a programming error, not a // default-to-interactive. if (!RUN_ORIGINS.includes(origin)) throw new Error(`runMessage requires a valid origin (got ${JSON.stringify(origin)}); one of: ${RUN_ORIGINS.join(", ")}`); + // The HTTP run API principal (config/api-principal.js): its key is an ADMIN credential, but no + // person stands behind it. So it ranks as an admin (`rankUntrusted` stays false) while every + // PERSONAL lookup — grants, secrets, Composio/Toolbox tokens, the capability's principal — is + // withheld exactly as for an unauthenticated author. The same holds for the schedules and + // background work it owns, whose later runs carry it as their author. + const apiKeyPrincipal = isApiPrincipal(authorId); + const rankUntrusted = Boolean(untrustedPrincipal) && !apiKeyPrincipal; + if (apiKeyPrincipal) untrustedPrincipal = true; assertRuntimeCanStart(); const entry = await getChannelEntry(channelId); if (!entry) throw new Error(`channel ${channelId} is not registered`); @@ -651,12 +659,10 @@ export async function runMessage({ channelId, authorId, workspaceId = "", text, // provisioning + the clean check so `mode:lean`→cleanMode is honored everywhere. `mode` expands // to the capability flags. // - // An override may only REDUCE capability, never introduce adminMode: the run-API key is not an - // admin credential (web/auth.js), and `authorId` on that path is caller-supplied, so allowing - // `mode:"full"` to set adminMode would let any key holder name an admin and get an unsandboxed - // --dangerously-skip-permissions run. The channel's own stored adminMode still stands. - const trustedAdminAuthor = !untrustedPrincipal && await isAdmin(authorId); - meta = authorModeMeta(meta, { isAdminAuthor: trustedAdminAuthor, untrustedPrincipal }); + // An override may only REDUCE capability, never introduce adminMode: like an admin's message, an + // API run works within the channel's configured mode, which only the channel's settings change. + const trustedAdminAuthor = apiKeyPrincipal || (!rankUntrusted && await isAdmin(authorId)); + meta = authorModeMeta(meta, { isAdminAuthor: trustedAdminAuthor, untrustedPrincipal: rankUntrusted }); // What the channel's container is built from is the CHANNEL's posture, not this run's: a per-run // `mode` only narrows tools. Resolving mounts from the overridden view dropped an Admin channel's // operator-home grant for one API run, which changed the container's mounts — so it waited for @@ -1046,7 +1052,7 @@ export async function runMessage({ channelId, authorId, workspaceId = "", text, userIdentity, composio, toolbox, composioUserToken, composioToken, composioUserEndpoint, composioEndpoint, toolboxToken, makeToolboxUrl, makeToolboxKey, composioIdentityPrefix, } = await resolveRunIntegrations({ meta, channelId, authorId, threadKey, workspaceId, clean, untrustedPrincipal }); - const authorIsAdmin = userIdentity.isAdmin; + const authorIsAdmin = userIdentity.isAdmin || apiKeyPrincipal; outputSecrets.push(composioUserToken, composioToken, toolboxToken, makeToolboxKey); // Engine homes are deliberately isolated. Resolve these once in the daemon and carry them into // the gateway MCP instead of letting its subprocess derive paths from the disposable HOME. @@ -1105,14 +1111,14 @@ export async function runMessage({ channelId, authorId, workspaceId = "", text, // even when the channel selects optional servers; Clean supplies an empty payload. const strictMcp = true; - const dangerouslySkip = mayEscalate({ meta, isAdminAuthor: authorIsAdmin, untrustedPrincipal, origin }); + const dangerouslySkip = mayEscalate({ meta, isAdminAuthor: authorIsAdmin, untrustedPrincipal: rankUntrusted, origin }); // Admin outranks auto: a non-escalated run whose STORED author is an admin in an adminMode // channel runs at the AUTO tier (writable work folder, auto-approved permission prompts) // instead of the read floor. This covers every daemon origin — background agents, their // continuations, schedules, recovery — which used to complete read-only in admin channels and // silently do nothing. A2 stands: these runs still NEVER get the permission bypass; the // unattended ceiling is auto, and only for the admin who authored the persisted prompt. - const adminUnattended = adminUnattendedTier({ meta, isAdminAuthor: authorIsAdmin, untrustedPrincipal, dangerouslySkip }); + const adminUnattended = adminUnattendedTier({ meta, isAdminAuthor: authorIsAdmin, untrustedPrincipal: rankUntrusted, dangerouslySkip }); if (adminUnattended) meta = { ...meta, autoMode: true }; // The bypass allowance rides per-spawn: only an escalated run gets the settings variant that // honors --dangerously-skip-permissions; the shared channel settings file always hard-disables diff --git a/src/mcp/gateway-server.js b/src/mcp/gateway-server.js index 621fa842..3c1e7e11 100644 --- a/src/mcp/gateway-server.js +++ b/src/mcp/gateway-server.js @@ -129,12 +129,11 @@ export function ctxFromClaims(claims = {}, { engine = "", toolset = "", progress const claimedAuthor = claims.authorId || ""; const threadKey = claims.threadKey || ""; const origin = claims.origin || ""; - // A verified human (a Slack-authenticated author) versus the HTTP run API's key. An API run's - // signed capability keeps the caller-named author for ATTRIBUTION only; every tool acts as the - // fixed API principal (gateway/modes.js) — an approved member of this channel with no personal - // scope and no admin rank. Runs that API work later spawns (a schedule, a background agent) - // carry that principal as their author, so they resolve the same way even though the daemon - // minted their capability for a "trusted" origin. + // A verified human (a Slack-authenticated author) versus the HTTP run API's admin key. Every tool + // in an API run acts as the fixed API principal (config/api-principal.js): an admin of this + // channel with no personal scope. A capability that names any other author without vouching for + // it (principalTrusted false) maps there too, so a named id is never authority. Runs that API + // work later spawns (a schedule, a background agent) carry the principal as their author. const apiPrincipal = claims.principalTrusted !== true || isApiPrincipal(claimedAuthor); const principalTrusted = !apiPrincipal; const createdBy = apiPrincipal ? (claimedAuthor ? API_PRINCIPAL : "") : claimedAuthor; @@ -146,13 +145,13 @@ export function ctxFromClaims(claims = {}, { engine = "", toolset = "", progress // work-dir, host browse, gateway update). requireManage: the SAFE settings (MCP allowlist, bash, // auto) — an admin OR, when the channel opts in (manageAccess "members"/"custom"), an approved // member / listed manager. The author comes only from the verified run capability. - const requireAdmin = async () => principalTrusted && Boolean(createdBy) && (await isAdmin(createdBy)); + // The HTTP run API principal ranks as an admin: its key is an admin credential. Control-plane + // changes still need a human click on their approval card, exactly as for an admin's message. + const requireAdmin = async () => Boolean(createdBy) && (apiPrincipal || (await isAdmin(createdBy))); const requireManage = async () => { if (!createdBy) return false; + if (apiPrincipal) return true; const meta = await loadMeta(); - // The API principal manages exactly where an approved member would ("members" policy) — and - // every control-plane change still needs a human click on its approval card. - if (apiPrincipal) return canManage(meta || {}, { authorId: API_PRINCIPAL, isAdminUser: false, isApprovedUser: true }); return canManage(meta || {}, { authorId: createdBy, isAdminUser: await isAdmin(createdBy), diff --git a/src/mcp/tools/skills.js b/src/mcp/tools/skills.js index 9e666c90..6ae4ed14 100644 --- a/src/mcp/tools/skills.js +++ b/src/mcp/tools/skills.js @@ -5,7 +5,7 @@ // allowed in the channel; a member's own tier needs no card. Registered via register(server, ctx). import { z } from "zod"; import { readFileSync } from "node:fs"; -import { getUser, isAdmin, isApproved, getChannelMeta, getChannelEntry } from "../../config/store.js"; +import { getUser, isAdminPrincipal, isApproved, getChannelMeta, getChannelEntry } from "../../config/store.js"; import { getOrgAccessGrants, getSkillsContextWarnTokens, getSkillsPublish, getEngine } from "../../config/settings.js"; import { resolveAccessGrants } from "../../gateway/access-grants.js"; import { getSkill, listSkills, listCategories, skillBundle, revisionFile, listProposals, listSources, addSource, updateSource, removeSource, excludeSkill, restoreSkill, effectiveRevisionFor, listRevisions, usageCountsBySlug, setSkillDiscoverable, SOURCE_KINDS, SOURCE_MODES } from "../../gateway/skills/catalog.js"; @@ -90,10 +90,10 @@ function publishLine(p) { export function register(server, ctx) { const { channelId, slug, createdBy, text, requireAdmin, requireManage, loadMeta } = ctx; - const isAdminUser = async () => Boolean(createdBy) && (await isAdmin(createdBy)); - // The HTTP run API principal is an approved member for shared-library work, but it is not a + // The HTTP run API principal ranks as an admin (its key is an admin credential), but it is not a // person: it has no personal grants and cannot own a personal skill. - const approvedAuthor = async () => Boolean(createdBy) && (Boolean(ctx.apiPrincipal) || (await isAdminUser()) || (await isApproved(createdBy))); + const isAdminUser = async () => Boolean(createdBy) && (await isAdminPrincipal(createdBy)); + const approvedAuthor = async () => Boolean(createdBy) && ((await isAdminUser()) || (await isApproved(createdBy))); const personalAuthor = async () => !ctx.apiPrincipal && (await approvedAuthor()); const activeSkillSlugs = async () => { const stored = (await loadMeta()) || {}; diff --git a/src/slack/approvals.js b/src/slack/approvals.js index 4976e8d7..4738b0b0 100644 --- a/src/slack/approvals.js +++ b/src/slack/approvals.js @@ -7,7 +7,7 @@ import { randomUUID } from "node:crypto"; import { logEvent } from "../util/logger.js"; -import { getChannelMeta, patchChannelMeta, isAdmin, isApproved } from "../config/store.js"; +import { getChannelMeta, patchChannelMeta, isAdmin, isAdminPrincipal, isApproved } from "../config/store.js"; import { effectiveMeta } from "../gateway/run.js"; import { canManage, isApiPrincipal, isAuthorized } from "../gateway/modes.js"; import { toolTarget } from "../engines/stream.js"; @@ -209,6 +209,9 @@ async function approvalLinkChoices(entry, { durable = false, approveText = "Appr // same thread; a DM on a surface with no ephemeral). Best-effort by design: a card that posted is // answerable by its buttons, so a failure here must never fail the approval. async function deliverApprovalLinks(client, entry, { id, threadKey, durable = false, approveText, denyText } = {}) { + // A link is minted FOR a person and decides as them; the HTTP run API principal is no person to + // deliver one to (and could never pass canResolveApproval as a requester). Its card's buttons stay. + if (isApiPrincipal(entry.authorId)) return []; try { const baseUrl = approvalLinkBase({ capabilities: client?.approvalDelivery?.capabilities || slackAdapter.capabilities, requester: entry.authorId }); if (!baseUrl || !client) return []; @@ -251,7 +254,8 @@ export async function requestApproval(slack, { channelId, slug, authorId, thread // Admin outranks auto (run.js adminUnattendedTier): the admin's own runs in an adminMode // channel behave at least like auto mode, so their unattended turns don't stall on a click // nobody sees. Non-admin authors in the same channel still get buttons. - if (meta.adminMode && authorId && (await isAdmin(authorId))) { + // The AUTHOR's rank: the HTTP run API principal is an admin key (config/api-principal.js). + if (meta.adminMode && authorId && (await isAdminPrincipal(authorId))) { return { allow: true, reason: "admin mode (auto-approved for the admin author)" }; } if ((meta.approvedTools || []).includes(toolName)) return { allow: true, reason: "approved forever for this channel" }; diff --git a/src/web/auth.js b/src/web/auth.js index d6c83304..4a093826 100644 --- a/src/web/auth.js +++ b/src/web/auth.js @@ -87,8 +87,9 @@ function cookieAttrs(req) { const OPEN_PATHS = new Set(["/login", "/login.html", "/api/login", "/api/health", "/api/skills/webhook/github", "/mcp/skills"]); // The HTTP run API accepts a bearer API key as an alternative to the admin session cookie, so an -// automation can fire runs without logging in. Scoped to /api/runs only — the key is NOT a general -// admin credential (it can't reach the settings/token/fs-browser routes). Bearer or X-API-Key. +// automation can fire runs without logging in. It is an admin credential for the RUNS it starts — +// they act as the admin API principal (config/api-principal.js) — but it is scoped to /api/runs +// only: it can't reach the settings/token/fs-browser routes. Bearer or X-API-Key. const API_RUN_PREFIX = "/api/runs"; function bearerToken(req) { const h = String(req.headers.authorization || ""); diff --git a/test/approvals-layer.test.js b/test/approvals-layer.test.js index 1a64c414..f1e61025 100644 --- a/test/approvals-layer.test.js +++ b/test/approvals-layer.test.js @@ -204,4 +204,20 @@ test("no shipped doc claims auto mode skips a control-plane skills approval", () assert.match(docs, /Auto mode does not bypass it/i); assert.match(guide, /\*\*always\*\* show an Approve\/Deny card/i); assert.match(guide, /Auto mode and admin mode do NOT skip it/i); -}); \ No newline at end of file +}); +test("the HTTP run API principal ranks as an admin for its own permission prompts; a clicker id never does", async () => { + const { isAdminPrincipal, isAdmin } = await import("../src/config/store.js"); + assert.equal(await isAdminPrincipal("api"), true, "the run API key is an admin credential"); + assert.equal(await isAdmin("api"), false, "but no stored user — clicks and links authorize as people only"); + assert.equal(await isAdminPrincipal(MEMBER), false); + assert.equal(await isAdminPrincipal(ADMIN), true); + + const slug = "approvals-layer-api-admin"; + await upsertChannelEntry("C_AL_API_ADMIN", { name: slug, type: "channel", isDM: false }); + await saveChannelMeta(slug, { ...(await getChannelMeta(slug)), adminMode: true, autoMode: false }); + const client = fakeClient(); + setApprovalClient(client); + const auto = await requestApproval(null, { channelId: "C_AL_API_ADMIN", slug, authorId: "api", threadKey: "1700.000900", toolName: "Bash", toolInput: { command: "true" } }); + assert.equal(auto.allow, true, "an Admin channel auto-approves the admin API principal's prompt like an admin author's"); + assert.equal(client.posted.length, 0, "no card is posted for it"); +}); diff --git a/test/gateway-mcp-authz.test.js b/test/gateway-mcp-authz.test.js index 3859c4ac..b3d02efb 100644 --- a/test/gateway-mcp-authz.test.js +++ b/test/gateway-mcp-authz.test.js @@ -115,9 +115,9 @@ test("admin-only MCP tools refuse a non-admin signed principal", async () => { }); }); -// An HTTP run API capability names an author the API key never proved. The run gets the channel's -// gateway tools like any member's message, but acts as the fixed API principal: the named (even -// admin) id is attribution only and never becomes admin rank or anybody's personal scope. +// An HTTP run API capability names an author the API key never proved. The key is an admin +// credential, so the run gets the channel's gateway tools like an admin's message — but it acts as +// the fixed API principal: the named id is attribution only and never anybody's personal scope. function apiCapability(author = "U_MCP_API_SPOOF") { return mintGatewayCapability({ secret: "authz-test-secret", @@ -131,19 +131,23 @@ function apiCapability(author = "U_MCP_API_SPOOF") { }); } -test("API-spoofed admin ids get no admin or personal authority from the gateway tools", async () => { +test("an API run ranks as the admin API principal, never as the admin id it names, and has no personal scope", async () => { await setUser("U_MCP_API_SPOOF", { approved: true, isAdmin: true, composioToken: "spoofed-unchanged" }); + ipcCalls.length = 0; await withGateway({ author: "U_MCP_API_SPOOF", capability: apiCapability() }, async (client) => { + // Admin tools work: the run API key is an admin credential. const folders = await client.callTool({ name: "list_folders", arguments: {} }); - assert.match(resultText(folders), /Only admins|admin/i); - assert.doesNotMatch(resultText(folders), /\/Users\/|\/home\/|Slack Agent/i); + assert.doesNotMatch(resultText(folders), /Only admins/i); - const adminMode = await client.callTool({ name: "set_channel_admin_mode", arguments: { enabled: true } }); - assert.match(resultText(adminMode), /Only admins/i); + // Control-plane changes still ask a human first, crediting the API principal. + await client.callTool({ name: "set_channel_admin_mode", arguments: { enabled: false } }); + const asked = ipcCalls.find((call) => call.path === "/internal/approval"); + assert.ok(asked, "an admin-tier change still goes through its approval card"); + assert.equal(asked.body.authorId, "api"); + // No personal scope: nobody's tokens or skills can be written, including the named admin's. const token = await client.callTool({ name: "set_my_composio_token", arguments: { token: "api-planted-token" } }); assert.match(resultText(token), /No verified user context/i); - const mySkills = await client.callTool({ name: "add_my_skills", arguments: { slugs: ["anything"] } }); assert.match(resultText(mySkills), /Only approved members have personal skill grants/i); }); diff --git a/test/run-escalation.test.js b/test/run-escalation.test.js index ace858a0..38f28762 100644 --- a/test/run-escalation.test.js +++ b/test/run-escalation.test.js @@ -51,9 +51,11 @@ test("no overrides returns the meta untouched", () => { // ── Origin → escalation matrix (the 2026-08 update plan (internal repo) A2) ──────────────────────── // Escalation is derived from principal + origin, never from an optional boolean a call site can // forget. Exactly one origin is escalatable: a live, watched, Slack-authenticated turn. -test("exhaustive origin matrix: only slack_foreground escalates, and only fully-privileged", () => { +test("exhaustive origin matrix: only live Slack and live run-API turns escalate, and only fully-privileged", () => { + // api_foreground escalates only as the admin API principal (runMessage passes a trusted rank for + // it and never for a caller-named author); every daemon-triggered origin never does. for (const origin of RUN_ORIGINS) { - const expected = origin === "slack_foreground"; + const expected = origin === "slack_foreground" || origin === "api_foreground"; assert.equal( mayEscalate({ meta: { adminMode: true }, isAdminAuthor: true, origin }), expected, diff --git a/test/runtime-integration-run.test.js b/test/runtime-integration-run.test.js index 6606850c..42dbcb32 100644 --- a/test/runtime-integration-run.test.js +++ b/test/runtime-integration-run.test.js @@ -424,7 +424,25 @@ test("a per-run API mode narrows tools but never changes what the channel's cont assert.equal(resolved[0].adminMode, true, `${mode}: the runtime target keeps the channel's Admin posture`); assert.equal(operatorHomeGranted({ meta: resolved[0], settings: { fullAccessHome: true } }), true, `${mode}: the mount set is unchanged`); assert.equal(backend.calls.spawn.length, 1); + // The API key is an admin credential: in this Admin channel a Full run escalates exactly like an + // admin's Slack message, and a narrowed mode does not. const flags = backend.calls.spawn[0].args?.join?.(" ") || JSON.stringify(backend.calls.spawn[0]); - assert.doesNotMatch(flags, /--dangerously-skip-permissions/, `${mode}: an API run never escalates`); + if (mode === "full") assert.match(flags, /--dangerously-skip-permissions/, "full: the admin API principal escalates in an Admin channel"); + else assert.doesNotMatch(flags, /--dangerously-skip-permissions/, `${mode}: a narrowed API run never escalates`); } }); + +test("an API run escalates only as the API principal — a caller-named admin author never does", async () => { + saveSettings({ engine: "claude", memoryReviewEvery: 0, composioMode: "personal" }); + await setUser("U_RT_NAMED_ADMIN", { name: "Named Admin", approved: true, isAdmin: true }); + await channel("C_RT_API_ESC", "rt-api-esc", { adminMode: true, allowBash: true }); + const spawned = async (authorId, threadKey) => { + const backend = createFakeRuntimeBackend(); + useBackend(backend); + await runMessage({ channelId: "C_RT_API_ESC", authorId, text: "api escalation", threadKey, untrustedPrincipal: true, origin: "api_foreground", preferCold: true }); + return backend.calls.spawn[0].args.join(" "); + }; + assert.match(await spawned("api", "api:esc-principal"), /--dangerously-skip-permissions/); + assert.doesNotMatch(await spawned("U_RT_NAMED_ADMIN", "api:esc-named"), /--dangerously-skip-permissions/, + "an untrusted capability naming a real admin never borrows that admin's rank"); +}); From b0bf381641e79a66bcaa4811fa24dedcaed9bd4e Mon Sep 17 00:00:00 2001 From: Tiberiu Socaci Date: Fri, 25 Sep 2026 21:36:11 +0300 Subject: [PATCH 3/4] test: pin that API runs get org and channel secrets, skills and folder An API run acts as the admin api principal with no personal scope. Pin the other half: it still receives the organization's and the channel's secrets and skills and runs in the channel's own folder, while the personal secrets and skills of an admin the request names never reach it. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Tiberiu Socaci --- TEST-PLAN.md | 3 ++ test/api-runs-channel-parity.test.js | 44 ++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/TEST-PLAN.md b/TEST-PLAN.md index 065f83b7..a885e982 100644 --- a/TEST-PLAN.md +++ b/TEST-PLAN.md @@ -3244,6 +3244,9 @@ Unit coverage: - [x] A per-run `mode` of read/worker/auto/lean/full on an Admin channel resolves the runtime target with the channel's Admin posture (operator-home grant unchanged) (`test/runtime-integration-run.test.js`). +- [x] An API run naming an admin gets the organization's and the channel's secrets in its + environment and their skills in the channel folder, and works in the channel's own folder — + but never that admin's personal secret or personal skill (`test/api-runs-channel-parity.test.js`). Live acceptance (Claude and Codex each; fixture `qa-api-parity-`: a Slack channel in Worker mode with **Auto on**, channel memory on, one channel skill granted, the shared Composio identity diff --git a/test/api-runs-channel-parity.test.js b/test/api-runs-channel-parity.test.js index 143bf171..a8c8dd55 100644 --- a/test/api-runs-channel-parity.test.js +++ b/test/api-runs-channel-parity.test.js @@ -124,3 +124,47 @@ test("a completed API run reports the container resume command and queues the ch assert.equal(reviews[0].authorId, "api", "the reviewer acts as the API principal, never a caller-named author"); assert.match(await reviews[0].fetchTranscript(), /invoices are due on the 5th[\s\S]*Assistant:/); }); + +test("an API run gets the organization's and the channel's secrets, skills and folder — and nobody's personal scope", async () => { + const { setUser, getChannelMeta } = await import("../src/config/store.js"); + const { patchOrgEnv, patchUserEnv } = await import("../src/config/scoped-env.js"); + const { patchChannelEnv } = await import("../src/config/channel-env.js"); + const { createLocalSkill } = await import("../src/gateway/skills/authoring.js"); + const { existsSync } = await import("node:fs"); + const skill = (slug, personal = false) => createLocalSkill({ slug, createdBy: "U_API_SCOPE_ADMIN", personal, publish: false, + files: [{ path: "SKILL.md", content: `---\nname: ${slug}\ndescription: API scope fixture ${slug}\n---\nbody\n` }] }); + await skill("api-scope-org-skill"); + await skill("api-scope-channel-skill"); + await skill("api-scope-personal-skill", true); + + saveSettings({ engine: "claude", memoryReviewEvery: 0, composioMode: "personal", accessGrants: { skills: ["api-scope-org-skill"] } }); + patchOrgEnv({ set: { name: "API_SCOPE_ORG_SECRET", value: "org-secret-value-123456" }, actor: "U_API_SCOPE_ADMIN" }); + // The caller NAMES an admin who has personal secrets and skills; none of it may reach the run. + await setUser("U_API_SCOPE_ADMIN", { name: "Scope Admin", approved: true, isAdmin: true, skills: ["api-scope-personal-skill"] }); + await patchUserEnv("U_API_SCOPE_ADMIN", { set: { name: "API_SCOPE_PERSONAL_SECRET", value: "personal-secret-value-123456" } }); + + const entry = await channel("C_API_PAR_SCOPE", "api-par-scope", { skills: ["api-scope-channel-skill"] }); + const meta = await getChannelMeta(entry.slug); + await saveChannelMeta(entry.slug, { ...meta, env: patchChannelEnv(meta.env, { set: { name: "API_SCOPE_CHANNEL_SECRET", value: "channel-secret-value-123456" } }) }); + + const backend = createFakeRuntimeBackend(); + const resolved = []; + setRuntimeResolver((slug, m) => { resolved.push(m); return fakeTarget(backend, slug, m); }); + const started = await startApiRun({ message: "list what you can use", channel: "C_API_PAR_SCOPE", author: "U_API_SCOPE_ADMIN", queueMemoryReview: () => null }); + const job = await settled(started.jobId); + assert.equal(job.status, "completed", job.error || ""); + + const spawn = backend.calls.spawn[0]; + assert.equal(spawn.env.API_SCOPE_ORG_SECRET, "org-secret-value-123456", "organization secrets reach an API run"); + assert.equal(spawn.env.API_SCOPE_CHANNEL_SECRET, "channel-secret-value-123456", "channel secrets reach an API run"); + assert.equal(spawn.env.API_SCOPE_PERSONAL_SECRET, undefined, "no personal secret, even of the admin it names"); + + const workDir = resolveRuntime(entry.slug, meta).cwd; + assert.equal(spawn.cwd, workDir, "the run works in the channel's own folder (its files)"); + assert.ok(existsSync(path.join(workDir, ".claude", "skills", "api-scope-org-skill", "SKILL.md")), "organization skills are materialized"); + assert.ok(existsSync(path.join(workDir, ".claude", "skills", "api-scope-channel-skill", "SKILL.md")), "channel skills are materialized"); + // The run's effective grant set is the org + channel (+ author) union; the API run's has no author tier. + const skills = resolved.at(-1).skills.map((s) => String(s).toLowerCase()); + assert.ok(skills.includes("api-scope-org-skill") && skills.includes("api-scope-channel-skill")); + assert.ok(!skills.includes("api-scope-personal-skill"), "the named admin's personal skill never joins an API run"); +}); From 12c24118efc707b241d3fd0be7509eed155222a1 Mon Sep 17 00:00:00 2001 From: Tiberiu Socaci Date: Fri, 25 Sep 2026 21:37:01 +0300 Subject: [PATCH 4/4] Release 0.5.7 Cut the 0.5.7 release section, bump the package and lockfile versions, and record the release decision. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Tiberiu Socaci --- CHANGELOG.md | 2 +- docs/RELEASE-CHECKLIST.md | 7 +++++++ package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 11 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fdc81fab..ae5792d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ product overview. > | Makeitfuture Sustainable Use License 1.1 | 2026-08-20 | never published | > | Makeitfuture Sustainable Use License 1.0 | 2026-08-06 | never published | -## Unreleased +## 0.5.7 — 2026-09-25 - **An HTTP API run now works like an admin's message in its channel.** The run API key is an admin credential. An API run gets the channel's mode as an admin would, including Auto and, in an diff --git a/docs/RELEASE-CHECKLIST.md b/docs/RELEASE-CHECKLIST.md index b40e166d..71f52512 100644 --- a/docs/RELEASE-CHECKLIST.md +++ b/docs/RELEASE-CHECKLIST.md @@ -6,6 +6,13 @@ > CI passed on the exact candidate and a live smoke ran for Claude and Qwen. The owner released it > without Codex live acceptance (the Codex account was usage-limited until 2026-09-24) and without > the full live campaign; both remain open for the next release, see RELEASE-ACCEPTANCE.md. +> 0.5.7 was published on 2026-09-25 by explicit owner decision after the full automated gate and +> CI passed on the exact candidate. It makes an HTTP run API turn behave like an admin's message +> in its channel (the API key is an admin credential acting as the `api` principal, with no +> personal scope), joins API runs to their thread's queue, keeps a per-run mode from rebuilding a +> channel's container and fixes the container resume command. Unit and integration coverage is +> in TEST-PLAN.md ("HTTP run API channel parity"); its live Claude/Codex cases had not run at the +> cut and stay open for the next release. > 0.5.6 was published on 2026-09-25 by explicit owner decision after the full automated gate and > CI passed on the exact candidate. Live on Xavier before the cut (QA-0925, Airtable): the Composio > staging route for consumer keys (FSHARE-02 both engines, after the Codex handoff-rule fix), the diff --git a/package-lock.json b/package-lock.json index c6543219..85f3e3df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "channelgate", - "version": "0.5.6", + "version": "0.5.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "channelgate", - "version": "0.5.6", + "version": "0.5.7", "license": "SEE LICENSE IN LICENSE.md", "dependencies": { "@composio/core": "0.14.0", diff --git a/package.json b/package.json index e3fd833c..977d5997 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "channelgate", - "version": "0.5.6", + "version": "0.5.7", "private": true, "license": "SEE LICENSE IN LICENSE.md", "author": "Tiberiu Socaci (MAKEITFUTURE S.R.L.)",