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
57 changes: 53 additions & 4 deletions electron/codexApprovals.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
/**
* Codex app-server ServerRequest mapping (issue #1171).
*
* Command approval and input requests reuse the pending-permission queue. Everything else
* is fail-closed until a dedicated UI exists. Do not send `updatedCommand`
* Command, file-change, sandbox-permission and input requests reuse the
* pending-permission queue. Everything else is fail-closed until a
* dedicated UI exists (#1190). Do not send `updatedCommand`
* into this RPC: Accept runs the proposed string (#509 hole).
*/

Expand All @@ -23,7 +24,6 @@ const JSONRPC_METHOD_NOT_FOUND = -32601;

/** Methods we recognize but do not map onto PermissionPrompt. */
const UNSUPPORTED_METHODS = new Set([
METHOD_FILE_CHANGE,
METHOD_PERMISSIONS,
METHOD_ELICITATION,
METHOD_USER_INPUT,
Expand Down Expand Up @@ -55,7 +55,7 @@ function decisionNames(available) {
* @param {string} method
* @param {unknown} params
* @returns {{
* action: "command" | "mcp" | "input" | "unsupported" | "unknown",
* action: "command" | "approval" | "mcp" | "input" | "unsupported" | "unknown",
* method: string,
* kind?: string | null,
* reason: string,
Expand Down Expand Up @@ -92,6 +92,10 @@ function classifyServerRequest(method, params) {
}
return { action: "command", method: name, kind, reason: name };
}
if (name === METHOD_FILE_CHANGE || (name === METHOD_PERMISSIONS &&
params?.permissions && typeof params.permissions === "object")) {
return { action: "approval", method: name, reason: name };
}
if ((name === METHOD_ELICITATION && typeof params?.message === "string") ||
(name === METHOD_USER_INPUT && Array.isArray(params?.questions) && params.questions.length)) {
return { action: "input", method: name, reason: name };
Expand Down Expand Up @@ -174,7 +178,51 @@ function pendingFromMcp(rpcId, params) {
};
}

/**
* File-change and sandbox-permission asks. A file change is only shown with
* the diff from its already-streamed item (`changesJson`); without it the
* caller fails closed rather than asking for a blind approval. Permissions
* carry the requested profile so Accept grants exactly that, nothing wider.
*
* @param {unknown} rpcId
* @param {string} method
* @param {Record<string, any>} params
* @param {string} [changesJson]
*/
function pendingFromApproval(rpcId, method, params, changesJson) {
const fileChange = method === METHOD_FILE_CHANGE;
if (fileChange && !changesJson) throw new Error("File change item not found");
const rawInput = Object.fromEntries(Object.entries(fileChange
? { reason: params.reason, grantRoot: params.grantRoot }
: { cwd: params.cwd, reason: params.reason, permissions: params.permissions })
.filter(([, v]) => v != null));
const toolName = fileChange ? "file change" : "sandbox permissions";
const reason = typeof params.reason === "string" && params.reason ? params.reason : "";
return {
id: stringifyRpcId(rpcId), rpcId, method, toolName,
summary: reason ? `${toolName}: ${reason}` : toolName,
input: fileChange
? `${Object.keys(rawInput).length ? `${JSON.stringify(rawInput, null, 2)}\n` : ""}${changesJson}`
: JSON.stringify(rawInput, null, 2),
rawInput, command: null, commandEditable: false, acceptAlways: true,
availableDecisions: [DECISION_ACCEPT, DECISION_SESSION, DECISION_DECLINE],
grant: fileChange ? undefined : params.permissions,
};
}

function approvalResponse(method, decision, content = {}) {
if (method === METHOD_PERMISSIONS) {
// An empty profile grants nothing: decline and cancel both land here.
const ok = decision === DECISION_ACCEPT || decision === DECISION_SESSION;
const req = ok && content ? content : {};
return {
permissions: {
...(req.network ? { network: req.network } : {}),
...(req.fileSystem ? { fileSystem: req.fileSystem } : {}),
},
scope: decision === DECISION_SESSION ? "session" : "turn",
};
}
if (method === METHOD_ELICITATION) {
const action = decision === DECISION_SESSION ? DECISION_ACCEPT : decision;
return { action, content: action === DECISION_ACCEPT ? content : null, _meta: null };
Expand Down Expand Up @@ -237,6 +285,7 @@ module.exports = {
stringifyRpcId,
pendingFromCommand,
pendingFromMcp,
pendingFromApproval,
approvalResponse,
mapSolentaDecision,
unsupportedError,
Expand Down
11 changes: 9 additions & 2 deletions electron/runner-permissions.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ const {
classifyServerRequest,
pendingFromCommand,
pendingFromMcp,
pendingFromApproval,
METHOD_FILE_CHANGE,
approvalResponse,
mapSolentaDecision,
unsupportedError,
Expand Down Expand Up @@ -164,7 +166,7 @@ function createPermissions(ctx) {
if (id === undefined || id === null) return false;

const classified = classifyServerRequest(method, msg && msg.params);
if (!["command", "mcp", "input"].includes(classified.action)) {
if (!["command", "approval", "mcp", "input"].includes(classified.action)) {
try {
replyCodexJsonRpcError(
e,
Expand All @@ -179,7 +181,12 @@ function createPermissions(ctx) {

let pending;
try {
// The fileChange ask carries no diff; its item streamed in just before.
const changes = method === METHOD_FILE_CHANGE
? store.getMessages(threadId).find((m) => m.tool && m.tool.id === msg.params?.itemId)?.tool.input
Comment on lines +185 to +186

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve the full file-change diff for approval

For any file-change payload whose serialized changes exceeds INPUT_TRUNCATE (2,000 characters), this reads tool.input, which was already truncated by ingestCodexTool in runner-provider-codex.js, and presents only that prefix in the approval card; accepting still authorizes the entire server-side patch, including every unseen hunk. Keep an untruncated ephemeral copy of the streamed changes for approval correlation rather than sourcing the security decision from the display-truncated message.

Useful? React with 👍 / 👎.

: undefined;
pending = classified.action === "input" ? pendingFromInput(id, method, msg.params)
: classified.action === "approval" ? pendingFromApproval(id, method, msg.params, changes)
: classified.action === "mcp" ? pendingFromMcp(id, msg.params) : pendingFromCommand(id, msg.params);
} catch {
replyCodexJsonRpcError(e, id, unsupportedError(method, `${method}: unsupported input schema or URL`));
Expand Down Expand Up @@ -256,7 +263,7 @@ function createPermissions(ctx) {
}
const pending = e.pendingPermissions[idx];
const mapped = mapSolentaDecision(decision, pending.availableDecisions);
const content = pending.inputRequest && mapped === "accept" ? pending.validateInput(input.inputValues) : undefined;
const content = pending.inputRequest && mapped === "accept" ? pending.validateInput(input.inputValues) : pending.grant;
replyCodexJsonRpc(
e,
pending.rpcId !== undefined ? pending.rpcId : pending.id,
Expand Down
2 changes: 1 addition & 1 deletion electron/test/codex-approvals-runner.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -426,7 +426,7 @@ describe("Codex ServerRequest reply path (#1171)", () => {
);
});

it("JSON-RPC-errors fileChange and writeStdin", async () => {
it("JSON-RPC-errors writeStdin and a fileChange with no streamed diff", async () => {
const thread = await startAsk();
assert.equal(
runner.handleCodexServerRequest(thread.id, {
Expand Down
30 changes: 29 additions & 1 deletion electron/test/codex-approvals.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ const {
JSONRPC_METHOD_NOT_FOUND,
classifyServerRequest,
pendingFromCommand,
pendingFromApproval,
approvalResponse,
mapSolentaDecision,
unsupportedError,
} = require("../codexApprovals.js");
Expand All @@ -37,9 +39,35 @@ describe("codexApprovals classifyServerRequest", () => {
assert.match(got.reason, /writeStdin/);
});

it("maps file-change and sandbox-permission asks onto the approval card (#1190)", () => {
assert.equal(classifyServerRequest(METHOD_FILE_CHANGE, {}).action, "approval");
assert.equal(classifyServerRequest(METHOD_PERMISSIONS, { permissions: {} }).action, "approval");
});

it("grants exactly the requested sandbox profile, or nothing", () => {
const permissions = { network: { enabled: true }, fileSystem: null };
const pending = pendingFromApproval("p", METHOD_PERMISSIONS, { cwd: "/w", reason: "fetch deps", permissions });
assert.equal(pending.summary, "sandbox permissions: fetch deps");
assert.deepEqual(pending.availableDecisions, [DECISION_ACCEPT, DECISION_SESSION, DECISION_DECLINE]);
assert.deepEqual(approvalResponse(METHOD_PERMISSIONS, DECISION_ACCEPT, pending.grant),
{ permissions: { network: { enabled: true } }, scope: "turn" });
assert.deepEqual(approvalResponse(METHOD_PERMISSIONS, DECISION_SESSION, pending.grant),
{ permissions: { network: { enabled: true } }, scope: "session" });
for (const decision of [DECISION_DECLINE, DECISION_CANCEL]) {
assert.deepEqual(approvalResponse(METHOD_PERMISSIONS, decision, pending.grant),
{ permissions: {}, scope: "turn" });
}
});

it("never shows a file-change ask without its diff", () => {
assert.throws(() => pendingFromApproval("f", METHOD_FILE_CHANGE, { reason: "x" }));
const pending = pendingFromApproval("f", METHOD_FILE_CHANGE, { grantRoot: "/etc" }, '[{"path":"/etc/hosts"}]');
assert.match(pending.input, /"grantRoot": "\/etc"[\s\S]*\/etc\/hosts/);
assert.deepEqual(approvalResponse(METHOD_FILE_CHANGE, DECISION_SESSION), { decision: DECISION_SESSION });
});

it("fail-closes known non-command ServerRequests", () => {
for (const method of [
METHOD_FILE_CHANGE,
METHOD_PERMISSIONS,
METHOD_ELICITATION,
METHOD_USER_INPUT,
Expand Down
29 changes: 29 additions & 0 deletions electron/test/codex-appserver-approvals.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -225,4 +225,33 @@ require(${JSON.stringify(helper)}).main();
assert.deepEqual(readRpc(rpcFile).find((m) => m.id === "ask-1").result, result);
assert.equal(runner.getPendingPermission(thread.id), null);
});

// #1190: sandbox overlay and file-change asks ride the same card.
for (const [scenario, decision, preview, result] of [
["ask-permissions", "allowAlways", /"enabled": true/,
{ permissions: { network: { enabled: true } }, scope: "session" }],
["ask-permissions", "deny", /fetch deps/, { permissions: {}, scope: "turn" }],
["ask-file-change", "allow", /\/etc\/hosts/, { decision: DECISION_ACCEPT }],
]) it(`round-trips ${scenario} ${decision} through the real JSON-RPC transport`, async () => {
process.env.CODER_FAKE_CODEX_SCENARIO = scenario;
const thread = store.getThreads()[0];
await runner.startRun({ threadId: thread.id, prompt: "do work" });
await waitFor(() => runner.getPendingPermission(thread.id));
const pending = runner.getPendingPermission(thread.id);
assert.match(pending.input, preview);
runner.respondPermission({ threadId: thread.id, requestId: pending.requestId, decision });
await waitFor(() => readRpc(rpcFile).some((m) => m.id === "ask-1" && m.result));
assert.deepEqual(readRpc(rpcFile).find((m) => m.id === "ask-1").result, result);
assert.equal(runner.getPendingPermission(thread.id), null);
});

it("stop grants nothing to an outstanding sandbox-permission ask", async () => {
process.env.CODER_FAKE_CODEX_SCENARIO = "ask-permissions";
const thread = store.getThreads()[0];
await runner.startRun({ threadId: thread.id, prompt: "do work" });
await waitFor(() => runner.getPendingPermission(thread.id));
runner.stopRun({ threadId: thread.id });
await waitFor(() => readRpc(rpcFile).some((m) => m.id === "ask-1" && m.result));
assert.deepEqual(readRpc(rpcFile).find((m) => m.id === "ask-1").result, { permissions: {}, scope: "turn" });
});
});
2 changes: 1 addition & 1 deletion electron/test/codex-jsonrpc.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,7 @@ describe("createCodexJsonRpcSession (#1171)", () => {
assert.equal(session.respondJsonRpc("input", { answers: {} }), false);
});

it("JSON-RPC-errors fileChange until a diff card exists", async () => {
it("JSON-RPC-errors a ServerRequest nobody handles", async () => {
const dir = tmp();
const replyFile = path.join(dir, "replies.jsonl");
const bin = writeFakeAppServer(dir);
Expand Down
13 changes: 13 additions & 0 deletions electron/test/support/fakeCodexCli.js
Original file line number Diff line number Diff line change
Expand Up @@ -522,6 +522,19 @@ async function runAppServer() {
});
return;
}
if (scenario === "ask-permissions") {
send({ jsonrpc: "2.0", id: "ask-1", method: "item/permissions/requestApproval",
params: { threadId, turnId, itemId: "perm", environmentId: null, startedAtMs: 1, cwd: process.cwd(),
reason: "fetch deps", permissions: { network: { enabled: true }, fileSystem: null } } });
return;
}
if (scenario === "ask-file-change") {
notify("item/started", { threadId, turnId, item: { type: "fileChange", id: "fc-1", status: "inProgress",
changes: [{ path: "/etc/hosts", kind: { type: "update" }, diff: "+127.0.0.1 x" }] } });
send({ jsonrpc: "2.0", id: "ask-1", method: "item/fileChange/requestApproval",
params: { threadId, turnId, itemId: "fc-1", startedAtMs: 1, grantRoot: "/etc" } });
return;
}
if (scenario === "ask-command") {
send({
jsonrpc: "2.0",
Expand Down
Loading