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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,8 +141,9 @@ Behavior:

- `sandbox` backends honor the full `agent_profile` natively
- local harness backends (`claude-code`, `codex`, `kimi-code`, `gemini`, `pi`) persist the full profile and reject profile dimensions they cannot execute
- Pi passes the replacement system prompt, additive instructions, skills, and prompt templates through Pi's native per-process flags using unique files that are removed after the run; a profile also disables ambient context, skill, and prompt-template discovery
- Pi passes the replacement system prompt, additive instructions, skills, and prompt templates through Pi's native per-process flags using unique files that are removed after the run; a profile grants run-scoped project-file trust without persisting approval, while ambient context, skill, and prompt-template discovery stays disabled
- `agent_profile.extensions.pi.load` selects an exact Pi extension set from installed package names or absolute paths; when present, the bridge passes `--no-extensions` plus one `--extension` flag per resolved entry
- when Pi runs inside the OS filesystem jail, the executor translates each installed-package path only after confinement is confirmed; host, Docker, and explicit unconfined-fallback paths keep their normal argv
- an EMPTY `load` list (`"extensions": { "pi": { "load": [] } }`) is the complete-isolation request: `--no-extensions` and nothing else, so no installed extension loads. Use it when a run must not inherit state an extension persists across runs — a paired experiment whose two arms share such an extension is silently unpaired, and nothing in the response reports it
- Pi rejects generic profile file mounts because Pi has no request-scoped loader that can preserve their declared task-relative paths

Expand Down
36 changes: 26 additions & 10 deletions src/backends/pi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@
import { existsSync, readFileSync } from 'node:fs'
import { randomUUID } from 'node:crypto'
import { homedir } from 'node:os'
import { isAbsolute, join } from 'node:path'
import { isAbsolute, join, resolve } from 'node:path'
import type { Backend, ChatDelta, ChatRequest, BackendHealth } from './types.js'
import { versionHealth } from './health.js'
import { BackendError } from './types.js'
Expand All @@ -68,6 +68,7 @@ import {
import { contentToText } from './content.js'
import { scopedHostSpawner } from '../executors/scoped-host.js'
import { resolveSpawnerCwd, type Spawner } from '../executors/types.js'
import { registerJailArgumentRewrite } from '../jail/index.js'
import { readProcessLines, waitForProcessClose } from './process-lines.js'
import { BoundedDiagnosticBuffer } from './diagnostic-buffer.js'
import { terminateSpawned } from '../executors/process-tree.js'
Expand Down Expand Up @@ -190,15 +191,30 @@ function piExtensionArgs(
)
}

