Skip to content

Container secrets: MCP relay, egress proxy with placeholders, SSH sessions, Codex login broker (P1–P4) - #113

Merged
makeitfutureDev merged 37 commits into
betafrom
feat/engine-auth-broker
Sep 26, 2026
Merged

makeitfutureDev merged 37 commits into
betafrom
feat/engine-auth-broker

Conversation

@makeitfutureDev

Copy link
Copy Markdown
Owner

Container secrets, MCP relay, egress proxy, SSH sessions and the Codex login broker

Implements all four phases of docs/CONTAINER-SECRETS-PLAN.md (branch docs/container-secrets-network) as one stack: feat/mcp-relay (P1) → feat/egress-proxy (P2) → feat/ssh-placeholders (P3) → feat/engine-auth-broker (P4, this PR). 33 signed-off commits, 131 files.

Design in one paragraph

A channel container never holds a real credential. Header-bearing remote MCPs (Composio user/agent, toolbox, Make) are relayed by the daemon over the existing control socket, keyed by the signed run capability (P1). Ordinary containers run --network none; egress exists only through a per-channel Unix socket to the daemon's TLS-terminating proxy (src/gateway/egress/), which swaps cgph_… placeholders for the live value only on each secret's declared hosts and headers, refuses private/metadata destinations, enforces the Allow-network switch live, scrubs responses and audits swaps (P2). SSH and VS Code sessions get the same placeholders, the proxy env via SetEnv, and a cg-egress-connect ProxyCommand helper for outbound SSH (P3). Codex's real auth.json is no longer mounted: containers get an access-only file whose bearer is a placeholder JWT swapped by the proxy; a daemon relay owns refresh, unruled secrets are withheld by default on new installs, and a static check refuses secret-resolver values written under artifact dirs (P4). One agent uid per container; no per-developer OS users. Personal secrets pause while another author's turn, job or SSH session is active in the channel.

Reviews folded in

Two read-only reviews (P1, P2) found 6 + 13 defects; all fixed with regression tests (grant release with holds and a dial cap on the relay; absolute-form request-line host escape; Host-header fronting; personal-secret concurrency; scrub bypasses; DNS/connection/response-header bounds; listener restore at boot; explicit upstream rejectUnauthorized).

Checks (final stacked HEAD)

node scripts/run-tests.mjs   # tests 3117 / # pass 3090 / # fail 0 / # skipped 27
npm run check:static         Static check passed: 760 JavaScript files parse, no undeclared identifiers, no secret written under an artifact dir; whitespace is clean.
npm run secret-scan          Secret scan clean (922 files/blobs checked)
npm run check:dco -- origin/beta..HEAD   DCO check passed: 33 commit(s)
npm run test:security-coverage           exit 0
npm audit --omit=dev --audit-level=high  found 0 vulnerabilities

Live evidence executed here (no container CLI on this host)

  • Claude Code (2.1.281): no offline token-shape check — placeholder-shaped CLAUDE_CODE_OAUTH_TOKEN reaches the API (401); HTTPS_PROXY honoured.
  • Codex (0.156.1): a real turn on an ACCESS-ONLY auth.json succeeds; an old last_refresh triggers no refresh; a placeholder JWT (real header+claims, cgph_r… signature) passes codex login status and completes a real turn THROUGH this branch's proxy with 23 header swaps (incl. the WebSocket turn), none to auth.openai.com.
  • The egress CA cross-checked with openssl verify.

UNEXECUTED live gates — must run before merge (TEST-PLAN.md, 48 markers)

Requires a Linux host with rootless Podman and npm run build:image (spec 1.6.0; containers are recreated once). Per phase: Composio through the relay on Claude and Codex; engine turns under --network none; gh api, git clone over HTTPS and vercel whoami with placeholders; agent-browser on HTTPS (whether agent-browser 0.36.0 honours AGENT_BROWSER_ARGS is unverified); bypass proof from inside a container (curl --noproxy '*', getent hosts, /proc/net/dev only lo, bridge gateway and metadata refused); placeholder unswapped on an undeclared host; live rotation and revocation; network switch flip; fail-closed when the service is down; legacy containerEgressMode=bridge both ways; restart restore; personal pause (SSH and another author); SSH session gates (7); Codex gates (9, incl. Codex with network OFF and the 48 h refresh path).

