mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 17:30:31 +00:00
436 lines
15 KiB
TypeScript
436 lines
15 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import type { ServerRequest } from "../../../../src/app-server/connection/rpc-messages";
|
|
import {
|
|
appServerApprovalResponse as approvalResponse,
|
|
appServerApprovalRequest as toPendingApproval,
|
|
} from "../../../../src/app-server/protocol/server-requests";
|
|
import { defaultPendingApprovalOptions, type PendingApproval } from "../../../../src/domain/pending-requests/model";
|
|
|
|
function expectPresent<T>(value: T | null | undefined): T {
|
|
if (value === null || value === undefined) throw new Error("Expected value to be present");
|
|
return value;
|
|
}
|
|
|
|
function approvalActionOptions(approval: PendingApproval) {
|
|
return approval.actionOptions && approval.actionOptions.length > 0 ? approval.actionOptions : defaultPendingApprovalOptions();
|
|
}
|
|
|
|
function approvalActionLabels(approval: NonNullable<ReturnType<typeof toPendingApproval>>): (string | null)[] {
|
|
return approvalActionOptions(approval).map((option) => option.label);
|
|
}
|
|
|
|
function approvalResponseAt(approval: NonNullable<ReturnType<typeof toPendingApproval>>, index: number): unknown {
|
|
const option = expectPresent(approvalActionOptions(approval)[index]);
|
|
return approvalResponse(approval, option.action);
|
|
}
|
|
|
|
describe("approval model", () => {
|
|
it("classifies command approvals and builds v2 decisions", () => {
|
|
const request: ServerRequest = {
|
|
id: 1,
|
|
method: "item/commandExecution/requestApproval",
|
|
params: {
|
|
command: "npm run build",
|
|
cwd: "/tmp/project",
|
|
threadId: "thread",
|
|
turnId: "turn",
|
|
itemId: "item",
|
|
environmentId: null,
|
|
startedAtMs: 1,
|
|
reason: null,
|
|
commandActions: [],
|
|
proposedExecpolicyAmendment: null,
|
|
proposedNetworkPolicyAmendments: [],
|
|
availableDecisions: ["accept", "acceptForSession", "decline", "cancel"],
|
|
},
|
|
};
|
|
const approval = expectPresent(toPendingApproval(request));
|
|
|
|
expect(approval.title).toBe("Command approval");
|
|
expect(approval.summary).toBe("npm run build");
|
|
expect(approvalActionLabels(approval)).toEqual(["Allow", "Allow session", "Deny", "Cancel"]);
|
|
expect(approvalResponseAt(approval, 1)).toEqual({ decision: "acceptForSession" });
|
|
});
|
|
|
|
it("builds permission grants only for accept actions", () => {
|
|
const request: ServerRequest = {
|
|
id: 2,
|
|
method: "item/permissions/requestApproval",
|
|
params: {
|
|
cwd: "/tmp/project",
|
|
threadId: "thread",
|
|
turnId: "turn",
|
|
itemId: "item",
|
|
environmentId: null,
|
|
startedAtMs: 1,
|
|
reason: "Need network",
|
|
permissions: { network: { enabled: true }, fileSystem: null },
|
|
},
|
|
};
|
|
const approval = expectPresent(toPendingApproval(request));
|
|
|
|
expect(approvalResponse(approval, "decline")).toEqual({ permissions: {}, scope: "turn" });
|
|
expect(approvalResponse(approval, "accept")).toEqual({
|
|
permissions: { network: { enabled: true } },
|
|
scope: "turn",
|
|
});
|
|
});
|
|
|
|
it("normalizes omitted file change optional fields before display", () => {
|
|
const approval = expectPresent(
|
|
toPendingApproval({
|
|
id: 29,
|
|
method: "item/fileChange/requestApproval",
|
|
params: {
|
|
threadId: "thread",
|
|
turnId: "turn",
|
|
itemId: "file",
|
|
startedAtMs: 1,
|
|
},
|
|
} as unknown as ServerRequest),
|
|
);
|
|
|
|
expect(approval).toMatchObject({ kind: "fileChange", title: "File change approval" });
|
|
expect(approval.summary).toBe("Allow file changes?");
|
|
expect(approval.details).toEqual([]);
|
|
});
|
|
|
|
it("uses command approval decisions supplied by app-server", () => {
|
|
const allowRegistryDecision = {
|
|
applyNetworkPolicyAmendment: { network_policy_amendment: { host: "registry.npmjs.org", action: "allow" } },
|
|
} as const;
|
|
const allowApiDecision = {
|
|
applyNetworkPolicyAmendment: { network_policy_amendment: { host: "api.github.com", action: "allow" } },
|
|
} as const;
|
|
const request: ServerRequest = {
|
|
id: 30,
|
|
method: "item/commandExecution/requestApproval",
|
|
params: {
|
|
command: null,
|
|
cwd: "/tmp/project",
|
|
threadId: "thread",
|
|
turnId: "turn",
|
|
itemId: "command",
|
|
environmentId: null,
|
|
startedAtMs: 1,
|
|
reason: "Needs network access",
|
|
networkApprovalContext: { host: "registry.npmjs.org", protocol: "https" },
|
|
commandActions: [],
|
|
proposedExecpolicyAmendment: null,
|
|
proposedNetworkPolicyAmendments: [],
|
|
availableDecisions: [allowRegistryDecision, allowApiDecision, "decline"],
|
|
},
|
|
};
|
|
const approval = expectPresent(toPendingApproval(request));
|
|
const options = approvalActionOptions(approval);
|
|
|
|
expect(options.map((option) => option.label)).toEqual(["Allow network rule", "Allow network rule", "Deny"]);
|
|
expect(new Set(options.map((option) => option.id)).size).toBe(options.length);
|
|
expect(approvalResponseAt(approval, 0)).toEqual({ decision: allowRegistryDecision });
|
|
expect(approvalResponseAt(approval, 1)).toEqual({ decision: allowApiDecision });
|
|
expect(approvalResponseAt(approval, 2)).toEqual({ decision: "decline" });
|
|
});
|
|
|
|
it("keeps future simple command decisions renderable", () => {
|
|
const futureDecision = "restartWithNetwork";
|
|
const request = {
|
|
id: 32,
|
|
method: "item/commandExecution/requestApproval",
|
|
params: {
|
|
command: null,
|
|
cwd: "/tmp/project",
|
|
threadId: "thread",
|
|
turnId: "turn",
|
|
itemId: "command",
|
|
environmentId: null,
|
|
startedAtMs: 1,
|
|
reason: null,
|
|
commandActions: [],
|
|
proposedExecpolicyAmendment: null,
|
|
proposedNetworkPolicyAmendments: [],
|
|
availableDecisions: [futureDecision],
|
|
},
|
|
} as unknown as ServerRequest;
|
|
const approval = expectPresent(toPendingApproval(request));
|
|
const options = approvalActionOptions(approval);
|
|
|
|
expect(options).toEqual([
|
|
{
|
|
id: "approval-option:0:restartWithNetwork",
|
|
label: "Choose",
|
|
action: { kind: "approval-option", intent: "decline", response: { decision: futureDecision } },
|
|
intent: "decline",
|
|
},
|
|
]);
|
|
expect(approvalResponseAt(approval, 0)).toEqual({ decision: futureDecision });
|
|
});
|
|
|
|
it("falls back to generic command approval actions when app-server omits decisions", () => {
|
|
const approval = expectPresent(
|
|
toPendingApproval({
|
|
id: 31,
|
|
method: "item/commandExecution/requestApproval",
|
|
params: {
|
|
command: "npm run build",
|
|
cwd: "/tmp/project",
|
|
threadId: "thread",
|
|
turnId: "turn",
|
|
itemId: "command",
|
|
environmentId: null,
|
|
startedAtMs: 1,
|
|
reason: null,
|
|
commandActions: [],
|
|
proposedExecpolicyAmendment: null,
|
|
proposedNetworkPolicyAmendments: [],
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(approvalActionLabels(approval)).toEqual(["Allow", "Allow session", "Deny", "Cancel"]);
|
|
expect(approvalResponse(approval, "accept-session")).toEqual({ decision: "acceptForSession" });
|
|
});
|
|
|
|
it("shows approval reasons first in pending request summaries", () => {
|
|
const command = expectPresent(
|
|
toPendingApproval({
|
|
id: 20,
|
|
method: "item/commandExecution/requestApproval",
|
|
params: {
|
|
command: "npm run build",
|
|
cwd: "/tmp/project",
|
|
threadId: "thread",
|
|
turnId: "turn",
|
|
itemId: "command",
|
|
environmentId: null,
|
|
startedAtMs: 1,
|
|
reason: "Needs unsandboxed access",
|
|
commandActions: [],
|
|
proposedExecpolicyAmendment: null,
|
|
proposedNetworkPolicyAmendments: [],
|
|
},
|
|
}),
|
|
);
|
|
const fileChange = expectPresent(
|
|
toPendingApproval({
|
|
id: 21,
|
|
method: "item/fileChange/requestApproval",
|
|
params: {
|
|
threadId: "thread",
|
|
turnId: "turn",
|
|
itemId: "file",
|
|
startedAtMs: 1,
|
|
reason: "Write outside workspace",
|
|
grantRoot: "/tmp/project",
|
|
},
|
|
}),
|
|
);
|
|
const permissions = expectPresent(
|
|
toPendingApproval({
|
|
id: 22,
|
|
method: "item/permissions/requestApproval",
|
|
params: {
|
|
cwd: "/tmp/project",
|
|
threadId: "thread",
|
|
turnId: "turn",
|
|
itemId: "permissions",
|
|
environmentId: null,
|
|
startedAtMs: 1,
|
|
reason: "Need network",
|
|
permissions: { network: { enabled: true }, fileSystem: null },
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(command.summary).toBe("Needs unsandboxed access\nnpm run build");
|
|
expect(fileChange.summary).toBe("Write outside workspace\ngrant root: /tmp/project");
|
|
expect(permissions.summary.startsWith("Need network\ncwd: /tmp/project")).toBe(true);
|
|
});
|
|
|
|
it("keeps approval details semantic and omits raw payloads", () => {
|
|
const approval = expectPresent(
|
|
toPendingApproval({
|
|
id: 23,
|
|
method: "item/permissions/requestApproval",
|
|
params: {
|
|
cwd: "/tmp/project",
|
|
threadId: "thread",
|
|
turnId: "turn",
|
|
itemId: "permissions",
|
|
environmentId: null,
|
|
startedAtMs: 1,
|
|
reason: "Need network",
|
|
permissions: { network: { enabled: true }, fileSystem: null },
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(approval.details).toEqual([
|
|
{ key: "reason", value: "Need network" },
|
|
{ key: "cwd", value: "/tmp/project" },
|
|
{ key: "network", value: "enabled" },
|
|
]);
|
|
});
|
|
|
|
it("formats command approval request payloads as compact detail rows", () => {
|
|
const approval = expectPresent(
|
|
toPendingApproval({
|
|
id: 25,
|
|
method: "item/commandExecution/requestApproval",
|
|
params: {
|
|
command: "rg TODO src && sed -n '1,20p' src/main.ts",
|
|
cwd: "/tmp/project",
|
|
threadId: "thread",
|
|
turnId: "turn",
|
|
itemId: "command",
|
|
environmentId: null,
|
|
startedAtMs: 1,
|
|
reason: "Needs network access",
|
|
networkApprovalContext: { host: "registry.npmjs.org", protocol: "https" },
|
|
commandActions: [
|
|
{ type: "search", command: "rg TODO src", query: "TODO", path: "/tmp/project/src" },
|
|
{ type: "read", command: "sed -n '1,20p' src/main.ts", name: "main.ts", path: "/tmp/project/src/main.ts" },
|
|
{ type: "listFiles", command: "ls", path: null },
|
|
],
|
|
additionalPermissions: {
|
|
network: { enabled: true },
|
|
fileSystem: {
|
|
read: null,
|
|
write: null,
|
|
entries: [{ path: { type: "path", path: "/tmp/project/generated" }, access: "write" }],
|
|
},
|
|
},
|
|
proposedExecpolicyAmendment: ["rg", "TODO"],
|
|
proposedNetworkPolicyAmendments: [
|
|
{ host: "registry.npmjs.org", action: "allow" },
|
|
{ host: "example.com", action: "deny" },
|
|
],
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(approval.details).toEqual([
|
|
{ key: "reason", value: "Needs network access" },
|
|
{ key: "command", value: "rg TODO src && sed -n '1,20p' src/main.ts" },
|
|
{ key: "cwd", value: "/tmp/project" },
|
|
{ key: "network", value: "https://registry.npmjs.org" },
|
|
{ key: "actions", value: 'search "TODO" in src\nread src/main.ts\nlist files workspace' },
|
|
{ key: "additional network", value: "enabled" },
|
|
{ key: "additional filesystem", value: "/tmp/project/generated (write)" },
|
|
{ key: "future command rule", value: "rg\nTODO" },
|
|
{ key: "future network rules", value: "allow registry.npmjs.org\ndeny example.com" },
|
|
]);
|
|
});
|
|
|
|
it("keeps unknown command approval detail payload entries visible", () => {
|
|
const approval = expectPresent(
|
|
toPendingApproval({
|
|
id: 26,
|
|
method: "item/commandExecution/requestApproval",
|
|
params: {
|
|
command: null,
|
|
cwd: "/tmp/project",
|
|
threadId: "thread",
|
|
turnId: "turn",
|
|
itemId: "command",
|
|
environmentId: null,
|
|
startedAtMs: 1,
|
|
reason: null,
|
|
commandActions: [{ path: "/tmp/project/src/main.ts" }, "legacy action"],
|
|
proposedExecpolicyAmendment: null,
|
|
proposedNetworkPolicyAmendments: [{ host: "api.github.com" }, { action: "allow" }, "legacy rule"],
|
|
},
|
|
} as unknown as ServerRequest),
|
|
);
|
|
|
|
expect(approval.details).toEqual([
|
|
{ key: "cwd", value: "/tmp/project" },
|
|
{ key: "actions", value: '{\n "path": "/tmp/project/src/main.ts"\n}\nlegacy action' },
|
|
{ key: "future network rules", value: "rule api.github.com\nallow (unknown host)\nlegacy rule" },
|
|
]);
|
|
});
|
|
|
|
it("omits empty approval detail rows", () => {
|
|
const approval = expectPresent(
|
|
toPendingApproval({
|
|
id: 24,
|
|
method: "item/commandExecution/requestApproval",
|
|
params: {
|
|
command: "npm test",
|
|
cwd: "/tmp/project",
|
|
threadId: "thread",
|
|
turnId: "turn",
|
|
itemId: "command",
|
|
environmentId: null,
|
|
startedAtMs: 1,
|
|
reason: null,
|
|
commandActions: [],
|
|
proposedExecpolicyAmendment: null,
|
|
proposedNetworkPolicyAmendments: [],
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(approval.details).toEqual([
|
|
{ key: "command", value: "npm test" },
|
|
{ key: "cwd", value: "/tmp/project" },
|
|
]);
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: "command approval",
|
|
request: {
|
|
id: 3,
|
|
method: "item/commandExecution/requestApproval",
|
|
params: {
|
|
command: "npm test",
|
|
cwd: "/tmp/project",
|
|
threadId: "thread",
|
|
turnId: "turn",
|
|
itemId: "command",
|
|
environmentId: null,
|
|
startedAtMs: 1,
|
|
reason: null,
|
|
commandActions: [],
|
|
proposedExecpolicyAmendment: null,
|
|
proposedNetworkPolicyAmendments: [],
|
|
availableDecisions: ["acceptForSession", "cancel", "decline"],
|
|
},
|
|
},
|
|
acceptSession: { decision: "acceptForSession" },
|
|
cancel: { decision: "cancel" },
|
|
},
|
|
{
|
|
name: "file change approval",
|
|
request: {
|
|
id: 4,
|
|
method: "item/fileChange/requestApproval",
|
|
params: {
|
|
threadId: "thread",
|
|
turnId: "turn",
|
|
itemId: "file",
|
|
startedAtMs: 1,
|
|
reason: "write",
|
|
grantRoot: "/tmp/project",
|
|
},
|
|
},
|
|
acceptSession: { decision: "acceptForSession" },
|
|
cancel: { decision: "cancel" },
|
|
},
|
|
] satisfies { name: string; request: ServerRequest; acceptSession: unknown; cancel: unknown }[])("builds action responses for $name", ({
|
|
request,
|
|
acceptSession,
|
|
cancel,
|
|
}) => {
|
|
const approval = expectPresent(toPendingApproval(request));
|
|
if (approval.kind === "command") {
|
|
expect(approvalResponseAt(approval, 0)).toEqual(acceptSession);
|
|
expect(approvalResponseAt(approval, 1)).toEqual(cancel);
|
|
expect(approvalResponseAt(approval, 2)).toEqual({ decision: "decline" });
|
|
return;
|
|
}
|
|
expect(approvalResponse(approval, "accept-session")).toEqual(acceptSession);
|
|
expect(approvalResponse(approval, "cancel")).toEqual(cancel);
|
|
expect(approvalResponse(approval, "decline")).toEqual({ decision: "decline" });
|
|
});
|
|
});
|