const configuredAgentDir = process.env.PI_CODING_AGENT_DIR
const hostNpmRoot = join(configuredAgentDir ?? join(homedir(), '.pi', 'agent'), 'npm', 'node_modules')
// Pi expands `~` itself. Keeping the default path HOME-relative makes the
// same argv work for host execution and for a container whose mounted Pi
// agent directory lives under a different HOME.
const runtimeNpmRoot = join(configuredAgentDir ?? '~/.pi/agent', 'npm', 'node_modules')
const entries = new Set((load as string[]).map((spec) =>
resolvePiExtensionPath(spec.trim(), hostNpmRoot, runtimeNpmRoot),
))
const configuredAgentDir = process.env.PI_CODING_AGENT_DIR?.trim()
const hostAgentDir = configuredAgentDir
? resolve(configuredAgentDir)
: join(homedir(), '.pi', 'agent')
const hostNpmRoot = join(hostAgentDir, 'npm', 'node_modules')
// Pi expands `~` itself. Keep the ordinary argv portable across a direct
// host run, the explicit unconfined fallback, and a container whose mounted
// Pi directory lives under a different HOME. The executor rewrites exact
// package arguments only after it proves the OS jail will actually apply.
const runtimeNpmRoot = join(
configuredAgentDir ? hostAgentDir : '~/.pi/agent',
'npm',
'node_modules',
)
const jailedNpmRoot = req.jailSpec
? join(req.jailSpec.root, '.pi', 'agent', 'npm', 'node_modules')
: runtimeNpmRoot
const entries = new Set((load as string[]).map((spec) => {
const normalizedSpec = spec.trim()
const runtimePath = resolvePiExtensionPath(normalizedSpec, hostNpmRoot, runtimeNpmRoot)
const jailedPath = resolvePiExtensionPath(normalizedSpec, hostNpmRoot, jailedNpmRoot)
registerJailArgumentRewrite(req.jailSpec, runtimePath, jailedPath, '--extension')
return runtimePath
}))
return [
'--no-extensions',
...[...entries].flatMap((entry) => ['--extension', entry]),
Expand Down
7 changes: 6 additions & 1 deletion src/backends/profile-support.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,12 @@ function piProfileFlags(
profileRoot: string,
nativeLoaders: PiPlanNativeLoaders,
): string[] {
const flags = ['--no-context-files', '--no-skills', '--no-prompt-templates']
// Exact profiles load request-scoped resources from unique paths under the
// workspace. Grant project-file trust for this process so Pi neither prompts
// nor persists approval into its read-only global settings. Ambient context,
// skills, and prompt templates remain disabled by the flags below; callers
// control ambient extensions separately through extensions.pi.load.
const flags = ['--approve', '--no-context-files', '--no-skills', '--no-prompt-templates']

if (plan.systemPrompt !== undefined) {
const systemPromptDir = join(profileRoot, '.cli-bridge')
Expand Down
20 changes: 19 additions & 1 deletion src/executors/jail-support.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,28 @@ export async function applyJail(
)
}

const wrap = await backend.wrap(bin, args, opts.jail)
// Only the executor knows that the jail will actually run. Apply backend-
// declared path translations here, after availability is proven; the
// explicit warn fallback above must preserve the normal host/Docker argv.
const rewrittenArgs = rewriteJailArguments(args, opts.jail.argumentRewrites)
const wrap = await backend.wrap(bin, rewrittenArgs, opts.jail)
// Merge any jail-supplied env onto the child env. The merged result
// still flows through sanitizeHostEnv at the spawn site, so the host
// env allowlist continues to apply.
const env = wrap.env ? { ...(opts.env ?? {}), ...wrap.env } : opts.env
return { bin: wrap.bin, args: wrap.args, env, cleanup: wrap.cleanup }
}

export function rewriteJailArguments(
args: string[],
rewrites: ReadonlyArray<{ from: string; to: string; precededBy?: string }> | undefined,
): string[] {
if (!rewrites?.length) return args
return args.map((arg, index) => {
const rewrite = rewrites.find(
(entry) => entry.from === arg
&& (entry.precededBy === undefined || args[index - 1] === entry.precededBy),
)
return rewrite?.to ?? arg
})
}
13 changes: 13 additions & 0 deletions src/jail/auth-preserve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,19 @@ export function authSourcesFor(backendName: string): JailAuthSource[] {
// 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'
}
if (backendName === 'pi') {
// Mirror CODEX_HOME: a custom Pi directory is the real provider catalog,
// not an alias for ~/.pi/agent. Surface that exact source at Pi's stable
// in-jail location, then redirect the child-only env var to it.
const piAgentDir = process.env.PI_CODING_AGENT_DIR?.trim()
if (piAgentDir) {
const source = resolve(piAgentDir)
const idx = out.findIndex((e) => e.jailRel === '.pi/agent')
if (idx >= 0) out.splice(idx, 1)
if (existsSync(source)) out.push({ source, jailRel: '.pi/agent' })
}
for (const e of out) if (e.jailRel === '.pi/agent') e.envVar = 'PI_CODING_AGENT_DIR'
}
return out
}

Expand Down
27 changes: 27 additions & 0 deletions src/jail/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,33 @@ export function registerJailReadable(spec: JailSpec | null | undefined, ...paths
spec.extraReadablePaths = [...merged]
}

/**
* Register one exact argv translation that applies only when a jail really
* wraps the command. This keeps the ordinary host/Docker argv valid when a
* requested jail is unavailable and the operator explicitly permits fallback.
*/
export function registerJailArgumentRewrite(
spec: JailSpec | null | undefined,
from: string,
to: string,
precededBy?: string,
): void {
if (!spec || from === to) return
const existing = spec.argumentRewrites?.find(
(entry) => entry.from === from && entry.precededBy === precededBy,
)
if (existing) {
if (existing.to !== to) {
throw new Error(`conflicting jail argument rewrite for ${from}`)
}
return
}
spec.argumentRewrites = [
...(spec.argumentRewrites ?? []),
{ from, to, ...(precededBy ? { precededBy } : {}) },
]
}

