From deb682abfee018fa55e6e3c270012f8d3ebd3dbb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 3 Aug 2026 02:56:43 -0700 Subject: [PATCH] refactor(plugins): consolidate extension runtime helpers (#118509) * refactor(plugins): consolidate extension runtime helpers * fix(ci): satisfy extension type and lint checks * chore(plugin-sdk): regenerate API baseline for #118509 --- config/max-lines-baseline.txt | 1 - .../.generated/plugin-sdk-api-baseline.sha256 | 4 +- docs/plugins/sdk-subpaths.md | 4 +- extensions/canvas/runtime-api.ts | 16 +- extensions/canvas/src/capability.ts | 34 - extensions/canvas/src/host/server.test.ts | 28 +- extensions/canvas/src/host/server.ts | 126 +--- .../src/service-attributes.ts | 65 +- .../diagnostics-otel/src/service-constants.ts | 1 - .../diagnostics-otel/src/service-exporter.ts | 6 +- .../src/service-genai-attributes.ts | 12 +- .../src/service-recorders-harness.ts | 35 +- .../src/service-recorders-model.ts | 16 +- .../src/service-recorders-operations.ts | 35 +- .../src/service-recorders-tools.ts | 44 +- .../src/service-recorders-usage.ts | 44 +- .../diagnostics-prometheus/src/service.ts | 208 +++--- .../onepassword/onepassword-op-path.d.ts | 2 - extensions/onepassword/onepassword-op-path.js | 2 - .../onepassword/src/secret-ref-cli.test.ts | 565 +++++------------ extensions/onepassword/src/secret-ref-cli.ts | 450 ++----------- extensions/qa-lab/web/src/app.browser.test.ts | 14 +- extensions/qa-lab/web/src/app.ts | 4 +- .../qa-lab/web/src/ui-conversation-key.ts | 16 +- .../qa-lab/web/src/ui-render-content.ts | 25 +- extensions/qa-lab/web/src/ui-render.test.ts | 10 + extensions/qa-lab/web/src/ui-types.ts | 67 +- extensions/vault/src/cli.test.ts | 34 +- extensions/vault/src/cli.ts | 328 +--------- extensions/voice-call/index.test.ts | 82 ++- extensions/voice-call/index.ts | 594 +++++------------- extensions/voice-call/src/command-service.ts | 171 +++++ .../src/gateway-continue-operation.test.ts | 5 +- .../src/gateway-continue-operation.ts | 18 +- scripts/plugin-sdk-surface-report.mjs | 8 +- src/plugin-sdk/diagnostic-runtime.ts | 31 + src/plugin-sdk/secret-ref-runtime.ts | 346 ++++++++++ 37 files changed, 1339 insertions(+), 2112 deletions(-) delete mode 100644 extensions/canvas/src/capability.ts create mode 100644 extensions/voice-call/src/command-service.ts diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index e40c65b6a56b..7dcf1d6660e3 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -277,7 +277,6 @@ extensions/telegram/src/thread-bindings.ts extensions/telegram/src/webhook.test.ts extensions/tlon/src/monitor/index.ts extensions/voice-call/index.test.ts -extensions/voice-call/index.ts extensions/voice-call/src/cli.ts extensions/voice-call/src/media-stream.ts extensions/voice-call/src/webhook.test.ts diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index fc1b08e20761..e3d19002f9a8 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -61,7 +61,7 @@ c1ea9510dfda047609a99d5d2cd1f1560f5d469a36e6b695766213d695c25b0f module/convers dea96213010cbc816345b1229534f1acb8b0bcfdbd7814c62eefc77030fa9cd8 module/core 4af19d59c2f18674e7d7f7dc1b358b644dc707e6bd601dc47168bd9e4a669940 module/dedupe-runtime f70c93d28053ca2e8353e45e6515ce7acef188097c6117d1545965d0699c8004 module/device-bootstrap -6215d3af5923bf5a616d73062534968b69f448e3e30adc64ae9caebdd1a46d71 module/diagnostic-runtime +68c726280f6585af96c071758ba383e55480b4fc913eae7c26aea1af331dedf3 module/diagnostic-runtime ea81ef06956c1bc0853fa00afbbc2b5a4019116aaf8a436e1b27d06f7a2c9e88 module/directory-runtime e8adcff47c1b677cd2c01a2130fe4d226ac30ab3c88a3ceb4df5a8660316c4a9 module/discord f65408d85477bb362ebe6ed9148c1bb6b9eb7839733e5e3119f4ff8f1cfd0567 module/error-runtime @@ -120,7 +120,7 @@ b6b8edc50ecab8386c9acd8f374a207212b5a99c8f518538bbcf0c458dda3881 module/runtime aa8a411ad37c1d1143b67376bf2d20255b9eedff61d80815f42e4f8ed7bd8e58 module/secret-file 44adc2205f926172fcd3762ca8a96c1485beabcb1bef8b9acfd2233cefea2a6a module/secret-input 57dcb1462d4c4f9a98d934c4ca975b163d704758af9821a64001ff3ac05637c3 module/secret-input-runtime -e576b537880f63b3a91f3608f7e84c873bce6c6a3d9a0ba98c247f46de788d25 module/secret-ref-runtime +dc0ee07d392a85c218939000b28c0138f139215da00f5592b34a68ba8e29a25d module/secret-ref-runtime 62ccaafc8e0677e850339f4a4333f9f16ae9fed979bcef003890b2a47507147f module/security-runtime 673c64502fdffb2d6361a7cf2ad0c33ffe15707b5e5027de1d88701ce3d8ade1 module/session-catalog 50f5e344f98c27570b7a30e32a906b612e2383d21f102e88cd93e1d5425a6de9 module/session-discussion diff --git a/docs/plugins/sdk-subpaths.md b/docs/plugins/sdk-subpaths.md index 396bf54104b6..4ab07f3d618a 100644 --- a/docs/plugins/sdk-subpaths.md +++ b/docs/plugins/sdk-subpaths.md @@ -202,7 +202,7 @@ usage endpoint failed or returned no usable usage data. | `plugin-sdk/channel-secret-runtime` | Deprecated broad secret-contract surface (`collectSimpleChannelFieldAssignments`, `getChannelSurface`, `pushAssignment`, secret target types); prefer the focused subpaths below | | `plugin-sdk/channel-secret-basic-runtime` | Narrow secret-contract exports and target-registry builders for non-TTS channel/plugin secret surfaces | | `plugin-sdk/channel-secret-tts-runtime` | Private-local after July 2026; Narrow nested channel TTS secret assignment helpers | - | `plugin-sdk/secret-ref-runtime` | Narrow SecretRef typing, resolution, and shared setup-plan construction for plugin-owned secret providers | + | `plugin-sdk/secret-ref-runtime` | Narrow SecretRef typing, resolution, setup-plan construction, and setup CLI scaffolding for plugin-owned secret providers | | `plugin-sdk/security-runtime` | Deprecated broad barrel for trust, DM gating, root-bounded file/path helpers including create-only writes, sync/async atomic file replacement, sibling temp writes, cross-device move fallback, private file-store helpers, symlink-parent guards, external-content, sensitive text redaction, constant-time secret comparison, and secret-collection helpers; prefer focused security/SSRF/secret subpaths | | `plugin-sdk/ssrf-policy` | Host allowlist and private-network SSRF policy helpers | | `plugin-sdk/ssrf-dispatcher` | Private-local after July 2026; Narrow pinned-dispatcher helpers without the broad infra runtime surface | @@ -304,7 +304,7 @@ Use `isLoopbackHost(host)` when a plugin must accept only the local machine. It | `plugin-sdk/exec-approvals-runtime` | Private-local after July 2026; Exec approval policy file helpers without the broad infra-runtime barrel | | `plugin-sdk/infra-runtime` | Deprecated compatibility shim; use the focused runtime subpaths above | | `plugin-sdk/collection-runtime` | Small bounded cache helpers | - | `plugin-sdk/diagnostic-runtime` | Diagnostic flag, event, and trace-context helpers | + | `plugin-sdk/diagnostic-runtime` | Diagnostic flag, event, trace-context, and low-cardinality dimension normalization helpers | | `plugin-sdk/error-runtime` | Error graph, formatting, unknown-value coercion, shared error classification helpers, `PlatformMessageNotDispatchedError`, `isApprovalNotFoundError` | | `plugin-sdk/fetch-runtime` | Private-local after July 2026; Wrapped fetch, proxy, EnvHttpProxyAgent option, and pinned lookup helpers | | `plugin-sdk/runtime-fetch` | Private-local after July 2026; Dispatcher-aware runtime fetch without proxy/guarded-fetch imports | diff --git a/extensions/canvas/runtime-api.ts b/extensions/canvas/runtime-api.ts index 87c0f733ccbb..f3c2fc0e885c 100644 --- a/extensions/canvas/runtime-api.ts +++ b/extensions/canvas/runtime-api.ts @@ -1,4 +1,4 @@ -/** Runtime API exports for Canvas plugin host, CLI, and capability helpers. */ +/** Runtime API exports for Canvas plugin host and CLI helpers. */ export { canvasConfigSchema, isCanvasHostEnabled, @@ -14,23 +14,11 @@ export { CANVAS_WS_PATH, handleA2uiHttpRequest, } from "./src/host/a2ui.js"; -export { - createCanvasHostHandler, - startCanvasHost, - type CanvasHostHandler, - type CanvasHostServer, -} from "./src/host/server.js"; +export { createCanvasHostHandler, type CanvasHostHandler } from "./src/host/server.js"; export { registerNodesCanvasCommands, type CanvasCliDependencies, type CanvasNodesRpcOpts, } from "./src/cli.js"; export { canvasSnapshotTempPath, parseCanvasSnapshotPayload } from "./src/cli-helpers.js"; -export { - buildCanvasScopedHostUrl, - CANVAS_CAPABILITY_PATH_PREFIX, - CANVAS_CAPABILITY_TTL_MS, - mintCanvasCapabilityToken, - normalizeCanvasScopedUrl, -} from "./src/capability.js"; export { resolveCanvasHostUrl } from "./src/host-url.js"; diff --git a/extensions/canvas/src/capability.ts b/extensions/canvas/src/capability.ts deleted file mode 100644 index cf2841471792..000000000000 --- a/extensions/canvas/src/capability.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Canvas capability-token helpers for scoped hosted node URLs. - */ -import { - buildPluginNodeCapabilityScopedHostUrl, - DEFAULT_PLUGIN_NODE_CAPABILITY_TTL_MS, - mintPluginNodeCapabilityToken, - normalizePluginNodeCapabilityScopedUrl, - PLUGIN_NODE_CAPABILITY_PATH_PREFIX, - type NormalizedPluginNodeCapabilityUrl, -} from "openclaw/plugin-sdk/gateway-runtime"; - -/** Path prefix used for Canvas capability-scoped gateway routes. */ -export const CANVAS_CAPABILITY_PATH_PREFIX = PLUGIN_NODE_CAPABILITY_PATH_PREFIX; -/** Default Canvas capability token TTL in milliseconds. */ -export const CANVAS_CAPABILITY_TTL_MS = DEFAULT_PLUGIN_NODE_CAPABILITY_TTL_MS; - -/** Normalized Canvas capability-scoped URL shape. */ -type NormalizedCanvasScopedUrl = NormalizedPluginNodeCapabilityUrl; - -/** Creates a new opaque Canvas capability token. */ -export function mintCanvasCapabilityToken(): string { - return mintPluginNodeCapabilityToken(); -} - -/** Builds a Canvas host URL scoped by the supplied capability token. */ -export function buildCanvasScopedHostUrl(baseUrl: string, capability: string): string | undefined { - return buildPluginNodeCapabilityScopedHostUrl(baseUrl, capability); -} - -/** Normalizes and validates a Canvas capability-scoped URL. */ -export function normalizeCanvasScopedUrl(rawUrl: string): NormalizedCanvasScopedUrl { - return normalizePluginNodeCapabilityScopedUrl(rawUrl); -} diff --git a/extensions/canvas/src/host/server.test.ts b/extensions/canvas/src/host/server.test.ts index 1834bc74cff9..6ec1ce481307 100644 --- a/extensions/canvas/src/host/server.test.ts +++ b/extensions/canvas/src/host/server.test.ts @@ -197,7 +197,6 @@ describe("canvas host", () => { log: (..._args: Parameters) => {}, }; let createCanvasHostHandler: typeof import("./server.js").createCanvasHostHandler; - let startCanvasHost: typeof import("./server.js").startCanvasHost; let WebSocketServerClass: typeof import("ws").WebSocketServer; let watcherState: ReturnType; let fixtureRoot = ""; @@ -226,7 +225,6 @@ describe("canvas host", () => { }); beforeAll(async () => { - vi.doUnmock("undici"); vi.doMock("node:timers", async (importOriginal) => { const actual = await importOriginal(); return { @@ -241,7 +239,7 @@ describe("canvas host", () => { }); vi.resetModules(); const serverModule = await import("./server.js"); - ({ createCanvasHostHandler, startCanvasHost } = serverModule); + ({ createCanvasHostHandler } = serverModule); const wsModule = await vi.importActual("ws"); WebSocketServerClass = wsModule.WebSocketServer; fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-canvas-fixtures-")); @@ -502,15 +500,12 @@ describe("canvas host", () => { } }); - it("serves canvas content from the mounted base path and reuses handlers without double close", async () => { + it("serves canvas content from the mounted base path", async () => { const dir = await createCaseDir(); await fs.writeFile(path.join(dir, "index.html"), "v1", "utf8"); const handler = await createTestCanvasHostHandler(dir); - const originalClose = handler.close; - const closeSpy = vi.fn(async () => originalClose()); - try { const response = await captureHandlerResponse(handler, `${CANVAS_HOST_PATH}/`); expect(response.status).toBe(200); @@ -523,25 +518,8 @@ describe("canvas host", () => { const miss = await captureHandlerResponse(handler, "/"); expect(miss.handled).toBe(false); - - handler.close = closeSpy; - const hosted = await startCanvasHost({ - runtime: quietRuntime, - handler, - ownsHandler: false, - port: 0, - listenHost: "127.0.0.1", - allowInTests: true, - }); - - try { - expect(hosted.port).toBeGreaterThan(0); - } finally { - await hosted.close(); - expect(closeSpy).not.toHaveBeenCalled(); - } } finally { - await originalClose(); + await handler.close(); } }); diff --git a/extensions/canvas/src/host/server.ts b/extensions/canvas/src/host/server.ts index 9951d69a94c6..6610006644dd 100644 --- a/extensions/canvas/src/host/server.ts +++ b/extensions/canvas/src/host/server.ts @@ -2,7 +2,7 @@ * Canvas host server and static-file/live-reload handler implementation. */ import fs from "node:fs/promises"; -import http, { type IncomingMessage, type Server, type ServerResponse } from "node:http"; +import type { IncomingMessage, ServerResponse } from "node:http"; import type { Socket } from "node:net"; import path from "node:path"; import type { Duplex } from "node:stream"; @@ -14,47 +14,14 @@ import chokidar from "chokidar"; import { detectMime } from "openclaw/plugin-sdk/media-mime"; import { isTruthyEnvValue, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { resolveStateDir } from "openclaw/plugin-sdk/state-paths"; -import { - lowercasePreservingWhitespace, - normalizeOptionalString, -} from "openclaw/plugin-sdk/string-coerce-runtime"; +import { lowercasePreservingWhitespace } from "openclaw/plugin-sdk/string-coerce-runtime"; import { ensureDir, resolveUserPath } from "openclaw/plugin-sdk/text-utility-runtime"; import { WebSocketServer } from "ws"; -import { - CANVAS_HOST_PATH, - CANVAS_WS_PATH, - injectCanvasRuntime, - isA2uiPath, -} from "./a2ui-shared.js"; +import { CANVAS_HOST_PATH, CANVAS_WS_PATH, injectCanvasRuntime } from "./a2ui-shared.js"; import { normalizeUrlPath, resolveFileWithinRoot } from "./file-resolver.js"; const CANVAS_LIVE_RELOAD_MAX_INBOUND_MESSAGE_BYTES = 64 * 1024; -/** Options for Canvas host creation. */ -type CanvasHostOpts = { - runtime: RuntimeEnv; - rootDir?: string; - port?: number; - listenHost?: string; - allowInTests?: boolean; - liveReload?: boolean; - watchFactory?: typeof chokidar.watch; - webSocketServerClass?: typeof WebSocketServer; -}; - -/** Options for starting a standalone Canvas host HTTP server. */ -type CanvasHostServerOpts = CanvasHostOpts & { - handler?: CanvasHostHandler; - ownsHandler?: boolean; -}; - -/** Running Canvas host server handle. */ -export type CanvasHostServer = { - port: number; - rootDir: string; - close: () => Promise; -}; - /** Options for creating only the Canvas host request handler. */ type CanvasHostHandlerOpts = { runtime: RuntimeEnv; @@ -435,90 +402,3 @@ export async function createCanvasHostHandler( }, }; } - -/** Starts a standalone loopback Canvas host HTTP server. */ -export async function startCanvasHost(opts: CanvasHostServerOpts): Promise { - if (isDisabledByEnv() && opts.allowInTests !== true) { - return { port: 0, rootDir: "", close: async () => {} }; - } - - const handler = - opts.handler ?? - (await createCanvasHostHandler({ - runtime: opts.runtime, - rootDir: opts.rootDir, - basePath: CANVAS_HOST_PATH, - allowInTests: opts.allowInTests, - liveReload: opts.liveReload, - watchFactory: opts.watchFactory, - webSocketServerClass: opts.webSocketServerClass, - })); - const ownsHandler = opts.ownsHandler ?? opts.handler === undefined; - - const bindHost = normalizeOptionalString(opts.listenHost) || "127.0.0.1"; - const server: Server = http.createServer((req, res) => { - if (lowercasePreservingWhitespace(req.headers.upgrade ?? "") === "websocket") { - return; - } - void (async () => { - if (req.url && isA2uiPath(new URL(req.url, "http://localhost").pathname)) { - const { handleA2uiHttpRequest } = await import("./a2ui.js"); - if (await handleA2uiHttpRequest(req, res)) { - return; - } - } - if (await handler.handleHttpRequest(req, res)) { - return; - } - res.statusCode = 404; - res.setHeader("Content-Type", "text/plain; charset=utf-8"); - res.end("Not Found"); - })().catch((err: unknown) => { - opts.runtime.error(`Canvas host request failed: ${String(err)}`); - res.statusCode = 500; - res.setHeader("Content-Type", "text/plain; charset=utf-8"); - res.end("error"); - }); - }); - server.on("upgrade", (req, socket, head) => { - if (handler.handleUpgrade(req, socket, head)) { - return; - } - socket.destroy(); - }); - - const listenPort = - typeof opts.port === "number" && Number.isFinite(opts.port) && opts.port > 0 ? opts.port : 0; - await new Promise((resolve, reject) => { - const onError = (err: NodeJS.ErrnoException) => { - server.off("listening", onListening); - reject(err); - }; - const onListening = () => { - server.off("error", onError); - resolve(); - }; - server.once("error", onError); - server.once("listening", onListening); - server.listen(listenPort, bindHost); - }); - - const addr = server.address(); - const boundPort = typeof addr === "object" && addr ? addr.port : 0; - opts.runtime.log( - `canvas host listening on http://${bindHost}:${boundPort} (root ${handler.rootDir})`, - ); - - return { - port: boundPort, - rootDir: handler.rootDir, - close: async () => { - if (ownsHandler) { - await handler.close(); - } - await new Promise((resolve, reject) => { - server.close((err) => (err ? reject(err) : resolve())); - }); - }, - }; -} diff --git a/extensions/diagnostics-otel/src/service-attributes.ts b/extensions/diagnostics-otel/src/service-attributes.ts index 84ec1a9a6a31..a12cee4255e9 100644 --- a/extensions/diagnostics-otel/src/service-attributes.ts +++ b/extensions/diagnostics-otel/src/service-attributes.ts @@ -1,10 +1,10 @@ import type { LogRecord } from "@opentelemetry/api-logs"; +import { normalizeDiagnosticValue } from "openclaw/plugin-sdk/diagnostic-runtime"; import type { DiagnosticEventPayload, DiagnosticTraceContext } from "../api.js"; import { redactSensitiveText } from "../api.js"; import { BLOCKED_OTEL_LOG_ATTRIBUTE_KEYS, DROPPED_OTEL_ATTRIBUTE_KEYS, - LOW_CARDINALITY_VALUE_RE, MAX_OTEL_LOG_ATTRIBUTE_COUNT, MAX_OTEL_LOG_ATTRIBUTE_VALUE_CHARS, OTEL_LOG_ATTRIBUTE_KEY_RE, @@ -26,18 +26,6 @@ export function redactOtelAttributes(attributes: Record= 0 ? redacted.slice(0, scopedLaneIndex) : redacted; - return LOW_CARDINALITY_VALUE_RE.test(lane) ? lane : fallback; -} - export function shouldCaptureOtelLogBody(policy: OtelContentCapturePolicy): boolean { return policy.logBodies; } @@ -187,7 +158,7 @@ function assignOtelSecurityEventAttributes( assignOtelLogAttribute( attributes, `openclaw.security.attribute.${key}`, - typeof value === "string" ? lowCardinalityAttr(value) : value, + typeof value === "string" ? normalizeDiagnosticValue(value) : value, ); } } @@ -216,11 +187,19 @@ export function assignOtelSecurityAttributes( ): void { assignOtelLogAttribute(attributes, "openclaw.security.event_id", evt.eventId); assignOtelLogAttribute(attributes, "openclaw.security.category", evt.category); - assignOtelLogAttribute(attributes, "openclaw.security.action", lowCardinalityAttr(evt.action)); + assignOtelLogAttribute( + attributes, + "openclaw.security.action", + normalizeDiagnosticValue(evt.action), + ); assignOtelLogAttribute(attributes, "openclaw.security.outcome", evt.outcome); assignOtelLogAttribute(attributes, "openclaw.security.severity", evt.severity); if (evt.reason) { - assignOtelLogAttribute(attributes, "openclaw.security.reason", lowCardinalityAttr(evt.reason)); + assignOtelLogAttribute( + attributes, + "openclaw.security.reason", + normalizeDiagnosticValue(evt.reason), + ); } if (evt.actor) { assignOtelLogAttribute(attributes, "openclaw.security.actor.kind", evt.actor.kind); @@ -228,35 +207,35 @@ export function assignOtelSecurityAttributes( assignOtelLogAttribute( attributes, "openclaw.security.actor.id_hash", - lowCardinalityAttr(evt.actor.idHash), + normalizeDiagnosticValue(evt.actor.idHash), ); } if (evt.actor.deviceIdHash) { assignOtelLogAttribute( attributes, "openclaw.security.actor.device_id_hash", - lowCardinalityAttr(evt.actor.deviceIdHash), + normalizeDiagnosticValue(evt.actor.deviceIdHash), ); } if (evt.actor.channel) { assignOtelLogAttribute( attributes, "openclaw.security.actor.channel", - lowCardinalityAttr(evt.actor.channel), + normalizeDiagnosticValue(evt.actor.channel), ); } if (evt.actor.role) { assignOtelLogAttribute( attributes, "openclaw.security.actor.role", - lowCardinalityAttr(evt.actor.role), + normalizeDiagnosticValue(evt.actor.role), ); } if (evt.actor.scopes?.length) { assignOtelLogAttribute( attributes, "openclaw.security.actor.scopes", - evt.actor.scopes.map((scope) => lowCardinalityAttr(scope)).join(","), + evt.actor.scopes.map((scope) => normalizeDiagnosticValue(scope)).join(","), ); } } @@ -266,7 +245,7 @@ export function assignOtelSecurityAttributes( assignOtelLogAttribute( attributes, "openclaw.security.target.id_hash", - lowCardinalityAttr(evt.target.idHash), + normalizeDiagnosticValue(evt.target.idHash), ); } if (evt.target.name) { @@ -280,7 +259,7 @@ export function assignOtelSecurityAttributes( assignOtelLogAttribute( attributes, "openclaw.security.target.owner", - lowCardinalityAttr(evt.target.owner), + normalizeDiagnosticValue(evt.target.owner), ); } } @@ -289,7 +268,7 @@ export function assignOtelSecurityAttributes( assignOtelLogAttribute( attributes, "openclaw.security.policy.id", - lowCardinalityAttr(evt.policy.id), + normalizeDiagnosticValue(evt.policy.id), ); } if (evt.policy.decision) { @@ -299,7 +278,7 @@ export function assignOtelSecurityAttributes( assignOtelLogAttribute( attributes, "openclaw.security.policy.reason", - lowCardinalityAttr(evt.policy.reason), + normalizeDiagnosticValue(evt.policy.reason), ); } } @@ -308,7 +287,7 @@ export function assignOtelSecurityAttributes( assignOtelLogAttribute( attributes, "openclaw.security.control.id", - lowCardinalityAttr(evt.control.id), + normalizeDiagnosticValue(evt.control.id), ); } if (evt.control.family) { diff --git a/extensions/diagnostics-otel/src/service-constants.ts b/extensions/diagnostics-otel/src/service-constants.ts index adf3de450ec2..c6b40c4a1022 100644 --- a/extensions/diagnostics-otel/src/service-constants.ts +++ b/extensions/diagnostics-otel/src/service-constants.ts @@ -21,7 +21,6 @@ export const DROPPED_OTEL_ATTRIBUTE_KEYS = new Set([ "openclaw.traceId", "openclaw.trace_id", ]); -export const LOW_CARDINALITY_VALUE_RE = /^[A-Za-z0-9_.:-]{1,120}$/u; export const SECURITY_TARGET_NAME_VALUE_RE = /^[A-Za-z0-9@/_.:-]{1,256}$/u; export const MAX_OTEL_LOG_BODY_CHARS = 4 * 1024; export const MAX_OTEL_LOG_ATTRIBUTE_COUNT = 64; diff --git a/extensions/diagnostics-otel/src/service-exporter.ts b/extensions/diagnostics-otel/src/service-exporter.ts index f9ac91a92377..d01f35397fa9 100644 --- a/extensions/diagnostics-otel/src/service-exporter.ts +++ b/extensions/diagnostics-otel/src/service-exporter.ts @@ -1,7 +1,7 @@ import { readFileSync } from "node:fs"; import nodePath from "node:path"; +import { normalizeDiagnosticValue } from "openclaw/plugin-sdk/diagnostic-runtime"; import { createNodeProxyAgent } from "openclaw/plugin-sdk/fetch-runtime"; -import { lowCardinalityAttr } from "./service-attributes.js"; import { OTEL_EXPORTER_OTLP_CERTIFICATE_ENV, OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE_ENV, @@ -153,9 +153,9 @@ export function formatError(err: unknown): string { export function errorCategory(err: unknown): string { try { if (err instanceof Error && typeof err.name === "string" && err.name.trim()) { - return lowCardinalityAttr(err.name, "Error"); + return normalizeDiagnosticValue(err.name, "Error"); } - return lowCardinalityAttr(typeof err, "unknown"); + return normalizeDiagnosticValue(typeof err, "unknown"); } catch { return "unknown"; } diff --git a/extensions/diagnostics-otel/src/service-genai-attributes.ts b/extensions/diagnostics-otel/src/service-genai-attributes.ts index e5126a2333ed..e87bc4c141ab 100644 --- a/extensions/diagnostics-otel/src/service-genai-attributes.ts +++ b/extensions/diagnostics-otel/src/service-genai-attributes.ts @@ -1,8 +1,8 @@ import { SpanKind } from "@opentelemetry/api"; import { GEN_AI_OPERATION_NAME_VALUE_INVOKE_AGENT } from "@opentelemetry/semantic-conventions/incubating"; +import { normalizeDiagnosticValue } from "openclaw/plugin-sdk/diagnostic-runtime"; import type { DiagnosticEventPayload } from "../api.js"; import { redactSensitiveText } from "../api.js"; -import { lowCardinalityAttr } from "./service-attributes.js"; import { GEN_AI_LATEST_EXPERIMENTAL_OPT_IN, OTEL_SEMCONV_STABILITY_OPT_IN_ENV, @@ -161,13 +161,13 @@ export function assignGenAiSpanIdentityAttrs( }, ): void { if (emitLatestGenAiSemconv()) { - attrs["gen_ai.provider.name"] = lowCardinalityAttr(input.provider); + attrs["gen_ai.provider.name"] = normalizeDiagnosticValue(input.provider); } else { - attrs["gen_ai.system"] = lowCardinalityAttr(input.provider); + attrs["gen_ai.system"] = normalizeDiagnosticValue(input.provider); } if (input.model) { // Span attributes carry the full model id; only metric labels need bounded cardinality - // (the gen_ai metrics below still use lowCardinalityAttr). The low-cardinality allowlist + // (the gen_ai metrics below still use normalizeDiagnosticValue). The low-cardinality allowlist // regex rejects "/", so provider-qualified ids like "anthropic/claude-sonnet-4.6" collapse // to "unknown" on the SPAN — breaking model attribution in trace backends (e.g. Langfuse // reads gen_ai.request.model). Keep the redacted raw model on the span. @@ -206,7 +206,7 @@ export function modelCallSpanName(evt: { const operationName = genAiOperationName(evt.api, evt.observationUnit); return operationName === GEN_AI_OPERATION_NAME_VALUE_INVOKE_AGENT ? operationName - : `${operationName} ${lowCardinalityAttr(evt.model)}`; + : `${operationName} ${normalizeDiagnosticValue(evt.model)}`; } export function modelCallSpanKind(): SpanKind | undefined { @@ -220,7 +220,7 @@ export function addUpstreamRequestIdSpanEvent( if (!upstreamRequestIdHash) { return; } - const boundedHash = lowCardinalityAttr(upstreamRequestIdHash); + const boundedHash = normalizeDiagnosticValue(upstreamRequestIdHash); if (boundedHash === "unknown") { return; } diff --git a/extensions/diagnostics-otel/src/service-recorders-harness.ts b/extensions/diagnostics-otel/src/service-recorders-harness.ts index e83381ab90a1..f532c19e1b91 100644 --- a/extensions/diagnostics-otel/src/service-recorders-harness.ts +++ b/extensions/diagnostics-otel/src/service-recorders-harness.ts @@ -1,10 +1,13 @@ import { SpanStatusCode } from "@opentelemetry/api"; +import { + normalizeDiagnosticValue, + normalizeDiagnosticLane, +} from "openclaw/plugin-sdk/diagnostic-runtime"; import type { DiagnosticEventMetadata, DiagnosticEventPayload, DiagnosticEventPrivateData, } from "../api.js"; -import { lowCardinalityAttr, lowCardinalityQueueLaneAttr } from "./service-attributes.js"; import { normalizeOtelErrorMessage } from "./service-content-normalization.js"; import type { DiagnosticsRecorderRuntime } from "./service-recorder-runtime.js"; import type { HarnessRunDiagnosticEvent, ModelFailoverDiagnosticEvent } from "./service-types.js"; @@ -25,16 +28,16 @@ export function createHarnessRecorders(runtime: DiagnosticsRecorderRuntime) { } = runtime; const harnessRunMetricAttrs = (evt: HarnessRunDiagnosticEvent) => ({ - "openclaw.harness.id": lowCardinalityAttr(evt.harnessId, "unknown"), - "openclaw.harness.plugin": lowCardinalityAttr(evt.pluginId), + "openclaw.harness.id": normalizeDiagnosticValue(evt.harnessId, "unknown"), + "openclaw.harness.plugin": normalizeDiagnosticValue(evt.pluginId), ...(evt.type === "harness.run.started" ? {} : { "openclaw.outcome": evt.type === "harness.run.error" ? "error" : evt.outcome, }), - "openclaw.provider": lowCardinalityAttr(evt.provider, "unknown"), - "openclaw.model": lowCardinalityAttr(evt.model, "unknown"), - ...(evt.channel ? { "openclaw.channel": lowCardinalityAttr(evt.channel) } : {}), + "openclaw.provider": normalizeDiagnosticValue(evt.provider, "unknown"), + "openclaw.model": normalizeDiagnosticValue(evt.model, "unknown"), + ...(evt.channel ? { "openclaw.channel": normalizeDiagnosticValue(evt.channel) } : {}), }); const recordHarnessRunStarted = ( @@ -67,7 +70,7 @@ export function createHarnessRecorders(runtime: DiagnosticsRecorderRuntime) { ...harnessRunMetricAttrs(evt), }; if (evt.resultClassification) { - spanAttrs["openclaw.harness.result_classification"] = lowCardinalityAttr( + spanAttrs["openclaw.harness.result_classification"] = normalizeDiagnosticValue( evt.resultClassification, ); } @@ -113,7 +116,7 @@ export function createHarnessRecorders(runtime: DiagnosticsRecorderRuntime) { metadata: DiagnosticEventMetadata, privateData: DiagnosticEventPrivateData, ) => { - const errorType = lowCardinalityAttr(evt.errorCategory, "other"); + const errorType = normalizeDiagnosticValue(evt.errorCategory, "other"); const attrs = { ...harnessRunMetricAttrs(evt), "openclaw.harness.phase": evt.phase, @@ -190,21 +193,21 @@ export function createHarnessRecorders(runtime: DiagnosticsRecorderRuntime) { metadata: DiagnosticEventMetadata, ) => { const metricAttrs: Record = { - "openclaw.failover.reason": lowCardinalityAttr(evt.reason, "unknown"), + "openclaw.failover.reason": normalizeDiagnosticValue(evt.reason, "unknown"), "openclaw.failover.suspended": evt.suspended === undefined ? "unknown" : String(evt.suspended), - "openclaw.lane": lowCardinalityQueueLaneAttr(evt.lane, "unknown"), - "openclaw.model": lowCardinalityAttr(evt.fromModel), - "openclaw.provider": lowCardinalityAttr(evt.fromProvider), - "openclaw.failover.to_model": lowCardinalityAttr(evt.toModel), - "openclaw.failover.to_provider": lowCardinalityAttr(evt.toProvider), + "openclaw.lane": normalizeDiagnosticLane(evt.lane, "unknown"), + "openclaw.model": normalizeDiagnosticValue(evt.fromModel), + "openclaw.provider": normalizeDiagnosticValue(evt.fromProvider), + "openclaw.failover.to_model": normalizeDiagnosticValue(evt.toModel), + "openclaw.failover.to_provider": normalizeDiagnosticValue(evt.toProvider), }; modelFailoverCounter.add(1, metricAttrs); if (!tracesEnabled) { return; } const spanAttrs: Record = { - "openclaw.failover.reason": lowCardinalityAttr(evt.reason, "unknown"), + "openclaw.failover.reason": normalizeDiagnosticValue(evt.reason, "unknown"), }; if (evt.fromProvider) { spanAttrs["openclaw.provider"] = evt.fromProvider; @@ -219,7 +222,7 @@ export function createHarnessRecorders(runtime: DiagnosticsRecorderRuntime) { spanAttrs["openclaw.failover.to_model"] = evt.toModel; } if (evt.lane) { - spanAttrs["openclaw.lane"] = lowCardinalityQueueLaneAttr(evt.lane, "unknown"); + spanAttrs["openclaw.lane"] = normalizeDiagnosticLane(evt.lane, "unknown"); } if (evt.suspended !== undefined) { spanAttrs["openclaw.failover.suspended"] = evt.suspended; diff --git a/extensions/diagnostics-otel/src/service-recorders-model.ts b/extensions/diagnostics-otel/src/service-recorders-model.ts index c8ebdab3f5b5..f2801d3ea228 100644 --- a/extensions/diagnostics-otel/src/service-recorders-model.ts +++ b/extensions/diagnostics-otel/src/service-recorders-model.ts @@ -1,7 +1,7 @@ import { SpanStatusCode } from "@opentelemetry/api"; +import { normalizeDiagnosticValue } from "openclaw/plugin-sdk/diagnostic-runtime"; import { redactSensitiveText } from "../api.js"; import type { DiagnosticEventMetadata, DiagnosticEventPayload } from "../api.js"; -import { lowCardinalityAttr } from "./service-attributes.js"; import { addUpstreamRequestIdSpanEvent, assignGenAiModelCallAttrs, @@ -38,8 +38,8 @@ export function createModelRecorders(runtime: DiagnosticsRecorderRuntime) { const modelCallMetricAttrs = (evt: ModelCallLifecycleDiagnosticEvent) => ({ "openclaw.provider": evt.provider, "openclaw.model": evt.model, - "openclaw.api": lowCardinalityAttr(evt.api), - "openclaw.transport": lowCardinalityAttr(evt.transport), + "openclaw.api": normalizeDiagnosticValue(evt.api), + "openclaw.transport": normalizeDiagnosticValue(evt.transport), "openclaw.model_call.observation_unit": modelCallObservationUnit(evt), }); const genAiModelCallMetricAttrs = ( @@ -47,8 +47,8 @@ export function createModelRecorders(runtime: DiagnosticsRecorderRuntime) { errorType?: string, ) => ({ "gen_ai.operation.name": genAiOperationName(evt.api, evt.observationUnit), - "gen_ai.provider.name": lowCardinalityAttr(evt.provider), - "gen_ai.request.model": lowCardinalityAttr(evt.model), + "gen_ai.provider.name": normalizeDiagnosticValue(evt.provider), + "gen_ai.request.model": normalizeDiagnosticValue(evt.model), ...(errorType ? { "error.type": errorType } : {}), }); const recordGenAiModelCallDuration = ( @@ -152,12 +152,12 @@ export function createModelRecorders(runtime: DiagnosticsRecorderRuntime) { metadata: DiagnosticEventMetadata, modelContent?: OtelModelCallContent, ) => { - const errorType = lowCardinalityAttr(evt.errorCategory, "other"); + const errorType = normalizeDiagnosticValue(evt.errorCategory, "other"); const metricAttrs = { ...modelCallMetricAttrs(evt), "openclaw.errorCategory": errorType, ...(evt.failureKind - ? { "openclaw.failureKind": lowCardinalityAttr(evt.failureKind, "other") } + ? { "openclaw.failureKind": normalizeDiagnosticValue(evt.failureKind, "other") } : {}), }; modelCallDurationHistogram.record(evt.durationMs, metricAttrs); @@ -173,7 +173,7 @@ export function createModelRecorders(runtime: DiagnosticsRecorderRuntime) { "error.type": errorType, }; if (evt.failureKind) { - spanAttrs["openclaw.failureKind"] = lowCardinalityAttr(evt.failureKind, "other"); + spanAttrs["openclaw.failureKind"] = normalizeDiagnosticValue(evt.failureKind, "other"); } assignGenAiModelCallAttrs(spanAttrs, evt); if (evt.api) { diff --git a/extensions/diagnostics-otel/src/service-recorders-operations.ts b/extensions/diagnostics-otel/src/service-recorders-operations.ts index dd779d4c5f4a..8f4b7f89ce05 100644 --- a/extensions/diagnostics-otel/src/service-recorders-operations.ts +++ b/extensions/diagnostics-otel/src/service-recorders-operations.ts @@ -1,11 +1,14 @@ import { SpanStatusCode } from "@opentelemetry/api"; +import { + normalizeDiagnosticValue, + normalizeDiagnosticLane, +} from "openclaw/plugin-sdk/diagnostic-runtime"; import { redactSensitiveText } from "../api.js"; import type { DiagnosticEventMetadata, DiagnosticEventPayload, DiagnosticEventPrivateData, } from "../api.js"; -import { lowCardinalityAttr, lowCardinalityQueueLaneAttr } from "./service-attributes.js"; import { normalizeOtelErrorMessage } from "./service-content-normalization.js"; import type { DiagnosticsRecorderRuntime } from "./service-recorder-runtime.js"; import type { SessionRecoveryDiagnosticEvent, TalkDiagnosticEvent } from "./service-types.js"; @@ -50,7 +53,7 @@ export function createOperationsRecorders(runtime: DiagnosticsRecorderRuntime) { const recordLaneEnqueue = ( evt: Extract, ) => { - const attrs = { "openclaw.lane": lowCardinalityQueueLaneAttr(evt.lane) }; + const attrs = { "openclaw.lane": normalizeDiagnosticLane(evt.lane) }; laneEnqueueCounter.add(1, attrs); queueDepthHistogram.record(evt.queueSize, attrs); }; @@ -58,7 +61,7 @@ export function createOperationsRecorders(runtime: DiagnosticsRecorderRuntime) { const recordLaneDequeue = ( evt: Extract, ) => { - const attrs = { "openclaw.lane": lowCardinalityQueueLaneAttr(evt.lane) }; + const attrs = { "openclaw.lane": normalizeDiagnosticLane(evt.lane) }; laneDequeueCounter.add(1, attrs); queueDepthHistogram.record(evt.queueSize, attrs); if (typeof evt.waitMs === "number") { @@ -78,8 +81,8 @@ export function createOperationsRecorders(runtime: DiagnosticsRecorderRuntime) { evt: Extract, ) => { sessionTurnCreatedCounter.add(1, { - "openclaw.agent": lowCardinalityAttr(evt.agentId, "unknown"), - "openclaw.channel": lowCardinalityAttr(evt.channel, "unknown"), + "openclaw.agent": normalizeDiagnosticValue(evt.agentId, "unknown"), + "openclaw.channel": normalizeDiagnosticValue(evt.channel, "unknown"), "openclaw.trigger": evt.trigger, }); }; @@ -126,7 +129,7 @@ export function createOperationsRecorders(runtime: DiagnosticsRecorderRuntime) { ) => { const attrs = sessionRecoveryAttrs(evt); attrs["openclaw.status"] = evt.status; - attrs["openclaw.action"] = lowCardinalityAttr(evt.action, "unknown"); + attrs["openclaw.action"] = normalizeDiagnosticValue(evt.action, "unknown"); if (evt.outcomeReason) { attrs["openclaw.reason"] = redactSensitiveText(evt.outcomeReason); } @@ -135,11 +138,11 @@ export function createOperationsRecorders(runtime: DiagnosticsRecorderRuntime) { }; const talkEventAttrs = (evt: TalkDiagnosticEvent): Record => ({ - "openclaw.talk.brain": lowCardinalityAttr(evt.brain), - "openclaw.talk.event_type": lowCardinalityAttr(evt.talkEventType), - "openclaw.talk.mode": lowCardinalityAttr(evt.mode), - "openclaw.talk.provider": lowCardinalityAttr(evt.provider), - "openclaw.talk.transport": lowCardinalityAttr(evt.transport), + "openclaw.talk.brain": normalizeDiagnosticValue(evt.brain), + "openclaw.talk.event_type": normalizeDiagnosticValue(evt.talkEventType), + "openclaw.talk.mode": normalizeDiagnosticValue(evt.mode), + "openclaw.talk.provider": normalizeDiagnosticValue(evt.provider), + "openclaw.talk.transport": normalizeDiagnosticValue(evt.transport), }); const recordTalkEvent = (evt: TalkDiagnosticEvent, metadata: DiagnosticEventMetadata) => { @@ -163,13 +166,13 @@ export function createOperationsRecorders(runtime: DiagnosticsRecorderRuntime) { const toolLoopAttrs = ( evt: Extract, ): Record => ({ - "openclaw.toolName": lowCardinalityAttr(evt.toolName, "tool"), + "openclaw.toolName": normalizeDiagnosticValue(evt.toolName, "tool"), "openclaw.loop.level": evt.level, "openclaw.loop.action": evt.action, "openclaw.loop.detector": evt.detector, "openclaw.loop.count": evt.count, ...(evt.pairedToolName - ? { "openclaw.loop.paired_tool": lowCardinalityAttr(evt.pairedToolName, "tool") } + ? { "openclaw.loop.paired_tool": normalizeDiagnosticValue(evt.pairedToolName, "tool") } : {}), }); @@ -285,7 +288,7 @@ export function createOperationsRecorders(runtime: DiagnosticsRecorderRuntime) { attrs["openclaw.channel"] = evt.channel; } if (evt.blockedBy) { - attrs["openclaw.blocked_by"] = lowCardinalityAttr(evt.blockedBy, "unknown"); + attrs["openclaw.blocked_by"] = normalizeDiagnosticValue(evt.blockedBy, "unknown"); } durationHistogram.record(evt.durationMs, attrs); if (!tracesEnabled) { @@ -296,10 +299,10 @@ export function createOperationsRecorders(runtime: DiagnosticsRecorderRuntime) { }; addRunAttrs(spanAttrs, evt); if (evt.blockedBy) { - spanAttrs["openclaw.blocked_by"] = lowCardinalityAttr(evt.blockedBy, "unknown"); + spanAttrs["openclaw.blocked_by"] = normalizeDiagnosticValue(evt.blockedBy, "unknown"); } if (evt.errorCategory) { - spanAttrs["openclaw.errorCategory"] = lowCardinalityAttr(evt.errorCategory, "other"); + spanAttrs["openclaw.errorCategory"] = normalizeDiagnosticValue(evt.errorCategory, "other"); } // Redacted message goes on the span only, never the low-cardinality metric attrs. const redactedError = normalizeOtelErrorMessage(privateData.errorMessage); diff --git a/extensions/diagnostics-otel/src/service-recorders-tools.ts b/extensions/diagnostics-otel/src/service-recorders-tools.ts index dd0cc6ea9997..83c23275669c 100644 --- a/extensions/diagnostics-otel/src/service-recorders-tools.ts +++ b/extensions/diagnostics-otel/src/service-recorders-tools.ts @@ -1,7 +1,7 @@ import { SpanStatusCode } from "@opentelemetry/api"; +import { normalizeDiagnosticValue } from "openclaw/plugin-sdk/diagnostic-runtime"; import { redactSensitiveText } from "../api.js"; import type { DiagnosticEventMetadata, DiagnosticEventPayload } from "../api.js"; -import { lowCardinalityAttr } from "./service-attributes.js"; import { positiveFiniteNumber } from "./service-genai-attributes.js"; import { assignOtelToolContentAttributes, @@ -51,20 +51,22 @@ export function createToolAndSystemRecorders(runtime: DiagnosticsRecorderRuntime >, ): Record => ({ "openclaw.toolName": evt.toolName, - "openclaw.tool.source": lowCardinalityAttr(evt.toolSource, "core"), + "openclaw.tool.source": normalizeDiagnosticValue(evt.toolSource, "core"), "gen_ai.tool.name": evt.toolName, - ...(evt.toolOwner ? { "openclaw.tool.owner": lowCardinalityAttr(evt.toolOwner) } : {}), + ...(evt.toolOwner ? { "openclaw.tool.owner": normalizeDiagnosticValue(evt.toolOwner) } : {}), ...paramsSummaryAttrs(evt.paramsSummary), }); const skillUsedAttrs = ( evt: Extract, ): Record => ({ - "openclaw.skill.name": lowCardinalityAttr(evt.skillName, "skill"), - "openclaw.skill.source": lowCardinalityAttr(evt.skillSource), - "openclaw.skill.activation": lowCardinalityAttr(evt.activation), - ...(evt.agentId ? { "openclaw.agent": lowCardinalityAttr(evt.agentId) } : {}), - ...(evt.toolName ? { "openclaw.toolName": lowCardinalityAttr(evt.toolName, "tool") } : {}), + "openclaw.skill.name": normalizeDiagnosticValue(evt.skillName, "skill"), + "openclaw.skill.source": normalizeDiagnosticValue(evt.skillSource), + "openclaw.skill.activation": normalizeDiagnosticValue(evt.activation), + ...(evt.agentId ? { "openclaw.agent": normalizeDiagnosticValue(evt.agentId) } : {}), + ...(evt.toolName + ? { "openclaw.toolName": normalizeDiagnosticValue(evt.toolName, "tool") } + : {}), }); const recordSkillUsed = ( @@ -139,7 +141,7 @@ export function createToolAndSystemRecorders(runtime: DiagnosticsRecorderRuntime ) => { const attrs = { ...toolExecutionBaseAttrs(evt), - "openclaw.errorCategory": lowCardinalityAttr(evt.errorCategory, "other"), + "openclaw.errorCategory": normalizeDiagnosticValue(evt.errorCategory, "other"), }; toolExecutionDurationHistogram.record(evt.durationMs, attrs); if (!tracesEnabled) { @@ -149,7 +151,7 @@ export function createToolAndSystemRecorders(runtime: DiagnosticsRecorderRuntime addRunAttrs(spanAttrs, evt); assignOtelToolIdentityAttributes(spanAttrs, evt); if (evt.errorCode) { - spanAttrs["openclaw.errorCode"] = lowCardinalityAttr(evt.errorCode, "other"); + spanAttrs["openclaw.errorCode"] = normalizeDiagnosticValue(evt.errorCode, "other"); } assignOtelToolContentAttributes(spanAttrs, toolContent, contentCapturePolicy); const span = @@ -172,7 +174,7 @@ export function createToolAndSystemRecorders(runtime: DiagnosticsRecorderRuntime ) => { toolExecutionBlockedCounter.add(1, { ...toolExecutionBaseAttrs(evt), - "openclaw.deniedReason": lowCardinalityAttr(evt.deniedReason, "other"), + "openclaw.deniedReason": normalizeDiagnosticValue(evt.deniedReason, "other"), }); if (!tracesEnabled) { return; @@ -180,7 +182,7 @@ export function createToolAndSystemRecorders(runtime: DiagnosticsRecorderRuntime const spanAttrs: Record = { ...toolExecutionBaseAttrs(evt), "openclaw.outcome": "blocked", - "openclaw.deniedReason": lowCardinalityAttr(evt.deniedReason, "other"), + "openclaw.deniedReason": normalizeDiagnosticValue(evt.deniedReason, "other"), }; addRunAttrs(spanAttrs, evt); assignOtelToolIdentityAttributes(spanAttrs, evt); @@ -195,10 +197,10 @@ export function createToolAndSystemRecorders(runtime: DiagnosticsRecorderRuntime const recordPayloadLarge = (evt: Extract) => { const attrs = { "openclaw.payload.action": evt.action, - "openclaw.payload.surface": lowCardinalityAttr(evt.surface, "unknown"), - "openclaw.channel": lowCardinalityAttr(evt.channel, "none"), - "openclaw.plugin": lowCardinalityAttr(evt.pluginId, "none"), - "openclaw.reason": lowCardinalityAttr(evt.reason, "none"), + "openclaw.payload.surface": normalizeDiagnosticValue(evt.surface, "unknown"), + "openclaw.channel": normalizeDiagnosticValue(evt.channel, "none"), + "openclaw.plugin": normalizeDiagnosticValue(evt.pluginId, "none"), + "openclaw.reason": normalizeDiagnosticValue(evt.reason, "none"), }; payloadLargeCounter.add(1, attrs); const bytes = positiveFiniteNumber(evt.bytes); @@ -232,7 +234,7 @@ export function createToolAndSystemRecorders(runtime: DiagnosticsRecorderRuntime spanAttrs["openclaw.exec.exit_code"] = evt.exitCode; } if (evt.exitSignal) { - spanAttrs["openclaw.exec.exit_signal"] = lowCardinalityAttr(evt.exitSignal, "other"); + spanAttrs["openclaw.exec.exit_signal"] = normalizeDiagnosticValue(evt.exitSignal, "other"); } if (evt.timedOut !== undefined) { spanAttrs["openclaw.exec.timed_out"] = evt.timedOut; @@ -267,7 +269,7 @@ export function createToolAndSystemRecorders(runtime: DiagnosticsRecorderRuntime ) => { const reason = evt.reasons.join(":"); const attrs = { - "openclaw.liveness.reason": lowCardinalityAttr(reason, "unknown"), + "openclaw.liveness.reason": normalizeDiagnosticValue(reason, "unknown"), }; livenessWarningCounter.add(1, attrs); queueDepthHistogram.record(evt.queued, { "openclaw.channel": "liveness" }); @@ -327,7 +329,7 @@ export function createToolAndSystemRecorders(runtime: DiagnosticsRecorderRuntime return; } const spanAttrs: Record = { - "openclaw.phase": lowCardinalityAttr(evt.name, "unknown"), + "openclaw.phase": normalizeDiagnosticValue(evt.name, "unknown"), ...(evt.cpuUserMs !== undefined ? { "openclaw.phase.cpu_user_ms": evt.cpuUserMs } : {}), ...(evt.cpuSystemMs !== undefined ? { "openclaw.phase.cpu_system_ms": evt.cpuSystemMs } : {}), ...(evt.cpuTotalMs !== undefined ? { "openclaw.phase.cpu_total_ms": evt.cpuTotalMs } : {}), @@ -353,12 +355,12 @@ export function createToolAndSystemRecorders(runtime: DiagnosticsRecorderRuntime return; } telemetryExporterCounter.add(1, { - "openclaw.exporter": lowCardinalityAttr(evt.exporter, "unknown"), + "openclaw.exporter": normalizeDiagnosticValue(evt.exporter, "unknown"), "openclaw.signal": evt.signal, "openclaw.status": evt.status, ...(evt.reason ? { "openclaw.reason": evt.reason } : {}), ...(evt.errorCategory - ? { "openclaw.errorCategory": lowCardinalityAttr(evt.errorCategory, "other") } + ? { "openclaw.errorCategory": normalizeDiagnosticValue(evt.errorCategory, "other") } : {}), }); }; diff --git a/extensions/diagnostics-otel/src/service-recorders-usage.ts b/extensions/diagnostics-otel/src/service-recorders-usage.ts index 232c9ed06be0..3b962d98a6f8 100644 --- a/extensions/diagnostics-otel/src/service-recorders-usage.ts +++ b/extensions/diagnostics-otel/src/service-recorders-usage.ts @@ -1,7 +1,7 @@ import { SpanStatusCode } from "@opentelemetry/api"; +import { normalizeDiagnosticValue } from "openclaw/plugin-sdk/diagnostic-runtime"; import { redactSensitiveText } from "../api.js"; import type { DiagnosticEventMetadata, DiagnosticEventPayload } from "../api.js"; -import { lowCardinalityAttr } from "./service-attributes.js"; import { assignGenAiSpanIdentityAttrs, assignPositiveNumberAttr, @@ -54,14 +54,14 @@ export function createUsageRecorders(runtime: DiagnosticsRecorderRuntime) { ) => { const attrs = { "openclaw.channel": evt.channel ?? "unknown", - "openclaw.agent": lowCardinalityAttr(evt.agentId), + "openclaw.agent": normalizeDiagnosticValue(evt.agentId), "openclaw.provider": evt.provider ?? "unknown", "openclaw.model": evt.model ?? "unknown", }; const genAiAttrs: Record = { "gen_ai.operation.name": "chat", - "gen_ai.provider.name": lowCardinalityAttr(evt.provider), - "gen_ai.request.model": lowCardinalityAttr(evt.model), + "gen_ai.provider.name": normalizeDiagnosticValue(evt.provider), + "gen_ai.request.model": normalizeDiagnosticValue(evt.model), }; const usage = evt.usage; @@ -155,8 +155,8 @@ export function createUsageRecorders(runtime: DiagnosticsRecorderRuntime) { evt: Extract, ) => { const attrs = { - "openclaw.channel": lowCardinalityAttr(evt.channel), - "openclaw.webhook": lowCardinalityAttr(evt.updateType), + "openclaw.channel": normalizeDiagnosticValue(evt.channel), + "openclaw.webhook": normalizeDiagnosticValue(evt.updateType), }; if (typeof evt.durationMs === "number") { webhookDurationHistogram.record(evt.durationMs, attrs); @@ -171,8 +171,8 @@ export function createUsageRecorders(runtime: DiagnosticsRecorderRuntime) { const recordWebhookError = (evt: Extract) => { const attrs = { - "openclaw.channel": lowCardinalityAttr(evt.channel), - "openclaw.webhook": lowCardinalityAttr(evt.updateType), + "openclaw.channel": normalizeDiagnosticValue(evt.channel), + "openclaw.webhook": normalizeDiagnosticValue(evt.updateType), }; webhookErrorCounter.add(1, attrs); if (!tracesEnabled) { @@ -194,8 +194,8 @@ export function createUsageRecorders(runtime: DiagnosticsRecorderRuntime) { evt: Extract, ) => { const attrs = { - "openclaw.channel": lowCardinalityAttr(evt.channel), - "openclaw.source": lowCardinalityAttr(evt.source), + "openclaw.channel": normalizeDiagnosticValue(evt.channel), + "openclaw.source": normalizeDiagnosticValue(evt.source), }; messageQueuedCounter.add(1, attrs); if (typeof evt.queueDepth === "number") { @@ -207,8 +207,8 @@ export function createUsageRecorders(runtime: DiagnosticsRecorderRuntime) { evt: Extract, ) => { messageReceivedCounter.add(1, { - "openclaw.channel": lowCardinalityAttr(evt.channel), - "openclaw.source": lowCardinalityAttr(evt.source), + "openclaw.channel": normalizeDiagnosticValue(evt.channel), + "openclaw.source": normalizeDiagnosticValue(evt.source), }); }; @@ -217,8 +217,8 @@ export function createUsageRecorders(runtime: DiagnosticsRecorderRuntime) { metadata: DiagnosticEventMetadata, ) => { const attrs = { - "openclaw.channel": lowCardinalityAttr(evt.channel), - "openclaw.source": lowCardinalityAttr(evt.source), + "openclaw.channel": normalizeDiagnosticValue(evt.channel), + "openclaw.source": normalizeDiagnosticValue(evt.source), }; messageDispatchStartedCounter.add(1, attrs); if (!tracesEnabled) { @@ -242,10 +242,10 @@ export function createUsageRecorders(runtime: DiagnosticsRecorderRuntime) { evt: Extract, ) => { const attrs = { - "openclaw.channel": lowCardinalityAttr(evt.channel), + "openclaw.channel": normalizeDiagnosticValue(evt.channel), "openclaw.outcome": evt.outcome, - "openclaw.reason": lowCardinalityAttr(evt.reason, "none"), - "openclaw.source": lowCardinalityAttr(evt.source), + "openclaw.reason": normalizeDiagnosticValue(evt.reason, "none"), + "openclaw.source": normalizeDiagnosticValue(evt.source), }; messageDispatchCompletedCounter.add(1, attrs); messageDispatchDurationHistogram.record(evt.durationMs, attrs); @@ -256,7 +256,7 @@ export function createUsageRecorders(runtime: DiagnosticsRecorderRuntime) { metadata: DiagnosticEventMetadata, ) => { const attrs = { - "openclaw.channel": lowCardinalityAttr(evt.channel), + "openclaw.channel": normalizeDiagnosticValue(evt.channel), "openclaw.outcome": evt.outcome ?? "unknown", }; messageProcessedCounter.add(1, attrs); @@ -268,7 +268,7 @@ export function createUsageRecorders(runtime: DiagnosticsRecorderRuntime) { } const spanAttrs: Record = { ...attrs }; if (evt.reason) { - spanAttrs["openclaw.reason"] = lowCardinalityAttr(evt.reason, "unknown"); + spanAttrs["openclaw.reason"] = normalizeDiagnosticValue(evt.reason, "unknown"); } const trackedSpan = getTrackedInternalOrTrustedSpan(evt, metadata); const span = @@ -290,8 +290,8 @@ export function createUsageRecorders(runtime: DiagnosticsRecorderRuntime) { }; const messageDeliveryAttrs = (evt: MessageDeliveryDiagnosticEvent): Record => ({ - "openclaw.channel": lowCardinalityAttr(evt.channel), - "openclaw.delivery.kind": lowCardinalityAttr(evt.deliveryKind, "other"), + "openclaw.channel": normalizeDiagnosticValue(evt.channel), + "openclaw.delivery.kind": normalizeDiagnosticValue(evt.deliveryKind, "other"), }); const recordMessageDeliveryStarted = ( @@ -331,7 +331,7 @@ export function createUsageRecorders(runtime: DiagnosticsRecorderRuntime) { const attrs = { ...messageDeliveryAttrs(evt), "openclaw.outcome": "error", - "openclaw.errorCategory": lowCardinalityAttr(evt.errorCategory, "other"), + "openclaw.errorCategory": normalizeDiagnosticValue(evt.errorCategory, "other"), }; messageDeliveryDurationHistogram.record(evt.durationMs, attrs); if (!tracesEnabled) { diff --git a/extensions/diagnostics-prometheus/src/service.ts b/extensions/diagnostics-prometheus/src/service.ts index 23edbb1dfd77..e27b13376dc7 100644 --- a/extensions/diagnostics-prometheus/src/service.ts +++ b/extensions/diagnostics-prometheus/src/service.ts @@ -1,5 +1,9 @@ // Diagnostics Prometheus plugin module implements service behavior. import type { IncomingMessage, ServerResponse } from "node:http"; +import { + normalizeDiagnosticValue, + normalizeDiagnosticLane, +} from "openclaw/plugin-sdk/diagnostic-runtime"; import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import type { DiagnosticEventMetadata, @@ -49,34 +53,8 @@ const BYTE_BUCKETS = [ 4294967296, 17179869184, ]; const RATIO_BUCKETS = [0.01, 0.05, 0.1, 0.25, 0.5, 0.75, 1, 2, 4, 8, 16]; -const LOW_CARDINALITY_VALUE_RE = /^[A-Za-z0-9_.:-]{1,120}$/u; const MAX_PROMETHEUS_SERIES = 2048; const DROPPED_SERIES_COUNTER_NAME = "openclaw_prometheus_series_dropped_total"; -function lowCardinalityLabel(value: string | undefined, fallback = "unknown"): string { - if (!value) { - return fallback; - } - const redacted = redactSensitiveText(value.trim()); - const redactedLower = redacted.toLowerCase(); - if (redactedLower.startsWith("agent:") || redactedLower.includes(":agent:")) { - return fallback; - } - return LOW_CARDINALITY_VALUE_RE.test(redacted) ? redacted : fallback; -} - -function lowCardinalityQueueLaneLabel(value: string | undefined, fallback = "unknown"): string { - if (!value) { - return fallback; - } - const redacted = redactSensitiveText(value.trim()); - const redactedLower = redacted.toLowerCase(); - if (redactedLower.startsWith("agent:")) { - return fallback; - } - const scopedLaneIndex = redacted.indexOf(":"); - const lane = scopedLaneIndex >= 0 ? redacted.slice(0, scopedLaneIndex) : redacted; - return LOW_CARDINALITY_VALUE_RE.test(lane) ? lane : fallback; -} function numericValue(value: number | undefined): number | undefined { return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined; @@ -310,12 +288,12 @@ function runLabels(evt: { trigger?: string; }): LabelSet { return { - ...(evt.blockedBy ? { blocked_by: lowCardinalityLabel(evt.blockedBy) } : {}), - channel: lowCardinalityLabel(evt.channel), - model: lowCardinalityLabel(evt.model), - outcome: lowCardinalityLabel(evt.outcome, "unknown"), - provider: lowCardinalityLabel(evt.provider), - trigger: lowCardinalityLabel(evt.trigger), + ...(evt.blockedBy ? { blocked_by: normalizeDiagnosticValue(evt.blockedBy) } : {}), + channel: normalizeDiagnosticValue(evt.channel), + model: normalizeDiagnosticValue(evt.model), + outcome: normalizeDiagnosticValue(evt.outcome, "unknown"), + provider: normalizeDiagnosticValue(evt.provider), + trigger: normalizeDiagnosticValue(evt.trigger), }; } @@ -329,14 +307,16 @@ function modelCallLabels(evt: { type: string; }): LabelSet { return { - api: lowCardinalityLabel(evt.api), + api: normalizeDiagnosticValue(evt.api), error_category: - evt.type === "model.call.error" ? lowCardinalityLabel(evt.errorCategory, "other") : "none", - model: lowCardinalityLabel(evt.model), + evt.type === "model.call.error" + ? normalizeDiagnosticValue(evt.errorCategory, "other") + : "none", + model: normalizeDiagnosticValue(evt.model), observation_unit: evt.observationUnit === "turn" ? "turn" : "request", outcome: evt.type === "model.call.error" ? "error" : "completed", - provider: lowCardinalityLabel(evt.provider), - transport: lowCardinalityLabel(evt.transport), + provider: normalizeDiagnosticValue(evt.provider), + transport: normalizeDiagnosticValue(evt.transport), }; } @@ -344,13 +324,13 @@ function modelFailoverLabels( evt: Extract, ): LabelSet { return { - from_model: lowCardinalityLabel(evt.fromModel), - from_provider: lowCardinalityLabel(evt.fromProvider), - lane: lowCardinalityQueueLaneLabel(evt.lane), - reason: lowCardinalityLabel(evt.reason, "other"), + from_model: normalizeDiagnosticValue(evt.fromModel), + from_provider: normalizeDiagnosticValue(evt.fromProvider), + lane: normalizeDiagnosticLane(evt.lane), + reason: normalizeDiagnosticValue(evt.reason, "other"), suspended: evt.suspended === undefined ? "unknown" : String(evt.suspended), - to_model: lowCardinalityLabel(evt.toModel), - to_provider: lowCardinalityLabel(evt.toProvider), + to_model: normalizeDiagnosticValue(evt.toModel), + to_provider: normalizeDiagnosticValue(evt.toProvider), }; } @@ -365,13 +345,13 @@ function toolExecutionLabels(evt: { return { error_category: evt.type === "tool.execution.error" - ? lowCardinalityLabel(evt.errorCategory, "other") + ? normalizeDiagnosticValue(evt.errorCategory, "other") : "none", outcome: evt.type === "tool.execution.error" ? "error" : "completed", - params_kind: lowCardinalityLabel(evt.paramsSummary?.kind), - tool: lowCardinalityLabel(evt.toolName, "tool"), - tool_owner: lowCardinalityLabel(evt.toolOwner, "none"), - tool_source: lowCardinalityLabel(evt.toolSource, "core"), + params_kind: normalizeDiagnosticValue(evt.paramsSummary?.kind), + tool: normalizeDiagnosticValue(evt.toolName, "tool"), + tool_owner: normalizeDiagnosticValue(evt.toolOwner, "none"), + tool_source: normalizeDiagnosticValue(evt.toolSource, "core"), }; } @@ -379,11 +359,11 @@ function toolExecutionBlockedLabels( evt: Extract, ): LabelSet { return { - denied_reason: lowCardinalityLabel(evt.deniedReason, "other"), - params_kind: lowCardinalityLabel(evt.paramsSummary?.kind), - tool: lowCardinalityLabel(evt.toolName, "tool"), - tool_owner: lowCardinalityLabel(evt.toolOwner, "none"), - tool_source: lowCardinalityLabel(evt.toolSource, "core"), + denied_reason: normalizeDiagnosticValue(evt.deniedReason, "other"), + params_kind: normalizeDiagnosticValue(evt.paramsSummary?.kind), + tool: normalizeDiagnosticValue(evt.toolName, "tool"), + tool_owner: normalizeDiagnosticValue(evt.toolOwner, "none"), + tool_source: normalizeDiagnosticValue(evt.toolSource, "core"), }; } @@ -394,10 +374,10 @@ function skillLabels(evt: { skillSource?: string; }): LabelSet { return { - activation: lowCardinalityLabel(evt.activation, "unknown"), - agent: lowCardinalityLabel(evt.agentId), - skill: lowCardinalityLabel(evt.skillName, "skill"), - source: lowCardinalityLabel(evt.skillSource), + activation: normalizeDiagnosticValue(evt.activation, "unknown"), + agent: normalizeDiagnosticValue(evt.agentId), + skill: normalizeDiagnosticValue(evt.skillName, "skill"), + source: normalizeDiagnosticValue(evt.skillSource), }; } @@ -413,15 +393,17 @@ function harnessLabels(evt: { type: string; }): LabelSet { return { - channel: lowCardinalityLabel(evt.channel), + channel: normalizeDiagnosticValue(evt.channel), error_category: - evt.type === "harness.run.error" ? lowCardinalityLabel(evt.errorCategory, "other") : "none", - harness: lowCardinalityLabel(evt.harnessId), - model: lowCardinalityLabel(evt.model), - outcome: evt.type === "harness.run.error" ? "error" : lowCardinalityLabel(evt.outcome), - phase: evt.type === "harness.run.error" ? lowCardinalityLabel(evt.phase) : "none", - plugin: lowCardinalityLabel(evt.pluginId), - provider: lowCardinalityLabel(evt.provider), + evt.type === "harness.run.error" + ? normalizeDiagnosticValue(evt.errorCategory, "other") + : "none", + harness: normalizeDiagnosticValue(evt.harnessId), + model: normalizeDiagnosticValue(evt.model), + outcome: evt.type === "harness.run.error" ? "error" : normalizeDiagnosticValue(evt.outcome), + phase: evt.type === "harness.run.error" ? normalizeDiagnosticValue(evt.phase) : "none", + plugin: normalizeDiagnosticValue(evt.pluginId), + provider: normalizeDiagnosticValue(evt.provider), }; } @@ -432,8 +414,8 @@ function webhookLabels( >, ): LabelSet { return { - channel: lowCardinalityLabel(evt.channel), - webhook: lowCardinalityLabel(evt.updateType), + channel: normalizeDiagnosticValue(evt.channel), + webhook: normalizeDiagnosticValue(evt.updateType), }; } @@ -441,7 +423,7 @@ function sessionStuckLabels( evt: Extract, ): LabelSet { return { - reason: lowCardinalityLabel(evt.reason, "none"), + reason: normalizeDiagnosticValue(evt.reason, "none"), state: evt.state, }; } @@ -455,11 +437,11 @@ function sessionRecoveryLabels( return { action: evt.type === "session.recovery.completed" - ? lowCardinalityLabel(evt.action, "unknown") + ? normalizeDiagnosticValue(evt.action, "unknown") : evt.allowActiveAbort ? "abort" : "recover", - active_work_kind: lowCardinalityLabel(evt.activeWorkKind, "none"), + active_work_kind: normalizeDiagnosticValue(evt.activeWorkKind, "none"), state: evt.state, status: evt.type === "session.recovery.completed" ? evt.status : "requested", }; @@ -469,7 +451,7 @@ function livenessLabels( evt: Extract, ): LabelSet { return { - reason: lowCardinalityLabel(evt.reasons.join(":"), "unknown"), + reason: normalizeDiagnosticValue(evt.reasons.join(":"), "unknown"), }; } @@ -478,20 +460,20 @@ function payloadLargeLabels( ): LabelSet { return { action: evt.action, - channel: lowCardinalityLabel(evt.channel, "none"), - plugin: lowCardinalityLabel(evt.pluginId, "none"), - reason: lowCardinalityLabel(evt.reason, "none"), - surface: lowCardinalityLabel(evt.surface, "unknown"), + channel: normalizeDiagnosticValue(evt.channel, "none"), + plugin: normalizeDiagnosticValue(evt.pluginId, "none"), + reason: normalizeDiagnosticValue(evt.reason, "none"), + surface: normalizeDiagnosticValue(evt.surface, "unknown"), }; } function talkLabels(evt: Extract): LabelSet { return { - brain: lowCardinalityLabel(evt.brain), - event_type: lowCardinalityLabel(evt.talkEventType), - mode: lowCardinalityLabel(evt.mode), - provider: lowCardinalityLabel(evt.provider), - transport: lowCardinalityLabel(evt.transport), + brain: normalizeDiagnosticValue(evt.brain), + event_type: normalizeDiagnosticValue(evt.talkEventType), + mode: normalizeDiagnosticValue(evt.mode), + provider: normalizeDiagnosticValue(evt.provider), + transport: normalizeDiagnosticValue(evt.transport), }; } @@ -500,10 +482,10 @@ function recordModelUsage( evt: Extract, ) { const labels = { - agent: lowCardinalityLabel(evt.agentId), - channel: lowCardinalityLabel(evt.channel), - model: lowCardinalityLabel(evt.model), - provider: lowCardinalityLabel(evt.provider), + agent: normalizeDiagnosticValue(evt.agentId), + channel: normalizeDiagnosticValue(evt.channel), + model: normalizeDiagnosticValue(evt.model), + provider: normalizeDiagnosticValue(evt.provider), }; const usage = evt.usage; const recordTokens = (tokenType: string, value: number | undefined) => { @@ -643,17 +625,17 @@ function recordDiagnosticEvent( return; case "message.processed": store.counter("openclaw_message_processed_total", "Inbound messages processed by outcome.", { - channel: lowCardinalityLabel(evt.channel), + channel: normalizeDiagnosticValue(evt.channel), outcome: evt.outcome, - reason: lowCardinalityLabel(evt.reason, "none"), + reason: normalizeDiagnosticValue(evt.reason, "none"), }); store.histogram( "openclaw_message_processed_duration_seconds", "Inbound message processing duration in seconds.", { - channel: lowCardinalityLabel(evt.channel), + channel: normalizeDiagnosticValue(evt.channel), outcome: evt.outcome, - reason: lowCardinalityLabel(evt.reason, "none"), + reason: normalizeDiagnosticValue(evt.reason, "none"), }, seconds(evt.durationMs), ); @@ -685,15 +667,15 @@ function recordDiagnosticEvent( "openclaw_message_delivery_started_total", "Outbound message delivery attempts started.", { - channel: lowCardinalityLabel(evt.channel), - delivery_kind: lowCardinalityLabel(evt.deliveryKind, "other"), + channel: normalizeDiagnosticValue(evt.channel), + delivery_kind: normalizeDiagnosticValue(evt.deliveryKind, "other"), }, ); return; case "message.received": store.counter("openclaw_message_received_total", "Inbound messages received by channel.", { - channel: lowCardinalityLabel(evt.channel), - source: lowCardinalityLabel(evt.source), + channel: normalizeDiagnosticValue(evt.channel), + source: normalizeDiagnosticValue(evt.source), }); return; case "message.dispatch.started": @@ -701,8 +683,8 @@ function recordDiagnosticEvent( "openclaw_message_dispatch_started_total", "Inbound message dispatch attempts started by channel.", { - channel: lowCardinalityLabel(evt.channel), - source: lowCardinalityLabel(evt.source), + channel: normalizeDiagnosticValue(evt.channel), + source: normalizeDiagnosticValue(evt.source), }, ); return; @@ -711,20 +693,20 @@ function recordDiagnosticEvent( "openclaw_message_dispatch_completed_total", "Inbound message dispatch attempts completed by outcome.", { - channel: lowCardinalityLabel(evt.channel), + channel: normalizeDiagnosticValue(evt.channel), outcome: evt.outcome, - reason: lowCardinalityLabel(evt.reason, "none"), - source: lowCardinalityLabel(evt.source), + reason: normalizeDiagnosticValue(evt.reason, "none"), + source: normalizeDiagnosticValue(evt.source), }, ); store.histogram( "openclaw_message_dispatch_duration_seconds", "Inbound message dispatch duration in seconds.", { - channel: lowCardinalityLabel(evt.channel), + channel: normalizeDiagnosticValue(evt.channel), outcome: evt.outcome, - reason: lowCardinalityLabel(evt.reason, "none"), - source: lowCardinalityLabel(evt.source), + reason: normalizeDiagnosticValue(evt.reason, "none"), + source: normalizeDiagnosticValue(evt.source), }, seconds(evt.durationMs), ); @@ -735,11 +717,11 @@ function recordDiagnosticEvent( "openclaw_message_delivery_total", "Outbound message delivery attempts by outcome.", { - channel: lowCardinalityLabel(evt.channel), - delivery_kind: lowCardinalityLabel(evt.deliveryKind, "other"), + channel: normalizeDiagnosticValue(evt.channel), + delivery_kind: normalizeDiagnosticValue(evt.deliveryKind, "other"), error_category: evt.type === "message.delivery.error" - ? lowCardinalityLabel(evt.errorCategory, "other") + ? normalizeDiagnosticValue(evt.errorCategory, "other") : "none", outcome: evt.type === "message.delivery.error" ? "error" : "completed", }, @@ -748,11 +730,11 @@ function recordDiagnosticEvent( "openclaw_message_delivery_duration_seconds", "Outbound message delivery duration in seconds.", { - channel: lowCardinalityLabel(evt.channel), - delivery_kind: lowCardinalityLabel(evt.deliveryKind, "other"), + channel: normalizeDiagnosticValue(evt.channel), + delivery_kind: normalizeDiagnosticValue(evt.deliveryKind, "other"), error_category: evt.type === "message.delivery.error" - ? lowCardinalityLabel(evt.errorCategory, "other") + ? normalizeDiagnosticValue(evt.errorCategory, "other") : "none", outcome: evt.type === "message.delivery.error" ? "error" : "completed", }, @@ -795,7 +777,7 @@ function recordDiagnosticEvent( "openclaw_queue_lane_size", "Current diagnostic queue lane size.", { - lane: lowCardinalityQueueLaneLabel(evt.lane), + lane: normalizeDiagnosticLane(evt.lane), }, numericValue(evt.queueSize), ); @@ -803,14 +785,14 @@ function recordDiagnosticEvent( store.histogram( "openclaw_queue_lane_wait_seconds", "Queue lane wait time in seconds.", - { lane: lowCardinalityQueueLaneLabel(evt.lane) }, + { lane: normalizeDiagnosticLane(evt.lane) }, seconds(evt.waitMs), ); } return; case "session.state": store.counter("openclaw_session_state_total", "Session state observations.", { - reason: lowCardinalityLabel(evt.reason, "none"), + reason: normalizeDiagnosticValue(evt.reason, "none"), state: evt.state, }); if (evt.queueDepth !== undefined) { @@ -839,8 +821,8 @@ function recordDiagnosticEvent( return; case "session.turn.created": store.counter("openclaw_session_turn_created_total", "Agent session turns created.", { - agent: lowCardinalityLabel(evt.agentId), - channel: lowCardinalityLabel(evt.channel), + agent: normalizeDiagnosticValue(evt.agentId), + channel: normalizeDiagnosticValue(evt.channel), trigger: evt.trigger, }); return; @@ -974,8 +956,8 @@ function recordDiagnosticEvent( break; case "telemetry.exporter": store.counter("openclaw_telemetry_exporter_total", "Telemetry exporter lifecycle events.", { - exporter: lowCardinalityLabel(evt.exporter), - reason: lowCardinalityLabel(evt.reason, "none"), + exporter: normalizeDiagnosticValue(evt.exporter), + reason: normalizeDiagnosticValue(evt.reason, "none"), signal: evt.signal, status: evt.status, }); diff --git a/extensions/onepassword/onepassword-op-path.d.ts b/extensions/onepassword/onepassword-op-path.d.ts index e276f383a656..b9108789c3e4 100644 --- a/extensions/onepassword/onepassword-op-path.d.ts +++ b/extensions/onepassword/onepassword-op-path.d.ts @@ -1,5 +1,3 @@ -export function resolveTrustedOnePasswordDirectoryPath(targetPath: string): Promise; - export function resolveTrustedOnePasswordCli(options?: { configuredPath?: string; pathEnv?: string; diff --git a/extensions/onepassword/onepassword-op-path.js b/extensions/onepassword/onepassword-op-path.js index 4571d43744a1..86ca8f1ea65d 100644 --- a/extensions/onepassword/onepassword-op-path.js +++ b/extensions/onepassword/onepassword-op-path.js @@ -6,8 +6,6 @@ function errorCode(error) { } const resolveTrustedExecutablePath = pluginSecretRefSetup.resolveTrustedExecutablePath; -export const resolveTrustedOnePasswordDirectoryPath = - pluginSecretRefSetup.resolveTrustedDirectoryPath; export async function resolveTrustedOnePasswordCli(options = {}) { const configuredPath = options.configuredPath?.trim(); diff --git a/extensions/onepassword/src/secret-ref-cli.test.ts b/extensions/onepassword/src/secret-ref-cli.test.ts index f4243fa228e5..48384976eec5 100644 --- a/extensions/onepassword/src/secret-ref-cli.test.ts +++ b/extensions/onepassword/src/secret-ref-cli.test.ts @@ -1,13 +1,17 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { inspectPathPermissions } from "@openclaw/fs-safe/permissions"; import { Command } from "commander"; import type { OpenClawConfig } from "openclaw/plugin-sdk/plugin-entry"; import { afterEach, describe, expect, it, vi } from "vitest"; import { encodeOnePasswordSecretId } from "../onepassword-secret-id.js"; import { registerOnePasswordSecretRefCommands, testing } from "./secret-ref-cli.js"; +type OnePasswordPlan = { + providerUpserts: Record; + targets: Array>; +}; + function captureStdout() { let output = ""; vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { @@ -17,7 +21,7 @@ function captureStdout() { return () => output; } -function createProgram(config: OpenClawConfig): Command { +function createProgram(config: OpenClawConfig = {}): Command { const program = new Command().exitOverride(); const onepassword = program.command("onepassword"); registerOnePasswordSecretRefCommands({ @@ -36,19 +40,29 @@ async function runStatus( const output = captureStdout(); await createProgram(config).parseAsync( ["onepassword", "secretref", "status", "--json", ...args], - { - from: "user", - }, + { from: "user" }, ); return JSON.parse(output()) as Record; } -function createOpenAiPlan() { - return testing.buildPlan({ - providerAlias: "onepassword", - providerConfig: testing.buildProviderConfig(), - providerSecrets: [{ providerId: "openai", secretId: "op://openclaw/OpenAI/credential" }], - }); +async function runSetup(planPath: string, args: string[]): Promise { + const output = captureStdout(); + await createProgram().parseAsync( + ["onepassword", "secretref", "setup", "--plan-out", planPath, ...args], + { from: "user" }, + ); + return output(); +} + +async function createSetupPlan(args: string[]): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-onepassword-cli-")); + const planPath = path.join(dir, "plan.json"); + try { + await runSetup(planPath, args); + return JSON.parse(await fs.readFile(planPath, "utf8")) as OnePasswordPlan; + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } } afterEach(() => { @@ -56,215 +70,180 @@ afterEach(() => { vi.unstubAllEnvs(); }); -describe("1Password CLI helpers", () => { - it("builds a secrets apply plan for model provider API keys", () => { - const plan = testing.buildPlan({ - providerAlias: "onepassword", - providerConfig: testing.buildProviderConfig(), - providerSecrets: [ - { - providerId: "anthropic", - secretId: "op://openclaw/Anthropic/credential", - }, - { - providerId: "openrouter", - secretId: "openclaw/OpenRouter/credential", - }, - ], - }); +describe("1Password SecretRef setup", () => { + it("builds provider config and model API-key targets", async () => { + const plan = await createSetupPlan([ + "--anthropic-id", + "op://openclaw/Anthropic/credential", + "--openrouter-id", + "openclaw/OpenRouter/credential", + "--provider-key", + "xai=op://openclaw/xAI/credential", + ]); expect(plan.providerUpserts.onepassword).toEqual({ source: "exec", - pluginIntegration: { - pluginId: "onepassword", - integrationId: "onepassword", - }, + pluginIntegration: { pluginId: "onepassword", integrationId: "onepassword" }, }); expect(plan.targets).toEqual([ - { + expect.objectContaining({ type: "models.providers.apiKey", - path: "models.providers.anthropic.apiKey", - pathSegments: ["models", "providers", "anthropic", "apiKey"], providerId: "anthropic", - ref: { - source: "exec", - provider: "onepassword", - id: "op://openclaw/Anthropic/credential", - }, - }, - { - type: "models.providers.apiKey", - path: "models.providers.openrouter.apiKey", - pathSegments: ["models", "providers", "openrouter", "apiKey"], - providerId: "openrouter", - ref: { - source: "exec", - provider: "onepassword", - id: "openclaw/OpenRouter/credential", - }, - }, + ref: { source: "exec", provider: "onepassword", id: "op://openclaw/Anthropic/credential" }, + }), + expect.objectContaining({ providerId: "openrouter" }), + expect.objectContaining({ providerId: "xai" }), ]); }); - it("builds a secrets apply plan for arbitrary known openclaw secret targets", () => { - const plan = testing.buildPlan({ - providerAlias: "onepassword", - providerConfig: testing.buildProviderConfig(), - providerSecrets: [], - configTargetSecrets: testing.parseConfigTargetMappings([ - "channels.telegram.botToken=op://openclaw/Telegram/botToken", - "models.providers.openai.headers.x-api-key=op://openclaw/OpenAI/proxyKey", - "auth-profiles:main:profiles.openai.key=op://openclaw/OpenAI/credential", - ]), - }); + it("builds arbitrary known OpenClaw and auth-profile targets", async () => { + const plan = await createSetupPlan([ + "--target", + "channels.telegram.botToken=op://openclaw/Telegram/botToken", + "--target", + "models.providers.openai.headers.x-api-key=op://openclaw/OpenAI/proxyKey", + "--target", + "auth-profiles:main:profiles.openai.key=op://openclaw/OpenAI/credential", + ]); expect(plan.targets).toEqual([ - { + expect.objectContaining({ type: "channels.telegram.botToken", path: "channels.telegram.botToken", - pathSegments: ["channels", "telegram", "botToken"], - ref: { - source: "exec", - provider: "onepassword", - id: "op://openclaw/Telegram/botToken", - }, - }, - { + }), + expect.objectContaining({ type: "models.providers.headers", - path: "models.providers.openai.headers.x-api-key", - pathSegments: ["models", "providers", "openai", "headers", "x-api-key"], providerId: "openai", - ref: { - source: "exec", - provider: "onepassword", - id: "op://openclaw/OpenAI/proxyKey", - }, - }, - { + }), + expect.objectContaining({ type: "auth-profiles.api_key.key", path: "profiles.openai.key", - pathSegments: ["profiles", "openai", "key"], agentId: "main", - ref: { - source: "exec", - provider: "onepassword", - id: "op://openclaw/OpenAI/credential", - }, - }, + }), ]); }); - it("parses custom provider mappings", () => { - expect(testing.parseProviderKeyMappings(["xai=op://openclaw/xAI/credential"])).toEqual([ - { - providerId: "xai", - secretId: "op://openclaw/xAI/credential", - }, - ]); - }); - - it("accepts native 1Password refs with spaces and encoded selectors", () => { + it("encodes native 1Password refs with spaces and selectors", async () => { const nativeRef = "op://Personal/OpenClaw QA API Key/password?attribute=value%20one"; - expect(testing.parseProviderKeyMappings([`openai=${nativeRef}`])).toEqual([ - { - providerId: "openai", - secretId: encodeOnePasswordSecretId(nativeRef), - }, - ]); + const plan = await createSetupPlan(["--provider-key", `openai=${nativeRef}`]); + expect(plan.targets[0]).toMatchObject({ + providerId: "openai", + ref: { id: encodeOnePasswordSecretId(nativeRef) }, + }); }); it.each([ - ["posix", "/tmp/plan.json", "/tmp/plan.json"], - ["posix", "/tmp/plan with spaces.json", "'/tmp/plan with spaces.json'"], - ["posix", "/tmp/plan'$(touch pwn).json", "'/tmp/plan'\\''$(touch pwn).json'"], - ["powershell", String.raw`C:\$env:TEMP\plan';.json`, String.raw`'C:\$env:TEMP\plan'';.json'`], - ["cmd", String.raw`C:\Users\Jane Doe\plan.json`, String.raw`"C:\Users\Jane Doe\plan.json"`], - ] satisfies Array<["cmd" | "posix" | "powershell", string, string]>)( - "shell-quotes %s command arguments for %j", - (shell, value, expected) => { - expect(testing.quoteCliArg(value, shell)).toBe(expected); + [ + "duplicate providers", + [ + "--openai-id", + "op://openclaw/OpenAI/credential", + "--provider-key", + "OpenAI=op://openclaw/OpenAI/other", + ], + "Duplicate model provider id", + ], + [ + "non-canonical auth-profile agent ids", + ["--target", "auth-profiles:../main:profiles.openai.key=op://openclaw/OpenAI/credential"], + "Invalid --target auth-profiles target for 1Password", + ], + [ + "traversal secret ids", + ["--provider-key", "openai=op://openclaw/../credential"], + "Invalid --provider-key openai 1Password SecretRef id", + ], + [ + "unsupported targets", + ["--target", "secrets.github_pat=op://openclaw/GitHub/pat"], + "Unknown or unsupported 1Password setup target path", + ], + [ + "duplicate target paths", + [ + "--openai-id", + "op://openclaw/OpenAI/credential", + "--target", + "models.providers.openai.apiKey=op://openclaw/OpenAI/other", + ], + "Duplicate secret target path", + ], + ["empty plans", [], "No SecretRef targets selected"], + ])("rejects %s", async (_label, args, message) => { + await expect(createSetupPlan(args)).rejects.toThrow(message); + }); + + it.each(["/absolute/path", "op://openclaw\\OpenAI\\credential", "op://vault/clé"])( + "rejects invalid 1Password ref %s", + async (id) => { + await expect(createSetupPlan(["--provider-key", `openai=${id}`])).rejects.toThrow( + "Invalid --provider-key openai 1Password SecretRef id", + ); }, ); - it("rejects line breaks in generated command arguments", () => { - expect(() => testing.quoteCliArg("plan.json\nopenclaw secrets reload", "posix")).toThrow( - /cannot contain CR or LF/, - ); - expect(() => testing.quoteCliArg("plan.json\r& whoami", "cmd")).toThrow( - /cannot contain CR or LF/, - ); + it("prints a quoted canonical plan path after the readiness command", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-1password-setup-test-")); + const planPath = path.join(tempDir, "plan with spaces.json"); + const canonicalPlanPath = path.join(await fs.realpath(tempDir), "plan with spaces.json"); + try { + const output = await runSetup(planPath, ["--openai-id", "op://openclaw/OpenAI/credential"]); + expect(output).toContain("openclaw onepassword secretref status"); + expect(output).toContain( + `openclaw secrets apply --from '${canonicalPlanPath}' --dry-run --allow-exec`, + ); + expect(output).toContain(`openclaw secrets apply --from '${canonicalPlanPath}' --allow-exec`); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } }); - it("renders native follow-up commands for both Windows shells", () => { - expect(testing.renderApplyCommands(String.raw`C:\Users\Jane Doe\plan;.json`, "win32")).toEqual([ - "PowerShell:", - String.raw` openclaw secrets apply --from 'C:\Users\Jane Doe\plan;.json' --dry-run --allow-exec`, - String.raw` openclaw secrets apply --from 'C:\Users\Jane Doe\plan;.json' --allow-exec`, - "Command Prompt:", - String.raw` openclaw secrets apply --from "C:\Users\Jane Doe\plan;.json" --dry-run --allow-exec`, - String.raw` openclaw secrets apply --from "C:\Users\Jane Doe\plan;.json" --allow-exec`, - ]); - }); + it.skipIf(process.platform === "win32")( + "rejects plan output in a directory writable by another account", + async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-secret-plan-test-")); + const planPath = path.join(tempDir, "plan.json"); + try { + await fs.chmod(tempDir, 0o777); + await expect( + runSetup(planPath, ["--openai-id", "op://openclaw/OpenAI/credential"]), + ).rejects.toThrow("path is writable by another user"); + await expect(fs.stat(planPath)).rejects.toMatchObject({ code: "ENOENT" }); + } finally { + await fs.chmod(tempDir, 0o700); + await fs.rm(tempDir, { recursive: true, force: true }); + } + }, + ); - it("omits unsafe interactive Command Prompt commands", () => { - const commands = testing.renderApplyCommands(String.raw`C:\%TEMP%\plan!.json`, "win32"); - expect(commands).toContain( - "Command Prompt: unavailable for paths containing % or !; use PowerShell.", - ); - expect(commands.filter((command) => command.includes("openclaw secrets apply"))).toHaveLength( - 2, - ); - expect(() => testing.quoteCliArg(String.raw`C:\%TEMP%\plan!.json`, "cmd")).toThrow( - /cannot safely quote/, - ); - }); - - it("parses config target mappings", () => { - expect( - testing.parseConfigTargetMappings([ - "channels.telegram.botToken=op://openclaw/Telegram/botToken", - "auth-profiles:main:profiles.openai.key=op://openclaw/OpenAI/credential", - ]), - ).toEqual([ - { - path: "channels.telegram.botToken", - secretId: "op://openclaw/Telegram/botToken", - }, - { - path: "profiles.openai.key", - agentId: "main", - secretId: "op://openclaw/OpenAI/credential", - }, - ]); - }); - - it("rejects non-canonical auth-profile agent ids", () => { - expect(() => - testing.parseConfigTargetMappings([ - "auth-profiles:../main:profiles.openai.key=op://openclaw/OpenAI/credential", - ]), - ).toThrow("Invalid --target auth-profiles target for 1Password"); - }); - - it("rejects duplicate model providers", () => { - expect(() => - testing.collectProviderSecrets({ - openaiId: "op://openclaw/OpenAI/credential", - providerKey: ["openai=op://openclaw/OpenAI/other"], - }), - ).toThrow("Duplicate model provider id in 1Password setup: openai"); - }); - - it("rejects setup plans without targets", () => { - expect(() => - testing.buildPlan({ - providerAlias: "onepassword", - providerConfig: testing.buildProviderConfig(), - providerSecrets: [], - }), - ).toThrow("No SecretRef targets selected"); - }); + it.skipIf(process.platform === "win32")( + "writes through the canonical directory instead of a replaceable alias", + async () => { + const trustedDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-secret-plan-trusted-")); + const aliasParent = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-secret-plan-alias-")); + const aliasDir = path.join(aliasParent, "output"); + const canonicalPlanPath = path.join(await fs.realpath(trustedDir), "plan.json"); + try { + await fs.symlink(trustedDir, aliasDir); + await fs.chmod(aliasParent, 0o777); + const output = await runSetup(path.join(aliasDir, "plan.json"), [ + "--openai-id", + "op://openclaw/OpenAI/credential", + ]); + expect(output).toContain(`Plan written to ${canonicalPlanPath}`); + expect(JSON.parse(await fs.readFile(canonicalPlanPath, "utf8"))).toMatchObject({ + version: 1, + }); + } finally { + await fs.chmod(aliasParent, 0o700); + await fs.rm(aliasParent, { recursive: true, force: true }); + await fs.rm(trustedDir, { recursive: true, force: true }); + } + }, + ); +}); +describe("1Password readiness", () => { it("reports trusted executable and token prerequisites without exposing the token", async () => { const resolveTrustedCli = vi.fn(async () => "/trusted/op"); const readTokenFile = vi.fn(() => "not-a-real-service-account-token"); @@ -296,10 +275,7 @@ describe("1Password CLI helpers", () => { it("reports untrusted op and unsafe token prerequisites", async () => { await expect( testing.inspectSecretRefReadiness( - { - env: { CLAW_1PASSWORD_OP: "op", PATH: "/bin" }, - tokenFile: "/missing-token", - }, + { env: { CLAW_1PASSWORD_OP: "op", PATH: "/bin" }, tokenFile: "/missing-token" }, { resolveTrustedCli: async () => { throw new Error("unsafe path detail"); @@ -318,208 +294,6 @@ describe("1Password CLI helpers", () => { prerequisitesReady: false, }); }); - - it("rejects traversal segments in SecretRef ids", () => { - expect(() => testing.parseProviderKeyMappings(["openai=op://openclaw/../credential"])).toThrow( - "Invalid --provider-key openai 1Password SecretRef id", - ); - }); - - it("rejects invalid 1Password references before encoding", () => { - for (const id of ["/absolute/path", "op://openclaw\\OpenAI\\credential", "op://vault/clé"]) { - expect(() => testing.parseProviderKeyMappings([`openai=${id}`])).toThrow( - "Invalid --provider-key openai 1Password SecretRef id", - ); - } - }); - - it("rejects unsupported config target paths", () => { - expect(() => - testing.buildPlan({ - providerAlias: "onepassword", - providerConfig: testing.buildProviderConfig(), - providerSecrets: [], - configTargetSecrets: [ - { - path: "secrets.github_pat", - secretId: "op://openclaw/GitHub/pat", - }, - ], - }), - ).toThrow("Unknown or unsupported 1Password setup target path: secrets.github_pat"); - }); - - it("rejects duplicate config target paths", () => { - expect(() => - testing.buildPlan({ - providerAlias: "onepassword", - providerConfig: testing.buildProviderConfig(), - providerSecrets: [ - { - providerId: "openai", - secretId: "op://openclaw/OpenAI/credential", - }, - ], - configTargetSecrets: [ - { - path: "models.providers.openai.apiKey", - secretId: "op://openclaw/OpenAI/other", - }, - ], - }), - ).toThrow("Duplicate secret target path in 1Password setup: models.providers.openai.apiKey"); - }); - - it("creates plan files exclusively with owner-only permissions", async () => { - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-1password-plan-test-")); - const planPath = path.join(tempDir, "plan.json"); - const plan = createOpenAiPlan(); - try { - await testing.writePlanFile(plan, planPath); - if (process.platform !== "win32") { - expect((await fs.stat(planPath)).mode & 0o777).toBe(0o600); - } else { - const permissions = await inspectPathPermissions(planPath); - expect(permissions).toMatchObject({ - ok: true, - source: "windows-acl", - ownerTrusted: true, - groupReadable: false, - groupWritable: false, - worldReadable: false, - worldWritable: false, - }); - } - await expect(testing.writePlanFile(plan, planPath)).rejects.toThrow( - "Plan path already exists", - ); - - const symlinkPath = path.join(tempDir, "symlink.json"); - await fs.symlink(planPath, symlinkPath); - await expect(testing.writePlanFile(plan, symlinkPath)).rejects.toThrow( - "Plan path already exists", - ); - } finally { - await fs.rm(tempDir, { recursive: true, force: true }); - } - }); - - it.skipIf(process.platform === "win32")( - "rejects plan output in a directory writable by another account", - async () => { - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-1password-plan-test-")); - const planPath = path.join(tempDir, "plan.json"); - const plan = createOpenAiPlan(); - try { - await fs.chmod(tempDir, 0o777); - await expect(testing.writePlanFile(plan, planPath)).rejects.toThrow( - "path is writable by another user", - ); - await expect(fs.stat(planPath)).rejects.toMatchObject({ code: "ENOENT" }); - } finally { - await fs.chmod(tempDir, 0o700); - await fs.rm(tempDir, { recursive: true, force: true }); - } - }, - ); - - it.skipIf(process.platform === "win32")( - "writes through the canonical directory instead of a replaceable alias", - async () => { - const trustedDir = await fs.mkdtemp( - path.join(os.tmpdir(), "openclaw-1password-plan-trusted-"), - ); - const aliasParent = await fs.mkdtemp( - path.join(os.tmpdir(), "openclaw-1password-plan-alias-"), - ); - const aliasDir = path.join(aliasParent, "output"); - const canonicalPlanPath = path.join(await fs.realpath(trustedDir), "plan.json"); - const plan = createOpenAiPlan(); - try { - await fs.symlink(trustedDir, aliasDir); - await fs.chmod(aliasParent, 0o777); - await expect(testing.writePlanFile(plan, path.join(aliasDir, "plan.json"))).resolves.toBe( - canonicalPlanPath, - ); - expect(JSON.parse(await fs.readFile(canonicalPlanPath, "utf8"))).toMatchObject({ - version: 1, - }); - } finally { - await fs.chmod(aliasParent, 0o700); - await fs.rm(aliasParent, { recursive: true, force: true }); - await fs.rm(trustedDir, { recursive: true, force: true }); - } - }, - ); - - it.skipIf(process.platform === "win32")( - "rejects unrenderable plan paths before creating a file", - async () => { - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-1password-plan-test-")); - const planPath = path.join(tempDir, "plan\n.json"); - const plan = createOpenAiPlan(); - try { - await expect(testing.writePlanFile(plan, planPath)).rejects.toThrow( - "Command argument cannot contain CR or LF", - ); - await expect(fs.stat(planPath)).rejects.toMatchObject({ code: "ENOENT" }); - } finally { - await fs.rm(tempDir, { recursive: true, force: true }); - } - }, - ); - - it("writes a Windows plan through the atomic private-file primitive", async () => { - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-1password-plan-test-")); - const planPath = path.join(tempDir, "plan.json"); - const plan = createOpenAiPlan(); - const createPrivateWindowsFile = vi.fn(async (filePath: string, content: string) => { - await fs.writeFile(filePath, content, { flag: "wx" }); - }); - const resolveTrustedPlanDirectory = vi.fn(async (directoryPath: string) => directoryPath); - try { - await testing.writePlanFile(plan, planPath, { - platform: "win32", - createPrivateWindowsFile, - resolveTrustedPlanDirectory, - }); - expect(resolveTrustedPlanDirectory).toHaveBeenCalledWith(path.resolve(tempDir)); - expect(createPrivateWindowsFile).toHaveBeenCalledWith(planPath, expect.any(String)); - expect(JSON.parse(await fs.readFile(planPath, "utf8"))).toMatchObject({ version: 1 }); - } finally { - await fs.rm(tempDir, { recursive: true, force: true }); - } - }); - - it("prints the readiness check before plan application", async () => { - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-1password-setup-test-")); - const planPath = path.join(tempDir, "plan with spaces.json"); - const canonicalPlanPath = path.join(await fs.realpath(tempDir), "plan with spaces.json"); - const output = captureStdout(); - try { - await createProgram({}).parseAsync( - [ - "onepassword", - "secretref", - "setup", - "--openai-id", - "op://openclaw/OpenAI/credential", - "--plan-out", - planPath, - ], - { from: "user" }, - ); - expect(output()).toContain("openclaw onepassword secretref status"); - expect(output()).toContain( - `openclaw secrets apply --from '${canonicalPlanPath}' --dry-run --allow-exec`, - ); - expect(output()).toContain( - `openclaw secrets apply --from '${canonicalPlanPath}' --allow-exec`, - ); - } finally { - await fs.rm(tempDir, { recursive: true, force: true }); - } - }); }); describe("1Password CLI status", () => { @@ -534,8 +308,8 @@ describe("1Password CLI status", () => { }, }, }); - expect(result.providerAlias).toBe("corp-onepassword"); expect(result).toMatchObject({ + providerAlias: "corp-onepassword", providerReady: true, opStatus: "not-found", tokenFileStatus: "missing-or-unsafe", @@ -557,8 +331,7 @@ describe("1Password CLI status", () => { }, }, }); - expect(result.providerAlias).toBe("corp-onepassword"); - expect(result.providerReady).toBe(true); + expect(result).toMatchObject({ providerAlias: "corp-onepassword", providerReady: true }); }); it("requires an explicit alias when multiple providers are configured", async () => { diff --git a/extensions/onepassword/src/secret-ref-cli.ts b/extensions/onepassword/src/secret-ref-cli.ts index 560cdfe6f001..7d3ec38c7bd1 100644 --- a/extensions/onepassword/src/secret-ref-cli.ts +++ b/extensions/onepassword/src/secret-ref-cli.ts @@ -1,52 +1,46 @@ import { randomUUID } from "node:crypto"; import path from "node:path"; -import { createInterface } from "node:readline/promises"; -import { isRecord } from "openclaw/plugin-sdk/channel-secret-basic-runtime"; import type { OpenClawConfig } from "openclaw/plugin-sdk/plugin-entry"; import { DEFAULT_SECRET_FILE_MAX_BYTES, tryReadSecretFileSync, } from "openclaw/plugin-sdk/secret-file-runtime"; -import { pluginSecretRefSetup } from "openclaw/plugin-sdk/secret-ref-runtime"; +import { createPluginSecretRefSetupCli } from "openclaw/plugin-sdk/secret-ref-runtime"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path"; -import { - resolveTrustedOnePasswordCli, - resolveTrustedOnePasswordDirectoryPath, -} from "../onepassword-op-path.js"; +import { resolveTrustedOnePasswordCli } from "../onepassword-op-path.js"; import { encodeOnePasswordSecretId } from "../onepassword-secret-id.js"; -type CommandLike = { - command(name: string): CommandLike; - description(value: string): CommandLike; - option( - flags: string, - description: string, - defaultValueOrParser?: string | ((value: string, previous?: string[]) => string[]), - defaultValue?: string[], - ): CommandLike; - action(fn: (options: TOptions) => void | Promise): CommandLike; -}; +const ONEPASSWORD_PROVIDER_ALIAS = "onepassword"; -type OnePasswordExecProviderConfig = { - source: "exec"; +function normalizeOnePasswordSecretId(label: string, value: string): string { + try { + return encodeOnePasswordSecretId(value); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + throw new Error(`Invalid ${label} 1Password SecretRef id: ${detail}`, { cause: error }); + } +} + +const onePasswordSecretRefSetupCli = createPluginSecretRefSetupCli({ + productName: "1Password", + secretIdLabel: "1Password SecretRef id", + secretIdPlaceholder: "1password-secret-id", + defaultProviderAlias: ONEPASSWORD_PROVIDER_ALIAS, pluginIntegration: { - pluginId: "onepassword"; - integrationId: "onepassword"; - }; -}; + pluginId: "onepassword", + integrationId: "onepassword", + }, + normalizeSecretId: normalizeOnePasswordSecretId, + defaultPlanPath: () => + path.join(resolvePreferredOpenClawTmpDir(), `openclaw-1password-secrets-${randomUUID()}.json`), + beforeApplyCommands: [ + "openclaw plugins enable onepassword", + "openclaw onepassword secretref status", + ], +}); -type ProviderSecretMapping = { - providerId: string; - secretId: string; -}; - -type ConfigTargetSecretMapping = { - path: string; - agentId?: string; - secretId: string; -}; - -type SecretsApplyPlan = ReturnType; +type CommandLike = Parameters[0]; type RegisterOnePasswordSecretRefCommandsParams = { command: CommandLike; @@ -60,26 +54,6 @@ type StatusOptions = { providerAlias?: string; }; -type SetupOptions = { - planOut?: string; - providerAlias?: string; - openaiId?: string; - anthropicId?: string; - openrouterId?: string; - providerKey?: string[]; - target?: string[]; -}; - -type ProviderStatus = { - configured: boolean; - source?: string; - command?: string; - pluginIntegration?: { - pluginId: string; - integrationId: string; - }; -}; - type SecretRefReadiness = { opCommand: string; opBinaryPath: string | null; @@ -94,14 +68,6 @@ type ReadinessDependencies = { readTokenFile?: (filePath: string) => string | undefined; }; -type WritePlanFileDependencies = { - platform?: NodeJS.Platform; - createPrivateWindowsFile?: (filePath: string, content: string) => Promise; - resolveTrustedPlanDirectory?: typeof resolveTrustedOnePasswordDirectoryPath; -}; - -const ONEPASSWORD_PROVIDER_ALIAS = "onepassword"; - function writeLine(message = ""): void { process.stdout.write(`${message}\n`); } @@ -110,123 +76,6 @@ function writeJson(value: unknown): void { process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); } -function normalizeOptionalString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - -type CommandShell = "cmd" | "posix" | "powershell"; - -function quoteCliArg(value: string, shell: CommandShell): string { - if (/\r|\n/u.test(value)) { - throw new Error("Command argument cannot contain CR or LF"); - } - if (shell === "cmd") { - if (/[%!]/u.test(value)) { - throw new Error("Interactive Command Prompt cannot safely quote paths containing % or !"); - } - const escaped = value.replaceAll('"', '\\"'); - return /[ \t"&|<>^()]/u.test(value) ? `"${escaped}"` : escaped || '""'; - } - if (shell === "powershell") { - return `'${value.replaceAll("'", "''")}'`; - } - if (/^[A-Za-z0-9_/:=.,@%+-]+$/.test(value)) { - return value; - } - return `'${value.replaceAll("'", "'\\''")}'`; -} - -function renderApplyCommands( - planPath: string, - platform: NodeJS.Platform = process.platform, -): string[] { - const render = (shell: CommandShell, extraIndent = "") => { - const quotedPlanPath = quoteCliArg(planPath, shell); - return [ - `${extraIndent}openclaw secrets apply --from ${quotedPlanPath} --dry-run --allow-exec`, - `${extraIndent}openclaw secrets apply --from ${quotedPlanPath} --allow-exec`, - ]; - }; - if (platform !== "win32") { - return render("posix"); - } - // Windows cannot reveal which parent shell will receive these copy-paste commands. - // Print native variants instead of emitting syntax that is unsafe in the other shell. - const powershellCommands = ["PowerShell:", ...render("powershell", " ")]; - if (/[%!]/u.test(planPath)) { - return [ - ...powershellCommands, - "Command Prompt: unavailable for paths containing % or !; use PowerShell.", - ]; - } - return [...powershellCommands, "Command Prompt:", ...render("cmd", " ")]; -} - -function assertValidProviderAlias(value: string): void { - pluginSecretRefSetup.assertValidProviderAlias(value); -} - -function normalizeOnePasswordSecretId(label: string, value: string): string { - try { - return encodeOnePasswordSecretId(value); - } catch (error) { - const detail = error instanceof Error ? error.message : String(error); - throw new Error(`Invalid ${label} 1Password SecretRef id: ${detail}`, { cause: error }); - } -} - -function readProviderStatus(config: OpenClawConfig, providerAlias: string): ProviderStatus { - const provider = config.secrets?.providers?.[providerAlias]; - if (!isRecord(provider)) { - return { configured: false }; - } - const base = { - configured: true, - source: normalizeOptionalString(provider.source), - }; - if (provider.source !== "exec") { - return base; - } - if ("pluginIntegration" in provider) { - return { - ...base, - pluginIntegration: provider.pluginIntegration as ProviderStatus["pluginIntegration"], - }; - } - return { - ...base, - command: normalizeOptionalString(provider.command), - }; -} - -function isOnePasswordIntegrationProvider(value: unknown): boolean { - if (!isRecord(value) || value.source !== "exec" || !isRecord(value.pluginIntegration)) { - return false; - } - return ( - value.pluginIntegration.pluginId === "onepassword" && - value.pluginIntegration.integrationId === "onepassword" - ); -} - -function resolveStatusProviderAlias(config: OpenClawConfig, requestedAlias?: string): string { - const explicitAlias = normalizeOptionalString(requestedAlias); - if (explicitAlias) { - assertValidProviderAlias(explicitAlias); - return explicitAlias; - } - const configuredAliases = Object.entries(config.secrets?.providers ?? {}) - .filter(([, provider]) => isOnePasswordIntegrationProvider(provider)) - .map(([alias]) => alias) - .toSorted(); - if (configuredAliases.length > 1) { - throw new Error( - `Multiple 1Password provider aliases are configured (${configuredAliases.join(", ")}). Use --provider-alias .`, - ); - } - return configuredAliases[0] ?? ONEPASSWORD_PROVIDER_ALIAS; -} - async function inspectSecretRefReadiness( params: { env: NodeJS.ProcessEnv; tokenFile: string }, dependencies: ReadinessDependencies = {}, @@ -275,152 +124,13 @@ async function inspectSecretRefReadiness( }; } -function buildProviderConfig(): OnePasswordExecProviderConfig { - return { - source: "exec", - pluginIntegration: { - pluginId: "onepassword", - integrationId: "onepassword", - }, - }; -} - -function parseTargetSpecifier(value: string): { - path: string; - agentId?: string; -} { - return pluginSecretRefSetup.parseTargetSpecifier("1Password", value); -} - -function parseProviderKeyMappings(values: string[] | undefined): ProviderSecretMapping[] { - return (values ?? []).map((value) => { - const separator = value.indexOf("="); - if (separator <= 0 || separator === value.length - 1) { - throw new Error( - `Invalid --provider-key value "${value}". Use =<1password-secret-id>.`, - ); - } - const providerId = value.slice(0, separator).trim(); - pluginSecretRefSetup.assertValidModelProviderId("--provider-key", providerId); - const secretId = normalizeOnePasswordSecretId( - `--provider-key ${providerId}`, - value.slice(separator + 1).trim(), - ); - return { providerId, secretId }; - }); -} - -function parseConfigTargetMappings(values: string[] | undefined): ConfigTargetSecretMapping[] { - return (values ?? []).map((value) => { - const separator = value.indexOf("="); - if (separator <= 0 || separator === value.length - 1) { - throw new Error( - `Invalid --target value "${value}". Use =<1password-secret-id>.`, - ); - } - const target = parseTargetSpecifier(value.slice(0, separator).trim()); - const secretId = normalizeOnePasswordSecretId( - `--target ${target.path}`, - value.slice(separator + 1).trim(), - ); - return Object.assign( - { path: target.path, secretId }, - target.agentId ? { agentId: target.agentId } : {}, - ); - }); -} - -function collectProviderSecrets(options: { - openaiId?: string; - anthropicId?: string; - openrouterId?: string; - providerKey?: string[]; -}): ProviderSecretMapping[] { - const providerSecrets: ProviderSecretMapping[] = []; - if (options.openaiId) { - providerSecrets.push({ providerId: "openai", secretId: options.openaiId }); - } - if (options.anthropicId) { - providerSecrets.push({ providerId: "anthropic", secretId: options.anthropicId }); - } - if (options.openrouterId) { - providerSecrets.push({ providerId: "openrouter", secretId: options.openrouterId }); - } - providerSecrets.push(...parseProviderKeyMappings(options.providerKey)); - - const seen = new Set(); - for (const entry of providerSecrets) { - const normalized = entry.providerId.toLowerCase(); - if (seen.has(normalized)) { - throw new Error(`Duplicate model provider id in 1Password setup: ${entry.providerId}`); - } - seen.add(normalized); - } - return providerSecrets; -} - -function buildPlan(params: { - providerAlias: string; - providerConfig: OnePasswordExecProviderConfig; - providerSecrets: ProviderSecretMapping[]; - configTargetSecrets?: ConfigTargetSecretMapping[]; -}): SecretsApplyPlan { - const plan = pluginSecretRefSetup.buildPlan({ productName: "1Password", ...params }); - if (plan.targets.length === 0) { - throw new Error( - "No SecretRef targets selected. Pass --openai-id, --anthropic-id, --openrouter-id, --provider-key, or --target.", - ); - } - return plan; -} - -async function promptOptionalSecretId(label: string): Promise { - if (!process.stdin.isTTY || !process.stdout.isTTY) { - return undefined; - } - const rl = createInterface({ input: process.stdin, output: process.stdout }); - try { - return normalizeOptionalString( - await rl.question(`${label} 1Password SecretRef id (blank to skip): `), - ); - } finally { - rl.close(); - } -} - -async function promptProviderSecrets(options: SetupOptions): Promise { - const openaiId = - normalizeOptionalString(options.openaiId) ?? (await promptOptionalSecretId("OpenAI")); - const anthropicId = - normalizeOptionalString(options.anthropicId) ?? (await promptOptionalSecretId("Anthropic")); - const openrouterId = - normalizeOptionalString(options.openrouterId) ?? (await promptOptionalSecretId("OpenRouter")); - const normalizedOpenaiId = openaiId - ? normalizeOnePasswordSecretId("OpenAI", openaiId) - : undefined; - const normalizedAnthropicId = anthropicId - ? normalizeOnePasswordSecretId("Anthropic", anthropicId) - : undefined; - const normalizedOpenrouterId = openrouterId - ? normalizeOnePasswordSecretId("OpenRouter", openrouterId) - : undefined; - return collectProviderSecrets({ - ...(normalizedOpenaiId ? { openaiId: normalizedOpenaiId } : {}), - ...(normalizedAnthropicId ? { anthropicId: normalizedAnthropicId } : {}), - ...(normalizedOpenrouterId ? { openrouterId: normalizedOpenrouterId } : {}), - providerKey: options.providerKey, - }); -} - async function runStatus( params: RegisterOnePasswordSecretRefCommandsParams, options: StatusOptions, ): Promise { - const config = params.config; - const providerAlias = resolveStatusProviderAlias(config, options.providerAlias); - const provider = readProviderStatus(config, providerAlias); - const providerReady = isOnePasswordIntegrationProvider( - config.secrets?.providers?.[providerAlias], + const { providerAlias, provider, providerReady } = onePasswordSecretRefSetupCli.inspectProvider( + params.config, + options.providerAlias, ); const readiness = await inspectSecretRefReadiness({ env: params.env ?? process.env, @@ -470,7 +180,7 @@ async function runStatus( if (issues.length === 0) { return; } - writeLine(""); + writeLine(); writeLine("Next actions:"); if (!providerReady) { writeLine(" Generate and apply a 1Password SecretRef setup plan."); @@ -485,60 +195,6 @@ async function runStatus( } } -async function writePlanFile( - plan: SecretsApplyPlan, - requestedPath?: string, - dependencies: WritePlanFileDependencies = {}, -): Promise { - const requestedPlanPath = - normalizeOptionalString(requestedPath) ?? - path.join(resolvePreferredOpenClawTmpDir(), `openclaw-1password-secrets-${randomUUID()}.json`); - const content = `${JSON.stringify(plan, null, 2)}\n`; - const requestedPlanPathAbsolute = path.resolve(requestedPlanPath); - const planDirectory = await ( - dependencies.resolveTrustedPlanDirectory ?? resolveTrustedOnePasswordDirectoryPath - )(path.dirname(requestedPlanPathAbsolute)); - // Write through the canonical directory returned by the trust check. Reusing the requested - // alias would let another local account retarget a writable parent symlink after validation. - const planPath = path.join(planDirectory, path.basename(requestedPlanPathAbsolute)); - const platform = dependencies.platform ?? process.platform; - // Validate the exact canonical path before the exclusive write. Follow-up command rendering - // must not fail after leaving a plan behind that the next setup attempt cannot overwrite. - renderApplyCommands(planPath, platform); - await pluginSecretRefSetup.writePlanFile({ - planPath, - content, - platform, - createPrivateWindowsFile: dependencies.createPrivateWindowsFile, - }); - return planPath; -} - -async function runSetup(options: SetupOptions): Promise { - const providerAlias = - normalizeOptionalString(options.providerAlias) ?? ONEPASSWORD_PROVIDER_ALIAS; - assertValidProviderAlias(providerAlias); - const providerSecrets = await promptProviderSecrets(options); - const plan = buildPlan({ - providerAlias, - providerConfig: buildProviderConfig(), - providerSecrets, - configTargetSecrets: parseConfigTargetMappings(options.target), - }); - const planPath = await writePlanFile(plan, options.planOut); - writeLine(`Plan written to ${planPath}`); - writeLine(`Targets: ${plan.targets.length}`); - writeLine(""); - writeLine("Next steps:"); - writeLine(" openclaw plugins enable onepassword"); - writeLine(" openclaw onepassword secretref status"); - for (const command of renderApplyCommands(planPath)) { - writeLine(` ${command}`); - } - writeLine(" openclaw secrets audit --check --allow-exec"); - writeLine(" openclaw secrets reload"); -} - export function registerOnePasswordSecretRefCommands( params: RegisterOnePasswordSecretRefCommandsParams, ): void { @@ -549,41 +205,7 @@ export function registerOnePasswordSecretRefCommands( .option("--json", "Print JSON status") .option("--provider-alias ", "Secret provider alias to inspect") .action((options: StatusOptions) => runStatus(params, options)); - secretRef - .command("setup") - .description("Create a 1Password SecretRef setup plan") - .option("--plan-out ", "Write the generated secrets apply plan to a path") - .option( - "--provider-alias ", - "Secret provider alias to configure", - ONEPASSWORD_PROVIDER_ALIAS, - ) - .option("--openai-id ", "1Password SecretRef id for models.providers.openai.apiKey") - .option("--anthropic-id ", "1Password SecretRef id for models.providers.anthropic.apiKey") - .option("--openrouter-id ", "1Password SecretRef id for models.providers.openrouter.apiKey") - .option( - "--provider-key ", - "1Password SecretRef id for any models.providers..apiKey target", - (value: string, previous: string[] = []) => [...previous, value], - [], - ) - .option( - "--target ", - "1Password SecretRef id for any known SecretRef target path", - (value: string, previous: string[] = []) => [...previous, value], - [], - ) - .action((options: SetupOptions) => runSetup(options)); + onePasswordSecretRefSetupCli.registerSetupCommand(secretRef); } -export const testing = { - buildPlan, - buildProviderConfig, - collectProviderSecrets, - parseConfigTargetMappings, - parseProviderKeyMappings, - quoteCliArg, - renderApplyCommands, - inspectSecretRefReadiness, - writePlanFile, -}; +export const testing = { inspectSecretRefReadiness }; diff --git a/extensions/qa-lab/web/src/app.browser.test.ts b/extensions/qa-lab/web/src/app.browser.test.ts index 0ab4dd5c950d..e43f6914067f 100644 --- a/extensions/qa-lab/web/src/app.browser.test.ts +++ b/extensions/qa-lab/web/src/app.browser.test.ts @@ -1,8 +1,9 @@ /* @vitest-environment jsdom */ import { readFileSync } from "node:fs"; import path from "node:path"; +import type { QaBusStateSnapshot } from "openclaw/plugin-sdk/qa-channel-protocol"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { Bootstrap, RunnerSelection, Snapshot } from "./ui-types.js"; +import type { Bootstrap, RunnerSelection } from "./ui-types.js"; const httpMock = vi.hoisted(() => { class QaLabHttpError extends Error { @@ -105,7 +106,13 @@ function createBootstrap(selection: RunnerSelection): Bootstrap { async function mountRunner( selection: RunnerSelection, - snapshot: Snapshot = { conversations: [], events: [], messages: [], threads: [] }, + snapshot: QaBusStateSnapshot = { + conversations: [], + cursor: 0, + events: [], + messages: [], + threads: [], + }, ) { let bootstrap = createBootstrap(selection); httpMock.getJson.mockImplementation(async (url: string) => { @@ -215,12 +222,15 @@ describe("QA Lab runner browser interactions", () => { }, { conversations: [{ accountId: "default", id: "qa-room", kind: "channel" }], + cursor: 0, events: [], messages: [], threads: [ { accountId: "default", conversationId: "qa-room", + createdAt: 0, + createdBy: "qa-operator", id: "owned-thread", title: "Owned thread", }, diff --git a/extensions/qa-lab/web/src/app.ts b/extensions/qa-lab/web/src/app.ts index 56cc8c7ac539..7dec06f54e1a 100644 --- a/extensions/qa-lab/web/src/app.ts +++ b/extensions/qa-lab/web/src/app.ts @@ -1,4 +1,5 @@ import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; +import type { QaBusStateSnapshot } from "openclaw/plugin-sdk/qa-channel-protocol"; // Qa Lab plugin module implements app behavior. import { defaultQaModelForMode, isQaFastModeEnabled } from "../../model-selection.js"; import { normalizeCaptureSavedView, normalizeCaptureSavedViews } from "./capture-saved-view.js"; @@ -11,7 +12,6 @@ import { type ReportEnvelope, type RunnerResolvedPlan, type RunnerSelection, - type Snapshot, type TabId, type CaptureEventsEnvelope, type CaptureCoverageEnvelope, @@ -342,7 +342,7 @@ export async function createQaLabApp(root: HTMLDivElement) { try { const [bootstrap, snapshot, report, outcomes] = await Promise.all([ getJson("/api/bootstrap"), - getJson("/api/state"), + getJson("/api/state"), getJson("/api/report"), getJson("/api/outcomes"), ]); diff --git a/extensions/qa-lab/web/src/ui-conversation-key.ts b/extensions/qa-lab/web/src/ui-conversation-key.ts index 2b14a249ff2c..c7a46878de01 100644 --- a/extensions/qa-lab/web/src/ui-conversation-key.ts +++ b/extensions/qa-lab/web/src/ui-conversation-key.ts @@ -1,6 +1,10 @@ -import type { Conversation, Message, Thread } from "./ui-types.js"; +import type { + QaBusMessage, + QaBusSnapshotConversation, + QaBusThread, +} from "openclaw/plugin-sdk/qa-channel-protocol"; -type ConversationIdentity = Pick; +type ConversationIdentity = Pick; // Raw ids can collide across accounts and conversation kinds. Keep one key // shape for sidebar selection, transcript filtering, and thread navigation. @@ -9,9 +13,9 @@ export function conversationSelectionKey(identity: ConversationIdentity): string } export function findConversationBySelectionKey( - conversations: Conversation[], + conversations: QaBusSnapshotConversation[], selectionKey: string | null, -): Conversation | undefined { +): QaBusSnapshotConversation | undefined { if (!selectionKey) { return undefined; } @@ -20,7 +24,7 @@ export function findConversationBySelectionKey( ); } -export function messageConversationSelectionKey(message: Message): string { +export function messageConversationSelectionKey(message: QaBusMessage): string { return conversationSelectionKey({ accountId: message.accountId, id: message.conversation.id, @@ -28,7 +32,7 @@ export function messageConversationSelectionKey(message: Message): string { }); } -export function threadConversationSelectionKey(thread: Thread): string { +export function threadConversationSelectionKey(thread: QaBusThread): string { // QA bus thread records come only from channel-scoped createThread; direct // message thread ids do not create sidebar thread records. return conversationSelectionKey({ diff --git a/extensions/qa-lab/web/src/ui-render-content.ts b/extensions/qa-lab/web/src/ui-render-content.ts index 8f4d83b02dd3..3db5751c175e 100644 --- a/extensions/qa-lab/web/src/ui-render-content.ts +++ b/extensions/qa-lab/web/src/ui-render-content.ts @@ -1,3 +1,8 @@ +import type { + QaBusAttachment, + QaBusMessage, + QaBusSnapshotConversation, +} from "openclaw/plugin-sdk/qa-channel-protocol"; import { conversationSelectionKey, findConversationBySelectionKey, @@ -6,9 +11,9 @@ import { } from "./ui-conversation-key.js"; import { findScenarioOutcome } from "./ui-render-scenario.js"; import { badgeHtml, esc, formatIso, formatTime } from "./ui-render-utils.js"; -import type { Attachment, Conversation, Message, SeedScenario, UiState } from "./ui-types.js"; +import type { SeedScenario, UiState } from "./ui-types.js"; -function attachmentSourceUrl(attachment: Attachment): string | null { +function attachmentSourceUrl(attachment: QaBusAttachment): string | null { if (attachment.url?.trim()) { return attachment.url; } @@ -18,7 +23,7 @@ function attachmentSourceUrl(attachment: Attachment): string | null { return null; } -function renderMessageAttachments(message: Message): string { +function renderMessageAttachments(message: QaBusMessage): string { const attachments = message.attachments ?? []; if (attachments.length === 0) { return ""; @@ -91,8 +96,8 @@ function filteredMessages(state: UiState) { } function formatConversationLabel( - conversation: Conversation, - conversations: Conversation[], + conversation: QaBusSnapshotConversation, + conversations: QaBusSnapshotConversation[], ): string { const label = conversation.title || conversation.id; const sidebarCollisions = conversations.filter( @@ -239,14 +244,14 @@ export function renderChatView(state: UiState): string { `; } -function messageAvatar(m: Message): { emoji: string; bg: string; role: string } { +function messageAvatar(m: QaBusMessage): { emoji: string; bg: string; role: string } { if (m.direction === "outbound") { return { emoji: "\uD83E\uDD80", bg: "#7c6cff", role: "Claw" }; // 🦀 } return { emoji: "\uD83E\uDD9E", bg: "#d97706", role: "Clawfather" }; // 🦞 } -function renderMessage(m: Message): string { +function renderMessage(m: QaBusMessage): string { const name = m.senderName || m.senderId; const avatar = messageAvatar(m); const dirClass = m.direction === "inbound" ? "msg-direction-inbound" : "msg-direction-outbound"; @@ -288,7 +293,7 @@ function recentInspectorMessages(state: UiState, limit = 18) { return (state.snapshot?.messages ?? []).slice(-limit).toReversed(); } -function renderInspectorLiveMessage(message: Message): string { +function renderInspectorLiveMessage(message: QaBusMessage): string { const avatar = messageAvatar(message); const conversationLabel = message.conversation.title || message.conversation.id; const threadLabel = message.threadTitle || message.threadId; @@ -483,9 +488,7 @@ export function renderEventsView(state: UiState): string { const detail = "thread" in e ? `${e.thread.conversationId}/${e.thread.id}` - : e.message - ? `${e.message.senderId}: ${e.message.text}` - : ""; + : `${e.message.senderId}: ${e.message.text}`; return `
${esc(e.kind)} diff --git a/extensions/qa-lab/web/src/ui-render.test.ts b/extensions/qa-lab/web/src/ui-render.test.ts index ca85a0b2dde8..193c8a700c26 100644 --- a/extensions/qa-lab/web/src/ui-render.test.ts +++ b/extensions/qa-lab/web/src/ui-render.test.ts @@ -97,6 +97,7 @@ describe("QA Lab UI evidence render", () => { { accountId: "account-b", id: "shared", kind: "channel" }, { accountId: "account-a", id: "shared", kind: "direct" }, ], + cursor: 0, events: [], messages: [ { @@ -134,12 +135,16 @@ describe("QA Lab UI evidence render", () => { { accountId: "account-a", conversationId: "shared", + createdAt: 0, + createdBy: "openclaw", id: "selected-thread", title: "Selected thread", }, { accountId: "account-b", conversationId: "shared", + createdAt: 0, + createdBy: "openclaw", id: "foreign-thread", title: "Foreign thread", }, @@ -167,6 +172,7 @@ describe("QA Lab UI evidence render", () => { { accountId: "account-a", id: "shared", kind: "group" }, { accountId: "account-b", id: "shared", kind: "channel" }, ], + cursor: 0, events: [], messages: [], threads: [], @@ -197,6 +203,7 @@ describe("QA Lab UI evidence render", () => { { accountId: "account-a", id: "shared", kind: "channel" }, { accountId: "account-a", id: "shared", kind: "direct" }, ], + cursor: 0, events: [], messages: [ { @@ -251,6 +258,7 @@ describe("QA Lab UI evidence render", () => { const selectedConversationKey = JSON.stringify(["default", "channel", "qa-room"]); const snapshot: NonNullable = { conversations: [{ accountId: "default", id: "qa-room", kind: "channel" }], + cursor: 0, events: [], messages: [ { @@ -290,6 +298,8 @@ describe("QA Lab UI evidence render", () => { { accountId: "default", conversationId: "qa-room", + createdAt: 0, + createdBy: "openclaw", id: "owned-thread", title: "Owned thread", }, diff --git a/extensions/qa-lab/web/src/ui-types.ts b/extensions/qa-lab/web/src/ui-types.ts index 5c86096abecf..4b72a27ffe3f 100644 --- a/extensions/qa-lab/web/src/ui-types.ts +++ b/extensions/qa-lab/web/src/ui-types.ts @@ -1,3 +1,7 @@ +import type { + QaBusConversationKind, + QaBusStateSnapshot, +} from "openclaw/plugin-sdk/qa-channel-protocol"; import type { QaLabExecutionKind, QaLabResolvedRunPlan, @@ -13,65 +17,6 @@ import type { QaEvidenceProducerContextFile, } from "../../shared/evidence-gallery-types.js"; -/* ===== Shared types (unchanged from the bus protocol) ===== */ - -export type Conversation = { - accountId: string; - id: string; - kind: "direct" | "channel" | "group"; - title?: string; -}; - -export type Attachment = { - id: string; - kind: "image" | "video" | "audio" | "file"; - mimeType: string; - fileName?: string; - inline?: boolean; - url?: string; - contentBase64?: string; - width?: number; - height?: number; - durationMs?: number; - altText?: string; - transcript?: string; -}; - -export type Thread = { - accountId: string; - id: string; - conversationId: string; - title: string; -}; - -export type Message = { - accountId: string; - id: string; - direction: "inbound" | "outbound"; - conversation: Omit; - senderId: string; - senderName?: string; - text: string; - timestamp: number; - threadId?: string; - threadTitle?: string; - deleted?: boolean; - editedAt?: number; - attachments?: Attachment[]; - reactions: Array<{ emoji: string; senderId: string }>; -}; - -type BusEvent = - | { cursor: number; kind: "thread-created"; thread: Thread } - | { cursor: number; kind: string; message?: Message; emoji?: string }; - -export type Snapshot = { - conversations: Conversation[]; - threads: Thread[]; - messages: Message[]; - events: BusEvent[]; -}; - export type ReportEnvelope = { report: null | { outputPath: string; @@ -300,7 +245,7 @@ export type TabId = "chat" | "results" | "report" | "events" | "capture" | "evid export type UiState = { theme: "light" | "dark"; bootstrap: Bootstrap | null; - snapshot: Snapshot | null; + snapshot: QaBusStateSnapshot | null; latestReport: ReportEnvelope["report"]; scenarioRun: ScenarioRun | null; captureSessions: CaptureSessionSummary[]; @@ -371,7 +316,7 @@ export type UiState = { runnerDraftDirty: boolean; runnerPlanOverride: RunnerResolvedPlan | null; composer: { - conversationKind: "direct" | "channel" | "group"; + conversationKind: QaBusConversationKind; conversationId: string; senderId: string; senderName: string; diff --git a/extensions/vault/src/cli.test.ts b/extensions/vault/src/cli.test.ts index 8b8fadd8c000..56109cb7f3b4 100644 --- a/extensions/vault/src/cli.test.ts +++ b/extensions/vault/src/cli.test.ts @@ -40,12 +40,13 @@ async function createSetupPlan(args: string[]): Promise { } } -async function runSetup(planPath: string, args: string[]): Promise { +async function runSetup(planPath: string, args: string[]): Promise { const stdout = captureStdout(); try { await createProgram().parseAsync(["vault", "setup", "--plan-out", planPath, ...args], { from: "user", }); + return stdout.output(); } finally { stdout.restore(); } @@ -163,6 +164,7 @@ describe("vault CLI setup plan", () => { }); it.each([ + ["empty plans", [], "No SecretRef targets selected"], [ "duplicate providers", ["--openai-id", "providers/openai/apiKey", "--provider-key", "OpenAI=providers/openai/other"], @@ -197,6 +199,21 @@ describe("vault CLI setup plan", () => { await expect(createSetupPlan(args)).rejects.toThrow(message); }); + it("prints shell-safe commands using the canonical plan path", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-vault-command-")); + const planPath = path.join(dir, "plan with spaces.json"); + const canonicalPlanPath = path.join(await fs.realpath(dir), "plan with spaces.json"); + try { + const output = await runSetup(planPath, setupArgs); + expect(output).toContain( + `openclaw secrets apply --from '${canonicalPlanPath}' --dry-run --allow-exec`, + ); + expect(output).toContain(`openclaw secrets apply --from '${canonicalPlanPath}' --allow-exec`); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } + }); + it.each([ "providers/openai/apiKey/", "/providers/openai/apiKey", @@ -224,6 +241,21 @@ describe("vault CLI status", () => { expect(result.providerAlias).toBe("corp-vault"); }); + it("prefers the managed integration when the default alias is unrelated", async () => { + const result = await runStatus({ + secrets: { + providers: { + vault: { source: "exec", command: "/legacy/resolver" }, + "corp-vault": { + source: "exec", + pluginIntegration: { pluginId: "vault", integrationId: "vault" }, + }, + }, + }, + }); + expect(result.providerAlias).toBe("corp-vault"); + }); + it("requires an explicit alias when multiple Vault providers are configured", async () => { const config = { secrets: { diff --git a/extensions/vault/src/cli.ts b/extensions/vault/src/cli.ts index 7eac6d67dea5..ab5cb769d593 100644 --- a/extensions/vault/src/cli.ts +++ b/extensions/vault/src/cli.ts @@ -1,43 +1,38 @@ import path from "node:path"; -import { createInterface } from "node:readline/promises"; import { fileURLToPath } from "node:url"; -import { isRecord } from "openclaw/plugin-sdk/channel-secret-basic-runtime"; import type { OpenClawConfig } from "openclaw/plugin-sdk/plugin-entry"; -import { pluginSecretRefSetup } from "openclaw/plugin-sdk/secret-ref-runtime"; +import { createPluginSecretRefSetupCli } from "openclaw/plugin-sdk/secret-ref-runtime"; import { pathExists } from "openclaw/plugin-sdk/security-runtime"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path"; import { parseVaultSecretId } from "../vault-secret-id.js"; -type CommandLike = { - command(name: string): CommandLike; - description(value: string): CommandLike; - option( - flags: string, - description: string, - defaultValueOrParser?: string | ((value: string, previous?: string[]) => string[]), - defaultValue?: string[], - ): CommandLike; - action(fn: (options: TOptions) => void | Promise): CommandLike; -}; +const VAULT_PROVIDER_ALIAS = "vault"; -type VaultExecProviderConfig = { - source: "exec"; +function normalizeVaultSecretId(label: string, value: string): string { + try { + parseVaultSecretId(value); + return value; + } catch { + throw new Error(`Invalid ${label} Vault secret id: ${value}`); + } +} + +const vaultSecretRefSetupCli = createPluginSecretRefSetupCli({ + productName: "Vault", + secretIdLabel: "Vault secret id", + secretIdPlaceholder: "vault-secret-id", + defaultProviderAlias: VAULT_PROVIDER_ALIAS, pluginIntegration: { - pluginId: "vault"; - integrationId: "vault"; - }; -}; + pluginId: "vault", + integrationId: "vault", + }, + normalizeSecretId: normalizeVaultSecretId, + defaultPlanPath: () => + path.join(resolvePreferredOpenClawTmpDir(), `openclaw-vault-secrets-${process.pid}.json`), +}); -type ProviderSecretMapping = { - providerId: string; - secretId: string; -}; - -type ConfigTargetSecretMapping = { - path: string; - agentId?: string; - secretId: string; -}; +type CommandLike = Parameters[0]; type RegisterVaultCommandsParams = { program: CommandLike; @@ -49,28 +44,6 @@ type StatusOptions = { providerAlias?: string; }; -type SetupOptions = { - planOut?: string; - providerAlias?: string; - openaiId?: string; - anthropicId?: string; - openrouterId?: string; - providerKey?: string[]; - target?: string[]; -}; - -type ProviderStatus = { - configured: boolean; - source?: string; - command?: string; - pluginIntegration?: { - pluginId: string; - integrationId: string; - }; -}; - -const VAULT_PROVIDER_ALIAS = "vault"; - function writeLine(message = ""): void { process.stdout.write(`${message}\n`); } @@ -79,77 +52,6 @@ function writeJson(value: unknown): void { process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); } -function normalizeOptionalString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - -function assertValidProviderAlias(value: string): void { - pluginSecretRefSetup.assertValidProviderAlias(value); -} - -function assertValidVaultSecretId(label: string, value: string): void { - try { - parseVaultSecretId(value); - } catch { - throw new Error(`Invalid ${label} Vault secret id: ${value}`); - } -} - -function readProviderStatus(config: OpenClawConfig, providerAlias: string): ProviderStatus { - const provider = config.secrets?.providers?.[providerAlias]; - if (!isRecord(provider)) { - return { configured: false }; - } - const base = { - configured: true, - source: normalizeOptionalString(provider.source), - }; - if (provider.source !== "exec") { - return base; - } - if ("pluginIntegration" in provider) { - return { - ...base, - pluginIntegration: provider.pluginIntegration, - }; - } - return { - ...base, - command: normalizeOptionalString(provider.command), - }; -} - -function isVaultIntegrationProvider(value: unknown): boolean { - if (!isRecord(value) || value.source !== "exec" || !isRecord(value.pluginIntegration)) { - return false; - } - return ( - value.pluginIntegration.pluginId === "vault" && - value.pluginIntegration.integrationId === "vault" - ); -} - -function resolveStatusProviderAlias(config: OpenClawConfig, requestedAlias?: string): string { - const explicitAlias = normalizeOptionalString(requestedAlias); - if (explicitAlias) { - assertValidProviderAlias(explicitAlias); - return explicitAlias; - } - if (readProviderStatus(config, VAULT_PROVIDER_ALIAS).configured) { - return VAULT_PROVIDER_ALIAS; - } - const configuredAliases = Object.entries(config.secrets?.providers ?? {}) - .filter(([, provider]) => isVaultIntegrationProvider(provider)) - .map(([alias]) => alias) - .toSorted(); - if (configuredAliases.length > 1) { - throw new Error( - `Multiple Vault provider aliases are configured (${configuredAliases.join(", ")}). Use --provider-alias .`, - ); - } - return configuredAliases[0] ?? VAULT_PROVIDER_ALIAS; -} - function resolverScriptPathCandidates(baseUrl: string): [string, string] { return [ fileURLToPath(new URL("../vault-secret-ref-resolver.js", baseUrl)), @@ -170,134 +72,11 @@ async function resolveResolverScriptPath( return candidates[0]; } -function buildProviderConfig(): VaultExecProviderConfig { - return { - source: "exec", - pluginIntegration: { - pluginId: "vault", - integrationId: "vault", - }, - }; -} - -function parseTargetSpecifier(value: string): { - path: string; - agentId?: string; -} { - return pluginSecretRefSetup.parseTargetSpecifier("Vault", value); -} - -function parseProviderKeyMappings(values: string[] | undefined): ProviderSecretMapping[] { - return (values ?? []).map((value) => { - const separator = value.indexOf("="); - if (separator <= 0 || separator === value.length - 1) { - throw new Error( - `Invalid --provider-key value "${value}". Use =.`, - ); - } - const providerId = value.slice(0, separator).trim(); - const secretId = value.slice(separator + 1).trim(); - pluginSecretRefSetup.assertValidModelProviderId("--provider-key", providerId); - assertValidVaultSecretId(`--provider-key ${providerId}`, secretId); - return { providerId, secretId }; - }); -} - -function parseConfigTargetMappings(values: string[] | undefined): ConfigTargetSecretMapping[] { - return (values ?? []).map((value) => { - const separator = value.indexOf("="); - if (separator <= 0 || separator === value.length - 1) { - throw new Error( - `Invalid --target value "${value}". Use =.`, - ); - } - const target = parseTargetSpecifier(value.slice(0, separator).trim()); - const secretId = value.slice(separator + 1).trim(); - assertValidVaultSecretId(`--target ${target.path}`, secretId); - return Object.assign( - { path: target.path, secretId }, - target.agentId ? { agentId: target.agentId } : {}, - ); - }); -} - -function collectProviderSecrets(options: { - openaiId?: string; - anthropicId?: string; - openrouterId?: string; - providerKey?: string[]; -}): ProviderSecretMapping[] { - const providerSecrets: ProviderSecretMapping[] = []; - if (options.openaiId) { - providerSecrets.push({ providerId: "openai", secretId: options.openaiId }); - } - if (options.anthropicId) { - providerSecrets.push({ providerId: "anthropic", secretId: options.anthropicId }); - } - if (options.openrouterId) { - providerSecrets.push({ providerId: "openrouter", secretId: options.openrouterId }); - } - providerSecrets.push(...parseProviderKeyMappings(options.providerKey)); - - const seen = new Set(); - for (const entry of providerSecrets) { - const normalized = entry.providerId.toLowerCase(); - if (seen.has(normalized)) { - throw new Error(`Duplicate model provider id in Vault setup: ${entry.providerId}`); - } - seen.add(normalized); - } - return providerSecrets; -} - -function buildPlan(params: { - providerAlias: string; - providerConfig: VaultExecProviderConfig; - providerSecrets: ProviderSecretMapping[]; - configTargetSecrets?: ConfigTargetSecretMapping[]; -}) { - return pluginSecretRefSetup.buildPlan({ productName: "Vault", ...params }); -} - -async function promptOptionalSecretId(label: string): Promise { - if (!process.stdin.isTTY || !process.stdout.isTTY) { - return undefined; - } - const rl = createInterface({ input: process.stdin, output: process.stdout }); - try { - return normalizeOptionalString(await rl.question(`${label} Vault secret id (blank to skip): `)); - } finally { - rl.close(); - } -} - -async function promptProviderSecrets(options: SetupOptions): Promise { - const openaiId = - normalizeOptionalString(options.openaiId) ?? (await promptOptionalSecretId("OpenAI")); - const anthropicId = - normalizeOptionalString(options.anthropicId) ?? (await promptOptionalSecretId("Anthropic")); - const openrouterId = - normalizeOptionalString(options.openrouterId) ?? (await promptOptionalSecretId("OpenRouter")); - if (openaiId) { - assertValidVaultSecretId("OpenAI", openaiId); - } - if (anthropicId) { - assertValidVaultSecretId("Anthropic", anthropicId); - } - if (openrouterId) { - assertValidVaultSecretId("OpenRouter", openrouterId); - } - return collectProviderSecrets({ - ...(openaiId ? { openaiId } : {}), - ...(anthropicId ? { anthropicId } : {}), - ...(openrouterId ? { openrouterId } : {}), - providerKey: options.providerKey, - }); -} - async function runStatus(config: OpenClawConfig, options: StatusOptions): Promise { - const providerAlias = resolveStatusProviderAlias(config, options.providerAlias); - const provider = readProviderStatus(config, providerAlias); + const { providerAlias, provider } = vaultSecretRefSetupCli.inspectProvider( + config, + options.providerAlias, + ); const authMethod = normalizeOptionalString(process.env.OPENCLAW_VAULT_AUTH_METHOD) ?? "token"; const result = { providerAlias, @@ -343,33 +122,6 @@ async function runStatus(config: OpenClawConfig, options: StatusOptions): Promis writeLine(`KV version: ${result.kvVersion}`); } -async function runSetup(options: SetupOptions): Promise { - const providerAlias = normalizeOptionalString(options.providerAlias) ?? VAULT_PROVIDER_ALIAS; - assertValidProviderAlias(providerAlias); - const providerSecrets = await promptProviderSecrets(options); - const plan = buildPlan({ - providerAlias, - providerConfig: buildProviderConfig(), - providerSecrets, - configTargetSecrets: parseConfigTargetMappings(options.target), - }); - const planPath = - normalizeOptionalString(options.planOut) ?? - path.join(resolvePreferredOpenClawTmpDir(), `openclaw-vault-secrets-${process.pid}.json`); - await pluginSecretRefSetup.writePlanFile({ - planPath, - content: `${JSON.stringify(plan, null, 2)}\n`, - }); - writeLine(`Plan written to ${planPath}`); - writeLine(`Targets: ${plan.targets.length}`); - writeLine(""); - writeLine("Next steps:"); - writeLine(` openclaw secrets apply --from ${planPath} --dry-run --allow-exec`); - writeLine(` openclaw secrets apply --from ${planPath} --allow-exec`); - writeLine(" openclaw secrets audit --check --allow-exec"); - writeLine(" openclaw secrets reload"); -} - export function registerVaultCommands(params: RegisterVaultCommandsParams): void { const vault = params.program.command("vault").description("Manage Vault SecretRefs"); vault @@ -378,25 +130,5 @@ export function registerVaultCommands(params: RegisterVaultCommandsParams): void .option("--json", "Print JSON status") .option("--provider-alias ", "Secret provider alias to inspect") .action((options: StatusOptions) => runStatus(params.config, options)); - vault - .command("setup") - .description("Create a Vault SecretRef setup plan") - .option("--plan-out ", "Write the generated secrets apply plan to a path") - .option("--provider-alias ", "Secret provider alias to configure", VAULT_PROVIDER_ALIAS) - .option("--openai-id ", "Vault secret id for models.providers.openai.apiKey") - .option("--anthropic-id ", "Vault secret id for models.providers.anthropic.apiKey") - .option("--openrouter-id ", "Vault secret id for models.providers.openrouter.apiKey") - .option( - "--provider-key ", - "Vault secret id for any models.providers..apiKey target", - (value: string, previous: string[] = []) => [...previous, value], - [], - ) - .option( - "--target ", - "Vault secret id for any known SecretRef target path", - (value: string, previous: string[] = []) => [...previous, value], - [], - ) - .action((options: SetupOptions) => runSetup(options)); + vaultSecretRefSetupCli.registerSetupCommand(vault); } diff --git a/extensions/voice-call/index.test.ts b/extensions/voice-call/index.test.ts index d29c851a4383..8dffe592d08d 100644 --- a/extensions/voice-call/index.test.ts +++ b/extensions/voice-call/index.test.ts @@ -696,6 +696,47 @@ describe("voice-call plugin", () => { ]); }); + it("routes tool speech through the active realtime bridge", async () => { + runtimeStub.config.realtime.enabled = true; + runtimeStub.manager.getCall = vi.fn(() => undefined); + runtimeStub.manager.getCallByProviderCallId = vi.fn(() => + createCallRecord({ callId: "call-1", providerCallId: "CA123" }), + ); + runtimeStub.webhookServer.speakRealtime = vi.fn(() => ({ success: true })); + const { tools } = setup({ provider: "mock" }); + const tool = tools[0] as { + execute: (id: string, params: unknown) => Promise; + }; + + const result = (await tool.execute("id", { + action: "speak_to_user", + callId: "CA123", + message: "hello", + })) as { details: { success?: boolean } }; + + expect(runtimeStub.webhookServer["speakRealtime"]).toHaveBeenCalledWith("call-1", "hello"); + expect(runtimeStub.manager["speak"]).not.toHaveBeenCalled(); + expect(result.details.success).toBe(true); + }); + + it("keeps the tool's classic speech fallback when no realtime bridge is active", async () => { + runtimeStub.config.realtime.enabled = true; + const { tools } = setup({ provider: "mock" }); + const tool = tools[0] as { + execute: (id: string, params: unknown) => Promise; + }; + + const result = (await tool.execute("id", { + action: "speak_to_user", + callId: "call-1", + message: "hello", + })) as { details: { success?: boolean } }; + + expect(runtimeStub.webhookServer["speakRealtime"]).toHaveBeenCalledWith("call-1", "hello"); + expect(runtimeStub.manager["speak"]).toHaveBeenCalledWith("call-1", "hello"); + expect(result.details.success).toBe(true); + }); + it("reports ended call history when speaking to a stale call", async () => { runtimeStub.manager.getCall = vi.fn(() => undefined); runtimeStub.manager.getCallByProviderCallId = vi.fn(() => undefined); @@ -1059,7 +1100,7 @@ describe("voice-call plugin", () => { } }); - it("gateway continue operations return pending then completed results", async () => { + it("gateway continue operations return pending, completed, and failed results", async () => { let finishContinue: ((value: { success: true; transcript: string }) => void) | undefined; const continuePromise = new Promise<{ success: true; transcript: string }>((resolve) => { finishContinue = resolve; @@ -1111,18 +1152,41 @@ describe("voice-call plugin", () => { finishContinue?.({ success: true, transcript: "gateway hello" }); await continuePromise; - await Promise.resolve(); - - const completedRespond = vi.fn(); - await result?.({ - params: { operationId: startPayload?.operationId }, - respond: completedRespond, + const completedCall = await vi.waitFor(async () => { + const respond = vi.fn(); + await result?.({ params: { operationId: startPayload?.operationId }, respond }); + const call = firstRespondCall(respond); + const payload = call[1] as { status?: unknown } | undefined; + expect(payload?.status).toBe("completed"); + return call; }); - const completedCall = firstRespondCall(completedRespond); const completedPayload = completedCall[1] as { status?: unknown; result?: unknown } | undefined; expect(completedCall[0]).toBe(true); - expect(completedPayload?.status).toBe("completed"); expect(completedPayload?.result).toEqual({ success: true, transcript: "gateway hello" }); + + runtimeStub.manager.continueCall = vi.fn(async () => ({ + success: false, + error: "turn failed", + })) as VoiceCallRuntime["manager"]["continueCall"]; + const failedStartRespond = vi.fn(); + await start?.({ + params: { callId: "call-1", message: "Try again" }, + respond: failedStartRespond, + }); + const failedOperationId = ( + firstRespondCall(failedStartRespond)[1] as { operationId?: string } | undefined + )?.operationId; + + const failedCall = await vi.waitFor(async () => { + const respond = vi.fn(); + await result?.({ params: { operationId: failedOperationId }, respond }); + const call = firstRespondCall(respond); + const payload = call[1] as { status?: unknown } | undefined; + expect(payload?.status).toBe("failed"); + return call; + }); + expect(failedCall[0]).toBe(true); + expect(failedCall[1]).toMatchObject({ status: "failed", error: "turn failed" }); }); it("CLI setup prints human-readable checks by default", async () => { diff --git a/extensions/voice-call/index.ts b/extensions/voice-call/index.ts index 083d311511e2..3f62497040e7 100644 --- a/extensions/voice-call/index.ts +++ b/extensions/voice-call/index.ts @@ -1,7 +1,6 @@ // Voice Call plugin entrypoint registers its OpenClaw integration. import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { ErrorCodes, errorShape } from "openclaw/plugin-sdk/gateway-runtime"; -import { timestampMsToIsoString } from "openclaw/plugin-sdk/number-runtime"; import { normalizeAgentId, parseAgentSessionKey } from "openclaw/plugin-sdk/routing"; import { asOptionalRecord, @@ -17,6 +16,10 @@ import { import { VOICE_CALL_CLI_DESCRIPTOR } from "./cli-output-mode.js"; import { createVoiceCallRuntime, type VoiceCallRuntime } from "./runtime-entry.js"; import { registerVoiceCallCli } from "./src/cli.js"; +import { + createVoiceCallCommandService, + VoiceCallCommandInputError, +} from "./src/command-service.js"; import { VoiceCallConfigSchema, resolveVoiceCallConfig, @@ -25,7 +28,6 @@ import { } from "./src/config.js"; import type { CoreConfig } from "./src/core-bridge.js"; import { createVoiceCallContinueOperationStore } from "./src/gateway-continue-operation.js"; -import type { CallRecord } from "./src/types.js"; const VOICE_CALL_WRITE_METHOD_SCOPE = { scope: "operator.write" as const }; const VOICE_CALL_READ_METHOD_SCOPE = { scope: "operator.read" as const }; @@ -234,33 +236,6 @@ function isCliOnlyProcess(): boolean { return process.env.OPENCLAW_CLI === "1" && !process.argv.slice(2).includes("gateway"); } -type VoiceCallStatus = Pick< - CallRecord, - | "callId" - | "providerCallId" - | "provider" - | "direction" - | "state" - | "startedAt" - | "answeredAt" - | "endedAt" - | "endReason" ->; - -function toVoiceCallStatus(call: CallRecord): VoiceCallStatus { - return { - callId: call.callId, - ...(call.providerCallId !== undefined ? { providerCallId: call.providerCallId } : {}), - provider: call.provider, - direction: call.direction, - state: call.state, - startedAt: call.startedAt, - ...(call.answeredAt !== undefined ? { answeredAt: call.answeredAt } : {}), - ...(call.endedAt !== undefined ? { endedAt: call.endedAt } : {}), - ...(call.endReason !== undefined ? { endReason: call.endReason } : {}), - }; -} - const VOICE_CALL_RUNTIME_KEY = Symbol.for("openclaw.voice-call.runtime"); const VOICE_CALL_RUNTIME_PROMISE_KEY = Symbol.for("openclaw.voice-call.runtimePromise"); const VOICE_CALL_RUNTIME_STOP_PROMISE_KEY = Symbol.for("openclaw.voice-call.runtimeStopPromise"); @@ -349,363 +324,150 @@ export default definePluginEntry({ } }; - const respondError = ( - respond: GatewayRequestHandlerOptions["respond"], - message: string, - code: (typeof ErrorCodes)[keyof typeof ErrorCodes] = ErrorCodes.UNAVAILABLE, + const commands = createVoiceCallCommandService(ensureRuntime); + const registerGatewayCommand = ( + method: string, + handler: (options: GatewayRequestHandlerOptions) => unknown, + scope: typeof VOICE_CALL_WRITE_METHOD_SCOPE | typeof VOICE_CALL_READ_METHOD_SCOPE, ) => { - respond(false, undefined, errorShape(code, message)); - }; - - const sendError = (respond: GatewayRequestHandlerOptions["respond"], err: unknown) => { - respondError(respond, formatErrorMessage(err)); - }; - - const describeHistoricalCall = async (rt: VoiceCallRuntime, callId: string) => { - const call = await rt.manager.getCallFromMemoryOrStore(callId); - if (!call) { - return undefined; - } - const endedAt = timestampMsToIsoString(call.endedAt); - const details = [ - `last state=${call.state}`, - call.endReason ? `endReason=${call.endReason}` : undefined, - endedAt ? `endedAt=${endedAt}` : undefined, - ].filter(Boolean); - return `call is not active (${details.join(", ")})`; - }; - - const resolveCallMessageRequest = async (params: GatewayRequestHandlerOptions["params"]) => { - const callId = normalizeOptionalString(params?.callId) ?? ""; - const message = normalizeOptionalString(params?.message) ?? ""; - if (!callId || !message) { - return { error: "callId and message required" } as const; - } - const rt = await ensureRuntime(); - const activeCall = rt.manager.getCall(callId) ?? rt.manager.getCallByProviderCallId(callId); - if (activeCall) { - return { rt, callId: activeCall.callId, message } as const; - } - return { error: (await describeHistoricalCall(rt, callId)) ?? "Call not found" } as const; - }; - - const initiateCallAndRespond = async (params: { - rt: VoiceCallRuntime; - respond: GatewayRequestHandlerOptions["respond"]; - to: string; - message?: string; - mode?: "notify" | "conversation"; - dtmfSequence?: string; - sessionKey?: string; - requesterSessionKey?: string; - agentId?: string; - }) => { - const result = await params.rt.manager.initiateCall(params.to, params.sessionKey, { - message: params.message, - mode: params.mode, - dtmfSequence: params.dtmfSequence, - ...(params.requesterSessionKey ? { requesterSessionKey: params.requesterSessionKey } : {}), - ...(params.agentId ? { agentId: params.agentId } : {}), - }); - if (!result.success) { - respondError(params.respond, result.error || "initiate failed"); - return; - } - params.respond(true, { callId: result.callId, initiated: true }); - }; - - const respondToCallMessageAction = async (params: { - requestParams: GatewayRequestHandlerOptions["params"]; - respond: GatewayRequestHandlerOptions["respond"]; - action: ( - request: Exclude>, { error: string }>, - ) => Promise<{ - success: boolean; - error?: string; - transcript?: string; - }>; - failure: string; - includeTranscript?: boolean; - }) => { - const request = await resolveCallMessageRequest(params.requestParams); - if ("error" in request) { - respondError( - params.respond, - request.error ?? "callId and message required", - ErrorCodes.INVALID_REQUEST, - ); - return; - } - const result = await params.action(request); - if (!result.success) { - respondError(params.respond, result.error || params.failure); - return; - } - params.respond( - true, - params.includeTranscript - ? { success: true, transcript: result.transcript } - : { success: true }, + api.registerGatewayMethod( + method, + async (options: GatewayRequestHandlerOptions) => { + try { + options.respond(true, await handler(options)); + } catch (err) { + const code = + err instanceof VoiceCallCommandInputError + ? ErrorCodes.INVALID_REQUEST + : ErrorCodes.UNAVAILABLE; + options.respond(false, undefined, errorShape(code, formatErrorMessage(err))); + } + }, + scope, ); }; - api.registerGatewayMethod( + registerGatewayCommand( "voicecall.initiate", - async ({ params, respond }: GatewayRequestHandlerOptions) => { - try { - const message = normalizeOptionalString(params?.message) ?? ""; - if (!message) { - respondError(respond, "message required", ErrorCodes.INVALID_REQUEST); - return; - } - const rt = await ensureRuntime(); - const to = normalizeOptionalString(params?.to) ?? rt.config.toNumber; - if (!to) { - respondError(respond, "to required", ErrorCodes.INVALID_REQUEST); - return; - } - const mode = - params?.mode === "notify" || params?.mode === "conversation" ? params.mode : undefined; - await initiateCallAndRespond({ - rt, - respond, - to, - message, - mode, - sessionKey: normalizeOptionalString(params?.sessionKey), - requesterSessionKey: normalizeOptionalString(params?.requesterSessionKey), - }); - } catch (err) { - sendError(respond, err); + async ({ params }) => { + const message = normalizeOptionalString(params?.message); + if (!message) { + throw new VoiceCallCommandInputError("message required"); } + return await commands.initiate({ + to: normalizeOptionalString(params?.to), + message, + mode: + params?.mode === "notify" || params?.mode === "conversation" ? params.mode : undefined, + sessionKey: normalizeOptionalString(params?.sessionKey), + requesterSessionKey: normalizeOptionalString(params?.requesterSessionKey), + }); }, VOICE_CALL_WRITE_METHOD_SCOPE, ); - api.registerGatewayMethod( + registerGatewayCommand( "voicecall.continue", - async ({ params, respond }: GatewayRequestHandlerOptions) => { - try { - await respondToCallMessageAction({ - requestParams: params, - respond, - action: (request) => request.rt.manager.continueCall(request.callId, request.message), - failure: "continue failed", - includeTranscript: true, - }); - } catch (err) { - sendError(respond, err); - } - }, + ({ params }) => + commands.continueCall( + normalizeOptionalString(params?.callId), + normalizeOptionalString(params?.message), + ), VOICE_CALL_WRITE_METHOD_SCOPE, ); - api.registerGatewayMethod( + registerGatewayCommand( "voicecall.continue.start", - async ({ params, respond }: GatewayRequestHandlerOptions) => { - try { - const request = await resolveCallMessageRequest(params); - if ("error" in request) { - respondError( - respond, - request.error ?? "callId and message required", - ErrorCodes.INVALID_REQUEST, - ); - return; - } - respond(true, continueOperationStore.start(request)); - } catch (err) { - sendError(respond, err); - } - }, + async ({ params }) => + continueOperationStore.start( + await commands.prepareContinue( + normalizeOptionalString(params?.callId), + normalizeOptionalString(params?.message), + ), + ), VOICE_CALL_WRITE_METHOD_SCOPE, ); - api.registerGatewayMethod( + registerGatewayCommand( "voicecall.continue.result", - async ({ params, respond }: GatewayRequestHandlerOptions) => { - try { - const operationId = normalizeOptionalString(params?.operationId) ?? ""; - if (!operationId) { - respondError(respond, "operationId required", ErrorCodes.INVALID_REQUEST); - return; - } - const operation = continueOperationStore.read(operationId); - if (!operation.ok) { - respondError(respond, operation.error, ErrorCodes.INVALID_REQUEST); - return; - } - respond(true, operation.payload); - } catch (err) { - sendError(respond, err); + ({ params }) => { + const operationId = normalizeOptionalString(params?.operationId); + if (!operationId) { + throw new VoiceCallCommandInputError("operationId required"); } + const operation = continueOperationStore.read(operationId); + if (!operation.ok) { + throw new VoiceCallCommandInputError(operation.error); + } + return operation.payload; }, VOICE_CALL_READ_METHOD_SCOPE, ); - api.registerGatewayMethod( + registerGatewayCommand( "voicecall.speak", - async ({ params, respond }: GatewayRequestHandlerOptions) => { - try { - const request = await resolveCallMessageRequest(params); - if ("error" in request) { - respondError( - respond, - request.error ?? "callId and message required", - ErrorCodes.INVALID_REQUEST, - ); - return; - } - if (request.rt.config.realtime.enabled) { - const realtimeResult = request.rt.webhookServer.speakRealtime( - request.callId, - request.message, - ); - if (realtimeResult.success) { - respond(true, { success: true }); - return; - } - if (params?.allowTwimlFallback === false) { - respond(true, { - success: false, - error: realtimeResult.error ?? "Realtime bridge is not active", - }); - return; - } - } - const result = await request.rt.manager.speak(request.callId, request.message); - if (!result.success) { - respondError(respond, result.error || "speak failed"); - return; - } - respond(true, { success: true }); - } catch (err) { - sendError(respond, err); - } - }, + ({ params }) => + commands.speak({ + callId: normalizeOptionalString(params?.callId), + message: normalizeOptionalString(params?.message), + allowTwimlFallback: params?.allowTwimlFallback !== false, + }), VOICE_CALL_WRITE_METHOD_SCOPE, ); - api.registerGatewayMethod( + registerGatewayCommand( "voicecall.dtmf", - async ({ params, respond }: GatewayRequestHandlerOptions) => { - try { - const callId = normalizeOptionalString(params?.callId) ?? ""; - const digits = normalizeOptionalString(params?.digits) ?? ""; - if (!callId || !digits) { - respondError(respond, "callId and digits required", ErrorCodes.INVALID_REQUEST); - return; - } - const rt = await ensureRuntime(); - const result = await rt.manager.sendDtmf(callId, digits); - if (!result.success) { - respondError(respond, result.error || "dtmf failed"); - return; - } - respond(true, { success: true }); - } catch (err) { - sendError(respond, err); - } - }, + ({ params }) => + commands.sendDtmf( + normalizeOptionalString(params?.callId), + normalizeOptionalString(params?.digits), + ), VOICE_CALL_WRITE_METHOD_SCOPE, ); - api.registerGatewayMethod( + registerGatewayCommand( "voicecall.end", - async ({ params, respond }: GatewayRequestHandlerOptions) => { - try { - const callId = normalizeOptionalString(params?.callId) ?? ""; - if (!callId) { - respondError(respond, "callId required", ErrorCodes.INVALID_REQUEST); - return; - } - const rt = await ensureRuntime(); - const result = await rt.manager.endCall(callId); - if (!result.success) { - respondError(respond, result.error || "end failed"); - return; - } - respond(true, { success: true }); - } catch (err) { - sendError(respond, err); - } - }, + ({ params }) => commands.endCall(normalizeOptionalString(params?.callId)), VOICE_CALL_WRITE_METHOD_SCOPE, ); - api.registerGatewayMethod( + registerGatewayCommand( "voicecall.status", - async ({ params, respond }: GatewayRequestHandlerOptions) => { - try { - const raw = - normalizeOptionalString(params?.callId) ?? normalizeOptionalString(params?.sid) ?? ""; - const rt = await ensureRuntime(); - if (!raw) { - respond(true, { - found: true, - calls: rt.manager.getActiveCalls().map(toVoiceCallStatus), - }); - return; - } - const call = await rt.manager.getCallFromMemoryOrStore(raw); - if (!call) { - respond(true, { found: false }); - return; - } - respond(true, { found: true, call: toVoiceCallStatus(call) }); - } catch (err) { - sendError(respond, err); - } - }, + ({ params }) => + commands.status( + normalizeOptionalString(params?.callId) ?? normalizeOptionalString(params?.sid), + ), VOICE_CALL_READ_METHOD_SCOPE, ); - api.registerGatewayMethod( + registerGatewayCommand( "voicecall.start", - async ({ params, client, respond }: GatewayRequestHandlerOptions) => { - try { - const to = normalizeOptionalString(params?.to) ?? ""; - const message = normalizeOptionalString(params?.message) ?? ""; - const dtmfSequence = normalizeOptionalString(params?.dtmfSequence); - const sessionKey = normalizeOptionalString(params?.sessionKey); - const requesterSessionKey = normalizeOptionalString(params?.requesterSessionKey); - const requestedAgentId = normalizeOptionalString(params?.agentId); - const normalizedAgentId = requestedAgentId - ? normalizeAgentId(requestedAgentId) - : undefined; - const pluginOwnerId = normalizeOptionalString(client?.internal?.pluginRuntimeOwnerId); - if ( - requestedAgentId && - (!pluginOwnerId || normalizedAgentId !== requestedAgentId.toLowerCase()) - ) { - respondError( - respond, - "agentId requires a trusted plugin caller and a valid agent id", - ErrorCodes.INVALID_REQUEST, - ); - return; - } - if (!to) { - respondError(respond, "to required", ErrorCodes.INVALID_REQUEST); - return; - } - const mode = - params?.mode === "notify" || params?.mode === "conversation" ? params.mode : undefined; - const rt = await ensureRuntime(); - await initiateCallAndRespond({ - rt, - respond, - to, - message: message || undefined, - mode, - dtmfSequence, - sessionKey, - ...(requesterSessionKey ? { requesterSessionKey } : {}), - ...(normalizedAgentId ? { agentId: normalizedAgentId } : {}), - }); - } catch (err) { - sendError(respond, err); + async ({ params, client }) => { + const to = normalizeOptionalString(params?.to); + const requestedAgentId = normalizeOptionalString(params?.agentId); + const normalizedAgentId = requestedAgentId ? normalizeAgentId(requestedAgentId) : undefined; + const pluginOwnerId = normalizeOptionalString(client?.internal?.pluginRuntimeOwnerId); + if ( + requestedAgentId && + (!pluginOwnerId || normalizedAgentId !== requestedAgentId.toLowerCase()) + ) { + throw new VoiceCallCommandInputError( + "agentId requires a trusted plugin caller and a valid agent id", + ); } + if (!to) { + throw new VoiceCallCommandInputError("to required"); + } + return await commands.initiate({ + to, + message: normalizeOptionalString(params?.message), + mode: + params?.mode === "notify" || params?.mode === "conversation" ? params.mode : undefined, + dtmfSequence: normalizeOptionalString(params?.dtmfSequence), + sessionKey: normalizeOptionalString(params?.sessionKey), + requesterSessionKey: normalizeOptionalString(params?.requesterSessionKey), + agentId: normalizedAgentId, + }); }, VOICE_CALL_WRITE_METHOD_SCOPE, ); @@ -725,94 +487,59 @@ export default definePluginEntry({ parseAgentSessionKey(requesterSessionKey)?.agentId; const agentId = contextAgentId ? normalizeAgentId(contextAgentId) : undefined; try { - const rt = await ensureRuntime(); - + // Preserve tool error precedence: runtime availability is checked before model input. + await ensureRuntime(); if (typeof rawParams.action === "string") { switch (rawParams.action) { case "initiate_call": { - const message = normalizeOptionalString(rawParams.message) ?? ""; + const message = normalizeOptionalString(rawParams.message); if (!message) { - throw new Error("message required"); + throw new VoiceCallCommandInputError("message required"); } - const to = normalizeOptionalString(rawParams.to) ?? rt.config.toNumber; - if (!to) { - throw new Error("to required"); - } - const result = await rt.manager.initiateCall( - to, - normalizeOptionalString(rawParams.sessionKey), - { + return json( + await commands.initiate({ + to: normalizeOptionalString(rawParams.to), message, dtmfSequence: normalizeOptionalString(rawParams.dtmfSequence), mode: rawParams.mode === "notify" || rawParams.mode === "conversation" ? rawParams.mode : undefined, - ...(agentId ? { agentId } : {}), - ...(requesterSessionKey ? { requesterSessionKey } : {}), - }, + sessionKey: normalizeOptionalString(rawParams.sessionKey), + agentId, + requesterSessionKey, + }), ); - if (!result.success) { - throw new Error(result.error || "initiate failed"); - } - return json({ callId: result.callId, initiated: true }); } - case "continue_call": { - const callId = normalizeOptionalString(rawParams.callId) ?? ""; - const message = normalizeOptionalString(rawParams.message) ?? ""; - if (!callId || !message) { - throw new Error("callId and message required"); - } - const result = await rt.manager.continueCall(callId, message); - if (!result.success) { - throw new Error(result.error || "continue failed"); - } - return json({ success: true, transcript: result.transcript }); - } - case "speak_to_user": { - const callId = normalizeOptionalString(rawParams.callId) ?? ""; - const message = normalizeOptionalString(rawParams.message) ?? ""; - if (!callId || !message) { - throw new Error("callId and message required"); - } - const result = await rt.manager.speak(callId, message); - if (!result.success) { - throw new Error(result.error || "speak failed"); - } - return json({ success: true }); - } - case "send_dtmf": { - const callId = normalizeOptionalString(rawParams.callId) ?? ""; - const digits = normalizeOptionalString(rawParams.digits) ?? ""; - if (!callId || !digits) { - throw new Error("callId and digits required"); - } - const result = await rt.manager.sendDtmf(callId, digits); - if (!result.success) { - throw new Error(result.error || "dtmf failed"); - } - return json({ success: true }); - } - case "end_call": { - const callId = normalizeOptionalString(rawParams.callId) ?? ""; - if (!callId) { - throw new Error("callId required"); - } - const result = await rt.manager.endCall(callId); - if (!result.success) { - throw new Error(result.error || "end failed"); - } - return json({ success: true }); - } - case "get_status": { - const callId = normalizeOptionalString(rawParams.callId) ?? ""; - if (!callId) { - throw new Error("callId required"); - } - const call = await rt.manager.getCallFromMemoryOrStore(callId); + case "continue_call": return json( - call ? { found: true, call: toVoiceCallStatus(call) } : { found: false }, + await commands.continueCall( + normalizeOptionalString(rawParams.callId), + normalizeOptionalString(rawParams.message), + ), ); + case "speak_to_user": + return json( + await commands.speak({ + callId: normalizeOptionalString(rawParams.callId), + message: normalizeOptionalString(rawParams.message), + }), + ); + case "send_dtmf": + return json( + await commands.sendDtmf( + normalizeOptionalString(rawParams.callId), + normalizeOptionalString(rawParams.digits), + ), + ); + case "end_call": + return json(await commands.endCall(normalizeOptionalString(rawParams.callId))); + case "get_status": { + const callId = normalizeOptionalString(rawParams.callId); + if (!callId) { + throw new VoiceCallCommandInputError("callId required"); + } + return json(await commands.status(callId)); } } } @@ -823,28 +550,22 @@ export default definePluginEntry({ if (!sid) { throw new Error("sid required for status"); } - const call = await rt.manager.getCallFromMemoryOrStore(sid); - return json(call ? { found: true, call: toVoiceCallStatus(call) } : { found: false }); + return json(await commands.status(sid)); } - const to = normalizeOptionalString(rawParams.to) ?? rt.config.toNumber; - if (!to) { - throw new Error("to required for call"); - } - const result = await rt.manager.initiateCall( - to, - normalizeOptionalString(rawParams.sessionKey), - { - dtmfSequence: normalizeOptionalString(rawParams.dtmfSequence), - message: normalizeOptionalString(rawParams.message), - ...(agentId ? { agentId } : {}), - ...(requesterSessionKey ? { requesterSessionKey } : {}), - }, + return json( + await commands.initiate( + { + to: normalizeOptionalString(rawParams.to), + dtmfSequence: normalizeOptionalString(rawParams.dtmfSequence), + message: normalizeOptionalString(rawParams.message), + sessionKey: normalizeOptionalString(rawParams.sessionKey), + agentId, + requesterSessionKey, + }, + "to required for call", + ), ); - if (!result.success) { - throw new Error(result.error || "initiate failed"); - } - return json({ callId: result.callId, initiated: true }); } catch (err) { return json({ error: formatErrorMessage(err), @@ -912,4 +633,3 @@ export default definePluginEntry({ }); }, }); -/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/extensions/voice-call/src/command-service.ts b/extensions/voice-call/src/command-service.ts new file mode 100644 index 000000000000..46e4e6258cf1 --- /dev/null +++ b/extensions/voice-call/src/command-service.ts @@ -0,0 +1,171 @@ +// Voice Call command service owns operations shared by gateway and model-tool adapters. +import { timestampMsToIsoString } from "openclaw/plugin-sdk/number-runtime"; +import type { CallMode } from "./config.js"; +import type { VoiceCallRuntime } from "./runtime.js"; +import type { CallRecord } from "./types.js"; + +type VoiceCallStatus = Pick< + CallRecord, + | "callId" + | "providerCallId" + | "provider" + | "direction" + | "state" + | "startedAt" + | "answeredAt" + | "endedAt" + | "endReason" +>; + +export class VoiceCallCommandInputError extends Error {} + +function toVoiceCallStatus(call: CallRecord): VoiceCallStatus { + return { + callId: call.callId, + ...(call.providerCallId !== undefined ? { providerCallId: call.providerCallId } : {}), + provider: call.provider, + direction: call.direction, + state: call.state, + startedAt: call.startedAt, + ...(call.answeredAt !== undefined ? { answeredAt: call.answeredAt } : {}), + ...(call.endedAt !== undefined ? { endedAt: call.endedAt } : {}), + ...(call.endReason !== undefined ? { endReason: call.endReason } : {}), + }; +} + +function requireInput(value: string | undefined, message: string): string { + if (!value) { + throw new VoiceCallCommandInputError(message); + } + return value; +} + +function requireSuccess(result: { success: boolean; error?: string }, fallback: string): void { + if (!result.success) { + throw new Error(result.error || fallback); + } +} + +export function createVoiceCallCommandService(ensureRuntime: () => Promise) { + const describeHistoricalCall = async (rt: VoiceCallRuntime, callId: string) => { + const call = await rt.manager.getCallFromMemoryOrStore(callId); + if (!call) { + return undefined; + } + const endedAt = timestampMsToIsoString(call.endedAt); + const details = [ + `last state=${call.state}`, + call.endReason ? `endReason=${call.endReason}` : undefined, + endedAt ? `endedAt=${endedAt}` : undefined, + ].filter(Boolean); + return `call is not active (${details.join(", ")})`; + }; + + const resolveCallMessage = async (callId?: string, message?: string) => { + const resolvedCallId = requireInput(callId, "callId and message required"); + const resolvedMessage = requireInput(message, "callId and message required"); + const rt = await ensureRuntime(); + const activeCall = + rt.manager.getCall(resolvedCallId) ?? rt.manager.getCallByProviderCallId(resolvedCallId); + if (!activeCall) { + throw new VoiceCallCommandInputError( + (await describeHistoricalCall(rt, resolvedCallId)) ?? "Call not found", + ); + } + return { rt, callId: activeCall.callId, message: resolvedMessage }; + }; + + const prepareContinue = async (callId?: string, message?: string) => { + const request = await resolveCallMessage(callId, message); + return { + rt: request.rt, + callId: request.callId, + run: async () => { + const result = await request.rt.manager.continueCall(request.callId, request.message); + requireSuccess(result, "continue failed"); + return { success: true as const, transcript: result.transcript }; + }, + }; + }; + + return { + prepareContinue, + + async initiate( + params: { + to?: string; + message?: string; + mode?: CallMode; + sessionKey?: string; + dtmfSequence?: string; + requesterSessionKey?: string; + agentId?: string; + }, + missingToMessage = "to required", + ) { + const rt = await ensureRuntime(); + const to = requireInput(params.to ?? rt.config.toNumber, missingToMessage); + const result = await rt.manager.initiateCall(to, params.sessionKey, { + message: params.message, + mode: params.mode, + dtmfSequence: params.dtmfSequence, + ...(params.requesterSessionKey ? { requesterSessionKey: params.requesterSessionKey } : {}), + ...(params.agentId ? { agentId: params.agentId } : {}), + }); + requireSuccess(result, "initiate failed"); + return { callId: result.callId, initiated: true }; + }, + + async continueCall(callId?: string, message?: string) { + return await (await prepareContinue(callId, message)).run(); + }, + + async speak(params: { callId?: string; message?: string; allowTwimlFallback?: boolean }) { + const request = await resolveCallMessage(params.callId, params.message); + if (request.rt.config.realtime.enabled) { + const realtimeResult = request.rt.webhookServer.speakRealtime( + request.callId, + request.message, + ); + if (realtimeResult.success) { + return { success: true }; + } + if (params.allowTwimlFallback === false) { + return { + success: false, + error: realtimeResult.error ?? "Realtime bridge is not active", + }; + } + } + const result = await request.rt.manager.speak(request.callId, request.message); + requireSuccess(result, "speak failed"); + return { success: true }; + }, + + async sendDtmf(callId?: string, digits?: string) { + const resolvedCallId = requireInput(callId, "callId and digits required"); + const resolvedDigits = requireInput(digits, "callId and digits required"); + const rt = await ensureRuntime(); + const result = await rt.manager.sendDtmf(resolvedCallId, resolvedDigits); + requireSuccess(result, "dtmf failed"); + return { success: true }; + }, + + async endCall(callId?: string) { + const resolvedCallId = requireInput(callId, "callId required"); + const rt = await ensureRuntime(); + const result = await rt.manager.endCall(resolvedCallId); + requireSuccess(result, "end failed"); + return { success: true }; + }, + + async status(callId?: string) { + const rt = await ensureRuntime(); + if (!callId) { + return { found: true, calls: rt.manager.getActiveCalls().map(toVoiceCallStatus) }; + } + const call = await rt.manager.getCallFromMemoryOrStore(callId); + return call ? { found: true, call: toVoiceCallStatus(call) } : { found: false }; + }, + }; +} diff --git a/extensions/voice-call/src/gateway-continue-operation.test.ts b/extensions/voice-call/src/gateway-continue-operation.test.ts index cc17c145dabe..64db928a396b 100644 --- a/extensions/voice-call/src/gateway-continue-operation.test.ts +++ b/extensions/voice-call/src/gateway-continue-operation.test.ts @@ -15,13 +15,10 @@ describe("voice-call gateway continue operation store", () => { const started = store.start({ callId: "call-1", - message: "hello", rt: { config: {}, - manager: { - continueCall: async () => new Promise(() => {}), - }, } as never, + run: async () => await new Promise(() => {}), }); expect(started.pollTimeoutMs).toBe(MAX_TIMER_TIMEOUT_MS); diff --git a/extensions/voice-call/src/gateway-continue-operation.ts b/extensions/voice-call/src/gateway-continue-operation.ts index 02c66809bcc9..d3478495524a 100644 --- a/extensions/voice-call/src/gateway-continue-operation.ts +++ b/extensions/voice-call/src/gateway-continue-operation.ts @@ -69,7 +69,7 @@ type VoiceCallContinueOperationResultPayload = type VoiceCallContinueOperationRequest = { rt: VoiceCallRuntime; callId: string; - message: string; + run: () => Promise<{ success: true; transcript?: string }>; }; /** Create a process-local operation store for gateway continue-call polling. */ @@ -115,25 +115,13 @@ export function createVoiceCallContinueOperationStore(params: { pollTimeoutMs, }); - void request.rt.manager - .continueCall(request.callId, request.message) + void request + .run() .then((result) => { const current = operations.get(operationId); if (!current || current.status !== "pending") { return; } - if (!result.success) { - operations.set(operationId, { - operationId, - status: "failed", - callId: request.callId, - startedAtMs, - completedAtMs: Date.now(), - pollTimeoutMs, - error: result.error || "continue failed", - }); - return; - } operations.set(operationId, { operationId, status: "completed", diff --git a/scripts/plugin-sdk-surface-report.mjs b/scripts/plugin-sdk-surface-report.mjs index 9c6204d8856c..7e8e4b3753da 100644 --- a/scripts/plugin-sdk-surface-report.mjs +++ b/scripts/plugin-sdk-surface-report.mjs @@ -189,6 +189,8 @@ export function readPluginSdkSurfaceBudgets(env = process.env) { // +1: typed owner-required error for session store path resolution. // +1: native approval messaging target resolver. // +1: shared plugin SecretRef setup plan helper. + // +2: shared low-cardinality diagnostic dimension normalizers. + // +1: shared plugin SecretRef setup CLI factory. // +1: shared multi-claim ingress lifecycle fan-in. // +3: channel prompt-context entry/compat types and channel metadata builder. // +4: focused CLI root-option constants and parsers. @@ -210,7 +212,7 @@ export function readPluginSdkSurfaceBudgets(env = process.env) { // +45: restore typed session-catalog and tool-results exports promised to plugins. // +1: forwarding-routed approver-restricted native approval capability factory. // +1: shared inbound-event delivery correlation factory for channel plugins. - 4822, + 4825, env, ), publicFunctionExports: readPluginSdkSurfaceBudgetEnv( @@ -234,6 +236,8 @@ export function readPluginSdkSurfaceBudgets(env = process.env) { // +2: focused media-local-roots helpers. // +3: channel DM policy factory and its account/patch callbacks. // +1: native approval messaging target resolver. + // +2: shared low-cardinality diagnostic dimension normalizers. + // +1: shared plugin SecretRef setup CLI factory. // +1: shared multi-claim ingress lifecycle fan-in. // +1: channel metadata builder. // +3: focused CLI root-option parsers. @@ -252,7 +256,7 @@ export function readPluginSdkSurfaceBudgets(env = process.env) { // +14: restore callable session-catalog and tool-results helpers promised to plugins. // +1: forwarding-routed approver-restricted native approval capability factory. // +1: shared inbound-event delivery correlation factory for channel plugins. - 2899, + 2902, env, ), publicDeprecatedExports: readPluginSdkSurfaceBudgetEnv( diff --git a/src/plugin-sdk/diagnostic-runtime.ts b/src/plugin-sdk/diagnostic-runtime.ts index fada4d6d2fc0..e744fe0f4ca6 100644 --- a/src/plugin-sdk/diagnostic-runtime.ts +++ b/src/plugin-sdk/diagnostic-runtime.ts @@ -1,5 +1,36 @@ // Diagnostic flag/event helpers for plugins that want narrow runtime gating. +import { redactSensitiveText } from "../logging/redact.js"; + +const LOW_CARDINALITY_DIAGNOSTIC_VALUE_RE = /^[A-Za-z0-9_.:-]{1,120}$/u; + +export function normalizeDiagnosticValue(value: string | undefined, fallback = "unknown"): string { + if (!value) { + return fallback; + } + const redacted = redactSensitiveText(value.trim()); + const redactedLower = redacted.toLowerCase(); + // Session-shaped agent identifiers are unbounded and must never become exporter dimensions. + if (redactedLower.startsWith("agent:") || redactedLower.includes(":agent:")) { + return fallback; + } + return LOW_CARDINALITY_DIAGNOSTIC_VALUE_RE.test(redacted) ? redacted : fallback; +} + +export function normalizeDiagnosticLane(value: string | undefined, fallback = "unknown"): string { + if (!value) { + return fallback; + } + const redacted = redactSensitiveText(value.trim()); + if (redacted.toLowerCase().startsWith("agent:")) { + return fallback; + } + // Scoped lane suffixes carry session identity; exporters group only by the stable lane prefix. + const scopedLaneIndex = redacted.indexOf(":"); + const lane = scopedLaneIndex >= 0 ? redacted.slice(0, scopedLaneIndex) : redacted; + return LOW_CARDINALITY_DIAGNOSTIC_VALUE_RE.test(lane) ? lane : fallback; +} + export { isDiagnosticFlagEnabled } from "../infra/diagnostic-flags.js"; export type { DiagnosticEventMetadata, diff --git a/src/plugin-sdk/secret-ref-runtime.ts b/src/plugin-sdk/secret-ref-runtime.ts index a29db11c63d8..63b1f2e838f5 100644 --- a/src/plugin-sdk/secret-ref-runtime.ts +++ b/src/plugin-sdk/secret-ref-runtime.ts @@ -1,6 +1,12 @@ // Narrow shared secret-ref helpers for plugin config and secret-contract paths. import fs from "node:fs/promises"; +import path from "node:path"; +import { createInterface } from "node:readline/promises"; +import { isRecord } from "@openclaw/normalization-core/record-coerce"; +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { PluginIntegrationSecretProviderConfig } from "../config/types.secrets.js"; import { sameFileIdentity } from "../infra/fs-safe-advanced.js"; import { assertValidPluginModelProviderId, @@ -17,6 +23,63 @@ import { type PlanFileIdentity = { dev: bigint; ino: bigint }; +type SecretRefSetupCommand = { + command(name: string): SecretRefSetupCommand; + description(value: string): SecretRefSetupCommand; + option( + flags: string, + description: string, + defaultValueOrParser?: string | ((value: string, previous?: string[]) => string[]), + defaultValue?: string[], + ): SecretRefSetupCommand; + action(fn: (options: TOptions) => void | Promise): SecretRefSetupCommand; +}; + +type SecretRefSetupOptions = { + planOut?: string; + providerAlias?: string; + openaiId?: string; + anthropicId?: string; + openrouterId?: string; + providerKey?: string[]; + target?: string[]; +}; + +type SecretRefProviderStatus = { + configured: boolean; + source?: string; + command?: string; + pluginIntegration?: { + pluginId: string; + integrationId: string; + }; +}; + +type SecretRefProviderMapping = { + providerId: string; + secretId: string; +}; + +type SecretRefConfigTargetMapping = { + path: string; + agentId?: string; + secretId: string; +}; + +type PluginSecretRefSetupCliParams = { + productName: string; + secretIdLabel: string; + secretIdPlaceholder: string; + defaultProviderAlias: string; + pluginIntegration: { + pluginId: string; + integrationId: string; + }; + normalizeSecretId: (label: string, value: string) => string; + defaultPlanPath: () => string; + beforeApplyCommands?: readonly string[]; +}; + function throwPlanFileError(error: unknown, planPath: string): never { if ((error as NodeJS.ErrnoException)?.code === "EEXIST") { throw new Error(`Plan path already exists; choose a new --plan-out path: ${planPath}`, { @@ -82,6 +145,289 @@ async function writeSecretPlanFile(params: { } } +type CommandShell = "cmd" | "posix" | "powershell"; + +function quoteSecretRefCliArg(value: string, shell: CommandShell): string { + if (/\r|\n/u.test(value)) { + throw new Error("Command argument cannot contain CR or LF"); + } + if (shell === "cmd") { + if (/[%!]/u.test(value)) { + throw new Error("Interactive Command Prompt cannot safely quote paths containing % or !"); + } + const escaped = value.replaceAll('"', '\\"'); + return /[ \t"&|<>^()]/u.test(value) ? `"${escaped}"` : escaped || '""'; + } + if (shell === "powershell") { + return `'${value.replaceAll("'", "''")}'`; + } + if (/^[A-Za-z0-9_/:=.,@%+-]+$/.test(value)) { + return value; + } + return `'${value.replaceAll("'", "'\\''")}'`; +} + +function renderSecretRefApplyCommands( + planPath: string, + platform: NodeJS.Platform = process.platform, +): string[] { + const render = (shell: CommandShell, indent = "") => { + const quotedPlanPath = quoteSecretRefCliArg(planPath, shell); + return [ + `${indent}openclaw secrets apply --from ${quotedPlanPath} --dry-run --allow-exec`, + `${indent}openclaw secrets apply --from ${quotedPlanPath} --allow-exec`, + ]; + }; + if (platform !== "win32") { + return render("posix"); + } + // The parent shell is unknown, so emit native variants instead of unsafe hybrid syntax. + const powershellCommands = ["PowerShell:", ...render("powershell", " ")]; + if (/[%!]/u.test(planPath)) { + return [ + ...powershellCommands, + "Command Prompt: unavailable for paths containing % or !; use PowerShell.", + ]; + } + return [...powershellCommands, "Command Prompt:", ...render("cmd", " ")]; +} + +function readSecretRefProviderStatus( + config: OpenClawConfig, + providerAlias: string, +): SecretRefProviderStatus { + const provider = config.secrets?.providers?.[providerAlias]; + if (!isRecord(provider)) { + return { configured: false }; + } + const base = { + configured: true, + source: normalizeOptionalString(provider.source), + }; + if (provider.source !== "exec") { + return base; + } + if ("pluginIntegration" in provider) { + return { + ...base, + pluginIntegration: provider.pluginIntegration as SecretRefProviderStatus["pluginIntegration"], + }; + } + return { + ...base, + command: normalizeOptionalString(provider.command), + }; +} + +function writeSecretRefCliLine(message = ""): void { + process.stdout.write(`${message}\n`); +} + +/** Build the canonical setup/status adapter shared by plugin-owned SecretRef CLIs. */ +export function createPluginSecretRefSetupCli(params: PluginSecretRefSetupCliParams) { + const isIntegrationProvider = (value: unknown): boolean => + isRecord(value) && + value.source === "exec" && + isRecord(value.pluginIntegration) && + value.pluginIntegration.pluginId === params.pluginIntegration.pluginId && + value.pluginIntegration.integrationId === params.pluginIntegration.integrationId; + + const inspectProvider = (config: OpenClawConfig, requestedAlias?: string) => { + const explicitAlias = normalizeOptionalString(requestedAlias); + let providerAlias: string; + if (explicitAlias) { + assertValidPluginSecretProviderAlias(explicitAlias); + providerAlias = explicitAlias; + } else { + const configuredAliases = Object.entries(config.secrets?.providers ?? {}) + .filter(([, provider]) => isIntegrationProvider(provider)) + .map(([alias]) => alias) + .toSorted(); + if (configuredAliases.length > 1) { + throw new Error( + `Multiple ${params.productName} provider aliases are configured (${configuredAliases.join(", ")}). Use --provider-alias .`, + ); + } + providerAlias = configuredAliases[0] ?? params.defaultProviderAlias; + } + return { + providerAlias, + provider: readSecretRefProviderStatus(config, providerAlias), + providerReady: isIntegrationProvider(config.secrets?.providers?.[providerAlias]), + }; + }; + + const parseProviderKeyMappings = (values: string[] | undefined): SecretRefProviderMapping[] => + (values ?? []).map((value) => { + const separator = value.indexOf("="); + if (separator <= 0 || separator === value.length - 1) { + throw new Error( + `Invalid --provider-key value "${value}". Use =<${params.secretIdPlaceholder}>.`, + ); + } + const providerId = value.slice(0, separator).trim(); + assertValidPluginModelProviderId("--provider-key", providerId); + return { + providerId, + secretId: params.normalizeSecretId( + `--provider-key ${providerId}`, + value.slice(separator + 1).trim(), + ), + }; + }); + + const parseConfigTargetMappings = ( + values: string[] | undefined, + ): SecretRefConfigTargetMapping[] => + (values ?? []).map((value) => { + const separator = value.indexOf("="); + if (separator <= 0 || separator === value.length - 1) { + throw new Error( + `Invalid --target value "${value}". Use =<${params.secretIdPlaceholder}>.`, + ); + } + const target = parsePluginSecretTargetSpecifier( + params.productName, + value.slice(0, separator).trim(), + ); + const secretId = params.normalizeSecretId( + `--target ${target.path}`, + value.slice(separator + 1).trim(), + ); + return Object.assign( + { path: target.path, secretId }, + target.agentId ? { agentId: target.agentId } : {}, + ); + }); + + const promptOptionalSecretId = async (label: string): Promise => { + if (!process.stdin.isTTY || !process.stdout.isTTY) { + return undefined; + } + const readline = createInterface({ input: process.stdin, output: process.stdout }); + try { + return normalizeOptionalString( + await readline.question(`${label} ${params.secretIdLabel} (blank to skip): `), + ); + } finally { + readline.close(); + } + }; + + const collectProviderSecrets = async ( + options: SecretRefSetupOptions, + ): Promise => { + const commonProviders = [ + { providerId: "openai", label: "OpenAI", value: options.openaiId }, + { providerId: "anthropic", label: "Anthropic", value: options.anthropicId }, + { providerId: "openrouter", label: "OpenRouter", value: options.openrouterId }, + ] as const; + const providerSecrets: SecretRefProviderMapping[] = []; + for (const provider of commonProviders) { + const value = + normalizeOptionalString(provider.value) ?? (await promptOptionalSecretId(provider.label)); + if (value) { + providerSecrets.push({ + providerId: provider.providerId, + secretId: params.normalizeSecretId(provider.label, value), + }); + } + } + providerSecrets.push(...parseProviderKeyMappings(options.providerKey)); + const seen = new Set(); + for (const entry of providerSecrets) { + const normalized = entry.providerId.toLowerCase(); + if (seen.has(normalized)) { + throw new Error( + `Duplicate model provider id in ${params.productName} setup: ${entry.providerId}`, + ); + } + seen.add(normalized); + } + return providerSecrets; + }; + + const runSetup = async (options: SecretRefSetupOptions): Promise => { + const providerAlias = + normalizeOptionalString(options.providerAlias) ?? params.defaultProviderAlias; + assertValidPluginSecretProviderAlias(providerAlias); + const providerConfig: PluginIntegrationSecretProviderConfig = { + source: "exec", + pluginIntegration: params.pluginIntegration, + }; + const plan = buildPluginSecretRefSetupPlan({ + productName: params.productName, + providerAlias, + providerConfig, + providerSecrets: await collectProviderSecrets(options), + configTargetSecrets: parseConfigTargetMappings(options.target), + }); + if (plan.targets.length === 0) { + throw new Error( + "No SecretRef targets selected. Pass --openai-id, --anthropic-id, --openrouter-id, --provider-key, or --target.", + ); + } + const requestedPlanPath = normalizeOptionalString(options.planOut) ?? params.defaultPlanPath(); + const absolutePlanPath = path.resolve(requestedPlanPath); + const planDirectory = await resolveTrustedPlanDirectoryPath(path.dirname(absolutePlanPath)); + // Use the verified canonical parent for both the write and copy-paste commands. + const planPath = path.join(planDirectory, path.basename(absolutePlanPath)); + const applyCommands = renderSecretRefApplyCommands(planPath); + await writeSecretPlanFile({ + planPath, + content: `${JSON.stringify(plan, null, 2)}\n`, + }); + writeSecretRefCliLine(`Plan written to ${planPath}`); + writeSecretRefCliLine(`Targets: ${plan.targets.length}`); + writeSecretRefCliLine(); + writeSecretRefCliLine("Next steps:"); + for (const command of params.beforeApplyCommands ?? []) { + writeSecretRefCliLine(` ${command}`); + } + for (const command of applyCommands) { + writeSecretRefCliLine(` ${command}`); + } + writeSecretRefCliLine(" openclaw secrets audit --check --allow-exec"); + writeSecretRefCliLine(" openclaw secrets reload"); + }; + + const registerSetupCommand = (command: SecretRefSetupCommand): void => { + command + .command("setup") + .description(`Create a ${params.productName} SecretRef setup plan`) + .option("--plan-out ", "Write the generated secrets apply plan to a path") + .option( + "--provider-alias ", + "Secret provider alias to configure", + params.defaultProviderAlias, + ) + .option("--openai-id ", `${params.secretIdLabel} for models.providers.openai.apiKey`) + .option( + "--anthropic-id ", + `${params.secretIdLabel} for models.providers.anthropic.apiKey`, + ) + .option( + "--openrouter-id ", + `${params.secretIdLabel} for models.providers.openrouter.apiKey`, + ) + .option( + "--provider-key ", + `${params.secretIdLabel} for any models.providers..apiKey target`, + (value: string, previous: string[] = []) => [...previous, value], + [], + ) + .option( + "--target ", + `${params.secretIdLabel} for any known SecretRef target path`, + (value: string, previous: string[] = []) => [...previous, value], + [], + ) + .action((options: SecretRefSetupOptions) => runSetup(options)); + }; + + return { inspectProvider, registerSetupCommand }; +} + export { coerceSecretRef } from "../config/types.secrets.js"; export type { SecretInput, SecretRef } from "../config/types.secrets.js"; export { resolveSecretRefValues } from "../secrets/resolve.js";