From 13ae39a00cb7b4eea8f61fde5f9505faf1ab5d2a Mon Sep 17 00:00:00 2001 From: Tiberiu Socaci Date: Sun, 25 Oct 2026 23:38:50 +0200 Subject: [PATCH] fix(ssh): match container history to the verified author role Signed-off-by: Tiberiu Socaci --- CHANGELOG.md | 6 ++ FEATURES.md | 8 ++ TEST-PLAN.md | 27 ++++++ docs/SSH-ACCESS.md | 33 +++++-- src/gateway/ssh-broker.js | 36 +++++--- src/gateway/ssh-session.js | 35 ++++--- src/mcp/tools/ssh-access.js | 8 +- test/ssh-access.test.js | 12 +++ test/ssh-broker.test.js | 179 ++++++++++++++++++++++++++++++++++++ test/ssh-session.test.js | 33 +++++++ 10 files changed, 339 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 822fde54..4a03cfc8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,12 @@ product overview. ## Unreleased +- Fix SSH and VS Code Remote-SSH selecting project storage for organization admins: connections + and refreshes now use the same current-role runtime as ordinary chat turns. Role changes + disconnect before credential refresh, login/MCP cleanup is scoped to each container, and + connection guidance explains separate admin/project history. Existing operator-home SSH + restrictions remain enforced; no history is copied or deleted. + - Simplify admin sign-in to passkey selection without a username field. The selected credential identifies its account; password login stays permanently disabled after first enrollment. - Add individual dashboard admin usernames and passkey sign-in, with initial-password migration, diff --git a/FEATURES.md b/FEATURES.md index a10814f1..a365c938 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -3618,6 +3618,14 @@ are retired, bullet by bullet; everything else stands. a gateway run. Closing the window removes the live token and lease; the wrapper itself is inert. The lease record itself lives in daemon-owned metadata under the gateway root, never in the agent-writable artifact directory. → TEST-PLAN: Container runtime (v0.8 P1). +- **SSH uses the developer's current-role history scope:** organization admins connect to the + admin container/HOME and members or guests to the project container/HOME, matching ordinary + chat turns by that person. The broker rechecks rank on refresh and disconnects on scope + changes before preparing credentials. Per-user session files, held MCP relays and last-session + login cleanup stay within the selected container. The existing Admin + operator-home SSH + refusal stays enforced, with a second check on the resolved target. Connection guidance names + the requester's scope; no history migration or deletion is performed. + → TEST-PLAN: SSH runtime scope and history parity. - **Developers SSH into a channel container, not into the host** (`docs/SSH-ACCESS.md`). A person registers ONE public key once from chat (`add_my_ssh_key`, bound to the identity that pasted it, fingerprinted like `ssh-keygen -lf`, private keys and DSA refused, RSA under 2048 bits refused); diff --git a/TEST-PLAN.md b/TEST-PLAN.md index a58d28fc..fba7f94d 100644 --- a/TEST-PLAN.md +++ b/TEST-PLAN.md @@ -11466,3 +11466,30 @@ Documentation-only, engine-independent: no gateway runtime or Microsoft grants c live search returns the article and its Outlook/file headings, with no browser errors. Rebuilt source: 152 checked routes and 27,179 internal links; website-preserving export passed with 86 baseline source files unchanged, plus the updated docs entry/sitemap. + + +## SSH runtime scope and history parity + +- [x] Automated: `test/ssh-broker.test.js` uses the real runtime resolver with fake process I/O + to check admin/member HOME and artifact parity with chat, promotion/demotion disconnects + before credential preparation, resolved operator-home refusals, and independent cleanup while + the other runtime still has an SSH session. +- [x] Automated: `test/ssh-session.test.js` checks that ending a user's old runtime revokes its + held/current MCP grants without revoking the same user's grants in another runtime. +- [x] Automated: `test/ssh-access.test.js` checks current-role history guidance; existing + author-isolation and SSH home-mount refusal regressions remain required. +- [ ] Live SSH-SCOPE-01 (Claude and Codex): in a private Worker fixture with SSH installed, + use a current organization admin and an approved member with separate registered keys/grants. + Each asks "show SSH access" and starts an ordinary chat session with a harmless unique marker. + Connect by terminal/VS Code Remote-SSH as each actor; compare container name and HOME volume + with their own chat turn, then resume their native session with the corresponding engine. + Pass: admin matches admin, member matches project, work folder is shared, histories stay + separate, and the existing history remains intact across reconnect and container recreation. + Verify any changed SSH fingerprint against the selected scope's persisted public host key. +- [ ] Live SSH-SCOPE-02 (Claude and Codex): keep both actors connected in the same fixture. + Close the admin connection while the member stays connected, then reverse roles. Pass: only + the departing runtime's last-session login and relay files are cleaned; the surviving engine + can still use its own MCP session. In a disposable role fixture, promote/demote the connected + actor and verify disconnect before new credential preparation; reconnect uses the new scope. + Finally enable Admin mode with operator-home access on: SSH remains refused. Restore roles, + settings and grants. Live execution is pending; automated passes do not satisfy this gate. diff --git a/docs/SSH-ACCESS.md b/docs/SSH-ACCESS.md index 386c3097..08cabbe8 100644 --- a/docs/SSH-ACCESS.md +++ b/docs/SSH-ACCESS.md @@ -1,8 +1,9 @@ # SSH access to channel containers Developers can open a real SSH session — terminal, VS Code Remote-SSH, sftp, port forwards — -inside a channel's own container, the same box the assistant works in, without any account on -the gateway host and without any container ever listening on a port. One key per person, +inside the channel container selected for their current role, matching their own ordinary chat +turns, without any account on the gateway host and without any container ever listening on a +port. One key per person, registered once from chat; users enable their own access per channel without manager or admin approval; the container stays up for as long as a session is open. @@ -105,7 +106,17 @@ machine settings — merge-only, and never over a value you set yourself. Inside, you are user `agent` in the channel's work folder (an interactive login starts there; image spec 1.5.1), with the same environment an engine turn gets and the channel's persistent -`/home/agent` (installed tools, `gh`/`vercel`/`supabase` logins, Claude and Codex history). +`/home/agent` for their runtime scope (installed tools, `gh`/`vercel`/`supabase` logins, Claude and +Codex history). Current organization admins use the admin container and legacy HOME (`cg-…`); +members and guests use the separate project container and HOME (`cgp2-…`). The work folder is +shared, but native engine history and CLI logins are separate. "Show SSH access" names the +requester's scope. SSH rechecks roles while connected; a scope change disconnects the session +before preparing more credentials, and reconnecting selects the current scope. The existing +Admin-mode + `containerFullAccessHome` refusal still applies to everyone. + +An upgrade correcting SSH scope selection can return an admin to their existing `cg-…` history. +Sessions created in the project's `cgp2-…` HOME stay there; no history is deleted or automatically +copied across the boundary. Ordinary members continue to use project history. Codex uses the channel's selected login (a proxy placeholder in proxy mode, a host file mount in legacy bridge mode), and `codex` in a session gets what a chat turn's Codex gets: the gateway tools, your own Composio accounts as `composio-user`, the channel's as @@ -146,10 +157,11 @@ session gets: account" with the operator's organization and email, `/usage` shows the plan's windows, and the default model is the plan's. It is the operator's login (the same one every turn uses): your session's usage counts against it. Claude reads the login from an access-only file the daemon - writes and refreshes; it holds no refresh token and is removed when the channel's last session + writes and refreshes; it holds no refresh token and is removed when the container's last session ends. Behind the egress proxy its access token is the channel's relay placeholder (the plan, expiry and rate tier beside it are the real login's — they are facts, not secrets), so the file - is useless outside the container. `claude -r ` resumes a thread's own session. + is useless outside the container. `claude -r ` resumes a native session stored in + the same runtime scope; admin, project and `/sudo` host histories are separate. Codex over SSH uses the same selected login as a channel turn. "show SSH access" in the channel prints what a session gets. If a part could not be prepared — no Claude login on the @@ -204,7 +216,7 @@ egress proxy (image spec 1.6.0). An SSH session is inside that container, so the client downloads the server and copies it in over the session. - **The editor token file.** `/vscode/claude-token` (read by the `claude` wrapper outside an SSH session, e.g. a `podman exec` shell or an operator's `npm run vscode` window) holds - the same relay placeholder, and is removed when the channel's last session ends as well as when + the same relay placeholder, and is removed when the container's last session ends as well as when the `npm run vscode` launcher exits. An open `npm run vscode` window counts as live work for the channel's own and organization secrets and the Claude login, never for anyone's personal ones. @@ -271,7 +283,7 @@ ended); `show_channel_ssh` lists the live ones. | `/etc/ssh/sshd_config.d/channelgate.conf` | the Match block for the login account, plus an `AllowUsers`/`AllowGroups` line when the host restricts logins | | `///ssh/` | `sshd_config` (its one `SetEnv` carries the container env + the proxy set), `authorized_keys`, `host_key` for the container (identical path inside) | | `///ssh/users//` | one developer's `env` (proxy set first, then secrets — placeholders behind the proxy), `session.md`, `settings.json`, `mcp.json`, Codex's `codex-args.sh` + `codex-secrets.json` (the capability only) | -| `///vscode/claude-token` | the Claude login the wrapper reads outside an SSH session (the relay placeholder behind the proxy); removed with the channel's last session | +| `///vscode/claude-token` | the Claude login the wrapper reads outside an SSH session (the relay placeholder behind the proxy); removed with the container's last session | | `gateway.db` → `ssh_keys`, `ssh_sessions`, `events` | keys, sessions, audit | - *`channelgate-ssh@…: Permission denied (publickey)`* straight away, with nothing in the daemon @@ -321,4 +333,11 @@ ended); `show_channel_ssh` lists the live ones. network-off …`* — outbound SSH needs *Allow network* on; any other host must be a declared raw host. `kex_exchange_identification: Connection closed` right after it is ssh's own echo of that. - *Connection drops on daemon restart* — expected; reconnect. +- *History differs from another person's Slack session* — compare runtime scopes first. Admin + and project users share the work folder, but not HOME or native history. Your own ordinary + chat turns and SSH use the same scope at your current role. +- *Host-key warning after a scope change* — each scope keeps its own key at + `/ssh/host_key`. Ordinary container rebuilds preserve that file. Ask an operator + to verify the selected scope's fingerprint before updating the matching known-host entry; + a changed key by itself is not proof of a harmless rebuild. - `journalctl -u channelgate | grep '\[ssh\]'` shows binds, sessions and relay refresh failures. diff --git a/src/gateway/ssh-broker.js b/src/gateway/ssh-broker.js index 4139dd3a..4845bd19 100644 --- a/src/gateway/ssh-broker.js +++ b/src/gateway/ssh-broker.js @@ -25,6 +25,7 @@ import { isAuthorized } from "./modes.js"; import { isShuttingDown } from "./shutdown.js"; import { logEvent } from "../util/logger.js"; import { resolveRuntime } from "../runtimes/resolve.js"; +import { operatorHomeGranted } from "../runtimes/container/lifecycle.js"; import { cliEnv, defaultExec } from "../runtimes/container/cli.js"; import { containerRuntimeStatus } from "../runtimes/container/index.js"; import { prepareSshSession, releaseSshSession } from "./ssh-session.js"; @@ -161,23 +162,28 @@ async function runSession(socket, header, leftover, state) { } const { key, user, entry, meta } = auth; const id = randomUUID(); - // How many live sessions this developer, and this channel, have: the session files are per - // developer and the login file per channel, and each goes when its last session ends. - const userKey = `${entry.slug}\u0000${user.id}`; + // Match this person's chat runtime. Rank comes from current authorization, never the + // client header. The old Admin+home policy is still checked by authorizeSshAttach. + const target = deps.resolveTarget(entry.slug, meta, { isAdminAuthor: user.admin === true }); + if (!target?.container?.name || operatorHomeGranted(target)) { + writeLine(socket, { ok: false, error: !target?.container?.name + ? "this channel does not run in a container" + : "SSH is refused into a container that exposes the operator's home" }); + socket.end(); + return; + } + // Each runtime owns separate files and a separate login. An open session in the other + // scope must neither retain this container's credentials nor lose its own on our hang-up. + const containerKey = target.container.name; + const userKey = `${containerKey}\u0000${user.id}`; const counts = state.counts; let counted = false; const count = (delta) => { counts.users.set(userKey, (counts.users.get(userKey) || 0) + delta); - counts.channels.set(entry.slug, (counts.channels.get(entry.slug) || 0) + delta); + counts.containers.set(containerKey, (counts.containers.get(containerKey) || 0) + delta); if ((counts.users.get(userKey) || 0) <= 0) counts.users.delete(userKey); - if ((counts.channels.get(entry.slug) || 0) <= 0) counts.channels.delete(entry.slug); + if ((counts.containers.get(containerKey) || 0) <= 0) counts.containers.delete(containerKey); }; - const target = deps.resolveTarget(entry.slug, meta); - if (!target?.container?.name) { - writeLine(socket, { ok: false, error: "this channel does not run in a container" }); - socket.end(); - return; - } const targetKey = sessionTargetKey(target); // The lease comes FIRST — before ensureUp, like run.js — so the reaper cannot stop the container // between "it is up" and "sshd is inside". @@ -200,7 +206,7 @@ async function runSession(socket, header, leftover, state) { if (counted) { count(-1); const lastForUser = !counts.users.has(userKey); - const lastInChannel = !counts.channels.has(entry.slug); + const lastInChannel = !counts.containers.has(containerKey); try { await deps.releaseSession({ target, entry, user, cliBin, lastForUser, lastInChannel, log }); } catch (error) { log.warn?.(`[ssh] session files for ${entry.slug}/${user.id} not released: ${error?.message || error}`); } } @@ -245,8 +251,8 @@ async function runSession(socket, header, leftover, state) { await finish("SSH access is no longer authorized"); return false; } - currentTarget = deps.resolveTarget(current.entry.slug, current.meta); - if (sessionTargetKey(currentTarget) !== targetKey) { + currentTarget = deps.resolveTarget(current.entry.slug, current.meta, { isAdminAuthor: current.user.admin === true }); + if (operatorHomeGranted(currentTarget) || sessionTargetKey(currentTarget) !== targetKey) { await finish("the channel runtime changed — reconnect to use its current configuration"); return false; } @@ -402,7 +408,7 @@ export async function startSshBroker({ dir = sshAccessDir(), log = console, retr active = { server: null, dir, path: path.join(dir, SSH_ATTACH_SOCKET), sessions: new Map(), log, retryTimer: null, warned: false, warnedOwner: false, conns: new Set(), relayRefreshMs, headerTimeoutMs, - counts: { users: new Map(), channels: new Map() }, + counts: { users: new Map(), containers: new Map() }, unsubscribe: null, deps: { authorize: (header) => authorizeSshAttach(header, overrides.authorizeOptions || {}), diff --git a/src/gateway/ssh-session.js b/src/gateway/ssh-session.js index 2ccf0312..c16590a7 100644 --- a/src/gateway/ssh-session.js +++ b/src/gateway/ssh-session.js @@ -30,7 +30,7 @@ // // The files live per DEVELOPER under /ssh/users// (the wrapper picks the dir // by CG_SSH_USER, which sshd sets from the developer's key line), and are removed when that -// developer's last session in the channel ends; the login file goes when the channel's last +// developer's last session in that runtime ends; the login file goes when the container's last // session ends. Everyone in a container is one uid, so these are per developer for correctness, // not secrecy from each other — the same trust class as the box itself (docs/SSH-ACCESS.md). import path from "node:path"; @@ -54,7 +54,7 @@ import { buildEngineMcpRuntime } from "./run-engine-mcp.js"; import { resolveRunIntegrations } from "./run-integrations.js"; import { SSH_TOOLSET } from "../mcp/gateway-server.js"; import { containerSshDir, EGRESS_CONNECT_HELPER, sessionEgressEnv } from "./ssh-access.js"; -import { clearRemoteMcpsWhere, releaseRemoteMcps } from "../mcp/remote-mcp-registry.js"; +import { clearRemoteMcps, hasRemoteMcps, releaseRemoteMcps } from "../mcp/remote-mcp-registry.js"; export const SSH_SESSION_ORIGIN = "ssh_session"; export const SSH_USERS_SUBDIR = "users"; @@ -91,11 +91,15 @@ fs.renameSync(temporary, file); // with (the broker's contract: a running process keeps what it started with) while an unused // grant does not linger for its 12 hours. The session's end drops every grant it minted outright. const sessionRelayJtis = new Map(); -const relayKey = (slug, userId) => `${slug}\u0000${userId}`; -function adoptSessionRelays(slug, userId, jtis) { - const key = relayKey(slug, userId); - for (const jti of sessionRelayJtis.get(key) || []) releaseRemoteMcps(jti); - if (jtis.length) sessionRelayJtis.set(key, jtis); +const relayKey = (target, userId) => `${target.container.name}\u0000${userId}`; +function adoptSessionRelays(target, userId, jtis) { + const key = relayKey(target, userId); + const previous = sessionRelayJtis.get(key); + for (const jti of previous?.current || []) releaseRemoteMcps(jti); + // Keep held older grants so this container's final disconnect revokes them too. Expired + // and unused registrations are pruned on refresh; another runtime's grants stay separate. + const all = new Set([...(previous?.all || []), ...jtis].filter((jti) => hasRemoteMcps(jti))); + if (all.size) sessionRelayJtis.set(key, { current: jtis, all }); else sessionRelayJtis.delete(key); } @@ -140,11 +144,14 @@ export function renderSessionEnvFile(env = {}, { egress = {} } = {}) { * (`--append-system-prompt-file`) — never part of every channel's managed block, whose 4 KB budget * is for the rules every run shares. Named facts only; no value ever rides here. */ -export function renderSessionNote({ slug, userId, envFile, egress = null }) { +export function renderSessionNote({ slug, userId, envFile, egress = null, runtimeScope = "" }) { const lines = [ `This is an interactive SSH session in channel \`${slug}\`, prepared by the gateway for <@${userId}> exactly like one of that channel's chat turns: the channel's tool policy, its MCP servers (\`gateway\`, \`composio-user\` = this developer's own accounts, \`composio-agent\` = the channel's) and its secrets as environment variables.`, `Secrets change while a session is open. \`list_secrets\` is live, but a process keeps the environment it started with — this one included. The session's CURRENT environment is rewritten within a moment of any change at \`${envFile}\` (also \`$CG_SESSION_ENV\`). When a command needs a credential added after this process started, source that file in the SAME command: \`. "$CG_SESSION_ENV"; \`. Never print a value; refer to secrets by name. A newly selected MCP server needs a new \`claude\`.`, ]; + if (runtimeScope === "admin" || runtimeScope === "project") { + lines.push(`Your runtime/history scope is **${runtimeScope}**. This HOME and native engine history match this developer's ordinary chat turns at their current role. Admin and project histories are separate; a different scope's session ID cannot be resumed here. The project work folder is shared.`); + } if (egress?.active) { // Names and rules only — never a value, never a placeholder string. lines.push( @@ -434,7 +441,7 @@ export async function prepareSshSession({ target, entry, meta = {}, user, cliBin withheld: [...(resolved.withheld || [])].sort(), personalPaused: Boolean(resolved.personalPaused), } : null; - writePrivate(path.join(userDir, "session.md"), renderSessionNote({ slug, userId: user.id, envFile: path.join(userDir, "env"), egress: egressFacts })); + writePrivate(path.join(userDir, "session.md"), renderSessionNote({ slug, userId: user.id, envFile: path.join(userDir, "env"), egress: egressFacts, runtimeScope: target.runtimeScope })); // Names, scopes and whether the proxy protects each — never a value or a placeholder. result.secrets = names.map((name) => ({ name, scope: scopes[name] || "channel", protected: Boolean(placeholders[name]) })); result.egress = egressFacts ? { active: true, unprotected: egressFacts.unprotected, withheld: egressFacts.withheld, personalPaused: egressFacts.personalPaused } : { active: false }; @@ -453,12 +460,12 @@ export async function prepareSshSession({ target, entry, meta = {}, user, cliBin result.secrets = []; log?.warn?.(`[ssh] ${slug}/${user.id}: session files not prepared — ${result.problems.join("; ")}`); } - adoptSessionRelays(slug, user.id, relayJtis); + adoptSessionRelays(target, user.id, relayJtis); return result; } /** - * The developer's last session in the channel ended: drop their files; the channel's last: the + * The developer's last session in this container ended: drop their files; the container's last: the * login too — the in-container credentials file, the account record, and the host-side editor * token file (`/vscode/claude-token`, which the container can read through its mount). */ @@ -467,9 +474,9 @@ export async function releaseSshSession({ target, entry = null, user, cliBin, la try { rmSync(sshUserDir(target, user.id), { recursive: true, force: true }); } catch { /* already gone */ } // The developer's last session here ended: every relay grant any of its preparations minted // goes NOW, held or not — a process that outlived the session loses its relayed servers. - const slug = entry?.slug || target.slug || ""; - sessionRelayJtis.delete(relayKey(slug, user.id)); - clearRemoteMcpsWhere((meta) => meta.origin === SSH_SESSION_ORIGIN && meta.slug === slug && meta.authorId === user.id); + const key = relayKey(target, user.id); + for (const jti of sessionRelayJtis.get(key)?.all || []) clearRemoteMcps(jti); + sessionRelayJtis.delete(key); } if (!lastInChannel) return; // First, and host-side: it needs no container, so a container that is already gone still loses it. diff --git a/src/mcp/tools/ssh-access.js b/src/mcp/tools/ssh-access.js index d485c7ae..0124f935 100644 --- a/src/mcp/tools/ssh-access.js +++ b/src/mcp/tools/ssh-access.js @@ -98,7 +98,7 @@ export function register(server, ctx) { { description: "Enable YOUR OWN SSH access into THIS channel's container immediately, without manager or admin approval. Omit user or pass your own id/mention. This gives a full shell as the channel (its files, its CLI logins, Claude and Codex), " - + "the same box the assistant works in. You must already be allowed in this channel; connecting requires your own registered key (add_my_ssh_key). " + + "the same runtime/history scope as your own ordinary chat turns (admin or project, based on your current role). You must already be allowed in this channel; connecting requires your own registered key (add_my_ssh_key). " + "Granting another person access requires a channel manager/admin and their approval. Pass that person's user id or @mention.", inputSchema: { user: z.string().optional() }, }, @@ -157,10 +157,14 @@ export function register(server, ctx) { if (!meta) return text("Channel isn't set up yet — send a normal message first."); const setup = sshAccessState(); const granted = sshUsersOf(meta); + const requester = principalTrusted && createdBy ? await getUser(createdBy) : null; const lines = [`**SSH access for this channel** (\`${slug}\`)`]; lines.push(setup.configured ? `• Gateway endpoint: \`${setup.endpoint.user}@${setup.endpoint.host}${setup.endpoint.port !== 22 ? `:${setup.endpoint.port}` : ""}\`` : `• Gateway endpoint: **not set up** — an operator runs \`sudo bash scripts/install-ssh-access.sh\` on the gateway host (${setup.reason}).`); + if (requester) { + lines.push(`• Your SSH history scope: **${requester.isAdmin ? "admin" : "project"}** — the same HOME and native Claude/Codex history as your ordinary chat turns at your current role. Admin and project histories stay separate; the project folder is shared.`); + } if (sshBlockedByHomeGrant(meta, getContainerRuntime())) { lines.push("• ⚠️ Connections are REFUSED right now: this channel is in Admin mode while the gateway's `containerFullAccessHome` switch is on (its container would expose the operator's whole home). Turn one of them off."); } @@ -186,7 +190,7 @@ export function register(server, ctx) { if (setup.configured) { lines.push("", "Once granted, add this to `~/.ssh/config` on your laptop (your usual key; nothing per channel), then `ssh " + slug + "` or open it with VS Code Remote-SSH:", "```", connectSnippet({ endpoint: setup.endpoint, channel: slug }), "```", `To open VS Code straight on the channel folder: \`code --remote ssh-remote+${slug} ${effectiveWorkDir(slug, meta)}\` (from Remote-SSH's own connect menu, File → Open Folder starts in that folder; press OK).`, - "Inside you are user `agent` in the channel's work folder with its `/home/agent`, CLI logins and Codex. `claude` there is this channel's Claude exactly as a message here gets it: the channel's tool policy, the gateway tools (no background jobs or approval cards — no thread to post into), your own Composio accounts as `composio-user`, the channel's as `composio-agent`, the channel's MCP servers and its secrets by name — signed in as the gateway's own account, whose usage it counts against. `codex` gets the same gateway tools, Composio accounts, channel MCP servers and secrets, and starts in the channel folder so its AGENTS.md applies; `with-secrets ` runs any other command with your channel secrets. Where the gateway's egress proxy is the container's network, those secrets (and the Claude login) are placeholders that only work from inside the box through the proxy, on their declared hosts; your personal ones pause while another person has a session open there; `git@github.com` works through the proxy (Allow network on), and your own `ssh` needs `-o ProxyCommand='/opt/channelgate/bin/cg-egress-connect %h %p'`. Everyone in the box shares that one user; set your git identity per session. A daemon restart drops sessions — just reconnect."); + "Inside you are user `agent` in the channel's work folder with your runtime scope's `/home/agent`, CLI logins and native engine history. Only sessions stored in that scope can be resumed; a role change requires reconnecting. `claude` receives: the channel's tool policy, the gateway tools (no background jobs or approval cards — no thread to post into), your own Composio accounts as `composio-user`, the channel's as `composio-agent`, the channel's MCP servers and its secrets by name — signed in as the gateway's own account, whose usage it counts against. `codex` gets the same gateway tools, Composio accounts, channel MCP servers and secrets, and starts in the channel folder so its AGENTS.md applies; `with-secrets ` runs any other command with your channel secrets. Where the gateway's egress proxy is the container's network, those secrets (and the Claude login) are placeholders that only work from inside the box through the proxy, on their declared hosts; your personal ones pause while another person has a session open there; `git@github.com` works through the proxy (Allow network on), and your own `ssh` needs `-o ProxyCommand='/opt/channelgate/bin/cg-egress-connect %h %p'`. Everyone in the box shares that one user; set your git identity per session. A daemon restart drops sessions — just reconnect."); } return text(lines.join("\n")); }, diff --git a/test/ssh-access.test.js b/test/ssh-access.test.js index 35eeafd4..750075ea 100644 --- a/test/ssh-access.test.js +++ b/test/ssh-access.test.js @@ -390,3 +390,15 @@ test("tools: the block on Admin + containerFullAccessHome is spelled out on gran assert.equal(sshBlockedByHomeGrant(await getChannelMeta(entry.slug), original()), original().fullAccessHome === true); await saveChannelMeta(entry.slug, { ...(await getChannelMeta(entry.slug)), adminMode: false }); }); + + +test("show SSH access explains the requester's current-role history scope", async () => { + const admin = reply(await toolsFor(ADMIN).get("show_channel_ssh")({})); + const member = reply(await toolsFor(DEV).get("show_channel_ssh")({})); + assert.match(admin, /Your SSH history scope: \*\*admin\*\*/); + assert.match(member, /Your SSH history scope: \*\*project\*\*/); + for (const shown of [admin, member]) { + assert.match(shown, /Admin and project histories stay separate/); + assert.match(shown, /role change requires reconnecting/); + } +}); diff --git a/test/ssh-broker.test.js b/test/ssh-broker.test.js index eb8750d5..4bb40a5c 100644 --- a/test/ssh-broker.test.js +++ b/test/ssh-broker.test.js @@ -574,3 +574,182 @@ test("probing a live broker neither refuses, logs nor disturbs it", async () => await broker.stopSshBroker(); } }); + +// Exercise the real role-sensitive resolver: a fake resolver that ignores isAdminAuthor +// cannot detect SSH silently opening a different HOME from a chat turn. +const { resolveRuntime } = await import("../src/runtimes/resolve.js"); +const roleRuntimeSettings = { fullAccessHome: false, idleMinutes: 10 }; +function resolveRoleTarget(slug, meta, options = {}) { + const target = resolveRuntime(slug, meta, { ...options, settings: roleRuntimeSettings }); + return { ...target, runtime }; // Keep the real names, mounts, HOME and artifact paths; fake only I/O. +} +async function startRoleBroker(observed, releases) { + await broker.stopSshBroker(); + return broker.startSshBroker({ + dir: SSH_DIR, log: silent, retryMs: 0, relayRefreshMs: 60_000, authorizeOptions, + deps: { + ...deps, resolveTarget: resolveRoleTarget, + prepareSession: async (session) => { + observed.push({ target: session.target, user: session.user.id }); + return { claude: { relayed: true }, mcpServers: [], secrets: [], problems: [] }; + }, + releaseSession: async (session) => releases.push({ container: session.target.container.name, user: session.user.id, lastForUser: session.lastForUser, lastInChannel: session.lastInChannel }), + }, + }); +} +async function waitForRoleDisconnect() { + const deadline = Date.now() + 2000; + while (broker.liveSshSessions({ slug: entry.slug }).length) { + assert.ok(Date.now() < deadline, "role change did not disconnect the old container"); + await settle(); + } + await settle(); +} + +test("real resolver gives SSH the same scoped HOME and artifacts as chat for admins and members", async () => { + const observed = [], releases = []; + await startRoleBroker(observed, releases); + try { + const targets = []; + for (const isAdmin of [false, true]) { + await setUser(DEV, { name: "Apps", approved: true, isAdmin }); + const connection = await attach({ v: 1, key: ED25519, channel: entry.slug }); + assert.equal(connection.line.ok, true, JSON.stringify(connection.line)); + const expected = resolveRuntime(entry.slug, await getChannelMeta(entry.slug), { settings: roleRuntimeSettings, isAdminAuthor: isAdmin }); + const actual = observed.at(-1).target; + assert.equal(connection.line.container, expected.container.name); + assert.equal(actual.container.homeVolume, expected.container.homeVolume); + assert.equal(actual.artifactDir, expected.artifactDir); + assert.deepEqual(actual.container.mounts, expected.container.mounts); + targets.push(actual); + connection.socket.end(); + await waitForRoleDisconnect(); + } + assert.notEqual(targets[0].container.homeVolume, targets[1].container.homeVolume); + assert.notEqual(targets[0].artifactDir, targets[1].artifactDir); + assert.equal(targets[0].workDir, targets[1].workDir); + } finally { + await broker.stopSshBroker(); + await setUser(DEV, { name: "Apps", approved: true, isAdmin: false }); + } +}); + +test("real resolver disconnects promotion and demotion before refreshing credentials in the old scope", async () => { + const observed = [], releases = []; + await startRoleBroker(observed, releases); + try { + for (const isAdmin of [false, true]) { + await setUser(DEV, { name: "Apps", approved: true, isAdmin }); + const connection = await attach({ v: 1, key: ED25519, channel: entry.slug }); + assert.equal(connection.line.ok, true, JSON.stringify(connection.line)); + const child = children.at(-1); + const before = observed.length; + await setUser(DEV, { name: "Apps", approved: true, isAdmin: !isAdmin }); + await waitForRoleDisconnect(); + assert.equal(observed.length, before, "the old scope receives no credentials after a rank change"); + assert.equal(child.signalCode, "SIGTERM"); + assert.equal(reaper.leaseCount(connection.line.container), 0); + assert.equal(releases.at(-1).container, connection.line.container); + assert.equal(releases.at(-1).lastInChannel, true); + connection.socket.destroy(); + } + } finally { + await broker.stopSshBroker(); + await setUser(DEV, { name: "Apps", approved: true, isAdmin: false }); + } +}); + +test("concurrent admin and project SSH sessions clean up login files independently per container", async () => { + const observed = [], releases = []; + const original = await getChannelMeta(entry.slug); + await setUser(DEV, { name: "Apps", approved: true, isAdmin: true }); + await saveChannelMeta(entry.slug, { ...original, sshUsers: [DEV, STRANGER] }); + await startRoleBroker(observed, releases); + try { + const admin = await attach({ v: 1, key: ED25519, channel: entry.slug }); + const member = await attach({ v: 1, key: ECDSA, channel: entry.slug }); + assert.equal(admin.line.ok, true, JSON.stringify(admin.line)); + assert.equal(member.line.ok, true, JSON.stringify(member.line)); + assert.notEqual(admin.line.container, member.line.container); + admin.socket.end(); + await settle(); + assert.deepEqual(releases.at(-1), { container: admin.line.container, user: DEV, lastForUser: true, lastInChannel: true }); + assert.equal(reaper.leaseCount(member.line.container), 1, "the other scope remains attached"); + member.socket.end(); + await waitForRoleDisconnect(); + assert.deepEqual(releases.at(-1), { container: member.line.container, user: STRANGER, lastForUser: true, lastInChannel: true }); + } finally { + await broker.stopSshBroker(); + await setUser(DEV, { name: "Apps", approved: true, isAdmin: false }); + await saveChannelMeta(entry.slug, original); + } +}); + +test("resolved operator-home grants are refused before startup and credential refresh even if authorization saw older settings", async () => { + const observed = [], releases = []; + const original = await getChannelMeta(entry.slug); + await setUser(DEV, { name: "Apps", approved: true, isAdmin: true }); + await saveChannelMeta(entry.slug, { ...original, adminMode: true }); + const state = await startRoleBroker(observed, releases); + let fullAccessHome = true; + state.deps.resolveTarget = (slug, meta, options = {}) => ({ + ...resolveRuntime(slug, meta, { ...options, settings: { ...roleRuntimeSettings, fullAccessHome } }), runtime, + }); + try { + const beforeEnsure = ensured.length, beforeSpawn = children.length; + const refused = await attach({ v: 1, key: ED25519, channel: entry.slug }); + assert.equal(refused.line.ok, false, JSON.stringify(refused.line)); + assert.match(refused.line.error, /home/i); + assert.equal(ensured.length, beforeEnsure, "a forbidden resolved target is never started"); + assert.equal(children.length, beforeSpawn); + assert.equal(observed.length, 0); + refused.socket.destroy(); + + fullAccessHome = false; + const allowed = await attach({ v: 1, key: ED25519, channel: entry.slug }); + assert.equal(allowed.line.ok, true, JSON.stringify(allowed.line)); + const beforePrepare = observed.length; + fullAccessHome = true; + const { emitConfigChange } = await import("../src/config/change-events.js"); + emitConfigChange("channel-meta", { slug: entry.slug }); + await waitForRoleDisconnect(); + assert.equal(observed.length, beforePrepare, "a newly granted operator home ends SSH before credential preparation"); + assert.equal(children.at(-1).signalCode, "SIGTERM"); + allowed.socket.destroy(); + } finally { + await broker.stopSshBroker(); + await setUser(DEV, { name: "Apps", approved: true, isAdmin: false }); + await saveChannelMeta(entry.slug, original); + } +}); + +test("one user's overlapping reconnect across a role change releases files in each container independently", async () => { + const observed = [], releases = []; + await setUser(DEV, { name: "Apps", approved: true, isAdmin: false }); + const state = await startRoleBroker(observed, releases); + try { + const member = await attach({ v: 1, key: ED25519, channel: entry.slug }); + assert.equal(member.line.ok, true, JSON.stringify(member.line)); + const oldSession = state.sessions.get(member.line.session); + // Hold this session's refresh to model an overlap deterministically without depending on + // how quickly key materialization finishes relative to the config debounce timer. + oldSession.refresh = null; + await setUser(DEV, { name: "Apps", approved: true, isAdmin: true }); + const admin = await attach({ v: 1, key: ED25519, channel: entry.slug }); + assert.equal(admin.line.ok, true, JSON.stringify(admin.line)); + assert.notEqual(admin.line.container, member.line.container); + // Finish explicitly before the debounced config refresh: both shells can briefly coexist + // while a newly promoted user reconnects, but their session files belong to separate HOMEs. + assert.ok(state.sessions.has(oldSession.id), "the old scope still has an overlapping shell"); + await oldSession.finish("reconnected after role change"); + assert.deepEqual(releases.at(-1), { container: member.line.container, user: DEV, lastForUser: true, lastInChannel: true }); + assert.equal(reaper.leaseCount(admin.line.container), 1); + member.socket.destroy(); + admin.socket.end(); + await waitForRoleDisconnect(); + assert.deepEqual(releases.at(-1), { container: admin.line.container, user: DEV, lastForUser: true, lastInChannel: true }); + } finally { + await broker.stopSshBroker(); + await setUser(DEV, { name: "Apps", approved: true, isAdmin: false }); + } +}); diff --git a/test/ssh-session.test.js b/test/ssh-session.test.js index 37958fc0..65fea1b0 100644 --- a/test/ssh-session.test.js +++ b/test/ssh-session.test.js @@ -415,6 +415,39 @@ test("a refresh releases the previous relay grant unless a running process holds stillRunning(); }); +test("MCP grants and session notes stay in the user's runtime during an overlapping role change", async () => { + const { hasRemoteMcps, retainRemoteMcps } = await import("../src/mcp/remote-mcp-registry.js"); + const sessionEntry = { slug: "ssh-scope-relays", channelId: "C_SCOPE_RELAYS" }; + const project = { ...target("ssh-scope-project"), slug: sessionEntry.slug, runtimeScope: "project" }; + const admin = { ...target("ssh-scope-admin"), slug: sessionEntry.slug, runtimeScope: "admin" }; + const prepare = async (t) => { + const { deps } = fakes(); + await session.prepareSshSession({ target: t, entry: sessionEntry, meta: {}, user, cliBin: "podman", log: { warn() {} } }, deps); + const dir = session.sshUserDir(t, user.id); + assert.ok(readFileSync(path.join(dir, "session.md"), "utf8").includes(`runtime/history scope is **${t.runtimeScope}**`)); + const mcp = JSON.parse(readFileSync(path.join(dir, "mcp.json"), "utf8")).mcpServers; + const bundle = JSON.parse(readFileSync(path.join(dir, "codex-secrets.json"), "utf8")); + return [mcp.gateway.env.CG_GATEWAY_CAPABILITY, bundle.gatewayCapability].map(cap => verifyGatewayCapability(cap, { secret: SECRET }).claims.jti); + }; + const first = await prepare(project); + const releaseHeld = retainRemoteMcps(first[0]); + const refreshed = await prepare(project); + const other = await prepare(admin); + try { + assert.ok(hasRemoteMcps(first[0]), "held earlier grant stays live until its runtime ends"); + assert.ok(refreshed.every(jti => hasRemoteMcps(jti)), "preparing another runtime must not release this runtime's current grants"); + await session.releaseSshSession({ target: project, entry: sessionEntry, user, cliBin: "podman", lastForUser: true }, { runCommand: async () => {} }); + assert.ok([...first, ...refreshed].every(jti => !hasRemoteMcps(jti)), "all old-runtime grants are revoked, including held ones"); + assert.ok(other.every(jti => hasRemoteMcps(jti)), "same user's new-runtime grants remain usable"); + assert.ok(existsSync(session.sshUserDir(admin, user.id)), "new-runtime session files remain"); + } finally { + releaseHeld(); + await session.releaseSshSession({ target: project, entry: sessionEntry, user, cliBin: "podman", lastForUser: true }, { runCommand: async () => {} }); + await session.releaseSshSession({ target: admin, entry: sessionEntry, user, cliBin: "podman", lastForUser: true }, { runCommand: async () => {} }); + } + assert.ok(other.every(jti => !hasRemoteMcps(jti))); +}); + // ── Container-secrets P3: a session on placeholders ──────────────────────────────────────────── // Where the egress proxy is the container's network, a developer's session holds what a turn's // process holds — placeholders for every ruled secret, the relay placeholder as Claude's login —