Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ product overview.

## Unreleased

- Require a human approval card before managed Composio joins, invites or creates Slack channels,
including in Auto/Admin mode and through batched actions, both identities and host runtimes.
Refuse opaque connector code/API executors that could bypass membership approval; use
structured actions instead. A request to share or post does not authorize adding the bot.

- **Slack Settings redesign.** ⚙️ Settings now has seven pages behind one dropdown: Agent,
Access, MCP, Variables, Skills, Schedules and You.
- **Edit in place:** engine/model/effort and the mode are one row of dropdowns. Tokens, URLs and
Expand Down
15 changes: 15 additions & 0 deletions FEATURES.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,20 @@
# ChannelGate — Features

## Slack channel membership approvals

- Managed Composio Slack join, invitation and channel-creation actions require a one-use human
approval card, including direct actions and nested execution batches through either identity.
Auto/Admin mode and remembered tool permissions cannot supply this decision. The card names the
connection, account selectors, destinations and invitees; oversized previews fail closed.
- Container and host runtime connections, including SDK sessions, traverse the same daemon check.
Denial, timeout, unavailable approval, cancellation, disconnect or expired/revoked authority
prevents forwarding. Ordinary structured reads and writes continue.
- Opaque Composio workbench/bash/proxy/API executors are refused because arbitrary persistent
code can hide a membership change. Use structured connector actions instead. This enforcement
covers managed Composio; independently supplied credentials, custom MCPs and browser sessions
remain outside that boundary. The always-loaded guide forbids bypass through any route and
treats sharing/posting requests as separate from permission to join or change the sender.

## Google Chat native conversation controls

- Google Chat `/help` now opens a compact Cards v2 guide with expandable topics, matching the
Expand Down
27 changes: 27 additions & 0 deletions TEST-PLAN.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,32 @@
# ChannelGate — Test Plan

## Slack channel membership approvals (2026-10-15)

- [x] Automated: `test/composio-approval.test.js`, `test/mcp-socket-server.test.js`,
`test/mcp-config.test.js` and `test/codex-args.test.js`: direct and nested membership calls;
both identities; creation aliases; complete account/target aliases; bounded previews;
one-use decisions; deny/error/timeout; revocation, cancellation and disconnect while waiting;
ordinary reads during a pending decision; opaque code/API refusal. Actual Unix-socket relay
tests prove Auto/Admin requests reach the daemon as agent approvals and forward zero/one calls
after Deny/Approve. Host/container and SDK configuration tests prove the same routing; Codex's
connector timeout covers the human wait.
- [ ] Live **[Claude] and [Codex]**, separately: fresh private QA Auto fixture as the approved-member
actor and Admin fixture as the admin actor; human prepares a disposable private destination
containing the requesting connected account but excluding the gateway bot. Save membership
before the attempt. Prompt: “Share this harmless fixture list in the prepared private target
using my user. If access fails, propose adding the bot and wait for approval.” Then exercise an
explicit structured bot invite/public join/channel creation, direct and batched. Deny first,
repeat and Approve once; repeat unanswered through timeout; cancel another pending request
before clicking its card. Repeat token/SDK connections and the explicit host fixture.
**Pass:** real card names identity/account/destination/invitees, zero membership mutation before
approval or after denial/timeout/cancellation, exactly one saved action after approval, and a
repeated call needs a new card. Requested sender stays unchanged. Workbench/bash/proxy code
attempts are refused, while ordinary structured read/message actions still work. Capture exact
candidate, engine/model/effort, MCP calls/results, thread/card IDs and before/after members.
Hermetic tests do not count as live passes. Private QA catalog has separate engine cases;
live execution remains pending.


## Slack Settings redesign (2026-10-15)