Rollout notes

  • npm run build:image after merge; every existing container recreates once (network + mount fingerprint changed).
  • New settings: containerEgressMode (proxy default, bridge legacy escape), egressSecretsStrict (on for NEW installs only). Per-channel admin meta: rawNetwork, egressRawHosts (API only, no UI yet).
  • Migration 30 (egress_grants).
  • Remaining raw values, documented: daemon ANTHROPIC_API_KEY/OPENAI_API_KEY/CODEX_API_KEY, the Qwen provider key, unruled secrets on upgraded installs, Postgres passwords (raw TCP).

🤖 Generated with Claude Code

tbiyss and others added 30 commits September 26, 2026 18:01
The gateway capability gains an optional `remoteMcps` claim (server names
only, at most 16) and accepts a caller-fixed `jti`. A new in-memory
registry keyed by that jti holds the real URL and headers of each relayed
server for exactly the capability's lifetime, so a containerized run can
be handed a name instead of a credential.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Tiberiu Socaci <tsocaci@makeitfuture.com>
A new `remote-mcp` service on the control socket: the hello names a
server, the daemon checks the capability's signed `remoteMcps` claim AND
its in-memory registration under the capability's jti, then dials the
real URL with the real headers (Streamable HTTP, HTTP+SSE on a 4xx) and
relays tools/list and tools/call, re-authorizing each and forwarding
cancellation and progress. Refusals are fixed sentences that quote
nothing upstream.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Tiberiu Socaci <tsocaci@makeitfuture.com>
On an isolated target the header-bearing remotes (composio-user and
composio-agent in token/endpoint mode, makeitfuture-toolbox, make-toolbox)
become socket-bridge entries selecting the `remote-mcp` service. The
capability is minted with a fixed jti and the `remoteMcps` names, and the
real URL + headers are registered daemon-side under that jti, so the
cg-mcp-*.json in the artifact dir carries no credential. Host targets keep
the exact direct http entries.

The warm-pool fingerprint gains a value-free digest of each relayed URL
and header, so a rotated token still retires the warm process, and it now
normalizes the capability on every socket-bridged entry, not only the
gateway's. A remote whose URL the relay cannot dial (a non-https
override) is dropped from an isolated run and reported, never handed over.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Tiberiu Socaci <tsocaci@makeitfuture.com>
…capability

In a container, Codex's composio-user/-agent (token and endpoint mode),
makeitfuture-toolbox and make-toolbox entries become the same
secret-env-bridge -> socket-bridge chain as the gateway entry, selecting
the `remote-mcp` service and naming the server. The per-run 0600 bundle
then carries only `gatewayCapability` and no headers helper is written,
so nothing in the container's artifact dir holds a remote MCP credential.
The secret-env-bridge forwards CG_MCP_SERVICE and CG_MCP_SOCKET to the
bridge it launches. Sudo-host turns keep the native transport with
headers helpers.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Tiberiu Socaci <tsocaci@makeitfuture.com>
…x bundle

An SSH session's mcp.json already comes from the turn's assembler with the
container target, so Composio and the toolboxes are now relayed there too.
Its Codex bundle holds only `gatewayCapability` and no headers helper is
written; the relay registrations ride the session capability's 12-hour
lifetime and a refresh registers under a fresh jti.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Tiberiu Socaci <tsocaci@makeitfuture.com>
The image's socket bridge and secret-env-bridge now carry the
`remote-mcp` service; an older image's broker would drop the service
selection, so the daemon must ask for a rebuild.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Tiberiu Socaci <tsocaci@makeitfuture.com>
The pure core of the P2 egress proxy. A container will hold cgph_
placeholders instead of real credentials; rules.js swaps one for its real
value only on the hosts, headers and header positions its grant declares
(bearer, raw, Basic user/password, listed query parameters) and while
canUse says yes, never partially and never across a Host-header mismatch.
scrub.js streams text responses and puts an echoed real value back to its
placeholder, holding back only a possible value prefix so SSE stays live.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Tiberiu Socaci <tsocaci@makeitfuture.com>
…dresses

