fix(server): Improve OpenCode permission prompt context (#398)
Surface structured permission detail for OpenCode requests so clients can show command intent and richer context instead of generic placeholders. Humanize permission titles and include scope/reason metadata to make approval decisions clearer.
This commit is contained in:
@@ -36,6 +36,7 @@ import type {
|
||||
ListPersistedAgentsOptions,
|
||||
McpServerConfig,
|
||||
PersistedAgentDescriptor,
|
||||
ToolCallDetail,
|
||||
ToolCallTimelineItem,
|
||||
} from "../agent-sdk-types.js";
|
||||
import {
|
||||
@@ -1191,6 +1192,118 @@ function createCompactionTimelineItem(
|
||||
};
|
||||
}
|
||||
|
||||
const PERMISSION_COMMAND_KEYS = ["command", "cmd", "shellCommand"] as const;
|
||||
const PERMISSION_CWD_KEYS = ["cwd", "directory", "path", "workdir"] as const;
|
||||
const PERMISSION_REASON_KEYS = ["reason", "purpose", "description", "message"] as const;
|
||||
const PERMISSION_TITLE_BY_NAME: Record<string, string> = {
|
||||
external_directory: "Access external directory",
|
||||
bash: "Run shell command",
|
||||
read: "Read files",
|
||||
read_file: "Read files",
|
||||
write: "Write files",
|
||||
write_file: "Write files",
|
||||
create_file: "Write files",
|
||||
edit: "Edit files",
|
||||
apply_patch: "Edit files",
|
||||
apply_diff: "Edit files",
|
||||
};
|
||||
|
||||
function toHumanReadablePermissionTitle(permission: string): string {
|
||||
const mapped = PERMISSION_TITLE_BY_NAME[permission];
|
||||
if (mapped) {
|
||||
return mapped;
|
||||
}
|
||||
|
||||
const normalized = permission
|
||||
.split(/[\s_-]+/)
|
||||
.map((part) => part.trim())
|
||||
.filter((part) => part.length > 0)
|
||||
.map((part) => `${part[0]?.toUpperCase() ?? ""}${part.slice(1)}`)
|
||||
.join(" ");
|
||||
return normalized.length > 0 ? normalized : "Permission request";
|
||||
}
|
||||
|
||||
function readFirstStringFromRecord(
|
||||
record: Record<string, unknown> | null,
|
||||
keys: readonly string[],
|
||||
): string | null {
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
for (const key of keys) {
|
||||
const value = readNonEmptyString(record[key]);
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function readPermissionField(
|
||||
metadata: Record<string, unknown> | null,
|
||||
keys: readonly string[],
|
||||
): string | null {
|
||||
const direct = readFirstStringFromRecord(metadata, keys);
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
|
||||
const nestedInput = readOpenCodeRecord(metadata?.input);
|
||||
return readFirstStringFromRecord(nestedInput, keys);
|
||||
}
|
||||
|
||||
function buildOpenCodePermissionInput(params: {
|
||||
patterns: string[];
|
||||
metadata: Record<string, unknown> | null;
|
||||
tool: Record<string, unknown> | null;
|
||||
command: string | null;
|
||||
}): Record<string, unknown> {
|
||||
return {
|
||||
...(params.patterns.length > 0 ? { patterns: params.patterns } : {}),
|
||||
...(params.metadata ? { metadata: params.metadata } : {}),
|
||||
...(params.tool ? { tool: params.tool } : {}),
|
||||
...(params.command ? { command: params.command } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function buildOpenCodePermissionDetail(params: {
|
||||
permission: string;
|
||||
input: Record<string, unknown>;
|
||||
command: string | null;
|
||||
cwd: string | null;
|
||||
}): ToolCallDetail {
|
||||
if (params.command) {
|
||||
return {
|
||||
type: "shell",
|
||||
command: params.command,
|
||||
...(params.cwd ? { cwd: params.cwd } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "unknown",
|
||||
input: {
|
||||
permission: params.permission,
|
||||
...params.input,
|
||||
},
|
||||
output: null,
|
||||
};
|
||||
}
|
||||
|
||||
function buildOpenCodePermissionDescription(params: {
|
||||
reason: string | null;
|
||||
patterns: string[];
|
||||
}): string | undefined {
|
||||
const parts: string[] = [];
|
||||
if (params.reason) {
|
||||
parts.push(params.reason);
|
||||
}
|
||||
if (params.patterns.length > 0) {
|
||||
parts.push(`Scope: ${params.patterns.join(", ")}`);
|
||||
}
|
||||
return parts.length > 0 ? parts.join(" - ") : undefined;
|
||||
}
|
||||
|
||||
export function translateOpenCodeEvent(
|
||||
event: OpenCodeEvent,
|
||||
state: OpenCodeEventTranslationState,
|
||||
@@ -1353,6 +1466,31 @@ export function translateOpenCodeEvent(
|
||||
break;
|
||||
}
|
||||
|
||||
const metadata = readOpenCodeRecord(event.properties.metadata);
|
||||
const tool = readOpenCodeRecord(event.properties.tool);
|
||||
const patterns = Array.isArray(event.properties.patterns)
|
||||
? event.properties.patterns.filter((value): value is string => typeof value === "string")
|
||||
: [];
|
||||
const command = readPermissionField(metadata, PERMISSION_COMMAND_KEYS);
|
||||
const cwd = readPermissionField(metadata, PERMISSION_CWD_KEYS);
|
||||
const reason = readPermissionField(metadata, PERMISSION_REASON_KEYS);
|
||||
const input = buildOpenCodePermissionInput({
|
||||
patterns,
|
||||
metadata,
|
||||
tool,
|
||||
command,
|
||||
});
|
||||
const detail = buildOpenCodePermissionDetail({
|
||||
permission: event.properties.permission,
|
||||
input,
|
||||
command,
|
||||
cwd,
|
||||
});
|
||||
const description = buildOpenCodePermissionDescription({
|
||||
reason,
|
||||
patterns,
|
||||
});
|
||||
|
||||
events.push({
|
||||
type: "permission_requested",
|
||||
provider: "opencode",
|
||||
@@ -1361,9 +1499,10 @@ export function translateOpenCodeEvent(
|
||||
provider: "opencode",
|
||||
name: event.properties.permission,
|
||||
kind: "tool",
|
||||
title: event.properties.permission,
|
||||
description: event.properties.patterns?.join(", "),
|
||||
input: event.properties.metadata,
|
||||
title: toHumanReadablePermissionTitle(event.properties.permission),
|
||||
...(description ? { description } : {}),
|
||||
input,
|
||||
detail,
|
||||
},
|
||||
});
|
||||
break;
|
||||
|
||||
@@ -255,6 +255,114 @@ describe("translateOpenCodeEvent", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("humanizes permission requests and includes shell detail when command metadata exists", () => {
|
||||
const state = createState();
|
||||
|
||||
const result = translateOpenCodeEvent(
|
||||
{
|
||||
type: "permission.asked",
|
||||
properties: {
|
||||
id: "perm-1",
|
||||
sessionID: "session-1",
|
||||
permission: "external_directory",
|
||||
patterns: ["/home/user/secrets/*"],
|
||||
metadata: {
|
||||
command: "ls /home/user/secrets",
|
||||
reason: "Need to inspect generated files",
|
||||
},
|
||||
tool: {
|
||||
messageID: "message-1",
|
||||
callID: "call-1",
|
||||
},
|
||||
},
|
||||
},
|
||||
state,
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
type: "permission_requested",
|
||||
provider: "opencode",
|
||||
request: {
|
||||
id: "perm-1",
|
||||
provider: "opencode",
|
||||
name: "external_directory",
|
||||
kind: "tool",
|
||||
title: "Access external directory",
|
||||
description: "Need to inspect generated files - Scope: /home/user/secrets/*",
|
||||
input: {
|
||||
patterns: ["/home/user/secrets/*"],
|
||||
metadata: {
|
||||
command: "ls /home/user/secrets",
|
||||
reason: "Need to inspect generated files",
|
||||
},
|
||||
tool: {
|
||||
messageID: "message-1",
|
||||
callID: "call-1",
|
||||
},
|
||||
command: "ls /home/user/secrets",
|
||||
},
|
||||
detail: {
|
||||
type: "shell",
|
||||
command: "ls /home/user/secrets",
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls back to unknown permission detail when command metadata is absent", () => {
|
||||
const state = createState();
|
||||
|
||||
const result = translateOpenCodeEvent(
|
||||
{
|
||||
type: "permission.asked",
|
||||
properties: {
|
||||
id: "perm-2",
|
||||
sessionID: "session-1",
|
||||
permission: "external_directory",
|
||||
patterns: ["/tmp/outside/*"],
|
||||
metadata: {
|
||||
reason: "Need to access temporary checkout",
|
||||
},
|
||||
},
|
||||
},
|
||||
state,
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
type: "permission_requested",
|
||||
provider: "opencode",
|
||||
request: {
|
||||
id: "perm-2",
|
||||
provider: "opencode",
|
||||
name: "external_directory",
|
||||
kind: "tool",
|
||||
title: "Access external directory",
|
||||
description: "Need to access temporary checkout - Scope: /tmp/outside/*",
|
||||
input: {
|
||||
patterns: ["/tmp/outside/*"],
|
||||
metadata: {
|
||||
reason: "Need to access temporary checkout",
|
||||
},
|
||||
},
|
||||
detail: {
|
||||
type: "unknown",
|
||||
input: {
|
||||
permission: "external_directory",
|
||||
patterns: ["/tmp/outside/*"],
|
||||
metadata: {
|
||||
reason: "Need to access temporary checkout",
|
||||
},
|
||||
},
|
||||
output: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("emits usage_updated after step-finish parts", () => {
|
||||
const state = createState();
|
||||
state.accumulatedUsage.contextWindowMaxTokens = 400_000;
|
||||
|
||||
@@ -207,6 +207,44 @@ describe("shared messages stream parsing", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("parses permission request detail compatibly", () => {
|
||||
const parsed = AgentStreamMessageSchema.parse({
|
||||
type: "agent_stream",
|
||||
payload: {
|
||||
agentId: "agent_live",
|
||||
timestamp: "2026-02-08T20:10:00.000Z",
|
||||
event: {
|
||||
type: "permission_requested",
|
||||
provider: "opencode",
|
||||
request: {
|
||||
id: "perm-shell-1",
|
||||
provider: "opencode",
|
||||
name: "external_directory",
|
||||
kind: "tool",
|
||||
title: "Access external directory",
|
||||
input: {
|
||||
command: "ls /tmp/outside",
|
||||
},
|
||||
detail: {
|
||||
type: "shell",
|
||||
command: "ls /tmp/outside",
|
||||
cwd: "/home/dev/project",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.payload.event.type).toBe("permission_requested");
|
||||
if (parsed.payload.event.type === "permission_requested") {
|
||||
expect(parsed.payload.event.request.detail).toEqual({
|
||||
type: "shell",
|
||||
command: "ls /tmp/outside",
|
||||
cwd: "/home/dev/project",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects removed initialize_agent_request inbound payload", () => {
|
||||
const parsed = SessionInboundMessageSchema.safeParse({
|
||||
type: "initialize_agent_request",
|
||||
|
||||
@@ -259,6 +259,7 @@ export const AgentPermissionRequestPayloadSchema: z.ZodType<AgentPermissionReque
|
||||
title: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
input: z.record(z.unknown()).optional(),
|
||||
detail: z.lazy(() => ToolCallDetailPayloadSchema).optional(),
|
||||
suggestions: z.array(AgentPermissionUpdateSchema).optional(),
|
||||
actions: z.array(AgentPermissionActionSchema).optional(),
|
||||
metadata: z.record(z.unknown()).optional(),
|
||||
|
||||
Reference in New Issue
Block a user