mirror of
https://github.com/moltbot/moltbot.git
synced 2026-05-13 23:56:07 +00:00
Fix repeated Codex native approval prompts after allow-always (#78234)
* fix: reuse codex native approvals * fix: scope native approval reuse by session * fix: let codex guardian own native permission approvals * fix: refresh plugin approval protocol models --------- Co-authored-by: pashpashpash <nik@vault77.ai>
This commit is contained in:
@@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Plugin skills/Windows: publish plugin-provided skill directories as junctions on Windows so standard users without Developer Mode can register plugin skills without symlink EPERM failures. Fixes #77958. (#77971) Thanks @hclsys and @jarro.
|
||||
- MS Teams: surface blocked Bot Framework egress by logging JWKS fetch network failures and adding a Bot Connector send hint for transport-level reply failures. Fixes #77674. (#78081) Thanks @Beandon13.
|
||||
- Gateway/sessions: fast-path already-qualified model refs while building session-list rows so `openclaw sessions` and Control UI session lists avoid heavyweight model resolution on large stores. (#77902) Thanks @ragesaq.
|
||||
- Codex/approvals: in Codex approval modes, stop installing the pre-guardian native `PermissionRequest` hook by default so Codex's reviewer can approve safe commands before OpenClaw surfaces an approval, remember `allow-always` decisions for identical Codex native `PermissionRequest` payloads within the active session window, and make plugin approval requests validate/render their actual allowed decisions so Telegram and other native approval UIs cannot offer stale actions. Thanks @shakkernerd.
|
||||
- PR triage: mark external pull requests with `proof: supplied` when Barnacle finds structured real behavior proof, keep stale negative proof labels in sync across CRLF-edited PR bodies, and let ClawSweeper own the stronger `proof: sufficient` judgement.
|
||||
- Sessions CLI: show the selected agent runtime in the `openclaw sessions` table so terminal output matches the runtime visibility already present in JSON/status surfaces. Thanks @vincentkoc.
|
||||
- Talk/voice: unify realtime relay, transcription relay, managed-room handoff, Voice Call, Google Meet, VoiceClaw, and native clients around a shared Talk session controller and add the Gateway-managed `talk.session.*` RPC surface.
|
||||
|
||||
@@ -5227,6 +5227,7 @@ public struct PluginApprovalRequestParams: Codable, Sendable {
|
||||
public let severity: String?
|
||||
public let toolname: String?
|
||||
public let toolcallid: String?
|
||||
public let alloweddecisions: [String]?
|
||||
public let agentid: String?
|
||||
public let sessionkey: String?
|
||||
public let turnsourcechannel: String?
|
||||
@@ -5243,6 +5244,7 @@ public struct PluginApprovalRequestParams: Codable, Sendable {
|
||||
severity: String?,
|
||||
toolname: String?,
|
||||
toolcallid: String?,
|
||||
alloweddecisions: [String]?,
|
||||
agentid: String?,
|
||||
sessionkey: String?,
|
||||
turnsourcechannel: String?,
|
||||
@@ -5258,6 +5260,7 @@ public struct PluginApprovalRequestParams: Codable, Sendable {
|
||||
self.severity = severity
|
||||
self.toolname = toolname
|
||||
self.toolcallid = toolcallid
|
||||
self.alloweddecisions = alloweddecisions
|
||||
self.agentid = agentid
|
||||
self.sessionkey = sessionkey
|
||||
self.turnsourcechannel = turnsourcechannel
|
||||
@@ -5275,6 +5278,7 @@ public struct PluginApprovalRequestParams: Codable, Sendable {
|
||||
case severity
|
||||
case toolname = "toolName"
|
||||
case toolcallid = "toolCallId"
|
||||
case alloweddecisions = "allowedDecisions"
|
||||
case agentid = "agentId"
|
||||
case sessionkey = "sessionKey"
|
||||
case turnsourcechannel = "turnSourceChannel"
|
||||
|
||||
@@ -5227,6 +5227,7 @@ public struct PluginApprovalRequestParams: Codable, Sendable {
|
||||
public let severity: String?
|
||||
public let toolname: String?
|
||||
public let toolcallid: String?
|
||||
public let alloweddecisions: [String]?
|
||||
public let agentid: String?
|
||||
public let sessionkey: String?
|
||||
public let turnsourcechannel: String?
|
||||
@@ -5243,6 +5244,7 @@ public struct PluginApprovalRequestParams: Codable, Sendable {
|
||||
severity: String?,
|
||||
toolname: String?,
|
||||
toolcallid: String?,
|
||||
alloweddecisions: [String]?,
|
||||
agentid: String?,
|
||||
sessionkey: String?,
|
||||
turnsourcechannel: String?,
|
||||
@@ -5258,6 +5260,7 @@ public struct PluginApprovalRequestParams: Codable, Sendable {
|
||||
self.severity = severity
|
||||
self.toolname = toolname
|
||||
self.toolcallid = toolcallid
|
||||
self.alloweddecisions = alloweddecisions
|
||||
self.agentid = agentid
|
||||
self.sessionkey = sessionkey
|
||||
self.turnsourcechannel = turnsourcechannel
|
||||
@@ -5275,6 +5278,7 @@ public struct PluginApprovalRequestParams: Codable, Sendable {
|
||||
case severity
|
||||
case toolname = "toolName"
|
||||
case toolcallid = "toolCallId"
|
||||
case alloweddecisions = "allowedDecisions"
|
||||
case agentid = "agentId"
|
||||
case sessionkey = "sessionKey"
|
||||
case turnsourcechannel = "turnSourceChannel"
|
||||
|
||||
@@ -944,9 +944,14 @@ The Codex harness has three hook layers:
|
||||
OpenClaw does not use project or global Codex `hooks.json` files to route
|
||||
OpenClaw plugin behavior. For the supported native tool and permission bridge,
|
||||
OpenClaw injects per-thread Codex config for `PreToolUse`, `PostToolUse`,
|
||||
`PermissionRequest`, and `Stop`. Other Codex hooks such as `SessionStart` and
|
||||
`UserPromptSubmit` remain Codex-level controls; they are not exposed as
|
||||
OpenClaw plugin hooks in the v1 contract.
|
||||
`PermissionRequest`, and `Stop`. When Codex app-server approvals are enabled
|
||||
(`approvalPolicy` is not `"never"`), the default injected native hook config
|
||||
omits `PermissionRequest` so Codex's app-server reviewer and OpenClaw's approval
|
||||
bridge handle real escalations after review. Operators can still explicitly add
|
||||
`permission_request` to `nativeHookRelay.events` when they need the compatibility
|
||||
relay. Other Codex hooks such as `SessionStart` and `UserPromptSubmit` remain
|
||||
Codex-level controls; they are not exposed as OpenClaw plugin hooks in the v1
|
||||
contract.
|
||||
|
||||
For OpenClaw dynamic tools, OpenClaw executes the tool after Codex asks for the
|
||||
call, so OpenClaw fires the plugin and middleware behavior it owns in the
|
||||
@@ -973,19 +978,19 @@ around that boundary.
|
||||
|
||||
Supported in Codex runtime v1:
|
||||
|
||||
| Surface | Support | Why |
|
||||
| --------------------------------------------- | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| OpenAI model loop through Codex | Supported | Codex app-server owns the OpenAI turn, native thread resume, and native tool continuation. |
|
||||
| OpenClaw channel routing and delivery | Supported | Telegram, Discord, Slack, WhatsApp, iMessage, and other channels stay outside the model runtime. |
|
||||
| OpenClaw dynamic tools | Supported | Codex asks OpenClaw to execute these tools, so OpenClaw stays in the execution path. |
|
||||
| Prompt and context plugins | Supported | OpenClaw builds prompt overlays and projects context into the Codex turn before starting or resuming the thread. |
|
||||
| Context engine lifecycle | Supported | Assemble, ingest or after-turn maintenance, and context-engine compaction coordination run for Codex turns. |
|
||||
| Dynamic tool hooks | Supported | `before_tool_call`, `after_tool_call`, and tool-result middleware run around OpenClaw-owned dynamic tools. |
|
||||
| Lifecycle hooks | Supported as adapter observations | `llm_input`, `llm_output`, `agent_end`, `before_compaction`, and `after_compaction` fire with honest Codex-mode payloads. |
|
||||
| Final-answer revision gate | Supported through the native hook relay | Codex `Stop` is relayed to `before_agent_finalize`; `revise` asks Codex for one more model pass before finalization. |
|
||||
| Native shell, patch, and MCP block or observe | Supported through the native hook relay | Codex `PreToolUse` and `PostToolUse` are relayed for committed native tool surfaces, including MCP payloads on Codex app-server `0.125.0` or newer. Blocking is supported; argument rewriting is not. |
|
||||
| Native permission policy | Supported through the native hook relay | Codex `PermissionRequest` can be routed through OpenClaw policy where the runtime exposes it. If OpenClaw returns no decision, Codex continues through its normal guardian or user approval path. |
|
||||
| App-server trajectory capture | Supported | OpenClaw records the request it sent to app-server and the app-server notifications it receives. |
|
||||
| Surface | Support | Why |
|
||||
| --------------------------------------------- | ------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| OpenAI model loop through Codex | Supported | Codex app-server owns the OpenAI turn, native thread resume, and native tool continuation. |
|
||||
| OpenClaw channel routing and delivery | Supported | Telegram, Discord, Slack, WhatsApp, iMessage, and other channels stay outside the model runtime. |
|
||||
| OpenClaw dynamic tools | Supported | Codex asks OpenClaw to execute these tools, so OpenClaw stays in the execution path. |
|
||||
| Prompt and context plugins | Supported | OpenClaw builds prompt overlays and projects context into the Codex turn before starting or resuming the thread. |
|
||||
| Context engine lifecycle | Supported | Assemble, ingest or after-turn maintenance, and context-engine compaction coordination run for Codex turns. |
|
||||
| Dynamic tool hooks | Supported | `before_tool_call`, `after_tool_call`, and tool-result middleware run around OpenClaw-owned dynamic tools. |
|
||||
| Lifecycle hooks | Supported as adapter observations | `llm_input`, `llm_output`, `agent_end`, `before_compaction`, and `after_compaction` fire with honest Codex-mode payloads. |
|
||||
| Final-answer revision gate | Supported through the native hook relay | Codex `Stop` is relayed to `before_agent_finalize`; `revise` asks Codex for one more model pass before finalization. |
|
||||
| Native shell, patch, and MCP block or observe | Supported through the native hook relay | Codex `PreToolUse` and `PostToolUse` are relayed for committed native tool surfaces, including MCP payloads on Codex app-server `0.125.0` or newer. Blocking is supported; argument rewriting is not. |
|
||||
| Native permission policy | Supported through Codex app-server approvals and the compatibility native hook relay | Codex app-server approval requests route through OpenClaw after Codex review. The `PermissionRequest` native hook relay is opt-in for native approval modes because Codex emits it before guardian review. |
|
||||
| App-server trajectory capture | Supported | OpenClaw records the request it sent to app-server and the app-server notifications it receives. |
|
||||
|
||||
Not supported in Codex runtime v1:
|
||||
|
||||
@@ -1016,6 +1021,14 @@ it.
|
||||
For `PermissionRequest`, OpenClaw only returns explicit allow or deny decisions
|
||||
when policy decides. A no-decision result is not an allow. Codex treats it as no
|
||||
hook decision and falls through to its own guardian or user approval path.
|
||||
Codex app-server approval modes omit this native hook by default; this paragraph
|
||||
applies when `permission_request` is explicitly included in
|
||||
`nativeHookRelay.events` or a compatibility runtime installs it.
|
||||
When an operator chooses `allow-always` for a Codex native permission request,
|
||||
OpenClaw remembers that exact provider/session/tool input/cwd fingerprint for a
|
||||
bounded session window. The remembered decision is intentionally exact-match
|
||||
only: a changed command, arguments, tool payload, or cwd creates a fresh
|
||||
approval.
|
||||
|
||||
Codex MCP tool approval elicitations are routed through OpenClaw's plugin
|
||||
approval flow when Codex marks `_meta.codex_approval_kind` as
|
||||
|
||||
@@ -233,6 +233,8 @@ The config shape is identical to `approvals.exec`: `enabled`, `mode`, `agentFilt
|
||||
Channels that support shared interactive replies render the same approval buttons for both exec and
|
||||
plugin approvals. Channels without shared interactive UI fall back to plain text with `/approve`
|
||||
instructions.
|
||||
Plugin approval requests may restrict the available decisions. Approval surfaces use the request's
|
||||
declared decision set, and the Gateway rejects attempts to submit a decision that was not offered.
|
||||
|
||||
### Same-chat approvals on any channel
|
||||
|
||||
|
||||
@@ -364,13 +364,28 @@ type AppServerRequestHandler = (request: {
|
||||
}) => Promise<unknown>;
|
||||
|
||||
function extractRelayIdFromThreadRequest(params: unknown): string {
|
||||
const command = (
|
||||
params as {
|
||||
config?: {
|
||||
"hooks.PreToolUse"?: Array<{ hooks?: Array<{ command?: string }> }>;
|
||||
};
|
||||
const config = (params as { config?: Record<string, unknown> }).config;
|
||||
let command: string | undefined;
|
||||
for (const key of [
|
||||
"hooks.PreToolUse",
|
||||
"hooks.PostToolUse",
|
||||
"hooks.PermissionRequest",
|
||||
"hooks.Stop",
|
||||
]) {
|
||||
const entries = config?.[key];
|
||||
if (!Array.isArray(entries)) {
|
||||
continue;
|
||||
}
|
||||
).config?.["hooks.PreToolUse"]?.[0]?.hooks?.[0]?.command;
|
||||
for (const entry of entries as Array<{ hooks?: Array<{ command?: string }> }>) {
|
||||
command = entry.hooks?.find((hook) => typeof hook.command === "string")?.command;
|
||||
if (command) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (command) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
const match = command?.match(/--relay-id ([^ ]+)/);
|
||||
if (!match?.[1]) {
|
||||
throw new Error(`relay id missing from command: ${command}`);
|
||||
@@ -1153,6 +1168,85 @@ describe("runCodexAppServerAttempt", () => {
|
||||
expect(nativeHookRelayTesting.getNativeHookRelayRegistrationForTests(relayId)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("lets Codex app-server approval modes own native permission requests by default", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const harness = createStartedThreadHarness();
|
||||
|
||||
const run = runCodexAppServerAttempt(createParams(sessionFile, workspaceDir), {
|
||||
pluginConfig: {
|
||||
appServer: {
|
||||
mode: "guardian",
|
||||
},
|
||||
},
|
||||
});
|
||||
await harness.waitForMethod("turn/start");
|
||||
|
||||
const startRequest = harness.requests.find((request) => request.method === "thread/start");
|
||||
expect(startRequest?.params).toEqual(
|
||||
expect.objectContaining({
|
||||
config: expect.objectContaining({
|
||||
"features.codex_hooks": true,
|
||||
"hooks.PreToolUse": expect.any(Array),
|
||||
"hooks.PostToolUse": expect.any(Array),
|
||||
"hooks.Stop": expect.any(Array),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(startRequest?.params).toEqual(
|
||||
expect.objectContaining({
|
||||
config: expect.not.objectContaining({
|
||||
"hooks.PermissionRequest": expect.anything(),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const relayId = extractRelayIdFromThreadRequest(startRequest?.params);
|
||||
expect(nativeHookRelayTesting.getNativeHookRelayRegistrationForTests(relayId)).toMatchObject({
|
||||
allowedEvents: ["pre_tool_use", "post_tool_use", "before_agent_finalize"],
|
||||
});
|
||||
|
||||
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
|
||||
await run;
|
||||
expect(nativeHookRelayTesting.getNativeHookRelayRegistrationForTests(relayId)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves explicit native permission request relay events in app-server approval modes", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const harness = createStartedThreadHarness();
|
||||
|
||||
const run = runCodexAppServerAttempt(createParams(sessionFile, workspaceDir), {
|
||||
pluginConfig: {
|
||||
appServer: {
|
||||
mode: "guardian",
|
||||
},
|
||||
},
|
||||
nativeHookRelay: {
|
||||
enabled: true,
|
||||
events: ["permission_request"],
|
||||
},
|
||||
});
|
||||
await harness.waitForMethod("turn/start");
|
||||
|
||||
const startRequest = harness.requests.find((request) => request.method === "thread/start");
|
||||
expect(startRequest?.params).toEqual(
|
||||
expect.objectContaining({
|
||||
config: expect.objectContaining({
|
||||
"features.codex_hooks": true,
|
||||
"hooks.PermissionRequest": expect.any(Array),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const relayId = extractRelayIdFromThreadRequest(startRequest?.params);
|
||||
expect(nativeHookRelayTesting.getNativeHookRelayRegistrationForTests(relayId)).toMatchObject({
|
||||
allowedEvents: ["permission_request"],
|
||||
});
|
||||
|
||||
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
|
||||
await run;
|
||||
expect(nativeHookRelayTesting.getNativeHookRelayRegistrationForTests(relayId)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reuses the Codex native hook relay id across runs for the same session", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
|
||||
@@ -60,6 +60,7 @@ import { ensureCodexComputerUse } from "./computer-use.js";
|
||||
import {
|
||||
readCodexPluginConfig,
|
||||
resolveCodexAppServerRuntimeOptions,
|
||||
type CodexAppServerRuntimeOptions,
|
||||
type CodexPluginConfig,
|
||||
} from "./config.js";
|
||||
import { projectContextEngineAssemblyForCodex } from "./context-engine-projection.js";
|
||||
@@ -121,6 +122,8 @@ const CODEX_TURN_TERMINAL_IDLE_TIMEOUT_MS = 30 * 60_000;
|
||||
const CODEX_STEER_ALL_DEBOUNCE_MS = 500;
|
||||
const LOG_FIELD_MAX_LENGTH = 160;
|
||||
const CODEX_NATIVE_PROJECT_DOC_BASENAMES = new Set(["agents.md"]);
|
||||
const CODEX_NATIVE_HOOK_RELAY_EVENTS_WITH_APP_SERVER_APPROVALS =
|
||||
CODEX_NATIVE_HOOK_RELAY_EVENTS.filter((event) => event !== "permission_request");
|
||||
const CODEX_BOOTSTRAP_CONTEXT_ORDER = new Map<string, number>([
|
||||
["soul.md", 10],
|
||||
["identity.md", 20],
|
||||
@@ -370,6 +373,10 @@ export async function runCodexAppServerAttempt(
|
||||
const attemptClientFactory = resolveCodexAppServerClientFactory();
|
||||
const pluginConfig = readCodexPluginConfig(options.pluginConfig);
|
||||
const appServer = resolveCodexAppServerRuntimeOptions({ pluginConfig });
|
||||
const nativeHookRelayEvents = resolveCodexNativeHookRelayEvents({
|
||||
configuredEvents: options.nativeHookRelay?.events,
|
||||
appServer,
|
||||
});
|
||||
const resolvedWorkspace = resolveUserPath(params.workspaceDir);
|
||||
await fs.mkdir(resolvedWorkspace, { recursive: true });
|
||||
const sandboxSessionKey =
|
||||
@@ -574,6 +581,7 @@ export async function runCodexAppServerAttempt(
|
||||
});
|
||||
nativeHookRelay = createCodexNativeHookRelay({
|
||||
options: options.nativeHookRelay,
|
||||
events: nativeHookRelayEvents,
|
||||
agentId: sessionAgentId,
|
||||
sessionId: params.sessionId,
|
||||
sessionKey: sandboxSessionKey,
|
||||
@@ -584,7 +592,7 @@ export async function runCodexAppServerAttempt(
|
||||
const nativeHookRelayConfig = nativeHookRelay
|
||||
? buildCodexNativeHookRelayConfig({
|
||||
relay: nativeHookRelay,
|
||||
events: options.nativeHookRelay?.events,
|
||||
events: nativeHookRelayEvents,
|
||||
hookTimeoutSec: options.nativeHookRelay?.hookTimeoutSec,
|
||||
})
|
||||
: options.nativeHookRelay?.enabled === false
|
||||
@@ -1439,11 +1447,11 @@ function createCodexNativeHookRelay(params: {
|
||||
options:
|
||||
| {
|
||||
enabled?: boolean;
|
||||
events?: readonly NativeHookRelayEvent[];
|
||||
ttlMs?: number;
|
||||
gatewayTimeoutMs?: number;
|
||||
}
|
||||
| undefined;
|
||||
events: readonly NativeHookRelayEvent[];
|
||||
agentId: string | undefined;
|
||||
sessionId: string;
|
||||
sessionKey: string | undefined;
|
||||
@@ -1466,7 +1474,7 @@ function createCodexNativeHookRelay(params: {
|
||||
...(params.sessionKey ? { sessionKey: params.sessionKey } : {}),
|
||||
...(params.config ? { config: params.config } : {}),
|
||||
runId: params.runId,
|
||||
allowedEvents: params.options?.events ?? CODEX_NATIVE_HOOK_RELAY_EVENTS,
|
||||
allowedEvents: params.events,
|
||||
ttlMs: params.options?.ttlMs,
|
||||
signal: params.signal,
|
||||
command: {
|
||||
@@ -1475,6 +1483,22 @@ function createCodexNativeHookRelay(params: {
|
||||
});
|
||||
}
|
||||
|
||||
function resolveCodexNativeHookRelayEvents(params: {
|
||||
configuredEvents?: readonly NativeHookRelayEvent[];
|
||||
appServer: Pick<CodexAppServerRuntimeOptions, "approvalPolicy">;
|
||||
}): readonly NativeHookRelayEvent[] {
|
||||
if (params.configuredEvents?.length) {
|
||||
return params.configuredEvents;
|
||||
}
|
||||
// Codex emits PermissionRequest before the app-server approval reviewer has
|
||||
// resolved the command. In native approval modes, let Codex's app-server
|
||||
// approval bridge own the real escalation instead of surfacing a stale
|
||||
// pre-guardian OpenClaw plugin approval prompt.
|
||||
return params.appServer.approvalPolicy === "never"
|
||||
? CODEX_NATIVE_HOOK_RELAY_EVENTS
|
||||
: CODEX_NATIVE_HOOK_RELAY_EVENTS_WITH_APP_SERVER_APPROVALS;
|
||||
}
|
||||
|
||||
function buildCodexNativeHookRelayId(params: {
|
||||
agentId: string | undefined;
|
||||
sessionId: string;
|
||||
|
||||
@@ -1178,6 +1178,170 @@ describe("native hook relay registry", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("reuses allow-always PermissionRequest approvals for identical relay content", async () => {
|
||||
const relay = registerNativeHookRelay({
|
||||
provider: "codex",
|
||||
relayId: "codex-stable-permission-cache",
|
||||
sessionId: "session-1",
|
||||
runId: "run-1",
|
||||
});
|
||||
const approvalRequester = vi.fn(async () => "allow-always" as const);
|
||||
__testing.setNativeHookRelayPermissionApprovalRequesterForTests(approvalRequester);
|
||||
|
||||
const first = await invokeNativeHookRelay({
|
||||
provider: "codex",
|
||||
relayId: relay.relayId,
|
||||
event: "permission_request",
|
||||
rawPayload: {
|
||||
hook_event_name: "PermissionRequest",
|
||||
cwd: "/repo",
|
||||
tool_name: "Bash",
|
||||
tool_use_id: "native-call-1",
|
||||
tool_input: { command: "browserforce tabs" },
|
||||
},
|
||||
});
|
||||
relay.unregister();
|
||||
registerNativeHookRelay({
|
||||
provider: "codex",
|
||||
relayId: "codex-stable-permission-cache",
|
||||
sessionId: "session-1",
|
||||
runId: "run-2",
|
||||
});
|
||||
const second = await invokeNativeHookRelay({
|
||||
provider: "codex",
|
||||
relayId: relay.relayId,
|
||||
event: "permission_request",
|
||||
rawPayload: {
|
||||
hook_event_name: "PermissionRequest",
|
||||
cwd: "/repo",
|
||||
tool_name: "Bash",
|
||||
tool_use_id: "native-call-2",
|
||||
tool_input: { command: "browserforce tabs" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(approvalRequester).toHaveBeenCalledTimes(1);
|
||||
expect([first, second].map((response) => JSON.parse(response.stdout))).toEqual([
|
||||
{
|
||||
hookSpecificOutput: {
|
||||
hookEventName: "PermissionRequest",
|
||||
decision: { behavior: "allow" },
|
||||
},
|
||||
},
|
||||
{
|
||||
hookSpecificOutput: {
|
||||
hookEventName: "PermissionRequest",
|
||||
decision: { behavior: "allow" },
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not reuse allow-always PermissionRequest approvals across sessions with the same relay id", async () => {
|
||||
const relayId = "codex-stable-permission-cache-cross-session";
|
||||
const first = registerNativeHookRelay({
|
||||
provider: "codex",
|
||||
relayId,
|
||||
agentId: "agent-1",
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:session-1",
|
||||
runId: "run-1",
|
||||
});
|
||||
const approvalRequester = vi.fn(async () => "allow-always" as const);
|
||||
__testing.setNativeHookRelayPermissionApprovalRequesterForTests(approvalRequester);
|
||||
|
||||
await invokeNativeHookRelay({
|
||||
provider: "codex",
|
||||
relayId: first.relayId,
|
||||
event: "permission_request",
|
||||
rawPayload: {
|
||||
hook_event_name: "PermissionRequest",
|
||||
cwd: "/repo",
|
||||
tool_name: "Bash",
|
||||
tool_use_id: "native-call-1",
|
||||
tool_input: { command: "browserforce tabs" },
|
||||
},
|
||||
});
|
||||
first.unregister();
|
||||
const second = registerNativeHookRelay({
|
||||
provider: "codex",
|
||||
relayId,
|
||||
agentId: "agent-1",
|
||||
sessionId: "session-2",
|
||||
sessionKey: "agent:main:session-2",
|
||||
runId: "run-2",
|
||||
});
|
||||
await invokeNativeHookRelay({
|
||||
provider: "codex",
|
||||
relayId: second.relayId,
|
||||
event: "permission_request",
|
||||
rawPayload: {
|
||||
hook_event_name: "PermissionRequest",
|
||||
cwd: "/repo",
|
||||
tool_name: "Bash",
|
||||
tool_use_id: "native-call-2",
|
||||
tool_input: { command: "browserforce tabs" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(approvalRequester).toHaveBeenCalledTimes(2);
|
||||
expect(approvalRequester).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
agentId: "agent-1",
|
||||
sessionId: "session-2",
|
||||
sessionKey: "agent:main:session-2",
|
||||
toolInput: { command: "browserforce tabs" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps allow-always PermissionRequest reuse scoped to matching cwd and input", async () => {
|
||||
const relay = registerNativeHookRelay({
|
||||
provider: "codex",
|
||||
sessionId: "session-1",
|
||||
runId: "run-1",
|
||||
});
|
||||
const approvalRequester = vi.fn(async () => "allow-always" as const);
|
||||
__testing.setNativeHookRelayPermissionApprovalRequesterForTests(approvalRequester);
|
||||
|
||||
await invokeNativeHookRelay({
|
||||
provider: "codex",
|
||||
relayId: relay.relayId,
|
||||
event: "permission_request",
|
||||
rawPayload: {
|
||||
hook_event_name: "PermissionRequest",
|
||||
cwd: "/repo-a",
|
||||
tool_name: "Bash",
|
||||
tool_input: { command: "npm test" },
|
||||
},
|
||||
});
|
||||
await invokeNativeHookRelay({
|
||||
provider: "codex",
|
||||
relayId: relay.relayId,
|
||||
event: "permission_request",
|
||||
rawPayload: {
|
||||
hook_event_name: "PermissionRequest",
|
||||
cwd: "/repo-b",
|
||||
tool_name: "Bash",
|
||||
tool_input: { command: "npm test" },
|
||||
},
|
||||
});
|
||||
await invokeNativeHookRelay({
|
||||
provider: "codex",
|
||||
relayId: relay.relayId,
|
||||
event: "permission_request",
|
||||
rawPayload: {
|
||||
hook_event_name: "PermissionRequest",
|
||||
cwd: "/repo-a",
|
||||
tool_name: "Bash",
|
||||
tool_input: { command: "npm test -- --changed" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(approvalRequester).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("defers PermissionRequest when OpenClaw approval does not decide", async () => {
|
||||
__testing.setNativeHookRelayPermissionApprovalRequesterForTests(
|
||||
vi.fn(async () => "defer" as const),
|
||||
|
||||
@@ -151,6 +151,7 @@ type NativeHookRelayProviderAdapter = {
|
||||
const DEFAULT_RELAY_TTL_MS = 30 * 60 * 1000;
|
||||
const DEFAULT_RELAY_TIMEOUT_MS = 5_000;
|
||||
const DEFAULT_PERMISSION_TIMEOUT_MS = 120_000;
|
||||
const PERMISSION_ALLOW_ALWAYS_TTL_MS = 30 * 60 * 1000;
|
||||
const MAX_NATIVE_HOOK_RELAY_INVOCATIONS = 200;
|
||||
const MAX_NATIVE_HOOK_RELAY_JSON_DEPTH = 64;
|
||||
const MAX_NATIVE_HOOK_RELAY_JSON_NODES = 20_000;
|
||||
@@ -167,6 +168,7 @@ const MAX_APPROVAL_TITLE_LENGTH = 80;
|
||||
const MAX_APPROVAL_DESCRIPTION_LENGTH = 700;
|
||||
const MAX_PERMISSION_APPROVALS_PER_WINDOW = 12;
|
||||
const PERMISSION_APPROVAL_WINDOW_MS = 60_000;
|
||||
const MAX_PERMISSION_ALLOW_ALWAYS_ENTRIES = 512;
|
||||
const MAX_NATIVE_HOOK_BRIDGE_BODY_BYTES = 5_000_000;
|
||||
const MAX_NATIVE_HOOK_BRIDGE_RESPONSE_BYTES = 5_000_000;
|
||||
const NATIVE_HOOK_BRIDGE_RETRY_INTERVAL_MS = 25;
|
||||
@@ -179,11 +181,15 @@ const pendingPermissionApprovals = new Map<
|
||||
Promise<NativeHookRelayPermissionApprovalResult>
|
||||
>();
|
||||
const permissionApprovalWindows = new Map<string, number[]>();
|
||||
const permissionAllowAlwaysApprovals = new Map<string, { expiresAtMs: number }>();
|
||||
const log = createSubsystemLogger("agents/harness/native-hook-relay");
|
||||
|
||||
type NativeHookRelayPermissionDecision = "allow" | "deny";
|
||||
|
||||
type NativeHookRelayPermissionApprovalResult = NativeHookRelayPermissionDecision | "defer";
|
||||
type NativeHookRelayPermissionApprovalResult =
|
||||
| NativeHookRelayPermissionDecision
|
||||
| "allow-always"
|
||||
| "defer";
|
||||
|
||||
type NativeHookRelayPermissionApprovalRequest = {
|
||||
provider: NativeHookRelayProvider;
|
||||
@@ -285,6 +291,7 @@ export function registerNativeHookRelay(
|
||||
params: RegisterNativeHookRelayParams,
|
||||
): NativeHookRelayRegistrationHandle {
|
||||
pruneExpiredNativeHookRelays();
|
||||
pruneNativeHookRelayPermissionAllowAlways();
|
||||
const relayId = normalizeRelayId(params.relayId) ?? randomUUID();
|
||||
const allowedEvents = normalizeAllowedEvents(params.allowedEvents);
|
||||
unregisterNativeHookRelay(relayId);
|
||||
@@ -921,6 +928,13 @@ async function runNativeHookRelayPermissionRequest(params: {
|
||||
registration: params.registration,
|
||||
request,
|
||||
});
|
||||
const allowAlwaysKey = nativeHookRelayPermissionAllowAlwaysKey({
|
||||
registration: params.registration,
|
||||
request,
|
||||
});
|
||||
if (hasNativeHookRelayPermissionAllowAlways(allowAlwaysKey)) {
|
||||
return params.adapter.renderPermissionDecisionResponse("allow");
|
||||
}
|
||||
const pendingApproval = pendingPermissionApprovals.get(approvalKey);
|
||||
try {
|
||||
const decision = await (pendingApproval ??
|
||||
@@ -932,6 +946,10 @@ async function runNativeHookRelayPermissionRequest(params: {
|
||||
if (decision === "allow") {
|
||||
return params.adapter.renderPermissionDecisionResponse("allow");
|
||||
}
|
||||
if (decision === "allow-always") {
|
||||
rememberNativeHookRelayPermissionAllowAlways(allowAlwaysKey);
|
||||
return params.adapter.renderPermissionDecisionResponse("allow");
|
||||
}
|
||||
if (decision === "deny") {
|
||||
return params.adapter.renderPermissionDecisionResponse("deny", "Denied by user");
|
||||
}
|
||||
@@ -1017,6 +1035,25 @@ function nativeHookRelayPermissionApprovalKey(params: {
|
||||
].join(":");
|
||||
}
|
||||
|
||||
function nativeHookRelayPermissionAllowAlwaysKey(params: {
|
||||
registration: NativeHookRelayRegistration;
|
||||
request: NativeHookRelayPermissionApprovalRequest;
|
||||
}): string {
|
||||
const hash = createHash("sha256");
|
||||
hash.update("openclaw:native-hook-relay:permission-allow-always:v2");
|
||||
hash.update("\0");
|
||||
hash.update(params.registration.relayId);
|
||||
hash.update("\0");
|
||||
hash.update(params.request.provider);
|
||||
hash.update("\0");
|
||||
hash.update(params.request.agentId ?? "");
|
||||
hash.update("\0");
|
||||
hash.update(params.request.sessionKey ?? params.request.sessionId);
|
||||
hash.update("\0");
|
||||
hash.update(permissionRequestContentFingerprint(params.request));
|
||||
return hash.digest("hex");
|
||||
}
|
||||
|
||||
function permissionRequestFallbackKey(request: NativeHookRelayPermissionApprovalRequest): string {
|
||||
const command = readOptionalString(request.toolInput.command);
|
||||
if (command) {
|
||||
@@ -1049,6 +1086,8 @@ function permissionRequestContentFingerprint(
|
||||
const hash = createHash("sha256");
|
||||
hash.update(request.toolName);
|
||||
hash.update("\0");
|
||||
hash.update(request.cwd ?? "");
|
||||
hash.update("\0");
|
||||
updateJsonHash(hash, request.toolInput);
|
||||
return hash.digest("hex");
|
||||
}
|
||||
@@ -1140,6 +1179,40 @@ function consumeNativeHookRelayPermissionBudget(relayId: string, now = Date.now(
|
||||
return true;
|
||||
}
|
||||
|
||||
function hasNativeHookRelayPermissionAllowAlways(key: string, now = Date.now()): boolean {
|
||||
const entry = permissionAllowAlwaysApprovals.get(key);
|
||||
if (!entry) {
|
||||
return false;
|
||||
}
|
||||
if (entry.expiresAtMs <= now) {
|
||||
permissionAllowAlwaysApprovals.delete(key);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function rememberNativeHookRelayPermissionAllowAlways(key: string, now = Date.now()): void {
|
||||
pruneNativeHookRelayPermissionAllowAlways(now);
|
||||
permissionAllowAlwaysApprovals.set(key, {
|
||||
expiresAtMs: now + PERMISSION_ALLOW_ALWAYS_TTL_MS,
|
||||
});
|
||||
while (permissionAllowAlwaysApprovals.size > MAX_PERMISSION_ALLOW_ALWAYS_ENTRIES) {
|
||||
const oldestKey = permissionAllowAlwaysApprovals.keys().next().value;
|
||||
if (typeof oldestKey !== "string") {
|
||||
break;
|
||||
}
|
||||
permissionAllowAlwaysApprovals.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
|
||||
function pruneNativeHookRelayPermissionAllowAlways(now = Date.now()): void {
|
||||
for (const [key, entry] of permissionAllowAlwaysApprovals) {
|
||||
if (entry.expiresAtMs <= now) {
|
||||
permissionAllowAlwaysApprovals.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function removeNativeHookRelayPermissionState(relayId: string): void {
|
||||
permissionApprovalWindows.delete(relayId);
|
||||
for (const key of pendingPermissionApprovals.keys()) {
|
||||
@@ -1316,6 +1389,11 @@ async function requestNativeHookRelayPermissionApproval(
|
||||
severity: "warning",
|
||||
toolName: request.toolName,
|
||||
toolCallId: request.toolCallId,
|
||||
allowedDecisions: [
|
||||
PluginApprovalResolutions.ALLOW_ONCE,
|
||||
PluginApprovalResolutions.ALLOW_ALWAYS,
|
||||
PluginApprovalResolutions.DENY,
|
||||
],
|
||||
agentId: request.agentId,
|
||||
sessionKey: request.sessionKey,
|
||||
timeoutMs,
|
||||
@@ -1338,12 +1416,12 @@ async function requestNativeHookRelayPermissionApproval(
|
||||
});
|
||||
decision = waitResult?.decision;
|
||||
}
|
||||
if (
|
||||
decision === PluginApprovalResolutions.ALLOW_ONCE ||
|
||||
decision === PluginApprovalResolutions.ALLOW_ALWAYS
|
||||
) {
|
||||
if (decision === PluginApprovalResolutions.ALLOW_ONCE) {
|
||||
return "allow";
|
||||
}
|
||||
if (decision === PluginApprovalResolutions.ALLOW_ALWAYS) {
|
||||
return "allow-always";
|
||||
}
|
||||
if (decision === PluginApprovalResolutions.DENY) {
|
||||
return "deny";
|
||||
}
|
||||
@@ -1629,6 +1707,7 @@ export const __testing = {
|
||||
invocations.length = 0;
|
||||
pendingPermissionApprovals.clear();
|
||||
permissionApprovalWindows.clear();
|
||||
permissionAllowAlwaysApprovals.clear();
|
||||
nativeHookRelayPermissionApprovalRequester = requestNativeHookRelayPermissionApproval;
|
||||
},
|
||||
getNativeHookRelayInvocationsForTests(): NativeHookRelayInvocation[] {
|
||||
|
||||
@@ -14,6 +14,12 @@ export const PluginApprovalRequestParamsSchema = Type.Object(
|
||||
severity: Type.Optional(Type.String({ enum: ["info", "warning", "critical"] })),
|
||||
toolName: Type.Optional(Type.String()),
|
||||
toolCallId: Type.Optional(Type.String()),
|
||||
allowedDecisions: Type.Optional(
|
||||
Type.Array(Type.String({ enum: ["allow-once", "allow-always", "deny"] }), {
|
||||
minItems: 1,
|
||||
maxItems: 3,
|
||||
}),
|
||||
),
|
||||
agentId: Type.Optional(Type.String()),
|
||||
sessionKey: Type.Optional(Type.String()),
|
||||
turnSourceChannel: Type.Optional(Type.String()),
|
||||
|
||||
@@ -322,6 +322,40 @@ describe("createPluginApprovalHandlers", () => {
|
||||
expect.objectContaining({ message: expect.stringContaining("unexpected property") }),
|
||||
);
|
||||
});
|
||||
|
||||
it("stores scoped allowed decisions on plugin approval requests", async () => {
|
||||
const handlers = createPluginApprovalHandlers(manager);
|
||||
const respond = vi.fn();
|
||||
const opts = createMockOptions(
|
||||
"plugin.approval.request",
|
||||
{
|
||||
title: "T",
|
||||
description: "D",
|
||||
allowedDecisions: ["allow-once", "deny", "allow-once"],
|
||||
twoPhase: true,
|
||||
},
|
||||
{ respond },
|
||||
);
|
||||
|
||||
const handlerPromise = handlers["plugin.approval.request"](opts);
|
||||
await vi.waitFor(() => {
|
||||
expect(respond).toHaveBeenCalledWith(
|
||||
true,
|
||||
expect.objectContaining({ status: "accepted", id: expect.any(String) }),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
const acceptedCall = respond.mock.calls.find(
|
||||
(call) => (call[1] as Record<string, unknown>)?.status === "accepted",
|
||||
);
|
||||
const approvalId = (acceptedCall?.[1] as Record<string, unknown>)?.id as string;
|
||||
expect(manager.getSnapshot(approvalId)?.request).toMatchObject({
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
});
|
||||
manager.resolve(approvalId, "deny");
|
||||
await handlerPromise;
|
||||
});
|
||||
});
|
||||
|
||||
describe("plugin.approval.list", () => {
|
||||
@@ -463,6 +497,35 @@ describe("createPluginApprovalHandlers", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects decisions outside plugin approval allowed decisions", async () => {
|
||||
const handlers = createPluginApprovalHandlers(manager);
|
||||
const record = manager.create(
|
||||
{
|
||||
title: "T",
|
||||
description: "D",
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
},
|
||||
60_000,
|
||||
);
|
||||
void manager.register(record, 60_000);
|
||||
|
||||
const opts = createMockOptions("plugin.approval.resolve", {
|
||||
id: record.id,
|
||||
decision: "allow-always",
|
||||
});
|
||||
await handlers["plugin.approval.resolve"](opts);
|
||||
expect(opts.respond).toHaveBeenCalledWith(
|
||||
false,
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
code: "INVALID_REQUEST",
|
||||
message: "allow-always is unavailable for this plugin approval",
|
||||
details: { allowedDecisions: ["allow-once", "deny"] },
|
||||
}),
|
||||
);
|
||||
expect(manager.getSnapshot(record.id)?.decision).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects unknown approval id", async () => {
|
||||
const handlers = createPluginApprovalHandlers(manager);
|
||||
const opts = createMockOptions("plugin.approval.resolve", {
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { PluginApprovalRequestPayload } from "../../infra/plugin-approvals.
|
||||
import {
|
||||
DEFAULT_PLUGIN_APPROVAL_TIMEOUT_MS,
|
||||
MAX_PLUGIN_APPROVAL_TIMEOUT_MS,
|
||||
resolvePluginApprovalRequestAllowedDecisions,
|
||||
} from "../../infra/plugin-approvals.js";
|
||||
import { normalizeOptionalString } from "../../shared/string-coerce.js";
|
||||
import type { ExecApprovalManager } from "../exec-approval-manager.js";
|
||||
@@ -61,6 +62,7 @@ export function createPluginApprovalHandlers(
|
||||
severity?: string | null;
|
||||
toolName?: string | null;
|
||||
toolCallId?: string | null;
|
||||
allowedDecisions?: string[] | null;
|
||||
agentId?: string | null;
|
||||
sessionKey?: string | null;
|
||||
turnSourceChannel?: string | null;
|
||||
@@ -86,6 +88,13 @@ export function createPluginApprovalHandlers(
|
||||
severity: (p.severity as PluginApprovalRequestPayload["severity"]) ?? null,
|
||||
toolName: p.toolName ?? null,
|
||||
toolCallId: p.toolCallId ?? null,
|
||||
...(Array.isArray(p.allowedDecisions)
|
||||
? {
|
||||
allowedDecisions: resolvePluginApprovalRequestAllowedDecisions({
|
||||
allowedDecisions: p.allowedDecisions,
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
agentId: p.agentId ?? null,
|
||||
sessionKey: p.sessionKey ?? null,
|
||||
turnSourceChannel: normalizeTrimmedString(p.turnSourceChannel),
|
||||
@@ -166,14 +175,24 @@ export function createPluginApprovalHandlers(
|
||||
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "invalid decision"));
|
||||
return;
|
||||
}
|
||||
const decision = p.decision;
|
||||
await handleApprovalResolve({
|
||||
manager,
|
||||
inputId: p.id,
|
||||
decision: p.decision,
|
||||
decision,
|
||||
respond,
|
||||
context,
|
||||
client,
|
||||
exposeAmbiguousPrefixError: false,
|
||||
validateDecision: (snapshot) =>
|
||||
resolvePluginApprovalRequestAllowedDecisions(snapshot.request).includes(decision)
|
||||
? null
|
||||
: {
|
||||
message: `${decision} is unavailable for this plugin approval`,
|
||||
details: {
|
||||
allowedDecisions: resolvePluginApprovalRequestAllowedDecisions(snapshot.request),
|
||||
},
|
||||
},
|
||||
resolvedEventName: "plugin.approval.resolved",
|
||||
buildResolvedEvent: ({ approvalId, decision, resolvedBy, snapshot, nowMs }) => ({
|
||||
id: approvalId,
|
||||
|
||||
@@ -14,7 +14,10 @@ import {
|
||||
resolveExecApprovalRequestAllowedDecisions,
|
||||
type ExecApprovalRequest,
|
||||
} from "./exec-approvals.js";
|
||||
import type { PluginApprovalRequest } from "./plugin-approvals.js";
|
||||
import {
|
||||
resolvePluginApprovalRequestAllowedDecisions,
|
||||
type PluginApprovalRequest,
|
||||
} from "./plugin-approvals.js";
|
||||
|
||||
type ApprovalPhase = "pending" | "resolved" | "expired";
|
||||
|
||||
@@ -105,6 +108,7 @@ export function buildPendingApprovalView(request: ApprovalRequest): PendingAppro
|
||||
...buildPluginViewBase(pluginRequest, "pending"),
|
||||
actions: buildExecApprovalActionDescriptors({
|
||||
approvalCommandId: pluginRequest.id,
|
||||
allowedDecisions: resolvePluginApprovalRequestAllowedDecisions(pluginRequest.request),
|
||||
}),
|
||||
expiresAtMs: pluginRequest.expiresAtMs,
|
||||
};
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
approvalDecisionLabel,
|
||||
buildPluginApprovalExpiredMessage,
|
||||
buildPluginApprovalRequestMessage,
|
||||
resolvePluginApprovalRequestAllowedDecisions,
|
||||
type PluginApprovalRequest,
|
||||
type PluginApprovalResolved,
|
||||
} from "./plugin-approvals.js";
|
||||
@@ -461,6 +462,7 @@ function buildPluginPendingPayload(params: {
|
||||
request: params.request,
|
||||
nowMs: params.nowMs,
|
||||
text: buildPluginApprovalRequestMessage(params.request, params.nowMs),
|
||||
allowedDecisions: resolvePluginApprovalRequestAllowedDecisions(params.request.request),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -154,6 +154,46 @@ describe("plugin approval forwarding", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("renders only request-scoped plugin approval decisions", async () => {
|
||||
const deliver = vi.fn().mockResolvedValue([]);
|
||||
const { forwarder } = createForwarder({ cfg: PLUGIN_TARGETS_CFG, deliver });
|
||||
const result = await forwarder.handlePluginApprovalRequested!(
|
||||
makePluginRequest({
|
||||
request: {
|
||||
...makePluginRequest().request,
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
await flushPendingDelivery();
|
||||
const deliveryArgs = deliver.mock.calls[0]?.[0] as
|
||||
| { payloads?: Array<{ text?: string; interactive?: unknown }> }
|
||||
| undefined;
|
||||
const payload = deliveryArgs?.payloads?.[0];
|
||||
expect(payload?.text).toContain("Reply with: /approve <id> allow-once|deny");
|
||||
expect(payload?.text).not.toContain("allow-always");
|
||||
expect(payload?.interactive).toEqual({
|
||||
blocks: [
|
||||
{
|
||||
type: "buttons",
|
||||
buttons: [
|
||||
{
|
||||
label: "Allow Once",
|
||||
value: "/approve plugin-req-1 allow-once",
|
||||
style: "success",
|
||||
},
|
||||
{
|
||||
label: "Deny",
|
||||
value: "/approve plugin-req-1 deny",
|
||||
style: "danger",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("includes severity icon for critical", async () => {
|
||||
const deliver = vi.fn().mockResolvedValue([]);
|
||||
const { forwarder } = createForwarder({ cfg: PLUGIN_TARGETS_CFG, deliver });
|
||||
|
||||
@@ -7,6 +7,7 @@ export type PluginApprovalRequestPayload = {
|
||||
severity?: "info" | "warning" | "critical" | null;
|
||||
toolName?: string | null;
|
||||
toolCallId?: string | null;
|
||||
allowedDecisions?: readonly ExecApprovalDecision[] | null;
|
||||
agentId?: string | null;
|
||||
sessionKey?: string | null;
|
||||
turnSourceChannel?: string | null;
|
||||
@@ -34,6 +35,11 @@ export const DEFAULT_PLUGIN_APPROVAL_TIMEOUT_MS = 120_000;
|
||||
export const MAX_PLUGIN_APPROVAL_TIMEOUT_MS = 600_000;
|
||||
export const PLUGIN_APPROVAL_TITLE_MAX_LENGTH = 80;
|
||||
export const PLUGIN_APPROVAL_DESCRIPTION_MAX_LENGTH = 256;
|
||||
export const DEFAULT_PLUGIN_APPROVAL_DECISIONS = [
|
||||
"allow-once",
|
||||
"allow-always",
|
||||
"deny",
|
||||
] as const satisfies readonly ExecApprovalDecision[];
|
||||
|
||||
export function approvalDecisionLabel(decision: ExecApprovalDecision): string {
|
||||
if (decision === "allow-once") {
|
||||
@@ -45,6 +51,23 @@ export function approvalDecisionLabel(decision: ExecApprovalDecision): string {
|
||||
return "denied";
|
||||
}
|
||||
|
||||
export function resolvePluginApprovalRequestAllowedDecisions(params?: {
|
||||
allowedDecisions?: readonly ExecApprovalDecision[] | readonly string[] | null;
|
||||
}): readonly ExecApprovalDecision[] {
|
||||
const explicit: ExecApprovalDecision[] = [];
|
||||
if (Array.isArray(params?.allowedDecisions)) {
|
||||
for (const decision of params.allowedDecisions) {
|
||||
if (
|
||||
(decision === "allow-once" || decision === "allow-always" || decision === "deny") &&
|
||||
!explicit.includes(decision)
|
||||
) {
|
||||
explicit.push(decision);
|
||||
}
|
||||
}
|
||||
}
|
||||
return explicit.length > 0 ? explicit : DEFAULT_PLUGIN_APPROVAL_DECISIONS;
|
||||
}
|
||||
|
||||
export function buildPluginApprovalRequestMessage(
|
||||
request: PluginApprovalRequest,
|
||||
nowMsValue: number,
|
||||
@@ -67,7 +90,11 @@ export function buildPluginApprovalRequestMessage(
|
||||
lines.push(`ID: ${request.id}`);
|
||||
const expiresIn = Math.max(0, Math.round((request.expiresAtMs - nowMsValue) / 1000));
|
||||
lines.push(`Expires in: ${expiresIn}s`);
|
||||
lines.push("Reply with: /approve <id> allow-once|allow-always|deny");
|
||||
lines.push(
|
||||
`Reply with: /approve <id> ${resolvePluginApprovalRequestAllowedDecisions(request.request).join(
|
||||
"|",
|
||||
)}`,
|
||||
);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
|
||||
@@ -102,6 +102,54 @@ describe("plugin-sdk/approval-renderers", () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "builds plugin pending payloads with request-scoped decisions",
|
||||
payload: buildPluginApprovalPendingReplyPayload({
|
||||
request: {
|
||||
id: "plugin-approval-123",
|
||||
request: {
|
||||
title: "Sensitive action",
|
||||
description: "Needs approval",
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
},
|
||||
createdAtMs: 1_000,
|
||||
expiresAtMs: 61_000,
|
||||
},
|
||||
nowMs: 1_000,
|
||||
}),
|
||||
textExpected: (text: string) =>
|
||||
expect(text).toContain("Reply with: /approve <id> allow-once|deny"),
|
||||
interactiveExpected: {
|
||||
blocks: [
|
||||
{
|
||||
type: "buttons",
|
||||
buttons: [
|
||||
{
|
||||
label: "Allow Once",
|
||||
value: "/approve plugin-approval-123 allow-once",
|
||||
style: "success",
|
||||
},
|
||||
{
|
||||
label: "Deny",
|
||||
value: "/approve plugin-approval-123 deny",
|
||||
style: "danger",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
channelDataExpected: {
|
||||
execApproval: {
|
||||
agentId: undefined,
|
||||
approvalId: "plugin-approval-123",
|
||||
approvalKind: "plugin",
|
||||
approvalSlug: "plugin-a",
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
sessionKey: undefined,
|
||||
state: "pending",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "builds generic resolved payloads with approval metadata",
|
||||
payload: buildApprovalResolvedReplyPayload({
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
import {
|
||||
buildPluginApprovalRequestMessage,
|
||||
buildPluginApprovalResolvedMessage,
|
||||
resolvePluginApprovalRequestAllowedDecisions,
|
||||
type PluginApprovalRequest,
|
||||
type PluginApprovalResolved,
|
||||
} from "../infra/plugin-approvals.js";
|
||||
@@ -77,7 +78,9 @@ export function buildPluginApprovalPendingReplyPayload(params: {
|
||||
approvalId: params.request.id,
|
||||
approvalSlug: params.approvalSlug ?? params.request.id.slice(0, 8),
|
||||
text: params.text ?? buildPluginApprovalRequestMessage(params.request, params.nowMs),
|
||||
allowedDecisions: params.allowedDecisions,
|
||||
allowedDecisions:
|
||||
params.allowedDecisions ??
|
||||
resolvePluginApprovalRequestAllowedDecisions(params.request.request),
|
||||
channelData: params.channelData,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user