Resolves every address of a destination and refuses it when any one is
loopback, private, link-local (incl. cloud metadata), CGNAT, multicast or
an IPv4-mapped form of those, reusing src/web/ip-policy.js; the first
address is pinned for the connect so DNS rebinding cannot move it. Mode
off admits only engine hosts, mode on admits all public hosts or an
allowlist, and rawPassthrough host:port rules mark plain tunnels.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Tiberiu Socaci <tsocaci@makeitfuture.com>
…ss CA

Node parses certificates but cannot issue them and we take no new runtime
dependency, so x509.js is a minimal DER encoder for exactly two shapes:
an ECDSA P-256 CA (basicConstraints CA:true pathLen 0, keyCertSign and
cRLSign) and serverAuth leaves with a dNSName or iPAddress SAN and
SKI/AKI, signed with ecdsa-with-SHA256. The leaf's issuer is copied byte
for byte from the CA subject. Tests prove a verified TLS handshake with
only the CA trusted, and openssl verify agrees.

ca.js creates the CA once under a 0700 dir (ca.key 0600, ca.pem 0644),
repairs modes on load, refuses a mismatched key instead of re-keying, and
mints 24 h leaves on demand into a 512-entry LRU, renewed an hour early.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Tiberiu Socaci <tsocaci@makeitfuture.com>
FEATURES and the socket protocol bullet name the `remote-mcp` service,
TEST-PLAN gains the regression cases and the unexecuted live gate
(Composio through the relay on Claude and Codex with the artifact dir
grepped for tokens), CHANGELOG notes the user-visible change and the
image rebuild, OPERATIONS explains what an operator sees, and the stale
remote-secret-bridge header no longer claims its bundle sits under a
sandbox-denied gateway root.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Tiberiu Socaci <tsocaci@makeitfuture.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Tiberiu Socaci <tsocaci@makeitfuture.com>
…only

Never derive the refusal text from the thrown error, so a future change
to the authorization path cannot leak detail into a container.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Tiberiu Socaci <tsocaci@makeitfuture.com>
createEgressProxy() serves HTTP/1.1 proxy connections handed in by a
per-channel Unix-socket listener (serveEgressConnection(socket, ctx)).
CONNECT runs the destination policy: refused answers 403 with a JSON
category, rawPassthrough rules get a plain tunnel to the pinned address,
everything else is TLS-terminated with a leaf from the deployment CA and
parsed by an internal http.Server. Each request is forwarded over https
to the pinned address with servername set to the CONNECT host, headers
and listed query parameters swapped, bodies streamed unbuffered, and an
uncompressed text response scrubbed when a grant was swapped. WebSocket
upgrades are swapped, then piped. Absolute-form http:// requests swap
only grants that opt into plainHttp. A canUse refusal answers 403 naming
the secret; a placeholder on an undeclared host is forwarded unchanged.

One audit event per request or tunnel carries names, counts and reasons,
never a value. Every socket is tracked and close() destroys them all.
policyFor, resolveGrant and canUse are callbacks, so the wiring slice
supplies channel policy and grants without this module touching the DB.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Tiberiu Socaci <tsocaci@makeitfuture.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Tiberiu Socaci <tsocaci@makeitfuture.com>
…osio-sdk service

In a container, Codex in Composio SDK mode launched the image's socket
bridge bare: no CG_MCP_SERVICE and no capability, so the daemon's
composio-sdk service refused every such run. The entry now goes through
the same secret-env-bridge -> cg-mcp-bridge chain as the relayed remotes,
with CG_MCP_SERVICE="composio-sdk" and the session URL as the trailing
argument; without a secret bundle (no capability) no entry is emitted.
The sudo-host path is unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Tiberiu Socaci <tsocaci@makeitfuture.com>
Registrations now carry {channelId, slug, authorId, origin} and holds:
the minting caller holds one and every open relay connection another,
and the grant goes with the last hold. run.js releases its primary and
fallback grants in the turn's finally, so a cold turn's grant ends with
it and a warm Claude process keeps its own exactly as long as its bridge
connections stay open. An SSH refresh releases the previous
preparation's grants and the developer's last session drops them all.
Clearing a personal Composio or Toolbox token (clear_my_*_token or the
admin UI) drops every grant minted for that person's runs.

