diff --git a/README.md b/README.md index 4c5bc65..567d54a 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/backends/pi.ts b/src/backends/pi.ts index 3ca5c8b..da2c515 100644 --- a/src/backends/pi.ts +++ b/src/backends/pi.ts @@ -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' @@ -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' @@ -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]), diff --git a/src/backends/profile-support.ts b/src/backends/profile-support.ts index aa5e166..3eb3e94 100644 --- a/src/backends/profile-support.ts +++ b/src/backends/profile-support.ts @@ -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') diff --git a/src/executors/jail-support.ts b/src/executors/jail-support.ts index f993d42..72a8da6 100644 --- a/src/executors/jail-support.ts +++ b/src/executors/jail-support.ts @@ -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 + }) +} diff --git a/src/jail/auth-preserve.ts b/src/jail/auth-preserve.ts index a9a9dcf..246f043 100644 --- a/src/jail/auth-preserve.ts +++ b/src/jail/auth-preserve.ts @@ -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 } diff --git a/src/jail/index.ts b/src/jail/index.ts index 69597b3..4859ca6 100644 --- a/src/jail/index.ts +++ b/src/jail/index.ts @@ -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() diff --git a/src/jail/types.ts b/src/jail/types.ts index 8249e41..405df9f 100644 --- a/src/jail/types.ts +++ b/src/jail/types.ts @@ -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. */ diff --git a/tests/jail.test.ts b/tests/jail.test.ts index f10acec..d7c1822 100644 --- a/tests/jail.test.ts +++ b/tests/jail.test.ts @@ -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 })) @@ -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 })) @@ -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', () => { @@ -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') diff --git a/tests/pi-backend.test.ts b/tests/pi-backend.test.ts index a11a623..5d7bbe1 100644 --- a/tests/pi-backend.test.ts +++ b/tests/pi-backend.test.ts @@ -133,6 +133,7 @@ describe('PiBackend', () => { }, null, new AbortController().signal)) expect(args.filter((arg) => arg === '--system-prompt')).toHaveLength(1) + expect(args).toContain('--approve') expect(systemPrompt).toBe('SYSTEM_ONCE') expect(argValue(args, '--thinking')).toBe('xhigh') expect(args.at(-1)).toBe('TASK_UNCHANGED') @@ -274,6 +275,7 @@ describe('PiBackend', () => { expect(beta?.promptTemplate).toBe('BETA_PROMPT_TEMPLATE\n') for (const entry of captured) { + expect(entry.args).toContain('--approve') expect(entry.args).toContain('--no-context-files') expect(entry.args).toContain('--no-skills') expect(entry.args).toContain('--no-prompt-templates') @@ -410,6 +412,32 @@ describe('PiBackend', () => { } }) + it('does not change project-approval behavior for a call without an exact profile', async () => { + const cwd = mkdtempSync(join(tmpdir(), 'pi-no-profile-approval-')) + let args: string[] = [] + try { + const backend = new PiBackend({ + bin: 'pi', + timeoutMs: 1000, + spawner: piSpawner([ + { type: 'message_update', assistantMessageEvent: { type: 'text_delta', delta: 'ok' } }, + { type: 'turn_end', message: { usage: { input: 2, output: 1 } } }, + ], (_bin, rawArgs) => { + args = [...rawArgs] + }), + }) + await collect(backend.chat({ + model: 'pi/zai-coding-paas/glm-5.2', + messages: [{ role: 'user', content: 'work' }], + cwd, + }, null, new AbortController().signal)) + + expect(args).not.toContain('--approve') + } finally { + rmSync(cwd, { recursive: true, force: true }) + } + }) + it('emits only text deltas and streams turn usage separately from completion', async () => { const backend = new PiBackend({ bin: 'pi', @@ -1361,6 +1389,7 @@ describe('PiBackend', () => { expect(args).toContain('--no-extensions') expect(args).not.toContain('--extension') + expect(args).toContain('--approve') expect(args).toContain('--no-context-files') expect(args).toContain('--no-skills') expect(args).toContain('--no-prompt-templates') @@ -1420,6 +1449,57 @@ describe('PiBackend', () => { } }) + it('loads a custom AgentDir extension through its stable in-jail path', async () => { + const cwd = mkdtempSync(join(tmpdir(), 'pi-profile-jailed-extension-')) + const jailRoot = join(cwd, '.agent-home') + const agentDir = mkdtempSync(join(tmpdir(), 'pi-profile-custom-agent-dir-')) + const packageDir = join(agentDir, 'npm', 'node_modules', 'pi-zai-glm') + const extensionDir = join(packageDir, 'extensions') + const previousAgentDir = process.env.PI_CODING_AGENT_DIR + let args: string[] = [] + let jail: ChatRequest['jailSpec'] + try { + mkdirSync(extensionDir, { recursive: true }) + writeFileSync( + join(packageDir, 'package.json'), + JSON.stringify({ name: 'pi-zai-glm', pi: { extensions: ['./extensions'] } }), + ) + writeFileSync(join(extensionDir, 'provider.ts'), 'export default () => undefined\n') + process.env.PI_CODING_AGENT_DIR = agentDir + + const backend = new PiBackend({ + bin: 'pi', + timeoutMs: 1000, + spawner: piSpawner([ + { type: 'message_update', assistantMessageEvent: { type: 'text_delta', delta: 'ok' } }, + { type: 'turn_end', message: { usage: { input: 2, output: 1 } } }, + ], (_bin, rawArgs, opts) => { + args = [...rawArgs] + jail = opts.jail + }), + }) + await collect(backend.chat({ + model: 'pi/zai-coding-paas/glm-5.2', + messages: [{ role: 'user', content: 'work' }], + cwd, + jailSpec: { root: jailRoot, projectDir: cwd, readConfine: true }, + agent_profile: { extensions: { pi: { load: ['pi-zai-glm'] } } }, + }, null, new AbortController().signal)) + + expect(argValue(args, '--extension')).toBe(packageDir) + expect(jail?.argumentRewrites).toEqual([{ + from: packageDir, + to: join(jailRoot, '.pi', 'agent', 'npm', 'node_modules', 'pi-zai-glm'), + precededBy: '--extension', + }]) + } finally { + if (previousAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR + else process.env.PI_CODING_AGENT_DIR = previousAgentDir + rmSync(cwd, { recursive: true, force: true }) + rmSync(agentDir, { recursive: true, force: true }) + } + }) + it('fails before spawn when Pi-specific extension controls are unknown', async () => { const cwd = mkdtempSync(join(tmpdir(), 'pi-profile-extension-unknown-')) let spawns = 0