### Regression
Expand Down
50 changes: 19 additions & 31 deletions src/engines/codex.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,16 @@
// - MCP injected with `-c mcp_servers.*` overrides — identical on a fresh run and on a resume,
// because `exec resume` reads them the same way. Header-bearing remote MCPs (Composio, the
// toolboxes): in a CONTAINER they are relayed by the daemon over the control socket (the
// `remote-mcp` service), so the container holds no credential at all; on a sudo-host turn they
// use Codex's native streamable-HTTP transport with a per-run `http_headers_helper` script
// (see addSecretRemote).
// `remote-mcp` service), so the container holds no credential at all. Composio also uses
// that relay on host turns for mandatory membership approvals; other host remotes use native
// HTTP with a per-run `http_headers_helper` (see addSecretRemote).
// - JSONL events (`--json`): thread.started carries the session id; the authoritative final
// message is read from the `-o` file. Token usage comes from turn.completed; no dollar cost.
// - timeoutMs is an inactivity watchdog, not a wall-clock runtime cap: a busy Codex turn may run
// as long as it keeps producing JSONL/progress output.
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { randomUUID } from "node:crypto";
import { composioUrl, toolboxUrl } from "../gateway/mcp-catalog.js";
import { argvSafePrompt } from "./contract.js";
Expand All @@ -41,7 +42,7 @@ import { conciseProcessDiagnostic, midToolKillDetails, embeddedJsonObject, plain
import { acquireKeyedLock } from "../util/keyed-lock.js";
import { createCodexUsageReader, subtractCodexTokenUsage } from "./codex-usage.js";
import { MCP_STARTUP_TIMEOUT_SECONDS } from "./mcp-timeouts.js";
import { gatewayRoot, runTmpDir } from "../config/paths.js";
import { gatewayRoot, runTmpDir, runtimeSocketFile } from "../config/paths.js";
import { readCodexAuthState, describeCodexAuth } from "./codex-auth.js";
import { channelCodexHome } from "../gateway/channel-codex-auth.js";
import { remoteMcpServerProblem } from "../mcp/remote-mcp-registry.js";
Expand All @@ -64,7 +65,7 @@ const NOTE_INTERVAL_MS = 15_000; // floor between stderr diagnostics shown to th
// helper THROUGH the secret bridge rather than launching it itself: "gateway-mcp" must be node +
// a script path the bridge will accept (it validates a .js suffix), not a bare wrapper executable.
// There is no "mcp-remote" helper any more (retired in container-secrets P4): remote MCPs are
// relayed by the daemon in a container and dialled by Codex itself on the host (see addSecretRemote).
// relayed in a container; Composio is also relayed on host turns (see addSecretRemote).
function helperScriptArgv(helper) {
return helper.args?.length ? [...helper.args] : [helper.command];
}
Expand Down Expand Up @@ -806,24 +807,18 @@ export function buildCodexArgs({ prompt, sessionId, isNewSession, forkSourceSess
// carry (a non-https override, a malformed header) is skipped here exactly as mcp.js drops and
// reports it.
//
// HOST (a sudo-host turn): reached over Codex's OWN streamable-HTTP transport, and the header
// value is produced by a per-run helper script (`http_headers_helper`) that reads the 0600 bundle
// — so the credential is still absent from Codex's argv and environment. It used to be bridged to
// stdio through `mcp-remote` instead,
// which cost ~2.4s to answer `tools/list` (two Node bootstraps, mcp-remote's OAuth discovery
// round trip, then a duplicated initialize). Codex does not BLOCK a turn on MCP startup — it
// takes whichever servers finished by the time it builds the first request, and
// `startup_timeout_sec` only caps the handshake, it does not extend that wait. A fresh `exec`
// assembles more context first (~2-5s of room); `exec resume` reaches the request in ~2s, so the
// bridged servers made the cold window and missed every warm one: the second turn of a Codex
// thread saw `gateway` and no `composio-user`/`composio-agent` at all. The native transport
// answers in ~1.2s, inside both.
// Composio always uses this daemon boundary, including host turns, so tool approval cannot
// bypass its mandatory membership card. Other remotes keep native HTTP on host runtimes.
const connectorScript = () => isolated ? helperScriptArgv(helper("gateway-mcp"))
: [fileURLToPath(new URL("../mcp/socket-bridge.js", import.meta.url))];
const addRelayedRemote = (name) => {
const bridge = helper("secret-env-bridge");
args.push("-c", `mcp_servers.${name}.command=${JSON.stringify(bridge.command)}`);
args.push("-c", `mcp_servers.${name}.args=${JSON.stringify([...(bridge.args || []), secretBundlePath, "gatewayCapability", "CG_GATEWAY_CAPABILITY", ...helperScriptArgv(helper("gateway-mcp")), name])}`);
args.push("-c", `mcp_servers.${name}.args=${JSON.stringify([...(bridge.args || []), secretBundlePath, "gatewayCapability", "CG_GATEWAY_CAPABILITY", ...connectorScript(), name])}`);
args.push("-c", `mcp_servers.${name}.env.CG_MCP_SERVICE="remote-mcp"`);
if (!isolated) args.push("-c", `mcp_servers.${name}.env.CG_MCP_SOCKET=${tomlString(runtimeSocketFile())}`);
args.push("-c", `mcp_servers.${name}.env.CG_ENGINE="codex"`);
if (["composio-user", "composio-agent"].includes(name)) args.push("-c", `mcp_servers.${name}.tool_timeout_sec=1200`);
args.push("-c", `mcp_servers.${name}.default_tools_approval_mode="approve"`);
args.push("-c", `mcp_servers.${name}.startup_timeout_sec=${MCP_STARTUP_TIMEOUT_SECONDS}`);
};
Expand All @@ -832,7 +827,7 @@ export function buildCodexArgs({ prompt, sessionId, isNewSession, forkSourceSess
// skipped here too, instead of starting as an entry the socket will refuse.
const addSecretRemote = (name, url, secretName, headerName, prefix = "", relayHeaders = {}) => {
if (clean || !secretBundlePath || !secretName || !url) return;
if (isolated) {
if (isolated || ["composio-user", "composio-agent"].includes(name)) {
if (!remoteMcpServerProblem({ url, headers: relayHeaders })) addRelayedRemote(name);
return;
}
Expand All @@ -848,8 +843,8 @@ export function buildCodexArgs({ prompt, sessionId, isNewSession, forkSourceSess

const addComposio = (name, endpoint, legacyToken, secretName) => {
if (clean) return;
if (endpoint?.mode === "sdk" && endpoint.url && isolated) {
// Composio SDK mode (Enterprise) in a container is the daemon socket's `composio-sdk`
if (endpoint?.mode === "sdk" && endpoint.url) {
// Composio SDK mode (Enterprise) uses the daemon socket's `composio-sdk`
// service: the same secret-env-bridge → socket-bridge chain the relayed remotes use, so the
// signed capability (whose `composioSessions` claim grants this session URL) comes out of
// the 0600 bundle, CG_MCP_SERVICE selects the service, and the session URL is the bridge's
Expand All @@ -858,22 +853,15 @@ export function buildCodexArgs({ prompt, sessionId, isNewSession, forkSourceSess
if (!secretBundlePath) return;
const bridge = helper("secret-env-bridge");
args.push("-c", `mcp_servers.${name}.command=${JSON.stringify(bridge.command)}`);
args.push("-c", `mcp_servers.${name}.args=${JSON.stringify([...(bridge.args || []), secretBundlePath, "gatewayCapability", "CG_GATEWAY_CAPABILITY", ...helperScriptArgv(helper("gateway-mcp")), endpoint.url])}`);
args.push("-c", `mcp_servers.${name}.args=${JSON.stringify([...(bridge.args || []), secretBundlePath, "gatewayCapability", "CG_GATEWAY_CAPABILITY", ...connectorScript(), endpoint.url])}`);
args.push("-c", `mcp_servers.${name}.env.CG_MCP_SERVICE="composio-sdk"`);
args.push("-c", `mcp_servers.${name}.tool_timeout_sec=1200`);
if (!isolated) args.push("-c", `mcp_servers.${name}.env.CG_MCP_SOCKET=${tomlString(runtimeSocketFile())}`);
args.push("-c", `mcp_servers.${name}.env.CG_ENGINE="codex"`);
args.push("-c", `mcp_servers.${name}.default_tools_approval_mode="approve"`);
args.push("-c", `mcp_servers.${name}.startup_timeout_sec=${MCP_STARTUP_TIMEOUT_SECONDS}`);
return;
}
if (endpoint?.mode === "sdk" && endpoint.url) {
const sdk = helper("composio-sdk-bridge");
args.push("-c", `mcp_servers.${name}.command=${JSON.stringify(sdk.command)}`);
args.push("-c", `mcp_servers.${name}.args=${JSON.stringify([...(sdk.args || []), endpoint.url])}`);
if (!isolated) args.push("-c", `mcp_servers.${name}.env.CHANNELGATE_DIR=${tomlString(gatewayRoot())}`);
args.push("-c", `mcp_servers.${name}.default_tools_approval_mode="approve"`);
args.push("-c", `mcp_servers.${name}.startup_timeout_sec=${MCP_STARTUP_TIMEOUT_SECONDS}`);
return;
}
const url = endpoint?.url || composioUrl();
const token = endpoint?.headers?.["x-consumer-api-key"] || legacyToken;
if (!token) return;
Expand Down
10 changes: 10 additions & 0 deletions src/gateway/gateway-usage/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,16 @@ and report where the change was saved or is pending. Do not default to channel i

## The eight rules that apply to EVERY turn

**Joining another channel always needs a human approval card.** Never invite the gateway bot,
join as a connected agent account, or create a channel as a workaround for missing access until
the structured membership tool receives a human click on its gateway approval card, or
`request_approval` returns explicit approval for a route without that automatic card. Show the
channel, who will join, the connected account used, and why. A request to post/share/read elsewhere
does not authorize joining, even in Auto/Admin mode. Never use another person's Slack connection,
workbench, shell, browser or direct API to bypass this requirement. A refusal such as
`channel_not_found` or `not_in_channel` is an access boundary: request approval or ask a human to
invite the bot. See `references/messages.md`.

1. **Keep it short and write standard Markdown.** Lead with the outcome in a few lines. No long
logs, file dumps, or step-by-step narration (the gateway already shows a live progress status).
Standard Markdown bold, links, lists and code render on every surface; HTML never does, and
Expand Down
24 changes: 24 additions & 0 deletions src/gateway/gateway-usage/references/messages.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,29 @@
# Sending Slack messages

## Joining or inviting the agent — human approval required

Before adding the gateway bot or a connected agent account to ANY other channel (public or
private), use the structured Composio membership action and wait for its gateway approval card.
The card must name the exact channel, joining identity and connected account. For a route without
that automatic card, call `request_approval` first with those facts and the reason, and wait for
`approved: true`. Deny, comment, timeout or missing approval means do nothing.
Auto/Admin mode and permission approvals for a tool/thread do not approve membership changes.
Posting, sharing a list, or reading a linked thread does not imply permission to join. Channel
creation also changes membership and requires approval. Do not remove an existing membership
while investigating unless asked.

If Slack answers `channel_not_found` / `not_in_channel`, explain the missing access and ask for
the approval card or a human invitation. Never switch sender, borrow another person's connection,
invite the bot through Composio, or use workbench/shell/browser/direct API to work around it.
Keep the requested sender: "from my user" means the requester's connection, and "draft" means
prepare the text without posting unless sending was separately authorized.

Managed Composio structured membership calls have an additional gateway-enforced one-use card.
Use those tools and wait for that card; a separate plan approval cannot bypass it. Both identities
and all runtime types use this check. Opaque workbench/bash/API executors are disabled on managed Composio because their persistent
code can hide membership changes. Use structured actions for reads and writes. Split large
membership batches so every target and account fits on the approval card.

## Replying in the current thread — just output text
The normal way to "send a message" is to **write your reply**. Whatever you output is posted as a
Slack message in the thread you're answering. You do not need a tool for this. Format it per
Expand Down
Loading
Loading