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 4d8cc602..ae5792d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,26 @@ product overview. > | Makeitfuture Sustainable Use License 1.1 | 2026-08-20 | never published | > | Makeitfuture Sustainable Use License 1.0 | 2026-08-06 | never published | +## 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 + 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. +- 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..3372db48 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -1017,12 +1017,31 @@ 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 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 a1982145..a885e982 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,63 @@ 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 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) + (`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 +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`. +- [ ] **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`. +- [ ] **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/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.)", 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 636a0fdb..dd477ca5 100644 --- a/src/gateway/api-runs.js +++ b/src/gateway/api-runs.js @@ -9,6 +9,12 @@ // 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 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. // Completed output has a separate delivery checkpoint so recovery can resend it without a run. @@ -26,11 +32,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 +50,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 +413,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 +559,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 +571,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 +608,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 +621,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 +668,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 +685,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,10 +721,16 @@ 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, - 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, @@ -690,20 +738,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. - // 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), 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 +773,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 +801,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 +840,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/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 79119696..5fe6f02b 100644 --- a/src/gateway/modes.js +++ b/src/gateway/modes.js @@ -153,6 +153,9 @@ 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 (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; if (isDM) return isAdminUser || isApprovedUser; diff --git a/src/gateway/run.js b/src/gateway/run.js index 69aee22e..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,16 +531,15 @@ 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. 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 = {}; @@ -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,16 @@ 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 + // 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 +796,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 @@ -1040,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. @@ -1099,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 40cca4e3..3c1e7e11 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,17 @@ 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 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; const claimedEngine = ENGINE_CLAIMS.includes(claims.engine) ? claims.engine : ""; const activeEngine = claimedEngine || (ENGINE_CLAIMS.includes(engine) ? engine : getDefaultEngine()); @@ -138,9 +145,12 @@ 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 (!principalTrusted || !createdBy) return false; + if (!createdBy) return false; + if (apiPrincipal) return true; const meta = await loadMeta(); return canManage(meta || {}, { authorId: createdBy, @@ -150,7 +160,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 +177,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 +394,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..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,8 +90,11 @@ 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 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 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()) || {}; 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..4738b0b0 100644 --- a/src/slack/approvals.js +++ b/src/slack/approvals.js @@ -7,9 +7,9 @@ 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, 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: [ @@ -203,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 []; @@ -245,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/api-runs-channel-parity.test.js b/test/api-runs-channel-parity.test.js new file mode 100644 index 00000000..a8c8dd55 --- /dev/null +++ b/test/api-runs-channel-parity.test.js @@ -0,0 +1,170 @@ +// 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:/); +}); + +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"); +}); 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 a8e41a5d..b3d02efb 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,68 @@ 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 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", 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("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.doesNotMatch(resultText(folders), /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); }); + 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/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 eca96a9f..42dbcb32 100644 --- a/test/runtime-integration-run.test.js +++ b/test/runtime-integration-run.test.js @@ -405,3 +405,44 @@ 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); + // 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]); + 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"); +});