From 62cba4ac8cd64a34e612056f7be2929a429044d8 Mon Sep 17 00:00:00 2001 From: Tiberiu Socaci Date: Thu, 1 Oct 2026 12:56:11 +0300 Subject: [PATCH 001/139] feat(mcp): add custom MCP connections by URL and Bearer token Admins can add a remote MCP server (name, HTTPS URL, Bearer token) to a conversation or to one person from the admin UI. Runs receive the conversation's servers as custom- and the author's own as my-, in Claude and Codex turns, fallback and SSH sessions; Lean gets none and the run API gets no personal ones. In a container the daemon relays them like the Make toolbox, so the token never enters the container. URLs are checked against the SSRF policy at save and on every relay dial, pinned to the vetted addresses through a dedicated agent that never uses an env proxy, and redirects are not followed. Tokens are write-only; replacing or removing a server drops only that server from live relay grants. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Tiberiu Socaci --- AGENTS.md | 4 +- CHANGELOG.md | 6 + FEATURES.md | 40 +++ TEST-PLAN.md | 72 +++++ public/admin-custom-mcps.js | 164 ++++++++++ public/app.js | 27 ++ public/index.html | 12 + public/styles.css | 5 + src/engines/adapters.js | 2 +- src/engines/codex.js | 27 +- src/gateway/custom-mcp-store.js | 71 +++++ src/gateway/custom-mcps.js | 211 +++++++++++++ .../references/administration.md | 6 + src/gateway/mcp.js | 13 +- src/gateway/run-grant-artifacts.js | 4 + src/gateway/run-integrations.js | 13 +- src/gateway/run.js | 18 +- src/gateway/ssh-session.js | 15 +- src/mcp/public-fetch.js | 77 +++++ src/mcp/remote-mcp-registry.js | 28 +- src/mcp/remote-relay.js | 11 +- src/mcp/socket-server.js | 1 + src/mcp/tools/channel-admin.js | 7 +- src/web/routes/channels.js | 55 ++++ src/web/routes/users.js | 46 +++ test/custom-mcps.test.js | 282 ++++++++++++++++++ test/mcp-control-plane-approval.test.js | 14 + test/progress-report-availability.test.js | 9 +- 28 files changed, 1212 insertions(+), 28 deletions(-) create mode 100644 public/admin-custom-mcps.js create mode 100644 src/gateway/custom-mcp-store.js create mode 100644 src/gateway/custom-mcps.js create mode 100644 src/mcp/public-fetch.js create mode 100644 test/custom-mcps.test.js diff --git a/AGENTS.md b/AGENTS.md index 7d5452ff..c2d928af 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -112,7 +112,9 @@ post/edit the reply in the thread (degraded to the surface's capabilities) → u and TTL), `composio-user` (the author's personal token only), `composio-agent` (channel token → organization default; suppressed in DMs), the toolbox/Make servers when a token resolves, then — through the engine adapter — the channel/organization-selected catalog servers ("Cloud MCP") - and plugin-package servers mapped onto explicitly selected connections. Lean injects nothing. + and plugin-package servers mapped onto explicitly selected connections, plus the custom MCP + connections people add by URL + Bearer token (`custom-mcps.js` rules, `custom-mcp-store.js` + writes; relayed with a per-dial public-address check, `src/mcp/public-fetch.js`). Lean injects nothing. Slack beyond the gateway's own bot tools is the **Composio** Slack toolkit — there is no separate hosted Slack MCP. Composio SDK mode is Enterprise-only (`src/ee/`). - `src/gateway/modes.js` — the three base modes `read` (Read-only), `worker` (shell + file diff --git a/CHANGELOG.md b/CHANGELOG.md index f5e36e5b..6f09d8bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,12 @@ product overview. ## Unreleased +- **Custom MCP servers.** An admin can add a remote MCP server by URL and Bearer token to a + conversation (Connections → *Custom MCP servers*, reached as `custom-`) or to one person + (*Personal MCP servers*, reached as `my-` in that person's runs only). Claude and Codex + both receive them. The gateway connects on the agent's behalf, so the token never enters the + container. URLs must be HTTPS and public, checked at save and on every connection. Tokens are + write-only. Servers that need OAuth still go through Composio. - ChatGPT sign-in in channel settings now displays the device link and complete code from current Codex CLI output. The sign-in and API key buttons align without wrapping; proxied hosts pass the public TLS CA bundle to the login process. diff --git a/FEATURES.md b/FEATURES.md index 0b5315e6..903405ef 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -1,5 +1,45 @@ # ChannelGate — Features +## Custom MCP connections (Bearer token) + +- An admin can add a remote MCP server by **name, HTTPS URL and Bearer token** to a conversation + (admin UI → conversation → Connections → *Custom MCP servers*) or to one person (Users → the + person → *Personal MCP servers*). Each scope holds at most five. OAuth-only servers are out of + scope and go through Composio. +- Runs receive a conversation's servers as `custom-` and the AUTHOR's own as `my-`, in + Claude and Codex turns (fresh, resumed, cross-engine fallback) and in SSH sessions. The prefixes + keep them apart from each other, the built-in servers and Cloud MCP selections. Lean injects + none. The HTTP run API, whose author is not authenticated, gets the conversation's servers but + never a personal one. Claude's per-run lockdown admits each server by name and URL and + pre-approves its tools, as for the other gateway-injected remotes. A container run's settings list + the name only. The URL is listed only for a host turn, because a URL may carry a query-string key. + Like the Make toolbox, the daemon relays the server whatever the conversation's network switch + says. +- **The token never enters the container.** As with the Make toolbox, the daemon registers URL + + `Authorization: Bearer …` in the in-memory relay registry, and the container gets only the + socket bridge and signed capability. Codex's container bundle holds only the capability. A + direct-host `/sudo` turn dials the server itself (Claude: header in the 0600 MCP config; Codex: + a headers helper reading the 0600 bundle). +- **Public addresses only, checked twice.** A save refuses non-HTTPS URLs, embedded credentials, + fragments, and any host that resolves to loopback, private, link-local or metadata addresses. + The daemon relay sits outside the egress proxy, so each dial repeats the check and pins the + connection to the vetted records (`src/mcp/public-fetch.js`). It uses its own agent, never an + env proxy, and never follows a redirect. +- **Write-only.** Listings (`GET /api/channels`, `/api/users`, the per-scope `custom-mcps` + routes) return name, server name, URL, last4 and who/when. There is no reveal path; replace a + token by entering a new one. Saving an existing name without a token keeps the stored token. +- Replacing or removing a server drops that one server from every live relay grant in its scope + (`revokeRemoteMcpServer`). The old token stops working at the next relayed request; the turn's + other servers keep running. Tokens are redacted from replies, the live stream and job output, + and a rotation retires a warm Claude process through the relay digest. Audit events + `channel_custom_mcp_set/_removed` and `user_custom_mcp_set/_removed` carry the name and URL host, + never the token. +- The gateway tool `list_channel_mcps` also lists the conversation's custom server NAMES, never a + URL or token. The `gateway-usage` guide tells the agent these are added by people in the admin UI, never by + writing MCP config files, which the engines ignore (`--setting-sources ""`, + `--strict-mcp-config`, Codex `--ignore-user-config`). + → TEST-PLAN: Custom MCP connections. + ## Channel-specific Codex authentication - Runtime settings ask which Codex login a channel uses before showing setup. The shared view diff --git a/TEST-PLAN.md b/TEST-PLAN.md index 9c507c42..ce46d4aa 100644 --- a/TEST-PLAN.md +++ b/TEST-PLAN.md @@ -1,5 +1,77 @@ # ChannelGate — Test Plan +## Custom MCP connections (2026-10-01) + +### Regression +- [x] Automated: `node --test test/custom-mcps.test.js`. + - Name, URL and token rules. The save-time address check refuses loopback, `localhost`, + metadata, private and mixed public/private resolutions. + - Upsert keeps the token on a URL-only update; the five-entry cap; remove. The masked listing + never contains the token. + - Run resolution: `custom-`/`my-` prefixes; no personal servers for an untrusted principal; + none in Lean. + - Container payload: a relay entry, the claim names the servers, and the registry holds the + Bearer header with `publicOnly`; no token in the config JSON; a relay digest per server. + - Host payload: plain http entries. + - Claude lockdown admission by name + URL with `mcp__` pre-approved. + - Codex: relay in a container, no custom token in the container bundle; headers helper on the + sudo host; none when clean. + - Pinned fetch: vetted records only, a dedicated agent, the auth header passed, a redirect + returned rather than followed, refusal propagated. + - The relay requests the public-only dial only for `publicOnly` registrations. + `revokeRemoteMcpServer` removes one server from matching grants only. + - Admin routes: channel and user PUT/GET/DELETE; tokens absent from `/channels`, `/users` and + the per-scope listings; 400 for loopback and plain-http URLs; URL-only update keeps the token; + 404 for an unknown channel. +- [x] Regression neighbours: `test/mcp-config.test.js`, `test/codex-args.test.js`, + `test/remote-mcp-registry.test.js`, `test/mcp-remote-relay.test.js`, + `test/mcp-socket-server.test.js`, `test/ssh-session.test.js`, `test/folders-settings.test.js`, + `test/make-toolbox*.test.js`, `test/admin-ui-controls.test.js`, `test/channel-env.test.js`. +- [x] Smoke (engine-independent transport, 2026-10-01): a local TLS MCP server built with the SDK's + `StreamableHTTPServerTransport`, dialled by `connectRemoteClient` through + `createPublicPinnedFetch` with resolve pinned to 127.0.0.1. Result: `tools/list` returned + `echo`, `tools/call` returned `echo:hi`, and the server saw `Authorization: Bearer smoke-token`. + The first attempt exposed that the global agent tunnels through `HTTPS_PROXY`; the fetch now + uses its own agent. + +- [x] Automated: `test/mcp-control-plane-approval.test.js` — `list_channel_mcps` names + `custom-linear` and contains neither the URL (with a query-string key) nor the token; + `test/progress-report-availability.test.js` source-shape check of the run.js/adapters plumbing. +- [x] Browser (engine-independent UI, 2026-10-01, headless Chromium against a scratch + `createWebApp` harness): Conversations → MCP Connections → *Custom MCP servers*. + - A seeded row renders as `custom-linear` with the URL and `••••ABCD`. + - Adding `https://127.0.0.1:4791/api` shows "URL refused: refuses to reach a loopback address". + - Adding `Notes` at a public literal IP saves as `custom-notes` ("2 set") and clears the token + box. + - Edit → a URL-only change keeps the stored token (checked in the store). + - Remove → confirm leaves "1 set". + - The user drawer's *Personal MCP servers* saves `my-mine`. + - Two layout bugs found and fixed: a long URL clipped the action buttons, and the drawer's + `.state { display:block }` broke table cells. + +### Live gates (Claude AND Codex) — UNEXECUTED +Fixture: a disposable conversation in Worker mode, network on. A public MCP server that accepts a +static Bearer token and exposes a harmless read tool, e.g. a disposable deployment of the SDK's +example server behind HTTPS on a public host. Record the token's last four characters. +- [ ] Channel scope: admin UI → the conversation → Connections → *Custom MCP servers*, add name + `qa`, the URL and the token. Pass: the row shows `custom-qa`, the URL and `Bearer ••••`; + `GET /api/channels` contains no token. Ask: "List the tools of your custom-qa server and call + the read tool." Pass, for Claude and again for Codex (`codex …` directive): the reply names + `mcp__custom-qa__…` tools and the tool result. No approval card in Worker mode. No token in the + reply, the thread or `docker/podman inspect` of the channel container; `grep -r ` over + the channel's artifact dir finds nothing. +- [ ] Personal scope: Users → the QA author → *Personal MCP servers*, add `qa2`. Pass: the author's + turn sees `my-qa2`. A different approved user's turn in the same conversation does not. A + `POST /api/runs` turn in that conversation does not. +- [ ] Refusal: adding `https://127.0.0.1:4747/`, `https://169.254.169.254/` or a hostname + resolving to a private address is refused with "URL refused…"; `http://` is refused. +- [ ] Rotation and removal: during a long turn, replace the token, then remove the server. Pass: + the next relayed request fails with `remote MCP is not authorized for this run`, Composio in the + same turn keeps working, and the next turn has the new token or no server. +- [ ] Lean: enable Lean. Pass: neither `custom-qa` nor `my-qa2` appears in either engine. +- [ ] SSH (engine-independent wiring, both CLIs): open an SSH session as the QA author; `claude` + and `codex` inside list `custom-qa` and `my-qa2`. + ## Channel-specific Codex authentication (2026-09-29) - [x] Automated: current colored Codex CLI device output yields the complete code and approved diff --git a/public/admin-custom-mcps.js b/public/admin-custom-mcps.js new file mode 100644 index 00000000..789add36 --- /dev/null +++ b/public/admin-custom-mcps.js @@ -0,0 +1,164 @@ +// One custom MCP connection editor, shared by the conversation scope (channel card → Connections) +// and the personal scope (user drawer) — see src/gateway/custom-mcps.js. +// +// A custom MCP is a remote server URL plus a Bearer token. The token is write-only like an +// environment secret: the list shows name, the server name the agent sees, the URL and the last +// four characters, there is no reveal call, and the token box is cleared the moment it is stored. +// Saving an existing name with the token box empty keeps the stored token (a URL change). +import { api } from "./admin-api.js"; +import { confirmDialog } from "./admin-view.js"; + +// `endpoint(name)` builds the per-server URL; PUT/DELETE hang off it and answer with the scope's +// full masked list. +export function mountCustomMcpEditor({ + root, + endpoint, + servers = [], + emptyText = "No custom MCP servers.", + removeBody = "Runs stop receiving this server. The token can't be recovered — you would have to issue a new one.", + savedText = (serverName) => `Saved. Its tools reach the next run as mcp__${serverName}__…`, + userName = () => "", +}) { + const list = root.querySelector(".cmcp-list"); + const state = root.querySelector(".cmcp-state"); + const hint = root.querySelector(".cmcp-hint"); + const nameInput = root.querySelector(".cmcp-name"); + const urlInput = root.querySelector(".cmcp-url"); + const tokenInput = root.querySelector(".cmcp-token"); + const saveButton = root.querySelector(".cmcp-save"); + let current = Array.isArray(servers) ? servers : []; + + const render = () => { + if (state) state.textContent = current.length ? `${current.length} set` : "none"; + list.textContent = ""; + if (current.length === 0) { + const empty = document.createElement("em"); + empty.className = "state"; + empty.textContent = emptyText; + list.appendChild(empty); + return; + } + const wrap = document.createElement("div"); + wrap.className = "utable vars-table-wrap"; + const table = document.createElement("table"); + table.className = "vars-table"; + const head = table.createTHead().insertRow(); + for (const label of ["Server", "URL", "Token", "Updated", ""]) { + const th = document.createElement("th"); + th.textContent = label; + head.appendChild(th); + } + const body = table.createTBody(); + for (const entry of current) { + const row = body.insertRow(); + const cell = (content, { className = "", title = "" } = {}) => { + const td = row.insertCell(); + if (className) td.className = className; + if (title) td.title = title; + if (content instanceof Node) td.appendChild(content); else td.textContent = content; + return td; + }; + const name = document.createElement("code"); + name.textContent = entry.serverName; + cell(name, { title: `Stored as "${entry.name}"` }); + cell(entry.url, { className: "cmcp-url-cell", title: entry.url }); + cell(`••••${entry.tokenLast4 || ""}`, { className: "state", title: "Bearer token (write-only)" }); + // Who set it rides in the tooltip: the card is narrow, and the URL needs the room. + const who = String(entry.setBy || ""); + const id = /^<@([A-Z0-9]+)>$/.exec(who)?.[1] || (/^[UW][A-Z0-9]+$/.test(who) ? who : ""); + cell(entry.setAt ? new Date(entry.setAt).toISOString().slice(0, 10) : "—", { className: "state", title: who ? `Set by ${id ? (userName(id) || id) : who}` : "" }); + const actions = row.insertCell(); + actions.className = "vars-actions"; + const edit = document.createElement("button"); + edit.type = "button"; + edit.className = "ghost cmcp-edit"; + edit.textContent = "Edit"; + edit.title = "Change the URL or replace the token"; + edit.addEventListener("click", () => { + nameInput.value = entry.name; + urlInput.value = entry.url; + tokenInput.value = ""; + tokenInput.placeholder = "leave blank to keep the current token"; + hint.textContent = `Editing ${entry.serverName}.`; + urlInput.focus(); + }); + actions.appendChild(edit); + const remove = document.createElement("button"); + remove.type = "button"; + remove.className = "ghost cmcp-remove"; + remove.textContent = "Remove"; + remove.addEventListener("click", async () => { + const ok = await confirmDialog({ title: `Remove ${entry.serverName}?`, body: removeBody, confirmLabel: "Remove", danger: true }); + if (!ok) return; + remove.disabled = true; + try { + const result = await api(endpoint(entry.name), { method: "DELETE" }); + current = result.servers || []; + hint.textContent = `Removed ${entry.serverName}.`; + render(); + } catch (e) { + remove.disabled = false; + hint.textContent = e.message || "Couldn't remove that server."; + } + }); + actions.appendChild(remove); + } + wrap.appendChild(table); + list.appendChild(wrap); + }; + render(); + + // Server names are lowercase-with-dashes everywhere they are shown; fold as typed. + nameInput.addEventListener("input", () => { + const folded = nameInput.value.toLowerCase(); + if (folded !== nameInput.value) nameInput.value = folded; + tokenInput.placeholder = current.some((entry) => entry.name === folded.trim()) + ? "leave blank to keep the current token" + : "Bearer token — stored, never shown again"; + }); + + saveButton.addEventListener("click", async () => { + const name = nameInput.value.trim().toLowerCase(); + const url = urlInput.value.trim(); + const token = tokenInput.value.trim(); + const existing = current.some((entry) => entry.name === name); + if (!name || !url || (!existing && !token)) { + hint.textContent = existing ? "A name and a URL are required." : "A name, a URL and a token are required."; + return; + } + saveButton.disabled = true; + hint.textContent = "checking and saving…"; + try { + const result = await api(endpoint(name), { method: "PUT", body: JSON.stringify({ url, ...(token ? { token } : {}) }) }); + current = result.servers || []; + const saved = current.find((entry) => entry.name === name); + nameInput.value = ""; + urlInput.value = ""; + tokenInput.value = ""; + tokenInput.placeholder = "Bearer token — stored, never shown again"; + hint.textContent = savedText(saved?.serverName || name); + render(); + } catch (e) { + tokenInput.value = ""; + hint.textContent = e.message || "Couldn't save that server."; + } finally { + saveButton.disabled = false; + } + }); + + return { setServers(next) { current = Array.isArray(next) ? next : []; render(); } }; +} + +export function customMcpEditorMarkup() { + const root = document.createElement("div"); + root.innerHTML = ` +
+
+ + + + +
+ `; + return root; +} diff --git a/public/app.js b/public/app.js index c221ab4d..b58197b9 100644 --- a/public/app.js +++ b/public/app.js @@ -21,6 +21,7 @@ import { loadSkills } from "./admin-skills.js"; import { mountSkillAssignmentPicker } from "./skill-assignment-picker.js"; import { mountUserPicker } from "./admin-user-picker.js"; import { mountSecretEditor, secretEditorMarkup } from "./admin-secrets.js"; +import { customMcpEditorMarkup, mountCustomMcpEditor } from "./admin-custom-mcps.js"; import { describeEvent, eventLabel, isAdminEvent } from "./admin-events.js"; // ── Inline SVG icon ───────────────────────────────────────────────────────────── @@ -2222,6 +2223,19 @@ function renderChannelDetail(ch) { savedText: (name) => `Saved ${name}. It reaches the next run in this channel.`, }); + // Custom MCP servers (gateway/custom-mcps.js): saved immediately like the env secrets above, + // never round-tripped by the card's Save button, token write-only. + const customMcpCard = card.querySelector(".custom-mcp-card"); + customMcpCard.querySelector(".ch-custom-mcps").appendChild(customMcpEditorMarkup()); + mountCustomMcpEditor({ + root: customMcpCard, + servers: Array.isArray(meta.customMcps) ? meta.customMcps : [], + userName: (id) => USERS[id]?.name || "", + endpoint: (name) => `/api/channels/${encodeURIComponent(ch.channelId)}/custom-mcps/${encodeURIComponent(name)}`, + emptyText: "No custom MCP servers — runs here get the built-in connections and any Cloud MCP selected below.", + removeBody: "Runs in this conversation stop receiving this server, including any run in progress. The token can't be recovered.", + }); + const makeToolboxUrlInput = card.querySelector(".ch-make-toolbox-url"); const makeToolboxKeyInput = card.querySelector(".ch-make-toolbox-key"); const makeToolboxState = card.querySelector(".ch-make-toolbox-state"); @@ -3086,6 +3100,19 @@ function openUserDrawer(id) { // This person's OWN environment secrets (config/scoped-env.js). Mounted on the row rather than // folded into the Save button: a secret write is one blind overwrite that must not ride along // with an unrelated profile save, and the endpoints are per-variable for the same reason. + // This person's OWN custom MCP servers (gateway/custom-mcps.js) — same immediate, write-only + // contract as their secrets below. + const customMcpRow = drawer.querySelector(".ud-custom-mcps-row"); + customMcpRow.querySelector(".ud-custom-mcps").appendChild(customMcpEditorMarkup()); + mountCustomMcpEditor({ + root: customMcpRow, + servers: u.customMcps || [], + userName: (uid) => USERS[uid]?.name || "", + endpoint: (name) => `/api/users/${encodeURIComponent(id)}/custom-mcps/${encodeURIComponent(name)}`, + emptyText: "No personal MCP servers.", + removeBody: "This person's runs stop receiving this server everywhere, including any run in progress. The token can't be recovered.", + }); + const secretsRow = drawer.querySelector(".ud-secrets-row"); secretsRow.querySelector(".ud-secrets").appendChild(secretEditorMarkup()); mountSecretEditor({ diff --git a/public/index.html b/public/index.html index 9adc1e30..5adcee34 100644 --- a/public/index.html +++ b/public/index.html @@ -1229,6 +1229,12 @@

Make MCP toolbox

+
+

Custom MCP servers

+

Remote MCP servers every run in this conversation receives, each as custom-<name>, authenticated with a Bearer token (servers that need an OAuth sign-in go through Composio). HTTPS only, public addresses only. The gateway connects on the agent's behalf, so the token never enters the conversation's container. Saved and removed immediately; write-only — a stored token is never shown again.

+ +
+
@@ -1448,6 +1454,12 @@

+
+ +

Remote MCP servers injected as my-<name> only into runs this person authors, in every conversation they talk in. Bearer token only (OAuth servers go through Composio); HTTPS, public addresses only. Saved and removed immediately, separately from the Save button below. Write-only: a stored token is never shown again.

+
+
+

Environment variables injected only into runs this person authors, in every conversation they talk in — never into anyone else's turn in those same conversations. A conversation's own secret of the same name still wins there. Saved and removed immediately, separately from the Save button below. Write-only: a stored value is never shown again.

diff --git a/public/styles.css b/public/styles.css index 89fbf851..43e64321 100644 --- a/public/styles.css +++ b/public/styles.css @@ -678,6 +678,9 @@ button.clear-tok.armed { background: rgba(229, 96, 77, .14); border-color: rgba( .vars-table td.state { color: var(--faint); font-style: normal; white-space: nowrap; } .vars-table .vars-secret, .vars-table .vars-readable { white-space: nowrap; } .vars-table .vars-actions { text-align: right; width: 1%; white-space: nowrap; } +/* Custom MCP servers (public/admin-custom-mcps.js): a long URL truncates (full value in the title) + so the row's Edit/Remove actions stay on screen. */ +.vars-table td.cmcp-url-cell { max-width: 0; width: 40%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .secret-add { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } .secret-add input { flex: 1 1 220px; min-width: 0; box-sizing: border-box; background: var(--panel-2); border: 1px solid var(--line); color: var(--ink); padding: 9px 12px; border-radius: 8px; font: inherit; } .secret-add .secret-save { flex: none; } @@ -746,6 +749,8 @@ button.clear-tok.armed { background: rgba(229, 96, 77, .14); border-color: rgba( .drawer .ud-name { width: 100%; box-sizing: border-box; background: var(--panel-2); border: 1px solid var(--line); color: var(--ink); padding: 9px 12px; border-radius: 8px; font: inherit; } .drawer .ud-row .state { display: block; font-size: 11px; color: var(--faint); margin-top: 4px; } .drawer .ud-row .fldlab .state { display: inline; margin: 0 0 0 7px; text-transform: none; letter-spacing: 0; font-weight: 400; } +/* …but a .state TABLE CELL (the custom MCP and secret tables) must stay a cell. */ +.drawer .ud-row .vars-table td.state { display: table-cell; margin-top: 0; font-size: inherit; } .drawer-foot { display: flex; align-items: center; gap: 12px; margin-top: 16px; } .drawer .tokchip .empty { color: var(--faint); } diff --git a/src/engines/adapters.js b/src/engines/adapters.js index f54af80a..74c3b329 100644 --- a/src/engines/adapters.js +++ b/src/engines/adapters.js @@ -344,7 +344,7 @@ const codex = validateEngineAdapter({ for (const server of codexMcpPolicy.servers) { if (server.enabled && !server.definition) server.enabled = false; } - return runCodex({ cwd: ctx.cwd, prompt: ctx.prompt, extraEnv: r.channelEnv, browserNamespace: r.browserNamespace, sessionId: ctx.session.id, isNewSession: ctx.session.fresh, dangerouslySkip: r.dangerouslySkip, writable: r.writable, networkMode: ctx.policy.network.mode, clean: r.clean, autoApprove: r.autoApprove, composioUserEndpoint: r.composioUserEndpoint, composioEndpoint: r.composioEndpoint, composioUserToken: r.composioUserToken, composioToken: r.composioToken, toolboxToken: r.toolboxToken, makeToolboxUrl: r.makeToolboxUrl, makeToolboxKey: r.makeToolboxKey, codexMcpPolicy, gatewayCapability: r.gatewayCapability, gatewayFsRoot: r.gatewayFsRoot, gatewayWorkspaceRoot: r.gatewayWorkspaceRoot, progressReport: r.progressReport, model: r.model, effort: r.effort, codexStateDir: r.codexStateDir, personalSkills: r.personalSkillCatalog, pluginSkills: requirePluginRuntime(r.pluginRuntime, this.id).skills, attachments: r.attachments, target, artifactDir: ctx.artifactDir ?? target.artifactDir ?? null, signal: r.signal, timeoutMs: r.timeoutMs, maxSilenceMs: r.maxSilenceMs, onDelta: r.onDelta, onEvent: r.onEvent, onSessionResolved: r.onSessionResolved }); + return runCodex({ cwd: ctx.cwd, prompt: ctx.prompt, extraEnv: r.channelEnv, browserNamespace: r.browserNamespace, sessionId: ctx.session.id, isNewSession: ctx.session.fresh, dangerouslySkip: r.dangerouslySkip, writable: r.writable, networkMode: ctx.policy.network.mode, clean: r.clean, autoApprove: r.autoApprove, composioUserEndpoint: r.composioUserEndpoint, composioEndpoint: r.composioEndpoint, composioUserToken: r.composioUserToken, composioToken: r.composioToken, toolboxToken: r.toolboxToken, makeToolboxUrl: r.makeToolboxUrl, makeToolboxKey: r.makeToolboxKey, customMcps: r.customMcps, codexMcpPolicy, gatewayCapability: r.gatewayCapability, gatewayFsRoot: r.gatewayFsRoot, gatewayWorkspaceRoot: r.gatewayWorkspaceRoot, progressReport: r.progressReport, model: r.model, effort: r.effort, codexStateDir: r.codexStateDir, personalSkills: r.personalSkillCatalog, pluginSkills: requirePluginRuntime(r.pluginRuntime, this.id).skills, attachments: r.attachments, target, artifactDir: ctx.artifactDir ?? target.artifactDir ?? null, signal: r.signal, timeoutMs: r.timeoutMs, maxSilenceMs: r.maxSilenceMs, onDelta: r.onDelta, onEvent: r.onEvent, onSessionResolved: r.onSessionResolved }); }, interrupt: () => false, discoverMcps: () => listEngineMcps("codex"), diff --git a/src/engines/codex.js b/src/engines/codex.js index d8b6fa18..97ea5f6e 100644 --- a/src/engines/codex.js +++ b/src/engines/codex.js @@ -107,9 +107,18 @@ process.stdout.write(JSON.stringify({ [HEADER]: PREFIX + value })); // MCP credentials are relayed by the daemon (see addSecretRemote), and the artifact dir the bundle // lives in is readable by every process in the container. A sudo-host turn keeps the tokens its // headers helpers read. -export function codexSecretBundle({ isolated = false, gatewayCapability = "", composioUserToken = "", composioToken = "", toolboxToken = "", makeToolboxKey = "" } = {}) { +export function codexSecretBundle({ isolated = false, gatewayCapability = "", composioUserToken = "", composioToken = "", toolboxToken = "", makeToolboxKey = "", customMcps = {} } = {}) { if (isolated) return { gatewayCapability }; - return { gatewayCapability, composioUserToken, composioToken, toolboxToken, makeToolboxKey }; + const bundle = { gatewayCapability, composioUserToken, composioToken, toolboxToken, makeToolboxKey }; + for (const [name, entry] of Object.entries(customMcps || {})) { + if (entry?.token) bundle[customMcpSecretName(name)] = entry.token; + } + return bundle; +} + +// The bundle key a custom MCP's token is stored under for its headers helper (a sudo-host turn). +export function customMcpSecretName(serverName) { + return `customMcp:${serverName}`; } // Names that can ride in a `-c` dotted key path. TOML bare keys are exactly [A-Za-z0-9_-]; anything @@ -656,7 +665,7 @@ export function codexSpawnDropsCapabilities({ target = null, cwd = "", dangerous } // Build `codex exec` argv. `outFile` receives the final agent message (authoritative content). -export function buildCodexArgs({ prompt, sessionId, isNewSession, cwd, dangerouslySkip, writable = false, networkMode = "off", clean = false, autoApprove = false, composioUserEndpoint = null, composioEndpoint = null, composioUserToken = "", composioToken = "", toolboxToken = "", makeToolboxUrl = "", makeToolboxKey = "", secretBundlePath = "", codexMcpPolicy = null, gatewayCapability = "", gatewayFsRoot = "", gatewayWorkspaceRoot = "", progressReport = false, model = "", effort = "", personalSkills = null, pluginSkills = null, attachments = [], target = null, outFile, headerHelpers = [] }) { +export function buildCodexArgs({ prompt, sessionId, isNewSession, cwd, dangerouslySkip, writable = false, networkMode = "off", clean = false, autoApprove = false, composioUserEndpoint = null, composioEndpoint = null, composioUserToken = "", composioToken = "", toolboxToken = "", makeToolboxUrl = "", makeToolboxKey = "", customMcps = {}, secretBundlePath = "", codexMcpPolicy = null, gatewayCapability = "", gatewayFsRoot = "", gatewayWorkspaceRoot = "", progressReport = false, model = "", effort = "", personalSkills = null, pluginSkills = null, attachments = [], target = null, outFile, headerHelpers = [] }) { const runtimeTarget = runtimeTargetOr(target, cwd); const isolated = isIsolatedTarget(runtimeTarget); const helper = (name) => runtimeTarget.runtime.helperCommand(runtimeTarget, name); @@ -872,6 +881,13 @@ export function buildCodexArgs({ prompt, sessionId, isNewSession, cwd, dangerous addSecretRemote("make-toolbox", makeToolboxUrl, "makeToolboxKey", "Authorization", "Bearer ", { Authorization: `Bearer ${makeToolboxKey}` }); } + // Custom MCP connections (src/gateway/custom-mcps.js): `custom-` / `my-`, both bare + // TOML keys by construction. Relayed by the daemon in a container exactly like the toolboxes. + for (const [name, entry] of Object.entries(clean ? {} : customMcps || {})) { + if (!entry?.url || !entry?.token || !BARE_TOML_KEY.test(name)) continue; + addSecretRemote(name, entry.url, customMcpSecretName(name), "Authorization", "Bearer ", { Authorization: `Bearer ${entry.token}` }); + } + // Prompt must come before image flags: Codex's `-i/--image ...` option is variadic, so any // positional after the last `-i` is consumed as another image and the CLI exits with no prompt. args.push(argvSafePrompt((clean ? "" : codexPersonalSkillPrefix(personalSkills)) @@ -982,6 +998,7 @@ export async function runCodex({ toolboxToken = "", makeToolboxUrl = "", makeToolboxKey = "", + customMcps = {}, codexMcpPolicy = null, gatewayCapability = "", gatewayFsRoot = "", @@ -1072,7 +1089,7 @@ export async function runCodex({ const scratchDir = await mkdtemp(path.join(scratchBase, "run-")); const outFile = path.join(scratchDir, `cg-codex-${randomUUID()}.txt`); const secretDir = artifactDir ? path.join(artifactDir, "run") : runTmpDir(); - const bundle = codexSecretBundle({ isolated, gatewayCapability, composioUserToken, composioToken, toolboxToken, makeToolboxKey }); + const bundle = codexSecretBundle({ isolated, gatewayCapability, composioUserToken, composioToken, toolboxToken, makeToolboxKey, customMcps }); const secretBundlePath = !clean && Object.values(bundle).some(Boolean) ? path.join(secretDir, `cg-codex-secrets-${randomUUID()}.json`) : ""; @@ -1084,7 +1101,7 @@ export async function runCodex({ // pure argv builder. They carry no credential of their own — each one reads its entry out of the // 0600 bundle above — but they are still per-run files, created and removed with it. const headerHelpers = []; - const args = buildCodexArgs({ prompt, sessionId, isNewSession, cwd, dangerouslySkip, writable, networkMode, clean, autoApprove, composioUserEndpoint, composioEndpoint, composioUserToken, composioToken, toolboxToken, makeToolboxUrl, makeToolboxKey, secretBundlePath, codexMcpPolicy, gatewayCapability, gatewayFsRoot, gatewayWorkspaceRoot, progressReport, model, effort, codexStateDir, personalSkills, pluginSkills, attachments, target: runtime, outFile, headerHelpers }); + const args = buildCodexArgs({ prompt, sessionId, isNewSession, cwd, dangerouslySkip, writable, networkMode, clean, autoApprove, composioUserEndpoint, composioEndpoint, composioUserToken, composioToken, toolboxToken, makeToolboxUrl, makeToolboxKey, customMcps, secretBundlePath, codexMcpPolicy, gatewayCapability, gatewayFsRoot, gatewayWorkspaceRoot, progressReport, model, effort, codexStateDir, personalSkills, pluginSkills, attachments, target: runtime, outFile, headerHelpers }); for (const spec of headerHelpers) { await writeFile(spec.path, headerHelperSource({ ...spec, bundlePath: secretBundlePath }), { mode: 0o700 }); } diff --git a/src/gateway/custom-mcp-store.js b/src/gateway/custom-mcp-store.js new file mode 100644 index 00000000..b5c99d67 --- /dev/null +++ b/src/gateway/custom-mcp-store.js @@ -0,0 +1,71 @@ +// The write side of custom MCP connections (src/gateway/custom-mcps.js holds the rules): add, +// update and remove one entry on a channel or on a person, then cut that one server out of every +// live relay grant so a replaced or removed token stops working at the relay's next request. +// Every surface (the admin UI routes today) goes through here, so validation, the public-address +// check and the audit line cannot differ between them. Audit rows carry the name and the URL's +// host — never the token. +import { getUser, patchChannelMeta, setUser } from "../config/store.js"; +import { logEvent } from "../util/logger.js"; +import { revokeRemoteMcpServer } from "../mcp/remote-mcp-registry.js"; +import { + assertPublicCustomMcpUrl, + customMcpServerName, + listCustomMcps, + normalizeCustomMcpName, + patchCustomMcps, +} from "./custom-mcps.js"; + +function hostOf(url) { + try { return new URL(url).host; } catch { return ""; } +} + +/** Add or update one of a channel's custom MCP servers. Returns the masked list. */ +export async function saveChannelCustomMcp(slug, { name, url, token } = {}, { actor = "", lookup } = {}) { + const checkedUrl = await assertPublicCustomMcpUrl(url, lookup ? { lookup } : {}); + const saved = await patchChannelMeta(slug, (existing) => ({ + customMcps: patchCustomMcps(existing?.customMcps, { set: { name, url: checkedUrl, token }, actor }), + })); + const stored = normalizeCustomMcpName(name); + revokeRemoteMcpServer((meta) => meta.slug === slug, customMcpServerName("channel", stored)); + logEvent("channel_custom_mcp_set", { slug, name: stored, host: hostOf(checkedUrl), actor }); + return listCustomMcps(saved?.customMcps, "channel"); +} + +/** Remove one of a channel's custom MCP servers. Returns the masked list. */ +export async function removeChannelCustomMcp(slug, name, { actor = "" } = {}) { + const stored = normalizeCustomMcpName(name); + const saved = await patchChannelMeta(slug, (existing) => ({ + customMcps: patchCustomMcps(existing?.customMcps, { remove: stored }), + })); + revokeRemoteMcpServer((meta) => meta.slug === slug, customMcpServerName("channel", stored)); + logEvent("channel_custom_mcp_removed", { slug, name: stored, actor }); + return listCustomMcps(saved?.customMcps, "channel"); +} + +/** A person's own custom MCP servers, masked. */ +export async function listUserCustomMcps(userId) { + return listCustomMcps((await getUser(userId))?.customMcps, "user"); +} + +/** Add or update one of a person's own custom MCP servers. Returns the masked list. */ +export async function saveUserCustomMcp(userId, { name, url, token } = {}, { actor = "", lookup } = {}) { + if (!userId) throw new Error("No user to change."); + const checkedUrl = await assertPublicCustomMcpUrl(url, lookup ? { lookup } : {}); + const next = patchCustomMcps((await getUser(userId))?.customMcps, { set: { name, url: checkedUrl, token }, actor: actor || userId }); + const saved = await setUser(userId, { customMcps: next }); + const stored = normalizeCustomMcpName(name); + revokeRemoteMcpServer((meta) => meta.authorId === userId, customMcpServerName("user", stored)); + logEvent("user_custom_mcp_set", { user: userId, name: stored, host: hostOf(checkedUrl), actor: actor || userId }); + return listCustomMcps(saved.customMcps, "user"); +} + +/** Remove one of a person's own custom MCP servers. Returns the masked list. */ +export async function removeUserCustomMcp(userId, name, { actor = "" } = {}) { + if (!userId) throw new Error("No user to change."); + const stored = normalizeCustomMcpName(name); + const next = patchCustomMcps((await getUser(userId))?.customMcps, { remove: stored }); + const saved = await setUser(userId, { customMcps: next }); + revokeRemoteMcpServer((meta) => meta.authorId === userId, customMcpServerName("user", stored)); + logEvent("user_custom_mcp_removed", { user: userId, name: stored, actor: actor || userId }); + return listCustomMcps(saved.customMcps, "user"); +} diff --git a/src/gateway/custom-mcps.js b/src/gateway/custom-mcps.js new file mode 100644 index 00000000..bf099c49 --- /dev/null +++ b/src/gateway/custom-mcps.js @@ -0,0 +1,211 @@ +// Custom MCP connections: a remote MCP server a channel manager (for the conversation) or a person +// (for themselves) adds by URL + Bearer token. Bearer only — servers that need an OAuth flow go +// through Composio. One home for the record shape, its validation, the masked listing every +// surface returns, and what a run receives. +// +// Stored as a list on the channel's meta (`meta.customMcps`) or the person's user record +// (`user.customMcps`): `{ name, url, token, setBy, setAt }`. The token is WRITE-ONLY, like a +// channel's environment secrets: every surface lists name + server name + URL + last4 + who/when, +// and there is no reveal path — replace it by entering a new one. +// +// The engine sees each server under a scope-prefixed name — `custom-` for the channel's, +// `my-` for the author's own — so the two scopes never collide with each other, with the +// built-in servers (gateway, composio-*, the toolboxes) or with a catalog selection, and the tool +// names themselves (`mcp__custom-linear__…`, `mcp__my-linear__…`) say whose connection it is. +// +// A run reaches them exactly like the other header-bearing remotes (src/gateway/mcp.js): in a +// container the DAEMON relays them, so the token never enters the container; `publicOnly` makes +// that relay re-check the destination against the SSRF policy on every dial and pin the connection +// to the vetted addresses (src/mcp/public-fetch.js) — the daemon sits outside the egress proxy, so +// without it a custom URL would be a way into the host's loopback, private network or cloud +// metadata. A save is refused up front for the same reason (assertPublicCustomMcpUrl). +import { resolvePublicHttpUrl } from "../web/security.js"; + +export const CUSTOM_MCP_LIMIT = 5; +export const CUSTOM_MCP_SCOPES = Object.freeze({ channel: "custom-", user: "my-" }); +const NAME_RE = /^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$/; +const MAX_URL_LENGTH = 2048; +const MIN_TOKEN_LENGTH = 8; +const MAX_TOKEN_BYTES = 8 * 1024 - "Bearer ".length; +// RFC 6750 b64token plus the characters real API keys use; never whitespace or a control byte, +// which is what would let a value break out of the Authorization header line. +const TOKEN_RE = /^[\x21-\x7e]+$/; + +function prefixFor(scope) { + const prefix = CUSTOM_MCP_SCOPES[scope]; + if (!prefix) throw new Error(`unknown custom MCP scope: ${scope}`); + return prefix; +} + +/** The lowercase stored name, or a throw naming the rule. */ +export function normalizeCustomMcpName(value) { + const name = String(value ?? "").trim().toLowerCase(); + if (!NAME_RE.test(name)) { + throw new Error("Name must be 1-32 characters: lowercase letters, digits and dashes, starting and ending with a letter or digit"); + } + return name; +} + +/** The server name the engine sees: `custom-` (channel) or `my-` (personal). */ +export function customMcpServerName(scope, name) { + return `${prefixFor(scope)}${name}`; +} + +/** The canonical https URL, or a throw. Syntax only — assertPublicCustomMcpUrl checks the address. */ +export function normalizeCustomMcpUrl(value) { + const raw = String(value ?? "").trim(); + if (!raw || raw.length > MAX_URL_LENGTH) throw new Error("URL must be an https:// address of at most 2048 characters"); + let url; + try { + url = new URL(raw); + } catch { + throw new Error("URL must be a valid https:// address"); + } + if (url.protocol !== "https:") throw new Error("URL must use https://"); + if (url.username || url.password) throw new Error("URL must not contain a username or password — put the token in the token field"); + if (url.hash) throw new Error("URL must not contain a #fragment"); + return url.href; +} + +/** + * Refuse a URL whose host resolves to a loopback, private, link-local or metadata address. The + * relay repeats this on every dial (DNS can change); this is the early, explainable refusal. + * `lookup` is injectable for tests. + */ +export async function assertPublicCustomMcpUrl(value, { lookup } = {}) { + const href = normalizeCustomMcpUrl(value); + try { + await resolvePublicHttpUrl(href, lookup ? { lookup } : {}); + } catch (error) { + throw new Error(`URL refused: ${error.message}`); + } + return href; +} + +/** The trimmed token, or a throw. A leading "Bearer " is accepted and dropped. */ +export function normalizeCustomMcpToken(value) { + const token = String(value ?? "").trim().replace(/^bearer\s+/i, ""); + if (token.length < MIN_TOKEN_LENGTH) throw new Error(`Token must be at least ${MIN_TOKEN_LENGTH} characters`); + if (Buffer.byteLength(token) > MAX_TOKEN_BYTES) throw new Error("Token is too long (8 KB at most)"); + if (!TOKEN_RE.test(token)) throw new Error("Token must not contain spaces, line breaks or control characters"); + return token; +} + +function cleanEntries(list) { + if (!Array.isArray(list)) return []; + const out = []; + const seen = new Set(); + for (const entry of list) { + if (!entry || typeof entry !== "object") continue; + const name = typeof entry.name === "string" ? entry.name : ""; + if (!NAME_RE.test(name) || seen.has(name)) continue; + if (typeof entry.url !== "string" || typeof entry.token !== "string" || !entry.token) continue; + seen.add(name); + out.push(entry); + } + return out; +} + +/** + * Add, update or remove one entry; returns the NEW list (the input is not mutated). `set` upserts + * by name: an update may omit `token` to keep the stored one, a new entry must carry it. The URL + * must already have passed assertPublicCustomMcpUrl when it came from a caller. + */ +export function patchCustomMcps(list, { set = null, remove = null, actor = "", now = new Date() } = {}) { + const current = cleanEntries(list); + if (remove != null) { + const name = normalizeCustomMcpName(remove); + if (!current.some((entry) => entry.name === name)) throw new Error(`No custom MCP named "${name}"`); + return current.filter((entry) => entry.name !== name); + } + if (!set) throw new Error("Nothing to change"); + const name = normalizeCustomMcpName(set.name); + const url = normalizeCustomMcpUrl(set.url); + const existing = current.find((entry) => entry.name === name); + const suppliedToken = typeof set.token === "string" && set.token.trim() ? normalizeCustomMcpToken(set.token) : ""; + if (!existing && !suppliedToken) throw new Error("A Bearer token is required for a new custom MCP"); + if (!existing && current.length >= CUSTOM_MCP_LIMIT) throw new Error(`At most ${CUSTOM_MCP_LIMIT} custom MCP servers here — remove one first`); + const entry = { + name, + url, + token: suppliedToken || existing.token, + setBy: String(actor || ""), + setAt: (now instanceof Date ? now : new Date(now)).toISOString(), + }; + return existing ? current.map((item) => (item.name === name ? entry : item)) : [...current, entry]; +} + +/** The masked shape — the ONLY form a custom MCP list may leave the process in. */ +export function listCustomMcps(list, scope) { + prefixFor(scope); + return cleanEntries(list).map((entry) => ({ + name: entry.name, + serverName: customMcpServerName(scope, entry.name), + url: entry.url, + hasToken: true, + tokenLast4: entry.token.slice(-4), + setBy: typeof entry.setBy === "string" ? entry.setBy : "", + setAt: typeof entry.setAt === "string" ? entry.setAt : "", + })); +} + +/** + * What ONE run receives: `{ [serverName]: { url, token } }`. Lean (clean) gets nothing; the + * personal list only when the run's author is trusted (the HTTP run API names an author it never + * authenticated, so it gets no personal scope — the same rule as composio-user). An entry whose URL + * no longer parses is skipped rather than failing the turn. + */ +export function resolveCustomMcpsForRun({ channelList = [], userList = [], clean = false, principalTrusted = true } = {}) { + if (clean) return {}; + const out = {}; + const add = (scope, list) => { + for (const entry of cleanEntries(list)) { + let url; + try { url = normalizeCustomMcpUrl(entry.url); } catch { continue; } + out[customMcpServerName(scope, entry.name)] = { url, token: entry.token }; + } + }; + add("channel", channelList); + if (principalTrusted) add("user", userList); + return out; +} + +/** The relay/remote shape mcp.js registers: Bearer header + the per-dial public-address check. */ +export function customMcpRemotes(customMcps = {}) { + const out = {}; + for (const [name, { url, token } = {}] of Object.entries(customMcps || {})) { + if (!url || !token) continue; + out[name] = { url, headers: { Authorization: `Bearer ${token}` }, publicOnly: true }; + } + return out; +} + +/** The tokens a run must redact from everything it posts. */ +export function customMcpTokens(customMcps = {}) { + return Object.values(customMcps || {}).map((entry) => entry?.token || "").filter(Boolean); +} + +/** + * Admit this run's custom MCP servers in a generated Claude lockdown (`buildSettings` output), + * in place. A custom MCP is a stdio relay entry in a container (matched by name) and an http + * entry on a sudo-host turn (matched by URL, because the allowlist already carries serverUrl + * entries — see injectedRemoteAllowMatches); without the match, Claude Code drops the server + * before connecting. Callers pass `url` for a host turn ONLY: the settings file sits in the + * artifact dir a container can read, and a URL may carry a query-string key. Its tools are pre-approved like every other + * gateway-injected remote — the person who added it chose the server. `servers` is + * `[{ name, url }]` or the `{ [serverName]: { url } }` map a run resolves. + */ +export function admitCustomMcpsInSettings(settings, servers = []) { + const list = Array.isArray(servers) ? servers : Object.entries(servers || {}).map(([name, entry]) => ({ name, url: entry?.url })); + if (!list.length || !settings) return settings; + settings.allowedMcpServers ||= []; + settings.permissions ||= {}; + settings.permissions.allow ||= []; + for (const server of list) { + if (!server?.name) continue; + settings.allowedMcpServers.push({ serverName: server.name }); + if (server.url) settings.allowedMcpServers.push({ serverUrl: server.url }); + settings.permissions.allow.push(`mcp__${server.name}`); + } + return settings; +} diff --git a/src/gateway/gateway-usage/references/administration.md b/src/gateway/gateway-usage/references/administration.md index 2095dad0..2807979d 100644 --- a/src/gateway/gateway-usage/references/administration.md +++ b/src/gateway/gateway-usage/references/administration.md @@ -81,6 +81,12 @@ gateway applies them; explicitly exempt tools do not receive a human-approval re - `list_available_mcps` — servers the host offers. - `list_channel_mcps` — what's allowed here now. - `add_channel_mcps` / `remove_channel_mcps` — allow/stop MCP servers here (by name). +- **Custom MCP servers** (a remote server by URL + Bearer token) are added by a person, never by + you: an admin opens the admin UI → the conversation → Connections → *Custom MCP servers* (they + reach every run here as `custom-`), or the user drawer → *Personal MCP servers* (only + that person's runs, as `my-`). HTTPS and public addresses only; OAuth-only servers go + through Composio instead. Never ask for the token in chat, and never write an MCP definition + into a settings or `.mcp.json` file — the engine ignores them. ## Skills in this channel (any member, no card) - `show_channel_skills` — what is active here, by tier, with the context cost. diff --git a/src/gateway/mcp.js b/src/gateway/mcp.js index 34ccc20a..890126ae 100644 --- a/src/gateway/mcp.js +++ b/src/gateway/mcp.js @@ -39,6 +39,7 @@ import { runtimeSupports } from "../runtimes/contract.js"; import { requireComposioSdkEntitlement } from "../ee/composio-entitlement.js"; import { mintGatewayCapability, mintedCapabilityClaims } from "./mcp-capability.js"; import { registerRemoteMcps, remoteMcpServerProblem } from "../mcp/remote-mcp-registry.js"; +import { customMcpRemotes } from "./custom-mcps.js"; const GATEWAY_PATH = fileURLToPath(new URL("../mcp/gateway-server.js", import.meta.url)); const COMPOSIO_SDK_BRIDGE_PATH = fileURLToPath(new URL("../ee/composio-sdk-bridge.js", import.meta.url)); @@ -150,7 +151,7 @@ export async function buildMcpConfig(options = {}) { * when there is one — the caller owns one hold on it, see registerRemoteMcps) and `rejectedRemotes` (a remote an * isolated run could not be given, `[{ name, reason }]`, for the caller's rejected-MCP note). */ -export async function buildMcpRuntimePayload({ composioUserEndpoint = null, composioEndpoint = null, composioUserToken = "", composioToken = "", toolboxToken = "", makeToolboxUrl = "", makeToolboxKey = "", channelId = "", slug = "", authorId = "", threadKey = "", origin = "", progressReport = false, engine = "claude", principalTrusted = true, gatewayFsRoot = "", gatewayWorkspaceRoot = "", toolset = "", target = null, ttlMs = undefined } = {}) { +export async function buildMcpRuntimePayload({ composioUserEndpoint = null, composioEndpoint = null, composioUserToken = "", composioToken = "", toolboxToken = "", makeToolboxUrl = "", makeToolboxKey = "", customMcps = {}, channelId = "", slug = "", authorId = "", threadKey = "", origin = "", progressReport = false, engine = "claude", principalTrusted = true, gatewayFsRoot = "", gatewayWorkspaceRoot = "", toolset = "", target = null, ttlMs = undefined } = {}) { // Identity claim — fail closed on garbage instead of silently signing as Claude. const normalizedEngine = requireAdapter(engine || "claude").id; // Fail closed on an unknown capability key; a target that is absent or host-backed is today's path. @@ -169,6 +170,12 @@ export async function buildMcpRuntimePayload({ composioUserEndpoint = null, comp if (sharedRemote) httpRemotes["composio-agent"] = sharedRemote; if (toolboxToken) httpRemotes["makeitfuture-toolbox"] = { url: toolboxUrl(), headers: { Authorization: `Bearer ${toolboxToken}` } }; if (makeToolboxUrl && makeToolboxKey) httpRemotes["make-toolbox"] = { url: makeToolboxUrl, headers: { Authorization: `Bearer ${makeToolboxKey}` } }; + // Custom MCP connections (src/gateway/custom-mcps.js): `custom-` / `my-`, Bearer + // header, `publicOnly` so the relay re-checks the destination on every dial. The prefixes keep + // them from ever replacing one of the built-in entries above. + for (const [name, remote] of Object.entries(customMcpRemotes(customMcps))) { + if (!Object.hasOwn(httpRemotes, name)) httpRemotes[name] = remote; + } // Each server is checked on its own before anything is registered. The relay dials https only // and carries bounded, single-line text headers; a remote that fails that (a plain-http // COMPOSIO_MCP_URL / TOOLBOX_MCP_URL override, a malformed endpoint header) cannot be relayed, @@ -278,9 +285,9 @@ export async function buildMcpRuntimePayload({ composioUserEndpoint = null, comp const entry = isolated && httpRemotes[name] ? relayServer(name) : composioServer(endpoint, legacyToken, composioOpts); if (entry) servers[name] = entry; } - for (const name of ["makeitfuture-toolbox", "make-toolbox"]) { + for (const name of ["makeitfuture-toolbox", "make-toolbox", ...Object.keys(customMcpRemotes(customMcps))]) { const remote = httpRemotes[name]; - if (remote) servers[name] = isolated ? relayServer(name) : { type: "http", url: remote.url, headers: remote.headers }; + if (remote && !servers[name]) servers[name] = isolated ? relayServer(name) : { type: "http", url: remote.url, headers: remote.headers }; } return { configJson: JSON.stringify({ mcpServers: servers }), relayDigest, gatewayCapability, relayedMcps, relayJti: relayedMcps.length ? jti : "", rejectedRemotes }; } diff --git a/src/gateway/run-grant-artifacts.js b/src/gateway/run-grant-artifacts.js index 61f29422..e5e8af13 100644 --- a/src/gateway/run-grant-artifacts.js +++ b/src/gateway/run-grant-artifacts.js @@ -6,6 +6,7 @@ import { access, cp, lstat, mkdir, mkdtemp, readFile, readdir, readlink, rename, import os from "node:os"; import path from "node:path"; import { buildSettings, enableSkills } from "./folders.js"; +import { admitCustomMcpsInSettings } from "./custom-mcps.js"; import { ensureRealDir } from "./safe-fs.js"; import { parseFrontmatter, skillMetadata } from "./skills/frontmatter.js"; import { grantedPluginPackages, compilePluginPackage, relocatePluginServers, pluginSkillCatalog } from "./plugin-runtime.js"; @@ -192,6 +193,8 @@ export async function createRunGrantArtifacts({ allowBypass = false, // A home-guarded run (run.js homeGuardRequired): read-only tools, and no plugin command servers. homeGuard = false, + // This run's custom MCP connections, `[{ name, url }]` (src/gateway/custom-mcps.js). + customMcpServers = [], target = null, } = {}) { // WHERE this run's engine-facing files go: under the channel's artifact dir, which the container @@ -333,6 +336,7 @@ export async function createRunGrantArtifacts({ settings.allowedMcpServers.push({ serverName: server.name }); settings.permissions.allow.push(`mcp__${server.name}`); } + admitCustomMcpsInSettings(settings, customMcpServers); const content = `${JSON.stringify(settings, null, 2)}\n`; const digest = createHash("sha256").update(content).digest("hex").slice(0, 24); const settingsRoot = path.join(stableArtifactRoot, "claude-settings"); diff --git a/src/gateway/run-integrations.js b/src/gateway/run-integrations.js index d0061989..f6e8a5a9 100644 --- a/src/gateway/run-integrations.js +++ b/src/gateway/run-integrations.js @@ -3,12 +3,13 @@ // engine environment: a chat turn (run.js) and an interactive SSH session (ssh-session.js), which // must be the same environment or "it works in Slack but not over SSH" is the bug. Moved out of // run.js verbatim; run.js re-exports the Composio resolvers so their existing importers stay put. -import { getComposioToken, getToolboxToken, isAdmin, isApproved } from "../config/store.js"; +import { getComposioToken, getToolboxToken, getUser, isAdmin, isApproved } from "../config/store.js"; import { getComposioMode, getDefaultComposioToken, getDefaultToolboxToken } from "../config/settings.js"; import { resolveSdkSession } from "../ee/composio-sdk.js"; import { requireComposioSdkEntitlement } from "../ee/composio-entitlement.js"; import { canManage } from "./modes.js"; import { resolveMakeToolboxRuntime } from "./make-toolbox.js"; +import { resolveCustomMcpsForRun } from "./custom-mcps.js"; import { resolveRunUserIdentity } from "./access-grants.js"; import { composioIdentitiesForRun, composioIdentityPreamble } from "./mcp.js"; @@ -185,10 +186,18 @@ export async function resolveRunIntegrations({ meta = {}, channelId = "", author makeToolboxKey: meta.makeToolboxKey, clean, }); + // Custom MCP connections: the conversation's own, plus the AUTHOR's personal ones — never a + // personal list for an author the run did not authenticate (the run API), same as composio-user. + const customMcps = resolveCustomMcpsForRun({ + channelList: meta.customMcps, + userList: !clean && !untrustedPrincipal && authorId ? (await getUser(authorId))?.customMcps : [], + clean, + principalTrusted: !untrustedPrincipal, + }); return { userIdentity, composio, toolbox, identities, composioUserToken, composioToken, composioUserEndpoint, composioEndpoint, toolboxToken: toolbox.token, - makeToolboxUrl, makeToolboxKey, + makeToolboxUrl, makeToolboxKey, customMcps, composioIdentityPrefix: composioIdentityPreamble(identities), }; } diff --git a/src/gateway/run.js b/src/gateway/run.js index c1f35a1a..70aeef3a 100644 --- a/src/gateway/run.js +++ b/src/gateway/run.js @@ -37,6 +37,7 @@ import { runtimeIdentityPreamble } from "./runtime-identity.js"; import { runtimeAccessPreamble } from "./runtime-access.js"; import { channelCredentialsPreamble } from "./channel-credentials.js"; import { resolveRunIntegrations } from "./run-integrations.js"; +import { customMcpTokens } from "./custom-mcps.js"; import { modelBelongsToEngine, effortBelongsToEngine } from "../engines/registry.js"; import { writeFile, rm, mkdir } from "node:fs/promises"; import path from "node:path"; @@ -1109,10 +1110,10 @@ export async function runMessage({ channelId, authorId, workspaceId = "", text, // are never mutated when SDK mode is active. Clean mode injects none. const { userIdentity, composio, toolbox, composioUserToken, composioToken, composioUserEndpoint, composioEndpoint, toolboxToken, - makeToolboxUrl, makeToolboxKey, composioIdentityPrefix, + makeToolboxUrl, makeToolboxKey, customMcps, composioIdentityPrefix, } = await resolveRunIntegrations({ meta, channelId, authorId, threadKey, workspaceId, clean, untrustedPrincipal }); const authorIsAdmin = userIdentity.isAdmin || apiKeyPrincipal; - outputSecrets.push(composioUserToken, composioToken, toolboxToken, makeToolboxKey); + outputSecrets.push(composioUserToken, composioToken, toolboxToken, makeToolboxKey, ...customMcpTokens(customMcps)); // 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. const gatewayFsRoot = allowedFsRoot(); @@ -1122,7 +1123,7 @@ export async function runMessage({ channelId, authorId, workspaceId = "", text, const mcpRuntimeInput = { clean, composioUserEndpoint, composioEndpoint, composioUserToken, composioToken, - toolboxToken, makeToolboxUrl, makeToolboxKey, + toolboxToken, makeToolboxUrl, makeToolboxKey, customMcps, channelId, slug: entry.slug, authorId, threadKey, origin, progressReport: progressReportEnabled, principalTrusted: !untrustedPrincipal, @@ -1292,6 +1293,7 @@ export async function runMessage({ channelId, authorId, workspaceId = "", text, composio: composio.shared.source, toolbox: toolbox.source, makeToolbox: Boolean(makeToolboxUrl && makeToolboxKey), + customMcps: Object.keys(customMcps), }); // Tokens must never ride on argv — `ps` can read a process's args for its whole life (minutes, @@ -1348,6 +1350,12 @@ export async function runMessage({ channelId, authorId, workspaceId = "", text, // the plugin is the only delivery path that keeps them working (see materializePlugin). workspaceAgentsDir: path.join(cwd, ".claude", "agents"), needsClaudeSettings, + // This run's custom MCP connections must be on the Claude lockdown's server allowlist, or the + // CLI drops them before connecting (src/gateway/custom-mcps.js). Per run: the personal ones + // belong to this author only. The URL is listed only for a host turn, which dials it itself; + // a container's entry is the relay bridge, matched by name, and a URL may carry a query-string + // key that has no business in a file the container can read. + customMcpServers: Object.entries(customMcps).map(([name, { url }]) => ({ name, ...(target?.backend === "host" ? { url } : {}) })), allowBypass: dangerouslySkip, homeGuard: homeGuarded, // Decides both WHERE the artifacts land and WHICH of them exist: an isolated runtime gets no @@ -1399,7 +1407,7 @@ export async function runMessage({ channelId, authorId, workspaceId = "", text, channelEnv, channelEnvFingerprint: channelEnvFp, browserNamespace, writable: codexWritable, autoApprove: codexAutoApprove, clean, composioUserEndpoint, composioEndpoint, composioUserToken, composioToken, toolboxToken, - makeToolboxUrl, makeToolboxKey, gatewayCapability, gatewayFsRoot, gatewayWorkspaceRoot, progressReport: progressReportEnabled, + makeToolboxUrl, makeToolboxKey, customMcps, gatewayCapability, gatewayFsRoot, gatewayWorkspaceRoot, progressReport: progressReportEnabled, allowedMcps: clean ? [] : (meta[adapter.mcpMetaKey] || []), claudePluginDirs: grantArtifacts.claudePluginDirs, claudePluginEphemeral: grantArtifacts.claudePluginEphemeral, @@ -1634,7 +1642,7 @@ export async function runMessage({ channelId, authorId, workspaceId = "", text, mcpConfigFingerprint: fallbackMcpRuntime.mcpConfigFingerprint, strictMcp, settingsFile: runSettingsFile, permissionPromptTool, writable: codexWritable, autoApprove: codexAutoApprove, clean, composioUserEndpoint, composioEndpoint, - composioUserToken, composioToken, toolboxToken, makeToolboxUrl, makeToolboxKey, + composioUserToken, composioToken, toolboxToken, makeToolboxUrl, makeToolboxKey, customMcps, gatewayCapability: fallbackMcpRuntime.gatewayCapability, gatewayFsRoot, gatewayWorkspaceRoot, progressReport: progressReportEnabled, model: modelOverride, effort: "", attachments, signal, timeoutMs, maxSilenceMs, onDelta: scopedOnDelta, onEvent: scopedOnEvent, onSessionResolved: sessionResolved(fbKey, fallbackEngine), diff --git a/src/gateway/ssh-session.js b/src/gateway/ssh-session.js index 8d49b364..57d113a8 100644 --- a/src/gateway/ssh-session.js +++ b/src/gateway/ssh-session.js @@ -49,6 +49,7 @@ import { isIsolatedTarget } from "../engines/runtime-target.js"; import { listEngineMcps, codexMcpPolicyFor } from "./mcp-discovery.js"; import { readDaemonClaudeAccount } from "./claude-token-relay.js"; import { buildSettings } from "./folders.js"; +import { admitCustomMcpsInSettings } from "./custom-mcps.js"; import { buildEngineMcpRuntime } from "./run-engine-mcp.js"; import { resolveRunIntegrations } from "./run-integrations.js"; import { SSH_TOOLSET } from "../mcp/gateway-server.js"; @@ -247,7 +248,7 @@ async function prepareCodexSessionFiles({ target, userDir, integrations, meta, c clean, engine: "codex", target, allowedMcps: allowed, pluginRuntime: null, fingerprintNow: now(), composioUserEndpoint: integrations.composioUserEndpoint, composioEndpoint: integrations.composioEndpoint, composioUserToken: integrations.composioUserToken, composioToken: integrations.composioToken, - toolboxToken: integrations.toolboxToken, makeToolboxUrl: integrations.makeToolboxUrl, makeToolboxKey: integrations.makeToolboxKey, + toolboxToken: integrations.toolboxToken, makeToolboxUrl: integrations.makeToolboxUrl, makeToolboxKey: integrations.makeToolboxKey, customMcps: integrations.customMcps || {}, channelId: entry.channelId, slug: entry.slug, authorId: user.id, threadKey, origin: SSH_SESSION_ORIGIN, progressReport: false, principalTrusted: true, gatewayFsRoot: allowedFsRoot(), gatewayWorkspaceRoot: workspaceRoot(), @@ -263,6 +264,7 @@ async function prepareCodexSessionFiles({ target, userDir, integrations, meta, c composioToken: integrations.composioToken || "", toolboxToken: integrations.toolboxToken || "", makeToolboxKey: integrations.makeToolboxKey || "", + customMcps: integrations.customMcps || {}, }); const secretBundlePath = !clean && Object.values(bundle).some(Boolean) ? path.join(userDir, "codex-secrets.json") : ""; if (secretBundlePath) writePrivate(secretBundlePath, JSON.stringify(bundle)); @@ -272,7 +274,7 @@ async function prepareCodexSessionFiles({ target, userDir, integrations, meta, c prompt: "", sessionId: "", isNewSession: true, cwd: target.workDir || "/", dangerouslySkip: false, writable: true, clean, composioUserEndpoint: integrations.composioUserEndpoint, composioEndpoint: integrations.composioEndpoint, composioUserToken: integrations.composioUserToken, composioToken: integrations.composioToken, - toolboxToken: integrations.toolboxToken, makeToolboxUrl: integrations.makeToolboxUrl, makeToolboxKey: integrations.makeToolboxKey, + toolboxToken: integrations.toolboxToken, makeToolboxUrl: integrations.makeToolboxUrl, makeToolboxKey: integrations.makeToolboxKey, customMcps: integrations.customMcps || {}, secretBundlePath, codexMcpPolicy: policy, gatewayCapability, gatewayFsRoot: allowedFsRoot(), gatewayWorkspaceRoot: workspaceRoot(), target, outFile: "/dev/null", headerHelpers, }); @@ -378,13 +380,20 @@ export async function prepareSshSession({ target, entry, meta = {}, user, cliBin clean, engine: "claude", target, allowedMcps: meta[adapter.mcpMetaKey] || [], pluginRuntime: null, fingerprintNow: now(), composioUserEndpoint: integrations.composioUserEndpoint, composioEndpoint: integrations.composioEndpoint, composioUserToken: integrations.composioUserToken, composioToken: integrations.composioToken, - toolboxToken: integrations.toolboxToken, makeToolboxUrl: integrations.makeToolboxUrl, makeToolboxKey: integrations.makeToolboxKey, + toolboxToken: integrations.toolboxToken, makeToolboxUrl: integrations.makeToolboxUrl, makeToolboxKey: integrations.makeToolboxKey, customMcps: integrations.customMcps || {}, channelId: entry.channelId, slug, authorId: user.id, threadKey, origin: SSH_SESSION_ORIGIN, progressReport: false, principalTrusted: true, gatewayFsRoot: allowedFsRoot(), gatewayWorkspaceRoot: workspaceRoot(), toolset: SSH_TOOLSET, ttlMs: SSH_CAPABILITY_TTL_MS, }); if (runtime.relayJti) relayJtis.push(runtime.relayJti); + // The lockdown above could not know this developer's custom MCP servers yet; without them on + // its allowlist Claude Code would drop them (src/gateway/custom-mcps.js). + if (Object.keys(integrations.customMcps || {}).length) { + // Names only: an SSH session is always the container, whose entry is the relay bridge. + const lockdown = admitCustomMcpsInSettings(await buildLockdown({ ...meta, _slug: slug }, { target }), Object.keys(integrations.customMcps).map((name) => ({ name }))); + writePrivate(path.join(userDir, "settings.json"), `${JSON.stringify(lockdown, null, 2)}\n`); + } writePrivate(path.join(userDir, "mcp.json"), runtime.mcpConfigJson); result.mcpServers = Object.keys(JSON.parse(runtime.mcpConfigJson).mcpServers || {}); result.rejectedMcps = runtime.rejectedMcps || []; diff --git a/src/mcp/public-fetch.js b/src/mcp/public-fetch.js new file mode 100644 index 00000000..a9b3e27f --- /dev/null +++ b/src/mcp/public-fetch.js @@ -0,0 +1,77 @@ +// A fetch for the daemon's remote-MCP relay that only ever reaches PUBLIC addresses — used for the +// custom MCP connections people add themselves (src/gateway/custom-mcps.js). The relay runs in the +// daemon, outside every channel's egress proxy, so a user-supplied URL dialled with the plain +// global fetch would be a way into the host's loopback services (the admin API included), the +// private network and cloud metadata. +// +// Every request — the Streamable HTTP POSTs, the SSE GET, the session DELETE — resolves the host, +// refuses it if ANY record is internal (src/web/security.js), then connects ONLY to the vetted +// records through a pinned `lookup` (no second DNS resolution, so a rebinding answer between the +// check and the connect cannot redirect it). TLS still verifies the certificate against the +// original hostname. Redirects are never followed: a 3xx comes back as-is and the MCP client +// treats it as a failed request, so a public server cannot bounce the relay — and its +// Authorization header — somewhere internal. +// +// The requests use their OWN agent, never the global one: with NODE_USE_ENV_PROXY (or a future +// default) the global agent tunnels through HTTPS_PROXY, and a proxy resolves the host itself — +// which would skip both the pinned lookup and the address check. +import { Agent, request as httpsRequest } from "node:https"; +import { Readable } from "node:stream"; +import { resolvePublicHttpUrl } from "../web/security.js"; + +const NULL_BODY_STATUSES = new Set([101, 204, 205, 304]); + +function requestHref(input) { + if (typeof input === "string") return input; + if (input instanceof URL) return input.href; + return String(input?.url || ""); +} + +function requestBody(body) { + if (body == null) return null; + if (typeof body === "string" || body instanceof Uint8Array) return body; + if (body instanceof ArrayBuffer) return new Uint8Array(body); + throw new TypeError("public MCP fetch supports string or byte request bodies only"); +} + +/** A fetch-compatible function; `resolve` and `requestImpl` are injectable for tests only. */ +export function createPublicPinnedFetch({ resolve = resolvePublicHttpUrl, requestImpl = httpsRequest } = {}) { + const agent = new Agent({ keepAlive: false }); + return async function publicPinnedFetch(input, init = {}) { + const { url, addresses } = await resolve(requestHref(input)); + if (url.protocol !== "https:") throw new TypeError("remote MCP relay requires an HTTPS URL"); + if (!Array.isArray(addresses) || !addresses.length) throw new TypeError("refusing to connect without vetted addresses"); + const method = String(init.method || "GET").toUpperCase(); + const headers = Object.fromEntries(new Headers(init.headers || {}).entries()); + const body = requestBody(init.body); + if (init.signal?.aborted) throw init.signal.reason ?? new DOMException("The operation was aborted", "AbortError"); + return await new Promise((resolvePromise, reject) => { + const lookup = (_hostname, options, callback) => { + if (options?.all) return callback(null, addresses); + return callback(null, addresses[0].address, addresses[0].family); + }; + const req = requestImpl(url, { method, headers, lookup, agent, ...(init.signal ? { signal: init.signal } : {}) }, (res) => { + const responseHeaders = new Headers(); + const raw = res.rawHeaders || []; + for (let i = 0; i + 1 < raw.length; i += 2) responseHeaders.append(raw[i], raw[i + 1]); + const nullBody = NULL_BODY_STATUSES.has(res.statusCode) || method === "HEAD"; + if (nullBody) res.resume(); + try { + resolvePromise(new Response(nullBody ? null : Readable.toWeb(res), { + status: res.statusCode, + statusText: res.statusMessage || "", + headers: responseHeaders, + })); + } catch (error) { + res.destroy(); + reject(error); + } + }); + req.on("error", reject); + if (body != null) req.write(body); + req.end(); + }); + }; +} + +export const publicPinnedFetch = createPublicPinnedFetch(); diff --git a/src/mcp/remote-mcp-registry.js b/src/mcp/remote-mcp-registry.js index 9f715fd2..11c18360 100644 --- a/src/mcp/remote-mcp-registry.js +++ b/src/mcp/remote-mcp-registry.js @@ -116,7 +116,9 @@ export function registerRemoteMcps({ jti, exp, servers, meta = {}, now = Date.no const server = servers[name]; const problem = remoteMcpServerProblem(server); if (problem) throw new Error(`remote MCP server refused: ${problem}`); - map.set(name, Object.freeze({ url: server.url, headers: Object.freeze({ ...(server.headers || {}) }) })); + // `publicOnly` (a custom MCP a person added, src/gateway/custom-mcps.js): the relay re-checks + // the destination against the SSRF policy on every dial and pins the connection to it. + map.set(name, Object.freeze({ url: server.url, headers: Object.freeze({ ...(server.headers || {}) }), publicOnly: server.publicOnly === true })); } sweep(now); registrations.set(jti, { exp, servers: map, meta: cleanMeta(meta), holds: 1 }); @@ -150,7 +152,7 @@ export function releaseRemoteMcps(jti) { if (entry.holds <= 0) drop(jti); } -/** `{ url, headers }` for one server of one live registration, or null. Never the stored object. */ +/** `{ url, headers, publicOnly }` for one server of one live registration, or null. Never the stored object. */ // Runs on every relayed request (the relay re-authorizes each one), so the full sweep is throttled; // the entry being asked about is always checked against its own expiry. export function lookupRemoteMcp(jti, name, now = Date.now()) { @@ -161,7 +163,7 @@ export function lookupRemoteMcp(jti, name, now = Date.now()) { return null; } const server = entry?.servers.get(typeof name === "string" ? name : ""); - return server ? { url: server.url, headers: { ...server.headers } } : null; + return server ? { url: server.url, headers: { ...server.headers }, ...(server.publicOnly ? { publicOnly: true } : {}) } : null; } /** @@ -214,6 +216,26 @@ export function revokeRemoteMcpsForAuthor(authorId) { return clearRemoteMcpsWhere((meta) => meta.authorId === authorId); } +/** + * A custom MCP connection was removed or its token/URL replaced (src/gateway/custom-mcps.js): take + * that ONE server out of every registration whose metadata matches, so the old credential stops + * working at the relay's next request while the rest of a live turn's servers (Composio, the + * toolboxes) keep running. Returns how many registrations lost the server. + */ +export function revokeRemoteMcpServer(predicate, name) { + if (!validRemoteMcpName(name)) return 0; + let revoked = 0; + for (const [jti, entry] of [...registrations]) { + if (!entry.servers.has(name)) continue; + let match = false; + try { match = Boolean(predicate(entry.meta, jti)); } catch { match = false; } + if (!match) continue; + entry.servers.delete(name); + revoked += 1; + } + return revoked; +} + /** Counts only — for tests and diagnostics; never a name, a URL or a header. */ export function remoteMcpRegistryStats(now = Date.now()) { sweep(now); diff --git a/src/mcp/remote-relay.js b/src/mcp/remote-relay.js index 09b37477..615a343a 100644 --- a/src/mcp/remote-relay.js +++ b/src/mcp/remote-relay.js @@ -15,6 +15,7 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { publicPinnedFetch } from "./public-fetch.js"; import { CallToolRequestSchema, CallToolResultSchema, @@ -62,8 +63,12 @@ function unavailable(status) { }); } -export async function connectRemoteClient({ url, headers = {}, fetch = undefined, retryDelaysMs = REMOTE_CONNECT_RETRY_DELAYS_MS } = {}) { +// `publicOnly` (a custom MCP, src/gateway/custom-mcps.js) dials through publicPinnedFetch: every +// request re-resolves the host, refuses an internal address and pins the connection to the vetted +// records (src/mcp/public-fetch.js). An injected test `fetch` still wins. +export async function connectRemoteClient({ url, headers = {}, fetch = undefined, publicOnly = false, retryDelaysMs = REMOTE_CONNECT_RETRY_DELAYS_MS } = {}) { const target = validateRemoteUrl(url); + if (publicOnly && !fetch) fetch = publicPinnedFetch; const requestInit = { headers: { ...headers } }; for (let attempt = 0; ; attempt++) { const streamable = new Client(CLIENT_INFO, { capabilities: {} }); @@ -158,12 +163,12 @@ export function createRelayHandlers(remote, authorize) { * (a connected Client-like object) or `connect` may be injected by tests; production dials * `url` with `headers` through connectRemoteClient. */ -export async function runRemoteRelay({ url, headers = {}, transport, authorize, remote = null, connect = connectRemoteClient } = {}) { +export async function runRemoteRelay({ url, headers = {}, publicOnly = false, transport, authorize, remote = null, connect = connectRemoteClient } = {}) { if (typeof authorize !== "function") throw new Error("remote MCP relay requires an authorization check"); if (!transport) throw new Error("remote MCP relay requires a transport"); authorize(); validateRemoteUrl(url); - const client = remote || await connect({ url, headers }); + const client = remote || await connect({ url, headers, ...(publicOnly ? { publicOnly: true } : {}) }); const handlers = createRelayHandlers(client, authorize); const instructions = typeof client.getInstructions === "function" ? client.getInstructions() : undefined; const server = new Server( diff --git a/src/mcp/socket-server.js b/src/mcp/socket-server.js index 9b8c704d..8a81052e 100644 --- a/src/mcp/socket-server.js +++ b/src/mcp/socket-server.js @@ -257,6 +257,7 @@ export function serveMcpConnection(socket, { handlers = {}, secret = () => proce relay = await runRemoteRelay({ url: remoteTarget.url, headers: remoteTarget.headers, + publicOnly: remoteTarget.publicOnly === true, transport, authorize: () => authorizeRemoteMcp(verify(), remoteName), ...(connectRemote ? { connect: connectRemote } : {}), diff --git a/src/mcp/tools/channel-admin.js b/src/mcp/tools/channel-admin.js index 9acac801..ef10bf84 100644 --- a/src/mcp/tools/channel-admin.js +++ b/src/mcp/tools/channel-admin.js @@ -37,6 +37,7 @@ import { allowedFsRoot, resolveWithinRoot } from "../../web/security.js"; import { formatUpdateResult, startUpdate } from "../../gateway/updater.js"; import { updateGatewayGuide, resetGatewayGuide, readGatewayGuide } from "../../gateway/guide.js"; import { slackThreadFor } from "../../slack/thread-keys.js"; +import { listCustomMcps } from "../../gateway/custom-mcps.js"; export function register(server, ctx) { const { channelId, slug, createdBy, threadKey, activeEngine, text, daemon, requireAdmin, requireManage, loadMeta } = ctx; @@ -77,7 +78,11 @@ export function register(server, ctx) { const meta = await loadMeta(); const field = selectionFieldForEngine(activeEngine); const names = (meta?.[field] || []).map((m) => m.name); - return text(names.length ? `Allowed here for ${engineLabel(activeEngine)}: ${names.join(", ")}` : `No extra ${engineLabel(activeEngine)} MCP servers are allowed in this channel (personal/shared Composio + gateway controls are built in when configured).`); + // Custom MCP connections (gateway/custom-mcps.js) are added by a person in the admin UI and + // reach every engine; names only — a URL may carry a query-string key. + const custom = listCustomMcps(meta?.customMcps, "channel").map((m) => m.serverName); + const customLine = custom.length ? `\nCustom MCP servers here (every engine; managed in the admin UI): ${custom.join(", ")}` : ""; + return text((names.length ? `Allowed here for ${engineLabel(activeEngine)}: ${names.join(", ")}` : `No extra ${engineLabel(activeEngine)} MCP servers are allowed in this channel (personal/shared Composio + gateway controls are built in when configured).`) + customLine); } ); diff --git a/src/web/routes/channels.js b/src/web/routes/channels.js index d87da0e4..f39e094a 100644 --- a/src/web/routes/channels.js +++ b/src/web/routes/channels.js @@ -27,6 +27,8 @@ import { import { syncWorkspaceSkillsOrThrow } from "../../gateway/skills/workspace-sync.js"; import { ensureChannelFolder, effectiveWorkDir, gatewayInstructionsBlock, splitGatewayBlock, channelSeed } from "../../gateway/folders.js"; import { workspaceConflictsBySlug } from "../../gateway/workspace-assignments.js"; +import { listCustomMcps } from "../../gateway/custom-mcps.js"; +import { removeChannelCustomMcp, saveChannelCustomMcp } from "../../gateway/custom-mcp-store.js"; import { memoryEnabled, countMemoryFacts, MEM_FILE, MEM_DIR } from "../../gateway/channel-memory.js"; import { ENGINES, @@ -102,6 +104,8 @@ export function maskChannelMeta(meta = {}) { makeToolboxKey: undefined, hasMakeToolboxKey: mk.has, makeToolboxKeyLast4: mk.last4, + // Name, server name, URL and last4 only — the token is write-only (gateway/custom-mcps.js). + customMcps: listCustomMcps(meta.customMcps, "channel"), }; } @@ -703,6 +707,57 @@ export function createChannelsRouter({ } }); + // ── Custom MCP connections (gateway/custom-mcps.js) ───────────────────────── + // A remote MCP server this conversation's runs receive as `custom-`, authenticated with a + // Bearer token. Same write-only contract as the environment secrets: GET lists name + URL + + // last4, and there is no reveal route — a token is replaced by entering a new one. + router.get("/channels/:channelId/custom-mcps", async (req, res, next) => { + try { + const ctx = await resolveChannelCtx(req.params.channelId); + if (!ctx) return res.status(404).json({ error: "unknown channel" }); + res.json({ servers: listCustomMcps(ctx.meta.customMcps, "channel") }); + } catch (e) { + next(e); + } + }); + + // Add or update one server. `token` may be omitted on an update to keep the stored one. + router.put("/channels/:channelId/custom-mcps/:name", async (req, res, next) => { + try { + const ctx = await resolveChannelCtx(req.params.channelId); + if (!ctx) return res.status(404).json({ error: "unknown channel" }); + let servers; + try { + servers = await saveChannelCustomMcp(ctx.entry.slug, { + name: req.params.name, + url: typeof req.body?.url === "string" ? req.body.url : "", + token: typeof req.body?.token === "string" ? req.body.token : "", + }, { actor: WEB_ADMIN_ACTOR }); + } catch (e) { + return res.status(400).json({ error: e.message }); + } + res.json({ ok: true, servers }); + } catch (e) { + next(e); + } + }); + + router.delete("/channels/:channelId/custom-mcps/:name", async (req, res, next) => { + try { + const ctx = await resolveChannelCtx(req.params.channelId); + if (!ctx) return res.status(404).json({ error: "unknown channel" }); + let servers; + try { + servers = await removeChannelCustomMcp(ctx.entry.slug, req.params.name, { actor: WEB_ADMIN_ACTOR }); + } catch (e) { + return res.status(400).json({ error: e.message }); + } + res.json({ ok: true, servers }); + } catch (e) { + next(e); + } + }); + // ── Channel memory (MEMORY.md index + memory/ topic files) ────────────────── // Markdown is the source of truth; SQLite FTS is a derived cache rebuilt on search. The UI // edits MEMORY.md directly and lists topic files. Storage is uncapped; prompt recall is not. diff --git a/src/web/routes/users.js b/src/web/routes/users.js index 789bc8eb..a6a27e4d 100644 --- a/src/web/routes/users.js +++ b/src/web/routes/users.js @@ -11,6 +11,8 @@ import { revokeRemoteMcpsForAuthor } from "../../mcp/remote-mcp-registry.js"; // channel's: listUserEnv's masked shape is the only thing that may leave the process. import { listUserEnv, patchUserEnv } from "../../config/scoped-env.js"; import { listEnvVars, normalizeEnvName, swapRuleFieldsFrom } from "../../config/channel-env.js"; +import { listCustomMcps } from "../../gateway/custom-mcps.js"; +import { listUserCustomMcps, removeUserCustomMcp, saveUserCustomMcp } from "../../gateway/custom-mcp-store.js"; // The user listing is built field by field, never `...u`. That is the property that matters here: // a stored user record can carry secrets this file has never heard of — a personal token from an @@ -40,6 +42,8 @@ function maskUsers(users) { // Names + last4 only, like every other secret surface. Listing them here is what makes the // scope administrable at all: nobody but the person can see them from chat. secrets: listEnvVars(u.env), + // Name, server name, URL and last4 only — the token is write-only (gateway/custom-mcps.js). + customMcps: listCustomMcps(u.customMcps, "user"), ...cleanAccessGrants(u), }; } @@ -157,6 +161,48 @@ export function createUsersRouter() { } }); + // ── One person's own custom MCP connections (gateway/custom-mcps.js) ─────── + // Injected as `my-` only into runs this person authored. Write-only like their secrets. + router.get("/users/:userId/custom-mcps", async (req, res, next) => { + try { + res.json({ servers: await listUserCustomMcps(req.params.userId) }); + } catch (e) { + next(e); + } + }); + + router.put("/users/:userId/custom-mcps/:name", async (req, res, next) => { + try { + let servers; + try { + servers = await saveUserCustomMcp(req.params.userId, { + name: req.params.name, + url: typeof req.body?.url === "string" ? req.body.url : "", + token: typeof req.body?.token === "string" ? req.body.token : "", + }, { actor: USER_SECRET_ACTOR }); + } catch (e) { + return res.status(400).json({ error: e.message }); + } + res.json({ ok: true, servers }); + } catch (e) { + next(e); + } + }); + + router.delete("/users/:userId/custom-mcps/:name", async (req, res, next) => { + try { + let servers; + try { + servers = await removeUserCustomMcp(req.params.userId, req.params.name, { actor: USER_SECRET_ACTOR }); + } catch (e) { + return res.status(400).json({ error: e.message }); + } + res.json({ ok: true, servers }); + } catch (e) { + next(e); + } + }); + router.put("/users/:userId", async (req, res, next) => { try { const { userId } = req.params; diff --git a/test/custom-mcps.test.js b/test/custom-mcps.test.js new file mode 100644 index 00000000..6195603c --- /dev/null +++ b/test/custom-mcps.test.js @@ -0,0 +1,282 @@ +// Custom MCP connections (src/gateway/custom-mcps.js): a remote MCP server added by URL + Bearer +// token on a conversation (`custom-`) or on a person (`my-`). Covers the record rules, +// what a run receives, the Claude/Codex wiring, the daemon relay's public-address pinning, the +// per-server revocation, and the admin routes' write-only contract. +import test, { after } from "node:test"; +import assert from "node:assert/strict"; +import express from "express"; +import { ensureTestEnv } from "./helpers.js"; +import { createFakeRuntime } from "./fixtures/fake-runtime-backend.js"; + +ensureTestEnv(); +process.env.CG_APPROVAL_SECRET = "custom-mcp-signing-secret"; + +const { + CUSTOM_MCP_LIMIT, + admitCustomMcpsInSettings, + assertPublicCustomMcpUrl, + customMcpRemotes, + customMcpTokens, + listCustomMcps, + normalizeCustomMcpName, + normalizeCustomMcpToken, + normalizeCustomMcpUrl, + patchCustomMcps, + resolveCustomMcpsForRun, +} = await import("../src/gateway/custom-mcps.js"); +const { buildMcpRuntimePayload } = await import("../src/gateway/mcp.js"); +const { verifyGatewayCapability } = await import("../src/gateway/mcp-capability.js"); +const { lookupRemoteMcp, registerRemoteMcps, revokeRemoteMcpServer, clearRemoteMcps } = await import("../src/mcp/remote-mcp-registry.js"); +const { createPublicPinnedFetch } = await import("../src/mcp/public-fetch.js"); +const { runRemoteRelay } = await import("../src/mcp/remote-relay.js"); +const { buildCodexArgs, codexSecretBundle } = await import("../src/engines/codex.js"); +const { hostBackend } = await import("../src/runtimes/host.js"); +const { buildSettings } = await import("../src/gateway/folders.js"); +const { workspaceRoot } = await import("../src/config/paths.js"); +const { allowedFsRoot } = await import("../src/web/security.js"); + +const PUBLIC = async () => [{ address: "93.184.215.14", family: 4 }]; +const identity = { + channelId: "C_CUSTOM", + slug: "custom-mcp-test", + authorId: "U_CUSTOM", + threadKey: "1.000", + origin: "slack_foreground", + gatewayFsRoot: allowedFsRoot(), + gatewayWorkspaceRoot: workspaceRoot(), +}; + +test("names, URLs and tokens are validated and normalized", () => { + assert.equal(normalizeCustomMcpName(" Linear "), "linear"); + assert.equal(normalizeCustomMcpName("my-server-2"), "my-server-2"); + for (const bad of ["", "-x", "x-", "has space", "under_score", "a".repeat(33), "ünï"]) { + assert.throws(() => normalizeCustomMcpName(bad), /Name must be/, bad); + } + assert.equal(normalizeCustomMcpUrl(" https://mcp.example.com/mcp "), "https://mcp.example.com/mcp"); + assert.throws(() => normalizeCustomMcpUrl("http://mcp.example.com/mcp"), /https/); + assert.throws(() => normalizeCustomMcpUrl("https://user:pw@mcp.example.com/"), /username or password/); + assert.throws(() => normalizeCustomMcpUrl("https://mcp.example.com/#x"), /fragment/); + assert.throws(() => normalizeCustomMcpUrl("not a url"), /valid/); + assert.equal(normalizeCustomMcpToken("Bearer abcdefgh123"), "abcdefgh123", "a pasted 'Bearer ' prefix is dropped"); + assert.throws(() => normalizeCustomMcpToken("short"), /at least/); + assert.throws(() => normalizeCustomMcpToken("abc def ghi"), /spaces/); + assert.throws(() => normalizeCustomMcpToken("abcdefgh\r\nX-Evil: 1"), /spaces|line breaks/); +}); + +test("the address check refuses loopback, private and metadata destinations", async () => { + assert.equal(await assertPublicCustomMcpUrl("https://mcp.example.com/mcp", { lookup: PUBLIC }), "https://mcp.example.com/mcp"); + await assert.rejects(assertPublicCustomMcpUrl("https://127.0.0.1/mcp"), /URL refused: .*loopback/); + await assert.rejects(assertPublicCustomMcpUrl("https://localhost/mcp"), /URL refused/); + await assert.rejects(assertPublicCustomMcpUrl("https://169.254.169.254/mcp"), /URL refused/); + await assert.rejects( + assertPublicCustomMcpUrl("https://internal.example.com/mcp", { lookup: async () => [{ address: "10.1.2.3", family: 4 }] }), + /URL refused: .*private/, + ); + await assert.rejects( + assertPublicCustomMcpUrl("https://mixed.example.com/mcp", { lookup: async () => [{ address: "93.184.215.14", family: 4 }, { address: "127.0.0.1", family: 4 }] }), + /URL refused/, "ANY internal record refuses the host", + ); +}); + +test("patch upserts by name, keeps the token on a URL-only update, caps the list and masks every listing", () => { + let list = patchCustomMcps([], { set: { name: "Linear", url: "https://mcp.linear.app/mcp", token: "lin_secret_1234" }, actor: "<@U1>", now: new Date("2026-10-01T00:00:00Z") }); + assert.deepEqual(list, [{ name: "linear", url: "https://mcp.linear.app/mcp", token: "lin_secret_1234", setBy: "<@U1>", setAt: "2026-10-01T00:00:00.000Z" }]); + list = patchCustomMcps(list, { set: { name: "linear", url: "https://mcp.linear.app/v2" } }); + assert.equal(list[0].token, "lin_secret_1234", "an update without a token keeps the stored one"); + assert.equal(list[0].url, "https://mcp.linear.app/v2"); + assert.throws(() => patchCustomMcps(list, { set: { name: "other", url: "https://x.example.com/" } }), /token is required/); + for (let i = 1; i < CUSTOM_MCP_LIMIT; i += 1) list = patchCustomMcps(list, { set: { name: `s${i}`, url: "https://x.example.com/", token: `token-${i}-abcdef` } }); + assert.equal(list.length, CUSTOM_MCP_LIMIT); + assert.throws(() => patchCustomMcps(list, { set: { name: "overflow", url: "https://x.example.com/", token: "token-overflow" } }), /At most/); + assert.equal(patchCustomMcps(list, { set: { name: "s1", url: "https://y.example.com/" } }).length, CUSTOM_MCP_LIMIT, "updating an existing entry is not blocked by the cap"); + list = patchCustomMcps(list, { remove: "s1" }); + assert.ok(!list.some((entry) => entry.name === "s1")); + assert.throws(() => patchCustomMcps(list, { remove: "missing" }), /No custom MCP/); + + const masked = listCustomMcps(list, "channel"); + assert.deepEqual(masked[0], { name: "linear", serverName: "custom-linear", url: "https://mcp.linear.app/v2", hasToken: true, tokenLast4: "1234", setBy: "", setAt: masked[0].setAt }); + assert.ok(!JSON.stringify(masked).includes("lin_secret"), "the token never leaves in a listing"); + assert.equal(listCustomMcps(list, "user")[0].serverName, "my-linear"); +}); + +test("a run gets the channel's servers, plus the author's own only when the author is trusted; Lean gets none", () => { + const channelList = [{ name: "linear", url: "https://mcp.linear.app/mcp", token: "chan-token-1" }]; + const userList = [{ name: "notes", url: "https://notes.example.com/mcp", token: "user-token-2" }, { name: "bad", url: "ftp://x", token: "t-bad-12345" }]; + assert.deepEqual(resolveCustomMcpsForRun({ channelList, userList }), { + "custom-linear": { url: "https://mcp.linear.app/mcp", token: "chan-token-1" }, + "my-notes": { url: "https://notes.example.com/mcp", token: "user-token-2" }, + }); + assert.deepEqual(Object.keys(resolveCustomMcpsForRun({ channelList, userList, principalTrusted: false })), ["custom-linear"]); + assert.deepEqual(resolveCustomMcpsForRun({ channelList, userList, clean: true }), {}); + const runMap = resolveCustomMcpsForRun({ channelList, userList }); + assert.deepEqual(customMcpRemotes(runMap)["custom-linear"], { url: "https://mcp.linear.app/mcp", headers: { Authorization: "Bearer chan-token-1" }, publicOnly: true }); + assert.deepEqual(customMcpTokens(runMap).sort(), ["chan-token-1", "user-token-2"]); +}); + +test("a container run relays custom servers through the daemon with the public-only dial, never the token", async () => { + const customMcps = { "custom-linear": { url: "https://mcp.linear.app/mcp", token: "relay-secret-123" }, "my-notes": { url: "https://notes.example.com/mcp", token: "relay-secret-456" } }; + const payload = await buildMcpRuntimePayload({ ...identity, customMcps, target: createFakeRuntime().target() }); + assert.doesNotMatch(payload.configJson, /relay-secret|Bearer/); + const servers = JSON.parse(payload.configJson).mcpServers; + assert.equal(servers["custom-linear"].env.CG_MCP_SERVICE, "remote-mcp"); + assert.deepEqual(servers["my-notes"].args.slice(-1), ["my-notes"]); + const claims = verifyGatewayCapability(servers.gateway.env.CG_GATEWAY_CAPABILITY, { secret: process.env.CG_APPROVAL_SECRET }).claims; + assert.deepEqual(claims.remoteMcps, ["custom-linear", "my-notes"]); + assert.deepEqual(lookupRemoteMcp(claims.jti, "custom-linear"), { url: "https://mcp.linear.app/mcp", headers: { Authorization: "Bearer relay-secret-123" }, publicOnly: true }); + assert.ok(payload.relayDigest["my-notes"].startsWith("sha256:"), "a rotated token still retires a warm process"); + clearRemoteMcps(claims.jti); + + // A sudo-host turn dials it itself: a plain http entry with the header. + const host = JSON.parse((await buildMcpRuntimePayload({ ...identity, customMcps })).configJson).mcpServers; + assert.deepEqual(host["custom-linear"], { type: "http", url: "https://mcp.linear.app/mcp", headers: { Authorization: "Bearer relay-secret-123" } }); +}); + +test("the Claude lockdown admits each custom server by name and URL and pre-approves its tools", async () => { + const settings = admitCustomMcpsInSettings(await buildSettings({ _slug: "x" }), [{ name: "custom-linear", url: "https://mcp.linear.app/mcp" }]); + assert.ok(settings.allowedMcpServers.some((m) => m.serverName === "custom-linear")); + assert.ok(settings.allowedMcpServers.some((m) => m.serverUrl === "https://mcp.linear.app/mcp")); + assert.ok(settings.permissions.allow.includes("mcp__custom-linear")); + const fromMap = admitCustomMcpsInSettings({}, { "my-notes": { url: "https://notes.example.com/mcp", token: "t" } }); + assert.deepEqual(fromMap.allowedMcpServers, [{ serverName: "my-notes" }, { serverUrl: "https://notes.example.com/mcp" }]); + assert.ok(!JSON.stringify(fromMap).includes('"t"'), "only names and URLs reach the settings file"); + const nameOnly = admitCustomMcpsInSettings({}, [{ name: "custom-q" }]); + assert.deepEqual(nameOnly.allowedMcpServers, [{ serverName: "custom-q" }], "a container run's settings carry no URL"); +}); + +test("Codex relays custom servers in a container and uses a headers helper on the sudo host", () => { + const target = createFakeRuntime().target(); + const customMcps = { "custom-linear": { url: "https://mcp.linear.app/mcp", token: "codex-secret-789" } }; + const bundle = `${target.artifactDir}/run/codex-secrets.json`; + const args = buildCodexArgs({ prompt: "x", sessionId: "t", isNewSession: true, cwd: target.cwd, outFile: `${target.artifactDir}/tmp/o.txt`, target, customMcps, secretBundlePath: bundle, headerHelpers: [] }); + assert.ok(args.includes(`mcp_servers.custom-linear.env.CG_MCP_SERVICE="remote-mcp"`)); + assert.ok(!args.join("\n").includes("codex-secret-789")); + assert.ok(!args.some((arg) => arg.startsWith("mcp_servers.custom-linear.url="))); + assert.deepEqual(codexSecretBundle({ isolated: true, gatewayCapability: "cap", customMcps }), { gatewayCapability: "cap" }, "a container's bundle holds no custom token"); + + const host = hostBackend.prepareTarget({ slug: "sudo", cwd: "/work", workDir: "/work", cleanWorkDir: "", meta: { sudoMode: true }, settings: {} }); + const helpers = []; + const hostArgs = buildCodexArgs({ prompt: "x", sessionId: "t", isNewSession: true, cwd: "/work", outFile: "/tmp/o.txt", target: host, customMcps, secretBundlePath: "/tmp/b.json", headerHelpers: helpers }); + assert.ok(hostArgs.includes(`mcp_servers.custom-linear.url="https://mcp.linear.app/mcp"`)); + assert.deepEqual(helpers.map(({ secretName, headerName, prefix }) => ({ secretName, headerName, prefix })), [{ secretName: "customMcp:custom-linear", headerName: "Authorization", prefix: "Bearer " }]); + assert.equal(codexSecretBundle({ customMcps })["customMcp:custom-linear"], "codex-secret-789"); + const clean = buildCodexArgs({ prompt: "x", sessionId: "t", isNewSession: true, cwd: "/work", outFile: "/tmp/o.txt", target: host, clean: true, customMcps, secretBundlePath: "/tmp/b.json", headerHelpers: [] }); + assert.ok(!clean.some((arg) => arg.includes("custom-linear"))); +}); + +test("the public-only fetch pins the connection to the vetted records and never follows a redirect", async () => { + const seen = []; + const requestImpl = (url, options, onResponse) => { + seen.push({ url: url.href, options }); + const listeners = {}; + queueMicrotask(() => { + options.lookup(url.hostname, { all: true }, (err, records) => seen.push({ records })); + const { Readable } = process.getBuiltinModule("node:stream"); + const res = Readable.from([Buffer.from('{"ok":true}')]); + res.statusCode = url.pathname === "/redirect" ? 302 : 200; + res.statusMessage = "OK"; + res.rawHeaders = ["content-type", "application/json", ...(url.pathname === "/redirect" ? ["location", "https://127.0.0.1/"] : [])]; + onResponse(res); + }); + return { on: (event, fn) => { listeners[event] = fn; }, write: (body) => seen.push({ body }), end() {} }; + }; + const fetch = createPublicPinnedFetch({ resolve: async (href) => ({ url: new URL(href), addresses: [{ address: "93.184.215.14", family: 4 }] }), requestImpl }); + const response = await fetch("https://mcp.example.com/mcp", { method: "POST", headers: { Authorization: "Bearer abc", "content-type": "application/json" }, body: "{}" }); + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { ok: true }); + assert.equal(seen[0].options.method, "POST"); + assert.equal(seen[0].options.headers.authorization, "Bearer abc"); + const { globalAgent } = await import("node:https"); + assert.ok(seen[0].options.agent && seen[0].options.agent !== globalAgent, "never the global agent, which may tunnel through HTTPS_PROXY"); + assert.deepEqual(seen.find((entry) => entry.records).records, [{ address: "93.184.215.14", family: 4 }]); + const redirected = await fetch("https://mcp.example.com/redirect"); + assert.equal(redirected.status, 302, "a redirect is handed back, not followed"); + + const refusing = createPublicPinnedFetch({ resolve: async () => { throw new Error("mcp.example.com resolves to a private address"); }, requestImpl }); + await assert.rejects(refusing("https://mcp.example.com/mcp"), /private address/); +}); + +test("the relay asks for the public-only dial exactly when the registration says so", async () => { + const calls = []; + const connect = async (options) => { + calls.push(options); + throw new Error("stop here"); + }; + for (const publicOnly of [true, false]) { + await assert.rejects(runRemoteRelay({ url: "https://mcp.example.com/mcp", headers: {}, publicOnly, transport: {}, authorize: () => {}, connect }), /stop here/); + } + assert.equal(calls[0].publicOnly, true); + assert.equal(Object.hasOwn(calls[1], "publicOnly"), false); +}); + +test("revoking one custom server leaves the rest of a live grant untouched", () => { + const exp = Date.now() + 60_000; + registerRemoteMcps({ jti: "jti-a", exp, servers: { "composio-user": { url: "https://c.example.com/", headers: {} }, "custom-linear": { url: "https://l.example.com/", headers: {}, publicOnly: true } }, meta: { slug: "chan-a", authorId: "U1" } }); + registerRemoteMcps({ jti: "jti-b", exp, servers: { "custom-linear": { url: "https://l.example.com/", headers: {}, publicOnly: true } }, meta: { slug: "chan-b", authorId: "U1" } }); + assert.equal(revokeRemoteMcpServer((meta) => meta.slug === "chan-a", "custom-linear"), 1); + assert.equal(lookupRemoteMcp("jti-a", "custom-linear"), null); + assert.ok(lookupRemoteMcp("jti-a", "composio-user"), "the other servers keep working"); + assert.ok(lookupRemoteMcp("jti-b", "custom-linear"), "another channel's grant is untouched"); + clearRemoteMcps("jti-a"); + clearRemoteMcps("jti-b"); +}); + +// ── Admin routes ──────────────────────────────────────────────────────────────────────────────── +const { createAdminRouter } = await import("../src/web/routes/admin.js"); +const { defaultChannelMeta, getChannelMeta, getUser, saveChannelMeta, setUser, upsertChannelEntry } = await import("../src/config/store.js"); +const entry = await upsertChannelEntry("C_CMCP", { name: "custom-mcp-routes", type: "channel", isDM: false }); +await saveChannelMeta(entry.slug, defaultChannelMeta({ channelId: "C_CMCP", name: "custom-mcp-routes", type: "channel", isDM: false })); +await setUser("U_CMCP", { name: "Custom Person", approved: true }); +const app = express(); +app.use(express.json()); +app.use(createAdminRouter({ slack: { snapshot: () => ({ status: "disconnected", connected: false }) } })); +const server = await new Promise((resolve) => { const s = app.listen(0, "127.0.0.1", () => resolve(s)); }); +after(() => server.close()); +const base = `http://127.0.0.1:${server.address().port}`; +async function call(path, { method = "GET", body } = {}) { + const response = await fetch(base + path, { method, headers: body ? { "content-type": "application/json" } : {}, ...(body ? { body: JSON.stringify(body) } : {}) }); + return { status: response.status, json: await response.json() }; +} + +// A literal public address needs no DNS, so the save path's address check runs for real offline. +const PUBLIC_URL = "https://93.184.215.14/mcp"; + +test("channel routes save write-only, refuse internal addresses, mask every listing and remove", async () => { + const saved = await call("/channels/C_CMCP/custom-mcps/Linear", { method: "PUT", body: { url: PUBLIC_URL, token: "chan-route-secret-9876" } }); + assert.equal(saved.status, 200); + assert.deepEqual(saved.json.servers.map((s) => [s.serverName, s.tokenLast4]), [["custom-linear", "9876"]]); + assert.equal((await getChannelMeta(entry.slug)).customMcps[0].token, "chan-route-secret-9876"); + + const listed = await call("/channels"); + const channel = listed.json.channels.find((item) => item.channelId === "C_CMCP"); + assert.equal(channel.meta.customMcps[0].serverName, "custom-linear"); + assert.ok(!JSON.stringify(listed.json).includes("chan-route-secret"), "the channel list never carries the token"); + assert.ok(!JSON.stringify(await call("/channels/C_CMCP/custom-mcps")).includes("chan-route-secret")); + + const loop = await call("/channels/C_CMCP/custom-mcps/evil", { method: "PUT", body: { url: "https://127.0.0.1:4747/api", token: "abcdefgh1234" } }); + assert.equal(loop.status, 400); + assert.match(loop.json.error, /refused/); + const plain = await call("/channels/C_CMCP/custom-mcps/evil", { method: "PUT", body: { url: "http://93.184.215.14/", token: "abcdefgh1234" } }); + assert.equal(plain.status, 400); + + const urlOnly = await call("/channels/C_CMCP/custom-mcps/linear", { method: "PUT", body: { url: "https://93.184.215.14/v2" } }); + assert.equal(urlOnly.status, 200); + assert.equal((await getChannelMeta(entry.slug)).customMcps[0].token, "chan-route-secret-9876"); + + const removed = await call("/channels/C_CMCP/custom-mcps/linear", { method: "DELETE" }); + assert.deepEqual(removed.json.servers, []); + assert.equal((await call("/channels/NOPE/custom-mcps")).status, 404); +}); + +test("user routes keep a person's servers on their record, masked in the user list", async () => { + const saved = await call("/users/U_CMCP/custom-mcps/notes", { method: "PUT", body: { url: PUBLIC_URL, token: "user-route-secret-5555" } }); + assert.equal(saved.status, 200); + assert.equal(saved.json.servers[0].serverName, "my-notes"); + assert.equal((await getUser("U_CMCP")).customMcps[0].token, "user-route-secret-5555"); + const users = await call("/users"); + const flat = JSON.stringify(users.json); + assert.ok(flat.includes("my-notes")); + assert.ok(!flat.includes("user-route-secret"), "the user list never carries the token"); + const removed = await call("/users/U_CMCP/custom-mcps/notes", { method: "DELETE" }); + assert.deepEqual(removed.json.servers, []); + assert.equal((await call("/users/U_CMCP/custom-mcps/notes", { method: "DELETE" })).status, 400); +}); diff --git a/test/mcp-control-plane-approval.test.js b/test/mcp-control-plane-approval.test.js index 7f1b88dc..a5f2e682 100644 --- a/test/mcp-control-plane-approval.test.js +++ b/test/mcp-control-plane-approval.test.js @@ -639,3 +639,17 @@ test("the approved-result wrapper preserves mixed content, structured data, meta await server.close(); } }); + +test("list_channel_mcps names the conversation's custom MCP servers, never their URL or token", async () => { + const original = await getChannelMeta(SLUG); + try { + await saveChannelMeta(SLUG, { ...original, customMcps: [{ name: "linear", url: "https://mcp.example.com/mcp?key=url-secret", token: "list-token-secret" }] }); + await withGateway({}, async (client) => { + const listed = resultText(await client.callTool({ name: "list_channel_mcps", arguments: {} })); + assert.match(listed, /Custom MCP servers here .*custom-linear/); + assert.doesNotMatch(listed, /url-secret|list-token-secret|mcp\.example\.com/); + }); + } finally { + await saveChannelMeta(SLUG, original); + } +}); diff --git a/test/progress-report-availability.test.js b/test/progress-report-availability.test.js index f85c39dd..8a28ddcb 100644 --- a/test/progress-report-availability.test.js +++ b/test/progress-report-availability.test.js @@ -100,11 +100,18 @@ test("runMessage threads one per-channel Make toolbox through Claude, Codex, and // an interactive SSH session share — and threaded into run.js's MCP input from there. assert.match(integrations, /makeToolboxUrl:\s*meta\.makeToolboxUrl/); assert.match(integrations, /makeToolboxKey:\s*meta\.makeToolboxKey/); - assert.match(run, /makeToolboxUrl, makeToolboxKey, composioIdentityPrefix,\s*\} = await resolveRunIntegrations\(/); + assert.match(run, /makeToolboxUrl, makeToolboxKey, customMcps, composioIdentityPrefix,\s*\} = await resolveRunIntegrations\(/); assert.match(run, /const mcpRuntimeInput = \{[\s\S]*?makeToolboxUrl, makeToolboxKey/); // `target` rides alongside `engine`: mcp.js needs both to pick the gateway server entry (the // checkout's stdio server on the host, the in-container bridge for an isolated runtime). assert.match(engineMcp, /buildMcpRuntimePayload\(\{ \.\.\.identity, engine, target \}\)/); assert.ok((run.match(/makeToolboxUrl, makeToolboxKey/g) || []).length >= 2); assert.match(adapters, /makeToolboxUrl: r\.makeToolboxUrl, makeToolboxKey: r\.makeToolboxKey/); + // Custom MCP connections (gateway/custom-mcps.js) ride the same three paths: the MCP input, the + // primary runtime bag and the fallback's, plus Codex's adapter call and the Claude allowlist. + assert.match(integrations, /resolveCustomMcpsForRun\(/); + assert.match(run, /const mcpRuntimeInput = \{[\s\S]*?makeToolboxKey, customMcps,/); + assert.ok((run.match(/makeToolboxKey, customMcps/g) || []).length >= 3); + assert.match(run, /customMcpServers: Object\.entries\(customMcps\)\.map\(\(\[name, \{ url \}\]\) => \(\{ name, \.\.\.\(target\?\.backend === "host"/); + assert.match(adapters, /customMcps: r\.customMcps/); }); From 4a17dc85d97f7c486f9652c2ebbe08a606ca5190 Mon Sep 17 00:00:00 2001 From: Tiberiu Socaci Date: Thu, 1 Oct 2026 16:20:58 +0300 Subject: [PATCH 002/139] docs: add first team workflow guidance Signed-off-by: Tiberiu Socaci --- INSTALL.md | 32 ++++++++++++++++++++++++++++++++ README.md | 3 +++ 2 files changed, 35 insertions(+) diff --git a/INSTALL.md b/INSTALL.md index a06719fe..94a8828b 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -202,6 +202,38 @@ In the admin UI: message must @mention the bot (a DM needs no mention). - Switch engine (Claude ↔ Codex) any time in **Settings**. +### Set up a first team workflow + +For a shared workflow, start with one dedicated channel and one representative task: + +1. Invite the bot and the people who will use it. A private Slack channel limits who can see the + conversation in Slack; ChannelGate still checks each sender against its own user and channel + access settings. Approve users and choose the channel access policy in the admin UI. +2. Set the channel's working folder, tool mode and network access for that task. Keep ordinary + work in the channel container. Reserve the admin-only `/sudo` thread for host maintenance; + [the runtime boundaries](./docs/OPERATIONS.md#container-runtime) explain the difference. +3. Put the source documents the agent may use in the channel workspace, and add standing + instructions for durable rules. A thread resumes its own engine session; channel memory is + separate and can supply useful context to later threads in that conversation. Inspect and + correct saved memory as the workflow develops. Changes to channel instructions follow the + gateway's approval controls. +4. Turn a repeated procedure into a skill with a clear trigger and steps. Grant it to the channel + or its template. Review a third-party skill's source and trial it with oversight before using it + in scheduled work. Keep a workflow specific to one channel in that channel's skill section; + see the [skills guide](./docs/SKILLS.md). +5. Connect only the needed apps. Choose a requester's personal account for personal work and a + channel or organization account for shared work. Personal connections remain scoped to their + owner; the selected agent connection can serve the channel. Test the chosen account with a small, + representative request before relying on a larger automation. +6. Run that task in the channel, inspect the answer and requested approvals, then adjust the + instructions, skills and access settings from what actually happened. Check usage and audit + history in the admin UI when reviewing the pilot. + +Slack Socket Mode needs no public inbound URL. If operators need the admin UI or approval links +from outside the host, put a tunnel or reverse proxy in front of the loopback service and set +**Settings → Connection → Public URL** to its HTTPS origin. See +[reverse proxies and tunnels](./docs/OPERATIONS.md#reverse-proxies-and-tunnels). + ## Backup & restore config (encrypted) Your config (`~/.channelgate/config` + each channel's `meta.json`) holds the Slack and diff --git a/README.md b/README.md index f32c3e8f..54416b03 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,9 @@ prerequisite and run `npm run build:image` before sending the first prompt. explicit channel guest grants admit additional users. DMs require approval or admin status. 5. DM the bot, or invite it to a channel and mention it: `@channelgate summarize the files in this folder`. +For a shared deployment, follow the [first team workflow checklist](./INSTALL.md#set-up-a-first-team-workflow) +to set access, workspace context, skills and connected accounts before automating a recurring task. + For a service that starts at boot, follow the [systemd installation steps](./INSTALL.md#the-systemd-service-starts-at-boot-restarts-on-failure). The service uses its own account and needs credentials configured for that account. For other chat surfaces, follow the [Microsoft Teams and Google Chat setup guide](./docs/PLATFORMS.md). From 6fb9b286c076c993375cc8611131c394246a54c2 Mon Sep 17 00:00:00 2001 From: Tiberiu Socaci Date: Thu, 1 Oct 2026 16:33:59 +0300 Subject: [PATCH 003/139] docs: distinguish gateway channels from website deployments Signed-off-by: Tiberiu Socaci --- FEATURES.md | 6 ++++-- README.md | 17 +++++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/FEATURES.md b/FEATURES.md index 71d18de1..eb06a7ba 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -750,8 +750,10 @@ A categorized catalog of what's shipped. Cross-linked to `TEST-PLAN.md` checks. not a second code path. → TEST-PLAN: Qwen harnesses. ## Public website -- The marketing / early-access site (and its lead-routing contract) lives in its own - repository — this repository ships product code only. +- The public `channelgate.dev` website and customer portal are deployed separately on Vercel; + their source, lead routing and platform services are outside this repository. This repository + ships the self-hosted gateway daemon and its built-in local admin UI. A chat development channel is a + gateway conversation with its own workspace and container, not a Vercel deployment. - **Public entrypoint:** README leads with a descriptive product heading, team benefits and setup, followed by eight feature groups, practical use cases and explicit platform/engine support. Searchable product terms and descriptive documentation links cover self-hosted AI agents, Slack, diff --git a/README.md b/README.md index 54416b03..5ef99716 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,23 @@ For a service that starts at boot, follow the [systemd installation steps](./INS The service uses its own account and needs credentials configured for that account. For other chat surfaces, follow the [Microsoft Teams and Google Chat setup guide](./docs/PLATFORMS.md). +### The website, gateway and development channels + +These are separate parts of a ChannelGate setup: + +| Part | Where it runs | What it does | +| --- | --- | --- | +| **Public website and customer portal** (`channelgate.dev`) | Vercel, with platform services | Product information, account signup and license management. It is deployed separately from this repository. | +| **Gateway** | The operator's Linux host | Connects to chat, runs agent turns in conversation containers, and serves the built-in admin UI at `127.0.0.1:4747` by default. Operators may expose that UI through their own tunnel or reverse proxy. | +| **Development channel** | A conversation in a connected chat workspace | Has its own workspace, container, permissions and agent sessions. It is a place to work on code or websites, not a deployment target by itself. | + +Vercel can host a website built *using* a ChannelGate development channel; it does not host the +ChannelGate daemon or that channel's container. A website deployment and a gateway software update +are separate operations. A Vercel preview is a preview of the website, while `channelgate.dev` is +its production address; neither is a gateway development channel. See +[privacy and data flow](./docs/PRIVACY-AND-DATA-FLOW.md) for the specific requests a gateway sends +to the platform. + ## Full feature list These are the shipped product capabilities, grouped by the work they enable. Availability depends From 7f6a2cdff12f22b83efc8a1e37b9388a752bc270 Mon Sep 17 00:00:00 2001 From: Tiberiu Socaci Date: Fri, 2 Oct 2026 18:19:14 +0300 Subject: [PATCH 004/139] fix: recover runtime history transfers and report Google Chat failures Signed-off-by: Tiberiu Socaci --- CHANGELOG.md | 7 ++ FEATURES.md | 11 ++++ TEST-PLAN.md | 35 ++++++++++ src/gateway/run.js | 5 +- src/gateway/session-carry.js | 13 +++- src/platforms/boot.js | 5 +- src/platforms/googlechat/pubsub.js | 7 +- src/platforms/googlechat/transport.js | 9 ++- src/platforms/manager.js | 15 ++++- src/runtimes/container/carry.js | 33 +++++++--- src/runtimes/container/index.js | 8 +-- src/runtimes/contract.js | 9 +-- test/container-carry.test.js | 88 +++++++++++++++++++++++++ test/platform-googlechat.test.js | 26 ++++++++ test/platform-transport-manager.test.js | 48 ++++++++++++++ test/session-carry.live.test.js | 75 +++++++++++++++++++++ test/session-carry.test.js | 30 +++++++++ 17 files changed, 397 insertions(+), 27 deletions(-) create mode 100644 test/platform-transport-manager.test.js create mode 100644 test/session-carry.live.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index acbadf8e..4fb142d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,13 @@ product overview. ## Unreleased +- Fix a history-transfer deadlock when `/sudo` crosses a container whose mounts need recreation. + Transfer preparation now excludes its own lease, reports waits, handles Stop, and times out + into transcript recovery instead of hanging indefinitely. +- Google Chat now reports a fatal Pub/Sub receiver error as a failed connection, stops intake, + and gives an actionable Subscriber-role hint for permission errors. Reconnect restores status + without allowing an old connection's late error to overwrite it. + - Channel Runtime now shows only engine and model controls for the shared login. Dedicated Codex sign-in uses a method dropdown: choosing ChatGPT starts the device flow and clicking its code copies it; the API key field appears only for that method. The shared sign-in moved to Settings. diff --git a/FEATURES.md b/FEATURES.md index eb06a7ba..1c141a23 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -1,5 +1,16 @@ # ChannelGate — Features +## Runtime history transfer and Google Chat failure recovery + +- Switching a thread between its container and `/sudo` host carries native history without + treating the transfer's own lease as an active occupant that blocks container recreation. + Preparation reports waits, responds to Stop, and falls back to transcript recovery after a + bounded preparation timeout. An explicit Stop never starts a replacement engine turn. +- Fatal Google Chat receiver errors stop intake and mark the connection as failed; health and + connector availability no longer report a stopped receiver as connected. Pub/Sub permission + failures name the Subscriber role and reconnect action. Reconnecting clears the failure, and + callbacks from older connections cannot overwrite the replacement connection's state. + ## Channel-specific Codex authentication - Runtime settings ask which Codex login a channel uses. The shared choice shows the channel's diff --git a/TEST-PLAN.md b/TEST-PLAN.md index 49794e35..c450d2df 100644 --- a/TEST-PLAN.md +++ b/TEST-PLAN.md @@ -1,5 +1,40 @@ # ChannelGate — Test Plan +## Runtime history transfer and Google Chat failure recovery (2026-10-02) + +- [x] Automated: `test/session-carry.test.js`, `test/container-carry.test.js`, + `test/container-lifecycle.test.js`, and `test/runtime-integration-run.test.js` cover caller-lease + forwarding through container→host transfer, stale mount recreation, lease release, Stop during + preparation, timeout fallback, and cancellation without starting a healed turn. +- [x] Automated: `test/platform-googlechat.test.js` and `test/platform-transport-manager.test.js` + cover a real puller receiving a simulated 403 after startup, stopped inbox dispatch, failed + connection status and absent live connector, reconnect recovery, early failure during startup, + and stale failure callbacks after reconnect/disconnect. This transport behavior is engine-independent. +- [x] Live container fixture proof: `CG_LIVE_CONTAINER=1 node --test + test/session-carry.live.test.js` passed with real Podman and the runtime image. Disposable + Claude and Codex native-layout fixture files roundtripped host→container→host; changing the + clean-workspace bind source recreated the container while its carry lease was held. Bytes + survived both rebuilds, leases returned to zero, and the disposable container/volume were + removed. This verifies file movement and lifecycle, not real-engine conversation acceptance. +- [ ] Live Claude and Codex acceptance (separate runs): use a disposable private admin test + channel with the authorized admin test actor, pin the engine, and send “Remember carry-check-42 + for the next message.” After its reply, change a fixture workspace mount without destroying its + persistent HOME; enable `/sudo` in the same thread and ask “What marker did I give you?”. Pass: + container recreation and native history transfer finish, the answer contains the marker, and + no indefinite “waiting for active runs” occurs. Repeat host→container after `/sudo off`. + With another fixture occupant holding a lease, repeat and press Stop during the preparation + wait. Pass: cancellation releases the transfer lease, starts no engine, and the next turn runs. + Copy commands remain bounded by their existing 120-second CLI timeout; cancellation during an + already executing copy is checked on completion, whereas readiness waits cancel immediately. +- [ ] Live Google Chat acceptance (engine-independent): use a dedicated test subscription and + service account without Subscriber, connect, then poll settings/health. Pass: status becomes + error with a Subscriber/reconnect remedy and no live connector remains. Grant Subscriber on + that subscription, reconnect, and send one test message; pass: exactly one reply. A separate + healthy Slack fixture must continue answering throughout. +- [ ] Private QA catalog: mirror the preceding two engine cases and the engine-independent + Google case to the requesting maintainer's selected personal Airtable connection. Pending + explicit connection-use authorization; automated results are not a claim of live acceptance. + ## Channel-specific Codex authentication (2026-09-29) - [x] Automated: Chromium confirms the shared-login channel view has no gateway status/sign-in diff --git a/src/gateway/run.js b/src/gateway/run.js index c1f35a1a..f9aff60a 100644 --- a/src/gateway/run.js +++ b/src/gateway/run.js @@ -979,7 +979,10 @@ export async function runMessage({ channelId, authorId, workspaceId = "", text, let carried = null; try { carried = await carrySession({ - engine, sessionId, cwd, storedRuntime: sessionRuntime, target, slug: entry.slug, threadKey, + engine, sessionId, cwd, storedRuntime: sessionRuntime, target, slug: entry.slug, threadKey, signal, + announce: (text) => { + try { onEvent?.({ kind: "notice", scope: "gateway", text }); } catch { /* best-effort progress */ } + }, }); } finally { clearTimeout(carryTimer); diff --git a/src/gateway/session-carry.js b/src/gateway/session-carry.js index ae94ec16..43fb226a 100644 --- a/src/gateway/session-carry.js +++ b/src/gateway/session-carry.js @@ -59,6 +59,8 @@ export function buildCarryEntries({ engine, cwd, sessionId, fromDir, toDir }) { * @param {string} [options.threadKey] for the log line * @param {Function} [options.resolveFor] resolveRuntime, injectable for tests * @param {Function} [options.log] console.log, injectable for tests + * @param {AbortSignal} [options.signal] cancellation of the turn, including container preparation + * @param {Function} [options.announce] progress while the source or destination container is prepared * @returns {Promise<{direction: string, files: number}|null>} null when nothing was carried */ export async function carrySession({ @@ -71,6 +73,8 @@ export async function carrySession({ threadKey = "", resolveFor = resolveRuntime, log = console.log, + signal = null, + announce = null, } = {}) { const where = `${slug}/${threadKey}`; const to = String(target?.backend || "host"); @@ -79,6 +83,7 @@ export async function carrySession({ if (!sessionId || !target || from === to) return null; try { + signal?.throwIfAborted(); if (!engineSessionState(engine)) { log(`[gateway] ${where}: ${engine} does not declare where it keeps a session, so its history stays on ${from} — the resume falls back to the existing heal`); return null; @@ -106,16 +111,20 @@ export async function carrySession({ let result; try { result = to === "host" - ? await mover.runtime.copyOut(mover, entries) - : await mover.runtime.copyIn(mover, entries); + ? await mover.runtime.copyOut(mover, entries, { lease, signal, announce }) + : await mover.runtime.copyIn(mover, entries, { lease, signal, announce }); } finally { lease.release(); } + signal?.throwIfAborted(); const files = Number(result?.copied) || 0; if (!files) return null; log(`[gateway] ${where}: carried ${engine} session ${sessionId} ${direction} (${files} file${files === 1 ? "" : "s"})`); return { direction, files }; } catch (error) { + // A user Stop must end the turn, never fall through to a fresh engine attempt. + signal?.throwIfAborted(); + if (error?.name === "AbortError") throw error; log(`[gateway] ${where}: session carry-over failed (${error?.message || error}) — the resume falls back to the existing heal`); return null; } diff --git a/src/platforms/boot.js b/src/platforms/boot.js index 4e76cd85..1c61500b 100644 --- a/src/platforms/boot.js +++ b/src/platforms/boot.js @@ -21,7 +21,7 @@ import { startTeams, botIdFor } from "./msteams/transport.js"; // The connector is built BEFORE the transport starts, on purpose: a Pub/Sub pull can deliver its // first event inside start(), and an ingest that does not exist yet would drop that message. Wiring // it up front means the very first message is answerable. -async function startGoogleChatTransport(config, log) { +async function startGoogleChatTransport(config, log, { onFatal } = {}) { const capabilities = requirePlatform("googlechat").capabilities; const auth = createGoogleAuth({ serviceAccount: config.serviceAccountJson }); const api = createChatApi({ auth }); @@ -31,6 +31,7 @@ async function startGoogleChatTransport(config, log) { ...config, capabilities, onMessage: ingest, + onFatal, log, deps: { auth, api, connector }, }); @@ -49,7 +50,7 @@ async function startTeamsTransport(config, log) { } export function createPlatformTransports({ log = console } = {}) { - const googlechat = createTransportManager({ platform: "googlechat", log, start: (config) => startGoogleChatTransport(config, log) }); + const googlechat = createTransportManager({ platform: "googlechat", log, start: (config, lifecycle) => startGoogleChatTransport(config, log, lifecycle) }); const msteams = createTransportManager({ platform: "msteams", log, start: (config) => startTeamsTransport(config, log) }); registerTransport("googlechat", googlechat); registerTransport("msteams", msteams); diff --git a/src/platforms/googlechat/pubsub.js b/src/platforms/googlechat/pubsub.js index 0c4384e0..b25cce0c 100644 --- a/src/platforms/googlechat/pubsub.js +++ b/src/platforms/googlechat/pubsub.js @@ -72,7 +72,12 @@ export function createPubSubPuller({ }); if (!res.ok) { const text = await res.text().catch(() => ""); - const err = new Error(`Pub/Sub ${path} failed (${res.status}): ${text.slice(0, 300)}`); + let detail = text.slice(0, 300); + try { detail = String(JSON.parse(text).error?.message || detail).slice(0, 300); } catch { /* non-JSON response */ } + const remedy = res.status === 403 + ? ` Check that the configured service account has Pub/Sub Subscriber on ${subscription}, then reconnect Google Chat.` + : ""; + const err = new Error(`Pub/Sub ${path} failed (${res.status}): ${detail}${remedy}`); err.status = res.status; // 401/403/404 are configuration facts, not weather: a revoked key, a service account without // roles/pubsub.subscriber, or a subscription that does not exist. Backing off and retrying diff --git a/src/platforms/googlechat/transport.js b/src/platforms/googlechat/transport.js index e3e27442..72ada4bf 100644 --- a/src/platforms/googlechat/transport.js +++ b/src/platforms/googlechat/transport.js @@ -14,6 +14,7 @@ export async function startGoogleChat({ capabilities, onMessage, onBotUserId = null, + onFatal = null, log = console, deps = {}, } = {}) { @@ -62,8 +63,12 @@ export async function startGoogleChat({ return inbox.accept({ id, conversationId, payload: { event, botId: selfId } }); } - const puller = deps.puller || createPubSubPuller({ auth, subscription, onEvent, log, - onFatal: (err) => log.error?.(`[googlechat] transport stopped: ${err?.message || err}`) }); + const puller = deps.puller || createPubSubPuller({ auth, subscription, onEvent, log, fetchImpl: deps.fetchImpl, + onFatal: (err) => { + inbox.stop(); + log.error?.(`[googlechat] transport stopped: ${err?.message || err}`); + onFatal?.(err); + } }); inbox.start(); try { puller.start(); } catch (error) { inbox.stop(); throw error; } diff --git a/src/platforms/manager.js b/src/platforms/manager.js index c6667de1..2a8604de 100644 --- a/src/platforms/manager.js +++ b/src/platforms/manager.js @@ -57,13 +57,22 @@ export function createTransportManager({ platform, start, log = console } = {}) error = null; await stopTransport(previous); try { - const started = await start(config); + // Fatal errors can arrive during start() or after it resolves. Bind them to + // this generation so a late failure from an old connection cannot poison its replacement. + let failed = false; + const onFatal = (err) => { + if (gen !== generation) return; + failed = true; + status = "error"; + error = err?.message || String(err); + }; + const started = await start(config, { onFatal }); if (gen !== generation) { await stopTransport(started); return snapshot(); } current = started; - status = "connected"; + if (!failed) status = "connected"; } catch (err) { log.error?.(`[${platform}] connect failed: ${err?.message || err}`); if (gen === generation) { @@ -90,7 +99,7 @@ export function createTransportManager({ platform, start, log = console } = {}) connect, disconnect, snapshot, - getConnector: () => current?.connector ?? null, + getConnector: () => status === "connected" ? current?.connector ?? null : null, // The live transport, for the few things that need more than a connector (the Teams webhook // handler the Express route mounts, the Chat puller's health). getTransport: () => current, diff --git a/src/runtimes/container/carry.js b/src/runtimes/container/carry.js index 5d8ddb44..f536d585 100644 --- a/src/runtimes/container/carry.js +++ b/src/runtimes/container/carry.js @@ -137,12 +137,29 @@ export function createContainerCarry({ exec, lifecycle, log = () => {} } = {}) { // A carry may run against a container the idle reaper has stopped — that is rather the point: // the history lives in a volume that outlives the container. Bringing it up is the backend's own // job (the caller holds a lease around the whole carry so the reaper cannot stop it again - // mid-copy). `retry` keeps the out-of-band-removal self-heal every other exec has. - async function runScript(target, script) { - await lifecycle.ensureUp(target, {}); - return exec.runExec(target, [target.container.name, "/bin/sh", "-c", script], { + // mid-copy). Forward that lease so a mount rebuild never waits on its own caller. + async function runScript(target, script, { lease = null, signal = null, announce = null } = {}) { + signal?.throwIfAborted(); + // A busy old container must not strand history transfer indefinitely. Timeout is a + // recoverable carry failure; an explicit Stop remains cancellation of the whole turn. + const timeout = AbortSignal.timeout(CARRY_TIMEOUT_MS); + try { + await lifecycle.ensureUp(target, { lease, announce, signal: signal ? AbortSignal.any([signal, timeout]) : timeout }); + } catch (error) { + signal?.throwIfAborted(); + // Lifecycle labels all cancelled waits AbortError; preserve a timeout as a carry failure. + if (timeout.aborted) throw timeout.reason; + throw error; + } + signal?.throwIfAborted(); + const result = await exec.runExec(target, [target.container.name, "/bin/sh", "-c", script], { timeoutMs: CARRY_TIMEOUT_MS, + // The generic exec retry prepares without the carry lease. A vanished container is + // instead a recoverable carry failure. CLI exec itself is bounded, not abortable. + retry: false, }); + signal?.throwIfAborted(); + return result; } function cleanup(dir) { @@ -153,7 +170,7 @@ export function createContainerCarry({ exec, lifecycle, log = () => {} } = {}) { } } - async function copyIn(target, entries = []) { + async function copyIn(target, entries = [], opts = {}) { if (!target?.artifactDir) throw new Error("this target has no artifact dir — a carry has nowhere to stage"); const stagingDir = carryStagingDir(target, newCarryId()); try { @@ -171,7 +188,7 @@ export function createContainerCarry({ exec, lifecycle, log = () => {} } = {}) { } } if (!pairs.length) return { copied: 0 }; - const result = await runScript(target, buildCopyInScript(pairs)); + const result = await runScript(target, buildCopyInScript(pairs), opts); if (result.code !== 0) { throw new Error(`copying session state into ${target.container.name} failed: ${String(result.stderr || "").trim() || `exit ${result.code}`}`); } @@ -181,12 +198,12 @@ export function createContainerCarry({ exec, lifecycle, log = () => {} } = {}) { } } - async function copyOut(target, entries = []) { + async function copyOut(target, entries = [], opts = {}) { if (!target?.artifactDir) throw new Error("this target has no artifact dir — a carry has nowhere to stage"); const stagingDir = carryStagingDir(target, newCarryId()); try { mkdirSync(stagingDir, { recursive: true, mode: 0o700 }); - const result = await runScript(target, buildCopyOutScript(entries, stagingDir)); + const result = await runScript(target, buildCopyOutScript(entries, stagingDir), opts); if (result.code !== 0) { throw new Error(`copying session state out of ${target.container.name} failed: ${String(result.stderr || "").trim() || `exit ${result.code}`}`); } diff --git a/src/runtimes/container/index.js b/src/runtimes/container/index.js index 1a553523..0957d5ec 100644 --- a/src/runtimes/container/index.js +++ b/src/runtimes/container/index.js @@ -257,12 +257,12 @@ export const containerBackend = Object.freeze({ // The carry pair (contract.js OPTIONAL_METHODS): a thread's engine-native history follows it // when its channel changes runtime backend. Both halves go through the bind-mounted artifact // dir — the daemon cannot touch the HOME volume itself. See ./carry.js. - async copyIn(target, entries) { - return runtime().carry.copyIn(target, entries); + async copyIn(target, entries, opts = {}) { + return runtime().carry.copyIn(target, entries, opts); }, - async copyOut(target, entries) { - return runtime().carry.copyOut(target, entries); + async copyOut(target, entries, opts = {}) { + return runtime().carry.copyOut(target, entries, opts); }, // The relayed Codex login file (contract.js OPTIONAL_METHODS): staged through the artifact dir diff --git a/src/runtimes/contract.js b/src/runtimes/contract.js index a96672a6..cfcbac00 100644 --- a/src/runtimes/contract.js +++ b/src/runtimes/contract.js @@ -57,9 +57,10 @@ export const OPTIONAL_METHODS = Object.freeze([ // authenticate in this runtime?" gate. The local runtime has none: the daemon's own logins are // right there. "credentialError", - // copyIn(target, entries) / copyOut(target, entries) → Promise<{ copied: number }> — move + // copyIn(target, entries, opts) / copyOut(target, entries, opts) → Promise<{ copied: number }> — move // ENGINE STATE between the daemon's filesystem and this runtime's. `copyIn` writes into the - // runtime, `copyOut` reads out of it; both take CarryEntry[] (src/runtimes/copy.js) whose + // runtime, `copyOut` reads out of it. opts carries { lease, signal, announce } into preparation. + // Both take CarryEntry[] (src/runtimes/copy.js) whose // `from`/`to` are absolute on their own side and share the relative tail `rel`. They exist so a // thread's engine-native history follows it when its channel changes runtime backend — see // src/gateway/session-carry.js. A backend that declares neither simply cannot carry, and the @@ -202,11 +203,11 @@ export const HELPER_COMMANDS = Object.freeze([ * image's /opt/channelgate bundle). Unknown names throw. * * OPTIONAL (see OPTIONAL_METHODS): - * copyIn(target, entries) → Promise<{ copied }> — write engine state from the DAEMON's filesystem + * copyIn(target, entries, opts) → Promise<{ copied }> — write engine state from the DAEMON's filesystem * into this runtime. Host: a plain node:fs copy. Container: stage under the * bind-mounted artifact dir, then one exec inside that copies each entry * into place; the staging dir is removed either way. - * copyOut(target, entries) → Promise<{ copied }> — the same in reverse. Overwrite, never delete, and a + * copyOut(target, entries, opts) → Promise<{ copied }> — the same in reverse. Overwrite, never delete, and a * missing source is 0 copied rather than an error. * inspectState(target, request) → Promise<[{ path, mtimeMs, head }]> — the files inside this runtime that * match `request.globs` (carry-style: `*` inside a segment, never across diff --git a/test/container-carry.test.js b/test/container-carry.test.js index 1eda1461..5a86ddf1 100644 --- a/test/container-carry.test.js +++ b/test/container-carry.test.js @@ -224,3 +224,91 @@ test("boot: the idle reaper runs even with the gateway switch off, so a carry ca __resetContainerRuntime(); } }); + +test("sudo history carry rebuilds stale mounts without waiting on its own lease", async () => { + const { createFakeCli, inspectLine } = await import("./container-fake-cli.js"); + const { createContainerCli } = await import("../src/runtimes/container/cli.js"); + const { createContainerImage } = await import("../src/runtimes/container/image.js"); + const { createContainerLifecycle } = await import("../src/runtimes/container/lifecycle.js"); + const { createContainerReaper } = await import("../src/runtimes/container/reaper.js"); + const { currentInstallId } = await import("../src/runtimes/container/names.js"); + const { carrySession } = await import("../src/gateway/session-carry.js"); + const h = harness("carry-rebuild"); + const fake = createFakeCli({ routes: [ + { match: (a) => a[1] === "image" && a[2] === "inspect", result: { code: 0, stdout: "sha256:img|1.0.0" } }, + { match: (a) => a[1] === "inspect", result: { code: 0, stdout: inspectLine({ + name: h.target.container.name, install: currentInstallId(), fingerprint: "old", mountFingerprint: "old-mounts", + }) } }, + ] }); + const cli = createContainerCli({ exec: fake.exec }); + const reaper = createContainerReaper(); + const lifecycle = createContainerLifecycle({ cli, image: createContainerImage({ cli }), reaper, + sleep: async () => { throw new Error("carry must not wait for its own lease"); }, + }); + const carry = createContainerCarry({ lifecycle, exec: { async runExec() { return { code: 0 }; } } }); + const container = { ...h.target, runtime: { acquireLease: reaper.acquireLease, ...carry } }; + const result = await carrySession({ + engine: "claude", sessionId: SESSION, cwd: h.target.cwd, storedRuntime: { backend: "container" }, + target: { ...h.target, backend: "host", container: null }, resolveFor: () => container, log() {}, + }); + // Empty fake history is fine; the real lifecycle must still have recreated the old container. + assert.equal(result, null); + assert.equal(fake.find("rm").length, 1); + assert.ok(fake.find("run").some((args) => args.includes("--name") && args.includes(h.target.container.name))); + assert.equal(reaper.leaseCount(h.target.container.name), 0); +}); + +test("carry cancellation during preparation cleans staging and never executes the copy", async () => { + const h = harness("carry-cancel"); + const controller = new AbortController(); + const lease = { id: "carry:one" }; + const notices = []; + const carry = createContainerCarry({ + lifecycle: { async ensureUp(_target, opts) { + assert.equal(opts.lease, lease); + opts.announce("Waiting for another run"); + controller.abort(); + opts.signal.throwIfAborted(); + } }, + exec: { async runExec() { assert.fail("cancelled preparation must not copy"); } }, + }); + await assert.rejects(carry.copyOut(h.target, [], { lease, signal: controller.signal, announce: (s) => notices.push(s) }), { name: "AbortError" }); + assert.deepEqual(notices, ["Waiting for another run"]); + assert.deepEqual(walkFiles(path.join(h.target.artifactDir, "carry")), []); +}); + +test("carry preparation timeout remains a recoverable failure instead of a user Stop", async (t) => { + const timeout = new AbortController(); + timeout.abort(new DOMException("Carry preparation timed out", "TimeoutError")); + t.mock.method(AbortSignal, "timeout", () => timeout.signal); + const h = harness("carry-timeout"); + const carry = createContainerCarry({ + lifecycle: { async ensureUp() { + // The lifecycle deliberately uses AbortError for every cancelled container wait. + throw new DOMException("Run aborted while waiting", "AbortError"); + } }, + exec: { async runExec() { assert.fail("timed out preparation must not copy"); } }, + }); + await assert.rejects(carry.copyOut(h.target, []), { name: "TimeoutError" }); + assert.deepEqual(walkFiles(path.join(h.target.artifactDir, "carry")), []); +}); + +test("Stop during bounded copy prevents copied history being applied on the host", async () => { + const h = harness("carry-stop-copy"); + const controller = new AbortController(); + const destination = path.join(tempDir("carry-stopped-host-"), "session.jsonl"); + const carry = createContainerCarry({ + lifecycle: { async ensureUp() {} }, + exec: { async runExec(_target, _args, opts) { + assert.equal(opts.retry, false); + assert.equal(opts.timeoutMs, CARRY_TIMEOUT_MS); + controller.abort(); + return { code: 0 }; + } }, + }); + await assert.rejects(carry.copyOut(h.target, [{ rel: "session.jsonl", kind: "file", from: "/inside/session.jsonl", to: destination }], { + signal: controller.signal, + }), { name: "AbortError" }); + assert.equal(existsSync(destination), false); + assert.deepEqual(walkFiles(path.join(h.target.artifactDir, "carry")), []); +}); diff --git a/test/platform-googlechat.test.js b/test/platform-googlechat.test.js index 9192ec67..43e2629f 100644 --- a/test/platform-googlechat.test.js +++ b/test/platform-googlechat.test.js @@ -13,6 +13,7 @@ import { createPubSubPuller, createDedupe, isSubscriptionName } from "../src/pla import { parseChatEvent, normalizeMessage, mentionsBot, resolveThreadKey, createSeenThreads } from "../src/platforms/googlechat/events.js"; import { createGoogleChatConnector, toSpaceName } from "../src/platforms/googlechat/connector.js"; import { googleChatAdapter } from "../src/platforms/googlechat.js"; +import { createTransportManager } from "../src/platforms/manager.js"; import { startGoogleChat } from "../src/platforms/googlechat/transport.js"; ensureTestEnv(); @@ -445,3 +446,28 @@ test("stopping a puller never waits for a blocked handler or dispatches the rest await loop; assert.deepEqual(seen, [1]); }); + + +test("a live Google pull permission failure reaches manager health and stops inbox dispatch", async () => { + let failPull; + const response = new Promise((resolve) => { failPull = resolve; }); + let inboxStopped = false; + const manager = createTransportManager({ platform: "googlechat", log: { error() {} }, + start: (_config, { onFatal }) => startGoogleChat({ + subscription: "projects/cg-test/subscriptions/chat", onMessage() {}, onFatal, + capabilities: googleChatAdapter.capabilities, log: { info() {}, error() {} }, + deps: { auth: fakeAuth(), api: fakeChatApi(), connector: {}, + inbox: { start() {}, stop() { inboxStopped = true; } }, + fetchImpl: async () => response }, + }), + }); + assert.equal((await manager.connect({})).connected, true); + failPull(fail(403, JSON.stringify({ error: { message: "User not authorized" } }))); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(manager.snapshot().status, "error"); + assert.equal(manager.getConnector(), null); + assert.match(manager.snapshot().error, /Pub\/Sub Subscriber.*reconnect Google Chat/); + assert.equal(inboxStopped, true); + assert.equal(manager.getTransport().puller.running, false); + await manager.disconnect(); +}); diff --git a/test/platform-transport-manager.test.js b/test/platform-transport-manager.test.js new file mode 100644 index 00000000..74ce233b --- /dev/null +++ b/test/platform-transport-manager.test.js @@ -0,0 +1,48 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createTransportManager } from "../src/platforms/manager.js"; + +const quiet = { error() {} }; + +test("a runtime transport failure removes the live connector and reconnect clears the error", async () => { + const callbacks = []; + let stopped = 0; + const manager = createTransportManager({ platform: "fixture", log: quiet, + start: async (_config, { onFatal }) => { + callbacks.push(onFatal); + return { connector: { id: callbacks.length }, stop: async () => { stopped++; } }; + }, + }); + assert.equal((await manager.connect({})).connected, true); + callbacks[0](new Error("permission denied")); + assert.equal(manager.snapshot().status, "error"); + assert.equal(manager.snapshot().connected, false); + assert.equal(manager.snapshot().error, "permission denied"); + assert.equal(manager.getConnector(), null); + assert.equal((await manager.connect({})).connected, true); + assert.equal(stopped, 1); + assert.equal(manager.snapshot().error, null); + callbacks[0](new Error("stale failure")); + assert.equal(manager.snapshot().connected, true); + assert.equal(manager.getConnector().id, 2); + await manager.disconnect(); + callbacks[1](new Error("late failure after disconnect")); + assert.equal(manager.snapshot().status, "disconnected"); + assert.equal(stopped, 2); +}); + +test("a failure during start cannot be overwritten by connected status", async () => { + let stopped = false; + const manager = createTransportManager({ platform: "fixture", log: quiet, + start: async (_config, { onFatal }) => { + onFatal(new Error("first pull denied")); + return { connector: {}, stop: async () => { stopped = true; } }; + }, + }); + const snapshot = await manager.connect({}); + assert.equal(snapshot.status, "error"); + assert.equal(snapshot.error, "first pull denied"); + assert.equal(manager.getConnector(), null); + await manager.disconnect(); + assert.equal(stopped, true); +}); diff --git a/test/session-carry.live.test.js b/test/session-carry.live.test.js new file mode 100644 index 00000000..392861a5 --- /dev/null +++ b/test/session-carry.live.test.js @@ -0,0 +1,75 @@ +// Fixture-only live proof: no engine prompt, real login or external message is sent. +// Real disposable containers prove both native history layouts survive a mount rebuild while +// carrySession holds its own lease. Opt in with CG_LIVE_CONTAINER=1. +import test from "node:test"; +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import { mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs"; +import path from "node:path"; +import { ensureTestEnv, tempDir } from "./helpers.js"; + +ensureTestEnv(); +const LIVE = process.env.CG_LIVE_CONTAINER === "1"; + +test("live: Claude and Codex fixture history roundtrips through a container rebuild with the carry lease held", { + skip: !LIVE && "opt-in: CG_LIVE_CONTAINER=1", timeout: 180_000, +}, async (t) => { + const { __setContainerRuntime, __resetContainerRuntime, stopContainerRuntime, containerBackend } = await import("../src/runtimes/container/index.js"); + const { resolveRuntime } = await import("../src/runtimes/resolve.js"); + const { carrySession } = await import("../src/gateway/session-carry.js"); + const { engineStateDir, engineSessionFiles } = await import("../src/engines/registry.js"); + const settings = { + cli: "auto", image: process.env.CG_LIVE_IMAGE || "channelgate/runtime:latest", + idleMinutes: 10, maxRunning: 8, pidsLimit: 1024, hasClaudeOauthToken: true, + }; + const runtime = __setContainerRuntime({ log() {} }); + const slug = `cg-carry-fixtures-${randomUUID().slice(0, 8)}`; + const target = resolveRuntime(slug, { platform: "slack", channelId: "C-CARRY-FIXTURE" }, { settings }); + const host = { ...target, backend: "host", container: null }; + let mayHaveContainer = false; + try { + const caps = await runtime.cli.probe(settings, { image: settings.image }); + if (!caps.ok) return t.skip(caps.reason); + const image = await runtime.image.inspect(caps, settings); + if (!image.present) return t.skip(image.reason); + const inspectId = async () => { + const result = await runtime.cli.runWith(caps, ["inspect", "--type", "container", "--format", "{{.Id}}", target.container.name]); + assert.equal(result.code, 0, result.stderr); + return String(result.stdout).trim(); + }; + mayHaveContainer = true; + for (const engine of ["claude", "codex"]) { + const sessionId = randomUUID(); + const rel = engine === "claude" + ? engineSessionFiles(engine, { cwd: target.cwd, sessionId })[0].rel + : `sessions/2026/10/02/rollout-fixture-${sessionId}.jsonl`; + const fixture = path.join(engineStateDir(engine, host), rel); + const body = `${JSON.stringify({ fixture: true, engine, sessionId })}\n`; + mkdirSync(path.dirname(fixture), { recursive: true }); + writeFileSync(fixture, body); + assert.deepEqual(await carrySession({ + engine, sessionId, cwd: target.cwd, storedRuntime: { backend: "host" }, target, + resolveFor: () => host, log() {}, + }), { direction: "host→container", files: 1 }); + const before = await inspectId(); + rmSync(fixture); + // Change a real bind source while retaining cwd and the persistent HOME volume. + target.cleanWorkDir = tempDir(`cg-carry-new-clean-${engine}-`); + assert.deepEqual(await carrySession({ + engine, sessionId, cwd: target.cwd, storedRuntime: { backend: "container" }, target: host, + resolveFor: () => target, log() {}, + }), { direction: "container→host", files: 1 }); + assert.notEqual(await inspectId(), before, `${engine}: stale mounts must recreate the container`); + assert.equal(readFileSync(fixture, "utf8"), body); + assert.equal(runtime.reaper.leaseCount(target.container.name), 0); + t.diagnostic(`${engine}: fixture roundtrip preserved bytes across a real container rebuild`); + } + } finally { + try { + if (mayHaveContainer) await containerBackend.destroy(target, { volumes: true, reason: "disposable session fixture cleanup" }); + } finally { + stopContainerRuntime(); + __resetContainerRuntime(); + } + } +}); diff --git a/test/session-carry.test.js b/test/session-carry.test.js index f36e342a..41bde669 100644 --- a/test/session-carry.test.js +++ b/test/session-carry.test.js @@ -300,3 +300,33 @@ test("carry: entries pair the engine's relative paths with both state dirs, shar // Identical state dirs mean there is no boundary to cross. assert.deepEqual(buildCarryEntries({ engine: "codex", cwd: "/w", sessionId: SESSION, fromDir: "/same", toDir: "/same" }), []); }); + +test("carry: cancellation propagates and releases its lease instead of healing", async () => { + const h = carryHarness("cancel"); + const controller = new AbortController(); + const announce = () => {}; + h.containerTarget.runtime.copyIn = async (_target, _entries, opts) => { + assert.equal(opts.signal, controller.signal); + assert.equal(opts.announce, announce); + assert.equal(typeof opts.lease.release, "function"); + controller.abort(); + throw controller.signal.reason; + }; + await assert.rejects(carrySession({ + engine: "claude", sessionId: SESSION, cwd: "/w/cancel", storedRuntime: "host", + target: h.containerTarget, signal: controller.signal, announce, log: h.log, + }), { name: "AbortError" }); + assert.equal(h.calls.leases[0].released, true); + assert.deepEqual(h.logs, []); +}); + +test("carry: a stopped turn never acquires a lease or starts copying", async () => { + const h = carryHarness("pre-cancel"); + const controller = new AbortController(); + controller.abort(); + await assert.rejects(carrySession({ + engine: "claude", sessionId: SESSION, cwd: "/w/cancel", target: h.containerTarget, + signal: controller.signal, log: h.log, + }), { name: "AbortError" }); + assert.equal(h.calls.leases.length, 0); +}); From 9e6be40542fd66ec2f85f5d96acc41ac51ee6acc Mon Sep 17 00:00:00 2001 From: Tiberiu Socaci Date: Sat, 3 Oct 2026 09:06:13 +0300 Subject: [PATCH 005/139] docs: clarify Google Chat Pub/Sub setup Signed-off-by: Tiberiu Socaci --- docs/PLATFORMS.md | 60 ++++++++++++++++++++++++++++++++++------------- 1 file changed, 44 insertions(+), 16 deletions(-) diff --git a/docs/PLATFORMS.md b/docs/PLATFORMS.md index 626dae3a..75ae06bc 100644 --- a/docs/PLATFORMS.md +++ b/docs/PLATFORMS.md @@ -1,8 +1,8 @@ # Chat platforms ChannelGate speaks to three chat surfaces. Slack is GA; Google Chat and Microsoft Teams are in -**Beta** — their transports are implemented and tested, but have not yet run against a live -tenant, and their in-chat feature set is deliberately smaller (see *What works where* below). +**Beta** — their in-chat feature set is deliberately smaller (see *What works where* below). +Google Chat has been exercised against a live tenant; Teams still needs live tenant acceptance. Every surface goes through the same seam: a capability descriptor in `src/platforms/.js`, a `ChatConnector` that owns the wire format, and one platform-neutral ingest path @@ -20,20 +20,48 @@ rule — the same posture as Slack's Socket Mode. ### Setup -1. **Google Cloud project** — enable the *Google Chat API* and the *Cloud Pub/Sub API*. -2. **Topic + subscription** — create a topic (e.g. `chat-events`) and a **pull** subscription on it - (e.g. `chat-events-sub`). -3. **Service account** — create one, download a JSON key, then: - - grant it `roles/pubsub.subscriber` **on the subscription**; - - grant Chat's publisher service account `chat-api-push@system.gserviceaccount.com` the role - `roles/pubsub.publisher` **on the topic** (this is what lets Google publish to it). -4. **Chat app configuration** (Google Chat API → Configuration): - - app name, avatar, description; - - enable *Receive 1:1 messages* and *Join spaces and group conversations*; - - **Connection settings → Cloud Pub/Sub**, with the topic from step 2; - - subscribe to the message and membership events. -5. **ChannelGate** — Settings → Connection → *Google Chat*: paste the service-account JSON, enter the - subscription as `projects//subscriptions/`, Save, then **Connect**. +1. **Google Cloud project:** enable the *Google Chat API* and *Cloud Pub/Sub API* in the same + project. Keep its project ID for the resource names below. +2. **Pub/Sub topic and subscription:** create a topic such as `chat-events`, then attach a + **Pull** subscription such as `chat-events-sub`. The resource names have different forms: + `projects//topics/` and `projects//subscriptions/`. +3. **ChannelGate service account:** create a service account in the project and download its JSON + key. On the **subscription's Permissions** panel, grant that account **Pub/Sub Subscriber** + (`roles/pubsub.subscriber`). This is the identity ChannelGate uses to pull events and call the + Chat API; keep its JSON key private. +4. **Chat app** (Google Chat API → Configuration): set its name, avatar, description, and + **App status → Live - available to users**. Enable interactive features, including direct + messages and, for spaces, *Join spaces and group conversations*. Set **Connection settings → + Cloud Pub/Sub** to the full topic name `projects//topics/`. Set visibility so + the intended testers can find the app, enable error logging, and **Save**. +5. **Google Chat publisher:** on the **topic's Permissions** panel, grant **Pub/Sub Publisher** + (`roles/pubsub.publisher`) to the identity for the Chat app's configuration mode: + - **Google Workspace add-on:** copy **Service account email** from the Chat API Configuration + page's Cloud Pub/Sub connection settings (it may look like + `service-@gcp-sa-gsuiteaddons.iam.gserviceaccount.com`). Grant that **exact** + address the publisher role on the topic. This is separate from the JSON-key account in step 3. + If the add-on checkbox is already selected and disabled, leave it selected; this mode works + with ChannelGate. + - **Standalone Chat app:** grant `chat-api-push@system.gserviceaccount.com` the publisher role + on the topic. Google instructs you to clear *Build this Chat app as a Google Workspace + add-on* when configuring a new standalone app. This publisher is **not** the one to use for + an add-on. +6. **ChannelGate:** in Settings → Connection → *Google Chat*, paste the JSON key from step 3, + enter `projects//subscriptions/`, **Save**, then **Connect**. A + *Connected — pulling …* status confirms the subscription can be polled; it does not establish + that Google Chat can publish to the topic. +7. **Test:** add the Chat app to a space or open a direct message, then send a **new** message + mentioning it in a space. Check for an actual ChannelGate reply. The sender must also be an + approved user in ChannelGate's **Users** settings; otherwise the app responds that the user + is not approved. + +If the pull fails with `403`, recheck the step 3 **subscription** grant and reconnect. If it is +connected but receives no messages, recheck the step 5 **topic** grant for the app's actual mode, +then verify the Chat app is Live, interactive features are enabled, and its configured topic name +matches step 2. Google's [Workspace add-on Pub/Sub guide](https://developers.google.com/workspace/add-ons/chat/quickstart-pubsub), +[standalone Chat app Pub/Sub guide](https://developers.google.com/workspace/chat/quickstart/pub-sub), +and [Chat troubleshooting guide](https://developers.google.com/workspace/chat/troubleshoot-chat-apps) +cover these distinct configurations. The app's own `users/…` id is learned automatically the first time it is added to a space; the optional field exists only to short-circuit that. From e6e26a41ff540c26100d6abc887684391010f8e7 Mon Sep 17 00:00:00 2001 From: Tiberiu Socaci Date: Sun, 4 Oct 2026 16:39:24 +0300 Subject: [PATCH 006/139] feat: fork Slack threads with native engine history Signed-off-by: Tiberiu Socaci --- CHANGELOG.md | 2 + FEATURES.md | 6 +++ TEST-PLAN.md | 9 ++++ src/engines/adapters.js | 4 +- src/engines/claude.js | 9 +++- src/engines/codex.js | 9 ++-- src/gateway/active-runs.js | 1 + src/gateway/run.js | 24 ++++++--- src/slack/help.js | 1 + src/slack/message-normalize.js | 2 +- src/slack/message-pipeline.js | 76 ++++++++++++++++++++++++--- test/fixtures/claude | 6 ++- test/fixtures/codex | 12 ++++- test/slack-fork.test.js | 96 ++++++++++++++++++++++++++++++++++ 14 files changed, 230 insertions(+), 27 deletions(-) create mode 100644 test/slack-fork.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fb142d5..a70b447c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,8 @@ product overview. ## Unreleased +- Add `@agent /fork ` for Claude and Codex sessions in Slack. It starts a separate + engine branch in a linked new thread and runs the supplied message there. - Fix a history-transfer deadlock when `/sudo` crosses a container whose mounts need recreation. Transfer preparation now excludes its own lease, reports waits, handles Stop, and times out into transcript recovery instead of hanging indefinitely. diff --git a/FEATURES.md b/FEATURES.md index 1c141a23..c6de20ee 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -1526,6 +1526,12 @@ A categorized catalog of what's shipped. Cross-linked to `TEST-PLAN.md` checks. overrides that don't belong to the new engine, starts a fresh session (the new engine can't resume the old one's conversation), and replays the Slack thread context into it so the new engine continues the conversation instead of starting blind. +- Slack `@agent /fork ` branches a completed Claude or Codex container session using + the engine's native fork command. The bot posts a new top-level message linking to the source, + posts the new request in that thread, links back from the source, and runs the request in the + forked session. The source session keeps its ID and history; the new Slack `thread_ts` maps to + the engine's distinct child ID. Engine, model, effort and clean-mode pins carry into the branch. + A running or sudo/source-host session is refused until it can be forked safely. - Gateway-wide **default model** per engine (Settings → Engine & runtime, validated like `/model`): when a thread/channel/DM-template sets no model, the run gets the gateway default as an explicit `--model`/`-m` — so the admin's interactive terminal `/model` choice (written to diff --git a/TEST-PLAN.md b/TEST-PLAN.md index c450d2df..1ef86bec 100644 --- a/TEST-PLAN.md +++ b/TEST-PLAN.md @@ -5227,6 +5227,15 @@ none` for its cases and live gates. Kept as history. come back (`test/help-text.test.js`). - [ ] `/clear` drops the session (next message is a cold start); `/help` shows the practical guide and lists all commands (including `/status`, `/stop`, and `/pending`). +- [x] Unit/fixture (`test/slack-fork.test.js`): `/fork ` is parsed as a command; + Claude uses `--resume --fork-session`, Codex uses `exec fork `, and both + keep confinement flags. A fake Slack DM receives a linked top-level root, a separate new + request in that thread and the engine reply there; source and child IDs remain distinct. +- [ ] Live Slack acceptance (Claude and Codex separately): finish a source turn, send + `@agent /fork Compare an alternative approach` in its thread, and verify the source link, + child root, separate request and answer. Follow up in both threads; each must remember its + own continuation while retaining the shared pre-fork history. Try during a running turn, + without a prior session, and from `/sudo`: each must refuse without creating a new root. - [ ] `/delete` (org-admin only) inside a thread removes the BOT's messages there (replies before the parent) and posts an ephemeral summary; without an Admin User Token, human messages stay and the summary explains how to enable full deletion (Settings → Slack credentials). diff --git a/src/engines/adapters.js b/src/engines/adapters.js index c62f362e..98fde93d 100644 --- a/src/engines/adapters.js +++ b/src/engines/adapters.js @@ -127,7 +127,7 @@ const claude = validateEngineAdapter({ throw error; }); } - return runClaude({ cwd: ctx.cwd, prompt: ctx.prompt, sessionId: ctx.session.id, isNewSession: ctx.session.fresh, mcpConfig: r.mcpConfigFile, strictMcp: r.strictMcp, dangerouslySkip: r.dangerouslySkip, settingsFile: r.settingsFile, model: r.model, effort: r.effort, timeoutMs: r.timeoutMs, maxSilenceMs: r.maxSilenceMs, signal: r.signal, onDelta: r.onDelta, onEvent: r.onEvent, permissionPromptTool: r.permissionPromptTool, pluginDirs: r.claudePluginDirs, instructionFile: r.instructionFile, home: r.claudeHome, configDir: r.claudeConfigDir, extraEnv: r.channelEnv, browserNamespace: r.browserNamespace, target, claudeOauthToken }); + return runClaude({ cwd: ctx.cwd, prompt: ctx.prompt, sessionId: ctx.session.id, isNewSession: ctx.session.fresh, forkSourceSessionId: r.forkSourceSessionId, mcpConfig: r.mcpConfigFile, strictMcp: r.strictMcp, dangerouslySkip: r.dangerouslySkip, settingsFile: r.settingsFile, model: r.model, effort: r.effort, timeoutMs: r.timeoutMs, maxSilenceMs: r.maxSilenceMs, signal: r.signal, onDelta: r.onDelta, onEvent: r.onEvent, onSessionResolved: r.onSessionResolved, permissionPromptTool: r.permissionPromptTool, pluginDirs: r.claudePluginDirs, instructionFile: r.instructionFile, home: r.claudeHome, configDir: r.claudeConfigDir, extraEnv: r.channelEnv, browserNamespace: r.browserNamespace, target, claudeOauthToken }); }, interrupt: ({ poolKey }) => abortPooled(poolKey), discoverMcps: () => listEngineMcps("claude"), @@ -344,7 +344,7 @@ const codex = validateEngineAdapter({ for (const server of codexMcpPolicy.servers) { if (server.enabled && !server.definition) server.enabled = false; } - return runCodex({ cwd: ctx.cwd, prompt: ctx.prompt, extraEnv: r.channelEnv, browserNamespace: r.browserNamespace, sessionId: ctx.session.id, isNewSession: ctx.session.fresh, dangerouslySkip: r.dangerouslySkip, writable: r.writable, networkMode: ctx.policy.network.mode, clean: r.clean, autoApprove: r.autoApprove, composioUserEndpoint: r.composioUserEndpoint, composioEndpoint: r.composioEndpoint, composioUserToken: r.composioUserToken, composioToken: r.composioToken, toolboxToken: r.toolboxToken, makeToolboxUrl: r.makeToolboxUrl, makeToolboxKey: r.makeToolboxKey, codexMcpPolicy, gatewayCapability: r.gatewayCapability, gatewayFsRoot: r.gatewayFsRoot, gatewayWorkspaceRoot: r.gatewayWorkspaceRoot, progressReport: r.progressReport, model: r.model, effort: r.effort, codexStateDir: r.codexStateDir, personalSkills: r.personalSkillCatalog, pluginSkills: requirePluginRuntime(r.pluginRuntime, this.id).skills, attachments: r.attachments, target, artifactDir: ctx.artifactDir ?? target.artifactDir ?? null, signal: r.signal, timeoutMs: r.timeoutMs, maxSilenceMs: r.maxSilenceMs, onDelta: r.onDelta, onEvent: r.onEvent, onSessionResolved: r.onSessionResolved }); + return runCodex({ cwd: ctx.cwd, prompt: ctx.prompt, extraEnv: r.channelEnv, browserNamespace: r.browserNamespace, sessionId: ctx.session.id, isNewSession: ctx.session.fresh, forkSourceSessionId: r.forkSourceSessionId, dangerouslySkip: r.dangerouslySkip, writable: r.writable, networkMode: ctx.policy.network.mode, clean: r.clean, autoApprove: r.autoApprove, composioUserEndpoint: r.composioUserEndpoint, composioEndpoint: r.composioEndpoint, composioUserToken: r.composioUserToken, composioToken: r.composioToken, toolboxToken: r.toolboxToken, makeToolboxUrl: r.makeToolboxUrl, makeToolboxKey: r.makeToolboxKey, codexMcpPolicy, gatewayCapability: r.gatewayCapability, gatewayFsRoot: r.gatewayFsRoot, gatewayWorkspaceRoot: r.gatewayWorkspaceRoot, progressReport: r.progressReport, model: r.model, effort: r.effort, codexStateDir: r.codexStateDir, personalSkills: r.personalSkillCatalog, pluginSkills: requirePluginRuntime(r.pluginRuntime, this.id).skills, attachments: r.attachments, target, artifactDir: ctx.artifactDir ?? target.artifactDir ?? null, signal: r.signal, timeoutMs: r.timeoutMs, maxSilenceMs: r.maxSilenceMs, onDelta: r.onDelta, onEvent: r.onEvent, onSessionResolved: r.onSessionResolved }); }, interrupt: () => false, discoverMcps: ({ channelId = "" } = {}) => listEngineMcps("codex", { channelId }), diff --git a/src/engines/claude.js b/src/engines/claude.js index a63749dc..0248975c 100644 --- a/src/engines/claude.js +++ b/src/engines/claude.js @@ -128,6 +128,7 @@ export function buildClaudeArgs({ prompt, sessionId, isNewSession, + forkSourceSessionId = "", mcpConfig, // value for --mcp-config: a PATH to a 0600 file (tokens must never ride on argv) strictMcp, dangerouslySkip, @@ -145,7 +146,8 @@ export function buildClaudeArgs({ // from ~/.claude; they sit outside the org/channel/user access tiers and would defeat clean mode. args.push("--setting-sources", ""); - if (isNewSession) args.push("--session-id", sessionId); + if (forkSourceSessionId) args.push("-r", forkSourceSessionId, "--fork-session"); + else if (isNewSession) args.push("--session-id", sessionId); else args.push("-r", sessionId); // Load the gateway lockdown explicitly so a custom (real-project) work dir is never modified. @@ -220,6 +222,7 @@ export async function runClaude({ prompt, sessionId, isNewSession, + forkSourceSessionId = "", mcpConfig = null, strictMcp = true, dangerouslySkip = false, @@ -253,8 +256,9 @@ export async function runClaude({ signal = null, onDelta = null, onEvent = null, + onSessionResolved = null, }) { - const args = buildClaudeArgs({ prompt, sessionId, isNewSession, mcpConfig, strictMcp, dangerouslySkip, settingsFile, model, effort, permissionPromptTool, pluginDirs, instructionFile, disallowedTools }); + const args = buildClaudeArgs({ prompt, sessionId, isNewSession, forkSourceSessionId, mcpConfig, strictMcp, dangerouslySkip, settingsFile, model, effort, permissionPromptTool, pluginDirs, instructionFile, disallowedTools }); const runtime = runtimeTargetOr(target, cwd); // What a provider failure calls itself in the thread. "Claude" is the CLI; the harness may be // another provider driving it (src/engines/qwen.js), and the person reading the error has to @@ -338,6 +342,7 @@ export async function runClaude({ // failure that actually ended the turn. providerError = claudeProviderError(p, harnessLabel) || providerError; stream.consume(p); + if (forkSourceSessionId && p.type === "system" && p.subtype === "init" && p.session_id) onSessionResolved?.(p.session_id); if (p.type === "result") result = p; }; diff --git a/src/engines/codex.js b/src/engines/codex.js index d8b6fa18..a40af84d 100644 --- a/src/engines/codex.js +++ b/src/engines/codex.js @@ -656,13 +656,13 @@ export function codexSpawnDropsCapabilities({ target = null, cwd = "", dangerous } // Build `codex exec` argv. `outFile` receives the final agent message (authoritative content). -export function buildCodexArgs({ prompt, sessionId, isNewSession, cwd, dangerouslySkip, writable = false, networkMode = "off", clean = false, autoApprove = false, composioUserEndpoint = null, composioEndpoint = null, composioUserToken = "", composioToken = "", toolboxToken = "", makeToolboxUrl = "", makeToolboxKey = "", secretBundlePath = "", codexMcpPolicy = null, gatewayCapability = "", gatewayFsRoot = "", gatewayWorkspaceRoot = "", progressReport = false, model = "", effort = "", personalSkills = null, pluginSkills = null, attachments = [], target = null, outFile, headerHelpers = [] }) { +export function buildCodexArgs({ prompt, sessionId, isNewSession, forkSourceSessionId = "", cwd, dangerouslySkip, writable = false, networkMode = "off", clean = false, autoApprove = false, composioUserEndpoint = null, composioEndpoint = null, composioUserToken = "", composioToken = "", toolboxToken = "", makeToolboxUrl = "", makeToolboxKey = "", secretBundlePath = "", codexMcpPolicy = null, gatewayCapability = "", gatewayFsRoot = "", gatewayWorkspaceRoot = "", progressReport = false, model = "", effort = "", personalSkills = null, pluginSkills = null, attachments = [], target = null, outFile, headerHelpers = [] }) { const runtimeTarget = runtimeTargetOr(target, cwd); const isolated = isIsolatedTarget(runtimeTarget); const helper = (name) => runtimeTarget.runtime.helperCommand(runtimeTarget, name); if (!["off", "on"].includes(networkMode)) throw new Error(`Unknown Codex network mode: ${networkMode}`); - const resuming = !isNewSession; - const base = isNewSession ? ["exec"] : ["exec", "resume", sessionId]; + const resuming = !isNewSession || Boolean(forkSourceSessionId); + const base = forkSourceSessionId ? ["exec", "fork", forkSourceSessionId] : isNewSession ? ["exec"] : ["exec", "resume", sessionId]; const args = [...base, "--json", "--skip-git-repo-check", "-o", outFile]; // No anonymous usage metrics to OpenAI from any turn (src/engines/codex-telemetry.js). args.push("-c", CODEX_NO_TELEMETRY); @@ -970,6 +970,7 @@ export async function runCodex({ browserNamespace = "", sessionId, isNewSession, + forkSourceSessionId = "", dangerouslySkip = false, writable = false, networkMode = "off", @@ -1084,7 +1085,7 @@ export async function runCodex({ // pure argv builder. They carry no credential of their own — each one reads its entry out of the // 0600 bundle above — but they are still per-run files, created and removed with it. const headerHelpers = []; - const args = buildCodexArgs({ prompt, sessionId, isNewSession, cwd, dangerouslySkip, writable, networkMode, clean, autoApprove, composioUserEndpoint, composioEndpoint, composioUserToken, composioToken, toolboxToken, makeToolboxUrl, makeToolboxKey, secretBundlePath, codexMcpPolicy, gatewayCapability, gatewayFsRoot, gatewayWorkspaceRoot, progressReport, model, effort, codexStateDir, personalSkills, pluginSkills, attachments, target: runtime, outFile, headerHelpers }); + const args = buildCodexArgs({ prompt, sessionId, isNewSession, forkSourceSessionId, cwd, dangerouslySkip, writable, networkMode, clean, autoApprove, composioUserEndpoint, composioEndpoint, composioUserToken, composioToken, toolboxToken, makeToolboxUrl, makeToolboxKey, secretBundlePath, codexMcpPolicy, gatewayCapability, gatewayFsRoot, gatewayWorkspaceRoot, progressReport, model, effort, codexStateDir, personalSkills, pluginSkills, attachments, target: runtime, outFile, headerHelpers }); for (const spec of headerHelpers) { await writeFile(spec.path, headerHelperSource({ ...spec, bundlePath: secretBundlePath }), { mode: 0o700 }); } diff --git a/src/gateway/active-runs.js b/src/gateway/active-runs.js index 0db2f95a..6c206bfc 100644 --- a/src/gateway/active-runs.js +++ b/src/gateway/active-runs.js @@ -487,6 +487,7 @@ export async function recoverRuns(stale, { workspaceId: rec.workspaceId || process.env.CG_SLACK_TEAM_ID || "", text: rec.text, threadKey: rec.threadKey, + forkSourceSessionId: rec.forkSourceSessionId || "", attachments: Array.isArray(rec.attachments) ? rec.attachments : [], origin: "recovery", // replayed after a restart with nobody watching — never escalates signal: handle.controller.signal, diff --git a/src/gateway/run.js b/src/gateway/run.js index f9aff60a..713a4723 100644 --- a/src/gateway/run.js +++ b/src/gateway/run.js @@ -630,7 +630,7 @@ function assertRuntimeCanStart() { } } -export async function runMessage({ channelId, authorId, workspaceId = "", text, threadKey, attachments = [], signal = null, onDelta, onEvent, onRuntimeResolved, sessionId: presetSessionId = "", overrides = null, getFallbackContext = null, preferCold = false, progressReport = false, untrustedPrincipal = false, origin = "", fallbackPolicy = "", sudoSourceThreadKey = "" }) { +export async function runMessage({ channelId, authorId, workspaceId = "", text, threadKey, attachments = [], signal = null, onDelta, onEvent, onRuntimeResolved, onForkSessionResolved, sessionId: presetSessionId = "", forkSourceSessionId = "", overrides = null, getFallbackContext = null, preferCold = false, progressReport = false, untrustedPrincipal = false, origin = "", fallbackPolicy = "", sudoSourceThreadKey = "" }) { // 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(", ")}`); @@ -904,6 +904,9 @@ export async function runMessage({ channelId, authorId, workspaceId = "", text, isNew = true; } else { ({ sessionId, isNew, engine: sessionEngine, runtime: sessionRuntime } = await resolveSession(entry.slug, threadKey, engine, runtimeStamp)); + // A recovering fork may have a provisional row from before the engine announced its child id. + // Its first attempt must fork the source, never resume that uncreated provisional id. + if (forkSourceSessionId) isNew = true; // The stored session was minted by a DIFFERENT engine than the one this turn resolved to. // A session id is engine-specific — Claude can't resume a Codex thread_id and vice-versa — so a // cross-engine resume is never attempted. Which side wins is decideThreadEngine's call: an @@ -1374,15 +1377,15 @@ export async function runMessage({ channelId, authorId, workspaceId = "", text, // session before completing a turn. saveSession writes synchronously before its promise // returns and retains the same /clear-generation guard as the successful-result path. const sessionResolved = (key, owner) => (id) => { - void saveSession(entry.slug, key, id, owner, sessionGen, runtimeStamp).catch((error) => { - console.warn(`[gateway] could not persist announced session: ${error.message}`); - }); + void saveSession(entry.slug, key, id, owner, sessionGen, runtimeStamp) + .then(() => { if (forkSourceSessionId) onForkSessionResolved?.(id); }) + .catch((error) => console.warn(`[gateway] could not persist announced session: ${error.message}`)); }; const runOnce = async (sid, fresh, promptOverride = null, modelOverride = model) => { assertRuntimeCanStart(); // The runtime facts belong to THIS attempt, including a model retry or session heal. Keep // them per-prompt even in clean mode: they expose no memory, optional skills or connectors. - const prompt = (fresh ? memoryPrefix : "") + composioIdentityPrefix + channelCredentialsPrefix + const prompt = (fresh && !forkSourceSessionId ? memoryPrefix : "") + composioIdentityPrefix + channelCredentialsPrefix + runtimeIdentityPreamble({ engine, model: modelOverride, effort, fresh }) + runtimeAccessPreamble(target, { clean, allowNetwork: Boolean(meta.allowNetwork) }) + (promptOverride ?? turnText); @@ -1395,10 +1398,11 @@ export async function runMessage({ channelId, authorId, workspaceId = "", text, target, claudeOauthToken, artifactDir: target.artifactDir, runtime: { target, claudeOauthToken, claudeTokenFingerprint: claudeTokenFp, artifactDir: target.artifactDir, - preferCold, keepAliveMs: keepAliveMs(), poolKey: `${entry.slug}::${threadKey}`, + preferCold: preferCold || Boolean(forkSourceSessionId), keepAliveMs: keepAliveMs(), poolKey: `${entry.slug}::${threadKey}`, mcpConfigFile, mcpConfigJson, mcpConfigFingerprint, strictMcp, dangerouslySkip, settingsFile: runSettingsFile, model: modelOverride, effort, permissionPromptTool, timeoutMs, maxSilenceMs, signal, onDelta: scopedOnDelta, onEvent: scopedOnEvent, onSessionResolved: sessionResolved(threadKey, engine), + forkSourceSessionId: fresh ? forkSourceSessionId : "", channelEnv, channelEnvFingerprint: channelEnvFp, browserNamespace, writable: codexWritable, autoApprove: codexAutoApprove, clean, composioUserEndpoint, composioEndpoint, composioUserToken, composioToken, toolboxToken, @@ -1954,6 +1958,9 @@ export async function runMessage({ channelId, authorId, workspaceId = "", text, } assertCompletedTurn(result, engine, sid); + if (forkSourceSessionId && (!result.sessionId || result.sessionId === forkSourceSessionId)) { + throw new Error(`${engineLabel(engine)} did not return a distinct fork session id`); + } // A RESUME that returns an empty result (0 tokens, no output) is a broken session state — most // often a turn SIGKILLed mid-write by a stop/restart, leaving a dangling tool_use in the @@ -1983,8 +1990,9 @@ export async function runMessage({ channelId, authorId, workspaceId = "", text, // persist it over the locally-minted UUID so the next turn's resume actually finds the // thread. Stamp the row with THIS run's engine: a literal here silently migrates any other // self-minting engine's thread onto that engine on its second turn. - if (mintsOwnSessionId(engine) && result.sessionId && result.sessionId !== sid) { + if ((mintsOwnSessionId(engine) || forkSourceSessionId) && result.sessionId && result.sessionId !== sid) { await saveSession(entry.slug, threadKey, result.sessionId, engine, sessionGen, runtimeStamp); + if (forkSourceSessionId) onForkSessionResolved?.(result.sessionId); } // The engine exited 0 but its "answer" is a usage-limit notice that did no work → switch to the @@ -2046,7 +2054,7 @@ export async function runMessage({ channelId, authorId, workspaceId = "", text, // without this notice in front. result = transientRetryNote(engine, result); - const finalSessionId = mintsOwnSessionId(engine) ? result.sessionId ?? sid : sid; + const finalSessionId = mintsOwnSessionId(engine) || forkSourceSessionId ? result.sessionId ?? sid : sid; // Back-fill the owning engine on a pre-v4/legacy session row (engine ''), so the NEXT harness // switch is caught by the clean reset-before-spawn path above instead of a failed cross-engine // resume. Only when the row was unlabeled AND we resumed it as-is this turn (a fresh mint/reset diff --git a/src/slack/help.js b/src/slack/help.js index d81e93b7..baea5b6d 100644 --- a/src/slack/help.js +++ b/src/slack/help.js @@ -19,6 +19,7 @@ export const HELP_TEXT = "• `/menu` — show only the Files, Variables, and Settings buttons; use `@agent /menu` inside a channel thread\n" + "• `/help` — show this guide\n" + "• `/clear` — start a fresh session in this thread\n" + + "• `/fork ` — branch this Claude or Codex session into a linked new Slack thread and run the new message there (after the current turn finishes)\n" + "• `/delete` — delete this thread's messages (admin; irreversible—everyone's if an admin user token is set in Settings, otherwise mine only)\n" + "• `/secrets` — see which environment variables this channel has (name + last 4 only) and add or replace one. Values are never shown again, to anyone: this is how a channel gets its OWN CLI login (its own Supabase or Vercel account) instead of sharing the host's\n" + "• `/context` — token usage of the last turn\n" + diff --git a/src/slack/message-normalize.js b/src/slack/message-normalize.js index 0532473f..e595ad09 100644 --- a/src/slack/message-normalize.js +++ b/src/slack/message-normalize.js @@ -3,7 +3,7 @@ export const SLACK_MENTION_RE = /<@([A-Z0-9]+)(?:\|[^>]+)?>/g; const STOP_WORDS = new Set(["stop", "cancel", "abort", "halt", "stop it", "please stop", "stop please", "nevermind", "never mind"]); const PENDING_WORDS = new Set(["pending", "my followups", "my follow-ups", "followups", "follow-ups"]); -const SLASH_COMMANDS = new Set(["help", "menu", "clear", "context", "model", "effort", "engine", "compact", "update", "mode", "sudo", "pending", "followups", "resume", "delete", "files"]); +const SLASH_COMMANDS = new Set(["help", "menu", "clear", "fork", "context", "model", "effort", "engine", "compact", "update", "mode", "sudo", "pending", "followups", "resume", "delete", "files"]); function normalizedControlText(text, punctuation = /[!.…]+$/) { return (text || "").trim().toLowerCase().replace(punctuation, "").replace(/\s+/g, " "); diff --git a/src/slack/message-pipeline.js b/src/slack/message-pipeline.js index a039a041..29149064 100644 --- a/src/slack/message-pipeline.js +++ b/src/slack/message-pipeline.js @@ -12,7 +12,7 @@ import { runMessage, isEmptyResult } from "../gateway/run.js"; import { modeLabel, MODES, modeSettingsPatch, canManage, isAuthorized, authorizationDenialReason } from "../gateway/modes.js"; import { postModelWizard } from "./model-wizard.js"; -import { getSessionMap, clearSession, hasThreadSession, getSessionEngine, saveSession } from "../gateway/sessions.js"; +import { getSession, getSessionMap, clearSession, hasThreadSession, getSessionEngine, getSessionRuntime, saveSession } from "../gateway/sessions.js"; import { planSessionAdoption } from "../gateway/session-adopt.js"; import { resolveRuntime } from "../runtimes/resolve.js"; import { setThreadEngine, getThreadEngine, resolveThreadEngine, setThreadClean, getThreadClean, setThreadSudo, getThreadSudo, setThreadModel, getThreadModel, setThreadEffort, getThreadEffort } from "../gateway/thread-engine.js"; @@ -73,6 +73,16 @@ import { engineSwitchChoices, engineSwitchChoiceBlocks, engineSwitchChoiceText } // queued ones, and a finishing run only ever clears its own entry. // Last turn's context usage per thread, for /context. const lastCtx = new Map(); +const pendingForkThreads = new Set(); +const pendingForkSources = new Set(); + +async function slackPermalink(client, channel, ts) { + try { + const result = await client.chat.getPermalink({ channel, message_ts: ts }); + if (result?.permalink) return result.permalink; + } catch { /* Slack's canonical permalink is optional; its archive URL still works. */ } + return `https://slack.com/archives/${channel}/p${String(ts).replace(".", "")}`; +} // Terminalize every matching in-flight run synchronously — no Slack calls, no awaits. The shared // core of the `stop` command (stopRunsInChannel below, which adds the user-facing messaging) and @@ -591,7 +601,7 @@ async function ensureUserKnown(client, userId) { // harness-switch card (src/slack/engine-switch-choice.js) re-entering with the original event — // run it on `engineChoice`, pin the thread there when `engineChoiceSwitch`, and hand the pending // row over exactly like a busy-thread choice. -export async function processMessageEvent(event, client, { botUserId = "", teamId = "", bypassMention = false, dedupeTrigger = false, activeViewContext = null, busyChoice = "", busyTargetRunId = "", busyChoiceId = "", onBusyChoiceAccepted = null, engineChoice = "", engineChoiceSwitch = false, engineChoiceId = "", onEngineChoiceAccepted = null, questionSubmissionId = "", onQuestionSubmissionAccepted = null } = {}) { +export async function processMessageEvent(event, client, { botUserId = "", teamId = "", bypassMention = false, dedupeTrigger = false, activeViewContext = null, busyChoice = "", busyTargetRunId = "", busyChoiceId = "", onBusyChoiceAccepted = null, engineChoice = "", engineChoiceSwitch = false, engineChoiceId = "", onEngineChoiceAccepted = null, questionSubmissionId = "", onQuestionSubmissionAccepted = null, syntheticFork = null } = {}) { try { if (isIgnorable(event, botUserId, getTrustedBotApps())) return; // A message without a human author (e.g. a trusted-bot post carrying no `user`) can't be @@ -627,6 +637,14 @@ export async function processMessageEvent(event, client, { botUserId = "", teamI return; } const threadKey = event.thread_ts ?? event.ts; + if (!syntheticFork && pendingForkThreads.has(`${entry.slug}::${threadKey}`)) { + await client.chat.postMessage({ channel: event.channel, thread_ts: threadKey, text: "This fork is still starting. Please send your next message after its first reply." }); + return; + } + if (pendingForkSources.has(`${entry.slug}::${threadKey}`)) { + await client.chat.postMessage({ channel: event.channel, thread_ts: threadKey, text: "A fork of this thread is starting. Please send your next message after the new thread appears." }); + return; + } // Sudo is a THREAD trust boundary, stronger than the channel's ordinary admission policy. // Reject before hydration, attachment reads, queueing, or any process spawn. The stored flag // is never authority by itself: the sender's current organization-admin status is rechecked. @@ -646,7 +664,7 @@ export async function processMessageEvent(event, client, { botUserId = "", teamI // A question continuation is an internal event containing the answers authenticated by its // Slack interaction handler. It is not a message Slack can hydrate: using the card timestamp // would replace the answers with the card's text and could carry unrelated attachments. - if (!questionSubmissionId) event = await hydrateSlackMessage(event, client, { + if (!questionSubmissionId && !syntheticFork) event = await hydrateSlackMessage(event, client, { includeThreadFiles: async (message) => { const text = stripMentions(message.text, botUserId); const command = parseSlashCommand(text); @@ -701,7 +719,7 @@ export async function processMessageEvent(event, client, { botUserId = "", teamI } // In-thread control commands (/clear, /model, /effort, /context, /pending, /help, /compact). - const sc = files.length === 0 ? parseSlashCommand(prompt) : null; + const sc = !syntheticFork && files.length === 0 ? parseSlashCommand(prompt) : null; // /compact is a real command only on engines that declare supports.compact — judged against // the THREAD's effective engine (override → session-born → channel → gateway default), never // the gateway default alone: a Codex thread under a Claude-default gateway must not receive @@ -715,6 +733,43 @@ export async function processMessageEvent(event, client, { botUserId = "", teamI const reply = (t) => client.chat.postMessage({ channel: event.channel, thread_ts: threadKey, text: t }); if (sc.cmd === "help") { await reply(HELP_TEXT); + } else if (sc.cmd === "fork") { + const task = sc.arg.trim(); + if (!task) { await reply("Use `/fork ` inside a thread with a Claude or Codex session."); return; } + if (runQueue.isActive(runKey)) { await reply("Wait for this thread's current run to finish before forking it."); return; } + pendingForkSources.add(runKey); + try { + if (await getThreadSudo(entry.slug, threadKey)) { await reply("A sudo thread cannot be forked into an ordinary channel thread."); return; } + const sourceId = await getSession(entry.slug, threadKey); + const sourceEngine = await getSessionEngine(entry.slug, threadKey); + const sourceRuntime = await getSessionRuntime(entry.slug, threadKey); + if (!sourceId || !["claude", "codex"].includes(sourceEngine)) { await reply("This thread needs a completed Claude or Codex turn before it can be forked."); return; } + if (sourceRuntime?.backend !== "container") { await reply("This session's history is outside the channel container. Run another turn here to carry it into the container before forking."); return; } + abortPooled(runKey); // close an idle Claude process so its transcript is settled before cloning + const sourceUrl = await slackPermalink(client, event.channel, threadKey); + const root = await client.chat.postMessage({ channel: event.channel, text: `↪️ Fork of <${sourceUrl}|this thread>` }); + if (!root?.ts) throw new Error("Slack did not return a thread timestamp for the fork."); + const forkKey = String(root.ts); + const pendingKey = `${entry.slug}::${forkKey}`; + pendingForkThreads.add(pendingKey); + try { + await setThreadEngine(entry.slug, forkKey, sourceEngine); + await setThreadModel(entry.slug, forkKey, await getThreadModel(entry.slug, threadKey)); + await setThreadEffort(entry.slug, forkKey, await getThreadEffort(entry.slug, threadKey)); + await setThreadClean(entry.slug, forkKey, await getThreadClean(entry.slug, threadKey)); + const request = await client.chat.postMessage({ channel: event.channel, thread_ts: forkKey, text: `*New request from <@${event.user}>:* ${task}` }); + if (!request?.ts) throw new Error("Slack did not return a message timestamp for the fork request."); + const forkUrl = await slackPermalink(client, event.channel, forkKey); + await reply(`↪️ Fork started in <${forkUrl}|a new thread>.`); + await processMessageEvent({ ...event, ts: request.ts, thread_ts: forkKey, text: task, files: [] }, client, { + botUserId, teamId, bypassMention: true, syntheticFork: { sourceSessionId: sourceId, sourceRunKey: runKey }, + }); + } finally { + pendingForkThreads.delete(pendingKey); + } + } finally { + pendingForkSources.delete(runKey); + } } else if (sc.cmd === "menu") { await client.chat.postMessage({ channel: event.channel, thread_ts: threadKey, ...buildMenuCard(event.channel, threadKey, event.user) }); } else if (sc.cmd === "clear") { @@ -1046,7 +1101,7 @@ export async function processMessageEvent(event, client, { botUserId = "", teamI // switch phrase ("try again with codex", "switch to codex", "use claude") — a switch verb // immediately before the engine name, so an incidental mention ("the codex CLI") won't flip it. // It sticks until changed; the rest of the message is the task. - if (files.length === 0) { + if (!syntheticFork && files.length === 0) { const trimmed = prompt.trim(); const anchored = new RegExp(`^(${engineIdAlternation()})\\b[\\s:,.;–—-]*([\\s\\S]*)$`, "i").exec(trimmed); // Only a switch INTENT near the START counts (index ≤ 12, allowing a short lead like @@ -1091,7 +1146,7 @@ export async function processMessageEvent(event, client, { botUserId = "", teamI // thread replay: just Claude Code's own baseline + the message. Sticky for the thread (the // session is built on the bare context, so resumed turns must stay bare); "/clean off" // reverts — the NEXT message then starts a re-provisioned turn in the same thread. - { + if (!syntheticFork) { const cm = /^\/clean\b[\s:,.;–—-]*([\s\S]*)$/i.exec(prompt.trim()); if (cm) { const rest = cm[1].trim(); @@ -1422,7 +1477,7 @@ export async function processMessageEvent(event, client, { botUserId = "", teamI let threadContext = ""; // Replay the earlier thread when the bot is first pulled into an existing thread OR when the // engine was just switched (the new engine starts a fresh, blind session — give it context). - if (!threadClean && event.thread_ts && (!(await hasThreadSession(entry.slug, threadKey)) || engineSwitched)) { + if (!syntheticFork && !threadClean && event.thread_ts && (!(await hasThreadSession(entry.slug, threadKey)) || engineSwitched)) { threadContext = await fetchThreadContext(client, { channelId: event.channel, threadTs: threadKey, currentTs: contextCurrentTs, botUserId }); } // The requester may lose access while this accepted answer waits behind another turn. @@ -1468,6 +1523,7 @@ export async function processMessageEvent(event, client, { botUserId = "", teamI isDM: Boolean(meta.isDM), ...(questionSubmissionId ? { questionSubmissionId } : {}), text: textForRun, + ...(syntheticFork ? { forkSourceSessionId: syntheticFork.sourceSessionId } : {}), attachments: attachmentPaths, startedAt: Date.now(), }; @@ -1488,6 +1544,12 @@ export async function processMessageEvent(event, client, { botUserId = "", teamI text: textForRun, threadKey, + ...(syntheticFork ? { forkSourceSessionId: syntheticFork.sourceSessionId } : {}), + onForkSessionResolved: syntheticFork ? () => { + recordActiveRun(runId, { ...promotedRun, forkSourceSessionId: "" }); + pendingForkThreads.delete(`${entry.slug}::${threadKey}`); + pendingForkSources.delete(syntheticFork.sourceRunKey); + } : undefined, attachments: attachmentPaths, signal: handle.controller.signal, progressReport: true, diff --git a/test/fixtures/claude b/test/fixtures/claude index 3b20f37f..8bd7f8e4 100755 --- a/test/fixtures/claude +++ b/test/fixtures/claude @@ -2,7 +2,7 @@ # Echo harness-relevant argv facts through the reply so the E2E can assert the spawned process # actually RECEIVED them (mirrors test/fixtures/codex): a resume turn must carry -r, and MCP # injection must ride --mcp-config. Unit tests on buildClaudeArgs can't prove the spawn. -resume=no; mcp=no; cancel=no; fail=no; auth_fail=no; limit_fail=no; limit_answer=no; memory_save=no; answerless=no; mcp_path=""; prev="" +resume=no; fork=no; mcp=no; cancel=no; fail=no; auth_fail=no; limit_fail=no; limit_answer=no; memory_save=no; answerless=no; mcp_path=""; prev="" # Whether the gateway handed this process a relayed login (CLAUDE_CODE_OAUTH_TOKEN). The value # itself is never echoed — only that it was there — so the E2E can prove the spawn was # authenticated the way a real turn would be. @@ -16,6 +16,7 @@ settings=no; permprompt=no for arg in "$@"; do case "$arg" in -r) resume=yes ;; + --fork-session) fork=yes ;; --mcp-config) mcp=yes ;; --settings) settings=yes ;; --permission-prompt-tool) permprompt=yes ;; @@ -133,4 +134,5 @@ fi if [ "$stream_text" = "yes" ]; then printf '%s\n' "{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Stub engine reply (resume=$resume; mcp=$mcp; settings=$settings; permprompt=$permprompt; oauth=$oauth; model=$model)\"}}}" fi -printf '%s\n' "{\"type\":\"result\",\"subtype\":\"success\",\"result\":\"Stub engine reply (resume=$resume; mcp=$mcp; settings=$settings; permprompt=$permprompt; oauth=$oauth; model=$model)\",\"session_id\":\"stub-session\",\"usage\":{\"input_tokens\":12,\"output_tokens\":4},\"total_cost_usd\":0.001}" +if [ "$fork" = "yes" ]; then session_id="11111111-2222-3333-4444-555555555556"; else session_id="stub-session"; fi +printf '%s\n' "{\"type\":\"result\",\"subtype\":\"success\",\"result\":\"Stub engine reply (resume=$resume; mcp=$mcp; settings=$settings; permprompt=$permprompt; oauth=$oauth; model=$model)\",\"session_id\":\"$session_id\",\"usage\":{\"input_tokens\":12,\"output_tokens\":4},\"total_cost_usd\":0.001}" diff --git a/test/fixtures/codex b/test/fixtures/codex index af59a680..713e6f62 100755 --- a/test/fixtures/codex +++ b/test/fixtures/codex @@ -18,6 +18,8 @@ fi out="" previous="" resume="no" +fork="no" +resume_id="" mcp="no" cancel="no" fail="no" @@ -44,7 +46,9 @@ network_domain="no" for arg in "$@"; do if [ "$previous" = "-o" ]; then out="$arg"; fi if [ "$previous" = "-m" ]; then model="$arg"; fi + if [ "$previous" = "resume" ]; then resume_id="$arg"; fi if [ "$arg" = "resume" ]; then resume="yes"; fi + if [ "$arg" = "fork" ]; then fork="yes"; fi case "$arg" in mcp_servers.gateway.command=*) mcp="yes" ;; features.network_proxy.enabled=true) network_proxy="yes" ;; @@ -211,7 +215,13 @@ if [ "$reject_all_models_body" = "yes" ] || { [ "$reject_model_body" = "yes" ] & printf '{"type":"turn.failed","error":{"message":"{\\"type\\":\\"error\\",\\"status\\":400,\\"error\\":{\\"type\\":\\"invalid_request_error\\",\\"message\\":\\"The '\''%s'\'' model is not supported when using Codex with a ChatGPT account.\\"}}"}}\n' "$model" exit 1 fi -printf '%s\n' '{"type":"thread.started","thread_id":"codex-stub-session"}' +if [ "$fork" = "yes" ]; then + printf '%s\n' '{"type":"thread.started","thread_id":"codex-stub-fork"}' +elif [ -n "$resume_id" ]; then + printf '{"type":"thread.started","thread_id":"%s"}\n' "$resume_id" +else + printf '%s\n' '{"type":"thread.started","thread_id":"codex-stub-session"}' +fi printf '%s\n' '{"type":"item.started","item":{"type":"mcp_tool_call","server":"gateway","tool":"slack_channel_history"}}' # The answer as a streamed assistant message, the way a real turn reports it: the Slack message is # written from these deltas, so anything the gateway adds to the finished reply afterwards is NOT diff --git a/test/slack-fork.test.js b/test/slack-fork.test.js new file mode 100644 index 00000000..ccb95fc4 --- /dev/null +++ b/test/slack-fork.test.js @@ -0,0 +1,96 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; +import assert from "node:assert/strict"; +import { ensureTestEnv } from "./helpers.js"; + +ensureTestEnv(); +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"; +process.env.PROGRESS_VIEW = "shimmer"; +const { useFakeRuntime } = await import("./runtime-fake.js"); +await useFakeRuntime(); +const { saveSettings } = await import("../src/config/settings.js"); +const { setUser, getChannelEntry } = await import("../src/config/store.js"); +const { getSession, getSessionEngine } = await import("../src/gateway/sessions.js"); +const { buildClaudeArgs } = await import("../src/engines/claude.js"); +const { buildCodexArgs } = await import("../src/engines/codex.js"); +const { createFakeRuntime } = await import("./fixtures/fake-runtime-backend.js"); +const { parseSlashCommand } = await import("../src/slack/message-normalize.js"); +const { processMessageEvent } = await import("../src/slack/message-pipeline.js"); + +function slack() { + const posted = []; + let nextTs = 2000; + const ok = async () => ({ ok: true }); + return { + posted, + chat: { + postMessage: async (message) => { + posted.push(message); + return { ok: true, ts: `${++nextTs}.001` }; + }, + getPermalink: async ({ channel, message_ts }) => ({ permalink: `https://slack.test/archives/${channel}/p${String(message_ts).replace(".", "")}` }), + update: ok, + postEphemeral: ok, + }, + users: { info: async ({ user }) => ({ user: { id: user, real_name: "Fork User" } }), list: async () => ({ members: [], response_metadata: {} }) }, + conversations: { history: async () => ({ messages: [] }), replies: async () => ({ messages: [], response_metadata: {} }) }, + apiCall: ok, + }; +} + +test("fork command preserves its message and both CLIs receive their native fork flags", () => { + assert.deepEqual(parseSlashCommand("/fork investigate the other approach"), { cmd: "fork", arg: "investigate the other approach" }); + const claude = buildClaudeArgs({ prompt: "new request", sessionId: "child", isNewSession: true, forkSourceSessionId: "parent" }); + assert.deepEqual(claude.slice(claude.indexOf("-r"), claude.indexOf("-r") + 3), ["-r", "parent", "--fork-session"]); + assert.ok(!claude.includes("--session-id")); + const target = createFakeRuntime().target(); + const codex = buildCodexArgs({ prompt: "new request", sessionId: "child", isNewSession: true, forkSourceSessionId: "parent", cwd: target.cwd, target, outFile: `${target.artifactDir}/fork-answer` }); + assert.deepEqual(codex.slice(0, 3), ["exec", "fork", "parent"]); + assert.ok(!codex.includes("-C"), "fork uses the process cwd like resume"); + assert.ok(codex.includes("--ignore-user-config")); +}); + +for (const engine of ["claude", "codex"]) { + test(`${engine} /fork creates a linked Slack root and distinct resumable engine session`, async () => { + saveSettings({ engine, composioMode: "personal" }); + const user = `U_FORK_${engine.toUpperCase()}`; + const channel = `D_FORK_${engine.toUpperCase()}`; + await setUser(user, { name: "Fork User", approved: true }); + const client = slack(); + const sourceTs = engine === "claude" ? "1000.001" : "1001.001"; + const event = { type: "message", channel, channel_type: "im", user, ts: sourceTs, text: "Source task" }; + await processMessageEvent(event, client, { botUserId: "U_BOT", teamId: "T_FORK" }); + const entry = await getChannelEntry(channel); + const sourceId = await getSession(entry.slug, sourceTs); + assert.ok(sourceId); + await processMessageEvent({ ...event, ts: `${sourceTs.slice(0, 4)}.002`, thread_ts: sourceTs, text: "/fork Explore another solution" }, client, { botUserId: "U_BOT", teamId: "T_FORK" }); + const root = client.posted.find((m) => !m.thread_ts && /Fork of/.test(m.text || "")); + assert.ok(root, "fork gets a new top-level Slack message"); + const forkTs = client.posted.find((m) => m.thread_ts === sourceTs && /Fork started/.test(m.text || ""))?.text.match(/p(\d+)/)?.[1]; + assert.ok(forkTs, "source thread links to child"); + const childTs = `${forkTs.slice(0, -3)}.${forkTs.slice(-3)}`; + assert.ok(client.posted.some((m) => m.thread_ts === childTs && /New request from/.test(m.text || "") && /Explore another solution/.test(m.text || ""))); + const childId = await getSession(entry.slug, childTs); + assert.ok(childId); + assert.notEqual(childId, sourceId, "fork must not resume or overwrite the original session"); + assert.equal(await getSession(entry.slug, sourceTs), sourceId); + assert.equal(await getSessionEngine(entry.slug, childTs), engine); + assert.ok(client.posted.some((m) => m.thread_ts === childTs && /stub .*reply/i.test(m.text || "")), "new request is answered in child thread"); + await processMessageEvent({ ...event, ts: "9000.002", thread_ts: childTs, text: "Continue the alternative" }, client, { botUserId: "U_BOT", teamId: "T_FORK" }); + assert.equal(await getSession(entry.slug, childTs), childId, "follow-up resumes the forked session"); + assert.equal(await getSession(entry.slug, sourceTs), sourceId, "follow-up leaves source untouched"); + }); +} + +test("/fork without a completed source session refuses before posting a new Slack root", async () => { + const user = "U_FORK_EMPTY"; + const channel = "D_FORK_EMPTY"; + await setUser(user, { name: "Fork User", approved: true }); + const client = slack(); + await processMessageEvent({ type: "message", channel, channel_type: "im", user, ts: "5000.001", text: "/fork Something new" }, client, { botUserId: "U_BOT" }); + assert.ok(client.posted.some((m) => m.thread_ts === "5000.001" && /needs a completed/.test(m.text || ""))); + assert.equal(client.posted.some((m) => !m.thread_ts), false); +}); From 28178ce1ed5030f184fed34e599f478fdc3df7dc Mon Sep 17 00:00:00 2001 From: Tiberiu Socaci Date: Sun, 4 Oct 2026 17:35:54 +0300 Subject: [PATCH 007/139] feat: add configurable model shortcuts for Slack threads Signed-off-by: Tiberiu Socaci --- CHANGELOG.md | 2 + FEATURES.md | 4 ++ TEST-PLAN.md | 8 ++++ public/app.js | 59 +++++++++++++++++++++++++++- public/index.html | 6 +++ src/config/model-shortcuts.js | 19 +++++++++ src/config/settings.js | 6 +++ src/slack/help.js | 2 +- src/slack/message-pipeline.js | 39 +++++++++++++++++- src/web/routes/settings.js | 15 +++++++ test/model-shortcut-pipeline.test.js | 43 ++++++++++++++++++++ test/model-shortcuts.test.js | 58 +++++++++++++++++++++++++++ 12 files changed, 257 insertions(+), 4 deletions(-) create mode 100644 src/config/model-shortcuts.js create mode 100644 test/model-shortcut-pipeline.test.js create mode 100644 test/model-shortcuts.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index a70b447c..49bb8302 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,8 @@ product overview. ## Unreleased +- Gateway Settings now lets admins map short names to an engine and model. In Slack, mention the bot as `@agent:astra` to pin that model to a thread, with an optional task after the name. Mappings can be updated as models change. + - Add `@agent /fork ` for Claude and Codex sessions in Slack. It starts a separate engine branch in a linked new thread and runs the supplied message there. - Fix a history-transfer deadlock when `/sudo` crosses a container whose mounts need recreation. diff --git a/FEATURES.md b/FEATURES.md index c6de20ee..dc657ec8 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -1,5 +1,9 @@ # ChannelGate — Features +## Gateway model shortcuts + +Admins can add gateway-wide model shortcuts in Settings → Access & security → Model shortcuts. Each name selects an engine and model. In Slack, `@agent:astra task` pins that engine and model to the current thread and runs the task; `@agent:astra` switches the thread without starting an agent turn. Changing a mapping affects later shortcut selections, while threads already pinned keep their selected model. Channel runtime-change access and dedicated Codex-login restrictions apply. + ## Runtime history transfer and Google Chat failure recovery - Switching a thread between its container and `/sudo` host carries native history without diff --git a/TEST-PLAN.md b/TEST-PLAN.md index 1ef86bec..f5e0c898 100644 --- a/TEST-PLAN.md +++ b/TEST-PLAN.md @@ -1,5 +1,13 @@ # ChannelGate — Test Plan +## Model shortcuts acceptance (2026-10-04) + +- [x] Automated: `test/model-shortcuts.test.js` checks parsing, saving, repointing, clearing and refusing invalid mappings. +- [ ] Live: In gateway Settings, add `astra` → Codex / a currently offered model and `opus` → Claude / a currently offered model. Save and reload; verify both mappings remain. Repoint `astra` to another offered Codex model, save and reload. +- [ ] Live: In a Slack channel with runtime changes allowed for the author, send `@agent:astra summarize this thread` as a new root message. Verify the reply uses the selected Codex model and the thread settings show the pin. Reply `@agent:opus` in the same thread; verify the switch acknowledgement and the next ordinary request uses the selected Claude model with earlier thread context available. +- [ ] Live: In a channel restricted to admin runtime changes, verify a non-admin's `@agent:astra task` is refused and leaves the thread runtime unchanged. Verify an unknown name is refused. In a channel with its own Codex login, verify a Claude shortcut is refused. +- [ ] Live: Run the reverse engine path with `@agent:opus task` as a new root message and `@agent:astra` within that thread, confirming the Codex answer retains earlier context. Check both engines' recorded model and effort (the shortcut clears a previous effort pin). + ## Runtime history transfer and Google Chat failure recovery (2026-10-02) - [x] Automated: `test/session-carry.test.js`, `test/container-carry.test.js`, diff --git a/public/app.js b/public/app.js index 07daf2d7..a69a53e5 100644 --- a/public/app.js +++ b/public/app.js @@ -180,6 +180,59 @@ function syncModelOptions({ engineSelect, modelSelect, value, engine, blankLabel modelSelect.value = options.some(([v]) => v === current) ? current : ""; } +function addModelShortcutRow(name = "", target = {}) { + const box = document.getElementById("model-shortcuts-editor"); + const row = document.createElement("div"); + row.className = "model-shortcut-row"; + row.style.cssText = "display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin:8px 0"; + row.innerHTML = ` + + + `; + const engineSelect = row.querySelector(".shortcut-engine"); + const modelSelect = row.querySelector(".shortcut-model"); + engineSelect.innerHTML = engineOptionsHtml(); + if (target.engine && ![...engineSelect.options].some((option) => option.value === target.engine)) { + const option = document.createElement("option"); + option.value = target.engine; + option.textContent = `${target.engine} (disabled)`; + engineSelect.add(option); + } + engineSelect.value = target.engine || engineSelect.options[0]?.value || "claude"; + const fillModels = (value = "") => { + const engine = engineSelect.value; + const manifestModels = ENGINE_MANIFESTS.find((m) => m.id === engine)?.models || []; + const options = manifestModels.length ? manifestModels.map((m) => [m.value, m.label || m.value]) : (MODEL_OPTIONS[engine] || []); + if (value && !options.some(([id]) => id === value)) options.push([value, value]); + modelSelect.innerHTML = options.map(([id, label]) => ``).join(""); + modelSelect.value = value && options.some(([id]) => id === value) ? value : (options[0]?.[0] || ""); + }; + row.querySelector(".shortcut-name").value = name; + fillModels(target.model); + engineSelect.addEventListener("change", () => fillModels()); + row.querySelector(".shortcut-remove").addEventListener("click", () => { row.remove(); markSettingsDirty(); }); + box.append(row); +} + +function paintModelShortcuts(shortcuts) { + const box = document.getElementById("model-shortcuts-editor"); + box.replaceChildren(); + for (const [name, target] of Object.entries(shortcuts || {})) addModelShortcutRow(name, target); +} + +function collectModelShortcuts() { + const shortcuts = {}; + for (const row of document.querySelectorAll("#model-shortcuts-editor .model-shortcut-row")) { + const name = row.querySelector(".shortcut-name").value.trim().toLowerCase(); + if (!/^[a-z][a-z0-9_-]{0,23}$/.test(name)) throw new Error(`Invalid model shortcut name: ${name || "(blank)"}`); + if (Object.hasOwn(shortcuts, name)) throw new Error(`Duplicate model shortcut: ${name}`); + shortcuts[name] = { engine: row.querySelector(".shortcut-engine").value, model: row.querySelector(".shortcut-model").value }; + } + return shortcuts; +} + +document.getElementById("add-model-shortcut").addEventListener("click", () => { addModelShortcutRow(); markSettingsDirty(); }); + function checkboxList(container, items, selected, valueKey = "value", labelKey = "label") { container.innerHTML = ""; if (!items.length) { @@ -3606,6 +3659,7 @@ function readSettingsForm() { defaultCodexModel: document.getElementById("set-default-codex-model").value, ...collectQwenProviders(), modelChangeAccess: document.getElementById("set-model-change-access").value, + modelShortcuts: collectModelShortcuts(), engineEnabled: { ...ENGINE_ENABLED }, engineFallback: document.getElementById("set-engine-fallback").checked, engineFallbackMode: document.getElementById("set-engine-fallback-mode").value, @@ -3739,6 +3793,7 @@ function paintSettings(s) { syncModelOptions({ modelSelect: document.getElementById("set-default-claude-model"), engine: "claude", value: s.defaultClaudeModel || "", blankLabel: "CLI default" }); syncModelOptions({ modelSelect: document.getElementById("set-default-codex-model"), engine: "codex", value: s.defaultCodexModel || "", blankLabel: "CLI default" }); document.getElementById("set-model-change-access").value = s.modelChangeAccess || "admins"; + paintModelShortcuts(s.modelShortcuts); document.getElementById("set-engine-fallback").checked = s.engineFallback !== false; document.getElementById("set-engine-fallback-mode").value = s.engineFallbackMode || "auto"; document.getElementById("set-show-message-cost").checked = s.showMessageCost !== false; @@ -4147,7 +4202,9 @@ function bindSettings() { saved.textContent = "saving…"; // Only the fields this admin actually changed. Everything else is left to whatever the daemon // holds now — the whole point: an unrelated save must not revert another writer. - const form = readSettingsForm(); + let form; + try { form = readSettingsForm(); } + catch (e) { saved.textContent = "✗ " + e.message; return; } const patch = diffSettingsPayload(SETTINGS_BASELINE || {}, form); const newPw = form.adminPassword || ""; // Only (re)connect Slack when a Slack token was actually changed in this save — a normal diff --git a/public/index.html b/public/index.html index 70bf4dae..70c2381c 100644 --- a/public/index.html +++ b/public/index.html @@ -908,6 +908,12 @@

