Skip to content
Open
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ The adapter advertises ACP auth methods during initialization. Clients can authe
- `CODEX_CONFIG` - JSON object merged into the Codex session config.
- `MODEL_PROVIDER` - model provider to pass to Codex for new sessions.
- `DEFAULT_AUTH_REQUEST` - ACP auth request JSON used when Codex requires authentication.
- `INITIAL_AGENT_MODE` - initial mode id: `read-only`, `workspace-write`, `agent`, or `agent-full-access`.
- `INITIAL_AGENT_MODE` - initial mode id: `read-only`, `workspace-write`, `agent`, `agent-full-access`, or `ask-always`.
- `NO_BROWSER` - hide browser-based ChatGPT auth when set.
- `APP_SERVER_LOGS` - directory for adapter logs.

Expand Down
2 changes: 1 addition & 1 deletion readme-dev.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ Set `CODEX_PATH` to run a different Codex binary; versions other than the one sp
- `CODEX_CONFIG` - JSON object merged into the Codex session config.
- `MODEL_PROVIDER` - model provider to pass to Codex for new sessions.
- `DEFAULT_AUTH_REQUEST` - ACP auth request JSON used when Codex requires authentication.
- `INITIAL_AGENT_MODE` - initial mode id: `read-only`, `workspace-write`, `agent`, or `agent-full-access`.
- `INITIAL_AGENT_MODE` - initial mode id: `read-only`, `workspace-write`, `agent`, `agent-full-access`, or `ask-always`.
- `NO_BROWSER` - hide browser-based ChatGPT auth when set.
- `APP_SERVER_LOGS` - directory for adapter logs.

Expand Down
24 changes: 23 additions & 1 deletion src/AgentMode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,22 @@ export class AgentMode {
},
"read-only",
);
static readonly AskAlways = new AgentMode(
"ask-always",
"Ask every time",
"Ask before every command and file edit, including reads. Approved commands run in the workspace sandbox without network access.",
"standard",
"untrusted",
"user",
{
type: "workspaceWrite",
writableRoots: [],
networkAccess: false,
excludeTmpdirEnvVar: false,
excludeSlashTmp: false,
},
"workspace-write",
);
static readonly WorkspaceWrite = new AgentMode(
"workspace-write",
"Workspace access",
Expand Down Expand Up @@ -127,7 +143,13 @@ export class AgentMode {
}

static all(): AgentMode[] {
return [AgentMode.ReadOnly, AgentMode.WorkspaceWrite, AgentMode.Agent, AgentMode.AgentFullAccess];
return [
AgentMode.ReadOnly,
AgentMode.WorkspaceWrite,
AgentMode.Agent,
AgentMode.AgentFullAccess,
AgentMode.AskAlways,
];
}

static find(modeId: string): AgentMode | null {
Expand Down
15 changes: 15 additions & 0 deletions src/__tests__/CodexACPAgent/data/ask-always-mode-policy.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
[
{
"approvalPolicy": "untrusted",
"approvalsReviewer": "user",
"sandboxPolicy": {
"type": "workspaceWrite",
"writableRoots": [
"/test/extra"
],
"networkAccess": false,
"excludeTmpdirEnvVar": false,
"excludeSlashTmp": false
}
}
]
24 changes: 24 additions & 0 deletions src/__tests__/CodexACPAgent/e2e/acp-e2e-file-approval.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,30 @@ describeE2E("E2E workspace access mode file permission tests", () => {
});
});

describeE2E("E2E ask-always mode file permission tests", () => {
let fixture: SpawnedAgentFixture;

beforeEach(async () => {
fixture = await createAuthenticatedFixture(AgentMode.AskAlways);
});

afterEach(async () => {
await fixture.dispose();
});

it("requests permission before applying a workspace file edit", async () => {
fixture.setPermissionResponder(createPermissionResponder("edit", ApprovalOptionId.AllowOnce));
const sessionId = await expectFileEditApplied(fixture, newFilePathIn(fixture.workspaceDir));
expect(fixture.readPermissionRequests(sessionId, "edit").length).toBeGreaterThanOrEqual(1);
expect(fixture.readPermissionRequests(sessionId, "execute")).toHaveLength(0);
});

it("does not apply a workspace file edit when permission is cancelled", async () => {
fixture.setPermissionResponder(() => createPermissionResponse(null));
await expectFileEditBlocked(fixture, newFilePathIn(fixture.workspaceDir));
});
});

describeE2E("E2E switching to read-only mode file permission tests", () => {
let fixture: SpawnedAgentFixture;

Expand Down
55 changes: 55 additions & 0 deletions src/__tests__/CodexACPAgent/e2e/acp-e2e-shell-approval.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,61 @@ describeE2E("E2E read-only mode shell permission tests", () => {
});
});

