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
This commit is contained in:
Peter Steinberger
2026-08-03 02:56:43 -07:00
committed by GitHub
parent 188957a18a
commit deb682abfe
37 changed files with 1339 additions and 2112 deletions
-1
View File
@@ -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
@@ -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
+2 -2
View File
@@ -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 |
+2 -14
View File
@@ -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";
-34
View File
@@ -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);
}
+3 -25
View File
@@ -197,7 +197,6 @@ describe("canvas host", () => {
log: (..._args: Parameters<typeof console.log>) => {},
};
let createCanvasHostHandler: typeof import("./server.js").createCanvasHostHandler;
let startCanvasHost: typeof import("./server.js").startCanvasHost;
let WebSocketServerClass: typeof import("ws").WebSocketServer;
let watcherState: ReturnType<typeof createMockWatcherState>;
let fixtureRoot = "";
@@ -226,7 +225,6 @@ describe("canvas host", () => {
});
beforeAll(async () => {
vi.doUnmock("undici");
vi.doMock("node:timers", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:timers")>();
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<typeof import("ws")>("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"), "<html><body>v1</body></html>", "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();
}
});
+3 -123
View File
@@ -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<void>;
};
/** 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<CanvasHostServer> {
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<void>((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<void>((resolve, reject) => {
server.close((err) => (err ? reject(err) : resolve()));
});
},
};
}
@@ -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<string, string | number
return redactedAttributes;
}
export function lowCardinalityAttr(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 securityTargetNameAttr(value: string | undefined, fallback = "unknown"): string {
if (!value) {
return fallback;
@@ -50,23 +38,6 @@ function securityTargetNameAttr(value: string | undefined, fallback = "unknown")
return SECURITY_TARGET_NAME_VALUE_RE.test(redacted) ? redacted : fallback;
}
export function lowCardinalityQueueLaneAttr(
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;
}
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) {
@@ -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;
@@ -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";
}
@@ -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;
}
@@ -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<string, string> = {
"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<string, string | number | boolean> = {
"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;
@@ -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) {
@@ -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<DiagnosticEventPayload, { type: "queue.lane.enqueue" }>,
) => {
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<DiagnosticEventPayload, { type: "queue.lane.dequeue" }>,
) => {
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<DiagnosticEventPayload, { type: "session.turn.created" }>,
) => {
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<string, string> => ({
"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<DiagnosticEventPayload, { type: "tool.loop" }>,
): Record<string, string | number> => ({
"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);
@@ -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<string, string | number | boolean> => ({
"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<DiagnosticEventPayload, { type: "skill.used" }>,
): Record<string, string | number | boolean> => ({
"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<string, string | number | boolean> = {
...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<DiagnosticEventPayload, { type: "payload.large" }>) => {
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<string, string | number> = {
"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") }
: {}),
});
};
@@ -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<string, string> = {
"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<DiagnosticEventPayload, { type: "webhook.processed" }>,
) => {
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<DiagnosticEventPayload, { type: "webhook.error" }>) => {
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<DiagnosticEventPayload, { type: "message.queued" }>,
) => {
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<DiagnosticEventPayload, { type: "message.received" }>,
) => {
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<DiagnosticEventPayload, { type: "message.dispatch.completed" }>,
) => {
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<string, string | number> = { ...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<string, string> => ({
"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) {
+95 -113
View File
@@ -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<DiagnosticEventPayload, { type: "model.failover" }>,
): 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<DiagnosticEventPayload, { type: "tool.execution.blocked" }>,
): 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<DiagnosticEventPayload, { type: "session.stuck" }>,
): 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<DiagnosticEventPayload, { type: "diagnostic.liveness.warning" }>,
): 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<DiagnosticEventPayload, { type: "talk.event" }>): 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<DiagnosticEventPayload, { type: "model.usage" }>,
) {
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,
});
-2
View File
@@ -1,5 +1,3 @@
export function resolveTrustedOnePasswordDirectoryPath(targetPath: string): Promise<string>;
export function resolveTrustedOnePasswordCli(options?: {
configuredPath?: string;
pathEnv?: string;
@@ -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();
+169 -396
View File
@@ -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<string, unknown>;
targets: Array<Record<string, unknown>>;
};
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<string, unknown>;
}
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<string> {
const output = captureStdout();
await createProgram().parseAsync(
["onepassword", "secretref", "setup", "--plan-out", planPath, ...args],
{ from: "user" },
);
return output();
}
async function createSetupPlan(args: string[]): Promise<OnePasswordPlan> {
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 () => {
+36 -414
View File
@@ -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<TOptions>(fn: (options: TOptions) => void | Promise<void>): 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<typeof pluginSecretRefSetup.buildPlan>;
type CommandLike = Parameters<typeof onePasswordSecretRefSetupCli.registerSetupCommand>[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<void>;
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 <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 <model-provider-id>=<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 <openclaw-config-path>=<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<string>();
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<string | undefined> {
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<ProviderSecretMapping[]> {
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<void> {
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<string> {
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<void> {
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 <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 <path>", "Write the generated secrets apply plan to a path")
.option(
"--provider-alias <alias>",
"Secret provider alias to configure",
ONEPASSWORD_PROVIDER_ALIAS,
)
.option("--openai-id <id>", "1Password SecretRef id for models.providers.openai.apiKey")
.option("--anthropic-id <id>", "1Password SecretRef id for models.providers.anthropic.apiKey")
.option("--openrouter-id <id>", "1Password SecretRef id for models.providers.openrouter.apiKey")
.option(
"--provider-key <provider=id>",
"1Password SecretRef id for any models.providers.<provider>.apiKey target",
(value: string, previous: string[] = []) => [...previous, value],
[],
)
.option(
"--target <path=id>",
"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 };
+12 -2
View File
@@ -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",
},
+2 -2
View File
@@ -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<Bootstrap>("/api/bootstrap"),
getJson<Snapshot>("/api/state"),
getJson<QaBusStateSnapshot>("/api/state"),
getJson<ReportEnvelope>("/api/report"),
getJson<OutcomesEnvelope>("/api/outcomes"),
]);
@@ -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<Conversation, "accountId" | "id" | "kind">;
type ConversationIdentity = Pick<QaBusSnapshotConversation, "accountId" | "id" | "kind">;
// 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({
+14 -11
View File
@@ -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 {
</div>`;
}
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 `
<div class="event-row">
<span class="event-kind">${esc(e.kind)}</span>
@@ -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<UiState["snapshot"]> = {
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",
},
+6 -61
View File
@@ -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<Conversation, "accountId">;
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;
+33 -1
View File
@@ -40,12 +40,13 @@ async function createSetupPlan(args: string[]): Promise<VaultPlan> {
}
}
async function runSetup(planPath: string, args: string[]): Promise<void> {
async function runSetup(planPath: string, args: string[]): Promise<string> {
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: {
+30 -298
View File
@@ -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<TOptions>(fn: (options: TOptions) => void | Promise<void>): 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<typeof vaultSecretRefSetupCli.registerSetupCommand>[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 <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 <model-provider-id>=<vault-secret-id>.`,
);
}
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 <openclaw-config-path>=<vault-secret-id>.`,
);
}
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<string>();
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<string | undefined> {
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<ProviderSecretMapping[]> {
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<void> {
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<void> {
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 <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 <path>", "Write the generated secrets apply plan to a path")
.option("--provider-alias <alias>", "Secret provider alias to configure", VAULT_PROVIDER_ALIAS)
.option("--openai-id <id>", "Vault secret id for models.providers.openai.apiKey")
.option("--anthropic-id <id>", "Vault secret id for models.providers.anthropic.apiKey")
.option("--openrouter-id <id>", "Vault secret id for models.providers.openrouter.apiKey")
.option(
"--provider-key <provider=id>",
"Vault secret id for any models.providers.<provider>.apiKey target",
(value: string, previous: string[] = []) => [...previous, value],
[],
)
.option(
"--target <path=id>",
"Vault secret id for any known SecretRef target path",
(value: string, previous: string[] = []) => [...previous, value],
[],
)
.action((options: SetupOptions) => runSetup(options));
vaultSecretRefSetupCli.registerSetupCommand(vault);
}
+73 -9
View File
@@ -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<unknown>;
};
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<unknown>;
};
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 () => {
+157 -437
View File
@@ -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<Awaited<ReturnType<typeof resolveCallMessageRequest>>, { 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. */
@@ -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<VoiceCallRuntime>) {
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 };
},
};
}
@@ -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);
@@ -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",
+6 -2
View File
@@ -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(
+31
View File
@@ -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,
+346
View File
@@ -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<TOptions>(fn: (options: TOptions) => void | Promise<void>): 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 <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 <model-provider-id>=<${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 <openclaw-config-path>=<${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<string | undefined> => {
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<SecretRefProviderMapping[]> => {
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<string>();
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<void> => {
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 <path>", "Write the generated secrets apply plan to a path")
.option(
"--provider-alias <alias>",
"Secret provider alias to configure",
params.defaultProviderAlias,
)
.option("--openai-id <id>", `${params.secretIdLabel} for models.providers.openai.apiKey`)
.option(
"--anthropic-id <id>",
`${params.secretIdLabel} for models.providers.anthropic.apiKey`,
)
.option(
"--openrouter-id <id>",
`${params.secretIdLabel} for models.providers.openrouter.apiKey`,
)
.option(
"--provider-key <provider=id>",
`${params.secretIdLabel} for any models.providers.<provider>.apiKey target`,
(value: string, previous: string[] = []) => [...previous, value],
[],
)
.option(
"--target <path=id>",
`${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";