Each relayed server is validated on its own before registration: an
invalid URL or header drops and announces only that server instead of
failing the container turn, and Codex skips the same server.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Tiberiu Socaci <tsocaci@makeitfuture.com>
The socket's close listener is attached before the upstream dial, and
whichever of hang-up and dial-settled comes second tears the relay down,
so an upstream client is never left open after its container is gone.
A relay slot is held until the dial settles, at most 8 per grant and
server. Hello fields are read with typeof checks, a handshake error can
no longer become an unhandled rejection, and a gateway hello carrying
arguments (a pre-1.6.0 image's broker dropped CG_MCP_SERVICE) is refused
with the rebuild remedy.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Tiberiu Socaci <tsocaci@makeitfuture.com>
A JSON-RPC error the remote answered with passes unchanged; anything
else (the SDK's "Error POSTing to endpoint: <body>", DNS failures)
becomes "remote MCP request failed".

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Tiberiu Socaci <tsocaci@makeitfuture.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Tiberiu Socaci <tsocaci@makeitfuture.com>
…talog

The data plane the egress proxy swaps against. Migration 30 adds
egress_grants: placeholder -> (scope, channel, owner, secret name), never
a value. grants.js mints one stable placeholder per key, resolves the
CURRENT value live from the store that owns it (organization settings,
channel meta, the user row, the Claude relay cached 60 s), revokes a
removed secret's row, and wraps a spawn's env resolution
(resolveEgressRunEnv): ruled names become placeholders, unruled ones stay
raw and are flagged unprotected, or are withheld under strict mode, with
every real value kept for the redactor. containerClaudeCredential hands
a proxy-mode container the channel's relay placeholder in the Anthropic
token shape.

catalog-rules.js holds the built-in rules (GitHub, Vercel, Supabase,
Make, Composio; the CLI catalog gains a swap block per integration) and
validates an entry's own "used on hosts" declaration, which channel-env
now stores, keeps across a value rotation and lists as protected/hosts.
The name families are credential names only: GH_REPO, VERCEL_ORG_ID and
SUPABASE_DB_PASSWORD stay raw.

liveness.js records live turns, jobs and reviews per channel and reads
SSH sessions from the broker. The container backend gets a hook module
(egress-hook.js) the daemon's service registers with, and egress-env.js
is the one list of proxy/CA variables, all of them reserved names.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Tiberiu Socaci <tsocaci@makeitfuture.com>
A proxy-mode channel container (the new default, containerEgressMode
"proxy") has no network of its own. Its only way out is the daemon's
egress proxy over a per-channel unix socket under <root>/eg/<12 hex>/,
bind-mounted read-only at /run/channelgate/egress (deliberately not under
the run dir every container mounts), plus the CA trust bundle at
/run/channelgate/egress-ca.pem. cg-init starts the in-image forwarder
(src/mcp/egress-forwarder.js, staged as bin/cg-egress.mjs) on
127.0.0.1:3128 under CG_EGRESS=proxy; one helper sets the proxy and CA
variables at create, forced into every exec env-file and applied last in
the Claude and Codex env; Chromium gets --proxy-server plus the CA's SPKI
pin through AGENT_BROWSER_ARGS.

The service (src/gateway/egress/service.js) boots right after the control
socket: CA, trust bundle written in place, proxy, backend hook, and a
config-change listener that revokes the placeholders of removed secrets.
Its policy reads the channel's current meta per request (Allow network
off = engine endpoints and the channel's selected remote MCPs only;
github.com:22 plus admin-declared egressRawHosts as raw tunnels when on),
its swap gate is channel binding plus liveness (turns, jobs, reviews and
SSH sessions mark it), and it audits only non-relay swaps, refusals,
blocks and tunnels. A service that cannot start logs one line; proxy-mode
runs, jobs and SSH containers then fail closed naming the remedy.

run.js, background jobs and the memory reviewer resolve secrets through
the grants wrapper and hand Claude the relay placeholder; the credential
preamble says which names are placeholders and where they work, which are
raw, which were withheld; the redactor keeps every real value. The
network switch is enforced (networkEnforcedFor) except under the legacy
bridge mode or a channel's rawNetwork escape, and every surface says so.
list_secrets, set_secret (hosts/headers/format), the admin secrets editor
("Used on hosts") and the admin settings (Egress mode, strict secrets)
expose the protection. Tests pinning the old bridge contract now pin the
new one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Tiberiu Socaci <tsocaci@makeitfuture.com>
FEATURES gains the proxy core (which its own commit deliberately left
for this one), the --network none container contract and the
placeholder model, and the old "Allow Network is advisory" entries now
say where that is still true. TEST-PLAN gains the regression cases for
the core and the integration and eight UNEXECUTED live gates (no podman
on the development host): engine turns on both engines, gh/git/vercel
with placeholders, agent-browser over HTTPS, the bypass proof, an
unswapped placeholder on a foreign host, live rotation and revocation,
the live switch and fail-closed boot, and the legacy escape. CHANGELOG,
OPERATIONS (network, CA trust bundle, secrets through the proxy, audit,
the legacy escape, raw hosts), PRIVACY, README, WHY, the engine and
OpenCode notes, the contributor contract (the confinement rule, the
src/gateway/egress map, migration 30) and the operating-manual and
channelgate skill pages follow.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Tiberiu Socaci <tsocaci@makeitfuture.com>
… cap, hardened hellos)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A --network none container has no route for an ssh client. The new image
helper /opt/channelgate/bin/cg-egress-connect (a POSIX shim over
bin/cg-egress-connect.mjs, staged verbatim from src/mcp/egress-connect.js,
node built-ins only) speaks CONNECT host:port to the in-container
forwarder on 127.0.0.1:3128 and pipes stdio through the raw tunnel the
proxy allows (github.com:22 and the declared raw hosts, Allow network
on). A refusal is one stderr line with the proxy's reason and exit 1; an
unreachable forwarder names the error. The proxy cannot inject an SSH
key. Image spec stays 1.6.0 (unreleased, same release as the forwarder).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Tiberiu Socaci <tsocaci@makeitfuture.com>
A developer's SSH session now resolves its environment through
resolveEgressRunEnv like a turn: behind the egress proxy the session env
file exports placeholders for every ruled secret (unruled names stay raw
and flagged, or are withheld in strict mode), and result.secrets reports
{ name, scope, protected }. Claude's access-only login file and the
host-side vscode/claude-token hold the channel's relay placeholder with
the real login's plan facts; the token file is also removed when the
channel's last SSH session ends.

