add support for -w,--writable-root to add more writable roots for sandbox (#263)

This adds support for a new flag, `-w,--writable-root`, that can be
specified multiple times to _amend_ the list of folders that should be
configured as "writable roots" by the sandbox used in `full-auto` mode.
Values that are passed as relative paths will be resolved to absolute
paths.

Incidentally, this required updating a number of the `agent*.test.ts`
files: it feels like some of the setup logic across those tests could be
consolidated.

In my testing, it seems that this might be slightly out of distribution
for the model, as I had to explicitly tell it to run `apply_patch` and
that it had the permissions to write those files (initially, it just
showed me a diff and told me to apply it myself). Nevertheless, I think
this is a good starting point.
This commit is contained in:
Michael Bolin
2025-04-17 15:39:26 -07:00
committed by GitHub
parent d5eed65963
commit ae5b1b5cb5
22 changed files with 103 additions and 13 deletions

View File

@@ -22,6 +22,7 @@ type Props = {
imagePaths?: Array<string>;
rollout?: AppRollout;
approvalPolicy: ApprovalPolicy;
additionalWritableRoots: ReadonlyArray<string>;
fullStdout: boolean;
};
@@ -31,6 +32,7 @@ export default function App({
rollout,
imagePaths,
approvalPolicy,
additionalWritableRoots,
fullStdout,
}: Props): JSX.Element {
const app = useApp();
@@ -97,6 +99,7 @@ export default function App({
prompt={prompt}
imagePaths={imagePaths}
approvalPolicy={approvalPolicy}
additionalWritableRoots={additionalWritableRoots}
fullStdout={fullStdout}
/>
);

View File

@@ -53,13 +53,14 @@ const cli = meow(
$ codex completion <bash|zsh|fish>
Options
-h, --help Show usage and exit
-m, --model <model> Model to use for completions (default: o4-mini)
-i, --image <path> Path(s) to image files to include as input
-v, --view <rollout> Inspect a previously saved rollout instead of starting a session
-q, --quiet Non-interactive mode that only prints the assistant's final output
-c, --config Open the instructions file in your editor
-a, --approval-mode <mode> Override the approval policy: 'suggest', 'auto-edit', or 'full-auto'
-h, --help Show usage and exit
-m, --model <model> Model to use for completions (default: o4-mini)
-i, --image <path> Path(s) to image files to include as input
-v, --view <rollout> Inspect a previously saved rollout instead of starting a session
-q, --quiet Non-interactive mode that only prints the assistant's final output
-c, --config Open the instructions file in your editor
-w, --writable-root <path> Writable folder for sandbox in full-auto mode (can be specified multiple times)
-a, --approval-mode <mode> Override the approval policy: 'suggest', 'auto-edit', or 'full-auto'
--auto-edit Automatically approve file edits; still prompt for commands
--full-auto Automatically approve edits and commands when executed in the sandbox
@@ -122,6 +123,13 @@ const cli = meow(
description:
"Determine the approval mode for Codex (default: suggest) Values: suggest, auto-edit, full-auto",
},
writableRoot: {
type: "string",
isMultiple: true,
aliases: ["w"],
description:
"Writable folder for sandbox in full-auto mode (can be specified multiple times)",
},
noProjectDoc: {
type: "boolean",
description: "Disable automatic inclusion of projectlevel codex.md",
@@ -276,6 +284,11 @@ if (fullContextMode) {
process.exit(0);
}
// Ensure that all values in additionalWritableRoots are absolute paths.
const additionalWritableRoots: ReadonlyArray<string> = (
cli.flags.writableRoot ?? []
).map((p) => path.resolve(p));
// If we are running in --quiet mode, do that and exit.
const quietMode = Boolean(cli.flags.quiet);
const autoApproveEverything = Boolean(
@@ -298,6 +311,7 @@ if (quietMode) {
approvalPolicy: autoApproveEverything
? AutoApprovalMode.FULL_AUTO
: AutoApprovalMode.SUGGEST,
additionalWritableRoots,
config,
});
onExit();
@@ -332,6 +346,7 @@ const instance = render(
rollout={rollout}
imagePaths={imagePaths}
approvalPolicy={approvalPolicy}
additionalWritableRoots={additionalWritableRoots}
fullStdout={fullStdout}
/>,
{
@@ -393,11 +408,13 @@ async function runQuietMode({
prompt,
imagePaths,
approvalPolicy,
additionalWritableRoots,
config,
}: {
prompt: string;
imagePaths: Array<string>;
approvalPolicy: ApprovalPolicy;
additionalWritableRoots: ReadonlyArray<string>;
config: AppConfig;
}): Promise<void> {
const agent = new AgentLoop({
@@ -405,6 +422,7 @@ async function runQuietMode({
config: config,
instructions: config.instructions,
approvalPolicy,
additionalWritableRoots,
onItem: (item: ResponseItem) => {
// eslint-disable-next-line no-console
console.log(formatResponseItemForQuietMode(item));

View File

@@ -37,6 +37,7 @@ type Props = {
prompt?: string;
imagePaths?: Array<string>;
approvalPolicy: ApprovalPolicy;
additionalWritableRoots: ReadonlyArray<string>;
fullStdout: boolean;
};
@@ -122,6 +123,7 @@ export default function TerminalChat({
prompt: _initialPrompt,
imagePaths: _initialImagePaths,
approvalPolicy: initialApprovalPolicy,
additionalWritableRoots,
fullStdout,
}: Props): React.ReactElement {
const [model, setModel] = useState<string>(config.model);
@@ -183,6 +185,7 @@ export default function TerminalChat({
config,
instructions: config.instructions,
approvalPolicy,
additionalWritableRoots,
onLastResponseId: setLastResponseId,
onItem: (item) => {
log(`onItem: ${JSON.stringify(item)}`);
@@ -248,7 +251,13 @@ export default function TerminalChat({
agentRef.current = undefined;
forceUpdate(); // rerender after teardown too
};
}, [model, config, approvalPolicy, requestConfirmation]);
}, [
model,
config,
approvalPolicy,
requestConfirmation,
additionalWritableRoots,
]);
// whenever loading starts/stops, reset or start a timer — but pause the
// timer while a confirmation overlay is displayed so we don't trigger a

View File

@@ -45,6 +45,9 @@ type AgentLoopParams = {
onItem: (item: ResponseItem) => void;
onLoading: (loading: boolean) => void;
/** Extra writable roots to use with sandbox execution. */
additionalWritableRoots: ReadonlyArray<string>;
/** Called when the command is not auto-approved to request explicit user review. */
getCommandConfirmation: (
command: Array<string>,
@@ -58,6 +61,7 @@ export class AgentLoop {
private instructions?: string;
private approvalPolicy: ApprovalPolicy;
private config: AppConfig;
private additionalWritableRoots: ReadonlyArray<string>;
// Using `InstanceType<typeof OpenAI>` sidesteps typing issues with the OpenAI package under
// the TS 5+ `moduleResolution=bundler` setup. OpenAI client instance. We keep the concrete
@@ -213,6 +217,7 @@ export class AgentLoop {
onLoading,
getCommandConfirmation,
onLastResponseId,
additionalWritableRoots,
}: AgentLoopParams & { config?: AppConfig }) {
this.model = model;
this.instructions = instructions;
@@ -229,6 +234,7 @@ export class AgentLoop {
model,
instructions: instructions ?? "",
} as AppConfig);
this.additionalWritableRoots = additionalWritableRoots;
this.onItem = onItem;
this.onLoading = onLoading;
this.getCommandConfirmation = getCommandConfirmation;
@@ -358,6 +364,7 @@ export class AgentLoop {
args,
this.config,
this.approvalPolicy,
this.additionalWritableRoots,
this.getCommandConfirmation,
this.execAbortController?.signal,
);

View File

@@ -16,7 +16,12 @@ const DEFAULT_TIMEOUT_MS = 10_000; // 10 seconds
* mapped to a non-zero exit code and the error message should be in stderr.
*/
export function exec(
{ cmd, workdir, timeoutInMillis }: ExecInput,
{
cmd,
workdir,
timeoutInMillis,
additionalWritableRoots,
}: ExecInput & { additionalWritableRoots: ReadonlyArray<string> },
sandbox: SandboxType,
abortSignal?: AbortSignal,
): Promise<ExecResult> {
@@ -30,7 +35,12 @@ export function exec(
timeout: timeoutInMillis || DEFAULT_TIMEOUT_MS,
...(workdir ? { cwd: workdir } : {}),
};
const writableRoots = [process.cwd(), os.tmpdir()];
// Merge default writable roots with any user-specified ones.
const writableRoots = [
process.cwd(),
os.tmpdir(),
...additionalWritableRoots,
];
return execForSandbox(cmd, opts, writableRoots, abortSignal);
}

View File

@@ -74,6 +74,7 @@ export async function handleExecCommand(
args: ExecInput,
config: AppConfig,
policy: ApprovalPolicy,
additionalWritableRoots: ReadonlyArray<string>,
getCommandConfirmation: (
command: Array<string>,
applyPatch: ApplyPatchCommand | undefined,
@@ -91,6 +92,7 @@ export async function handleExecCommand(
args,
/* applyPatch */ undefined,
/* runInSandbox */ false,
additionalWritableRoots,
abortSignal,
).then(convertSummaryToResult);
}
@@ -138,6 +140,7 @@ export async function handleExecCommand(
args,
applyPatch,
runInSandbox,
additionalWritableRoots,
abortSignal,
);
// If the operation was aborted in the meantime, propagate the cancellation
@@ -170,7 +173,13 @@ export async function handleExecCommand(
} else {
// The user has approved the command, so we will run it outside of the
// sandbox.
const summary = await execCommand(args, applyPatch, false, abortSignal);
const summary = await execCommand(
args,
applyPatch,
false,
additionalWritableRoots,
abortSignal,
);
return convertSummaryToResult(summary);
}
} else {
@@ -202,6 +211,7 @@ async function execCommand(
execInput: ExecInput,
applyPatchCommand: ApplyPatchCommand | undefined,
runInSandbox: boolean,
additionalWritableRoots: ReadonlyArray<string>,
abortSignal?: AbortSignal,
): Promise<ExecCommandSummary> {
let { workdir } = execInput;
@@ -239,7 +249,11 @@ async function execCommand(
const execResult =
applyPatchCommand != null
? execApplyPatch(applyPatchCommand.patch)
: await exec(execInput, await getSandbox(runInSandbox), abortSignal);
: await exec(
{ ...execInput, additionalWritableRoots },
await getSandbox(runInSandbox),
abortSignal,
);
const duration = Date.now() - start;
const { stdout, stderr, exitCode } = execResult;

View File

@@ -88,6 +88,7 @@ describe("cancel before first function_call", () => {
const { _test } = (await import("openai")) as any;
const agent = new AgentLoop({
additionalWritableRoots: [],
model: "any",
instructions: "",
approvalPolicy: { mode: "auto" } as any,

View File

@@ -99,6 +99,7 @@ describe("cancel clears previous_response_id", () => {
model: "any",
instructions: "",
approvalPolicy: { mode: "auto" } as any,
additionalWritableRoots: [],
onItem: () => {},
onLoading: () => {},
getCommandConfirmation: async () => ({ review: "yes" } as any),

View File

@@ -92,6 +92,7 @@ describe("Agent cancellation race", () => {
const items: Array<any> = [];
const agent = new AgentLoop({
additionalWritableRoots: [],
model: "any",
instructions: "",
config: { model: "any", instructions: "" },

View File

@@ -91,6 +91,7 @@ describe("Agent cancellation", () => {
instructions: "",
config: { model: "any", instructions: "" },
approvalPolicy: { mode: "auto" } as any,
additionalWritableRoots: [],
onItem: (item) => {
received.push(item);
},
@@ -136,6 +137,7 @@ describe("Agent cancellation", () => {
const received: Array<any> = [];
const agent = new AgentLoop({
additionalWritableRoots: [],
model: "any",
instructions: "",
config: { model: "any", instructions: "" },

View File

@@ -118,6 +118,7 @@ describe("function_call_output includes original call ID", () => {
model: "any",
instructions: "",
approvalPolicy: { mode: "auto" } as any,
additionalWritableRoots: [],
onItem: () => {},
onLoading: () => {},
getCommandConfirmation: async () => ({ review: "yes" } as any),

View File

@@ -56,6 +56,7 @@ describe("AgentLoop generic network/server errors", () => {
const received: Array<any> = [];
const agent = new AgentLoop({
additionalWritableRoots: [],
model: "any",
instructions: "",
approvalPolicy: { mode: "auto" } as any,
@@ -99,6 +100,7 @@ describe("AgentLoop generic network/server errors", () => {
const received: Array<any> = [];
const agent = new AgentLoop({
additionalWritableRoots: [],
model: "any",
instructions: "",
approvalPolicy: { mode: "auto" } as any,

View File

@@ -34,6 +34,7 @@ describe("Agent interrupt and continue", () => {
// Create the agent
const agent = new AgentLoop({
additionalWritableRoots: [],
model: "test-model",
instructions: "",
approvalPolicy: { mode: "auto" } as any,

View File

@@ -58,6 +58,7 @@ describe("AgentLoop invalid request / 4xx errors", () => {
model: "any",
instructions: "",
approvalPolicy: { mode: "auto" } as any,
additionalWritableRoots: [],
onItem: (i) => received.push(i),
onLoading: () => {},
getCommandConfirmation: async () => ({ review: "yes" } as any),

View File

@@ -58,6 +58,7 @@ describe("AgentLoop max_tokens too large error", () => {
const received: Array<any> = [];
const agent = new AgentLoop({
additionalWritableRoots: [],
model: "any",
instructions: "",
approvalPolicy: { mode: "auto" } as any,

View File

@@ -109,6 +109,7 @@ describe("AgentLoop network resilience", () => {
model: "any",
instructions: "",
approvalPolicy: { mode: "auto" } as any,
additionalWritableRoots: [],
onItem: (i) => received.push(i),
onLoading: () => {},
getCommandConfirmation: async () => ({ review: "yes" } as any),
@@ -150,6 +151,7 @@ describe("AgentLoop network resilience", () => {
model: "any",
instructions: "",
approvalPolicy: { mode: "auto" } as any,
additionalWritableRoots: [],
onItem: (i) => received.push(i),
onLoading: () => {},
getCommandConfirmation: async () => ({ review: "yes" } as any),

View File

@@ -112,6 +112,7 @@ describe("AgentLoop", () => {
expect(config.instructions).toContain("Hello docs!");
const agent = new AgentLoop({
additionalWritableRoots: [],
model: "o3", // arbitrary
instructions: config.instructions,
config,

View File

@@ -79,6 +79,7 @@ describe("AgentLoop ratelimit handling", () => {
model: "any",
instructions: "",
approvalPolicy: { mode: "auto" } as any,
additionalWritableRoots: [],
onItem: (i) => received.push(i),
onLoading: () => {},
getCommandConfirmation: async () => ({ review: "yes" } as any),

View File

@@ -97,6 +97,7 @@ describe("AgentLoop automatic retry on 5xx errors", () => {
model: "any",
instructions: "",
approvalPolicy: { mode: "auto" } as any,
additionalWritableRoots: [],
onItem: (i) => received.push(i),
onLoading: () => {},
getCommandConfirmation: async () => ({ review: "yes" } as any),
@@ -134,6 +135,7 @@ describe("AgentLoop automatic retry on 5xx errors", () => {
model: "any",
instructions: "",
approvalPolicy: { mode: "auto" } as any,
additionalWritableRoots: [],
onItem: (i) => received.push(i),
onLoading: () => {},
getCommandConfirmation: async () => ({ review: "yes" } as any),

View File

@@ -82,7 +82,14 @@ describe("Agent terminate (hard cancel)", () => {
it("suppresses function_call_output and stops processing once terminate() is invoked", async () => {
// Simulate a longrunning exec that would normally resolve with output.
vi.spyOn(handleExec, "handleExecCommand").mockImplementation(
async (_args, _config, _policy, _getConf, abortSignal) => {
async (
_args,
_config,
_policy,
_additionalWritableRoots,
_getConf,
abortSignal,
) => {
// Wait until the abort signal is fired or 2s (whichever comes first).
await new Promise<void>((resolve) => {
if (abortSignal?.aborted) {
@@ -106,6 +113,7 @@ describe("Agent terminate (hard cancel)", () => {
instructions: "",
config: { model: "any", instructions: "" },
approvalPolicy: { mode: "auto" } as any,
additionalWritableRoots: [],
onItem: (item) => received.push(item),
onLoading: () => {},
getCommandConfirmation: async () => ({ review: "yes" } as any),
@@ -141,6 +149,7 @@ describe("Agent terminate (hard cancel)", () => {
instructions: "",
config: { model: "any", instructions: "" },
approvalPolicy: { mode: "auto" } as any,
additionalWritableRoots: [],
onItem: () => {},
onLoading: () => {},
getCommandConfirmation: async () => ({ review: "yes" } as any),

View File

@@ -107,6 +107,7 @@ describe("thinking time counter", () => {
model: "any",
instructions: "",
approvalPolicy: { mode: "auto" } as any,
additionalWritableRoots: [],
onItem: (i) => items.push(i),
onLoading: () => {},
getCommandConfirmation: async () => ({ review: "yes" } as any),

View File

@@ -53,10 +53,12 @@ describe("handleExecCommand invalid executable", () => {
const policy = { mode: "auto" } as any;
const getConfirmation = async () => ({ review: "yes" } as any);
const additionalWritableRoots: Array<string> = [];
const { outputText, metadata } = await handleExecCommand(
execInput,
config,
policy,
additionalWritableRoots,
getConfirmation,
);