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,
|
ListPersistedAgentsOptions,
|
||||||
McpServerConfig,
|
McpServerConfig,
|
||||||
PersistedAgentDescriptor,
|
PersistedAgentDescriptor,
|
||||||
|
ToolCallDetail,
|
||||||
ToolCallTimelineItem,
|
ToolCallTimelineItem,
|
||||||
} from "../agent-sdk-types.js";
|
} from "../agent-sdk-types.js";
|
||||||
import {
|
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(
|
export function translateOpenCodeEvent(
|
||||||
event: OpenCodeEvent,
|
event: OpenCodeEvent,
|
||||||
state: OpenCodeEventTranslationState,
|
state: OpenCodeEventTranslationState,
|
||||||
@@ -1353,6 +1466,31 @@ export function translateOpenCodeEvent(
|
|||||||
break;
|
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({
|
events.push({
|
||||||
type: "permission_requested",
|
type: "permission_requested",
|
||||||
provider: "opencode",
|
provider: "opencode",
|
||||||
@@ -1361,9 +1499,10 @@ export function translateOpenCodeEvent(
|
|||||||
provider: "opencode",
|
provider: "opencode",
|
||||||
name: event.properties.permission,
|
name: event.properties.permission,
|
||||||
kind: "tool",
|
kind: "tool",
|
||||||
title: event.properties.permission,
|
title: toHumanReadablePermissionTitle(event.properties.permission),
|
||||||
description: event.properties.patterns?.join(", "),
|
...(description ? { description } : {}),
|
||||||
input: event.properties.metadata,
|
input,
|
||||||
|
detail,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
break;
|
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", () => {
|
it("emits usage_updated after step-finish parts", () => {
|
||||||
const state = createState();
|
const state = createState();
|
||||||
state.accumulatedUsage.contextWindowMaxTokens = 400_000;
|
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", () => {
|
it("rejects removed initialize_agent_request inbound payload", () => {
|
||||||
const parsed = SessionInboundMessageSchema.safeParse({
|
const parsed = SessionInboundMessageSchema.safeParse({
|
||||||
type: "initialize_agent_request",
|
type: "initialize_agent_request",
|
||||||
|
|||||||
@@ -259,6 +259,7 @@ export const AgentPermissionRequestPayloadSchema: z.ZodType<AgentPermissionReque
|
|||||||
title: z.string().optional(),
|
title: z.string().optional(),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
input: z.record(z.unknown()).optional(),
|
input: z.record(z.unknown()).optional(),
|
||||||
|
detail: z.lazy(() => ToolCallDetailPayloadSchema).optional(),
|
||||||
suggestions: z.array(AgentPermissionUpdateSchema).optional(),
|
suggestions: z.array(AgentPermissionUpdateSchema).optional(),
|
||||||
actions: z.array(AgentPermissionActionSchema).optional(),
|
actions: z.array(AgentPermissionActionSchema).optional(),
|
||||||
metadata: z.record(z.unknown()).optional(),
|
metadata: z.record(z.unknown()).optional(),
|
||||||
|
|||||||
Reference in New Issue
Block a user