mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
188957a18a
commit
deb682abfe
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user