Runtime changes

Controls both channel-wide and thread-only /model changes. DM users can always customize their own runtime.
+
+

Model shortcuts

+

Mention the bot followed by a colon and a name, for example @agent:astra review this. The selected engine and model are pinned to that thread. Use the same form without a task to switch an existing thread. Names apply gateway-wide; channel runtime permissions still apply.

+
+ +

Container runtime

Every channel's engine processes run inside its own long-lived Linux container — separate HOME, separate CLI logins, its own process namespace, and none of the host's files reachable beyond the channel's own folders. This is the only runtime: the daemon needs podman or docker on this machine plus a built image (npm run build:image). Admin-mode channels run in their container too; the permission bypass applies inside it, never on the host.

diff --git a/src/config/model-shortcuts.js b/src/config/model-shortcuts.js new file mode 100644 index 00000000..66cd1761 --- /dev/null +++ b/src/config/model-shortcuts.js @@ -0,0 +1,19 @@ +// Gateway-wide names for explicit, thread-scoped Slack model choices. +export const MODEL_SHORTCUT_NAME_RE = /^[a-z][a-z0-9_-]{0,23}$/; +export const MAX_MODEL_SHORTCUTS = 32; + +export function modelShortcutsFromSettings(settings) { + const raw = settings?.modelShortcuts; + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {}; + return Object.fromEntries(Object.entries(raw).filter(([name, target]) => + MODEL_SHORTCUT_NAME_RE.test(name) && target && typeof target === "object" && + typeof target.engine === "string" && typeof target.model === "string" + )); +} + +// Slack gives the bot mention as <@ID>; stripMentions leaves the colon at the start. +// Require a separating space before the task, so ":astraight" never selects ":astra". +export function parseModelShortcut(text) { + const match = /^:\s*([a-z][a-z0-9_-]{0,23})(?=\s|$)\s*([\s\S]*)$/i.exec(String(text || "").trim()); + return match ? { name: match[1].toLowerCase(), task: match[2].trim() } : null; +} diff --git a/src/config/settings.js b/src/config/settings.js index 019011b0..9f321832 100644 --- a/src/config/settings.js +++ b/src/config/settings.js @@ -14,6 +14,7 @@ import { QWEN_PROVIDERS, qwenProvider } from "../engines/qwen.js"; // The default container image ref lives with the image module (a dependency-free leaf) so the // transactional updater can name the same image without importing this file's database layer. import { CONTAINER_DEFAULT_IMAGE } from "../runtimes/container/image.js"; +import { modelShortcutsFromSettings } from "./model-shortcuts.js"; // settings.json key → environment variable it feeds. const ENV_MAP = { @@ -308,6 +309,10 @@ export function getDefaultModel(engine) { return typeof v === "string" ? v.trim() : ""; } +export function getModelShortcuts() { + return modelShortcutsFromSettings(getSettings()); +} + // Who may use Slack's /model wizard in a CHANNEL (both channel-wide and thread-scoped picks). // Everyone reaching this gate has already passed the channel's normal authorization policy, so // "users" means every authorized channel user (approved members plus explicit guest grants). @@ -1032,6 +1037,7 @@ export function settingsForApi() { // instead of carrying a hard-coded copy of the table. qwenProviders: qwenProviderSettings(), modelChangeAccess: getModelChangeAccess(), + modelShortcuts: getModelShortcuts(), engineEnabled: getEngineEnabledMap(), engineFallback: getEngineFallback(), engineFallbackMode: getEngineFallbackMode(), diff --git a/src/slack/help.js b/src/slack/help.js index baea5b6d..38258dfe 100644 --- a/src/slack/help.js +++ b/src/slack/help.js @@ -14,7 +14,7 @@ export const HELP_TEXT = "• *Reminders and schedules:* ask naturally, for example `remind me in 2 hours to call the client` or `every Monday at 09:00 summarize new messages`. Reminders only post a nudge; scheduled tasks wake the agent to do work. Ask `list schedules` or `delete schedule ` to manage them.\n" + "• *Long-running work:* ask me to run it in the background. In an Auto/Full-access channel, the gateway keeps the job alive after the current turn and reports back in this thread when it finishes.\n" + "• *Direct host work:* an organization admin can enable `/sudo` for one thread. Its admin messages run directly as the gateway daemon OS user, outside the channel container; non-admin messages to that thread are rejected. Use `/sudo off` to restore the normal container.\n" + - "• *Useful checks:* `/status` shows active runs, background jobs, and schedules; `/pending` shows threads waiting on your decision; `/model` changes Claude/Codex, model, and effort; `/mode` shows the channel's tool-access mode.\n\n" + + "• *Useful checks:* `/status` shows active runs, background jobs, and schedules; `/pending` shows threads waiting on your decision; `/model` changes Claude/Codex, model, and effort; `@agent:shortcut task` picks a configured model for this thread; `/mode` shows the channel's tool-access mode.\n\n" + "*Commands* (this thread/channel)\n" + "• `/menu` — show only the Files, Variables, and Settings buttons; use `@agent /menu` inside a channel thread\n" + "• `/help` — show this guide\n" + diff --git a/src/slack/message-pipeline.js b/src/slack/message-pipeline.js index 29149064..5bdf0744 100644 --- a/src/slack/message-pipeline.js +++ b/src/slack/message-pipeline.js @@ -39,7 +39,8 @@ import { noteBotReply, noteUserActivity } from "../gateway/nudges.js"; import { applyLoopWakeup, stopLoops, stopThreadLoops } from "../gateway/loops.js"; import { buildPendingReportForUser } from "../gateway/followups.js"; -import { resolveSlackConfig, getProgressView, getContextWindow, getTrustedBotApps, getDefaultChannelAccess, applyChannelTemplate, getDefaultNudges, getSlackAdminUserToken, canChangeChannelRuntime, getWhisperEnabled, getEngineFallbackMode } from "../config/settings.js"; +import { resolveSlackConfig, getProgressView, getContextWindow, getTrustedBotApps, getDefaultChannelAccess, applyChannelTemplate, getDefaultNudges, getSlackAdminUserToken, canChangeChannelRuntime, getWhisperEnabled, getEngineFallbackMode, getModelShortcuts, isEngineEnabled } from "../config/settings.js"; +import { parseModelShortcut } from "../config/model-shortcuts.js"; import { mdToMrkdwn, resolveMentions } from "./format.js"; import { answerImageBlocks, shareAnswerImageFiles } from "./images.js"; import { appendSlackTables, extractSlackTables, formatSlackTables } from "./block-content.js"; @@ -1082,6 +1083,40 @@ export async function processMessageEvent(event, client, { botUserId = "", teamI // stamp), and the session-stamp comparison after the directive blocks (covers switches made // out-of-band — the /model wizard, the admin UI, a flipped gateway default). let engineSwitched = false; + // A colon immediately after the bot mention selects a configured model shortcut. The + // setting is read for each message, so admins can repoint names without restarting Slack. + // Persist the resolved target on this thread; later edits to the shortcut affect new picks. + const shortcut = !syntheticFork && !engineChoice ? parseModelShortcut(prompt) : null; + if (shortcut) { + const shortcuts = getModelShortcuts(); + const target = Object.hasOwn(shortcuts, shortcut.name) ? shortcuts[shortcut.name] : null; + if (!target) { + await client.chat.postMessage({ channel: event.channel, thread_ts: threadKey, text: `Unknown model shortcut \`:${shortcut.name}\`. Ask an admin to add it in Settings → Model shortcuts.` }); + return; + } + if (!isDM && !canChangeChannelRuntime(authorIsAdmin)) { + await client.chat.postMessage({ channel: event.channel, thread_ts: threadKey, text: "Only admins can change the model in this channel." }); + return; + } + if (!isEngineEnabled(target.engine) || !modelBelongsToEngine(target.model, target.engine)) { + await client.chat.postMessage({ channel: event.channel, thread_ts: threadKey, text: `Model shortcut \`:${shortcut.name}\` needs an enabled engine and valid model. Update it in Settings.` }); + return; + } + if (meta.codexAuthSource === "channel" && target.engine !== "codex") { + await client.chat.postMessage({ channel: event.channel, thread_ts: threadKey, text: "This channel uses its own Codex login, so its engine stays Codex." }); + return; + } + engineSwitched = (await getThreadEngine(entry.slug, threadKey)) !== target.engine; + await setThreadEngine(entry.slug, threadKey, target.engine); + await setThreadModel(entry.slug, threadKey, target.model); + // Effort is model-specific. A prior thread pin must not leak into a new shortcut. + await setThreadEffort(entry.slug, threadKey, ""); + prompt = shortcut.task; + if (!prompt && files.length === 0) { + await client.chat.postMessage({ channel: event.channel, thread_ts: threadKey, text: `✅ This thread now uses *${engineLabel(target.engine)}* · \`${target.model}\`.` }); + return; + } + } // A harness-switch card click: the person chose where this message runs. "Switch" pins the // thread there (the same per-thread choice the `claude` / `codex` directive makes); "try // again" leaves the thread as it is. Any engine directive in the text was already applied @@ -1101,7 +1136,7 @@ export async function processMessageEvent(event, client, { botUserId = "", teamI // switch phrase ("try again with codex", "switch to codex", "use claude") — a switch verb // immediately before the engine name, so an incidental mention ("the codex CLI") won't flip it. // It sticks until changed; the rest of the message is the task. - if (!syntheticFork && files.length === 0) { + if (!syntheticFork && !shortcut && files.length === 0) { const trimmed = prompt.trim(); const anchored = new RegExp(`^(${engineIdAlternation()})\\b[\\s:,.;–—-]*([\\s\\S]*)$`, "i").exec(trimmed); // Only a switch INTENT near the START counts (index ≤ 12, allowing a short lead like diff --git a/src/web/routes/settings.js b/src/web/routes/settings.js index 313e63e4..225abd2b 100644 --- a/src/web/routes/settings.js +++ b/src/web/routes/settings.js @@ -51,6 +51,7 @@ import { isSubscriptionName } from "../../platforms/googlechat/pubsub.js"; import { logEvent } from "../../util/logger.js"; import { checkForUpdate, startUpdate } from "../../gateway/updater.js"; import { isValidModel } from "../../slack/util.js"; +import { MODEL_SHORTCUT_NAME_RE, MAX_MODEL_SHORTCUTS } from "../../config/model-shortcuts.js"; // Organization-wide environment secrets. WRITE-ONLY, exactly like the per-channel ones: // listOrgEnv is the only shape that may leave the process (config/scoped-env.js). import { listOrgEnv, patchOrgEnv } from "../../config/scoped-env.js"; @@ -341,6 +342,20 @@ export function createSettingsRouter({ patch.engine = body.engine; } if (typeof body.modelChangeAccess === "string" && MODEL_CHANGE_ACCESS_MODES.includes(body.modelChangeAccess)) patch.modelChangeAccess = body.modelChangeAccess; + if (body.modelShortcuts !== undefined) { + const entries = body.modelShortcuts && typeof body.modelShortcuts === "object" && !Array.isArray(body.modelShortcuts) + ? Object.entries(body.modelShortcuts) : null; + if (!entries || entries.length > MAX_MODEL_SHORTCUTS) return res.status(400).json({ error: `modelShortcuts must contain at most ${MAX_MODEL_SHORTCUTS} names` }); + const shortcuts = {}; + for (const [rawName, target] of entries) { + const name = rawName.toLowerCase(); + if (!MODEL_SHORTCUT_NAME_RE.test(name) || Object.hasOwn(shortcuts, name)) return res.status(400).json({ error: `invalid or duplicate model shortcut "${rawName}"` }); + if (!target || !ENGINES.includes(target.engine) || typeof target.model !== "string" || !isValidModel(target.model) || !modelBelongsToEngine(target.model, target.engine)) + return res.status(400).json({ error: `invalid model target for shortcut "${rawName}"` }); + shortcuts[name] = { engine: target.engine, model: target.model }; + } + patch.modelShortcuts = shortcuts; + } // Gateway default model per engine (blank clears → CLI default). Same isValidModel guard as // /model and the channel-meta routes — a typo'd id here would break EVERY defaulted run. // Keys come from the ADAPTERS, not a literal pair: a harness added later (Qwen) would diff --git a/test/model-shortcut-pipeline.test.js b/test/model-shortcut-pipeline.test.js new file mode 100644 index 00000000..56d92427 --- /dev/null +++ b/test/model-shortcut-pipeline.test.js @@ -0,0 +1,43 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { ensureTestEnv } from "./helpers.js"; + +ensureTestEnv(); +const { setUser, upsertChannelEntry, saveChannelMeta } = await import("../src/config/store.js"); +const { saveSettings } = await import("../src/config/settings.js"); +const { getThreadEngine, getThreadModel, getThreadEffort, setThreadEffort } = await import("../src/gateway/thread-engine.js"); +const { processMessageEvent } = await import("../src/slack/message-pipeline.js"); + +const USER = "U_SHORTCUT"; +const BOT = "BSHORTCUT"; +const CHANNEL = "D_SHORTCUT"; +const messages = []; +const client = { + chat: { postMessage: async (message) => { messages.push(message); return { ok: true, ts: "bot.1" }; } }, + apiCall: async () => ({ ok: true }), + conversations: { + info: async () => ({ channel: { id: CHANNEL, is_im: true } }), + replies: async () => ({ messages: [] }), + history: async () => ({ messages: [] }), + }, +}; + +test("colon mention switches a DM thread and clears an old effort pin", async () => { + await setUser(USER, { name: "Shortcut User", approved: true, isAdmin: false }); + const entry = await upsertChannelEntry(CHANNEL, { name: "shortcut-dm", type: "im", isDM: true }); + await saveChannelMeta(entry.slug, { channelId: CHANNEL, name: entry.name, type: "im", isDM: true, template: "custom", engine: "claude" }); + saveSettings({ modelShortcuts: { astra: { engine: "codex", model: "gpt-6-astra" } } }); + await setThreadEffort(entry.slug, "123.001", "high"); + await processMessageEvent({ type: "message", channel: CHANNEL, channel_type: "im", user: USER, text: `<@${BOT}>:astra`, ts: "123.001" }, client, { botUserId: BOT }); + assert.equal(await getThreadEngine(entry.slug, "123.001"), "codex"); + assert.equal(await getThreadModel(entry.slug, "123.001"), "gpt-6-astra"); + assert.equal(await getThreadEffort(entry.slug, "123.001"), ""); + assert.match(messages.at(-1).text, /gpt-6-astra/); +}); + +test("unknown shortcut cannot change the thread", async () => { + const entry = await upsertChannelEntry(CHANNEL, { name: "shortcut-dm", type: "im", isDM: true }); + await processMessageEvent({ type: "message", channel: CHANNEL, channel_type: "im", user: USER, text: `<@${BOT}>:unknown`, ts: "123.002" }, client, { botUserId: BOT }); + assert.equal(await getThreadEngine(entry.slug, "123.002"), ""); + assert.match(messages.at(-1).text, /Unknown model shortcut/); +}); diff --git a/test/model-shortcuts.test.js b/test/model-shortcuts.test.js new file mode 100644 index 00000000..194dcb24 --- /dev/null +++ b/test/model-shortcuts.test.js @@ -0,0 +1,58 @@ +import test, { after } from "node:test"; +import assert from "node:assert/strict"; +import express from "express"; +import { ensureTestEnv } from "./helpers.js"; + +ensureTestEnv(); +const { parseModelShortcut } = await import("../src/config/model-shortcuts.js"); +const { getModelShortcuts, settingsForApi, saveSettings } = await import("../src/config/settings.js"); +const { createAdminRouter } = await import("../src/web/routes/admin.js"); + +const app = express(); +app.use(express.json()); +app.use(createAdminRouter({ slack: { snapshot: () => ({ connected: false }) } })); +const server = await new Promise((resolve) => { + const listening = app.listen(0, "127.0.0.1", () => resolve(listening)); +}); +after(() => server.close()); + +async function put(modelShortcuts, expectedStatus = 200) { + const response = await fetch(`http://127.0.0.1:${server.address().port}/settings`, { + method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ modelShortcuts }), + }); + assert.equal(response.status, expectedStatus); + return response.json(); +} + +test("mention suffix parses a shortcut and preserves the task", () => { + assert.deepEqual(parseModelShortcut(":astra build this"), { name: "astra", task: "build this" }); + assert.deepEqual(parseModelShortcut(": FABLE"), { name: "fable", task: "" }); + assert.equal(parseModelShortcut("review :astra"), null); + assert.equal(parseModelShortcut(":astra, build this"), null); +}); + +test("settings save and expose valid engine/model mappings", async () => { + await put({ astra: { engine: "codex", model: "gpt-6-astra" }, opus: { engine: "claude", model: "claude-opus-5-5" } }); + assert.deepEqual(getModelShortcuts(), { + astra: { engine: "codex", model: "gpt-6-astra" }, + opus: { engine: "claude", model: "claude-opus-5-5" }, + }); + assert.deepEqual(settingsForApi().modelShortcuts, getModelShortcuts()); + await put({ astra: { engine: "codex", model: "gpt-5.6-sol" } }); + assert.equal(getModelShortcuts().astra.model, "gpt-5.6-sol", "an admin can repoint the shortcut when models change"); + await put({}); + assert.deepEqual(getModelShortcuts(), {}); +}); + +test("invalid shortcut saves leave the old mapping intact", async () => { + saveSettings({ modelShortcuts: { sol: { engine: "codex", model: "gpt-5.6-sol" } } }); + for (const invalid of [ + { "bad name": { engine: "codex", model: "gpt-5.6-sol" } }, + { sol: { engine: "claude", model: "gpt-5.6-sol" } }, + { sol: { engine: "codex", model: "not a model" } }, + { SOL: { engine: "codex", model: "gpt-5.6-sol" }, sol: { engine: "codex", model: "gpt-5.6-sol" } }, + ]) { + await put(invalid, 400); + assert.equal(getModelShortcuts().sol.model, "gpt-5.6-sol"); + } +}); From 98431f96ba4d360ad92506978a83aa0a1dc1b21c Mon Sep 17 00:00:00 2001 From: Tiberiu Socaci Date: Sun, 4 Oct 2026 17:37:24 +0300 Subject: [PATCH 008/139] fix: preserve live model catalog when showing saved shortcuts Signed-off-by: Tiberiu Socaci --- public/app.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/app.js b/public/app.js index a69a53e5..0fc88fb1 100644 --- a/public/app.js +++ b/public/app.js @@ -202,7 +202,7 @@ function addModelShortcutRow(name = "", target = {}) { const fillModels = (value = "") => { const engine = engineSelect.value; const manifestModels = ENGINE_MANIFESTS.find((m) => m.id === engine)?.models || []; - const options = manifestModels.length ? manifestModels.map((m) => [m.value, m.label || m.value]) : (MODEL_OPTIONS[engine] || []); + const options = manifestModels.length ? manifestModels.map((m) => [m.value, m.label || m.value]) : [...(MODEL_OPTIONS[engine] || [])]; if (value && !options.some(([id]) => id === value)) options.push([value, value]); modelSelect.innerHTML = options.map(([id, label]) => ``).join(""); modelSelect.value = value && options.some(([id]) => id === value) ? value : (options[0]?.[0] || ""); From 7c294e35f867123112cb243bb4ed88391d7e7185 Mon Sep 17 00:00:00 2001 From: Tiberiu Socaci Date: Sun, 4 Oct 2026 17:45:06 +0300 Subject: [PATCH 009/139] test: cover Slack's spaced mention shortcut syntax Signed-off-by: Tiberiu Socaci --- CHANGELOG.md | 2 +- FEATURES.md | 2 +- TEST-PLAN.md | 6 +++--- public/index.html | 2 +- src/slack/help.js | 2 +- test/model-shortcut-pipeline.test.js | 10 +++++++--- 6 files changed, 14 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 49bb8302..16c6ab55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,7 @@ product overview. ## Unreleased -- Gateway Settings now lets admins map short names to an engine and model. In Slack, mention the bot as `@agent:astra` to pin that model to a thread, with an optional task after the name. Mappings can be updated as models change. +- Gateway Settings now lets admins map short names to an engine and model. In Slack, select the bot mention and type `:astra` after Slack's inserted space (`@agent :astra`) to pin that model to a thread, with an optional task after the name. Mappings can be updated as models change. - Add `@agent /fork ` for Claude and Codex sessions in Slack. It starts a separate engine branch in a linked new thread and runs the supplied message there. diff --git a/FEATURES.md b/FEATURES.md index dc657ec8..282786ea 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -2,7 +2,7 @@ ## Gateway model shortcuts -Admins can add gateway-wide model shortcuts in Settings → Access & security → Model shortcuts. Each name selects an engine and model. In Slack, `@agent:astra task` pins that engine and model to the current thread and runs the task; `@agent:astra` switches the thread without starting an agent turn. Changing a mapping affects later shortcut selections, while threads already pinned keep their selected model. Channel runtime-change access and dedicated Codex-login restrictions apply. +Admins can add gateway-wide model shortcuts in Settings → Access & security → Model shortcuts. Each name selects an engine and model. In Slack, `@agent :astra task` pins that engine and model to the current thread and runs the task; `@agent :astra` switches the thread without starting an agent turn. Slack's automatically inserted space after the mention is accepted. Changing a mapping affects later shortcut selections, while threads already pinned keep their selected model. Channel runtime-change access and dedicated Codex-login restrictions apply. ## Runtime history transfer and Google Chat failure recovery diff --git a/TEST-PLAN.md b/TEST-PLAN.md index f5e0c898..da81f933 100644 --- a/TEST-PLAN.md +++ b/TEST-PLAN.md @@ -4,9 +4,9 @@ - [x] Automated: `test/model-shortcuts.test.js` checks parsing, saving, repointing, clearing and refusing invalid mappings. - [ ] Live: In gateway Settings, add `astra` → Codex / a currently offered model and `opus` → Claude / a currently offered model. Save and reload; verify both mappings remain. Repoint `astra` to another offered Codex model, save and reload. -- [ ] Live: In a Slack channel with runtime changes allowed for the author, send `@agent:astra summarize this thread` as a new root message. Verify the reply uses the selected Codex model and the thread settings show the pin. Reply `@agent:opus` in the same thread; verify the switch acknowledgement and the next ordinary request uses the selected Claude model with earlier thread context available. -- [ ] Live: In a channel restricted to admin runtime changes, verify a non-admin's `@agent:astra task` is refused and leaves the thread runtime unchanged. Verify an unknown name is refused. In a channel with its own Codex login, verify a Claude shortcut is refused. -- [ ] Live: Run the reverse engine path with `@agent:opus task` as a new root message and `@agent:astra` within that thread, confirming the Codex answer retains earlier context. Check both engines' recorded model and effort (the shortcut clears a previous effort pin). +- [ ] Live: In a Slack channel with runtime changes allowed for the author, select the bot from Slack's mention picker and send `@agent :astra summarize this thread` as a new root message, retaining Slack's inserted space. Verify the reply uses the selected Codex model and the thread settings show the pin. Reply `@agent :opus` in the same thread; verify the switch acknowledgement and the next ordinary request uses the selected Claude model with earlier thread context available. +- [ ] Live: In a channel restricted to admin runtime changes, verify a non-admin's `@agent :astra task` is refused and leaves the thread runtime unchanged. Verify an unknown name is refused. In a channel with its own Codex login, verify a Claude shortcut is refused. +- [ ] Live: Run the reverse engine path with `@agent :opus task` as a new root message and `@agent :astra` within that thread, confirming the Codex answer retains earlier context. Check both engines' recorded model and effort (the shortcut clears a previous effort pin). ## Runtime history transfer and Google Chat failure recovery (2026-10-02) diff --git a/public/index.html b/public/index.html index 70c2381c..df6e4bd1 100644 --- a/public/index.html +++ b/public/index.html @@ -910,7 +910,7 @@