sshd starts a clean environment, so the full egressEnv(target) map plus
AGENT_BROWSER_ARGS and a gateway-owned GIT_SSH_COMMAND through
cg-egress-connect ride the one SetEnv line and lead the session env file
(after unsetting ALL_PROXY). The session note names the proxy, the CA,
the protected/unprotected/withheld names, the personal pause rule and the
ProxyCommand, never a value.

resolveEgressRunEnv now reports personalPaused when the author holds
personal placeholders while another person's SSH session is open
(liveness reads the broker's live sessions), and the per-attempt
credential note says those secrets are paused. An operator's npm run
vscode window keeps non-personal grants swapping through its editor
lease, since that launcher runs in its own process.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Tiberiu Socaci <tsocaci@makeitfuture.com>
SSH-ACCESS gains the network-and-secrets section: the proxy env inside a
session, placeholders, the personal-scope pause rule, -L forwards to
external hosts no longer working under --network none (container-local
-L and -R unchanged), VS Code server downloads through the proxy env,
outbound SSH through cg-egress-connect and the fact that the proxy cannot
inject a key. FEATURES, CHANGELOG and the agent-facing administration
page follow. TEST-PLAN records the regression cases and seven UNEXECUTED
live gates (no podman on the development host).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Tiberiu Socaci <tsocaci@makeitfuture.com>
Review fixes to the proxy core:
- An absolute-form request line inside a TLS tunnel (`GET https://other/`)
  is refused with 400 instead of being forwarded as the upstream path, a
  request with no Host header is never swapped into, and the upstream
  always receives the CONNECT/URL host as Host.
- A Host header naming another host than the CONNECT or absolute-URL host
  is refused with 403 host-mismatch (no domain fronting past the policy).
- A swapped request asks for accept-encoding: identity; response header
  values, bodies without a Content-Type and non-101 answers to an Upgrade
  (now sent through Node's HTTP client) are scrubbed too. Declared binary
  types still pass through.
- DNS lookups race a 5 s deadline (504 dns-timeout), at most 16 policy
  decisions run per channel at once (503 too-many-lookups), and an
  upstream gets 60 s after the request was sent to answer (504).
- Upstream TLS is verified with rejectUnauthorized against Node's roots
  plus the host bundle, built once; certificate failures classify as
  upstream-tls.
- An other-channel refusal no longer names the secret.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Tiberiu Socaci <tsocaci@makeitfuture.com>
…teners at boot

Review fixes to the integration:
- A personal placeholder is refused (another-author-active) while any
  other person's turn, background job or SSH session is live in the
  channel, not only another person's SSH session. The memory reviewer
  marks the channel live with no owner, and the credential preamble says
  personal placeholders can pause.
- At boot the service re-binds the listener of every proxy-mode
  container that kept running across the restart. Chosen over hooking
  background-job recovery because jobs are not the only survivors: an
  attached editor and processes left from SSH sessions need the network
  back too, and none of them passes through a turn.
- Runs reconcile grants by the STORED entry names, so a secret that
  briefly resolves empty keeps its placeholder instead of being revoked
  while warm processes still hold it.
- The network mode is part of the mount fingerprint, so clearing
  rawNetwork is never deferred behind running work.
- A channel listener is rebound when the channel id behind its slug
  changes, a close waits for an in-flight bind, and each channel socket
  accepts at most 256 connections.
- The forwarder keeps half-open connections (raw tunnels are no longer
  truncated when the client half-closes) and cg-init logs its failures
  to /run/cg/egress.log.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Tiberiu Socaci <tsocaci@makeitfuture.com>
OPERATIONS and FEATURES describe request pinning, the scrub and
deadline rules, the boot restore and the wider personal-secret pause,
and list the limits: a Qwen provider key and a daemon CODEX_API_KEY
still reach proxy-mode containers raw, and a self-hosted Qwen endpoint
on a private address is refused. The secrets editor, set_secret and the
operating manual warn against multi-tenant host suffixes such as
*.vercel.app. TEST-PLAN gains the regression cases and two unexecuted
live gates (restart restore, personal pause).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Tiberiu Socaci <tsocaci@makeitfuture.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Tiberiu Socaci <tsocaci@makeitfuture.com>
tbiyss and others added 7 commits September 26, 2026 19:42
…proxy

Until now every channel container bind-mounted the operator's REAL
~/.codex/auth.json read-write: one channel could read the refresh token,
and every channel's sign-in was the same file. Behind the egress proxy
Codex is now relayed like Claude:

- placeholders.js gains a JWT shape (the real token's header and claims,
  the core `cgph_r…` as the signature segment, since codex-cli rejects a
  non-JWT) and rules.js a `jwt` format that replaces the WHOLE token;
  a jwt grant refuses a bare placeholder and a bearer grant a JWT one.
- src/gateway/codex-token-relay.js (the claude-token-relay twin) reads
  the login the CLI would use, and renews it with one cheap ephemeral
  `codex exec` turn in the login's own CODEX_HOME under a keyed lock
  when the access token has < 48 h left, backing off when the CLI
  declines to renew a still-valid token.
- grants.js: the relay scope is keyed by secret name; CODEX_ACCESS_TOKEN
  swaps on api.openai.com, chatgpt.com and auth.openai.com in the
  Authorization header only. containerCodexCredential renders the
  container's access-only auth.json (placeholder token, id_token claims
  without a signature, EMPTY refresh token, last_refresh now).
- credentials.js: Codex mode "relay" when egress is active and the login
  is a ChatGPT sign-in; the codex-auth mount is dropped (the fingerprint
  change recreates each container once). Bridge mode and an API-key
  auth.json keep the shared file (the documented remaining exposure).
- The runner writes the file before every Codex spawn through a new
  optional backend method, writeHomeFile: staged 0600 through the
  artifact dir, renamed into place inside the HOME volume, and refused
  when the destination is still a mount. SSH sessions place it too.
- cg-init removes, under the proxy, a leftover Codex login that carries
  a refresh token (an old copy); a mounted file is never touched.

Spike (codex-cli 0.156.1): an access-only auth.json runs a turn; a
16-day-old last_refresh triggers no refresh; on a 401 the CLI calls the
refresh endpoint, which rejects the empty refresh token; the JWT
placeholder passes `codex login status` and a proxied turn (WebSocket
included) with the swap.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Tiberiu Socaci <tsocaci@makeitfuture.com>
containerEgressSecretsStrict now defaults ON: a secret with no egress
rule is withheld from proxy-mode containers instead of injected raw.
The getter reads an absent key as strict, and server.js pins the value
once at boot (after the first-boot admin password, from the "operator
configured?" answer read before it): a brand-new install stores true,
an install that existed before this default stores false, so an upgrade
never silently withholds a secret a working channel relies on. A
stored choice is never touched. Unreadable settings fail closed.

list_secrets reports the remaining unruled secrets as a FINDING line
(names, what happens to them under the setting in force, and how to
declare hosts), and its footnote follows the strict setting. The
settings UI text says an unruled secret is withheld and how to add
hosts.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Tiberiu Socaci <tsocaci@makeitfuture.com>
Since the remote-MCP relay (container-secrets P1) nothing launches a
header-bearing remote MCP through the stdio bridge: a container relays
it over the control socket and a host run dials it natively. The
bridge, its "mcp-remote" entry in HELPER_COMMANDS and in the host and
image helper tables, its BUNDLE_ENTRIES line and the mcp-remote bundle
extra package are removed; the helper test now pins that the name is
unknown. The image keeps installing the pinned mcp-remote globally as a
tool (containers/ is unchanged by this commit).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Tiberiu Socaci <tsocaci@makeitfuture.com>
…act dirs

The artifact dir is bind-mounted into the channel container, so every
member's turn can read what the daemon writes there. check:static gains
a deliberately simple AST rule (scripts/static-secret-writes.mjs, src/
only): a name is artifact-derived when its declaration or assignment
mentions artifactDir/runArtifactRoot/sshUserDir/sshUsersDir/
channelArtifactDir or another derived name (to a fixpoint); a
writeFile*/appendFile*/writePrivate/writeSecretFile call whose path is
artifact-derived is flagged when its content mentions
composioUserToken, composioToken, toolboxToken, makeToolboxKey,
relay.token, resolvedRunEnv or realValues. A reviewed exception carries
`static-check: allow-secret-artifact-write <why>` on one of the two
lines above: the VS Code token file, which holds the relay PLACEHOLDER
behind the proxy (a real access token only in the legacy bridge mode,
where every turn's env holds it anyway). Fixture tests prove a
violation is caught through derived names and that the tree is clean.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Tiberiu Socaci <tsocaci@makeitfuture.com>
OPERATIONS gains the Codex login relay section (the access-only file,
the JWT-shaped placeholder, the swap hosts, the cheap-turn refresh and
its back-off, what an operator sees) and the strict default's
new-install/upgrade rule; its caveats now say where the shared Codex
file remains (legacy bridge, API-key login) and that SSH sessions hold
placeholders. ENGINE-CAPABILITIES gains a container-login row,
COMPATIBILITY records the three Codex 0.156.1 behaviors the relay
relies on. FEATURES, CHANGELOG, INSTALL and the privacy note follow.
AGENTS.md: the Codex twin sentence in the "operator's own Claude login"
rule, the strict default in the secrets rule, and the module map gains
codex-token-relay.js, containerCodexCredential, egress-connect.js and
static-secret-writes.mjs (remote-secret-bridge.js is gone). TEST-PLAN
records the spike results verbatim, the regression cases and nine
UNEXECUTED live gates (no podman on the development host).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Tiberiu Socaci <tsocaci@makeitfuture.com>
…ranch

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Tiberiu Socaci <tsocaci@makeitfuture.com>
…le for Codex with the network off

Observed on the 2026-09-26 codex-cli 0.156.1 spike turn; the login relay still swaps only
on the three API hosts.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Tiberiu Socaci <tsocaci@makeitfuture.com>
@makeitfutureDev
makeitfutureDev merged commit a1845f2 into beta Sep 26, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants