merge: refresh Daytona hydration branch from main

* origin/main: (57 commits)
  fix(media): fall back when providers return empty output (#118660)
  fix(nextcloud-talk): allow bounded parallel room deliveries (#118692)
  test(state): reuse current agent database fixture (#118677)
  fix(scripts): bound check-file-utils git lookup (#111582)
  fix(slack): scope interactive conversation bindings safely (#118662)
  fix(agents): avoid false compaction after mid-turn precheck (#117963)
  fix(ui): show original filename on chat history attachment cards instead of managed UUID suffix (#118628)
  fix(whatsapp): preserve terminal retry exhaustion lifecycle (#118659)
  fix(doctor): audit every agent workspace (#111840)
  fix(release): skip unavailable Buzz on frozen candidates (#118670)
  fix(ui): surface onboarding and pairing clipboard failures (#118651)
  fix(config): stop plugin schemas rejecting the channel key core writes (#117992)
  test(memory): isolate wiki plugin fixtures (#118654)
  fix(line): clear default access token when removing account (#118055)
  feat: enable rich setup controls in custodian chat (#114631)
  test(ui): reuse responsive browser fixtures (#118655)
  chore(tui): stabilize queued-turn admission in Gateway PTY test (#118638)
  fix(xai): classify exhausted credits as billing (#118615)
  fix(otel): fail closed when configured TLS material is invalid (#118648)
  fix(cli): preserve gateway request errors in health JSON (#118645)
  ...
This commit is contained in:
Vincent Koc
2026-08-03 07:17:56 -07:00
417 changed files with 15844 additions and 8003 deletions
@@ -1819,8 +1819,15 @@ jobs:
sut_tmp="${temp_root}/sut-tmp"
install -d -o "$SUT_UID" -g "$SUT_GID" -m 0700 "$sut_tmp"
export TMPDIR="$sut_tmp"
workspace="${temp_root}/workspace"
[[ -d "$workspace" && ! -L "$workspace" ]]
[[ "$(realpath -e "$workspace")" == "$workspace" ]]
# The trusted scenario host creates fixtures while the isolated SUT reads
# and mutates the workspace. Keep every other runtime directory SUT-private.
chown -R "$RUNNER_UID:$SUT_GID" "$workspace"
chmod -R u=rwX,g=rwX,o= "$workspace"
find "$workspace" -type d -exec chmod g+s {} +
for path in \
"$temp_root/workspace" \
"${OPENCLAW_HOME:?}" \
"${OPENCLAW_STATE_DIR:?}" \
"${XDG_CACHE_HOME:?}" \
@@ -588,7 +588,7 @@ jobs:
if-no-files-found: error
- name: Require requested Buzz QA runner
if: always() && steps.resolve_buzz.outcome == 'success' && steps.resolve_buzz.outputs.available != 'true'
if: always() && inputs.expected_sha == '' && steps.resolve_buzz.outcome == 'success' && steps.resolve_buzz.outputs.available != 'true'
shell: bash
run: |
echo "::error::The selected ref does not declare the requested Buzz QA runner."
+1
View File
@@ -59,6 +59,7 @@ Docs: https://docs.openclaw.ai
- **Gateway TTS playback:** add an operator-scoped `tts.speak` RPC that returns configured-provider speech as inline whole-clip audio for remote clients. (#100708, #100770)
- **Workboard dispatch cap:** add a request-scoped `--max-starts` override while preserving the default cap, sequential starts, and one-card-per-owner guard. (#100174) Thanks @souvikDevloper.
- **Plugin install provenance warnings:** require explicit `--force` acknowledgement for arbitrary executable plugin sources in CLI and chat installs, keep trusted ClawHub, bundled, official-catalog, and tracked-update flows frictionless, and restrict Crestodian installs to trusted sources. (#102197) Thanks @jesse-merhi.
- **Custodian rich setup controls:** render the Gateway's sanitized wizard steps as native selects, multiselects, text fields, and masked secret inputs while preserving text-only chat compatibility. (#114631) Thanks @jesse-merhi.
### Fixes
@@ -9297,6 +9297,7 @@ public struct ConfigSchemaLookupResult: Codable, Sendable {
public struct SystemAgentChatParams: Codable, Sendable {
public let sessionid: String
public let message: String?
public let wizardanswer: [String: AnyCodable]?
public let welcomevariant: AnyCodable?
public let reset: Bool?
public let context: [String: AnyCodable]?
@@ -9305,6 +9306,7 @@ public struct SystemAgentChatParams: Codable, Sendable {
public init(
sessionid: String,
message: String? = nil,
wizardanswer: [String: AnyCodable]? = nil,
welcomevariant: AnyCodable? = nil,
reset: Bool? = nil,
context: [String: AnyCodable]? = nil,
@@ -9312,6 +9314,7 @@ public struct SystemAgentChatParams: Codable, Sendable {
{
self.sessionid = sessionid
self.message = message
self.wizardanswer = wizardanswer
self.welcomevariant = welcomevariant
self.reset = reset
self.context = context
@@ -9321,6 +9324,7 @@ public struct SystemAgentChatParams: Codable, Sendable {
private enum CodingKeys: String, CodingKey {
case sessionid = "sessionId"
case message
case wizardanswer = "wizardAnswer"
case welcomevariant = "welcomeVariant"
case reset
case context
@@ -9339,6 +9343,7 @@ public struct SystemAgentChatResult: Codable, Sendable {
public let needsapproval: Bool?
public let proposalid: String?
public let question: [String: AnyCodable]?
public let step: WizardStep?
public init(
sessionid: String,
@@ -9350,7 +9355,8 @@ public struct SystemAgentChatResult: Codable, Sendable {
agentid: String? = nil,
needsapproval: Bool? = nil,
proposalid: String? = nil,
question: [String: AnyCodable]? = nil)
question: [String: AnyCodable]? = nil,
step: WizardStep? = nil)
{
self.sessionid = sessionid
self.reply = reply
@@ -9362,6 +9368,7 @@ public struct SystemAgentChatResult: Codable, Sendable {
self.needsapproval = needsapproval
self.proposalid = proposalid
self.question = question
self.step = step
}
private enum CodingKeys: String, CodingKey {
@@ -9375,6 +9382,7 @@ public struct SystemAgentChatResult: Codable, Sendable {
case needsapproval = "needsApproval"
case proposalid = "proposalId"
case question
case step
}
}
-5
View File
@@ -620,11 +620,6 @@ const config = {
entry: ["src/*.ts!", "src/host/embeddings-worker-child.ts!"],
project: ["src/**/*.ts!"],
},
"packages/speech-core": {
entry: ["runtime-api.ts!", "speaker.ts!", "voice-models.ts!"],
project: ["**/*.ts!"],
ignoreDependencies: ["openclaw"],
},
"packages/*": {
entry: ["index.js!", "scripts/postinstall.js!"],
project: ["index.js!", "scripts/**/*.js!"],
-2
View File
@@ -277,7 +277,6 @@ extensions/telegram/src/thread-bindings.ts
extensions/telegram/src/webhook.test.ts
extensions/tlon/src/monitor/index.ts
extensions/voice-call/index.test.ts
extensions/voice-call/index.ts
extensions/voice-call/src/cli.ts
extensions/voice-call/src/media-stream.ts
extensions/voice-call/src/webhook.test.ts
@@ -320,7 +319,6 @@ packages/markdown-core/src/ir.ts
packages/memory-host-sdk/src/host/session-files.ts
packages/sdk/src/client.ts
packages/sdk/src/index.test.ts
packages/speech-core/src/tts.test.ts
packages/tool-call-repair/src/stream-normalizer.test.ts
packages/tool-call-repair/src/stream-normalizer.ts
src/acp/control-plane/manager.test.ts
@@ -6,7 +6,7 @@ e5e67ddf3cab38fcbf9220bc3160715897e2709d9a9ff6ff36f1ecc9453c2367 module/agent-c
74daa746deb548379d3f0d6eac3c4d082df1034c4360cc03bf51fee0f10a2e4d module/agent-harness
95a907e1c33305b9473be64cc8d723e1a12b909eda86b15b94cee91879fb6a89 module/agent-harness-runtime
5168648cd946abad8a92822889f13ceacc87ed502314a66190d0b1eb8ebe76ea module/agent-media-payload
fd54eb654443d646d6430d2be99d1f25c32701f1c45aaa870ffe74aefc7d7f00 module/agent-runtime
6ee8bb70cd7b8a5a976ee84cd0c6e632dbfa5e4a047f616cdc00fa5b27879b27 module/agent-runtime
56b6d5fb6af3d95af1200065aca2e7d4f59e5fa59740505fe6ff433077ef6646 module/allow-from
55cea5390d68839ca7768b4a0cc570b17b65fa0fa3bc4d76130ef0f16cb79ede module/allowlist-config-edit
7ddd81bd5f55de9adf64bf4d92d012f24b37b6da0a72805a3a220d8feff24ca3 module/approval-auth-runtime
@@ -29,7 +29,7 @@ c0f910ebfa3dbf283145fb1e3b9c016d03e853ef13e70402b09f3b9d9c2f4ab0 module/channel
9a5aaf650f9242523bb57bdc2556c323ab64e55aa11e25e2685b73b23ee12534 module/channel-dm-policy
ba41c40956d6b4565605fa38c2d12f4b8471a0f8afe842798716b9032ee4d74a module/channel-entry-contract
982f29a18e07228e3da82cae67d06ff38249592a29c2fd28f01f0d2016ff80d9 module/channel-feedback
d645d24bcb7a5f68cc46c692ad0d1fbd19be0a99996f31e9479ce9cffce301c1 module/channel-inbound
1d13edd07a8ae8e21ece6c542cb0cde22e5b6e11d3bb9b2bfaa5ebd2a5239bf8 module/channel-inbound
76bb7f531f3702c801e8fe7479e9e499f601fb361a4303afdcb45fc0da440e4b module/channel-inbound-debounce
df567ce2f4a4ba8a0937f825c46e83763412a724e36b72ce727dc4203c7dd134 module/channel-ingress-runtime
0e6efb79730fae59bb549ad00d9af2848c139b4bb1762e83b66886284e1c1421 module/channel-lifecycle
@@ -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
@@ -107,7 +107,7 @@ aa2a56b4448c8ebdec9d06aac95d809995f533093d42fa32cd75e1d852967245 module/questio
2e09c3181e79e157ed5366b144d116ef8cc06023256ace3fa59b35c43cab513a module/reply-chunking
7994045066b29af1fc6b36ae32068f2a6f277195971af84701cb739cc23d0579 module/reply-dispatch-runtime
ac2b199e95c5c8b1e2a65e62bd41d1b6322e531bca294ef4979a297a12640bce module/reply-history
f394fe4d5a7ed9e4d574063ae44e8d6af85c9a0e7d8b329f750ca16b0664325f module/reply-payload
ad69a4a6970cfac86f9379efb927beac898217946d025c3aea55e6f399da08bb module/reply-payload
1f899eb54013f268d6698ce8e6943289ea0e872747e8e868259106829518db86 module/reply-runtime
b4043b356372f6af64ee3c26e4d6a6d623b817e4d95346358dc0e64a3b61d1e0 module/root-walk
97fc4ed1ac6e62b7af95b4352cee3893252d8b691fef71a000c2602b3128541a module/routing
@@ -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
+8
View File
@@ -635,6 +635,14 @@ For an end-to-end authoring guide, see
artifacts still use `listActiveMemoryPublicArtifacts(...)` from the retained
`openclaw/plugin-sdk/memory-host-core` facade until a focused public consumer
API exists; they must not reach into another plugin's private layout.
- A memory runtime that can return session-transcript hits should implement
`runtime.authorizeSearchHits(...)`. The host calls this hook before raw search
hits reach caller-visible surfaces and supplies the requesting agent, session
key, and sandbox state. Return only hits the requester may observe. If the hook
is absent, OpenClaw fails closed by withholding session-source hits while
retaining ordinary memory hits. Keep transcript identity and visibility
policy in the owning memory plugin; callers must not infer authorization from
paths or duplicate plugin-specific rules.
- `MemoryFlushPlan.model` can pin the flush turn to an exact `provider/model`
reference, such as `ollama/qwen3:8b`, without inheriting the active fallback
chain.
+2 -2
View File
@@ -202,7 +202,7 @@ usage endpoint failed or returned no usable usage data.
| `plugin-sdk/channel-secret-runtime` | Deprecated broad secret-contract surface (`collectSimpleChannelFieldAssignments`, `getChannelSurface`, `pushAssignment`, secret target types); prefer the focused subpaths below |
| `plugin-sdk/channel-secret-basic-runtime` | Narrow secret-contract exports and target-registry builders for non-TTS channel/plugin secret surfaces |
| `plugin-sdk/channel-secret-tts-runtime` | Private-local after July 2026; Narrow nested channel TTS secret assignment helpers |
| `plugin-sdk/secret-ref-runtime` | Narrow SecretRef typing, resolution, and shared setup-plan construction for plugin-owned secret providers |
| `plugin-sdk/secret-ref-runtime` | Narrow SecretRef typing, resolution, setup-plan construction, and setup CLI scaffolding for plugin-owned secret providers |
| `plugin-sdk/security-runtime` | Deprecated broad barrel for trust, DM gating, root-bounded file/path helpers including create-only writes, sync/async atomic file replacement, sibling temp writes, cross-device move fallback, private file-store helpers, symlink-parent guards, external-content, sensitive text redaction, constant-time secret comparison, and secret-collection helpers; prefer focused security/SSRF/secret subpaths |
| `plugin-sdk/ssrf-policy` | Host allowlist and private-network SSRF policy helpers |
| `plugin-sdk/ssrf-dispatcher` | Private-local after July 2026; Narrow pinned-dispatcher helpers without the broad infra runtime surface |
@@ -304,7 +304,7 @@ Use `isLoopbackHost(host)` when a plugin must accept only the local machine. It
| `plugin-sdk/exec-approvals-runtime` | Private-local after July 2026; Exec approval policy file helpers without the broad infra-runtime barrel |
| `plugin-sdk/infra-runtime` | Deprecated compatibility shim; use the focused runtime subpaths above |
| `plugin-sdk/collection-runtime` | Small bounded cache helpers |
| `plugin-sdk/diagnostic-runtime` | Diagnostic flag, event, and trace-context helpers |
| `plugin-sdk/diagnostic-runtime` | Diagnostic flag, event, trace-context, and low-cardinality dimension normalization helpers |
| `plugin-sdk/error-runtime` | Error graph, formatting, unknown-value coercion, shared error classification helpers, `PlatformMessageNotDispatchedError`, `isApprovalNotFoundError` |
| `plugin-sdk/fetch-runtime` | Private-local after July 2026; Wrapped fetch, proxy, EnvHttpProxyAgent option, and pinned lookup helpers |
| `plugin-sdk/runtime-fetch` | Private-local after July 2026; Dispatcher-aware runtime fetch without proxy/guarded-fetch imports |
+1 -1
View File
@@ -264,7 +264,7 @@ Current runtime behavior:
- Voice Call exposes the shared `openclaw_agent_consult` realtime tool by default. The realtime model can call it when the caller asks for deeper reasoning, current information, or normal OpenClaw tools.
- `realtime.consultPolicy` optionally adds guidance for when the realtime model should call `openclaw_agent_consult`.
- `realtime.agentContext.enabled` is default-off. When enabled, Voice Call injects a bounded agent identity and selected workspace-file capsule into the realtime provider instructions at session setup.
- `realtime.fastContext.enabled` is default-off. When enabled, Voice Call first searches indexed memory/session context for the consult question and returns those snippets to the realtime model within `realtime.fastContext.timeoutMs` before falling back to the full consult agent only if `realtime.fastContext.fallbackToConsult` is true.
- `realtime.fastContext.enabled` is default-off. When enabled, Voice Call first searches indexed memory/session context for the consult question and returns authorized snippets to the realtime model within `realtime.fastContext.timeoutMs` before falling back to the full consult agent only if `realtime.fastContext.fallbackToConsult` is true. The active memory plugin authorizes session-transcript hits; plugins without that capability fail closed for session hits while ordinary memory hits remain available.
- If `realtime.provider` points at an unregistered provider, or no realtime voice provider is registered at all, Voice Call logs a warning and skips realtime media instead of failing the whole plugin.
- `inboundPolicy` must not be `"disabled"` when `realtime.enabled` is true; `validateProviderConfig` rejects that combination.
- Consult session keys reuse the stored call session when available, then fall back to the configured `sessionScope` (`per-phone` by default, or `per-call` for isolated calls).
+3 -1
View File
@@ -115,7 +115,8 @@ Session controls:
- `/trace <on|off>`
- `/reasoning <on|off|stream>`
- `/usage <off|tokens|full|reset>` (`reset`/`inherit`/`clear`/`default` clears the session override)
- `/goal [status] | /goal start <objective> | /goal edit <objective> | /goal pause|resume|complete|block|clear`
- `/goal <objective> | /goal [status] | /goal start <objective> | /goal edit <objective> | /goal pause|resume|complete|block|clear`
- `/btw <side question>` (alias: `/side`; asks without changing future session context)
- `/elevated <on|off|ask|full>` (alias: `/elev`)
- `/activation <mention|always>`
- `/queue <steer|followup|collect|interrupt> [debounce:<duration>] [cap:<n>] [drop:<summarize|old|new>]`
@@ -126,6 +127,7 @@ Session lifecycle:
- `/new` (spawn a fresh, isolated session under a new key; does not affect other TUI clients on the old session)
- `/reset` (reset the current session key in place)
- `/abort` (abort the active run)
- `/stop` (stop the active or queued run)
- `/settings`
- `/exit` (or `/quit`)
+2 -14
View File
@@ -1,4 +1,4 @@
/** Runtime API exports for Canvas plugin host, CLI, and capability helpers. */
/** Runtime API exports for Canvas plugin host and CLI helpers. */
export {
canvasConfigSchema,
isCanvasHostEnabled,
@@ -14,23 +14,11 @@ export {
CANVAS_WS_PATH,
handleA2uiHttpRequest,
} from "./src/host/a2ui.js";
export {
createCanvasHostHandler,
startCanvasHost,
type CanvasHostHandler,
type CanvasHostServer,
} from "./src/host/server.js";
export { createCanvasHostHandler, type CanvasHostHandler } from "./src/host/server.js";
export {
registerNodesCanvasCommands,
type CanvasCliDependencies,
type CanvasNodesRpcOpts,
} from "./src/cli.js";
export { canvasSnapshotTempPath, parseCanvasSnapshotPayload } from "./src/cli-helpers.js";
export {
buildCanvasScopedHostUrl,
CANVAS_CAPABILITY_PATH_PREFIX,
CANVAS_CAPABILITY_TTL_MS,
mintCanvasCapabilityToken,
normalizeCanvasScopedUrl,
} from "./src/capability.js";
export { resolveCanvasHostUrl } from "./src/host-url.js";
-34
View File
@@ -1,34 +0,0 @@
/**
* Canvas capability-token helpers for scoped hosted node URLs.
*/
import {
buildPluginNodeCapabilityScopedHostUrl,
DEFAULT_PLUGIN_NODE_CAPABILITY_TTL_MS,
mintPluginNodeCapabilityToken,
normalizePluginNodeCapabilityScopedUrl,
PLUGIN_NODE_CAPABILITY_PATH_PREFIX,
type NormalizedPluginNodeCapabilityUrl,
} from "openclaw/plugin-sdk/gateway-runtime";
/** Path prefix used for Canvas capability-scoped gateway routes. */
export const CANVAS_CAPABILITY_PATH_PREFIX = PLUGIN_NODE_CAPABILITY_PATH_PREFIX;
/** Default Canvas capability token TTL in milliseconds. */
export const CANVAS_CAPABILITY_TTL_MS = DEFAULT_PLUGIN_NODE_CAPABILITY_TTL_MS;
/** Normalized Canvas capability-scoped URL shape. */
type NormalizedCanvasScopedUrl = NormalizedPluginNodeCapabilityUrl;
/** Creates a new opaque Canvas capability token. */
export function mintCanvasCapabilityToken(): string {
return mintPluginNodeCapabilityToken();
}
/** Builds a Canvas host URL scoped by the supplied capability token. */
export function buildCanvasScopedHostUrl(baseUrl: string, capability: string): string | undefined {
return buildPluginNodeCapabilityScopedHostUrl(baseUrl, capability);
}
/** Normalizes and validates a Canvas capability-scoped URL. */
export function normalizeCanvasScopedUrl(rawUrl: string): NormalizedCanvasScopedUrl {
return normalizePluginNodeCapabilityScopedUrl(rawUrl);
}
+3 -25
View File
@@ -197,7 +197,6 @@ describe("canvas host", () => {
log: (..._args: Parameters<typeof console.log>) => {},
};
let createCanvasHostHandler: typeof import("./server.js").createCanvasHostHandler;
let startCanvasHost: typeof import("./server.js").startCanvasHost;
let WebSocketServerClass: typeof import("ws").WebSocketServer;
let watcherState: ReturnType<typeof createMockWatcherState>;
let fixtureRoot = "";
@@ -226,7 +225,6 @@ describe("canvas host", () => {
});
beforeAll(async () => {
vi.doUnmock("undici");
vi.doMock("node:timers", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:timers")>();
return {
@@ -241,7 +239,7 @@ describe("canvas host", () => {
});
vi.resetModules();
const serverModule = await import("./server.js");
({ createCanvasHostHandler, startCanvasHost } = serverModule);
({ createCanvasHostHandler } = serverModule);
const wsModule = await vi.importActual<typeof import("ws")>("ws");
WebSocketServerClass = wsModule.WebSocketServer;
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-canvas-fixtures-"));
@@ -502,15 +500,12 @@ describe("canvas host", () => {
}
});
it("serves canvas content from the mounted base path and reuses handlers without double close", async () => {
it("serves canvas content from the mounted base path", async () => {
const dir = await createCaseDir();
await fs.writeFile(path.join(dir, "index.html"), "<html><body>v1</body></html>", "utf8");
const handler = await createTestCanvasHostHandler(dir);
const originalClose = handler.close;
const closeSpy = vi.fn(async () => originalClose());
try {
const response = await captureHandlerResponse(handler, `${CANVAS_HOST_PATH}/`);
expect(response.status).toBe(200);
@@ -523,25 +518,8 @@ describe("canvas host", () => {
const miss = await captureHandlerResponse(handler, "/");
expect(miss.handled).toBe(false);
handler.close = closeSpy;
const hosted = await startCanvasHost({
runtime: quietRuntime,
handler,
ownsHandler: false,
port: 0,
listenHost: "127.0.0.1",
allowInTests: true,
});
try {
expect(hosted.port).toBeGreaterThan(0);
} finally {
await hosted.close();
expect(closeSpy).not.toHaveBeenCalled();
}
} finally {
await originalClose();
await handler.close();
}
});
+3 -123
View File
@@ -2,7 +2,7 @@
* Canvas host server and static-file/live-reload handler implementation.
*/
import fs from "node:fs/promises";
import http, { type IncomingMessage, type Server, type ServerResponse } from "node:http";
import type { IncomingMessage, ServerResponse } from "node:http";
import type { Socket } from "node:net";
import path from "node:path";
import type { Duplex } from "node:stream";
@@ -14,47 +14,14 @@ import chokidar from "chokidar";
import { detectMime } from "openclaw/plugin-sdk/media-mime";
import { isTruthyEnvValue, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
import {
lowercasePreservingWhitespace,
normalizeOptionalString,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { lowercasePreservingWhitespace } from "openclaw/plugin-sdk/string-coerce-runtime";
import { ensureDir, resolveUserPath } from "openclaw/plugin-sdk/text-utility-runtime";
import { WebSocketServer } from "ws";
import {
CANVAS_HOST_PATH,
CANVAS_WS_PATH,
injectCanvasRuntime,
isA2uiPath,
} from "./a2ui-shared.js";
import { CANVAS_HOST_PATH, CANVAS_WS_PATH, injectCanvasRuntime } from "./a2ui-shared.js";
import { normalizeUrlPath, resolveFileWithinRoot } from "./file-resolver.js";
const CANVAS_LIVE_RELOAD_MAX_INBOUND_MESSAGE_BYTES = 64 * 1024;
/** Options for Canvas host creation. */
type CanvasHostOpts = {
runtime: RuntimeEnv;
rootDir?: string;
port?: number;
listenHost?: string;
allowInTests?: boolean;
liveReload?: boolean;
watchFactory?: typeof chokidar.watch;
webSocketServerClass?: typeof WebSocketServer;
};
/** Options for starting a standalone Canvas host HTTP server. */
type CanvasHostServerOpts = CanvasHostOpts & {
handler?: CanvasHostHandler;
ownsHandler?: boolean;
};
/** Running Canvas host server handle. */
export type CanvasHostServer = {
port: number;
rootDir: string;
close: () => Promise<void>;
};
/** Options for creating only the Canvas host request handler. */
type CanvasHostHandlerOpts = {
runtime: RuntimeEnv;
@@ -435,90 +402,3 @@ export async function createCanvasHostHandler(
},
};
}
/** Starts a standalone loopback Canvas host HTTP server. */
export async function startCanvasHost(opts: CanvasHostServerOpts): Promise<CanvasHostServer> {
if (isDisabledByEnv() && opts.allowInTests !== true) {
return { port: 0, rootDir: "", close: async () => {} };
}
const handler =
opts.handler ??
(await createCanvasHostHandler({
runtime: opts.runtime,
rootDir: opts.rootDir,
basePath: CANVAS_HOST_PATH,
allowInTests: opts.allowInTests,
liveReload: opts.liveReload,
watchFactory: opts.watchFactory,
webSocketServerClass: opts.webSocketServerClass,
}));
const ownsHandler = opts.ownsHandler ?? opts.handler === undefined;
const bindHost = normalizeOptionalString(opts.listenHost) || "127.0.0.1";
const server: Server = http.createServer((req, res) => {
if (lowercasePreservingWhitespace(req.headers.upgrade ?? "") === "websocket") {
return;
}
void (async () => {
if (req.url && isA2uiPath(new URL(req.url, "http://localhost").pathname)) {
const { handleA2uiHttpRequest } = await import("./a2ui.js");
if (await handleA2uiHttpRequest(req, res)) {
return;
}
}
if (await handler.handleHttpRequest(req, res)) {
return;
}
res.statusCode = 404;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("Not Found");
})().catch((err: unknown) => {
opts.runtime.error(`Canvas host request failed: ${String(err)}`);
res.statusCode = 500;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("error");
});
});
server.on("upgrade", (req, socket, head) => {
if (handler.handleUpgrade(req, socket, head)) {
return;
}
socket.destroy();
});
const listenPort =
typeof opts.port === "number" && Number.isFinite(opts.port) && opts.port > 0 ? opts.port : 0;
await new Promise<void>((resolve, reject) => {
const onError = (err: NodeJS.ErrnoException) => {
server.off("listening", onListening);
reject(err);
};
const onListening = () => {
server.off("error", onError);
resolve();
};
server.once("error", onError);
server.once("listening", onListening);
server.listen(listenPort, bindHost);
});
const addr = server.address();
const boundPort = typeof addr === "object" && addr ? addr.port : 0;
opts.runtime.log(
`canvas host listening on http://${bindHost}:${boundPort} (root ${handler.rootDir})`,
);
return {
port: boundPort,
rootDir: handler.rootDir,
close: async () => {
if (ownsHandler) {
await handler.close();
}
await new Promise<void>((resolve, reject) => {
server.close((err) => (err ? reject(err) : resolve()));
});
},
};
}
@@ -444,6 +444,51 @@ describe("createCodexDynamicToolBridge", () => {
expect(onAgentToolResult).toHaveBeenCalledWith(
expect.objectContaining({ toolName: "sessions_spawn", isError: false }),
);
expect(bridge.telemetry.acceptedSessionSpawns).toEqual([
{ runId: "run_5f3a9c", childSessionKey: "child-7b21" },
]);
});
it("preserves an accepted sessions_spawn after result middleware strips its details", async () => {
const registry = createEmptyPluginRegistry();
const handler = vi.fn(async (event: { result: AgentToolResult<unknown> }) => ({
result: {
...event.result,
content: [{ type: "text" as const, text: "Child launch recorded." }],
details: {},
},
}));
registry.agentToolResultMiddlewares.push({
pluginId: "result-compactor",
pluginName: "Result Compactor",
rawHandler: handler,
handler,
runtimes: ["codex"],
source: "test",
});
setActivePluginRegistry(registry);
const bridge = createBridgeWithToolResult(
"sessions_spawn",
textToolResult("Accepted: launching child session.", {
status: "accepted",
runId: "run_compacted",
childSessionKey: "child-compacted",
}),
);
const result = await bridge.handleToolCall({
threadId: "thread-1",
turnId: "turn-1",
callId: "call-compacted",
namespace: null,
tool: "sessions_spawn",
arguments: { task: "scan logs" },
});
expect(result).toEqual(expectInputText("Child launch recorded."));
expect(bridge.telemetry.acceptedSessionSpawns).toEqual([
{ runId: "run_compacted", childSessionKey: "child-compacted" },
]);
});
it("retains only MCP App preview details for OpenClaw transcript projection", async () => {
@@ -500,6 +545,7 @@ describe("createCodexDynamicToolBridge", () => {
});
expect(result.success).toBe(false);
expect(bridge.telemetry.acceptedSessionSpawns).toEqual([]);
});
it("treats accepted goal tool statuses (created / updated) as successful dynamic tool calls", async () => {
@@ -45,7 +45,11 @@ import { emitTrustedDiagnosticEvent } from "openclaw/plugin-sdk/diagnostic-runti
import { expectDefined } from "openclaw/plugin-sdk/expect-runtime";
import type { ImageContent, TextContent } from "openclaw/plugin-sdk/llm";
import { normalizeOpenAIToolSchemas } from "openclaw/plugin-sdk/provider-tools";
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
import {
asOptionalRecord,
isRecord,
normalizeOptionalString,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import {
DEFAULT_MAX_LIVE_TOOL_RESULT_CHARS,
estimateToolResultTextChars,
@@ -381,10 +385,24 @@ export type CodexDynamicToolBridge = {
toolMediaUrls: string[];
toolAudioAsVoice: boolean;
successfulCronAdds?: number;
acceptedSessionSpawns: Array<{ runId: string; childSessionKey: string }>;
quarantinedTools: CodexDynamicToolSchemaQuarantine[];
};
};
function normalizeAcceptedSessionSpawn(result: unknown): {
runId: string;
childSessionKey: string;
} | null {
const details = asOptionalRecord(asOptionalRecord(result)?.details);
if (!details || details.status !== "accepted") {
return null;
}
const runId = normalizeOptionalString(details.runId);
const childSessionKey = normalizeOptionalString(details.childSessionKey);
return runId && childSessionKey ? { runId, childSessionKey } : null;
}
/** Namespace attached to OpenClaw-owned dynamic tools exposed to Codex. */
const CODEX_OPENCLAW_DYNAMIC_TOOL_NAMESPACE = "openclaw";
@@ -490,6 +508,7 @@ export function createCodexDynamicToolBridge(params: {
messagingToolSourceReplyPayloads: [],
toolMediaUrls: [],
toolAudioAsVoice: false,
acceptedSessionSpawns: [],
quarantinedTools,
};
const middlewareRunner = createAgentToolResultMiddlewareRunner({
@@ -654,6 +673,14 @@ export function createCodexDynamicToolBridge(params: {
result: middlewareResult,
});
const resultIsError = rawIsError || isToolResultError(result);
// A successful spawn is durable before presentation middleware can rewrite details.
const acceptedSessionSpawn =
toolName === "sessions_spawn" && !rawIsError
? normalizeAcceptedSessionSpawn(telemetryRawResult)
: null;
if (acceptedSessionSpawn) {
telemetry.acceptedSessionSpawns.push(acceptedSessionSpawn);
}
const finalResultFailureKind = resolveToolResultFailureKind(result);
const resultFailureKind = rawResultFailureKind ?? finalResultFailureKind;
const observerResult =
@@ -647,7 +647,7 @@ describe("CodexAppServerEventProjector commentary projection", () => {
expect(result.lastAssistant).toBeUndefined();
});
it("preserves sessions_yield detection in attempt results", () => {
it("preserves accepted session spawns as yield continuation evidence", () => {
const projector = new CodexAppServerEventProjector(
{
prompt: "hello",
@@ -664,8 +664,20 @@ describe("CodexAppServerEventProjector commentary projection", () => {
TURN_ID,
);
const result = projector.buildResult(buildEmptyToolTelemetry(), { yieldDetected: true });
const result = projector.buildResult(
{
...buildEmptyToolTelemetry(),
acceptedSessionSpawns: [
{ runId: "child-run", childSessionKey: "agent:main:subagent:child" },
],
},
{ yieldDetected: true },
);
expect(result.yieldDetected).toBe(true);
expect(result.acceptedSessionSpawns).toEqual([
{ runId: "child-run", childSessionKey: "agent:main:subagent:child" },
]);
expect(result.replayMetadata).toEqual({ hadPotentialSideEffects: true, replaySafe: false });
});
});
@@ -73,7 +73,7 @@ type CodexAppServerToolTelemetry = {
toolMediaUrls?: string[];
toolAudioAsVoice?: boolean;
successfulCronAdds?: number;
};
} & Pick<EmbeddedRunAttemptResult, "acceptedSessionSpawns">;
export class CodexAppServerEventProjector {
private readonly assistantProjection: CodexAssistantProjection;
@@ -403,7 +403,7 @@ export class CodexAppServerEventProjector {
const toolMetas = this.toolProgressProjection.toolMetas;
const hadPotentialSideEffects =
toolTelemetry.didSendViaMessagingTool ||
(toolTelemetry.successfulCronAdds ?? 0) > 0 ||
Boolean(toolTelemetry.successfulCronAdds || toolTelemetry.acceptedSessionSpawns?.length) ||
this.generatedMediaProjection.hasGeneratedMedia() ||
this.toolProgressProjection.hasPotentialSideEffects;
return {
@@ -439,6 +439,7 @@ export class CodexAppServerEventProjector {
hostOwnedToolMediaUrls: this.generatedMediaProjection.buildHostOwnedMediaUrls(toolTelemetry),
toolAudioAsVoice: toolTelemetry.toolAudioAsVoice,
successfulCronAdds: toolTelemetry.successfulCronAdds,
acceptedSessionSpawns: toolTelemetry.acceptedSessionSpawns,
cloudCodeAssistFormatError: false,
attemptUsage: projectedUsage,
...(this.completedCompactionCount > 0
@@ -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,
@@ -10,7 +10,6 @@ import {
import type {
OtelHttpAgentFactory,
OtelHttpAgentOptions,
OtelLogger,
OtelSignalIdentifier,
} from "./service-types.js";
@@ -28,14 +27,10 @@ function resolveOtelUrl(endpoint: string | undefined, path: string): string | un
return endpoint;
}
if (/[?#]/u.test(endpoint)) {
try {
const url = new URL(endpoint);
const basePath = url.pathname.replace(/\/+$/u, "");
url.pathname = `${basePath}/${path}`;
return url.toString();
} catch {
// Fall back to the historical concatenation path for non-URL test doubles.
}
const url = new URL(endpoint);
const basePath = url.pathname.replace(/\/+$/u, "");
url.pathname = `${basePath}/${path}`;
return url.toString();
}
return `${endpoint}/${path}`;
}
@@ -43,21 +38,43 @@ function resolveOtelUrl(endpoint: string | undefined, path: string): string | un
export function resolveSignalOtelUrl(params: {
signalEndpoint?: string;
signalEnvEndpoint?: string;
sharedEnvEndpoint?: string;
endpoint?: string;
path: string;
}): string | undefined {
return resolveOtelUrl(
normalizeEndpoint(params.signalEndpoint ?? params.signalEnvEndpoint) ?? params.endpoint,
params.path,
);
const endpoint =
normalizeEndpoint(params.signalEndpoint ?? params.signalEnvEndpoint) ?? params.endpoint;
// OTLP parses nonblank env values verbatim even when explicit config takes precedence.
const signalEnvEndpoint = params.signalEnvEndpoint?.trim() ? params.signalEnvEndpoint : undefined;
const sharedEnvEndpoint = params.sharedEnvEndpoint?.trim() ? params.sharedEnvEndpoint : undefined;
const consumedSharedEnvEndpoint = signalEnvEndpoint ? undefined : sharedEnvEndpoint;
const appendedSharedEnvEndpoint = consumedSharedEnvEndpoint
? `${consumedSharedEnvEndpoint}${consumedSharedEnvEndpoint.endsWith("/") ? "" : "/"}${params.path}`
: undefined;
const resolvedEndpoint =
endpoint && URL.canParse(endpoint) ? resolveOtelUrl(endpoint, params.path) : endpoint;
for (const candidate of [
endpoint,
signalEnvEndpoint ?? sharedEnvEndpoint,
appendedSharedEnvEndpoint,
resolvedEndpoint,
]) {
if (candidate && !URL.canParse(candidate)) {
throw new Error(
"Configured OpenTelemetry collector endpoint is invalid; check the collector URL",
);
}
}
return resolvedEndpoint;
}
function readOtelEnvFile(params: {
signalIdentifier: OtelSignalIdentifier;
signalSuffix: "CERTIFICATE" | "CLIENT_CERTIFICATE" | "CLIENT_KEY";
sharedEnvName: string;
logger: OtelLogger;
warning: string;
label: string;
}): Buffer | undefined {
const signalEnvName = `OTEL_EXPORTER_OTLP_${params.signalIdentifier}_${params.signalSuffix}`;
const filePath =
@@ -67,48 +84,53 @@ function readOtelEnvFile(params: {
return undefined;
}
try {
return readFileSync(nodePath.resolve(process.cwd(), filePath));
const material = readFileSync(nodePath.resolve(process.cwd(), filePath));
if (material.length > 0) {
return material;
}
} catch {
params.logger.warn(`diagnostics-otel: ${params.warning}`);
return undefined;
// Never expose certificate paths or silently downgrade to system trust.
}
throw new Error(
`Configured OpenTelemetry ${params.label} file is missing, empty, or unreadable; refusing insecure export`,
);
}
function normalizeOtelEnvValue(value: string | undefined): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
return value?.trim() ? value : undefined;
}
export function resolveOtelHttpAgentOptions(params: {
url: string | undefined;
signalIdentifier: OtelSignalIdentifier;
logger: OtelLogger;
}): OtelHttpAgentFactory | undefined {
const { url, signalIdentifier, logger } = params;
if (!url) {
return undefined;
}
}): OtelHttpAgentFactory | OtelHttpAgentOptions | undefined {
const { url, signalIdentifier } = params;
const ca = readOtelEnvFile({
signalIdentifier,
signalSuffix: "CERTIFICATE",
sharedEnvName: OTEL_EXPORTER_OTLP_CERTIFICATE_ENV,
logger,
warning: "failed to read root certificate file",
label: "TLS root certificate",
});
const cert = readOtelEnvFile({
signalIdentifier,
signalSuffix: "CLIENT_CERTIFICATE",
sharedEnvName: OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE_ENV,
logger,
warning: "failed to read client certificate chain file",
label: "mTLS client certificate",
});
const key = readOtelEnvFile({
signalIdentifier,
signalSuffix: "CLIENT_KEY",
sharedEnvName: OTEL_EXPORTER_OTLP_CLIENT_KEY_ENV,
logger,
warning: "failed to read client certificate private key file",
label: "mTLS client private key",
});
if ((cert === undefined) !== (key === undefined)) {
throw new Error(
"Configured OpenTelemetry mTLS requires both a client certificate and private key; refusing insecure export",
);
}
if (!url) {
return undefined;
}
const agentOptions: OtelHttpAgentOptions = {
keepAlive: true,
...(ca !== undefined ? { ca } : {}),
@@ -117,13 +139,13 @@ export function resolveOtelHttpAgentOptions(params: {
};
try {
const agent = createNodeProxyAgent({ mode: "env", targetUrl: url, agentOptions });
return agent ? () => agent : undefined;
if (agent) {
return () => agent;
}
} catch {
logger.warn(
`diagnostics-otel: env proxy agent unavailable for OTLP ${signalIdentifier.toLowerCase()} exporter; falling back to default Node agent`,
);
return undefined;
throw new Error("Configured telemetry proxy is invalid or unsupported; refusing direct export");
}
return (ca || cert || key) && new URL(url).protocol === "https:" ? agentOptions : undefined;
}
export function resolveSampleRate(value: number | undefined): number | undefined {
@@ -153,9 +175,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;
}
@@ -20,7 +20,7 @@ import {
normalizeOtelLogString,
type OtelContentCapturePolicy,
} from "./service-content-normalization.js";
import { errorCategory, formatError, resolveOtelHttpAgentOptions } from "./service-exporter.js";
import { errorCategory, formatError } from "./service-exporter.js";
import {
addTraceAttributes,
contextForTrustedTraceContext,
@@ -28,6 +28,8 @@ import {
} from "./service-trace-context.js";
import type {
BuiltOtelLogRecord,
OtelHttpAgentFactory,
OtelHttpAgentOptions,
OtelLogger,
TelemetryExporterDiagnosticEvent,
} from "./service-types.js";
@@ -50,6 +52,7 @@ export function createDiagnosticsLogExporter(params: {
logsEnabled: boolean;
logsToOtlp: boolean;
logsToStdout: boolean;
logHttpAgentOptions?: OtelHttpAgentFactory | OtelHttpAgentOptions;
logUrl?: string;
resource: Resource;
serviceName: string;
@@ -63,6 +66,7 @@ export function createDiagnosticsLogExporter(params: {
logsEnabled,
logsToOtlp,
logsToStdout,
logHttpAgentOptions,
logUrl,
resource,
serviceName,
@@ -86,11 +90,6 @@ export function createDiagnosticsLogExporter(params: {
let otelLogger: { emit: (logRecord: LogRecord) => void } | undefined;
if (logsToOtlp) {
const logHttpAgentOptions = resolveOtelHttpAgentOptions({
url: logUrl,
signalIdentifier: "LOGS",
logger,
});
const logExporter = new OTLPLogExporter({
...(logUrl ? { url: logUrl } : {}),
...(headers ? { headers } : {}),
@@ -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) {
@@ -5,11 +5,14 @@
// test feeds in, collapsing the diagnostic and OTel id spaces into one value. That hides
// a parent lookup keyed by one id space and queried with the other.
//
// It drives the service through the OPENCLAW_OTEL_PRELOADED seam so the plugin uses this
// file's tracer provider instead of starting its own NodeSDK. trace.disable() in teardown
// then fully releases the global API slot; a NodeSDK cannot be unregistered, and the
// leftover dead provider would make any later real-SDK test export nothing.
import { trace } from "@opentelemetry/api";
// Trace cases use the OPENCLAW_OTEL_PRELOADED seam to retain this file's tracer provider.
// Collector-boundary cases start the real NodeSDK, so teardown restores every global SDK
// registration; otherwise a shutdown provider would poison later real-SDK cases.
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { context, diag, DiagLogLevel, metrics, propagation, trace } from "@opentelemetry/api";
import { logs } from "@opentelemetry/api-logs";
import {
BasicTracerProvider,
InMemorySpanExporter,
@@ -23,17 +26,70 @@ import {
resetDiagnosticEventsForTest,
waitForDiagnosticEventsDrained,
} from "openclaw/plugin-sdk/diagnostic-runtime";
import { afterEach, beforeEach, expect, test } from "vitest";
import { startOtelService, stopStartedOtelServices } from "./service.test-helpers.js";
import { afterEach, beforeEach, expect, test, vi } from "vitest";
import { createDiagnosticsOtelService } from "./service.js";
import {
createOtelContext,
startOtelService,
stopStartedOtelServices,
} from "./service.test-helpers.js";
const PRELOAD_ENV = "OPENCLAW_OTEL_PRELOADED";
const ENDPOINT_ENV_KEYS = [
"OTEL_EXPORTER_OTLP_ENDPOINT",
"OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
"OTEL_EXPORTER_OTLP_METRICS_ENDPOINT",
"OTEL_EXPORTER_OTLP_LOGS_ENDPOINT",
"OTEL_EXPORTER_OTLP_CERTIFICATE",
"OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE",
"OTEL_EXPORTER_OTLP_CLIENT_KEY",
"OTEL_EXPORTER_OTLP_TRACES_CERTIFICATE",
"OTEL_EXPORTER_OTLP_TRACES_CLIENT_CERTIFICATE",
"OTEL_EXPORTER_OTLP_TRACES_CLIENT_KEY",
"OTEL_EXPORTER_OTLP_METRICS_CERTIFICATE",
"OTEL_EXPORTER_OTLP_METRICS_CLIENT_CERTIFICATE",
"OTEL_EXPORTER_OTLP_METRICS_CLIENT_KEY",
"OTEL_EXPORTER_OTLP_LOGS_CERTIFICATE",
"OTEL_EXPORTER_OTLP_LOGS_CLIENT_CERTIFICATE",
"OTEL_EXPORTER_OTLP_LOGS_CLIENT_KEY",
"OTEL_LOG_LEVEL",
] as const;
const OTEL_GLOBAL_API_KEY = Symbol.for("opentelemetry.js.api.1");
const OTEL_GLOBAL_LOGS_KEY = Symbol.for("io.opentelemetry.js.api.logs");
type OtelGlobalRegistrations = {
context?: Parameters<typeof context.setGlobalContextManager>[0];
diag?: Parameters<typeof diag.setLogger>[0];
metrics?: Parameters<typeof metrics.setGlobalMeterProvider>[0];
propagation?: Parameters<typeof propagation.setGlobalPropagator>[0];
trace?: Parameters<typeof trace.setGlobalTracerProvider>[0];
};
let exporter: InMemorySpanExporter;
let provider: BasicTracerProvider;
let originalPreloaded: string | undefined;
let originalEndpointEnv: Record<(typeof ENDPOINT_ENV_KEYS)[number], string | undefined>;
let originalOtelGlobals: OtelGlobalRegistrations;
let originalLogsProvider: ReturnType<typeof logs.getLoggerProvider> | undefined;
function registeredOtelGlobals(): OtelGlobalRegistrations | undefined {
return (globalThis as unknown as Record<symbol, OtelGlobalRegistrations | undefined>)[
OTEL_GLOBAL_API_KEY
];
}
beforeEach(() => {
originalPreloaded = process.env[PRELOAD_ENV];
originalEndpointEnv = Object.fromEntries(
ENDPOINT_ENV_KEYS.map((key) => [key, process.env[key]]),
) as Record<(typeof ENDPOINT_ENV_KEYS)[number], string | undefined>;
for (const key of ENDPOINT_ENV_KEYS) {
delete process.env[key];
}
originalOtelGlobals = { ...registeredOtelGlobals() };
originalLogsProvider = Object.hasOwn(globalThis, OTEL_GLOBAL_LOGS_KEY)
? logs.getLoggerProvider()
: undefined;
process.env[PRELOAD_ENV] = "1";
exporter = new InMemorySpanExporter();
provider = new BasicTracerProvider({ spanProcessors: [new SimpleSpanProcessor(exporter)] });
@@ -43,13 +99,58 @@ beforeEach(() => {
afterEach(async () => {
await stopStartedOtelServices();
await provider.shutdown();
trace.disable();
const currentGlobals = registeredOtelGlobals();
if (currentGlobals?.context !== originalOtelGlobals.context) {
context.disable();
if (originalOtelGlobals.context) {
context.setGlobalContextManager(originalOtelGlobals.context);
}
}
if (currentGlobals?.propagation !== originalOtelGlobals.propagation) {
propagation.disable();
if (originalOtelGlobals.propagation) {
propagation.setGlobalPropagator(originalOtelGlobals.propagation);
}
}
if (currentGlobals?.metrics !== originalOtelGlobals.metrics) {
metrics.disable();
if (originalOtelGlobals.metrics) {
metrics.setGlobalMeterProvider(originalOtelGlobals.metrics);
}
}
if (currentGlobals?.trace !== originalOtelGlobals.trace) {
trace.disable();
if (originalOtelGlobals.trace) {
trace.setGlobalTracerProvider(originalOtelGlobals.trace);
}
}
if (Object.hasOwn(globalThis, OTEL_GLOBAL_LOGS_KEY) || originalLogsProvider) {
logs.disable();
if (originalLogsProvider) {
logs.setGlobalLoggerProvider(originalLogsProvider);
}
}
exporter.reset();
if (originalPreloaded === undefined) {
delete process.env[PRELOAD_ENV];
} else {
process.env[PRELOAD_ENV] = originalPreloaded;
}
for (const key of ENDPOINT_ENV_KEYS) {
const value = originalEndpointEnv[key];
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
diag.disable();
if (originalOtelGlobals.diag) {
diag.setLogger(originalOtelGlobals.diag, {
logLevel: DiagLogLevel.ALL,
suppressOverrideMessage: true,
});
}
resetDiagnosticEventsForTest();
});
@@ -60,6 +161,24 @@ function spanNamed(spans: ReadableSpan[], name: string) {
return spans.find((span) => span.name === name);
}
function captureOtelDiagnostics(): string[] {
const messages: string[] = [];
const capture = (...args: unknown[]) => {
messages.push(args.map((value) => String(value)).join(" "));
};
diag.setLogger(
{
debug: () => {},
error: capture,
info: () => {},
verbose: () => {},
warn: capture,
},
{ logLevel: DiagLogLevel.ALL, suppressOverrideMessage: true },
);
return messages;
}
// Covers all three completeTrackedLifecycleSpan owners: run.completed,
// harness.run.completed, and message.processed. The mocked suite cannot tell the two id
// spaces apart, so a regression at any one of them is only visible here.
@@ -237,3 +356,274 @@ test("leaves exec spans parentless rather than naming a span nobody exported", a
expect(execSpan).toBeDefined();
expect(execSpan?.parentSpanContext).toBeUndefined();
}, 30_000);
const OTEL_ENDPOINT_SIGNAL_CASES = [
{
signal: "traces",
envKey: "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
configKey: "tracesEndpoint",
flags: { traces: true, metrics: false, logs: false },
},
{
signal: "metrics",
envKey: "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT",
configKey: "metricsEndpoint",
flags: { traces: false, metrics: true, logs: false },
},
{
signal: "logs",
envKey: "OTEL_EXPORTER_OTLP_LOGS_ENDPOINT",
configKey: "logsEndpoint",
flags: { traces: false, metrics: false, logs: true },
},
] as const;
const OTEL_ENDPOINT_SECURITY_CASES = OTEL_ENDPOINT_SIGNAL_CASES.flatMap((signal) =>
(
[
"shared configuration",
"signal configuration",
"signal environment",
"shared environment",
"Unicode-prefixed signal environment",
"Unicode-prefixed shared environment",
"path-concatenated shared environment",
"path-concatenated shared configuration",
"path-concatenated signal configuration",
] as const
).map((source) => Object.assign({ source }, signal)),
);
test.each(OTEL_ENDPOINT_SECURITY_CASES)(
"rejects malformed $signal collector $source before the real SDK can expose credentials",
async ({ signal, envKey, configKey, flags, source }) => {
process.env[PRELOAD_ENV] = "0";
const credential = `qa-otel-${signal}-endpoint-password-sentinel`;
const malformedEndpoint = source.startsWith("Unicode-prefixed")
? `\u00a0https://operator:${credential}@collector.example.com/otlp`
: source === "path-concatenated shared environment"
? `https://operator:${credential}@collector.example.com: `
: source.startsWith("path-concatenated")
? `https://operator:${credential}@collector.example.com /`
: `https://operator:${credential}@[`;
const configuredEndpoint = source.endsWith("shared configuration")
? malformedEndpoint
: "https://collector.example.com/otlp";
if (source.endsWith("signal environment")) {
process.env[envKey] = malformedEndpoint;
} else if (source.endsWith("shared environment")) {
process.env.OTEL_EXPORTER_OTLP_ENDPOINT = malformedEndpoint;
}
const diagnostics = captureOtelDiagnostics();
const ctx = createOtelContext(configuredEndpoint, flags);
if (source.endsWith("signal configuration") || source.endsWith("signal environment")) {
ctx.config.diagnostics!.otel![configKey] = source.endsWith("signal configuration")
? malformedEndpoint
: "https://signal.example.com/otlp";
}
ctx.internalDiagnostics!.emit = () => {};
const service = createDiagnosticsOtelService();
let failure: unknown;
try {
await service.start(ctx);
} catch (error) {
failure = error;
} finally {
await service.stop?.(ctx);
}
expect(diagnostics.join("\n")).not.toContain(credential);
expect(failure).toBeInstanceOf(Error);
const startupError = failure as Error;
expect(startupError.message).toBe(
"Configured OpenTelemetry collector endpoint is invalid; check the collector URL",
);
expect(startupError.stack).not.toContain(credential);
expect(startupError).not.toHaveProperty("cause");
expect(JSON.stringify(vi.mocked(ctx.logger.error).mock.calls)).not.toContain(credential);
expect(JSON.stringify(vi.mocked(ctx.logger.warn).mock.calls)).not.toContain(credential);
},
);
test.each([
{
disabledSignal: "metrics",
envKey: "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT",
flags: { traces: true, metrics: false, logs: false },
},
{
disabledSignal: "traces",
envKey: "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
flags: { traces: false, metrics: true, logs: false },
},
{
disabledSignal: "logs",
envKey: "OTEL_EXPORTER_OTLP_LOGS_ENDPOINT",
flags: { traces: true, metrics: false, logs: false },
},
{
disabledSignal: "stdout-only logs",
envKey: "OTEL_EXPORTER_OTLP_LOGS_ENDPOINT",
flags: { traces: true, metrics: false, logs: true, logsExporter: "stdout" },
},
] as const)(
"does not auto-create an undeclared $disabledSignal OTLP exporter",
async ({ disabledSignal, envKey, flags }) => {
process.env[PRELOAD_ENV] = "0";
const credential = `qa-otel-${disabledSignal.replaceAll(" ", "-")}-disabled-password`;
process.env[envKey] = `https://operator:${credential}@[`;
const diagnostics = captureOtelDiagnostics();
const ctx = createOtelContext("https://collector.example.com/otlp", flags);
ctx.internalDiagnostics!.emit = () => {};
const service = createDiagnosticsOtelService();
try {
await service.start(ctx);
expect(diagnostics.join("\n")).not.toContain(credential);
} finally {
await service.stop?.(ctx);
}
},
);
const OTEL_TLS_MATERIAL_CASES = [
{ suffix: "CERTIFICATE", label: "TLS root certificate" },
{ suffix: "CLIENT_CERTIFICATE", label: "mTLS client certificate" },
{ suffix: "CLIENT_KEY", label: "mTLS client private key" },
] as const;
const OTEL_TLS_FILE_SECURITY_CASES = OTEL_ENDPOINT_SIGNAL_CASES.flatMap((signal) =>
OTEL_TLS_MATERIAL_CASES.flatMap((material) =>
(["shared", "signal"] as const).map((scope) => Object.assign({ scope }, signal, material)),
),
);
test.each(OTEL_TLS_FILE_SECURITY_CASES)(
"refuses the real $signal exporter when its $scope $label file cannot be read",
async ({ signal, suffix, label, scope, flags }) => {
process.env[PRELOAD_ENV] = "0";
const pathSentinel = `qa-otel-${signal}-${suffix.toLowerCase()}-file-sentinel`;
const missingPath = `/definitely-missing/${pathSentinel}.pem`;
const envKey =
scope === "shared"
? `OTEL_EXPORTER_OTLP_${suffix}`
: `OTEL_EXPORTER_OTLP_${signal.toUpperCase()}_${suffix}`;
process.env[envKey] = missingPath;
const diagnostics = captureOtelDiagnostics();
const ctx = createOtelContext("https://collector.example.com/otlp", flags);
ctx.internalDiagnostics!.emit = () => {};
const service = createDiagnosticsOtelService();
let failure: unknown;
try {
await service.start(ctx);
} catch (error) {
failure = error;
} finally {
await service.stop?.(ctx);
}
expect(failure).toBeInstanceOf(Error);
const startupError = failure as Error;
expect(startupError.message).toBe(
`Configured OpenTelemetry ${label} file is missing, empty, or unreadable; refusing insecure export`,
);
expect(startupError.stack).not.toContain(pathSentinel);
expect(startupError).not.toHaveProperty("cause");
expect(diagnostics.join("\n")).not.toContain(pathSentinel);
expect(JSON.stringify(vi.mocked(ctx.logger.error).mock.calls)).not.toContain(pathSentinel);
expect(JSON.stringify(vi.mocked(ctx.logger.warn).mock.calls)).not.toContain(pathSentinel);
},
);
test.each(OTEL_ENDPOINT_SIGNAL_CASES)(
"refuses invalid TLS material before the real default $signal exporter is constructed",
async ({ signal, flags }) => {
process.env[PRELOAD_ENV] = "0";
process.env[`OTEL_EXPORTER_OTLP_${signal.toUpperCase()}_CERTIFICATE`] =
"/definitely-missing/qa-otel-default-root.pem";
const ctx = createOtelContext("", flags);
ctx.internalDiagnostics!.emit = () => {};
const service = createDiagnosticsOtelService();
try {
await expect(service.start(ctx)).rejects.toThrow(
"Configured OpenTelemetry TLS root certificate file is missing, empty, or unreadable; refusing insecure export",
);
} finally {
await service.stop?.(ctx);
}
},
);
test.each(
OTEL_ENDPOINT_SIGNAL_CASES.flatMap((signal) =>
OTEL_TLS_MATERIAL_CASES.map((material) => Object.assign({}, signal, material)),
),
)(
"rejects an empty $signal $label file before the SDK can silently downgrade trust",
async ({ signal, suffix, label, flags }) => {
process.env[PRELOAD_ENV] = "0";
const certDir = mkdtempSync(path.join(tmpdir(), "openclaw-otel-empty-tls-"));
const emptyMaterialPath = path.join(certDir, "empty.pem");
writeFileSync(emptyMaterialPath, "");
process.env[`OTEL_EXPORTER_OTLP_${signal.toUpperCase()}_${suffix}`] = emptyMaterialPath;
const ctx = createOtelContext("https://collector.example.com/otlp", flags);
ctx.internalDiagnostics!.emit = () => {};
const service = createDiagnosticsOtelService();
try {
await expect(service.start(ctx)).rejects.toThrow(
`Configured OpenTelemetry ${label} file is missing, empty, or unreadable; refusing insecure export`,
);
} finally {
await service.stop?.(ctx);
rmSync(certDir, { force: true, recursive: true });
}
},
);
test.each(OTEL_ENDPOINT_SIGNAL_CASES)(
"rejects the raw whitespace-padded $signal TLS certificate path the SDK cannot read",
async ({ signal, flags }) => {
process.env[PRELOAD_ENV] = "0";
process.env[`OTEL_EXPORTER_OTLP_${signal.toUpperCase()}_CERTIFICATE`] = ` ${process.execPath} `;
const ctx = createOtelContext("https://collector.example.com/otlp", flags);
ctx.internalDiagnostics!.emit = () => {};
const service = createDiagnosticsOtelService();
try {
await expect(service.start(ctx)).rejects.toThrow(
"Configured OpenTelemetry TLS root certificate file is missing, empty, or unreadable; refusing insecure export",
);
} finally {
await service.stop?.(ctx);
}
},
);
test.each(
OTEL_ENDPOINT_SIGNAL_CASES.flatMap((signal) =>
(["CLIENT_CERTIFICATE", "CLIENT_KEY"] as const).map((suffix) =>
Object.assign({ suffix }, signal),
),
),
)(
"rejects the real $signal exporter when only $suffix mTLS material is configured",
async ({ signal, suffix, flags }) => {
process.env[PRELOAD_ENV] = "0";
process.env[`OTEL_EXPORTER_OTLP_${signal.toUpperCase()}_${suffix}`] = process.execPath;
const ctx = createOtelContext("https://collector.example.com/otlp", flags);
ctx.internalDiagnostics!.emit = () => {};
const service = createDiagnosticsOtelService();
try {
await expect(service.start(ctx)).rejects.toThrow(
"Configured OpenTelemetry mTLS requires both a client certificate and private key; refusing insecure export",
);
} finally {
await service.stop?.(ctx);
}
},
);
+327 -18
View File
@@ -57,6 +57,7 @@ const telemetryState = vi.hoisted(() => {
const sdkStart = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
const sdkShutdown = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
const sdkCtor = vi.hoisted(() => vi.fn());
const logEmit = vi.hoisted(() => vi.fn());
const logShutdown = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
const traceExporterCtor = vi.hoisted(() => vi.fn());
@@ -107,6 +108,10 @@ vi.mock("@opentelemetry/api", () => ({
vi.mock("@opentelemetry/sdk-node", () => ({
NodeSDK: class {
constructor(options?: unknown) {
sdkCtor(options);
}
start = sdkStart;
shutdown = sdkShutdown;
},
@@ -220,7 +225,9 @@ const LATE_CHILD_ELAPSED_MS = 30 * 60_000 + 1_000;
const PROTO_KEY = "__proto__";
const MAX_TEST_OTEL_CONTENT_ATTRIBUTE_CHARS = 128 * 1024;
const OTEL_TRUNCATED_SUFFIX_MAX_CHARS = 20;
const OTEL_TEST_USERINFO = ["operator", "example-fixture"].join(":");
const ORIGINAL_OPENCLAW_OTEL_PRELOADED = process.env.OPENCLAW_OTEL_PRELOADED;
const ORIGINAL_OTEL_EXPORTER_OTLP_ENDPOINT = process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
const ORIGINAL_OTEL_EXPORTER_OTLP_PROTOCOL = process.env.OTEL_EXPORTER_OTLP_PROTOCOL;
const ORIGINAL_OTEL_EXPORTER_OTLP_TRACES_ENDPOINT = process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT;
const ORIGINAL_OTEL_EXPORTER_OTLP_METRICS_ENDPOINT =
@@ -584,6 +591,7 @@ describe("diagnostics-otel service", () => {
telemetryState.tracer.setSpanContext.mockClear();
telemetryState.meter.createCounter.mockClear();
telemetryState.meter.createHistogram.mockClear();
sdkCtor.mockClear();
sdkStart.mockClear();
sdkShutdown.mockClear();
logEmit.mockReset();
@@ -597,6 +605,7 @@ describe("diagnostics-otel service", () => {
createNodeProxyAgentMock.mockReturnValue(undefined);
unhandledRejectionHandlerState.reset();
unhandledRejectionHandlerState.register.mockClear();
delete process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
delete process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT;
delete process.env.OTEL_EXPORTER_OTLP_METRICS_ENDPOINT;
delete process.env.OTEL_EXPORTER_OTLP_LOGS_ENDPOINT;
@@ -613,6 +622,11 @@ describe("diagnostics-otel service", () => {
} else {
process.env.OPENCLAW_OTEL_PRELOADED = ORIGINAL_OPENCLAW_OTEL_PRELOADED;
}
if (ORIGINAL_OTEL_EXPORTER_OTLP_ENDPOINT === undefined) {
delete process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
} else {
process.env.OTEL_EXPORTER_OTLP_ENDPOINT = ORIGINAL_OTEL_EXPORTER_OTLP_ENDPOINT;
}
if (ORIGINAL_OTEL_EXPORTER_OTLP_PROTOCOL === undefined) {
delete process.env.OTEL_EXPORTER_OTLP_PROTOCOL;
} else {
@@ -1687,6 +1701,16 @@ describe("diagnostics-otel service", () => {
"https://collector.example.com/otlp#tenant-a",
"https://collector.example.com/otlp/v1/traces#tenant-a",
],
[
"preserves valid collector credentials and query parameters",
`https://${OTEL_TEST_USERINFO}@collector.example.com/otlp?tenant=red`,
`https://${OTEL_TEST_USERINFO}@collector.example.com/otlp/v1/traces?tenant=red`,
],
[
"preserves parseable non-HTTP collector URL schemes",
"custom+otel://collector.example.com/otlp",
"custom+otel://collector.example.com/otlp/v1/traces",
],
[
"keeps signal-qualified endpoint unchanged when signal path casing differs",
"https://collector.example.com/v1/Traces",
@@ -1763,6 +1787,105 @@ describe("diagnostics-otel service", () => {
expect(logOptions.url).toBe("https://log-env.example.com/otlp/v1/logs");
});
test("ignores malformed shared OTLP env when valid signal endpoints shadow it", async () => {
process.env.OTEL_EXPORTER_OTLP_ENDPOINT = "https://operator:qa-ignored-shared-password@[";
process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT = "https://trace-env.example.com/v1/traces";
process.env.OTEL_EXPORTER_OTLP_METRICS_ENDPOINT = "https://metric-env.example.com/v1/metrics";
process.env.OTEL_EXPORTER_OTLP_LOGS_ENDPOINT = "https://log-env.example.com/v1/logs";
await startOtelService({ traces: true, metrics: true, logs: true });
expect(firstExporterOptions(traceExporterCtor).url).toBe(
"https://trace-env.example.com/v1/traces",
);
expect(firstExporterOptions(metricExporterCtor).url).toBe(
"https://metric-env.example.com/v1/metrics",
);
expect(firstExporterOptions(logExporterCtor).url).toBe("https://log-env.example.com/v1/logs");
});
test("treats whitespace-only OTLP environment endpoints as unset", async () => {
process.env.OTEL_EXPORTER_OTLP_ENDPOINT = " \u00a0 ";
process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT = " \t ";
process.env.OTEL_EXPORTER_OTLP_METRICS_ENDPOINT = "\u2000";
process.env.OTEL_EXPORTER_OTLP_LOGS_ENDPOINT = "\ufeff";
await startOtelService({ traces: true, metrics: true, logs: true });
expect(firstExporterOptions(traceExporterCtor).url).toBe(`${OTEL_TEST_ENDPOINT}/v1/traces`);
expect(firstExporterOptions(metricExporterCtor).url).toBe(`${OTEL_TEST_ENDPOINT}/v1/metrics`);
expect(firstExporterOptions(logExporterCtor).url).toBe(`${OTEL_TEST_ENDPOINT}/v1/logs`);
});
test.each([
{
enabledSignal: "traces",
flags: { traces: true, metrics: false, logs: false },
metricReaderCount: 0,
tracesDisabled: false,
},
{
enabledSignal: "metrics",
flags: { traces: false, metrics: true, logs: false },
metricReaderCount: 1,
tracesDisabled: true,
},
{
enabledSignal: "traces and metrics",
flags: { traces: true, metrics: true, logs: false },
metricReaderCount: 1,
tracesDisabled: false,
},
] as const)(
"keeps NodeSDK exporter ownership explicit for $enabledSignal",
async ({ flags, metricReaderCount, tracesDisabled }) => {
await startOtelService(flags);
const options = mockCallArg(sdkCtor, 0) as {
logRecordProcessors?: unknown[];
metricReaders?: unknown[];
spanProcessors?: unknown[];
};
expect(options.logRecordProcessors).toEqual([]);
expect(options.metricReaders).toHaveLength(metricReaderCount);
expect(options).not.toHaveProperty("metricReader");
if (tracesDisabled) {
expect(options.spanProcessors).toEqual([]);
}
},
);
test("ignores malformed collector endpoints for preloaded traces and metrics", async () => {
process.env.OPENCLAW_OTEL_PRELOADED = "1";
process.env.OTEL_EXPORTER_OTLP_ENDPOINT = "https://operator:qa-preloaded-shared-password@[";
process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT =
"https://operator:qa-preloaded-trace-password@[";
process.env.OTEL_EXPORTER_OTLP_METRICS_ENDPOINT =
"https://operator:qa-preloaded-metric-password@[";
await startOtelService({ traces: true, metrics: true, logs: false });
expect(sdkCtor).not.toHaveBeenCalled();
expect(traceExporterCtor).not.toHaveBeenCalled();
expect(metricExporterCtor).not.toHaveBeenCalled();
});
test("ignores malformed collector endpoints for stdout-only diagnostics", async () => {
process.env.OTEL_EXPORTER_OTLP_ENDPOINT = "https://operator:qa-stdout-shared-password@[";
process.env.OTEL_EXPORTER_OTLP_LOGS_ENDPOINT = "https://operator:qa-stdout-log-password@[";
await startOtelService({
endpoint: "https://operator:qa-stdout-config-password@[",
traces: false,
metrics: false,
logs: true,
logsExporter: "stdout",
});
expect(sdkCtor).not.toHaveBeenCalled();
expect(logExporterCtor).not.toHaveBeenCalled();
});
test("passes env proxy agents to OTLP HTTP exporters", async () => {
createNodeProxyAgentMock.mockReturnValue(nodeProxyAgent);
@@ -1805,11 +1928,14 @@ describe("diagnostics-otel service", () => {
try {
const rootCertificatePath = path.join(certDir, "root.pem");
const clientCertificatePath = path.join(certDir, "client.pem");
const sharedClientCertificatePath = path.join(certDir, "shared-client.pem");
const clientKeyPath = path.join(certDir, "client-key.pem");
writeFileSync(rootCertificatePath, "root-certificate");
writeFileSync(clientCertificatePath, "trace-client-certificate");
writeFileSync(sharedClientCertificatePath, "shared-client-certificate");
writeFileSync(clientKeyPath, "client-key");
process.env.OTEL_EXPORTER_OTLP_CERTIFICATE = rootCertificatePath;
process.env.OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE = sharedClientCertificatePath;
process.env.OTEL_EXPORTER_OTLP_TRACES_CLIENT_CERTIFICATE = clientCertificatePath;
process.env.OTEL_EXPORTER_OTLP_CLIENT_KEY = clientKeyPath;
createNodeProxyAgentMock.mockReturnValue(nodeProxyAgent);
@@ -1836,6 +1962,7 @@ describe("diagnostics-otel service", () => {
expect(metricCall.agentOptions).toEqual({
keepAlive: true,
ca: Buffer.from("root-certificate"),
cert: Buffer.from("shared-client-certificate"),
key: Buffer.from("client-key"),
});
} finally {
@@ -1869,32 +1996,214 @@ describe("diagnostics-otel service", () => {
}
});
test("falls back to default OTLP agents when env proxy agent creation fails", async () => {
test("pins validated collector TLS material on direct HTTPS exporter agents", async () => {
const certDir = mkdtempSync(path.join(tmpdir(), "openclaw-otel-direct-tls-"));
try {
const rootCertificatePath = path.join(certDir, "root.pem");
writeFileSync(rootCertificatePath, "explicit-root-certificate");
process.env.OTEL_EXPORTER_OTLP_CERTIFICATE = rootCertificatePath;
await startOtelService({
endpoint: "https://collector.example.com/otlp",
traces: true,
});
expect(firstExporterOptions(traceExporterCtor).httpAgentOptions).toEqual({
keepAlive: true,
ca: Buffer.from("explicit-root-certificate"),
});
} finally {
rmSync(certDir, { force: true, recursive: true });
}
});
test("validates log TLS before constructing any trace, metric, or SDK owner", async () => {
process.env.OTEL_EXPORTER_OTLP_LOGS_CERTIFICATE =
"/definitely-missing/qa-otel-log-root-atomic.pem";
await expect(
startOtelService({
endpoint: "https://collector.example.com/otlp",
traces: true,
metrics: true,
logs: true,
}),
).rejects.toThrow(
"Configured OpenTelemetry TLS root certificate file is missing, empty, or unreadable; refusing insecure export",
);
expect(traceExporterCtor).not.toHaveBeenCalled();
expect(metricExporterCtor).not.toHaveBeenCalled();
expect(logExporterCtor).not.toHaveBeenCalled();
expect(sdkCtor).not.toHaveBeenCalled();
expect(sdkStart).not.toHaveBeenCalled();
});
test("never falls back from an unreadable signal TLS file to readable shared trust", async () => {
process.env.OTEL_EXPORTER_OTLP_CERTIFICATE = process.execPath;
process.env.OTEL_EXPORTER_OTLP_TRACES_CERTIFICATE =
"/definitely-missing/qa-otel-signal-override.pem";
await expect(startOtelService({ traces: true })).rejects.toThrow(
"Configured OpenTelemetry TLS root certificate file is missing, empty, or unreadable; refusing insecure export",
);
expect(traceExporterCtor).not.toHaveBeenCalled();
});
test("lets a readable signal TLS file shadow an unreadable shared trust file", async () => {
process.env.OTEL_EXPORTER_OTLP_CERTIFICATE =
"/definitely-missing/qa-otel-shadowed-shared-root.pem";
process.env.OTEL_EXPORTER_OTLP_TRACES_CERTIFICATE = process.execPath;
await startOtelService({ traces: true });
expect(traceExporterCtor).toHaveBeenCalledTimes(1);
});
test("keeps valid ambient TLS material compatible with plain HTTP collectors", async () => {
process.env.OTEL_EXPORTER_OTLP_CERTIFICATE = process.execPath;
await startOtelService({ endpoint: "http://collector.example.com/otlp", traces: true });
expect(traceExporterCtor).toHaveBeenCalledTimes(1);
expect(firstExporterOptions(traceExporterCtor).httpAgentOptions).toBeUndefined();
});
test("does not validate TLS material owned by a preloaded SDK", async () => {
process.env.OPENCLAW_OTEL_PRELOADED = "1";
process.env.OTEL_EXPORTER_OTLP_TRACES_CERTIFICATE =
"/definitely-missing/qa-otel-preloaded-traces-root.pem";
process.env.OTEL_EXPORTER_OTLP_METRICS_CERTIFICATE =
"/definitely-missing/qa-otel-preloaded-metrics-root.pem";
await startOtelService({ traces: true, metrics: true, logs: false });
expect(sdkCtor).not.toHaveBeenCalled();
});
test("still validates plugin-owned OTLP logs when a trace SDK is preloaded", async () => {
process.env.OPENCLAW_OTEL_PRELOADED = "1";
process.env.OTEL_EXPORTER_OTLP_LOGS_CERTIFICATE =
"/definitely-missing/qa-otel-preloaded-log-root.pem";
await expect(startOtelService({ traces: true, logs: true })).rejects.toThrow(
"Configured OpenTelemetry TLS root certificate file is missing, empty, or unreadable; refusing insecure export",
);
expect(logExporterCtor).not.toHaveBeenCalled();
});
test.each([
{
signal: "disabled traces",
envKey: "OTEL_EXPORTER_OTLP_TRACES_CERTIFICATE",
flags: { traces: false, metrics: true, logs: false },
},
{
signal: "disabled metrics",
envKey: "OTEL_EXPORTER_OTLP_METRICS_CERTIFICATE",
flags: { traces: true, metrics: false, logs: false },
},
{
signal: "disabled logs",
envKey: "OTEL_EXPORTER_OTLP_LOGS_CERTIFICATE",
flags: { traces: true, metrics: false, logs: false },
},
{
signal: "stdout-only logs",
envKey: "OTEL_EXPORTER_OTLP_LOGS_CERTIFICATE",
flags: { traces: true, metrics: false, logs: true, logsExporter: "stdout" },
},
] as const)("does not read TLS files for $signal", async ({ envKey, flags }) => {
process.env[envKey] = "/definitely-missing/qa-otel-inactive-signal-root.pem";
await startOtelService(flags);
expect(sdkCtor).toHaveBeenCalledTimes(1);
});
test.each([
["traces", { traces: true }, "unsupported proxy protocol"],
["metrics", { metrics: true }, "invalid proxy URL"],
["logs", { logs: true }, "unsupported proxy protocol"],
] as const)(
"refuses direct %s export when the configured proxy cannot initialize",
async (_signal, signals, errorMessage) => {
createNodeProxyAgentMock.mockImplementation(() => {
throw new Error(errorMessage);
});
await expect(
startOtelService({ endpoint: "https://collector.example.com/otlp", ...signals }),
).rejects.toThrow(
"Configured telemetry proxy is invalid or unsupported; refusing direct export",
);
expect(traceExporterCtor).not.toHaveBeenCalled();
expect(metricExporterCtor).not.toHaveBeenCalled();
expect(logExporterCtor).not.toHaveBeenCalled();
},
);
test("redacts proxy credentials from telemetry startup failures", async () => {
const proxyPassword = "qa-otel-proxy-password-sentinel";
createNodeProxyAgentMock.mockImplementation(() => {
throw new Error("unsupported proxy protocol");
throw new Error(`Invalid proxy URL: "https://operator:${proxyPassword}@proxy.example.com"`);
});
const { ctx } = await startOtelService({
const failure = await startOtelService({
endpoint: "https://collector.example.com/otlp",
traces: true,
metrics: true,
logs: true,
});
}).catch((error: unknown) => error);
expect(firstExporterOptions(traceExporterCtor).httpAgentOptions).toBeUndefined();
expect(firstExporterOptions(metricExporterCtor).httpAgentOptions).toBeUndefined();
expect(firstExporterOptions(logExporterCtor).httpAgentOptions).toBeUndefined();
expect(ctx.logger.warn).toHaveBeenCalledWith(
"diagnostics-otel: env proxy agent unavailable for OTLP traces exporter; falling back to default Node agent",
);
expect(ctx.logger.warn).toHaveBeenCalledWith(
"diagnostics-otel: env proxy agent unavailable for OTLP metrics exporter; falling back to default Node agent",
);
expect(ctx.logger.warn).toHaveBeenCalledWith(
"diagnostics-otel: env proxy agent unavailable for OTLP logs exporter; falling back to default Node agent",
);
expect(failure).toBeInstanceOf(Error);
expect(failure).toMatchObject({
message: "Configured telemetry proxy is invalid or unsupported; refusing direct export",
});
expect(failure).not.toHaveProperty("cause");
expect(String(failure)).not.toContain(proxyPassword);
expect(traceExporterCtor).not.toHaveBeenCalled();
});
test.each([
{
disabledSignal: "traces",
enabledSignal: "metrics",
disabledEndpoint: "tracesEndpoint",
signals: { traces: false, metrics: true },
},
{
disabledSignal: "metrics",
enabledSignal: "traces",
disabledEndpoint: "metricsEndpoint",
signals: { traces: true, metrics: false },
},
] as const)(
"does not resolve proxy settings for disabled $disabledSignal export",
async ({ disabledSignal, enabledSignal, disabledEndpoint, signals }) => {
createNodeProxyAgentMock.mockImplementation(({ targetUrl }: { targetUrl: string }) => {
if (targetUrl.includes(`disabled-${disabledSignal}.example.com`)) {
throw new Error("invalid disabled-signal proxy");
}
return nodeProxyAgent;
});
await startOtelService({
endpoint: "https://collector.example.com/otlp",
...signals,
configure: (ctx) => {
ctx.config.diagnostics!.otel![disabledEndpoint] =
`https://disabled-${disabledSignal}.example.com/otlp`;
},
});
expect(createNodeProxyAgentCalls()).toEqual([
expect.objectContaining({
targetUrl: `https://collector.example.com/otlp/v1/${enabledSignal}`,
}),
]);
},
);
test("leaves OTLP HTTP exporters on their default agents when env proxy is bypassed", async () => {
await startOtelService({
endpoint: "https://collector.example.com/otlp",
+51 -33
View File
@@ -141,9 +141,8 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
return;
}
const endpoint = normalizeEndpoint(
otel.endpoint ?? process.env[OTEL_EXPORTER_OTLP_ENDPOINT_ENV],
);
const sharedEnvEndpoint = process.env[OTEL_EXPORTER_OTLP_ENDPOINT_ENV];
const endpoint = normalizeEndpoint(otel.endpoint ?? sharedEnvEndpoint);
const headers = otel.headers ?? undefined;
const serviceName =
otel.serviceName?.trim() || process.env.OTEL_SERVICE_NAME || DEFAULT_SERVICE_NAME;
@@ -155,35 +154,48 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
[ATTR_SERVICE_NAME]: serviceName,
});
const logUrl = resolveSignalOtelUrl({
signalEndpoint: otel.logsEndpoint,
signalEnvEndpoint: process.env[OTEL_EXPORTER_OTLP_LOGS_ENDPOINT_ENV],
endpoint,
path: "v1/logs",
});
const logUrl = logsToOtlp
? resolveSignalOtelUrl({
signalEndpoint: otel.logsEndpoint,
signalEnvEndpoint: process.env[OTEL_EXPORTER_OTLP_LOGS_ENDPOINT_ENV],
sharedEnvEndpoint,
endpoint,
path: "v1/logs",
})
: undefined;
const traceUrl =
!sdkPreloaded && tracesEnabled
? resolveSignalOtelUrl({
signalEndpoint: otel.tracesEndpoint,
signalEnvEndpoint: process.env[OTEL_EXPORTER_OTLP_TRACES_ENDPOINT_ENV],
sharedEnvEndpoint,
endpoint,
path: "v1/traces",
})
: undefined;
const metricUrl =
!sdkPreloaded && metricsEnabled
? resolveSignalOtelUrl({
signalEndpoint: otel.metricsEndpoint,
signalEnvEndpoint: process.env[OTEL_EXPORTER_OTLP_METRICS_ENDPOINT_ENV],
sharedEnvEndpoint,
endpoint,
path: "v1/metrics",
})
: undefined;
// Validate every owned signal before any SDK can export with downgraded TLS trust.
const logHttpAgentOptions = logsToOtlp
? resolveOtelHttpAgentOptions({ url: logUrl, signalIdentifier: "LOGS" })
: undefined;
const traceHttpAgentOptions =
!sdkPreloaded && tracesEnabled
? resolveOtelHttpAgentOptions({ url: traceUrl, signalIdentifier: "TRACES" })
: undefined;
const metricHttpAgentOptions =
!sdkPreloaded && metricsEnabled
? resolveOtelHttpAgentOptions({ url: metricUrl, signalIdentifier: "METRICS" })
: undefined;
if (!sdkPreloaded && (tracesEnabled || metricsEnabled)) {
const traceUrl = resolveSignalOtelUrl({
signalEndpoint: otel.tracesEndpoint,
signalEnvEndpoint: process.env[OTEL_EXPORTER_OTLP_TRACES_ENDPOINT_ENV],
endpoint,
path: "v1/traces",
});
const metricUrl = resolveSignalOtelUrl({
signalEndpoint: otel.metricsEndpoint,
signalEnvEndpoint: process.env[OTEL_EXPORTER_OTLP_METRICS_ENDPOINT_ENV],
endpoint,
path: "v1/metrics",
});
const traceHttpAgentOptions = resolveOtelHttpAgentOptions({
url: traceUrl,
signalIdentifier: "TRACES",
logger: ctx.logger,
});
const metricHttpAgentOptions = resolveOtelHttpAgentOptions({
url: metricUrl,
signalIdentifier: "METRICS",
logger: ctx.logger,
});
const traceExporter = tracesEnabled
? new OTLPTraceExporter({
...(traceUrl ? { url: traceUrl } : {}),
@@ -219,8 +231,13 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
sdk = new NodeSDK({
resource,
...(spanProcessors ? { spanProcessors } : traceExporter ? { traceExporter } : {}),
...(metricReader ? { metricReader } : {}),
...(spanProcessors
? { spanProcessors }
: traceExporter
? { traceExporter }
: { spanProcessors: [] }),
metricReaders: metricReader ? [metricReader] : [],
logRecordProcessors: [],
...(sampleRate !== undefined
? {
sampler: new ParentBasedSampler({
@@ -268,6 +285,7 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
logsEnabled,
logsToOtlp,
logsToStdout,
logHttpAgentOptions,
logUrl,
resource,
serviceName,
+95 -113
View File
@@ -1,5 +1,9 @@
// Diagnostics Prometheus plugin module implements service behavior.
import type { IncomingMessage, ServerResponse } from "node:http";
import {
normalizeDiagnosticValue,
normalizeDiagnosticLane,
} from "openclaw/plugin-sdk/diagnostic-runtime";
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
import type {
DiagnosticEventMetadata,
@@ -49,34 +53,8 @@ const BYTE_BUCKETS = [
4294967296, 17179869184,
];
const RATIO_BUCKETS = [0.01, 0.05, 0.1, 0.25, 0.5, 0.75, 1, 2, 4, 8, 16];
const LOW_CARDINALITY_VALUE_RE = /^[A-Za-z0-9_.:-]{1,120}$/u;
const MAX_PROMETHEUS_SERIES = 2048;
const DROPPED_SERIES_COUNTER_NAME = "openclaw_prometheus_series_dropped_total";
function lowCardinalityLabel(value: string | undefined, fallback = "unknown"): string {
if (!value) {
return fallback;
}
const redacted = redactSensitiveText(value.trim());
const redactedLower = redacted.toLowerCase();
if (redactedLower.startsWith("agent:") || redactedLower.includes(":agent:")) {
return fallback;
}
return LOW_CARDINALITY_VALUE_RE.test(redacted) ? redacted : fallback;
}
function lowCardinalityQueueLaneLabel(value: string | undefined, fallback = "unknown"): string {
if (!value) {
return fallback;
}
const redacted = redactSensitiveText(value.trim());
const redactedLower = redacted.toLowerCase();
if (redactedLower.startsWith("agent:")) {
return fallback;
}
const scopedLaneIndex = redacted.indexOf(":");
const lane = scopedLaneIndex >= 0 ? redacted.slice(0, scopedLaneIndex) : redacted;
return LOW_CARDINALITY_VALUE_RE.test(lane) ? lane : fallback;
}
function numericValue(value: number | undefined): number | undefined {
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
@@ -310,12 +288,12 @@ function runLabels(evt: {
trigger?: string;
}): LabelSet {
return {
...(evt.blockedBy ? { blocked_by: lowCardinalityLabel(evt.blockedBy) } : {}),
channel: lowCardinalityLabel(evt.channel),
model: lowCardinalityLabel(evt.model),
outcome: lowCardinalityLabel(evt.outcome, "unknown"),
provider: lowCardinalityLabel(evt.provider),
trigger: lowCardinalityLabel(evt.trigger),
...(evt.blockedBy ? { blocked_by: normalizeDiagnosticValue(evt.blockedBy) } : {}),
channel: normalizeDiagnosticValue(evt.channel),
model: normalizeDiagnosticValue(evt.model),
outcome: normalizeDiagnosticValue(evt.outcome, "unknown"),
provider: normalizeDiagnosticValue(evt.provider),
trigger: normalizeDiagnosticValue(evt.trigger),
};
}
@@ -329,14 +307,16 @@ function modelCallLabels(evt: {
type: string;
}): LabelSet {
return {
api: lowCardinalityLabel(evt.api),
api: normalizeDiagnosticValue(evt.api),
error_category:
evt.type === "model.call.error" ? lowCardinalityLabel(evt.errorCategory, "other") : "none",
model: lowCardinalityLabel(evt.model),
evt.type === "model.call.error"
? normalizeDiagnosticValue(evt.errorCategory, "other")
: "none",
model: normalizeDiagnosticValue(evt.model),
observation_unit: evt.observationUnit === "turn" ? "turn" : "request",
outcome: evt.type === "model.call.error" ? "error" : "completed",
provider: lowCardinalityLabel(evt.provider),
transport: lowCardinalityLabel(evt.transport),
provider: normalizeDiagnosticValue(evt.provider),
transport: normalizeDiagnosticValue(evt.transport),
};
}
@@ -344,13 +324,13 @@ function modelFailoverLabels(
evt: Extract<DiagnosticEventPayload, { type: "model.failover" }>,
): LabelSet {
return {
from_model: lowCardinalityLabel(evt.fromModel),
from_provider: lowCardinalityLabel(evt.fromProvider),
lane: lowCardinalityQueueLaneLabel(evt.lane),
reason: lowCardinalityLabel(evt.reason, "other"),
from_model: normalizeDiagnosticValue(evt.fromModel),
from_provider: normalizeDiagnosticValue(evt.fromProvider),
lane: normalizeDiagnosticLane(evt.lane),
reason: normalizeDiagnosticValue(evt.reason, "other"),
suspended: evt.suspended === undefined ? "unknown" : String(evt.suspended),
to_model: lowCardinalityLabel(evt.toModel),
to_provider: lowCardinalityLabel(evt.toProvider),
to_model: normalizeDiagnosticValue(evt.toModel),
to_provider: normalizeDiagnosticValue(evt.toProvider),
};
}
@@ -365,13 +345,13 @@ function toolExecutionLabels(evt: {
return {
error_category:
evt.type === "tool.execution.error"
? lowCardinalityLabel(evt.errorCategory, "other")
? normalizeDiagnosticValue(evt.errorCategory, "other")
: "none",
outcome: evt.type === "tool.execution.error" ? "error" : "completed",
params_kind: lowCardinalityLabel(evt.paramsSummary?.kind),
tool: lowCardinalityLabel(evt.toolName, "tool"),
tool_owner: lowCardinalityLabel(evt.toolOwner, "none"),
tool_source: lowCardinalityLabel(evt.toolSource, "core"),
params_kind: normalizeDiagnosticValue(evt.paramsSummary?.kind),
tool: normalizeDiagnosticValue(evt.toolName, "tool"),
tool_owner: normalizeDiagnosticValue(evt.toolOwner, "none"),
tool_source: normalizeDiagnosticValue(evt.toolSource, "core"),
};
}
@@ -379,11 +359,11 @@ function toolExecutionBlockedLabels(
evt: Extract<DiagnosticEventPayload, { type: "tool.execution.blocked" }>,
): LabelSet {
return {
denied_reason: lowCardinalityLabel(evt.deniedReason, "other"),
params_kind: lowCardinalityLabel(evt.paramsSummary?.kind),
tool: lowCardinalityLabel(evt.toolName, "tool"),
tool_owner: lowCardinalityLabel(evt.toolOwner, "none"),
tool_source: lowCardinalityLabel(evt.toolSource, "core"),
denied_reason: normalizeDiagnosticValue(evt.deniedReason, "other"),
params_kind: normalizeDiagnosticValue(evt.paramsSummary?.kind),
tool: normalizeDiagnosticValue(evt.toolName, "tool"),
tool_owner: normalizeDiagnosticValue(evt.toolOwner, "none"),
tool_source: normalizeDiagnosticValue(evt.toolSource, "core"),
};
}
@@ -394,10 +374,10 @@ function skillLabels(evt: {
skillSource?: string;
}): LabelSet {
return {
activation: lowCardinalityLabel(evt.activation, "unknown"),
agent: lowCardinalityLabel(evt.agentId),
skill: lowCardinalityLabel(evt.skillName, "skill"),
source: lowCardinalityLabel(evt.skillSource),
activation: normalizeDiagnosticValue(evt.activation, "unknown"),
agent: normalizeDiagnosticValue(evt.agentId),
skill: normalizeDiagnosticValue(evt.skillName, "skill"),
source: normalizeDiagnosticValue(evt.skillSource),
};
}
@@ -413,15 +393,17 @@ function harnessLabels(evt: {
type: string;
}): LabelSet {
return {
channel: lowCardinalityLabel(evt.channel),
channel: normalizeDiagnosticValue(evt.channel),
error_category:
evt.type === "harness.run.error" ? lowCardinalityLabel(evt.errorCategory, "other") : "none",
harness: lowCardinalityLabel(evt.harnessId),
model: lowCardinalityLabel(evt.model),
outcome: evt.type === "harness.run.error" ? "error" : lowCardinalityLabel(evt.outcome),
phase: evt.type === "harness.run.error" ? lowCardinalityLabel(evt.phase) : "none",
plugin: lowCardinalityLabel(evt.pluginId),
provider: lowCardinalityLabel(evt.provider),
evt.type === "harness.run.error"
? normalizeDiagnosticValue(evt.errorCategory, "other")
: "none",
harness: normalizeDiagnosticValue(evt.harnessId),
model: normalizeDiagnosticValue(evt.model),
outcome: evt.type === "harness.run.error" ? "error" : normalizeDiagnosticValue(evt.outcome),
phase: evt.type === "harness.run.error" ? normalizeDiagnosticValue(evt.phase) : "none",
plugin: normalizeDiagnosticValue(evt.pluginId),
provider: normalizeDiagnosticValue(evt.provider),
};
}
@@ -432,8 +414,8 @@ function webhookLabels(
>,
): LabelSet {
return {
channel: lowCardinalityLabel(evt.channel),
webhook: lowCardinalityLabel(evt.updateType),
channel: normalizeDiagnosticValue(evt.channel),
webhook: normalizeDiagnosticValue(evt.updateType),
};
}
@@ -441,7 +423,7 @@ function sessionStuckLabels(
evt: Extract<DiagnosticEventPayload, { type: "session.stuck" }>,
): LabelSet {
return {
reason: lowCardinalityLabel(evt.reason, "none"),
reason: normalizeDiagnosticValue(evt.reason, "none"),
state: evt.state,
};
}
@@ -455,11 +437,11 @@ function sessionRecoveryLabels(
return {
action:
evt.type === "session.recovery.completed"
? lowCardinalityLabel(evt.action, "unknown")
? normalizeDiagnosticValue(evt.action, "unknown")
: evt.allowActiveAbort
? "abort"
: "recover",
active_work_kind: lowCardinalityLabel(evt.activeWorkKind, "none"),
active_work_kind: normalizeDiagnosticValue(evt.activeWorkKind, "none"),
state: evt.state,
status: evt.type === "session.recovery.completed" ? evt.status : "requested",
};
@@ -469,7 +451,7 @@ function livenessLabels(
evt: Extract<DiagnosticEventPayload, { type: "diagnostic.liveness.warning" }>,
): LabelSet {
return {
reason: lowCardinalityLabel(evt.reasons.join(":"), "unknown"),
reason: normalizeDiagnosticValue(evt.reasons.join(":"), "unknown"),
};
}
@@ -478,20 +460,20 @@ function payloadLargeLabels(
): LabelSet {
return {
action: evt.action,
channel: lowCardinalityLabel(evt.channel, "none"),
plugin: lowCardinalityLabel(evt.pluginId, "none"),
reason: lowCardinalityLabel(evt.reason, "none"),
surface: lowCardinalityLabel(evt.surface, "unknown"),
channel: normalizeDiagnosticValue(evt.channel, "none"),
plugin: normalizeDiagnosticValue(evt.pluginId, "none"),
reason: normalizeDiagnosticValue(evt.reason, "none"),
surface: normalizeDiagnosticValue(evt.surface, "unknown"),
};
}
function talkLabels(evt: Extract<DiagnosticEventPayload, { type: "talk.event" }>): LabelSet {
return {
brain: lowCardinalityLabel(evt.brain),
event_type: lowCardinalityLabel(evt.talkEventType),
mode: lowCardinalityLabel(evt.mode),
provider: lowCardinalityLabel(evt.provider),
transport: lowCardinalityLabel(evt.transport),
brain: normalizeDiagnosticValue(evt.brain),
event_type: normalizeDiagnosticValue(evt.talkEventType),
mode: normalizeDiagnosticValue(evt.mode),
provider: normalizeDiagnosticValue(evt.provider),
transport: normalizeDiagnosticValue(evt.transport),
};
}
@@ -500,10 +482,10 @@ function recordModelUsage(
evt: Extract<DiagnosticEventPayload, { type: "model.usage" }>,
) {
const labels = {
agent: lowCardinalityLabel(evt.agentId),
channel: lowCardinalityLabel(evt.channel),
model: lowCardinalityLabel(evt.model),
provider: lowCardinalityLabel(evt.provider),
agent: normalizeDiagnosticValue(evt.agentId),
channel: normalizeDiagnosticValue(evt.channel),
model: normalizeDiagnosticValue(evt.model),
provider: normalizeDiagnosticValue(evt.provider),
};
const usage = evt.usage;
const recordTokens = (tokenType: string, value: number | undefined) => {
@@ -643,17 +625,17 @@ function recordDiagnosticEvent(
return;
case "message.processed":
store.counter("openclaw_message_processed_total", "Inbound messages processed by outcome.", {
channel: lowCardinalityLabel(evt.channel),
channel: normalizeDiagnosticValue(evt.channel),
outcome: evt.outcome,
reason: lowCardinalityLabel(evt.reason, "none"),
reason: normalizeDiagnosticValue(evt.reason, "none"),
});
store.histogram(
"openclaw_message_processed_duration_seconds",
"Inbound message processing duration in seconds.",
{
channel: lowCardinalityLabel(evt.channel),
channel: normalizeDiagnosticValue(evt.channel),
outcome: evt.outcome,
reason: lowCardinalityLabel(evt.reason, "none"),
reason: normalizeDiagnosticValue(evt.reason, "none"),
},
seconds(evt.durationMs),
);
@@ -685,15 +667,15 @@ function recordDiagnosticEvent(
"openclaw_message_delivery_started_total",
"Outbound message delivery attempts started.",
{
channel: lowCardinalityLabel(evt.channel),
delivery_kind: lowCardinalityLabel(evt.deliveryKind, "other"),
channel: normalizeDiagnosticValue(evt.channel),
delivery_kind: normalizeDiagnosticValue(evt.deliveryKind, "other"),
},
);
return;
case "message.received":
store.counter("openclaw_message_received_total", "Inbound messages received by channel.", {
channel: lowCardinalityLabel(evt.channel),
source: lowCardinalityLabel(evt.source),
channel: normalizeDiagnosticValue(evt.channel),
source: normalizeDiagnosticValue(evt.source),
});
return;
case "message.dispatch.started":
@@ -701,8 +683,8 @@ function recordDiagnosticEvent(
"openclaw_message_dispatch_started_total",
"Inbound message dispatch attempts started by channel.",
{
channel: lowCardinalityLabel(evt.channel),
source: lowCardinalityLabel(evt.source),
channel: normalizeDiagnosticValue(evt.channel),
source: normalizeDiagnosticValue(evt.source),
},
);
return;
@@ -711,20 +693,20 @@ function recordDiagnosticEvent(
"openclaw_message_dispatch_completed_total",
"Inbound message dispatch attempts completed by outcome.",
{
channel: lowCardinalityLabel(evt.channel),
channel: normalizeDiagnosticValue(evt.channel),
outcome: evt.outcome,
reason: lowCardinalityLabel(evt.reason, "none"),
source: lowCardinalityLabel(evt.source),
reason: normalizeDiagnosticValue(evt.reason, "none"),
source: normalizeDiagnosticValue(evt.source),
},
);
store.histogram(
"openclaw_message_dispatch_duration_seconds",
"Inbound message dispatch duration in seconds.",
{
channel: lowCardinalityLabel(evt.channel),
channel: normalizeDiagnosticValue(evt.channel),
outcome: evt.outcome,
reason: lowCardinalityLabel(evt.reason, "none"),
source: lowCardinalityLabel(evt.source),
reason: normalizeDiagnosticValue(evt.reason, "none"),
source: normalizeDiagnosticValue(evt.source),
},
seconds(evt.durationMs),
);
@@ -735,11 +717,11 @@ function recordDiagnosticEvent(
"openclaw_message_delivery_total",
"Outbound message delivery attempts by outcome.",
{
channel: lowCardinalityLabel(evt.channel),
delivery_kind: lowCardinalityLabel(evt.deliveryKind, "other"),
channel: normalizeDiagnosticValue(evt.channel),
delivery_kind: normalizeDiagnosticValue(evt.deliveryKind, "other"),
error_category:
evt.type === "message.delivery.error"
? lowCardinalityLabel(evt.errorCategory, "other")
? normalizeDiagnosticValue(evt.errorCategory, "other")
: "none",
outcome: evt.type === "message.delivery.error" ? "error" : "completed",
},
@@ -748,11 +730,11 @@ function recordDiagnosticEvent(
"openclaw_message_delivery_duration_seconds",
"Outbound message delivery duration in seconds.",
{
channel: lowCardinalityLabel(evt.channel),
delivery_kind: lowCardinalityLabel(evt.deliveryKind, "other"),
channel: normalizeDiagnosticValue(evt.channel),
delivery_kind: normalizeDiagnosticValue(evt.deliveryKind, "other"),
error_category:
evt.type === "message.delivery.error"
? lowCardinalityLabel(evt.errorCategory, "other")
? normalizeDiagnosticValue(evt.errorCategory, "other")
: "none",
outcome: evt.type === "message.delivery.error" ? "error" : "completed",
},
@@ -795,7 +777,7 @@ function recordDiagnosticEvent(
"openclaw_queue_lane_size",
"Current diagnostic queue lane size.",
{
lane: lowCardinalityQueueLaneLabel(evt.lane),
lane: normalizeDiagnosticLane(evt.lane),
},
numericValue(evt.queueSize),
);
@@ -803,14 +785,14 @@ function recordDiagnosticEvent(
store.histogram(
"openclaw_queue_lane_wait_seconds",
"Queue lane wait time in seconds.",
{ lane: lowCardinalityQueueLaneLabel(evt.lane) },
{ lane: normalizeDiagnosticLane(evt.lane) },
seconds(evt.waitMs),
);
}
return;
case "session.state":
store.counter("openclaw_session_state_total", "Session state observations.", {
reason: lowCardinalityLabel(evt.reason, "none"),
reason: normalizeDiagnosticValue(evt.reason, "none"),
state: evt.state,
});
if (evt.queueDepth !== undefined) {
@@ -839,8 +821,8 @@ function recordDiagnosticEvent(
return;
case "session.turn.created":
store.counter("openclaw_session_turn_created_total", "Agent session turns created.", {
agent: lowCardinalityLabel(evt.agentId),
channel: lowCardinalityLabel(evt.channel),
agent: normalizeDiagnosticValue(evt.agentId),
channel: normalizeDiagnosticValue(evt.channel),
trigger: evt.trigger,
});
return;
@@ -974,8 +956,8 @@ function recordDiagnosticEvent(
break;
case "telemetry.exporter":
store.counter("openclaw_telemetry_exporter_total", "Telemetry exporter lifecycle events.", {
exporter: lowCardinalityLabel(evt.exporter),
reason: lowCardinalityLabel(evt.reason, "none"),
exporter: normalizeDiagnosticValue(evt.exporter),
reason: normalizeDiagnosticValue(evt.reason, "none"),
signal: evt.signal,
status: evt.status,
});
@@ -66,10 +66,11 @@ export async function dispatchPluginDiscordInteractiveEvent(params: {
},
reply: async ({ text, ephemeral = true }: { text: string; ephemeral?: boolean }) => {
responded = true;
await params.interaction.reply({
content: text,
ephemeral,
});
const payload = { content: text, ephemeral };
// Deferred component replies edit the public source; follow-ups preserve reply visibility.
await (acknowledged
? params.interaction.followUp(payload)
: params.interaction.reply(payload));
},
followUp: async ({ text, ephemeral = true }: { text: string; ephemeral?: boolean }) => {
responded = true;
@@ -919,6 +919,48 @@ describe("discord component interactions", () => {
expect(dispatchReplyMock).not.toHaveBeenCalled();
});
it.each([
{ visibility: "private", ephemeral: true },
{ visibility: "public", ephemeral: false },
{ visibility: "default-private", ephemeral: undefined },
])(
"sends $visibility plugin replies as new messages after component acknowledgment",
async ({ ephemeral }) => {
registerDiscordComponentEntries({
entries: [createButtonEntry({ callbackData: "codex:approve" })],
modals: [],
});
dispatchPluginInteractiveHandlerMock.mockImplementation(async (params: unknown) => {
const typedParams = params as {
onMatched: () => Promise<void>;
respond: { reply: (payload: { text: string; ephemeral?: boolean }) => Promise<void> };
};
await typedParams.onMatched();
await typedParams.respond.reply({
text: "Plugin result",
...(ephemeral === undefined ? {} : { ephemeral }),
});
return { matched: true, handled: true, duplicate: false };
});
const acknowledge = vi.fn().mockResolvedValue(undefined);
const followUp = vi.fn().mockResolvedValue(undefined);
const reply = vi.fn().mockResolvedValue(undefined);
const button = createDiscordComponentButton(createComponentContext());
const { interaction } = createComponentButtonInteraction({ acknowledge, followUp, reply });
await button.run(interaction, { cid: "btn_1" } as ComponentData);
expect(acknowledge).toHaveBeenCalledTimes(1);
expect(followUp).toHaveBeenCalledWith({
content: "Plugin result",
ephemeral: ephemeral ?? true,
});
expect(reply).not.toHaveBeenCalled();
expect(dispatchReplyMock).not.toHaveBeenCalled();
},
);
it("lets plugin Discord interactions clear components after acknowledging", async () => {
registerDiscordComponentEntries({
entries: [createButtonEntry({ callbackData: "codex:approve" })],
@@ -66,6 +66,38 @@ describe("searchMessagesDiscord", () => {
expect(result).toEqual(results);
});
it("preserves valid empty Discord search results", async () => {
const results = { messages: [], total_results: 0 };
restMock.get.mockResolvedValueOnce(results);
await expect(
searchMessagesDiscord({ guildId: "G1", content: "test" }, { cfg: {} as never }),
).resolves.toEqual(results);
});
it("surfaces a pending Discord search index and its retry delay", async () => {
restMock.get.mockResolvedValueOnce({
message: "Index not yet available. Try again later",
code: 110000,
documents_indexed: 0,
retry_after: 2,
});
await expect(
searchMessagesDiscord({ guildId: "G1", content: "test" }, { cfg: {} as never }),
).rejects.toThrow(
"Discord message search unavailable: Index not yet available. Try again later (retry after 2s)",
);
});
it("rejects object search responses without a messages array", async () => {
restMock.get.mockResolvedValueOnce({ total_results: 1 });
await expect(
searchMessagesDiscord({ guildId: "G1", content: "test" }, { cfg: {} as never }),
).rejects.toThrow("Unexpected Discord response for message search: expected messages array.");
});
it("throws a clear error when Discord returns a non-object search response", async () => {
restMock.get.mockResolvedValueOnce("\u001f\ufffd\u0008raw gzip bytes");
+16 -1
View File
@@ -17,6 +17,7 @@ import {
searchGuildMessages,
unpinChannelMessage,
} from "./internal/discord.js";
import { parseDiscordRetryAfterBodySeconds } from "./retry-after.js";
import { resolveDiscordRest } from "./send.shared.js";
import type {
DiscordMessageEdit,
@@ -248,8 +249,22 @@ export async function searchMessagesDiscord(query: DiscordSearchQuery, opts: Dis
const limit = Math.min(Math.max(Math.floor(query.limit), 1), 25);
params.set("limit", String(limit));
}
return assertDiscordResponseObject(
const result = assertDiscordResponseObject(
await searchGuildMessages(rest, query.guildId, params),
"message search",
);
// Discord returns HTTP 202 with code 110000 while the guild search index is warming.
if (result.code === 110000) {
const message =
typeof result.message === "string" && result.message.trim()
? result.message.trim()
: "Discord search index is not yet available";
const retryAfter = parseDiscordRetryAfterBodySeconds(result.retry_after);
const retryHint = retryAfter === undefined ? "" : ` (retry after ${retryAfter}s)`;
throw new Error(`Discord message search unavailable: ${message}${retryHint}`);
}
if (!Array.isArray(result.messages)) {
throw new Error("Unexpected Discord response for message search: expected messages array.");
}
return result;
}
@@ -159,8 +159,9 @@ describe("PDF document extractor", () => {
.mockResolvedValueOnce({ text: "", images: [] });
const extractor = createPdfDocumentExtractor();
await extractor.extract(request({ pageNumbers: [3, 2, 0, 1], maxPages: 2 }));
const result = await extractor.extract(request({ pageNumbers: [3, 2, 0, 1], maxPages: 2 }));
expect(result).toEqual({ text: "", images: [] });
expect(pdfDocument.extract).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ mode: "text", pages: [2, 1] }),
@@ -175,6 +176,24 @@ describe("PDF document extractor", () => {
);
});
it("rejects selected pages outside the PDF page count before extraction", async () => {
pdfDocument.pageCount = 1;
pdfDocument.extract.mockResolvedValueOnce({ text: "", images: [] });
const extractor = createPdfDocumentExtractor();
await expect(extractor.extract(request({ pageNumbers: [2] }))).rejects.toThrow(
"No requested PDF pages exist in this 1-page document.",
);
expect(pdfDocument.extract).not.toHaveBeenCalled();
expect(pdfDocument.destroy).toHaveBeenCalledTimes(1);
await expect(extractor.extract(request({ pageNumbers: [] }))).resolves.toEqual({
text: "",
images: [],
});
expect(pdfDocument.destroy).toHaveBeenCalledTimes(2);
});
it("reports image fallback failures and returns extracted text", async () => {
const onImageExtractionError = vi.fn();
const failure = new Error("render failed");
@@ -189,4 +208,25 @@ describe("PDF document extractor", () => {
expect(onImageExtractionError).toHaveBeenCalledWith(failure);
expect(pdfDocument.destroy).toHaveBeenCalledTimes(1);
});
it.each([
{ label: "empty", text: "", reportError: true },
{ label: "whitespace-only", text: " \t\n", reportError: false },
])("surfaces image fallback failures for $label PDF text", async ({ text, reportError }) => {
const { PdfBudgetError } = await vi.importActual<typeof import("clawpdf")>("clawpdf");
const onImageExtractionError = vi.fn();
const failure = new PdfBudgetError("renderPixels", 100);
pdfDocument.extract.mockResolvedValueOnce({ text, images: [] }).mockRejectedValueOnce(failure);
const overrides = reportError ? { onImageExtractionError } : {};
await expect(createPdfDocumentExtractor().extract(request(overrides))).rejects.toMatchObject({
message: "PDF image extraction failed with no extractable text.",
cause: failure,
});
expect(onImageExtractionError).toHaveBeenCalledTimes(reportError ? 1 : 0);
if (reportError) {
expect(onImageExtractionError).toHaveBeenCalledWith(failure);
}
expect(pdfDocument.destroy).toHaveBeenCalledTimes(1);
});
});
@@ -70,6 +70,9 @@ async function extractPdfContent(
.filter((p) => Number.isInteger(p) && p >= 1 && p <= pdf.pageCount)
.slice(0, request.maxPages)
: undefined;
if (request.pageNumbers?.length && pages?.length === 0) {
throw new Error(`No requested PDF pages exist in this ${pdf.pageCount}-page document.`);
}
const pageSelection = pages ? { pages } : { maxPages: request.maxPages };
const textResult = await pdf.extract({
@@ -116,6 +119,9 @@ async function extractPdfContent(
return { text, images };
} catch (err) {
request.onImageExtractionError?.(err);
if (!text.trim()) {
throw new Error("PDF image extraction failed with no extractable text.", { cause: err });
}
return { text, images: [] };
}
} finally {
@@ -0,0 +1,51 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { listLineAccountIds } from "./accounts.js";
import { lineConfigAdapter } from "./config-adapter.js";
describe("LINE config adapter", () => {
beforeEach(() => {
vi.stubEnv("LINE_CHANNEL_ACCESS_TOKEN", "");
});
afterEach(() => {
vi.unstubAllEnvs();
});
it("clears default account credentials while preserving named accounts", () => {
const cfg = {
channels: {
line: {
channelAccessToken: "default-token",
channelSecret: "default-secret",
tokenFile: "/tmp/default-token",
secretFile: "/tmp/default-secret",
name: "Default LINE",
accounts: {
alerts: {
channelAccessToken: "alerts-token",
channelSecret: "alerts-secret",
},
},
},
},
} satisfies OpenClawConfig;
const nextCfg = lineConfigAdapter.deleteAccount!({ cfg, accountId: "default" });
expect(nextCfg.channels?.line).toMatchObject({
accounts: {
alerts: {
channelAccessToken: "alerts-token",
channelSecret: "alerts-secret",
},
},
});
expect(nextCfg.channels?.line?.channelAccessToken).toBeUndefined();
expect(nextCfg.channels?.line?.channelSecret).toBeUndefined();
expect(nextCfg.channels?.line?.tokenFile).toBeUndefined();
expect(nextCfg.channels?.line?.secretFile).toBeUndefined();
expect(nextCfg.channels?.line?.name).toBeUndefined();
expect(listLineAccountIds(nextCfg)).toEqual(["alerts"]);
});
});
+1 -1
View File
@@ -21,7 +21,7 @@ export const lineConfigAdapter = createScopedChannelConfigAdapter<
resolveAccount: (cfg, accountId) =>
resolveLineAccount({ cfg, accountId: accountId ?? undefined }),
defaultAccountId: resolveDefaultLineAccountId,
clearBaseFields: ["channelSecret", "tokenFile", "secretFile"],
clearBaseFields: ["channelAccessToken", "channelSecret", "tokenFile", "secretFile", "name"],
resolveAllowFrom: (account) => account.config.allowFrom,
formatAllowFrom: (allowFrom) => normalizeStringEntries(allowFrom).map(normalizeLineAllowFrom),
});
@@ -31,6 +31,15 @@ function mockGeneratedPngResponse() {
});
}
function mockEditedPngResponse() {
postMultipartRequestMock.mockResolvedValue({
response: jsonResponse({
data: [{ b64_json: Buffer.from("png-bytes").toString("base64") }],
}),
release: vi.fn(async () => {}),
});
}
function mockObjectArg(mock: unknown, index = -1): Record<string, unknown> {
const calls = (mock as { mock?: { calls?: Array<Array<unknown>> } }).mock?.calls ?? [];
const call = index < 0 ? calls.at(index) : calls[index];
@@ -147,8 +156,8 @@ describe("litellm image generation provider", () => {
});
});
it("routes to the edit endpoint when input images are provided", async () => {
mockGeneratedPngResponse();
it("routes to the edit endpoint as multipart when input images are provided", async () => {
mockEditedPngResponse();
const provider = buildLitellmImageGenerationProvider();
await provider.generateImage({
@@ -164,9 +173,40 @@ describe("litellm image generation provider", () => {
],
});
expect(mockObjectArg(postJsonRequestMock).url).toBe("http://localhost:4000/images/edits");
const call = postJsonRequestMock.mock.calls[0]?.[0] as { body: { images: unknown[] } };
expect(call.body.images).toHaveLength(1);
// Edits must be multipart, never JSON: LiteLLM's /images/edits maps onto
// `aimage_edit(image=...)` and rejects a JSON body outright.
expect(postJsonRequestMock).not.toHaveBeenCalled();
expect(mockObjectArg(postMultipartRequestMock).url).toBe("http://localhost:4000/images/edits");
const form = mockObjectArg(postMultipartRequestMock).body as FormData;
expect(form.get("model")).toBe("gpt-image-2");
expect(form.get("prompt")).toBe("refine the hero");
// A single reference uses the singular `image` part name.
expect(form.getAll("image")).toHaveLength(1);
expect(form.getAll("image[]")).toHaveLength(0);
expect(form.get("image")).toBeInstanceOf(Blob);
});
it("sends multiple reference images as repeated image[] parts", async () => {
mockEditedPngResponse();
const provider = buildLitellmImageGenerationProvider();
await provider.generateImage({
provider: "litellm",
model: "gpt-image-2",
prompt: "merge these",
cfg: {},
inputImages: [
{ buffer: Buffer.from("first"), mimeType: "image/png" },
{ buffer: Buffer.from("second"), mimeType: "image/jpeg" },
],
});
const form = mockObjectArg(postMultipartRequestMock).body as FormData;
// Both names are accepted by OpenAI-compatible edit endpoints, but only one
// may be present per request — sending both is an error.
expect(form.getAll("image[]")).toHaveLength(2);
expect(form.getAll("image")).toHaveLength(0);
});
it("throws a clear error when the API key is missing", async () => {
+23 -18
View File
@@ -3,9 +3,8 @@ import { isIP } from "node:net";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import {
createOpenAiCompatibleImageGenerationProvider,
imageSourceUploadFileName,
type ImageGenerationProvider,
type ImageGenerationSourceImage,
toImageDataUrl,
} from "openclaw/plugin-sdk/image-generation";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { LITELLM_BASE_URL } from "./onboard.js";
@@ -41,10 +40,6 @@ function resolveConfiguredLitellmBaseUrl(cfg: OpenClawConfig | undefined): strin
return normalizeOptionalString(resolveLitellmProviderConfig(cfg)?.baseUrl) ?? LITELLM_BASE_URL;
}
function imageToDataUrl(image: ImageGenerationSourceImage): string {
return toImageDataUrl({ buffer: image.buffer, mimeType: image.mimeType });
}
// LiteLLM's default proxy is loopback. Auto-enable private-network access only
// for loopback-style hosts; LAN/custom private endpoints should use the
// explicit models.providers.litellm.request.allowPrivateNetwork opt-in.
@@ -124,18 +119,28 @@ export function buildLitellmImageGenerationProvider(): ImageGenerationProvider {
size: req.size ?? DEFAULT_SIZE,
},
}),
buildEditRequest: ({ req, inputImages, model, count }) => ({
kind: "json",
body: {
model,
prompt: req.prompt,
n: count,
size: req.size ?? DEFAULT_SIZE,
images: inputImages.map((image) => ({
image_url: imageToDataUrl(image),
})),
},
}),
// LiteLLM's /v1/images/edits is multipart (OpenAI's edits schema): the
// reference image must be an uploaded file part, not a JSON field — a JSON
// body fails before the request reaches the provider.
buildEditRequest: ({ req, inputImages, model, count }) => {
const form = new FormData();
form.set("model", model);
form.set("prompt", req.prompt);
form.set("n", String(count));
form.set("size", req.size ?? DEFAULT_SIZE);
// OpenAI-compatible edits take repeated `image[]` parts when more than one
// reference is supplied, and a single `image` part otherwise.
const partName = inputImages.length > 1 ? "image[]" : "image";
for (const [index, image] of inputImages.entries()) {
const mimeType = normalizeOptionalString(image.mimeType) ?? "image/png";
form.append(
partName,
new Blob([new Uint8Array(image.buffer)], { type: mimeType }),
imageSourceUploadFileName({ image, index }),
);
}
return { kind: "multipart", form };
},
missingApiKeyError: "LiteLLM API key missing",
failureLabels: {
generate: "LiteLLM image generation failed",
@@ -84,6 +84,7 @@ function createRecordingMatrixClient(recorder: WireRecorder): Partial<MatrixClie
};
const client: Partial<MatrixClient> = {
getUserId: async () => BOT_USER_ID,
prepareRoomForMessageSend: async () => "m.room.message",
sendMessage: async (roomId: string, content: Record<string, unknown>) => {
const eventId = mintEventId();
// Snapshot before recording: edit flows reuse content structures, and the
+436 -7
View File
@@ -6,6 +6,9 @@ import os from "node:os";
import path from "node:path";
import { CryptoEvent } from "matrix-js-sdk/lib/crypto-api/CryptoEvent.js";
import type { DecryptionFailureCode as DecryptionFailureCodeValue } from "matrix-js-sdk/lib/crypto-api/index.js";
import { MatrixError } from "matrix-js-sdk/lib/http-api/errors.js";
import { MsgType } from "matrix-js-sdk/lib/matrix.js";
import { EventStatus } from "matrix-js-sdk/lib/models/event-status.js";
import { resetPluginStateStoreForTests } from "openclaw/plugin-sdk/plugin-state-test-runtime";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { installMatrixTestRuntime } from "../test-runtime.js";
@@ -247,6 +250,7 @@ type MatrixJsClientStub = {
setAccountData: ReturnType<typeof vi.fn>;
getRoomIdForAlias: ReturnType<typeof vi.fn>;
sendMessage: ReturnType<typeof vi.fn>;
resendEvent: ReturnType<typeof vi.fn>;
sendEvent: ReturnType<typeof vi.fn>;
sendStateEvent: ReturnType<typeof vi.fn>;
redactEvent: ReturnType<typeof vi.fn>;
@@ -278,12 +282,18 @@ function createMatrixJsClientStub(): MatrixJsClientStub {
client.getDeviceId = vi.fn(() => "DEVICE123");
client.getJoinedRooms = vi.fn(async () => ({ joined_rooms: [] }));
client.getJoinedRoomMembers = vi.fn(async () => ({ joined: {} }));
client.getStateEvent = vi.fn(async () => ({}));
client.getStateEvent = vi.fn(async (_roomId: string, eventType: string) => {
if (eventType === "m.room.encryption") {
throw new MatrixError({ errcode: "M_NOT_FOUND", error: "State event not found" }, 404);
}
return {};
});
client.getAccountData = vi.fn(() => undefined);
client.getAccountDataFromServer = vi.fn(async () => null);
client.setAccountData = vi.fn(async () => {});
client.getRoomIdForAlias = vi.fn(async () => ({ room_id: "!resolved:example.org" }));
client.sendMessage = vi.fn(async () => ({ event_id: "$sent" }));
client.resendEvent = vi.fn(async () => ({ event_id: "$resent" }));
client.sendEvent = vi.fn(async () => ({ event_id: "$sent-event" }));
client.sendStateEvent = vi.fn(async () => ({ event_id: "$state" }));
client.redactEvent = vi.fn(async () => ({ event_id: "$redact" }));
@@ -409,6 +419,425 @@ describe("MatrixClient request hardening", () => {
await expect(first.getTransactionScopeId()).resolves.toBe(await first.getTransactionScopeId());
});
it.each([null, { hasEncryptionStateEvent: () => false }])(
"detects authoritative room encryption when the synced room cache is incomplete",
async (room) => {
matrixJsClient.getRoom.mockReturnValue(room);
matrixJsClient.getStateEvent.mockResolvedValue({ algorithm: "m.megolm.v1.aes-sha2" });
const client = new MatrixClient("https://matrix.example.org", "token");
await expect(client.getMessageWireEventType("!room:example.org")).resolves.toBe(
"m.room.encrypted",
);
expect(matrixJsClient.getStateEvent).toHaveBeenCalledWith(
"!room:example.org",
"m.room.encryption",
"",
);
},
);
it("treats an existing malformed room-encryption state as encrypted", async () => {
matrixJsClient.getRoom.mockReturnValue(null);
matrixJsClient.getStateEvent.mockResolvedValue({});
const client = new MatrixClient("https://matrix.example.org", "token");
await expect(client.getMessageWireEventType("!room:example.org")).resolves.toBe(
"m.room.encrypted",
);
});
it("trusts cached encrypted room state without probing the homeserver", async () => {
matrixJsClient.getRoom.mockReturnValue({ hasEncryptionStateEvent: () => true });
matrixJsClient.getStateEvent.mockRejectedValue(new Error("state unavailable"));
const client = new MatrixClient("https://matrix.example.org", "token");
await expect(client.getMessageWireEventType("!room:example.org")).resolves.toBe(
"m.room.encrypted",
);
expect(matrixJsClient.getStateEvent).not.toHaveBeenCalled();
});
it("preserves persisted encryption settings when the homeserver no longer exposes room state", async () => {
matrixJsClient.getRoom.mockReturnValue({ hasEncryptionStateEvent: () => false });
matrixJsClient.getCrypto.mockReturnValue({
isEncryptionEnabledInRoom: vi.fn(async () => true),
});
matrixJsClient.getStateEvent.mockRejectedValue(
new MatrixError({ errcode: "M_NOT_FOUND", error: "State event not found" }, 404),
);
const client = new MatrixClient("https://matrix.example.org", "token", { encryption: true });
await expect(client.getMessageWireEventType("!room:example.org")).resolves.toBe(
"m.room.encrypted",
);
await expect(
client.sendMessage("!room:example.org", { msgtype: "m.text", body: "secret" }),
).resolves.toBe("$sent");
expect(matrixJsClient.getStateEvent).not.toHaveBeenCalled();
});
it("accepts plaintext only when the homeserver explicitly reports missing encryption state", async () => {
matrixJsClient.getRoom.mockReturnValue(null);
matrixJsClient.getStateEvent.mockRejectedValue(
new MatrixError({ errcode: "M_NOT_FOUND", error: "State event not found" }, 404),
);
const client = new MatrixClient("https://matrix.example.org", "token");
await expect(client.getMessageWireEventType("!room:example.org")).resolves.toBe(
"m.room.message",
);
await expect(
client.sendMessage("!room:example.org", { msgtype: "m.text", body: "hello" }),
).resolves.toBe("$sent");
expect(matrixJsClient.getStateEvent).toHaveBeenCalled();
});
it.each([
new MatrixError({ errcode: "M_UNRECOGNIZED", error: "Endpoint not found" }, 404),
new MatrixError({ errcode: "M_NOT_FOUND", error: "Malformed proxy response" }, 503),
new MatrixError({ errcode: "M_UNKNOWN_TOKEN", error: "Access token not found" }, 401),
new Error("Matrix state endpoint unavailable"),
])("fails closed when authoritative room encryption state cannot be verified", async (error) => {
matrixJsClient.getRoom.mockReturnValue(null);
matrixJsClient.getStateEvent.mockRejectedValue(error);
const client = new MatrixClient("https://matrix.example.org", "token");
await expect(client.getMessageWireEventType("!room:example.org")).rejects.toBe(error);
await expect(
client.sendMessage("!room:example.org", { msgtype: "m.text", body: "secret" }),
).rejects.toBe(error);
expect(matrixJsClient.sendMessage).not.toHaveBeenCalled();
});
it.each(["message", "poll"])(
"blocks %s before plaintext dispatch when the room is encrypted and crypto is disabled",
async (kind) => {
matrixJsClient.getRoom.mockReturnValue(null);
matrixJsClient.getStateEvent.mockResolvedValue({ algorithm: "m.megolm.v1.aes-sha2" });
const client = new MatrixClient("https://matrix.example.org", "token");
const operation =
kind === "message"
? client.sendMessage("!room:example.org", { msgtype: "m.text", body: "secret" })
: client.sendEvent("!room:example.org", "m.poll.start", { "m.text": "secret" });
await expect(operation).rejects.toThrow(/enable encryption/i);
expect(matrixJsClient.sendMessage).not.toHaveBeenCalled();
expect(matrixJsClient.sendEvent).not.toHaveBeenCalled();
},
);
it("blocks encrypted sends until the SDK has a room object available for encryption", async () => {
matrixJsClient.getRoom.mockReturnValue(null);
matrixJsClient.getCrypto.mockReturnValue({
isEncryptionEnabledInRoom: vi.fn(async () => true),
});
const client = new MatrixClient("https://matrix.example.org", "token", { encryption: true });
await expect(
client.sendMessage("!room:example.org", { msgtype: "m.text", body: "secret" }),
).rejects.toThrow(/sync/i);
expect(matrixJsClient.sendMessage).not.toHaveBeenCalled();
});
it("blocks encrypted sends when cached room state and the crypto backend both miss encryption", async () => {
matrixJsClient.getRoom.mockReturnValue({ hasEncryptionStateEvent: () => false });
matrixJsClient.getCrypto.mockReturnValue({
isEncryptionEnabledInRoom: vi.fn(async () => false),
});
matrixJsClient.getStateEvent.mockResolvedValue({ algorithm: "m.megolm.v1.aes-sha2" });
const client = new MatrixClient("https://matrix.example.org", "token", { encryption: true });
await expect(
client.sendMessage("!room:example.org", { msgtype: "m.text", body: "secret" }),
).rejects.toThrow(/sync/i);
expect(matrixJsClient.sendMessage).not.toHaveBeenCalled();
});
it("does not treat an initialized crypto facade as a working SDK encryption backend", async () => {
matrixJsClient.getRoom.mockReturnValue({ hasEncryptionStateEvent: () => true });
const client = new MatrixClient("https://matrix.example.org", "token", { encryption: true });
(client as { crypto?: object }).crypto = {};
await expect(
client.sendMessage("!room:example.org", { msgtype: "m.text", body: "secret" }),
).rejects.toThrow(/enable encryption/i);
expect(matrixJsClient.sendMessage).not.toHaveBeenCalled();
});
it.each([
{
label: "primary attachment",
content: {
msgtype: "m.image",
body: "photo.png",
url: "mxc://example/plain-primary",
},
},
{
label: "audio attachment",
content: {
msgtype: "m.audio",
body: "recording.mp3",
url: "mxc://example/plain-audio",
},
},
{
label: "video attachment",
content: {
msgtype: "m.video",
body: "recording.mp4",
url: "mxc://example/plain-video",
},
},
{
label: "file attachment",
content: {
msgtype: "m.file",
body: "report.pdf",
url: "mxc://example/plain-file",
},
},
{
label: "thumbnail",
content: {
msgtype: "m.image",
body: "photo.png",
info: { thumbnail_url: "mxc://example/plain-thumbnail" },
},
},
{
label: "location thumbnail",
content: {
msgtype: MsgType.Location,
body: "Current location",
geo_uri: "geo:1,2",
info: { thumbnail_url: "mxc://example/plain-location-thumbnail" },
},
},
])("rejects an unencrypted $label in an encrypted room", async ({ content }) => {
matrixJsClient.getRoom.mockReturnValue({ hasEncryptionStateEvent: () => true });
matrixJsClient.getCrypto.mockReturnValue({
isEncryptionEnabledInRoom: vi.fn(async () => true),
});
const client = new MatrixClient("https://matrix.example.org", "token", { encryption: true });
await expect(client.sendMessage("!room:example.org", content)).rejects.toThrow(
/unencrypted media.*retry/i,
);
expect(matrixJsClient.sendMessage).not.toHaveBeenCalled();
});
it.each([
{
label: "primary attachment",
content: {
msgtype: "m.image",
body: "photo.png",
url: "mxc://example/plain-primary",
},
},
{
label: "thumbnail",
content: {
msgtype: "m.image",
body: "photo.png",
info: { thumbnail_url: "mxc://example/plain-thumbnail" },
},
},
{
label: "location thumbnail",
content: {
msgtype: MsgType.Location,
body: "Current location",
geo_uri: "geo:1,2",
info: { thumbnail_url: "mxc://example/plain-location-thumbnail" },
},
},
])("rejects an unencrypted $label sent through the generic event owner", async ({ content }) => {
matrixJsClient.getRoom.mockReturnValue({ hasEncryptionStateEvent: () => true });
matrixJsClient.getCrypto.mockReturnValue({
isEncryptionEnabledInRoom: vi.fn(async () => true),
});
const client = new MatrixClient("https://matrix.example.org", "token", { encryption: true });
await expect(client.sendEvent("!room:example.org", "m.room.message", content)).rejects.toThrow(
/unencrypted media.*retry/i,
);
expect(matrixJsClient.sendEvent).not.toHaveBeenCalled();
});
it("allows fully encrypted attachments and preserves text URLs in encrypted rooms", async () => {
matrixJsClient.getRoom.mockReturnValue({ hasEncryptionStateEvent: () => true });
matrixJsClient.getCrypto.mockReturnValue({
isEncryptionEnabledInRoom: vi.fn(async () => true),
});
const client = new MatrixClient("https://matrix.example.org", "token", { encryption: true });
const encryptedFile = {
url: "mxc://example/encrypted",
key: { alg: "A256CTR", key_ops: ["encrypt", "decrypt"], kty: "oct", k: "key", ext: true },
iv: "iv",
hashes: { sha256: "hash" },
v: "v2",
};
await expect(
client.sendMessage("!room:example.org", {
msgtype: "m.image",
body: "photo.png",
file: encryptedFile,
info: { thumbnail_file: encryptedFile },
}),
).resolves.toBe("$sent");
await expect(
client.sendMessage("!room:example.org", {
msgtype: MsgType.Location,
body: "Current location",
geo_uri: "geo:1,2",
info: { thumbnail_file: encryptedFile },
}),
).resolves.toBe("$sent");
await expect(
client.sendMessage("!room:example.org", {
msgtype: "m.text",
body: "custom link",
url: "https://example.org/custom-text-field",
}),
).resolves.toBe("$sent");
await expect(
client.sendEvent("!room:example.org", "m.room.message", {
msgtype: "m.image",
body: "photo.png",
file: encryptedFile,
info: { thumbnail_file: encryptedFile },
}),
).resolves.toBe("$sent-event");
await expect(
client.sendEvent("!room:example.org", "m.room.message", {
msgtype: MsgType.Location,
body: "Current location",
geo_uri: "geo:1,2",
info: { thumbnail_file: encryptedFile },
}),
).resolves.toBe("$sent-event");
await expect(
client.sendEvent("!room:example.org", "m.room.message", {
msgtype: "m.text",
body: "custom link",
url: "https://example.org/custom-text-field",
}),
).resolves.toBe("$sent-event");
await expect(
client.sendEvent("!room:example.org", "m.poll.start", { "m.text": "Lunch?" }),
).resolves.toBe("$sent-event");
});
it.each([
{ eventType: "m.room.encrypted", message: /encrypted wire events.*sdk/i },
{ eventType: "m.room.redaction", message: /redaction wire events.*redactEvent/i },
])("rejects caller-supplied reserved $eventType wire events", async ({ eventType, message }) => {
matrixJsClient.getRoom.mockReturnValue({ hasEncryptionStateEvent: () => true });
matrixJsClient.getCrypto.mockReturnValue({
isEncryptionEnabledInRoom: vi.fn(async () => true),
});
const client = new MatrixClient("https://matrix.example.org", "token", { encryption: true });
await expect(
client.sendEvent("!room:example.org", eventType, { body: "secret" }),
).rejects.toThrow(message);
expect(matrixJsClient.sendEvent).not.toHaveBeenCalled();
});
it("preserves the dedicated Matrix room-event redaction owner", async () => {
const client = new MatrixClient("https://matrix.example.org", "token");
await expect(client.redactEvent("!room:example.org", "$target")).resolves.toBe("$redact");
expect(matrixJsClient.redactEvent).toHaveBeenCalledWith(
"!room:example.org",
"$target",
undefined,
undefined,
);
});
it("preserves the Matrix protocol exemption for unencrypted reactions", async () => {
matrixJsClient.getRoom.mockReturnValue(null);
matrixJsClient.getStateEvent.mockRejectedValue(new Error("state unavailable"));
const client = new MatrixClient("https://matrix.example.org", "token");
await expect(
client.sendEvent("!room:example.org", "m.reaction", {
"m.relates_to": { event_id: "$target", key: "👍", rel_type: "m.annotation" },
}),
).resolves.toBe("$sent-event");
expect(matrixJsClient.getStateEvent).not.toHaveBeenCalled();
});
it("returns an already-sent durable event without probing current room state", async () => {
matrixJsClient.getRoom.mockReturnValue({
hasEncryptionStateEvent: () => false,
getEventForTxnId: () => ({ status: EventStatus.SENT, getId: () => "$already-sent" }),
});
matrixJsClient.getStateEvent.mockRejectedValue(new Error("state unavailable"));
const client = new MatrixClient("https://matrix.example.org", "token");
await expect(
client.sendMessage(
"!room:example.org",
{ msgtype: "m.text", body: "already delivered" },
"oc_already_sent",
),
).resolves.toBe("$already-sent");
expect(matrixJsClient.getStateEvent).not.toHaveBeenCalled();
expect(matrixJsClient.sendMessage).not.toHaveBeenCalled();
});
it("checks encrypted-room readiness before retrying an unsent durable event", async () => {
matrixJsClient.getRoom.mockReturnValue({
hasEncryptionStateEvent: () => false,
getEventForTxnId: () => ({
status: EventStatus.NOT_SENT,
getId: () => "~pending",
getContent: () => ({ msgtype: "m.text", body: "secret" }),
}),
});
matrixJsClient.getStateEvent.mockResolvedValue({ algorithm: "m.megolm.v1.aes-sha2" });
const client = new MatrixClient("https://matrix.example.org", "token");
await expect(
client.sendMessage("!room:example.org", { msgtype: "m.text", body: "secret" }, "oc_retry"),
).rejects.toThrow(/enable encryption/i);
expect(matrixJsClient.resendEvent).not.toHaveBeenCalled();
});
it("rejects unencrypted attachment references before retrying an unsent encrypted event", async () => {
matrixJsClient.getRoom.mockReturnValue({
hasEncryptionStateEvent: () => true,
getEventForTxnId: () => ({
status: EventStatus.NOT_SENT,
getId: () => "~pending",
getContent: () => ({
msgtype: "m.image",
body: "photo.png",
url: "mxc://example/plain-primary",
}),
}),
});
matrixJsClient.getCrypto.mockReturnValue({
isEncryptionEnabledInRoom: vi.fn(async () => true),
});
const client = new MatrixClient("https://matrix.example.org", "token", { encryption: true });
await expect(
client.sendMessage(
"!room:example.org",
{ msgtype: "m.text", body: "caller content cannot replace the pending event" },
"oc_retry",
),
).rejects.toThrow(/unencrypted media.*retry/i);
expect(matrixJsClient.resendEvent).not.toHaveBeenCalled();
});
it("passes stable transaction ids into matrix-js-sdk timeline sends", async () => {
const client = new MatrixClient("https://matrix.example.org", "token");
@@ -661,9 +1090,9 @@ describe("MatrixClient request hardening", () => {
"m.relates_to": { event_id: "$target", key: "👍", rel_type: "m.annotation" },
});
await Promise.resolve();
await Promise.resolve();
expect(started).toEqual(["message"]);
await vi.waitFor(() => {
expect(started).toEqual(["message"]);
});
expect(matrixJsClient.sendEvent).not.toHaveBeenCalled();
releaseFirst?.();
@@ -696,9 +1125,9 @@ describe("MatrixClient request hardening", () => {
body: "b",
});
await Promise.resolve();
await Promise.resolve();
expect(started).toEqual(["!room-a:example.org", "!room-b:example.org"]);
await vi.waitFor(() => {
expect(started).toEqual(["!room-a:example.org", "!room-b:example.org"]);
});
releaseFirst?.();
@@ -101,6 +101,7 @@ export abstract class MatrixClientBase {
eventType: string,
stateKey?: string,
): Promise<Record<string, unknown>>;
abstract getMessageWireEventType(roomId: string): Promise<"m.room.message" | "m.room.encrypted">;
abstract downloadContent(
mxcUrl: string,
opts?: { allowRemote?: boolean; maxBytes?: number; readIdleTimeoutMs?: number },
@@ -352,8 +353,8 @@ export abstract class MatrixClientBase {
client: this.client,
verificationManager: this.verificationManager,
recoveryKeyStore: this.recoveryKeyStore,
getRoomStateEvent: (roomId, eventType, stateKey = "") =>
this.getRoomStateEvent(roomId, eventType, stateKey),
isRoomEncrypted: async (roomId) =>
(await this.getMessageWireEventType(roomId)) === "m.room.encrypted",
downloadContent: (mxcUrl, opts) => this.downloadContent(mxcUrl, opts),
});
}
@@ -1,8 +1,16 @@
import { createHash } from "node:crypto";
import { MatrixEventEvent, Preset, type MatrixEvent } from "matrix-js-sdk/lib/matrix.js";
import {
EventType,
MatrixError,
MatrixEventEvent,
MsgType,
Preset,
type MatrixEvent,
} from "matrix-js-sdk/lib/matrix.js";
import { EventStatus } from "matrix-js-sdk/lib/models/event-status.js";
import type { Direction } from "matrix-js-sdk/lib/models/event-timeline.js";
import { formatMatrixErrorReason } from "../errors.js";
import { MATRIX_REACTION_EVENT_TYPE } from "../reaction-common.js";
import { MatrixClientBase, type MatrixMessageWireDispatch } from "./client-base.js";
import { matrixEventToRaw, parseMxc } from "./event-helpers.js";
import { noop } from "./logger.js";
@@ -201,6 +209,7 @@ export abstract class MatrixClientCore extends MatrixClientBase {
return existingId;
}
if (existing.status === EventStatus.NOT_SENT && room) {
await this.prepareRoomForMessageSend(roomId, existing.getContent());
const resent = await this.client.resendEvent(existing, room);
return resent.event_id;
}
@@ -209,6 +218,7 @@ export abstract class MatrixClientCore extends MatrixClientBase {
);
}
}
await this.prepareRoomForMessageSend(roomId, content);
const sent = await this.client.sendMessage(roomId, content as never, transactionId);
return sent.event_id;
},
@@ -221,9 +231,62 @@ export abstract class MatrixClientCore extends MatrixClientBase {
return "m.room.encrypted";
}
const crypto = this.client.getCrypto();
return crypto && (await crypto.isEncryptionEnabledInRoom(roomId))
? "m.room.encrypted"
: "m.room.message";
if (crypto && (await crypto.isEncryptionEnabledInRoom(roomId))) {
return "m.room.encrypted";
}
try {
// A missing local room/state is unknown; only the homeserver can prove
// that encryption was never enabled before plaintext leaves the client.
await this.getRoomStateEvent(roomId, "m.room.encryption", "");
return "m.room.encrypted";
} catch (error) {
if (
error instanceof MatrixError &&
error.httpStatus === 404 &&
error.errcode === "M_NOT_FOUND"
) {
return "m.room.message";
}
throw error;
}
}
async prepareRoomForMessageSend(
roomId: string,
content?: MessageEventContent,
): Promise<"m.room.message" | "m.room.encrypted"> {
if ((await this.getMessageWireEventType(roomId)) === "m.room.message") {
return "m.room.message";
}
const crypto = this.client.getCrypto();
if (!crypto) {
throw new Error("Encrypted Matrix room: enable encryption before sending messages");
}
const room = this.client.getRoom(roomId);
// matrix-js-sdk skips encryption for unknown rooms; authoritative state
// alone does not hydrate its Room or configure the crypto backend.
if (
!room ||
(!room.hasEncryptionStateEvent() && !(await crypto.isEncryptionEnabledInRoom(roomId)))
) {
throw new Error("Encrypted Matrix room is not ready: wait for room sync before sending");
}
if (
content &&
(((content.msgtype === MsgType.Image ||
content.msgtype === MsgType.Audio ||
content.msgtype === MsgType.Video ||
content.msgtype === MsgType.File) &&
typeof content.url === "string") ||
(content.info &&
"thumbnail_url" in content.info &&
typeof content.info.thumbnail_url === "string"))
) {
// Room encryption can change after media uploads; never reference a
// plaintext primary or thumbnail from a newly encrypted room event.
throw new Error("Encrypted Matrix room contains unencrypted media; retry the send");
}
return "m.room.encrypted";
}
async sendEvent(
@@ -232,6 +295,21 @@ export abstract class MatrixClientCore extends MatrixClientBase {
content: Record<string, unknown>,
): Promise<string> {
return await this.runSerializedRoomSend(roomId, async () => {
// SDK encryption trusts these wire event types without inspecting their
// payload; only SDK encryption and the dedicated redaction owner may emit them.
if (
eventType === EventType.RoomMessageEncrypted.toString() ||
eventType === EventType.RoomRedaction.toString()
) {
throw new Error(
eventType === EventType.RoomRedaction.toString()
? "Matrix redaction wire events must use redactEvent"
: "Matrix encrypted wire events must be generated by the SDK",
);
}
if (eventType !== MATRIX_REACTION_EVENT_TYPE) {
await this.prepareRoomForMessageSend(roomId, content);
}
const sent = await this.client.sendEvent(roomId, eventType as never, content as never);
return sent.event_id;
});
@@ -39,55 +39,64 @@ function createFacadeHarness(params?: {
client?: Partial<MatrixCryptoFacadeDeps["client"]>;
verificationManager?: Partial<MatrixVerificationManager>;
recoveryKeySummary?: ReturnType<MatrixRecoveryKeyStore["getRecoveryKeySummary"]>;
getRoomStateEvent?: MatrixCryptoFacadeDeps["getRoomStateEvent"];
isRoomEncrypted?: MatrixCryptoFacadeDeps["isRoomEncrypted"];
downloadContent?: MatrixCryptoFacadeDeps["downloadContent"];
}) {
const getRoomStateEvent: MatrixCryptoFacadeDeps["getRoomStateEvent"] =
params?.getRoomStateEvent ?? (async () => ({}));
const isRoomEncrypted: MatrixCryptoFacadeDeps["isRoomEncrypted"] =
params?.isRoomEncrypted ?? (async () => false);
const downloadContent: MatrixCryptoFacadeDeps["downloadContent"] =
params?.downloadContent ?? (async () => Buffer.alloc(0));
const facade = createMatrixCryptoFacade({
client: {
getRoom: params?.client?.getRoom ?? (() => null),
getCrypto: params?.client?.getCrypto ?? (() => undefined),
getUserId: params?.client?.getUserId ?? (() => "@bot:example.org"),
},
verificationManager: createVerificationManagerMock(params?.verificationManager),
recoveryKeyStore: createRecoveryKeyStoreMock(params?.recoveryKeySummary ?? null),
getRoomStateEvent,
isRoomEncrypted,
downloadContent,
});
return { facade, getRoomStateEvent, downloadContent };
return { facade, isRoomEncrypted, downloadContent };
}
describe("createMatrixCryptoFacade", () => {
it("detects encrypted rooms from cached room state", async () => {
it("delegates encrypted-room classification to the canonical client owner", async () => {
const isRoomEncrypted = vi.fn(async () => true);
const { facade } = createFacadeHarness({
client: {
getRoom: () => ({
hasEncryptionStateEvent: () => true,
}),
},
isRoomEncrypted,
});
await expect(facade.isRoomEncrypted("!room:example.org")).resolves.toBe(true);
expect(isRoomEncrypted).toHaveBeenCalledWith("!room:example.org");
});
it("preserves authoritative plaintext-room classification", async () => {
const isRoomEncrypted = vi.fn(async () => false);
const { facade } = createFacadeHarness({
isRoomEncrypted,
});
await expect(facade.isRoomEncrypted("!room:example.org")).resolves.toBe(false);
expect(isRoomEncrypted).toHaveBeenCalledWith("!room:example.org");
});
it("never downgrades an existing malformed encryption event to plaintext", async () => {
const { facade } = createFacadeHarness({
isRoomEncrypted: async () => true,
});
await expect(facade.isRoomEncrypted("!room:example.org")).resolves.toBe(true);
});
it("falls back to server room state when room cache has no encryption event", async () => {
const getRoomStateEvent = vi.fn(async () => ({
algorithm: "m.megolm.v1.aes-sha2",
}));
it("propagates authoritative room-state failures without permitting plaintext", async () => {
const error = new Error("Matrix room state authorization failed");
const { facade } = createFacadeHarness({
client: {
getRoom: () => ({
hasEncryptionStateEvent: () => false,
}),
isRoomEncrypted: async () => {
throw error;
},
getRoomStateEvent,
});
await expect(facade.isRoomEncrypted("!room:example.org")).resolves.toBe(true);
expect(getRoomStateEvent).toHaveBeenCalledWith("!room:example.org", "m.room.encryption", "");
await expect(facade.isRoomEncrypted("!room:example.org")).rejects.toBe(error);
});
it("forwards verification requests and uses client crypto API", async () => {
@@ -110,7 +119,6 @@ describe("createMatrixCryptoFacade", () => {
}));
const { facade } = createFacadeHarness({
client: {
getRoom: () => null,
getCrypto: () => crypto,
},
verificationManager: {
@@ -176,7 +184,6 @@ describe("createMatrixCryptoFacade", () => {
};
const { facade } = createFacadeHarness({
client: {
getRoom: () => null,
getCrypto: () => crypto,
},
verificationManager: {
@@ -11,7 +11,6 @@ import type {
} from "./verification-manager.js";
type MatrixCryptoFacadeClient = {
getRoom: (roomId: string) => { hasEncryptionStateEvent: () => boolean } | null;
getCrypto: () => unknown;
getUserId: () => string | null;
};
@@ -106,11 +105,7 @@ export function createMatrixCryptoFacade(deps: {
client: MatrixCryptoFacadeClient;
verificationManager: MatrixVerificationManager;
recoveryKeyStore: MatrixRecoveryKeyStore;
getRoomStateEvent: (
roomId: string,
eventType: string,
stateKey?: string,
) => Promise<Record<string, unknown>>;
isRoomEncrypted: (roomId: string) => Promise<boolean>;
downloadContent: (
mxcUrl: string,
opts?: { maxBytes?: number; readIdleTimeoutMs?: number },
@@ -129,18 +124,7 @@ export function createMatrixCryptoFacade(deps: {
) => {
// compatibility no-op
},
isRoomEncrypted: async (roomId: string): Promise<boolean> => {
const room = deps.client.getRoom(roomId);
if (room?.hasEncryptionStateEvent()) {
return true;
}
try {
const event = await deps.getRoomStateEvent(roomId, "m.room.encryption", "");
return typeof event.algorithm === "string" && event.algorithm.length > 0;
} catch {
return false;
}
},
isRoomEncrypted: deps.isRoomEncrypted,
requestOwnUserVerification: async () => {
const crypto = deps.client.getCrypto() as MatrixVerificationCryptoApi | undefined;
return await deps.verificationManager.requestOwnUserVerification(crypto);
+168 -4
View File
@@ -118,12 +118,14 @@ const makeClient = () => {
const getEvent = vi.fn();
const getJoinedRoomMembers = vi.fn().mockResolvedValue([]);
const uploadContent = vi.fn().mockResolvedValue("mxc://example/file");
const prepareRoomForMessageSend = vi.fn();
const client = {
sendMessage,
sendEvent,
getEvent,
getJoinedRoomMembers,
uploadContent,
prepareRoomForMessageSend,
getTransactionScopeId: vi.fn().mockResolvedValue("scope-1"),
getMessageWireEventType: vi.fn().mockResolvedValue("m.room.message"),
getUserId: vi.fn().mockResolvedValue("@bot:example.org"),
@@ -132,6 +134,24 @@ const makeClient = () => {
stop: vi.fn(() => undefined),
stopAndPersist: vi.fn(async () => undefined),
} as unknown as import("./sdk.js").MatrixClient;
prepareRoomForMessageSend.mockImplementation(
async (roomId: string, content?: import("./sdk.js").MessageEventContent) => {
const eventType = await client.getMessageWireEventType(roomId);
if (eventType === "m.room.encrypted" && !client.crypto) {
throw new Error("Encrypted Matrix room: enable encryption before sending messages");
}
if (
eventType === "m.room.encrypted" &&
(typeof content?.url === "string" ||
(content?.info &&
"thumbnail_url" in content.info &&
typeof content.info.thumbnail_url === "string"))
) {
throw new Error("Encrypted Matrix room contains unencrypted media; retry the send");
}
return eventType;
},
);
return { client, sendMessage, sendEvent, getEvent, getJoinedRoomMembers, uploadContent };
};
@@ -141,6 +161,7 @@ function makeEncryptedMediaClient() {
isRoomEncrypted: vi.fn().mockResolvedValue(true),
encryptMedia: vi.fn().mockResolvedValue(createEncryptedMediaPayload()),
};
vi.spyOn(result.client, "getMessageWireEventType").mockResolvedValue("m.room.encrypted");
return result;
}
@@ -582,6 +603,7 @@ describe("sendMessageMatrix media", () => {
const uploadArg = mockCallArg(uploadContent, "uploadContent", 0);
expect(Buffer.isBuffer(uploadArg)).toBe(true);
expect(uploadArg).toEqual(Buffer.from("media"));
expect(uploadContent).toHaveBeenCalledWith(Buffer.from("media"), "image/png", "photo.png");
const content = sentContent(sendMessage) as {
url?: string;
@@ -595,6 +617,104 @@ describe("sendMessageMatrix media", () => {
expect(content.url).toBe("mxc://example/file");
});
it("rejects encrypted-room media before upload when encryption is unavailable", async () => {
const { client, sendMessage, uploadContent } = makeClient();
vi.spyOn(client, "getMessageWireEventType").mockResolvedValue("m.room.encrypted");
await expect(
sendMessageMatrix("room:!room:example", "caption", {
client,
cfg: {} as never,
mediaUrl: "file:///tmp/photo.png",
}),
).rejects.toThrow(/enable encryption/i);
expect(uploadContent).not.toHaveBeenCalled();
expect(sendMessage).not.toHaveBeenCalled();
});
it.each(["text", "media"])(
"rejects encrypted-room %s before reporting a platform dispatch when encryption is disabled",
async (kind) => {
const { client, sendMessage, uploadContent } = makeClient();
vi.spyOn(client, "getMessageWireEventType").mockResolvedValue("m.room.encrypted");
const onPlatformSendDispatch = vi.fn();
await expect(
sendMessageMatrix("room:!room:example", "secret", {
client,
cfg: {} as never,
...(kind === "media" ? { mediaUrl: "file:///tmp/photo.png" } : {}),
onPlatformSendDispatch,
}),
).rejects.toThrow(/enable encryption/i);
expect(onPlatformSendDispatch).not.toHaveBeenCalled();
expect(uploadContent).not.toHaveBeenCalled();
expect(sendMessage).not.toHaveBeenCalled();
if (kind === "media") {
expect(loadOutboundMediaFromUrlMock).not.toHaveBeenCalled();
}
},
);
it("rejects uploads when a room becomes encrypted while media is loading", async () => {
const { client, sendMessage, uploadContent } = makeClient();
const onPlatformSendDispatch = vi.fn();
loadOutboundMediaFromUrlMock.mockImplementationOnce(async () => {
vi.spyOn(client, "getMessageWireEventType").mockResolvedValue("m.room.encrypted");
return {
buffer: Buffer.from("secret media"),
fileName: "secret.png",
contentType: "image/png",
kind: "image",
};
});
await expect(
sendMessageMatrix("room:!room:example", "secret", {
client,
cfg: {} as never,
mediaUrl: "file:///tmp/secret.png",
onPlatformSendDispatch,
}),
).rejects.toThrow(/enable encryption/i);
expect(uploadContent).not.toHaveBeenCalled();
expect(sendMessage).not.toHaveBeenCalled();
expect(onPlatformSendDispatch).not.toHaveBeenCalled();
});
it("encrypts uploads when a room becomes encrypted while media is loading", async () => {
const { client, sendMessage, uploadContent } = makeClient();
(client as { crypto?: object }).crypto = {
encryptMedia: vi.fn().mockResolvedValue(createEncryptedMediaPayload()),
};
loadOutboundMediaFromUrlMock.mockImplementationOnce(async () => {
vi.spyOn(client, "getMessageWireEventType").mockResolvedValue("m.room.encrypted");
return {
buffer: Buffer.from("secret media"),
fileName: "secret.png",
contentType: "image/png",
kind: "image",
};
});
await sendMessageMatrix("room:!room:example", "secret", {
client,
cfg: {} as never,
mediaUrl: "file:///tmp/secret.png",
});
expect(uploadContent).toHaveBeenCalledWith(
Buffer.from("encrypted"),
"application/octet-stream",
);
const content = sentContent(sendMessage);
expect(content.file).toBeDefined();
expect(content.url).toBeUndefined();
});
it("records each media and overflow event with its actual kind and reply relation", async () => {
const { client, sendMessage } = makeClient();
resolveTextChunkLimitMock.mockReturnValue(6);
@@ -646,18 +766,26 @@ describe("sendMessageMatrix media", () => {
expect(uploadArg instanceof Uint8Array ? Buffer.from(uploadArg).toString() : undefined).toBe(
"encrypted",
);
expect(uploadContent).toHaveBeenCalledWith(
Buffer.from("encrypted"),
"application/octet-stream",
);
const content = sentContent(sendMessage) as {
url?: string;
file?: { url?: string };
filename?: string;
info?: { mimetype?: string };
};
expect(content.url).toBeUndefined();
expect(content.file?.url).toBe("mxc://example/file");
expect(content.filename).toBe("photo.png");
expect(content.info?.mimetype).toBe("image/png");
});
it("encrypts thumbnail via thumbnail_file when room is encrypted", async () => {
const { client, sendMessage, uploadContent } = makeClient();
const isRoomEncrypted = vi.fn().mockResolvedValue(true);
vi.spyOn(client, "getMessageWireEventType").mockResolvedValue("m.room.encrypted");
const encryptMedia = vi.fn().mockResolvedValue({
buffer: Buffer.from("encrypted-thumb"),
file: {
@@ -668,7 +796,6 @@ describe("sendMessageMatrix media", () => {
},
});
(client as { crypto?: object }).crypto = {
isRoomEncrypted,
encryptMedia,
};
// Return image metadata so thumbnail generation is triggered (image > 800px)
@@ -688,8 +815,11 @@ describe("sendMessageMatrix media", () => {
});
// encryptMedia called twice: once for main media, once for thumbnail
expect(isRoomEncrypted).toHaveBeenCalledTimes(1);
expect(encryptMedia).toHaveBeenCalledTimes(2);
expect(uploadContent.mock.calls).toEqual([
[Buffer.from("encrypted-thumb"), "application/octet-stream"],
[Buffer.from("encrypted-thumb"), "application/octet-stream"],
]);
const content = sentContent(sendMessage) as {
url?: string;
@@ -778,7 +908,10 @@ describe("sendMessageMatrix media", () => {
mediaUrl: "file:///tmp/photo.png",
});
expect(uploadContent).toHaveBeenCalledTimes(2);
expect(uploadContent.mock.calls).toEqual([
[Buffer.from("media"), "image/png", "photo.png"],
[Buffer.from("thumb"), "image/jpeg", "thumbnail.jpg"],
]);
const content = sentContent(sendMessage) as {
info?: {
thumbnail_url?: string;
@@ -801,6 +934,37 @@ describe("sendMessageMatrix media", () => {
});
});
it("rejects mixed attachments when a room becomes encrypted while an image is resized", async () => {
const { client, sendMessage, uploadContent } = makeClient();
const onPlatformSendDispatch = vi.fn();
(client as { crypto?: object }).crypto = {
encryptMedia: vi.fn().mockResolvedValue(createEncryptedMediaPayload()),
};
getImageMetadataMock
.mockResolvedValueOnce({ width: 1600, height: 1200 })
.mockResolvedValueOnce({ width: 800, height: 600 });
resizeToJpegMock.mockImplementationOnce(async () => {
vi.spyOn(client, "getMessageWireEventType").mockResolvedValue("m.room.encrypted");
return Buffer.from("secret thumbnail");
});
await expect(
sendMessageMatrix("room:!room:example", "caption", {
client,
cfg: {} as never,
mediaUrl: "file:///tmp/photo.png",
onPlatformSendDispatch,
}),
).rejects.toThrow(/unencrypted media.*retry/i);
expect(uploadContent.mock.calls).toEqual([
[Buffer.from("media"), "image/png", "photo.png"],
[Buffer.from("encrypted"), "application/octet-stream"],
]);
expect(sendMessage).not.toHaveBeenCalled();
expect(onPlatformSendDispatch).not.toHaveBeenCalled();
});
it("uses explicit cfg for media sends instead of runtime loadConfig fallbacks", async () => {
const { client } = makeClient();
const explicitCfg = {
+7 -6
View File
@@ -38,7 +38,7 @@ import {
buildMediaContent,
prepareImageInfo,
resolveMediaDurationMs,
uploadMediaMaybeEncrypted,
uploadMediaWithEncryption,
} from "./send/media.js";
import { normalizeThreadId, resolveMatrixRoomId } from "./send/targets.js";
import {
@@ -212,12 +212,10 @@ export async function sendMessageMatrix(
},
async (client) => {
const roomId = await resolveMatrixRoomId(client, to);
const wireEventType = await client.prepareRoomForMessageSend(roomId);
const cfg = requireRuntimeConfig(opts.cfg, "Matrix send") as CoreConfig;
const threadId = normalizeThreadId(opts.threadId);
const transactionScopeId = durableIdentity ? await client.getTransactionScopeId() : undefined;
const wireEventType = durableIdentity
? await client.getMessageWireEventType(roomId)
: undefined;
const storedPlan = durableIdentity
? await loadMatrixDeliveryPlan({
identity: durableIdentity,
@@ -258,7 +256,7 @@ export async function sendMessageMatrix(
mediaLocalRoots: opts.mediaLocalRoots,
mediaReadFile: opts.mediaReadFile,
});
const uploaded = await uploadMediaMaybeEncrypted(client, roomId, media.buffer, {
const uploaded = await uploadMediaWithEncryption(client, roomId, media.buffer, {
contentType: media.contentType,
filename: media.fileName,
});
@@ -281,7 +279,7 @@ export async function sendMessageMatrix(
? await prepareImageInfo({
buffer: media.buffer,
client,
encrypted: Boolean(uploaded.file),
roomId,
})
: undefined;
const [firstChunk, ...rest] = chunks;
@@ -345,6 +343,9 @@ export async function sendMessageMatrix(
}));
}
if (opts.mediaUrl) {
await client.prepareRoomForMessageSend(roomId, plannedEvents[0]?.content);
}
let platformDispatchStarted = false;
if (!durableIdentity) {
await opts.onPlatformSendDispatch?.();
+22 -47
View File
@@ -170,7 +170,7 @@ function resolveAifcIma4DurationSeconds(buffer: Buffer, sampleRate?: number): nu
export async function prepareImageInfo(params: {
buffer: Buffer;
client: MatrixClient;
encrypted?: boolean;
roomId: string;
}): Promise<DimensionalFileInfo | undefined> {
const meta = await getCore()
.media.getImageMetadata(params.buffer)
@@ -191,10 +191,9 @@ export async function prepareImageInfo(params: {
const thumbMeta = await getCore()
.media.getImageMetadata(thumbBuffer)
.catch(() => null);
const result = await uploadMediaWithEncryption(params.client, thumbBuffer, {
const result = await uploadMediaWithEncryption(params.client, params.roomId, thumbBuffer, {
contentType: "image/jpeg",
filename: "thumbnail.jpg",
encrypted: params.encrypted === true,
});
if (result.file) {
imageInfo.thumbnail_file = result.file;
@@ -250,44 +249,7 @@ export async function resolveMediaDurationMs(params: {
return undefined;
}
async function uploadFile(
client: MatrixClient,
file: Buffer,
params: {
contentType?: string;
filename?: string;
},
): Promise<string> {
return await client.uploadContent(file, params.contentType, params.filename);
}
async function uploadMediaWithEncryption(
client: MatrixClient,
buffer: Buffer,
params: {
contentType?: string;
filename?: string;
encrypted: boolean;
},
): Promise<{ url: string; file?: EncryptedFile }> {
if (params.encrypted && client.crypto) {
const encrypted = await client.crypto.encryptMedia(buffer);
const mxc = await client.uploadContent(encrypted.buffer, params.contentType, params.filename);
const file: EncryptedFile = { url: mxc, ...encrypted.file };
return {
url: mxc,
file,
};
}
const mxc = await uploadFile(client, buffer, params);
return { url: mxc };
}
/**
* Upload media with optional encryption for E2EE rooms.
*/
export async function uploadMediaMaybeEncrypted(
export async function uploadMediaWithEncryption(
client: MatrixClient,
roomId: string,
buffer: Buffer,
@@ -296,10 +258,23 @@ export async function uploadMediaMaybeEncrypted(
filename?: string;
},
): Promise<{ url: string; file?: EncryptedFile }> {
// Check if room is encrypted and crypto is available
const isEncrypted = Boolean(client.crypto && (await client.crypto.isRoomEncrypted(roomId)));
return await uploadMediaWithEncryption(client, buffer, {
...params,
encrypted: isEncrypted,
});
// Downloads and thumbnail generation can yield while encryption changes;
// resolve room policy at the upload boundary instead of reusing stale facts.
if ((await client.prepareRoomForMessageSend(roomId)) === "m.room.encrypted") {
if (!client.crypto) {
throw new Error("Encrypted Matrix room: enable encryption before uploading media");
}
const encrypted = await client.crypto.encryptMedia(buffer);
// Upload URLs and headers are visible; keep real media metadata inside
// the encrypted room event instead of exposing it with the ciphertext.
const mxc = await client.uploadContent(encrypted.buffer, "application/octet-stream");
const file: EncryptedFile = { url: mxc, ...encrypted.file };
return {
url: mxc,
file,
};
}
const mxc = await client.uploadContent(buffer, params.contentType, params.filename);
return { url: mxc };
}
@@ -92,6 +92,20 @@ type MattermostFileInfo = {
size?: number | null;
};
export function parseMattermostApiStatus(error: unknown): number | undefined {
if (!error || typeof error !== "object") {
return undefined;
}
const message = "message" in error && typeof error.message === "string" ? error.message : "";
// Read only the provider's status prefix; upstream details can mention other HTTP statuses.
const match = /Mattermost API (\d{3})\b/.exec(message);
if (!match) {
return undefined;
}
const status = Number(match[1]);
return Number.isFinite(status) ? status : undefined;
}
export function normalizeMattermostBaseUrl(raw?: string | null): string | undefined {
const trimmed = raw?.trim();
if (!trimmed) {
@@ -110,6 +110,34 @@ function directChannelRetryCall() {
) as [unknown, unknown, MattermostDirectRetryOptions?];
}
async function createMattermostProviderFailure(
status: number,
statusText: string,
message: string,
): Promise<Error> {
const { createMattermostClient } =
await vi.importActual<typeof import("./client.js")>("./client.js");
const client = createMattermostClient({
baseUrl: "https://mattermost.example.com",
botToken: "test-bot-token",
fetchImpl: async () =>
new Response(JSON.stringify({ message }), {
status,
statusText,
headers: { "content-type": "application/json" },
}),
});
try {
await client.request("/teams/team-first/channels/name/release-alerts");
} catch (error) {
if (error instanceof Error) {
return error;
}
throw error;
}
throw new Error("Expected the Mattermost provider request to fail");
}
vi.mock("../../runtime-api.js", () => ({
loadOutboundMediaFromUrl: mockState.loadOutboundMediaFromUrl,
}));
@@ -162,7 +190,9 @@ vi.mock("./accounts.js", () => ({
resolveMattermostAccount: mockState.resolveMattermostAccount,
}));
vi.mock("./client.js", () => ({
vi.mock("./client.js", async () => ({
parseMattermostApiStatus: (await vi.importActual<typeof import("./client.js")>("./client.js"))
.parseMattermostApiStatus,
createMattermostClient: mockState.createMattermostClient,
createMattermostDirectChannelWithRetry: mockState.createMattermostDirectChannelWithRetry,
createMattermostPost: mockState.createMattermostPost,
@@ -263,6 +293,90 @@ describe("sendMessageMattermost", () => {
});
});
it("continues searching later teams only when a channel is genuinely absent", async () => {
mockState.fetchMattermostUserTeams.mockResolvedValueOnce([
{ id: "team-first" },
{ id: "team-second" },
]);
mockState.fetchMattermostChannelByName
.mockRejectedValueOnce(await createMattermostProviderFailure(404, "Not Found", "missing"))
.mockResolvedValueOnce({ id: "channel-second" });
const result = await sendMessageMattermost("#release-alerts", "hello", { cfg: TEST_CFG });
expect(result.channelId).toBe("channel-second");
expect(mockState.fetchMattermostChannelByName).toHaveBeenNthCalledWith(
1,
{},
"team-first",
"release-alerts",
);
expect(mockState.fetchMattermostChannelByName).toHaveBeenNthCalledWith(
2,
{},
"team-second",
"release-alerts",
);
expect(mockState.createMattermostPost).toHaveBeenCalledOnce();
});
it("reports a missing named channel after every team returns not found", async () => {
mockState.fetchMattermostUserTeams.mockResolvedValueOnce([
{ id: "team-first" },
{ id: "team-second" },
]);
mockState.fetchMattermostChannelByName.mockRejectedValue(
await createMattermostProviderFailure(404, "Not Found", "missing channel"),
);
await expect(
sendMessageMattermost("#release-alerts", "hello", { cfg: TEST_CFG }),
).rejects.toThrow('Mattermost channel "#release-alerts" not found in any team');
expect(mockState.fetchMattermostChannelByName).toHaveBeenCalledTimes(2);
expect(mockState.createMattermostPost).not.toHaveBeenCalled();
});
it.each([
{
name: "an expired bot token",
createError: () => createMattermostProviderFailure(401, "Unauthorized", "bot token expired"),
},
{
name: "missing channel permissions",
createError: () => createMattermostProviderFailure(403, "Forbidden", "access denied"),
},
{
name: "provider rate limiting",
createError: () => createMattermostProviderFailure(429, "Too Many Requests", "retry later"),
},
{
name: "an outage whose detail mentions a missing resource",
createError: () =>
createMattermostProviderFailure(503, "Service Unavailable", "upstream returned 404"),
},
{
name: "a network failure",
createError: async () => new Error("connect ECONNRESET 192.0.2.12:443"),
},
])("preserves $name while resolving a named channel", async ({ createError }) => {
const error = await createError();
mockState.fetchMattermostUserTeams.mockResolvedValueOnce([
{ id: "team-first" },
{ id: "team-second" },
]);
mockState.fetchMattermostChannelByName
.mockRejectedValueOnce(error)
.mockResolvedValueOnce({ id: "channel-second" });
await expect(sendMessageMattermost("#release-alerts", "hello", { cfg: TEST_CFG })).rejects.toBe(
error,
);
expect(mockState.fetchMattermostChannelByName).toHaveBeenCalledOnce();
expect(mockState.createMattermostPost).not.toHaveBeenCalled();
});
it.each(MATTERMOST_MARKDOWN_GOLDENS)("$name", async ({ input, before, after }) => {
expect(convertMarkdownTables(input, "code")).toBe(before);
+5 -2
View File
@@ -26,6 +26,7 @@ import {
fetchMattermostUserByUsername,
fetchMattermostUserTeams,
normalizeMattermostBaseUrl,
parseMattermostApiStatus,
uploadMattermostFile,
type MattermostUser,
type CreateDmChannelRetryOptions,
@@ -233,8 +234,10 @@ async function resolveChannelIdByName(params: {
);
return channel.id;
}
} catch {
// Channel not found in this team, try next
} catch (error) {
if (parseMattermostApiStatus(error) !== 404) {
throw error;
}
}
}
throw new Error(`Mattermost channel "#${name}" not found in any team the bot belongs to`);
@@ -11,7 +11,9 @@ vi.mock("./accounts.js", () => ({
resolveMattermostAccount,
}));
vi.mock("./client.js", () => ({
vi.mock("./client.js", async () => ({
parseMattermostApiStatus: (await vi.importActual<typeof import("./client.js")>("./client.js"))
.parseMattermostApiStatus,
createMattermostClient,
fetchMattermostUser,
fetchMattermostChannel,
@@ -11,6 +11,7 @@ import {
fetchMattermostChannel,
fetchMattermostUser,
normalizeMattermostBaseUrl,
parseMattermostApiStatus,
} from "./client.js";
import { resolveMattermostTrustedChatKind } from "./monitor-auth.js";
import type { OpenClawConfig } from "./runtime-api.js";
@@ -138,19 +139,6 @@ function isExplicitMattermostTarget(raw: string): boolean {
);
}
function parseMattermostApiStatus(err: unknown): number | undefined {
if (!err || typeof err !== "object") {
return undefined;
}
const msg = "message" in err && typeof err.message === "string" ? err.message : "";
const match = /Mattermost API (\d{3})\b/.exec(msg);
if (!match) {
return undefined;
}
const code = Number(match[1]);
return Number.isFinite(code) ? code : undefined;
}
export async function resolveMattermostOpaqueTarget(params: {
input: string;
cfg?: OpenClawConfig;
@@ -49,6 +49,7 @@ describe("mattermost thread participation", () => {
afterEach(() => {
threadParticipationMemory.clear();
resetPluginStateStoreForTests();
vi.restoreAllMocks();
});
it("remembers a thread the bot replied in", async () => {
@@ -85,10 +86,13 @@ describe("mattermost thread participation", () => {
).resolves.toBe(false);
});
it("recovers participation from the persistent store after the in-memory cache is lost", async () => {
it("restores participation after a restart without extending its original expiry", async () => {
const repliedAt = 1_711_406_400_000;
const now = vi.spyOn(Date, "now").mockReturnValue(repliedAt);
recordMattermostThreadParticipation("acct", "chan", "root-1");
await flush();
// Simulate a restart: in-memory cache cleared, persistent SQLite store intact.
now.mockReturnValue(repliedAt + 7 * 24 * 60 * 60 * 1000 - 1000);
// Simulate a restart near expiry: memory is lost, but the SQLite row is still valid.
threadParticipationMemory.clear();
await expect(
hasMattermostThreadParticipationWithPersistence({
@@ -97,6 +101,15 @@ describe("mattermost thread participation", () => {
threadRootId: "root-1",
}),
).resolves.toBe(true);
now.mockReturnValue(repliedAt + 7 * 24 * 60 * 60 * 1000 + 1000);
await expect(
hasMattermostThreadParticipationWithPersistence({
accountId: "acct",
channelId: "chan",
threadRootId: "root-1",
}),
).resolves.toBe(false);
});
it("degrades to in-memory only when the persistent store fails", async () => {
@@ -37,6 +37,8 @@ const threadParticipation = createPersistentDedupeCache<MattermostThreadParticip
"thread-participation-state",
"Mattermost persistent thread participation state failed",
),
// Restoring participation must not extend its original mention-bypass window.
readTimestamp: ({ repliedAt }) => repliedAt,
},
});
+38
View File
@@ -10,8 +10,10 @@ import { buildPromptSection } from "./src/prompt-section.js";
const closeMemorySearchManagerMock = vi.hoisted(() => vi.fn(async () => {}));
const getMemorySearchManagerMock = vi.hoisted(() => vi.fn(async () => null));
const authorizeSearchHitsMock = vi.hoisted(() => vi.fn(async ({ hits }) => hits));
const createMemoryRuntimeMock = vi.hoisted(() =>
vi.fn((_host: MemoryCoreRuntimeHost = {}) => ({
authorizeSearchHits: authorizeSearchHitsMock,
closeAllMemorySearchManagers: vi.fn(async () => {}),
closeMemorySearchManager: closeMemorySearchManagerMock,
getMemorySearchManager: getMemorySearchManagerMock,
@@ -319,6 +321,42 @@ describe("memory-core plugin runtime registration", () => {
});
});
it("forwards search-hit authorization through the registered memory runtime", async () => {
const runtime = registerMemoryCoreRuntime();
const cfg = {} as OpenClawConfig;
const hits = [
{
source: "sessions" as const,
path: "sessions/private.jsonl",
startLine: 1,
endLine: 1,
score: 1,
snippet: "private",
},
];
await expect(
runtime.authorizeSearchHits?.({
cfg,
agentId: "main",
requesterSessionKey: "agent:main:voice:15550001234",
sandboxed: false,
hits,
}),
).resolves.toEqual(hits);
expect(authorizeSearchHitsMock).toHaveBeenCalledWith({
cfg,
agentId: "main",
requesterSessionKey: "agent:main:voice:15550001234",
sandboxed: false,
hits,
});
expect(createMemoryRuntimeMock).toHaveBeenCalledWith({
acquireLocalService: hostRuntime.llm.acquireLocalService,
withLease: expect.any(Function),
});
});
it("binds the host SQLite lease hook to tools and CLI runtime", async () => {
const runtime = registerMemoryCoreRuntime();
const cfg = {} as OpenClawConfig;
+8
View File
@@ -252,6 +252,14 @@ function createLazyMemoryRuntime(host: MemoryCoreRuntimeHost): MemoryPluginRunti
const { createMemoryRuntime } = await loadRuntimeProviderModule();
return await createMemoryRuntime(host).getMemorySearchManager(params);
},
async authorizeSearchHits(params) {
const { createMemoryRuntime } = await loadRuntimeProviderModule();
const runtime = createMemoryRuntime(host);
if (!runtime.authorizeSearchHits) {
throw new Error("memory-core runtime search authorization is unavailable");
}
return await runtime.authorizeSearchHits(params);
},
resolveMemoryBackendConfig(params) {
return resolveMemoryBackendConfig(params);
},
@@ -35,6 +35,7 @@ import {
closeMemoryIndexManagersForAgent,
MemoryIndexManager as RuntimeMemoryIndexManager,
} from "./manager.js";
import { isolateMemoryManagerTestConfig } from "./test-config-helpers.js";
// This suite performs real sqlite/media indexing and can exceed the global
// timeout when it shares a packed CI extension shard.
@@ -417,7 +418,7 @@ describe("memory index", () => {
temporalDecay?: { enabled: boolean };
};
}): TestCfg {
return {
return isolateMemoryManagerTestConfig({
memory: {
search: {
...(params.provider !== undefined ? { provider: params.provider } : {}),
@@ -449,7 +450,7 @@ describe("memory index", () => {
list: [{ id: "main", default: true }],
},
models: params.providerAliases ? { providers: params.providerAliases } : undefined,
};
});
}
async function seedMemoryIndexSessionTranscript(params: {
@@ -1,5 +1,6 @@
// Memory Core provider tests cover plugin runtime integration.
import type { OpenClawConfig } from "openclaw/plugin-sdk/memory-core-host-runtime-core";
import type { MemorySearchResult } from "openclaw/plugin-sdk/memory-core-host-runtime-files";
import { describe, expect, it, vi } from "vitest";
const managerDebug = {
@@ -17,6 +18,7 @@ const getMemorySearchManagerMock = vi.hoisted(() =>
error: undefined,
})),
);
const filterMemorySearchHitsBySessionVisibilityMock = vi.hoisted(() => vi.fn());
vi.mock("./memory/index.js", () => ({
closeAllMemorySearchManagers: vi.fn(async () => {}),
@@ -24,6 +26,10 @@ vi.mock("./memory/index.js", () => ({
getMemorySearchManager: getMemorySearchManagerMock,
}));
vi.mock("./session-search-visibility.js", () => ({
filterMemorySearchHitsBySessionVisibility: filterMemorySearchHitsBySessionVisibilityMock,
}));
import { createMemoryRuntime, memoryRuntime } from "./runtime-provider.js";
describe("memoryRuntime", () => {
@@ -97,4 +103,39 @@ describe("memoryRuntime", () => {
withLease: secondLease,
});
});
it("delegates raw-hit authorization to the canonical session visibility filter", async () => {
const cfg = {} as OpenClawConfig;
const hits: MemorySearchResult[] = [
{
source: "sessions",
path: "sessions/private.jsonl",
startLine: 1,
endLine: 1,
score: 1,
snippet: "private",
},
];
filterMemorySearchHitsBySessionVisibilityMock.mockResolvedValue([]);
if (!memoryRuntime.authorizeSearchHits) {
throw new Error("memory runtime search authorizer is unavailable");
}
await expect(
memoryRuntime.authorizeSearchHits({
cfg,
agentId: "main",
requesterSessionKey: "agent:main:voice:15550001234",
sandboxed: false,
hits,
}),
).resolves.toEqual([]);
expect(filterMemorySearchHitsBySessionVisibilityMock).toHaveBeenCalledWith({
cfg,
agentId: "main",
requesterSessionKey: "agent:main:voice:15550001234",
sandboxed: false,
hits,
});
});
});
@@ -25,6 +25,11 @@ export function createMemoryRuntime(host: MemoryCoreRuntimeHost = {}): MemoryPlu
resolveMemoryBackendConfig(params) {
return resolveMemoryBackendConfig(params);
},
async authorizeSearchHits(params) {
const { filterMemorySearchHitsBySessionVisibility } =
await import("./session-search-visibility.js");
return await filterMemorySearchHitsBySessionVisibility(params);
},
async closeAllMemorySearchManagers() {
await closeAllMemorySearchManagers();
},
@@ -168,6 +168,49 @@ describe("filterMemorySearchHitsBySessionVisibility", () => {
expect(filtered).toEqual(hits);
});
it("keeps memory but hides an unrelated same-agent session from a voice requester", async () => {
combinedSessionStore = {
"agent:main:voice:15550001111": {
sessionId: "voice",
updatedAt: 2,
sessionFile: "/tmp/sessions/voice.jsonl",
chatType: "direct",
},
"agent:main:telegram:direct:owner": {
sessionId: "private",
updatedAt: 1,
sessionFile: "/tmp/sessions/private.jsonl",
chatType: "direct",
},
};
const memoryHit: MemorySearchResult = {
path: "memory/allowed.md",
source: "memory",
score: 1,
snippet: "Visible memory",
startLine: 1,
endLine: 2,
};
const sessionHit: MemorySearchResult = {
path: "sessions/private.jsonl",
source: "sessions",
score: 1,
snippet: "Private session secret",
startLine: 1,
endLine: 2,
};
const filtered = await filterMemorySearchHitsBySessionVisibility({
cfg: asOpenClawConfig({}),
agentId: "main",
requesterSessionKey: "agent:main:voice:15550001111",
sandboxed: false,
hits: [memoryHit, sessionHit],
});
expect(filtered).toEqual([memoryHit]);
});
it("allows another same-agent private transcript through trusted conversation recall", async () => {
combinedSessionStore = {
"agent:main:telegram:direct:owner": {
@@ -82,6 +82,8 @@ describe("agent-scoped memory-wiki tools", () => {
it("keeps apply, search, and get behavior isolated by configured agent", async () => {
const vaultParent = await createTempDir("memory-wiki-agent-vaults-");
const appConfig = {
// This suite registers memory-core directly; runtime discovery would load unrelated plugins.
plugins: { enabled: false },
agents: {
list: [{ id: "support", default: true }, { id: "marketing" }],
},
@@ -18,6 +18,8 @@ import { createMemoryWikiTestHarness } from "./test-helpers.js";
const { createVault } = createMemoryWikiTestHarness();
const appConfig = {
// This suite registers memory-core directly; runtime discovery would load unrelated plugins.
plugins: { enabled: false },
agents: { list: [{ id: "main", default: true }, { id: "secondary" }] },
} as OpenClawConfig;
@@ -220,6 +220,88 @@ describe("Nextcloud Talk durable ingress", () => {
});
});
it("drains other rooms while keeping unadopted same-room deliveries ordered", async () => {
await withQueue(async (queue) => {
let releaseRoomA!: () => void;
const roomADelivery = new Promise<void>((resolve) => {
releaseRoomA = resolve;
});
const delivered: string[] = [];
const spool = startSpool(queue, async (message, lifecycle) => {
delivered.push(message.messageId);
if (message.messageId === "room-a-1") {
await roomADelivery;
}
await lifecycle.onAdopted();
});
try {
await spool.receive(createRawEvent({ messageId: "room-a-1", roomToken: "room-a" }));
await vi.waitFor(() => expect(delivered).toEqual(["room-a-1"]));
await spool.receive(createRawEvent({ messageId: "room-a-2", roomToken: "room-a" }));
await spool.receive(createRawEvent({ messageId: "room-b-1", roomToken: "room-b" }));
await vi.waitFor(() => expect(delivered).toEqual(["room-a-1", "room-b-1"]));
expect(await queue.listPending()).toEqual([
expect.objectContaining({ id: "room-a-2", laneKey: "room:room-a" }),
]);
releaseRoomA();
await vi.waitFor(() => expect(delivered).toEqual(["room-a-1", "room-b-1", "room-a-2"]));
} finally {
releaseRoomA();
await spool.stop();
}
});
});
it("caps active room deliveries after durable adoption across repeated pumps", async () => {
await withQueue(async (queue) => {
let releaseDeliveries!: () => void;
const deliveryGate = new Promise<void>((resolve) => {
releaseDeliveries = resolve;
});
let activeDeliveries = 0;
let maxActiveDeliveries = 0;
const deliver = vi.fn(async (_message, lifecycle) => {
activeDeliveries += 1;
maxActiveDeliveries = Math.max(maxActiveDeliveries, activeDeliveries);
await lifecycle.onAdopted();
try {
await deliveryGate;
} finally {
activeDeliveries -= 1;
}
});
const spool = startSpool(queue, deliver);
try {
for (let index = 0; index < 33; index += 1) {
await spool.receive(
createRawEvent({
messageId: `room-delivery-${index}`,
roomToken: `room-${index}`,
}),
);
}
await vi.waitFor(() => expect(deliver).toHaveBeenCalledTimes(32));
expect(maxActiveDeliveries).toBe(32);
expect(await queue.listPending()).toEqual([
expect.objectContaining({ id: "room-delivery-32", laneKey: "room:room-32" }),
]);
releaseDeliveries();
await vi.waitFor(() => expect(deliver).toHaveBeenCalledTimes(33));
expect(maxActiveDeliveries).toBe(32);
} finally {
releaseDeliveries();
await spool.stop();
}
});
});
it("stores the exact raw envelope in the room lane", async () => {
await withQueue(async (queue) => {
const rawEvent = createRawEvent({ messageId: "msg-raw", roomToken: "test-room-token" });
@@ -179,13 +179,12 @@ export function createNextcloudTalkWebhookSpool(options: {
await options.deliver(message, lifecycle);
},
pollIntervalMs: options.pollIntervalMs ?? NEXTCLOUD_TALK_INGRESS_POLL_INTERVAL_MS,
// Preserve Nextcloud Talk's existing one-drain-at-a-time delivery cycle.
waitForDeliveryIdleBeforeRepump: true,
retention: {
completedMaxEntries: 10_000,
failedMaxEntries: 10_000,
},
drain: {
startLimit: 32, // Keep the shared drain's active-delivery ceiling across repumps.
resolveNonRetryableFailure,
...(options.adoptionStallTimeoutMs === undefined
? {}
-2
View File
@@ -1,5 +1,3 @@
export function resolveTrustedOnePasswordDirectoryPath(targetPath: string): Promise<string>;
export function resolveTrustedOnePasswordCli(options?: {
configuredPath?: string;
pathEnv?: string;
@@ -6,8 +6,6 @@ function errorCode(error) {
}
const resolveTrustedExecutablePath = pluginSecretRefSetup.resolveTrustedExecutablePath;
export const resolveTrustedOnePasswordDirectoryPath =
pluginSecretRefSetup.resolveTrustedDirectoryPath;
export async function resolveTrustedOnePasswordCli(options = {}) {
const configuredPath = options.configuredPath?.trim();
+169 -396
View File
@@ -1,13 +1,17 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { inspectPathPermissions } from "@openclaw/fs-safe/permissions";
import { Command } from "commander";
import type { OpenClawConfig } from "openclaw/plugin-sdk/plugin-entry";
import { afterEach, describe, expect, it, vi } from "vitest";
import { encodeOnePasswordSecretId } from "../onepassword-secret-id.js";
import { registerOnePasswordSecretRefCommands, testing } from "./secret-ref-cli.js";
type OnePasswordPlan = {
providerUpserts: Record<string, unknown>;
targets: Array<Record<string, unknown>>;
};
function captureStdout() {
let output = "";
vi.spyOn(process.stdout, "write").mockImplementation((chunk) => {
@@ -17,7 +21,7 @@ function captureStdout() {
return () => output;
}
function createProgram(config: OpenClawConfig): Command {
function createProgram(config: OpenClawConfig = {}): Command {
const program = new Command().exitOverride();
const onepassword = program.command("onepassword");
registerOnePasswordSecretRefCommands({
@@ -36,19 +40,29 @@ async function runStatus(
const output = captureStdout();
await createProgram(config).parseAsync(
["onepassword", "secretref", "status", "--json", ...args],
{
from: "user",
},
{ from: "user" },
);
return JSON.parse(output()) as Record<string, unknown>;
}
function createOpenAiPlan() {
return testing.buildPlan({
providerAlias: "onepassword",
providerConfig: testing.buildProviderConfig(),
providerSecrets: [{ providerId: "openai", secretId: "op://openclaw/OpenAI/credential" }],
});
async function runSetup(planPath: string, args: string[]): Promise<string> {
const output = captureStdout();
await createProgram().parseAsync(
["onepassword", "secretref", "setup", "--plan-out", planPath, ...args],
{ from: "user" },
);
return output();
}
async function createSetupPlan(args: string[]): Promise<OnePasswordPlan> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-onepassword-cli-"));
const planPath = path.join(dir, "plan.json");
try {
await runSetup(planPath, args);
return JSON.parse(await fs.readFile(planPath, "utf8")) as OnePasswordPlan;
} finally {
await fs.rm(dir, { recursive: true, force: true });
}
}
afterEach(() => {
@@ -56,215 +70,180 @@ afterEach(() => {
vi.unstubAllEnvs();
});
describe("1Password CLI helpers", () => {
it("builds a secrets apply plan for model provider API keys", () => {
const plan = testing.buildPlan({
providerAlias: "onepassword",
providerConfig: testing.buildProviderConfig(),
providerSecrets: [
{
providerId: "anthropic",
secretId: "op://openclaw/Anthropic/credential",
},
{
providerId: "openrouter",
secretId: "openclaw/OpenRouter/credential",
},
],
});
describe("1Password SecretRef setup", () => {
it("builds provider config and model API-key targets", async () => {
const plan = await createSetupPlan([
"--anthropic-id",
"op://openclaw/Anthropic/credential",
"--openrouter-id",
"openclaw/OpenRouter/credential",
"--provider-key",
"xai=op://openclaw/xAI/credential",
]);
expect(plan.providerUpserts.onepassword).toEqual({
source: "exec",
pluginIntegration: {
pluginId: "onepassword",
integrationId: "onepassword",
},
pluginIntegration: { pluginId: "onepassword", integrationId: "onepassword" },
});
expect(plan.targets).toEqual([
{
expect.objectContaining({
type: "models.providers.apiKey",
path: "models.providers.anthropic.apiKey",
pathSegments: ["models", "providers", "anthropic", "apiKey"],
providerId: "anthropic",
ref: {
source: "exec",
provider: "onepassword",
id: "op://openclaw/Anthropic/credential",
},
},
{
type: "models.providers.apiKey",
path: "models.providers.openrouter.apiKey",
pathSegments: ["models", "providers", "openrouter", "apiKey"],
providerId: "openrouter",
ref: {
source: "exec",
provider: "onepassword",
id: "openclaw/OpenRouter/credential",
},
},
ref: { source: "exec", provider: "onepassword", id: "op://openclaw/Anthropic/credential" },
}),
expect.objectContaining({ providerId: "openrouter" }),
expect.objectContaining({ providerId: "xai" }),
]);
});
it("builds a secrets apply plan for arbitrary known openclaw secret targets", () => {
const plan = testing.buildPlan({
providerAlias: "onepassword",
providerConfig: testing.buildProviderConfig(),
providerSecrets: [],
configTargetSecrets: testing.parseConfigTargetMappings([
"channels.telegram.botToken=op://openclaw/Telegram/botToken",
"models.providers.openai.headers.x-api-key=op://openclaw/OpenAI/proxyKey",
"auth-profiles:main:profiles.openai.key=op://openclaw/OpenAI/credential",
]),
});
it("builds arbitrary known OpenClaw and auth-profile targets", async () => {
const plan = await createSetupPlan([
"--target",
"channels.telegram.botToken=op://openclaw/Telegram/botToken",
"--target",
"models.providers.openai.headers.x-api-key=op://openclaw/OpenAI/proxyKey",
"--target",
"auth-profiles:main:profiles.openai.key=op://openclaw/OpenAI/credential",
]);
expect(plan.targets).toEqual([
{
expect.objectContaining({
type: "channels.telegram.botToken",
path: "channels.telegram.botToken",
pathSegments: ["channels", "telegram", "botToken"],
ref: {
source: "exec",
provider: "onepassword",
id: "op://openclaw/Telegram/botToken",
},
},
{
}),
expect.objectContaining({
type: "models.providers.headers",
path: "models.providers.openai.headers.x-api-key",
pathSegments: ["models", "providers", "openai", "headers", "x-api-key"],
providerId: "openai",
ref: {
source: "exec",
provider: "onepassword",
id: "op://openclaw/OpenAI/proxyKey",
},
},
{
}),
expect.objectContaining({
type: "auth-profiles.api_key.key",
path: "profiles.openai.key",
pathSegments: ["profiles", "openai", "key"],
agentId: "main",
ref: {
source: "exec",
provider: "onepassword",
id: "op://openclaw/OpenAI/credential",
},
},
}),
]);
});
it("parses custom provider mappings", () => {
expect(testing.parseProviderKeyMappings(["xai=op://openclaw/xAI/credential"])).toEqual([
{
providerId: "xai",
secretId: "op://openclaw/xAI/credential",
},
]);
});
it("accepts native 1Password refs with spaces and encoded selectors", () => {
it("encodes native 1Password refs with spaces and selectors", async () => {
const nativeRef = "op://Personal/OpenClaw QA API Key/password?attribute=value%20one";
expect(testing.parseProviderKeyMappings([`openai=${nativeRef}`])).toEqual([
{
providerId: "openai",
secretId: encodeOnePasswordSecretId(nativeRef),
},
]);
const plan = await createSetupPlan(["--provider-key", `openai=${nativeRef}`]);
expect(plan.targets[0]).toMatchObject({
providerId: "openai",
ref: { id: encodeOnePasswordSecretId(nativeRef) },
});
});
it.each([
["posix", "/tmp/plan.json", "/tmp/plan.json"],
["posix", "/tmp/plan with spaces.json", "'/tmp/plan with spaces.json'"],
["posix", "/tmp/plan'$(touch pwn).json", "'/tmp/plan'\\''$(touch pwn).json'"],
["powershell", String.raw`C:\$env:TEMP\plan';.json`, String.raw`'C:\$env:TEMP\plan'';.json'`],
["cmd", String.raw`C:\Users\Jane Doe\plan.json`, String.raw`"C:\Users\Jane Doe\plan.json"`],
] satisfies Array<["cmd" | "posix" | "powershell", string, string]>)(
"shell-quotes %s command arguments for %j",
(shell, value, expected) => {
expect(testing.quoteCliArg(value, shell)).toBe(expected);
[
"duplicate providers",
[
"--openai-id",
"op://openclaw/OpenAI/credential",
"--provider-key",
"OpenAI=op://openclaw/OpenAI/other",
],
"Duplicate model provider id",
],
[
"non-canonical auth-profile agent ids",
["--target", "auth-profiles:../main:profiles.openai.key=op://openclaw/OpenAI/credential"],
"Invalid --target auth-profiles target for 1Password",
],
[
"traversal secret ids",
["--provider-key", "openai=op://openclaw/../credential"],
"Invalid --provider-key openai 1Password SecretRef id",
],
[
"unsupported targets",
["--target", "secrets.github_pat=op://openclaw/GitHub/pat"],
"Unknown or unsupported 1Password setup target path",
],
[
"duplicate target paths",
[
"--openai-id",
"op://openclaw/OpenAI/credential",
"--target",
"models.providers.openai.apiKey=op://openclaw/OpenAI/other",
],
"Duplicate secret target path",
],
["empty plans", [], "No SecretRef targets selected"],
])("rejects %s", async (_label, args, message) => {
await expect(createSetupPlan(args)).rejects.toThrow(message);
});
it.each(["/absolute/path", "op://openclaw\\OpenAI\\credential", "op://vault/clé"])(
"rejects invalid 1Password ref %s",
async (id) => {
await expect(createSetupPlan(["--provider-key", `openai=${id}`])).rejects.toThrow(
"Invalid --provider-key openai 1Password SecretRef id",
);
},
);
it("rejects line breaks in generated command arguments", () => {
expect(() => testing.quoteCliArg("plan.json\nopenclaw secrets reload", "posix")).toThrow(
/cannot contain CR or LF/,
);
expect(() => testing.quoteCliArg("plan.json\r& whoami", "cmd")).toThrow(
/cannot contain CR or LF/,
);
it("prints a quoted canonical plan path after the readiness command", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-1password-setup-test-"));
const planPath = path.join(tempDir, "plan with spaces.json");
const canonicalPlanPath = path.join(await fs.realpath(tempDir), "plan with spaces.json");
try {
const output = await runSetup(planPath, ["--openai-id", "op://openclaw/OpenAI/credential"]);
expect(output).toContain("openclaw onepassword secretref status");
expect(output).toContain(
`openclaw secrets apply --from '${canonicalPlanPath}' --dry-run --allow-exec`,
);
expect(output).toContain(`openclaw secrets apply --from '${canonicalPlanPath}' --allow-exec`);
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it("renders native follow-up commands for both Windows shells", () => {
expect(testing.renderApplyCommands(String.raw`C:\Users\Jane Doe\plan;.json`, "win32")).toEqual([
"PowerShell:",
String.raw` openclaw secrets apply --from 'C:\Users\Jane Doe\plan;.json' --dry-run --allow-exec`,
String.raw` openclaw secrets apply --from 'C:\Users\Jane Doe\plan;.json' --allow-exec`,
"Command Prompt:",
String.raw` openclaw secrets apply --from "C:\Users\Jane Doe\plan;.json" --dry-run --allow-exec`,
String.raw` openclaw secrets apply --from "C:\Users\Jane Doe\plan;.json" --allow-exec`,
]);
});
it.skipIf(process.platform === "win32")(
"rejects plan output in a directory writable by another account",
async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-secret-plan-test-"));
const planPath = path.join(tempDir, "plan.json");
try {
await fs.chmod(tempDir, 0o777);
await expect(
runSetup(planPath, ["--openai-id", "op://openclaw/OpenAI/credential"]),
).rejects.toThrow("path is writable by another user");
await expect(fs.stat(planPath)).rejects.toMatchObject({ code: "ENOENT" });
} finally {
await fs.chmod(tempDir, 0o700);
await fs.rm(tempDir, { recursive: true, force: true });
}
},
);
it("omits unsafe interactive Command Prompt commands", () => {
const commands = testing.renderApplyCommands(String.raw`C:\%TEMP%\plan!.json`, "win32");
expect(commands).toContain(
"Command Prompt: unavailable for paths containing % or !; use PowerShell.",
);
expect(commands.filter((command) => command.includes("openclaw secrets apply"))).toHaveLength(
2,
);
expect(() => testing.quoteCliArg(String.raw`C:\%TEMP%\plan!.json`, "cmd")).toThrow(
/cannot safely quote/,
);
});
it("parses config target mappings", () => {
expect(
testing.parseConfigTargetMappings([
"channels.telegram.botToken=op://openclaw/Telegram/botToken",
"auth-profiles:main:profiles.openai.key=op://openclaw/OpenAI/credential",
]),
).toEqual([
{
path: "channels.telegram.botToken",
secretId: "op://openclaw/Telegram/botToken",
},
{
path: "profiles.openai.key",
agentId: "main",
secretId: "op://openclaw/OpenAI/credential",
},
]);
});
it("rejects non-canonical auth-profile agent ids", () => {
expect(() =>
testing.parseConfigTargetMappings([
"auth-profiles:../main:profiles.openai.key=op://openclaw/OpenAI/credential",
]),
).toThrow("Invalid --target auth-profiles target for 1Password");
});
it("rejects duplicate model providers", () => {
expect(() =>
testing.collectProviderSecrets({
openaiId: "op://openclaw/OpenAI/credential",
providerKey: ["openai=op://openclaw/OpenAI/other"],
}),
).toThrow("Duplicate model provider id in 1Password setup: openai");
});
it("rejects setup plans without targets", () => {
expect(() =>
testing.buildPlan({
providerAlias: "onepassword",
providerConfig: testing.buildProviderConfig(),
providerSecrets: [],
}),
).toThrow("No SecretRef targets selected");
});
it.skipIf(process.platform === "win32")(
"writes through the canonical directory instead of a replaceable alias",
async () => {
const trustedDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-secret-plan-trusted-"));
const aliasParent = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-secret-plan-alias-"));
const aliasDir = path.join(aliasParent, "output");
const canonicalPlanPath = path.join(await fs.realpath(trustedDir), "plan.json");
try {
await fs.symlink(trustedDir, aliasDir);
await fs.chmod(aliasParent, 0o777);
const output = await runSetup(path.join(aliasDir, "plan.json"), [
"--openai-id",
"op://openclaw/OpenAI/credential",
]);
expect(output).toContain(`Plan written to ${canonicalPlanPath}`);
expect(JSON.parse(await fs.readFile(canonicalPlanPath, "utf8"))).toMatchObject({
version: 1,
});
} finally {
await fs.chmod(aliasParent, 0o700);
await fs.rm(aliasParent, { recursive: true, force: true });
await fs.rm(trustedDir, { recursive: true, force: true });
}
},
);
});
describe("1Password readiness", () => {
it("reports trusted executable and token prerequisites without exposing the token", async () => {
const resolveTrustedCli = vi.fn(async () => "/trusted/op");
const readTokenFile = vi.fn(() => "not-a-real-service-account-token");
@@ -296,10 +275,7 @@ describe("1Password CLI helpers", () => {
it("reports untrusted op and unsafe token prerequisites", async () => {
await expect(
testing.inspectSecretRefReadiness(
{
env: { CLAW_1PASSWORD_OP: "op", PATH: "/bin" },
tokenFile: "/missing-token",
},
{ env: { CLAW_1PASSWORD_OP: "op", PATH: "/bin" }, tokenFile: "/missing-token" },
{
resolveTrustedCli: async () => {
throw new Error("unsafe path detail");
@@ -318,208 +294,6 @@ describe("1Password CLI helpers", () => {
prerequisitesReady: false,
});
});
it("rejects traversal segments in SecretRef ids", () => {
expect(() => testing.parseProviderKeyMappings(["openai=op://openclaw/../credential"])).toThrow(
"Invalid --provider-key openai 1Password SecretRef id",
);
});
it("rejects invalid 1Password references before encoding", () => {
for (const id of ["/absolute/path", "op://openclaw\\OpenAI\\credential", "op://vault/clé"]) {
expect(() => testing.parseProviderKeyMappings([`openai=${id}`])).toThrow(
"Invalid --provider-key openai 1Password SecretRef id",
);
}
});
it("rejects unsupported config target paths", () => {
expect(() =>
testing.buildPlan({
providerAlias: "onepassword",
providerConfig: testing.buildProviderConfig(),
providerSecrets: [],
configTargetSecrets: [
{
path: "secrets.github_pat",
secretId: "op://openclaw/GitHub/pat",
},
],
}),
).toThrow("Unknown or unsupported 1Password setup target path: secrets.github_pat");
});
it("rejects duplicate config target paths", () => {
expect(() =>
testing.buildPlan({
providerAlias: "onepassword",
providerConfig: testing.buildProviderConfig(),
providerSecrets: [
{
providerId: "openai",
secretId: "op://openclaw/OpenAI/credential",
},
],
configTargetSecrets: [
{
path: "models.providers.openai.apiKey",
secretId: "op://openclaw/OpenAI/other",
},
],
}),
).toThrow("Duplicate secret target path in 1Password setup: models.providers.openai.apiKey");
});
it("creates plan files exclusively with owner-only permissions", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-1password-plan-test-"));
const planPath = path.join(tempDir, "plan.json");
const plan = createOpenAiPlan();
try {
await testing.writePlanFile(plan, planPath);
if (process.platform !== "win32") {
expect((await fs.stat(planPath)).mode & 0o777).toBe(0o600);
} else {
const permissions = await inspectPathPermissions(planPath);
expect(permissions).toMatchObject({
ok: true,
source: "windows-acl",
ownerTrusted: true,
groupReadable: false,
groupWritable: false,
worldReadable: false,
worldWritable: false,
});
}
await expect(testing.writePlanFile(plan, planPath)).rejects.toThrow(
"Plan path already exists",
);
const symlinkPath = path.join(tempDir, "symlink.json");
await fs.symlink(planPath, symlinkPath);
await expect(testing.writePlanFile(plan, symlinkPath)).rejects.toThrow(
"Plan path already exists",
);
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it.skipIf(process.platform === "win32")(
"rejects plan output in a directory writable by another account",
async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-1password-plan-test-"));
const planPath = path.join(tempDir, "plan.json");
const plan = createOpenAiPlan();
try {
await fs.chmod(tempDir, 0o777);
await expect(testing.writePlanFile(plan, planPath)).rejects.toThrow(
"path is writable by another user",
);
await expect(fs.stat(planPath)).rejects.toMatchObject({ code: "ENOENT" });
} finally {
await fs.chmod(tempDir, 0o700);
await fs.rm(tempDir, { recursive: true, force: true });
}
},
);
it.skipIf(process.platform === "win32")(
"writes through the canonical directory instead of a replaceable alias",
async () => {
const trustedDir = await fs.mkdtemp(
path.join(os.tmpdir(), "openclaw-1password-plan-trusted-"),
);
const aliasParent = await fs.mkdtemp(
path.join(os.tmpdir(), "openclaw-1password-plan-alias-"),
);
const aliasDir = path.join(aliasParent, "output");
const canonicalPlanPath = path.join(await fs.realpath(trustedDir), "plan.json");
const plan = createOpenAiPlan();
try {
await fs.symlink(trustedDir, aliasDir);
await fs.chmod(aliasParent, 0o777);
await expect(testing.writePlanFile(plan, path.join(aliasDir, "plan.json"))).resolves.toBe(
canonicalPlanPath,
);
expect(JSON.parse(await fs.readFile(canonicalPlanPath, "utf8"))).toMatchObject({
version: 1,
});
} finally {
await fs.chmod(aliasParent, 0o700);
await fs.rm(aliasParent, { recursive: true, force: true });
await fs.rm(trustedDir, { recursive: true, force: true });
}
},
);
it.skipIf(process.platform === "win32")(
"rejects unrenderable plan paths before creating a file",
async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-1password-plan-test-"));
const planPath = path.join(tempDir, "plan\n.json");
const plan = createOpenAiPlan();
try {
await expect(testing.writePlanFile(plan, planPath)).rejects.toThrow(
"Command argument cannot contain CR or LF",
);
await expect(fs.stat(planPath)).rejects.toMatchObject({ code: "ENOENT" });
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
},
);
it("writes a Windows plan through the atomic private-file primitive", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-1password-plan-test-"));
const planPath = path.join(tempDir, "plan.json");
const plan = createOpenAiPlan();
const createPrivateWindowsFile = vi.fn(async (filePath: string, content: string) => {
await fs.writeFile(filePath, content, { flag: "wx" });
});
const resolveTrustedPlanDirectory = vi.fn(async (directoryPath: string) => directoryPath);
try {
await testing.writePlanFile(plan, planPath, {
platform: "win32",
createPrivateWindowsFile,
resolveTrustedPlanDirectory,
});
expect(resolveTrustedPlanDirectory).toHaveBeenCalledWith(path.resolve(tempDir));
expect(createPrivateWindowsFile).toHaveBeenCalledWith(planPath, expect.any(String));
expect(JSON.parse(await fs.readFile(planPath, "utf8"))).toMatchObject({ version: 1 });
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it("prints the readiness check before plan application", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-1password-setup-test-"));
const planPath = path.join(tempDir, "plan with spaces.json");
const canonicalPlanPath = path.join(await fs.realpath(tempDir), "plan with spaces.json");
const output = captureStdout();
try {
await createProgram({}).parseAsync(
[
"onepassword",
"secretref",
"setup",
"--openai-id",
"op://openclaw/OpenAI/credential",
"--plan-out",
planPath,
],
{ from: "user" },
);
expect(output()).toContain("openclaw onepassword secretref status");
expect(output()).toContain(
`openclaw secrets apply --from '${canonicalPlanPath}' --dry-run --allow-exec`,
);
expect(output()).toContain(
`openclaw secrets apply --from '${canonicalPlanPath}' --allow-exec`,
);
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
});
describe("1Password CLI status", () => {
@@ -534,8 +308,8 @@ describe("1Password CLI status", () => {
},
},
});
expect(result.providerAlias).toBe("corp-onepassword");
expect(result).toMatchObject({
providerAlias: "corp-onepassword",
providerReady: true,
opStatus: "not-found",
tokenFileStatus: "missing-or-unsafe",
@@ -557,8 +331,7 @@ describe("1Password CLI status", () => {
},
},
});
expect(result.providerAlias).toBe("corp-onepassword");
expect(result.providerReady).toBe(true);
expect(result).toMatchObject({ providerAlias: "corp-onepassword", providerReady: true });
});
it("requires an explicit alias when multiple providers are configured", async () => {
+36 -414
View File
@@ -1,52 +1,46 @@
import { randomUUID } from "node:crypto";
import path from "node:path";
import { createInterface } from "node:readline/promises";
import { isRecord } from "openclaw/plugin-sdk/channel-secret-basic-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/plugin-entry";
import {
DEFAULT_SECRET_FILE_MAX_BYTES,
tryReadSecretFileSync,
} from "openclaw/plugin-sdk/secret-file-runtime";
import { pluginSecretRefSetup } from "openclaw/plugin-sdk/secret-ref-runtime";
import { createPluginSecretRefSetupCli } from "openclaw/plugin-sdk/secret-ref-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
import {
resolveTrustedOnePasswordCli,
resolveTrustedOnePasswordDirectoryPath,
} from "../onepassword-op-path.js";
import { resolveTrustedOnePasswordCli } from "../onepassword-op-path.js";
import { encodeOnePasswordSecretId } from "../onepassword-secret-id.js";
type CommandLike = {
command(name: string): CommandLike;
description(value: string): CommandLike;
option(
flags: string,
description: string,
defaultValueOrParser?: string | ((value: string, previous?: string[]) => string[]),
defaultValue?: string[],
): CommandLike;
action<TOptions>(fn: (options: TOptions) => void | Promise<void>): CommandLike;
};
const ONEPASSWORD_PROVIDER_ALIAS = "onepassword";
type OnePasswordExecProviderConfig = {
source: "exec";
function normalizeOnePasswordSecretId(label: string, value: string): string {
try {
return encodeOnePasswordSecretId(value);
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
throw new Error(`Invalid ${label} 1Password SecretRef id: ${detail}`, { cause: error });
}
}
const onePasswordSecretRefSetupCli = createPluginSecretRefSetupCli({
productName: "1Password",
secretIdLabel: "1Password SecretRef id",
secretIdPlaceholder: "1password-secret-id",
defaultProviderAlias: ONEPASSWORD_PROVIDER_ALIAS,
pluginIntegration: {
pluginId: "onepassword";
integrationId: "onepassword";
};
};
pluginId: "onepassword",
integrationId: "onepassword",
},
normalizeSecretId: normalizeOnePasswordSecretId,
defaultPlanPath: () =>
path.join(resolvePreferredOpenClawTmpDir(), `openclaw-1password-secrets-${randomUUID()}.json`),
beforeApplyCommands: [
"openclaw plugins enable onepassword",
"openclaw onepassword secretref status",
],
});
type ProviderSecretMapping = {
providerId: string;
secretId: string;
};
type ConfigTargetSecretMapping = {
path: string;
agentId?: string;
secretId: string;
};
type SecretsApplyPlan = ReturnType<typeof pluginSecretRefSetup.buildPlan>;
type CommandLike = Parameters<typeof onePasswordSecretRefSetupCli.registerSetupCommand>[0];
type RegisterOnePasswordSecretRefCommandsParams = {
command: CommandLike;
@@ -60,26 +54,6 @@ type StatusOptions = {
providerAlias?: string;
};
type SetupOptions = {
planOut?: string;
providerAlias?: string;
openaiId?: string;
anthropicId?: string;
openrouterId?: string;
providerKey?: string[];
target?: string[];
};
type ProviderStatus = {
configured: boolean;
source?: string;
command?: string;
pluginIntegration?: {
pluginId: string;
integrationId: string;
};
};
type SecretRefReadiness = {
opCommand: string;
opBinaryPath: string | null;
@@ -94,14 +68,6 @@ type ReadinessDependencies = {
readTokenFile?: (filePath: string) => string | undefined;
};
type WritePlanFileDependencies = {
platform?: NodeJS.Platform;
createPrivateWindowsFile?: (filePath: string, content: string) => Promise<void>;
resolveTrustedPlanDirectory?: typeof resolveTrustedOnePasswordDirectoryPath;
};
const ONEPASSWORD_PROVIDER_ALIAS = "onepassword";
function writeLine(message = ""): void {
process.stdout.write(`${message}\n`);
}
@@ -110,123 +76,6 @@ function writeJson(value: unknown): void {
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
}
function normalizeOptionalString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
type CommandShell = "cmd" | "posix" | "powershell";
function quoteCliArg(value: string, shell: CommandShell): string {
if (/\r|\n/u.test(value)) {
throw new Error("Command argument cannot contain CR or LF");
}
if (shell === "cmd") {
if (/[%!]/u.test(value)) {
throw new Error("Interactive Command Prompt cannot safely quote paths containing % or !");
}
const escaped = value.replaceAll('"', '\\"');
return /[ \t"&|<>^()]/u.test(value) ? `"${escaped}"` : escaped || '""';
}
if (shell === "powershell") {
return `'${value.replaceAll("'", "''")}'`;
}
if (/^[A-Za-z0-9_/:=.,@%+-]+$/.test(value)) {
return value;
}
return `'${value.replaceAll("'", "'\\''")}'`;
}
function renderApplyCommands(
planPath: string,
platform: NodeJS.Platform = process.platform,
): string[] {
const render = (shell: CommandShell, extraIndent = "") => {
const quotedPlanPath = quoteCliArg(planPath, shell);
return [
`${extraIndent}openclaw secrets apply --from ${quotedPlanPath} --dry-run --allow-exec`,
`${extraIndent}openclaw secrets apply --from ${quotedPlanPath} --allow-exec`,
];
};
if (platform !== "win32") {
return render("posix");
}
// Windows cannot reveal which parent shell will receive these copy-paste commands.
// Print native variants instead of emitting syntax that is unsafe in the other shell.
const powershellCommands = ["PowerShell:", ...render("powershell", " ")];
if (/[%!]/u.test(planPath)) {
return [
...powershellCommands,
"Command Prompt: unavailable for paths containing % or !; use PowerShell.",
];
}
return [...powershellCommands, "Command Prompt:", ...render("cmd", " ")];
}
function assertValidProviderAlias(value: string): void {
pluginSecretRefSetup.assertValidProviderAlias(value);
}
function normalizeOnePasswordSecretId(label: string, value: string): string {
try {
return encodeOnePasswordSecretId(value);
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
throw new Error(`Invalid ${label} 1Password SecretRef id: ${detail}`, { cause: error });
}
}
function readProviderStatus(config: OpenClawConfig, providerAlias: string): ProviderStatus {
const provider = config.secrets?.providers?.[providerAlias];
if (!isRecord(provider)) {
return { configured: false };
}
const base = {
configured: true,
source: normalizeOptionalString(provider.source),
};
if (provider.source !== "exec") {
return base;
}
if ("pluginIntegration" in provider) {
return {
...base,
pluginIntegration: provider.pluginIntegration as ProviderStatus["pluginIntegration"],
};
}
return {
...base,
command: normalizeOptionalString(provider.command),
};
}
function isOnePasswordIntegrationProvider(value: unknown): boolean {
if (!isRecord(value) || value.source !== "exec" || !isRecord(value.pluginIntegration)) {
return false;
}
return (
value.pluginIntegration.pluginId === "onepassword" &&
value.pluginIntegration.integrationId === "onepassword"
);
}
function resolveStatusProviderAlias(config: OpenClawConfig, requestedAlias?: string): string {
const explicitAlias = normalizeOptionalString(requestedAlias);
if (explicitAlias) {
assertValidProviderAlias(explicitAlias);
return explicitAlias;
}
const configuredAliases = Object.entries(config.secrets?.providers ?? {})
.filter(([, provider]) => isOnePasswordIntegrationProvider(provider))
.map(([alias]) => alias)
.toSorted();
if (configuredAliases.length > 1) {
throw new Error(
`Multiple 1Password provider aliases are configured (${configuredAliases.join(", ")}). Use --provider-alias <alias>.`,
);
}
return configuredAliases[0] ?? ONEPASSWORD_PROVIDER_ALIAS;
}
async function inspectSecretRefReadiness(
params: { env: NodeJS.ProcessEnv; tokenFile: string },
dependencies: ReadinessDependencies = {},
@@ -275,152 +124,13 @@ async function inspectSecretRefReadiness(
};
}
function buildProviderConfig(): OnePasswordExecProviderConfig {
return {
source: "exec",
pluginIntegration: {
pluginId: "onepassword",
integrationId: "onepassword",
},
};
}
function parseTargetSpecifier(value: string): {
path: string;
agentId?: string;
} {
return pluginSecretRefSetup.parseTargetSpecifier("1Password", value);
}
function parseProviderKeyMappings(values: string[] | undefined): ProviderSecretMapping[] {
return (values ?? []).map((value) => {
const separator = value.indexOf("=");
if (separator <= 0 || separator === value.length - 1) {
throw new Error(
`Invalid --provider-key value "${value}". Use <model-provider-id>=<1password-secret-id>.`,
);
}
const providerId = value.slice(0, separator).trim();
pluginSecretRefSetup.assertValidModelProviderId("--provider-key", providerId);
const secretId = normalizeOnePasswordSecretId(
`--provider-key ${providerId}`,
value.slice(separator + 1).trim(),
);
return { providerId, secretId };
});
}
function parseConfigTargetMappings(values: string[] | undefined): ConfigTargetSecretMapping[] {
return (values ?? []).map((value) => {
const separator = value.indexOf("=");
if (separator <= 0 || separator === value.length - 1) {
throw new Error(
`Invalid --target value "${value}". Use <openclaw-config-path>=<1password-secret-id>.`,
);
}
const target = parseTargetSpecifier(value.slice(0, separator).trim());
const secretId = normalizeOnePasswordSecretId(
`--target ${target.path}`,
value.slice(separator + 1).trim(),
);
return Object.assign(
{ path: target.path, secretId },
target.agentId ? { agentId: target.agentId } : {},
);
});
}
function collectProviderSecrets(options: {
openaiId?: string;
anthropicId?: string;
openrouterId?: string;
providerKey?: string[];
}): ProviderSecretMapping[] {
const providerSecrets: ProviderSecretMapping[] = [];
if (options.openaiId) {
providerSecrets.push({ providerId: "openai", secretId: options.openaiId });
}
if (options.anthropicId) {
providerSecrets.push({ providerId: "anthropic", secretId: options.anthropicId });
}
if (options.openrouterId) {
providerSecrets.push({ providerId: "openrouter", secretId: options.openrouterId });
}
providerSecrets.push(...parseProviderKeyMappings(options.providerKey));
const seen = new Set<string>();
for (const entry of providerSecrets) {
const normalized = entry.providerId.toLowerCase();
if (seen.has(normalized)) {
throw new Error(`Duplicate model provider id in 1Password setup: ${entry.providerId}`);
}
seen.add(normalized);
}
return providerSecrets;
}
function buildPlan(params: {
providerAlias: string;
providerConfig: OnePasswordExecProviderConfig;
providerSecrets: ProviderSecretMapping[];
configTargetSecrets?: ConfigTargetSecretMapping[];
}): SecretsApplyPlan {
const plan = pluginSecretRefSetup.buildPlan({ productName: "1Password", ...params });
if (plan.targets.length === 0) {
throw new Error(
"No SecretRef targets selected. Pass --openai-id, --anthropic-id, --openrouter-id, --provider-key, or --target.",
);
}
return plan;
}
async function promptOptionalSecretId(label: string): Promise<string | undefined> {
if (!process.stdin.isTTY || !process.stdout.isTTY) {
return undefined;
}
const rl = createInterface({ input: process.stdin, output: process.stdout });
try {
return normalizeOptionalString(
await rl.question(`${label} 1Password SecretRef id (blank to skip): `),
);
} finally {
rl.close();
}
}
async function promptProviderSecrets(options: SetupOptions): Promise<ProviderSecretMapping[]> {
const openaiId =
normalizeOptionalString(options.openaiId) ?? (await promptOptionalSecretId("OpenAI"));
const anthropicId =
normalizeOptionalString(options.anthropicId) ?? (await promptOptionalSecretId("Anthropic"));
const openrouterId =
normalizeOptionalString(options.openrouterId) ?? (await promptOptionalSecretId("OpenRouter"));
const normalizedOpenaiId = openaiId
? normalizeOnePasswordSecretId("OpenAI", openaiId)
: undefined;
const normalizedAnthropicId = anthropicId
? normalizeOnePasswordSecretId("Anthropic", anthropicId)
: undefined;
const normalizedOpenrouterId = openrouterId
? normalizeOnePasswordSecretId("OpenRouter", openrouterId)
: undefined;
return collectProviderSecrets({
...(normalizedOpenaiId ? { openaiId: normalizedOpenaiId } : {}),
...(normalizedAnthropicId ? { anthropicId: normalizedAnthropicId } : {}),
...(normalizedOpenrouterId ? { openrouterId: normalizedOpenrouterId } : {}),
providerKey: options.providerKey,
});
}
async function runStatus(
params: RegisterOnePasswordSecretRefCommandsParams,
options: StatusOptions,
): Promise<void> {
const config = params.config;
const providerAlias = resolveStatusProviderAlias(config, options.providerAlias);
const provider = readProviderStatus(config, providerAlias);
const providerReady = isOnePasswordIntegrationProvider(
config.secrets?.providers?.[providerAlias],
const { providerAlias, provider, providerReady } = onePasswordSecretRefSetupCli.inspectProvider(
params.config,
options.providerAlias,
);
const readiness = await inspectSecretRefReadiness({
env: params.env ?? process.env,
@@ -470,7 +180,7 @@ async function runStatus(
if (issues.length === 0) {
return;
}
writeLine("");
writeLine();
writeLine("Next actions:");
if (!providerReady) {
writeLine(" Generate and apply a 1Password SecretRef setup plan.");
@@ -485,60 +195,6 @@ async function runStatus(
}
}
async function writePlanFile(
plan: SecretsApplyPlan,
requestedPath?: string,
dependencies: WritePlanFileDependencies = {},
): Promise<string> {
const requestedPlanPath =
normalizeOptionalString(requestedPath) ??
path.join(resolvePreferredOpenClawTmpDir(), `openclaw-1password-secrets-${randomUUID()}.json`);
const content = `${JSON.stringify(plan, null, 2)}\n`;
const requestedPlanPathAbsolute = path.resolve(requestedPlanPath);
const planDirectory = await (
dependencies.resolveTrustedPlanDirectory ?? resolveTrustedOnePasswordDirectoryPath
)(path.dirname(requestedPlanPathAbsolute));
// Write through the canonical directory returned by the trust check. Reusing the requested
// alias would let another local account retarget a writable parent symlink after validation.
const planPath = path.join(planDirectory, path.basename(requestedPlanPathAbsolute));
const platform = dependencies.platform ?? process.platform;
// Validate the exact canonical path before the exclusive write. Follow-up command rendering
// must not fail after leaving a plan behind that the next setup attempt cannot overwrite.
renderApplyCommands(planPath, platform);
await pluginSecretRefSetup.writePlanFile({
planPath,
content,
platform,
createPrivateWindowsFile: dependencies.createPrivateWindowsFile,
});
return planPath;
}
async function runSetup(options: SetupOptions): Promise<void> {
const providerAlias =
normalizeOptionalString(options.providerAlias) ?? ONEPASSWORD_PROVIDER_ALIAS;
assertValidProviderAlias(providerAlias);
const providerSecrets = await promptProviderSecrets(options);
const plan = buildPlan({
providerAlias,
providerConfig: buildProviderConfig(),
providerSecrets,
configTargetSecrets: parseConfigTargetMappings(options.target),
});
const planPath = await writePlanFile(plan, options.planOut);
writeLine(`Plan written to ${planPath}`);
writeLine(`Targets: ${plan.targets.length}`);
writeLine("");
writeLine("Next steps:");
writeLine(" openclaw plugins enable onepassword");
writeLine(" openclaw onepassword secretref status");
for (const command of renderApplyCommands(planPath)) {
writeLine(` ${command}`);
}
writeLine(" openclaw secrets audit --check --allow-exec");
writeLine(" openclaw secrets reload");
}
export function registerOnePasswordSecretRefCommands(
params: RegisterOnePasswordSecretRefCommandsParams,
): void {
@@ -549,41 +205,7 @@ export function registerOnePasswordSecretRefCommands(
.option("--json", "Print JSON status")
.option("--provider-alias <alias>", "Secret provider alias to inspect")
.action((options: StatusOptions) => runStatus(params, options));
secretRef
.command("setup")
.description("Create a 1Password SecretRef setup plan")
.option("--plan-out <path>", "Write the generated secrets apply plan to a path")
.option(
"--provider-alias <alias>",
"Secret provider alias to configure",
ONEPASSWORD_PROVIDER_ALIAS,
)
.option("--openai-id <id>", "1Password SecretRef id for models.providers.openai.apiKey")
.option("--anthropic-id <id>", "1Password SecretRef id for models.providers.anthropic.apiKey")
.option("--openrouter-id <id>", "1Password SecretRef id for models.providers.openrouter.apiKey")
.option(
"--provider-key <provider=id>",
"1Password SecretRef id for any models.providers.<provider>.apiKey target",
(value: string, previous: string[] = []) => [...previous, value],
[],
)
.option(
"--target <path=id>",
"1Password SecretRef id for any known SecretRef target path",
(value: string, previous: string[] = []) => [...previous, value],
[],
)
.action((options: SetupOptions) => runSetup(options));
onePasswordSecretRefSetupCli.registerSetupCommand(secretRef);
}
export const testing = {
buildPlan,
buildProviderConfig,
collectProviderSecrets,
parseConfigTargetMappings,
parseProviderKeyMappings,
quoteCliArg,
renderApplyCommands,
inspectSecretRefReadiness,
writePlanFile,
};
export const testing = { inspectSecretRefReadiness };
@@ -96,10 +96,29 @@ describe("Telegram QA transport adapter", () => {
const addOutboundMessage = vi.fn().mockResolvedValue({ id: "out-1" });
const editMessage = vi.fn().mockResolvedValue({ id: "out-1" });
const adapter = await createTelegramQaTransportAdapter({
adapterOptions: { sutAccountId: "sut" },
adapterOptions: {
sutAccountId: "sut",
transportPolicy: { requireGroupMention: true },
},
messages: { addInboundMessage, addOutboundMessage, editMessage },
} as never);
expect(adapter.createGatewayConfig?.({ baseUrl: "http://127.0.0.1:1234" })).toMatchObject({
channels: {
telegram: {
accounts: {
sut: {
groups: {
"-100123": {
requireMention: true,
},
},
},
},
},
},
});
await vi.waitFor(() => expect(pollResolvers).toHaveLength(1));
await adapter.sendInbound?.({
conversation: { id: "logical-room", kind: "group" },
@@ -185,10 +204,38 @@ describe("Telegram QA transport adapter", () => {
expect.objectContaining({ messageId: "out-1", text: "final", timestamp: 101_000 }),
);
await adapter.resetTransport?.();
await vi.waitFor(() => expect(pollResolvers).toHaveLength(3));
await adapter.sendInbound?.({
conversation: { id: "next-room", kind: "group" },
senderId: "driver",
text: "next",
});
pollResolvers[2]?.([
{
update_id: 3,
edited_message: {
message_id: 13,
date: 102,
chat: { id: -100123 },
from: { id: 2, is_bot: true, username: "openclaw_qa_bot" },
text: "orphan final",
},
},
]);
await vi.waitFor(() => expect(addOutboundMessage).toHaveBeenCalledTimes(2));
expect(addOutboundMessage).toHaveBeenLastCalledWith(
expect.objectContaining({
to: "group:next-room",
text: "orphan final",
timestamp: 102_000,
}),
);
await vi.waitFor(() => expect(pollResolvers).toHaveLength(4));
mocks.heartbeatStop.mockRejectedValueOnce(new Error("heartbeat stop failed"));
const cleanup = adapter.cleanup?.();
pollResolvers[2]?.([]);
pollResolvers[3]?.([]);
await cleanup;
expect(mocks.shouldRetainQaGatewayCredentialLease).not.toHaveBeenCalled();
await expect(adapter.cleanupAfterGatewayStop?.()).rejects.toThrow("heartbeat stop failed");
@@ -134,17 +134,17 @@ export async function createTelegramQaTransportAdapter(
continue;
}
const existingMessageId = busMessageIds.get(message.messageId);
if (update.edited_message) {
if (existingMessageId) {
await context.messages.editMessage({
accountId,
messageId: existingMessageId,
text: message.text,
timestamp: message.timestamp,
});
}
if (update.edited_message && existingMessageId) {
await context.messages.editMessage({
accountId,
messageId: existingMessageId,
text: message.text,
timestamp: message.timestamp,
});
continue;
}
// Telegram may expose only the final edit after the adapter resets between
// scenarios. Adopt that edit so the live observation cannot disappear.
const outbound = await context.messages.addOutboundMessage({
accountId,
to: `${logicalConversationKind}:${logicalConversationId}`,
@@ -223,6 +223,8 @@ export async function createTelegramQaTransportAdapter(
sutToken: runtimeEnv.sutToken,
driverBotId: driverIdentity.id,
sutAccountId: accountId,
// Mention-gating scenarios opt in through the shared transport policy.
requireMention: options.transportPolicy?.requireGroupMention === true,
}),
waitReady: async ({ gateway, timeoutMs, pollIntervalMs }) =>
await waitForTelegramChannelRunning(gateway, accountId, {
@@ -25,7 +25,7 @@ describe("Telegram QA profiles", () => {
expect(live).not.toContain("telegram-long-final-reuses-preview");
expect(mock).toContain("telegram-long-final-reuses-preview");
expect(mock).toContain("telegram-assistant-transcript-role-boundary");
expect(mock).not.toContain("telegram-assistant-transcript-role-boundary");
expect(mock).not.toContain("telegram-startup-getme-live");
});
@@ -36,7 +36,7 @@ describe("Telegram QA profiles", () => {
});
expect(scenarioIds).toContain("channel-message-flows");
expect(scenarioIds).toContain("native-command-session-target");
expect(scenarioIds).not.toContain("native-command-session-target");
});
it("lets explicit scenarios override profile selection", () => {
@@ -76,6 +76,7 @@ describe("Telegram QA API boundary", () => {
sutToken: "placeholder",
driverBotId: 1,
sutAccountId: "sut",
requireMention: true,
},
);
@@ -100,6 +101,28 @@ describe("Telegram QA API boundary", () => {
});
});
it("disables mention gating only inside the exact leased QA group", () => {
const config = buildTelegramQaConfig(
{},
{
groupId: "-100123",
sutToken: "placeholder",
driverBotId: 1,
sutAccountId: "sut",
requireMention: false,
},
);
expect(config.channels?.telegram?.groups).toBeUndefined();
expect(config.channels?.telegram?.accounts?.sut?.groups).toEqual({
"-100123": {
groupPolicy: "allowlist",
allowFrom: ["1"],
requireMention: false,
},
});
});
it("waits for the selected Telegram account to become connected", async () => {
const call = vi
.fn()
@@ -257,6 +257,7 @@ export function buildTelegramQaConfig(
sutToken: string;
driverBotId: number;
sutAccountId: string;
requireMention: boolean;
},
): OpenClawConfig {
return {
@@ -305,7 +306,7 @@ export function buildTelegramQaConfig(
[params.groupId]: {
groupPolicy: "allowlist",
allowFrom: [String(params.driverBotId)],
requireMention: true,
requireMention: params.requireMention,
},
},
},
@@ -64,7 +64,7 @@ describe("taxonomy profile scenario selection", () => {
expect(liveTelegram).toContain("telegram-help-command");
expect(liveTelegram).not.toContain("telegram-assistant-transcript-role-boundary");
expect(mockTelegram).toContain("telegram-assistant-transcript-role-boundary");
expect(mockTelegram).not.toContain("telegram-assistant-transcript-role-boundary");
expect(mockTelegram).not.toContain("discord-canary");
});
@@ -270,7 +270,6 @@ export const QA_SUBAGENT_TERMINAL_MARKERS = {
fallback: "QA-SUBAGENT-TERMINAL-FALLBACK-OK",
} as const;
export const QA_SUBAGENT_TERMINAL_METADATA_SENTINEL = "QA-SUBAGENT-TERMINAL-INTERNAL-MUST-NOT-LEAK";
export const QA_SUBAGENT_TERMINAL_WORKER_DELAY_MS = 5_000;
export const QA_NATIVE_STOP_DELAY_PROMPT_RE =
/subagent recovery worker native command target proof\.\s*wait until stopped\./i;
export const QA_NATIVE_STOP_DELAY_MS = 180_000;
@@ -10,6 +10,7 @@ export type QaMockResponsesDispatchResult = {
type: string;
message: string;
};
onResponseSent?: () => void;
previewPauseMs?: number;
};
@@ -232,6 +233,7 @@ export function attachQaMockResponsesWebSocketServer(params: {
}
sendEvent(event);
}
dispatched.onResponseSent?.();
})
.catch(() => {
cachedResponse = undefined;
@@ -2688,6 +2688,68 @@ describe("qa mock openai server", () => {
expect(outputText(payload)).toBe(expected);
});
it("binds crossed same-case parent responses to their matching workers", async () => {
const server = await startMockServer();
const firstChildSessionKey = "agent:qa:subagent:child-1";
const secondChildSessionKey = "agent:qa:subagent:child-2";
const startChild = (runtimeSessionId: string, childSessionKey: string) =>
postNonStreamingResponses(server, {
model: "gpt-5.6-luna",
instructions: [
`Runtime: embedded | sessionId=${runtimeSessionId}`,
`- Your session: ${childSessionKey}.`,
].join("\n"),
input: [makeUserInput("Subagent terminal reply QA worker: visible.")],
});
const settleParent = async (
runtimeSessionId: string,
childSessionKey: string,
callId: string,
) => {
const parent = await expectNonStreamingResponsesJson(server, {
model: "gpt-5.6-luna",
instructions: `Runtime: embedded | sessionId=${runtimeSessionId}`,
tools: [SESSIONS_SPAWN_TOOL, SESSIONS_YIELD_TOOL],
input: [
makeUserInput("Subagent terminal reply QA check: visible."),
makeToolOutputWithCallId(
callId,
JSON.stringify({ status: "accepted", childSessionKey, runId: `run-${callId}` }),
),
],
});
expect(outputText(parent)).toBe("NO_REPLY");
};
const firstChildResponse = startChild("qa-terminal-child-1", firstChildSessionKey);
const secondChildResponse = startChild("qa-terminal-child-2", secondChildSessionKey);
let firstChildSettled = false;
let secondChildSettled = false;
void firstChildResponse.then(() => {
firstChildSettled = true;
});
void secondChildResponse.then(() => {
secondChildSettled = true;
});
await expect
.poll(async () => {
const inflight = await getJson<unknown[]>(server, "/debug/inflight-requests");
return inflight.length;
})
.toBe(2);
await settleParent("qa-terminal-parent-2", secondChildSessionKey, "call_spawn_2");
const secondChild = await (await expectOk(secondChildResponse)).json();
expect(outputText(secondChild)).toBe("QA-SUBAGENT-TERMINAL-VISIBLE-OK");
expect(secondChildSettled).toBe(true);
expect(firstChildSettled).toBe(false);
await settleParent("qa-terminal-parent-1", firstChildSessionKey, "call_spawn_1");
const firstChild = await (await expectOk(firstChildResponse)).json();
expect(outputText(firstChild)).toBe("QA-SUBAGENT-TERMINAL-VISIBLE-OK");
});
it("keeps the empty terminal worker empty across retry prompts", async () => {
const server = await startMockServer();
const payload = await expectNonStreamingResponsesJson(server, {
@@ -64,7 +64,6 @@ import {
QA_SUBAGENT_DIRECT_FALLBACK_MARKER,
QA_SUBAGENT_TERMINAL_MARKERS,
QA_SUBAGENT_TERMINAL_METADATA_SENTINEL,
QA_SUBAGENT_TERMINAL_WORKER_DELAY_MS,
QA_NATIVE_STOP_DELAY_PROMPT_RE,
QA_NATIVE_STOP_DELAY_MS,
QA_IMAGE_GENERATION_PROMPT_RE,
@@ -468,9 +467,76 @@ function extractScenarioPlannedTool(events: StreamEvent[]) {
: { name: wireName, args: wireArgs, wireName };
}
type TerminalRequesterSettleGate = {
markSettled: (caseName: string, childSessionKey: string) => void;
waitUntilSettled: (caseName: string, childSessionKey: string) => Promise<void>;
};
function createTerminalRequesterSettleGate(): TerminalRequesterSettleGate {
const settledChildren = new Set<string>();
const waiterPromises = new Map<string, Promise<void>>();
const waiters = new Map<string, () => void>();
const childKey = (caseName: string, childSessionKey: string) => `${caseName}\n${childSessionKey}`;
return {
markSettled(caseName, childSessionKey) {
const key = childKey(caseName, childSessionKey);
settledChildren.add(key);
waiters.get(key)?.();
},
async waitUntilSettled(caseName, childSessionKey) {
const key = childKey(caseName, childSessionKey);
if (settledChildren.has(key)) {
return;
}
const existing = waiterPromises.get(key);
if (existing) {
return await existing;
}
const promise = new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
waiters.delete(key);
waiterPromises.delete(key);
reject(new Error(`terminal requester did not settle: ${caseName} (${childSessionKey})`));
}, 30_000);
const finish = () => {
clearTimeout(timeout);
waiters.delete(key);
waiterPromises.delete(key);
resolve();
};
waiters.set(key, finish);
});
waiterPromises.set(key, promise);
await promise;
},
};
}
function resolveQaRuntimeSessionId(input: ResponsesInputItem[], body: Record<string, unknown>) {
return /\bRuntime:\s*[^\n]*\bsessionId=([^\s|]+)/u.exec(extractAllRequestTexts(input, body))?.[1];
}
function resolveQaChildSessionKey(input: ResponsesInputItem[], body: Record<string, unknown>) {
const systemPrompt = extractAllRequestTexts(
input.filter((item) => item.role === "developer" || item.role === "system"),
body,
);
return /^- Your session:\s*(.+?)\.\s*$/mu.exec(systemPrompt)?.[1]?.trim();
}
function resolveAcceptedChildSessionKey(input: ResponsesInputItem[]) {
const output = parseToolOutputJson(extractToolOutput(input));
return output?.status === "accepted" && typeof output.childSessionKey === "string"
? output.childSessionKey.trim() || undefined
: undefined;
}
async function buildResponsesPayload(
body: Record<string, unknown>,
scenarioState: MockScenarioState,
options: {
waitForTerminalRequesterSettled?: (caseName: string, childSessionKey: string) => Promise<void>;
} = {},
) {
const providerVariant = resolveProviderVariant(
typeof body.model === "string" ? body.model : undefined,
@@ -778,7 +844,10 @@ async function buildResponsesPayload(
.at(-1)?.[1]
?.toLowerCase();
if (terminalWorkerCase) {
await sleep(QA_SUBAGENT_TERMINAL_WORKER_DELAY_MS);
const childSessionKey = resolveQaChildSessionKey(input, body);
if (options.waitForTerminalRequesterSettled && childSessionKey) {
await options.waitForTerminalRequesterSettled(terminalWorkerCase, childSessionKey);
}
}
if (terminalWorkerCase === "silent") {
return buildAssistantEvents("NO_REPLY");
@@ -1931,6 +2000,7 @@ export async function startQaMockOpenAiServer(params?: {
}) {
const host = params?.host ?? "127.0.0.1";
const finalOnlyMarkerPauseMs = params?.finalOnlyMarkerPauseMs ?? 1_500;
const terminalRequesterSettleGate = createTerminalRequesterSettleGate();
const scenarioStates = new Map<string, MockScenarioState>();
const scenarioStateFor = (body: Record<string, unknown>): MockScenarioState => {
const input = Array.isArray(body.input)
@@ -1939,12 +2009,8 @@ export async function startQaMockOpenAiServer(params?: {
system: body.system as AnthropicMessagesRequest["system"],
messages: [],
});
const systemPrompt = extractAllRequestTexts(
input.filter((item) => item.role === "developer" || item.role === "system"),
body,
);
const sessionId =
/\bRuntime:\s*[^\n]*\bsessionId=([^\s|]+)/u.exec(systemPrompt)?.[1] ??
resolveQaRuntimeSessionId(input, body) ??
(body.client_metadata as { session_id?: unknown } | undefined)?.session_id;
const key = typeof sessionId === "string" ? sessionId : "";
// Runtime session identity survives provider switches and cache-boundary changes.
@@ -1989,12 +2055,29 @@ export async function startQaMockOpenAiServer(params?: {
inflightRequests.set(inflightRequestId, { prompt, allInputText });
let events: StreamEvent[];
try {
events = await buildResponsesPayload(request.body, scenarioStateFor(request.body));
events = await buildResponsesPayload(request.body, scenarioStateFor(request.body), {
waitForTerminalRequesterSettled: terminalRequesterSettleGate.waitUntilSettled,
});
} finally {
inflightRequests.delete(inflightRequestId);
}
const resolvedModel = typeof request.body.model === "string" ? request.body.model : "";
const plannedTool = extractScenarioPlannedTool(events);
const terminalRequesterCase = extractLastMatchingUserTurn(
input,
QA_SUBAGENT_TERMINAL_MATRIX_PROMPT_RE,
)
?.text.match(QA_SUBAGENT_TERMINAL_MATRIX_PROMPT_RE)?.[1]
?.toLowerCase();
const settledTerminalRequester =
terminalRequesterCase && resolveQaRuntimeSessionId(input, request.body)
? {
caseName: terminalRequesterCase,
childSessionKey: resolveAcceptedChildSessionKey(input),
}
: undefined;
const settledTerminalCaseName = settledTerminalRequester?.caseName;
const settledChildSessionKey = settledTerminalRequester?.childSessionKey;
recordRequest({
raw: request.raw,
body: request.body,
@@ -2016,6 +2099,15 @@ export async function startQaMockOpenAiServer(params?: {
});
return {
events,
...(settledTerminalCaseName && settledChildSessionKey
? {
onResponseSent: () =>
terminalRequesterSettleGate.markSettled(
settledTerminalCaseName,
settledChildSessionKey,
),
}
: {}),
...(QA_PROVIDER_HTTP_503_AFTER_TOOL_PROMPT_RE.test(allInputText) && hasToolOutput(input)
? {
failure: {
@@ -2189,6 +2281,7 @@ export async function startQaMockOpenAiServer(params?: {
return;
}
writeJson(res, 200, completion.response);
dispatched.onResponseSent?.();
return;
}
if (dispatched.previewPauseMs !== undefined) {
@@ -2196,6 +2289,7 @@ export async function startQaMockOpenAiServer(params?: {
} else {
writeSse(res, events);
}
dispatched.onResponseSent?.();
return;
}
if (req.method === "POST" && url.pathname === "/v1/messages") {
@@ -25,17 +25,17 @@ describe("qa scenario catalog channel contracts", () => {
const scenario = readQaScenarioById("native-command-session-target");
const config = readQaScenarioExecutionConfig("native-command-session-target") as
| {
requiredChannelDriver?: string;
requiredProviderMode?: string;
sessionKey?: string;
}
| undefined;
expect(scenario.execution.channel).toBe("telegram");
expect(config?.requiredProviderMode).toBe("mock-openai");
expect(config?.sessionKey).toBe("agent:main:telegram:direct:qa-native-operator");
expect(JSON.stringify(requireFlowScenario(scenario).execution.flow)).toContain(
"session.key === config.sessionKey && session.hasActiveRun === true",
);
expect(config?.requiredChannelDriver).toBe("crabline");
const flow = JSON.stringify(requireFlowScenario(scenario).execution.flow);
expect(flow).toContain("transport.buildAgentDelivery");
expect(flow).toContain("peer: { kind: 'group', id: delivery.replyTo }");
});
it("keeps channel-owned scenarios independent from the driver implementation", () => {
@@ -143,6 +143,36 @@ describe("qa scenario catalog channel contracts", () => {
expect(flow).not.toContain('"value":"subagent-1: ok\\nsubagent-2: ok"');
});
it("settles terminal-reply scenarios from durable task facts instead of sleeps", () => {
const scenario = requireFlowScenario(readQaScenarioById("subagent-completion-direct-fallback"));
const flow = JSON.stringify(scenario.execution.flow);
const config = scenario.execution.config as
| { cases?: Array<{ name?: string; marker?: string; expectedSendCount?: number }> }
| undefined;
expect(config?.cases).toEqual([
{
name: "visible",
marker: "QA-SUBAGENT-TERMINAL-VISIBLE-OK",
expectedSendCount: 1,
},
{ name: "silent", marker: "NO_REPLY", expectedSendCount: 0 },
{
name: "fallback",
marker: "QA-SUBAGENT-TERMINAL-FALLBACK-OK",
expectedSendCount: 1,
},
]);
expect(flow).toContain("env.gateway.call('tasks.list'");
expect(flow).toContain("task.title === `qa-terminal-${caseName}`");
expect(flow).toContain("task.status === 'completed'");
expect(flow).toContain("task.deliveryStatus === 'delivered'");
expect(flow).toContain("readSettledTerminalTask('restart')");
expect(flow).toContain("readSettledTerminalTask('empty')");
expect(flow).toContain("verdicts.length === 5");
expect(flow).not.toContain('"call":"sleep"');
});
it("keeps channel streaming evidence portable across QA Channel and Crabline Telegram", () => {
const scenario = requireFlowScenario(readQaScenarioById("channel-message-flows"));
@@ -151,21 +181,21 @@ describe("qa scenario catalog channel contracts", () => {
expect(scenario.coverage?.primary).toEqual(["channels.streaming-final-reply"]);
expect(scenario.coverage?.secondary).toEqual([`${agentRuntime}.streaming-replies-delivery`]);
expect(scenario.gatewayConfigPatch).toMatchObject({
channels: {
telegram: {
groups: { "*": { requireMention: false } },
streaming: { mode: "partial" },
},
},
channels: { telegram: { streaming: { mode: "partial" } } },
});
expect(scenario.gatewayConfigPatch).not.toHaveProperty("channels.telegram.groups");
});
it("disables Telegram mention gating for deterministic group delivery proofs", () => {
it("keeps transcript-role delivery on the Crabline driver", () => {
const scenario = readQaScenarioById("telegram-assistant-transcript-role-boundary");
const config = readQaScenarioExecutionConfig("telegram-assistant-transcript-role-boundary") as
| {
requiredChannelDriver?: string;
}
| undefined;
expect(scenario.gatewayConfigPatch).toMatchObject({
channels: { telegram: { groups: { "*": { requireMention: false } } } },
});
expect(scenario.gatewayConfigPatch).toBeUndefined();
expect(config?.requiredChannelDriver).toBe("crabline");
});
it("rejects malformed string matcher lists before running a flow", () => {
+12 -2
View File
@@ -1,8 +1,9 @@
/* @vitest-environment jsdom */
import { readFileSync } from "node:fs";
import path from "node:path";
import type { QaBusStateSnapshot } from "openclaw/plugin-sdk/qa-channel-protocol";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Bootstrap, RunnerSelection, Snapshot } from "./ui-types.js";
import type { Bootstrap, RunnerSelection } from "./ui-types.js";
const httpMock = vi.hoisted(() => {
class QaLabHttpError extends Error {
@@ -105,7 +106,13 @@ function createBootstrap(selection: RunnerSelection): Bootstrap {
async function mountRunner(
selection: RunnerSelection,
snapshot: Snapshot = { conversations: [], events: [], messages: [], threads: [] },
snapshot: QaBusStateSnapshot = {
conversations: [],
cursor: 0,
events: [],
messages: [],
threads: [],
},
) {
let bootstrap = createBootstrap(selection);
httpMock.getJson.mockImplementation(async (url: string) => {
@@ -215,12 +222,15 @@ describe("QA Lab runner browser interactions", () => {
},
{
conversations: [{ accountId: "default", id: "qa-room", kind: "channel" }],
cursor: 0,
events: [],
messages: [],
threads: [
{
accountId: "default",
conversationId: "qa-room",
createdAt: 0,
createdBy: "qa-operator",
id: "owned-thread",
title: "Owned thread",
},
+2 -2
View File
@@ -1,4 +1,5 @@
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import type { QaBusStateSnapshot } from "openclaw/plugin-sdk/qa-channel-protocol";
// Qa Lab plugin module implements app behavior.
import { defaultQaModelForMode, isQaFastModeEnabled } from "../../model-selection.js";
import { normalizeCaptureSavedView, normalizeCaptureSavedViews } from "./capture-saved-view.js";
@@ -11,7 +12,6 @@ import {
type ReportEnvelope,
type RunnerResolvedPlan,
type RunnerSelection,
type Snapshot,
type TabId,
type CaptureEventsEnvelope,
type CaptureCoverageEnvelope,
@@ -342,7 +342,7 @@ export async function createQaLabApp(root: HTMLDivElement) {
try {
const [bootstrap, snapshot, report, outcomes] = await Promise.all([
getJson<Bootstrap>("/api/bootstrap"),
getJson<Snapshot>("/api/state"),
getJson<QaBusStateSnapshot>("/api/state"),
getJson<ReportEnvelope>("/api/report"),
getJson<OutcomesEnvelope>("/api/outcomes"),
]);
@@ -1,6 +1,10 @@
import type { Conversation, Message, Thread } from "./ui-types.js";
import type {
QaBusMessage,
QaBusSnapshotConversation,
QaBusThread,
} from "openclaw/plugin-sdk/qa-channel-protocol";
type ConversationIdentity = Pick<Conversation, "accountId" | "id" | "kind">;
type ConversationIdentity = Pick<QaBusSnapshotConversation, "accountId" | "id" | "kind">;
// Raw ids can collide across accounts and conversation kinds. Keep one key
// shape for sidebar selection, transcript filtering, and thread navigation.
@@ -9,9 +13,9 @@ export function conversationSelectionKey(identity: ConversationIdentity): string
}
export function findConversationBySelectionKey(
conversations: Conversation[],
conversations: QaBusSnapshotConversation[],
selectionKey: string | null,
): Conversation | undefined {
): QaBusSnapshotConversation | undefined {
if (!selectionKey) {
return undefined;
}
@@ -20,7 +24,7 @@ export function findConversationBySelectionKey(
);
}
export function messageConversationSelectionKey(message: Message): string {
export function messageConversationSelectionKey(message: QaBusMessage): string {
return conversationSelectionKey({
accountId: message.accountId,
id: message.conversation.id,
@@ -28,7 +32,7 @@ export function messageConversationSelectionKey(message: Message): string {
});
}
export function threadConversationSelectionKey(thread: Thread): string {
export function threadConversationSelectionKey(thread: QaBusThread): string {
// QA bus thread records come only from channel-scoped createThread; direct
// message thread ids do not create sidebar thread records.
return conversationSelectionKey({
+14 -11
View File
@@ -1,3 +1,8 @@
import type {
QaBusAttachment,
QaBusMessage,
QaBusSnapshotConversation,
} from "openclaw/plugin-sdk/qa-channel-protocol";
import {
conversationSelectionKey,
findConversationBySelectionKey,
@@ -6,9 +11,9 @@ import {
} from "./ui-conversation-key.js";
import { findScenarioOutcome } from "./ui-render-scenario.js";
import { badgeHtml, esc, formatIso, formatTime } from "./ui-render-utils.js";
import type { Attachment, Conversation, Message, SeedScenario, UiState } from "./ui-types.js";
import type { SeedScenario, UiState } from "./ui-types.js";
function attachmentSourceUrl(attachment: Attachment): string | null {
function attachmentSourceUrl(attachment: QaBusAttachment): string | null {
if (attachment.url?.trim()) {
return attachment.url;
}
@@ -18,7 +23,7 @@ function attachmentSourceUrl(attachment: Attachment): string | null {
return null;
}
function renderMessageAttachments(message: Message): string {
function renderMessageAttachments(message: QaBusMessage): string {
const attachments = message.attachments ?? [];
if (attachments.length === 0) {
return "";
@@ -91,8 +96,8 @@ function filteredMessages(state: UiState) {
}
function formatConversationLabel(
conversation: Conversation,
conversations: Conversation[],
conversation: QaBusSnapshotConversation,
conversations: QaBusSnapshotConversation[],
): string {
const label = conversation.title || conversation.id;
const sidebarCollisions = conversations.filter(
@@ -239,14 +244,14 @@ export function renderChatView(state: UiState): string {
</div>`;
}
function messageAvatar(m: Message): { emoji: string; bg: string; role: string } {
function messageAvatar(m: QaBusMessage): { emoji: string; bg: string; role: string } {
if (m.direction === "outbound") {
return { emoji: "\uD83E\uDD80", bg: "#7c6cff", role: "Claw" }; // 🦀
}
return { emoji: "\uD83E\uDD9E", bg: "#d97706", role: "Clawfather" }; // 🦞
}
function renderMessage(m: Message): string {
function renderMessage(m: QaBusMessage): string {
const name = m.senderName || m.senderId;
const avatar = messageAvatar(m);
const dirClass = m.direction === "inbound" ? "msg-direction-inbound" : "msg-direction-outbound";
@@ -288,7 +293,7 @@ function recentInspectorMessages(state: UiState, limit = 18) {
return (state.snapshot?.messages ?? []).slice(-limit).toReversed();
}
function renderInspectorLiveMessage(message: Message): string {
function renderInspectorLiveMessage(message: QaBusMessage): string {
const avatar = messageAvatar(message);
const conversationLabel = message.conversation.title || message.conversation.id;
const threadLabel = message.threadTitle || message.threadId;
@@ -483,9 +488,7 @@ export function renderEventsView(state: UiState): string {
const detail =
"thread" in e
? `${e.thread.conversationId}/${e.thread.id}`
: e.message
? `${e.message.senderId}: ${e.message.text}`
: "";
: `${e.message.senderId}: ${e.message.text}`;
return `
<div class="event-row">
<span class="event-kind">${esc(e.kind)}</span>
@@ -97,6 +97,7 @@ describe("QA Lab UI evidence render", () => {
{ accountId: "account-b", id: "shared", kind: "channel" },
{ accountId: "account-a", id: "shared", kind: "direct" },
],
cursor: 0,
events: [],
messages: [
{
@@ -134,12 +135,16 @@ describe("QA Lab UI evidence render", () => {
{
accountId: "account-a",
conversationId: "shared",
createdAt: 0,
createdBy: "openclaw",
id: "selected-thread",
title: "Selected thread",
},
{
accountId: "account-b",
conversationId: "shared",
createdAt: 0,
createdBy: "openclaw",
id: "foreign-thread",
title: "Foreign thread",
},
@@ -167,6 +172,7 @@ describe("QA Lab UI evidence render", () => {
{ accountId: "account-a", id: "shared", kind: "group" },
{ accountId: "account-b", id: "shared", kind: "channel" },
],
cursor: 0,
events: [],
messages: [],
threads: [],
@@ -197,6 +203,7 @@ describe("QA Lab UI evidence render", () => {
{ accountId: "account-a", id: "shared", kind: "channel" },
{ accountId: "account-a", id: "shared", kind: "direct" },
],
cursor: 0,
events: [],
messages: [
{
@@ -251,6 +258,7 @@ describe("QA Lab UI evidence render", () => {
const selectedConversationKey = JSON.stringify(["default", "channel", "qa-room"]);
const snapshot: NonNullable<UiState["snapshot"]> = {
conversations: [{ accountId: "default", id: "qa-room", kind: "channel" }],
cursor: 0,
events: [],
messages: [
{
@@ -290,6 +298,8 @@ describe("QA Lab UI evidence render", () => {
{
accountId: "default",
conversationId: "qa-room",
createdAt: 0,
createdBy: "openclaw",
id: "owned-thread",
title: "Owned thread",
},
+6 -61
View File
@@ -1,3 +1,7 @@
import type {
QaBusConversationKind,
QaBusStateSnapshot,
} from "openclaw/plugin-sdk/qa-channel-protocol";
import type {
QaLabExecutionKind,
QaLabResolvedRunPlan,
@@ -13,65 +17,6 @@ import type {
QaEvidenceProducerContextFile,
} from "../../shared/evidence-gallery-types.js";
/* ===== Shared types (unchanged from the bus protocol) ===== */
export type Conversation = {
accountId: string;
id: string;
kind: "direct" | "channel" | "group";
title?: string;
};
export type Attachment = {
id: string;
kind: "image" | "video" | "audio" | "file";
mimeType: string;
fileName?: string;
inline?: boolean;
url?: string;
contentBase64?: string;
width?: number;
height?: number;
durationMs?: number;
altText?: string;
transcript?: string;
};
export type Thread = {
accountId: string;
id: string;
conversationId: string;
title: string;
};
export type Message = {
accountId: string;
id: string;
direction: "inbound" | "outbound";
conversation: Omit<Conversation, "accountId">;
senderId: string;
senderName?: string;
text: string;
timestamp: number;
threadId?: string;
threadTitle?: string;
deleted?: boolean;
editedAt?: number;
attachments?: Attachment[];
reactions: Array<{ emoji: string; senderId: string }>;
};
type BusEvent =
| { cursor: number; kind: "thread-created"; thread: Thread }
| { cursor: number; kind: string; message?: Message; emoji?: string };
export type Snapshot = {
conversations: Conversation[];
threads: Thread[];
messages: Message[];
events: BusEvent[];
};
export type ReportEnvelope = {
report: null | {
outputPath: string;
@@ -300,7 +245,7 @@ export type TabId = "chat" | "results" | "report" | "events" | "capture" | "evid
export type UiState = {
theme: "light" | "dark";
bootstrap: Bootstrap | null;
snapshot: Snapshot | null;
snapshot: QaBusStateSnapshot | null;
latestReport: ReportEnvelope["report"];
scenarioRun: ScenarioRun | null;
captureSessions: CaptureSessionSummary[];
@@ -371,7 +316,7 @@ export type UiState = {
runnerDraftDirty: boolean;
runnerPlanOverride: RunnerResolvedPlan | null;
composer: {
conversationKind: "direct" | "channel" | "group";
conversationKind: QaBusConversationKind;
conversationId: string;
senderId: string;
senderName: string;
@@ -20,7 +20,7 @@ afterEach(() => {
});
function makeTtsStyleVoiceFile(): string {
// Mirrors cron auto-TTS: speech-core writes the voice file under the preferred
// Mirrors cron auto-TTS: the TTS runtime writes the voice file under the preferred
// OpenClaw temp root, which is outside the QQ Bot media storage tree.
const tmpRoot = resolvePreferredOpenClawTmpDir();
const ttsDir = makeTrackedDir(tmpRoot, "tts-");
+24
View File
@@ -862,6 +862,12 @@ describe("slackPlugin messaging targets", () => {
expect(messaging?.resolveDeliveryTarget?.({ conversationId: "c08gqh53ejm" })).toEqual({
to: "channel:c08gqh53ejm",
});
expect(messaging?.resolveDeliveryTarget?.({ conversationId: "G08GQH53EJM" })).toEqual({
to: "channel:g08gqh53ejm",
});
expect(messaging?.resolveDeliveryTarget?.({ conversationId: "user:U08GQH53EJM" })).toEqual({
to: "user:u08gqh53ejm",
});
expect(
messaging?.resolveDeliveryTarget?.({
conversationId: "1712345678.123456",
@@ -871,6 +877,24 @@ describe("slackPlugin messaging targets", () => {
to: "channel:c08gqh53ejm",
threadId: "1712345678.123456",
});
expect(
messaging?.resolveDeliveryTarget?.({
conversationId: "1712345678.654321",
parentConversationId: "user:U08GQH53EJM",
}),
).toEqual({
to: "user:u08gqh53ejm",
threadId: "1712345678.654321",
});
expect(
messaging?.resolveDeliveryTarget?.({
conversationId: "1712345678.777777",
parentConversationId: "G08GQH53EJM",
}),
).toEqual({
to: "channel:g08gqh53ejm",
threadId: "1712345678.777777",
});
expect(messaging?.resolveSessionTarget?.({ kind: "channel", id: "C08GQH53EJM" })).toBe(
"channel:c08gqh53ejm",
);
+2 -2
View File
@@ -664,8 +664,8 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount, SlackProbe> = crea
const parent = parentConversationId?.trim();
const child = conversationId.trim();
return parent && parent !== child
? { to: normalizeSlackMessagingTarget(`channel:${parent}`), threadId: child }
: { to: normalizeSlackMessagingTarget(`channel:${child}`) };
? { to: normalizeSlackMessagingTarget(parent), threadId: child }
: { to: normalizeSlackMessagingTarget(child) };
},
resolveSessionTarget: ({ id }) => {
// Session identities stay folded; send.ts restores unambiguous IDs at the API boundary.
+20 -4
View File
@@ -103,10 +103,20 @@ type SlackInteractiveDispatchContext = Omit<
export async function dispatchSlackPluginInteractiveHandler(params: {
data: string;
interactionId: string;
channelType?: "im" | "mpim" | "channel" | "group";
ctx: SlackInteractiveDispatchContext;
respond: SlackInteractiveHandlerContext["respond"];
onMatched?: () => Promise<void> | void;
}) {
const senderId = params.ctx.senderId?.trim();
const baseConversationId =
params.channelType === "im"
? senderId
? `user:${senderId}`
: ""
: params.ctx.conversationId.trim();
const threadId = params.ctx.threadId?.trim() || undefined;
return await dispatchPluginInteractiveHandler<SlackInteractiveHandlerRegistration>({
channel: "slack",
data: params.data,
@@ -124,14 +134,20 @@ export async function dispatchSlackPluginInteractiveHandler(params: {
},
respond: params.respond,
...createInteractiveConversationBindingHelpers({
registration,
// The shared helpers fail closed without owner authority; never expose it to unauthenticated actions.
registration:
params.ctx.auth.isAuthorizedSender && baseConversationId
? registration
: { ...registration, pluginRoot: undefined },
senderId: params.ctx.senderId,
conversation: {
channel: "slack",
accountId: params.ctx.accountId,
conversationId: params.ctx.conversationId,
parentConversationId: params.ctx.parentConversationId,
threadId: params.ctx.threadId,
conversationId: threadId ?? baseConversationId,
parentConversationId: threadId
? (params.ctx.parentConversationId ?? baseConversationId)
: params.ctx.parentConversationId,
threadId,
},
}),
}),
@@ -770,6 +770,7 @@ async function dispatchSlackPluginInteraction(params: {
parsed: ParsedSlackBlockAction;
pluginInteractionData: string;
auth: { isAuthorizedSender: boolean };
channelType?: Parameters<typeof dispatchSlackPluginInteractiveHandler>[0]["channelType"];
respond?: SlackBlockActionRespond;
}): Promise<boolean> {
const pluginInteractionId = buildSlackPluginInteractionId({
@@ -793,6 +794,7 @@ async function dispatchSlackPluginInteraction(params: {
const pluginResult = await dispatchSlackPluginInteractiveHandler({
data: params.pluginInteractionData,
interactionId: pluginInteractionId,
channelType: params.channelType,
ctx: {
accountId: params.ctx.accountId,
interactionId: pluginInteractionId,
@@ -1138,6 +1140,7 @@ async function handleSlackBlockAction(params: {
auth: {
isAuthorizedSender,
},
channelType: auth.channelType,
respond,
});
if (handled) {
@@ -222,6 +222,7 @@ async function dispatchSlackModalPluginInteractiveHandler(params: {
interactionType: SlackModalInteractionKind;
data: string | undefined;
auth: { isAuthorizedSender: boolean };
channelType?: Parameters<typeof dispatchSlackPluginInteractiveHandler>[0]["channelType"];
payload: SlackModalEventBase["payload"];
stateValues?: unknown;
sessionRouting: SlackModalEventBase["sessionRouting"];
@@ -248,6 +249,7 @@ async function dispatchSlackModalPluginInteractiveHandler(params: {
const result = await dispatchSlackPluginInteractiveHandler({
data: params.data,
interactionId,
channelType: params.channelType,
ctx: {
accountId: params.ctx.accountId,
interactionId,
@@ -377,6 +379,7 @@ async function emitSlackModalLifecycleEvent(params: {
interactionType: params.interactionType,
data: pluginInteractiveData,
auth: { isAuthorizedSender: auth.allowed },
channelType: auth.channelType,
payload,
stateValues,
sessionRouting,
@@ -17,6 +17,35 @@ const dispatchPluginInteractiveHandlerMock = vi.hoisted(() =>
duplicate: false,
})),
);
const privilegedInteractiveBindingOperationMock = vi.hoisted(() =>
vi.fn((operation: "request" | "detach" | "get", conversation: Record<string, unknown>) => {
if (operation === "request") {
return { status: "bound" as const, binding: conversation };
}
return operation === "detach" ? { removed: true } : conversation;
}),
);
const createInteractiveConversationBindingHelpersMock = vi.hoisted(() =>
vi.fn(
(params: { registration: { pluginRoot?: string }; conversation: Record<string, unknown> }) => ({
requestConversationBinding: async () =>
params.registration.pluginRoot
? privilegedInteractiveBindingOperationMock("request", params.conversation)
: {
status: "error" as const,
message: "This interaction cannot bind the current conversation.",
},
detachConversationBinding: async () =>
params.registration.pluginRoot
? privilegedInteractiveBindingOperationMock("detach", params.conversation)
: { removed: false },
getCurrentConversationBinding: async () =>
params.registration.pluginRoot
? privilegedInteractiveBindingOperationMock("get", params.conversation)
: null,
}),
),
);
const resolvePluginConversationBindingApprovalMock = vi.hoisted(() => vi.fn());
const buildPluginBindingResolvedTextMock = vi.hoisted(() => vi.fn(() => "Binding updated."));
type ApprovalResolveMockResult = {
@@ -68,43 +97,12 @@ vi.mock("openclaw/plugin-sdk/question-gateway-runtime", () => ({
},
}));
vi.mock("../../interactive-dispatch.js", () => ({
dispatchSlackPluginInteractiveHandler: (params: {
data: string;
interactionId: string;
ctx: {
interaction?: Record<string, unknown>;
} & Record<string, unknown>;
respond: unknown;
}) =>
(dispatchPluginInteractiveHandlerMock as (arg: unknown) => Promise<unknown>)({
channel: "slack",
data: params.data,
dedupeId: params.interactionId,
invoke: async ({
registration,
namespace,
payload,
}: {
registration: { handler: (ctx: unknown) => unknown };
namespace: string;
payload: string;
}) =>
registration.handler({
...params.ctx,
channel: "slack",
interaction: {
...params.ctx.interaction,
data: params.data,
namespace,
payload,
},
respond: params.respond,
requestConversationBinding: vi.fn(),
detachConversationBinding: vi.fn(),
getCurrentConversationBinding: vi.fn(),
}),
}),
vi.mock("openclaw/plugin-sdk/plugin-runtime", () => ({
dispatchPluginInteractiveHandler: (arg: unknown) => dispatchPluginInteractiveHandlerMock(arg),
createInteractiveConversationBindingHelpers: (params: {
registration: { pluginRoot?: string };
conversation: Record<string, unknown>;
}) => createInteractiveConversationBindingHelpersMock(params),
}));
vi.mock("../conversation.runtime.js", () => {
@@ -372,6 +370,50 @@ function expectRecordFields(
}
}
async function invokeSlackPluginBindingHandler(
dispatchCall: unknown,
params: { namespace?: string; payload?: string } = {},
) {
const invoke = requireRecord(dispatchCall, "plugin interactive dispatch").invoke;
if (typeof invoke !== "function") {
throw new Error("Expected plugin interactive handler invocation");
}
let context: Record<string, unknown> | undefined;
let operations: { request: unknown; current: unknown; detach: unknown } | undefined;
await invoke({
registration: {
pluginId: "qa-interactive-plugin",
pluginRoot: "/plugins/qa-interactive-plugin",
handler: async (value: unknown) => {
context = requireRecord(value, "plugin interactive handler context");
const request = context.requestConversationBinding;
const current = context.getCurrentConversationBinding;
const detach = context.detachConversationBinding;
if (
typeof request !== "function" ||
typeof current !== "function" ||
typeof detach !== "function"
) {
throw new Error("Expected plugin conversation binding helpers");
}
operations = {
request: await request({ summary: "Bind this conversation" }),
current: await current(),
detach: await detach(),
};
},
},
namespace: params.namespace ?? "qa",
payload: params.payload ?? "bind",
});
if (!context || !operations) {
throw new Error("Expected plugin interactive handler to complete");
}
return { context, ...operations };
}
function slackInteractionPayload(callIndex = 0): Record<string, unknown> {
const eventText = mockCallArg(enqueueSystemEventMock, callIndex, "enqueueSystemEvent");
if (typeof eventText !== "string") {
@@ -416,6 +458,8 @@ describe("registerSlackInteractionEvents", () => {
enqueueSystemEventMock.mockReturnValue(true);
requestHeartbeatMock.mockClear();
dispatchPluginInteractiveHandlerMock.mockClear();
createInteractiveConversationBindingHelpersMock.mockClear();
privilegedInteractiveBindingOperationMock.mockClear();
resolvePluginConversationBindingApprovalMock.mockClear();
resolvePluginConversationBindingApprovalMock.mockResolvedValue({ status: "expired" });
buildPluginBindingResolvedTextMock.mockClear();
@@ -825,6 +869,98 @@ describe("registerSlackInteractionEvents", () => {
expect(app.client.chat.update).not.toHaveBeenCalled();
});
it.each([
{
name: "channel root",
channelId: "C123",
channelType: "channel" as const,
conversationId: "C123",
},
{
name: "channel thread",
channelId: "C123",
channelType: "channel" as const,
threadId: "100.100",
conversationId: "100.100",
parentConversationId: "C123",
},
{
name: "private channel root",
channelId: "G123",
channelType: "group" as const,
conversationId: "G123",
},
{
name: "group direct-message root",
channelId: "G456",
channelType: "mpim" as const,
conversationId: "G456",
},
{
name: "direct-message root",
channelId: "D123",
channelType: "im" as const,
conversationId: "user:U_BINDER",
},
{
name: "direct-message thread",
channelId: "D123",
channelType: "im" as const,
threadId: "200.200",
conversationId: "200.200",
parentConversationId: "user:U_BINDER",
},
])(
"binds the canonical $name conversation without changing public action context",
async (testCase) => {
dispatchPluginInteractiveHandlerMock.mockResolvedValueOnce({
matched: true,
handled: true,
duplicate: false,
});
const { ctx, getHandler } = createContext({
allowFrom: ["U_BINDER"],
resolveChannelName: async () => ({ type: testCase.channelType }),
});
registerSlackInteractionEvents({ ctx: ctx as never });
await getHandler()({
ack: vi.fn().mockResolvedValue(undefined),
body: {
user: { id: "U_BINDER" },
channel: { id: testCase.channelId },
container: {
channel_id: testCase.channelId,
message_ts: "300.300",
thread_ts: testCase.threadId,
},
message: { ts: "300.300" },
},
action: { type: "button", action_id: "qa", value: "bind" },
});
const { context, request, current, detach } = await invokeSlackPluginBindingHandler(
mockCallArg(dispatchPluginInteractiveHandlerMock, 0, "plugin interactive dispatcher"),
);
const expectedConversation = {
channel: "slack",
accountId: "default",
conversationId: testCase.conversationId,
parentConversationId: testCase.parentConversationId,
threadId: testCase.threadId,
};
expect(context.conversationId).toBe(testCase.channelId);
expect(context.parentConversationId).toBeUndefined();
expect(context.threadId).toBe(testCase.threadId);
expect(requireRecord(context.auth, "registration auth").isAuthorizedSender).toBe(true);
expect(request).toEqual({ status: "bound", binding: expectedConversation });
expect(current).toEqual(expectedConversation);
expect(detach).toEqual({ removed: true });
expect(privilegedInteractiveBindingOperationMock).toHaveBeenCalledTimes(3);
},
);
it("passes false command auth to Slack plugin interactions for non-allowlisted senders", async () => {
dispatchPluginInteractiveHandlerMock.mockResolvedValueOnce({
matched: true,
@@ -896,6 +1032,16 @@ describe("registerSlackInteractionEvents", () => {
"registration handler ctx",
);
expect(requireRecord(registrationCtx.auth, "registration auth").isAuthorizedSender).toBe(false);
const denied = await invokeSlackPluginBindingHandler(dispatchCall, {
namespace: "codex",
payload: "approve:thread-1",
});
expect(denied.context.conversationId).toBe("C1");
expect(denied.request).toMatchObject({ status: "error" });
expect(denied.current).toBeNull();
expect(denied.detach).toEqual({ removed: false });
expect(privilegedInteractiveBindingOperationMock).not.toHaveBeenCalled();
});
it("passes true command auth to Slack plugin interactions for allowlisted senders", async () => {
@@ -3263,6 +3409,22 @@ describe("registerSlackInteractionEvents", () => {
senderId: "U777",
});
expect(requireRecord(registrationCtx.auth, "registration auth").isAuthorizedSender).toBe(true);
const binding = await invokeSlackPluginBindingHandler(dispatchCall, {
namespace: "dean.contract",
payload: "confirm_hearing",
});
expect(binding.context.conversationId).toBe("D777");
expect(binding.request).toEqual({
status: "bound",
binding: {
channel: "slack",
accountId: "default",
conversationId: "user:U777",
parentConversationId: undefined,
threadId: undefined,
},
});
const interaction = requireRecord(registrationCtx.interaction, "registration interaction") as {
inputs?: unknown[];
stateValues?: unknown;
@@ -3367,6 +3529,16 @@ describe("registerSlackInteractionEvents", () => {
"registration handler ctx",
);
expect(requireRecord(registrationCtx.auth, "registration auth").isAuthorizedSender).toBe(false);
const denied = await invokeSlackPluginBindingHandler(dispatchCall, {
namespace: "dean.contract",
payload: "confirm_hearing",
});
expect(denied.context.conversationId).toBe("");
expect(denied.request).toMatchObject({ status: "error" });
expect(denied.current).toBeNull();
expect(denied.detach).toEqual({ removed: false });
expect(privilegedInteractiveBindingOperationMock).not.toHaveBeenCalled();
expectRecordFields(requireRecord(registrationCtx.interaction, "registration interaction"), {
kind: "view_submission",
data: "dean.contract:confirm_hearing",
@@ -3476,6 +3648,11 @@ describe("registerSlackInteractionEvents", () => {
it("keeps no-channel modal events open when allowFrom is unset", async () => {
enqueueSystemEventMock.mockClear();
dispatchPluginInteractiveHandlerMock.mockResolvedValueOnce({
matched: true,
handled: true,
duplicate: false,
});
const { ctx, getViewHandler } = createContext({ allowFrom: [] });
registerSlackInteractionEvents({ ctx: ctx as never });
const viewHandler = getViewHandler();
@@ -3510,6 +3687,16 @@ describe("registerSlackInteractionEvents", () => {
);
expect(deliveryContext).not.toHaveProperty("to");
expect(requestHeartbeatMock).toHaveBeenCalledOnce();
const denied = await invokeSlackPluginBindingHandler(
mockCallArg(dispatchPluginInteractiveHandlerMock, 0, "plugin interactive dispatcher"),
);
expect(requireRecord(denied.context.auth, "registration auth").isAuthorizedSender).toBe(true);
expect(denied.context.conversationId).toBe("");
expect(denied.request).toMatchObject({ status: "error" });
expect(denied.current).toBeNull();
expect(denied.detach).toEqual({ removed: false });
expect(privilegedInteractiveBindingOperationMock).not.toHaveBeenCalled();
});
it("captures modal input labels and picker values across block types", async () => {
+19 -4
View File
@@ -93,9 +93,14 @@ describe("slack sent-thread-cache", () => {
expect(hasSlackThreadParticipation("A1", "C123", "1700000000.005000")).toBe(true);
});
it("writes and reads persistent thread participation when runtime state is available", async () => {
it("restores persistent thread participation without extending its original expiry", async () => {
const repliedAt = 1_711_406_400_000;
const ttlMs = 24 * 60 * 60 * 1000;
const now = vi.spyOn(Date, "now").mockReturnValue(repliedAt);
const register = vi.fn().mockResolvedValue(undefined);
const lookup = vi.fn().mockResolvedValue({ repliedAt: 123 });
const lookup = vi
.fn()
.mockImplementation(async () => (Date.now() < repliedAt + ttlMs ? { repliedAt } : undefined));
const openKeyedStore = vi.fn(() => ({
register,
lookup,
@@ -109,14 +114,14 @@ describe("slack sent-thread-cache", () => {
logging: { getChildLogger: () => ({ warn: vi.fn() }) },
} as never);
vi.spyOn(Date, "now").mockReturnValue(1_711_406_400_000);
recordSlackThreadParticipation("A1", "C123", "1700000000.000002");
await vi.waitFor(() => expect(register).toHaveBeenCalledTimes(1));
expect(register).toHaveBeenCalledWith("A1:C123:1700000000.000002", {
repliedAt: 1_711_406_400_000,
repliedAt,
});
now.mockReturnValue(repliedAt + ttlMs - 1000);
clearSlackThreadParticipationCache();
await expect(
hasSlackThreadParticipationWithPersistence({
@@ -137,6 +142,16 @@ describe("slack sent-thread-cache", () => {
}),
).resolves.toBe(true);
expect(lookup).not.toHaveBeenCalled();
now.mockReturnValue(repliedAt + ttlMs + 1000);
await expect(
hasSlackThreadParticipationWithPersistence({
accountId: "A1",
channelId: "C123",
threadTs: "1700000000.000002",
}),
).resolves.toBe(false);
expect(lookup).toHaveBeenCalledWith("A1:C123:1700000000.000002");
});
it("falls back to in-memory thread participation when persistent state cannot open", async () => {
@@ -37,6 +37,8 @@ const threadParticipation = createPersistentDedupeCache<SlackThreadParticipation
"thread-participation-state",
"Slack persistent thread participation state failed",
),
// Restoring participation must not extend its original mention-bypass window.
readTimestamp: ({ repliedAt }) => repliedAt,
},
});
+58
View File
@@ -370,6 +370,64 @@ describe("stopSlackStream finalize error handling", () => {
expect(alreadyDelivered.stopped).toBe(false);
});
it("finalizes a stream started during failed stop after fallback delivery", async () => {
const streamTs = "1700000000.500300";
const startStream = vi.fn(async () => ({ ok: true, ts: streamTs }));
const stopStream = vi
.fn()
.mockRejectedValueOnce(slackApiError("user_not_found"))
.mockResolvedValueOnce({ ok: true, ts: streamTs });
const client = {
chat: {
startStream,
appendStream: vi.fn(async () => ({ ok: true })),
stopStream,
},
};
const streamer = new ChatStreamer(
client as never,
{ debug: vi.fn() } as never,
{
channel: "C123",
thread_ts: "1700000000.000100",
},
{ buffer_size: 256 },
);
const session: SlackStreamSession = {
streamer,
channel: "C123",
threadTs: "1700000000.000100",
stopped: false,
delivered: false,
pendingText: "",
};
const metadata = { event_type: "openclaw.reply", event_payload: { turn: "qa" } };
await appendSlackStream({ session, text: "short buffered reply" });
await expect(stopSlackStream({ session, metadata })).rejects.toBeInstanceOf(
SlackStreamNotDeliveredError,
);
expect(streamer.ts).toBe(streamTs);
expect(session.delivered).toBe(false);
markSlackStreamFallbackDelivered(session);
expect(session.stopped).toBe(false);
await expect(stopSlackStream({ session, metadata })).resolves.toEqual({ messageId: streamTs });
expect(startStream).toHaveBeenCalledOnce();
expect(stopStream).toHaveBeenCalledTimes(2);
expect(stopStream).toHaveBeenNthCalledWith(2, {
token: undefined,
channel: "C123",
ts: streamTs,
chunks: [],
metadata,
});
expect(session.stopped).toBe(true);
expect(session.delivered).toBe(true);
expect(session.pendingText).toBe("");
});
it("clears the SDK buffer before finalizing an already-visible fallback stream", async () => {
const startStream = vi.fn(async () => ({ ok: true, ts: "1700000000.500300" }));
const stopStream = vi.fn(async () => ({ ok: true, ts: "1700000000.500300" }));

Some files were not shown because too many files have changed in this diff Show More