Runtime changes

Model shortcuts

-

Mention the bot followed by a colon and a name, for example @agent:astra review this. The selected engine and model are pinned to that thread. Use the same form without a task to switch an existing thread. Names apply gateway-wide; channel runtime permissions still apply.

+

Mention the bot, then type a colon and a name after Slack's inserted space, for example @agent :astra review this. The selected engine and model are pinned to that thread. Use the same form without a task to switch an existing thread. Names apply gateway-wide; channel runtime permissions still apply.

diff --git a/src/slack/help.js b/src/slack/help.js index 38258dfe..950fb0d0 100644 --- a/src/slack/help.js +++ b/src/slack/help.js @@ -14,7 +14,7 @@ export const HELP_TEXT = "• *Reminders and schedules:* ask naturally, for example `remind me in 2 hours to call the client` or `every Monday at 09:00 summarize new messages`. Reminders only post a nudge; scheduled tasks wake the agent to do work. Ask `list schedules` or `delete schedule ` to manage them.\n" + "• *Long-running work:* ask me to run it in the background. In an Auto/Full-access channel, the gateway keeps the job alive after the current turn and reports back in this thread when it finishes.\n" + "• *Direct host work:* an organization admin can enable `/sudo` for one thread. Its admin messages run directly as the gateway daemon OS user, outside the channel container; non-admin messages to that thread are rejected. Use `/sudo off` to restore the normal container.\n" + - "• *Useful checks:* `/status` shows active runs, background jobs, and schedules; `/pending` shows threads waiting on your decision; `/model` changes Claude/Codex, model, and effort; `@agent:shortcut task` picks a configured model for this thread; `/mode` shows the channel's tool-access mode.\n\n" + + "• *Useful checks:* `/status` shows active runs, background jobs, and schedules; `/pending` shows threads waiting on your decision; `/model` changes Claude/Codex, model, and effort; `@agent :shortcut task` picks a configured model for this thread; `/mode` shows the channel's tool-access mode.\n\n" + "*Commands* (this thread/channel)\n" + "• `/menu` — show only the Files, Variables, and Settings buttons; use `@agent /menu` inside a channel thread\n" + "• `/help` — show this guide\n" + diff --git a/test/model-shortcut-pipeline.test.js b/test/model-shortcut-pipeline.test.js index 56d92427..13de6f99 100644 --- a/test/model-shortcut-pipeline.test.js +++ b/test/model-shortcut-pipeline.test.js @@ -7,6 +7,8 @@ const { setUser, upsertChannelEntry, saveChannelMeta } = await import("../src/co const { saveSettings } = await import("../src/config/settings.js"); const { getThreadEngine, getThreadModel, getThreadEffort, setThreadEffort } = await import("../src/gateway/thread-engine.js"); const { processMessageEvent } = await import("../src/slack/message-pipeline.js"); +const { stripMentions } = await import("../src/slack/message-normalize.js"); +const { parseModelShortcut } = await import("../src/config/model-shortcuts.js"); const USER = "U_SHORTCUT"; const BOT = "BSHORTCUT"; @@ -22,13 +24,15 @@ const client = { }, }; -test("colon mention switches a DM thread and clears an old effort pin", async () => { +test("Slack's inserted space before :astra switches a DM thread and clears an old effort pin", async () => { await setUser(USER, { name: "Shortcut User", approved: true, isAdmin: false }); const entry = await upsertChannelEntry(CHANNEL, { name: "shortcut-dm", type: "im", isDM: true }); await saveChannelMeta(entry.slug, { channelId: CHANNEL, name: entry.name, type: "im", isDM: true, template: "custom", engine: "claude" }); saveSettings({ modelShortcuts: { astra: { engine: "codex", model: "gpt-6-astra" } } }); await setThreadEffort(entry.slug, "123.001", "high"); - await processMessageEvent({ type: "message", channel: CHANNEL, channel_type: "im", user: USER, text: `<@${BOT}>:astra`, ts: "123.001" }, client, { botUserId: BOT }); + assert.equal(stripMentions(`<@${BOT}> :astra`, BOT), ":astra"); + assert.deepEqual(parseModelShortcut(stripMentions(`<@${BOT}> :astra review this`, BOT)), { name: "astra", task: "review this" }); + await processMessageEvent({ type: "message", channel: CHANNEL, channel_type: "im", user: USER, text: `<@${BOT}> :astra`, ts: "123.001" }, client, { botUserId: BOT }); assert.equal(await getThreadEngine(entry.slug, "123.001"), "codex"); assert.equal(await getThreadModel(entry.slug, "123.001"), "gpt-6-astra"); assert.equal(await getThreadEffort(entry.slug, "123.001"), ""); @@ -37,7 +41,7 @@ test("colon mention switches a DM thread and clears an old effort pin", async () test("unknown shortcut cannot change the thread", async () => { const entry = await upsertChannelEntry(CHANNEL, { name: "shortcut-dm", type: "im", isDM: true }); - await processMessageEvent({ type: "message", channel: CHANNEL, channel_type: "im", user: USER, text: `<@${BOT}>:unknown`, ts: "123.002" }, client, { botUserId: BOT }); + await processMessageEvent({ type: "message", channel: CHANNEL, channel_type: "im", user: USER, text: `<@${BOT}> :unknown`, ts: "123.002" }, client, { botUserId: BOT }); assert.equal(await getThreadEngine(entry.slug, "123.002"), ""); assert.match(messages.at(-1).text, /Unknown model shortcut/); }); From fcda5872bee00cf8b44d3fc670d827c1546f83b2 Mon Sep 17 00:00:00 2001 From: Tiberiu Socaci Date: Mon, 5 Oct 2026 09:17:30 +0300 Subject: [PATCH 010/139] feat: add quiet conditional interval schedules Signed-off-by: Tiberiu Socaci --- CHANGELOG.md | 3 + FEATURES.md | 6 ++ TEST-PLAN.md | 11 +++ public/app.js | 10 ++- public/index.html | 2 +- src/config/schedules.js | 7 +- .../gateway-usage/references/reminders.md | 10 ++- src/gateway/scheduler.js | 47 ++++++++---- src/mcp/tools/schedules.js | 29 ++++++-- src/web/routes/schedules.js | 7 +- test/schedule-conditional.test.js | 72 +++++++++++++++++++ 11 files changed, 175 insertions(+), 29 deletions(-) create mode 100644 test/schedule-conditional.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index a70b447c..5af01f12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,9 @@ product overview. ## Unreleased +- Scheduled checks can run every fixed number of days and DM their creator only when the result + begins with a configured match prefix. Routine checks make no Slack posts. + - Add `@agent /fork ` for Claude and Codex sessions in Slack. It starts a separate engine branch in a linked new thread and runs the supplied message there. - Fix a history-transfer deadlock when `/sudo` crosses a container whose mounts need recreation. diff --git a/FEATURES.md b/FEATURES.md index c6de20ee..31813633 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -1329,6 +1329,12 @@ A categorized catalog of what's shipped. Cross-linked to `TEST-PLAN.md` checks. sessions remain fresh per run — the session key is minted per fire and never derived from the reused anchor, so grouping the day's results never joins them into one conversation. Existing schedules retain standard per-run announcements. → TEST-PLAN: Scheduling & reminders. +- Scheduled tasks can use `interval_days` for a fixed 24-hour-day cadence (including exactly + 14 days), and `delivery:"dm-on-match"` with a required `match_prefix` for quiet monitoring. + The scheduler posts no start or routine result; only a result beginning with the prefix is + sent as a DM to the creator, then the schedule stops. Failed runs remain in schedule status and events. The next interval + advances from the planned due time, skipping missed periods rather than replaying checks. + → TEST-PLAN: Scheduling & reminders. - Native `/loop` in a thread: Claude Code's own loop skill paces itself with `ScheduleWakeup` / `CronCreate`, both of which are session-local (the harness documents its cron store as "gone when Claude exits") and therefore inert in a headless turn. The daemon reads the pacing call out of the diff --git a/TEST-PLAN.md b/TEST-PLAN.md index 1ef86bec..705e29a4 100644 --- a/TEST-PLAN.md +++ b/TEST-PLAN.md @@ -5901,6 +5901,17 @@ none` for its cases and live gates. Kept as history. two surfaces. ### Scheduling & reminders — time zone, label, acknowledgment + escalation (Slice 8.9) +- [x] Unit: `interval_days:14` creates an exact fixed interval, the scheduler runs once when due, + advances from the planned due time, and does not replay missed periods. `dm-on-match` makes + no start or routine post, while a `FOUND:` result goes only to the creator's DM + (`test/schedule-conditional.test.js`). +- [ ] Live (Claude and Codex): in a disposable channel, create a two-week conditional check with + `delivery:"dm-on-match"`, `match_prefix:"FOUND:"`, and a prompt whose first run returns + `NO_UPDATE`. Move its due time forward using the test database, fire it, and require no + channel or DM post. Then return `FOUND: ` from the next run and require + exactly one creator DM, no channel post, and a disabled schedule to avoid repeat alerts. Inspect status + after a controlled failure; require an error recorded without a Slack post. + Private QA cases: AUT-QUIET-01-C and AUT-QUIET-01-X. - [x] Unit: the daemon's zone is resolved from `TZ` (POSIX `:Zone` spelling included) or the platform, `zonedStamp` renders "2026-09-08 09:15 Europe/Bucharest (06:15 UTC)" (full UTC date when the two calendars disagree, no parenthetical on a UTC daemon, no throw on an unusable diff --git a/public/app.js b/public/app.js index 07daf2d7..933cd874 100644 --- a/public/app.js +++ b/public/app.js @@ -2786,6 +2786,7 @@ function parseScheduleCron(cron) { function friendlySchedule(schedule) { if (schedule.once) return `Once · ${schedule.runAt ? new Date(schedule.runAt).toLocaleString() : "time unavailable"}`; + if (schedule.intervalDays) return `Every ${schedule.intervalDays} days · next ${schedule.runAt ? new Date(schedule.runAt).toLocaleString() : "time unavailable"}`; const parsed = parseScheduleCron(schedule.cron); const at = parsed.time ? ` at ${parsed.time}` : ""; if (parsed.frequency === "daily") return `Daily${at}`; @@ -2806,8 +2807,9 @@ function localDateTimeValue(value) { function syncScheduleTimingFields() { const once = Boolean(scheduleEditor?.schedule.once); + const interval = Boolean(scheduleEditor?.schedule.intervalDays); const frequency = document.getElementById("schedule-frequency").value; - document.getElementById("schedule-recurring-fields").hidden = once; + document.getElementById("schedule-recurring-fields").hidden = once || interval; document.getElementById("schedule-once-wrap").hidden = !once; document.getElementById("schedule-day-wrap").hidden = once || frequency !== "weekly"; document.getElementById("schedule-month-day-wrap").hidden = once || frequency !== "monthly"; @@ -2840,6 +2842,7 @@ function openScheduleEditor(schedule) { scheduleDetail("Channel", schedule.channelName || schedule.slug || schedule.channelId), scheduleDetail("Type", schedule.kind === "reminder" ? "Reminder" : "Task"), scheduleDetail("Status", status), + ...(schedule.intervalDays ? [scheduleDetail("Timing", friendlySchedule(schedule))] : []), ].join(""); document.getElementById("schedule-modal-description").value = schedule.description || ""; document.getElementById("schedule-modal-enabled").checked = Boolean(schedule.enabled); @@ -2859,6 +2862,7 @@ function openScheduleEditor(schedule) { const delivery = document.getElementById("schedule-modal-delivery"); delivery.value = schedule.delivery || "standard"; delivery.querySelector('option[value="daily-thread"]').disabled = Boolean(schedule.once); + delivery.querySelector('option[value="dm-on-match"]').disabled = !schedule.matchPrefix; prompt.value = schedule.prompt || ""; syncScheduleTimingFields(); error.textContent = ""; @@ -2901,7 +2905,7 @@ async function saveScheduleEditor() { // datetime-local is in the browser's timezone. Send an explicit instant so a daemon in // another timezone cannot shift it (or turn a future task into an immediately due one). if (schedule.once) body.runAt = new Date(document.getElementById("schedule-modal-run-at").value).toISOString(); - else body.cron = cronFromScheduleEditor(); + else if (!schedule.intervalDays) body.cron = cronFromScheduleEditor(); if (schedule.kind !== "reminder") body.delivery = document.getElementById("schedule-modal-delivery").value; const result = await api(`/api/schedules/${scheduleEditor.schedule.id}`, { method: "PUT", @@ -2951,7 +2955,7 @@ function renderSchedules() { row.className = "sched-row"; // Last-run: a status dot (ok/warn) + relative-ish text; "never run" when it hasn't fired yet. const runHtml = s.lastRun - ? `last ${escapeHtml(new Date(s.lastRun).toLocaleString())}` + ? `last ${escapeHtml(new Date(s.lastRun).toLocaleString())}` : `never run`; row.innerHTML = ` diff --git a/public/index.html b/public/index.html index 70bf4dae..8bcbf4f3 100644 --- a/public/index.html +++ b/public/index.html @@ -1096,7 +1096,7 @@

Automation details

- +