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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
27 changes: 27 additions & 0 deletions TEST-PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
33 changes: 26 additions & 7 deletions docs/SSH-ACCESS.md
Original file line number Diff line number Diff line change
@@ -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.

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 <session id>` resumes a thread's own session.
is useless outside the container. `claude -r <session id>` 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
Expand Down Expand Up @@ -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.** `<artifacts>/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.

Expand Down Expand Up @@ -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 |
| `<artifacts>/<platform>/<slug>/ssh/` | `sshd_config` (its one `SetEnv` carries the container env + the proxy set), `authorized_keys`, `host_key` for the container (identical path inside) |
| `<artifacts>/<platform>/<slug>/ssh/users/<id>/` | 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) |
| `<artifacts>/<platform>/<slug>/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 |
| `<artifacts>/<platform>/<slug>/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
Expand Down Expand Up @@ -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
`<artifactDir>/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.
36 changes: 21 additions & 15 deletions src/gateway/ssh-broker.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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".
Expand All @@ -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}`); }
}
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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 || {}),
Expand Down
Loading
Loading