mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
@@ -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."
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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!"],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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`)
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** Runtime API exports for Canvas plugin host, CLI, and capability helpers. */
|
||||
/** Runtime API exports for Canvas plugin host and CLI helpers. */
|
||||
export {
|
||||
canvasConfigSchema,
|
||||
isCanvasHostEnabled,
|
||||
@@ -14,23 +14,11 @@ export {
|
||||
CANVAS_WS_PATH,
|
||||
handleA2uiHttpRequest,
|
||||
} from "./src/host/a2ui.js";
|
||||
export {
|
||||
createCanvasHostHandler,
|
||||
startCanvasHost,
|
||||
type CanvasHostHandler,
|
||||
type CanvasHostServer,
|
||||
} from "./src/host/server.js";
|
||||
export { createCanvasHostHandler, type CanvasHostHandler } from "./src/host/server.js";
|
||||
export {
|
||||
registerNodesCanvasCommands,
|
||||
type CanvasCliDependencies,
|
||||
type CanvasNodesRpcOpts,
|
||||
} from "./src/cli.js";
|
||||
export { canvasSnapshotTempPath, parseCanvasSnapshotPayload } from "./src/cli-helpers.js";
|
||||
export {
|
||||
buildCanvasScopedHostUrl,
|
||||
CANVAS_CAPABILITY_PATH_PREFIX,
|
||||
CANVAS_CAPABILITY_TTL_MS,
|
||||
mintCanvasCapabilityToken,
|
||||
normalizeCanvasScopedUrl,
|
||||
} from "./src/capability.js";
|
||||
export { resolveCanvasHostUrl } from "./src/host-url.js";
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
/**
|
||||
* Canvas capability-token helpers for scoped hosted node URLs.
|
||||
*/
|
||||
import {
|
||||
buildPluginNodeCapabilityScopedHostUrl,
|
||||
DEFAULT_PLUGIN_NODE_CAPABILITY_TTL_MS,
|
||||
mintPluginNodeCapabilityToken,
|
||||
normalizePluginNodeCapabilityScopedUrl,
|
||||
PLUGIN_NODE_CAPABILITY_PATH_PREFIX,
|
||||
type NormalizedPluginNodeCapabilityUrl,
|
||||
} from "openclaw/plugin-sdk/gateway-runtime";
|
||||
|
||||
/** Path prefix used for Canvas capability-scoped gateway routes. */
|
||||
export const CANVAS_CAPABILITY_PATH_PREFIX = PLUGIN_NODE_CAPABILITY_PATH_PREFIX;
|
||||
/** Default Canvas capability token TTL in milliseconds. */
|
||||
export const CANVAS_CAPABILITY_TTL_MS = DEFAULT_PLUGIN_NODE_CAPABILITY_TTL_MS;
|
||||
|
||||
/** Normalized Canvas capability-scoped URL shape. */
|
||||
type NormalizedCanvasScopedUrl = NormalizedPluginNodeCapabilityUrl;
|
||||
|
||||
/** Creates a new opaque Canvas capability token. */
|
||||
export function mintCanvasCapabilityToken(): string {
|
||||
return mintPluginNodeCapabilityToken();
|
||||
}
|
||||
|
||||
/** Builds a Canvas host URL scoped by the supplied capability token. */
|
||||
export function buildCanvasScopedHostUrl(baseUrl: string, capability: string): string | undefined {
|
||||
return buildPluginNodeCapabilityScopedHostUrl(baseUrl, capability);
|
||||
}
|
||||
|
||||
/** Normalizes and validates a Canvas capability-scoped URL. */
|
||||
export function normalizeCanvasScopedUrl(rawUrl: string): NormalizedCanvasScopedUrl {
|
||||
return normalizePluginNodeCapabilityScopedUrl(rawUrl);
|
||||
}
|
||||
@@ -197,7 +197,6 @@ describe("canvas host", () => {
|
||||
log: (..._args: Parameters<typeof console.log>) => {},
|
||||
};
|
||||
let createCanvasHostHandler: typeof import("./server.js").createCanvasHostHandler;
|
||||
let startCanvasHost: typeof import("./server.js").startCanvasHost;
|
||||
let WebSocketServerClass: typeof import("ws").WebSocketServer;
|
||||
let watcherState: ReturnType<typeof createMockWatcherState>;
|
||||
let fixtureRoot = "";
|
||||
@@ -226,7 +225,6 @@ describe("canvas host", () => {
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
vi.doUnmock("undici");
|
||||
vi.doMock("node:timers", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:timers")>();
|
||||
return {
|
||||
@@ -241,7 +239,7 @@ describe("canvas host", () => {
|
||||
});
|
||||
vi.resetModules();
|
||||
const serverModule = await import("./server.js");
|
||||
({ createCanvasHostHandler, startCanvasHost } = serverModule);
|
||||
({ createCanvasHostHandler } = serverModule);
|
||||
const wsModule = await vi.importActual<typeof import("ws")>("ws");
|
||||
WebSocketServerClass = wsModule.WebSocketServer;
|
||||
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-canvas-fixtures-"));
|
||||
@@ -502,15 +500,12 @@ describe("canvas host", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("serves canvas content from the mounted base path and reuses handlers without double close", async () => {
|
||||
it("serves canvas content from the mounted base path", async () => {
|
||||
const dir = await createCaseDir();
|
||||
await fs.writeFile(path.join(dir, "index.html"), "<html><body>v1</body></html>", "utf8");
|
||||
|
||||
const handler = await createTestCanvasHostHandler(dir);
|
||||
|
||||
const originalClose = handler.close;
|
||||
const closeSpy = vi.fn(async () => originalClose());
|
||||
|
||||
try {
|
||||
const response = await captureHandlerResponse(handler, `${CANVAS_HOST_PATH}/`);
|
||||
expect(response.status).toBe(200);
|
||||
@@ -523,25 +518,8 @@ describe("canvas host", () => {
|
||||
|
||||
const miss = await captureHandlerResponse(handler, "/");
|
||||
expect(miss.handled).toBe(false);
|
||||
|
||||
handler.close = closeSpy;
|
||||
const hosted = await startCanvasHost({
|
||||
runtime: quietRuntime,
|
||||
handler,
|
||||
ownsHandler: false,
|
||||
port: 0,
|
||||
listenHost: "127.0.0.1",
|
||||
allowInTests: true,
|
||||
});
|
||||
|
||||
try {
|
||||
expect(hosted.port).toBeGreaterThan(0);
|
||||
} finally {
|
||||
await hosted.close();
|
||||
expect(closeSpy).not.toHaveBeenCalled();
|
||||
}
|
||||
} finally {
|
||||
await originalClose();
|
||||
await handler.close();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Canvas host server and static-file/live-reload handler implementation.
|
||||
*/
|
||||
import fs from "node:fs/promises";
|
||||
import http, { type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import type { Socket } from "node:net";
|
||||
import path from "node:path";
|
||||
import type { Duplex } from "node:stream";
|
||||
@@ -14,47 +14,14 @@ import chokidar from "chokidar";
|
||||
import { detectMime } from "openclaw/plugin-sdk/media-mime";
|
||||
import { isTruthyEnvValue, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
|
||||
import {
|
||||
lowercasePreservingWhitespace,
|
||||
normalizeOptionalString,
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { lowercasePreservingWhitespace } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { ensureDir, resolveUserPath } from "openclaw/plugin-sdk/text-utility-runtime";
|
||||
import { WebSocketServer } from "ws";
|
||||
import {
|
||||
CANVAS_HOST_PATH,
|
||||
CANVAS_WS_PATH,
|
||||
injectCanvasRuntime,
|
||||
isA2uiPath,
|
||||
} from "./a2ui-shared.js";
|
||||
import { CANVAS_HOST_PATH, CANVAS_WS_PATH, injectCanvasRuntime } from "./a2ui-shared.js";
|
||||
import { normalizeUrlPath, resolveFileWithinRoot } from "./file-resolver.js";
|
||||
|
||||
const CANVAS_LIVE_RELOAD_MAX_INBOUND_MESSAGE_BYTES = 64 * 1024;
|
||||
|
||||
/** Options for Canvas host creation. */
|
||||
type CanvasHostOpts = {
|
||||
runtime: RuntimeEnv;
|
||||
rootDir?: string;
|
||||
port?: number;
|
||||
listenHost?: string;
|
||||
allowInTests?: boolean;
|
||||
liveReload?: boolean;
|
||||
watchFactory?: typeof chokidar.watch;
|
||||
webSocketServerClass?: typeof WebSocketServer;
|
||||
};
|
||||
|
||||
/** Options for starting a standalone Canvas host HTTP server. */
|
||||
type CanvasHostServerOpts = CanvasHostOpts & {
|
||||
handler?: CanvasHostHandler;
|
||||
ownsHandler?: boolean;
|
||||
};
|
||||
|
||||
/** Running Canvas host server handle. */
|
||||
export type CanvasHostServer = {
|
||||
port: number;
|
||||
rootDir: string;
|
||||
close: () => Promise<void>;
|
||||
};
|
||||
|
||||
/** Options for creating only the Canvas host request handler. */
|
||||
type CanvasHostHandlerOpts = {
|
||||
runtime: RuntimeEnv;
|
||||
@@ -435,90 +402,3 @@ export async function createCanvasHostHandler(
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Starts a standalone loopback Canvas host HTTP server. */
|
||||
export async function startCanvasHost(opts: CanvasHostServerOpts): Promise<CanvasHostServer> {
|
||||
if (isDisabledByEnv() && opts.allowInTests !== true) {
|
||||
return { port: 0, rootDir: "", close: async () => {} };
|
||||
}
|
||||
|
||||
const handler =
|
||||
opts.handler ??
|
||||
(await createCanvasHostHandler({
|
||||
runtime: opts.runtime,
|
||||
rootDir: opts.rootDir,
|
||||
basePath: CANVAS_HOST_PATH,
|
||||
allowInTests: opts.allowInTests,
|
||||
liveReload: opts.liveReload,
|
||||
watchFactory: opts.watchFactory,
|
||||
webSocketServerClass: opts.webSocketServerClass,
|
||||
}));
|
||||
const ownsHandler = opts.ownsHandler ?? opts.handler === undefined;
|
||||
|
||||
const bindHost = normalizeOptionalString(opts.listenHost) || "127.0.0.1";
|
||||
const server: Server = http.createServer((req, res) => {
|
||||
if (lowercasePreservingWhitespace(req.headers.upgrade ?? "") === "websocket") {
|
||||
return;
|
||||
}
|
||||
void (async () => {
|
||||
if (req.url && isA2uiPath(new URL(req.url, "http://localhost").pathname)) {
|
||||
const { handleA2uiHttpRequest } = await import("./a2ui.js");
|
||||
if (await handleA2uiHttpRequest(req, res)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (await handler.handleHttpRequest(req, res)) {
|
||||
return;
|
||||
}
|
||||
res.statusCode = 404;
|
||||
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||
res.end("Not Found");
|
||||
})().catch((err: unknown) => {
|
||||
opts.runtime.error(`Canvas host request failed: ${String(err)}`);
|
||||
res.statusCode = 500;
|
||||
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||
res.end("error");
|
||||
});
|
||||
});
|
||||
server.on("upgrade", (req, socket, head) => {
|
||||
if (handler.handleUpgrade(req, socket, head)) {
|
||||
return;
|
||||
}
|
||||
socket.destroy();
|
||||
});
|
||||
|
||||
const listenPort =
|
||||
typeof opts.port === "number" && Number.isFinite(opts.port) && opts.port > 0 ? opts.port : 0;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onError = (err: NodeJS.ErrnoException) => {
|
||||
server.off("listening", onListening);
|
||||
reject(err);
|
||||
};
|
||||
const onListening = () => {
|
||||
server.off("error", onError);
|
||||
resolve();
|
||||
};
|
||||
server.once("error", onError);
|
||||
server.once("listening", onListening);
|
||||
server.listen(listenPort, bindHost);
|
||||
});
|
||||
|
||||
const addr = server.address();
|
||||
const boundPort = typeof addr === "object" && addr ? addr.port : 0;
|
||||
opts.runtime.log(
|
||||
`canvas host listening on http://${bindHost}:${boundPort} (root ${handler.rootDir})`,
|
||||
);
|
||||
|
||||
return {
|
||||
port: boundPort,
|
||||
rootDir: handler.rootDir,
|
||||
close: async () => {
|
||||
if (ownsHandler) {
|
||||
await handler.close();
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((err) => (err ? reject(err) : resolve()));
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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?.();
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
? {}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
export function resolveTrustedOnePasswordDirectoryPath(targetPath: string): Promise<string>;
|
||||
|
||||
export function resolveTrustedOnePasswordCli(options?: {
|
||||
configuredPath?: string;
|
||||
pathEnv?: string;
|
||||
|
||||
@@ -6,8 +6,6 @@ function errorCode(error) {
|
||||
}
|
||||
|
||||
const resolveTrustedExecutablePath = pluginSecretRefSetup.resolveTrustedExecutablePath;
|
||||
export const resolveTrustedOnePasswordDirectoryPath =
|
||||
pluginSecretRefSetup.resolveTrustedDirectoryPath;
|
||||
|
||||
export async function resolveTrustedOnePasswordCli(options = {}) {
|
||||
const configuredPath = options.configuredPath?.trim();
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { inspectPathPermissions } from "@openclaw/fs-safe/permissions";
|
||||
import { Command } from "commander";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { encodeOnePasswordSecretId } from "../onepassword-secret-id.js";
|
||||
import { registerOnePasswordSecretRefCommands, testing } from "./secret-ref-cli.js";
|
||||
|
||||
type OnePasswordPlan = {
|
||||
providerUpserts: Record<string, unknown>;
|
||||
targets: Array<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
function captureStdout() {
|
||||
let output = "";
|
||||
vi.spyOn(process.stdout, "write").mockImplementation((chunk) => {
|
||||
@@ -17,7 +21,7 @@ function captureStdout() {
|
||||
return () => output;
|
||||
}
|
||||
|
||||
function createProgram(config: OpenClawConfig): Command {
|
||||
function createProgram(config: OpenClawConfig = {}): Command {
|
||||
const program = new Command().exitOverride();
|
||||
const onepassword = program.command("onepassword");
|
||||
registerOnePasswordSecretRefCommands({
|
||||
@@ -36,19 +40,29 @@ async function runStatus(
|
||||
const output = captureStdout();
|
||||
await createProgram(config).parseAsync(
|
||||
["onepassword", "secretref", "status", "--json", ...args],
|
||||
{
|
||||
from: "user",
|
||||
},
|
||||
{ from: "user" },
|
||||
);
|
||||
return JSON.parse(output()) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function createOpenAiPlan() {
|
||||
return testing.buildPlan({
|
||||
providerAlias: "onepassword",
|
||||
providerConfig: testing.buildProviderConfig(),
|
||||
providerSecrets: [{ providerId: "openai", secretId: "op://openclaw/OpenAI/credential" }],
|
||||
});
|
||||
async function runSetup(planPath: string, args: string[]): Promise<string> {
|
||||
const output = captureStdout();
|
||||
await createProgram().parseAsync(
|
||||
["onepassword", "secretref", "setup", "--plan-out", planPath, ...args],
|
||||
{ from: "user" },
|
||||
);
|
||||
return output();
|
||||
}
|
||||
|
||||
async function createSetupPlan(args: string[]): Promise<OnePasswordPlan> {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-onepassword-cli-"));
|
||||
const planPath = path.join(dir, "plan.json");
|
||||
try {
|
||||
await runSetup(planPath, args);
|
||||
return JSON.parse(await fs.readFile(planPath, "utf8")) as OnePasswordPlan;
|
||||
} finally {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
@@ -56,215 +70,180 @@ afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe("1Password CLI helpers", () => {
|
||||
it("builds a secrets apply plan for model provider API keys", () => {
|
||||
const plan = testing.buildPlan({
|
||||
providerAlias: "onepassword",
|
||||
providerConfig: testing.buildProviderConfig(),
|
||||
providerSecrets: [
|
||||
{
|
||||
providerId: "anthropic",
|
||||
secretId: "op://openclaw/Anthropic/credential",
|
||||
},
|
||||
{
|
||||
providerId: "openrouter",
|
||||
secretId: "openclaw/OpenRouter/credential",
|
||||
},
|
||||
],
|
||||
});
|
||||
describe("1Password SecretRef setup", () => {
|
||||
it("builds provider config and model API-key targets", async () => {
|
||||
const plan = await createSetupPlan([
|
||||
"--anthropic-id",
|
||||
"op://openclaw/Anthropic/credential",
|
||||
"--openrouter-id",
|
||||
"openclaw/OpenRouter/credential",
|
||||
"--provider-key",
|
||||
"xai=op://openclaw/xAI/credential",
|
||||
]);
|
||||
|
||||
expect(plan.providerUpserts.onepassword).toEqual({
|
||||
source: "exec",
|
||||
pluginIntegration: {
|
||||
pluginId: "onepassword",
|
||||
integrationId: "onepassword",
|
||||
},
|
||||
pluginIntegration: { pluginId: "onepassword", integrationId: "onepassword" },
|
||||
});
|
||||
expect(plan.targets).toEqual([
|
||||
{
|
||||
expect.objectContaining({
|
||||
type: "models.providers.apiKey",
|
||||
path: "models.providers.anthropic.apiKey",
|
||||
pathSegments: ["models", "providers", "anthropic", "apiKey"],
|
||||
providerId: "anthropic",
|
||||
ref: {
|
||||
source: "exec",
|
||||
provider: "onepassword",
|
||||
id: "op://openclaw/Anthropic/credential",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "models.providers.apiKey",
|
||||
path: "models.providers.openrouter.apiKey",
|
||||
pathSegments: ["models", "providers", "openrouter", "apiKey"],
|
||||
providerId: "openrouter",
|
||||
ref: {
|
||||
source: "exec",
|
||||
provider: "onepassword",
|
||||
id: "openclaw/OpenRouter/credential",
|
||||
},
|
||||
},
|
||||
ref: { source: "exec", provider: "onepassword", id: "op://openclaw/Anthropic/credential" },
|
||||
}),
|
||||
expect.objectContaining({ providerId: "openrouter" }),
|
||||
expect.objectContaining({ providerId: "xai" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("builds a secrets apply plan for arbitrary known openclaw secret targets", () => {
|
||||
const plan = testing.buildPlan({
|
||||
providerAlias: "onepassword",
|
||||
providerConfig: testing.buildProviderConfig(),
|
||||
providerSecrets: [],
|
||||
configTargetSecrets: testing.parseConfigTargetMappings([
|
||||
"channels.telegram.botToken=op://openclaw/Telegram/botToken",
|
||||
"models.providers.openai.headers.x-api-key=op://openclaw/OpenAI/proxyKey",
|
||||
"auth-profiles:main:profiles.openai.key=op://openclaw/OpenAI/credential",
|
||||
]),
|
||||
});
|
||||
it("builds arbitrary known OpenClaw and auth-profile targets", async () => {
|
||||
const plan = await createSetupPlan([
|
||||
"--target",
|
||||
"channels.telegram.botToken=op://openclaw/Telegram/botToken",
|
||||
"--target",
|
||||
"models.providers.openai.headers.x-api-key=op://openclaw/OpenAI/proxyKey",
|
||||
"--target",
|
||||
"auth-profiles:main:profiles.openai.key=op://openclaw/OpenAI/credential",
|
||||
]);
|
||||
|
||||
expect(plan.targets).toEqual([
|
||||
{
|
||||
expect.objectContaining({
|
||||
type: "channels.telegram.botToken",
|
||||
path: "channels.telegram.botToken",
|
||||
pathSegments: ["channels", "telegram", "botToken"],
|
||||
ref: {
|
||||
source: "exec",
|
||||
provider: "onepassword",
|
||||
id: "op://openclaw/Telegram/botToken",
|
||||
},
|
||||
},
|
||||
{
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: "models.providers.headers",
|
||||
path: "models.providers.openai.headers.x-api-key",
|
||||
pathSegments: ["models", "providers", "openai", "headers", "x-api-key"],
|
||||
providerId: "openai",
|
||||
ref: {
|
||||
source: "exec",
|
||||
provider: "onepassword",
|
||||
id: "op://openclaw/OpenAI/proxyKey",
|
||||
},
|
||||
},
|
||||
{
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: "auth-profiles.api_key.key",
|
||||
path: "profiles.openai.key",
|
||||
pathSegments: ["profiles", "openai", "key"],
|
||||
agentId: "main",
|
||||
ref: {
|
||||
source: "exec",
|
||||
provider: "onepassword",
|
||||
id: "op://openclaw/OpenAI/credential",
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("parses custom provider mappings", () => {
|
||||
expect(testing.parseProviderKeyMappings(["xai=op://openclaw/xAI/credential"])).toEqual([
|
||||
{
|
||||
providerId: "xai",
|
||||
secretId: "op://openclaw/xAI/credential",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("accepts native 1Password refs with spaces and encoded selectors", () => {
|
||||
it("encodes native 1Password refs with spaces and selectors", async () => {
|
||||
const nativeRef = "op://Personal/OpenClaw QA API Key/password?attribute=value%20one";
|
||||
expect(testing.parseProviderKeyMappings([`openai=${nativeRef}`])).toEqual([
|
||||
{
|
||||
providerId: "openai",
|
||||
secretId: encodeOnePasswordSecretId(nativeRef),
|
||||
},
|
||||
]);
|
||||
const plan = await createSetupPlan(["--provider-key", `openai=${nativeRef}`]);
|
||||
expect(plan.targets[0]).toMatchObject({
|
||||
providerId: "openai",
|
||||
ref: { id: encodeOnePasswordSecretId(nativeRef) },
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["posix", "/tmp/plan.json", "/tmp/plan.json"],
|
||||
["posix", "/tmp/plan with spaces.json", "'/tmp/plan with spaces.json'"],
|
||||
["posix", "/tmp/plan'$(touch pwn).json", "'/tmp/plan'\\''$(touch pwn).json'"],
|
||||
["powershell", String.raw`C:\$env:TEMP\plan';.json`, String.raw`'C:\$env:TEMP\plan'';.json'`],
|
||||
["cmd", String.raw`C:\Users\Jane Doe\plan.json`, String.raw`"C:\Users\Jane Doe\plan.json"`],
|
||||
] satisfies Array<["cmd" | "posix" | "powershell", string, string]>)(
|
||||
"shell-quotes %s command arguments for %j",
|
||||
(shell, value, expected) => {
|
||||
expect(testing.quoteCliArg(value, shell)).toBe(expected);
|
||||
[
|
||||
"duplicate providers",
|
||||
[
|
||||
"--openai-id",
|
||||
"op://openclaw/OpenAI/credential",
|
||||
"--provider-key",
|
||||
"OpenAI=op://openclaw/OpenAI/other",
|
||||
],
|
||||
"Duplicate model provider id",
|
||||
],
|
||||
[
|
||||
"non-canonical auth-profile agent ids",
|
||||
["--target", "auth-profiles:../main:profiles.openai.key=op://openclaw/OpenAI/credential"],
|
||||
"Invalid --target auth-profiles target for 1Password",
|
||||
],
|
||||
[
|
||||
"traversal secret ids",
|
||||
["--provider-key", "openai=op://openclaw/../credential"],
|
||||
"Invalid --provider-key openai 1Password SecretRef id",
|
||||
],
|
||||
[
|
||||
"unsupported targets",
|
||||
["--target", "secrets.github_pat=op://openclaw/GitHub/pat"],
|
||||
"Unknown or unsupported 1Password setup target path",
|
||||
],
|
||||
[
|
||||
"duplicate target paths",
|
||||
[
|
||||
"--openai-id",
|
||||
"op://openclaw/OpenAI/credential",
|
||||
"--target",
|
||||
"models.providers.openai.apiKey=op://openclaw/OpenAI/other",
|
||||
],
|
||||
"Duplicate secret target path",
|
||||
],
|
||||
["empty plans", [], "No SecretRef targets selected"],
|
||||
])("rejects %s", async (_label, args, message) => {
|
||||
await expect(createSetupPlan(args)).rejects.toThrow(message);
|
||||
});
|
||||
|
||||
it.each(["/absolute/path", "op://openclaw\\OpenAI\\credential", "op://vault/clé"])(
|
||||
"rejects invalid 1Password ref %s",
|
||||
async (id) => {
|
||||
await expect(createSetupPlan(["--provider-key", `openai=${id}`])).rejects.toThrow(
|
||||
"Invalid --provider-key openai 1Password SecretRef id",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects line breaks in generated command arguments", () => {
|
||||
expect(() => testing.quoteCliArg("plan.json\nopenclaw secrets reload", "posix")).toThrow(
|
||||
/cannot contain CR or LF/,
|
||||
);
|
||||
expect(() => testing.quoteCliArg("plan.json\r& whoami", "cmd")).toThrow(
|
||||
/cannot contain CR or LF/,
|
||||
);
|
||||
it("prints a quoted canonical plan path after the readiness command", async () => {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-1password-setup-test-"));
|
||||
const planPath = path.join(tempDir, "plan with spaces.json");
|
||||
const canonicalPlanPath = path.join(await fs.realpath(tempDir), "plan with spaces.json");
|
||||
try {
|
||||
const output = await runSetup(planPath, ["--openai-id", "op://openclaw/OpenAI/credential"]);
|
||||
expect(output).toContain("openclaw onepassword secretref status");
|
||||
expect(output).toContain(
|
||||
`openclaw secrets apply --from '${canonicalPlanPath}' --dry-run --allow-exec`,
|
||||
);
|
||||
expect(output).toContain(`openclaw secrets apply --from '${canonicalPlanPath}' --allow-exec`);
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("renders native follow-up commands for both Windows shells", () => {
|
||||
expect(testing.renderApplyCommands(String.raw`C:\Users\Jane Doe\plan;.json`, "win32")).toEqual([
|
||||
"PowerShell:",
|
||||
String.raw` openclaw secrets apply --from 'C:\Users\Jane Doe\plan;.json' --dry-run --allow-exec`,
|
||||
String.raw` openclaw secrets apply --from 'C:\Users\Jane Doe\plan;.json' --allow-exec`,
|
||||
"Command Prompt:",
|
||||
String.raw` openclaw secrets apply --from "C:\Users\Jane Doe\plan;.json" --dry-run --allow-exec`,
|
||||
String.raw` openclaw secrets apply --from "C:\Users\Jane Doe\plan;.json" --allow-exec`,
|
||||
]);
|
||||
});
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"rejects plan output in a directory writable by another account",
|
||||
async () => {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-secret-plan-test-"));
|
||||
const planPath = path.join(tempDir, "plan.json");
|
||||
try {
|
||||
await fs.chmod(tempDir, 0o777);
|
||||
await expect(
|
||||
runSetup(planPath, ["--openai-id", "op://openclaw/OpenAI/credential"]),
|
||||
).rejects.toThrow("path is writable by another user");
|
||||
await expect(fs.stat(planPath)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
} finally {
|
||||
await fs.chmod(tempDir, 0o700);
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("omits unsafe interactive Command Prompt commands", () => {
|
||||
const commands = testing.renderApplyCommands(String.raw`C:\%TEMP%\plan!.json`, "win32");
|
||||
expect(commands).toContain(
|
||||
"Command Prompt: unavailable for paths containing % or !; use PowerShell.",
|
||||
);
|
||||
expect(commands.filter((command) => command.includes("openclaw secrets apply"))).toHaveLength(
|
||||
2,
|
||||
);
|
||||
expect(() => testing.quoteCliArg(String.raw`C:\%TEMP%\plan!.json`, "cmd")).toThrow(
|
||||
/cannot safely quote/,
|
||||
);
|
||||
});
|
||||
|
||||
it("parses config target mappings", () => {
|
||||
expect(
|
||||
testing.parseConfigTargetMappings([
|
||||
"channels.telegram.botToken=op://openclaw/Telegram/botToken",
|
||||
"auth-profiles:main:profiles.openai.key=op://openclaw/OpenAI/credential",
|
||||
]),
|
||||
).toEqual([
|
||||
{
|
||||
path: "channels.telegram.botToken",
|
||||
secretId: "op://openclaw/Telegram/botToken",
|
||||
},
|
||||
{
|
||||
path: "profiles.openai.key",
|
||||
agentId: "main",
|
||||
secretId: "op://openclaw/OpenAI/credential",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects non-canonical auth-profile agent ids", () => {
|
||||
expect(() =>
|
||||
testing.parseConfigTargetMappings([
|
||||
"auth-profiles:../main:profiles.openai.key=op://openclaw/OpenAI/credential",
|
||||
]),
|
||||
).toThrow("Invalid --target auth-profiles target for 1Password");
|
||||
});
|
||||
|
||||
it("rejects duplicate model providers", () => {
|
||||
expect(() =>
|
||||
testing.collectProviderSecrets({
|
||||
openaiId: "op://openclaw/OpenAI/credential",
|
||||
providerKey: ["openai=op://openclaw/OpenAI/other"],
|
||||
}),
|
||||
).toThrow("Duplicate model provider id in 1Password setup: openai");
|
||||
});
|
||||
|
||||
it("rejects setup plans without targets", () => {
|
||||
expect(() =>
|
||||
testing.buildPlan({
|
||||
providerAlias: "onepassword",
|
||||
providerConfig: testing.buildProviderConfig(),
|
||||
providerSecrets: [],
|
||||
}),
|
||||
).toThrow("No SecretRef targets selected");
|
||||
});
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"writes through the canonical directory instead of a replaceable alias",
|
||||
async () => {
|
||||
const trustedDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-secret-plan-trusted-"));
|
||||
const aliasParent = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-secret-plan-alias-"));
|
||||
const aliasDir = path.join(aliasParent, "output");
|
||||
const canonicalPlanPath = path.join(await fs.realpath(trustedDir), "plan.json");
|
||||
try {
|
||||
await fs.symlink(trustedDir, aliasDir);
|
||||
await fs.chmod(aliasParent, 0o777);
|
||||
const output = await runSetup(path.join(aliasDir, "plan.json"), [
|
||||
"--openai-id",
|
||||
"op://openclaw/OpenAI/credential",
|
||||
]);
|
||||
expect(output).toContain(`Plan written to ${canonicalPlanPath}`);
|
||||
expect(JSON.parse(await fs.readFile(canonicalPlanPath, "utf8"))).toMatchObject({
|
||||
version: 1,
|
||||
});
|
||||
} finally {
|
||||
await fs.chmod(aliasParent, 0o700);
|
||||
await fs.rm(aliasParent, { recursive: true, force: true });
|
||||
await fs.rm(trustedDir, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("1Password readiness", () => {
|
||||
it("reports trusted executable and token prerequisites without exposing the token", async () => {
|
||||
const resolveTrustedCli = vi.fn(async () => "/trusted/op");
|
||||
const readTokenFile = vi.fn(() => "not-a-real-service-account-token");
|
||||
@@ -296,10 +275,7 @@ describe("1Password CLI helpers", () => {
|
||||
it("reports untrusted op and unsafe token prerequisites", async () => {
|
||||
await expect(
|
||||
testing.inspectSecretRefReadiness(
|
||||
{
|
||||
env: { CLAW_1PASSWORD_OP: "op", PATH: "/bin" },
|
||||
tokenFile: "/missing-token",
|
||||
},
|
||||
{ env: { CLAW_1PASSWORD_OP: "op", PATH: "/bin" }, tokenFile: "/missing-token" },
|
||||
{
|
||||
resolveTrustedCli: async () => {
|
||||
throw new Error("unsafe path detail");
|
||||
@@ -318,208 +294,6 @@ describe("1Password CLI helpers", () => {
|
||||
prerequisitesReady: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects traversal segments in SecretRef ids", () => {
|
||||
expect(() => testing.parseProviderKeyMappings(["openai=op://openclaw/../credential"])).toThrow(
|
||||
"Invalid --provider-key openai 1Password SecretRef id",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects invalid 1Password references before encoding", () => {
|
||||
for (const id of ["/absolute/path", "op://openclaw\\OpenAI\\credential", "op://vault/clé"]) {
|
||||
expect(() => testing.parseProviderKeyMappings([`openai=${id}`])).toThrow(
|
||||
"Invalid --provider-key openai 1Password SecretRef id",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects unsupported config target paths", () => {
|
||||
expect(() =>
|
||||
testing.buildPlan({
|
||||
providerAlias: "onepassword",
|
||||
providerConfig: testing.buildProviderConfig(),
|
||||
providerSecrets: [],
|
||||
configTargetSecrets: [
|
||||
{
|
||||
path: "secrets.github_pat",
|
||||
secretId: "op://openclaw/GitHub/pat",
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toThrow("Unknown or unsupported 1Password setup target path: secrets.github_pat");
|
||||
});
|
||||
|
||||
it("rejects duplicate config target paths", () => {
|
||||
expect(() =>
|
||||
testing.buildPlan({
|
||||
providerAlias: "onepassword",
|
||||
providerConfig: testing.buildProviderConfig(),
|
||||
providerSecrets: [
|
||||
{
|
||||
providerId: "openai",
|
||||
secretId: "op://openclaw/OpenAI/credential",
|
||||
},
|
||||
],
|
||||
configTargetSecrets: [
|
||||
{
|
||||
path: "models.providers.openai.apiKey",
|
||||
secretId: "op://openclaw/OpenAI/other",
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toThrow("Duplicate secret target path in 1Password setup: models.providers.openai.apiKey");
|
||||
});
|
||||
|
||||
it("creates plan files exclusively with owner-only permissions", async () => {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-1password-plan-test-"));
|
||||
const planPath = path.join(tempDir, "plan.json");
|
||||
const plan = createOpenAiPlan();
|
||||
try {
|
||||
await testing.writePlanFile(plan, planPath);
|
||||
if (process.platform !== "win32") {
|
||||
expect((await fs.stat(planPath)).mode & 0o777).toBe(0o600);
|
||||
} else {
|
||||
const permissions = await inspectPathPermissions(planPath);
|
||||
expect(permissions).toMatchObject({
|
||||
ok: true,
|
||||
source: "windows-acl",
|
||||
ownerTrusted: true,
|
||||
groupReadable: false,
|
||||
groupWritable: false,
|
||||
worldReadable: false,
|
||||
worldWritable: false,
|
||||
});
|
||||
}
|
||||
await expect(testing.writePlanFile(plan, planPath)).rejects.toThrow(
|
||||
"Plan path already exists",
|
||||
);
|
||||
|
||||
const symlinkPath = path.join(tempDir, "symlink.json");
|
||||
await fs.symlink(planPath, symlinkPath);
|
||||
await expect(testing.writePlanFile(plan, symlinkPath)).rejects.toThrow(
|
||||
"Plan path already exists",
|
||||
);
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"rejects plan output in a directory writable by another account",
|
||||
async () => {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-1password-plan-test-"));
|
||||
const planPath = path.join(tempDir, "plan.json");
|
||||
const plan = createOpenAiPlan();
|
||||
try {
|
||||
await fs.chmod(tempDir, 0o777);
|
||||
await expect(testing.writePlanFile(plan, planPath)).rejects.toThrow(
|
||||
"path is writable by another user",
|
||||
);
|
||||
await expect(fs.stat(planPath)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
} finally {
|
||||
await fs.chmod(tempDir, 0o700);
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"writes through the canonical directory instead of a replaceable alias",
|
||||
async () => {
|
||||
const trustedDir = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), "openclaw-1password-plan-trusted-"),
|
||||
);
|
||||
const aliasParent = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), "openclaw-1password-plan-alias-"),
|
||||
);
|
||||
const aliasDir = path.join(aliasParent, "output");
|
||||
const canonicalPlanPath = path.join(await fs.realpath(trustedDir), "plan.json");
|
||||
const plan = createOpenAiPlan();
|
||||
try {
|
||||
await fs.symlink(trustedDir, aliasDir);
|
||||
await fs.chmod(aliasParent, 0o777);
|
||||
await expect(testing.writePlanFile(plan, path.join(aliasDir, "plan.json"))).resolves.toBe(
|
||||
canonicalPlanPath,
|
||||
);
|
||||
expect(JSON.parse(await fs.readFile(canonicalPlanPath, "utf8"))).toMatchObject({
|
||||
version: 1,
|
||||
});
|
||||
} finally {
|
||||
await fs.chmod(aliasParent, 0o700);
|
||||
await fs.rm(aliasParent, { recursive: true, force: true });
|
||||
await fs.rm(trustedDir, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"rejects unrenderable plan paths before creating a file",
|
||||
async () => {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-1password-plan-test-"));
|
||||
const planPath = path.join(tempDir, "plan\n.json");
|
||||
const plan = createOpenAiPlan();
|
||||
try {
|
||||
await expect(testing.writePlanFile(plan, planPath)).rejects.toThrow(
|
||||
"Command argument cannot contain CR or LF",
|
||||
);
|
||||
await expect(fs.stat(planPath)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("writes a Windows plan through the atomic private-file primitive", async () => {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-1password-plan-test-"));
|
||||
const planPath = path.join(tempDir, "plan.json");
|
||||
const plan = createOpenAiPlan();
|
||||
const createPrivateWindowsFile = vi.fn(async (filePath: string, content: string) => {
|
||||
await fs.writeFile(filePath, content, { flag: "wx" });
|
||||
});
|
||||
const resolveTrustedPlanDirectory = vi.fn(async (directoryPath: string) => directoryPath);
|
||||
try {
|
||||
await testing.writePlanFile(plan, planPath, {
|
||||
platform: "win32",
|
||||
createPrivateWindowsFile,
|
||||
resolveTrustedPlanDirectory,
|
||||
});
|
||||
expect(resolveTrustedPlanDirectory).toHaveBeenCalledWith(path.resolve(tempDir));
|
||||
expect(createPrivateWindowsFile).toHaveBeenCalledWith(planPath, expect.any(String));
|
||||
expect(JSON.parse(await fs.readFile(planPath, "utf8"))).toMatchObject({ version: 1 });
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("prints the readiness check before plan application", async () => {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-1password-setup-test-"));
|
||||
const planPath = path.join(tempDir, "plan with spaces.json");
|
||||
const canonicalPlanPath = path.join(await fs.realpath(tempDir), "plan with spaces.json");
|
||||
const output = captureStdout();
|
||||
try {
|
||||
await createProgram({}).parseAsync(
|
||||
[
|
||||
"onepassword",
|
||||
"secretref",
|
||||
"setup",
|
||||
"--openai-id",
|
||||
"op://openclaw/OpenAI/credential",
|
||||
"--plan-out",
|
||||
planPath,
|
||||
],
|
||||
{ from: "user" },
|
||||
);
|
||||
expect(output()).toContain("openclaw onepassword secretref status");
|
||||
expect(output()).toContain(
|
||||
`openclaw secrets apply --from '${canonicalPlanPath}' --dry-run --allow-exec`,
|
||||
);
|
||||
expect(output()).toContain(
|
||||
`openclaw secrets apply --from '${canonicalPlanPath}' --allow-exec`,
|
||||
);
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("1Password CLI status", () => {
|
||||
@@ -534,8 +308,8 @@ describe("1Password CLI status", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.providerAlias).toBe("corp-onepassword");
|
||||
expect(result).toMatchObject({
|
||||
providerAlias: "corp-onepassword",
|
||||
providerReady: true,
|
||||
opStatus: "not-found",
|
||||
tokenFileStatus: "missing-or-unsafe",
|
||||
@@ -557,8 +331,7 @@ describe("1Password CLI status", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.providerAlias).toBe("corp-onepassword");
|
||||
expect(result.providerReady).toBe(true);
|
||||
expect(result).toMatchObject({ providerAlias: "corp-onepassword", providerReady: true });
|
||||
});
|
||||
|
||||
it("requires an explicit alias when multiple providers are configured", async () => {
|
||||
|
||||
@@ -1,52 +1,46 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import path from "node:path";
|
||||
import { createInterface } from "node:readline/promises";
|
||||
import { isRecord } from "openclaw/plugin-sdk/channel-secret-basic-runtime";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import {
|
||||
DEFAULT_SECRET_FILE_MAX_BYTES,
|
||||
tryReadSecretFileSync,
|
||||
} from "openclaw/plugin-sdk/secret-file-runtime";
|
||||
import { pluginSecretRefSetup } from "openclaw/plugin-sdk/secret-ref-runtime";
|
||||
import { createPluginSecretRefSetupCli } from "openclaw/plugin-sdk/secret-ref-runtime";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
|
||||
import {
|
||||
resolveTrustedOnePasswordCli,
|
||||
resolveTrustedOnePasswordDirectoryPath,
|
||||
} from "../onepassword-op-path.js";
|
||||
import { resolveTrustedOnePasswordCli } from "../onepassword-op-path.js";
|
||||
import { encodeOnePasswordSecretId } from "../onepassword-secret-id.js";
|
||||
|
||||
type CommandLike = {
|
||||
command(name: string): CommandLike;
|
||||
description(value: string): CommandLike;
|
||||
option(
|
||||
flags: string,
|
||||
description: string,
|
||||
defaultValueOrParser?: string | ((value: string, previous?: string[]) => string[]),
|
||||
defaultValue?: string[],
|
||||
): CommandLike;
|
||||
action<TOptions>(fn: (options: TOptions) => void | Promise<void>): CommandLike;
|
||||
};
|
||||
const ONEPASSWORD_PROVIDER_ALIAS = "onepassword";
|
||||
|
||||
type OnePasswordExecProviderConfig = {
|
||||
source: "exec";
|
||||
function normalizeOnePasswordSecretId(label: string, value: string): string {
|
||||
try {
|
||||
return encodeOnePasswordSecretId(value);
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`Invalid ${label} 1Password SecretRef id: ${detail}`, { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
const onePasswordSecretRefSetupCli = createPluginSecretRefSetupCli({
|
||||
productName: "1Password",
|
||||
secretIdLabel: "1Password SecretRef id",
|
||||
secretIdPlaceholder: "1password-secret-id",
|
||||
defaultProviderAlias: ONEPASSWORD_PROVIDER_ALIAS,
|
||||
pluginIntegration: {
|
||||
pluginId: "onepassword";
|
||||
integrationId: "onepassword";
|
||||
};
|
||||
};
|
||||
pluginId: "onepassword",
|
||||
integrationId: "onepassword",
|
||||
},
|
||||
normalizeSecretId: normalizeOnePasswordSecretId,
|
||||
defaultPlanPath: () =>
|
||||
path.join(resolvePreferredOpenClawTmpDir(), `openclaw-1password-secrets-${randomUUID()}.json`),
|
||||
beforeApplyCommands: [
|
||||
"openclaw plugins enable onepassword",
|
||||
"openclaw onepassword secretref status",
|
||||
],
|
||||
});
|
||||
|
||||
type ProviderSecretMapping = {
|
||||
providerId: string;
|
||||
secretId: string;
|
||||
};
|
||||
|
||||
type ConfigTargetSecretMapping = {
|
||||
path: string;
|
||||
agentId?: string;
|
||||
secretId: string;
|
||||
};
|
||||
|
||||
type SecretsApplyPlan = ReturnType<typeof pluginSecretRefSetup.buildPlan>;
|
||||
type CommandLike = Parameters<typeof onePasswordSecretRefSetupCli.registerSetupCommand>[0];
|
||||
|
||||
type RegisterOnePasswordSecretRefCommandsParams = {
|
||||
command: CommandLike;
|
||||
@@ -60,26 +54,6 @@ type StatusOptions = {
|
||||
providerAlias?: string;
|
||||
};
|
||||
|
||||
type SetupOptions = {
|
||||
planOut?: string;
|
||||
providerAlias?: string;
|
||||
openaiId?: string;
|
||||
anthropicId?: string;
|
||||
openrouterId?: string;
|
||||
providerKey?: string[];
|
||||
target?: string[];
|
||||
};
|
||||
|
||||
type ProviderStatus = {
|
||||
configured: boolean;
|
||||
source?: string;
|
||||
command?: string;
|
||||
pluginIntegration?: {
|
||||
pluginId: string;
|
||||
integrationId: string;
|
||||
};
|
||||
};
|
||||
|
||||
type SecretRefReadiness = {
|
||||
opCommand: string;
|
||||
opBinaryPath: string | null;
|
||||
@@ -94,14 +68,6 @@ type ReadinessDependencies = {
|
||||
readTokenFile?: (filePath: string) => string | undefined;
|
||||
};
|
||||
|
||||
type WritePlanFileDependencies = {
|
||||
platform?: NodeJS.Platform;
|
||||
createPrivateWindowsFile?: (filePath: string, content: string) => Promise<void>;
|
||||
resolveTrustedPlanDirectory?: typeof resolveTrustedOnePasswordDirectoryPath;
|
||||
};
|
||||
|
||||
const ONEPASSWORD_PROVIDER_ALIAS = "onepassword";
|
||||
|
||||
function writeLine(message = ""): void {
|
||||
process.stdout.write(`${message}\n`);
|
||||
}
|
||||
@@ -110,123 +76,6 @@ function writeJson(value: unknown): void {
|
||||
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function normalizeOptionalString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
type CommandShell = "cmd" | "posix" | "powershell";
|
||||
|
||||
function quoteCliArg(value: string, shell: CommandShell): string {
|
||||
if (/\r|\n/u.test(value)) {
|
||||
throw new Error("Command argument cannot contain CR or LF");
|
||||
}
|
||||
if (shell === "cmd") {
|
||||
if (/[%!]/u.test(value)) {
|
||||
throw new Error("Interactive Command Prompt cannot safely quote paths containing % or !");
|
||||
}
|
||||
const escaped = value.replaceAll('"', '\\"');
|
||||
return /[ \t"&|<>^()]/u.test(value) ? `"${escaped}"` : escaped || '""';
|
||||
}
|
||||
if (shell === "powershell") {
|
||||
return `'${value.replaceAll("'", "''")}'`;
|
||||
}
|
||||
if (/^[A-Za-z0-9_/:=.,@%+-]+$/.test(value)) {
|
||||
return value;
|
||||
}
|
||||
return `'${value.replaceAll("'", "'\\''")}'`;
|
||||
}
|
||||
|
||||
function renderApplyCommands(
|
||||
planPath: string,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): string[] {
|
||||
const render = (shell: CommandShell, extraIndent = "") => {
|
||||
const quotedPlanPath = quoteCliArg(planPath, shell);
|
||||
return [
|
||||
`${extraIndent}openclaw secrets apply --from ${quotedPlanPath} --dry-run --allow-exec`,
|
||||
`${extraIndent}openclaw secrets apply --from ${quotedPlanPath} --allow-exec`,
|
||||
];
|
||||
};
|
||||
if (platform !== "win32") {
|
||||
return render("posix");
|
||||
}
|
||||
// Windows cannot reveal which parent shell will receive these copy-paste commands.
|
||||
// Print native variants instead of emitting syntax that is unsafe in the other shell.
|
||||
const powershellCommands = ["PowerShell:", ...render("powershell", " ")];
|
||||
if (/[%!]/u.test(planPath)) {
|
||||
return [
|
||||
...powershellCommands,
|
||||
"Command Prompt: unavailable for paths containing % or !; use PowerShell.",
|
||||
];
|
||||
}
|
||||
return [...powershellCommands, "Command Prompt:", ...render("cmd", " ")];
|
||||
}
|
||||
|
||||
function assertValidProviderAlias(value: string): void {
|
||||
pluginSecretRefSetup.assertValidProviderAlias(value);
|
||||
}
|
||||
|
||||
function normalizeOnePasswordSecretId(label: string, value: string): string {
|
||||
try {
|
||||
return encodeOnePasswordSecretId(value);
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`Invalid ${label} 1Password SecretRef id: ${detail}`, { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
function readProviderStatus(config: OpenClawConfig, providerAlias: string): ProviderStatus {
|
||||
const provider = config.secrets?.providers?.[providerAlias];
|
||||
if (!isRecord(provider)) {
|
||||
return { configured: false };
|
||||
}
|
||||
const base = {
|
||||
configured: true,
|
||||
source: normalizeOptionalString(provider.source),
|
||||
};
|
||||
if (provider.source !== "exec") {
|
||||
return base;
|
||||
}
|
||||
if ("pluginIntegration" in provider) {
|
||||
return {
|
||||
...base,
|
||||
pluginIntegration: provider.pluginIntegration as ProviderStatus["pluginIntegration"],
|
||||
};
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
command: normalizeOptionalString(provider.command),
|
||||
};
|
||||
}
|
||||
|
||||
function isOnePasswordIntegrationProvider(value: unknown): boolean {
|
||||
if (!isRecord(value) || value.source !== "exec" || !isRecord(value.pluginIntegration)) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
value.pluginIntegration.pluginId === "onepassword" &&
|
||||
value.pluginIntegration.integrationId === "onepassword"
|
||||
);
|
||||
}
|
||||
|
||||
function resolveStatusProviderAlias(config: OpenClawConfig, requestedAlias?: string): string {
|
||||
const explicitAlias = normalizeOptionalString(requestedAlias);
|
||||
if (explicitAlias) {
|
||||
assertValidProviderAlias(explicitAlias);
|
||||
return explicitAlias;
|
||||
}
|
||||
const configuredAliases = Object.entries(config.secrets?.providers ?? {})
|
||||
.filter(([, provider]) => isOnePasswordIntegrationProvider(provider))
|
||||
.map(([alias]) => alias)
|
||||
.toSorted();
|
||||
if (configuredAliases.length > 1) {
|
||||
throw new Error(
|
||||
`Multiple 1Password provider aliases are configured (${configuredAliases.join(", ")}). Use --provider-alias <alias>.`,
|
||||
);
|
||||
}
|
||||
return configuredAliases[0] ?? ONEPASSWORD_PROVIDER_ALIAS;
|
||||
}
|
||||
|
||||
async function inspectSecretRefReadiness(
|
||||
params: { env: NodeJS.ProcessEnv; tokenFile: string },
|
||||
dependencies: ReadinessDependencies = {},
|
||||
@@ -275,152 +124,13 @@ async function inspectSecretRefReadiness(
|
||||
};
|
||||
}
|
||||
|
||||
function buildProviderConfig(): OnePasswordExecProviderConfig {
|
||||
return {
|
||||
source: "exec",
|
||||
pluginIntegration: {
|
||||
pluginId: "onepassword",
|
||||
integrationId: "onepassword",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parseTargetSpecifier(value: string): {
|
||||
path: string;
|
||||
agentId?: string;
|
||||
} {
|
||||
return pluginSecretRefSetup.parseTargetSpecifier("1Password", value);
|
||||
}
|
||||
|
||||
function parseProviderKeyMappings(values: string[] | undefined): ProviderSecretMapping[] {
|
||||
return (values ?? []).map((value) => {
|
||||
const separator = value.indexOf("=");
|
||||
if (separator <= 0 || separator === value.length - 1) {
|
||||
throw new Error(
|
||||
`Invalid --provider-key value "${value}". Use <model-provider-id>=<1password-secret-id>.`,
|
||||
);
|
||||
}
|
||||
const providerId = value.slice(0, separator).trim();
|
||||
pluginSecretRefSetup.assertValidModelProviderId("--provider-key", providerId);
|
||||
const secretId = normalizeOnePasswordSecretId(
|
||||
`--provider-key ${providerId}`,
|
||||
value.slice(separator + 1).trim(),
|
||||
);
|
||||
return { providerId, secretId };
|
||||
});
|
||||
}
|
||||
|
||||
function parseConfigTargetMappings(values: string[] | undefined): ConfigTargetSecretMapping[] {
|
||||
return (values ?? []).map((value) => {
|
||||
const separator = value.indexOf("=");
|
||||
if (separator <= 0 || separator === value.length - 1) {
|
||||
throw new Error(
|
||||
`Invalid --target value "${value}". Use <openclaw-config-path>=<1password-secret-id>.`,
|
||||
);
|
||||
}
|
||||
const target = parseTargetSpecifier(value.slice(0, separator).trim());
|
||||
const secretId = normalizeOnePasswordSecretId(
|
||||
`--target ${target.path}`,
|
||||
value.slice(separator + 1).trim(),
|
||||
);
|
||||
return Object.assign(
|
||||
{ path: target.path, secretId },
|
||||
target.agentId ? { agentId: target.agentId } : {},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function collectProviderSecrets(options: {
|
||||
openaiId?: string;
|
||||
anthropicId?: string;
|
||||
openrouterId?: string;
|
||||
providerKey?: string[];
|
||||
}): ProviderSecretMapping[] {
|
||||
const providerSecrets: ProviderSecretMapping[] = [];
|
||||
if (options.openaiId) {
|
||||
providerSecrets.push({ providerId: "openai", secretId: options.openaiId });
|
||||
}
|
||||
if (options.anthropicId) {
|
||||
providerSecrets.push({ providerId: "anthropic", secretId: options.anthropicId });
|
||||
}
|
||||
if (options.openrouterId) {
|
||||
providerSecrets.push({ providerId: "openrouter", secretId: options.openrouterId });
|
||||
}
|
||||
providerSecrets.push(...parseProviderKeyMappings(options.providerKey));
|
||||
|
||||
const seen = new Set<string>();
|
||||
for (const entry of providerSecrets) {
|
||||
const normalized = entry.providerId.toLowerCase();
|
||||
if (seen.has(normalized)) {
|
||||
throw new Error(`Duplicate model provider id in 1Password setup: ${entry.providerId}`);
|
||||
}
|
||||
seen.add(normalized);
|
||||
}
|
||||
return providerSecrets;
|
||||
}
|
||||
|
||||
function buildPlan(params: {
|
||||
providerAlias: string;
|
||||
providerConfig: OnePasswordExecProviderConfig;
|
||||
providerSecrets: ProviderSecretMapping[];
|
||||
configTargetSecrets?: ConfigTargetSecretMapping[];
|
||||
}): SecretsApplyPlan {
|
||||
const plan = pluginSecretRefSetup.buildPlan({ productName: "1Password", ...params });
|
||||
if (plan.targets.length === 0) {
|
||||
throw new Error(
|
||||
"No SecretRef targets selected. Pass --openai-id, --anthropic-id, --openrouter-id, --provider-key, or --target.",
|
||||
);
|
||||
}
|
||||
return plan;
|
||||
}
|
||||
|
||||
async function promptOptionalSecretId(label: string): Promise<string | undefined> {
|
||||
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
||||
return undefined;
|
||||
}
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
try {
|
||||
return normalizeOptionalString(
|
||||
await rl.question(`${label} 1Password SecretRef id (blank to skip): `),
|
||||
);
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function promptProviderSecrets(options: SetupOptions): Promise<ProviderSecretMapping[]> {
|
||||
const openaiId =
|
||||
normalizeOptionalString(options.openaiId) ?? (await promptOptionalSecretId("OpenAI"));
|
||||
const anthropicId =
|
||||
normalizeOptionalString(options.anthropicId) ?? (await promptOptionalSecretId("Anthropic"));
|
||||
const openrouterId =
|
||||
normalizeOptionalString(options.openrouterId) ?? (await promptOptionalSecretId("OpenRouter"));
|
||||
const normalizedOpenaiId = openaiId
|
||||
? normalizeOnePasswordSecretId("OpenAI", openaiId)
|
||||
: undefined;
|
||||
const normalizedAnthropicId = anthropicId
|
||||
? normalizeOnePasswordSecretId("Anthropic", anthropicId)
|
||||
: undefined;
|
||||
const normalizedOpenrouterId = openrouterId
|
||||
? normalizeOnePasswordSecretId("OpenRouter", openrouterId)
|
||||
: undefined;
|
||||
return collectProviderSecrets({
|
||||
...(normalizedOpenaiId ? { openaiId: normalizedOpenaiId } : {}),
|
||||
...(normalizedAnthropicId ? { anthropicId: normalizedAnthropicId } : {}),
|
||||
...(normalizedOpenrouterId ? { openrouterId: normalizedOpenrouterId } : {}),
|
||||
providerKey: options.providerKey,
|
||||
});
|
||||
}
|
||||
|
||||
async function runStatus(
|
||||
params: RegisterOnePasswordSecretRefCommandsParams,
|
||||
options: StatusOptions,
|
||||
): Promise<void> {
|
||||
const config = params.config;
|
||||
const providerAlias = resolveStatusProviderAlias(config, options.providerAlias);
|
||||
const provider = readProviderStatus(config, providerAlias);
|
||||
const providerReady = isOnePasswordIntegrationProvider(
|
||||
config.secrets?.providers?.[providerAlias],
|
||||
const { providerAlias, provider, providerReady } = onePasswordSecretRefSetupCli.inspectProvider(
|
||||
params.config,
|
||||
options.providerAlias,
|
||||
);
|
||||
const readiness = await inspectSecretRefReadiness({
|
||||
env: params.env ?? process.env,
|
||||
@@ -470,7 +180,7 @@ async function runStatus(
|
||||
if (issues.length === 0) {
|
||||
return;
|
||||
}
|
||||
writeLine("");
|
||||
writeLine();
|
||||
writeLine("Next actions:");
|
||||
if (!providerReady) {
|
||||
writeLine(" Generate and apply a 1Password SecretRef setup plan.");
|
||||
@@ -485,60 +195,6 @@ async function runStatus(
|
||||
}
|
||||
}
|
||||
|
||||
async function writePlanFile(
|
||||
plan: SecretsApplyPlan,
|
||||
requestedPath?: string,
|
||||
dependencies: WritePlanFileDependencies = {},
|
||||
): Promise<string> {
|
||||
const requestedPlanPath =
|
||||
normalizeOptionalString(requestedPath) ??
|
||||
path.join(resolvePreferredOpenClawTmpDir(), `openclaw-1password-secrets-${randomUUID()}.json`);
|
||||
const content = `${JSON.stringify(plan, null, 2)}\n`;
|
||||
const requestedPlanPathAbsolute = path.resolve(requestedPlanPath);
|
||||
const planDirectory = await (
|
||||
dependencies.resolveTrustedPlanDirectory ?? resolveTrustedOnePasswordDirectoryPath
|
||||
)(path.dirname(requestedPlanPathAbsolute));
|
||||
// Write through the canonical directory returned by the trust check. Reusing the requested
|
||||
// alias would let another local account retarget a writable parent symlink after validation.
|
||||
const planPath = path.join(planDirectory, path.basename(requestedPlanPathAbsolute));
|
||||
const platform = dependencies.platform ?? process.platform;
|
||||
// Validate the exact canonical path before the exclusive write. Follow-up command rendering
|
||||
// must not fail after leaving a plan behind that the next setup attempt cannot overwrite.
|
||||
renderApplyCommands(planPath, platform);
|
||||
await pluginSecretRefSetup.writePlanFile({
|
||||
planPath,
|
||||
content,
|
||||
platform,
|
||||
createPrivateWindowsFile: dependencies.createPrivateWindowsFile,
|
||||
});
|
||||
return planPath;
|
||||
}
|
||||
|
||||
async function runSetup(options: SetupOptions): Promise<void> {
|
||||
const providerAlias =
|
||||
normalizeOptionalString(options.providerAlias) ?? ONEPASSWORD_PROVIDER_ALIAS;
|
||||
assertValidProviderAlias(providerAlias);
|
||||
const providerSecrets = await promptProviderSecrets(options);
|
||||
const plan = buildPlan({
|
||||
providerAlias,
|
||||
providerConfig: buildProviderConfig(),
|
||||
providerSecrets,
|
||||
configTargetSecrets: parseConfigTargetMappings(options.target),
|
||||
});
|
||||
const planPath = await writePlanFile(plan, options.planOut);
|
||||
writeLine(`Plan written to ${planPath}`);
|
||||
writeLine(`Targets: ${plan.targets.length}`);
|
||||
writeLine("");
|
||||
writeLine("Next steps:");
|
||||
writeLine(" openclaw plugins enable onepassword");
|
||||
writeLine(" openclaw onepassword secretref status");
|
||||
for (const command of renderApplyCommands(planPath)) {
|
||||
writeLine(` ${command}`);
|
||||
}
|
||||
writeLine(" openclaw secrets audit --check --allow-exec");
|
||||
writeLine(" openclaw secrets reload");
|
||||
}
|
||||
|
||||
export function registerOnePasswordSecretRefCommands(
|
||||
params: RegisterOnePasswordSecretRefCommandsParams,
|
||||
): void {
|
||||
@@ -549,41 +205,7 @@ export function registerOnePasswordSecretRefCommands(
|
||||
.option("--json", "Print JSON status")
|
||||
.option("--provider-alias <alias>", "Secret provider alias to inspect")
|
||||
.action((options: StatusOptions) => runStatus(params, options));
|
||||
secretRef
|
||||
.command("setup")
|
||||
.description("Create a 1Password SecretRef setup plan")
|
||||
.option("--plan-out <path>", "Write the generated secrets apply plan to a path")
|
||||
.option(
|
||||
"--provider-alias <alias>",
|
||||
"Secret provider alias to configure",
|
||||
ONEPASSWORD_PROVIDER_ALIAS,
|
||||
)
|
||||
.option("--openai-id <id>", "1Password SecretRef id for models.providers.openai.apiKey")
|
||||
.option("--anthropic-id <id>", "1Password SecretRef id for models.providers.anthropic.apiKey")
|
||||
.option("--openrouter-id <id>", "1Password SecretRef id for models.providers.openrouter.apiKey")
|
||||
.option(
|
||||
"--provider-key <provider=id>",
|
||||
"1Password SecretRef id for any models.providers.<provider>.apiKey target",
|
||||
(value: string, previous: string[] = []) => [...previous, value],
|
||||
[],
|
||||
)
|
||||
.option(
|
||||
"--target <path=id>",
|
||||
"1Password SecretRef id for any known SecretRef target path",
|
||||
(value: string, previous: string[] = []) => [...previous, value],
|
||||
[],
|
||||
)
|
||||
.action((options: SetupOptions) => runSetup(options));
|
||||
onePasswordSecretRefSetupCli.registerSetupCommand(secretRef);
|
||||
}
|
||||
|
||||
export const testing = {
|
||||
buildPlan,
|
||||
buildProviderConfig,
|
||||
collectProviderSecrets,
|
||||
parseConfigTargetMappings,
|
||||
parseProviderKeyMappings,
|
||||
quoteCliArg,
|
||||
renderApplyCommands,
|
||||
inspectSecretRefReadiness,
|
||||
writePlanFile,
|
||||
};
|
||||
export const testing = { inspectSecretRefReadiness };
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/* @vitest-environment jsdom */
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { QaBusStateSnapshot } from "openclaw/plugin-sdk/qa-channel-protocol";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Bootstrap, RunnerSelection, Snapshot } from "./ui-types.js";
|
||||
import type { Bootstrap, RunnerSelection } from "./ui-types.js";
|
||||
|
||||
const httpMock = vi.hoisted(() => {
|
||||
class QaLabHttpError extends Error {
|
||||
@@ -105,7 +106,13 @@ function createBootstrap(selection: RunnerSelection): Bootstrap {
|
||||
|
||||
async function mountRunner(
|
||||
selection: RunnerSelection,
|
||||
snapshot: Snapshot = { conversations: [], events: [], messages: [], threads: [] },
|
||||
snapshot: QaBusStateSnapshot = {
|
||||
conversations: [],
|
||||
cursor: 0,
|
||||
events: [],
|
||||
messages: [],
|
||||
threads: [],
|
||||
},
|
||||
) {
|
||||
let bootstrap = createBootstrap(selection);
|
||||
httpMock.getJson.mockImplementation(async (url: string) => {
|
||||
@@ -215,12 +222,15 @@ describe("QA Lab runner browser interactions", () => {
|
||||
},
|
||||
{
|
||||
conversations: [{ accountId: "default", id: "qa-room", kind: "channel" }],
|
||||
cursor: 0,
|
||||
events: [],
|
||||
messages: [],
|
||||
threads: [
|
||||
{
|
||||
accountId: "default",
|
||||
conversationId: "qa-room",
|
||||
createdAt: 0,
|
||||
createdBy: "qa-operator",
|
||||
id: "owned-thread",
|
||||
title: "Owned thread",
|
||||
},
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import type { QaBusStateSnapshot } from "openclaw/plugin-sdk/qa-channel-protocol";
|
||||
// Qa Lab plugin module implements app behavior.
|
||||
import { defaultQaModelForMode, isQaFastModeEnabled } from "../../model-selection.js";
|
||||
import { normalizeCaptureSavedView, normalizeCaptureSavedViews } from "./capture-saved-view.js";
|
||||
@@ -11,7 +12,6 @@ import {
|
||||
type ReportEnvelope,
|
||||
type RunnerResolvedPlan,
|
||||
type RunnerSelection,
|
||||
type Snapshot,
|
||||
type TabId,
|
||||
type CaptureEventsEnvelope,
|
||||
type CaptureCoverageEnvelope,
|
||||
@@ -342,7 +342,7 @@ export async function createQaLabApp(root: HTMLDivElement) {
|
||||
try {
|
||||
const [bootstrap, snapshot, report, outcomes] = await Promise.all([
|
||||
getJson<Bootstrap>("/api/bootstrap"),
|
||||
getJson<Snapshot>("/api/state"),
|
||||
getJson<QaBusStateSnapshot>("/api/state"),
|
||||
getJson<ReportEnvelope>("/api/report"),
|
||||
getJson<OutcomesEnvelope>("/api/outcomes"),
|
||||
]);
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { Conversation, Message, Thread } from "./ui-types.js";
|
||||
import type {
|
||||
QaBusMessage,
|
||||
QaBusSnapshotConversation,
|
||||
QaBusThread,
|
||||
} from "openclaw/plugin-sdk/qa-channel-protocol";
|
||||
|
||||
type ConversationIdentity = Pick<Conversation, "accountId" | "id" | "kind">;
|
||||
type ConversationIdentity = Pick<QaBusSnapshotConversation, "accountId" | "id" | "kind">;
|
||||
|
||||
// Raw ids can collide across accounts and conversation kinds. Keep one key
|
||||
// shape for sidebar selection, transcript filtering, and thread navigation.
|
||||
@@ -9,9 +13,9 @@ export function conversationSelectionKey(identity: ConversationIdentity): string
|
||||
}
|
||||
|
||||
export function findConversationBySelectionKey(
|
||||
conversations: Conversation[],
|
||||
conversations: QaBusSnapshotConversation[],
|
||||
selectionKey: string | null,
|
||||
): Conversation | undefined {
|
||||
): QaBusSnapshotConversation | undefined {
|
||||
if (!selectionKey) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -20,7 +24,7 @@ export function findConversationBySelectionKey(
|
||||
);
|
||||
}
|
||||
|
||||
export function messageConversationSelectionKey(message: Message): string {
|
||||
export function messageConversationSelectionKey(message: QaBusMessage): string {
|
||||
return conversationSelectionKey({
|
||||
accountId: message.accountId,
|
||||
id: message.conversation.id,
|
||||
@@ -28,7 +32,7 @@ export function messageConversationSelectionKey(message: Message): string {
|
||||
});
|
||||
}
|
||||
|
||||
export function threadConversationSelectionKey(thread: Thread): string {
|
||||
export function threadConversationSelectionKey(thread: QaBusThread): string {
|
||||
// QA bus thread records come only from channel-scoped createThread; direct
|
||||
// message thread ids do not create sidebar thread records.
|
||||
return conversationSelectionKey({
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import type {
|
||||
QaBusAttachment,
|
||||
QaBusMessage,
|
||||
QaBusSnapshotConversation,
|
||||
} from "openclaw/plugin-sdk/qa-channel-protocol";
|
||||
import {
|
||||
conversationSelectionKey,
|
||||
findConversationBySelectionKey,
|
||||
@@ -6,9 +11,9 @@ import {
|
||||
} from "./ui-conversation-key.js";
|
||||
import { findScenarioOutcome } from "./ui-render-scenario.js";
|
||||
import { badgeHtml, esc, formatIso, formatTime } from "./ui-render-utils.js";
|
||||
import type { Attachment, Conversation, Message, SeedScenario, UiState } from "./ui-types.js";
|
||||
import type { SeedScenario, UiState } from "./ui-types.js";
|
||||
|
||||
function attachmentSourceUrl(attachment: Attachment): string | null {
|
||||
function attachmentSourceUrl(attachment: QaBusAttachment): string | null {
|
||||
if (attachment.url?.trim()) {
|
||||
return attachment.url;
|
||||
}
|
||||
@@ -18,7 +23,7 @@ function attachmentSourceUrl(attachment: Attachment): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function renderMessageAttachments(message: Message): string {
|
||||
function renderMessageAttachments(message: QaBusMessage): string {
|
||||
const attachments = message.attachments ?? [];
|
||||
if (attachments.length === 0) {
|
||||
return "";
|
||||
@@ -91,8 +96,8 @@ function filteredMessages(state: UiState) {
|
||||
}
|
||||
|
||||
function formatConversationLabel(
|
||||
conversation: Conversation,
|
||||
conversations: Conversation[],
|
||||
conversation: QaBusSnapshotConversation,
|
||||
conversations: QaBusSnapshotConversation[],
|
||||
): string {
|
||||
const label = conversation.title || conversation.id;
|
||||
const sidebarCollisions = conversations.filter(
|
||||
@@ -239,14 +244,14 @@ export function renderChatView(state: UiState): string {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function messageAvatar(m: Message): { emoji: string; bg: string; role: string } {
|
||||
function messageAvatar(m: QaBusMessage): { emoji: string; bg: string; role: string } {
|
||||
if (m.direction === "outbound") {
|
||||
return { emoji: "\uD83E\uDD80", bg: "#7c6cff", role: "Claw" }; // 🦀
|
||||
}
|
||||
return { emoji: "\uD83E\uDD9E", bg: "#d97706", role: "Clawfather" }; // 🦞
|
||||
}
|
||||
|
||||
function renderMessage(m: Message): string {
|
||||
function renderMessage(m: QaBusMessage): string {
|
||||
const name = m.senderName || m.senderId;
|
||||
const avatar = messageAvatar(m);
|
||||
const dirClass = m.direction === "inbound" ? "msg-direction-inbound" : "msg-direction-outbound";
|
||||
@@ -288,7 +293,7 @@ function recentInspectorMessages(state: UiState, limit = 18) {
|
||||
return (state.snapshot?.messages ?? []).slice(-limit).toReversed();
|
||||
}
|
||||
|
||||
function renderInspectorLiveMessage(message: Message): string {
|
||||
function renderInspectorLiveMessage(message: QaBusMessage): string {
|
||||
const avatar = messageAvatar(message);
|
||||
const conversationLabel = message.conversation.title || message.conversation.id;
|
||||
const threadLabel = message.threadTitle || message.threadId;
|
||||
@@ -483,9 +488,7 @@ export function renderEventsView(state: UiState): string {
|
||||
const detail =
|
||||
"thread" in e
|
||||
? `${e.thread.conversationId}/${e.thread.id}`
|
||||
: e.message
|
||||
? `${e.message.senderId}: ${e.message.text}`
|
||||
: "";
|
||||
: `${e.message.senderId}: ${e.message.text}`;
|
||||
return `
|
||||
<div class="event-row">
|
||||
<span class="event-kind">${esc(e.kind)}</span>
|
||||
|
||||
@@ -97,6 +97,7 @@ describe("QA Lab UI evidence render", () => {
|
||||
{ accountId: "account-b", id: "shared", kind: "channel" },
|
||||
{ accountId: "account-a", id: "shared", kind: "direct" },
|
||||
],
|
||||
cursor: 0,
|
||||
events: [],
|
||||
messages: [
|
||||
{
|
||||
@@ -134,12 +135,16 @@ describe("QA Lab UI evidence render", () => {
|
||||
{
|
||||
accountId: "account-a",
|
||||
conversationId: "shared",
|
||||
createdAt: 0,
|
||||
createdBy: "openclaw",
|
||||
id: "selected-thread",
|
||||
title: "Selected thread",
|
||||
},
|
||||
{
|
||||
accountId: "account-b",
|
||||
conversationId: "shared",
|
||||
createdAt: 0,
|
||||
createdBy: "openclaw",
|
||||
id: "foreign-thread",
|
||||
title: "Foreign thread",
|
||||
},
|
||||
@@ -167,6 +172,7 @@ describe("QA Lab UI evidence render", () => {
|
||||
{ accountId: "account-a", id: "shared", kind: "group" },
|
||||
{ accountId: "account-b", id: "shared", kind: "channel" },
|
||||
],
|
||||
cursor: 0,
|
||||
events: [],
|
||||
messages: [],
|
||||
threads: [],
|
||||
@@ -197,6 +203,7 @@ describe("QA Lab UI evidence render", () => {
|
||||
{ accountId: "account-a", id: "shared", kind: "channel" },
|
||||
{ accountId: "account-a", id: "shared", kind: "direct" },
|
||||
],
|
||||
cursor: 0,
|
||||
events: [],
|
||||
messages: [
|
||||
{
|
||||
@@ -251,6 +258,7 @@ describe("QA Lab UI evidence render", () => {
|
||||
const selectedConversationKey = JSON.stringify(["default", "channel", "qa-room"]);
|
||||
const snapshot: NonNullable<UiState["snapshot"]> = {
|
||||
conversations: [{ accountId: "default", id: "qa-room", kind: "channel" }],
|
||||
cursor: 0,
|
||||
events: [],
|
||||
messages: [
|
||||
{
|
||||
@@ -290,6 +298,8 @@ describe("QA Lab UI evidence render", () => {
|
||||
{
|
||||
accountId: "default",
|
||||
conversationId: "qa-room",
|
||||
createdAt: 0,
|
||||
createdBy: "openclaw",
|
||||
id: "owned-thread",
|
||||
title: "Owned thread",
|
||||
},
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import type {
|
||||
QaBusConversationKind,
|
||||
QaBusStateSnapshot,
|
||||
} from "openclaw/plugin-sdk/qa-channel-protocol";
|
||||
import type {
|
||||
QaLabExecutionKind,
|
||||
QaLabResolvedRunPlan,
|
||||
@@ -13,65 +17,6 @@ import type {
|
||||
QaEvidenceProducerContextFile,
|
||||
} from "../../shared/evidence-gallery-types.js";
|
||||
|
||||
/* ===== Shared types (unchanged from the bus protocol) ===== */
|
||||
|
||||
export type Conversation = {
|
||||
accountId: string;
|
||||
id: string;
|
||||
kind: "direct" | "channel" | "group";
|
||||
title?: string;
|
||||
};
|
||||
|
||||
export type Attachment = {
|
||||
id: string;
|
||||
kind: "image" | "video" | "audio" | "file";
|
||||
mimeType: string;
|
||||
fileName?: string;
|
||||
inline?: boolean;
|
||||
url?: string;
|
||||
contentBase64?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
durationMs?: number;
|
||||
altText?: string;
|
||||
transcript?: string;
|
||||
};
|
||||
|
||||
export type Thread = {
|
||||
accountId: string;
|
||||
id: string;
|
||||
conversationId: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
export type Message = {
|
||||
accountId: string;
|
||||
id: string;
|
||||
direction: "inbound" | "outbound";
|
||||
conversation: Omit<Conversation, "accountId">;
|
||||
senderId: string;
|
||||
senderName?: string;
|
||||
text: string;
|
||||
timestamp: number;
|
||||
threadId?: string;
|
||||
threadTitle?: string;
|
||||
deleted?: boolean;
|
||||
editedAt?: number;
|
||||
attachments?: Attachment[];
|
||||
reactions: Array<{ emoji: string; senderId: string }>;
|
||||
};
|
||||
|
||||
type BusEvent =
|
||||
| { cursor: number; kind: "thread-created"; thread: Thread }
|
||||
| { cursor: number; kind: string; message?: Message; emoji?: string };
|
||||
|
||||
export type Snapshot = {
|
||||
conversations: Conversation[];
|
||||
threads: Thread[];
|
||||
messages: Message[];
|
||||
events: BusEvent[];
|
||||
};
|
||||
|
||||
export type ReportEnvelope = {
|
||||
report: null | {
|
||||
outputPath: string;
|
||||
@@ -300,7 +245,7 @@ export type TabId = "chat" | "results" | "report" | "events" | "capture" | "evid
|
||||
export type UiState = {
|
||||
theme: "light" | "dark";
|
||||
bootstrap: Bootstrap | null;
|
||||
snapshot: Snapshot | null;
|
||||
snapshot: QaBusStateSnapshot | null;
|
||||
latestReport: ReportEnvelope["report"];
|
||||
scenarioRun: ScenarioRun | null;
|
||||
captureSessions: CaptureSessionSummary[];
|
||||
@@ -371,7 +316,7 @@ export type UiState = {
|
||||
runnerDraftDirty: boolean;
|
||||
runnerPlanOverride: RunnerResolvedPlan | null;
|
||||
composer: {
|
||||
conversationKind: "direct" | "channel" | "group";
|
||||
conversationKind: QaBusConversationKind;
|
||||
conversationId: string;
|
||||
senderId: string;
|
||||
senderName: string;
|
||||
|
||||
@@ -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-");
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user