export function selectJailBackend(platform: NodeJS.Platform = process.platform): JailBackend {
if (platform === 'linux') return new LinuxBwrapJail()
if (platform === 'darwin') return new MacosSeatbeltJail()
Expand Down
13 changes: 13 additions & 0 deletions src/jail/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,19 @@ export interface JailSpec {
* the operator HOME (e.g. a custom `CODEX_HOME`) still lands at the location
* the confined CLI reads. Populated per backend by {@link authSourcesFor}. */
authSources?: JailAuthSource[]
/** Exact argv values that differ only while the OS jail is active.
* Backends declare both values; the executor applies the rewrite after it
* has proved a jail backend is available. Fallback and Docker paths retain
* the original argument. */
argumentRewrites?: JailArgumentRewrite[]
}

/** One exact command argument and the value visible inside an active jail. */
export interface JailArgumentRewrite {
from: string
to: string
/** Optional flag that must immediately precede the rewritten value. */
precededBy?: string
}

/** A host credential/config path and where it must appear inside the jail. */
Expand Down
135 changes: 135 additions & 0 deletions tests/jail.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -342,12 +342,57 @@ describe('auth preservation', () => {
// Lands at the jail's ~/.pi/agent, where pi (HOME=root) reads its state.
expect(sources[0]?.jailRel).toBe('.pi/agent')
expect(sources[0]?.source).toBe(join(fakeHome, '.pi', 'agent'))
expect(sources[0]?.envVar).toBe('PI_CODING_AGENT_DIR')
} finally {
if (prev === undefined) delete process.env.HOME
else process.env.HOME = prev
}
})

