mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 14:01:24 +08:00
Compare commits
3 Commits
codex/ui-e
...
codex/matr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5af238053 | ||
|
|
043c4b1947 | ||
|
|
39d273cbbe |
59
CHANGELOG.md
59
CHANGELOG.md
@@ -6,66 +6,12 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Changes
|
||||
|
||||
- Memory/Active Memory: add a new optional Active Memory plugin that gives OpenClaw a dedicated memory sub-agent right before the main reply, so ongoing chats can automatically pull in relevant preferences, context, and past details without making users remember to manually say "remember this" or "search memory" first. Includes configurable message/recent/full context modes, live `/verbose` inspection, advanced prompt/thinking overrides for tuning, and opt-in transcript persistence for debugging.
|
||||
- macOS/Talk: add an experimental local MLX speech provider for Talk Mode, with explicit provider selection, local utterance playback, interruption handling, and system-voice fallback. (#63539) Thanks @ImLukeF.
|
||||
- Docs i18n: chunk raw doc translation, reject truncated tagged outputs, avoid ambiguous body-only wrapper unwrapping, and recover from terminated Pi translation sessions without changing the default `openai/gpt-5.4` path. (#62969, #63808) Thanks @hxy91819.
|
||||
- QA/testing: add a `--runner multipass` lane for `openclaw qa suite` so repo-backed QA scenarios can run inside a disposable Linux VM and write back the usual report, summary, and VM logs. (#63426) Thanks @shakkernerd.
|
||||
- Gateway: split startup and runtime seams so gateway lifecycle sequencing, reload state, and shutdown behavior stay easier to maintain without changing observed behavior. (#63975) Thanks @gumadeiras.
|
||||
- Control UI/webchat: normalize assistant `MEDIA:`/reply/voice directives into structured bubble rendering, rename the unreleased rich web shortcode to `[embed ...]`, and surface session runtime roots so hosted web content is written to the correct document path instead of guessed local files.
|
||||
|
||||
### Fixes
|
||||
|
||||
- fix(agents): guard nodes tool outPath against workspace boundary [AI-assisted]. (#63551) Thanks @pgondhi987.
|
||||
- fix(qqbot): enforce media storage boundary for all outbound local file paths [AI]. (#63271) Thanks @pgondhi987.
|
||||
- iMessage/self-chat: distinguish normal DM outbound rows from true self-chat using `destination_caller_id` plus chat participants, while preserving multi-handle self-chat aliases so outbound DM replies stop looping back as inbound messages. (#61619) Thanks @neeravmakwana.
|
||||
- fix(browser): auto-generate browser control auth token for none/trusted-proxy modes [AI]. (#63280) Thanks @pgondhi987.
|
||||
- fix(exec): replace TOCTOU check-then-read with atomic pinned-fd open in script preflight [AI]. (#62333) Thanks @pgondhi987.
|
||||
- WhatsApp/auto-reply: keep inbound reply, media, and composing sends on the current socket across reconnects, wait through reconnect gaps, and retry timeout-only send failures without dropping the active socket ref. (#62892) Thanks @mcaxtr.
|
||||
- Config/plugins: let config writes keep disabled plugin entries without forcing required plugin config schemas or crashing raw plugin validation, so slot switches and similar plugin-state updates persist cleanly. (#63296) Thanks @fuller-stack-dev.
|
||||
- WhatsApp/outbound queue: drain queued WhatsApp deliveries when the listener reconnects without dropping reconnect-delayed sends after a special TTL or rewriting retry history, so disconnect-window outbound messages can recover once the channel is ready again. (#46299) Thanks @manuel-claw.
|
||||
- Tools/web_fetch: add an opt-in `tools.web.fetch.ssrfPolicy.allowRfc2544BenchmarkRange` config so fake-IP proxy environments that resolve public sites into `198.18.0.0/15` can use `web_fetch` without weakening the default SSRF block. (#61830) Thanks @xing-xing-coder.
|
||||
- Daemon/gateway install: preserve safe custom service env vars on forced reinstall, merge prior custom PATH segments behind the managed service PATH, and stop removed managed env keys from persisting as custom carryover. (#63136) Thanks @WarrenJones.
|
||||
- Config validation: surface the actual offending field for strict-schema union failures in bindings, including top-level unexpected keys on the matching ACP branch. (#40841) Thanks @Hollychou924.
|
||||
- QQBot/security: replace raw `fetch()` in the image-size probe with SSRF-guarded `fetchRemoteMedia`, fix `resolveRepoRoot()` to walk up to `.git` instead of hardcoding two parent levels, and refresh the raw-fetch allowlist to match the corrected scan. (#63495) Thanks @dims.
|
||||
- Cron/scheduling: treat `nextRunAtMs <= 0` as invalid across cron update, maintenance, timer, and stale-delivery paths so corrupted zero timestamps self-heal instead of causing immediate runs or skipped deliveries. (#63507) Thanks @WarrenJones.
|
||||
- Status: show configured fallback models in `/status` and shared session status cards so per-agent fallback configuration is visible before a live failover happens. (#33111) Thanks @AnCoSONG.
|
||||
- Fireworks/FirePass: disable Kimi K2.5 Turbo reasoning output by forcing thinking off on the FirePass path and hardening the provider wrapper so hidden reasoning no longer leaks into visible replies. (#63607) Thanks @frankekn.
|
||||
- Sessions/model selection: preserve catalog-backed session model labels and keep already-qualified session model refs stable when catalog metadata is unavailable, so Control UI model selection survives reloads without bogus provider-prefixed values. (#61382) Thanks @Mule-ME.
|
||||
- Gateway/startup: keep WebSocket RPC available while channels and plugin sidecars start, hold `chat.history` unavailable until startup sidecars finish so synchronous history reads cannot stall startup (reported in #63450), refresh advertised gateway methods after deferred plugin reloads, and enforce the pre-auth WebSocket upgrade budget before the no-handler 503 path so upgrade floods cannot bypass connection limits during that window. (#63480) Thanks @neeravmakwana.
|
||||
- Dreaming/cron: reconcile managed dreaming cron from the resolved gateway startup config so boot-time schedule recovery respects the configured cadence and timezone. (#63873) Thanks @mbelinky.
|
||||
- Dreaming/cron: keep managed dreaming cron reconciled after startup by rechecking lifecycle state during runtime config/plugin changes, recovering missing managed jobs, and applying cadence/timezone updates idempotently. (#63929) Thanks @mbelinky.
|
||||
- Gateway/tailscale: start Tailscale exposure and the gateway update check before awaiting channel and plugin sidecar startup so remote operators are not locked out when startup sidecars stall.
|
||||
- QQBot/streaming: make block streaming configurable per QQ bot account via `streaming.mode` (`"partial"` | `"off"`, default `"partial"`) instead of hardcoding it off, so responses can be delivered incrementally. (#63746)
|
||||
- Dreaming/gateway: require `operator.admin` for persistent `/dreaming on|off` changes and treat missing gateway client scopes as unprivileged instead of silently allowing config writes. (#63872) Thanks @mbelinky.
|
||||
- Matrix/multi-account: keep room-level `account` scoping, inherited room overrides, and implicit account selection consistent across top-level default auth, named accounts, and cached-credential env setups. (#58449) thanks @Daanvdplas and @gumadeiras.
|
||||
- Gateway/pairing: prefer explicit QR bootstrap auth over earlier Tailscale auth classification so iOS `/pair qr` silent bootstrap pairing does not fall through to `pairing required`. (#59232) Thanks @ngutman.
|
||||
- WhatsApp/outbound queue: drain same-account pending WhatsApp deliveries when the listener reconnects, including fresh queued sends that are already retry-eligible, so reconnects recover deliverable outbound messages without waiting for another gateway restart. (#63916) Thanks @mcaxtr.
|
||||
- Config/Discord: coerce safe integer numeric Discord IDs to strings during config validation, keep unsafe or precision-losing numeric snowflakes rejected, and align `openclaw doctor` repair guidance with the same fail-closed behavior. (#45125) Thanks @moliendocode.
|
||||
- Gateway/sessions: scope bare `sessions.create` aliases like `main` to the requested agent while preserving the canonical `global` and `unknown` sentinel keys. (#58207) thanks @jalehman.
|
||||
- `/context detail` now compares the tracked prompt estimate with cached context usage and surfaces untracked provider/runtime overhead when present. (#28391) thanks @ImLukeF.
|
||||
- Gateway/session reset: emit the typed `before_reset` hook for gateway `/new` and `/reset`, preserving reset-hook behavior even when the previous transcript has already been archived. (#53872) thanks @VACInc
|
||||
- Plugins/commands: pass the active host `sessionKey` into plugin command contexts, and include `sessionId` when it is already available from the active session entry, so bundled and third-party commands can resolve the current conversation reliably. (#59044) Thanks @jalehman.
|
||||
- Agents/auth: honor `models.providers.*.authHeader` for pi embedded runner model requests by injecting `Authorization: Bearer <apiKey>` when requested. (#54390) Thanks @lndyzwdxhs.
|
||||
- Dreaming/cron: stop runtime cron reconciliation on ordinary user turns and only recover managed dreaming cron state during heartbeat-triggered dreaming checks, so unrelated chat traffic does not silently recreate removed jobs. (#63938) Thanks @mbelinky.
|
||||
- UI/compaction: keep the compaction indicator in a retry-pending state until the run actually finishes, so the UI does not show `Context compacted` before compaction actually finishes. (#55132) Thanks @mpz4life.
|
||||
- Cron/tool schemas: keep cron tool schemas strict-model-friendly while still preserving `failureAlert=false`, nullable `agentId`/`sessionKey`, and flattened add/update recovery for the newly exposed cron job fields. (#55043) Thanks @brunolorente.
|
||||
- BlueBubbles/config: accept `enrichGroupParticipantsFromContacts` in the core strict config schema so gateways no longer fail validation or startup when the BlueBubbles plugin writes that field. (#56889) Thanks @zqchris.
|
||||
- Agents/failover: classify AbortError and stream-abort messages as timeout so Ollama NDJSON stream aborts stop showing `reason=unknown` in model fallback logs. (#58324) Thanks @yelog
|
||||
- Exec approvals: route Slack, Discord, and Telegram approvals through the shared channel approval-capability path so native approval auth, delivery, and `/approve` handling stay aligned across channels while preserving Telegram session-key agent filtering. (#58634) thanks @gumadeiras
|
||||
- Matrix/runtime: resolve the verification/bootstrap runtime from a distinct packaged Matrix entry so global npm installs stop failing on crypto bootstrap with missing-module or recursive runtime alias errors. (#59249) Thanks @gumadeiras.
|
||||
- Matrix/streaming: preserve ordered block flushes before tool, message, and agent boundaries, add explicit `channels.matrix.blockStreaming` opt-in so Matrix `streaming: "off"` stays final-only by default, and move MiniMax plain-text final handling into the MiniMax provider runtime instead of the shared core heuristic. (#59266) thanks @gumadeiras
|
||||
- Gateway/agents: fix stale run-context TTL cleanup so the new maintenance sweep compiles and resets orphaned run sequence state correctly. (#52731) thanks @artwalker
|
||||
- Memory/lancedb: accept `dreaming` config when `memory-lancedb` owns the memory slot so Dreaming surfaces can read slot-owner settings without schema rejection. (#63874) Thanks @mbelinky.
|
||||
- Control UI/dreaming: keep the Dreaming trace area contained and scrollable so overlays no longer cover tabs or blow out the page layout. (#63875) Thanks @mbelinky.
|
||||
- Dreaming/diary: add idempotent narrative subagent runs, preserve restrictive `DREAMS.md` permissions during atomic writes, and surface temp cleanup failures so repeated sweeps do not double-run the same narrative request or silently weaken diary safety. (#63876) Thanks @mbelinky.
|
||||
- Heartbeats/sessions: remove stale accumulated isolated heartbeat session keys when the next tick converges them back to the canonical sibling, so repaired sessions stop showing orphaned `:heartbeat:heartbeat` variants in session listings. (#59606) Thanks @rogerdigital.
|
||||
- Cron/Telegram: collapse isolated announce delivery to the final assistant-visible text only for Telegram targets, while preserving existing multi-message direct delivery semantics for other channels. (#63228) Thanks @welfo-beo.
|
||||
- Gateway/thread routing: preserve Slack, Telegram, and Mattermost thread-child delivery targets so bound subagent completion messages land in the originating thread instead of top-level channels. (#54840) Thanks @yzzymt.
|
||||
- ACP/stream relay: pass parent delivery context to ACP stream relay system events so `streamTo="parent"` updates route to the correct thread or topic instead of falling back to the main DM. (#57056) Thanks @pingren.
|
||||
- Agents/sessions: preserve announce `threadId` when `sessions.list` fallback rehydrates agent-to-agent announce targets so final announce messages stay in the originating thread/topic. (#63506) Thanks @SnowSky1.
|
||||
- Browser/plugin SDK: route browser auth, profile, host-inspection, and doctor readiness helpers through browser plugin public facades so core compatibility helpers stop carrying duplicate runtime implementations. (#63957) Thanks @joshavant.
|
||||
- Browser/act: centralize `/act` request normalization and execution dispatch while adding stable machine-readable route-level error codes for invalid requests, selector misuse, evaluate-disabled gating, target mismatch, and existing-session unsupported actions. (#63977) Thanks @joshavant.
|
||||
- Windows/exec: settle supervisor waits from child exit state after stdout and stderr drain even when `close` never arrives, so CLI commands stop hanging or dying with forced `SIGKILL` on Windows. (#64072) Thanks @obviyus.
|
||||
|
||||
## 2026.4.9
|
||||
|
||||
@@ -113,7 +59,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Plugins/contracts: keep test-only helpers out of production contract barrels, load shared contract harnesses through bundled test surfaces, and harden guardrails so indirect re-exports and canonical `*.test.ts` files stay blocked. (#63311) Thanks @altaywtf.
|
||||
- Control UI/models: preserve provider-qualified refs for OpenRouter catalog models whose ids already contain slashes so picker selections submit allowlist-compatible model refs instead of dropping the `openrouter/` prefix. (#63416) Thanks @sallyom.
|
||||
- Plugin SDK/command auth: split command status builders onto the lightweight `openclaw/plugin-sdk/command-status` subpath while preserving deprecated `command-auth` compatibility exports, so auth-only plugin imports no longer pull status/context warmup into CLI onboarding paths. (#63174) Thanks @hxy91819.
|
||||
- Wizard/plugin config: coerce integer-typed plugin config fields from interactive text input so integer schema values persist as numbers instead of failing validation. (#63346) Thanks @jalehman.
|
||||
|
||||
## 2026.4.8
|
||||
|
||||
@@ -230,10 +175,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Agents/model resolution: let explicit `openai-codex/gpt-5.4` selection prefer provider runtime metadata when it reports a larger context window, keeping configured Codex runs aligned with the live provider limits. (#62694) Thanks @ruclaw7.
|
||||
- Agents/model resolution: keep explicit-model runtime comparisons on the configured workspace plugin registry, so workspace-installed providers do not silently fall back to stale explicit metadata during runtime model lookup.
|
||||
- Providers/Z.AI: default onboarding and endpoint detection to GLM-5.1 instead of GLM-5. (#61998) Thanks @serg0x.
|
||||
- Cron/isolated: resolve auth profiles without treating every isolated run as a brand-new auth session, so profile-based providers (for example OpenRouter) keep a stable credential choice instead of rotating or ignoring stored keys. (#62783) Thanks @neeravmakwana.
|
||||
- CLI/tasks: `openclaw tasks cancel` now records operator cancellation for CLI runtime tasks instead of returning "Task runtime does not support cancellation yet", so stuck `running` CLI tasks can be cleared. (#62419) Thanks @neeravmakwana.
|
||||
- Sessions/context: resolve context window limits using the active provider plus model (not bare model id alone) when persisting session usage, applying inline directives, and sizing memory-flush / preflight compaction thresholds, so duplicate model ids across providers no longer leak the wrong `contextTokens` into the session store or `/status`. (#62472) Thanks @neeravmakwana.
|
||||
- Channels/setup: exclude workspace shadow entries from channel setup catalog lookups and align trust checks with auto-enable so workspace-scoped overrides no longer bypass the trusted catalog. (`GHSA-82qx-6vj7-p8m2`) Thanks @zsxsoft.
|
||||
|
||||
## 2026.4.5
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"originHash" : "31972864afdac74537794e1a3b7bd22484c09ec1be8e3624fb9ea582e9222ad9",
|
||||
"originHash" : "fb90e7b1977f43661ac91681d16da11f9ddd85630407ef170eaada0a6ee39972",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "axorcist",
|
||||
@@ -28,15 +28,6 @@
|
||||
"version" : "0.1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "eventsource",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/mattt/EventSource.git",
|
||||
"state" : {
|
||||
"revision" : "a3a85a85214caf642abaa96ae664e4c772a59f6e",
|
||||
"version" : "1.4.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "menubarextraaccess",
|
||||
"kind" : "remoteSourceControl",
|
||||
@@ -46,33 +37,6 @@
|
||||
"version" : "1.2.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "mlx-audio-swift",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/Blaizzy/mlx-audio-swift",
|
||||
"state" : {
|
||||
"revision" : "fcbd04daa1bfebe881932f630af2ba6ce9af3274",
|
||||
"version" : "0.1.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "mlx-swift",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/ml-explore/mlx-swift.git",
|
||||
"state" : {
|
||||
"revision" : "61b9e011e09a62b489f6bd647958f1555bdf2896",
|
||||
"version" : "0.31.3"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "mlx-swift-lm",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/ml-explore/mlx-swift-lm.git",
|
||||
"state" : {
|
||||
"revision" : "25b00d4e22e61ec9c41efda47990cd2084ec87ff",
|
||||
"version" : "2.31.3"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "peekaboo",
|
||||
"kind" : "remoteSourceControl",
|
||||
@@ -100,33 +64,6 @@
|
||||
"version" : "1.2.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-asn1",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-asn1.git",
|
||||
"state" : {
|
||||
"revision" : "9f542610331815e29cc3821d3b6f488db8715517",
|
||||
"version" : "1.6.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-atomics",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-atomics.git",
|
||||
"state" : {
|
||||
"revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7",
|
||||
"version" : "1.3.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-collections",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-collections.git",
|
||||
"state" : {
|
||||
"revision" : "6675bc0ff86e61436e615df6fc5174e043e57924",
|
||||
"version" : "1.4.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-concurrency-extras",
|
||||
"kind" : "remoteSourceControl",
|
||||
@@ -136,33 +73,6 @@
|
||||
"version" : "1.3.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-crypto",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-crypto.git",
|
||||
"state" : {
|
||||
"revision" : "bb4ba815dab96d4edc1e0b86d7b9acf9ff973a84",
|
||||
"version" : "4.3.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-huggingface",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/huggingface/swift-huggingface.git",
|
||||
"state" : {
|
||||
"revision" : "b721959445b617d0bf03910b2b4aced345fd93bf",
|
||||
"version" : "0.9.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-jinja",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/huggingface/swift-jinja.git",
|
||||
"state" : {
|
||||
"revision" : "0aeefadec459ce8e11a333769950fb86183aca43",
|
||||
"version" : "2.3.5"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-log",
|
||||
"kind" : "remoteSourceControl",
|
||||
@@ -172,15 +82,6 @@
|
||||
"version" : "1.10.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-nio",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-nio.git",
|
||||
"state" : {
|
||||
"revision" : "558f24a4647193b5a0e2104031b71c55d31ff83a",
|
||||
"version" : "2.97.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-numerics",
|
||||
"kind" : "remoteSourceControl",
|
||||
@@ -208,15 +109,6 @@
|
||||
"version" : "1.6.4"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-transformers",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/huggingface/swift-transformers.git",
|
||||
"state" : {
|
||||
"revision" : "58c4bc11963a140358d791f678a60a2745a23146",
|
||||
"version" : "1.2.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swiftui-math",
|
||||
"kind" : "remoteSourceControl",
|
||||
@@ -234,15 +126,6 @@
|
||||
"revision" : "5b06b811c0f5313b6b84bbef98c635a630638c38",
|
||||
"version" : "0.3.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "yyjson",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/ibireme/yyjson.git",
|
||||
"state" : {
|
||||
"revision" : "8b4a38dc994a110abaec8a400615567bd996105f",
|
||||
"version" : "0.12.0"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 3
|
||||
|
||||
@@ -20,7 +20,6 @@ let package = Package(
|
||||
.package(url: "https://github.com/apple/swift-log.git", from: "1.10.1"),
|
||||
.package(url: "https://github.com/sparkle-project/Sparkle", from: "2.9.0"),
|
||||
.package(url: "https://github.com/steipete/Peekaboo.git", branch: "main"),
|
||||
.package(url: "https://github.com/Blaizzy/mlx-audio-swift", exact: "0.1.2"),
|
||||
.package(path: "../shared/OpenClawKit"),
|
||||
.package(path: "../../Swabble"),
|
||||
],
|
||||
@@ -55,7 +54,6 @@ let package = Package(
|
||||
.product(name: "Sparkle", package: "Sparkle"),
|
||||
.product(name: "PeekabooBridge", package: "Peekaboo"),
|
||||
.product(name: "PeekabooAutomationKit", package: "Peekaboo"),
|
||||
.product(name: "MLXAudioTTS", package: "mlx-audio-swift"),
|
||||
],
|
||||
exclude: [
|
||||
"Resources/Info.plist",
|
||||
|
||||
@@ -1,178 +0,0 @@
|
||||
import Foundation
|
||||
import MLXAudioTTS
|
||||
import OSLog
|
||||
|
||||
// swiftformat:disable wrap wrapMultilineStatementBraces trailingCommas redundantSelf extensionAccessControl
|
||||
/// Runtime access stays serialized through `TalkModeRuntime` actor helper methods.
|
||||
final class TalkMLXSpeechSynthesizer {
|
||||
enum SynthesizeError: Error {
|
||||
case canceled
|
||||
case modelLoadFailed(String)
|
||||
case audioGenerationFailed
|
||||
case audioPlaybackFailed
|
||||
case timedOut
|
||||
}
|
||||
|
||||
static let shared = TalkMLXSpeechSynthesizer()
|
||||
static let defaultModelRepo = "mlx-community/Soprano-80M-bf16"
|
||||
|
||||
private let logger = Logger(subsystem: "ai.openclaw", category: "talk.mlx")
|
||||
private var currentToken = UUID()
|
||||
private var modelRepo: String?
|
||||
private var model: (any SpeechGenerationModel)?
|
||||
|
||||
private init() {}
|
||||
|
||||
func stop() {
|
||||
self.currentToken = UUID()
|
||||
}
|
||||
|
||||
func synthesize(
|
||||
text: String,
|
||||
modelRepo: String?,
|
||||
language: String?,
|
||||
voicePreset: String?) async throws -> Data {
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return Data() }
|
||||
|
||||
self.stop()
|
||||
let token = UUID()
|
||||
self.currentToken = token
|
||||
|
||||
let resolvedRepo = Self.resolvedModelRepo(modelRepo)
|
||||
let rawModel = try await self.loadModel(
|
||||
modelRepo: resolvedRepo,
|
||||
token: token)
|
||||
let model = UncheckedSpeechModel(raw: rawModel)
|
||||
guard self.currentToken == token else {
|
||||
throw SynthesizeError.canceled
|
||||
}
|
||||
|
||||
let audioData: Data
|
||||
do {
|
||||
let audio = try await model.generateAudio(
|
||||
text: trimmed,
|
||||
voice: voicePreset,
|
||||
language: language)
|
||||
audioData = Self.makeWavData(
|
||||
samples: audio,
|
||||
sampleRate: Double(model.sampleRateValue()))
|
||||
} catch {
|
||||
self.logger.error(
|
||||
"talk mlx generation failed: \(error.localizedDescription, privacy: .public)")
|
||||
throw SynthesizeError.audioGenerationFailed
|
||||
}
|
||||
|
||||
guard self.currentToken == token else {
|
||||
throw SynthesizeError.canceled
|
||||
}
|
||||
return audioData
|
||||
}
|
||||
|
||||
private func loadModel(
|
||||
modelRepo: String,
|
||||
token: UUID) async throws -> any SpeechGenerationModel {
|
||||
if let model = self.model, self.modelRepo == modelRepo {
|
||||
return model
|
||||
}
|
||||
|
||||
self.logger.info("talk mlx loading modelRepo=\(modelRepo, privacy: .public)")
|
||||
do {
|
||||
let model = try await TTS.loadModel(modelRepo: modelRepo)
|
||||
guard self.currentToken == token else {
|
||||
throw SynthesizeError.canceled
|
||||
}
|
||||
self.model = model
|
||||
self.modelRepo = modelRepo
|
||||
return model
|
||||
} catch is CancellationError {
|
||||
throw SynthesizeError.canceled
|
||||
} catch {
|
||||
self.logger.error(
|
||||
"talk mlx load failed: \(error.localizedDescription, privacy: .public)")
|
||||
throw SynthesizeError.modelLoadFailed(modelRepo)
|
||||
}
|
||||
}
|
||||
|
||||
private static func resolvedModelRepo(_ modelRepo: String?) -> String {
|
||||
let trimmed = modelRepo?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return trimmed.isEmpty ? Self.defaultModelRepo : trimmed
|
||||
}
|
||||
|
||||
private static func makeWavData(samples: [Float], sampleRate: Double) -> Data {
|
||||
let channels: UInt16 = 1
|
||||
let bitsPerSample: UInt16 = 16
|
||||
let blockAlign = channels * (bitsPerSample / 8)
|
||||
let sampleRateInt = UInt32(sampleRate.rounded())
|
||||
let byteRate = sampleRateInt * UInt32(blockAlign)
|
||||
let dataSize = UInt32(samples.count) * UInt32(blockAlign)
|
||||
|
||||
var data = Data(capacity: Int(44 + dataSize))
|
||||
data.append(contentsOf: [0x52, 0x49, 0x46, 0x46]) // RIFF
|
||||
data.appendLEUInt32(36 + dataSize)
|
||||
data.append(contentsOf: [0x57, 0x41, 0x56, 0x45]) // WAVE
|
||||
|
||||
data.append(contentsOf: [0x66, 0x6D, 0x74, 0x20]) // fmt
|
||||
data.appendLEUInt32(16)
|
||||
data.appendLEUInt16(1)
|
||||
data.appendLEUInt16(channels)
|
||||
data.appendLEUInt32(sampleRateInt)
|
||||
data.appendLEUInt32(byteRate)
|
||||
data.appendLEUInt16(blockAlign)
|
||||
data.appendLEUInt16(bitsPerSample)
|
||||
|
||||
data.append(contentsOf: [0x64, 0x61, 0x74, 0x61]) // data
|
||||
data.appendLEUInt32(dataSize)
|
||||
|
||||
for sample in samples {
|
||||
let clamped = max(-1.0, min(1.0, sample))
|
||||
let scaled = Int16((clamped * Float(Int16.max)).rounded())
|
||||
data.appendLEInt16(scaled)
|
||||
}
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
extension TalkMLXSpeechSynthesizer: @unchecked Sendable {}
|
||||
|
||||
private struct UncheckedSpeechModel {
|
||||
let raw: any SpeechGenerationModel
|
||||
|
||||
func sampleRateValue() -> Int {
|
||||
raw.sampleRate
|
||||
}
|
||||
|
||||
func generateAudio(
|
||||
text: String,
|
||||
voice: String?,
|
||||
language: String?) async throws -> [Float] {
|
||||
let generatedAudio = try await raw.generate(
|
||||
text: text,
|
||||
voice: voice,
|
||||
refAudio: nil,
|
||||
refText: nil,
|
||||
language: language)
|
||||
return generatedAudio.asArray(Float.self)
|
||||
}
|
||||
}
|
||||
|
||||
extension UncheckedSpeechModel: @unchecked Sendable {}
|
||||
|
||||
extension Data {
|
||||
fileprivate mutating func appendLEUInt16(_ value: UInt16) {
|
||||
var littleEndian = value.littleEndian
|
||||
Swift.withUnsafeBytes(of: &littleEndian) { append(contentsOf: $0) }
|
||||
}
|
||||
|
||||
fileprivate mutating func appendLEUInt32(_ value: UInt32) {
|
||||
var littleEndian = value.littleEndian
|
||||
Swift.withUnsafeBytes(of: &littleEndian) { append(contentsOf: $0) }
|
||||
}
|
||||
|
||||
fileprivate mutating func appendLEInt16(_ value: Int16) {
|
||||
var littleEndian = value.littleEndian
|
||||
Swift.withUnsafeBytes(of: &littleEndian) { append(contentsOf: $0) }
|
||||
}
|
||||
}
|
||||
|
||||
// swiftformat:enable wrap wrapMultilineStatementBraces trailingCommas redundantSelf extensionAccessControl
|
||||
@@ -44,13 +44,7 @@ enum TalkModeGatewayConfigParser {
|
||||
acc[key] = value
|
||||
} ?? [:]
|
||||
let model = activeConfig?["modelId"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let resolvedModel: String? = if model?.isEmpty == false {
|
||||
model!
|
||||
} else if activeProvider == defaultProvider {
|
||||
defaultModelIdFallback
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
let resolvedModel = (model?.isEmpty == false) ? model! : defaultModelIdFallback
|
||||
let outputFormat = activeConfig?["outputFormat"]?.stringValue
|
||||
let interrupt = talk?["interruptOnSpeech"]?.boolValue
|
||||
let apiKey = activeConfig?["apiKey"]?.stringValue
|
||||
|
||||
@@ -10,7 +10,6 @@ actor TalkModeRuntime {
|
||||
|
||||
enum PlaybackPlan: Equatable {
|
||||
case elevenLabsThenSystemVoice(apiKey: String, voiceId: String)
|
||||
case mlxThenSystemVoice
|
||||
case systemVoiceOnly
|
||||
}
|
||||
|
||||
@@ -18,8 +17,6 @@ actor TalkModeRuntime {
|
||||
private let ttsLogger = Logger(subsystem: "ai.openclaw", category: "talk.tts")
|
||||
private static let defaultModelIdFallback = "eleven_v3"
|
||||
private static let defaultTalkProvider = "elevenlabs"
|
||||
private static let mlxTalkProvider = "mlx"
|
||||
private static let systemTalkProvider = "system"
|
||||
private static let defaultSilenceTimeoutMs = TalkDefaults.silenceTimeoutMs
|
||||
|
||||
private final class RMSMeter: @unchecked Sendable {
|
||||
@@ -68,7 +65,6 @@ actor TalkModeRuntime {
|
||||
private var modelOverrideActive = false
|
||||
private var defaultOutputFormat: String?
|
||||
private var interruptOnSpeech: Bool = true
|
||||
private var activeTalkProvider = TalkModeRuntime.defaultTalkProvider
|
||||
private var lastInterruptedAtSeconds: Double?
|
||||
private var voiceAliases: [String: String] = [:]
|
||||
private var lastSpokenText: String?
|
||||
@@ -466,7 +462,7 @@ actor TalkModeRuntime {
|
||||
private func playAssistant(text: String) async {
|
||||
guard let input = await self.preparePlaybackInput(text: text) else { return }
|
||||
|
||||
switch Self.playbackPlan(provider: input.provider, apiKey: input.apiKey, voiceId: input.voiceId) {
|
||||
switch Self.playbackPlan(apiKey: input.apiKey, voiceId: input.voiceId) {
|
||||
case let .elevenLabsThenSystemVoice(apiKey, voiceId):
|
||||
do {
|
||||
try await self.playElevenLabs(input: input, apiKey: apiKey, voiceId: voiceId)
|
||||
@@ -481,23 +477,6 @@ actor TalkModeRuntime {
|
||||
self.ttsLogger.error("talk system voice failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
case .mlxThenSystemVoice:
|
||||
do {
|
||||
try await self.playMLX(input: input)
|
||||
} catch TalkMLXSpeechSynthesizer.SynthesizeError.canceled {
|
||||
self.ttsLogger.info("talk mlx canceled")
|
||||
return
|
||||
} catch {
|
||||
self.ttsLogger
|
||||
.error(
|
||||
"talk MLX failed: \(error.localizedDescription, privacy: .public); " +
|
||||
"falling back to system voice")
|
||||
do {
|
||||
try await self.playSystemVoice(input: input)
|
||||
} catch {
|
||||
self.ttsLogger.error("talk system voice failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
case .systemVoiceOnly:
|
||||
do {
|
||||
try await self.playSystemVoice(input: input)
|
||||
@@ -512,30 +491,19 @@ actor TalkModeRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
static func playbackPlan(provider: String, apiKey: String?, voiceId: String?) -> PlaybackPlan {
|
||||
switch provider {
|
||||
case self.defaultTalkProvider:
|
||||
guard let apiKey, !apiKey.isEmpty, let voiceId else {
|
||||
return .systemVoiceOnly
|
||||
}
|
||||
return .elevenLabsThenSystemVoice(apiKey: apiKey, voiceId: voiceId)
|
||||
case self.mlxTalkProvider:
|
||||
return .mlxThenSystemVoice
|
||||
case self.systemTalkProvider:
|
||||
return .systemVoiceOnly
|
||||
default:
|
||||
static func playbackPlan(apiKey: String?, voiceId: String?) -> PlaybackPlan {
|
||||
guard let apiKey, !apiKey.isEmpty, let voiceId else {
|
||||
return .systemVoiceOnly
|
||||
}
|
||||
return .elevenLabsThenSystemVoice(apiKey: apiKey, voiceId: voiceId)
|
||||
}
|
||||
|
||||
private struct TalkPlaybackInput {
|
||||
let generation: Int
|
||||
let provider: String
|
||||
let cleanedText: String
|
||||
let directive: TalkDirective?
|
||||
let apiKey: String?
|
||||
let voiceId: String?
|
||||
let voicePreset: String?
|
||||
let language: String?
|
||||
let synthTimeoutSeconds: Double
|
||||
}
|
||||
@@ -584,20 +552,18 @@ actor TalkModeRuntime {
|
||||
resolvedVoice ??
|
||||
self.currentVoiceId ??
|
||||
self.defaultVoiceId
|
||||
let voicePreset = preferredVoice
|
||||
let provider = self.activeTalkProvider
|
||||
|
||||
let language = ElevenLabsTTSClient.validatedLanguage(directive?.language)
|
||||
|
||||
let voiceId: String? = if provider == Self.defaultTalkProvider, let apiKey, !apiKey.isEmpty {
|
||||
let voiceId: String? = if let apiKey, !apiKey.isEmpty {
|
||||
await self.resolveVoiceId(preferred: preferredVoice, apiKey: apiKey)
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
|
||||
if provider == Self.defaultTalkProvider, apiKey?.isEmpty != false {
|
||||
if apiKey?.isEmpty != false {
|
||||
self.ttsLogger.warning("talk missing ELEVENLABS_API_KEY; falling back to system voice")
|
||||
} else if provider == Self.defaultTalkProvider, voiceId == nil {
|
||||
} else if voiceId == nil {
|
||||
self.ttsLogger.warning("talk missing voiceId; falling back to system voice")
|
||||
} else if let voiceId {
|
||||
self.ttsLogger
|
||||
@@ -613,21 +579,15 @@ actor TalkModeRuntime {
|
||||
|
||||
return TalkPlaybackInput(
|
||||
generation: gen,
|
||||
provider: provider,
|
||||
cleanedText: cleaned,
|
||||
directive: directive,
|
||||
apiKey: apiKey,
|
||||
voiceId: voiceId,
|
||||
voicePreset: voicePreset,
|
||||
language: language,
|
||||
synthTimeoutSeconds: synthTimeoutSeconds)
|
||||
}
|
||||
|
||||
private func playElevenLabs(
|
||||
input: TalkPlaybackInput,
|
||||
apiKey: String,
|
||||
voiceId: String) async throws
|
||||
{
|
||||
private func playElevenLabs(input: TalkPlaybackInput, apiKey: String, voiceId: String) async throws {
|
||||
let desiredOutputFormat = input.directive?.outputFormat ?? self.defaultOutputFormat ?? "pcm_44100"
|
||||
let outputFormat = ElevenLabsTTSClient.validatedOutputFormat(desiredOutputFormat)
|
||||
if outputFormat == nil, !desiredOutputFormat.isEmpty {
|
||||
@@ -736,39 +696,6 @@ actor TalkModeRuntime {
|
||||
self.ttsLogger.info("talk system voice done")
|
||||
}
|
||||
|
||||
private func playMLX(input: TalkPlaybackInput) async throws {
|
||||
self.ttsLogger.info("talk mlx start chars=\(input.cleanedText.count, privacy: .public)")
|
||||
if self.interruptOnSpeech {
|
||||
guard await self.prepareForPlayback(generation: input.generation) else { return }
|
||||
}
|
||||
await MainActor.run { TalkModeController.shared.updatePhase(.speaking) }
|
||||
self.phase = .speaking
|
||||
let modelRepo = input.directive?.modelId ?? self.currentModelId
|
||||
let audioData: Data
|
||||
do {
|
||||
audioData = try await AsyncTimeout.withTimeout(
|
||||
seconds: input.synthTimeoutSeconds,
|
||||
onTimeout: {
|
||||
TalkMLXSpeechSynthesizer.SynthesizeError.timedOut
|
||||
},
|
||||
operation: { [self] in
|
||||
try await self.synthesizeMLXVoice(
|
||||
text: input.cleanedText,
|
||||
modelRepo: modelRepo,
|
||||
language: input.language,
|
||||
voicePreset: input.voicePreset)
|
||||
})
|
||||
} catch TalkMLXSpeechSynthesizer.SynthesizeError.timedOut {
|
||||
self.stopMLXVoice()
|
||||
throw TalkMLXSpeechSynthesizer.SynthesizeError.timedOut
|
||||
}
|
||||
let result = await self.playTalkAudio(data: audioData)
|
||||
if !result.finished, result.interruptedAt == nil {
|
||||
throw TalkMLXSpeechSynthesizer.SynthesizeError.audioPlaybackFailed
|
||||
}
|
||||
self.ttsLogger.info("talk mlx done")
|
||||
}
|
||||
|
||||
private func prepareForPlayback(generation: Int) async -> Bool {
|
||||
await self.startRecognition()
|
||||
return self.isCurrent(generation)
|
||||
@@ -823,13 +750,10 @@ actor TalkModeRuntime {
|
||||
|
||||
func stopSpeaking(reason: TalkStopReason) async {
|
||||
let usePCM = self.lastPlaybackWasPCM
|
||||
let remoteInterruptedAt = usePCM ? await self.stopPCM() : await self.stopMP3()
|
||||
let interruptedAt = usePCM ? await self.stopPCM() : await self.stopMP3()
|
||||
_ = usePCM ? await self.stopMP3() : await self.stopPCM()
|
||||
let localInterruptedAt = await self.stopTalkAudio()
|
||||
await TalkSystemSpeechSynthesizer.shared.stop()
|
||||
self.stopMLXVoice()
|
||||
guard self.phase == .speaking else { return }
|
||||
let interruptedAt = remoteInterruptedAt ?? localInterruptedAt
|
||||
if reason == .speech, let interruptedAt {
|
||||
self.lastInterruptedAtSeconds = interruptedAt
|
||||
}
|
||||
@@ -871,33 +795,6 @@ extension TalkModeRuntime {
|
||||
StreamingAudioPlayer.shared.stop()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func playTalkAudio(data: Data) async -> TalkPlaybackResult {
|
||||
await TalkAudioPlayer.shared.play(data: data)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func stopTalkAudio() -> Double? {
|
||||
TalkAudioPlayer.shared.stop()
|
||||
}
|
||||
|
||||
private func synthesizeMLXVoice(
|
||||
text: String,
|
||||
modelRepo: String?,
|
||||
language: String?,
|
||||
voicePreset: String?) async throws -> Data
|
||||
{
|
||||
try await TalkMLXSpeechSynthesizer.shared.synthesize(
|
||||
text: text,
|
||||
modelRepo: modelRepo,
|
||||
language: language,
|
||||
voicePreset: voicePreset)
|
||||
}
|
||||
|
||||
private func stopMLXVoice() {
|
||||
TalkMLXSpeechSynthesizer.shared.stop()
|
||||
}
|
||||
|
||||
// MARK: - Config
|
||||
|
||||
private func reloadConfig() async {
|
||||
@@ -913,7 +810,6 @@ extension TalkModeRuntime {
|
||||
}
|
||||
self.defaultOutputFormat = cfg.outputFormat
|
||||
self.interruptOnSpeech = cfg.interruptOnSpeech
|
||||
self.activeTalkProvider = cfg.activeProvider
|
||||
self.silenceWindow = TimeInterval(cfg.silenceTimeoutMs) / 1000
|
||||
self.apiKey = cfg.apiKey
|
||||
let hasApiKey = (cfg.apiKey?.isEmpty == false)
|
||||
@@ -921,8 +817,7 @@ extension TalkModeRuntime {
|
||||
let modelLabel = (cfg.modelId?.isEmpty == false) ? cfg.modelId! : "none"
|
||||
self.logger
|
||||
.info(
|
||||
"talk config provider=\(cfg.activeProvider, privacy: .public) " +
|
||||
"talk config voiceId=\(voiceLabel, privacy: .public) " +
|
||||
"talk config voiceId=\(voiceLabel, privacy: .public) " +
|
||||
"modelId=\(modelLabel, privacy: .public) " +
|
||||
"apiKey=\(hasApiKey, privacy: .public) " +
|
||||
"interrupt=\(cfg.interruptOnSpeech, privacy: .public) " +
|
||||
@@ -964,17 +859,11 @@ extension TalkModeRuntime {
|
||||
await MainActor.run {
|
||||
AppStateStore.shared.seamColorHex = parsed.seamColorHex
|
||||
}
|
||||
if parsed.activeProvider == Self.defaultTalkProvider {
|
||||
self.ttsLogger.info("talk config provider from talk.resolved")
|
||||
} else if parsed.activeProvider == Self.mlxTalkProvider ||
|
||||
parsed.activeProvider == Self.systemTalkProvider
|
||||
{
|
||||
self.ttsLogger.info(
|
||||
"talk provider \(parsed.activeProvider, privacy: .public) active")
|
||||
} else {
|
||||
if parsed.activeProvider != Self.defaultTalkProvider {
|
||||
self.ttsLogger
|
||||
.info(
|
||||
"talk provider \(parsed.activeProvider, privacy: .public) unsupported; using system voice")
|
||||
.info("talk provider \(parsed.activeProvider, privacy: .public) unsupported; using system voice")
|
||||
} else if parsed.normalizedPayload {
|
||||
self.ttsLogger.info("talk config provider from talk.resolved")
|
||||
}
|
||||
return parsed
|
||||
} catch {
|
||||
|
||||
@@ -2837,7 +2837,6 @@ public struct ModelChoice: Codable, Sendable {
|
||||
public let id: String
|
||||
public let name: String
|
||||
public let provider: String
|
||||
public let alias: String?
|
||||
public let contextwindow: Int?
|
||||
public let reasoning: Bool?
|
||||
|
||||
@@ -2845,14 +2844,12 @@ public struct ModelChoice: Codable, Sendable {
|
||||
id: String,
|
||||
name: String,
|
||||
provider: String,
|
||||
alias: String?,
|
||||
contextwindow: Int?,
|
||||
reasoning: Bool?)
|
||||
{
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.provider = provider
|
||||
self.alias = alias
|
||||
self.contextwindow = contextwindow
|
||||
self.reasoning = reasoning
|
||||
}
|
||||
@@ -2861,7 +2858,6 @@ public struct ModelChoice: Codable, Sendable {
|
||||
case id
|
||||
case name
|
||||
case provider
|
||||
case alias
|
||||
case contextwindow = "contextWindow"
|
||||
case reasoning
|
||||
}
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import OpenClawProtocol
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
struct TalkModeGatewayConfigTests {
|
||||
@Test func `mlx provider does not inherit elevenlabs defaults`() {
|
||||
let snapshot = ConfigSnapshot(
|
||||
path: nil,
|
||||
exists: true,
|
||||
raw: nil,
|
||||
hash: nil,
|
||||
parsed: nil,
|
||||
valid: true,
|
||||
config: [
|
||||
"talk": AnyCodable([
|
||||
"provider": "mlx",
|
||||
"providers": [
|
||||
"mlx": [
|
||||
"voiceId": "unused-voice",
|
||||
],
|
||||
],
|
||||
"resolved": [
|
||||
"provider": "mlx",
|
||||
"config": [
|
||||
"voiceId": "unused-voice",
|
||||
],
|
||||
],
|
||||
]),
|
||||
],
|
||||
issues: nil
|
||||
)
|
||||
|
||||
let parsed = TalkModeGatewayConfigParser.parse(
|
||||
snapshot: snapshot,
|
||||
defaultProvider: "elevenlabs",
|
||||
defaultModelIdFallback: "eleven_v3",
|
||||
defaultSilenceTimeoutMs: TalkDefaults.silenceTimeoutMs,
|
||||
envVoice: "env-voice",
|
||||
sagVoice: "sag-voice",
|
||||
envApiKey: "env-key"
|
||||
)
|
||||
|
||||
#expect(parsed.activeProvider == "mlx")
|
||||
#expect(parsed.modelId == nil)
|
||||
#expect(parsed.apiKey == nil)
|
||||
#expect(parsed.voiceId == "unused-voice")
|
||||
}
|
||||
}
|
||||
@@ -13,34 +13,11 @@ struct TalkModeRuntimeSpeechTests {
|
||||
}
|
||||
|
||||
@Test func `playback plan falls back only from elevenlabs`() {
|
||||
let elevenLabsPlan = TalkModeRuntime.playbackPlan(
|
||||
provider: "elevenlabs",
|
||||
apiKey: "key",
|
||||
voiceId: "voice"
|
||||
)
|
||||
let missingKeyPlan = TalkModeRuntime.playbackPlan(
|
||||
provider: "elevenlabs",
|
||||
apiKey: nil,
|
||||
voiceId: "voice"
|
||||
)
|
||||
let missingVoicePlan = TalkModeRuntime.playbackPlan(
|
||||
provider: "elevenlabs",
|
||||
apiKey: "key",
|
||||
voiceId: nil
|
||||
)
|
||||
let blankKeyPlan = TalkModeRuntime.playbackPlan(
|
||||
provider: "elevenlabs",
|
||||
apiKey: "",
|
||||
voiceId: "voice"
|
||||
)
|
||||
let mlxPlan = TalkModeRuntime.playbackPlan(provider: "mlx", apiKey: nil, voiceId: nil)
|
||||
let systemPlan = TalkModeRuntime.playbackPlan(provider: "system", apiKey: nil, voiceId: nil)
|
||||
|
||||
#expect(elevenLabsPlan == .elevenLabsThenSystemVoice(apiKey: "key", voiceId: "voice"))
|
||||
#expect(missingKeyPlan == .systemVoiceOnly)
|
||||
#expect(missingVoicePlan == .systemVoiceOnly)
|
||||
#expect(blankKeyPlan == .systemVoiceOnly)
|
||||
#expect(mlxPlan == .mlxThenSystemVoice)
|
||||
#expect(systemPlan == .systemVoiceOnly)
|
||||
#expect(
|
||||
TalkModeRuntime.playbackPlan(apiKey: "key", voiceId: "voice")
|
||||
== .elevenLabsThenSystemVoice(apiKey: "key", voiceId: "voice"))
|
||||
#expect(TalkModeRuntime.playbackPlan(apiKey: nil, voiceId: "voice") == .systemVoiceOnly)
|
||||
#expect(TalkModeRuntime.playbackPlan(apiKey: "key", voiceId: nil) == .systemVoiceOnly)
|
||||
#expect(TalkModeRuntime.playbackPlan(apiKey: "", voiceId: "voice") == .systemVoiceOnly)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2837,7 +2837,6 @@ public struct ModelChoice: Codable, Sendable {
|
||||
public let id: String
|
||||
public let name: String
|
||||
public let provider: String
|
||||
public let alias: String?
|
||||
public let contextwindow: Int?
|
||||
public let reasoning: Bool?
|
||||
|
||||
@@ -2845,14 +2844,12 @@ public struct ModelChoice: Codable, Sendable {
|
||||
id: String,
|
||||
name: String,
|
||||
provider: String,
|
||||
alias: String?,
|
||||
contextwindow: Int?,
|
||||
reasoning: Bool?)
|
||||
{
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.provider = provider
|
||||
self.alias = alias
|
||||
self.contextwindow = contextwindow
|
||||
self.reasoning = reasoning
|
||||
}
|
||||
@@ -2861,7 +2858,6 @@ public struct ModelChoice: Codable, Sendable {
|
||||
case id
|
||||
case name
|
||||
case provider
|
||||
case alias
|
||||
case contextwindow = "contextWindow"
|
||||
case reasoning
|
||||
}
|
||||
|
||||
@@ -180,7 +180,7 @@ The lookup token accepts a task ID, run ID, or session key. Shows the full recor
|
||||
openclaw tasks cancel <lookup>
|
||||
```
|
||||
|
||||
For ACP and subagent tasks, this kills the child session. For CLI-tracked tasks, cancellation is recorded in the task registry (there is no separate child runtime handle). Status transitions to `cancelled` and a delivery notification is sent when applicable.
|
||||
For ACP and subagent tasks, this kills the child session. Status transitions to `cancelled` and a delivery notification is sent.
|
||||
|
||||
### `tasks notify`
|
||||
|
||||
|
||||
@@ -1,608 +0,0 @@
|
||||
---
|
||||
title: "Active Memory"
|
||||
summary: "A plugin-owned blocking memory sub-agent that injects relevant memory into interactive chat sessions"
|
||||
read_when:
|
||||
- You want to understand what active memory is for
|
||||
- You want to turn active memory on for a conversational agent
|
||||
- You want to tune active memory behavior without enabling it everywhere
|
||||
---
|
||||
|
||||
# Active Memory
|
||||
|
||||
Active memory is an optional plugin-owned blocking memory sub-agent that runs
|
||||
before the main reply for eligible conversational sessions.
|
||||
|
||||
It exists because most memory systems are capable but reactive. They rely on
|
||||
the main agent to decide when to search memory, or on the user to say things
|
||||
like "remember this" or "search memory." By then, the moment where memory would
|
||||
have made the reply feel natural has already passed.
|
||||
|
||||
Active memory gives the system one bounded chance to surface relevant memory
|
||||
before the main reply is generated.
|
||||
|
||||
## Paste This Into Your Agent
|
||||
|
||||
Paste this into your agent if you want it to enable Active Memory with a
|
||||
self-contained, safe-default setup:
|
||||
|
||||
```json5
|
||||
{
|
||||
plugins: {
|
||||
entries: {
|
||||
"active-memory": {
|
||||
enabled: true,
|
||||
config: {
|
||||
enabled: true,
|
||||
agents: ["main"],
|
||||
allowedChatTypes: ["direct"],
|
||||
modelFallbackPolicy: "default-remote",
|
||||
queryMode: "recent",
|
||||
promptStyle: "balanced",
|
||||
timeoutMs: 15000,
|
||||
maxSummaryChars: 220,
|
||||
persistTranscripts: false,
|
||||
logging: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
This turns the plugin on for the `main` agent, keeps it limited to direct-message
|
||||
style sessions by default, lets it inherit the current session model first, and
|
||||
still allows the built-in remote fallback if no explicit or inherited model is
|
||||
available.
|
||||
|
||||
After that, restart the gateway:
|
||||
|
||||
```bash
|
||||
node scripts/run-node.mjs gateway --profile dev
|
||||
```
|
||||
|
||||
To inspect it live in a conversation:
|
||||
|
||||
```text
|
||||
/verbose on
|
||||
```
|
||||
|
||||
## Turn active memory on
|
||||
|
||||
The safest setup is:
|
||||
|
||||
1. enable the plugin
|
||||
2. target one conversational agent
|
||||
3. keep logging on only while tuning
|
||||
|
||||
Start with this in `openclaw.json`:
|
||||
|
||||
```json5
|
||||
{
|
||||
plugins: {
|
||||
entries: {
|
||||
"active-memory": {
|
||||
enabled: true,
|
||||
config: {
|
||||
agents: ["main"],
|
||||
allowedChatTypes: ["direct"],
|
||||
modelFallbackPolicy: "default-remote",
|
||||
queryMode: "recent",
|
||||
promptStyle: "balanced",
|
||||
timeoutMs: 15000,
|
||||
maxSummaryChars: 220,
|
||||
persistTranscripts: false,
|
||||
logging: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Then restart the gateway:
|
||||
|
||||
```bash
|
||||
node scripts/run-node.mjs gateway --profile dev
|
||||
```
|
||||
|
||||
What this means:
|
||||
|
||||
- `plugins.entries.active-memory.enabled: true` turns the plugin on
|
||||
- `config.agents: ["main"]` opts only the `main` agent into active memory
|
||||
- `config.allowedChatTypes: ["direct"]` keeps active memory on for direct-message style sessions only by default
|
||||
- if `config.model` is unset, active memory inherits the current session model first
|
||||
- `config.modelFallbackPolicy: "default-remote"` keeps the built-in remote fallback as the default when no explicit or inherited model is available
|
||||
- `config.promptStyle: "balanced"` uses the default general-purpose prompt style for `recent` mode
|
||||
- active memory still runs only on eligible interactive persistent chat sessions
|
||||
|
||||
## How to see it
|
||||
|
||||
Active memory injects hidden system context for the model. It does not expose
|
||||
raw `<active_memory_plugin>...</active_memory_plugin>` tags to the client.
|
||||
|
||||
## Session toggle
|
||||
|
||||
Use the plugin command when you want to pause or resume active memory for the
|
||||
current chat session without editing config:
|
||||
|
||||
```text
|
||||
/active-memory status
|
||||
/active-memory off
|
||||
/active-memory on
|
||||
```
|
||||
|
||||
This is session-scoped. It does not change
|
||||
`plugins.entries.active-memory.enabled`, agent targeting, or other global
|
||||
configuration.
|
||||
|
||||
If you want the command to write config and pause or resume active memory for
|
||||
all sessions, use the explicit global form:
|
||||
|
||||
```text
|
||||
/active-memory status --global
|
||||
/active-memory off --global
|
||||
/active-memory on --global
|
||||
```
|
||||
|
||||
The global form writes `plugins.entries.active-memory.config.enabled`. It leaves
|
||||
`plugins.entries.active-memory.enabled` on so the command remains available to
|
||||
turn active memory back on later.
|
||||
|
||||
If you want to see what active memory is doing in a live session, turn verbose
|
||||
mode on for that session:
|
||||
|
||||
```text
|
||||
/verbose on
|
||||
```
|
||||
|
||||
With verbose enabled, OpenClaw can show:
|
||||
|
||||
- an active memory status line such as `Active Memory: ok 842ms recent 34 chars`
|
||||
- a readable debug summary such as `Active Memory Debug: Lemon pepper wings with blue cheese.`
|
||||
|
||||
Those lines are derived from the same active memory pass that feeds the hidden
|
||||
system context, but they are formatted for humans instead of exposing raw prompt
|
||||
markup.
|
||||
|
||||
By default, the blocking memory sub-agent transcript is temporary and deleted
|
||||
after the run completes.
|
||||
|
||||
Example flow:
|
||||
|
||||
```text
|
||||
/verbose on
|
||||
what wings should i order?
|
||||
```
|
||||
|
||||
Expected visible reply shape:
|
||||
|
||||
```text
|
||||
...normal assistant reply...
|
||||
|
||||
🧩 Active Memory: ok 842ms recent 34 chars
|
||||
🔎 Active Memory Debug: Lemon pepper wings with blue cheese.
|
||||
```
|
||||
|
||||
## When it runs
|
||||
|
||||
Active memory uses two gates:
|
||||
|
||||
1. **Config opt-in**
|
||||
The plugin must be enabled, and the current agent id must appear in
|
||||
`plugins.entries.active-memory.config.agents`.
|
||||
2. **Strict runtime eligibility**
|
||||
Even when enabled and targeted, active memory only runs for eligible
|
||||
interactive persistent chat sessions.
|
||||
|
||||
The actual rule is:
|
||||
|
||||
```text
|
||||
plugin enabled
|
||||
+
|
||||
agent id targeted
|
||||
+
|
||||
allowed chat type
|
||||
+
|
||||
eligible interactive persistent chat session
|
||||
=
|
||||
active memory runs
|
||||
```
|
||||
|
||||
If any of those fail, active memory does not run.
|
||||
|
||||
## Session types
|
||||
|
||||
`config.allowedChatTypes` controls which kinds of conversations may run Active
|
||||
Memory at all.
|
||||
|
||||
The default is:
|
||||
|
||||
```json5
|
||||
allowedChatTypes: ["direct"]
|
||||
```
|
||||
|
||||
That means Active Memory runs by default in direct-message style sessions, but
|
||||
not in group or channel sessions unless you opt them in explicitly.
|
||||
|
||||
Examples:
|
||||
|
||||
```json5
|
||||
allowedChatTypes: ["direct"]
|
||||
```
|
||||
|
||||
```json5
|
||||
allowedChatTypes: ["direct", "group"]
|
||||
```
|
||||
|
||||
```json5
|
||||
allowedChatTypes: ["direct", "group", "channel"]
|
||||
```
|
||||
|
||||
## Where it runs
|
||||
|
||||
Active memory is a conversational enrichment feature, not a platform-wide
|
||||
inference feature.
|
||||
|
||||
| Surface | Runs active memory? |
|
||||
| ------------------------------------------------------------------- | ------------------------------------------------------- |
|
||||
| Control UI / web chat persistent sessions | Yes, if the plugin is enabled and the agent is targeted |
|
||||
| Other interactive channel sessions on the same persistent chat path | Yes, if the plugin is enabled and the agent is targeted |
|
||||
| Headless one-shot runs | No |
|
||||
| Heartbeat/background runs | No |
|
||||
| Generic internal `agent-command` paths | No |
|
||||
| Sub-agent/internal helper execution | No |
|
||||
|
||||
## Why use it
|
||||
|
||||
Use active memory when:
|
||||
|
||||
- the session is persistent and user-facing
|
||||
- the agent has meaningful long-term memory to search
|
||||
- continuity and personalization matter more than raw prompt determinism
|
||||
|
||||
It works especially well for:
|
||||
|
||||
- stable preferences
|
||||
- recurring habits
|
||||
- long-term user context that should surface naturally
|
||||
|
||||
It is a poor fit for:
|
||||
|
||||
- automation
|
||||
- internal workers
|
||||
- one-shot API tasks
|
||||
- places where hidden personalization would be surprising
|
||||
|
||||
## How it works
|
||||
|
||||
The runtime shape is:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
U["User Message"] --> Q["Build Memory Query"]
|
||||
Q --> R["Active Memory Blocking Memory Sub-Agent"]
|
||||
R -->|NONE or empty| M["Main Reply"]
|
||||
R -->|relevant summary| I["Append Hidden active_memory_plugin System Context"]
|
||||
I --> M["Main Reply"]
|
||||
```
|
||||
|
||||
The blocking memory sub-agent can use only:
|
||||
|
||||
- `memory_search`
|
||||
- `memory_get`
|
||||
|
||||
If the connection is weak, it should return `NONE`.
|
||||
|
||||
## Query modes
|
||||
|
||||
`config.queryMode` controls how much conversation the blocking memory sub-agent sees.
|
||||
|
||||
## Prompt styles
|
||||
|
||||
`config.promptStyle` controls how eager or strict the blocking memory sub-agent is
|
||||
when deciding whether to return memory.
|
||||
|
||||
Available styles:
|
||||
|
||||
- `balanced`: general-purpose default for `recent` mode
|
||||
- `strict`: least eager; best when you want very little bleed from nearby context
|
||||
- `contextual`: most continuity-friendly; best when conversation history should matter more
|
||||
- `recall-heavy`: more willing to surface memory on softer but still plausible matches
|
||||
- `precision-heavy`: aggressively prefers `NONE` unless the match is obvious
|
||||
- `preference-only`: optimized for favorites, habits, routines, taste, and recurring personal facts
|
||||
|
||||
Default mapping when `config.promptStyle` is unset:
|
||||
|
||||
```text
|
||||
message -> strict
|
||||
recent -> balanced
|
||||
full -> contextual
|
||||
```
|
||||
|
||||
If you set `config.promptStyle` explicitly, that override wins.
|
||||
|
||||
Example:
|
||||
|
||||
```json5
|
||||
promptStyle: "preference-only"
|
||||
```
|
||||
|
||||
## Model fallback policy
|
||||
|
||||
If `config.model` is unset, Active Memory tries to resolve a model in this order:
|
||||
|
||||
```text
|
||||
explicit plugin model
|
||||
-> current session model
|
||||
-> agent primary model
|
||||
-> optional built-in remote fallback
|
||||
```
|
||||
|
||||
`config.modelFallbackPolicy` controls the last step.
|
||||
|
||||
Default:
|
||||
|
||||
```json5
|
||||
modelFallbackPolicy: "default-remote"
|
||||
```
|
||||
|
||||
Other option:
|
||||
|
||||
```json5
|
||||
modelFallbackPolicy: "resolved-only"
|
||||
```
|
||||
|
||||
Use `resolved-only` if you want Active Memory to skip recall instead of falling
|
||||
back to the built-in remote default when no explicit or inherited model is
|
||||
available.
|
||||
|
||||
## Advanced escape hatches
|
||||
|
||||
These options are intentionally not part of the recommended setup.
|
||||
|
||||
`config.thinking` can override the blocking memory sub-agent thinking level:
|
||||
|
||||
```json5
|
||||
thinking: "medium"
|
||||
```
|
||||
|
||||
Default:
|
||||
|
||||
```json5
|
||||
thinking: "off"
|
||||
```
|
||||
|
||||
Do not enable this by default. Active Memory runs in the reply path, so extra
|
||||
thinking time directly increases user-visible latency.
|
||||
|
||||
`config.promptAppend` adds extra operator instructions after the default Active
|
||||
Memory prompt and before the conversation context:
|
||||
|
||||
```json5
|
||||
promptAppend: "Prefer stable long-term preferences over one-off events."
|
||||
```
|
||||
|
||||
`config.promptOverride` replaces the default Active Memory prompt. OpenClaw
|
||||
still appends the conversation context afterward:
|
||||
|
||||
```json5
|
||||
promptOverride: "You are a memory search agent. Return NONE or one compact user fact."
|
||||
```
|
||||
|
||||
Prompt customization is not recommended unless you are deliberately testing a
|
||||
different recall contract. The default prompt is tuned to return either `NONE`
|
||||
or compact user-fact context for the main model.
|
||||
|
||||
### `message`
|
||||
|
||||
Only the latest user message is sent.
|
||||
|
||||
```text
|
||||
Latest user message only
|
||||
```
|
||||
|
||||
Use this when:
|
||||
|
||||
- you want the fastest behavior
|
||||
- you want the strongest bias toward stable preference recall
|
||||
- follow-up turns do not need conversational context
|
||||
|
||||
Recommended timeout:
|
||||
|
||||
- start around `3000` to `5000` ms
|
||||
|
||||
### `recent`
|
||||
|
||||
The latest user message plus a small recent conversational tail is sent.
|
||||
|
||||
```text
|
||||
Recent conversation tail:
|
||||
user: ...
|
||||
assistant: ...
|
||||
user: ...
|
||||
|
||||
Latest user message:
|
||||
...
|
||||
```
|
||||
|
||||
Use this when:
|
||||
|
||||
- you want a better balance of speed and conversational grounding
|
||||
- follow-up questions often depend on the last few turns
|
||||
|
||||
Recommended timeout:
|
||||
|
||||
- start around `15000` ms
|
||||
|
||||
### `full`
|
||||
|
||||
The full conversation is sent to the blocking memory sub-agent.
|
||||
|
||||
```text
|
||||
Full conversation context:
|
||||
user: ...
|
||||
assistant: ...
|
||||
user: ...
|
||||
...
|
||||
```
|
||||
|
||||
Use this when:
|
||||
|
||||
- the strongest recall quality matters more than latency
|
||||
- the conversation contains important setup far back in the thread
|
||||
|
||||
Recommended timeout:
|
||||
|
||||
- increase it substantially compared with `message` or `recent`
|
||||
- start around `15000` ms or higher depending on thread size
|
||||
|
||||
In general, timeout should increase with context size:
|
||||
|
||||
```text
|
||||
message < recent < full
|
||||
```
|
||||
|
||||
## Transcript persistence
|
||||
|
||||
Active memory blocking memory sub-agent runs create a real `session.jsonl`
|
||||
transcript during the blocking memory sub-agent call.
|
||||
|
||||
By default, that transcript is temporary:
|
||||
|
||||
- it is written to a temp directory
|
||||
- it is used only for the blocking memory sub-agent run
|
||||
- it is deleted immediately after the run finishes
|
||||
|
||||
If you want to keep those blocking memory sub-agent transcripts on disk for debugging or
|
||||
inspection, turn persistence on explicitly:
|
||||
|
||||
```json5
|
||||
{
|
||||
plugins: {
|
||||
entries: {
|
||||
"active-memory": {
|
||||
enabled: true,
|
||||
config: {
|
||||
agents: ["main"],
|
||||
persistTranscripts: true,
|
||||
transcriptDir: "active-memory",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
When enabled, active memory stores transcripts in a separate directory under the
|
||||
target agent's sessions folder, not in the main user conversation transcript
|
||||
path.
|
||||
|
||||
The default layout is conceptually:
|
||||
|
||||
```text
|
||||
agents/<agent>/sessions/active-memory/<blocking-memory-sub-agent-session-id>.jsonl
|
||||
```
|
||||
|
||||
You can change the relative subdirectory with `config.transcriptDir`.
|
||||
|
||||
Use this carefully:
|
||||
|
||||
- blocking memory sub-agent transcripts can accumulate quickly on busy sessions
|
||||
- `full` query mode can duplicate a lot of conversation context
|
||||
- these transcripts contain hidden prompt context and recalled memories
|
||||
|
||||
## Configuration
|
||||
|
||||
All active memory configuration lives under:
|
||||
|
||||
```text
|
||||
plugins.entries.active-memory
|
||||
```
|
||||
|
||||
The most important fields are:
|
||||
|
||||
| Key | Type | Meaning |
|
||||
| --------------------------- | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
|
||||
| `enabled` | `boolean` | Enables the plugin itself |
|
||||
| `config.agents` | `string[]` | Agent ids that may use active memory |
|
||||
| `config.model` | `string` | Optional blocking memory sub-agent model ref; when unset, active memory uses the current session model |
|
||||
| `config.queryMode` | `"message" \| "recent" \| "full"` | Controls how much conversation the blocking memory sub-agent sees |
|
||||
| `config.promptStyle` | `"balanced" \| "strict" \| "contextual" \| "recall-heavy" \| "precision-heavy" \| "preference-only"` | Controls how eager or strict the blocking memory sub-agent is when deciding whether to return memory |
|
||||
| `config.thinking` | `"off" \| "minimal" \| "low" \| "medium" \| "high" \| "xhigh" \| "adaptive"` | Advanced thinking override for the blocking memory sub-agent; default `off` for speed |
|
||||
| `config.promptOverride` | `string` | Advanced full prompt replacement; not recommended for normal use |
|
||||
| `config.promptAppend` | `string` | Advanced extra instructions appended to the default or overridden prompt |
|
||||
| `config.timeoutMs` | `number` | Hard timeout for the blocking memory sub-agent |
|
||||
| `config.maxSummaryChars` | `number` | Maximum total characters allowed in the active-memory summary |
|
||||
| `config.logging` | `boolean` | Emits active memory logs while tuning |
|
||||
| `config.persistTranscripts` | `boolean` | Keeps blocking memory sub-agent transcripts on disk instead of deleting temp files |
|
||||
| `config.transcriptDir` | `string` | Relative blocking memory sub-agent transcript directory under the agent sessions folder |
|
||||
|
||||
Useful tuning fields:
|
||||
|
||||
| Key | Type | Meaning |
|
||||
| ----------------------------- | -------- | ------------------------------------------------------------- |
|
||||
| `config.maxSummaryChars` | `number` | Maximum total characters allowed in the active-memory summary |
|
||||
| `config.recentUserTurns` | `number` | Prior user turns to include when `queryMode` is `recent` |
|
||||
| `config.recentAssistantTurns` | `number` | Prior assistant turns to include when `queryMode` is `recent` |
|
||||
| `config.recentUserChars` | `number` | Max chars per recent user turn |
|
||||
| `config.recentAssistantChars` | `number` | Max chars per recent assistant turn |
|
||||
| `config.cacheTtlMs` | `number` | Cache reuse for repeated identical queries |
|
||||
|
||||
## Recommended setup
|
||||
|
||||
Start with `recent`.
|
||||
|
||||
```json5
|
||||
{
|
||||
plugins: {
|
||||
entries: {
|
||||
"active-memory": {
|
||||
enabled: true,
|
||||
config: {
|
||||
agents: ["main"],
|
||||
queryMode: "recent",
|
||||
promptStyle: "balanced",
|
||||
timeoutMs: 15000,
|
||||
maxSummaryChars: 220,
|
||||
logging: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
If you want to inspect live behavior while tuning, use `/verbose on` in the
|
||||
session instead of looking for a separate active-memory debug command.
|
||||
|
||||
Then move to:
|
||||
|
||||
- `message` if you want lower latency
|
||||
- `full` if you decide extra context is worth the slower blocking memory sub-agent
|
||||
|
||||
## Debugging
|
||||
|
||||
If active memory is not showing up where you expect:
|
||||
|
||||
1. Confirm the plugin is enabled under `plugins.entries.active-memory.enabled`.
|
||||
2. Confirm the current agent id is listed in `config.agents`.
|
||||
3. Confirm you are testing through an interactive persistent chat session.
|
||||
4. Turn on `config.logging: true` and watch the gateway logs.
|
||||
5. Verify memory search itself works with `openclaw memory status --deep`.
|
||||
|
||||
If memory hits are noisy, tighten:
|
||||
|
||||
- `maxSummaryChars`
|
||||
|
||||
If active memory is too slow:
|
||||
|
||||
- lower `queryMode`
|
||||
- lower `timeoutMs`
|
||||
- reduce recent turn counts
|
||||
- reduce per-turn char caps
|
||||
|
||||
## Related pages
|
||||
|
||||
- [Memory Search](/concepts/memory-search)
|
||||
- [Memory configuration reference](/reference/memory-config)
|
||||
- [Plugin SDK setup](/plugins/sdk-setup)
|
||||
@@ -138,6 +138,5 @@ earlier conversations. This is opt-in via
|
||||
|
||||
## Further reading
|
||||
|
||||
- [Active Memory](/concepts/active-memory) -- sub-agent memory for interactive chat sessions
|
||||
- [Memory](/concepts/memory) -- file layout, backends, tools
|
||||
- [Memory configuration reference](/reference/memory-config) -- all config knobs
|
||||
|
||||
@@ -52,21 +52,6 @@ pnpm qa:lab:watch
|
||||
rebuilds that bundle on change, and the browser auto-reloads when the QA Lab
|
||||
asset hash changes.
|
||||
|
||||
For a disposable Linux VM lane without bringing Docker into the QA path, run:
|
||||
|
||||
```bash
|
||||
pnpm openclaw qa suite --runner multipass --scenario channel-chat-baseline
|
||||
```
|
||||
|
||||
This boots a fresh Multipass guest, installs dependencies, builds OpenClaw
|
||||
inside the guest, runs `qa suite`, then copies the normal QA report and
|
||||
summary back into `.artifacts/qa-e2e/...` on the host.
|
||||
It reuses the same scenario-selection behavior as `qa suite` on the host.
|
||||
Live runs forward the supported QA auth inputs that are practical for the
|
||||
guest: env-based provider keys, the QA live provider config path, and
|
||||
`CODEX_HOME` when present. Keep `--output-dir` under the repo root so the guest
|
||||
can write back through the mounted workspace.
|
||||
|
||||
## Repo-backed seeds
|
||||
|
||||
Seed assets live in `qa/`:
|
||||
|
||||
@@ -2402,7 +2402,6 @@ OpenClaw uses the built-in model catalog. Add custom providers via `models.provi
|
||||
- `request.auth`: auth strategy override. Modes: `"provider-default"` (use provider's built-in auth), `"authorization-bearer"` (with `token`), `"header"` (with `headerName`, `value`, optional `prefix`).
|
||||
- `request.proxy`: HTTP proxy override. Modes: `"env-proxy"` (use `HTTP_PROXY`/`HTTPS_PROXY` env vars), `"explicit-proxy"` (with `url`). Both modes accept an optional `tls` sub-object.
|
||||
- `request.tls`: TLS override for direct connections. Fields: `ca`, `cert`, `key`, `passphrase` (all accept SecretRef), `serverName`, `insecureSkipVerify`.
|
||||
- `request.allowPrivateNetwork`: when `true`, allow HTTPS to `baseUrl` when DNS resolves to private, CGNAT, or similar ranges, via the provider HTTP fetch guard (operator opt-in for trusted self-hosted OpenAI-compatible endpoints). WebSocket uses the same `request` for headers/TLS but not that fetch SSRF gate. Default `false`.
|
||||
- `models.providers.*.models`: explicit provider model catalog entries.
|
||||
- `models.providers.*.models.*.contextWindow`: native model context window metadata.
|
||||
- `models.providers.*.models.*.contextTokens`: optional runtime context cap. Use this when you want a smaller effective context budget than the model's native `contextWindow`.
|
||||
@@ -2859,7 +2858,6 @@ See [Plugins](/tools/plugin).
|
||||
enabled: true,
|
||||
basePath: "/openclaw",
|
||||
// root: "dist/control-ui",
|
||||
// embedSandbox: "powerful", // powerful | isolated
|
||||
// allowedOrigins: ["https://control.example.com"], // required for non-loopback Control UI
|
||||
// dangerouslyAllowHostHeaderOriginFallback: false, // dangerous Host-header origin fallback mode
|
||||
// allowInsecureAuth: false,
|
||||
|
||||
@@ -26,9 +26,7 @@ Most days:
|
||||
- Faster local full-suite run on a roomy machine: `pnpm test:max`
|
||||
- Direct Vitest watch loop: `pnpm test:watch`
|
||||
- Direct file targeting now routes extension/channel paths too: `pnpm test extensions/discord/src/monitor/message-handler.preflight.test.ts`
|
||||
- Prefer targeted runs first when you are iterating on a single failure.
|
||||
- Docker-backed QA site: `pnpm qa:lab:up`
|
||||
- Linux VM-backed QA lane: `pnpm openclaw qa suite --runner multipass --scenario channel-chat-baseline`
|
||||
|
||||
When you touch tests or want extra confidence:
|
||||
|
||||
@@ -42,26 +40,6 @@ When debugging real providers/models (requires real creds):
|
||||
|
||||
Tip: when you only need one failing case, prefer narrowing live tests via the allowlist env vars described below.
|
||||
|
||||
## QA-specific runners
|
||||
|
||||
These commands sit beside the main test suites when you need QA-lab realism:
|
||||
|
||||
- `pnpm openclaw qa suite`
|
||||
- Runs repo-backed QA scenarios directly on the host.
|
||||
- `pnpm openclaw qa suite --runner multipass`
|
||||
- Runs the same QA suite inside a disposable Multipass Linux VM.
|
||||
- Keeps the same scenario-selection behavior as `qa suite` on the host.
|
||||
- Reuses the same provider/model selection flags as `qa suite`.
|
||||
- Live runs forward the supported QA auth inputs that are practical for the guest:
|
||||
env-based provider keys, the QA live provider config path, and `CODEX_HOME`
|
||||
when present.
|
||||
- Output dirs must stay under the repo root so the guest can write back through
|
||||
the mounted workspace.
|
||||
- Writes the normal QA report + summary plus Multipass logs under
|
||||
`.artifacts/qa-e2e/...`.
|
||||
- `pnpm qa:lab:up`
|
||||
- Starts the Docker-backed QA site for operator-style QA work.
|
||||
|
||||
## Test suites (what runs where)
|
||||
|
||||
Think of the suites as “increasing realism” (and increasing flakiness/cost):
|
||||
|
||||
@@ -17,22 +17,10 @@ conceptual overviews, see:
|
||||
- [Builtin Engine](/concepts/memory-builtin) -- default SQLite backend
|
||||
- [QMD Engine](/concepts/memory-qmd) -- local-first sidecar
|
||||
- [Memory Search](/concepts/memory-search) -- search pipeline and tuning
|
||||
- [Active Memory](/concepts/active-memory) -- enabling the memory sub-agent for interactive sessions
|
||||
|
||||
All memory search settings live under `agents.defaults.memorySearch` in
|
||||
`openclaw.json` unless noted otherwise.
|
||||
|
||||
If you are looking for the **active memory** feature toggle and sub-agent config,
|
||||
that lives under `plugins.entries.active-memory` instead of `memorySearch`.
|
||||
|
||||
Active memory uses a two-gate model:
|
||||
|
||||
1. the plugin must be enabled and target the current agent id
|
||||
2. the request must be an eligible interactive persistent chat session
|
||||
|
||||
See [Active Memory](/concepts/active-memory) for the activation model,
|
||||
plugin-owned config, transcript persistence, and safe rollout pattern.
|
||||
|
||||
---
|
||||
|
||||
## Provider selection
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
# Rich Output Protocol
|
||||
|
||||
Assistant output can carry a small set of delivery/render directives:
|
||||
|
||||
- `MEDIA:` for attachment delivery
|
||||
- `[[audio_as_voice]]` for audio presentation hints
|
||||
- `[[reply_to_current]]` / `[[reply_to:<id>]]` for reply metadata
|
||||
- `[canvas ...]` for Control UI rich rendering
|
||||
|
||||
These directives are separate. `MEDIA:` and reply/voice tags remain delivery metadata; `[canvas ...]` is the web-only rich render path.
|
||||
|
||||
## `[canvas ...]`
|
||||
|
||||
`[canvas ...]` is the only agent-facing rich render syntax for the Control UI.
|
||||
|
||||
Self-closing example:
|
||||
|
||||
```text
|
||||
[canvas ref="cv_123" title="Status" /]
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- `[view ...]` is no longer valid for new output.
|
||||
- Canvas shortcodes render in the assistant message surface only.
|
||||
- Only URL-backed canvases are rendered. Use `ref="..."` or `url="..."`.
|
||||
- Block-form inline HTML canvas shortcodes are not rendered.
|
||||
- The web UI strips the shortcode from visible text and renders the canvas inline.
|
||||
- `MEDIA:` is not a canvas alias and should not be used for rich canvas rendering.
|
||||
|
||||
## Stored Rendering Shape
|
||||
|
||||
The normalized/stored assistant content block is a structured `canvas` item:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "canvas",
|
||||
"preview": {
|
||||
"kind": "canvas",
|
||||
"surface": "assistant_message",
|
||||
"render": "url",
|
||||
"viewId": "cv_123",
|
||||
"url": "/__openclaw__/canvas/documents/cv_123/index.html",
|
||||
"title": "Status",
|
||||
"preferredHeight": 320
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Stored/rendered rich blocks use this `canvas` shape directly. `present_view` is not recognized.
|
||||
@@ -576,27 +576,6 @@ Notes:
|
||||
- If `gateway.auth.mode` is `none` or `trusted-proxy`, these loopback browser
|
||||
routes do not inherit those identity-bearing modes; keep them loopback-only.
|
||||
|
||||
### `/act` error contract
|
||||
|
||||
`POST /act` uses a structured error response for route-level validation and
|
||||
policy failures:
|
||||
|
||||
```json
|
||||
{ "error": "<message>", "code": "ACT_*" }
|
||||
```
|
||||
|
||||
Current `code` values:
|
||||
|
||||
- `ACT_KIND_REQUIRED` (HTTP 400): `kind` is missing or unrecognized.
|
||||
- `ACT_INVALID_REQUEST` (HTTP 400): action payload failed normalization or validation.
|
||||
- `ACT_SELECTOR_UNSUPPORTED` (HTTP 400): `selector` was used with an unsupported action kind.
|
||||
- `ACT_EVALUATE_DISABLED` (HTTP 403): `evaluate` (or `wait --fn`) is disabled by config.
|
||||
- `ACT_TARGET_ID_MISMATCH` (HTTP 403): top-level or batched `targetId` conflicts with request target.
|
||||
- `ACT_EXISTING_SESSION_UNSUPPORTED` (HTTP 501): action is not supported for existing-session profiles.
|
||||
|
||||
Other runtime failures may still return `{ "error": "<message>" }` without a
|
||||
`code` field.
|
||||
|
||||
### Playwright requirement
|
||||
|
||||
Some features (navigate/act/AI snapshot/role snapshot, element screenshots,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,120 +0,0 @@
|
||||
{
|
||||
"id": "active-memory",
|
||||
"name": "Active Memory",
|
||||
"description": "Runs a bounded blocking memory sub-agent before eligible conversational replies and injects relevant memory into prompt context.",
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": { "type": "boolean" },
|
||||
"agents": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"model": { "type": "string" },
|
||||
"modelFallbackPolicy": {
|
||||
"type": "string",
|
||||
"enum": ["default-remote", "resolved-only"]
|
||||
},
|
||||
"allowedChatTypes": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": ["direct", "group", "channel"]
|
||||
}
|
||||
},
|
||||
"thinking": {
|
||||
"type": "string",
|
||||
"enum": ["off", "minimal", "low", "medium", "high", "xhigh", "adaptive"]
|
||||
},
|
||||
"timeoutMs": { "type": "integer", "minimum": 250 },
|
||||
"queryMode": {
|
||||
"type": "string",
|
||||
"enum": ["message", "recent", "full"]
|
||||
},
|
||||
"promptStyle": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"balanced",
|
||||
"strict",
|
||||
"contextual",
|
||||
"recall-heavy",
|
||||
"precision-heavy",
|
||||
"preference-only"
|
||||
]
|
||||
},
|
||||
"promptOverride": { "type": "string" },
|
||||
"promptAppend": { "type": "string" },
|
||||
"maxSummaryChars": { "type": "integer", "minimum": 40, "maximum": 1000 },
|
||||
"recentUserTurns": { "type": "integer", "minimum": 0, "maximum": 4 },
|
||||
"recentAssistantTurns": { "type": "integer", "minimum": 0, "maximum": 3 },
|
||||
"recentUserChars": { "type": "integer", "minimum": 40, "maximum": 1000 },
|
||||
"recentAssistantChars": { "type": "integer", "minimum": 40, "maximum": 1000 },
|
||||
"logging": { "type": "boolean" },
|
||||
"persistTranscripts": { "type": "boolean" },
|
||||
"transcriptDir": { "type": "string" },
|
||||
"cacheTtlMs": { "type": "integer", "minimum": 1000, "maximum": 120000 }
|
||||
}
|
||||
},
|
||||
"uiHints": {
|
||||
"enabled": {
|
||||
"label": "Active Memory Recall",
|
||||
"help": "Globally enable or pause Active Memory recall while keeping the plugin command available."
|
||||
},
|
||||
"agents": {
|
||||
"label": "Target Agents",
|
||||
"help": "Explicit agent ids that may use active memory."
|
||||
},
|
||||
"model": {
|
||||
"label": "Memory Model",
|
||||
"help": "Provider/model used for the blocking memory sub-agent."
|
||||
},
|
||||
"modelFallbackPolicy": {
|
||||
"label": "Model Fallback Policy",
|
||||
"help": "Choose whether Active Memory falls back to the built-in remote default model when no explicit or inherited model is available."
|
||||
},
|
||||
"allowedChatTypes": {
|
||||
"label": "Allowed Chat Types",
|
||||
"help": "Choose which session types may run Active Memory. Defaults to direct-message style sessions only."
|
||||
},
|
||||
"timeoutMs": {
|
||||
"label": "Timeout (ms)"
|
||||
},
|
||||
"queryMode": {
|
||||
"label": "Query Mode",
|
||||
"help": "Choose whether the blocking memory sub-agent sees only the latest user message, a small recent tail, or the full conversation."
|
||||
},
|
||||
"promptStyle": {
|
||||
"label": "Prompt Style",
|
||||
"help": "Choose how eager or strict the blocking memory sub-agent should be when deciding whether to return memory."
|
||||
},
|
||||
"thinking": {
|
||||
"label": "Thinking Override",
|
||||
"help": "Advanced: optional thinking level for the blocking memory sub-agent. Defaults to off for speed."
|
||||
},
|
||||
"promptOverride": {
|
||||
"label": "Prompt Override",
|
||||
"help": "Advanced: replace the default Active Memory sub-agent instructions. Conversation context is still appended."
|
||||
},
|
||||
"promptAppend": {
|
||||
"label": "Prompt Append",
|
||||
"help": "Advanced: append extra operator instructions after the default Active Memory sub-agent instructions."
|
||||
},
|
||||
"maxSummaryChars": {
|
||||
"label": "Max Summary Characters",
|
||||
"help": "Maximum total characters allowed in the active-memory summary."
|
||||
},
|
||||
"logging": {
|
||||
"label": "Enable Logging",
|
||||
"help": "Emit active memory timing and result logs."
|
||||
},
|
||||
"persistTranscripts": {
|
||||
"label": "Persist Transcripts",
|
||||
"help": "Keep blocking memory sub-agent session transcripts on disk in a separate plugin-owned directory."
|
||||
},
|
||||
"transcriptDir": {
|
||||
"label": "Transcript Directory",
|
||||
"help": "Relative directory under the agent sessions folder used when transcript persistence is enabled."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ChannelAccountSnapshot } from "openclaw/plugin-sdk/channel-contract";
|
||||
import { collectIssuesForEnabledAccounts } from "openclaw/plugin-sdk/status-helpers";
|
||||
import { asRecord } from "./monitor-normalize.js";
|
||||
import type { ChannelAccountSnapshot } from "./runtime-api.js";
|
||||
|
||||
type BlueBubblesAccountStatus = {
|
||||
accountId?: unknown;
|
||||
|
||||
@@ -5,11 +5,13 @@ export {
|
||||
DEFAULT_OPENCLAW_BROWSER_COLOR,
|
||||
DEFAULT_OPENCLAW_BROWSER_ENABLED,
|
||||
DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME,
|
||||
DEFAULT_UPLOAD_DIR,
|
||||
parseBrowserHttpUrl,
|
||||
redactCdpUrl,
|
||||
resolveBrowserConfig,
|
||||
resolveBrowserControlAuth,
|
||||
resolveProfile,
|
||||
type BrowserControlAuth,
|
||||
type ResolvedBrowserConfig,
|
||||
type ResolvedBrowserProfile,
|
||||
} from "./browser-profiles.js";
|
||||
export { resolveBrowserControlAuth, type BrowserControlAuth } from "./browser-control-auth.js";
|
||||
export { parseBrowserHttpUrl, redactCdpUrl } from "./src/browser/config.js";
|
||||
} from "./src/browser/config.js";
|
||||
export { DEFAULT_UPLOAD_DIR } from "./src/browser/paths.js";
|
||||
|
||||
@@ -1,6 +1,2 @@
|
||||
export type { BrowserControlAuth } from "./src/browser/control-auth.js";
|
||||
export {
|
||||
ensureBrowserControlAuth,
|
||||
resolveBrowserControlAuth,
|
||||
shouldAutoGenerateBrowserAuth,
|
||||
} from "./src/browser/control-auth.js";
|
||||
export { ensureBrowserControlAuth, resolveBrowserControlAuth } from "./src/browser/control-auth.js";
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
export const ACT_MAX_BATCH_ACTIONS = 100;
|
||||
export const ACT_MAX_BATCH_DEPTH = 5;
|
||||
export const ACT_MAX_CLICK_DELAY_MS = 5_000;
|
||||
export const ACT_MAX_WAIT_TIME_MS = 30_000;
|
||||
|
||||
const ACT_MIN_TIMEOUT_MS = 500;
|
||||
const ACT_MAX_INTERACTION_TIMEOUT_MS = 60_000;
|
||||
const ACT_MAX_WAIT_TIMEOUT_MS = 120_000;
|
||||
const ACT_DEFAULT_INTERACTION_TIMEOUT_MS = 8_000;
|
||||
const ACT_DEFAULT_WAIT_TIMEOUT_MS = 20_000;
|
||||
|
||||
export function normalizeActBoundedNonNegativeMs(
|
||||
value: number | undefined,
|
||||
fieldName: string,
|
||||
maxMs: number,
|
||||
): number | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (!Number.isFinite(value) || value < 0) {
|
||||
throw new Error(`${fieldName} must be >= 0`);
|
||||
}
|
||||
const normalized = Math.floor(value);
|
||||
if (normalized > maxMs) {
|
||||
throw new Error(`${fieldName} exceeds maximum of ${maxMs}ms`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function resolveActInteractionTimeoutMs(timeoutMs?: number): number {
|
||||
const normalized =
|
||||
typeof timeoutMs === "number" && Number.isFinite(timeoutMs)
|
||||
? Math.floor(timeoutMs)
|
||||
: ACT_DEFAULT_INTERACTION_TIMEOUT_MS;
|
||||
return Math.max(ACT_MIN_TIMEOUT_MS, Math.min(ACT_MAX_INTERACTION_TIMEOUT_MS, normalized));
|
||||
}
|
||||
|
||||
export function resolveActWaitTimeoutMs(timeoutMs?: number): number {
|
||||
const normalized =
|
||||
typeof timeoutMs === "number" && Number.isFinite(timeoutMs)
|
||||
? Math.floor(timeoutMs)
|
||||
: ACT_DEFAULT_WAIT_TIMEOUT_MS;
|
||||
return Math.max(ACT_MIN_TIMEOUT_MS, Math.min(ACT_MAX_WAIT_TIMEOUT_MS, normalized));
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
||||
import { normalizeOptionalString, readStringValue } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
|
||||
import { asRecord } from "../record-shared.js";
|
||||
import type { ChromeMcpSnapshotNode } from "./chrome-mcp.snapshot.js";
|
||||
import type { BrowserTab } from "./client.js";
|
||||
@@ -332,7 +332,7 @@ async function callTool(
|
||||
}
|
||||
|
||||
async function withTempFile<T>(fn: (filePath: string) => Promise<T>): Promise<T> {
|
||||
const dir = await fs.mkdtemp(path.join(resolvePreferredOpenClawTmpDir(), "openclaw-chrome-mcp-"));
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-chrome-mcp-"));
|
||||
const filePath = path.join(dir, randomUUID());
|
||||
try {
|
||||
return await fn(filePath);
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
parseBrowserMajorVersion,
|
||||
resolveGoogleChromeExecutableForPlatform,
|
||||
} from "./chrome.executables.js";
|
||||
|
||||
describe("chrome executables", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("parses odd dotted browser version tokens using the last match", () => {
|
||||
expect(parseBrowserMajorVersion("Chromium 3.0/1.2.3")).toBe(1);
|
||||
});
|
||||
|
||||
it("returns null when no dotted version token exists", () => {
|
||||
expect(parseBrowserMajorVersion("no version here")).toBeNull();
|
||||
});
|
||||
|
||||
it("classifies beta Linux Google Chrome builds as canary", () => {
|
||||
vi.spyOn(fs, "existsSync").mockImplementation((candidate) => {
|
||||
return String(candidate) === "/usr/bin/google-chrome-beta";
|
||||
});
|
||||
|
||||
expect(resolveGoogleChromeExecutableForPlatform("linux")).toEqual({
|
||||
kind: "canary",
|
||||
path: "/usr/bin/google-chrome-beta",
|
||||
});
|
||||
});
|
||||
|
||||
it("classifies unstable Linux Google Chrome builds as canary", () => {
|
||||
vi.spyOn(fs, "existsSync").mockImplementation((candidate) => {
|
||||
return String(candidate) === "/usr/bin/google-chrome-unstable";
|
||||
});
|
||||
|
||||
expect(resolveGoogleChromeExecutableForPlatform("linux")).toEqual({
|
||||
kind: "canary",
|
||||
path: "/usr/bin/google-chrome-unstable",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -29,7 +29,6 @@ export {
|
||||
dragViaPlaywright,
|
||||
emulateMediaViaPlaywright,
|
||||
evaluateViaPlaywright,
|
||||
executeActViaPlaywright,
|
||||
fillFormViaPlaywright,
|
||||
getConsoleMessagesViaPlaywright,
|
||||
getNetworkRequestsViaPlaywright,
|
||||
|
||||
@@ -2,14 +2,6 @@ import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
||||
import type { Frame, Page } from "playwright-core";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import type { SsrFPolicy } from "../infra/net/ssrf.js";
|
||||
import {
|
||||
ACT_MAX_BATCH_ACTIONS,
|
||||
ACT_MAX_BATCH_DEPTH,
|
||||
ACT_MAX_CLICK_DELAY_MS,
|
||||
ACT_MAX_WAIT_TIME_MS,
|
||||
resolveActInteractionTimeoutMs,
|
||||
resolveActWaitTimeoutMs,
|
||||
} from "./act-policy.js";
|
||||
import type { BrowserActRequest, BrowserFormField } from "./client-actions-core.js";
|
||||
import { DEFAULT_FILL_FIELD_TYPE } from "./form-fields.js";
|
||||
import { DEFAULT_UPLOAD_DIR, resolveStrictExistingPathsWithinRoot } from "./paths.js";
|
||||
@@ -34,6 +26,9 @@ type TargetOpts = {
|
||||
targetId?: string;
|
||||
};
|
||||
|
||||
const MAX_CLICK_DELAY_MS = 5_000;
|
||||
const MAX_WAIT_TIME_MS = 30_000;
|
||||
const MAX_BATCH_ACTIONS = 100;
|
||||
const INTERACTION_NAVIGATION_GRACE_MS = 250;
|
||||
|
||||
type NavigationObservablePage = Pick<Page, "url"> & {
|
||||
@@ -62,7 +57,9 @@ async function getRestoredPageForTarget(opts: TargetOpts) {
|
||||
return page;
|
||||
}
|
||||
|
||||
const resolveInteractionTimeoutMs = resolveActInteractionTimeoutMs;
|
||||
function resolveInteractionTimeoutMs(timeoutMs?: number): number {
|
||||
return Math.max(500, Math.min(60_000, Math.floor(timeoutMs ?? 8000)));
|
||||
}
|
||||
|
||||
// Returns true only when the URL change indicates a cross-document navigation
|
||||
// (i.e., a real network fetch occurred). Same-document hash-only mutations —
|
||||
@@ -322,64 +319,22 @@ async function assertInteractionNavigationCompletedSafely<T>(opts: {
|
||||
return result as T;
|
||||
}
|
||||
|
||||
async function awaitActionWithAbort<T>(
|
||||
actionPromise: Promise<T>,
|
||||
async function awaitEvalWithAbort<T>(
|
||||
evalPromise: Promise<T>,
|
||||
abortPromise?: Promise<never>,
|
||||
): Promise<T> {
|
||||
if (!abortPromise) {
|
||||
return await actionPromise;
|
||||
return await evalPromise;
|
||||
}
|
||||
try {
|
||||
return await Promise.race([actionPromise, abortPromise]);
|
||||
return await Promise.race([evalPromise, abortPromise]);
|
||||
} catch (err) {
|
||||
// If abort wins the race, the action may reject later; avoid unhandled rejections.
|
||||
void actionPromise.catch(() => {});
|
||||
// If abort wins the race, evaluate may reject later; avoid unhandled rejections.
|
||||
void evalPromise.catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function createAbortPromise(signal?: AbortSignal): {
|
||||
abortPromise?: Promise<never>;
|
||||
cleanup: () => void;
|
||||
} {
|
||||
return createAbortPromiseWithListener(signal);
|
||||
}
|
||||
|
||||
function createAbortPromiseWithListener(
|
||||
signal?: AbortSignal,
|
||||
onAbort?: () => void,
|
||||
): {
|
||||
abortPromise?: Promise<never>;
|
||||
cleanup: () => void;
|
||||
} {
|
||||
if (!signal) {
|
||||
return { cleanup: () => {} };
|
||||
}
|
||||
let abortListener: (() => void) | undefined;
|
||||
const abortPromise: Promise<never> = signal.aborted
|
||||
? (() => {
|
||||
onAbort?.();
|
||||
return Promise.reject(signal.reason ?? new Error("aborted"));
|
||||
})()
|
||||
: new Promise((_, reject) => {
|
||||
abortListener = () => {
|
||||
onAbort?.();
|
||||
reject(signal.reason ?? new Error("aborted"));
|
||||
};
|
||||
signal.addEventListener("abort", abortListener, { once: true });
|
||||
});
|
||||
// Avoid unhandled rejections on early returns.
|
||||
void abortPromise.catch(() => {});
|
||||
return {
|
||||
abortPromise,
|
||||
cleanup: () => {
|
||||
if (abortListener) {
|
||||
signal.removeEventListener("abort", abortListener);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function assertPostInteractionNavigationSafe(opts: {
|
||||
cdpUrl: string;
|
||||
page: Awaited<ReturnType<typeof getPageForTargetId>>;
|
||||
@@ -435,11 +390,7 @@ export async function clickViaPlaywright(opts: {
|
||||
try {
|
||||
await assertInteractionNavigationCompletedSafely({
|
||||
action: async () => {
|
||||
const delayMs = resolveBoundedDelayMs(
|
||||
opts.delayMs,
|
||||
"click delayMs",
|
||||
ACT_MAX_CLICK_DELAY_MS,
|
||||
);
|
||||
const delayMs = resolveBoundedDelayMs(opts.delayMs, "click delayMs", MAX_CLICK_DELAY_MS);
|
||||
if (delayMs > 0) {
|
||||
await locator.hover({ timeout });
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||
@@ -678,15 +629,38 @@ export async function evaluateViaPlaywright(opts: {
|
||||
evaluateTimeout = Math.min(evaluateTimeout, outerTimeout);
|
||||
|
||||
const signal = opts.signal;
|
||||
const { abortPromise, cleanup } = createAbortPromiseWithListener(signal, () => {
|
||||
void forceDisconnectPlaywrightForTarget({
|
||||
cdpUrl: opts.cdpUrl,
|
||||
targetId: opts.targetId,
|
||||
reason: "evaluate aborted",
|
||||
}).catch(() => {});
|
||||
});
|
||||
if (signal?.aborted) {
|
||||
throw signal.reason ?? new Error("aborted");
|
||||
let abortListener: (() => void) | undefined;
|
||||
let abortReject: ((reason: unknown) => void) | undefined;
|
||||
let abortPromise: Promise<never> | undefined;
|
||||
if (signal) {
|
||||
abortPromise = new Promise((_, reject) => {
|
||||
abortReject = reject;
|
||||
});
|
||||
// Ensure the abort promise never becomes an unhandled rejection if we throw early.
|
||||
void abortPromise.catch(() => {});
|
||||
}
|
||||
if (signal) {
|
||||
const disconnect = () => {
|
||||
void forceDisconnectPlaywrightForTarget({
|
||||
cdpUrl: opts.cdpUrl,
|
||||
targetId: opts.targetId,
|
||||
reason: "evaluate aborted",
|
||||
}).catch(() => {});
|
||||
};
|
||||
if (signal.aborted) {
|
||||
disconnect();
|
||||
throw signal.reason ?? new Error("aborted");
|
||||
}
|
||||
abortListener = () => {
|
||||
disconnect();
|
||||
abortReject?.(signal.reason ?? new Error("aborted"));
|
||||
};
|
||||
signal.addEventListener("abort", abortListener, { once: true });
|
||||
// If the signal aborted between the initial check and listener registration, handle it.
|
||||
if (signal.aborted) {
|
||||
abortListener();
|
||||
throw signal.reason ?? new Error("aborted");
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -722,7 +696,7 @@ export async function evaluateViaPlaywright(opts: {
|
||||
timeoutMs: evaluateTimeout,
|
||||
});
|
||||
const result = await assertInteractionNavigationCompletedSafely({
|
||||
action: () => awaitActionWithAbort(evalPromise, abortPromise),
|
||||
action: () => awaitEvalWithAbort(evalPromise, abortPromise),
|
||||
cdpUrl: opts.cdpUrl,
|
||||
page,
|
||||
previousUrl,
|
||||
@@ -761,7 +735,7 @@ export async function evaluateViaPlaywright(opts: {
|
||||
timeoutMs: evaluateTimeout,
|
||||
});
|
||||
const result = await assertInteractionNavigationCompletedSafely({
|
||||
action: () => awaitActionWithAbort(evalPromise, abortPromise),
|
||||
action: () => awaitEvalWithAbort(evalPromise, abortPromise),
|
||||
cdpUrl: opts.cdpUrl,
|
||||
page,
|
||||
previousUrl,
|
||||
@@ -770,7 +744,9 @@ export async function evaluateViaPlaywright(opts: {
|
||||
});
|
||||
return result;
|
||||
} finally {
|
||||
cleanup();
|
||||
if (signal && abortListener) {
|
||||
signal.removeEventListener("abort", abortListener);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -807,63 +783,46 @@ export async function waitForViaPlaywright(opts: {
|
||||
loadState?: "load" | "domcontentloaded" | "networkidle";
|
||||
fn?: string;
|
||||
timeoutMs?: number;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<void> {
|
||||
const page = await getPageForTargetId(opts);
|
||||
ensurePageState(page);
|
||||
const timeout = resolveActWaitTimeoutMs(opts.timeoutMs);
|
||||
const { abortPromise, cleanup } = createAbortPromise(opts.signal);
|
||||
const waitForStep = async <T>(stepPromise: Promise<T>) => {
|
||||
await awaitActionWithAbort(stepPromise, abortPromise);
|
||||
};
|
||||
const timeout = normalizeTimeoutMs(opts.timeoutMs, 20_000);
|
||||
|
||||
try {
|
||||
if (typeof opts.timeMs === "number" && Number.isFinite(opts.timeMs)) {
|
||||
await waitForStep(
|
||||
page.waitForTimeout(
|
||||
resolveBoundedDelayMs(opts.timeMs, "wait timeMs", ACT_MAX_WAIT_TIME_MS),
|
||||
),
|
||||
);
|
||||
if (typeof opts.timeMs === "number" && Number.isFinite(opts.timeMs)) {
|
||||
await page.waitForTimeout(resolveBoundedDelayMs(opts.timeMs, "wait timeMs", MAX_WAIT_TIME_MS));
|
||||
}
|
||||
if (opts.text) {
|
||||
await page.getByText(opts.text).first().waitFor({
|
||||
state: "visible",
|
||||
timeout,
|
||||
});
|
||||
}
|
||||
if (opts.textGone) {
|
||||
await page.getByText(opts.textGone).first().waitFor({
|
||||
state: "hidden",
|
||||
timeout,
|
||||
});
|
||||
}
|
||||
if (opts.selector) {
|
||||
const selector = normalizeOptionalString(opts.selector) ?? "";
|
||||
if (selector) {
|
||||
await page.locator(selector).first().waitFor({ state: "visible", timeout });
|
||||
}
|
||||
if (opts.text) {
|
||||
await waitForStep(
|
||||
page.getByText(opts.text).first().waitFor({
|
||||
state: "visible",
|
||||
timeout,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (opts.url) {
|
||||
const url = normalizeOptionalString(opts.url) ?? "";
|
||||
if (url) {
|
||||
await page.waitForURL(url, { timeout });
|
||||
}
|
||||
if (opts.textGone) {
|
||||
await waitForStep(
|
||||
page.getByText(opts.textGone).first().waitFor({
|
||||
state: "hidden",
|
||||
timeout,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (opts.loadState) {
|
||||
await page.waitForLoadState(opts.loadState, { timeout });
|
||||
}
|
||||
if (opts.fn) {
|
||||
const fn = normalizeOptionalString(opts.fn) ?? "";
|
||||
if (fn) {
|
||||
await page.waitForFunction(fn, { timeout });
|
||||
}
|
||||
if (opts.selector) {
|
||||
const selector = normalizeOptionalString(opts.selector) ?? "";
|
||||
if (selector) {
|
||||
await waitForStep(page.locator(selector).first().waitFor({ state: "visible", timeout }));
|
||||
}
|
||||
}
|
||||
if (opts.url) {
|
||||
const url = normalizeOptionalString(opts.url) ?? "";
|
||||
if (url) {
|
||||
await waitForStep(page.waitForURL(url, { timeout }));
|
||||
}
|
||||
}
|
||||
if (opts.loadState) {
|
||||
await waitForStep(page.waitForLoadState(opts.loadState, { timeout }));
|
||||
}
|
||||
if (opts.fn) {
|
||||
const fn = normalizeOptionalString(opts.fn) ?? "";
|
||||
if (fn) {
|
||||
await waitForStep(page.waitForFunction(fn, { timeout }));
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1080,6 +1039,8 @@ export async function setInputFilesViaPlaywright(opts: {
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_BATCH_DEPTH = 5;
|
||||
|
||||
async function executeSingleAction(
|
||||
action: BrowserActRequest,
|
||||
cdpUrl: string,
|
||||
@@ -1087,10 +1048,9 @@ async function executeSingleAction(
|
||||
evaluateEnabled?: boolean,
|
||||
ssrfPolicy?: SsrFPolicy,
|
||||
depth = 0,
|
||||
signal?: AbortSignal,
|
||||
): Promise<unknown> {
|
||||
if (depth > ACT_MAX_BATCH_DEPTH) {
|
||||
throw new Error(`Batch nesting depth exceeds maximum of ${ACT_MAX_BATCH_DEPTH}`);
|
||||
): Promise<void> {
|
||||
if (depth > MAX_BATCH_DEPTH) {
|
||||
throw new Error(`Batch nesting depth exceeds maximum of ${MAX_BATCH_DEPTH}`);
|
||||
}
|
||||
const effectiveTargetId = action.targetId ?? targetId;
|
||||
switch (action.kind) {
|
||||
@@ -1202,22 +1162,21 @@ async function executeSingleAction(
|
||||
loadState: action.loadState,
|
||||
fn: action.fn,
|
||||
timeoutMs: action.timeoutMs,
|
||||
signal,
|
||||
});
|
||||
break;
|
||||
case "evaluate":
|
||||
if (!evaluateEnabled) {
|
||||
throw new Error("act:evaluate is disabled by config (browser.evaluateEnabled=false)");
|
||||
}
|
||||
return await evaluateViaPlaywright({
|
||||
await evaluateViaPlaywright({
|
||||
cdpUrl,
|
||||
targetId: effectiveTargetId,
|
||||
ssrfPolicy,
|
||||
fn: action.fn,
|
||||
ref: action.ref,
|
||||
timeoutMs: action.timeoutMs,
|
||||
signal,
|
||||
});
|
||||
break;
|
||||
case "close":
|
||||
await closePageViaPlaywright({
|
||||
cdpUrl,
|
||||
@@ -1233,51 +1192,11 @@ async function executeSingleAction(
|
||||
stopOnError: action.stopOnError,
|
||||
evaluateEnabled,
|
||||
depth: depth + 1,
|
||||
signal,
|
||||
});
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unsupported batch action kind: ${(action as { kind: string }).kind}`);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function executeActViaPlaywright(opts: {
|
||||
cdpUrl: string;
|
||||
action: BrowserActRequest;
|
||||
targetId?: string;
|
||||
evaluateEnabled?: boolean;
|
||||
ssrfPolicy?: SsrFPolicy;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<{
|
||||
result?: unknown;
|
||||
results?: Array<{ ok: boolean; error?: string }>;
|
||||
}> {
|
||||
if (opts.action.kind === "batch") {
|
||||
const batch = await batchViaPlaywright({
|
||||
cdpUrl: opts.cdpUrl,
|
||||
targetId: opts.targetId,
|
||||
ssrfPolicy: opts.ssrfPolicy,
|
||||
actions: opts.action.actions,
|
||||
stopOnError: opts.action.stopOnError,
|
||||
evaluateEnabled: opts.evaluateEnabled,
|
||||
signal: opts.signal,
|
||||
});
|
||||
return { results: batch.results };
|
||||
}
|
||||
const result = await executeSingleAction(
|
||||
opts.action,
|
||||
opts.cdpUrl,
|
||||
opts.targetId,
|
||||
opts.evaluateEnabled,
|
||||
opts.ssrfPolicy,
|
||||
0,
|
||||
opts.signal,
|
||||
);
|
||||
if (opts.action.kind === "evaluate") {
|
||||
return { result };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
export async function batchViaPlaywright(opts: {
|
||||
@@ -1288,20 +1207,16 @@ export async function batchViaPlaywright(opts: {
|
||||
evaluateEnabled?: boolean;
|
||||
ssrfPolicy?: SsrFPolicy;
|
||||
depth?: number;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<{ results: Array<{ ok: boolean; error?: string }> }> {
|
||||
const depth = opts.depth ?? 0;
|
||||
if (depth > ACT_MAX_BATCH_DEPTH) {
|
||||
throw new Error(`Batch nesting depth exceeds maximum of ${ACT_MAX_BATCH_DEPTH}`);
|
||||
if (depth > MAX_BATCH_DEPTH) {
|
||||
throw new Error(`Batch nesting depth exceeds maximum of ${MAX_BATCH_DEPTH}`);
|
||||
}
|
||||
if (opts.actions.length > ACT_MAX_BATCH_ACTIONS) {
|
||||
throw new Error(`Batch exceeds maximum of ${ACT_MAX_BATCH_ACTIONS} actions`);
|
||||
if (opts.actions.length > MAX_BATCH_ACTIONS) {
|
||||
throw new Error(`Batch exceeds maximum of ${MAX_BATCH_ACTIONS} actions`);
|
||||
}
|
||||
const results: Array<{ ok: boolean; error?: string }> = [];
|
||||
for (const action of opts.actions) {
|
||||
if (opts.signal?.aborted) {
|
||||
throw opts.signal.reason ?? new Error("aborted");
|
||||
}
|
||||
try {
|
||||
await executeSingleAction(
|
||||
action,
|
||||
@@ -1310,7 +1225,6 @@ export async function batchViaPlaywright(opts: {
|
||||
opts.evaluateEnabled,
|
||||
opts.ssrfPolicy,
|
||||
depth,
|
||||
opts.signal,
|
||||
);
|
||||
results.push({ ok: true });
|
||||
} catch (err) {
|
||||
|
||||
@@ -150,51 +150,4 @@ describe("pw-tools-core", () => {
|
||||
timeout: 1234,
|
||||
});
|
||||
});
|
||||
|
||||
it("clamps wait timeoutMs to 120000 for wait steps", async () => {
|
||||
const waitForSelector = vi.fn(async () => {});
|
||||
const page = {
|
||||
locator: vi.fn(() => ({
|
||||
first: () => ({ waitFor: waitForSelector }),
|
||||
})),
|
||||
waitForURL: vi.fn(async () => {}),
|
||||
waitForLoadState: vi.fn(async () => {}),
|
||||
waitForFunction: vi.fn(async () => {}),
|
||||
waitForTimeout: vi.fn(async () => {}),
|
||||
getByText: vi.fn(() => ({ first: () => ({ waitFor: vi.fn() }) })),
|
||||
};
|
||||
setPwToolsCoreCurrentPage(page);
|
||||
|
||||
await mod.waitForViaPlaywright({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
selector: "#main",
|
||||
timeoutMs: 999_999,
|
||||
});
|
||||
|
||||
expect(waitForSelector).toHaveBeenCalledWith({
|
||||
state: "visible",
|
||||
timeout: 120_000,
|
||||
});
|
||||
});
|
||||
|
||||
it("clamps interaction timeoutMs to 60000 for click steps", async () => {
|
||||
const click = vi.fn(async () => {});
|
||||
const page = {
|
||||
url: vi.fn(() => "https://example.com"),
|
||||
locator: vi.fn(() => ({ click })),
|
||||
};
|
||||
setPwToolsCoreCurrentPage(page);
|
||||
|
||||
await mod.clickViaPlaywright({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
selector: "#main",
|
||||
timeoutMs: 999_999,
|
||||
});
|
||||
|
||||
expect(click).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
timeout: 60_000,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
resolveTargetIdFromBody,
|
||||
withRouteTabContext,
|
||||
} from "./agent.shared.js";
|
||||
import { EXISTING_SESSION_LIMITS } from "./existing-session-limits.js";
|
||||
import { ensureOutputRootDir, resolveWritableOutputPathOrRespond } from "./output-paths.js";
|
||||
import { DEFAULT_DOWNLOAD_DIR } from "./path-output.js";
|
||||
import type { BrowserRouteRegistrar } from "./types.js";
|
||||
@@ -37,7 +36,11 @@ export function registerBrowserAgentActDownloadRoutes(
|
||||
targetId,
|
||||
run: async ({ profileCtx, cdpUrl, tab }) => {
|
||||
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
|
||||
return jsonError(res, 501, EXISTING_SESSION_LIMITS.download.waitUnsupported);
|
||||
return jsonError(
|
||||
res,
|
||||
501,
|
||||
"download waiting is not supported for existing-session profiles yet.",
|
||||
);
|
||||
}
|
||||
const pw = await requirePwAi(res, "wait for download");
|
||||
if (!pw) {
|
||||
@@ -87,7 +90,11 @@ export function registerBrowserAgentActDownloadRoutes(
|
||||
targetId,
|
||||
run: async ({ profileCtx, cdpUrl, tab }) => {
|
||||
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
|
||||
return jsonError(res, 501, EXISTING_SESSION_LIMITS.download.downloadUnsupported);
|
||||
return jsonError(
|
||||
res,
|
||||
501,
|
||||
"downloads are not supported for existing-session profiles yet.",
|
||||
);
|
||||
}
|
||||
const pw = await requirePwAi(res, "download");
|
||||
if (!pw) {
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import type { BrowserResponse } from "./types.js";
|
||||
|
||||
export const ACT_ERROR_CODES = {
|
||||
kindRequired: "ACT_KIND_REQUIRED",
|
||||
invalidRequest: "ACT_INVALID_REQUEST",
|
||||
selectorUnsupported: "ACT_SELECTOR_UNSUPPORTED",
|
||||
evaluateDisabled: "ACT_EVALUATE_DISABLED",
|
||||
unsupportedForExistingSession: "ACT_EXISTING_SESSION_UNSUPPORTED",
|
||||
targetIdMismatch: "ACT_TARGET_ID_MISMATCH",
|
||||
} as const;
|
||||
|
||||
export type ActErrorCode = (typeof ACT_ERROR_CODES)[keyof typeof ACT_ERROR_CODES];
|
||||
|
||||
export function jsonActError(
|
||||
res: BrowserResponse,
|
||||
status: number,
|
||||
code: ActErrorCode,
|
||||
message: string,
|
||||
) {
|
||||
res.status(status).json({ error: message, code });
|
||||
}
|
||||
|
||||
export function browserEvaluateDisabledMessage(action: "wait" | "evaluate"): string {
|
||||
return [
|
||||
action === "wait"
|
||||
? "wait --fn is disabled by config (browser.evaluateEnabled=false)."
|
||||
: "act:evaluate is disabled by config (browser.evaluateEnabled=false).",
|
||||
"Docs: /gateway/configuration#browser-openclaw-managed-browser",
|
||||
].join("\n");
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
resolveTargetIdFromBody,
|
||||
withRouteTabContext,
|
||||
} from "./agent.shared.js";
|
||||
import { EXISTING_SESSION_LIMITS } from "./existing-session-limits.js";
|
||||
import { DEFAULT_UPLOAD_DIR, resolveExistingPathsWithinRoot } from "./path-output.js";
|
||||
import type { BrowserRouteRegistrar } from "./types.js";
|
||||
import { jsonError, toBoolean, toNumber, toStringArray, toStringOrEmpty } from "./utils.js";
|
||||
@@ -47,14 +46,22 @@ export function registerBrowserAgentActHookRoutes(
|
||||
|
||||
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
|
||||
if (element) {
|
||||
return jsonError(res, 501, EXISTING_SESSION_LIMITS.hooks.uploadElement);
|
||||
return jsonError(
|
||||
res,
|
||||
501,
|
||||
"existing-session file uploads do not support element selectors; use ref/inputRef.",
|
||||
);
|
||||
}
|
||||
if (resolvedPaths.length !== 1) {
|
||||
return jsonError(res, 501, EXISTING_SESSION_LIMITS.hooks.uploadSingleFile);
|
||||
return jsonError(
|
||||
res,
|
||||
501,
|
||||
"existing-session file uploads currently support one file at a time.",
|
||||
);
|
||||
}
|
||||
const uid = inputRef || ref;
|
||||
if (!uid) {
|
||||
return jsonError(res, 501, EXISTING_SESSION_LIMITS.hooks.uploadRefRequired);
|
||||
return jsonError(res, 501, "existing-session file uploads require ref or inputRef.");
|
||||
}
|
||||
await uploadChromeMcpFile({
|
||||
profileName: profileCtx.profile.name,
|
||||
@@ -121,7 +128,11 @@ export function registerBrowserAgentActHookRoutes(
|
||||
run: async ({ profileCtx, cdpUrl, tab }) => {
|
||||
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
|
||||
if (timeoutMs) {
|
||||
return jsonError(res, 501, EXISTING_SESSION_LIMITS.hooks.dialogTimeout);
|
||||
return jsonError(
|
||||
res,
|
||||
501,
|
||||
"existing-session dialog handling does not support timeoutMs.",
|
||||
);
|
||||
}
|
||||
await evaluateChromeMcpScript({
|
||||
profileName: profileCtx.profile.name,
|
||||
|
||||
@@ -1,321 +0,0 @@
|
||||
import {
|
||||
ACT_MAX_BATCH_ACTIONS,
|
||||
ACT_MAX_CLICK_DELAY_MS,
|
||||
ACT_MAX_WAIT_TIME_MS,
|
||||
normalizeActBoundedNonNegativeMs,
|
||||
} from "../act-policy.js";
|
||||
import type { BrowserActRequest, BrowserFormField } from "../client-actions-core.js";
|
||||
import { normalizeBrowserFormField } from "../form-fields.js";
|
||||
import {
|
||||
type ActKind,
|
||||
isActKind,
|
||||
parseClickButton,
|
||||
parseClickModifiers,
|
||||
} from "./agent.act.shared.js";
|
||||
import { toBoolean, toNumber, toStringArray, toStringOrEmpty } from "./utils.js";
|
||||
|
||||
function normalizeActKind(raw: unknown): ActKind {
|
||||
const kind = toStringOrEmpty(raw);
|
||||
if (!isActKind(kind)) {
|
||||
throw new Error("kind is required");
|
||||
}
|
||||
return kind;
|
||||
}
|
||||
|
||||
export function countBatchActions(actions: BrowserActRequest[]): number {
|
||||
let count = 0;
|
||||
for (const action of actions) {
|
||||
count += 1;
|
||||
if (action.kind === "batch") {
|
||||
count += countBatchActions(action.actions);
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
export function validateBatchTargetIds(
|
||||
actions: BrowserActRequest[],
|
||||
targetId: string,
|
||||
): string | null {
|
||||
for (const action of actions) {
|
||||
if (action.targetId && action.targetId !== targetId) {
|
||||
return "batched action targetId must match request targetId";
|
||||
}
|
||||
if (action.kind === "batch") {
|
||||
const nestedError = validateBatchTargetIds(action.actions, targetId);
|
||||
if (nestedError) {
|
||||
return nestedError;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeFields(rawFields: unknown): BrowserFormField[] {
|
||||
const entries = Array.isArray(rawFields) ? rawFields : [];
|
||||
return entries
|
||||
.map((field) => {
|
||||
if (!field || typeof field !== "object") {
|
||||
return null;
|
||||
}
|
||||
return normalizeBrowserFormField(field as Record<string, unknown>);
|
||||
})
|
||||
.filter((field): field is BrowserFormField => field !== null);
|
||||
}
|
||||
|
||||
function normalizeBatchAction(value: unknown): BrowserActRequest {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new Error("batch actions must be objects");
|
||||
}
|
||||
return normalizeActRequest(value as Record<string, unknown>, { source: "batch" });
|
||||
}
|
||||
|
||||
export function normalizeActRequest(
|
||||
body: Record<string, unknown>,
|
||||
options?: { source?: "request" | "batch" },
|
||||
): BrowserActRequest {
|
||||
const source = options?.source ?? "request";
|
||||
const kind = normalizeActKind(body.kind);
|
||||
|
||||
switch (kind) {
|
||||
case "click": {
|
||||
const ref = toStringOrEmpty(body.ref) || undefined;
|
||||
const selector = toStringOrEmpty(body.selector) || undefined;
|
||||
if (!ref && !selector) {
|
||||
throw new Error("click requires ref or selector");
|
||||
}
|
||||
const buttonRaw = toStringOrEmpty(body.button);
|
||||
const button = buttonRaw ? parseClickButton(buttonRaw) : undefined;
|
||||
if (buttonRaw && !button) {
|
||||
throw new Error("click button must be left|right|middle");
|
||||
}
|
||||
const modifiersRaw = toStringArray(body.modifiers) ?? [];
|
||||
const parsedModifiers = parseClickModifiers(modifiersRaw);
|
||||
if (parsedModifiers.error) {
|
||||
throw new Error(parsedModifiers.error);
|
||||
}
|
||||
const doubleClick = toBoolean(body.doubleClick);
|
||||
const delayMs = normalizeActBoundedNonNegativeMs(
|
||||
toNumber(body.delayMs),
|
||||
"click delayMs",
|
||||
ACT_MAX_CLICK_DELAY_MS,
|
||||
);
|
||||
const timeoutMs = toNumber(body.timeoutMs);
|
||||
const targetId = toStringOrEmpty(body.targetId) || undefined;
|
||||
return {
|
||||
kind,
|
||||
...(ref ? { ref } : {}),
|
||||
...(selector ? { selector } : {}),
|
||||
...(targetId ? { targetId } : {}),
|
||||
...(doubleClick !== undefined ? { doubleClick } : {}),
|
||||
...(button ? { button } : {}),
|
||||
...(parsedModifiers.modifiers ? { modifiers: parsedModifiers.modifiers } : {}),
|
||||
...(delayMs !== undefined ? { delayMs } : {}),
|
||||
...(timeoutMs !== undefined ? { timeoutMs } : {}),
|
||||
};
|
||||
}
|
||||
case "type": {
|
||||
const ref = toStringOrEmpty(body.ref) || undefined;
|
||||
const selector = toStringOrEmpty(body.selector) || undefined;
|
||||
const text = body.text;
|
||||
if (!ref && !selector) {
|
||||
throw new Error("type requires ref or selector");
|
||||
}
|
||||
if (typeof text !== "string") {
|
||||
throw new Error("type requires text");
|
||||
}
|
||||
const targetId = toStringOrEmpty(body.targetId) || undefined;
|
||||
const submit = toBoolean(body.submit);
|
||||
const slowly = toBoolean(body.slowly);
|
||||
const timeoutMs = toNumber(body.timeoutMs);
|
||||
return {
|
||||
kind,
|
||||
...(ref ? { ref } : {}),
|
||||
...(selector ? { selector } : {}),
|
||||
text,
|
||||
...(targetId ? { targetId } : {}),
|
||||
...(submit !== undefined ? { submit } : {}),
|
||||
...(slowly !== undefined ? { slowly } : {}),
|
||||
...(timeoutMs !== undefined ? { timeoutMs } : {}),
|
||||
};
|
||||
}
|
||||
case "press": {
|
||||
const key = toStringOrEmpty(body.key);
|
||||
if (!key) {
|
||||
throw new Error("press requires key");
|
||||
}
|
||||
const targetId = toStringOrEmpty(body.targetId) || undefined;
|
||||
const delayMs = toNumber(body.delayMs);
|
||||
return {
|
||||
kind,
|
||||
key,
|
||||
...(targetId ? { targetId } : {}),
|
||||
...(delayMs !== undefined ? { delayMs } : {}),
|
||||
};
|
||||
}
|
||||
case "hover":
|
||||
case "scrollIntoView": {
|
||||
const ref = toStringOrEmpty(body.ref) || undefined;
|
||||
const selector = toStringOrEmpty(body.selector) || undefined;
|
||||
if (!ref && !selector) {
|
||||
throw new Error(`${kind} requires ref or selector`);
|
||||
}
|
||||
const targetId = toStringOrEmpty(body.targetId) || undefined;
|
||||
const timeoutMs = toNumber(body.timeoutMs);
|
||||
return {
|
||||
kind,
|
||||
...(ref ? { ref } : {}),
|
||||
...(selector ? { selector } : {}),
|
||||
...(targetId ? { targetId } : {}),
|
||||
...(timeoutMs !== undefined ? { timeoutMs } : {}),
|
||||
};
|
||||
}
|
||||
case "drag": {
|
||||
const startRef = toStringOrEmpty(body.startRef) || undefined;
|
||||
const startSelector = toStringOrEmpty(body.startSelector) || undefined;
|
||||
const endRef = toStringOrEmpty(body.endRef) || undefined;
|
||||
const endSelector = toStringOrEmpty(body.endSelector) || undefined;
|
||||
if (!startRef && !startSelector) {
|
||||
throw new Error("drag requires startRef or startSelector");
|
||||
}
|
||||
if (!endRef && !endSelector) {
|
||||
throw new Error("drag requires endRef or endSelector");
|
||||
}
|
||||
const targetId = toStringOrEmpty(body.targetId) || undefined;
|
||||
const timeoutMs = toNumber(body.timeoutMs);
|
||||
return {
|
||||
kind,
|
||||
...(startRef ? { startRef } : {}),
|
||||
...(startSelector ? { startSelector } : {}),
|
||||
...(endRef ? { endRef } : {}),
|
||||
...(endSelector ? { endSelector } : {}),
|
||||
...(targetId ? { targetId } : {}),
|
||||
...(timeoutMs !== undefined ? { timeoutMs } : {}),
|
||||
};
|
||||
}
|
||||
case "select": {
|
||||
const ref = toStringOrEmpty(body.ref) || undefined;
|
||||
const selector = toStringOrEmpty(body.selector) || undefined;
|
||||
const values = toStringArray(body.values);
|
||||
if ((!ref && !selector) || !values?.length) {
|
||||
throw new Error("select requires ref/selector and values");
|
||||
}
|
||||
const targetId = toStringOrEmpty(body.targetId) || undefined;
|
||||
const timeoutMs = toNumber(body.timeoutMs);
|
||||
return {
|
||||
kind,
|
||||
...(ref ? { ref } : {}),
|
||||
...(selector ? { selector } : {}),
|
||||
values,
|
||||
...(targetId ? { targetId } : {}),
|
||||
...(timeoutMs !== undefined ? { timeoutMs } : {}),
|
||||
};
|
||||
}
|
||||
case "fill": {
|
||||
const fields = normalizeFields(body.fields);
|
||||
if (!fields.length) {
|
||||
throw new Error("fill requires fields");
|
||||
}
|
||||
const targetId = toStringOrEmpty(body.targetId) || undefined;
|
||||
const timeoutMs = toNumber(body.timeoutMs);
|
||||
return {
|
||||
kind,
|
||||
fields,
|
||||
...(targetId ? { targetId } : {}),
|
||||
...(timeoutMs !== undefined ? { timeoutMs } : {}),
|
||||
};
|
||||
}
|
||||
case "resize": {
|
||||
const width = toNumber(body.width);
|
||||
const height = toNumber(body.height);
|
||||
if (width === undefined || height === undefined || width <= 0 || height <= 0) {
|
||||
throw new Error("resize requires positive width and height");
|
||||
}
|
||||
const targetId = toStringOrEmpty(body.targetId) || undefined;
|
||||
return {
|
||||
kind,
|
||||
width,
|
||||
height,
|
||||
...(targetId ? { targetId } : {}),
|
||||
};
|
||||
}
|
||||
case "wait": {
|
||||
const loadStateRaw = toStringOrEmpty(body.loadState);
|
||||
const loadState =
|
||||
loadStateRaw === "load" ||
|
||||
loadStateRaw === "domcontentloaded" ||
|
||||
loadStateRaw === "networkidle"
|
||||
? loadStateRaw
|
||||
: undefined;
|
||||
const timeMs = normalizeActBoundedNonNegativeMs(
|
||||
toNumber(body.timeMs),
|
||||
"wait timeMs",
|
||||
ACT_MAX_WAIT_TIME_MS,
|
||||
);
|
||||
const text = toStringOrEmpty(body.text) || undefined;
|
||||
const textGone = toStringOrEmpty(body.textGone) || undefined;
|
||||
const selector = toStringOrEmpty(body.selector) || undefined;
|
||||
const url = toStringOrEmpty(body.url) || undefined;
|
||||
const fn = toStringOrEmpty(body.fn) || undefined;
|
||||
if (timeMs === undefined && !text && !textGone && !selector && !url && !loadState && !fn) {
|
||||
throw new Error(
|
||||
"wait requires at least one of: timeMs, text, textGone, selector, url, loadState, fn",
|
||||
);
|
||||
}
|
||||
const targetId = toStringOrEmpty(body.targetId) || undefined;
|
||||
const timeoutMs = toNumber(body.timeoutMs);
|
||||
return {
|
||||
kind,
|
||||
...(timeMs !== undefined ? { timeMs } : {}),
|
||||
...(text ? { text } : {}),
|
||||
...(textGone ? { textGone } : {}),
|
||||
...(selector ? { selector } : {}),
|
||||
...(url ? { url } : {}),
|
||||
...(loadState ? { loadState } : {}),
|
||||
...(fn ? { fn } : {}),
|
||||
...(targetId ? { targetId } : {}),
|
||||
...(timeoutMs !== undefined ? { timeoutMs } : {}),
|
||||
};
|
||||
}
|
||||
case "evaluate": {
|
||||
const fn = toStringOrEmpty(body.fn);
|
||||
if (!fn) {
|
||||
throw new Error("evaluate requires fn");
|
||||
}
|
||||
const ref = toStringOrEmpty(body.ref) || undefined;
|
||||
const targetId = toStringOrEmpty(body.targetId) || undefined;
|
||||
const timeoutMs = toNumber(body.timeoutMs);
|
||||
return {
|
||||
kind,
|
||||
fn,
|
||||
...(ref ? { ref } : {}),
|
||||
...(targetId ? { targetId } : {}),
|
||||
...(timeoutMs !== undefined ? { timeoutMs } : {}),
|
||||
};
|
||||
}
|
||||
case "close": {
|
||||
const targetId = toStringOrEmpty(body.targetId) || undefined;
|
||||
return {
|
||||
kind,
|
||||
...(targetId ? { targetId } : {}),
|
||||
};
|
||||
}
|
||||
case "batch": {
|
||||
const actions = Array.isArray(body.actions) ? body.actions.map(normalizeBatchAction) : [];
|
||||
if (!actions.length) {
|
||||
throw new Error(source === "batch" ? "batch requires actions" : "actions are required");
|
||||
}
|
||||
if (countBatchActions(actions) > ACT_MAX_BATCH_ACTIONS) {
|
||||
throw new Error(`batch exceeds maximum of ${ACT_MAX_BATCH_ACTIONS} actions`);
|
||||
}
|
||||
const targetId = toStringOrEmpty(body.targetId) || undefined;
|
||||
const stopOnError = toBoolean(body.stopOnError);
|
||||
return {
|
||||
kind,
|
||||
actions,
|
||||
...(targetId ? { targetId } : {}),
|
||||
...(stopOnError !== undefined ? { stopOnError } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -37,7 +37,6 @@ import {
|
||||
shouldUsePlaywrightForAriaSnapshot,
|
||||
shouldUsePlaywrightForScreenshot,
|
||||
} from "./agent.snapshot.plan.js";
|
||||
import { EXISTING_SESSION_LIMITS } from "./existing-session-limits.js";
|
||||
import type { BrowserResponse, BrowserRouteRegistrar } from "./types.js";
|
||||
import { jsonError, toBoolean, toStringOrEmpty } from "./utils.js";
|
||||
|
||||
@@ -271,7 +270,11 @@ export function registerBrowserAgentSnapshotRoutes(
|
||||
return;
|
||||
}
|
||||
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
|
||||
return jsonError(res, 501, EXISTING_SESSION_LIMITS.snapshot.pdfUnsupported);
|
||||
return jsonError(
|
||||
res,
|
||||
501,
|
||||
"pdf is not supported for existing-session profiles yet; use screenshot/snapshot instead.",
|
||||
);
|
||||
}
|
||||
await withPlaywrightRouteContext({
|
||||
req,
|
||||
@@ -316,7 +319,11 @@ export function registerBrowserAgentSnapshotRoutes(
|
||||
run: async ({ profileCtx, tab, cdpUrl }) => {
|
||||
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
|
||||
if (element) {
|
||||
return jsonError(res, 400, EXISTING_SESSION_LIMITS.snapshot.screenshotElement);
|
||||
return jsonError(
|
||||
res,
|
||||
400,
|
||||
"element screenshots are not supported for existing-session profiles; use ref from snapshot.",
|
||||
);
|
||||
}
|
||||
const buffer = await takeChromeMcpScreenshot({
|
||||
profileName: profileCtx.profile.name,
|
||||
@@ -397,7 +404,11 @@ export function registerBrowserAgentSnapshotRoutes(
|
||||
}
|
||||
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
|
||||
if (plan.selectorValue || plan.frameSelectorValue) {
|
||||
return jsonError(res, 400, EXISTING_SESSION_LIMITS.snapshot.snapshotSelector);
|
||||
return jsonError(
|
||||
res,
|
||||
400,
|
||||
"selector/frame snapshots are not supported for existing-session profiles; snapshot the whole page and use refs.",
|
||||
);
|
||||
}
|
||||
const snapshot = await takeChromeMcpSnapshot({
|
||||
profileName: profileCtx.profile.name,
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
export const EXISTING_SESSION_LIMITS = {
|
||||
act: {
|
||||
clickSelector: "existing-session click does not support selector targeting yet; use ref.",
|
||||
clickButtonOrModifiers:
|
||||
"existing-session click currently supports left-click only (no button overrides/modifiers).",
|
||||
typeSelector: "existing-session type does not support selector targeting yet; use ref.",
|
||||
typeSlowly: "existing-session type does not support slowly=true; use fill/press instead.",
|
||||
pressDelay: "existing-session press does not support delayMs.",
|
||||
hoverSelector: "existing-session hover does not support selector targeting yet; use ref.",
|
||||
hoverTimeout: "existing-session hover does not support timeoutMs overrides.",
|
||||
scrollSelector:
|
||||
"existing-session scrollIntoView does not support selector targeting yet; use ref.",
|
||||
scrollTimeout: "existing-session scrollIntoView does not support timeoutMs overrides.",
|
||||
dragSelector:
|
||||
"existing-session drag does not support selector targeting yet; use startRef/endRef.",
|
||||
dragTimeout: "existing-session drag does not support timeoutMs overrides.",
|
||||
selectSelector: "existing-session select does not support selector targeting yet; use ref.",
|
||||
selectSingleValue: "existing-session select currently supports a single value only.",
|
||||
selectTimeout: "existing-session select does not support timeoutMs overrides.",
|
||||
fillTimeout: "existing-session fill does not support timeoutMs overrides.",
|
||||
waitNetworkIdle: "existing-session wait does not support loadState=networkidle yet.",
|
||||
evaluateTimeout: "existing-session evaluate does not support timeoutMs overrides.",
|
||||
batch: "existing-session batch is not supported yet; send actions individually.",
|
||||
},
|
||||
hooks: {
|
||||
uploadElement:
|
||||
"existing-session file uploads do not support element selectors; use ref/inputRef.",
|
||||
uploadSingleFile: "existing-session file uploads currently support one file at a time.",
|
||||
uploadRefRequired: "existing-session file uploads require ref or inputRef.",
|
||||
dialogTimeout: "existing-session dialog handling does not support timeoutMs.",
|
||||
},
|
||||
download: {
|
||||
waitUnsupported: "download waiting is not supported for existing-session profiles yet.",
|
||||
downloadUnsupported: "downloads are not supported for existing-session profiles yet.",
|
||||
},
|
||||
snapshot: {
|
||||
pdfUnsupported:
|
||||
"pdf is not supported for existing-session profiles yet; use screenshot/snapshot instead.",
|
||||
screenshotElement:
|
||||
"element screenshots are not supported for existing-session profiles; use ref from snapshot.",
|
||||
snapshotSelector:
|
||||
"selector/frame snapshots are not supported for existing-session profiles; snapshot the whole page and use refs.",
|
||||
},
|
||||
responseBody: "response body is not supported for existing-session profiles yet.",
|
||||
} as const;
|
||||
@@ -1,176 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
installAgentContractHooks,
|
||||
startServerAndBase,
|
||||
} from "./server.agent-contract.test-harness.js";
|
||||
import {
|
||||
setBrowserControlServerEvaluateEnabled,
|
||||
setBrowserControlServerProfiles,
|
||||
} from "./server.control-server.test-harness.js";
|
||||
import { getBrowserTestFetch } from "./test-fetch.js";
|
||||
|
||||
type ActErrorResponse = {
|
||||
error?: string;
|
||||
code?: string;
|
||||
};
|
||||
|
||||
type ActErrorHttpResponse = {
|
||||
status: number;
|
||||
body: ActErrorResponse;
|
||||
};
|
||||
|
||||
async function postActAndReadError(base: string, body?: unknown): Promise<ActErrorHttpResponse> {
|
||||
const realFetch = getBrowserTestFetch();
|
||||
const response = await realFetch(`${base}/act`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
return {
|
||||
status: response.status,
|
||||
body: (await response.json()) as ActErrorResponse,
|
||||
};
|
||||
}
|
||||
|
||||
describe("browser control server", () => {
|
||||
installAgentContractHooks();
|
||||
|
||||
const slowTimeoutMs = process.platform === "win32" ? 40_000 : 20_000;
|
||||
|
||||
it(
|
||||
"returns ACT_KIND_REQUIRED when kind is missing",
|
||||
async () => {
|
||||
const base = await startServerAndBase();
|
||||
const response = await postActAndReadError(base, {});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.code).toBe("ACT_KIND_REQUIRED");
|
||||
expect(response.body.error).toContain("kind is required");
|
||||
},
|
||||
slowTimeoutMs,
|
||||
);
|
||||
|
||||
it(
|
||||
"returns ACT_INVALID_REQUEST for malformed action payloads",
|
||||
async () => {
|
||||
const base = await startServerAndBase();
|
||||
const response = await postActAndReadError(base, {
|
||||
kind: "click",
|
||||
ref: {},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.code).toBe("ACT_INVALID_REQUEST");
|
||||
expect(response.body.error).toContain("click requires ref or selector");
|
||||
},
|
||||
slowTimeoutMs,
|
||||
);
|
||||
|
||||
it(
|
||||
"returns ACT_EXISTING_SESSION_UNSUPPORTED for unsupported existing-session actions",
|
||||
async () => {
|
||||
setBrowserControlServerProfiles({
|
||||
openclaw: {
|
||||
color: "#FF4500",
|
||||
driver: "existing-session",
|
||||
},
|
||||
});
|
||||
|
||||
const base = await startServerAndBase();
|
||||
const response = await postActAndReadError(base, {
|
||||
kind: "batch",
|
||||
actions: [{ kind: "press", key: "Enter" }],
|
||||
});
|
||||
|
||||
expect(response.status).toBe(501);
|
||||
expect(response.body.code).toBe("ACT_EXISTING_SESSION_UNSUPPORTED");
|
||||
expect(response.body.error).toContain("batch");
|
||||
},
|
||||
slowTimeoutMs,
|
||||
);
|
||||
|
||||
it(
|
||||
"returns ACT_TARGET_ID_MISMATCH for batched action targetId overrides",
|
||||
async () => {
|
||||
const base = await startServerAndBase();
|
||||
const response = await postActAndReadError(base, {
|
||||
kind: "batch",
|
||||
actions: [{ kind: "click", ref: "5", targetId: "other-tab" }],
|
||||
});
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.body.code).toBe("ACT_TARGET_ID_MISMATCH");
|
||||
expect(response.body.error).toContain("batched action targetId must match request targetId");
|
||||
},
|
||||
slowTimeoutMs,
|
||||
);
|
||||
|
||||
it(
|
||||
"returns ACT_TARGET_ID_MISMATCH for top-level action targetId overrides",
|
||||
async () => {
|
||||
const base = await startServerAndBase();
|
||||
const response = await postActAndReadError(base, {
|
||||
kind: "click",
|
||||
ref: "5",
|
||||
// Intentionally non-string: route-level target selection ignores this,
|
||||
// while action normalization stringifies it.
|
||||
targetId: 12345,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.body.code).toBe("ACT_TARGET_ID_MISMATCH");
|
||||
expect(response.body.error).toContain("action targetId must match request targetId");
|
||||
},
|
||||
slowTimeoutMs,
|
||||
);
|
||||
|
||||
it(
|
||||
"returns ACT_SELECTOR_UNSUPPORTED for selector on unsupported action kinds",
|
||||
async () => {
|
||||
const base = await startServerAndBase();
|
||||
const response = await postActAndReadError(base, {
|
||||
kind: "evaluate",
|
||||
fn: "() => 1",
|
||||
selector: "#submit",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.code).toBe("ACT_SELECTOR_UNSUPPORTED");
|
||||
expect(response.body.error).toContain("'selector' is not supported");
|
||||
},
|
||||
slowTimeoutMs,
|
||||
);
|
||||
|
||||
it(
|
||||
"returns ACT_INVALID_REQUEST for malformed unsupported selector actions before selector gating",
|
||||
async () => {
|
||||
const base = await startServerAndBase();
|
||||
const response = await postActAndReadError(base, {
|
||||
kind: "press",
|
||||
selector: "#submit",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.code).toBe("ACT_INVALID_REQUEST");
|
||||
expect(response.body.error).toContain("press requires key");
|
||||
},
|
||||
slowTimeoutMs,
|
||||
);
|
||||
|
||||
it(
|
||||
"returns ACT_EVALUATE_DISABLED when evaluate is blocked by config",
|
||||
async () => {
|
||||
setBrowserControlServerEvaluateEnabled(false);
|
||||
const base = await startServerAndBase();
|
||||
const response = await postActAndReadError(base, {
|
||||
kind: "evaluate",
|
||||
fn: "() => 1",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.body.code).toBe("ACT_EVALUATE_DISABLED");
|
||||
expect(response.body.error).toContain("browser.evaluateEnabled=false");
|
||||
},
|
||||
slowTimeoutMs,
|
||||
);
|
||||
});
|
||||
@@ -107,36 +107,18 @@ describe("browser control server", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const resizeZero = await postJson<{ error?: string; code?: string }>(`${base}/act`, {
|
||||
kind: "resize",
|
||||
width: 0,
|
||||
height: 600,
|
||||
});
|
||||
expect(resizeZero.code).toBe("ACT_INVALID_REQUEST");
|
||||
expect(resizeZero.error).toContain("resize requires positive width and height");
|
||||
expect(pwMocks.resizeViewportViaPlaywright).toHaveBeenCalledTimes(1);
|
||||
|
||||
const resizeNegative = await postJson<{ error?: string; code?: string }>(`${base}/act`, {
|
||||
kind: "resize",
|
||||
width: -800,
|
||||
height: 600,
|
||||
});
|
||||
expect(resizeNegative.code).toBe("ACT_INVALID_REQUEST");
|
||||
expect(resizeNegative.error).toContain("resize requires positive width and height");
|
||||
expect(pwMocks.resizeViewportViaPlaywright).toHaveBeenCalledTimes(1);
|
||||
|
||||
const wait = await postJson<{ ok: boolean }>(`${base}/act`, {
|
||||
kind: "wait",
|
||||
timeMs: 5,
|
||||
});
|
||||
expect(wait.ok).toBe(true);
|
||||
expect(pwMocks.waitForViaPlaywright).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
cdpUrl: state.cdpBaseUrl,
|
||||
targetId: "abcd1234",
|
||||
timeMs: 5,
|
||||
}),
|
||||
);
|
||||
expect(pwMocks.waitForViaPlaywright).toHaveBeenCalledWith({
|
||||
cdpUrl: state.cdpBaseUrl,
|
||||
targetId: "abcd1234",
|
||||
timeMs: 5,
|
||||
text: undefined,
|
||||
textGone: undefined,
|
||||
});
|
||||
|
||||
const evalRes = await postJson<{ ok: boolean; result?: string }>(`${base}/act`, {
|
||||
kind: "evaluate",
|
||||
@@ -238,13 +220,12 @@ describe("browser control server", () => {
|
||||
async () => {
|
||||
const base = await startServerAndBase();
|
||||
|
||||
const batchRes = await postJson<{ error?: string; code?: string }>(`${base}/act`, {
|
||||
const batchRes = await postJson<{ error?: string }>(`${base}/act`, {
|
||||
kind: "batch",
|
||||
actions: [{ kind: "click", ref: {} }],
|
||||
});
|
||||
|
||||
expect(batchRes.error).toContain("click requires ref or selector");
|
||||
expect(batchRes.code).toBe("ACT_INVALID_REQUEST");
|
||||
expect(pwMocks.batchViaPlaywright).not.toHaveBeenCalled();
|
||||
},
|
||||
slowTimeoutMs,
|
||||
@@ -255,13 +236,12 @@ describe("browser control server", () => {
|
||||
async () => {
|
||||
const base = await startServerAndBase();
|
||||
|
||||
const batchRes = await postJson<{ error?: string; code?: string }>(`${base}/act`, {
|
||||
const batchRes = await postJson<{ error?: string }>(`${base}/act`, {
|
||||
kind: "batch",
|
||||
actions: [{ kind: "click", ref: "5", targetId: "other-tab" }],
|
||||
});
|
||||
|
||||
expect(batchRes.error).toContain("batched action targetId must match request targetId");
|
||||
expect(batchRes.code).toBe("ACT_TARGET_ID_MISMATCH");
|
||||
expect(pwMocks.batchViaPlaywright).not.toHaveBeenCalled();
|
||||
},
|
||||
slowTimeoutMs,
|
||||
|
||||
@@ -90,21 +90,17 @@ describe("browser control server", () => {
|
||||
modifiers: ["Shift"],
|
||||
});
|
||||
expect(click.ok).toBe(true);
|
||||
expect(pwMocks.clickViaPlaywright).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
cdpUrl: state.cdpBaseUrl,
|
||||
targetId: "abcd1234",
|
||||
ref: "1",
|
||||
button: "left",
|
||||
modifiers: ["Shift"],
|
||||
ssrfPolicy: {
|
||||
dangerouslyAllowPrivateNetwork: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
const [clickArgs] = pwMocks.clickViaPlaywright.mock.calls[0] ?? [];
|
||||
expect((clickArgs as { doubleClick?: boolean }).doubleClick).toBeUndefined();
|
||||
expect(pwMocks.clickViaPlaywright).toHaveBeenNthCalledWith(1, {
|
||||
cdpUrl: state.cdpBaseUrl,
|
||||
targetId: "abcd1234",
|
||||
ref: "1",
|
||||
doubleClick: false,
|
||||
button: "left",
|
||||
modifiers: ["Shift"],
|
||||
ssrfPolicy: {
|
||||
dangerouslyAllowPrivateNetwork: true,
|
||||
},
|
||||
});
|
||||
|
||||
const clickSelector = await realFetch(`${base}/act`, {
|
||||
method: "POST",
|
||||
@@ -113,19 +109,15 @@ describe("browser control server", () => {
|
||||
});
|
||||
expect(clickSelector.status).toBe(200);
|
||||
expect(((await clickSelector.json()) as { ok?: boolean }).ok).toBe(true);
|
||||
expect(pwMocks.clickViaPlaywright).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
cdpUrl: state.cdpBaseUrl,
|
||||
targetId: "abcd1234",
|
||||
selector: "button.save",
|
||||
ssrfPolicy: {
|
||||
dangerouslyAllowPrivateNetwork: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
const [clickSelectorArgs] = pwMocks.clickViaPlaywright.mock.calls[1] ?? [];
|
||||
expect((clickSelectorArgs as { doubleClick?: boolean }).doubleClick).toBeUndefined();
|
||||
expect(pwMocks.clickViaPlaywright).toHaveBeenNthCalledWith(2, {
|
||||
cdpUrl: state.cdpBaseUrl,
|
||||
targetId: "abcd1234",
|
||||
selector: "button.save",
|
||||
doubleClick: false,
|
||||
ssrfPolicy: {
|
||||
dangerouslyAllowPrivateNetwork: true,
|
||||
},
|
||||
});
|
||||
|
||||
const type = await postJson<{ ok: boolean }>(`${base}/act`, {
|
||||
kind: "type",
|
||||
@@ -133,69 +125,53 @@ describe("browser control server", () => {
|
||||
text: "",
|
||||
});
|
||||
expect(type.ok).toBe(true);
|
||||
expect(pwMocks.typeViaPlaywright).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
cdpUrl: state.cdpBaseUrl,
|
||||
targetId: "abcd1234",
|
||||
ref: "1",
|
||||
text: "",
|
||||
ssrfPolicy: {
|
||||
dangerouslyAllowPrivateNetwork: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
const [typeArgs] = pwMocks.typeViaPlaywright.mock.calls[0] ?? [];
|
||||
expect((typeArgs as { submit?: boolean }).submit).toBeUndefined();
|
||||
expect((typeArgs as { slowly?: boolean }).slowly).toBeUndefined();
|
||||
expect(pwMocks.typeViaPlaywright).toHaveBeenNthCalledWith(1, {
|
||||
cdpUrl: state.cdpBaseUrl,
|
||||
targetId: "abcd1234",
|
||||
ref: "1",
|
||||
text: "",
|
||||
submit: false,
|
||||
slowly: false,
|
||||
ssrfPolicy: {
|
||||
dangerouslyAllowPrivateNetwork: true,
|
||||
},
|
||||
});
|
||||
|
||||
const press = await postJson<{ ok: boolean }>(`${base}/act`, {
|
||||
kind: "press",
|
||||
key: "Enter",
|
||||
});
|
||||
expect(press.ok).toBe(true);
|
||||
expect(pwMocks.pressKeyViaPlaywright).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
cdpUrl: state.cdpBaseUrl,
|
||||
targetId: "abcd1234",
|
||||
key: "Enter",
|
||||
ssrfPolicy: {
|
||||
dangerouslyAllowPrivateNetwork: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
const [pressArgs] = pwMocks.pressKeyViaPlaywright.mock.calls[0] ?? [];
|
||||
expect((pressArgs as { delayMs?: number }).delayMs).toBeUndefined();
|
||||
expect(pwMocks.pressKeyViaPlaywright).toHaveBeenCalledWith({
|
||||
cdpUrl: state.cdpBaseUrl,
|
||||
targetId: "abcd1234",
|
||||
key: "Enter",
|
||||
ssrfPolicy: {
|
||||
dangerouslyAllowPrivateNetwork: true,
|
||||
},
|
||||
});
|
||||
|
||||
const hover = await postJson<{ ok: boolean }>(`${base}/act`, {
|
||||
kind: "hover",
|
||||
ref: "2",
|
||||
});
|
||||
expect(hover.ok).toBe(true);
|
||||
expect(pwMocks.hoverViaPlaywright).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
cdpUrl: state.cdpBaseUrl,
|
||||
targetId: "abcd1234",
|
||||
ref: "2",
|
||||
}),
|
||||
);
|
||||
const [hoverArgs] = pwMocks.hoverViaPlaywright.mock.calls[0] ?? [];
|
||||
expect((hoverArgs as { timeoutMs?: number }).timeoutMs).toBeUndefined();
|
||||
expect(pwMocks.hoverViaPlaywright).toHaveBeenCalledWith({
|
||||
cdpUrl: state.cdpBaseUrl,
|
||||
targetId: "abcd1234",
|
||||
ref: "2",
|
||||
});
|
||||
|
||||
const scroll = await postJson<{ ok: boolean }>(`${base}/act`, {
|
||||
kind: "scrollIntoView",
|
||||
ref: "2",
|
||||
});
|
||||
expect(scroll.ok).toBe(true);
|
||||
expect(pwMocks.scrollIntoViewViaPlaywright).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
cdpUrl: state.cdpBaseUrl,
|
||||
targetId: "abcd1234",
|
||||
ref: "2",
|
||||
}),
|
||||
);
|
||||
const [scrollArgs] = pwMocks.scrollIntoViewViaPlaywright.mock.calls[0] ?? [];
|
||||
expect((scrollArgs as { timeoutMs?: number }).timeoutMs).toBeUndefined();
|
||||
expect(pwMocks.scrollIntoViewViaPlaywright).toHaveBeenCalledWith({
|
||||
cdpUrl: state.cdpBaseUrl,
|
||||
targetId: "abcd1234",
|
||||
ref: "2",
|
||||
});
|
||||
|
||||
const drag = await postJson<{ ok: boolean }>(`${base}/act`, {
|
||||
kind: "drag",
|
||||
@@ -203,15 +179,11 @@ describe("browser control server", () => {
|
||||
endRef: "4",
|
||||
});
|
||||
expect(drag.ok).toBe(true);
|
||||
expect(pwMocks.dragViaPlaywright).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
cdpUrl: state.cdpBaseUrl,
|
||||
targetId: "abcd1234",
|
||||
startRef: "3",
|
||||
endRef: "4",
|
||||
}),
|
||||
);
|
||||
const [dragArgs] = pwMocks.dragViaPlaywright.mock.calls[0] ?? [];
|
||||
expect((dragArgs as { timeoutMs?: number }).timeoutMs).toBeUndefined();
|
||||
expect(pwMocks.dragViaPlaywright).toHaveBeenCalledWith({
|
||||
cdpUrl: state.cdpBaseUrl,
|
||||
targetId: "abcd1234",
|
||||
startRef: "3",
|
||||
endRef: "4",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -95,206 +95,50 @@ export function getCdpMocks(): { createTargetViaCdp: MockFn; snapshotAria: MockF
|
||||
return cdpMocks as unknown as { createTargetViaCdp: MockFn; snapshotAria: MockFn };
|
||||
}
|
||||
|
||||
type ExecuteActMockAction = { kind: string } & Record<string, unknown>;
|
||||
type ExecuteActMockOptions = {
|
||||
cdpUrl: string;
|
||||
action: ExecuteActMockAction;
|
||||
targetId?: string;
|
||||
ssrfPolicy?: unknown;
|
||||
evaluateEnabled?: boolean;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
type PassThroughActDispatch = {
|
||||
mock: (opts?: unknown) => Promise<unknown>;
|
||||
fields: readonly string[];
|
||||
includeSsrf?: boolean;
|
||||
includeSignal?: boolean;
|
||||
};
|
||||
|
||||
function pickActionFields(
|
||||
action: ExecuteActMockAction,
|
||||
fields: readonly string[],
|
||||
): Record<string, unknown> {
|
||||
const picked: Record<string, unknown> = {};
|
||||
for (const field of fields) {
|
||||
picked[field] = action[field];
|
||||
}
|
||||
return picked;
|
||||
}
|
||||
|
||||
function buildActPayload(params: {
|
||||
cdpUrl: string;
|
||||
targetId?: string;
|
||||
action: ExecuteActMockAction;
|
||||
fields: readonly string[];
|
||||
ssrfPolicy?: unknown;
|
||||
signal?: AbortSignal;
|
||||
includeSsrf?: boolean;
|
||||
includeSignal?: boolean;
|
||||
}): Record<string, unknown> {
|
||||
return {
|
||||
cdpUrl: params.cdpUrl,
|
||||
targetId: params.targetId,
|
||||
...pickActionFields(params.action, params.fields),
|
||||
...(params.includeSsrf ? { ssrfPolicy: params.ssrfPolicy } : {}),
|
||||
...(params.includeSignal ? { signal: params.signal } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
const pwMocks = vi.hoisted(() => ({
|
||||
armDialogViaPlaywright: vi.fn(async () => {}),
|
||||
armFileUploadViaPlaywright: vi.fn(async () => {}),
|
||||
batchViaPlaywright: vi.fn(async (_opts?: unknown) => ({ results: [] })),
|
||||
clickViaPlaywright: vi.fn(async (_opts?: unknown) => {}),
|
||||
closePageViaPlaywright: vi.fn(async (_opts?: unknown) => {}),
|
||||
batchViaPlaywright: vi.fn(async () => ({ results: [] })),
|
||||
clickViaPlaywright: vi.fn(async () => {}),
|
||||
closePageViaPlaywright: vi.fn(async () => {}),
|
||||
closePlaywrightBrowserConnection: vi.fn(async () => {}),
|
||||
downloadViaPlaywright: vi.fn(async () => ({
|
||||
url: "https://example.com/report.pdf",
|
||||
suggestedFilename: "report.pdf",
|
||||
path: "/tmp/report.pdf",
|
||||
})),
|
||||
dragViaPlaywright: vi.fn(async (_opts?: unknown) => {}),
|
||||
evaluateViaPlaywright: vi.fn(async (_opts?: unknown) => "ok"),
|
||||
fillFormViaPlaywright: vi.fn(async (_opts?: unknown) => {}),
|
||||
dragViaPlaywright: vi.fn(async () => {}),
|
||||
evaluateViaPlaywright: vi.fn(async () => "ok"),
|
||||
fillFormViaPlaywright: vi.fn(async () => {}),
|
||||
getConsoleMessagesViaPlaywright: vi.fn(async () => []),
|
||||
hoverViaPlaywright: vi.fn(async (_opts?: unknown) => {}),
|
||||
scrollIntoViewViaPlaywright: vi.fn(async (_opts?: unknown) => {}),
|
||||
hoverViaPlaywright: vi.fn(async () => {}),
|
||||
scrollIntoViewViaPlaywright: vi.fn(async () => {}),
|
||||
navigateViaPlaywright: vi.fn(async () => ({ url: "https://example.com" })),
|
||||
pdfViaPlaywright: vi.fn(async () => ({ buffer: Buffer.from("pdf") })),
|
||||
pressKeyViaPlaywright: vi.fn(async (_opts?: unknown) => {}),
|
||||
pressKeyViaPlaywright: vi.fn(async () => {}),
|
||||
responseBodyViaPlaywright: vi.fn(async () => ({
|
||||
url: "https://example.com/api/data",
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: '{"ok":true}',
|
||||
})),
|
||||
resizeViewportViaPlaywright: vi.fn(async (_opts?: unknown) => {}),
|
||||
selectOptionViaPlaywright: vi.fn(async (_opts?: unknown) => {}),
|
||||
resizeViewportViaPlaywright: vi.fn(async () => {}),
|
||||
selectOptionViaPlaywright: vi.fn(async () => {}),
|
||||
setInputFilesViaPlaywright: vi.fn(async () => {}),
|
||||
snapshotAiViaPlaywright: vi.fn(async () => ({ snapshot: "ok" })),
|
||||
traceStopViaPlaywright: vi.fn(async () => {}),
|
||||
takeScreenshotViaPlaywright: vi.fn(async () => ({
|
||||
buffer: Buffer.from("png"),
|
||||
})),
|
||||
typeViaPlaywright: vi.fn(async (_opts?: unknown) => {}),
|
||||
typeViaPlaywright: vi.fn(async () => {}),
|
||||
waitForDownloadViaPlaywright: vi.fn(async () => ({
|
||||
url: "https://example.com/report.pdf",
|
||||
suggestedFilename: "report.pdf",
|
||||
path: "/tmp/report.pdf",
|
||||
})),
|
||||
waitForViaPlaywright: vi.fn(async (_opts?: unknown) => {}),
|
||||
executeActViaPlaywright: vi.fn(async (_opts?: ExecuteActMockOptions) => ({})),
|
||||
waitForViaPlaywright: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
const passThroughActDispatch: Record<string, PassThroughActDispatch> = {
|
||||
click: {
|
||||
mock: pwMocks.clickViaPlaywright,
|
||||
fields: ["ref", "selector", "doubleClick", "button", "modifiers", "delayMs", "timeoutMs"],
|
||||
includeSsrf: true,
|
||||
},
|
||||
type: {
|
||||
mock: pwMocks.typeViaPlaywright,
|
||||
fields: ["ref", "selector", "text", "submit", "slowly", "timeoutMs"],
|
||||
includeSsrf: true,
|
||||
},
|
||||
press: {
|
||||
mock: pwMocks.pressKeyViaPlaywright,
|
||||
fields: ["key", "delayMs"],
|
||||
includeSsrf: true,
|
||||
},
|
||||
hover: {
|
||||
mock: pwMocks.hoverViaPlaywright,
|
||||
fields: ["ref", "selector", "timeoutMs"],
|
||||
},
|
||||
scrollIntoView: {
|
||||
mock: pwMocks.scrollIntoViewViaPlaywright,
|
||||
fields: ["ref", "selector", "timeoutMs"],
|
||||
},
|
||||
drag: {
|
||||
mock: pwMocks.dragViaPlaywright,
|
||||
fields: ["startRef", "startSelector", "endRef", "endSelector", "timeoutMs"],
|
||||
},
|
||||
select: {
|
||||
mock: pwMocks.selectOptionViaPlaywright,
|
||||
fields: ["ref", "selector", "values", "timeoutMs"],
|
||||
},
|
||||
fill: {
|
||||
mock: pwMocks.fillFormViaPlaywright,
|
||||
fields: ["fields", "timeoutMs"],
|
||||
},
|
||||
resize: {
|
||||
mock: pwMocks.resizeViewportViaPlaywright,
|
||||
fields: ["width", "height"],
|
||||
},
|
||||
wait: {
|
||||
mock: pwMocks.waitForViaPlaywright,
|
||||
fields: ["timeMs", "text", "textGone", "selector", "url", "loadState", "fn", "timeoutMs"],
|
||||
includeSignal: true,
|
||||
},
|
||||
close: {
|
||||
mock: pwMocks.closePageViaPlaywright,
|
||||
fields: [],
|
||||
},
|
||||
};
|
||||
|
||||
pwMocks.executeActViaPlaywright.mockImplementation(
|
||||
async (opts: ExecuteActMockOptions | undefined) => {
|
||||
if (!opts) {
|
||||
return {};
|
||||
}
|
||||
const { cdpUrl, action, targetId, ssrfPolicy, evaluateEnabled, signal } = opts;
|
||||
const spec = passThroughActDispatch[action.kind];
|
||||
if (spec) {
|
||||
await spec.mock(
|
||||
buildActPayload({
|
||||
cdpUrl,
|
||||
targetId,
|
||||
action,
|
||||
fields: spec.fields,
|
||||
ssrfPolicy,
|
||||
signal,
|
||||
includeSsrf: spec.includeSsrf,
|
||||
includeSignal: spec.includeSignal,
|
||||
}),
|
||||
);
|
||||
return {};
|
||||
}
|
||||
|
||||
switch (action.kind) {
|
||||
case "evaluate": {
|
||||
if (!evaluateEnabled) {
|
||||
throw new Error("act:evaluate is disabled by config (browser.evaluateEnabled=false)");
|
||||
}
|
||||
const result = await pwMocks.evaluateViaPlaywright({
|
||||
cdpUrl,
|
||||
targetId,
|
||||
ssrfPolicy,
|
||||
fn: action.fn,
|
||||
ref: action.ref,
|
||||
timeoutMs: action.timeoutMs,
|
||||
signal,
|
||||
});
|
||||
return { result };
|
||||
}
|
||||
case "batch": {
|
||||
const result = await pwMocks.batchViaPlaywright({
|
||||
cdpUrl,
|
||||
targetId,
|
||||
actions: action.actions,
|
||||
stopOnError: action.stopOnError,
|
||||
evaluateEnabled,
|
||||
ssrfPolicy,
|
||||
signal,
|
||||
});
|
||||
return { results: result.results };
|
||||
}
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export function getPwMocks(): Record<string, MockFn> {
|
||||
return pwMocks as unknown as Record<string, MockFn>;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { resolvePreferredOpenClawTmpDir } from "../api.js";
|
||||
import { DiffArtifactStore } from "./store.js";
|
||||
|
||||
export async function createTempDiffRoot(prefix: string): Promise<{
|
||||
rootDir: string;
|
||||
cleanup: () => Promise<void>;
|
||||
}> {
|
||||
const rootDir = await fs.mkdtemp(path.join(resolvePreferredOpenClawTmpDir(), prefix));
|
||||
const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
return {
|
||||
rootDir,
|
||||
cleanup: async () => {
|
||||
|
||||
@@ -248,9 +248,9 @@ function createBoundThreadBindingManager(params: {
|
||||
|
||||
function createDispatchSpy() {
|
||||
const dispatchSpy = vi
|
||||
.fn<typeof dispatcherModule.dispatchReplyWithDispatcher>()
|
||||
.spyOn(dispatcherModule, "dispatchReplyWithDispatcher")
|
||||
.mockResolvedValue({} as never);
|
||||
nativeCommandTesting.setDispatchReplyWithDispatcher(dispatchSpy);
|
||||
nativeCommandTesting.setDispatchReplyWithDispatcher(dispatcherModule.dispatchReplyWithDispatcher);
|
||||
return dispatchSpy;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
export type { RuntimeEnv } from "../runtime-api.js";
|
||||
export { safeEqualSecret } from "openclaw/plugin-sdk/browser-security-runtime";
|
||||
export { applyBasicWebhookRequestGuards } from "openclaw/plugin-sdk/webhook-ingress";
|
||||
export {
|
||||
installRequestBodyLimitGuard,
|
||||
readWebhookBodyOrReject,
|
||||
} from "openclaw/plugin-sdk/webhook-request-guards";
|
||||
applyBasicWebhookRequestGuards,
|
||||
isRequestBodyLimitError,
|
||||
readRequestBodyWithLimit,
|
||||
requestBodyErrorToText,
|
||||
} from "openclaw/plugin-sdk/webhook-ingress";
|
||||
export { installRequestBodyLimitGuard } from "openclaw/plugin-sdk/webhook-request-guards";
|
||||
|
||||
@@ -4,9 +4,11 @@ import * as Lark from "@larksuiteoapi/node-sdk";
|
||||
import { createFeishuWSClient } from "./client.js";
|
||||
import {
|
||||
applyBasicWebhookRequestGuards,
|
||||
isRequestBodyLimitError,
|
||||
type RuntimeEnv,
|
||||
installRequestBodyLimitGuard,
|
||||
readWebhookBodyOrReject,
|
||||
readRequestBodyWithLimit,
|
||||
requestBodyErrorToText,
|
||||
safeEqualSecret,
|
||||
} from "./monitor-transport-runtime-api.js";
|
||||
import {
|
||||
@@ -188,20 +190,13 @@ export async function monitorWebhook({
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const body = await readWebhookBodyOrReject({
|
||||
req,
|
||||
res,
|
||||
const rawBody = await readRequestBodyWithLimit(req, {
|
||||
maxBytes: FEISHU_WEBHOOK_MAX_BODY_BYTES,
|
||||
timeoutMs: FEISHU_WEBHOOK_BODY_TIMEOUT_MS,
|
||||
profile: "pre-auth",
|
||||
});
|
||||
if (!body.ok || res.writableEnded) {
|
||||
if (guard.isTripped() || res.writableEnded) {
|
||||
return;
|
||||
}
|
||||
if (guard.isTripped()) {
|
||||
return;
|
||||
}
|
||||
const rawBody = body.value;
|
||||
|
||||
// Reject invalid signatures before any JSON parsing to keep the auth boundary strict.
|
||||
if (
|
||||
@@ -240,9 +235,17 @@ export async function monitorWebhook({
|
||||
res.end(JSON.stringify(value));
|
||||
}
|
||||
} catch (err) {
|
||||
error(`feishu[${accountId}]: webhook handler error: ${String(err)}`);
|
||||
if (!res.headersSent) {
|
||||
respondText(res, 500, "Internal Server Error");
|
||||
if (isRequestBodyLimitError(err)) {
|
||||
if (!res.headersSent) {
|
||||
respondText(res, err.statusCode, requestBodyErrorToText(err.code));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!guard.isTripped()) {
|
||||
error(`feishu[${accountId}]: webhook handler error: ${String(err)}`);
|
||||
if (!res.headersSent) {
|
||||
respondText(res, 500, "Internal Server Error");
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
guard.dispose();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/core";
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
|
||||
import type { PluginRuntime } from "../runtime-api.js";
|
||||
|
||||
const { setRuntime: setFeishuRuntime, getRuntime: getFeishuRuntime } =
|
||||
createPluginRuntimeStore<PluginRuntime>("Feishu runtime not initialized");
|
||||
|
||||
@@ -74,7 +74,7 @@ describe("fireworks provider plugin", () => {
|
||||
expect(catalog.provider.baseUrl).toBe(FIREWORKS_BASE_URL);
|
||||
expect(catalog.provider.models?.map((model) => model.id)).toEqual([FIREWORKS_DEFAULT_MODEL_ID]);
|
||||
expect(catalog.provider.models?.[0]).toMatchObject({
|
||||
reasoning: false,
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
contextWindow: FIREWORKS_DEFAULT_CONTEXT_WINDOW,
|
||||
maxTokens: FIREWORKS_DEFAULT_MAX_TOKENS,
|
||||
@@ -112,64 +112,4 @@ describe("fireworks provider plugin", () => {
|
||||
reasoning: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("disables reasoning metadata for Fireworks Kimi dynamic models", async () => {
|
||||
const provider = await registerSingleProviderPlugin(fireworksPlugin);
|
||||
const resolved = provider.resolveDynamicModel?.(
|
||||
createDynamicContext({
|
||||
provider: "fireworks",
|
||||
modelId: "accounts/fireworks/models/kimi-k2p5",
|
||||
models: [
|
||||
{
|
||||
id: FIREWORKS_DEFAULT_MODEL_ID,
|
||||
name: FIREWORKS_DEFAULT_MODEL_ID,
|
||||
provider: "fireworks",
|
||||
api: "openai-completions",
|
||||
baseUrl: FIREWORKS_BASE_URL,
|
||||
reasoning: false,
|
||||
input: ["text", "image"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: FIREWORKS_DEFAULT_CONTEXT_WINDOW,
|
||||
maxTokens: FIREWORKS_DEFAULT_MAX_TOKENS,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(resolved).toMatchObject({
|
||||
provider: "fireworks",
|
||||
id: "accounts/fireworks/models/kimi-k2p5",
|
||||
reasoning: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("disables reasoning metadata for Fireworks Kimi k2.5 aliases", async () => {
|
||||
const provider = await registerSingleProviderPlugin(fireworksPlugin);
|
||||
const resolved = provider.resolveDynamicModel?.(
|
||||
createDynamicContext({
|
||||
provider: "fireworks",
|
||||
modelId: "accounts/fireworks/routers/kimi-k2.5-turbo",
|
||||
models: [
|
||||
{
|
||||
id: FIREWORKS_DEFAULT_MODEL_ID,
|
||||
name: FIREWORKS_DEFAULT_MODEL_ID,
|
||||
provider: "fireworks",
|
||||
api: "openai-completions",
|
||||
baseUrl: FIREWORKS_BASE_URL,
|
||||
reasoning: false,
|
||||
input: ["text", "image"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: FIREWORKS_DEFAULT_CONTEXT_WINDOW,
|
||||
maxTokens: FIREWORKS_DEFAULT_MAX_TOKENS,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(resolved).toMatchObject({
|
||||
provider: "fireworks",
|
||||
id: "accounts/fireworks/routers/kimi-k2.5-turbo",
|
||||
reasoning: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
DEFAULT_CONTEXT_TOKENS,
|
||||
normalizeModelCompat,
|
||||
} from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import { isFireworksKimiModelId } from "./model-id.js";
|
||||
import { applyFireworksConfig, FIREWORKS_DEFAULT_MODEL_REF } from "./onboard.js";
|
||||
import {
|
||||
buildFireworksProvider,
|
||||
@@ -15,7 +14,6 @@ import {
|
||||
FIREWORKS_DEFAULT_MAX_TOKENS,
|
||||
FIREWORKS_DEFAULT_MODEL_ID,
|
||||
} from "./provider-catalog.js";
|
||||
import { wrapFireworksProviderStream } from "./stream.js";
|
||||
|
||||
const PROVIDER_ID = "fireworks";
|
||||
const OPENAI_COMPATIBLE_REPLAY_HOOKS = buildProviderReplayFamilyHooks({
|
||||
@@ -36,7 +34,6 @@ function resolveFireworksDynamicModel(ctx: ProviderResolveDynamicModelContext) {
|
||||
ctx,
|
||||
patch: {
|
||||
provider: PROVIDER_ID,
|
||||
reasoning: !isFireworksKimiModelId(modelId),
|
||||
},
|
||||
}) ??
|
||||
normalizeModelCompat({
|
||||
@@ -45,7 +42,7 @@ function resolveFireworksDynamicModel(ctx: ProviderResolveDynamicModelContext) {
|
||||
provider: PROVIDER_ID,
|
||||
api: "openai-completions",
|
||||
baseUrl: FIREWORKS_BASE_URL,
|
||||
reasoning: !isFireworksKimiModelId(modelId),
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: FIREWORKS_DEFAULT_CONTEXT_WINDOW,
|
||||
@@ -80,7 +77,6 @@ export default defineSingleProviderPluginEntry({
|
||||
allowExplicitBaseUrl: true,
|
||||
},
|
||||
...OPENAI_COMPATIBLE_REPLAY_HOOKS,
|
||||
wrapStreamFn: wrapFireworksProviderStream,
|
||||
resolveDynamicModel: (ctx) => resolveFireworksDynamicModel(ctx),
|
||||
isModernModelRef: () => true,
|
||||
},
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
export function isFireworksKimiModelId(modelId: string): boolean {
|
||||
const normalized = modelId.trim().toLowerCase();
|
||||
const lastSegment = normalized.split("/").pop() ?? normalized;
|
||||
return /^kimi-k2(?:p5|\.5)(?:[-_].+)?$/.test(lastSegment);
|
||||
}
|
||||
@@ -20,7 +20,7 @@ export function buildFireworksCatalogModels(): ModelDefinitionConfig[] {
|
||||
{
|
||||
id: FIREWORKS_DEFAULT_MODEL_ID,
|
||||
name: "Kimi K2.5 Turbo (Fire Pass)",
|
||||
reasoning: false, // Kimi K2.5 can expose reasoning in visible content on FirePass.
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
cost: ZERO_COST,
|
||||
contextWindow: FIREWORKS_DEFAULT_CONTEXT_WINDOW,
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
import type { StreamFn } from "@mariozechner/pi-agent-core";
|
||||
import type { Context, Model } from "@mariozechner/pi-ai";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createFireworksKimiThinkingDisabledWrapper,
|
||||
wrapFireworksProviderStream,
|
||||
} from "./stream.js";
|
||||
|
||||
function capturePayload(params: {
|
||||
provider: string;
|
||||
api: string;
|
||||
modelId: string;
|
||||
initialPayload?: Record<string, unknown>;
|
||||
}): Record<string, unknown> {
|
||||
let captured: Record<string, unknown> = {};
|
||||
const baseStreamFn: StreamFn = (_model, _context, options) => {
|
||||
const payload = { ...params.initialPayload };
|
||||
options?.onPayload?.(payload, _model);
|
||||
captured = payload;
|
||||
return {} as ReturnType<StreamFn>;
|
||||
};
|
||||
|
||||
const wrapped = createFireworksKimiThinkingDisabledWrapper(baseStreamFn);
|
||||
void wrapped(
|
||||
{
|
||||
api: params.api,
|
||||
provider: params.provider,
|
||||
id: params.modelId,
|
||||
} as Model<"openai-completions">,
|
||||
{ messages: [] } as Context,
|
||||
{},
|
||||
);
|
||||
|
||||
return captured;
|
||||
}
|
||||
|
||||
describe("createFireworksKimiThinkingDisabledWrapper", () => {
|
||||
it("forces thinking disabled for Fireworks Kimi models", () => {
|
||||
expect(
|
||||
capturePayload({
|
||||
provider: "fireworks",
|
||||
api: "openai-completions",
|
||||
modelId: "accounts/fireworks/routers/kimi-k2p5-turbo",
|
||||
}),
|
||||
).toMatchObject({ thinking: { type: "disabled" } });
|
||||
});
|
||||
|
||||
it("forces thinking disabled for Fireworks Kimi k2.5 aliases", () => {
|
||||
expect(
|
||||
capturePayload({
|
||||
provider: "fireworks",
|
||||
api: "openai-completions",
|
||||
modelId: "accounts/fireworks/routers/kimi-k2.5-turbo",
|
||||
}),
|
||||
).toMatchObject({ thinking: { type: "disabled" } });
|
||||
});
|
||||
|
||||
it("strips reasoning fields when disabling Fireworks Kimi thinking", () => {
|
||||
const payload = capturePayload({
|
||||
provider: "fireworks",
|
||||
api: "openai-completions",
|
||||
modelId: "accounts/fireworks/models/kimi-k2p5",
|
||||
initialPayload: {
|
||||
reasoning_effort: "low",
|
||||
reasoning: { effort: "low" },
|
||||
reasoningEffort: "low",
|
||||
},
|
||||
});
|
||||
|
||||
expect(payload).toEqual({ thinking: { type: "disabled" } });
|
||||
});
|
||||
|
||||
it("passes sanitized payloads to caller onPayload hooks", () => {
|
||||
let callbackPayload: Record<string, unknown> = {};
|
||||
const baseStreamFn: StreamFn = (_model, _context, options) => {
|
||||
const payload = {
|
||||
reasoning_effort: "high",
|
||||
reasoning: { effort: "high" },
|
||||
};
|
||||
options?.onPayload?.(payload, _model);
|
||||
return {} as ReturnType<StreamFn>;
|
||||
};
|
||||
|
||||
const wrapped = createFireworksKimiThinkingDisabledWrapper(baseStreamFn);
|
||||
void wrapped(
|
||||
{
|
||||
api: "openai-completions",
|
||||
provider: "fireworks",
|
||||
id: "accounts/fireworks/routers/kimi-k2p5-turbo",
|
||||
} as Model<"openai-completions">,
|
||||
{ messages: [] } as Context,
|
||||
{
|
||||
onPayload: (payload) => {
|
||||
callbackPayload = payload as Record<string, unknown>;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(callbackPayload).toEqual({ thinking: { type: "disabled" } });
|
||||
});
|
||||
|
||||
it("returns no provider wrapper for non-target Fireworks requests", () => {
|
||||
expect(
|
||||
wrapFireworksProviderStream({
|
||||
provider: "fireworks",
|
||||
modelId: "accounts/fireworks/models/qwen3.6-plus",
|
||||
model: {
|
||||
api: "openai-completions",
|
||||
provider: "fireworks",
|
||||
id: "accounts/fireworks/models/qwen3.6-plus",
|
||||
} as Model<"openai-completions">,
|
||||
streamFn: undefined,
|
||||
} as never),
|
||||
).toBeUndefined();
|
||||
|
||||
expect(
|
||||
wrapFireworksProviderStream({
|
||||
provider: "fireworks",
|
||||
modelId: "accounts/fireworks/routers/kimi-k2p5-turbo",
|
||||
model: {
|
||||
api: "openai-responses",
|
||||
provider: "fireworks",
|
||||
id: "accounts/fireworks/routers/kimi-k2p5-turbo",
|
||||
} as Model<"openai-responses">,
|
||||
streamFn: undefined,
|
||||
} as never),
|
||||
).toBeUndefined();
|
||||
|
||||
expect(
|
||||
wrapFireworksProviderStream({
|
||||
provider: "fireworks-ai",
|
||||
modelId: "accounts/fireworks/routers/kimi-k2p5-turbo",
|
||||
model: {
|
||||
api: "openai-completions",
|
||||
provider: "fireworks-ai",
|
||||
id: "accounts/fireworks/routers/kimi-k2p5-turbo",
|
||||
} as Model<"openai-completions">,
|
||||
streamFn: undefined,
|
||||
} as never),
|
||||
).toBeTypeOf("function");
|
||||
|
||||
expect(
|
||||
wrapFireworksProviderStream({
|
||||
provider: "openai",
|
||||
modelId: "gpt-5.4",
|
||||
model: {
|
||||
api: "openai-completions",
|
||||
provider: "openai",
|
||||
id: "gpt-5.4",
|
||||
} as Model<"openai-completions">,
|
||||
streamFn: undefined,
|
||||
} as never),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,39 +0,0 @@
|
||||
import type { StreamFn } from "@mariozechner/pi-agent-core";
|
||||
import { streamSimple } from "@mariozechner/pi-ai";
|
||||
import type { ProviderWrapStreamFnContext } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import { streamWithPayloadPatch } from "openclaw/plugin-sdk/provider-stream-shared";
|
||||
import { isFireworksKimiModelId } from "./model-id.js";
|
||||
|
||||
function isFireworksProviderId(providerId: string): boolean {
|
||||
const normalized = normalizeProviderId(providerId);
|
||||
return normalized === "fireworks" || normalized === "fireworks-ai";
|
||||
}
|
||||
|
||||
export function createFireworksKimiThinkingDisabledWrapper(
|
||||
baseStreamFn: StreamFn | undefined,
|
||||
): StreamFn {
|
||||
const underlying = baseStreamFn ?? streamSimple;
|
||||
return (model, context, options) =>
|
||||
streamWithPayloadPatch(underlying, model, context, options, (payloadObj) => {
|
||||
// Fireworks Kimi can emit chain-of-thought in visible `content` unless
|
||||
// the Anthropic-style thinking toggle is explicitly disabled.
|
||||
payloadObj.thinking = { type: "disabled" };
|
||||
delete payloadObj.reasoning;
|
||||
delete payloadObj.reasoning_effort;
|
||||
delete payloadObj.reasoningEffort;
|
||||
});
|
||||
}
|
||||
|
||||
export function wrapFireworksProviderStream(
|
||||
ctx: ProviderWrapStreamFnContext,
|
||||
): StreamFn | undefined {
|
||||
if (
|
||||
!isFireworksProviderId(ctx.provider) ||
|
||||
ctx.model?.api !== "openai-completions" ||
|
||||
!isFireworksKimiModelId(ctx.modelId)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return createFireworksKimiThinkingDisabledWrapper(ctx.streamFn);
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { GoogleGenAI } from "@google/genai";
|
||||
import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth";
|
||||
import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime";
|
||||
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
||||
import type {
|
||||
GeneratedVideoAsset,
|
||||
@@ -124,9 +124,7 @@ async function downloadGeneratedVideo(params: {
|
||||
file: unknown;
|
||||
index: number;
|
||||
}): Promise<GeneratedVideoAsset> {
|
||||
const tempDir = await mkdtemp(
|
||||
path.join(resolvePreferredOpenClawTmpDir(), "openclaw-google-video-"),
|
||||
);
|
||||
const tempDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-google-video-"));
|
||||
const downloadPath = path.join(tempDir, `video-${params.index + 1}.mp4`);
|
||||
try {
|
||||
await params.client.files.download({
|
||||
|
||||
@@ -104,22 +104,6 @@ describe("imessage monitor gating + envelope builders", () => {
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("parseIMessageNotification preserves destination_caller_id metadata", () => {
|
||||
expect(
|
||||
parseIMessageNotification({
|
||||
message: {
|
||||
id: 1,
|
||||
sender: "+15550001111",
|
||||
destination_caller_id: "+15550002222",
|
||||
is_from_me: true,
|
||||
text: "hello",
|
||||
},
|
||||
}),
|
||||
).toMatchObject({
|
||||
destination_caller_id: "+15550002222",
|
||||
});
|
||||
});
|
||||
|
||||
it("drops group messages without mention by default", () => {
|
||||
const decision = resolve({
|
||||
message: {
|
||||
|
||||
@@ -170,7 +170,6 @@ export function resolveIMessageInboundDecision(params: {
|
||||
const chatId = params.message.chat_id ?? undefined;
|
||||
const chatGuid = params.message.chat_guid ?? undefined;
|
||||
const chatIdentifier = params.message.chat_identifier ?? undefined;
|
||||
const destinationCallerId = params.message.destination_caller_id ?? undefined;
|
||||
const createdAt = params.message.created_at ? Date.parse(params.message.created_at) : undefined;
|
||||
const messageText = params.messageText.trim();
|
||||
const bodyText = params.bodyText.trim();
|
||||
@@ -204,16 +203,14 @@ export function resolveIMessageInboundDecision(params: {
|
||||
text: bodyText,
|
||||
createdAt,
|
||||
};
|
||||
const chatIdentifierNormalized = normalizeIMessageHandle(chatIdentifier ?? "") || undefined;
|
||||
const destinationCallerIdNormalized =
|
||||
normalizeIMessageHandle(destinationCallerId ?? "") || undefined;
|
||||
const matchesSelfChatDestination =
|
||||
destinationCallerIdNormalized == null || destinationCallerIdNormalized === senderNormalized;
|
||||
// Self-chat detection: in self-chat, sender == chat_identifier (both are the
|
||||
// user's own handle). When is_from_me=true in self-chat, the message could be
|
||||
// either: (a) a real user message typed by the user, or (b) an agent reply
|
||||
// echo reflected back by iMessage. We must distinguish them.
|
||||
const isSelfChat =
|
||||
!isGroup &&
|
||||
chatIdentifierNormalized != null &&
|
||||
senderNormalized === chatIdentifierNormalized &&
|
||||
matchesSelfChatDestination;
|
||||
chatIdentifier != null &&
|
||||
normalizeIMessageHandle(sender) === normalizeIMessageHandle(chatIdentifier);
|
||||
// Track whether we already processed the is_from_me=true self-chat path.
|
||||
// When true, the selfChatCache.has() check below must be skipped — we just
|
||||
// called remember() and would immediately match our own entry.
|
||||
|
||||
@@ -61,7 +61,6 @@ export function parseIMessageNotification(raw: unknown): IMessagePayload | null
|
||||
!isOptionalString(message.guid) ||
|
||||
!isOptionalNumber(message.chat_id) ||
|
||||
!isOptionalString(message.sender) ||
|
||||
!isOptionalString(message.destination_caller_id) ||
|
||||
!isOptionalBoolean(message.is_from_me) ||
|
||||
!isOptionalString(message.text) ||
|
||||
!isOptionalStringOrNumber(message.reply_to_id) ||
|
||||
|
||||
@@ -344,6 +344,7 @@ describe("self-chat is_from_me=true handling (Bruce Phase 2 fix)", () => {
|
||||
});
|
||||
|
||||
it("processes real user self-chat message (is_from_me=true, no echo cache match)", () => {
|
||||
// User sends "Hello" to themselves — is_from_me=true, sender==chat_identifier
|
||||
const echoCache = createSentMessageCache();
|
||||
const selfChatCache = createSelfChatCache();
|
||||
|
||||
@@ -353,7 +354,6 @@ describe("self-chat is_from_me=true handling (Bruce Phase 2 fix)", () => {
|
||||
id: 123703,
|
||||
sender: "+15551234567",
|
||||
chat_identifier: "+15551234567",
|
||||
destination_caller_id: "+15551234567",
|
||||
text: "Hello this is a test message",
|
||||
is_from_me: true,
|
||||
is_group: false,
|
||||
@@ -365,60 +365,10 @@ describe("self-chat is_from_me=true handling (Bruce Phase 2 fix)", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
// Real user message — should be dispatched, not dropped
|
||||
expect(decision.kind).toBe("dispatch");
|
||||
});
|
||||
|
||||
it("treats blank destination_caller_id as missing for real self-chat", () => {
|
||||
const echoCache = createSentMessageCache();
|
||||
const selfChatCache = createSelfChatCache();
|
||||
|
||||
const decision = resolveIMessageInboundDecision(
|
||||
createParams({
|
||||
message: {
|
||||
id: 123704,
|
||||
sender: "+15551234567",
|
||||
chat_identifier: "+15551234567",
|
||||
destination_caller_id: "",
|
||||
text: "Hello this is a test message",
|
||||
is_from_me: true,
|
||||
is_group: false,
|
||||
},
|
||||
messageText: "Hello this is a test message",
|
||||
bodyText: "Hello this is a test message",
|
||||
echoCache,
|
||||
selfChatCache,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(decision.kind).toBe("dispatch");
|
||||
});
|
||||
|
||||
it("drops DM false positives even when participant lists include the local handle", () => {
|
||||
const echoCache = createSentMessageCache();
|
||||
const selfChatCache = createSelfChatCache();
|
||||
|
||||
const decision = resolveIMessageInboundDecision(
|
||||
createParams({
|
||||
message: {
|
||||
id: 123705,
|
||||
sender: "+15551234567",
|
||||
chat_identifier: "+15551234567",
|
||||
destination_caller_id: "me@icloud.com",
|
||||
participants: ["+15551234567", "me@icloud.com"],
|
||||
text: "Hello from a normal DM row",
|
||||
is_from_me: true,
|
||||
is_group: false,
|
||||
},
|
||||
messageText: "Hello from a normal DM row",
|
||||
bodyText: "Hello from a normal DM row",
|
||||
echoCache,
|
||||
selfChatCache,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(decision).toEqual({ kind: "drop", reason: "from me" });
|
||||
});
|
||||
|
||||
it("drops agent reply echo in self-chat (is_from_me=true, echo cache text match)", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-03-24T12:00:00Z"));
|
||||
@@ -625,36 +575,7 @@ describe("self-chat is_from_me=true handling (Bruce Phase 2 fix)", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(decision).toEqual({ kind: "drop", reason: "from me" });
|
||||
});
|
||||
|
||||
it("uses destination_caller_id to avoid DM self-chat false positives", () => {
|
||||
const echoCache = createSentMessageCache();
|
||||
const selfChatCache = createSelfChatCache();
|
||||
|
||||
echoCache.remember("default:imessage:+15551234567", {
|
||||
text: "Clean outbound text",
|
||||
messageId: "p:0/GUID-outbound",
|
||||
});
|
||||
|
||||
const decision = resolveIMessageInboundDecision(
|
||||
createParams({
|
||||
message: {
|
||||
id: 10001,
|
||||
sender: "+15551234567",
|
||||
chat_identifier: "+15551234567",
|
||||
destination_caller_id: "+15550001111",
|
||||
text: "<22>\u0001corrupted stored text",
|
||||
is_from_me: true,
|
||||
is_group: false,
|
||||
},
|
||||
messageText: "<22>\u0001corrupted stored text",
|
||||
bodyText: "<22>\u0001corrupted stored text",
|
||||
echoCache,
|
||||
selfChatCache,
|
||||
}),
|
||||
);
|
||||
|
||||
// sender != chat_identifier → not self-chat → dropped as "from me"
|
||||
expect(decision).toEqual({ kind: "drop", reason: "from me" });
|
||||
});
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ export type IMessagePayload = {
|
||||
guid?: string | null;
|
||||
chat_id?: number | null;
|
||||
sender?: string | null;
|
||||
destination_caller_id?: string | null;
|
||||
is_from_me?: boolean | null;
|
||||
text?: string | null;
|
||||
reply_to_id?: number | string | null;
|
||||
|
||||
2
extensions/matrix/legacy-crypto-inspector.ts
Normal file
2
extensions/matrix/legacy-crypto-inspector.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export type { MatrixLegacyCryptoInspectionResult } from "./src/matrix/legacy-crypto-inspector.js";
|
||||
export { inspectLegacyMatrixCryptoStore } from "./src/matrix/legacy-crypto-inspector.js";
|
||||
@@ -1,36 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const LEGACY_CRYPTO_INSPECTOR_BASENAME_RE = /^legacy-crypto-inspector(?:[-.].*)?\.js$/u;
|
||||
|
||||
function hasSourceInspectorArtifact(currentDir: string): boolean {
|
||||
return [
|
||||
path.resolve(currentDir, "matrix", "legacy-crypto-inspector.ts"),
|
||||
path.resolve(currentDir, "matrix", "legacy-crypto-inspector.js"),
|
||||
].some((candidate) => fs.existsSync(candidate));
|
||||
}
|
||||
|
||||
function hasBuiltInspectorArtifact(currentDir: string): boolean {
|
||||
if (fs.existsSync(path.join(currentDir, "legacy-crypto-inspector.js"))) {
|
||||
return true;
|
||||
}
|
||||
if (fs.existsSync(path.join(currentDir, "extensions", "matrix", "legacy-crypto-inspector.js"))) {
|
||||
return true;
|
||||
}
|
||||
return fs
|
||||
.readdirSync(currentDir, { withFileTypes: true })
|
||||
.some((entry) => entry.isFile() && LEGACY_CRYPTO_INSPECTOR_BASENAME_RE.test(entry.name));
|
||||
}
|
||||
|
||||
export function isMatrixLegacyCryptoInspectorAvailable(): boolean {
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
if (hasSourceInspectorArtifact(currentDir)) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
return hasBuiltInspectorArtifact(currentDir);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,8 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { withTempHome } from "../../../test/helpers/temp-home.js";
|
||||
|
||||
const legacyCryptoInspectorAvailability = vi.hoisted(() => ({
|
||||
available: true,
|
||||
}));
|
||||
|
||||
vi.mock("./legacy-crypto-inspector-availability.js", () => ({
|
||||
isMatrixLegacyCryptoInspectorAvailable: () => legacyCryptoInspectorAvailability.available,
|
||||
}));
|
||||
|
||||
import { autoPrepareLegacyMatrixCrypto, detectLegacyMatrixCrypto } from "./legacy-crypto.js";
|
||||
import { resolveMatrixAccountStorageRoot } from "./storage-paths.js";
|
||||
import {
|
||||
@@ -83,9 +74,7 @@ function createOpsLegacyCryptoFixture(params: {
|
||||
}
|
||||
|
||||
describe("matrix legacy encrypted-state migration", () => {
|
||||
afterEach(() => {
|
||||
legacyCryptoInspectorAvailability.available = true;
|
||||
});
|
||||
afterEach(() => {});
|
||||
|
||||
it("extracts a saved backup key into the new recovery-key path", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
@@ -202,31 +191,4 @@ describe("matrix legacy encrypted-state migration", () => {
|
||||
expect(fs.existsSync(path.join(rootDir, "recovery-key.json"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("stays warning-only when the legacy crypto inspector artifact is unavailable", async () => {
|
||||
legacyCryptoInspectorAvailability.available = false;
|
||||
|
||||
await withTempHome(async (home) => {
|
||||
const { cfg } = writeDefaultLegacyCryptoFixture(home);
|
||||
|
||||
const detection = detectLegacyMatrixCrypto({ cfg, env: process.env });
|
||||
expect(detection.plans).toHaveLength(1);
|
||||
expect(detection.warnings).toContain(
|
||||
"Legacy Matrix encrypted state was detected, but the Matrix crypto inspector is unavailable.",
|
||||
);
|
||||
|
||||
const result = await autoPrepareLegacyMatrixCrypto({
|
||||
cfg,
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
migrated: false,
|
||||
changes: [],
|
||||
warnings: [
|
||||
"Legacy Matrix encrypted state was detected, but the Matrix crypto inspector is unavailable.",
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,6 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { writeJsonFileAtomically as writeJsonFileAtomicallyImpl } from "openclaw/plugin-sdk/json-store";
|
||||
import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
|
||||
import { resolveConfiguredMatrixAccountIds } from "./account-selection.js";
|
||||
import { isMatrixLegacyCryptoInspectorAvailable } from "./legacy-crypto-inspector-availability.js";
|
||||
import { formatMatrixErrorMessage } from "./matrix/errors.js";
|
||||
import {
|
||||
resolveLegacyMatrixFlatStoreTarget,
|
||||
@@ -109,6 +108,10 @@ type MatrixStoredRecoveryKey = {
|
||||
};
|
||||
};
|
||||
|
||||
function isMatrixLegacyCryptoInspectorAvailable(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
async function loadMatrixLegacyCryptoInspector(): Promise<MatrixLegacyCryptoInspector> {
|
||||
const module = await import("./matrix/legacy-crypto-inspector.js");
|
||||
return module.inspectLegacyMatrixCryptoStore as MatrixLegacyCryptoInspector;
|
||||
@@ -359,18 +362,6 @@ export async function autoPrepareLegacyMatrixCrypto(params: {
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
if (!params.deps?.inspectLegacyStore && !isMatrixLegacyCryptoInspectorAvailable()) {
|
||||
if (warnings.length > 0) {
|
||||
params.log?.warn?.(
|
||||
`matrix: legacy encrypted-state warnings:\n${warnings.map((entry) => `- ${entry}`).join("\n")}`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
migrated: false,
|
||||
changes,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
let inspectLegacyStore = params.deps?.inspectLegacyStore;
|
||||
if (!inspectLegacyStore) {
|
||||
|
||||
@@ -2,15 +2,6 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { withTempHome } from "../../../test/helpers/temp-home.js";
|
||||
|
||||
const legacyCryptoInspectorAvailability = vi.hoisted(() => ({
|
||||
available: true,
|
||||
}));
|
||||
|
||||
vi.mock("./legacy-crypto-inspector-availability.js", () => ({
|
||||
isMatrixLegacyCryptoInspectorAvailable: () => legacyCryptoInspectorAvailability.available,
|
||||
}));
|
||||
|
||||
import { detectLegacyMatrixCrypto } from "./legacy-crypto.js";
|
||||
import {
|
||||
hasActionableMatrixMigration,
|
||||
@@ -25,7 +16,6 @@ const createBackupArchiveMock = vi.hoisted(() => vi.fn());
|
||||
describe("matrix migration snapshots", () => {
|
||||
beforeEach(() => {
|
||||
createBackupArchiveMock.mockReset();
|
||||
legacyCryptoInspectorAvailability.available = true;
|
||||
createBackupArchiveMock.mockImplementation(
|
||||
async (params: { output?: string; includeWorkspace?: boolean }) => {
|
||||
const outputDir = params.output;
|
||||
@@ -134,49 +124,4 @@ describe("matrix migration snapshots", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps legacy Matrix crypto pending but not actionable when the inspector artifact is unavailable", async () => {
|
||||
legacyCryptoInspectorAvailability.available = false;
|
||||
|
||||
await withTempHome(async (home) => {
|
||||
const stateDir = path.join(home, ".openclaw");
|
||||
const { rootDir } = resolveMatrixAccountStorageRoot({
|
||||
stateDir,
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
});
|
||||
fs.mkdirSync(path.join(rootDir, "crypto"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(rootDir, "crypto", "bot-sdk.json"),
|
||||
JSON.stringify({ deviceId: "DEVICE123" }),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
},
|
||||
},
|
||||
} as never;
|
||||
|
||||
const detection = detectLegacyMatrixCrypto({
|
||||
cfg,
|
||||
env: process.env,
|
||||
});
|
||||
expect(detection.plans).toHaveLength(1);
|
||||
expect(detection.warnings).toContain(
|
||||
"Legacy Matrix encrypted state was detected, but the Matrix crypto inspector is unavailable.",
|
||||
);
|
||||
expect(
|
||||
hasActionableMatrixMigration({
|
||||
cfg,
|
||||
env: process.env,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { isMatrixLegacyCryptoInspectorAvailable } from "./legacy-crypto-inspector-availability.js";
|
||||
import { detectLegacyMatrixCrypto } from "./legacy-crypto.js";
|
||||
import { detectLegacyMatrixState } from "./legacy-state.js";
|
||||
import {
|
||||
@@ -9,6 +8,10 @@ import {
|
||||
type MatrixMigrationSnapshotResult,
|
||||
} from "./migration-snapshot-backup.js";
|
||||
|
||||
function isMatrixLegacyCryptoInspectorAvailable(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
export function hasPendingMatrixMigration(params: {
|
||||
cfg: OpenClawConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
|
||||
@@ -4,7 +4,6 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { resolveMemoryRemDreamingConfig } from "openclaw/plugin-sdk/memory-core-host-status";
|
||||
import { buildAgentSessionKey } from "openclaw/plugin-sdk/routing";
|
||||
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
|
||||
import {
|
||||
colorize,
|
||||
defaultRuntime,
|
||||
@@ -159,9 +158,7 @@ async function createHistoricalRemHarnessWorkspace(params: {
|
||||
skippedPaths: string[];
|
||||
}> {
|
||||
const sourceFiles = await listHistoricalDailyFiles(params.inputPath);
|
||||
const workspaceDir = await fs.mkdtemp(
|
||||
path.join(resolvePreferredOpenClawTmpDir(), "openclaw-rem-harness-"),
|
||||
);
|
||||
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-rem-harness-"));
|
||||
const memoryDir = path.join(workspaceDir, "memory");
|
||||
await fs.mkdir(memoryDir, { recursive: true });
|
||||
for (const filePath of sourceFiles) {
|
||||
@@ -1723,9 +1720,7 @@ export async function runMemoryRemBackfill(opts: MemoryRemBackfillOptions) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scratchDir = await fs.mkdtemp(
|
||||
path.join(resolvePreferredOpenClawTmpDir(), "openclaw-rem-backfill-"),
|
||||
);
|
||||
const scratchDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-rem-backfill-"));
|
||||
try {
|
||||
const sourceFiles = await listHistoricalDailyFiles(opts.path);
|
||||
if (sourceFiles.length === 0) {
|
||||
|
||||
@@ -52,17 +52,13 @@ function createHarness(initialConfig: OpenClawConfig = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function createCommandContext(
|
||||
args?: string,
|
||||
overrides?: Partial<Pick<PluginCommandContext, "gatewayClientScopes">>,
|
||||
): PluginCommandContext {
|
||||
function createCommandContext(args?: string): PluginCommandContext {
|
||||
return {
|
||||
channel: "webchat",
|
||||
isAuthorizedSender: true,
|
||||
commandBody: args ? `/dreaming ${args}` : "/dreaming",
|
||||
args,
|
||||
config: {},
|
||||
gatewayClientScopes: overrides?.gatewayClientScopes,
|
||||
requestConversationBinding: async () => ({ status: "error", message: "unsupported" }),
|
||||
detachConversationBinding: async () => ({ removed: false }),
|
||||
getCurrentConversationBinding: async () => null,
|
||||
@@ -119,48 +115,6 @@ describe("memory-core /dreaming command", () => {
|
||||
expect(result.text).toContain("Dreaming disabled.");
|
||||
});
|
||||
|
||||
it("blocks unscoped gateway callers from persisting dreaming config", async () => {
|
||||
const { command, runtime } = createHarness();
|
||||
|
||||
const result = await command.handler(
|
||||
createCommandContext("off", {
|
||||
gatewayClientScopes: [],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.text).toContain("requires operator.admin");
|
||||
expect(runtime.config.writeConfigFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks write-scoped gateway callers from persisting dreaming config", async () => {
|
||||
const { command, runtime } = createHarness();
|
||||
|
||||
const result = await command.handler(
|
||||
createCommandContext("off", {
|
||||
gatewayClientScopes: ["operator.write"],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.text).toContain("requires operator.admin");
|
||||
expect(runtime.config.writeConfigFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows admin-scoped gateway callers to persist dreaming config", async () => {
|
||||
const { command, runtime, getRuntimeConfig } = createHarness();
|
||||
|
||||
const result = await command.handler(
|
||||
createCommandContext("on", {
|
||||
gatewayClientScopes: ["operator.admin"],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(runtime.config.writeConfigFile).toHaveBeenCalledTimes(1);
|
||||
expect(resolveStoredDreaming(getRuntimeConfig())).toMatchObject({
|
||||
enabled: true,
|
||||
});
|
||||
expect(result.text).toContain("Dreaming enabled.");
|
||||
});
|
||||
|
||||
it("returns status without mutating config", async () => {
|
||||
const { command, runtime } = createHarness({
|
||||
plugins: {
|
||||
|
||||
@@ -75,10 +75,6 @@ function formatUsage(includeStatus: string): string {
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function requiresAdminToMutateDreaming(gatewayClientScopes?: readonly string[]): boolean {
|
||||
return Array.isArray(gatewayClientScopes) && !gatewayClientScopes.includes("operator.admin");
|
||||
}
|
||||
|
||||
export function registerDreamingCommand(api: OpenClawPluginApi): void {
|
||||
api.registerCommand({
|
||||
name: "dreaming",
|
||||
@@ -106,9 +102,6 @@ export function registerDreamingCommand(api: OpenClawPluginApi): void {
|
||||
}
|
||||
|
||||
if (firstToken === "on" || firstToken === "off") {
|
||||
if (requiresAdminToMutateDreaming(ctx.gatewayClientScopes)) {
|
||||
return { text: "⚠️ /dreaming on|off requires operator.admin for gateway clients." };
|
||||
}
|
||||
const enabled = firstToken === "on";
|
||||
const nextConfig = updateDreamingEnabledInConfig(currentConfig, enabled);
|
||||
await api.runtime.config.writeConfigFile(nextConfig);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
appendNarrativeEntry,
|
||||
buildBackfillDiaryEntry,
|
||||
@@ -18,10 +18,6 @@ import { createMemoryCoreTestHarness } from "./test-helpers.js";
|
||||
|
||||
const { createTempWorkspace } = createMemoryCoreTestHarness();
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("buildNarrativePrompt", () => {
|
||||
it("builds a prompt from snippets only", () => {
|
||||
const data: NarrativePhaseData = {
|
||||
@@ -316,64 +312,6 @@ describe("appendNarrativeEntry", () => {
|
||||
// Original content should still be there, after the diary.
|
||||
expect(content).toContain("# Existing");
|
||||
});
|
||||
|
||||
it("keeps existing diary content intact when the atomic replace fails", async () => {
|
||||
const workspaceDir = await createTempWorkspace("openclaw-dreaming-narrative-");
|
||||
const dreamsPath = path.join(workspaceDir, "DREAMS.md");
|
||||
await fs.writeFile(dreamsPath, "# Existing\n", "utf-8");
|
||||
const renameError = Object.assign(new Error("replace failed"), { code: "ENOSPC" });
|
||||
const renameSpy = vi.spyOn(fs, "rename").mockRejectedValueOnce(renameError);
|
||||
|
||||
await expect(
|
||||
appendNarrativeEntry({
|
||||
workspaceDir,
|
||||
narrative: "Appended dream.",
|
||||
nowMs: Date.parse("2026-04-05T03:00:00Z"),
|
||||
timezone: "UTC",
|
||||
}),
|
||||
).rejects.toThrow("replace failed");
|
||||
|
||||
expect(renameSpy).toHaveBeenCalledOnce();
|
||||
await expect(fs.readFile(dreamsPath, "utf-8")).resolves.toBe("# Existing\n");
|
||||
});
|
||||
|
||||
it("preserves restrictive dreams file permissions across atomic replace", async () => {
|
||||
const workspaceDir = await createTempWorkspace("openclaw-dreaming-narrative-");
|
||||
const dreamsPath = path.join(workspaceDir, "DREAMS.md");
|
||||
await fs.writeFile(dreamsPath, "# Existing\n", { encoding: "utf-8", mode: 0o600 });
|
||||
await fs.chmod(dreamsPath, 0o600);
|
||||
|
||||
await appendNarrativeEntry({
|
||||
workspaceDir,
|
||||
narrative: "Appended dream.",
|
||||
nowMs: Date.parse("2026-04-05T03:00:00Z"),
|
||||
timezone: "UTC",
|
||||
});
|
||||
|
||||
const stat = await fs.stat(dreamsPath);
|
||||
expect(stat.mode & 0o777).toBe(0o600);
|
||||
});
|
||||
|
||||
it("surfaces temp cleanup failure after atomic replace error", async () => {
|
||||
const workspaceDir = await createTempWorkspace("openclaw-dreaming-narrative-");
|
||||
const dreamsPath = path.join(workspaceDir, "DREAMS.md");
|
||||
await fs.writeFile(dreamsPath, "# Existing\n", "utf-8");
|
||||
vi.spyOn(fs, "rename").mockRejectedValueOnce(
|
||||
Object.assign(new Error("replace failed"), { code: "ENOSPC" }),
|
||||
);
|
||||
vi.spyOn(fs, "rm").mockRejectedValueOnce(
|
||||
Object.assign(new Error("cleanup failed"), { code: "EACCES" }),
|
||||
);
|
||||
|
||||
await expect(
|
||||
appendNarrativeEntry({
|
||||
workspaceDir,
|
||||
narrative: "Appended dream.",
|
||||
nowMs: Date.parse("2026-04-05T03:00:00Z"),
|
||||
timezone: "UTC",
|
||||
}),
|
||||
).rejects.toThrow("cleanup also failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateAndAppendDreamNarrative", () => {
|
||||
@@ -403,8 +341,6 @@ describe("generateAndAppendDreamNarrative", () => {
|
||||
const workspaceDir = await createTempWorkspace("openclaw-dreaming-narrative-");
|
||||
const subagent = createMockSubagent("The repository whispered of forgotten endpoints.");
|
||||
const logger = createMockLogger();
|
||||
const nowMs = Date.parse("2026-04-05T03:00:00Z");
|
||||
const expectedSessionKey = `dreaming-narrative-light-${nowMs}`;
|
||||
|
||||
await generateAndAppendDreamNarrative({
|
||||
subagent,
|
||||
@@ -413,15 +349,13 @@ describe("generateAndAppendDreamNarrative", () => {
|
||||
phase: "light",
|
||||
snippets: ["API endpoints need authentication"],
|
||||
},
|
||||
nowMs,
|
||||
nowMs: Date.parse("2026-04-05T03:00:00Z"),
|
||||
timezone: "UTC",
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(subagent.run).toHaveBeenCalledOnce();
|
||||
expect(subagent.run.mock.calls[0][0]).toMatchObject({
|
||||
idempotencyKey: expectedSessionKey,
|
||||
sessionKey: expectedSessionKey,
|
||||
deliver: false,
|
||||
});
|
||||
expect(subagent.waitForRun).toHaveBeenCalledOnce();
|
||||
|
||||
@@ -6,7 +6,6 @@ import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
|
||||
type SubagentSurface = {
|
||||
run: (params: {
|
||||
idempotencyKey: string;
|
||||
sessionKey: string;
|
||||
message: string;
|
||||
extraSystemPrompt?: string;
|
||||
@@ -278,27 +277,12 @@ async function assertSafeDreamsPath(dreamsPath: string): Promise<void> {
|
||||
|
||||
async function writeDreamsFileAtomic(dreamsPath: string, content: string): Promise<void> {
|
||||
await assertSafeDreamsPath(dreamsPath);
|
||||
const existing = await fs.stat(dreamsPath).catch((err: NodeJS.ErrnoException) => {
|
||||
if (err.code === "ENOENT") {
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
const mode = existing?.mode ?? 0o600;
|
||||
const tempPath = `${dreamsPath}.${process.pid}.${Date.now()}.tmp`;
|
||||
await fs.writeFile(tempPath, content, { encoding: "utf-8", flag: "wx", mode });
|
||||
await fs.chmod(tempPath, mode).catch(() => undefined);
|
||||
await fs.writeFile(tempPath, content, { encoding: "utf-8", flag: "wx" });
|
||||
try {
|
||||
await fs.rename(tempPath, dreamsPath);
|
||||
await fs.chmod(dreamsPath, mode).catch(() => undefined);
|
||||
} catch (err) {
|
||||
const cleanupError = await fs.rm(tempPath, { force: true }).catch((rmErr) => rmErr);
|
||||
if (cleanupError) {
|
||||
throw new Error(
|
||||
`Atomic DREAMS.md write failed (${formatErrorMessage(err)}); cleanup also failed (${formatErrorMessage(cleanupError)})`,
|
||||
{ cause: err },
|
||||
);
|
||||
}
|
||||
await fs.rm(tempPath, { force: true }).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -425,7 +409,7 @@ export async function appendNarrativeEntry(params: {
|
||||
}
|
||||
}
|
||||
|
||||
await writeDreamsFileAtomic(dreamsPath, updated.endsWith("\n") ? updated : `${updated}\n`);
|
||||
await fs.writeFile(dreamsPath, updated.endsWith("\n") ? updated : `${updated}\n`, "utf-8");
|
||||
return dreamsPath;
|
||||
}
|
||||
|
||||
@@ -450,7 +434,6 @@ export async function generateAndAppendDreamNarrative(params: {
|
||||
|
||||
try {
|
||||
const { runId } = await params.subagent.run({
|
||||
idempotencyKey: sessionKey,
|
||||
sessionKey,
|
||||
message,
|
||||
extraSystemPrompt: NARRATIVE_SYSTEM_PROMPT,
|
||||
|
||||
@@ -2,16 +2,9 @@ import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/memory-core";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
clearInternalHooks,
|
||||
createInternalHookEvent,
|
||||
registerInternalHook,
|
||||
triggerInternalHook,
|
||||
} from "../../../src/hooks/internal-hooks.js";
|
||||
import {
|
||||
__testing,
|
||||
reconcileShortTermDreamingCronJob,
|
||||
registerShortTermPromotionDreaming,
|
||||
resolveShortTermPromotionDreamingConfig,
|
||||
runShortTermDreamingPromotionIfTriggered,
|
||||
} from "./dreaming.js";
|
||||
@@ -25,15 +18,6 @@ type CronParam = NonNullable<Parameters<typeof reconcileShortTermDreamingCronJob
|
||||
type CronJobLike = Awaited<ReturnType<CronParam["list"]>>[number];
|
||||
type CronAddInput = Parameters<CronParam["add"]>[0];
|
||||
type CronPatch = Parameters<CronParam["update"]>[1];
|
||||
type DreamingPluginApi = Parameters<typeof registerShortTermPromotionDreaming>[0];
|
||||
type DreamingPluginApiTestDouble = {
|
||||
config: OpenClawConfig;
|
||||
pluginConfig: Record<string, unknown>;
|
||||
logger: ReturnType<typeof createLogger>;
|
||||
runtime: unknown;
|
||||
registerHook: (event: string, handler: Parameters<typeof registerInternalHook>[1]) => void;
|
||||
on: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
function createLogger() {
|
||||
return {
|
||||
@@ -58,14 +42,12 @@ function createCronHarness(
|
||||
opts?: { removeResult?: "boolean" | "unknown"; removeThrowsForIds?: string[] },
|
||||
) {
|
||||
const jobs: CronJobLike[] = [...initialJobs];
|
||||
let listCalls = 0;
|
||||
const addCalls: CronAddInput[] = [];
|
||||
const updateCalls: Array<{ id: string; patch: CronPatch }> = [];
|
||||
const removeCalls: string[] = [];
|
||||
|
||||
const cron: CronParam = {
|
||||
async list() {
|
||||
listCalls += 1;
|
||||
return jobs.map((job) => ({
|
||||
...job,
|
||||
...(job.schedule ? { schedule: { ...job.schedule } } : {}),
|
||||
@@ -122,36 +104,7 @@ function createCronHarness(
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
cron,
|
||||
jobs,
|
||||
addCalls,
|
||||
updateCalls,
|
||||
removeCalls,
|
||||
get listCalls() {
|
||||
return listCalls;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function getBeforeAgentReplyHandler(
|
||||
onMock: ReturnType<typeof vi.fn>,
|
||||
): (
|
||||
event: { cleanedBody: string },
|
||||
ctx: { trigger?: string; workspaceDir?: string },
|
||||
) => Promise<unknown> {
|
||||
const call = onMock.mock.calls.find(([eventName]) => eventName === "before_agent_reply");
|
||||
if (!call) {
|
||||
throw new Error("before_agent_reply hook was not registered");
|
||||
}
|
||||
return call[1] as (
|
||||
event: { cleanedBody: string },
|
||||
ctx: { trigger?: string; workspaceDir?: string },
|
||||
) => Promise<unknown>;
|
||||
}
|
||||
|
||||
function registerShortTermPromotionDreamingForTest(api: DreamingPluginApiTestDouble): void {
|
||||
registerShortTermPromotionDreaming(api as unknown as DreamingPluginApi);
|
||||
return { cron, jobs, addCalls, updateCalls, removeCalls };
|
||||
}
|
||||
|
||||
describe("short-term dreaming config", () => {
|
||||
@@ -708,410 +661,6 @@ describe("short-term dreaming cron reconciliation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("gateway startup reconciliation", () => {
|
||||
it("uses the startup cfg when reconciling the managed dreaming cron job", async () => {
|
||||
clearInternalHooks();
|
||||
const logger = createLogger();
|
||||
const harness = createCronHarness();
|
||||
const api: DreamingPluginApiTestDouble = {
|
||||
config: { plugins: { entries: {} } },
|
||||
pluginConfig: {},
|
||||
logger,
|
||||
runtime: {},
|
||||
registerHook: (event: string, handler: Parameters<typeof registerInternalHook>[1]) => {
|
||||
registerInternalHook(event, handler);
|
||||
},
|
||||
on: vi.fn(),
|
||||
};
|
||||
|
||||
try {
|
||||
registerShortTermPromotionDreamingForTest(api);
|
||||
await triggerInternalHook(
|
||||
createInternalHookEvent("gateway", "startup", "gateway:startup", {
|
||||
cfg: {
|
||||
hooks: { internal: { enabled: true } },
|
||||
plugins: {
|
||||
entries: {
|
||||
"memory-core": {
|
||||
config: {
|
||||
dreaming: {
|
||||
enabled: true,
|
||||
frequency: "15 4 * * *",
|
||||
timezone: "UTC",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
deps: { cron: harness.cron },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(harness.addCalls).toHaveLength(1);
|
||||
expect(harness.addCalls[0]).toMatchObject({
|
||||
schedule: {
|
||||
kind: "cron",
|
||||
expr: "15 4 * * *",
|
||||
tz: "UTC",
|
||||
},
|
||||
});
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
expect.stringContaining("created managed dreaming cron job"),
|
||||
);
|
||||
} finally {
|
||||
clearInternalHooks();
|
||||
}
|
||||
});
|
||||
|
||||
it("reconciles disabled->enabled config changes during runtime", async () => {
|
||||
clearInternalHooks();
|
||||
const logger = createLogger();
|
||||
const harness = createCronHarness();
|
||||
const onMock = vi.fn();
|
||||
const api: DreamingPluginApiTestDouble = {
|
||||
config: {
|
||||
plugins: {
|
||||
entries: {
|
||||
"memory-core": {
|
||||
config: {
|
||||
dreaming: {
|
||||
enabled: false,
|
||||
frequency: "0 2 * * *",
|
||||
timezone: "UTC",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
pluginConfig: {},
|
||||
logger,
|
||||
runtime: {},
|
||||
registerHook: (event: string, handler: Parameters<typeof registerInternalHook>[1]) => {
|
||||
registerInternalHook(event, handler);
|
||||
},
|
||||
on: onMock,
|
||||
};
|
||||
|
||||
try {
|
||||
registerShortTermPromotionDreamingForTest(api);
|
||||
const deps = { cron: harness.cron };
|
||||
await triggerInternalHook(
|
||||
createInternalHookEvent("gateway", "startup", "gateway:startup", {
|
||||
cfg: api.config,
|
||||
deps,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(harness.addCalls).toHaveLength(0);
|
||||
|
||||
api.config = {
|
||||
plugins: {
|
||||
entries: {
|
||||
"memory-core": {
|
||||
config: {
|
||||
dreaming: {
|
||||
enabled: true,
|
||||
frequency: "30 6 * * *",
|
||||
timezone: "America/New_York",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const beforeAgentReply = getBeforeAgentReplyHandler(onMock);
|
||||
await beforeAgentReply(
|
||||
{ cleanedBody: constants.DREAMING_SYSTEM_EVENT_TEXT },
|
||||
{ trigger: "heartbeat", workspaceDir: "." },
|
||||
);
|
||||
|
||||
expect(harness.addCalls).toHaveLength(1);
|
||||
expect(harness.addCalls[0]?.schedule).toMatchObject({
|
||||
kind: "cron",
|
||||
expr: "30 6 * * *",
|
||||
tz: "America/New_York",
|
||||
});
|
||||
} finally {
|
||||
clearInternalHooks();
|
||||
}
|
||||
});
|
||||
|
||||
it("reconciles cadence/timezone updates against the active cron service after startup", async () => {
|
||||
clearInternalHooks();
|
||||
const logger = createLogger();
|
||||
const startupHarness = createCronHarness();
|
||||
const onMock = vi.fn();
|
||||
const api: DreamingPluginApiTestDouble = {
|
||||
config: {
|
||||
plugins: {
|
||||
entries: {
|
||||
"memory-core": {
|
||||
config: {
|
||||
dreaming: {
|
||||
enabled: true,
|
||||
frequency: "0 1 * * *",
|
||||
timezone: "UTC",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
pluginConfig: {},
|
||||
logger,
|
||||
runtime: {},
|
||||
registerHook: (event: string, handler: Parameters<typeof registerInternalHook>[1]) => {
|
||||
registerInternalHook(event, handler);
|
||||
},
|
||||
on: onMock,
|
||||
};
|
||||
|
||||
try {
|
||||
registerShortTermPromotionDreamingForTest(api);
|
||||
const deps = { cron: startupHarness.cron };
|
||||
await triggerInternalHook(
|
||||
createInternalHookEvent("gateway", "startup", "gateway:startup", {
|
||||
cfg: api.config,
|
||||
deps,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(startupHarness.addCalls).toHaveLength(1);
|
||||
const managed = startupHarness.jobs.find((job) =>
|
||||
job.description?.includes("[managed-by=memory-core.short-term-promotion]"),
|
||||
);
|
||||
expect(managed).toBeDefined();
|
||||
|
||||
const reloadedHarness = createCronHarness(
|
||||
managed
|
||||
? [
|
||||
{
|
||||
...managed,
|
||||
schedule: managed.schedule ? { ...managed.schedule } : undefined,
|
||||
payload: managed.payload ? { ...managed.payload } : undefined,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
deps.cron = reloadedHarness.cron;
|
||||
api.config = {
|
||||
plugins: {
|
||||
entries: {
|
||||
"memory-core": {
|
||||
config: {
|
||||
dreaming: {
|
||||
enabled: true,
|
||||
frequency: "45 8 * * *",
|
||||
timezone: "America/Los_Angeles",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const beforeAgentReply = getBeforeAgentReplyHandler(onMock);
|
||||
await beforeAgentReply(
|
||||
{ cleanedBody: constants.DREAMING_SYSTEM_EVENT_TEXT },
|
||||
{ trigger: "heartbeat", workspaceDir: "." },
|
||||
);
|
||||
|
||||
expect(startupHarness.updateCalls).toHaveLength(0);
|
||||
expect(reloadedHarness.updateCalls).toHaveLength(1);
|
||||
expect(reloadedHarness.updateCalls[0]?.patch.schedule).toMatchObject({
|
||||
kind: "cron",
|
||||
expr: "45 8 * * *",
|
||||
tz: "America/Los_Angeles",
|
||||
});
|
||||
} finally {
|
||||
clearInternalHooks();
|
||||
}
|
||||
});
|
||||
|
||||
it("recreates the managed cron job when it is removed after startup", async () => {
|
||||
clearInternalHooks();
|
||||
const logger = createLogger();
|
||||
const harness = createCronHarness();
|
||||
const onMock = vi.fn();
|
||||
const api: DreamingPluginApiTestDouble = {
|
||||
config: {
|
||||
plugins: {
|
||||
entries: {
|
||||
"memory-core": {
|
||||
config: {
|
||||
dreaming: {
|
||||
enabled: true,
|
||||
frequency: "0 2 * * *",
|
||||
timezone: "UTC",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
pluginConfig: {},
|
||||
logger,
|
||||
runtime: {},
|
||||
registerHook: (event: string, handler: Parameters<typeof registerInternalHook>[1]) => {
|
||||
registerInternalHook(event, handler);
|
||||
},
|
||||
on: onMock,
|
||||
};
|
||||
|
||||
try {
|
||||
registerShortTermPromotionDreamingForTest(api);
|
||||
await triggerInternalHook(
|
||||
createInternalHookEvent("gateway", "startup", "gateway:startup", {
|
||||
cfg: api.config,
|
||||
deps: { cron: harness.cron },
|
||||
}),
|
||||
);
|
||||
expect(harness.addCalls).toHaveLength(1);
|
||||
|
||||
harness.jobs.splice(
|
||||
0,
|
||||
harness.jobs.length,
|
||||
...harness.jobs.filter(
|
||||
(job) => !job.description?.includes("[managed-by=memory-core.short-term-promotion]"),
|
||||
),
|
||||
);
|
||||
expect(harness.jobs).toHaveLength(0);
|
||||
|
||||
const beforeAgentReply = getBeforeAgentReplyHandler(onMock);
|
||||
await beforeAgentReply(
|
||||
{ cleanedBody: constants.DREAMING_SYSTEM_EVENT_TEXT },
|
||||
{ trigger: "heartbeat", workspaceDir: "." },
|
||||
);
|
||||
|
||||
expect(harness.addCalls).toHaveLength(2);
|
||||
expect(harness.addCalls[1]?.schedule).toMatchObject({
|
||||
kind: "cron",
|
||||
expr: "0 2 * * *",
|
||||
tz: "UTC",
|
||||
});
|
||||
} finally {
|
||||
clearInternalHooks();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not reconcile managed cron on non-heartbeat runtime replies", async () => {
|
||||
clearInternalHooks();
|
||||
const logger = createLogger();
|
||||
const harness = createCronHarness();
|
||||
const onMock = vi.fn();
|
||||
const api: DreamingPluginApiTestDouble = {
|
||||
config: {
|
||||
plugins: {
|
||||
entries: {
|
||||
"memory-core": {
|
||||
config: {
|
||||
dreaming: {
|
||||
enabled: true,
|
||||
frequency: "0 2 * * *",
|
||||
timezone: "UTC",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
pluginConfig: {},
|
||||
logger,
|
||||
runtime: {},
|
||||
registerHook: (event: string, handler: Parameters<typeof registerInternalHook>[1]) => {
|
||||
registerInternalHook(event, handler);
|
||||
},
|
||||
on: onMock,
|
||||
};
|
||||
|
||||
try {
|
||||
registerShortTermPromotionDreamingForTest(api);
|
||||
await triggerInternalHook(
|
||||
createInternalHookEvent("gateway", "startup", "gateway:startup", {
|
||||
cfg: api.config,
|
||||
deps: { cron: harness.cron },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(harness.listCalls).toBe(1);
|
||||
|
||||
const beforeAgentReply = getBeforeAgentReplyHandler(onMock);
|
||||
await beforeAgentReply({ cleanedBody: "hello" }, { trigger: "user", workspaceDir: "." });
|
||||
await beforeAgentReply(
|
||||
{ cleanedBody: "hello again" },
|
||||
{ trigger: "user", workspaceDir: "." },
|
||||
);
|
||||
|
||||
expect(harness.listCalls).toBe(1);
|
||||
} finally {
|
||||
clearInternalHooks();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not reconcile managed cron on every repeated runtime heartbeat", async () => {
|
||||
clearInternalHooks();
|
||||
const logger = createLogger();
|
||||
const harness = createCronHarness();
|
||||
const onMock = vi.fn();
|
||||
const now = Date.parse("2026-04-10T12:00:00Z");
|
||||
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(now);
|
||||
const api: DreamingPluginApiTestDouble = {
|
||||
config: {
|
||||
plugins: {
|
||||
entries: {
|
||||
"memory-core": {
|
||||
config: {
|
||||
dreaming: {
|
||||
enabled: true,
|
||||
frequency: "0 2 * * *",
|
||||
timezone: "UTC",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
pluginConfig: {},
|
||||
logger,
|
||||
runtime: {},
|
||||
registerHook: (event: string, handler: Parameters<typeof registerInternalHook>[1]) => {
|
||||
registerInternalHook(event, handler);
|
||||
},
|
||||
on: onMock,
|
||||
};
|
||||
|
||||
try {
|
||||
registerShortTermPromotionDreamingForTest(api);
|
||||
await triggerInternalHook(
|
||||
createInternalHookEvent("gateway", "startup", "gateway:startup", {
|
||||
cfg: api.config,
|
||||
deps: { cron: harness.cron },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(harness.listCalls).toBe(1);
|
||||
|
||||
const beforeAgentReply = getBeforeAgentReplyHandler(onMock);
|
||||
await beforeAgentReply(
|
||||
{ cleanedBody: constants.DREAMING_SYSTEM_EVENT_TEXT },
|
||||
{ trigger: "heartbeat", workspaceDir: "." },
|
||||
);
|
||||
await beforeAgentReply(
|
||||
{ cleanedBody: constants.DREAMING_SYSTEM_EVENT_TEXT },
|
||||
{ trigger: "heartbeat", workspaceDir: "." },
|
||||
);
|
||||
|
||||
expect(harness.listCalls).toBe(2);
|
||||
} finally {
|
||||
nowSpy.mockRestore();
|
||||
clearInternalHooks();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("short-term dreaming trigger", () => {
|
||||
it("applies promotions when the managed dreaming heartbeat event fires", async () => {
|
||||
const logger = createLogger();
|
||||
|
||||
@@ -35,7 +35,6 @@ const LEGACY_LIGHT_SLEEP_EVENT_TEXT = "__openclaw_memory_core_light_sleep__";
|
||||
const LEGACY_REM_SLEEP_CRON_NAME = "Memory REM Dreaming";
|
||||
const LEGACY_REM_SLEEP_CRON_TAG = "[managed-by=memory-core.dreaming.rem]";
|
||||
const LEGACY_REM_SLEEP_EVENT_TEXT = "__openclaw_memory_core_rem_sleep__";
|
||||
const RUNTIME_CRON_RECONCILE_INTERVAL_MS = 60_000;
|
||||
|
||||
type Logger = Pick<OpenClawPluginApi["logger"], "info" | "warn" | "error">;
|
||||
|
||||
@@ -87,11 +86,6 @@ type CronServiceLike = {
|
||||
remove: (id: string) => Promise<{ removed?: boolean }>;
|
||||
};
|
||||
|
||||
type StartupCronSourceRefs = {
|
||||
context: Record<string, unknown>;
|
||||
deps: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export type ShortTermPromotionDreamingConfig = {
|
||||
enabled: boolean;
|
||||
cron: string;
|
||||
@@ -287,11 +281,21 @@ function sortManagedJobs(managed: ManagedCronJobLike[]): ManagedCronJobLike[] {
|
||||
});
|
||||
}
|
||||
|
||||
function resolveCronServiceFromCandidate(candidate: unknown): CronServiceLike | null {
|
||||
if (!candidate || typeof candidate !== "object") {
|
||||
function resolveCronServiceFromStartupEvent(event: unknown): CronServiceLike | null {
|
||||
const payload = asRecord(event);
|
||||
if (!payload) {
|
||||
return null;
|
||||
}
|
||||
const cron = candidate as Partial<CronServiceLike>;
|
||||
if (payload.type !== "gateway" || payload.action !== "startup") {
|
||||
return null;
|
||||
}
|
||||
const context = asRecord(payload.context);
|
||||
const deps = asRecord(context?.deps);
|
||||
const cronCandidate = context?.cron ?? deps?.cron;
|
||||
if (!cronCandidate || typeof cronCandidate !== "object") {
|
||||
return null;
|
||||
}
|
||||
const cron = cronCandidate as Partial<CronServiceLike>;
|
||||
if (
|
||||
typeof cron.list !== "function" ||
|
||||
typeof cron.add !== "function" ||
|
||||
@@ -303,47 +307,6 @@ function resolveCronServiceFromCandidate(candidate: unknown): CronServiceLike |
|
||||
return cron as CronServiceLike;
|
||||
}
|
||||
|
||||
function resolveStartupCronSourceFromEvent(event: unknown): StartupCronSourceRefs | null {
|
||||
const payload = asRecord(event);
|
||||
if (!payload) {
|
||||
return null;
|
||||
}
|
||||
if (payload.type !== "gateway" || payload.action !== "startup") {
|
||||
return null;
|
||||
}
|
||||
const context = asRecord(payload.context);
|
||||
if (!context) {
|
||||
return null;
|
||||
}
|
||||
return { context, deps: asRecord(context.deps) };
|
||||
}
|
||||
|
||||
function resolveCronServiceFromStartupSource(
|
||||
source: StartupCronSourceRefs | null,
|
||||
): CronServiceLike | null {
|
||||
if (!source) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
resolveCronServiceFromCandidate(source.context.cron) ??
|
||||
resolveCronServiceFromCandidate(source.deps?.cron)
|
||||
);
|
||||
}
|
||||
|
||||
function resolveCronServiceFromStartupEvent(event: unknown): CronServiceLike | null {
|
||||
return resolveCronServiceFromStartupSource(resolveStartupCronSourceFromEvent(event));
|
||||
}
|
||||
|
||||
function resolveStartupConfigFromEvent(event: unknown, fallback: OpenClawConfig): OpenClawConfig {
|
||||
const startupEvent = asRecord(event);
|
||||
const startupContext = asRecord(startupEvent?.context);
|
||||
const startupCfg = asRecord(startupContext?.cfg);
|
||||
if (!startupCfg) {
|
||||
return fallback;
|
||||
}
|
||||
return startupCfg as OpenClawConfig;
|
||||
}
|
||||
|
||||
export function resolveShortTermPromotionDreamingConfig(params: {
|
||||
pluginConfig?: Record<string, unknown>;
|
||||
cfg?: OpenClawConfig;
|
||||
@@ -617,87 +580,24 @@ export async function runShortTermDreamingPromotionIfTriggered(params: {
|
||||
}
|
||||
|
||||
export function registerShortTermPromotionDreaming(api: OpenClawPluginApi): void {
|
||||
let startupCronSource: StartupCronSourceRefs | null = null;
|
||||
let unavailableCronWarningEmitted = false;
|
||||
let lastRuntimeReconcileAtMs = 0;
|
||||
let lastRuntimeConfigKey: string | null = null;
|
||||
let lastRuntimeCronRef: CronServiceLike | null = null;
|
||||
|
||||
const runtimeConfigKey = (config: ShortTermPromotionDreamingConfig): string =>
|
||||
[
|
||||
config.enabled ? "enabled" : "disabled",
|
||||
config.cron,
|
||||
config.timezone ?? "",
|
||||
String(config.limit),
|
||||
String(config.minScore),
|
||||
String(config.minRecallCount),
|
||||
String(config.minUniqueQueries),
|
||||
String(config.recencyHalfLifeDays ?? ""),
|
||||
String(config.maxAgeDays ?? ""),
|
||||
config.verboseLogging ? "verbose" : "quiet",
|
||||
config.storage?.mode ?? "",
|
||||
config.storage?.separateReports ? "separate" : "inline",
|
||||
].join("|");
|
||||
|
||||
const reconcileManagedDreamingCron = async (params: {
|
||||
reason: "startup" | "runtime";
|
||||
startupEvent?: unknown;
|
||||
}): Promise<ShortTermPromotionDreamingConfig> => {
|
||||
const startupCfg =
|
||||
params.reason === "startup" && params.startupEvent !== undefined
|
||||
? resolveStartupConfigFromEvent(params.startupEvent, api.config)
|
||||
: api.config;
|
||||
const config = resolveShortTermPromotionDreamingConfig({
|
||||
pluginConfig:
|
||||
resolveMemoryCorePluginConfig(startupCfg) ??
|
||||
resolveMemoryCorePluginConfig(api.config) ??
|
||||
api.pluginConfig,
|
||||
cfg: startupCfg,
|
||||
});
|
||||
if (params.reason === "startup" && params.startupEvent !== undefined) {
|
||||
startupCronSource = resolveStartupCronSourceFromEvent(params.startupEvent);
|
||||
}
|
||||
const cron = resolveCronServiceFromStartupSource(startupCronSource);
|
||||
const configKey = runtimeConfigKey(config);
|
||||
if (!cron && config.enabled && !unavailableCronWarningEmitted) {
|
||||
api.logger.warn(
|
||||
"memory-core: managed dreaming cron could not be reconciled (cron service unavailable).",
|
||||
);
|
||||
unavailableCronWarningEmitted = true;
|
||||
}
|
||||
if (cron) {
|
||||
unavailableCronWarningEmitted = false;
|
||||
}
|
||||
if (params.reason === "runtime") {
|
||||
const now = Date.now();
|
||||
const withinThrottleWindow =
|
||||
now - lastRuntimeReconcileAtMs < RUNTIME_CRON_RECONCILE_INTERVAL_MS;
|
||||
if (
|
||||
withinThrottleWindow &&
|
||||
lastRuntimeConfigKey === configKey &&
|
||||
lastRuntimeCronRef === cron
|
||||
) {
|
||||
return config;
|
||||
}
|
||||
lastRuntimeReconcileAtMs = now;
|
||||
lastRuntimeConfigKey = configKey;
|
||||
lastRuntimeCronRef = cron;
|
||||
}
|
||||
await reconcileShortTermDreamingCronJob({
|
||||
cron,
|
||||
config,
|
||||
logger: api.logger,
|
||||
});
|
||||
return config;
|
||||
};
|
||||
|
||||
api.registerHook(
|
||||
"gateway:startup",
|
||||
async (event: unknown) => {
|
||||
try {
|
||||
await reconcileManagedDreamingCron({
|
||||
reason: "startup",
|
||||
startupEvent: event,
|
||||
const config = resolveShortTermPromotionDreamingConfig({
|
||||
pluginConfig: resolveMemoryCorePluginConfig(api.config) ?? api.pluginConfig,
|
||||
cfg: api.config,
|
||||
});
|
||||
const cron = resolveCronServiceFromStartupEvent(event);
|
||||
if (!cron && config.enabled) {
|
||||
api.logger.warn(
|
||||
"memory-core: managed dreaming cron could not be reconciled (cron service unavailable).",
|
||||
);
|
||||
}
|
||||
await reconcileShortTermDreamingCronJob({
|
||||
cron,
|
||||
config,
|
||||
logger: api.logger,
|
||||
});
|
||||
} catch (err) {
|
||||
api.logger.error(
|
||||
@@ -710,11 +610,9 @@ export function registerShortTermPromotionDreaming(api: OpenClawPluginApi): void
|
||||
|
||||
api.on("before_agent_reply", async (event, ctx) => {
|
||||
try {
|
||||
if (ctx.trigger !== "heartbeat") {
|
||||
return undefined;
|
||||
}
|
||||
const config = await reconcileManagedDreamingCron({
|
||||
reason: "runtime",
|
||||
const config = resolveShortTermPromotionDreamingConfig({
|
||||
pluginConfig: resolveMemoryCorePluginConfig(api.config) ?? api.pluginConfig,
|
||||
cfg: api.config,
|
||||
});
|
||||
return await runShortTermDreamingPromotionIfTriggered({
|
||||
cleanedBody: event.cleanedBody,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
|
||||
import { afterAll, beforeAll } from "vitest";
|
||||
|
||||
export function createMemoryCoreTestHarness() {
|
||||
@@ -8,9 +8,7 @@ export function createMemoryCoreTestHarness() {
|
||||
let caseId = 0;
|
||||
|
||||
beforeAll(async () => {
|
||||
fixtureRoot = await fs.mkdtemp(
|
||||
path.join(resolvePreferredOpenClawTmpDir(), "memory-core-test-fixtures-"),
|
||||
);
|
||||
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "memory-core-test-fixtures-"));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { validateJsonSchemaValue } from "../../src/plugins/schema-validator.js";
|
||||
import { memoryConfigSchema } from "./config.js";
|
||||
|
||||
const manifest = JSON.parse(
|
||||
fs.readFileSync(new URL("./openclaw.plugin.json", import.meta.url), "utf-8"),
|
||||
) as { configSchema: Record<string, unknown> };
|
||||
|
||||
describe("memory-lancedb config", () => {
|
||||
it("accepts dreaming in the manifest schema and preserves it in runtime parsing", () => {
|
||||
const manifestResult = validateJsonSchemaValue({
|
||||
schema: manifest.configSchema,
|
||||
cacheKey: "memory-lancedb.manifest.dreaming",
|
||||
value: {
|
||||
embedding: {
|
||||
apiKey: "sk-test",
|
||||
},
|
||||
dreaming: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const parsed = memoryConfigSchema.parse({
|
||||
embedding: {
|
||||
apiKey: "sk-test",
|
||||
},
|
||||
dreaming: {
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(manifestResult.ok).toBe(true);
|
||||
expect(parsed.dreaming).toEqual({
|
||||
enabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("still rejects unrelated unknown top-level config keys", () => {
|
||||
expect(() => {
|
||||
memoryConfigSchema.parse({
|
||||
embedding: {
|
||||
apiKey: "sk-test",
|
||||
},
|
||||
dreaming: {
|
||||
enabled: true,
|
||||
},
|
||||
unexpected: true,
|
||||
});
|
||||
}).toThrow("memory config has unknown keys: unexpected");
|
||||
});
|
||||
|
||||
it("rejects non-object dreaming values in runtime parsing", () => {
|
||||
expect(() => {
|
||||
memoryConfigSchema.parse({
|
||||
embedding: {
|
||||
apiKey: "sk-test",
|
||||
},
|
||||
dreaming: true,
|
||||
});
|
||||
}).toThrow("dreaming config must be an object");
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,6 @@ export type MemoryConfig = {
|
||||
baseUrl?: string;
|
||||
dimensions?: number;
|
||||
};
|
||||
dreaming?: Record<string, unknown>;
|
||||
dbPath?: string;
|
||||
autoCapture?: boolean;
|
||||
autoRecall?: boolean;
|
||||
@@ -98,7 +97,7 @@ export const memoryConfigSchema = {
|
||||
const cfg = value as Record<string, unknown>;
|
||||
assertAllowedKeys(
|
||||
cfg,
|
||||
["embedding", "dreaming", "dbPath", "autoCapture", "autoRecall", "captureMaxChars"],
|
||||
["embedding", "dbPath", "autoCapture", "autoRecall", "captureMaxChars"],
|
||||
"memory config",
|
||||
);
|
||||
|
||||
@@ -119,15 +118,6 @@ export const memoryConfigSchema = {
|
||||
throw new Error("captureMaxChars must be between 100 and 10000");
|
||||
}
|
||||
|
||||
const dreaming =
|
||||
typeof cfg.dreaming === "undefined"
|
||||
? undefined
|
||||
: cfg.dreaming && typeof cfg.dreaming === "object" && !Array.isArray(cfg.dreaming)
|
||||
? (cfg.dreaming as Record<string, unknown>)
|
||||
: (() => {
|
||||
throw new Error("dreaming config must be an object");
|
||||
})();
|
||||
|
||||
return {
|
||||
embedding: {
|
||||
provider: "openai",
|
||||
@@ -137,7 +127,6 @@ export const memoryConfigSchema = {
|
||||
typeof embedding.baseUrl === "string" ? resolveEnvVars(embedding.baseUrl) : undefined,
|
||||
dimensions: typeof embedding.dimensions === "number" ? embedding.dimensions : undefined,
|
||||
},
|
||||
dreaming,
|
||||
dbPath: typeof cfg.dbPath === "string" ? cfg.dbPath : DEFAULT_DB_PATH,
|
||||
autoCapture: cfg.autoCapture === true,
|
||||
autoRecall: cfg.autoRecall !== false,
|
||||
|
||||
@@ -38,10 +38,6 @@
|
||||
"label": "Auto-Recall",
|
||||
"help": "Automatically inject relevant memories into context"
|
||||
},
|
||||
"dreaming": {
|
||||
"label": "Dreaming",
|
||||
"help": "Optional dreaming config consumed when this plugin owns the memory slot"
|
||||
},
|
||||
"captureMaxChars": {
|
||||
"label": "Capture Max Chars",
|
||||
"help": "Maximum message length eligible for auto-capture",
|
||||
@@ -81,9 +77,6 @@
|
||||
"autoRecall": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"dreaming": {
|
||||
"type": "object"
|
||||
},
|
||||
"captureMaxChars": {
|
||||
"type": "number",
|
||||
"minimum": 100,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
|
||||
import { afterEach, vi } from "vitest";
|
||||
import { createTestPluginApi } from "../../../test/helpers/plugins/plugin-api.js";
|
||||
import type { OpenClawPluginApi } from "../api.js";
|
||||
@@ -37,7 +37,7 @@ export function createMemoryWikiTestHarness() {
|
||||
});
|
||||
|
||||
async function createTempDir(prefix: string): Promise<string> {
|
||||
const tempDir = await fs.mkdtemp(path.join(resolvePreferredOpenClawTmpDir(), prefix));
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
tempDirs.push(tempDir);
|
||||
return tempDir;
|
||||
}
|
||||
|
||||
@@ -6,13 +6,10 @@
|
||||
"dependencies": {
|
||||
"@microsoft/teams.api": "2.0.6",
|
||||
"@microsoft/teams.apps": "2.0.6",
|
||||
"express": "^5.2.1",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"jwks-rsa": "^4.0.1"
|
||||
"express": "^5.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@openclaw/plugin-sdk": "workspace:*",
|
||||
"@types/jsonwebtoken": "^9.0.9",
|
||||
"openclaw": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -206,28 +206,6 @@ describe("msteams attachment helpers", () => {
|
||||
const urls = buildMSTeamsGraphMessageUrls(params);
|
||||
expect(urls[0]).toContain(expectedPath);
|
||||
});
|
||||
|
||||
it("uses resolved Graph chat ID for personal DMs instead of Bot Framework a: ID", () => {
|
||||
const urls = buildMSTeamsGraphMessageUrls({
|
||||
conversationType: "personal",
|
||||
conversationId: "19:real-graph-chat-id@unq.gbl.spaces",
|
||||
messageId: "msg-1",
|
||||
});
|
||||
expect(urls).toHaveLength(1);
|
||||
expect(urls[0]).toContain(
|
||||
"/chats/19%3Areal-graph-chat-id%40unq.gbl.spaces/messages/msg-1",
|
||||
);
|
||||
});
|
||||
|
||||
it("still builds URLs when a: conversation ID is passed (caller did not resolve)", () => {
|
||||
const urls = buildMSTeamsGraphMessageUrls({
|
||||
conversationType: "personal",
|
||||
conversationId: "a:1dRsHCobZ1AxURzY",
|
||||
messageId: "msg-1",
|
||||
});
|
||||
expect(urls).toHaveLength(1);
|
||||
expect(urls[0]).toContain("/chats/a%3A1dRsHCobZ1AxURzY/messages/msg-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildMSTeamsMediaPayload", () => {
|
||||
|
||||
@@ -550,105 +550,5 @@ describe("msteams attachments", () => {
|
||||
expectAttachmentMediaLength(media, 0);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
describe("OneDrive/SharePoint shared links", () => {
|
||||
const GRAPH_SHARES_URL_PREFIX = `https://${GRAPH_HOST}/v1.0/shares/`;
|
||||
const DEFAULT_GRAPH_ALLOW_HOSTS = [GRAPH_HOST];
|
||||
const PDF_PAYLOAD = Buffer.from("pdf-bytes");
|
||||
|
||||
const createGraphSharesFetchMock = () =>
|
||||
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
const auth = new Headers(init?.headers).get("Authorization");
|
||||
if (url.startsWith(GRAPH_SHARES_URL_PREFIX)) {
|
||||
if (!auth) {
|
||||
return createTextResponse("unauthorized", 401);
|
||||
}
|
||||
return createBufferResponse(PDF_PAYLOAD, CONTENT_TYPE_APPLICATION_PDF);
|
||||
}
|
||||
return createNotFoundResponse();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: "SharePoint URL",
|
||||
contentUrl: "https://contoso.sharepoint.com/personal/user/Documents/report.pdf",
|
||||
},
|
||||
{
|
||||
label: "OneDrive 1drv.ms URL",
|
||||
contentUrl: "https://1drv.ms/b/s!AkxYabcdefg",
|
||||
},
|
||||
{
|
||||
label: "OneDrive onedrive.live.com URL",
|
||||
contentUrl: "https://onedrive.live.com/share/file",
|
||||
},
|
||||
])("routes $label through Graph shares endpoint", async ({ contentUrl }) => {
|
||||
const tokenProvider = createTokenProvider();
|
||||
const fetchMock = createGraphSharesFetchMock();
|
||||
detectMimeMock.mockResolvedValueOnce(CONTENT_TYPE_APPLICATION_PDF);
|
||||
saveMediaBufferMock.mockResolvedValueOnce({
|
||||
id: "saved.pdf",
|
||||
path: SAVED_PDF_PATH,
|
||||
size: Buffer.byteLength(PDF_PAYLOAD),
|
||||
contentType: CONTENT_TYPE_APPLICATION_PDF,
|
||||
});
|
||||
|
||||
const media = await downloadMSTeamsAttachments(
|
||||
buildDownloadParams(
|
||||
[
|
||||
{
|
||||
contentType: "reference",
|
||||
contentUrl,
|
||||
name: "report.pdf",
|
||||
},
|
||||
],
|
||||
{
|
||||
tokenProvider,
|
||||
allowHosts: DEFAULT_GRAPH_ALLOW_HOSTS,
|
||||
authAllowHosts: DEFAULT_GRAPH_ALLOW_HOSTS,
|
||||
fetchFn: asFetchFn(fetchMock),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expectAttachmentMediaLength(media, 1);
|
||||
expect(media[0]?.path).toBe(SAVED_PDF_PATH);
|
||||
// The only host that should be fetched is graph.microsoft.com.
|
||||
const calledUrls = fetchMock.mock.calls.map(([input]) =>
|
||||
typeof input === "string" ? input : String(input),
|
||||
);
|
||||
expect(calledUrls.length).toBeGreaterThan(0);
|
||||
for (const url of calledUrls) {
|
||||
expect(url.startsWith(GRAPH_SHARES_URL_PREFIX)).toBe(true);
|
||||
}
|
||||
// Graph scope token was acquired for the shares fetch.
|
||||
expect(tokenProvider.getAccessToken).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls through to direct fetch for non-shared-link URLs", async () => {
|
||||
const directUrl = createTestUrl("direct.pdf");
|
||||
const fetchMock = createOkFetchMock(CONTENT_TYPE_APPLICATION_PDF, "pdf");
|
||||
detectMimeMock.mockResolvedValueOnce(CONTENT_TYPE_APPLICATION_PDF);
|
||||
saveMediaBufferMock.mockResolvedValueOnce({
|
||||
id: "saved.pdf",
|
||||
path: SAVED_PDF_PATH,
|
||||
size: Buffer.byteLength(PDF_BUFFER),
|
||||
contentType: CONTENT_TYPE_APPLICATION_PDF,
|
||||
});
|
||||
|
||||
const media = await downloadAttachmentsWithFetch(
|
||||
createPdfAttachments(directUrl),
|
||||
fetchMock,
|
||||
);
|
||||
|
||||
expectAttachmentMediaLength(media, 1);
|
||||
const calledUrls = fetchMock.mock.calls.map(([input]) =>
|
||||
typeof input === "string" ? input : String(input),
|
||||
);
|
||||
// Should have hit the original host, NOT graph shares.
|
||||
expect(calledUrls.some((url) => url === directUrl)).toBe(true);
|
||||
expect(calledUrls.some((url) => url.startsWith(GRAPH_SHARES_URL_PREFIX))).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
export {
|
||||
downloadMSTeamsBotFrameworkAttachment,
|
||||
downloadMSTeamsBotFrameworkAttachments,
|
||||
isBotFrameworkPersonalChatId,
|
||||
} from "./attachments/bot-framework.js";
|
||||
export {
|
||||
downloadMSTeamsAttachments,
|
||||
/** @deprecated Use `downloadMSTeamsAttachments` instead. */
|
||||
@@ -11,7 +6,6 @@ export {
|
||||
export { buildMSTeamsGraphMessageUrls, downloadMSTeamsGraphMedia } from "./attachments/graph.js";
|
||||
export {
|
||||
buildMSTeamsAttachmentPlaceholder,
|
||||
extractMSTeamsHtmlAttachmentIds,
|
||||
summarizeMSTeamsHtmlAttachments,
|
||||
} from "./attachments/html.js";
|
||||
export { buildMSTeamsMediaPayload } from "./attachments/payload.js";
|
||||
|
||||
@@ -1,317 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { setMSTeamsRuntime } from "../runtime.js";
|
||||
import {
|
||||
downloadMSTeamsBotFrameworkAttachment,
|
||||
downloadMSTeamsBotFrameworkAttachments,
|
||||
isBotFrameworkPersonalChatId,
|
||||
} from "./bot-framework.js";
|
||||
import type { MSTeamsAccessTokenProvider } from "./types.js";
|
||||
|
||||
type SavedCall = {
|
||||
buffer: Buffer;
|
||||
contentType?: string;
|
||||
direction: string;
|
||||
maxBytes: number;
|
||||
originalFilename?: string;
|
||||
};
|
||||
|
||||
type MockRuntime = {
|
||||
saveCalls: SavedCall[];
|
||||
savePath: string;
|
||||
savedContentType: string;
|
||||
};
|
||||
|
||||
function installRuntime(): MockRuntime {
|
||||
const state: MockRuntime = {
|
||||
saveCalls: [],
|
||||
savePath: "/tmp/bf-attachment.bin",
|
||||
savedContentType: "application/pdf",
|
||||
};
|
||||
setMSTeamsRuntime({
|
||||
media: {
|
||||
detectMime: async ({ headerMime }: { headerMime?: string }) =>
|
||||
headerMime ?? "application/pdf",
|
||||
},
|
||||
channel: {
|
||||
media: {
|
||||
saveMediaBuffer: async (
|
||||
buffer: Buffer,
|
||||
contentType: string | undefined,
|
||||
direction: string,
|
||||
maxBytes: number,
|
||||
originalFilename?: string,
|
||||
) => {
|
||||
state.saveCalls.push({
|
||||
buffer,
|
||||
contentType,
|
||||
direction,
|
||||
maxBytes,
|
||||
originalFilename,
|
||||
});
|
||||
return { path: state.savePath, contentType: state.savedContentType };
|
||||
},
|
||||
fetchRemoteMedia: async () => ({ buffer: Buffer.alloc(0), contentType: undefined }),
|
||||
},
|
||||
},
|
||||
} as unknown as Parameters<typeof setMSTeamsRuntime>[0]);
|
||||
return state;
|
||||
}
|
||||
|
||||
function createMockFetch(entries: Array<{ match: RegExp; response: Response }>): typeof fetch {
|
||||
return (async (input: RequestInfo | URL) => {
|
||||
const url =
|
||||
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
const entry = entries.find((e) => e.match.test(url));
|
||||
if (!entry) {
|
||||
return new Response("not found", { status: 404 });
|
||||
}
|
||||
return entry.response.clone();
|
||||
}) as typeof fetch;
|
||||
}
|
||||
|
||||
function buildTokenProvider(): MSTeamsAccessTokenProvider {
|
||||
return {
|
||||
getAccessToken: vi.fn(async (scope: string) => {
|
||||
if (scope.includes("botframework.com")) {
|
||||
return "bf-token";
|
||||
}
|
||||
return "graph-token";
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("isBotFrameworkPersonalChatId", () => {
|
||||
it("detects a: prefix personal chat IDs", () => {
|
||||
expect(isBotFrameworkPersonalChatId("a:1dRsHCobZ1AxURzY05Dc")).toBe(true);
|
||||
});
|
||||
|
||||
it("detects 8:orgid: prefix chat IDs", () => {
|
||||
expect(isBotFrameworkPersonalChatId("8:orgid:12345678-1234-1234-1234-123456789abc")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for Graph-compatible 19: thread IDs", () => {
|
||||
expect(isBotFrameworkPersonalChatId("19:abc@thread.tacv2")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for synthetic DM Graph IDs", () => {
|
||||
expect(isBotFrameworkPersonalChatId("19:aad-user-id_bot-app-id@unq.gbl.spaces")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for null/undefined/empty", () => {
|
||||
expect(isBotFrameworkPersonalChatId(null)).toBe(false);
|
||||
expect(isBotFrameworkPersonalChatId(undefined)).toBe(false);
|
||||
expect(isBotFrameworkPersonalChatId("")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("downloadMSTeamsBotFrameworkAttachment", () => {
|
||||
let runtime: MockRuntime;
|
||||
beforeEach(() => {
|
||||
runtime = installRuntime();
|
||||
});
|
||||
|
||||
it("fetches attachment info then view and saves media", async () => {
|
||||
const info = {
|
||||
name: "report.pdf",
|
||||
type: "application/pdf",
|
||||
views: [{ viewId: "original", size: 1024 }],
|
||||
};
|
||||
const fileBytes = Buffer.from("PDFBYTES", "utf-8");
|
||||
const fetchFn = createMockFetch([
|
||||
{
|
||||
match: /\/v3\/attachments\/att-1$/,
|
||||
response: new Response(JSON.stringify(info), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
},
|
||||
{
|
||||
match: /\/v3\/attachments\/att-1\/views\/original$/,
|
||||
response: new Response(fileBytes, {
|
||||
status: 200,
|
||||
headers: { "content-length": String(fileBytes.byteLength) },
|
||||
}),
|
||||
},
|
||||
]);
|
||||
|
||||
const media = await downloadMSTeamsBotFrameworkAttachment({
|
||||
serviceUrl: "https://smba.trafficmanager.net/amer/",
|
||||
attachmentId: "att-1",
|
||||
tokenProvider: buildTokenProvider(),
|
||||
maxBytes: 10_000_000,
|
||||
fetchFn,
|
||||
});
|
||||
|
||||
expect(media).toBeDefined();
|
||||
expect(media?.path).toBe(runtime.savePath);
|
||||
expect(runtime.saveCalls).toHaveLength(1);
|
||||
expect(runtime.saveCalls[0].buffer.toString("utf-8")).toBe("PDFBYTES");
|
||||
});
|
||||
|
||||
it("returns undefined when attachment info fetch fails", async () => {
|
||||
const fetchFn = createMockFetch([
|
||||
{
|
||||
match: /\/v3\/attachments\//,
|
||||
response: new Response("unauthorized", { status: 401 }),
|
||||
},
|
||||
]);
|
||||
|
||||
const media = await downloadMSTeamsBotFrameworkAttachment({
|
||||
serviceUrl: "https://smba.trafficmanager.net/amer",
|
||||
attachmentId: "att-1",
|
||||
tokenProvider: buildTokenProvider(),
|
||||
maxBytes: 10_000_000,
|
||||
fetchFn,
|
||||
});
|
||||
|
||||
expect(media).toBeUndefined();
|
||||
expect(runtime.saveCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("skips when attachment view size exceeds maxBytes", async () => {
|
||||
const info = {
|
||||
name: "huge.bin",
|
||||
type: "application/octet-stream",
|
||||
views: [{ viewId: "original", size: 50_000_000 }],
|
||||
};
|
||||
const fetchFn = createMockFetch([
|
||||
{
|
||||
match: /\/v3\/attachments\/big-1$/,
|
||||
response: new Response(JSON.stringify(info), { status: 200 }),
|
||||
},
|
||||
]);
|
||||
|
||||
const media = await downloadMSTeamsBotFrameworkAttachment({
|
||||
serviceUrl: "https://smba.trafficmanager.net/amer",
|
||||
attachmentId: "big-1",
|
||||
tokenProvider: buildTokenProvider(),
|
||||
maxBytes: 10_000_000,
|
||||
fetchFn,
|
||||
});
|
||||
|
||||
expect(media).toBeUndefined();
|
||||
expect(runtime.saveCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns undefined when no views are returned", async () => {
|
||||
const info = { name: "nothing", type: "application/pdf", views: [] };
|
||||
const fetchFn = createMockFetch([
|
||||
{
|
||||
match: /\/v3\/attachments\/empty-1$/,
|
||||
response: new Response(JSON.stringify(info), { status: 200 }),
|
||||
},
|
||||
]);
|
||||
|
||||
const media = await downloadMSTeamsBotFrameworkAttachment({
|
||||
serviceUrl: "https://smba.trafficmanager.net/amer",
|
||||
attachmentId: "empty-1",
|
||||
tokenProvider: buildTokenProvider(),
|
||||
maxBytes: 10_000_000,
|
||||
fetchFn,
|
||||
});
|
||||
|
||||
expect(media).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined without a tokenProvider", async () => {
|
||||
const fetchFn = vi.fn();
|
||||
const media = await downloadMSTeamsBotFrameworkAttachment({
|
||||
serviceUrl: "https://smba.trafficmanager.net/amer",
|
||||
attachmentId: "att-1",
|
||||
tokenProvider: undefined,
|
||||
maxBytes: 10_000_000,
|
||||
fetchFn: fetchFn as unknown as typeof fetch,
|
||||
});
|
||||
expect(media).toBeUndefined();
|
||||
expect(fetchFn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("downloadMSTeamsBotFrameworkAttachments", () => {
|
||||
beforeEach(() => {
|
||||
installRuntime();
|
||||
});
|
||||
|
||||
it("fetches every unique attachment id and returns combined media", async () => {
|
||||
const mkInfo = (viewId: string) => ({
|
||||
name: `file-${viewId}.pdf`,
|
||||
type: "application/pdf",
|
||||
views: [{ viewId, size: 10 }],
|
||||
});
|
||||
const fetchFn = createMockFetch([
|
||||
{
|
||||
match: /\/v3\/attachments\/att-1$/,
|
||||
response: new Response(JSON.stringify(mkInfo("original")), { status: 200 }),
|
||||
},
|
||||
{
|
||||
match: /\/v3\/attachments\/att-1\/views\/original$/,
|
||||
response: new Response(Buffer.from("A"), { status: 200 }),
|
||||
},
|
||||
{
|
||||
match: /\/v3\/attachments\/att-2$/,
|
||||
response: new Response(JSON.stringify(mkInfo("original")), { status: 200 }),
|
||||
},
|
||||
{
|
||||
match: /\/v3\/attachments\/att-2\/views\/original$/,
|
||||
response: new Response(Buffer.from("B"), { status: 200 }),
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await downloadMSTeamsBotFrameworkAttachments({
|
||||
serviceUrl: "https://smba.trafficmanager.net/amer",
|
||||
attachmentIds: ["att-1", "att-2", "att-1"],
|
||||
tokenProvider: buildTokenProvider(),
|
||||
maxBytes: 10_000,
|
||||
fetchFn,
|
||||
});
|
||||
|
||||
expect(result.media).toHaveLength(2);
|
||||
expect(result.attachmentCount).toBe(2);
|
||||
});
|
||||
|
||||
it("returns empty when no valid attachment ids", async () => {
|
||||
const result = await downloadMSTeamsBotFrameworkAttachments({
|
||||
serviceUrl: "https://smba.trafficmanager.net/amer",
|
||||
attachmentIds: [],
|
||||
tokenProvider: buildTokenProvider(),
|
||||
maxBytes: 10_000,
|
||||
fetchFn: vi.fn() as unknown as typeof fetch,
|
||||
});
|
||||
expect(result.media).toEqual([]);
|
||||
});
|
||||
|
||||
it("continues past a per-attachment failure", async () => {
|
||||
const fetchFn = createMockFetch([
|
||||
{
|
||||
match: /\/v3\/attachments\/ok$/,
|
||||
response: new Response(
|
||||
JSON.stringify({
|
||||
name: "ok.pdf",
|
||||
type: "application/pdf",
|
||||
views: [{ viewId: "original", size: 1 }],
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
},
|
||||
{
|
||||
match: /\/v3\/attachments\/ok\/views\/original$/,
|
||||
response: new Response(Buffer.from("OK"), { status: 200 }),
|
||||
},
|
||||
{
|
||||
match: /\/v3\/attachments\/bad$/,
|
||||
response: new Response("nope", { status: 500 }),
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await downloadMSTeamsBotFrameworkAttachments({
|
||||
serviceUrl: "https://smba.trafficmanager.net/amer",
|
||||
attachmentIds: ["bad", "ok"],
|
||||
tokenProvider: buildTokenProvider(),
|
||||
maxBytes: 10_000,
|
||||
fetchFn,
|
||||
});
|
||||
|
||||
expect(result.media).toHaveLength(1);
|
||||
expect(result.attachmentCount).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -1,306 +0,0 @@
|
||||
import { Buffer } from "node:buffer";
|
||||
import { fetchWithSsrFGuard, type SsrFPolicy } from "../../runtime-api.js";
|
||||
import { getMSTeamsRuntime } from "../runtime.js";
|
||||
import { ensureUserAgentHeader } from "../user-agent.js";
|
||||
import {
|
||||
inferPlaceholder,
|
||||
isUrlAllowed,
|
||||
type MSTeamsAttachmentFetchPolicy,
|
||||
resolveAttachmentFetchPolicy,
|
||||
resolveMediaSsrfPolicy,
|
||||
} from "./shared.js";
|
||||
import type {
|
||||
MSTeamsAccessTokenProvider,
|
||||
MSTeamsGraphMediaResult,
|
||||
MSTeamsInboundMedia,
|
||||
} from "./types.js";
|
||||
|
||||
/**
|
||||
* Bot Framework Service token scope for requesting a token used against
|
||||
* the Bot Connector (v3) REST endpoints such as `/v3/attachments/{id}`.
|
||||
*/
|
||||
const BOT_FRAMEWORK_SCOPE = "https://api.botframework.com";
|
||||
|
||||
/**
|
||||
* Detect Bot Framework personal chat ("a:") and MSA orgid ("8:orgid:") conversation
|
||||
* IDs. These identifiers are not recognized by Graph's `/chats/{id}` endpoint, so we
|
||||
* must fetch media via the Bot Framework v3 attachments endpoint instead.
|
||||
*
|
||||
* Graph-compatible IDs start with `19:` and are left untouched by this detector.
|
||||
*/
|
||||
export function isBotFrameworkPersonalChatId(conversationId: string | null | undefined): boolean {
|
||||
if (typeof conversationId !== "string") {
|
||||
return false;
|
||||
}
|
||||
const trimmed = conversationId.trim();
|
||||
return trimmed.startsWith("a:") || trimmed.startsWith("8:orgid:");
|
||||
}
|
||||
|
||||
type BotFrameworkView = {
|
||||
viewId?: string | null;
|
||||
size?: number | null;
|
||||
};
|
||||
|
||||
type BotFrameworkAttachmentInfo = {
|
||||
name?: string | null;
|
||||
type?: string | null;
|
||||
views?: BotFrameworkView[] | null;
|
||||
};
|
||||
|
||||
function normalizeServiceUrl(serviceUrl: string): string {
|
||||
// Bot Framework service URLs sometimes carry a trailing slash; normalize so
|
||||
// we can safely append `/v3/attachments/...` below.
|
||||
return serviceUrl.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
async function fetchBotFrameworkAttachmentInfo(params: {
|
||||
serviceUrl: string;
|
||||
attachmentId: string;
|
||||
accessToken: string;
|
||||
fetchFn?: typeof fetch;
|
||||
ssrfPolicy?: SsrFPolicy;
|
||||
}): Promise<BotFrameworkAttachmentInfo | undefined> {
|
||||
const url = `${normalizeServiceUrl(params.serviceUrl)}/v3/attachments/${encodeURIComponent(params.attachmentId)}`;
|
||||
const { response, release } = await fetchWithSsrFGuard({
|
||||
url,
|
||||
fetchImpl: params.fetchFn ?? fetch,
|
||||
init: {
|
||||
headers: ensureUserAgentHeader({ Authorization: `Bearer ${params.accessToken}` }),
|
||||
},
|
||||
policy: params.ssrfPolicy,
|
||||
auditContext: "msteams.botframework.attachmentInfo",
|
||||
});
|
||||
try {
|
||||
if (!response.ok) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return (await response.json()) as BotFrameworkAttachmentInfo;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchBotFrameworkAttachmentView(params: {
|
||||
serviceUrl: string;
|
||||
attachmentId: string;
|
||||
viewId: string;
|
||||
accessToken: string;
|
||||
maxBytes: number;
|
||||
fetchFn?: typeof fetch;
|
||||
ssrfPolicy?: SsrFPolicy;
|
||||
}): Promise<Buffer | undefined> {
|
||||
const url = `${normalizeServiceUrl(params.serviceUrl)}/v3/attachments/${encodeURIComponent(params.attachmentId)}/views/${encodeURIComponent(params.viewId)}`;
|
||||
const { response, release } = await fetchWithSsrFGuard({
|
||||
url,
|
||||
fetchImpl: params.fetchFn ?? fetch,
|
||||
init: {
|
||||
headers: ensureUserAgentHeader({ Authorization: `Bearer ${params.accessToken}` }),
|
||||
},
|
||||
policy: params.ssrfPolicy,
|
||||
auditContext: "msteams.botframework.attachmentView",
|
||||
});
|
||||
try {
|
||||
if (!response.ok) {
|
||||
return undefined;
|
||||
}
|
||||
const contentLength = response.headers.get("content-length");
|
||||
if (contentLength && Number(contentLength) > params.maxBytes) {
|
||||
return undefined;
|
||||
}
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
if (buffer.byteLength > params.maxBytes) {
|
||||
return undefined;
|
||||
}
|
||||
return buffer;
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download media for a single attachment via the Bot Framework v3 attachments
|
||||
* endpoint. Used for personal DM conversations where the Graph `/chats/{id}`
|
||||
* path is not usable because the Bot Framework conversation ID (`a:...`) is
|
||||
* not a valid Graph chat identifier.
|
||||
*/
|
||||
export async function downloadMSTeamsBotFrameworkAttachment(params: {
|
||||
serviceUrl: string;
|
||||
attachmentId: string;
|
||||
tokenProvider?: MSTeamsAccessTokenProvider;
|
||||
maxBytes: number;
|
||||
allowHosts?: string[];
|
||||
authAllowHosts?: string[];
|
||||
fetchFn?: typeof fetch;
|
||||
fileNameHint?: string | null;
|
||||
contentTypeHint?: string | null;
|
||||
preserveFilenames?: boolean;
|
||||
}): Promise<MSTeamsInboundMedia | undefined> {
|
||||
if (!params.serviceUrl || !params.attachmentId || !params.tokenProvider) {
|
||||
return undefined;
|
||||
}
|
||||
const policy: MSTeamsAttachmentFetchPolicy = resolveAttachmentFetchPolicy({
|
||||
allowHosts: params.allowHosts,
|
||||
authAllowHosts: params.authAllowHosts,
|
||||
});
|
||||
const baseUrl = `${normalizeServiceUrl(params.serviceUrl)}/v3/attachments/${encodeURIComponent(params.attachmentId)}`;
|
||||
if (!isUrlAllowed(baseUrl, policy.allowHosts)) {
|
||||
return undefined;
|
||||
}
|
||||
const ssrfPolicy = resolveMediaSsrfPolicy(policy.allowHosts);
|
||||
|
||||
let accessToken: string;
|
||||
try {
|
||||
accessToken = await params.tokenProvider.getAccessToken(BOT_FRAMEWORK_SCOPE);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
if (!accessToken) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const info = await fetchBotFrameworkAttachmentInfo({
|
||||
serviceUrl: params.serviceUrl,
|
||||
attachmentId: params.attachmentId,
|
||||
accessToken,
|
||||
fetchFn: params.fetchFn,
|
||||
ssrfPolicy,
|
||||
});
|
||||
if (!info) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const views = Array.isArray(info.views) ? info.views : [];
|
||||
// Prefer the "original" view when present, otherwise fall back to the first
|
||||
// view the Bot Framework service returned.
|
||||
const original = views.find((view) => view?.viewId === "original");
|
||||
const candidateView = original ?? views.find((view) => typeof view?.viewId === "string");
|
||||
const viewId =
|
||||
typeof candidateView?.viewId === "string" && candidateView.viewId
|
||||
? candidateView.viewId
|
||||
: undefined;
|
||||
if (!viewId) {
|
||||
return undefined;
|
||||
}
|
||||
if (
|
||||
typeof candidateView?.size === "number" &&
|
||||
candidateView.size > 0 &&
|
||||
candidateView.size > params.maxBytes
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const buffer = await fetchBotFrameworkAttachmentView({
|
||||
serviceUrl: params.serviceUrl,
|
||||
attachmentId: params.attachmentId,
|
||||
viewId,
|
||||
accessToken,
|
||||
maxBytes: params.maxBytes,
|
||||
fetchFn: params.fetchFn,
|
||||
ssrfPolicy,
|
||||
});
|
||||
if (!buffer) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const fileNameHint =
|
||||
(typeof params.fileNameHint === "string" && params.fileNameHint) ||
|
||||
(typeof info.name === "string" && info.name) ||
|
||||
undefined;
|
||||
const contentTypeHint =
|
||||
(typeof params.contentTypeHint === "string" && params.contentTypeHint) ||
|
||||
(typeof info.type === "string" && info.type) ||
|
||||
undefined;
|
||||
|
||||
const mime = await getMSTeamsRuntime().media.detectMime({
|
||||
buffer,
|
||||
headerMime: contentTypeHint,
|
||||
filePath: fileNameHint,
|
||||
});
|
||||
|
||||
try {
|
||||
const originalFilename = params.preserveFilenames ? fileNameHint : undefined;
|
||||
const saved = await getMSTeamsRuntime().channel.media.saveMediaBuffer(
|
||||
buffer,
|
||||
mime ?? contentTypeHint,
|
||||
"inbound",
|
||||
params.maxBytes,
|
||||
originalFilename,
|
||||
);
|
||||
return {
|
||||
path: saved.path,
|
||||
contentType: saved.contentType,
|
||||
placeholder: inferPlaceholder({ contentType: saved.contentType, fileName: fileNameHint }),
|
||||
};
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download media for every attachment referenced by a Bot Framework personal
|
||||
* chat activity. Returns all successfully fetched media along with diagnostics
|
||||
* compatible with `downloadMSTeamsGraphMedia`'s result shape so callers can
|
||||
* reuse the existing logging path.
|
||||
*/
|
||||
export async function downloadMSTeamsBotFrameworkAttachments(params: {
|
||||
serviceUrl: string;
|
||||
attachmentIds: string[];
|
||||
tokenProvider?: MSTeamsAccessTokenProvider;
|
||||
maxBytes: number;
|
||||
allowHosts?: string[];
|
||||
authAllowHosts?: string[];
|
||||
fetchFn?: typeof fetch;
|
||||
fileNameHint?: string | null;
|
||||
contentTypeHint?: string | null;
|
||||
preserveFilenames?: boolean;
|
||||
}): Promise<MSTeamsGraphMediaResult> {
|
||||
const seen = new Set<string>();
|
||||
const unique: string[] = [];
|
||||
for (const id of params.attachmentIds ?? []) {
|
||||
if (typeof id !== "string") {
|
||||
continue;
|
||||
}
|
||||
const trimmed = id.trim();
|
||||
if (!trimmed || seen.has(trimmed)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(trimmed);
|
||||
unique.push(trimmed);
|
||||
}
|
||||
if (unique.length === 0 || !params.serviceUrl || !params.tokenProvider) {
|
||||
return { media: [], attachmentCount: unique.length };
|
||||
}
|
||||
|
||||
const media: MSTeamsInboundMedia[] = [];
|
||||
for (const attachmentId of unique) {
|
||||
try {
|
||||
const item = await downloadMSTeamsBotFrameworkAttachment({
|
||||
serviceUrl: params.serviceUrl,
|
||||
attachmentId,
|
||||
tokenProvider: params.tokenProvider,
|
||||
maxBytes: params.maxBytes,
|
||||
allowHosts: params.allowHosts,
|
||||
authAllowHosts: params.authAllowHosts,
|
||||
fetchFn: params.fetchFn,
|
||||
fileNameHint: params.fileNameHint,
|
||||
contentTypeHint: params.contentTypeHint,
|
||||
preserveFilenames: params.preserveFilenames,
|
||||
});
|
||||
if (item) {
|
||||
media.push(item);
|
||||
}
|
||||
} catch {
|
||||
// Ignore per-attachment failures and continue.
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
media,
|
||||
attachmentCount: unique.length,
|
||||
};
|
||||
}
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
resolveAttachmentFetchPolicy,
|
||||
resolveRequestUrl,
|
||||
safeFetchWithPolicy,
|
||||
tryBuildGraphSharesUrlForSharedLink,
|
||||
} from "./shared.js";
|
||||
import type {
|
||||
MSTeamsAccessTokenProvider,
|
||||
@@ -66,21 +65,10 @@ function resolveDownloadCandidate(att: MSTeamsAttachmentLike): DownloadCandidate
|
||||
return null;
|
||||
}
|
||||
|
||||
// OneDrive/SharePoint shared links (delivered in 1:1 DMs when the user
|
||||
// picks "Attach > OneDrive") cannot be fetched directly — the URL returns
|
||||
// an HTML landing page rather than the file bytes. Rewrite them to the
|
||||
// Graph shares endpoint so the auth fallback attaches a Graph-scoped token
|
||||
// and the response is the real file content.
|
||||
const sharesUrl = tryBuildGraphSharesUrlForSharedLink(contentUrl);
|
||||
const resolvedUrl = sharesUrl ?? contentUrl;
|
||||
// Graph shares returns raw bytes without a declared content type we can
|
||||
// trust for routing — let the downloader infer MIME from the buffer.
|
||||
const resolvedContentTypeHint = sharesUrl ? undefined : contentType;
|
||||
|
||||
return {
|
||||
url: resolvedUrl,
|
||||
url: contentUrl,
|
||||
fileHint: name || undefined,
|
||||
contentTypeHint: resolvedContentTypeHint,
|
||||
contentTypeHint: contentType,
|
||||
placeholder: inferPlaceholder({ contentType, fileName: name }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import { downloadMSTeamsAttachments } from "./download.js";
|
||||
import { downloadAndStoreMSTeamsRemoteMedia } from "./remote-media.js";
|
||||
import {
|
||||
applyAuthorizationHeaderForUrl,
|
||||
encodeGraphShareId,
|
||||
GRAPH_ROOT,
|
||||
estimateBase64DecodedBytes,
|
||||
inferPlaceholder,
|
||||
@@ -323,15 +322,13 @@ export async function downloadMSTeamsGraphMedia(params: {
|
||||
const name = att.name ?? "file";
|
||||
|
||||
try {
|
||||
// SharePoint URLs need to be accessed via Graph shares API. Validate the
|
||||
// rewritten Graph URL, not the original SharePoint host, so the existing
|
||||
// Graph allowlist path can fetch shared files without separately allowing
|
||||
// arbitrary SharePoint hosts.
|
||||
// SharePoint URLs need to be accessed via Graph shares API
|
||||
const shareUrl = att.contentUrl!;
|
||||
const sharesUrl = `${GRAPH_ROOT}/shares/${encodeGraphShareId(shareUrl)}/driveItem/content`;
|
||||
if (!isUrlAllowed(sharesUrl, policy.allowHosts)) {
|
||||
if (!isUrlAllowed(shareUrl, policy.allowHosts)) {
|
||||
continue;
|
||||
}
|
||||
const encodedUrl = Buffer.from(shareUrl).toString("base64url");
|
||||
const sharesUrl = `${GRAPH_ROOT}/shares/u!${encodedUrl}/driveItem/content`;
|
||||
|
||||
const media = await downloadAndStoreMSTeamsRemoteMedia({
|
||||
url: sharesUrl,
|
||||
|
||||
@@ -8,37 +8,6 @@ import {
|
||||
} from "./shared.js";
|
||||
import type { MSTeamsAttachmentLike, MSTeamsHtmlAttachmentSummary } from "./types.js";
|
||||
|
||||
/**
|
||||
* Extract every `<attachment id="...">` reference from the HTML attachments in
|
||||
* the inbound activity. Returns the complete (non-sliced) list; callers that
|
||||
* need a capped diagnostic summary can truncate after calling this helper.
|
||||
*/
|
||||
export function extractMSTeamsHtmlAttachmentIds(
|
||||
attachments: MSTeamsAttachmentLike[] | undefined,
|
||||
): string[] {
|
||||
const list = Array.isArray(attachments) ? attachments : [];
|
||||
if (list.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const ids = new Set<string>();
|
||||
for (const att of list) {
|
||||
const html = extractHtmlFromAttachment(att);
|
||||
if (!html) {
|
||||
continue;
|
||||
}
|
||||
ATTACHMENT_TAG_RE.lastIndex = 0;
|
||||
let match: RegExpExecArray | null = ATTACHMENT_TAG_RE.exec(html);
|
||||
while (match) {
|
||||
const id = match[1]?.trim();
|
||||
if (id) {
|
||||
ids.add(id);
|
||||
}
|
||||
match = ATTACHMENT_TAG_RE.exec(html);
|
||||
}
|
||||
}
|
||||
return Array.from(ids);
|
||||
}
|
||||
|
||||
export function summarizeMSTeamsHtmlAttachments(
|
||||
attachments: MSTeamsAttachmentLike[] | undefined,
|
||||
): MSTeamsHtmlAttachmentSummary | undefined {
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
applyAuthorizationHeaderForUrl,
|
||||
encodeGraphShareId,
|
||||
extractInlineImageCandidates,
|
||||
isGraphSharedLinkUrl,
|
||||
isPrivateOrReservedIP,
|
||||
isUrlAllowed,
|
||||
resolveAndValidateIP,
|
||||
@@ -13,7 +11,6 @@ import {
|
||||
resolveMediaSsrfPolicy,
|
||||
safeFetch,
|
||||
safeFetchWithPolicy,
|
||||
tryBuildGraphSharesUrlForSharedLink,
|
||||
} from "./shared.js";
|
||||
|
||||
const publicResolve = async () => ({ address: "13.107.136.10" });
|
||||
@@ -398,75 +395,6 @@ describe("attachment fetch auth helpers", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Graph shared-link helpers", () => {
|
||||
it.each([
|
||||
["https://contoso.sharepoint.com/personal/user/Documents/report.pdf", true],
|
||||
["https://contoso.sharepoint.us/sites/team/file.docx", true],
|
||||
["https://contoso.sharepoint.cn/file", true],
|
||||
["https://tenant-my.sharepoint.com/:b:/g/personal/file", true],
|
||||
["https://1drv.ms/b/s!AkxYabc", true],
|
||||
["https://onedrive.live.com/view.aspx?resid=ABC", true],
|
||||
["https://onedrive.com/share/abc", true],
|
||||
["https://graph.microsoft.com/v1.0/me", false],
|
||||
["https://smba.trafficmanager.net/amer/v3", false],
|
||||
["https://example.com/file.pdf", false],
|
||||
["not-a-url", false],
|
||||
])("isGraphSharedLinkUrl(%s) === %s", (url, expected) => {
|
||||
expect(isGraphSharedLinkUrl(url)).toBe(expected);
|
||||
});
|
||||
|
||||
it("encodeGraphShareId uses u! + base64url without padding", () => {
|
||||
// Graph docs example: encoding "https://onedrive.live.com/redir?resid=..."
|
||||
// should yield u!aHR0cHM6... (base64url, no '+', '/', or trailing '=').
|
||||
const url = "https://contoso.sharepoint.com/sites/a/Shared Documents/file.pdf";
|
||||
const shareId = encodeGraphShareId(url);
|
||||
expect(shareId.startsWith("u!")).toBe(true);
|
||||
const encoded = shareId.slice(2);
|
||||
// base64url alphabet is A-Z, a-z, 0-9, '-', '_' (no padding).
|
||||
expect(encoded).toMatch(/^[A-Za-z0-9_-]+$/);
|
||||
// Round-trip check: decoding yields the original URL.
|
||||
const decoded = Buffer.from(encoded, "base64url").toString("utf8");
|
||||
expect(decoded).toBe(url);
|
||||
});
|
||||
|
||||
it("encodeGraphShareId swaps '+' and '/' for '-' and '_'", () => {
|
||||
// A URL whose standard base64 contains '+' and '/' chars.
|
||||
// Choose an input that base64 encodes with those characters.
|
||||
const url = "https://host.sharepoint.com/sites/path?x=???";
|
||||
const shareId = encodeGraphShareId(url);
|
||||
const encoded = shareId.slice(2);
|
||||
expect(encoded).not.toContain("+");
|
||||
expect(encoded).not.toContain("/");
|
||||
expect(encoded).not.toContain("=");
|
||||
});
|
||||
|
||||
it("tryBuildGraphSharesUrlForSharedLink rewrites SharePoint URLs", () => {
|
||||
const url = "https://contoso.sharepoint.com/personal/user/Documents/report.pdf";
|
||||
const result = tryBuildGraphSharesUrlForSharedLink(url);
|
||||
expect(result).toBeDefined();
|
||||
expect(result).toMatch(
|
||||
/^https:\/\/graph\.microsoft\.com\/v1\.0\/shares\/u![A-Za-z0-9_-]+\/driveItem\/content$/,
|
||||
);
|
||||
});
|
||||
|
||||
it("tryBuildGraphSharesUrlForSharedLink rewrites OneDrive URLs", () => {
|
||||
const url = "https://1drv.ms/b/s!AkxYabcdefg";
|
||||
const result = tryBuildGraphSharesUrlForSharedLink(url);
|
||||
expect(result).toBeDefined();
|
||||
expect(result).toMatch(
|
||||
/^https:\/\/graph\.microsoft\.com\/v1\.0\/shares\/u![A-Za-z0-9_-]+\/driveItem\/content$/,
|
||||
);
|
||||
});
|
||||
|
||||
it("tryBuildGraphSharesUrlForSharedLink returns undefined for non-shared URLs", () => {
|
||||
expect(
|
||||
tryBuildGraphSharesUrlForSharedLink("https://graph.microsoft.com/v1.0/me"),
|
||||
).toBeUndefined();
|
||||
expect(tryBuildGraphSharesUrlForSharedLink("https://example.com/file.pdf")).toBeUndefined();
|
||||
expect(tryBuildGraphSharesUrlForSharedLink("not-a-url")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("msteams inline image limits", () => {
|
||||
const smallPngDataUrl = "data:image/png;base64,aGVsbG8="; // "hello" (5 bytes)
|
||||
|
||||
|
||||
@@ -84,67 +84,6 @@ export const DEFAULT_MEDIA_AUTH_HOST_ALLOWLIST = [
|
||||
export const GRAPH_ROOT = "https://graph.microsoft.com/v1.0";
|
||||
export { isRecord };
|
||||
|
||||
/**
|
||||
* Host suffixes for SharePoint/OneDrive shared links that must be fetched via
|
||||
* the Graph `/shares/{shareId}/driveItem/content` endpoint instead of directly.
|
||||
*
|
||||
* Direct fetches of SharePoint/OneDrive shared URLs return empty/HTML landing
|
||||
* pages unless encoded as a Graph share id. See
|
||||
* https://learn.microsoft.com/en-us/graph/api/shares-get for the encoding.
|
||||
*/
|
||||
const GRAPH_SHARED_LINK_HOST_SUFFIXES = [
|
||||
".sharepoint.com",
|
||||
".sharepoint.us",
|
||||
".sharepoint.de",
|
||||
".sharepoint.cn",
|
||||
".sharepoint-df.com",
|
||||
"1drv.ms",
|
||||
"onedrive.live.com",
|
||||
"onedrive.com",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Returns true when the URL points at a SharePoint or OneDrive host whose
|
||||
* shared-link content must be fetched through the Graph shares API rather
|
||||
* than directly.
|
||||
*/
|
||||
export function isGraphSharedLinkUrl(url: string): boolean {
|
||||
let host: string;
|
||||
try {
|
||||
host = normalizeLowercaseStringOrEmpty(new URL(url).hostname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (!host) {
|
||||
return false;
|
||||
}
|
||||
return GRAPH_SHARED_LINK_HOST_SUFFIXES.some((suffix) => host === suffix || host.endsWith(suffix));
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a SharePoint/OneDrive URL as a Graph shareId using the documented
|
||||
* `u!` + base64url (no padding) scheme:
|
||||
* https://learn.microsoft.com/en-us/graph/api/shares-get#encoding-sharing-urls
|
||||
*/
|
||||
export function encodeGraphShareId(url: string): string {
|
||||
// Buffer.from(...).toString("base64url") already returns base64url without
|
||||
// padding, matching the Graph spec exactly.
|
||||
return `u!${Buffer.from(url, "utf8").toString("base64url")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* When `url` is a SharePoint/OneDrive shared link, return the matching
|
||||
* `GET /shares/{shareId}/driveItem/content` URL that actually yields the file
|
||||
* bytes. Returns `undefined` for non-shared-link URLs so callers can fall
|
||||
* through to the existing fetch path.
|
||||
*/
|
||||
export function tryBuildGraphSharesUrlForSharedLink(url: string): string | undefined {
|
||||
if (!isGraphSharedLinkUrl(url)) {
|
||||
return undefined;
|
||||
}
|
||||
return `${GRAPH_ROOT}/shares/${encodeGraphShareId(url)}/driveItem/content`;
|
||||
}
|
||||
|
||||
export function readNestedString(value: unknown, keys: Array<string | number>): string | undefined {
|
||||
let current: unknown = value;
|
||||
for (const key of keys) {
|
||||
|
||||
@@ -29,7 +29,6 @@ import { formatUnknownError } from "./errors.js";
|
||||
import { resolveMSTeamsGroupToolPolicy } from "./policy.js";
|
||||
import type { ProbeMSTeamsResult } from "./probe.js";
|
||||
import {
|
||||
looksLikeMSTeamsTargetId,
|
||||
normalizeMSTeamsMessagingTarget,
|
||||
normalizeMSTeamsUserInput,
|
||||
parseMSTeamsConversationId,
|
||||
@@ -167,7 +166,21 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount, ProbeMSTeamsRe
|
||||
normalizeTarget: normalizeMSTeamsMessagingTarget,
|
||||
resolveOutboundSessionRoute: (params) => resolveMSTeamsOutboundSessionRoute(params),
|
||||
targetResolver: {
|
||||
looksLikeId: (raw) => looksLikeMSTeamsTargetId(raw),
|
||||
looksLikeId: (raw) => {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
if (/^conversation:/i.test(trimmed)) {
|
||||
return true;
|
||||
}
|
||||
if (/^user:/i.test(trimmed)) {
|
||||
// Only treat as ID if the value after user: looks like a UUID
|
||||
const id = trimmed.slice("user:".length).trim();
|
||||
return /^[0-9a-fA-F-]{16,}$/.test(id);
|
||||
}
|
||||
return trimmed.includes("@thread");
|
||||
},
|
||||
hint: "<conversationId|user:ID|conversation:ID>",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -35,12 +35,8 @@ export function mergeStoredConversationReference(
|
||||
): StoredConversationReference {
|
||||
return {
|
||||
// Preserve fields from previous entry that may not be present on every activity
|
||||
// (e.g. timezone is only sent when clientInfo entity is available;
|
||||
// graphChatId is resolved via Graph API and cached for DM media downloads).
|
||||
// (e.g. timezone is only sent when clientInfo entity is available).
|
||||
...(existing?.timezone && !incoming.timezone ? { timezone: existing.timezone } : {}),
|
||||
...(existing?.graphChatId && !incoming.graphChatId
|
||||
? { graphChatId: existing.graphChatId }
|
||||
: {}),
|
||||
...incoming,
|
||||
lastSeenAt: nowIso,
|
||||
};
|
||||
|
||||
@@ -155,30 +155,6 @@ describe.each(storeFactories)("msteams conversation store ($name)", ({ createSto
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves graphChatId across upserts that omit it", async () => {
|
||||
const store = await createStore();
|
||||
|
||||
await store.upsert("conv-graph", {
|
||||
conversation: { id: "conv-graph", conversationType: "personal" },
|
||||
channelId: "msteams",
|
||||
serviceUrl: "https://service.example.com",
|
||||
user: { id: "u1" },
|
||||
graphChatId: "19:resolved-chat-id@unq.gbl.spaces",
|
||||
});
|
||||
|
||||
// Second upsert without graphChatId (normal activity-based upsert)
|
||||
await store.upsert("conv-graph", {
|
||||
conversation: { id: "conv-graph", conversationType: "personal" },
|
||||
channelId: "msteams",
|
||||
serviceUrl: "https://service.example.com",
|
||||
user: { id: "u1" },
|
||||
});
|
||||
|
||||
await expect(store.get("conv-graph")).resolves.toMatchObject({
|
||||
graphChatId: "19:resolved-chat-id@unq.gbl.spaces",
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers the freshest personal conversation for repeated upserts of the same user", async () => {
|
||||
const store = await createStore();
|
||||
|
||||
|
||||
@@ -3,25 +3,15 @@ import { describe, expect, it, vi } from "vitest";
|
||||
vi.mock("../attachments.js", () => ({
|
||||
downloadMSTeamsAttachments: vi.fn(async () => []),
|
||||
downloadMSTeamsGraphMedia: vi.fn(async () => ({ media: [] })),
|
||||
downloadMSTeamsBotFrameworkAttachments: vi.fn(async () => ({ media: [], attachmentCount: 0 })),
|
||||
buildMSTeamsGraphMessageUrls: vi.fn(() => [
|
||||
"https://graph.microsoft.com/v1.0/chats/c/messages/m",
|
||||
]),
|
||||
extractMSTeamsHtmlAttachmentIds: vi.fn(() => ["att-0", "att-1"]),
|
||||
isBotFrameworkPersonalChatId: vi.fn((id: string | null | undefined) => {
|
||||
if (typeof id !== "string") {
|
||||
return false;
|
||||
}
|
||||
return id.startsWith("a:") || id.startsWith("8:orgid:");
|
||||
}),
|
||||
}));
|
||||
|
||||
import {
|
||||
buildMSTeamsGraphMessageUrls,
|
||||
downloadMSTeamsAttachments,
|
||||
downloadMSTeamsBotFrameworkAttachments,
|
||||
downloadMSTeamsGraphMedia,
|
||||
extractMSTeamsHtmlAttachmentIds,
|
||||
buildMSTeamsGraphMessageUrls,
|
||||
} from "../attachments.js";
|
||||
import { resolveMSTeamsInboundMedia } from "./inbound-media.js";
|
||||
|
||||
@@ -83,143 +73,3 @@ describe("resolveMSTeamsInboundMedia graph fallback trigger", () => {
|
||||
expect(downloadMSTeamsGraphMedia).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveMSTeamsInboundMedia bot framework DM routing", () => {
|
||||
const dmParams = {
|
||||
...baseParams,
|
||||
conversationType: "personal",
|
||||
conversationId: "a:1dRsHCobZ1AxURzY05Dc",
|
||||
serviceUrl: "https://smba.trafficmanager.net/amer/",
|
||||
};
|
||||
|
||||
it("routes 'a:' conversation IDs through the Bot Framework attachment endpoint", async () => {
|
||||
vi.mocked(downloadMSTeamsAttachments).mockResolvedValue([]);
|
||||
vi.mocked(downloadMSTeamsBotFrameworkAttachments).mockClear();
|
||||
vi.mocked(downloadMSTeamsBotFrameworkAttachments).mockResolvedValue({
|
||||
media: [
|
||||
{
|
||||
path: "/tmp/report.pdf",
|
||||
contentType: "application/pdf",
|
||||
placeholder: "<media:document>",
|
||||
},
|
||||
],
|
||||
attachmentCount: 1,
|
||||
});
|
||||
vi.mocked(downloadMSTeamsGraphMedia).mockClear();
|
||||
|
||||
const mediaList = await resolveMSTeamsInboundMedia({
|
||||
...dmParams,
|
||||
attachments: [
|
||||
{
|
||||
contentType: "text/html",
|
||||
content: '<div>A file <attachment id="att-0"></attachment></div>',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(downloadMSTeamsBotFrameworkAttachments).toHaveBeenCalledTimes(1);
|
||||
const call = vi.mocked(downloadMSTeamsBotFrameworkAttachments).mock.calls[0]?.[0];
|
||||
expect(call?.serviceUrl).toBe(dmParams.serviceUrl);
|
||||
expect(call?.attachmentIds).toEqual(["att-0", "att-1"]);
|
||||
expect(downloadMSTeamsGraphMedia).not.toHaveBeenCalled();
|
||||
expect(mediaList).toHaveLength(1);
|
||||
expect(mediaList[0].path).toBe("/tmp/report.pdf");
|
||||
});
|
||||
|
||||
it("skips the Graph fallback entirely for 'a:' conversation IDs", async () => {
|
||||
vi.mocked(downloadMSTeamsAttachments).mockResolvedValue([]);
|
||||
vi.mocked(downloadMSTeamsBotFrameworkAttachments).mockClear();
|
||||
vi.mocked(downloadMSTeamsBotFrameworkAttachments).mockResolvedValue({
|
||||
media: [],
|
||||
attachmentCount: 1,
|
||||
});
|
||||
vi.mocked(downloadMSTeamsGraphMedia).mockClear();
|
||||
vi.mocked(buildMSTeamsGraphMessageUrls).mockClear();
|
||||
|
||||
await resolveMSTeamsInboundMedia({
|
||||
...dmParams,
|
||||
attachments: [
|
||||
{
|
||||
contentType: "text/html",
|
||||
content: '<div><attachment id="att-0"></attachment></div>',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(downloadMSTeamsBotFrameworkAttachments).toHaveBeenCalled();
|
||||
expect(buildMSTeamsGraphMessageUrls).not.toHaveBeenCalled();
|
||||
expect(downloadMSTeamsGraphMedia).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does NOT call the Bot Framework endpoint for Graph-compatible '19:' IDs", async () => {
|
||||
vi.mocked(downloadMSTeamsAttachments).mockResolvedValue([]);
|
||||
vi.mocked(downloadMSTeamsBotFrameworkAttachments).mockClear();
|
||||
vi.mocked(downloadMSTeamsGraphMedia).mockResolvedValue({ media: [] });
|
||||
|
||||
await resolveMSTeamsInboundMedia({
|
||||
...baseParams,
|
||||
conversationId: "19:abc@thread.tacv2",
|
||||
serviceUrl: "https://smba.trafficmanager.net/amer/",
|
||||
attachments: [
|
||||
{
|
||||
contentType: "text/html",
|
||||
content: '<div><attachment id="att-0"></attachment></div>',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(downloadMSTeamsBotFrameworkAttachments).not.toHaveBeenCalled();
|
||||
expect(downloadMSTeamsGraphMedia).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("logs when no attachment IDs are present on a BF DM with HTML content", async () => {
|
||||
vi.mocked(downloadMSTeamsAttachments).mockResolvedValue([]);
|
||||
vi.mocked(downloadMSTeamsBotFrameworkAttachments).mockClear();
|
||||
vi.mocked(extractMSTeamsHtmlAttachmentIds).mockReturnValueOnce([]);
|
||||
const log = { debug: vi.fn() };
|
||||
|
||||
await resolveMSTeamsInboundMedia({
|
||||
...dmParams,
|
||||
log,
|
||||
attachments: [{ contentType: "text/html", content: "<div>no attachments here</div>" }],
|
||||
});
|
||||
|
||||
expect(downloadMSTeamsBotFrameworkAttachments).not.toHaveBeenCalled();
|
||||
expect(log.debug).toHaveBeenCalledWith(
|
||||
"bot framework attachment ids unavailable",
|
||||
expect.objectContaining({ conversationType: "personal" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("logs when serviceUrl is missing for a BF DM with HTML content", async () => {
|
||||
vi.mocked(downloadMSTeamsAttachments).mockResolvedValue([]);
|
||||
vi.mocked(downloadMSTeamsBotFrameworkAttachments).mockClear();
|
||||
vi.mocked(downloadMSTeamsGraphMedia).mockClear();
|
||||
vi.mocked(buildMSTeamsGraphMessageUrls).mockClear();
|
||||
const log = { debug: vi.fn() };
|
||||
|
||||
await resolveMSTeamsInboundMedia({
|
||||
...baseParams,
|
||||
log,
|
||||
conversationType: "personal",
|
||||
conversationId: "a:bf-dm-id",
|
||||
attachments: [
|
||||
{
|
||||
contentType: "text/html",
|
||||
content: '<div><attachment id="att-0"></attachment></div>',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(downloadMSTeamsBotFrameworkAttachments).not.toHaveBeenCalled();
|
||||
// Graph fallback is also skipped because the ID is 'a:'
|
||||
expect(downloadMSTeamsGraphMedia).not.toHaveBeenCalled();
|
||||
expect(log.debug).toHaveBeenCalledWith(
|
||||
"bot framework attachment skipped (missing serviceUrl)",
|
||||
expect.objectContaining({
|
||||
conversationType: "personal",
|
||||
conversationId: "a:bf-dm-id",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import {
|
||||
buildMSTeamsGraphMessageUrls,
|
||||
downloadMSTeamsAttachments,
|
||||
downloadMSTeamsBotFrameworkAttachments,
|
||||
downloadMSTeamsGraphMedia,
|
||||
extractMSTeamsHtmlAttachmentIds,
|
||||
isBotFrameworkPersonalChatId,
|
||||
type MSTeamsAccessTokenProvider,
|
||||
type MSTeamsAttachmentLike,
|
||||
type MSTeamsHtmlAttachmentSummary,
|
||||
@@ -26,7 +23,6 @@ export async function resolveMSTeamsInboundMedia(params: {
|
||||
conversationType: string;
|
||||
conversationId: string;
|
||||
conversationMessageId?: string;
|
||||
serviceUrl?: string;
|
||||
activity: Pick<MSTeamsTurnContext["activity"], "id" | "replyToId" | "channelData">;
|
||||
log: MSTeamsLogger;
|
||||
/** When true, embeds original filename in stored path for later extraction. */
|
||||
@@ -41,7 +37,6 @@ export async function resolveMSTeamsInboundMedia(params: {
|
||||
conversationType,
|
||||
conversationId,
|
||||
conversationMessageId,
|
||||
serviceUrl,
|
||||
activity,
|
||||
log,
|
||||
preserveFilenames,
|
||||
@@ -61,50 +56,7 @@ export async function resolveMSTeamsInboundMedia(params: {
|
||||
(att) => typeof att.contentType === "string" && att.contentType.startsWith("text/html"),
|
||||
);
|
||||
|
||||
// Personal DMs with the bot use Bot Framework conversation IDs (`a:...`
|
||||
// or `8:orgid:...`) which Graph's `/chats/{id}` endpoint rejects with
|
||||
// "Invalid ThreadId". Fetch media via the Bot Framework v3 attachments
|
||||
// endpoint instead, which speaks the same identifier space.
|
||||
if (hasHtmlAttachment && isBotFrameworkPersonalChatId(conversationId)) {
|
||||
if (!serviceUrl) {
|
||||
log.debug?.("bot framework attachment skipped (missing serviceUrl)", {
|
||||
conversationType,
|
||||
conversationId,
|
||||
});
|
||||
} else {
|
||||
const attachmentIds = extractMSTeamsHtmlAttachmentIds(attachments);
|
||||
if (attachmentIds.length === 0) {
|
||||
log.debug?.("bot framework attachment ids unavailable", {
|
||||
conversationType,
|
||||
conversationId,
|
||||
});
|
||||
} else {
|
||||
const bfMedia = await downloadMSTeamsBotFrameworkAttachments({
|
||||
serviceUrl,
|
||||
attachmentIds,
|
||||
tokenProvider,
|
||||
maxBytes,
|
||||
allowHosts,
|
||||
authAllowHosts: params.authAllowHosts,
|
||||
preserveFilenames,
|
||||
});
|
||||
if (bfMedia.media.length > 0) {
|
||||
mediaList = bfMedia.media;
|
||||
} else {
|
||||
log.debug?.("bot framework attachments fetch empty", {
|
||||
conversationType,
|
||||
attachmentCount: bfMedia.attachmentCount ?? attachmentIds.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
hasHtmlAttachment &&
|
||||
mediaList.length === 0 &&
|
||||
!isBotFrameworkPersonalChatId(conversationId)
|
||||
) {
|
||||
if (hasHtmlAttachment) {
|
||||
const messageUrls = buildMSTeamsGraphMessageUrls({
|
||||
conversationType,
|
||||
conversationId,
|
||||
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
import { isRecord } from "../attachments/shared.js";
|
||||
import type { StoredConversationReference } from "../conversation-store.js";
|
||||
import { formatUnknownError } from "../errors.js";
|
||||
import { resolveGraphChatId } from "../graph-upload.js";
|
||||
import {
|
||||
fetchChannelMessage,
|
||||
fetchThreadReplies,
|
||||
@@ -527,42 +526,13 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
let graphConversationId = translateMSTeamsDmConversationIdForGraph({
|
||||
const graphConversationId = translateMSTeamsDmConversationIdForGraph({
|
||||
isDirectMessage,
|
||||
conversationId,
|
||||
aadObjectId: from.aadObjectId,
|
||||
appId,
|
||||
});
|
||||
|
||||
// For personal DMs the Bot Framework conversation ID (`a:...`) and the
|
||||
// synthetic `19:{userId}_{appId}@unq.gbl.spaces` format produced by
|
||||
// translateMSTeamsDmConversationIdForGraph are not always accepted by the
|
||||
// Graph `/chats/{chatId}/messages` endpoint. Resolve the real Graph chat
|
||||
// ID via the API (with conversation store caching) so the Graph media
|
||||
// download fallback works when the direct Bot Framework download fails.
|
||||
if (isDirectMessage && conversationId.startsWith("a:")) {
|
||||
const cached = await conversationStore.get(conversationId);
|
||||
if (cached?.graphChatId) {
|
||||
graphConversationId = cached.graphChatId;
|
||||
} else {
|
||||
try {
|
||||
const resolved = await resolveGraphChatId({
|
||||
botFrameworkConversationId: conversationId,
|
||||
userAadObjectId: from.aadObjectId ?? undefined,
|
||||
tokenProvider,
|
||||
});
|
||||
if (resolved) {
|
||||
graphConversationId = resolved;
|
||||
conversationStore
|
||||
.upsert(conversationId, { ...conversationRef, graphChatId: resolved })
|
||||
.catch(() => {});
|
||||
}
|
||||
} catch {
|
||||
log.debug?.("failed to resolve Graph chat ID for inbound media", { conversationId });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const mediaList = await resolveMSTeamsInboundMedia({
|
||||
attachments,
|
||||
htmlSummary: htmlSummary ?? undefined,
|
||||
@@ -573,7 +543,6 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
conversationType,
|
||||
conversationId: graphConversationId,
|
||||
conversationMessageId: conversationMessageId ?? undefined,
|
||||
serviceUrl: activity.serviceUrl,
|
||||
activity: {
|
||||
id: activity.id,
|
||||
replyToId: activity.replyToId,
|
||||
|
||||
@@ -26,7 +26,6 @@ vi.mock("./graph-users.js", () => ({
|
||||
}));
|
||||
|
||||
import {
|
||||
looksLikeMSTeamsTargetId,
|
||||
resolveMSTeamsChannelAllowlist,
|
||||
resolveMSTeamsUserAllowlist,
|
||||
} from "./resolve-allowlist.js";
|
||||
@@ -145,65 +144,3 @@ describe("resolveMSTeamsChannelAllowlist", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("looksLikeMSTeamsTargetId", () => {
|
||||
// Regression suite for https://github.com/openclaw/openclaw/issues/58001:
|
||||
// cron announce delivery rejected valid Teams conversation ids because the
|
||||
// validator only matched the `conversation:`-prefixed and `@thread`-suffixed
|
||||
// forms. It must now accept every documented Bot Framework + Graph format.
|
||||
it.each([
|
||||
"conversation:19:abc@thread.tacv2",
|
||||
"conversation:a:1abc",
|
||||
"conversation:8:orgid:2d8c2d2c-1111-2222-3333-444444444444",
|
||||
])("accepts conversation-prefixed ids (%s)", (raw) => {
|
||||
expect(looksLikeMSTeamsTargetId(raw)).toBe(true);
|
||||
});
|
||||
|
||||
it.each(["19:AdviChannelId@thread.tacv2", "19:abc@thread.tacv2", "19:abc@thread.skype"])(
|
||||
"accepts bare channel/group conversation ids (%s)",
|
||||
(raw) => {
|
||||
expect(looksLikeMSTeamsTargetId(raw)).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it("accepts the Graph 1:1 chat thread format", () => {
|
||||
expect(
|
||||
looksLikeMSTeamsTargetId(
|
||||
"19:40a1a0ed4ff24164a21955518990c197_2d8c2d2c11112222@unq.gbl.spaces",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it.each(["a:1abc123def", "a:1xyz-abc_def", "A:1UPPER"])(
|
||||
"accepts Bot Framework personal chat ids (%s)",
|
||||
(raw) => {
|
||||
expect(looksLikeMSTeamsTargetId(raw)).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["8:orgid:2d8c2d2c-1111-2222-3333-444444444444", "8:orgid:user-object-id"])(
|
||||
"accepts Bot Framework org-scoped personal chat ids (%s)",
|
||||
(raw) => {
|
||||
expect(looksLikeMSTeamsTargetId(raw)).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it("accepts Bot Framework user ids", () => {
|
||||
expect(looksLikeMSTeamsTargetId("29:1a2b3c4d5e6f")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts user:<aad-object-id> ids", () => {
|
||||
expect(looksLikeMSTeamsTargetId("user:40a1a0ed-4ff2-4164-a219-55518990c197")).toBe(true);
|
||||
});
|
||||
|
||||
it.each(["", " ", "user:John Smith", "Product Team/Roadmap", "Engineering", "hello"])(
|
||||
"rejects non-id inputs (%s)",
|
||||
(raw) => {
|
||||
expect(looksLikeMSTeamsTargetId(raw)).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it("normalizes leading/trailing whitespace before classifying", () => {
|
||||
expect(looksLikeMSTeamsTargetId(" 19:abc@thread.tacv2 ")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -65,63 +65,6 @@ export function parseMSTeamsConversationId(raw: string): string | null {
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect whether a raw target string looks like a Microsoft Teams conversation
|
||||
* or user id that cron announce delivery and other explicit-target paths can
|
||||
* forward verbatim to the channel adapter.
|
||||
*
|
||||
* Accepts both prefixed and bare formats:
|
||||
* - `conversation:<id>` — explicit conversation prefix
|
||||
* - `user:<aad-guid>` — user id (16+ hex chars, UUID-like)
|
||||
* - `19:abc@thread.tacv2` / `19:abc@thread.skype` — channel / legacy group
|
||||
* - `19:{userId}_{appId}@unq.gbl.spaces` — Graph 1:1 chat thread format
|
||||
* - `a:1xxx` — Bot Framework personal (1:1) chat id
|
||||
* - `8:orgid:xxx` — Bot Framework org-scoped personal chat id
|
||||
* - `29:xxx` — Bot Framework user id
|
||||
*
|
||||
* Display-name user targets such as `user:John Smith` intentionally return
|
||||
* false so that the Graph API directory lookup still runs for them.
|
||||
*/
|
||||
export function looksLikeMSTeamsTargetId(raw: string): boolean {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
if (/^conversation:/i.test(trimmed)) {
|
||||
return true;
|
||||
}
|
||||
if (/^user:/i.test(trimmed)) {
|
||||
// Only treat as an id when the value after `user:` looks like a UUID;
|
||||
// display names must fall through to directory lookup.
|
||||
const id = trimmed.slice("user:".length).trim();
|
||||
return /^[0-9a-fA-F-]{16,}$/.test(id);
|
||||
}
|
||||
// Bare Bot Framework / Graph conversation id formats.
|
||||
// Channel / group ids always start with `19:` and include an `@thread.*`
|
||||
// suffix (`@thread.tacv2` or the legacy `@thread.skype`). Personal chat
|
||||
// ids come in three shapes: `a:1...` (Bot Framework), `8:orgid:...`
|
||||
// (org-scoped Bot Framework), and `19:{userId}_{appId}@unq.gbl.spaces`
|
||||
// (Graph API 1:1 chat thread). Bot Framework user ids use `29:...`.
|
||||
if (/^19:.+@thread\.(tacv2|skype)$/i.test(trimmed)) {
|
||||
return true;
|
||||
}
|
||||
if (/^19:.+@unq\.gbl\.spaces$/i.test(trimmed)) {
|
||||
return true;
|
||||
}
|
||||
if (/^a:1[A-Za-z0-9_-]+$/i.test(trimmed)) {
|
||||
return true;
|
||||
}
|
||||
if (/^8:orgid:[A-Za-z0-9-]+$/i.test(trimmed)) {
|
||||
return true;
|
||||
}
|
||||
if (/^29:[A-Za-z0-9_-]+$/i.test(trimmed)) {
|
||||
return true;
|
||||
}
|
||||
// Fallback: anything containing @thread is still treated as a conversation
|
||||
// id so the current matches for tenant-specific suffixes remain accepted.
|
||||
return /@thread\b/i.test(trimmed);
|
||||
}
|
||||
|
||||
function normalizeMSTeamsTeamKey(raw: string): string | undefined {
|
||||
const trimmed = stripProviderPrefix(raw)
|
||||
.replace(/^team:/i, "")
|
||||
|
||||
@@ -7,43 +7,33 @@ import {
|
||||
} from "./sdk.js";
|
||||
import type { MSTeamsCredentials } from "./token.js";
|
||||
|
||||
const jwtValidatorState = vi.hoisted(() => ({
|
||||
instances: [] as Array<{ config: Record<string, unknown> }>,
|
||||
behaviorByJwks: new Map<string, "success" | "null" | "throw">(),
|
||||
calls: [] as Array<{ jwksUri: string; token: string; overrideOptions?: unknown }>,
|
||||
}));
|
||||
|
||||
const clientConstructorState = vi.hoisted(() => ({
|
||||
calls: [] as Array<{ serviceUrl: string; options: unknown }>,
|
||||
}));
|
||||
|
||||
// Track jwt.verify calls to assert audience/issuer/algorithm config.
|
||||
const jwtState = vi.hoisted(() => ({
|
||||
verifyBehavior: "success" as "success" | "throw",
|
||||
decodedHeader: { kid: "key-1" } as { kid?: string } | null,
|
||||
decodedPayload: { iss: "https://api.botframework.com" } as { iss?: string } | null,
|
||||
verifyCalls: [] as Array<{ token: string; options: unknown }>,
|
||||
}));
|
||||
vi.mock("@microsoft/teams.apps/dist/middleware/auth/jwt-validator.js", () => ({
|
||||
JwtValidator: class JwtValidator {
|
||||
private readonly config: Record<string, unknown>;
|
||||
|
||||
const jwtMockImpl = {
|
||||
decode: (token: string, opts?: { complete?: boolean }) => {
|
||||
if (opts?.complete) {
|
||||
return jwtState.decodedHeader ? { header: jwtState.decodedHeader } : null;
|
||||
constructor(config: Record<string, unknown>) {
|
||||
this.config = config;
|
||||
jwtValidatorState.instances.push({ config });
|
||||
}
|
||||
return jwtState.decodedPayload;
|
||||
},
|
||||
verify: (token: string, _key: string, options: unknown) => {
|
||||
jwtState.verifyCalls.push({ token, options });
|
||||
if (jwtState.verifyBehavior === "throw") {
|
||||
throw new Error("invalid signature");
|
||||
}
|
||||
return { sub: "ok" };
|
||||
},
|
||||
};
|
||||
|
||||
vi.mock("jsonwebtoken", () => ({
|
||||
...jwtMockImpl,
|
||||
default: jwtMockImpl,
|
||||
}));
|
||||
|
||||
vi.mock("jwks-rsa", () => ({
|
||||
JwksClient: class JwksClient {
|
||||
async getSigningKey(_kid: string) {
|
||||
return { getPublicKey: () => "mock-public-key" };
|
||||
async validateAccessToken(token: string, overrideOptions?: unknown): Promise<object | null> {
|
||||
const jwksUri = String((this.config.jwksUriOptions as { uri?: string })?.uri ?? "");
|
||||
jwtValidatorState.calls.push({ jwksUri, token, overrideOptions });
|
||||
const behavior = jwtValidatorState.behaviorByJwks.get(jwksUri) ?? "null";
|
||||
if (behavior === "throw") {
|
||||
throw new Error("validator error");
|
||||
}
|
||||
return behavior === "success" ? { sub: "ok" } : null;
|
||||
}
|
||||
},
|
||||
}));
|
||||
@@ -53,10 +43,9 @@ const originalFetch = globalThis.fetch;
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
clientConstructorState.calls.length = 0;
|
||||
jwtState.verifyCalls.length = 0;
|
||||
jwtState.verifyBehavior = "success";
|
||||
jwtState.decodedHeader = { kid: "key-1" };
|
||||
jwtState.decodedPayload = { iss: "https://api.botframework.com" };
|
||||
jwtValidatorState.instances.length = 0;
|
||||
jwtValidatorState.calls.length = 0;
|
||||
jwtValidatorState.behaviorByJwks.clear();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -197,90 +186,106 @@ describe("createBotFrameworkJwtValidator", () => {
|
||||
tenantId: "tenant-id",
|
||||
} satisfies MSTeamsCredentials;
|
||||
|
||||
it("validates a token with Bot Framework issuer and correct audience list", async () => {
|
||||
jwtState.decodedPayload = { iss: "https://api.botframework.com" };
|
||||
|
||||
const validator = await createBotFrameworkJwtValidator(creds);
|
||||
await expect(validator.validate("Bearer token-bf")).resolves.toBe(true);
|
||||
|
||||
expect(jwtState.verifyCalls).toHaveLength(1);
|
||||
const opts = jwtState.verifyCalls[0]?.options as Record<string, unknown>;
|
||||
expect(opts.audience).toEqual(["app-id", "api://app-id", "https://api.botframework.com"]);
|
||||
expect(opts.algorithms).toEqual(["RS256"]);
|
||||
expect(opts.clockTolerance).toBe(300);
|
||||
});
|
||||
|
||||
it("accepts tokens with aud: https://api.botframework.com (#58249)", async () => {
|
||||
// This is the critical fix: the old JwtValidator rejected this audience.
|
||||
jwtState.decodedPayload = { iss: "https://api.botframework.com" };
|
||||
|
||||
const validator = await createBotFrameworkJwtValidator(creds);
|
||||
await expect(validator.validate("Bearer botfw-token")).resolves.toBe(true);
|
||||
|
||||
const opts = jwtState.verifyCalls[0]?.options as Record<string, unknown>;
|
||||
expect((opts.audience as string[]).includes("https://api.botframework.com")).toBe(true);
|
||||
});
|
||||
|
||||
it("validates a token with Entra issuer", async () => {
|
||||
jwtState.decodedPayload = { iss: `https://login.microsoftonline.com/tenant-id/v2.0` };
|
||||
|
||||
const validator = await createBotFrameworkJwtValidator(creds);
|
||||
await expect(validator.validate("Bearer token-entra")).resolves.toBe(true);
|
||||
|
||||
expect(jwtState.verifyCalls).toHaveLength(1);
|
||||
const opts = jwtState.verifyCalls[0]?.options as Record<string, unknown>;
|
||||
expect(opts.issuer as string[]).toContain("https://login.microsoftonline.com/tenant-id/v2.0");
|
||||
});
|
||||
|
||||
it("validates a token with STS Windows issuer", async () => {
|
||||
jwtState.decodedPayload = {
|
||||
iss: "https://sts.windows.net/d6d49420-f39b-4df7-a1dc-d59a935871db/",
|
||||
};
|
||||
|
||||
const validator = await createBotFrameworkJwtValidator(creds);
|
||||
await expect(validator.validate("Bearer token-sts")).resolves.toBe(true);
|
||||
|
||||
expect(jwtState.verifyCalls).toHaveLength(1);
|
||||
const opts = jwtState.verifyCalls[0]?.options as Record<string, unknown>;
|
||||
expect(opts.issuer as string[]).toContain(
|
||||
"https://sts.windows.net/d6d49420-f39b-4df7-a1dc-d59a935871db/",
|
||||
it("validates with legacy Bot Framework JWKS and issuer first", async () => {
|
||||
jwtValidatorState.behaviorByJwks.set(
|
||||
"https://login.botframework.com/v1/.well-known/keys",
|
||||
"success",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects tokens with unknown issuer", async () => {
|
||||
jwtState.decodedPayload = { iss: "https://evil.example.com" };
|
||||
|
||||
const validator = await createBotFrameworkJwtValidator(creds);
|
||||
await expect(validator.validate("Bearer token-evil")).resolves.toBe(false);
|
||||
expect(jwtState.verifyCalls).toHaveLength(0);
|
||||
await expect(validator.validate("Bearer token-1", "https://service.example.com")).resolves.toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
expect(jwtValidatorState.instances).toHaveLength(2);
|
||||
expect(jwtValidatorState.calls).toHaveLength(1);
|
||||
expect(jwtValidatorState.calls[0]).toMatchObject({
|
||||
jwksUri: "https://login.botframework.com/v1/.well-known/keys",
|
||||
token: "token-1",
|
||||
overrideOptions: {
|
||||
validateServiceUrl: { expectedServiceUrl: "https://service.example.com" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("returns false when signature verification fails", async () => {
|
||||
jwtState.verifyBehavior = "throw";
|
||||
it("falls back to Entra JWKS when Bot Framework validation fails", async () => {
|
||||
jwtValidatorState.behaviorByJwks.set(
|
||||
"https://login.botframework.com/v1/.well-known/keys",
|
||||
"null",
|
||||
);
|
||||
jwtValidatorState.behaviorByJwks.set(
|
||||
"https://login.microsoftonline.com/common/discovery/v2.0/keys",
|
||||
"success",
|
||||
);
|
||||
|
||||
const validator = await createBotFrameworkJwtValidator(creds);
|
||||
await expect(validator.validate("Bearer token-bad")).resolves.toBe(false);
|
||||
await expect(validator.validate("Bearer token-2")).resolves.toBe(true);
|
||||
|
||||
expect(jwtValidatorState.calls).toHaveLength(2);
|
||||
expect(jwtValidatorState.calls[0]?.jwksUri).toBe(
|
||||
"https://login.botframework.com/v1/.well-known/keys",
|
||||
);
|
||||
expect(jwtValidatorState.calls[1]?.jwksUri).toBe(
|
||||
"https://login.microsoftonline.com/common/discovery/v2.0/keys",
|
||||
);
|
||||
|
||||
const entraConfig = jwtValidatorState.instances
|
||||
.map((instance) => instance.config)
|
||||
.find(
|
||||
(config) =>
|
||||
String((config.jwksUriOptions as { uri?: string })?.uri) ===
|
||||
"https://login.microsoftonline.com/common/discovery/v2.0/keys",
|
||||
);
|
||||
expect(entraConfig).toBeDefined();
|
||||
expect(entraConfig?.validateIssuer).toEqual({ allowedTenantIds: ["tenant-id"] });
|
||||
});
|
||||
|
||||
it("falls back to Entra JWKS when Bot Framework validation throws", async () => {
|
||||
jwtValidatorState.behaviorByJwks.set(
|
||||
"https://login.botframework.com/v1/.well-known/keys",
|
||||
"throw",
|
||||
);
|
||||
jwtValidatorState.behaviorByJwks.set(
|
||||
"https://login.microsoftonline.com/common/discovery/v2.0/keys",
|
||||
"success",
|
||||
);
|
||||
|
||||
const validator = await createBotFrameworkJwtValidator(creds);
|
||||
await expect(
|
||||
validator.validate("Bearer token-throw", "https://service.example.com"),
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(jwtValidatorState.calls).toHaveLength(2);
|
||||
expect(jwtValidatorState.calls[0]).toMatchObject({
|
||||
jwksUri: "https://login.botframework.com/v1/.well-known/keys",
|
||||
token: "token-throw",
|
||||
overrideOptions: {
|
||||
validateServiceUrl: { expectedServiceUrl: "https://service.example.com" },
|
||||
},
|
||||
});
|
||||
expect(jwtValidatorState.calls[1]).toMatchObject({
|
||||
jwksUri: "https://login.microsoftonline.com/common/discovery/v2.0/keys",
|
||||
token: "token-throw",
|
||||
overrideOptions: {
|
||||
validateServiceUrl: { expectedServiceUrl: "https://service.example.com" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("returns false when all validator paths fail", async () => {
|
||||
jwtValidatorState.behaviorByJwks.set(
|
||||
"https://login.botframework.com/v1/.well-known/keys",
|
||||
"throw",
|
||||
);
|
||||
|
||||
const validator = await createBotFrameworkJwtValidator(creds);
|
||||
await expect(validator.validate("Bearer token-3")).resolves.toBe(false);
|
||||
expect(jwtValidatorState.calls).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("returns false for empty bearer token", async () => {
|
||||
const validator = await createBotFrameworkJwtValidator(creds);
|
||||
await expect(validator.validate("Bearer ")).resolves.toBe(false);
|
||||
expect(jwtState.verifyCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns false when token has no kid header", async () => {
|
||||
jwtState.decodedHeader = { kid: undefined };
|
||||
|
||||
const validator = await createBotFrameworkJwtValidator(creds);
|
||||
await expect(validator.validate("Bearer no-kid")).resolves.toBe(false);
|
||||
expect(jwtState.verifyCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns false when token has no issuer claim", async () => {
|
||||
jwtState.decodedPayload = { iss: undefined };
|
||||
|
||||
const validator = await createBotFrameworkJwtValidator(creds);
|
||||
await expect(validator.validate("Bearer no-iss")).resolves.toBe(false);
|
||||
expect(jwtState.verifyCalls).toHaveLength(0);
|
||||
expect(jwtValidatorState.calls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -428,127 +428,72 @@ export async function loadMSTeamsSdkWithAuth(creds: MSTeamsCredentials) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Bot Framework issuer → JWKS mapping.
|
||||
* During Microsoft's transition, inbound service tokens can be signed by either
|
||||
* the legacy Bot Framework issuer or the Entra issuer. Each gets its own JWKS
|
||||
* endpoint so we verify signatures with the correct key set.
|
||||
*/
|
||||
const BOT_FRAMEWORK_ISSUERS: ReadonlyArray<{
|
||||
issuer: string | ((tenantId: string) => string);
|
||||
jwksUri: string;
|
||||
}> = [
|
||||
{
|
||||
issuer: "https://api.botframework.com",
|
||||
jwksUri: "https://login.botframework.com/v1/.well-known/keys",
|
||||
},
|
||||
{
|
||||
issuer: (tenantId: string) => `https://login.microsoftonline.com/${tenantId}/v2.0`,
|
||||
jwksUri: "https://login.microsoftonline.com/common/discovery/v2.0/keys",
|
||||
},
|
||||
{
|
||||
issuer: "https://sts.windows.net/d6d49420-f39b-4df7-a1dc-d59a935871db/",
|
||||
jwksUri: "https://login.microsoftonline.com/common/discovery/v2.0/keys",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Create a Bot Framework JWT validator using jsonwebtoken + jwks-rsa directly.
|
||||
* Create a Bot Framework JWT validator with strict multi-issuer support.
|
||||
*
|
||||
* The @microsoft/teams.apps JwtValidator hardcodes audience to [clientId, api://clientId],
|
||||
* which rejects valid Bot Framework tokens that carry aud: "https://api.botframework.com".
|
||||
* This implementation uses jsonwebtoken directly with the correct audience list, matching
|
||||
* the behavior of the legacy @microsoft/agents-hosting authorizeJWT middleware.
|
||||
* During Microsoft's transition, inbound service tokens can be signed by either:
|
||||
* - Legacy Bot Framework issuer/JWKS
|
||||
* - Entra issuer/JWKS
|
||||
*
|
||||
* Security invariants:
|
||||
* - signature verification via issuer-specific JWKS endpoints
|
||||
* - audience validation: appId, api://appId, and https://api.botframework.com
|
||||
* - issuer validation: strict allowlist (Bot Framework + tenant-scoped Entra)
|
||||
* - expiration validation with 5-minute clock tolerance
|
||||
* Security invariants are preserved for both paths:
|
||||
* - signature verification (issuer-specific JWKS)
|
||||
* - audience validation (appId)
|
||||
* - issuer validation (strict allowlist)
|
||||
* - expiration validation (Teams SDK defaults)
|
||||
*/
|
||||
export async function createBotFrameworkJwtValidator(creds: MSTeamsCredentials): Promise<{
|
||||
validate: (authHeader: string) => Promise<boolean>;
|
||||
validate: (authHeader: string, serviceUrl?: string) => Promise<boolean>;
|
||||
}> {
|
||||
const jwt = await import("jsonwebtoken");
|
||||
const { JwksClient } = await import("jwks-rsa");
|
||||
const { JwtValidator } =
|
||||
await import("@microsoft/teams.apps/dist/middleware/auth/jwt-validator.js");
|
||||
|
||||
const allowedAudiences: [string, ...string[]] = [
|
||||
creds.appId,
|
||||
`api://${creds.appId}`,
|
||||
"https://api.botframework.com",
|
||||
];
|
||||
const botFrameworkValidator = new JwtValidator({
|
||||
clientId: creds.appId,
|
||||
tenantId: creds.tenantId,
|
||||
validateIssuer: { allowedIssuer: "https://api.botframework.com" },
|
||||
jwksUriOptions: {
|
||||
type: "uri",
|
||||
uri: "https://login.botframework.com/v1/.well-known/keys",
|
||||
},
|
||||
});
|
||||
|
||||
const allowedIssuers = BOT_FRAMEWORK_ISSUERS.map((entry) =>
|
||||
typeof entry.issuer === "function" ? entry.issuer(creds.tenantId) : entry.issuer,
|
||||
) as [string, ...string[]];
|
||||
const entraValidator = new JwtValidator({
|
||||
clientId: creds.appId,
|
||||
tenantId: creds.tenantId,
|
||||
validateIssuer: { allowedTenantIds: [creds.tenantId] },
|
||||
jwksUriOptions: {
|
||||
type: "uri",
|
||||
uri: "https://login.microsoftonline.com/common/discovery/v2.0/keys",
|
||||
},
|
||||
});
|
||||
|
||||
// One JWKS client per distinct endpoint, cached for the validator lifetime.
|
||||
const jwksClients = new Map<string, InstanceType<typeof JwksClient>>();
|
||||
function getJwksClient(uri: string): InstanceType<typeof JwksClient> {
|
||||
let client = jwksClients.get(uri);
|
||||
if (!client) {
|
||||
client = new JwksClient({
|
||||
jwksUri: uri,
|
||||
cache: true,
|
||||
cacheMaxAge: 600_000,
|
||||
rateLimit: true,
|
||||
});
|
||||
jwksClients.set(uri, client);
|
||||
async function validateWithFallback(
|
||||
token: string,
|
||||
overrides: { validateServiceUrl: { expectedServiceUrl: string } } | undefined,
|
||||
): Promise<boolean> {
|
||||
for (const validator of [botFrameworkValidator, entraValidator]) {
|
||||
try {
|
||||
const result = await validator.validateAccessToken(token, overrides);
|
||||
if (result != null) {
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
/** Decode the token header without verification to determine the kid. */
|
||||
function decodeHeader(token: string): { kid?: string } | null {
|
||||
const decoded = jwt.decode(token, { complete: true });
|
||||
return decoded && typeof decoded === "object" ? (decoded.header as { kid?: string }) : null;
|
||||
}
|
||||
|
||||
/** Resolve the issuer entry for a token's issuer claim (pre-verification). */
|
||||
function resolveIssuerEntry(issuerClaim: string | undefined) {
|
||||
if (!issuerClaim) {
|
||||
return undefined;
|
||||
}
|
||||
return BOT_FRAMEWORK_ISSUERS.find((entry) => {
|
||||
const expected =
|
||||
typeof entry.issuer === "function" ? entry.issuer(creds.tenantId) : entry.issuer;
|
||||
return expected === issuerClaim;
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
return {
|
||||
async validate(authHeader: string, _serviceUrl?: string): Promise<boolean> {
|
||||
async validate(authHeader: string, serviceUrl?: string): Promise<boolean> {
|
||||
const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : authHeader;
|
||||
if (!token) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Decode without verification to extract issuer and kid for key lookup.
|
||||
const header = decodeHeader(token);
|
||||
const unverifiedPayload = jwt.decode(token) as { iss?: string } | null;
|
||||
if (!header?.kid || !unverifiedPayload?.iss) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Resolve which JWKS endpoint to use based on the issuer claim.
|
||||
const issuerEntry = resolveIssuerEntry(unverifiedPayload.iss);
|
||||
if (!issuerEntry) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const client = getJwksClient(issuerEntry.jwksUri);
|
||||
try {
|
||||
const signingKey = await client.getSigningKey(header.kid);
|
||||
const publicKey = signingKey.getPublicKey();
|
||||
jwt.verify(token, publicKey, {
|
||||
audience: allowedAudiences,
|
||||
issuer: allowedIssuers,
|
||||
algorithms: ["RS256"],
|
||||
clockTolerance: 300,
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const overrides = serviceUrl
|
||||
? ({ validateServiceUrl: { expectedServiceUrl: serviceUrl } } as const)
|
||||
: undefined;
|
||||
return await validateWithFallback(token, overrides);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type {
|
||||
CreateSandboxBackendParams,
|
||||
OpenClawConfig,
|
||||
RemoteShellSandboxHandle,
|
||||
SandboxBackendCommandParams,
|
||||
SandboxBackendCommandResult,
|
||||
SandboxBackendFactory,
|
||||
SandboxBackendHandle,
|
||||
SandboxBackendManager,
|
||||
SshSandboxSession,
|
||||
} from "openclaw/plugin-sdk/sandbox";
|
||||
@@ -17,7 +20,6 @@ import {
|
||||
sanitizeEnvVars,
|
||||
} from "openclaw/plugin-sdk/sandbox";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||
import type { OpenShellSandboxBackend } from "./backend.types.js";
|
||||
import {
|
||||
buildExecRemoteCommand,
|
||||
buildRemoteCommand,
|
||||
@@ -45,7 +47,11 @@ export function buildOpenShellSshExecEnv(): NodeJS.ProcessEnv {
|
||||
return sanitizeEnvVars(process.env).allowed;
|
||||
}
|
||||
|
||||
export type { OpenShellFsBridgeContext, OpenShellSandboxBackend } from "./backend.types.js";
|
||||
export type OpenShellSandboxBackend = SandboxBackendHandle &
|
||||
RemoteShellSandboxHandle & {
|
||||
mode: "mirror" | "remote";
|
||||
syncLocalPathToRemote(localPath: string, remotePath: string): Promise<void>;
|
||||
};
|
||||
|
||||
export function createOpenShellSandboxBackendFactory(
|
||||
params: CreateOpenShellSandboxBackendFactoryParams,
|
||||
@@ -511,5 +517,5 @@ function buildOpenShellSandboxName(scopeKey: string): string {
|
||||
}
|
||||
|
||||
function resolveOpenShellTmpRoot(): string {
|
||||
return path.resolve(resolvePreferredOpenClawTmpDir());
|
||||
return path.resolve(resolvePreferredOpenClawTmpDir() ?? os.tmpdir());
|
||||
}
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import type { RemoteShellSandboxHandle, SandboxBackendHandle } from "openclaw/plugin-sdk/sandbox";
|
||||
|
||||
export type OpenShellFsBridgeContext = Parameters<
|
||||
NonNullable<SandboxBackendHandle["createFsBridge"]>
|
||||
>[0]["sandbox"];
|
||||
|
||||
export type OpenShellSandboxBackend = SandboxBackendHandle &
|
||||
RemoteShellSandboxHandle & {
|
||||
mode: "mirror" | "remote";
|
||||
syncLocalPathToRemote(localPath: string, remotePath: string): Promise<void>;
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user