Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
c89309a
fix(bridge): durable streams + decouple job from connection + single-…
drewstone Jun 19, 2026
cee164e
Merge remote-tracking branch 'origin/main' into complete-62
drewstone Jun 28, 2026
3d184cc
fix(usage): emit estimated usage as an OpenAI trailer (choices: []) +…
drewstone Jun 28, 2026
785bcf2
fix(usage): count tool_calls in token estimate so tool-heavy turns ar…
drewstone Jun 28, 2026
ca43776
Merge remote-tracking branch 'origin/main' into complete-62
drewstone Jun 28, 2026
a9bd660
fix(jail): throw typed BackendError on fail-closed so it surfaces as …
drewstone Jun 28, 2026
9ccd7e9
fix(jail): effective gitignore via .git/info/exclude + fail-fast when…
drewstone Jun 28, 2026
8faba88
fix(jail): startup honors BRIDGE_JAIL_FALLBACK=warn (warn+continue, n…
drewstone Jun 28, 2026
5520d52
fix(jail): resolve real git dir for exclude (nested cwd + worktree .g…
drewstone Jun 28, 2026
01e7f0f
fix(jail): only fail-fast when a host executor exists; clean macOS au…
drewstone Jun 28, 2026
73064d7
fix(jail): startup fail-fast covers all host-spawn backends (ACP/fact…
drewstone Jun 28, 2026
f9f39de
fix(jail): confine macOS writes to root (drop temp whitelist); honor …
drewstone Jun 28, 2026
115e688
fix(jail): point codex CODEX_HOME inside the jail root (synthetic MCP…
drewstone Jun 28, 2026
8c088dc
fix(jail): preserve pi ~/.pi/agent state in the write-jail
drewstone Jun 28, 2026
8e7cb83
fix(jail): redirect CODEX_HOME in the jail layer (only when it wraps)…
drewstone Jun 28, 2026
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
12 changes: 12 additions & 0 deletions src/backends/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,18 @@ export class CodexBackend implements Backend {
resolveCodexAuthPath(),
)

// When MCP passthrough is active, the synthetic CODEX_HOME (merged MCP config
// + copied auth) is the source of truth. Register it as the jail's codex auth
// source so a CONFINED run gets it surfaced inside the jail with CODEX_HOME
// redirected there. The jail applies this only when it actually wraps; on
// docker/fallback paths the host `CODEX_HOME` env below is used unchanged.
if (req.jailSpec && codexHome) {
req.jailSpec.authSources = [
...(req.jailSpec.authSources ?? []).filter((s) => s.envVar !== 'CODEX_HOME'),
{ source: codexHome.homePath, jailRel: '.codex', envVar: 'CODEX_HOME' },
]
}

// Phase-2 host wiring: provision cwd-native profile dimensions (skills/context/
// hooks/subagents/commands) before spawn. MCP stays on the path above. Fail-safe.
provisionProfileWorkspace(req, session, 'codex', req.cwd ?? session?.cwd ?? process.cwd())
Expand Down
18 changes: 18 additions & 0 deletions src/backends/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,24 @@ export function tokensFromChars(chars: number): number {
return Math.max(0, Math.ceil(chars / 4))
}

/**
* Total characters the model actually reads for a request — message content PLUS
* assistant tool-call structures (id + name + arguments), which `flattenMessages`
* deliberately omits. Used only for usage estimation, where dropping tool calls
* would systematically undercount tool-heavy turns and make them look cheaper than
* they were. Counts the semantic payload, not JSON framing.
*/
export function estimateMessagesChars(messages: ChatMessage[]): number {
let n = 0
for (const m of messages) {
n += contentToText(m.content).length
for (const tc of m.tool_calls ?? []) {
n += (tc.id?.length ?? 0) + (tc.function?.name?.length ?? 0) + (tc.function?.arguments?.length ?? 0)
}
}
return n
}

export function collectSystemText(messages: ChatMessage[]): string {
return messages
.filter((m) => m.role === 'system')
Expand Down
8 changes: 6 additions & 2 deletions src/backends/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,8 +171,12 @@ export interface ChatDelta {
tool_calls?: Array<{ id: string; name: string; arguments: string }>
/** Terminal reason. Emitted once on the final chunk. */
finish_reason?: 'stop' | 'length' | 'tool_calls' | 'error' | 'timeout'
/** Backend-reported usage. Optional; present on final chunk when known. */
usage?: { input_tokens?: number; output_tokens?: number }
/**
* Token usage. Optional; present on the final chunk when known. `estimated`
* is set when the bridge derived it from text (~4 chars/token) because the
* backend CLI reported none — consumers price it as approximate, not measured.
*/
usage?: { input_tokens?: number; output_tokens?: number; estimated?: boolean }
/** Backend assigned id for this turn. Written to session store. */
internal_session_id?: string
/**
Expand Down
25 changes: 25 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,31 @@ export interface BackendExecutorConfig {
containerConfigDir?: string
}

/** Backends that never spawn a CLI on the host (remote HTTP, local proxy, or a
* socket to an already-running daemon), so the host write-jail never applies. */
const NON_HOST_SPAWN_BACKENDS = new Set(['sandbox', 'passthrough', 'nanoclaw'])

/**
* Whether any ENABLED backend will spawn a CLI on the host (and therefore be
* subject to the write-jail). True unless every enabled backend is remote/proxy
* or pinned to a docker executor. Errs toward true: an unrecognized backend is
* assumed to host-spawn, so the startup jail check fails closed rather than
* booting "healthy" and failing every request at runtime. Covers backends that
* are NOT in `executors` (e.g. ACP hermes/openclaw, factory, amp, forge), which
* default to host spawn.
*/
export function anyBackendSpawnsOnHost(
backends: Iterable<string>,
executors: Record<string, BackendExecutorConfig>,
): boolean {
for (const name of backends) {
if (NON_HOST_SPAWN_BACKENDS.has(name)) continue
if (executors[name]?.kind === 'docker') continue
return true
}
return false
}

const LOOPBACK = new Set(['127.0.0.1', '::1', 'localhost'])

export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
Expand Down
6 changes: 5 additions & 1 deletion src/executors/jail-support.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

import { selectJailBackend } from '../jail/index.js'
import type { JailBackend } from '../jail/index.js'
import { BackendError } from '../backends/types.js'
import type { SpawnOpts } from './types.js'

export interface JailedCommand {
Expand Down Expand Up @@ -54,9 +55,12 @@ export async function applyJail(
}
return { bin, args, env: opts.env }
}
throw new Error(
// Typed BackendError (not a plain Error) so the chat wrapper surfaces it as a
// real config error (5xx / typed SSE), not an opaque finish_reason:'error'.
throw new BackendError(
`write-jail requested but '${backend.name}' cannot run on this host, refusing to run ` +
`unconfined. ${ENABLE_HINT} Or set BRIDGE_JAIL_FALLBACK=warn to run without confinement.`,
'not_configured',
)
}

Expand Down
52 changes: 37 additions & 15 deletions src/jail/auth-preserve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
import { existsSync } from 'node:fs'
import { cp } from 'node:fs/promises'
import { homedir } from 'node:os'
import { join, relative } from 'node:path'
import { join, resolve } from 'node:path'
import type { JailAuthSource } from './types.js'

/**
* $HOME-relative auth/config paths per REGISTERED backend name. Aliases that
Expand All @@ -36,6 +37,10 @@ const AUTH_PATHS: Record<string, readonly string[]> = {
// is active; in the common no-MCP case it reads ~/.codex, which the jail would
// otherwise hide. Preserve it here so jailed codex authenticates either way.
codex: ['.codex'],
// pi keeps provider registrations / model defaults in ~/.pi/agent (the same
// dir config.ts mounts into pi's docker containers). Without it a jailed pi
// run starts from an empty HOME and loses every persisted provider/default.
pi: ['.pi/agent'],
}

/** The HOME the spawned CLIs actually read, honoring a cli-bridge-set HOME
Expand All @@ -44,18 +49,35 @@ function backendHome(): string {
return process.env.HOME?.trim() || homedir()
}

/** Absolute host auth paths for a backend that actually exist on this host. */
export function authSourcesFor(backendName: string): string[] {
/** Auth sources for a backend that actually exist on this host, each mapped to
* the jail-relative location the confined CLI reads. */
export function authSourcesFor(backendName: string): JailAuthSource[] {
const home = backendHome()
return (AUTH_PATHS[backendName] ?? [])
.map((rel) => join(home, rel))
.filter((abs) => existsSync(abs))
}

/** The path, inside the jail HOME, where an auth source must appear (its
* location relative to the real HOME the CLI reads). */
export function jailRelPath(source: string): string {
return relative(backendHome(), source)
const out: JailAuthSource[] = []
for (const rel of AUTH_PATHS[backendName] ?? []) {
const source = join(home, rel)
// rel is already a POSIX-style jail-relative target ('.claude', '.config/opencode').
if (existsSync(source)) out.push({ source, jailRel: rel })
}
if (backendName === 'codex') {
// codex.ts honors $CODEX_HOME (src/backends/codex.ts) and only falls back to
// ~/.codex when it is unset. Mirror that: when CODEX_HOME points elsewhere,
// surface THAT directory at the jail's ~/.codex (where a confined codex with
// HOME=root looks), replacing the default entry rather than copying the wrong
// creds. Without this, a custom-CODEX_HOME install loses its auth in the jail.
const codexHome = process.env.CODEX_HOME?.trim()
if (codexHome) {
const source = resolve(codexHome)
const idx = out.findIndex((e) => e.jailRel === '.codex')
if (idx >= 0) out.splice(idx, 1)
if (existsSync(source)) out.push({ source, jailRel: '.codex' })
}
// Redirect CODEX_HOME at the in-jail copy so a confined codex reads creds
// there rather than the (read-only) host path. The jail applies this only
// when it actually wraps — docker/fallback runs keep the host CODEX_HOME.
for (const e of out) if (e.jailRel === '.codex') e.envVar = 'CODEX_HOME'
}
return out
}

/**
Expand All @@ -65,11 +87,11 @@ export function jailRelPath(source: string): string {
* the copied destination paths so the caller can remove them on cleanup — the
* jail root is project-local, so copied credentials must NOT linger there.
*/
export async function copyAuthIntoJail(root: string, sources: string[] | undefined): Promise<string[]> {
export async function copyAuthIntoJail(root: string, sources: JailAuthSource[] | undefined): Promise<string[]> {
const copied: string[] = []
for (const source of sources ?? []) {
for (const { source, jailRel } of sources ?? []) {
if (!existsSync(source)) continue
const dest = join(root, jailRelPath(source))
const dest = join(root, jailRel)
await cp(source, dest, { recursive: true, force: true, errorOnExist: false })
copied.push(dest)
}
Expand Down
15 changes: 9 additions & 6 deletions src/jail/linux-bwrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,8 @@
import { spawnSync } from 'node:child_process'
import { accessSync, constants, existsSync } from 'node:fs'
import { delimiter, join } from 'node:path'
import { jailRelPath } from './auth-preserve.js'
import type { JailBackend, JailSpec, JailWrap } from './types.js'
import { jailEnv, prepareJailHome, resolveJailRoot } from './types.js'
import { ignoreJailRoot, jailEnv, prepareJailHome, resolveJailRoot } from './types.js'

const BWRAP_BIN = 'bwrap'

Expand All @@ -45,6 +44,7 @@ export class LinuxBwrapJail implements JailBackend {
async wrap(bin: string, args: string[], spec: JailSpec): Promise<JailWrap> {
const root = resolveJailRoot(spec.root, spec.projectDir)
await prepareJailHome(root)
ignoreJailRoot(spec.projectDir, root)

const bwrapArgs = [
'--unshare-user',
Expand Down Expand Up @@ -74,10 +74,13 @@ export class LinuxBwrapJail implements JailBackend {
// Make the backend's host auth readable inside the jail (read-only),
// bound AFTER the writable root so these specific subpaths stay read-only.
// HOME is the jail root, so ~/.claude etc. resolve to these binds.
for (const source of spec.authSources ?? []) {
if (existsSync(source)) {
bwrapArgs.push('--ro-bind', source, join(root, jailRelPath(source)))
}
for (const { source, jailRel, envVar } of spec.authSources ?? []) {
if (!existsSync(source)) continue
const dest = join(root, jailRel)
bwrapArgs.push('--ro-bind', source, dest)
// Point the backend's env var (e.g. CODEX_HOME) at the in-jail copy. Done
// here, where the jail truly applies, so non-jailed paths are untouched.
if (envVar) bwrapArgs.push('--setenv', envVar, dest)
}

// Redirect HOME + XDG dirs into the jail so stateful CLIs write inside it.
Expand Down
86 changes: 60 additions & 26 deletions src/jail/macos-seatbelt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,29 @@
* removes it (and its temp dir) after the spawn completes.
*/

import { accessSync, constants } from 'node:fs'
import { accessSync, constants, existsSync } from 'node:fs'
import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'
import { delimiter, join } from 'node:path'
import { tmpdir } from 'node:os'
import { copyAuthIntoJail } from './auth-preserve.js'
import type { JailBackend, JailSpec, JailWrap } from './types.js'
import { jailEnv, prepareJailHome, resolveJailRoot } from './types.js'
import { ignoreJailRoot, jailEnv, prepareJailHome, resolveJailRoot } from './types.js'

const SANDBOX_EXEC_BIN = 'sandbox-exec'
const SYSTEM_WRITABLE = ['/private/tmp', '/private/var/folders']
// Device nodes a normal process writes to (output redirection, RNG, tracing,
// the controlling tty). These are not filesystem locations a confined run can
// persist files to, so allowing them does not weaken the "writes confined to
// the jail root" guarantee. We deliberately do NOT allow the shared temp trees
// (/private/tmp, /private/var/folders): the CLI's temp writes are redirected to
// TMPDIR=<root>/.tmp (jailEnv), which sits inside the writable root.
const DEVICE_WRITABLE = [
'/dev/null',
'/dev/zero',
'/dev/random',
'/dev/urandom',
'/dev/dtracehelper',
'/dev/tty',
]

export class MacosSeatbeltJail implements JailBackend {
readonly name = 'seatbelt'
Expand All @@ -39,48 +52,69 @@ export class MacosSeatbeltJail implements JailBackend {
// Create the redirected HOME/XDG dirs under the (canonical) root so the CLI
// can write to them; they sit inside `root`, already in the writable set.
await prepareJailHome(root)
ignoreJailRoot(spec.projectDir, root)
// sandbox-exec cannot bind-mount, so copy the backend's host auth into the
// jail HOME (writable, under root) — the CLI authenticates as the operator.
// The copies are removed in cleanup() so credentials never linger in the
// project-local jail root.
const copiedAuth = await copyAuthIntoJail(root, spec.authSources)
const writable = [root, ...SYSTEM_WRITABLE]
for (const path of spec.extraWritablePaths ?? []) {
writable.push(await canonicalize(path))
const removeCopiedAuth = async (): Promise<void> => {
for (const copied of copiedAuth) {
await rm(copied, { recursive: true, force: true })
}
}
// From here on, any failure must remove the copied credentials — otherwise a
// throw before `cleanup` is returned leaves real auth under the repo jail root.
try {
const writable = [root]
for (const path of spec.extraWritablePaths ?? []) {
writable.push(await canonicalize(path))
}

// Point any backend env var (e.g. CODEX_HOME) at the in-jail copy. Done
// here, where the jail truly applies, so non-jailed paths are untouched.
const authEnv: Record<string, string> = {}
for (const { source, jailRel, envVar } of spec.authSources ?? []) {
if (envVar && existsSync(source)) authEnv[envVar] = join(root, jailRel)
}

const profile = buildProfile(writable)
const dir = await mkdtemp(join(tmpdir(), 'cli-bridge-jail-'))
const profilePath = join(dir, 'profile.sb')
await writeFile(profilePath, profile, { mode: 0o600 })
const profile = buildProfile(writable)
const dir = await mkdtemp(join(tmpdir(), 'cli-bridge-jail-'))
const profilePath = join(dir, 'profile.sb')
await writeFile(profilePath, profile, { mode: 0o600 })

return {
bin: SANDBOX_EXEC_BIN,
args: ['-f', profilePath, '-D', `HOME=${root}`, '-D', `WORK=${spec.projectDir}`, bin, ...args],
// sandbox-exec does NOT rewrite the child env; -D only parameterizes the
// profile. Return the real env so HOME/XDG actually point into the jail.
env: jailEnv(root),
cleanup: async () => {
await rm(dir, { recursive: true, force: true })
// Remove copied credentials from the project-local jail root.
for (const copied of copiedAuth) {
await rm(copied, { recursive: true, force: true })
}
},
return {
bin: SANDBOX_EXEC_BIN,
args: ['-f', profilePath, '-D', `HOME=${root}`, '-D', `WORK=${spec.projectDir}`, bin, ...args],
// sandbox-exec does NOT rewrite the child env; -D only parameterizes the
// profile. Return the real env so HOME/XDG actually point into the jail.
env: { ...jailEnv(root), ...authEnv },
cleanup: async () => {
await rm(dir, { recursive: true, force: true })
await removeCopiedAuth()
},
}
} catch (err) {
await removeCopiedAuth()
throw err
}
}
}

function buildProfile(writable: string[]): string {
const allow = writable.map((path) => ` (subpath "${sbplEscape(path)}")`).join('\n')
const allowSubpaths = writable.map((path) => ` (subpath "${sbplEscape(path)}")`).join('\n')
const allowDevices = DEVICE_WRITABLE.map((path) => ` (literal "${sbplEscape(path)}")`).join('\n')
return [
'(version 1)',
'(allow default)',
'',
'; Confine writes to the jail root and explicit writable paths.',
'; Deny all writes, then re-allow only the jail root + explicit writable paths',
'; (subpaths) and standard device nodes (literals). Shared temp trees stay',
'; denied; the CLI writes temp to TMPDIR=<root>/.tmp instead.',
'(deny file-write* (subpath "/"))',
'(allow file-write*',
allow,
allowSubpaths,
allowDevices,
')',
'',
].join('\n')
Expand Down
Loading