describeE2E("E2E ask-always mode shell permission tests", () => {
let fixture: SpawnedAgentFixture;

beforeEach(async () => {
fixture = await createAuthenticatedFixture(AgentMode.AskAlways);
});

afterEach(async () => {
await fixture.dispose();
});

it("requests permission for a command that only reads the workspace", async () => {
const fileName = generateFileNameForTest();
fs.writeFileSync(path.join(fixture.workspaceDir, fileName), "ask-always e2e");
fixture.setPermissionResponder(createPermissionResponder("execute", ApprovalOptionId.AllowOnce));
const sessionId = (await fixture.createSession()).sessionId;
const response = await fixture.connection.prompt({
sessionId,
prompt: [{
type: "text",
text: `Use your shell tool to run exactly \`cat '${fileName}'\` and nothing else.`,
}],
});

expectEndTurn(response);
expect(fixture.readPermissionRequests(sessionId, "execute").length).toBeGreaterThanOrEqual(1);
expect(fixture.readPermissionRequests(sessionId, "edit")).toHaveLength(0);
});

it("requests permission for a command that writes inside the workspace", async () => {
fixture.setPermissionResponder(createPermissionResponder("execute", ApprovalOptionId.AllowOnce));
const sessionId = await writeToFile(fixture, path.join(fixture.workspaceDir, generateFileNameForTest()));

expect(fixture.readPermissionRequests(sessionId, "execute").length).toBeGreaterThanOrEqual(1);
expect(fixture.readPermissionRequests(sessionId, "edit")).toHaveLength(0);
});

it("does not write inside the workspace when shell permission is cancelled", async () => {
fixture.setPermissionResponder(() => createPermissionResponse(null));
const filePath = path.join(fixture.workspaceDir, generateFileNameForTest());
const sessionId = (await fixture.createSession()).sessionId;
const response = await fixture.connection.prompt({
sessionId,
prompt: [{
type: "text",
text: `Use your shell tool to run exactly \`printf 'blocked' > '${filePath}'\`. Do not modify files any other way.`,
}],
});

expect(fs.existsSync(filePath),
`stopReason=${response.stopReason}; agent said: ${fixture.readText(sessionId)}`,
).toBe(false);
});
});

describeE2E("E2E workspace access mode shell permission tests", () => {
let fixture: SpawnedAgentFixture;

Expand Down
16 changes: 14 additions & 2 deletions src/__tests__/CodexACPAgent/session-config-options.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,13 +132,18 @@ describe("Session config options", () => {
name: "Full access",
description: "Unrestricted access to the internet and any file on your computer",
},
{
value: "ask-always",
name: "Ask every time",
description: "Ask before every command and file edit, including reads. Approved commands run in the workspace sandbox without network access.",
},
],
});
expect((modeOption as any).options.map((o: any) => o.value)).toEqual(
AgentMode.all().map(m => m.id)
);
expect(response.modes?.availableModes.map(mode => mode.id)).toEqual([
"read-only", "workspace-write", "agent", "agent-full-access",
"read-only", "workspace-write", "agent", "agent-full-access", "ask-always",
]);
});

Expand Down Expand Up @@ -263,6 +268,13 @@ describe("Session config options", () => {
{selection: "INITIAL_AGENT_MODE", initialMode: "workspace-write", modeId: "workspace-write"},
{selection: "session/set_mode", initialMode: "read-only", modeId: "workspace-write"},
{selection: "session/set_config_option", initialMode: "read-only", modeId: "workspace-write"},
{selection: "INITIAL_AGENT_MODE", initialMode: "ask-always", modeId: "ask-always"},
{selection: "session/set_mode", initialMode: "agent", modeId: "ask-always"},
{selection: "session/set_mode", initialMode: "workspace-write", modeId: "ask-always"},
{selection: "session/set_config_option", initialMode: "agent-full-access", modeId: "ask-always"},
{selection: "session/set_config_option", initialMode: "read-only", modeId: "ask-always"},
{selection: "session/set_mode", initialMode: "ask-always", modeId: "read-only"},
{selection: "session/set_config_option", initialMode: "ask-always", modeId: "workspace-write"},
])("applies $modeId permissions after $selection from $initialMode", async ({selection, initialMode, modeId}) => {
vi.stubEnv("INITIAL_AGENT_MODE", initialMode);
const {fast} = buildModels();
Expand Down Expand Up @@ -301,7 +313,7 @@ describe("Session config options", () => {
});

// Assert the actual outgoing policy, independently of the preset object.
// Additional session roots are writable only in the workspace-write preset.
// Additional session roots are writable only in the workspace-write sandbox presets.
const policies = turnStart.mock.calls.map(([{approvalPolicy, approvalsReviewer, sandboxPolicy}]) => ({
approvalPolicy, approvalsReviewer, sandboxPolicy,
}));
Expand Down