From 32dd1ffc5a7f7f5aab79f374e140091f5d30d93e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 02:39:46 +0100 Subject: [PATCH] refactor(approvals): unify structured path display --- CHANGELOG.md | 2 +- .../.generated/plugin-sdk-api-baseline.sha256 | 4 +-- docs/plugins/sdk-migration.md | 2 +- docs/plugins/sdk-subpaths.md | 3 +- .../codex/src/app-server/approval-bridge.ts | 14 ++++----- src/infra/approval-display-paths.test.ts | 27 +++++++++++++++++ src/infra/approval-display-paths.ts | 30 +++++++++++++++++++ src/infra/exec-approval-reply.test.ts | 13 ++++++++ src/infra/exec-approval-reply.ts | 3 +- src/plugin-sdk/agent-harness-runtime.ts | 1 + src/plugin-sdk/approval-runtime.ts | 1 + src/plugin-sdk/infra-runtime.ts | 1 + ui/src/ui/views/exec-approval.ts | 15 +++++++--- 13 files changed, 97 insertions(+), 19 deletions(-) create mode 100644 src/infra/approval-display-paths.test.ts create mode 100644 src/infra/approval-display-paths.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b02280869bb..9e2f28fe331 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,8 +65,8 @@ Docs: https://docs.openclaw.ai - Gateway/sessions: recover main-agent turns interrupted by a gateway restart from stale transcript-lock evidence, avoiding stuck `status: "running"` sessions without broad post-boot transcript scans. Fixes #70555. Thanks @bitloi. - Plugins/Google Meet: include live Chrome-node readiness in `googlemeet setup` and document the Parallels recovery checks, so stale node tokens or disconnected VM browsers are visible before an agent opens a meeting. Thanks @steipete. -- Codex approvals: compact home-directory permission paths to `~` without repeating them as a separate high-risk warning, while preserving filesystem root and wildcard host warnings. Thanks @steipete. - Context engine: keep safeguard compaction checks active after context-engine windowing and for `ownsCompaction` engines, so large transcripts can compact before prompt submission instead of waiting for provider overflow. Fixes #71325. Thanks @steipete. +- Approvals: compact structured home-directory paths to `~` across Codex permission prompts and exec approval metadata without repeating them as a separate high-risk warning, while preserving filesystem root and wildcard host warnings. Thanks @steipete. - Plugins/runtime deps: isolate the internal npm cache used for bundled plugin runtime-dependency repair and let package updates refresh/verify already-current installs, so failed update or sudo doctor runs can be repaired by rerunning `openclaw update`. Thanks @steipete. - Agents/delete: keep `--json` output machine-readable and retain workspaces that overlap another agent's workspace instead of moving shared state to Trash. Fixes #70889 and #70890. (#70897) Thanks @kaseonedge. - Browser/screenshot: honor `timeoutMs` through host and node screenshot requests, bound raw CDP screenshot commands, and avoid beyond-viewport CDP capture for ordinary viewport screenshots, so Windows Chrome captures no longer hang past the requested deadline. Fixes #68330. Thanks @Woodylai24. diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index d9316d62c2d..97846da94cc 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,2 +1,2 @@ -1b8ce6687d91267f78f589ee29d4cca0809fde73ea47c82ddbd14ecf54f1803a plugin-sdk-api-baseline.json -55c48203fe5d6409f690f4d27abde41502feec1bfb63d9096cd9958fcf45c2c2 plugin-sdk-api-baseline.jsonl +56ccee3ef8ff3b0ba7e2e765ae631b59254464585d5fef9db7e905f2c4c34ded plugin-sdk-api-baseline.json +39184cf8afaec691f0352d1a113e30a7099b87c0748237a3c7307e903ba24eee plugin-sdk-api-baseline.jsonl diff --git a/docs/plugins/sdk-migration.md b/docs/plugins/sdk-migration.md index 2e824ddbf32..ac700234903 100644 --- a/docs/plugins/sdk-migration.md +++ b/docs/plugins/sdk-migration.md @@ -278,7 +278,7 @@ releases. | `plugin-sdk/gateway-runtime` | Gateway helpers | Gateway client and channel-status patch helpers | | `plugin-sdk/config-runtime` | Config helpers | Config load/write helpers | | `plugin-sdk/telegram-command-config` | Telegram command helpers | Fallback-stable Telegram command validation helpers when the bundled Telegram contract surface is unavailable | - | `plugin-sdk/approval-runtime` | Approval prompt helpers | Exec/plugin approval payload, approval capability/profile helpers, native approval routing/runtime helpers | + | `plugin-sdk/approval-runtime` | Approval prompt helpers | Exec/plugin approval payload, approval capability/profile helpers, native approval routing/runtime helpers, and structured approval display path formatting | | `plugin-sdk/approval-auth-runtime` | Approval auth helpers | Approver resolution, same-chat action auth | | `plugin-sdk/approval-client-runtime` | Approval client helpers | Native exec approval profile/filter helpers | | `plugin-sdk/approval-delivery-runtime` | Approval delivery helpers | Native approval capability/delivery adapters | diff --git a/docs/plugins/sdk-subpaths.md b/docs/plugins/sdk-subpaths.md index db67fd39c57..6469d96512e 100644 --- a/docs/plugins/sdk-subpaths.md +++ b/docs/plugins/sdk-subpaths.md @@ -122,6 +122,7 @@ For the plugin authoring guide, see [Plugin SDK overview](/plugins/sdk-overview) | `plugin-sdk/approval-handler-runtime` | Broader approval handler runtime helpers; prefer the narrower adapter/gateway seams when they are enough | | `plugin-sdk/approval-native-runtime` | Native approval target + account-binding helpers | | `plugin-sdk/approval-reply-runtime` | Exec/plugin approval reply payload helpers | + | `plugin-sdk/approval-runtime` | Exec/plugin approval payload helpers, native approval routing/runtime helpers, and structured approval display helpers such as `formatApprovalDisplayPath` | | `plugin-sdk/reply-dedupe` | Narrow inbound reply dedupe reset helpers | | `plugin-sdk/channel-contract-testing` | Narrow channel contract test helpers without the broad testing barrel | | `plugin-sdk/command-auth-native` | Native command auth + native session-target helpers | @@ -156,7 +157,7 @@ For the plugin authoring guide, see [Plugin SDK overview](/plugins/sdk-overview) | `plugin-sdk/config-runtime` | Config load/write helpers and plugin-config lookup helpers | | `plugin-sdk/telegram-command-config` | Telegram command-name/description normalization and duplicate/conflict checks, even when the bundled Telegram contract surface is unavailable | | `plugin-sdk/text-autolink-runtime` | File-reference autolink detection without the broad text-runtime barrel | - | `plugin-sdk/approval-runtime` | Exec/plugin approval helpers, approval-capability builders, auth/profile helpers, native routing/runtime helpers | + | `plugin-sdk/approval-runtime` | Exec/plugin approval helpers, approval-capability builders, auth/profile helpers, native routing/runtime helpers, and structured approval display path formatting | | `plugin-sdk/reply-runtime` | Shared inbound/reply runtime helpers, chunking, dispatch, heartbeat, reply planner | | `plugin-sdk/reply-dispatch-runtime` | Narrow reply dispatch/finalize and conversation-label helpers | | `plugin-sdk/reply-history` | Shared short-window reply-history helpers such as `buildHistoryContext`, `recordPendingHistoryEntry`, and `clearHistoryEntriesIfEnabled` | diff --git a/extensions/codex/src/app-server/approval-bridge.ts b/extensions/codex/src/app-server/approval-bridge.ts index dc1d771f51a..71d5527ac58 100644 --- a/extensions/codex/src/app-server/approval-bridge.ts +++ b/extensions/codex/src/app-server/approval-bridge.ts @@ -1,5 +1,6 @@ import { type AgentApprovalEventData, + formatApprovalDisplayPath, type EmbeddedRunAttemptParams, } from "openclaw/plugin-sdk/agent-harness-runtime"; import { @@ -431,15 +432,10 @@ function sanitizePermissionHostValue(value: string): string { } function sanitizePermissionPathValue(value: string): string { - const normalized = sanitizePermissionScalar(value); - const homeCompacted = normalized - .replace(/^\/home\/(?!\.{1,2}(?=\/|$))[^/]+(?=\/|$)/, "~") - .replace(/^\/Users\/(?!\.{1,2}(?=\/|$))[^/]+(?=\/|$)/, "~") - .replace(/^[A-Za-z]:[\\/]Users[\\/](?!\.{1,2}(?=[\\/]|$))[^\\/]+(?=[\\/]|$)/i, "~"); - const displayPath = homeCompacted.startsWith("~") - ? homeCompacted.replace(/\\/g, "/") - : homeCompacted; - return truncate(displayPath, PERMISSION_VALUE_MAX_LENGTH); + return truncate( + formatApprovalDisplayPath(sanitizePermissionScalar(value)), + PERMISSION_VALUE_MAX_LENGTH, + ); } function sanitizePermissionScalar(value: string): string { diff --git a/src/infra/approval-display-paths.test.ts b/src/infra/approval-display-paths.test.ts new file mode 100644 index 00000000000..062121c969c --- /dev/null +++ b/src/infra/approval-display-paths.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { formatApprovalDisplayPath } from "./approval-display-paths.js"; + +describe("approval display paths", () => { + it.each([ + ["/home/alice", "~"], + ["/home/alice/.ssh/id_rsa", "~/.ssh/id_rsa"], + ["/Users/alice/Documents/project", "~/Documents/project"], + ["C:/Users/alice/project", "~/project"], + ["c:/users/bob/project", "~/project"], + ["C:\\Users\\alice\\.ssh\\id_rsa", "~/.ssh/id_rsa"], + ["D:\\Users\\alice\\Downloads\\file.txt", "~/Downloads/file.txt"], + ["/workspace/project", "/workspace/project"], + ["C:\\workspace\\project", "C:\\workspace\\project"], + ])("formats %s as %s", (input, expected) => { + expect(formatApprovalDisplayPath(input)).toBe(expected); + }); + + it.each([ + "/Users/alice/../Library", + "/home/alice/./project", + "C:/Users/alice/../Windows/System32", + "C:\\Users\\alice\\.\\project", + ])("does not compact relative-segment path %s", (input) => { + expect(formatApprovalDisplayPath(input)).toBe(input); + }); +}); diff --git a/src/infra/approval-display-paths.ts b/src/infra/approval-display-paths.ts new file mode 100644 index 00000000000..a2c2d5696ec --- /dev/null +++ b/src/infra/approval-display-paths.ts @@ -0,0 +1,30 @@ +export function formatApprovalDisplayPath(value: string): string { + const normalized = value.trim(); + if (!normalized || hasRelativePathSegment(normalized)) { + return normalized; + } + + const unixHomeMatch = normalized.match(/^\/(?:home|Users)\/([^/]+)(.*)$/); + if (unixHomeMatch && isSafeHomeSegment(unixHomeMatch[1])) { + return compactHomeSuffix(unixHomeMatch[2] ?? ""); + } + + const windowsHomeMatch = normalized.match(/^[A-Za-z]:[\\/]Users[\\/]([^\\/]+)(.*)$/i); + if (windowsHomeMatch && isSafeHomeSegment(windowsHomeMatch[1])) { + return compactHomeSuffix(windowsHomeMatch[2] ?? ""); + } + + return normalized; +} + +function compactHomeSuffix(suffix: string): string { + return `~${suffix.replace(/\\/g, "/")}`; +} + +function isSafeHomeSegment(segment: string | undefined): boolean { + return segment !== undefined && segment !== "." && segment !== ".."; +} + +function hasRelativePathSegment(value: string): boolean { + return /(^|[\\/])\.{1,2}(?=[\\/]|$)/.test(value); +} diff --git a/src/infra/exec-approval-reply.test.ts b/src/infra/exec-approval-reply.test.ts index 8894940e053..c700c4cb809 100644 --- a/src/infra/exec-approval-reply.test.ts +++ b/src/infra/exec-approval-reply.test.ts @@ -289,6 +289,19 @@ describe("exec approval reply helpers", () => { expect(payload.text).toContain("Full id: `req-1`"); }); + it("compacts structured cwd paths in pending reply payloads", () => { + const payload = buildExecApprovalPendingReplyPayload({ + approvalId: "req-home", + approvalSlug: "slug-home", + command: "pwd", + cwd: "C:\\Users\\alice\\project", + host: "gateway", + }); + + expect(payload.text).toContain("CWD: ~/project"); + expect(payload.text).not.toContain("C:\\Users\\alice"); + }); + it("omits allow-always actions when the effective policy requires approval every time", () => { const payload = buildExecApprovalPendingReplyPayload({ approvalId: "req-ask-always", diff --git a/src/infra/exec-approval-reply.ts b/src/infra/exec-approval-reply.ts index 2322ee74ee9..861d88627cb 100644 --- a/src/infra/exec-approval-reply.ts +++ b/src/infra/exec-approval-reply.ts @@ -5,6 +5,7 @@ import { normalizeOptionalLowercaseString, normalizeOptionalString, } from "../shared/string-coerce.js"; +import { formatApprovalDisplayPath } from "./approval-display-paths.js"; import { describeNativeExecApprovalClientSetup, listNativeExecApprovalClientLabels, @@ -322,7 +323,7 @@ export function buildExecApprovalPendingReplyPayload( info.push(`Node: ${params.nodeId}`); } if (params.cwd) { - info.push(`CWD: ${params.cwd}`); + info.push(`CWD: ${formatApprovalDisplayPath(params.cwd)}`); } if (typeof params.expiresAtMs === "number" && Number.isFinite(params.expiresAtMs)) { info.push( diff --git a/src/plugin-sdk/agent-harness-runtime.ts b/src/plugin-sdk/agent-harness-runtime.ts index e39ca9f6a13..0c394a8b109 100644 --- a/src/plugin-sdk/agent-harness-runtime.ts +++ b/src/plugin-sdk/agent-harness-runtime.ts @@ -55,6 +55,7 @@ export type { export { VERSION as OPENCLAW_VERSION } from "../version.js"; export { formatErrorMessage } from "../infra/errors.js"; +export { formatApprovalDisplayPath } from "../infra/approval-display-paths.js"; export { emitAgentEvent } from "../infra/agent-events.js"; export { log as embeddedAgentLog } from "../agents/pi-embedded-runner/logger.js"; export { resolveEmbeddedAgentRuntime } from "../agents/pi-embedded-runner/runtime.js"; diff --git a/src/plugin-sdk/approval-runtime.ts b/src/plugin-sdk/approval-runtime.ts index 7c588c03fd2..b1448ff2ac3 100644 --- a/src/plugin-sdk/approval-runtime.ts +++ b/src/plugin-sdk/approval-runtime.ts @@ -19,6 +19,7 @@ export { type ExecApprovalReplyMetadata, } from "../infra/exec-approval-reply.js"; export { resolveExecApprovalCommandDisplay } from "../infra/exec-approval-command-display.js"; +export { formatApprovalDisplayPath } from "../infra/approval-display-paths.js"; export { createChannelApproverDmTargetResolver, createChannelNativeOriginTargetResolver, diff --git a/src/plugin-sdk/infra-runtime.ts b/src/plugin-sdk/infra-runtime.ts index e181bc1c73a..c161e4fb8cd 100644 --- a/src/plugin-sdk/infra-runtime.ts +++ b/src/plugin-sdk/infra-runtime.ts @@ -42,6 +42,7 @@ export * from "../infra/exec-approval-session-target.ts"; export * from "../infra/exec-approvals.ts"; export * from "../infra/approval-native-delivery.ts"; export * from "../infra/approval-native-runtime.ts"; +export * from "../infra/approval-display-paths.ts"; export * from "../infra/plugin-approvals.ts"; export * from "../infra/fetch.js"; export * from "../infra/file-lock.js"; diff --git a/ui/src/ui/views/exec-approval.ts b/ui/src/ui/views/exec-approval.ts index 256a85addb2..4c8aaea5fe9 100644 --- a/ui/src/ui/views/exec-approval.ts +++ b/ui/src/ui/views/exec-approval.ts @@ -1,4 +1,5 @@ import { html, nothing } from "lit"; +import { formatApprovalDisplayPath } from "../../../../src/infra/approval-display-paths.ts"; import type { AppViewState } from "../app-view-state.ts"; import type { ExecApprovalRequest, @@ -19,11 +20,14 @@ function formatRemaining(ms: number): string { return `${hours}h`; } -function renderMetaRow(label: string, value?: string | null) { +function renderMetaRow(label: string, value?: string | null, opts?: { path?: boolean }) { if (!value) { return nothing; } - return html`
${label}${value}
`; + const displayValue = opts?.path ? formatApprovalDisplayPath(value) : value; + return html`
+ ${label}${displayValue} +
`; } function renderExecBody(request: ExecApprovalRequestPayload) { @@ -31,8 +35,11 @@ function renderExecBody(request: ExecApprovalRequestPayload) {
${request.command}
${renderMetaRow("Host", request.host)} ${renderMetaRow("Agent", request.agentId)} - ${renderMetaRow("Session", request.sessionKey)} ${renderMetaRow("CWD", request.cwd)} - ${renderMetaRow("Resolved", request.resolvedPath)} + ${renderMetaRow("Session", request.sessionKey)} + ${renderMetaRow("CWD", request.cwd, { + path: true, + })} + ${renderMetaRow("Resolved", request.resolvedPath, { path: true })} ${renderMetaRow("Security", request.security)} ${renderMetaRow("Ask", request.ask)}
`;