it('authSourcesFor(pi) honors a custom PI_CODING_AGENT_DIR instead of substituting the default', async () => {
const fakeHome = await mkdtemp(join(tmpdir(), 'cli-bridge-pihome-custom-'))
const customAgentDir = await mkdtemp(join(tmpdir(), 'cli-bridge-piagent-custom-'))
cleanups.push(() => rm(fakeHome, { recursive: true, force: true }))
cleanups.push(() => rm(customAgentDir, { recursive: true, force: true }))
await mkdir(join(fakeHome, '.pi', 'agent'), { recursive: true })
await writeFile(join(fakeHome, '.pi', 'agent', 'models.json'), '{"default":true}')
await writeFile(join(customAgentDir, 'models.json'), '{"custom":true}')
const previousHome = process.env.HOME
const previousAgentDir = process.env.PI_CODING_AGENT_DIR
process.env.HOME = fakeHome
process.env.PI_CODING_AGENT_DIR = customAgentDir
try {
expect(authSourcesFor('pi')).toEqual([{
source: resolve(customAgentDir),
jailRel: '.pi/agent',
envVar: 'PI_CODING_AGENT_DIR',
}])
} finally {
if (previousHome === undefined) delete process.env.HOME
else process.env.HOME = previousHome
if (previousAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR
else process.env.PI_CODING_AGENT_DIR = previousAgentDir
}
})

it('authSourcesFor(pi) does not fall back to ~/.pi/agent when a custom directory is missing', async () => {
const fakeHome = await mkdtemp(join(tmpdir(), 'cli-bridge-pihome-missing-custom-'))
cleanups.push(() => rm(fakeHome, { recursive: true, force: true }))
await mkdir(join(fakeHome, '.pi', 'agent'), { recursive: true })
const previousHome = process.env.HOME
const previousAgentDir = process.env.PI_CODING_AGENT_DIR
process.env.HOME = fakeHome
process.env.PI_CODING_AGENT_DIR = join(fakeHome, 'missing-custom-agent-dir')
try {
expect(authSourcesFor('pi')).toEqual([])
} finally {
if (previousHome === undefined) delete process.env.HOME
else process.env.HOME = previousHome
if (previousAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR
else process.env.PI_CODING_AGENT_DIR = previousAgentDir
}
})

it('bwrap read-only-binds an auth source into the jail HOME at its relative path', async () => {
const authDir = await mkdtemp(join(homedir(), '.cli-bridge-authtest-'))
cleanups.push(() => rm(authDir, { recursive: true, force: true }))
Expand Down Expand Up @@ -383,6 +428,23 @@ describe('auth preservation', () => {
).toBeGreaterThanOrEqual(0)
})

it('bwrap redirects PI_CODING_AGENT_DIR at Pi config inside the jail', async () => {
const authDir = await mkdtemp(join(homedir(), '.cli-bridge-piauth-'))
cleanups.push(() => rm(authDir, { recursive: true, force: true }))
const projectDir = await tempProjectDir()
const root = join(projectDir, '.agent-home')
const wrap = await new LinuxBwrapJail().wrap('/bin/sh', ['-c', 'x'], {
root,
projectDir,
authSources: [{ source: authDir, jailRel: '.pi/agent', envVar: 'PI_CODING_AGENT_DIR' }],
})
const expectedRoot = resolveJailRoot(root, projectDir)
expect(
seqIndex(wrap.args, '--setenv', 'PI_CODING_AGENT_DIR', join(expectedRoot, '.pi/agent')),
'PI_CODING_AGENT_DIR redirected to the in-jail config',
).toBeGreaterThanOrEqual(0)
})

it('seatbelt returns an auth env var (CODEX_HOME) pointing at the in-jail copy', async () => {
const authDir = await mkdtemp(join(homedir(), '.cli-bridge-codexauth-'))
cleanups.push(() => rm(authDir, { recursive: true, force: true }))
Expand All @@ -397,6 +459,21 @@ describe('auth preservation', () => {
const expectedRoot = await realpath(resolveJailRoot(root, projectDir))
expect(wrap.env?.CODEX_HOME).toBe(join(expectedRoot, '.codex'))
})

it('seatbelt points PI_CODING_AGENT_DIR at the copied in-jail config', async () => {
const authDir = await mkdtemp(join(homedir(), '.cli-bridge-piauth-'))
cleanups.push(() => rm(authDir, { recursive: true, force: true }))
const projectDir = await tempProjectDir()
const root = join(projectDir, '.agent-home')
const wrap = await new MacosSeatbeltJail().wrap('/bin/sh', ['-c', 'x'], {
root,
projectDir,
authSources: [{ source: authDir, jailRel: '.pi/agent', envVar: 'PI_CODING_AGENT_DIR' }],
})
if (wrap.cleanup) cleanups.push(async () => { await wrap.cleanup?.() })
const expectedRoot = await realpath(resolveJailRoot(root, projectDir))
expect(wrap.env?.PI_CODING_AGENT_DIR).toBe(join(expectedRoot, '.pi/agent'))
})
})

describe('applyJail fail-closed', () => {
Expand All @@ -423,6 +500,64 @@ describe('applyJail fail-closed', () => {
}
})

it('rewrites exact path arguments only when an available jail wraps the command', async () => {
const available: JailBackend = {
name: 'available-stub',
isAvailable: () => true,
wrap: (bin, args) => ({ bin, args }),
}
const opts = {
jail: {
root: '/proj/.agent-home',
projectDir: '/proj',
argumentRewrites: [{
from: '/host/pi-extension',
to: '/proj/.agent-home/pi-extension',
precededBy: '--extension',
}],
},
} as never

const result = await applyJail(
'pi',
['--extension', '/host/pi-extension', '/host/pi-extension'],
opts,
available,
)

expect(result.args).toEqual([
'--extension',
'/proj/.agent-home/pi-extension',
'/host/pi-extension',
])
})

it('keeps ordinary path arguments when explicit warn fallback runs unconfined', async () => {
process.env.BRIDGE_JAIL_FALLBACK = 'warn'
try {
const opts = {
jail: {
root: '/proj/.agent-home',
projectDir: '/proj',
argumentRewrites: [{
from: '/host/pi-extension',
to: '/proj/.agent-home/pi-extension',
precededBy: '--extension',
}],
},
} as never
const result = await applyJail(
'pi',
['--extension', '/host/pi-extension'],
opts,
unavailable,
)
expect(result.args).toEqual(['--extension', '/host/pi-extension'])
} finally {
delete process.env.BRIDGE_JAIL_FALLBACK
}
})

it('is a pure pass-through when no jail is requested (never throws)', async () => {
const r = await applyJail('mybin', ['--x'], {} as never, unavailable)
expect(r.bin).toBe('mybin')
Expand Down
Loading
Loading