mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Coarsen pending approval models
This commit is contained in:
parent
2ff5c89e2b
commit
b6629a75db
15 changed files with 483 additions and 443 deletions
|
|
@ -1,15 +1,19 @@
|
|||
import type { ServerRequest } from "../../generated/app-server/ServerRequest";
|
||||
import type {
|
||||
ApprovalAction,
|
||||
CommandApprovalDecision,
|
||||
ApprovalActionIntent,
|
||||
ApprovalDetailRow,
|
||||
McpElicitationAction,
|
||||
McpElicitationContentValue,
|
||||
PendingApproval,
|
||||
PendingApprovalOption,
|
||||
PendingMcpElicitation,
|
||||
PendingMcpElicitationField,
|
||||
PendingMcpElicitationOption,
|
||||
PendingUserInput,
|
||||
} from "../../domain/pending-requests/model";
|
||||
import { pathRelativeToRoot } from "../../shared/path/file-paths";
|
||||
import { jsonPreview } from "../../utils";
|
||||
|
||||
type AppServerRequestByMethod<Method extends ServerRequest["method"]> = Extract<ServerRequest, { method: Method }>;
|
||||
|
||||
|
|
@ -18,6 +22,13 @@ interface AppServerGrantedPermissionProfile {
|
|||
fileSystem?: unknown;
|
||||
}
|
||||
|
||||
type SimpleApprovalDecision = "accept" | "acceptForSession" | "decline" | "cancel";
|
||||
type CommandApprovalRequest = AppServerRequestByMethod<"item/commandExecution/requestApproval">;
|
||||
type CommandApprovalParams = CommandApprovalRequest["params"];
|
||||
type CommandApprovalDecision = NonNullable<CommandApprovalParams["availableDecisions"]>[number];
|
||||
type FileChangeApprovalParams = AppServerRequestByMethod<"item/fileChange/requestApproval">["params"];
|
||||
type PermissionsApprovalParams = AppServerRequestByMethod<"item/permissions/requestApproval">["params"];
|
||||
|
||||
export type AppServerApprovalResponse =
|
||||
| { decision: CommandApprovalDecision }
|
||||
| { scope: "session" | "turn"; permissions: AppServerGrantedPermissionProfile };
|
||||
|
|
@ -39,56 +50,25 @@ export interface AppServerMcpElicitationResponse {
|
|||
export function appServerApprovalRequest(request: ServerRequest): PendingApproval | null {
|
||||
switch (request.method) {
|
||||
case "item/commandExecution/requestApproval":
|
||||
return {
|
||||
requestId: request.id,
|
||||
method: request.method,
|
||||
params: request.params,
|
||||
};
|
||||
return commandApprovalRequest(request.id, request.params);
|
||||
case "item/fileChange/requestApproval":
|
||||
return {
|
||||
requestId: request.id,
|
||||
method: request.method,
|
||||
params: {
|
||||
...request.params,
|
||||
reason: request.params.reason ?? null,
|
||||
grantRoot: request.params.grantRoot ?? null,
|
||||
},
|
||||
};
|
||||
return fileChangeApprovalRequest(request.id, request.params);
|
||||
case "item/permissions/requestApproval":
|
||||
return {
|
||||
requestId: request.id,
|
||||
method: request.method,
|
||||
params: request.params,
|
||||
};
|
||||
return permissionsApprovalRequest(request.id, request.params);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function appServerApprovalResponse(approval: PendingApproval, action: ApprovalAction): AppServerApprovalResponse {
|
||||
if (approval.method === "item/commandExecution/requestApproval") {
|
||||
return {
|
||||
decision: isCommandDecisionAction(action) ? action.decision : commandDecision(action),
|
||||
};
|
||||
}
|
||||
|
||||
if (approval.method === "item/fileChange/requestApproval") {
|
||||
return {
|
||||
decision: fileChangeDecision(action),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
scope: action === "accept-session" ? "session" : "turn",
|
||||
permissions: action === "accept" || action === "accept-session" ? grantedPermissions(approval.params.permissions) : {},
|
||||
};
|
||||
if (isApprovalOptionAction(action)) return action.response as AppServerApprovalResponse;
|
||||
return approvalResponseForIntent(approval, action);
|
||||
}
|
||||
|
||||
export function appServerUserInputRequest(request: ServerRequest): PendingUserInput | null {
|
||||
if (request.method !== "item/tool/requestUserInput") return null;
|
||||
return {
|
||||
requestId: request.id,
|
||||
method: request.method,
|
||||
params: request.params,
|
||||
};
|
||||
}
|
||||
|
|
@ -115,7 +95,6 @@ export function appServerMcpElicitationRequest(request: ServerRequest): PendingM
|
|||
if (params.mode === "url") {
|
||||
return {
|
||||
requestId: request.id,
|
||||
method: request.method,
|
||||
params: {
|
||||
threadId: params.threadId,
|
||||
turnId: params.turnId,
|
||||
|
|
@ -130,7 +109,6 @@ export function appServerMcpElicitationRequest(request: ServerRequest): PendingM
|
|||
}
|
||||
return {
|
||||
requestId: request.id,
|
||||
method: request.method,
|
||||
params: {
|
||||
threadId: params.threadId,
|
||||
turnId: params.turnId,
|
||||
|
|
@ -154,21 +132,92 @@ export function appServerMcpElicitationResponse(
|
|||
};
|
||||
}
|
||||
|
||||
function isCommandDecisionAction(action: ApprovalAction): action is Extract<ApprovalAction, { kind: "command-decision" }> {
|
||||
function commandApprovalRequest(requestId: PendingApproval["requestId"], params: CommandApprovalParams): PendingApproval {
|
||||
const details = commandApprovalDetails(params);
|
||||
return {
|
||||
requestId,
|
||||
kind: "command",
|
||||
turnId: params.turnId,
|
||||
title: "Command approval",
|
||||
summary: approvalSummary(params.reason, params.command, "Command execution requested."),
|
||||
resultSummary: approvalResultSummary(params.reason, params.command, "Command execution requested."),
|
||||
details,
|
||||
responses: decisionResponses(commandDecision),
|
||||
actionOptions: commandApprovalActionOptions(params.availableDecisions),
|
||||
};
|
||||
}
|
||||
|
||||
function fileChangeApprovalRequest(requestId: PendingApproval["requestId"], params: FileChangeApprovalParams): PendingApproval {
|
||||
const reason = params.reason ?? null;
|
||||
const grantRoot = params.grantRoot ?? null;
|
||||
return {
|
||||
requestId,
|
||||
kind: "fileChange",
|
||||
turnId: params.turnId,
|
||||
title: "File change approval",
|
||||
summary: approvalSummary(reason, grantRoot ? `grant root: ${grantRoot}` : null, "Allow file changes?"),
|
||||
resultSummary: approvalResultSummary(reason, grantRoot ? `grant root: ${grantRoot}` : null, "Allow file changes?"),
|
||||
details: grantRoot ? [{ key: "grant root", value: grantRoot }] : [],
|
||||
responses: decisionResponses(fileChangeDecision),
|
||||
actionOptions: null,
|
||||
};
|
||||
}
|
||||
|
||||
function permissionsApprovalRequest(requestId: PendingApproval["requestId"], params: PermissionsApprovalParams): PendingApproval {
|
||||
const details: ApprovalDetailRow[] = [];
|
||||
addOptional(details, "reason", params.reason);
|
||||
addOptional(details, "cwd", params.cwd);
|
||||
addOptional(details, "environment", params.environmentId);
|
||||
details.push(...permissionRows(params.permissions));
|
||||
return {
|
||||
requestId,
|
||||
kind: "permission",
|
||||
turnId: params.turnId,
|
||||
title: "Permission approval",
|
||||
summary: approvalSummary(params.reason, `cwd: ${params.cwd}`, "Permission change requested."),
|
||||
resultSummary: approvalResultSummary(params.reason, `cwd: ${params.cwd}`, "Permission change requested."),
|
||||
details,
|
||||
responses: {
|
||||
accept: { scope: "turn", permissions: grantedPermissions(params.permissions) },
|
||||
acceptSession: { scope: "session", permissions: grantedPermissions(params.permissions) },
|
||||
decline: { scope: "turn", permissions: {} },
|
||||
cancel: { scope: "turn", permissions: {} },
|
||||
},
|
||||
actionOptions: null,
|
||||
};
|
||||
}
|
||||
|
||||
function decisionResponses(decision: (intent: ApprovalActionIntent) => SimpleApprovalDecision): PendingApproval["responses"] {
|
||||
return {
|
||||
accept: { decision: decision("accept") },
|
||||
acceptSession: { decision: decision("accept-session") },
|
||||
decline: { decision: decision("decline") },
|
||||
cancel: { decision: decision("cancel") },
|
||||
};
|
||||
}
|
||||
|
||||
function approvalResponseForIntent(approval: PendingApproval, intent: ApprovalActionIntent): AppServerApprovalResponse {
|
||||
if (intent === "accept") return approval.responses.accept as AppServerApprovalResponse;
|
||||
if (intent === "accept-session") return approval.responses.acceptSession as AppServerApprovalResponse;
|
||||
if (intent === "cancel") return approval.responses.cancel as AppServerApprovalResponse;
|
||||
return approval.responses.decline as AppServerApprovalResponse;
|
||||
}
|
||||
|
||||
function isApprovalOptionAction(action: ApprovalAction): action is Extract<ApprovalAction, { kind: "approval-option" }> {
|
||||
return typeof action === "object";
|
||||
}
|
||||
|
||||
function commandDecision(action: ApprovalAction): CommandApprovalDecision {
|
||||
if (action === "accept") return "accept";
|
||||
if (action === "accept-session") return "acceptForSession";
|
||||
if (action === "cancel") return "cancel";
|
||||
function commandDecision(intent: ApprovalActionIntent): SimpleApprovalDecision {
|
||||
if (intent === "accept") return "accept";
|
||||
if (intent === "accept-session") return "acceptForSession";
|
||||
if (intent === "cancel") return "cancel";
|
||||
return "decline";
|
||||
}
|
||||
|
||||
function fileChangeDecision(action: ApprovalAction): CommandApprovalDecision {
|
||||
if (action === "accept") return "accept";
|
||||
if (action === "accept-session") return "acceptForSession";
|
||||
if (action === "cancel") return "cancel";
|
||||
function fileChangeDecision(intent: ApprovalActionIntent): SimpleApprovalDecision {
|
||||
if (intent === "accept") return "accept";
|
||||
if (intent === "accept-session") return "acceptForSession";
|
||||
if (intent === "cancel") return "cancel";
|
||||
return "decline";
|
||||
}
|
||||
|
||||
|
|
@ -179,6 +228,241 @@ function grantedPermissions(requested: { network?: unknown; fileSystem?: unknown
|
|||
return granted;
|
||||
}
|
||||
|
||||
interface CommandAction {
|
||||
type: string;
|
||||
command?: unknown;
|
||||
name?: unknown;
|
||||
path?: unknown;
|
||||
query?: unknown;
|
||||
}
|
||||
|
||||
interface NetworkApprovalContext {
|
||||
host?: unknown;
|
||||
protocol?: unknown;
|
||||
}
|
||||
|
||||
function commandApprovalDetails(params: CommandApprovalParams): ApprovalDetailRow[] {
|
||||
const rows: ApprovalDetailRow[] = [];
|
||||
addOptional(rows, "reason", params.reason);
|
||||
addOptional(rows, "command", params.command);
|
||||
addOptional(rows, "cwd", params.cwd);
|
||||
addOptional(rows, "network", networkApprovalContextLabel(params.networkApprovalContext));
|
||||
rows.push(...commandActionRows(params.commandActions, params.cwd));
|
||||
rows.push(...prefixedPermissionRows("additional", params.additionalPermissions));
|
||||
addOptional(rows, "future command rule", params.proposedExecpolicyAmendment);
|
||||
addOptional(rows, "future network rules", networkPolicyAmendmentsLabel(params.proposedNetworkPolicyAmendments));
|
||||
return rows;
|
||||
}
|
||||
|
||||
function commandApprovalActionOptions(decisions: CommandApprovalParams["availableDecisions"]): PendingApprovalOption[] | null {
|
||||
if (!decisions || decisions.length === 0) return null;
|
||||
return decisions.map((decision, index) => {
|
||||
const intent = commandDecisionIntent(decision);
|
||||
return {
|
||||
id: `approval-option:${String(index)}:${commandDecisionKey(decision)}`,
|
||||
label: commandDecisionLabel(decision),
|
||||
intent,
|
||||
action: {
|
||||
kind: "approval-option",
|
||||
intent,
|
||||
response: { decision },
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function commandDecisionIntent(decision: CommandApprovalDecision): ApprovalActionIntent {
|
||||
if (typeof decision === "string") return simpleCommandDecisionIntent(decision);
|
||||
if ("acceptWithExecpolicyAmendment" in decision) return "accept-session";
|
||||
if ("applyNetworkPolicyAmendment" in decision) {
|
||||
return decision.applyNetworkPolicyAmendment.network_policy_amendment.action === "allow" ? "accept-session" : "decline";
|
||||
}
|
||||
return "decline";
|
||||
}
|
||||
|
||||
function simpleCommandDecisionIntent(decision: string): ApprovalActionIntent {
|
||||
if (decision === "accept") return "accept";
|
||||
if (decision === "acceptForSession") return "accept-session";
|
||||
if (decision === "cancel") return "cancel";
|
||||
return "decline";
|
||||
}
|
||||
|
||||
function commandDecisionLabel(decision: CommandApprovalDecision): string {
|
||||
if (typeof decision === "string") return simpleCommandDecisionLabel(decision);
|
||||
if ("acceptWithExecpolicyAmendment" in decision) return "Allow rule";
|
||||
if ("applyNetworkPolicyAmendment" in decision) {
|
||||
return decision.applyNetworkPolicyAmendment.network_policy_amendment.action === "allow" ? "Allow network rule" : "Deny network rule";
|
||||
}
|
||||
return "Choose";
|
||||
}
|
||||
|
||||
function simpleCommandDecisionLabel(decision: string): string {
|
||||
if (decision === "accept") return "Allow";
|
||||
if (decision === "acceptForSession") return "Allow session";
|
||||
if (decision === "decline") return "Deny";
|
||||
if (decision === "cancel") return "Cancel";
|
||||
return "Choose";
|
||||
}
|
||||
|
||||
function commandDecisionKey(decision: CommandApprovalDecision): string {
|
||||
if (typeof decision === "string") return decision;
|
||||
if ("acceptWithExecpolicyAmendment" in decision) return "acceptWithExecpolicyAmendment";
|
||||
if ("applyNetworkPolicyAmendment" in decision) {
|
||||
const amendment = decision.applyNetworkPolicyAmendment.network_policy_amendment;
|
||||
const host = amendment.host;
|
||||
return `applyNetworkPolicyAmendment:${amendment.action}:${typeof host === "string" ? host : ""}`;
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function approvalSummary(reason: unknown, target: unknown, fallback: string): string {
|
||||
const lines = [nonEmptyString(reason), nonEmptyString(target)].filter((value): value is string => Boolean(value));
|
||||
return (lines.length > 0 ? lines : [fallback]).join("\n");
|
||||
}
|
||||
|
||||
function approvalResultSummary(reason: unknown, target: unknown, fallback: string): string {
|
||||
return nonEmptyString(reason) ?? nonEmptyString(target) ?? fallback;
|
||||
}
|
||||
|
||||
function commandActionRows(value: unknown, cwd: string | null | undefined): ApprovalDetailRow[] {
|
||||
if (!Array.isArray(value) || value.length === 0) return [];
|
||||
return [
|
||||
{
|
||||
key: value.length === 1 ? "action" : "actions",
|
||||
value: value
|
||||
.map((action) => (isCommandAction(action) ? commandActionLabel(action, cwd) : stringValue(action, "unknown action")))
|
||||
.join("\n"),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function commandActionLabel(action: CommandAction, cwd: string | null | undefined): string {
|
||||
if (action.type === "read") {
|
||||
const path = pathLabel(action.path, cwd);
|
||||
return path ? `read ${path}` : `read ${nonEmptyString(action.name) ?? "file"}`;
|
||||
}
|
||||
if (action.type === "search") {
|
||||
const query = nonEmptyString(action.query);
|
||||
const path = pathLabel(action.path, cwd);
|
||||
if (query && path) return `search "${query}" in ${path}`;
|
||||
if (query) return `search "${query}"`;
|
||||
if (path) return `search ${path}`;
|
||||
return "search";
|
||||
}
|
||||
if (action.type === "listFiles") {
|
||||
return `list files ${pathLabel(action.path, cwd) ?? "workspace"}`;
|
||||
}
|
||||
return nonEmptyString(action.command) ?? action.type;
|
||||
}
|
||||
|
||||
function isCommandAction(value: unknown): value is CommandAction {
|
||||
return value !== null && typeof value === "object" && typeof (value as { type?: unknown }).type === "string";
|
||||
}
|
||||
|
||||
function pathLabel(path: unknown, cwd: string | null | undefined): string | null {
|
||||
const value = nonEmptyString(path);
|
||||
return value ? pathRelativeToRoot(value, cwd) : null;
|
||||
}
|
||||
|
||||
function networkApprovalContextLabel(value: unknown): string | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const context = value as NetworkApprovalContext;
|
||||
const host = nonEmptyString(context.host);
|
||||
if (!host) return null;
|
||||
const protocol = nonEmptyString(context.protocol);
|
||||
return protocol ? `${protocol}://${host}` : host;
|
||||
}
|
||||
|
||||
function prefixedPermissionRows(prefix: string, permissions: unknown): ApprovalDetailRow[] {
|
||||
if (!permissions) return [];
|
||||
return permissionRows(permissions).map((row) => ({ ...row, key: `${prefix} ${row.key}` }));
|
||||
}
|
||||
|
||||
function permissionRows(permissions: unknown): ApprovalDetailRow[] {
|
||||
const profile = asRecordOrNull(permissions);
|
||||
if (!profile) return [];
|
||||
const rows: ApprovalDetailRow[] = [];
|
||||
const networkEnabled = asRecordOrNull(profile["network"])?.["enabled"];
|
||||
if (typeof networkEnabled === "boolean") {
|
||||
rows.push({ key: "network", value: networkEnabled ? "enabled" : "disabled" });
|
||||
}
|
||||
|
||||
const fileSystem = asRecordOrNull(profile["fileSystem"]);
|
||||
if (!fileSystem) return rows;
|
||||
|
||||
const entries = fileSystem["entries"];
|
||||
if (Array.isArray(entries) && entries.length > 0) {
|
||||
rows.push({
|
||||
key: "filesystem",
|
||||
value: entries
|
||||
.map((entry) => {
|
||||
const record = asRecordOrNull(entry);
|
||||
return record ? `${fileSystemPathLabel(record["path"])} (${stringValue(record["access"], "unknown")})` : stringValue(entry);
|
||||
})
|
||||
.join("\n"),
|
||||
});
|
||||
}
|
||||
addOptional(rows, "read", fileSystem["read"]);
|
||||
addOptional(rows, "write", fileSystem["write"]);
|
||||
addOptional(rows, "glob depth", fileSystem["globScanMaxDepth"]);
|
||||
return rows;
|
||||
}
|
||||
|
||||
function fileSystemPathLabel(path: unknown): string {
|
||||
const record = asRecordOrNull(path);
|
||||
if (!record) return stringValue(path, "unknown");
|
||||
if (record["type"] === "path") return stringValue(record["path"], "unknown");
|
||||
if (record["type"] === "glob_pattern") return stringValue(record["pattern"], "unknown");
|
||||
|
||||
const special = asRecordOrNull(record["value"]);
|
||||
if (!special) return stringValue(path, "unknown");
|
||||
if (special["kind"] === "project_roots") {
|
||||
const subpath = nonEmptyString(special["subpath"]);
|
||||
return subpath ? `project_roots/${subpath}` : "project_roots";
|
||||
}
|
||||
if (special["kind"] === "unknown") {
|
||||
const specialPath = nonEmptyString(special["path"]) ?? "unknown";
|
||||
const subpath = nonEmptyString(special["subpath"]);
|
||||
return subpath ? `${specialPath}/${subpath}` : specialPath;
|
||||
}
|
||||
return nonEmptyString(special["kind"]) ?? "unknown";
|
||||
}
|
||||
|
||||
function networkPolicyAmendmentsLabel(value: unknown): string | null {
|
||||
if (!Array.isArray(value) || value.length === 0) return null;
|
||||
return value.map(networkPolicyAmendmentLabel).join("\n");
|
||||
}
|
||||
|
||||
function networkPolicyAmendmentLabel(value: unknown): string {
|
||||
if (!value || typeof value !== "object") return stringValue(value, "rule");
|
||||
const amendment = value as { action?: unknown; host?: unknown };
|
||||
return `${nonEmptyString(amendment.action) ?? "rule"} ${nonEmptyString(amendment.host) ?? "(unknown host)"}`;
|
||||
}
|
||||
|
||||
function addOptional(rows: ApprovalDetailRow[], key: string, value: unknown): void {
|
||||
if (value === null || value === undefined) return;
|
||||
if (Array.isArray(value) && value.length === 0) return;
|
||||
rows.push({ key, value: stringValue(value) });
|
||||
}
|
||||
|
||||
function stringValue(value: unknown, fallback = ""): string {
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") return String(value);
|
||||
if (Array.isArray(value) && value.every((item) => typeof item === "string" || typeof item === "number" || typeof item === "boolean")) {
|
||||
return value.join("\n");
|
||||
}
|
||||
if (value === null || value === undefined) return fallback;
|
||||
return jsonPreview(value);
|
||||
}
|
||||
|
||||
function nonEmptyString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function asRecordOrNull(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" ? (value as Record<string, unknown>) : null;
|
||||
}
|
||||
|
||||
function mcpElicitationFieldsFromSchema(
|
||||
properties: Record<string, AppServerMcpElicitationPrimitiveSchema | undefined>,
|
||||
required: ReadonlySet<string>,
|
||||
|
|
|
|||
|
|
@ -1,83 +1,48 @@
|
|||
export type PendingRequestId = string | number;
|
||||
|
||||
type SimpleApprovalDecision = "accept" | "acceptForSession" | "decline" | "cancel";
|
||||
export type ApprovalActionIntent = "accept" | "accept-session" | "decline" | "cancel";
|
||||
|
||||
export type CommandApprovalDecision =
|
||||
| SimpleApprovalDecision
|
||||
| { acceptWithExecpolicyAmendment: unknown }
|
||||
| { applyNetworkPolicyAmendment: { network_policy_amendment: { action: "allow" | "deny"; [key: string]: unknown } } };
|
||||
type ApprovalKind = "command" | "fileChange" | "permission";
|
||||
|
||||
interface CommandApprovalParams {
|
||||
threadId: string;
|
||||
turnId: string;
|
||||
itemId: string;
|
||||
startedAtMs: number;
|
||||
approvalId?: string | null;
|
||||
reason?: string | null;
|
||||
networkApprovalContext?: unknown;
|
||||
command?: string | null;
|
||||
cwd?: string | null;
|
||||
commandActions?: unknown[] | null;
|
||||
additionalPermissions?: unknown;
|
||||
proposedExecpolicyAmendment?: unknown;
|
||||
proposedNetworkPolicyAmendments?: unknown[] | null;
|
||||
availableDecisions?: CommandApprovalDecision[] | null;
|
||||
export interface ApprovalDetailRow {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface FileChangeApprovalParams {
|
||||
threadId: string;
|
||||
turnId: string;
|
||||
itemId: string;
|
||||
startedAtMs: number;
|
||||
reason: string | null;
|
||||
grantRoot: string | null;
|
||||
interface ApprovalResponseOptions {
|
||||
accept: unknown;
|
||||
acceptSession: unknown;
|
||||
decline: unknown;
|
||||
cancel: unknown;
|
||||
}
|
||||
|
||||
interface PermissionProfile {
|
||||
network?: { enabled?: boolean | null } | null;
|
||||
fileSystem?: {
|
||||
entries?: readonly { path: unknown; access?: unknown }[] | null;
|
||||
read?: unknown;
|
||||
write?: unknown;
|
||||
globScanMaxDepth?: unknown;
|
||||
} | null;
|
||||
interface ApprovalOptionAction {
|
||||
kind: "approval-option";
|
||||
intent: ApprovalActionIntent;
|
||||
response: unknown;
|
||||
}
|
||||
|
||||
interface PermissionsApprovalParams {
|
||||
threadId: string;
|
||||
turnId: string;
|
||||
itemId: string;
|
||||
startedAtMs: number;
|
||||
reason: string | null;
|
||||
cwd: string;
|
||||
environmentId: string | null;
|
||||
permissions: PermissionProfile;
|
||||
export type ApprovalAction = ApprovalActionIntent | ApprovalOptionAction;
|
||||
|
||||
export interface PendingApprovalOption {
|
||||
id: string;
|
||||
label: string;
|
||||
action: ApprovalAction;
|
||||
intent: ApprovalActionIntent;
|
||||
}
|
||||
|
||||
export type ApprovalAction = "accept" | "accept-session" | "decline" | "cancel" | CommandApprovalDecisionAction;
|
||||
|
||||
interface CommandApprovalDecisionAction {
|
||||
kind: "command-decision";
|
||||
decision: CommandApprovalDecision;
|
||||
export interface PendingApproval {
|
||||
requestId: PendingRequestId;
|
||||
kind: ApprovalKind;
|
||||
turnId: string | null;
|
||||
title: string;
|
||||
summary: string;
|
||||
resultSummary: string;
|
||||
details: readonly ApprovalDetailRow[];
|
||||
responses: ApprovalResponseOptions;
|
||||
actionOptions: readonly PendingApprovalOption[] | null;
|
||||
}
|
||||
|
||||
export type PendingApproval =
|
||||
| {
|
||||
requestId: PendingRequestId;
|
||||
method: "item/commandExecution/requestApproval";
|
||||
params: CommandApprovalParams;
|
||||
}
|
||||
| {
|
||||
requestId: PendingRequestId;
|
||||
method: "item/fileChange/requestApproval";
|
||||
params: FileChangeApprovalParams;
|
||||
}
|
||||
| {
|
||||
requestId: PendingRequestId;
|
||||
method: "item/permissions/requestApproval";
|
||||
params: PermissionsApprovalParams;
|
||||
};
|
||||
|
||||
interface PendingUserInputOption {
|
||||
label: string;
|
||||
description: string;
|
||||
|
|
@ -102,7 +67,6 @@ interface PendingUserInputParams {
|
|||
|
||||
export interface PendingUserInput {
|
||||
requestId: PendingRequestId;
|
||||
method: "item/tool/requestUserInput";
|
||||
params: PendingUserInputParams;
|
||||
}
|
||||
|
||||
|
|
@ -176,30 +140,11 @@ interface PendingMcpElicitationUrlParams {
|
|||
|
||||
export interface PendingMcpElicitation {
|
||||
requestId: PendingRequestId;
|
||||
method: "mcpServer/elicitation/request";
|
||||
params: PendingMcpElicitationFormParams | PendingMcpElicitationUrlParams;
|
||||
}
|
||||
|
||||
export function approvalActionKind(action: ApprovalAction): "accept" | "accept-session" | "decline" | "cancel" {
|
||||
if (!isCommandDecisionAction(action)) return action;
|
||||
const decision = action.decision;
|
||||
if (typeof decision === "string") return simpleApprovalActionKind(decision);
|
||||
if ("acceptWithExecpolicyAmendment" in decision) return "accept-session";
|
||||
if ("applyNetworkPolicyAmendment" in decision) {
|
||||
return decision.applyNetworkPolicyAmendment.network_policy_amendment.action === "allow" ? "accept-session" : "decline";
|
||||
}
|
||||
return "decline";
|
||||
}
|
||||
|
||||
function simpleApprovalActionKind(decision: string): "accept" | "accept-session" | "decline" | "cancel" {
|
||||
if (decision === "accept") return "accept";
|
||||
if (decision === "acceptForSession") return "accept-session";
|
||||
if (decision === "cancel") return "cancel";
|
||||
return "decline";
|
||||
}
|
||||
|
||||
function isCommandDecisionAction(action: ApprovalAction): action is CommandApprovalDecisionAction {
|
||||
return typeof action === "object";
|
||||
return typeof action === "object" ? action.intent : action;
|
||||
}
|
||||
|
||||
export function questionDefaultAnswer(question: PendingUserInputQuestion): string {
|
||||
|
|
|
|||
|
|
@ -1,187 +1,17 @@
|
|||
import { jsonPreview } from "../../../../utils";
|
||||
import { pathRelativeToRoot } from "../message-stream/format/path-labels";
|
||||
import { permissionRows, type MessageStreamPermissionProfile } from "../message-stream/format/permission-rows";
|
||||
import type { PendingApproval } from "../../../../domain/pending-requests/model";
|
||||
|
||||
interface ApprovalSummaryParts {
|
||||
reason: string | null;
|
||||
target: string | null;
|
||||
fallback: string;
|
||||
lines: string[];
|
||||
}
|
||||
|
||||
interface DetailRow {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface CommandAction {
|
||||
type: string;
|
||||
command?: unknown;
|
||||
name?: unknown;
|
||||
path?: unknown;
|
||||
query?: unknown;
|
||||
}
|
||||
|
||||
interface NetworkApprovalContext {
|
||||
host?: unknown;
|
||||
protocol?: unknown;
|
||||
}
|
||||
import type { ApprovalDetailRow, PendingApproval } from "../../../../domain/pending-requests/model";
|
||||
|
||||
export function approvalTitle(approval: PendingApproval): string {
|
||||
switch (approval.method) {
|
||||
case "item/commandExecution/requestApproval":
|
||||
return "Command approval";
|
||||
case "item/fileChange/requestApproval":
|
||||
return "File change approval";
|
||||
case "item/permissions/requestApproval":
|
||||
return "Permission approval";
|
||||
}
|
||||
return approval.title;
|
||||
}
|
||||
|
||||
export function approvalSummary(approval: PendingApproval): string {
|
||||
return approvalSummaryParts(approval).lines.join("\n");
|
||||
return approval.summary;
|
||||
}
|
||||
|
||||
export function approvalResultSummary(approval: PendingApproval): string {
|
||||
const summary = approvalSummaryParts(approval);
|
||||
return summary.reason ?? summary.target ?? summary.fallback;
|
||||
return approval.resultSummary;
|
||||
}
|
||||
|
||||
export function approvalDetails(approval: PendingApproval): DetailRow[] {
|
||||
const rows: DetailRow[] = [];
|
||||
addOptional(rows, "reason", approval.params.reason);
|
||||
switch (approval.method) {
|
||||
case "item/commandExecution/requestApproval":
|
||||
addOptional(rows, "command", approval.params.command);
|
||||
addOptional(rows, "cwd", approval.params.cwd);
|
||||
addOptional(rows, "network", networkApprovalContextLabel(approval.params.networkApprovalContext));
|
||||
rows.push(...commandActionRows(approval.params.commandActions, approval.params.cwd));
|
||||
rows.push(...prefixedPermissionRows("additional", approval.params.additionalPermissions));
|
||||
addOptional(rows, "future command rule", approval.params.proposedExecpolicyAmendment);
|
||||
addOptional(rows, "future network rules", networkPolicyAmendmentsLabel(approval.params.proposedNetworkPolicyAmendments));
|
||||
break;
|
||||
case "item/fileChange/requestApproval":
|
||||
addOptional(rows, "grant root", approval.params.grantRoot);
|
||||
break;
|
||||
case "item/permissions/requestApproval":
|
||||
addOptional(rows, "cwd", approval.params.cwd);
|
||||
addOptional(rows, "environment", approval.params.environmentId);
|
||||
rows.push(...permissionRows(approval.params.permissions as MessageStreamPermissionProfile));
|
||||
break;
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function approvalSummaryParts(approval: PendingApproval): ApprovalSummaryParts {
|
||||
switch (approval.method) {
|
||||
case "item/commandExecution/requestApproval": {
|
||||
const reason = nonEmptyString(approval.params.reason);
|
||||
const target = nonEmptyString(approval.params.command);
|
||||
return summaryParts(reason, target, "Command execution requested.");
|
||||
}
|
||||
case "item/fileChange/requestApproval": {
|
||||
const reason = nonEmptyString(approval.params.reason);
|
||||
const grantRoot = nonEmptyString(approval.params.grantRoot);
|
||||
return summaryParts(reason, grantRoot ? `grant root: ${grantRoot}` : null, "Allow file changes?");
|
||||
}
|
||||
case "item/permissions/requestApproval": {
|
||||
return summaryParts(approval.params.reason, `cwd: ${approval.params.cwd}`, "Permission change requested.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function summaryParts(reason: string | null, target: string | null, fallback: string, extra?: string | null): ApprovalSummaryParts {
|
||||
const lines = [reason, target, extra].filter((value): value is string => Boolean(value));
|
||||
return {
|
||||
reason,
|
||||
target,
|
||||
fallback,
|
||||
lines: lines.length > 0 ? lines : [fallback],
|
||||
};
|
||||
}
|
||||
|
||||
function commandActionRows(value: unknown, cwd: string | null | undefined): DetailRow[] {
|
||||
if (!Array.isArray(value) || value.length === 0) return [];
|
||||
return [
|
||||
{
|
||||
key: value.length === 1 ? "action" : "actions",
|
||||
value: value
|
||||
.map((action) => (isCommandAction(action) ? commandActionLabel(action, cwd) : stringValue(action, "unknown action")))
|
||||
.join("\n"),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function commandActionLabel(action: CommandAction, cwd: string | null | undefined): string {
|
||||
if (action.type === "read") {
|
||||
const path = pathLabel(action.path, cwd);
|
||||
return path ? `read ${path}` : `read ${nonEmptyString(action.name) ?? "file"}`;
|
||||
}
|
||||
if (action.type === "search") {
|
||||
const query = nonEmptyString(action.query);
|
||||
const path = pathLabel(action.path, cwd);
|
||||
if (query && path) return `search "${query}" in ${path}`;
|
||||
if (query) return `search "${query}"`;
|
||||
if (path) return `search ${path}`;
|
||||
return "search";
|
||||
}
|
||||
if (action.type === "listFiles") {
|
||||
return `list files ${pathLabel(action.path, cwd) ?? "workspace"}`;
|
||||
}
|
||||
return nonEmptyString(action.command) ?? action.type;
|
||||
}
|
||||
|
||||
function isCommandAction(value: unknown): value is CommandAction {
|
||||
return value !== null && typeof value === "object" && typeof (value as { type?: unknown }).type === "string";
|
||||
}
|
||||
|
||||
function pathLabel(path: unknown, cwd: string | null | undefined): string | null {
|
||||
const value = nonEmptyString(path);
|
||||
return value ? pathRelativeToRoot(value, cwd) : null;
|
||||
}
|
||||
|
||||
function networkApprovalContextLabel(value: unknown): string | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const context = value as NetworkApprovalContext;
|
||||
const host = nonEmptyString(context.host);
|
||||
if (!host) return null;
|
||||
const protocol = nonEmptyString(context.protocol);
|
||||
return protocol ? `${protocol}://${host}` : host;
|
||||
}
|
||||
|
||||
function prefixedPermissionRows(prefix: string, permissions: unknown): DetailRow[] {
|
||||
if (!permissions) return [];
|
||||
return permissionRows(permissions as MessageStreamPermissionProfile).map((row) => ({ ...row, key: `${prefix} ${row.key}` }));
|
||||
}
|
||||
|
||||
function networkPolicyAmendmentsLabel(value: unknown): string | null {
|
||||
if (!Array.isArray(value) || value.length === 0) return null;
|
||||
return value.map(networkPolicyAmendmentLabel).join("\n");
|
||||
}
|
||||
|
||||
function networkPolicyAmendmentLabel(value: unknown): string {
|
||||
if (!value || typeof value !== "object") return stringValue(value, "rule");
|
||||
const amendment = value as { action?: unknown; host?: unknown };
|
||||
return `${nonEmptyString(amendment.action) ?? "rule"} ${nonEmptyString(amendment.host) ?? "(unknown host)"}`;
|
||||
}
|
||||
|
||||
function addOptional(rows: DetailRow[], key: string, value: unknown): void {
|
||||
if (value === null || value === undefined) return;
|
||||
if (Array.isArray(value) && value.length === 0) return;
|
||||
rows.push({ key, value: stringValue(value) });
|
||||
}
|
||||
|
||||
function stringValue(value: unknown, fallback = ""): string {
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") return String(value);
|
||||
if (Array.isArray(value) && value.every((item) => typeof item === "string" || typeof item === "number" || typeof item === "boolean")) {
|
||||
return value.join("\n");
|
||||
}
|
||||
if (value === null || value === undefined) return fallback;
|
||||
return jsonPreview(value);
|
||||
}
|
||||
|
||||
function nonEmptyString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
export function approvalDetails(approval: PendingApproval): ApprovalDetailRow[] {
|
||||
return [...approval.details];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ export function createApprovalResultItem(approval: PendingApproval, action: Appr
|
|||
kind: "approvalResult",
|
||||
role: "tool",
|
||||
text: approvalResultText(approval, action),
|
||||
...definedProp("turnId", approvalTurnId(approval)),
|
||||
...definedProp("turnId", approval.turnId ?? undefined),
|
||||
provenance: { source: "localUser", channel: "response", interaction: "approvalResponse", sourceId: String(approval.requestId) },
|
||||
executionState: kind === "accept" || kind === "accept-session" ? "completed" : "failed",
|
||||
approval: {
|
||||
|
|
@ -91,11 +91,6 @@ function approvalResultStatus(kind: ReturnType<typeof approvalActionKind>): stri
|
|||
return "denied";
|
||||
}
|
||||
|
||||
function approvalTurnId(approval: PendingApproval): string | undefined {
|
||||
const params = approval.params as { turnId?: unknown };
|
||||
return typeof params.turnId === "string" ? params.turnId : undefined;
|
||||
}
|
||||
|
||||
function mcpElicitationResultText(elicitation: PendingMcpElicitation, action: McpElicitationAction): string {
|
||||
const label = `MCP request from ${elicitation.params.serverName}`;
|
||||
if (action === "accept") return `${label} accepted.`;
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ export function pendingRequestsSignature(
|
|||
return "";
|
||||
}
|
||||
return JSON.stringify({
|
||||
approvals: approvals.map((approval) => ({ id: approval.requestId, method: approval.method })),
|
||||
approvals: approvals.map((approval) => ({ id: approval.requestId, kind: approval.kind })),
|
||||
inputs: inputs.map((input) => ({
|
||||
id: input.requestId,
|
||||
questions: input.params.questions.map((question) => ({
|
||||
|
|
@ -55,8 +55,8 @@ export function pendingRequestFocusSignature(
|
|||
return "";
|
||||
}
|
||||
return JSON.stringify({
|
||||
approvals: approvals.map((approval) => ({ id: approval.requestId, method: approval.method })),
|
||||
inputs: inputs.map((input) => ({ id: input.requestId, method: input.method })),
|
||||
mcpElicitations: mcpElicitations.map((elicitation) => ({ id: elicitation.requestId, method: elicitation.method })),
|
||||
approvals: approvals.map((approval) => ({ id: approval.requestId, kind: approval.kind })),
|
||||
inputs: inputs.map((input) => ({ id: input.requestId })),
|
||||
mcpElicitations: mcpElicitations.map((elicitation) => ({ id: elicitation.requestId, mode: elicitation.params.mode })),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,4 @@
|
|||
import {
|
||||
approvalActionKind,
|
||||
type ApprovalAction,
|
||||
type CommandApprovalDecision,
|
||||
type PendingApproval,
|
||||
} from "../../../../domain/pending-requests/model";
|
||||
import { approvalActionKind, type ApprovalAction, type PendingApproval } from "../../../../domain/pending-requests/model";
|
||||
|
||||
export interface ApprovalActionOption {
|
||||
id: string;
|
||||
|
|
@ -13,14 +8,13 @@ export interface ApprovalActionOption {
|
|||
}
|
||||
|
||||
export function approvalActionOptions(approval: PendingApproval): ApprovalActionOption[] {
|
||||
if (approval.method !== "item/commandExecution/requestApproval") return defaultApprovalActionOptions();
|
||||
const decisions = approval.params.availableDecisions;
|
||||
if (!decisions || decisions.length === 0) return defaultApprovalActionOptions();
|
||||
return decisions.map((decision, index) => ({
|
||||
id: `command-decision:${String(index)}:${commandDecisionKey(decision)}`,
|
||||
label: commandDecisionLabel(decision),
|
||||
action: { kind: "command-decision", decision },
|
||||
className: commandDecisionClassName(decision),
|
||||
const options = approval.actionOptions;
|
||||
if (!options || options.length === 0) return defaultApprovalActionOptions();
|
||||
return options.map((option) => ({
|
||||
id: option.id,
|
||||
label: option.label,
|
||||
action: option.action,
|
||||
className: approvalActionClassName(option.action),
|
||||
}));
|
||||
}
|
||||
|
||||
|
|
@ -33,36 +27,8 @@ function defaultApprovalActionOptions(): ApprovalActionOption[] {
|
|||
];
|
||||
}
|
||||
|
||||
function commandDecisionLabel(decision: CommandApprovalDecision): string {
|
||||
if (typeof decision === "string") return simpleCommandDecisionLabel(decision);
|
||||
if ("acceptWithExecpolicyAmendment" in decision) return "Allow rule";
|
||||
if ("applyNetworkPolicyAmendment" in decision) {
|
||||
return decision.applyNetworkPolicyAmendment.network_policy_amendment.action === "allow" ? "Allow network rule" : "Deny network rule";
|
||||
}
|
||||
return "Choose";
|
||||
}
|
||||
|
||||
function simpleCommandDecisionLabel(decision: string): string {
|
||||
if (decision === "accept") return "Allow";
|
||||
if (decision === "acceptForSession") return "Allow session";
|
||||
if (decision === "decline") return "Deny";
|
||||
if (decision === "cancel") return "Cancel";
|
||||
return "Choose";
|
||||
}
|
||||
|
||||
function commandDecisionKey(decision: CommandApprovalDecision): string {
|
||||
if (typeof decision === "string") return decision;
|
||||
if ("acceptWithExecpolicyAmendment" in decision) return "acceptWithExecpolicyAmendment";
|
||||
if ("applyNetworkPolicyAmendment" in decision) {
|
||||
const amendment = decision.applyNetworkPolicyAmendment.network_policy_amendment;
|
||||
const host = amendment["host"];
|
||||
return `applyNetworkPolicyAmendment:${amendment.action}:${typeof host === "string" ? host : ""}`;
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function commandDecisionClassName(decision: CommandApprovalDecision): string {
|
||||
const kind = approvalActionKind({ kind: "command-decision", decision });
|
||||
function approvalActionClassName(action: ApprovalAction): string {
|
||||
const kind = approvalActionKind(action);
|
||||
if (kind === "accept") return "mod-cta";
|
||||
if (kind === "decline") return "mod-warning";
|
||||
return "";
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ describe("app-server server request protocol adapters", () => {
|
|||
|
||||
const approval = expectPresent(appServerApprovalRequest(request));
|
||||
|
||||
expect(approval.method).toBe("item/permissions/requestApproval");
|
||||
expect(approval.kind).toBe("permission");
|
||||
expect(appServerApprovalResponse(approval, "decline")).toEqual({ permissions: {}, scope: "turn" });
|
||||
expect(appServerApprovalResponse(approval, "accept")).toEqual({
|
||||
permissions: { network: { enabled: true } },
|
||||
|
|
@ -77,7 +77,6 @@ describe("app-server server request protocol adapters", () => {
|
|||
|
||||
expect(input).toMatchObject({
|
||||
requestId: 42,
|
||||
method: "mcpServer/elicitation/request",
|
||||
params: {
|
||||
mode: "form",
|
||||
threadId: "thread",
|
||||
|
|
|
|||
|
|
@ -18,16 +18,14 @@ describe("chat pending request state", () => {
|
|||
approvals: [
|
||||
{
|
||||
requestId: 1,
|
||||
method: "item/commandExecution/requestApproval",
|
||||
params: {
|
||||
threadId: "thread",
|
||||
turnId: "turn",
|
||||
itemId: "command",
|
||||
command: "pwd",
|
||||
cwd: "/tmp",
|
||||
reason: null,
|
||||
startedAtMs: 1,
|
||||
},
|
||||
kind: "command",
|
||||
turnId: "turn",
|
||||
title: "Command approval",
|
||||
summary: "pwd",
|
||||
resultSummary: "pwd",
|
||||
details: [{ key: "command", value: "pwd" }],
|
||||
responses: { accept: {}, acceptSession: {}, decline: {}, cancel: {} },
|
||||
actionOptions: null,
|
||||
},
|
||||
],
|
||||
pendingUserInputs: [],
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
} from "../../../../../src/features/chat/application/state/root-reducer";
|
||||
import type { ChatStateStore } from "../../../../../src/features/chat/application/state/store";
|
||||
import type { ServerNotification, ServerRequest } from "../../../../../src/app-server/connection/rpc-messages";
|
||||
import { appServerApprovalRequest, appServerUserInputRequest } from "../../../../../src/app-server/protocol/server-requests";
|
||||
import type { Thread as PanelThread } from "../../../../../src/domain/threads/model";
|
||||
import type { TurnRecord } from "../../../../../src/app-server/protocol/turn";
|
||||
import { createLocalIdSource } from "../../../../../src/shared/id/local-id";
|
||||
|
|
@ -70,6 +71,14 @@ function expectPresent<T>(value: T | null | undefined): T {
|
|||
return value;
|
||||
}
|
||||
|
||||
function pendingApprovalFromRequest(request: ServerRequest) {
|
||||
return expectPresent(appServerApprovalRequest(request));
|
||||
}
|
||||
|
||||
function pendingUserInputFromRequest(request: ServerRequest) {
|
||||
return expectPresent(appServerUserInputRequest(request));
|
||||
}
|
||||
|
||||
describe("ChatInboundHandler", () => {
|
||||
describe("active turn routing", () => {
|
||||
it("applies matching streaming deltas as assistant markdown", () => {
|
||||
|
|
@ -1101,22 +1110,22 @@ describe("ChatInboundHandler", () => {
|
|||
state = chatStateWith(state, {
|
||||
requests: {
|
||||
approvals: [
|
||||
{
|
||||
requestId: 50,
|
||||
pendingApprovalFromRequest({
|
||||
id: 50,
|
||||
method: "item/commandExecution/requestApproval",
|
||||
params: {
|
||||
...expectPresent(supportedApprovalRequests()[0]).params,
|
||||
threadId: "thread-active",
|
||||
} as Extract<ServerRequest, { method: "item/commandExecution/requestApproval" }>["params"],
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
state = chatStateWith(state, {
|
||||
requests: {
|
||||
pendingUserInputs: [
|
||||
{
|
||||
requestId: 50,
|
||||
pendingUserInputFromRequest({
|
||||
id: 50,
|
||||
method: "item/tool/requestUserInput",
|
||||
params: {
|
||||
threadId: "thread-active",
|
||||
|
|
@ -1125,7 +1134,7 @@ describe("ChatInboundHandler", () => {
|
|||
questions: [{ id: "note", header: "Note", question: "What now?", isOther: false, isSecret: false, options: null }],
|
||||
autoResolutionMs: null,
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
|
@ -1134,7 +1143,6 @@ describe("ChatInboundHandler", () => {
|
|||
pendingMcpElicitations: [
|
||||
{
|
||||
requestId: 50,
|
||||
method: "mcpServer/elicitation/request",
|
||||
params: {
|
||||
threadId: "thread-active",
|
||||
turnId: null,
|
||||
|
|
@ -1224,22 +1232,22 @@ describe("ChatInboundHandler", () => {
|
|||
state = chatStateWith(state, {
|
||||
requests: {
|
||||
approvals: [
|
||||
{
|
||||
requestId: 10,
|
||||
pendingApprovalFromRequest({
|
||||
id: 10,
|
||||
method: "item/commandExecution/requestApproval",
|
||||
params: expectPresent(supportedApprovalRequests()[0]).params as Extract<
|
||||
ServerRequest,
|
||||
{ method: "item/commandExecution/requestApproval" }
|
||||
>["params"],
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
state = chatStateWith(state, {
|
||||
requests: {
|
||||
pendingUserInputs: [
|
||||
{
|
||||
requestId: 20,
|
||||
pendingUserInputFromRequest({
|
||||
id: 20,
|
||||
method: "item/tool/requestUserInput",
|
||||
params: {
|
||||
threadId: "thread-active",
|
||||
|
|
@ -1248,7 +1256,7 @@ describe("ChatInboundHandler", () => {
|
|||
questions: [{ id: "note", header: "Note", question: "What now?", isOther: false, isSecret: false, options: null }],
|
||||
autoResolutionMs: null,
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import {
|
|||
} from "../../../../../src/app-server/protocol/server-requests";
|
||||
import { approvalDetails, approvalSummary, approvalTitle } from "../../../../../src/features/chat/domain/pending-requests/approval";
|
||||
import { createApprovalResultItem } from "../../../../../src/features/chat/domain/pending-requests/result-items";
|
||||
import type { CommandApprovalDecision } from "../../../../../src/domain/pending-requests/model";
|
||||
import { approvalActionOptions } from "../../../../../src/features/chat/presentation/pending-requests/approval-view";
|
||||
import type { ServerRequest } from "../../../../../src/app-server/connection/rpc-messages";
|
||||
|
||||
|
|
@ -80,13 +79,7 @@ describe("approval model", () => {
|
|||
} as unknown as ServerRequest),
|
||||
);
|
||||
|
||||
expect(approval).toMatchObject({
|
||||
method: "item/fileChange/requestApproval",
|
||||
params: {
|
||||
reason: null,
|
||||
grantRoot: null,
|
||||
},
|
||||
});
|
||||
expect(approval).toMatchObject({ kind: "fileChange", title: "File change approval" });
|
||||
expect(approvalSummary(approval)).toBe("Allow file changes?");
|
||||
expect(approvalDetails(approval)).toEqual([]);
|
||||
});
|
||||
|
|
@ -94,10 +87,10 @@ describe("approval model", () => {
|
|||
it("uses command approval decisions supplied by app-server", () => {
|
||||
const allowRegistryDecision = {
|
||||
applyNetworkPolicyAmendment: { network_policy_amendment: { host: "registry.npmjs.org", action: "allow" } },
|
||||
} satisfies CommandApprovalDecision;
|
||||
} as const;
|
||||
const allowApiDecision = {
|
||||
applyNetworkPolicyAmendment: { network_policy_amendment: { host: "api.github.com", action: "allow" } },
|
||||
} satisfies CommandApprovalDecision;
|
||||
} as const;
|
||||
const request: ServerRequest = {
|
||||
id: 30,
|
||||
method: "item/commandExecution/requestApproval",
|
||||
|
|
@ -134,7 +127,7 @@ describe("approval model", () => {
|
|||
});
|
||||
|
||||
it("keeps future simple command decisions renderable", () => {
|
||||
const futureDecision = "restartWithNetwork" as CommandApprovalDecision;
|
||||
const futureDecision = "restartWithNetwork";
|
||||
const request = {
|
||||
id: 32,
|
||||
method: "item/commandExecution/requestApproval",
|
||||
|
|
@ -157,9 +150,9 @@ describe("approval model", () => {
|
|||
|
||||
expect(options).toEqual([
|
||||
{
|
||||
id: "command-decision:0:restartWithNetwork",
|
||||
id: "approval-option:0:restartWithNetwork",
|
||||
label: "Choose",
|
||||
action: { kind: "command-decision", decision: futureDecision },
|
||||
action: { kind: "approval-option", intent: "decline", response: { decision: futureDecision } },
|
||||
className: "mod-warning",
|
||||
},
|
||||
]);
|
||||
|
|
@ -415,7 +408,7 @@ describe("approval model", () => {
|
|||
|
||||
for (const { request, acceptSession, cancel } of requests) {
|
||||
const approval = expectPresent(toPendingApproval(request));
|
||||
if (approval.method === "item/commandExecution/requestApproval") {
|
||||
if (approval.kind === "command") {
|
||||
const options = approvalActionOptions(approval);
|
||||
expect(approvalResponse(approval, expectPresent(options[0]).action)).toEqual(acceptSession);
|
||||
expect(approvalResponse(approval, expectPresent(options[1]).action)).toEqual(cancel);
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ describe("MCP elicitation request model", () => {
|
|||
|
||||
expect(input).toMatchObject({
|
||||
requestId: 42,
|
||||
method: "mcpServer/elicitation/request",
|
||||
params: {
|
||||
mode: "form",
|
||||
threadId: "thread",
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ describe("user input model", () => {
|
|||
};
|
||||
|
||||
const input = expectPresent(toPendingUserInput(request));
|
||||
expect(input).toMatchObject({ requestId: 7, method: "item/tool/requestUserInput" });
|
||||
expect(input).toMatchObject({ requestId: 7 });
|
||||
expect(questionDefaultAnswer(expectPresent(request.params.questions[0]))).toBe("Recommended");
|
||||
expect(answersForPendingUserInput(input, new Map())).toEqual({ direction: "Recommended" });
|
||||
expect(answersForPendingUserInput(input, new Map([["7:direction", "Left"]]))).toEqual({ direction: "Left" });
|
||||
|
|
@ -82,7 +82,7 @@ describe("user input model", () => {
|
|||
expect(pendingRequestFocusSignature([], [input], [])).toBe(
|
||||
JSON.stringify({
|
||||
approvals: [],
|
||||
inputs: [{ id: 7, method: "item/tool/requestUserInput" }],
|
||||
inputs: [{ id: 7 }],
|
||||
mcpElicitations: [],
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -762,15 +762,24 @@ function suggestion(display: string): ChatState["composer"]["suggestions"][numbe
|
|||
function approval(requestId: number): ChatState["requests"]["approvals"][number] {
|
||||
return {
|
||||
requestId,
|
||||
method: "item/commandExecution/requestApproval",
|
||||
params: { threadId: "thread", turnId: "turn", itemId: "item", command: "pwd", cwd: "/tmp", reason: "Need access", startedAtMs: 1 },
|
||||
kind: "command",
|
||||
turnId: "turn",
|
||||
title: "Command approval",
|
||||
summary: "Need access\npwd",
|
||||
resultSummary: "Need access",
|
||||
details: [
|
||||
{ key: "reason", value: "Need access" },
|
||||
{ key: "command", value: "pwd" },
|
||||
{ key: "cwd", value: "/tmp" },
|
||||
],
|
||||
responses: { accept: {}, acceptSession: {}, decline: {}, cancel: {} },
|
||||
actionOptions: null,
|
||||
};
|
||||
}
|
||||
|
||||
function userInput(requestId: number): ChatState["requests"]["pendingUserInputs"][number] {
|
||||
return {
|
||||
requestId,
|
||||
method: "item/tool/requestUserInput",
|
||||
params: {
|
||||
threadId: "thread",
|
||||
turnId: "turn",
|
||||
|
|
|
|||
|
|
@ -308,26 +308,36 @@ describe("pending request renderer decisions", () => {
|
|||
|
||||
it("renders command approval buttons from app-server available decisions", () => {
|
||||
const parent = document.createElement("div");
|
||||
const allowResponse = {
|
||||
decision: { applyNetworkPolicyAmendment: { network_policy_amendment: { host: "registry.npmjs.org", action: "allow" } } },
|
||||
};
|
||||
const denyResponse = { decision: "decline" };
|
||||
const approval: PendingApproval = {
|
||||
requestId: 43,
|
||||
method: "item/commandExecution/requestApproval",
|
||||
params: {
|
||||
threadId: "thread",
|
||||
turnId: "turn",
|
||||
itemId: "command",
|
||||
startedAtMs: 1,
|
||||
reason: "Needs network",
|
||||
networkApprovalContext: { host: "registry.npmjs.org", protocol: "https" },
|
||||
command: null,
|
||||
cwd: "/vault",
|
||||
commandActions: [],
|
||||
proposedExecpolicyAmendment: null,
|
||||
proposedNetworkPolicyAmendments: [],
|
||||
availableDecisions: [
|
||||
{ applyNetworkPolicyAmendment: { network_policy_amendment: { host: "registry.npmjs.org", action: "allow" } } },
|
||||
"decline",
|
||||
],
|
||||
},
|
||||
kind: "command",
|
||||
turnId: "turn",
|
||||
title: "Command approval",
|
||||
summary: "Needs network",
|
||||
resultSummary: "Needs network",
|
||||
details: [
|
||||
{ key: "reason", value: "Needs network" },
|
||||
{ key: "network", value: "https://registry.npmjs.org" },
|
||||
],
|
||||
responses: { accept: {}, acceptSession: {}, decline: denyResponse, cancel: {} },
|
||||
actionOptions: [
|
||||
{
|
||||
id: "approval-option:0:network-allow",
|
||||
label: "Allow network rule",
|
||||
intent: "accept-session",
|
||||
action: { kind: "approval-option", intent: "accept-session", response: allowResponse },
|
||||
},
|
||||
{
|
||||
id: "approval-option:1:decline",
|
||||
label: "Deny",
|
||||
intent: "decline",
|
||||
action: { kind: "approval-option", intent: "decline", response: denyResponse },
|
||||
},
|
||||
],
|
||||
};
|
||||
const resolveApproval = vi.fn();
|
||||
|
||||
|
|
@ -352,8 +362,9 @@ describe("pending request renderer decisions", () => {
|
|||
allowButton.click();
|
||||
});
|
||||
expect(resolveApproval).toHaveBeenCalledWith(approval.requestId, {
|
||||
kind: "command-decision",
|
||||
decision: { applyNetworkPolicyAmendment: { network_policy_amendment: { host: "registry.npmjs.org", action: "allow" } } },
|
||||
kind: "approval-option",
|
||||
intent: "accept-session",
|
||||
response: allowResponse,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -776,7 +787,6 @@ function pendingMcpElicitation({
|
|||
} = {}): PendingMcpElicitation {
|
||||
return {
|
||||
requestId,
|
||||
method: "mcpServer/elicitation/request",
|
||||
params: {
|
||||
threadId: "thread",
|
||||
turnId: null,
|
||||
|
|
@ -804,7 +814,6 @@ function pendingMcpElicitation({
|
|||
function pendingMcpMultiSelectElicitation(): ReturnType<typeof pendingMcpElicitationViewModel> {
|
||||
return pendingMcpElicitationViewModel({
|
||||
requestId: 53,
|
||||
method: "mcpServer/elicitation/request",
|
||||
params: {
|
||||
threadId: "thread",
|
||||
turnId: null,
|
||||
|
|
|
|||
|
|
@ -294,7 +294,6 @@ export function withMessageContentScrollHeight<T>(scrollHeight: number, fn: () =
|
|||
export function pendingUserInput(): PendingUserInput {
|
||||
return {
|
||||
requestId: 99,
|
||||
method: "item/tool/requestUserInput",
|
||||
params: {
|
||||
threadId: "thread",
|
||||
turnId: "turn",
|
||||
|
|
@ -350,16 +349,22 @@ export function pendingFreeformUserInput(): PendingUserInput {
|
|||
export function pendingApproval(): PendingApproval {
|
||||
return {
|
||||
requestId: 42,
|
||||
method: "item/permissions/requestApproval",
|
||||
params: {
|
||||
threadId: "thread",
|
||||
turnId: "turn",
|
||||
itemId: "permission",
|
||||
environmentId: null,
|
||||
startedAtMs: 1,
|
||||
cwd: "/vault",
|
||||
reason: "Need network",
|
||||
permissions: { network: { enabled: true }, fileSystem: null },
|
||||
kind: "permission",
|
||||
turnId: "turn",
|
||||
title: "Permission approval",
|
||||
summary: "Need network\ncwd: /vault",
|
||||
resultSummary: "Need network",
|
||||
details: [
|
||||
{ key: "reason", value: "Need network" },
|
||||
{ key: "cwd", value: "/vault" },
|
||||
{ key: "network", value: "enabled" },
|
||||
],
|
||||
responses: {
|
||||
accept: { permissions: { network: { enabled: true } }, scope: "turn" },
|
||||
acceptSession: { permissions: { network: { enabled: true } }, scope: "session" },
|
||||
decline: { permissions: {}, scope: "turn" },
|
||||
cancel: { permissions: {}, scope: "turn" },
|
||||
},
|
||||
actionOptions: null,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue